From e963f02878a83c75a974f9db3ff6351116bac905 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:07:02 -0700 Subject: [PATCH 01/29] WIP: same-host same-remote checkouts keep their own project id --- ...oject-host-setup-selector-normalization.ts | 17 ++++++--- src/shared/project-host-setup-projection.ts | 15 ++++---- src/shared/project-same-host-claim.ts | 38 +++++++++++++++++++ 3 files changed, 57 insertions(+), 13 deletions(-) create mode 100644 src/shared/project-same-host-claim.ts diff --git a/src/renderer/src/store/project-host-setup-selector-normalization.ts b/src/renderer/src/store/project-host-setup-selector-normalization.ts index 154ee4e5ea07..1c288ceb3de2 100644 --- a/src/renderer/src/store/project-host-setup-selector-normalization.ts +++ b/src/renderer/src/store/project-host-setup-selector-normalization.ts @@ -1,9 +1,6 @@ import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' -import { - getProjectIdentityKey, - type ProjectHostSetupProjection -} from '../../../shared/project-host-setup-projection' +import type { ProjectHostSetupProjection } from '../../../shared/project-host-setup-projection' export type NormalizedProjectHostSetupProjection = ProjectHostSetupProjection & { changed: boolean @@ -17,6 +14,11 @@ export function normalizeHydratedProjectHostSetupProjection( ): NormalizedProjectHostSetupProjection { const repoById = new Map(repos.map((repo) => [repo.id, repo])) const derivedProjectIds = new Set(derived.projects.map((project) => project.id)) + // Why the derived setup, not the repo's own remote key: whether a repo keeps the shared id + // depends on its same-host siblings, which only the whole-catalog projection sees. + const derivedProjectIdBySetupHost = new Map( + derived.setups.map((setup) => [`${setup.repoId}\u0000${setup.hostId}`, setup.projectId]) + ) const projectIdByHydratedProjectId = new Map() let changed = false const normalizedSetups = setups.map((setup) => { @@ -24,7 +26,12 @@ export function normalizeHydratedProjectHostSetupProjection( if (!repo) { return setup } - const projectId = getProjectIdentityKey(repo) + const projectId = + derivedProjectIdBySetupHost.get(`${repo.id}\u0000${setup.hostId}`) ?? + derived.setups.find((candidate) => candidate.repoId === repo.id)?.projectId + if (!projectId) { + return setup + } if (projectId === setup.projectId || projectId === `repo:${repo.id}`) { return setup } diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 1c4d6f06c4ba..9f5d766f683c 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -7,6 +7,7 @@ import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' import type { Repo } from './repo-types' +import { getSameHostOutclaimedRepos } from './project-same-host-claim' /** * A projection-local draft. `project` is always freshly built by `createProjectFromRepo`, never a @@ -132,12 +133,6 @@ export function getProjectIdForProviderIdentity(identity: ProjectProviderIdentit return `github:${githubRepoIdentityKey(identity)}` } -function getProjectId( - repo: Pick -): string { - return getProjectIdentityKey(repo) -} - function isGitHubRemoteHost(host: string): boolean { const hostname = host.toLowerCase().replace(/:\d+$/, '') // A generic git remote is provider-neutral. Only infer GHES when the host @@ -313,8 +308,12 @@ export function projectHostSetupProjectionFromRepos( const projectById = new Map() const setups: ProjectHostSetup[] = [] - for (const repo of repos) { - const projectId = getProjectId(repo) + const remoteProjectIds = repos.map(getProjectIdentityKey) + const outclaimed = getSameHostOutclaimedRepos(repos, remoteProjectIds) + for (const [index, repo] of repos.entries()) { + const projectId = outclaimed.has(repo) + ? `${HOST_LOCAL_PROJECT_ID_PREFIX}${repo.id}` + : remoteProjectIds[index] const existing = projectById.get(projectId) if (existing) { mergeProjectRepo(existing, repo) diff --git a/src/shared/project-same-host-claim.ts b/src/shared/project-same-host-claim.ts new file mode 100644 index 000000000000..6864f5a98ee4 --- /dev/null +++ b/src/shared/project-same-host-claim.ts @@ -0,0 +1,38 @@ +import { getRepoExecutionHostId } from './execution-host' +import type { Repo } from './repo-types' + +/** + * Repos that must not hold their remote-derived project id because a sibling on the same host + * already does. A shared remote is the only evidence that checkouts on different hosts are one + * project, but on one host two repo rows are two separate checkouts (linked worktrees share a + * row), and a fork, template or copied `.git/config` shares the remote too. So per host only the + * earliest-added checkout keeps the shared id. + */ +export function getSameHostOutclaimedRepos( + repos: readonly Repo[], + projectIds: readonly string[] +): ReadonlySet { + const claimKeys = repos.map( + (repo, index) => `${projectIds[index]}\u0000${getRepoExecutionHostId(repo)}` + ) + const claimantByKey = new Map() + for (const [index, repo] of repos.entries()) { + const claimant = claimantByKey.get(claimKeys[index]) + if (!claimant || compareClaims(repo, claimant) < 0) { + claimantByKey.set(claimKeys[index], repo) + } + } + // Why compare ids: duplicate rows of one repo are the same checkout, never rivals. + return new Set(repos.filter((repo, index) => claimantByKey.get(claimKeys[index])?.id !== repo.id)) +} + +// Why addedAt then id: repo order comes from disk/host enumeration, and a client and its paired +// server must pick the same claimant from the same rows. +function compareClaims(left: Repo, right: Repo): number { + const byAddedAt = claimTimestamp(left) - claimTimestamp(right) + return byAddedAt || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) +} + +function claimTimestamp(repo: Repo): number { + return Number.isFinite(repo.addedAt) ? repo.addedAt : 0 +} From a3d071a1a3939b89c8ac962e216332be8b33ad7a Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:06:23 -0700 Subject: [PATCH 02/29] WIP: claim the shared project id per checkout surface; drop the display-only clone split --- .../components/cmd-j/palette-results.test.ts | 21 -- .../ai-vault-session-projects.test.ts | 45 +++ ...yHostSetupsSection.settings-entry.test.tsx | 166 ----------- .../settings/RepositoryHostSetupsSection.tsx | 14 +- .../components/settings/RepositoryPane.tsx | 10 - .../RepositoryRemoveProjectButton.tsx | 27 +- .../settings/settings-project-list.test.ts | 112 +------ .../settings/settings-project-list.ts | 133 ++------- .../settings-project-section-renderer.tsx | 15 +- .../settings/use-settings-page-effects.ts | 2 +- .../settings/use-settings-store-model.ts | 11 +- ...ee-list-groups-project-host-setups.test.ts | 282 ------------------ .../grouping/project-grouping.ts | 85 +----- .../navigation/reveal-ancestors.ts | 9 +- .../settings-navigation-build-options.ts | 2 - .../settings-navigation-remote-sections.ts | 15 +- .../hooks/useSettingsNavigationMetadata.ts | 17 +- src/renderer/src/i18n/locales/en.json | 2 - src/renderer/src/i18n/locales/es.json | 2 - src/renderer/src/i18n/locales/fr.json | 2 - src/renderer/src/i18n/locales/ja.json | 2 - src/renderer/src/i18n/locales/ko.json | 2 - src/renderer/src/i18n/locales/zh.json | 2 - ...oject-host-setup-selector-normalization.ts | 15 +- src/shared/project-checkout-claim.ts | 79 +++++ .../project-host-setup-projection.test.ts | 51 +++- src/shared/project-host-setup-projection.ts | 26 +- src/shared/project-same-host-claim.ts | 38 --- 28 files changed, 250 insertions(+), 937 deletions(-) delete mode 100644 src/renderer/src/components/settings/RepositoryHostSetupsSection.settings-entry.test.tsx create mode 100644 src/shared/project-checkout-claim.ts delete mode 100644 src/shared/project-same-host-claim.ts diff --git a/src/renderer/src/components/cmd-j/palette-results.test.ts b/src/renderer/src/components/cmd-j/palette-results.test.ts index 4c87444e39ca..4c7ef2f052bd 100644 --- a/src/renderer/src/components/cmd-j/palette-results.test.ts +++ b/src/renderer/src/components/cmd-j/palette-results.test.ts @@ -507,27 +507,6 @@ describe('Cmd+J project and repo-group search', () => { ]) }) - it('splits independent same-host checkouts of one project into per-setup keys', () => { - // Why: two `cloned` checkouts share the project's remote identity but are - // distinct user clones; the palette follows the sidebar and surfaces each as - // its own jump target rather than collapsing them. See #5374. - const results = searchCmdJProjectResults({ - query: 'platform', - projectGroups: [], - repos: [repo('repo-1', 'platform-a'), repo('repo-2', 'platform-b')], - projects: [project('project-1', 'Platform')], - projectHostSetups: [ - setup('setup-1', 'project-1', 'local', 'repo-1'), - setup('setup-2', 'project-1', 'local', 'repo-2') - ] - }) - - expect(results.map((result) => result.rowKey)).toEqual([ - 'project:project-1::setup:repo-1', - 'project:project-1::setup:repo-2' - ]) - }) - it('keeps a provisioned runtime copy under one project key alongside a same-host checkout', () => { // Why: a `provisioned` (recipe-created ephemeral) copy shares the project's // remote identity but must not split the user's real checkout; it nests diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index 212cee5fb679..a865ed0c89d7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -11,6 +11,7 @@ import { buildAiVaultSessionProjectById } from './ai-vault-session-projects' import { groupAiVaultSessions } from '../../../../shared/ai-vault-session-filters' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' const baseSession: AiVaultSession = { id: 'claude:1', @@ -44,6 +45,50 @@ describe('toAiVaultProjectKey', () => { }) describe('buildAiVaultProjectContext', () => { + // #28589: an imported folder whose copied .git/config points at another checkout's remote. + it("keeps a same-host folder that shares a remote out of the other checkout's project", () => { + const remote = { + canonicalKey: 'github.com/bennyoooo/open-social', + remoteName: 'origin', + remoteUrl: 'https://github.com/Bennyoooo/open-social.git' + } + const openSocial = makeRepo({ + id: 'open-social', + displayName: 'open-social', + path: '/Users/benny/orca/open-social', + addedAt: 100, + gitRemoteIdentity: remote + }) + const niw = makeRepo({ + id: 'niw', + displayName: 'niw', + path: '/Users/benny/orca/projects/niw', + addedAt: 200, + gitRemoteIdentity: remote + }) + const niwSession = makeSession({ id: 'claude:niw', cwd: niw.path }) + const openSocialSession = makeSession({ id: 'claude:open-social', cwd: openSocial.path }) + + const context = buildAiVaultProjectContext({ + repos: [niw, openSocial], + worktrees: [], + projectHostSetupProjection: projectHostSetupProjectionFromRepos([niw, openSocial]), + activeRepo: niw, + activeWorktree: null, + sessions: [niwSession, openSocialSession] + }) + + expect(context.activeProjectKey).toBe('repo:niw') + expect(context.sessionProjectById.get(niwSession.id)).toMatchObject({ + key: 'repo:niw', + label: 'niw' + }) + expect(context.sessionProjectById.get(openSocialSession.id)).toMatchObject({ + key: 'project:github:bennyoooo/open-social', + label: 'open-social' + }) + }) + it('uses durable worktree project ids before repo fallback', () => { const repo = makeRepo({ id: 'repo-1', displayName: 'Legacy Repo', path: '/Users/ada/orca' }) const project = makeProject({ id: 'project-1', displayName: 'Canonical Orca' }) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.settings-entry.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.settings-entry.test.tsx deleted file mode 100644 index 5fdaf6cf09e6..000000000000 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.settings-entry.test.tsx +++ /dev/null @@ -1,166 +0,0 @@ -// @vitest-environment happy-dom - -import React, { act } from 'react' -import { createRoot, type Root } from 'react-dom/client' -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { toSshExecutionHostId } from '../../../../shared/execution-host' -import type { ProjectHostSetup } from '../../../../shared/project-types' -import type { Repo } from '../../../../shared/repo-types' -import { useAppStore } from '../../store' -import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' - -let container: HTMLDivElement -let root: Root - -const projectId = 'github:stablyai/orca' - -function makeRepo(overrides: Partial & Pick): Repo { - return { badgeColor: '#737373', addedAt: 100, kind: 'git', ...overrides } -} - -function makeSetup( - overrides: Partial & Pick -): ProjectHostSetup { - return { - projectId, - displayName: 'Orca', - kind: 'git', - setupState: 'ready', - setupMethod: 'legacy-repo', - createdAt: 100, - updatedAt: 100, - ...overrides - } -} - -function findButton(label: string): HTMLButtonElement | undefined { - return Array.from(container.querySelectorAll('button')).find((button) => - button.textContent?.includes(label) - ) -} - -beforeEach(() => { - useAppStore.setState(useAppStore.getInitialState(), true) - container = document.createElement('div') - document.body.appendChild(container) - root = createRoot(container) -}) - -afterEach(() => { - act(() => { - root.unmount() - }) - container.remove() - useAppStore.setState(useAppStore.getInitialState(), true) -}) - -describe('RepositoryHostSetupsSection settings entry scope', () => { - it('lists only its own settings entry when same-host clones split the project (#20861)', () => { - const cloneA = makeRepo({ id: 'clone-a', displayName: 'Orca', path: '/work/orca' }) - const cloneB = makeRepo({ id: 'clone-b', displayName: 'Orca B', path: '/work/orca-b' }) - const remoteRepo = makeRepo({ - id: 'remote-repo', - displayName: 'Orca', - path: '/home/alice/orca', - connectionId: 'openclaw 2' - }) - useAppStore.setState({ - repos: [cloneA, cloneB, remoteRepo], - projects: [ - { - id: projectId, - displayName: 'Orca', - badgeColor: '#737373', - sourceRepoIds: [cloneA.id, cloneB.id, remoteRepo.id], - createdAt: 100, - updatedAt: 100 - } - ], - projectHostSetups: [ - makeSetup({ id: 'clone-a', repoId: 'clone-a', hostId: 'local', path: '/work/orca' }), - makeSetup({ id: 'clone-b', repoId: 'clone-b', hostId: 'local', path: '/work/orca-b' }), - makeSetup({ - id: 'remote-repo', - repoId: 'remote-repo', - hostId: toSshExecutionHostId('openclaw 2'), - path: '/home/alice/orca' - }), - makeSetup({ - id: 'gpu-setup', - repoId: '', - hostId: 'runtime:gpu', - path: '', - setupState: 'not-set-up' - }) - ], - sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]) - }) - - act(() => { - root.render( - React.createElement(RepositoryHostSetupsSection, { - repo: cloneA, - settingsEntryRepoIds: new Set(['clone-a']), - forceVisible: true, - searchQuery: '', - searchEntries: [] - }) - ) - }) - - expect(container.textContent).toContain('/work/orca') - expect(container.textContent).toContain('Path pending') - expect(container.textContent).not.toContain('/work/orca-b') - expect(container.textContent).not.toContain('/home/alice/orca') - expect(findButton('Open')).toBeUndefined() - expect(findButton('Add to another host')).toBeUndefined() - }) - - it("stores a host switch under the clone entry's own selection key", () => { - const setSettingsProjectHostSelection = vi.fn() - const cloneA = makeRepo({ id: 'clone-a', displayName: 'Orca', path: '/work/orca' }) - const cloneATwin = makeRepo({ - id: 'clone-a', - displayName: 'Orca', - path: '/home/alice/orca', - connectionId: 'openclaw 2' - }) - const sshHostId = toSshExecutionHostId('openclaw 2') - useAppStore.setState({ - repos: [cloneA, cloneATwin], - projectHostSetups: [ - makeSetup({ id: 'clone-a', repoId: 'clone-a', hostId: 'local', path: '/work/orca' }), - makeSetup({ - id: 'clone-a-ssh', - repoId: 'clone-a', - hostId: sshHostId, - path: '/home/alice/orca' - }) - ], - sshTargetLabels: new Map([['openclaw 2', 'openclaw 2']]), - setSettingsProjectHostSelection - }) - - act(() => { - root.render( - React.createElement(RepositoryHostSetupsSection, { - repo: cloneA, - settingsEntryRepoIds: new Set(['clone-a']), - settingsSelectionKey: `${projectId}::setup:clone-a`, - forceVisible: true, - searchQuery: '', - searchEntries: [] - }) - ) - }) - act(() => { - findButton('Open')?.dispatchEvent(new MouseEvent('click', { bubbles: true })) - }) - - expect(setSettingsProjectHostSelection).toHaveBeenCalledWith( - `${projectId}::setup:clone-a`, - sshHostId, - 'clone-a-ssh' - ) - }) -}) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index d00508656db7..e552aaff6be0 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -34,8 +34,6 @@ import { type RepositoryHostSetupsSectionProps = { repo: Repo selectedProjectSetupId?: string - settingsSelectionKey?: string - settingsEntryRepoIds?: ReadonlySet forceVisible: boolean searchQuery: string searchEntries: SettingsSearchEntry[] @@ -62,8 +60,6 @@ function setupsByOwnedExecutionHost( export function RepositoryHostSetupsSection({ repo, selectedProjectSetupId, - settingsSelectionKey, - settingsEntryRepoIds, forceVisible, searchQuery, searchEntries @@ -125,13 +121,8 @@ export function RepositoryHostSetupsSection({ (setup) => setup.projectId === selectedProjectHostSetup.projectId ) : [] - // Why: a sibling entry's setups can't be opened from this pane; not-set-up - // placeholders belong to the project, not a checkout, so every entry keeps them. const projectHostSetups = setupsByOwnedExecutionHost( - allProjectHostSetups.filter( - (setup) => - !settingsEntryRepoIds || !setup.repoId.trim() || settingsEntryRepoIds.has(setup.repoId) - ), + allProjectHostSetups, selectedProjectHostSetup?.id ?? '' ) const openableProjectHostSetups = projectHostSetups.filter((setup) => setup.repoId.trim()) @@ -145,8 +136,7 @@ export function RepositoryHostSetupsSection({ }) const hostOptionById = new Map(hostOptions.map((option) => [option.id, option])) const [deletingSetupId, setDeletingSetupId] = useState(null) - // Why: split clone entries share a projectId, so each keeps its own selection. - const selectionKey = settingsSelectionKey ?? selectedProjectHostSetup?.projectId + const selectionKey = selectedProjectHostSetup?.projectId // Why: the single project pane switches host in place — set the ephemeral // per-entry selection instead of navigating to a separate repo section. const selectHost = (hostId: ExecutionHostId) => { diff --git a/src/renderer/src/components/settings/RepositoryPane.tsx b/src/renderer/src/components/settings/RepositoryPane.tsx index 04d94b62a01a..a87d45b06ae5 100644 --- a/src/renderer/src/components/settings/RepositoryPane.tsx +++ b/src/renderer/src/components/settings/RepositoryPane.tsx @@ -15,7 +15,6 @@ import { SparsePresetSettingsSection } from './SparsePresetSettingsSection' import { RepositorySourceControlAiSection } from './RepositorySourceControlAiSection' import { SearchableSetting } from './SearchableSetting' import { RepositoryRemoveProjectButton } from './RepositoryRemoveProjectButton' -import type { SettingsProjectRemovalScope } from './settings-project-list' import { matchesSettingsSearch } from './settings-search' import { useAppStore } from '../../store' import { getRepositoryIconSectionId } from './repository-settings-targets' @@ -60,9 +59,6 @@ type RepositoryPaneProps = { removeProject: (repoId: string) => void project?: Project | null selectedProjectSetupId?: string - settingsSelectionKey?: string - settingsEntryRepoIds?: ReadonlySet - removalScope?: SettingsProjectRemovalScope isLocalWindowsProject?: boolean wslAvailable?: boolean wslDistros?: string[] @@ -83,9 +79,6 @@ export function RepositoryPane({ removeProject, project = null, selectedProjectSetupId, - settingsSelectionKey, - settingsEntryRepoIds, - removalScope = 'project', isLocalWindowsProject = false, wslAvailable = false, wslDistros = EMPTY_WSL_DISTROS, @@ -256,7 +249,6 @@ export function RepositoryPane({ @@ -319,8 +311,6 @@ export function RepositoryPane({ void }) { @@ -54,7 +32,10 @@ export function RepositoryRemoveProjectButton({ return ( { expect(projects[0].setups).toHaveLength(2) // Representative is the local host's repo. expect(projects[0].representativeRepoId).toBe('local-1') - expect(getSettingsProjectRemovalScope(projects[0])).toBe('project') }) it('collapses a folder with the same id on local + runtime into one project', () => { @@ -83,112 +78,21 @@ describe('buildSettingsProjectList', () => { expect(projects[0].representativeRepoId).toBe('folder-x') }) - it('gives each same-host clone of one remote its own entry, like the sidebar (#20861)', () => { + it('gives each same-host clone of one remote its own entry; other hosts join the first (#20861)', () => { const repos: Repo[] = [ - makeRepo({ id: 'clone-a', displayName: 'app', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', displayName: 'app-b', gitRemoteIdentity: gitRemote }) - ] - - const projects = buildSettingsProjectList(repos) - - expect(projects.map((entry) => entry.representativeRepoId)).toEqual(['clone-a', 'clone-b']) - expect(projects.map((entry) => entry.checkoutLabel)).toEqual(['app', 'app-b']) - expect(buildRepoIdToRepresentative(projects).get('clone-b')).toBe('clone-b') - }) - - it('splits by the setups the sidebar groups with, not only the loaded repos', () => { - const cloneA = makeRepo({ id: 'clone-a', displayName: 'app', gitRemoteIdentity: gitRemote }) - const cloneB = makeRepo({ id: 'clone-b', displayName: 'app-b', gitRemoteIdentity: gitRemote }) - const sidebarProjection = projectHostSetupProjectionFromRepos([cloneA, cloneB]) - - const projects = buildSettingsProjectList([cloneA], { - projects: sidebarProjection.projects, - projectHostSetups: sidebarProjection.setups - }) - - expect(projects.map((entry) => entry.checkoutLabel)).toEqual(['app']) - expect(buildSettingsProjectList([cloneA]).map((entry) => entry.checkoutLabel)).toEqual([ - undefined - ]) - }) - - it("keeps a clone's same-id twin on another host in that clone's entry", () => { - const repos: Repo[] = [ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote, executionHostId: 'runtime:mac' }) - ] - - const projects = buildSettingsProjectList(repos) - - expect(projects.map((entry) => entry.representativeRepoId)).toEqual(['clone-a', 'clone-b']) - expect(projects[0].setups.map((setup) => setup.hostId)).toEqual(['local', 'runtime:mac']) - }) - - it('gives each clone entry its own host selection key', () => { - const repos: Repo[] = [ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'remote-9', gitRemoteIdentity: gitRemote, executionHostId: 'ssh:box' }) - ] - - const projects = buildSettingsProjectList(repos) - const keys = projects.map((entry) => entry.selectionKey) - - expect(new Set(keys).size).toBe(3) - expect(projects[2].selectionKey).toBe(projects[2].projectId) - expect(buildRepoIdToHostSelection(projects).get('clone-b')?.selectionKey).toBe(keys[1]) - }) - - it("keeps a clone's host pick when its sibling clone is removed", () => { - const cloneA = makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }) - const cloneATwin = makeRepo({ - id: 'clone-a', - gitRemoteIdentity: gitRemote, - executionHostId: 'runtime:mac' - }) - const cloneB = makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }) - const [splitCloneA] = buildSettingsProjectList([cloneA, cloneB, cloneATwin]) - const hostSelection: Record = { - [splitCloneA.selectionKey]: 'runtime:mac' - } - - const [unsplit] = buildSettingsProjectList([cloneA, cloneATwin]) - - expect(unsplit.selectionKey).not.toBe(splitCloneA.selectionKey) - expect(getSettingsEntryHostSelection(unsplit, hostSelection, {}).hostId).toBe('runtime:mac') - }) - - it("does not take a sibling clone's host pick", () => { - const projects = buildSettingsProjectList([ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }) - ]) - const hostSelection: Record = { [projects[1].selectionKey]: 'local' } - - expect(getSettingsEntryHostSelection(projects[0], hostSelection, {}).hostId).toBeUndefined() - }) - - it('keeps other hosts in a project-level entry when same-host clones split', () => { - const repos: Repo[] = [ - makeRepo({ id: 'clone-a', gitRemoteIdentity: gitRemote }), - makeRepo({ id: 'clone-b', gitRemoteIdentity: gitRemote }), + makeRepo({ id: 'clone-b', displayName: 'app-b', addedAt: 2, gitRemoteIdentity: gitRemote }), + makeRepo({ id: 'clone-a', displayName: 'app', addedAt: 1, gitRemoteIdentity: gitRemote }), makeRepo({ id: 'remote-9', gitRemoteIdentity: gitRemote, executionHostId: 'ssh:box' }) ] const projects = buildSettingsProjectList(repos) expect(projects.map((entry) => entry.setups.map((setup) => setup.repoId))).toEqual([ - ['clone-a'], ['clone-b'], - ['remote-9'] - ]) - expect(projects[2].checkoutLabel).toBeUndefined() - expect(projects.map(getSettingsProjectRemovalScope)).toEqual([ - 'checkout', - 'checkout', - 'split-project' + ['clone-a', 'remote-9'] ]) + expect(projects.map((entry) => entry.project.displayName)).toEqual(['app-b', 'app']) + expect(buildRepoIdToRepresentative(projects).get('clone-b')).toBe('clone-b') }) it('keeps the representative stable when an unrelated host is removed', () => { @@ -269,7 +173,7 @@ describe('deep-link resolution', () => { it('maps a repoId to its owning project + host for selection', () => { const map = buildRepoIdToHostSelection(projects) expect(map.get('remote-9')).toEqual({ - selectionKey: projects[0].selectionKey, + projectId: projects[0].projectId, hostId: 'runtime:home-mac' }) }) @@ -288,7 +192,7 @@ describe('deep-link resolution', () => { expect(getSettingsTargetHostSelection(sameIdProjects, 'same-repo', 'ssh:server')).toEqual( expect.objectContaining({ - selectionKey: sameIdProjects[0].selectionKey, + projectId: sameIdProjects[0].projectId, hostId: 'ssh:server' }) ) diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index 9d2bc449abcf..9c08bdb5f053 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -6,71 +6,30 @@ import { type ExecutionHostId } from '../../../../shared/execution-host' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' -import { getRepoHostIdentityForParts } from '../../../../shared/repo-host-identity' -import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' -import { - buildProjectGroupingIndex, - isCheckoutScopedProjectSetup, - type ProjectGroupingModel -} from '@/components/sidebar/worktree-list/grouping/project-grouping' export type SettingsProject = { projectId: string project: Project setups: ProjectHostSetup[] representativeRepoId: string - /** Keys this entry's host selection: the project id, or the clone's own key when split. */ - selectionKey: string - /** Only set when same-host clones split the project. */ - checkoutLabel?: string - /** Set on every entry of a split project; each holds only its own part. */ - splitProject?: boolean -} - -function getCheckoutSelectionKey(projectId: string, repoId: string): string { - return `${projectId}::setup:${repoId}` } /** - * The host (and setup) an entry's pane shows. Adding or removing a sibling - * clone changes an entry's selectionKey, so a pick saved under its other key - * carries over while that host is still in this entry. + * The host (and setup) an entry's pane shows: the stored pick, while that host + * still has a setup in this entry. */ export function getSettingsEntryHostSelection( settingsProject: SettingsProject, hostSelection: Readonly>, setupSelection: Readonly> ): { hostId: ExecutionHostId | undefined; setupId: string | undefined } { - const { projectId, selectionKey, setups } = settingsProject - const keys = new Set([ - selectionKey, - projectId, - ...setups.map((setup) => getCheckoutSelectionKey(projectId, setup.repoId)) - ]) - // Selection records are ordered by the store's last explicit pick, including alias keys. - for (const key of Object.keys(hostSelection).toReversed()) { - if (!keys.has(key)) { - continue - } - const hostId = hostSelection[key] - const setupId = setupSelection[key] - if (setups.some((setup) => setup.hostId === hostId && (!setupId || setup.id === setupId))) { - return { hostId, setupId } - } - } - return { hostId: undefined, setupId: undefined } -} - -/** What a pane's "Remove Project" removes: the project, one clone, or a split project's remainder. */ -export type SettingsProjectRemovalScope = 'project' | 'checkout' | 'split-project' - -export function getSettingsProjectRemovalScope( - settingsProject: SettingsProject -): SettingsProjectRemovalScope { - if (settingsProject.checkoutLabel !== undefined) { - return 'checkout' - } - return settingsProject.splitProject ? 'split-project' : 'project' + const hostId = hostSelection[settingsProject.projectId] + const setupId = setupSelection[settingsProject.projectId] + return settingsProject.setups.some( + (setup) => setup.hostId === hostId && (!setupId || setup.id === setupId) + ) + ? { hostId, setupId } + : { hostId: undefined, setupId: undefined } } /** @@ -99,78 +58,33 @@ export function getSettingsProjectRepresentativeRepoId( } /** - * Collapses repo rows into one entry per project, except that a same-host clone - * the sidebar gives its own header gets its own entry. Entries are derived from - * repos alone so the nav and pane lists agree exactly; pass the sidebar's - * `projectGrouping` so the split decision sees the same setups it does. + * One entry per project. Entries are derived from repos alone so the nav and + * pane lists agree exactly. */ -export function buildSettingsProjectList( - repos: readonly Repo[], - projectGrouping?: ProjectGroupingModel -): SettingsProject[] { +export function buildSettingsProjectList(repos: readonly Repo[]): SettingsProject[] { const projection = projectHostSetupProjectionFromRepos(repos) const projectById = new Map(projection.projects.map((project) => [project.id, project])) - const groupingIndex = buildProjectGroupingIndex( - projectGrouping ?? { projects: projection.projects, projectHostSetups: projection.setups } - ) - const suppliedSetupByCheckout = new Map( - projectGrouping?.projectHostSetups.map((setup) => [getSettingsSetupCheckoutKey(setup), setup]) - ) // Why: Settings metadata is rebuilt as repos refresh across hosts; index // setups once so many projects do not turn each refresh into an O(n²) scan. - const entriesByKey = new Map>() - // Why: a repo id owns one `repo-` section, so its same-id twin on another - // host joins the clone's entry instead of colliding with it. - const checkoutRepoIds = new Set( - groupingIndex === null - ? [] - : projection.setups - .filter((setup) => - isCheckoutScopedProjectSetup( - suppliedSetupByCheckout.get(getSettingsSetupCheckoutKey(setup)) ?? setup, - groupingIndex - ) - ) - .map((setup) => setup.repoId) - ) + const entriesByProjectId = new Map>() for (const setup of projection.setups) { const project = projectById.get(setup.projectId) if (!project) { continue } - const checkoutScoped = checkoutRepoIds.has(setup.repoId) - const key = checkoutScoped ? getCheckoutSelectionKey(project.id, setup.repoId) : project.id - const entry = entriesByKey.get(key) + const entry = entriesByProjectId.get(project.id) if (entry) { entry.setups.push(setup) } else { - entriesByKey.set(key, { - projectId: project.id, - selectionKey: key, - project, - setups: [setup], - ...(checkoutScoped ? { checkoutLabel: setup.displayName } : {}) - }) + entriesByProjectId.set(project.id, { projectId: project.id, project, setups: [setup] }) } } - const entryCountByProjectId = new Map() - for (const entry of entriesByKey.values()) { - entryCountByProjectId.set( - entry.projectId, - (entryCountByProjectId.get(entry.projectId) ?? 0) + 1 - ) - } - return [...entriesByKey.values()].map((entry) => ({ + return [...entriesByProjectId.values()].map((entry) => ({ ...entry, - representativeRepoId: getSettingsProjectRepresentativeRepoId(entry.setups), - ...((entryCountByProjectId.get(entry.projectId) ?? 0) > 1 ? { splitProject: true } : {}) + representativeRepoId: getSettingsProjectRepresentativeRepoId(entry.setups) })) } -function getSettingsSetupCheckoutKey(setup: ProjectHostSetup): string { - return `${getRepoHostIdentityForParts(setup.repoId, setup.hostId)}\0${normalizeRuntimePathForComparison(setup.path.trim())}` -} - /** * The host whose settings the project pane should show. Validates the stored * selection against the live setups so a disconnected/removed host never leaves @@ -215,12 +129,12 @@ export function buildRepoIdToRepresentative( * select that host in the pane's "Available Hosts" switcher. */ export function buildRepoIdToHostSelection( projects: readonly SettingsProject[] -): Map { - const map = new Map() +): Map { + const map = new Map() for (const settingsProject of projects) { for (const setup of settingsProject.setups) { if (setup.repoId.trim().length > 0 && !map.has(setup.repoId)) { - map.set(setup.repoId, { selectionKey: settingsProject.selectionKey, hostId: setup.hostId }) + map.set(setup.repoId, { projectId: settingsProject.projectId, hostId: setup.hostId }) } } } @@ -231,13 +145,13 @@ export function getSettingsTargetHostSelection( projects: readonly SettingsProject[], repoId: string, hostId: ExecutionHostId -): { selectionKey: string; hostId: ExecutionHostId; setupId: string } | null { +): { projectId: string; hostId: ExecutionHostId; setupId: string } | null { for (const settingsProject of projects) { const setup = settingsProject.setups.find( (candidate) => candidate.repoId === repoId && candidate.hostId === hostId ) if (setup) { - return { selectionKey: settingsProject.selectionKey, hostId, setupId: setup.id } + return { projectId: settingsProject.projectId, hostId, setupId: setup.id } } } return null @@ -271,8 +185,7 @@ export function resolveSettingsTargetRepoId( } /** - * Removes a Settings entry's setup on every host it exists on; an entry of a - * split project holds only its own part. Sequential so each host's teardown + + * Removes a Settings entry's setup on every host it exists on. Sequential so each host's teardown + * projection recompute don't interleave; setups without a repo row * (planned/not-set-up hosts) have nothing to remove. */ diff --git a/src/renderer/src/components/settings/settings-project-section-renderer.tsx b/src/renderer/src/components/settings/settings-project-section-renderer.tsx index d6ad190b5e42..edbf5ff0526d 100644 --- a/src/renderer/src/components/settings/settings-project-section-renderer.tsx +++ b/src/renderer/src/components/settings/settings-project-section-renderer.tsx @@ -2,11 +2,7 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../sha import { getRepoHostIdentity } from '../../store/slices/repo-host-identity' import { RepositoryPane } from './RepositoryPane' import { SettingsSection } from './SettingsSection' -import { - getSettingsEntryHostSelection, - getSettingsProjectHostRepo, - getSettingsProjectRemovalScope -} from './settings-project-list' +import { getSettingsEntryHostSelection, getSettingsProjectHostRepo } from './settings-project-list' import { translate } from '@/i18n/i18n' import type { SettingsRenderContext } from './settings-render-context' @@ -40,7 +36,7 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R title={translate( 'auto.components.settings.Settings.3bf149e873', 'Project Settings > {{value0}}', - { value0: settingsProject.checkoutLabel ?? project.displayName } + { value0: project.displayName } )} description={repo.path} searchEntries={navigation.getSectionSearchEntries(repoSectionId)} @@ -58,13 +54,6 @@ export function renderProjectSettingsSections(context: SettingsRenderContext): R removeProject={() => void model.removeProjectAllHosts(settingsProject.setups)} project={project} selectedProjectSetupId={hostSelection.setupId} - settingsSelectionKey={settingsProject.selectionKey} - settingsEntryRepoIds={ - settingsProject.splitProject - ? new Set(settingsProject.setups.map((setup) => setup.repoId)) - : undefined - } - removalScope={getSettingsProjectRemovalScope(settingsProject)} isLocalWindowsProject={ getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID && terminal.isWindowsTerminalHost diff --git a/src/renderer/src/components/settings/use-settings-page-effects.ts b/src/renderer/src/components/settings/use-settings-page-effects.ts index 9d2e05d0fd2f..361498932b67 100644 --- a/src/renderer/src/components/settings/use-settings-page-effects.ts +++ b/src/renderer/src/components/settings/use-settings-page-effects.ts @@ -181,7 +181,7 @@ export function useSettingsPageEffects( : repoIdToHostSelection.get(targetRepoId) if (hostSelection) { setSettingsProjectHostSelection( - hostSelection.selectionKey, + hostSelection.projectId, hostSelection.hostId, 'setupId' in hostSelection && typeof hostSelection.setupId === 'string' ? hostSelection.setupId diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index a11dd278d1cd..d4419b850295 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -3,7 +3,6 @@ import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import { DEFAULT_APP_FONT_FAMILY } from '../../../../shared/constants' import { useAppStore } from '../../store' -import { useProjectHostSetupProjection } from '@/store/selectors' import { useSystemPrefersDark } from '@/components/terminal-pane/use-system-prefers-dark' import { isMacUserAgent, isWindowsUserAgent } from '@/components/terminal-pane/pane-helpers' import { useConfirmationDialog } from '@/components/confirmation-dialog-context' @@ -64,16 +63,8 @@ export function useSettingsStoreModel() { const runtimeEnvironments = useAppStore((s) => s.runtimeEnvironments) const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) - const projectHostSetupProjection = useProjectHostSetupProjection() // Why: same inputs as nav metadata so both lists agree — the source of truth for the pane list. - const settingsProjectList = useMemo( - () => - buildSettingsProjectList(repos, { - projects: projectHostSetupProjection.projects, - projectHostSetups: projectHostSetupProjection.setups - }), - [repos, projectHostSetupProjection] - ) + const settingsProjectList = useMemo(() => buildSettingsProjectList(repos), [repos]) const repoIdToRepresentative = useMemo( () => buildRepoIdToRepresentative(settingsProjectList), [settingsProjectList] diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts index 7cd9fa0cb65d..1902bd7a1dd5 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts @@ -373,192 +373,6 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('splits same-host checkouts of one project into separate per-setup groups', () => { - const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } - const worktreeB: Worktree = { - ...worktree, - id: 'wt-2', - repoId: repoB.id, - path: '/tmp/orca-2-feature', - displayName: 'feature-b' - } - const localSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: repoB.id, - repoId: repoB.id, - path: repoB.path, - displayName: repoB.displayName - } - const rows = buildRows( - 'repo', - [worktree, worktreeB], - new Map([ - [repo.id, repo], - [repoB.id, repoB] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [worktreeB.id, worktreeB] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }], - projectHostSetups: [projectHostSetups[0]!, localSetupB] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers).toHaveLength(2) - expect(headers).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-1', - label: 'orca' - }), - expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-2', - label: 'orca-2' - }) - ]) - ) - }) - - it('splits only the surface with duplicate checkouts', () => { - const localRepoB: Repo = { - ...repo, - id: 'repo-local-b', - path: '/tmp/orca-b', - displayName: 'orca-b' - } - const localWorktreeB: Worktree = { - ...worktree, - id: 'wt-local-b', - repoId: localRepoB.id, - path: '/tmp/orca-b-feature', - displayName: 'feature-b' - } - const localSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: localRepoB.id, - repoId: localRepoB.id, - path: localRepoB.path, - displayName: localRepoB.displayName - } - const rows = buildRows( - 'repo', - [worktree, localWorktreeB, remoteWorktree], - new Map([ - [repo.id, repo], - [localRepoB.id, localRepoB], - [remoteRepo.id, remoteRepo] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [localWorktreeB.id, localWorktreeB], - [remoteWorktree.id, remoteWorktree] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.id] }], - projectHostSetups: [projectHostSetups[0]!, localSetupB, projectHostSetups[1]!] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key)).toEqual([ - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b', - 'project:github:stablyai/orca' - ]) - }) - - it('counts projection twins for one directory as one checkout', () => { - const runtimeHostId = 'runtime:m2-air' - const runtimeRepo: Repo = { - ...remoteRepo, - id: 'repo-runtime', - path: '/home/alice/orca-runtime', - connectionId: undefined, - executionHostId: runtimeHostId - } - const runtimeWorktree: Worktree = { - ...remoteWorktree, - id: 'wt-runtime', - repoId: runtimeRepo.id, - path: '/home/alice/orca-runtime-feature' - } - const runtimeSetup: ProjectHostSetup = { - ...projectHostSetups[1]!, - id: runtimeRepo.id, - repoId: runtimeRepo.id, - path: runtimeRepo.path, - hostId: runtimeHostId, - executionHostId: runtimeHostId - } - const derivedSetup: ProjectHostSetup = { - ...projectHostSetups[1]!, - hostId: runtimeHostId, - connectionId: 'intel mac', - executionHostId: runtimeHostId - } - const authoritativeSetup: ProjectHostSetup = { - ...derivedSetup, - id: 'setup-authoritative', - path: `${derivedSetup.path}/`, - connectionId: null, - executionHostId: 'ssh:intel%20mac' - } - const grouping = { - projects: [{ ...project, sourceRepoIds: [remoteRepo.id, runtimeRepo.id] }], - projectHostSetups: [runtimeSetup, derivedSetup, authoritativeSetup] - } - - expect([ - getGroupKeyForWorktree( - 'repo', - remoteWorktree, - new Map([[remoteRepo.id, remoteRepo]]), - null, - undefined, - undefined, - grouping - ), - getGroupKeyForWorktree( - 'repo', - runtimeWorktree, - new Map([[runtimeRepo.id, runtimeRepo]]), - null, - undefined, - undefined, - grouping - ) - ]).toEqual(['project:github:stablyai/orca', 'project:github:stablyai/orca']) - }) - it('keeps Git hosts grouped when folder setups share the project identity', () => { const windowsHostId = 'runtime:windows-server' const windowsRepo: Repo = { @@ -692,100 +506,4 @@ describe('buildRows with pinned worktrees', () => { count: 2 }) }) - - it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { - // Why: guards the intersection of #5374 (real same-host checkouts split) and - // #6320 (provisioned copies nest). Two legacy checkouts must each get their own - // header while a provisioned copy of the same project stays under the plain - // project header — all on one host surface, simultaneously. - const localRepoB: Repo = { - ...repo, - id: 'repo-local-b', - path: '/tmp/orca-b', - displayName: 'orca-b' - } - const localWorktreeB: Worktree = { - ...worktree, - id: 'wt-local-b', - repoId: localRepoB.id, - path: '/tmp/orca-b-feature', - displayName: 'feature-b' - } - const localSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: localRepoB.id, - repoId: localRepoB.id, - path: localRepoB.path, - displayName: localRepoB.displayName - } - const runtimeRepoB: Repo = { - ...repo, - id: 'repo-runtime-b', - path: '/tmp/orca-runtime-b', - displayName: 'orca-runtime-b' - } - const runtimeWorktreeB: Worktree = { - ...worktree, - id: 'wt-runtime-b', - repoId: runtimeRepoB.id, - path: '/tmp/orca-runtime-b-feature', - displayName: 'feature-runtime-b' - } - const runtimeSetupB: ProjectHostSetup = { - ...projectHostSetups[0]!, - id: runtimeRepoB.id, - repoId: runtimeRepoB.id, - path: runtimeRepoB.path, - displayName: runtimeRepoB.displayName, - setupMethod: 'provisioned' - } - const rows = buildRows( - 'repo', - [worktree, localWorktreeB, runtimeWorktreeB], - new Map([ - [repo.id, repo], - [localRepoB.id, localRepoB], - [runtimeRepoB.id, runtimeRepoB] - ]), - null, - new Set(), - undefined, - undefined, - undefined, - {}, - new Map([ - [worktree.id, worktree], - [localWorktreeB.id, localWorktreeB], - [runtimeWorktreeB.id, runtimeWorktreeB] - ]), - false, - undefined, - [], - new Set(), - new Map(), - new Map(), - [], - { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], - projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] - } - ) - - const headers = rows.filter((row) => row.type === 'header') - expect(headers.map((row) => row.key).sort()).toEqual([ - 'project:github:stablyai/orca', - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b' - ]) - // The provisioned copy nests under the plain project key with only its own - // worktree; it never gets a path-scoped `::setup:` header like the real - // checkouts do, and that header keeps the project's own display name. - expect( - headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') - ).toBe(false) - expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ - label: 'Orca', - count: 1 - }) - }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts index c0554bef73ce..3d20170d9fe5 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/project-grouping.ts @@ -2,12 +2,6 @@ import type { Project, ProjectHostSetup } from '../../../../../../shared/project import type { Repo } from '../../../../../../shared/repo-types' import type { Worktree } from '../../../../../../shared/worktree/types' import type { RenderableFolderWorkspace } from './folder-workspace-lanes' -import { toSshExecutionHostId } from '../../../../../../shared/execution-host' -import { parseWslUncPath } from '../../../../../../shared/wsl-paths' -import { - isWindowsAbsolutePathLike, - normalizeRuntimePathForComparison -} from '../../../../../../shared/cross-platform-path' export type OrderedGroupEntry = [string, WorktreeGroupEntry] @@ -29,45 +23,10 @@ export type WorktreeGroupEntry = { export type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map - surfaceKeysRequiringSetupGroups: Set } const projectGroupingIndexCache = new WeakMap() -// Why: provisioned and folder setups are not independent Git checkouts. -function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { - return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' -} - -// Why: execution target and filesystem namespace independently identify a surface. -function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { - return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` -} - -function getExecutionSurface(setup: ProjectHostSetup): string { - const connectionId = setup.connectionId?.trim() - if (connectionId) { - return toSshExecutionHostId(connectionId) - } - return setup.executionHostId?.trim() || setup.hostId -} - -// Why: projection twins differ by row identity, not checkout directory. -function getCheckoutIdentity(setup: ProjectHostSetup): string { - return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id -} - -function getPathSurface(setup: ProjectHostSetup): string { - const wslPath = parseWslUncPath(setup.path) - if (wslPath) { - return `wsl:${wslPath.distro.toLowerCase()}` - } - if (isWindowsAbsolutePathLike(setup.path)) { - return 'windows-host' - } - return 'default' -} - export function buildProjectGroupingIndex( model?: ProjectGroupingModel ): ProjectGroupingIndex | null { @@ -84,45 +43,14 @@ export function buildProjectGroupingIndex( projectGroupingIndexCache.set(model, null) return null } - const checkoutsByProjectSurface = new Map>() - for (const setup of projectHostSetups) { - if (!isDistinctUserCheckout(setup)) { - continue - } - const key = getProjectSetupSurfaceKey(setup) - const existing = checkoutsByProjectSurface.get(key) - if (existing) { - existing.add(getCheckoutIdentity(setup)) - } else { - checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)])) - } - } - const surfaceKeysRequiringSetupGroups = new Set() - for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) { - if (checkouts.size > 1) { - surfaceKeysRequiringSetupGroups.add(surfaceKey) - } - } const index = { projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), - surfaceKeysRequiringSetupGroups + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) } projectGroupingIndexCache.set(model, index) return index } -// Why: Settings splits projects by this same rule so it matches the sidebar headers. -export function isCheckoutScopedProjectSetup( - setup: ProjectHostSetup, - projectIndex: ProjectGroupingIndex -): boolean { - return ( - projectIndex.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) && - isDistinctUserCheckout(setup) - ) -} - export type ProjectHeaderRevealTarget = { key: string label: string @@ -145,17 +73,6 @@ export function getProjectGroupingForRepo( repo } } - if (projectIndex && isCheckoutScopedProjectSetup(setup, projectIndex)) { - // Why: only the ambiguous surface needs checkout-specific headers. - return { - key: `project:${project.id}::setup:${repoId}`, - label: repo?.displayName ?? setup.displayName, - repo, - projectId: project.id - } - } - // Why: provisioned runtime copies and non-ambiguous checkouts follow project - // identity rather than path-scoped setup identity, so they stay in one project. return { key: `project:${project.id}`, label: project.displayName, diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts index 563387d0b938..0220ecf4383c 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts @@ -7,9 +7,7 @@ function getProjectIdFromHeaderRowKey(rowKey: string): string | null { if (!rowKey.startsWith('project:')) { return null } - const withoutPrefix = rowKey.slice('project:'.length) - const setupSeparator = withoutPrefix.indexOf('::setup:') - return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) + return rowKey.slice('project:'.length) } function getRepoIdsFromHeaderRowKey( @@ -20,11 +18,6 @@ function getRepoIdsFromHeaderRowKey( if (rowKey.startsWith('repo:')) { return [rowKey.slice('repo:'.length)] } - const setupMarker = '::setup:' - const setupIndex = rowKey.indexOf(setupMarker) - if (rowKey.startsWith('project:') && setupIndex !== -1) { - return [rowKey.slice(setupIndex + setupMarker.length)] - } const projectId = getProjectIdFromHeaderRowKey(rowKey) if (!projectId) { return [] diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index 606485eb91fb..44f09a2c879c 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -1,4 +1,3 @@ -import type { ProjectGroupingModel } from '@/components/sidebar/worktree-list/grouping/project-grouping' import type { Repo } from '../../../shared/repo-types' export type SettingsNavigationBuildOptions = { @@ -14,5 +13,4 @@ export type SettingsNavigationBuildOptions = { isDev: boolean isLinearConnected: boolean repos: readonly Repo[] - projectGrouping?: ProjectGroupingModel } diff --git a/src/renderer/src/hooks/settings-navigation-remote-sections.ts b/src/renderer/src/hooks/settings-navigation-remote-sections.ts index 2068252c5bac..05a591334d6c 100644 --- a/src/renderer/src/hooks/settings-navigation-remote-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-remote-sections.ts @@ -24,14 +24,7 @@ import { import type { SettingsNavigationBuildOptions } from './settings-navigation-build-options' export function buildRemoteSettingsSections( - { - isMac, - isWindowsTerminalHost, - isWebClient, - isDev, - repos, - projectGrouping - }: SettingsNavigationBuildOptions, + { isMac, isWindowsTerminalHost, isWebClient, isDev, repos }: SettingsNavigationBuildOptions, runtimeEnvironmentsSearchEntry: SettingsNavSection['searchEntries'][number], reposById: ReadonlyMap ): SettingsNavSection[] { @@ -162,8 +155,8 @@ export function buildRemoteSettingsSections( // Why: one nav row per project, not per repo row — a project set up on // multiple hosts (local + a Remote Orca Server) collapses to a single // entry. Same inputs as the panes so this list matches them. - ...buildSettingsProjectList(repos, projectGrouping).map((settingsProject) => { - const { project, representativeRepoId, setups, checkoutLabel } = settingsProject + ...buildSettingsProjectList(repos).map((settingsProject) => { + const { project, representativeRepoId, setups } = settingsProject const representativeRepo = reposById.get(representativeRepoId) ?? repos[0] const hostSummary = setups.length > 1 @@ -175,7 +168,7 @@ export function buildRemoteSettingsSections( : (setups[0]?.path ?? representativeRepo.path) return { id: `repo-${representativeRepoId}`, - title: checkoutLabel ?? project.displayName, + title: project.displayName, description: `${getRepoKindLabel(project)} • ${hostSummary}`, icon: SlidersHorizontal, searchEntries: getRepositoryPaneSearchEntries(representativeRepo, { diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 647a90e87fa8..4c23d73dd1dd 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -20,9 +20,7 @@ import { } from '@/lib/windows-terminal-capabilities' import { defaultScopeHost } from '@/lib/default-creation-host' import { useAppStore } from '@/store' -import { useProjectHostSetupProjection } from '@/store/selectors' import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' -import type { ProjectGroupingModel } from '@/components/sidebar/worktree-list/grouping/project-grouping' import type { Repo } from '../../../shared/repo-types' import { buildCapabilitySettingsSections, @@ -48,8 +46,7 @@ export function buildSettingsNavigationMetadata({ mobileEmulatorCreationEnabled = !isWebClient, isDev = import.meta.env.DEV, isLinearConnected = false, - repos, - projectGrouping + repos }: { isMac: boolean isWindows: boolean @@ -63,7 +60,6 @@ export function buildSettingsNavigationMetadata({ isDev?: boolean isLinearConnected?: boolean repos: readonly Repo[] - projectGrouping?: ProjectGroupingModel }): SettingsNavSection[] { const terminalPaneSearchEntries = getTerminalPaneSearchEntries({ isWindows, @@ -91,8 +87,7 @@ export function buildSettingsNavigationMetadata({ mobileEmulatorCreationEnabled, isDev, isLinearConnected, - repos, - projectGrouping + repos } // Why: this array's order must mirror SETTINGS_NAV_GROUPS so the Settings @@ -115,7 +110,6 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const { i18n } = useTranslation() const activeLocale = i18n.language const repos = useAppStore((state) => state.repos) - const projectHostSetupProjection = useProjectHostSetupProjection() const settings = useAppStore((state) => state.settings) const [managedBrowserCreationEnabled, mobileEmulatorCreationEnabled] = useAppStore( useShallow((state) => { @@ -177,11 +171,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isDev: import.meta.env.DEV, isLinearConnected, - repos, - projectGrouping: { - projects: projectHostSetupProjection.projects, - projectHostSetups: projectHostSetupProjection.setups - } + repos }), // oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections. [ @@ -196,7 +186,6 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, repos, - projectHostSetupProjection, activeLocale ] ) diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 64f4492b979e..80a9ba4df40a 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -8399,8 +8399,6 @@ "c7ef4415de": "Display Name", "b0a0c14a1c": "Project-specific display details for the sidebar and tabs.", "removeProjectAllHosts": "Remove this project from Orca on all configured hosts.", - "removeProjectCheckout": "Remove this checkout from Orca. Other checkouts of this project stay.", - "removeProjectKeepCheckouts": "Remove this project from Orca. Checkouts that have their own settings stay.", "0909e5d650": "Remove Project", "ee5a290616": "Opened as folder. Git features are unavailable for this workspace.", "323debba71": "Type:", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 4b4a24245364..6fcbdaf4ecfe 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -8105,8 +8105,6 @@ "c7ef4415de": "Nombre para mostrar", "b0a0c14a1c": "Detalles de visualización específicos del proyecto para la barra lateral y las pestañas.", "removeProjectAllHosts": "Eliminar este proyecto de Orca en todos los hosts configurados.", - "removeProjectCheckout": "Eliminar este checkout de Orca. Los demás checkouts de este proyecto se conservan.", - "removeProjectKeepCheckouts": "Eliminar este proyecto de Orca. Los checkouts que tienen su propia configuración se conservan.", "0909e5d650": "Eliminar proyecto", "ee5a290616": "Abierto como carpeta. Las funciones de Git no están disponibles para este espacio de trabajo.", "323debba71": "Tipo:", diff --git a/src/renderer/src/i18n/locales/fr.json b/src/renderer/src/i18n/locales/fr.json index be2e8712ee56..c5eac50c7289 100644 --- a/src/renderer/src/i18n/locales/fr.json +++ b/src/renderer/src/i18n/locales/fr.json @@ -8251,8 +8251,6 @@ "c7ef4415de": "Nom d'affichage", "b0a0c14a1c": "Détails d'affichage propres au projet pour la barre latérale et les onglets.", "removeProjectAllHosts": "Retirer ce projet d'Orca sur tous les hôtes configurés.", - "removeProjectCheckout": "Retirer ce checkout d'Orca. Les autres checkouts de ce projet sont conservés.", - "removeProjectKeepCheckouts": "Retirer ce projet d'Orca. Les checkouts qui ont leurs propres paramètres sont conservés.", "0909e5d650": "Retirer le projet", "ee5a290616": "Ouvert en tant que dossier. Les fonctions Git sont indisponibles pour cet espace de travail.", "323debba71": "Type :", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index c506df1cadbe..62b8d587985a 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -8127,8 +8127,6 @@ "c7ef4415de": "表示名", "b0a0c14a1c": "サイドバーとタブのプロジェクト固有の表示詳細。", "removeProjectAllHosts": "このプロジェクトを、設定済みのすべてのホストの Orca から削除します。", - "removeProjectCheckout": "このチェックアウトを Orca から削除します。このプロジェクトの他のチェックアウトは残ります。", - "removeProjectKeepCheckouts": "このプロジェクトを Orca から削除します。独自の設定を持つチェックアウトは残ります。", "0909e5d650": "プロジェクトの削除", "ee5a290616": "フォルダーとして開きます。このワークスペースでは Git 機能を使用できません。", "323debba71": "タイプ:", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index ca8c8aa97ffb..f19b7b03a9c0 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -8090,8 +8090,6 @@ "c7ef4415de": "표시 이름", "b0a0c14a1c": "사이드바 및 탭에 대한 프로젝트별 표시 세부 정보입니다.", "removeProjectAllHosts": "구성된 모든 호스트의 Orca에서 이 프로젝트를 제거합니다.", - "removeProjectCheckout": "Orca에서 이 체크아웃을 제거합니다. 이 프로젝트의 다른 체크아웃은 유지됩니다.", - "removeProjectKeepCheckouts": "Orca에서 이 프로젝트를 제거합니다. 자체 설정이 있는 체크아웃은 유지됩니다.", "0909e5d650": "프로젝트 제거", "ee5a290616": "폴더로 열렸습니다. 이 워크스페이스에서는 Git 기능을 사용할 수 없습니다.", "323debba71": "유형:", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index bae98dd0f02d..4bf0c3962dfe 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -8090,8 +8090,6 @@ "c7ef4415de": "显示名称", "b0a0c14a1c": "侧边栏和选项卡的项目特定显示详细信息。", "removeProjectAllHosts": "从所有已配置主机的 Orca 中删除该项目。", - "removeProjectCheckout": "从 Orca 中删除此检出。此项目的其他检出将保留。", - "removeProjectKeepCheckouts": "从 Orca 中删除该项目。拥有独立设置的检出将保留。", "0909e5d650": "删除项目", "ee5a290616": "作为文件夹打开。 Git 功能对此工作区不可用。", "323debba71": "类型:", diff --git a/src/renderer/src/store/project-host-setup-selector-normalization.ts b/src/renderer/src/store/project-host-setup-selector-normalization.ts index 1c288ceb3de2..6ba749e1045d 100644 --- a/src/renderer/src/store/project-host-setup-selector-normalization.ts +++ b/src/renderer/src/store/project-host-setup-selector-normalization.ts @@ -1,6 +1,10 @@ import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' -import type { ProjectHostSetupProjection } from '../../../shared/project-host-setup-projection' +import { + getProjectIdentityKey, + isHostLocalProjectId, + type ProjectHostSetupProjection +} from '../../../shared/project-host-setup-projection' export type NormalizedProjectHostSetupProjection = ProjectHostSetupProjection & { changed: boolean @@ -32,11 +36,16 @@ export function normalizeHydratedProjectHostSetupProjection( if (!projectId) { return setup } - if (projectId === setup.projectId || projectId === `repo:${repo.id}`) { + // Why: a host-local id from a repo with no known remote means this client can't name the + // project yet; a host-local id for a repo with a remote means a same-surface sibling claimed it. + if (projectId === setup.projectId || isHostLocalProjectId(getProjectIdentityKey(repo))) { return setup } changed = true - projectIdByHydratedProjectId.set(setup.projectId, projectId) + // Why: an outclaimed checkout leaves its old project live for the claimant; only rename dead ids. + if (!derivedProjectIds.has(setup.projectId)) { + projectIdByHydratedProjectId.set(setup.projectId, projectId) + } return { ...setup, projectId } }) const normalizedProjects = projects.flatMap((project) => { diff --git a/src/shared/project-checkout-claim.ts b/src/shared/project-checkout-claim.ts new file mode 100644 index 000000000000..a2e9fff76c40 --- /dev/null +++ b/src/shared/project-checkout-claim.ts @@ -0,0 +1,79 @@ +import { toSshExecutionHostId } from './execution-host' +import type { ProjectHostSetup } from './project-types' +import { parseWslUncPath } from './wsl-paths' +import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' + +/** + * Setups that must not hold their remote-derived project id because another checkout on the + * same surface already does. A shared remote is the only evidence that checkouts on different + * hosts are one project, but two checkouts on one surface are separate clones (linked worktrees + * share a repo row), and a fork, template or copied `.git/config` shares the remote too. So per + * surface only the earliest-added checkout keeps the shared id. + */ +export function getOutclaimedProjectSetups( + setups: readonly ProjectHostSetup[] +): ReadonlySet { + const claimantBySurface = new Map() + for (const setup of setups) { + if (!isDistinctUserCheckout(setup)) { + continue + } + const surfaceKey = getProjectSetupSurfaceKey(setup) + const claimant = claimantBySurface.get(surfaceKey) + if (!claimant || compareClaims(setup, claimant) < 0) { + claimantBySurface.set(surfaceKey, setup) + } + } + return new Set( + setups.filter((setup) => { + const claimant = claimantBySurface.get(getProjectSetupSurfaceKey(setup)) + return ( + isDistinctUserCheckout(setup) && + claimant !== undefined && + getCheckoutIdentity(claimant) !== getCheckoutIdentity(setup) + ) + }) + ) +} + +// Why: provisioned and folder setups are not independent Git checkouts. +function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { + return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' +} + +// Why: execution target and filesystem namespace independently identify a surface, so a +// Windows checkout and a WSL checkout on one machine stay one project. +function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { + return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` +} + +function getExecutionSurface(setup: ProjectHostSetup): string { + const connectionId = setup.connectionId?.trim() + if (connectionId) { + return toSshExecutionHostId(connectionId) + } + return setup.executionHostId?.trim() || setup.hostId +} + +function getPathSurface(setup: ProjectHostSetup): string { + const wslPath = parseWslUncPath(setup.path) + if (wslPath) { + return `wsl:${wslPath.distro.toLowerCase()}` + } + if (isWindowsAbsolutePathLike(setup.path)) { + return 'windows-host' + } + return 'default' +} + +// Why: projection twins differ by row identity, not checkout directory. +function getCheckoutIdentity(setup: ProjectHostSetup): string { + return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id +} + +// Why createdAt (the repo's addedAt) then repo id: repo order comes from disk/host enumeration, +// and a client and its paired server must pick the same claimant from the same rows. +function compareClaims(left: ProjectHostSetup, right: ProjectHostSetup): number { + const byAddedAt = left.createdAt - right.createdAt + return byAddedAt || (left.repoId < right.repoId ? -1 : left.repoId > right.repoId ? 1 : 0) +} diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index f6eeb9a0bdb5..008e6510677e 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -19,6 +19,53 @@ function repo(overrides: Partial & Pick { + it('lets a shared remote join checkouts across hosts but not two checkouts on one host', () => { + const upstream = { owner: 'stablyai', repo: 'orca' } + const firstLocal = repo({ id: 'local-a', path: '/a', displayName: 'a', addedAt: 100, upstream }) + const secondLocal = repo({ + id: 'local-b', + path: '/b', + displayName: 'b', + addedAt: 200, + upstream + }) + const remoteCopy = repo({ + id: 'remote-a', + path: '/home/a', + displayName: 'a', + addedAt: 300, + connectionId: 'gpu-vm', + upstream + }) + + const projection = projectHostSetupProjectionFromRepos([secondLocal, remoteCopy, firstLocal]) + expect(projection.setups.map((setup) => [setup.repoId, setup.projectId])).toEqual([ + ['local-b', 'repo:local-b'], + ['remote-a', 'github:stablyai/orca'], + ['local-a', 'github:stablyai/orca'] + ]) + + // A WSL checkout is another surface of the same machine, so it joins the project too. + const wslCopy = repo({ + id: 'local-wsl', + path: '\\\\wsl.localhost\\Ubuntu\\home\\a\\orca', + displayName: 'a', + addedAt: 400, + upstream + }) + expect( + projectHostSetupProjectionFromRepos([firstLocal, wslCopy]).projects.map( + (p) => p.sourceRepoIds + ) + ).toEqual([['local-a', 'local-wsl']]) + + // Removing the claimant hands the shared id to the next checkout on that host. + const handedOff = projectHostSetupProjectionFromRepos([secondLocal, remoteCopy]) + expect(handedOff.projects.map((project) => [project.id, project.sourceRepoIds])).toEqual([ + ['github:stablyai/orca', ['local-b', 'remote-a']] + ]) + }) + it('keeps timestamps stable when addedAt is 0 across different now values', () => { const target = repo({ id: 'repo-1', @@ -302,8 +349,9 @@ describe('project host setup projection', () => { }), repo({ id: 'old-branch-checkout', - path: '/Users/alice/orca/workspaces/orca/re-enable-webgl-for-remote-runtime-terminals', + path: '/home/alice/orca/workspaces/orca/re-enable-webgl-for-remote-runtime-terminals', displayName: 're-enable-webgl-for-remote-runtime-terminals', + connectionId: 'gpu-vm', repoIcon: { type: 'image', src: 'https://github.com/stablyai.png?size=64', @@ -691,6 +739,7 @@ it('appends project source IDs without repeatedly copying an accumulating array' id: `source-${i}`, path: `/repos/${i}`, displayName: `Repo ${i}`, + connectionId: `host-${i}`, upstream: { owner: 'acme', repo: 'project' } }) ) diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 9f5d766f683c..c9b451c2566f 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -7,7 +7,7 @@ import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' import type { Repo } from './repo-types' -import { getSameHostOutclaimedRepos } from './project-same-host-claim' +import { getOutclaimedProjectSetups } from './project-checkout-claim' /** * A projection-local draft. `project` is always freshly built by `createProjectFromRepo`, never a @@ -308,22 +308,24 @@ export function projectHostSetupProjectionFromRepos( const projectById = new Map() const setups: ProjectHostSetup[] = [] - const remoteProjectIds = repos.map(getProjectIdentityKey) - const outclaimed = getSameHostOutclaimedRepos(repos, remoteProjectIds) + // Why normalize here: a repo row is untrusted persisted/wire data too, and these + // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. + const remoteSetups = repos.map((repo) => + normalizeProjectHostSetupRow(createSetupFromRepo(repo, getProjectIdentityKey(repo))) + ) + const outclaimed = getOutclaimedProjectSetups(remoteSetups) for (const [index, repo] of repos.entries()) { - const projectId = outclaimed.has(repo) - ? `${HOST_LOCAL_PROJECT_ID_PREFIX}${repo.id}` - : remoteProjectIds[index] - const existing = projectById.get(projectId) + const remoteSetup = remoteSetups[index] + const setup = outclaimed.has(remoteSetup) + ? { ...remoteSetup, projectId: `${HOST_LOCAL_PROJECT_ID_PREFIX}${repo.id}` } + : remoteSetup + const existing = projectById.get(setup.projectId) if (existing) { mergeProjectRepo(existing, repo) } else { - const project = createProjectFromRepo(repo, projectId) - projectById.set(projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) + const project = createProjectFromRepo(repo, setup.projectId) + projectById.set(setup.projectId, { project, sourceRepoIds: new Set(project.sourceRepoIds) }) } - // Why normalize here: a repo row is untrusted persisted/wire data too, and these - // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. - const setup = normalizeProjectHostSetupRow(createSetupFromRepo(repo, projectId)) setups.push(setup) } diff --git a/src/shared/project-same-host-claim.ts b/src/shared/project-same-host-claim.ts deleted file mode 100644 index 6864f5a98ee4..000000000000 --- a/src/shared/project-same-host-claim.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { getRepoExecutionHostId } from './execution-host' -import type { Repo } from './repo-types' - -/** - * Repos that must not hold their remote-derived project id because a sibling on the same host - * already does. A shared remote is the only evidence that checkouts on different hosts are one - * project, but on one host two repo rows are two separate checkouts (linked worktrees share a - * row), and a fork, template or copied `.git/config` shares the remote too. So per host only the - * earliest-added checkout keeps the shared id. - */ -export function getSameHostOutclaimedRepos( - repos: readonly Repo[], - projectIds: readonly string[] -): ReadonlySet { - const claimKeys = repos.map( - (repo, index) => `${projectIds[index]}\u0000${getRepoExecutionHostId(repo)}` - ) - const claimantByKey = new Map() - for (const [index, repo] of repos.entries()) { - const claimant = claimantByKey.get(claimKeys[index]) - if (!claimant || compareClaims(repo, claimant) < 0) { - claimantByKey.set(claimKeys[index], repo) - } - } - // Why compare ids: duplicate rows of one repo are the same checkout, never rivals. - return new Set(repos.filter((repo, index) => claimantByKey.get(claimKeys[index])?.id !== repo.id)) -} - -// Why addedAt then id: repo order comes from disk/host enumeration, and a client and its paired -// server must pick the same claimant from the same rows. -function compareClaims(left: Repo, right: Repo): number { - const byAddedAt = claimTimestamp(left) - claimTimestamp(right) - return byAddedAt || (left.id < right.id ? -1 : left.id > right.id ? 1 : 0) -} - -function claimTimestamp(repo: Repo): number { - return Number.isFinite(repo.addedAt) ? repo.addedAt : 0 -} From b4d1dda1c0a93988c02706416d6ac7c6826d45fb Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:46:29 -0700 Subject: [PATCH 03/29] WIP: index derived ids by repo in the hydration normalizer --- .../settings/settings-project-list.ts | 6 +++--- ...project-host-setup-selector-normalization.ts | 17 +++++++++++------ 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index 9c08bdb5f053..24e84fc93866 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -185,9 +185,9 @@ export function resolveSettingsTargetRepoId( } /** - * Removes a Settings entry's setup on every host it exists on. Sequential so each host's teardown + - * projection recompute don't interleave; setups without a repo row - * (planned/not-set-up hosts) have nothing to remove. + * Removes a Settings entry's setup on every host it exists on. Sequential so + * each host's teardown + projection recompute don't interleave; setups without + * a repo row (planned/not-set-up hosts) have nothing to remove. */ export async function removeSettingsProjectFromAllHosts( setups: readonly ProjectHostSetup[], diff --git a/src/renderer/src/store/project-host-setup-selector-normalization.ts b/src/renderer/src/store/project-host-setup-selector-normalization.ts index 6ba749e1045d..0215d975c367 100644 --- a/src/renderer/src/store/project-host-setup-selector-normalization.ts +++ b/src/renderer/src/store/project-host-setup-selector-normalization.ts @@ -19,10 +19,15 @@ export function normalizeHydratedProjectHostSetupProjection( const repoById = new Map(repos.map((repo) => [repo.id, repo])) const derivedProjectIds = new Set(derived.projects.map((project) => project.id)) // Why the derived setup, not the repo's own remote key: whether a repo keeps the shared id - // depends on its same-host siblings, which only the whole-catalog projection sees. - const derivedProjectIdBySetupHost = new Map( - derived.setups.map((setup) => [`${setup.repoId}\u0000${setup.hostId}`, setup.projectId]) - ) + // depends on its same-surface siblings, which only the whole-catalog projection sees. + const derivedProjectIdByRepoHost = new Map() + const derivedProjectIdByRepo = new Map() + for (const setup of derived.setups) { + derivedProjectIdByRepoHost.set(`${setup.repoId}\u0000${setup.hostId}`, setup.projectId) + if (!derivedProjectIdByRepo.has(setup.repoId)) { + derivedProjectIdByRepo.set(setup.repoId, setup.projectId) + } + } const projectIdByHydratedProjectId = new Map() let changed = false const normalizedSetups = setups.map((setup) => { @@ -31,8 +36,8 @@ export function normalizeHydratedProjectHostSetupProjection( return setup } const projectId = - derivedProjectIdBySetupHost.get(`${repo.id}\u0000${setup.hostId}`) ?? - derived.setups.find((candidate) => candidate.repoId === repo.id)?.projectId + derivedProjectIdByRepoHost.get(`${repo.id}\u0000${setup.hostId}`) ?? + derivedProjectIdByRepo.get(repo.id) if (!projectId) { return setup } From ab86447c58e28ef5d60442f4eff95fdab4268ab9 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 03:08:02 -0700 Subject: [PATCH 04/29] Resolve workspace project affiliation from current checkout owners --- .../session-search-scope-catalog.ts | 14 ++- .../session-search-scope-resolution.ts | 15 ++- .../use-recent-project-ids.test.ts | 33 ++++++ .../new-workspace/use-recent-project-ids.ts | 14 ++- .../right-sidebar/ai-vault-project-key.ts | 26 ++--- .../ai-vault-session-projects.test.ts | 54 ++++++--- .../ai-vault-session-projects.ts | 33 ++---- .../settings/settings-project-list.ts | 10 +- .../settings-project-regrouping.test.ts | 54 ++++----- .../settings/use-settings-store-model.ts | 11 +- ...ee-list-groups-project-host-setups.test.ts | 61 ++++++---- .../navigation/reveal-ancestors.ts | 9 +- .../settings-navigation-build-options.ts | 2 + .../settings-navigation-remote-sections.ts | 11 +- .../hooks/useSettingsNavigationMetadata.ts | 18 ++- src/renderer/src/lib/http-link-routing.ts | 19 +++- .../src/lib/local-preflight-context.ts | 20 ++-- .../lib/local-project-runtime-workspace.ts | 29 ++++- ...workspace-port-localhost-label-selector.ts | 29 +++-- .../store/project-host-setup-selector.test.ts | 6 +- src/shared/project-host-setup-lookup.test.ts | 34 ++++++ src/shared/project-host-setup-lookup.ts | 104 +++++++++++++++++- src/shared/project-section-grouping.ts | 80 +------------- 23 files changed, 442 insertions(+), 244 deletions(-) create mode 100644 src/shared/project-host-setup-lookup.test.ts diff --git a/src/main/ai-vault-search/session-search-scope-catalog.ts b/src/main/ai-vault-search/session-search-scope-catalog.ts index 4a53db8a8d47..3d93346d5df0 100644 --- a/src/main/ai-vault-search/session-search-scope-catalog.ts +++ b/src/main/ai-vault-search/session-search-scope-catalog.ts @@ -1,6 +1,7 @@ import type { GlobalSettings } from '../../shared/global-settings-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' +import type { WorkspaceProjectOwner } from '../../shared/project-host-setup-lookup' import type { WorktreeMeta } from '../../shared/worktree/meta-types' // Persisted state, never a git scan: a handful of prefixes answers a search that @@ -11,12 +12,19 @@ export type SessionSearchScopeCatalog = { 'id' | 'path' | 'kind' | 'worktreeBasePath' | 'connectionId' | 'executionHostId' >[] projects: readonly Pick[] - projectHostSetups: readonly Pick< + projectHostSetups: readonly (Pick< ProjectHostSetup, 'projectId' | 'repoId' | 'path' | 'worktreeBasePath' - >[] + > & + Partial>)[] /** Registered workspaces of this host, keyed by worktree id. */ - worktreeMeta: Readonly>> + worktreeMeta: Readonly< + Record< + string, + Pick & + Partial> + > + > settings: Pick } diff --git a/src/main/ai-vault-search/session-search-scope-resolution.ts b/src/main/ai-vault-search/session-search-scope-resolution.ts index af3ddc7f5bab..81b6d4c6eb6b 100644 --- a/src/main/ai-vault-search/session-search-scope-resolution.ts +++ b/src/main/ai-vault-search/session-search-scope-resolution.ts @@ -1,3 +1,4 @@ +import { getProjectHostSetupLookup } from '../../shared/project-host-setup-lookup' import { toAiVaultProjectKey } from '../../shared/ai-vault-project-key' import type { AiVaultSearchScopeIdentity } from '../../shared/ai-vault-search-scope' import { @@ -9,6 +10,7 @@ import { import { areRuntimePathsEqual } from '../../shared/worktree/ownership' import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' import type { SessionSearchHostScope } from './session-search-service' +import { getRepoExecutionHostId } from '../../shared/execution-host' import { managedWorktreeDirectories, ScopePathSet } from './session-search-scope-paths' type ScopeRepo = SessionSearchScopeCatalog['repos'][number] @@ -73,6 +75,8 @@ function registeredWorktreeId( function resolveProjectScope(projectKey: string, catalog: SessionSearchScopeCatalog): string[] { const paths = new ScopePathSet() const repoIds = new Set() + const owners = getProjectHostSetupLookup(catalog.projectHostSetups) + const reposById = new Map(catalog.repos.map((repo) => [repo.id, repo])) for (const setup of catalog.projectHostSetups) { if (toAiVaultProjectKey(setup.projectId, setup.repoId) === projectKey) { repoIds.add(setup.repoId) @@ -84,7 +88,14 @@ function resolveProjectScope(projectKey: string, catalog: SessionSearchScopeCata for (const project of catalog.projects) { if (project.id === projectId) { for (const repoId of project.sourceRepoIds) { - repoIds.add(repoId) + const repo = reposById.get(repoId) + const owner = owners.forWorkspace({ + repoId, + hostId: repo ? getRepoExecutionHostId(repo) : undefined + }) + if (!owner || toAiVaultProjectKey(owner.projectId, repoId) === projectKey) { + repoIds.add(repoId) + } } } } @@ -94,7 +105,7 @@ function resolveProjectScope(projectKey: string, catalog: SessionSearchScopeCata } for (const [worktreeId, meta] of Object.entries(catalog.worktreeMeta)) { const repoId = getRepoIdFromWorktreeId(worktreeId) - if (toAiVaultProjectKey(meta.projectId ?? null, repoId) === projectKey) { + if (toAiVaultProjectKey(owners.projectId({ ...meta, repoId }) ?? null, repoId) === projectKey) { repoIds.add(repoId) } } diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts index aa82e46ea46e..2f302d3a5336 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import type { Worktree } from '../../../../shared/worktree/types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { orderProjectIdsByRecency } from './use-recent-project-ids' function worktree(partial: Partial): Worktree { @@ -58,3 +59,35 @@ describe('orderProjectIdsByRecency', () => { expect(ids[0]).toBe('beta') }) }) + +it('indexes current workspace ownership linearly at N and 10N and reuses an unchanged catalog', () => { + const counts = [10, 100].map((count) => { + let reads = 0 + const repos = Array.from({ length: count }, (_, i) => ({ + id: `checkout-${i}`, + path: `/checkout/${i}`, + displayName: `${i}`, + badgeColor: '', + addedAt: i + })) + const setups = projectHostSetupProjectionFromRepos(repos).setups.map((setup) => ({ + ...setup, + get repoId() { + reads++ + return setup.repoId + } + })) + const workspaces = repos.map((repo, i) => + worktree({ repoId: repo.id, projectId: 'old-shared-project', createdAt: i }) + ) + const ids = orderProjectIdsByRecency(workspaces, setups) + expect(ids).toHaveLength(count * 2) + expect(ids[0]).toBe(`repo:checkout-${count - 1}`) + const firstReads = reads + expect(orderProjectIdsByRecency(workspaces, setups)).toEqual(ids) + expect(reads).toBe(firstReads) + expect(firstReads).toBeLessThanOrEqual(count * 8) + return firstReads + }) + expect(counts[1]).toBeLessThanOrEqual(counts[0] * 10) +}) diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts index e4f4c26a0174..19439dc367ea 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts @@ -1,4 +1,7 @@ import { useMemo } from 'react' +import { useProjectHostSetupProjection } from '@/store/selectors' +import { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' +import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Worktree } from '../../../../shared/worktree/types' import { useAllWorktrees } from '@/store/selectors' import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options' @@ -9,10 +12,14 @@ import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-p * per-project recency, and workspace creation is exactly the action this picker * is about to repeat, so it's the honest proxy rather than a new store field. */ -export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] { +export function orderProjectIdsByRecency( + worktrees: readonly Worktree[], + setups?: readonly ProjectHostSetup[] +): string[] { + const owners = getProjectHostSetupLookup(setups) const newestByProject = new Map() for (const worktree of worktrees) { - const projectId = worktree.projectId + const projectId = owners.projectId(worktree) // Legacy repo-only workspaces have no projectId and can't be attributed. if (projectId === undefined || projectId === '') { continue @@ -34,5 +41,6 @@ export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string export function useRecentProjectIds(): string[] { const worktrees = useAllWorktrees() - return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees]) + const { setups } = useProjectHostSetupProjection() + return useMemo(() => orderProjectIdsByRecency(worktrees, setups), [worktrees, setups]) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts index 0d5561ff39ec..826710faeb7a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts @@ -1,4 +1,5 @@ -import type { ProjectHostSetup } from '../../../../shared/project-types' +import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import type { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' @@ -6,18 +7,15 @@ import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' export function resolveActiveProjectKey( activeRepo: Repo | null, activeWorktree: Worktree | null, - setupByRepoId: ReadonlyMap + owners: ReturnType ): string | null { - if (activeWorktree?.projectId) { - return toAiVaultProjectKey(activeWorktree.projectId, activeWorktree.repoId) - } - - const setup = - (activeRepo ? setupByRepoId.get(activeRepo.id) : null) ?? - (activeWorktree ? setupByRepoId.get(activeWorktree.repoId) : null) - if (setup) { - return toAiVaultProjectKey(setup.projectId, setup.repoId || activeRepo?.id) - } - - return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null) + const owner = + activeWorktree ?? + (activeRepo + ? { + repoId: activeRepo.id, + hostId: getRepoExecutionHostId(activeRepo) + } + : null) + return toAiVaultProjectKey(owner ? owners.projectId(owner) : null, owner?.repoId ?? null) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index 89d6ee552ed0..bdba42b4e02f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -5,11 +5,11 @@ import type { Project, ProjectHostSetup } from '../../../../shared/project-types import * as paths from '../../../../shared/cross-platform-path' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' import { buildAiVaultProjectContext, buildAiVaultSessionProjectById } from './ai-vault-session-projects' +import { resolveSessionSearchScope } from '../../../../main/ai-vault-search/session-search-scope-resolution' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { truncateAiVaultListResult } from '../../../../shared/ai-vault-session-depth' import { @@ -40,14 +40,6 @@ const baseSession: AiVaultSession = { subagent: null } -describe('toAiVaultProjectKey', () => { - it('does not double-wrap compatibility repo project ids', () => { - expect(toAiVaultProjectKey('project-1', 'repo-1')).toBe('project:project-1') - expect(toAiVaultProjectKey('repo:repo-1', 'repo-1')).toBe('repo:repo-1') - expect(toAiVaultProjectKey(null, 'repo-1')).toBe('repo:repo-1') - }) -}) - describe('buildAiVaultProjectContext', () => { // #28589: an imported folder whose copied .git/config points at another checkout's remote. it("keeps a same-host folder that shares a remote out of the other checkout's project", () => { @@ -70,18 +62,48 @@ describe('buildAiVaultProjectContext', () => { addedAt: 200, gitRemoteIdentity: remote }) - const niwSession = makeSession({ id: 'claude:niw', cwd: niw.path }) + const linkedPath = '/Users/benny/workspaces/niw-topic' + const oldProjectId = 'github:bennyoooo/open-social' + const worktree = makeWorktree({ + id: `niw::${linkedPath}`, + repoId: niw.id, + projectHostSetupId: niw.id, + projectId: oldProjectId, + path: linkedPath + }) + const niwSession = makeSession({ id: 'claude:niw', cwd: linkedPath }) + const projection = projectHostSetupProjectionFromRepos([niw, openSocial]) const openSocialSession = makeSession({ id: 'claude:open-social', cwd: openSocial.path }) const context = buildAiVaultProjectContext({ repos: [niw, openSocial], - worktrees: [], - projectHostSetupProjection: projectHostSetupProjectionFromRepos([niw, openSocial]), + worktrees: [worktree], + projectHostSetupProjection: projection, activeRepo: niw, - activeWorktree: null, + activeWorktree: worktree, sessions: [niwSession, openSocialSession] }) + const catalog = { + repos: [niw, openSocial], + projects: projection.projects, + projectHostSetups: projection.setups, + worktreeMeta: { + [worktree.id]: { ...worktree, priorWorktreeIds: ['niw::/Users/benny/old-niw-topic'] } + }, + settings: { nestWorkspaces: false } + } + const ownerScope = resolveSessionSearchScope( + { kind: 'project', projectKey: 'repo:niw' }, + catalog + ) + expect(ownerScope).toEqual({ + kind: 'resolved', + paths: expect.arrayContaining([niw.path, linkedPath, '/Users/benny/old-niw-topic']) + }) + expect( + resolveSessionSearchScope({ kind: 'project', projectKey: `project:${oldProjectId}` }, catalog) + ).toEqual({ kind: 'resolved', paths: [openSocial.path] }) expect(context.activeProjectKey).toBe('repo:niw') expect(context.sessionProjectById.get(niwSession.id)).toMatchObject({ key: 'repo:niw', @@ -302,7 +324,7 @@ describe('buildAiVaultProjectContext', () => { expect(context.sessionProjectById.get(secondSession.id)?.label).toBe('Canonical Project') }) - it('chooses the most specific nested path and lets worktrees win equal-length ties', () => { + it('keeps the most specific workspace path while using its current checkout affiliation', () => { const repo = makeRepo({ id: 'repo-root', displayName: 'Root', path: '/repo' }) const childRepo = makeRepo({ id: 'repo-child', displayName: 'Child Repo', path: '/repo/pkg' }) const worktree = makeWorktree({ @@ -317,7 +339,7 @@ describe('buildAiVaultProjectContext', () => { repos: [repo, childRepo], worktrees: [worktree], projectHostSetupProjection: makeProjection({ - projects: [makeProject({ id: 'child-project', displayName: 'Child Project' })], + projects: [makeProject({ id: 'setup-child', displayName: 'Child Project' })], setups: [ makeSetup({ repoId: repo.id, projectId: 'root-project', path: repo.path }), makeSetup({ repoId: childRepo.id, projectId: 'setup-child', path: childRepo.path }) @@ -329,7 +351,7 @@ describe('buildAiVaultProjectContext', () => { }) expect(context.sessionProjectById.get(session.id)).toMatchObject({ - key: 'project:child-project', + key: 'project:setup-child', label: 'Child Project' }) }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index 678d8091fc23..9f0c42d4036d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -9,7 +9,7 @@ import { } from '../../../../shared/execution-host' import type { ProjectHostSetupProjection } from '../../../../shared/project-host-setup-projection' import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' +import { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -60,10 +60,10 @@ export function buildAiVaultProjectContext({ activeWorktree, sessions }: ProjectResolverArgs): AiVaultProjectContext { - const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) return { - activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, setupByRepoId), + activeProjectKey: resolveActiveProjectKey(activeRepo, activeWorktree, owners), activeRepoId: activeRepo?.id ?? activeWorktree?.repoId ?? null, projectLabelByKey: buildProjectLabelByKey(repos, projectHostSetupProjection), sessionProjectById: buildAiVaultSessionProjectById({ @@ -86,14 +86,9 @@ export function buildAiVaultSessionProjectById({ sessions }: Omit): Map { const repoById = new Map(repos.map((repo) => [repo.id, repo])) - const setupByRepoId = buildSetupByRepoId(projectHostSetupProjection.setups) + const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) - const candidates = buildProjectCandidates( - worktrees, - projectHostSetupProjection, - repoById, - setupByRepoId - ) + const candidates = buildProjectCandidates(worktrees, projectHostSetupProjection, repoById, owners) const sessionProjectById = new Map() const projectsByHostAndCwd = new Map< ExecutionHostId | null, @@ -117,18 +112,6 @@ export function buildAiVaultSessionProjectById({ return sessionProjectById } -function buildSetupByRepoId( - setups: readonly ProjectHostSetup[] -): ReadonlyMap { - const setupByRepoId = new Map() - for (const setup of setups) { - if (setup.repoId && !setupByRepoId.has(setup.repoId)) { - setupByRepoId.set(setup.repoId, setup) - } - } - return setupByRepoId -} - function buildProjectLabelByKey( repos: readonly Repo[], projection: ProjectHostSetupProjection @@ -163,7 +146,7 @@ function buildProjectCandidates( worktrees: readonly Worktree[], projection: ProjectHostSetupProjection, repoById: ReadonlyMap, - setupByRepoId: ReadonlyMap + owners: ReturnType ): SessionProjectCandidate[] { const candidates: SessionProjectCandidate[] = [] const setupRepoIds = new Set() @@ -173,7 +156,7 @@ function buildProjectCandidates( continue } const repo = repoById.get(worktree.repoId) - const setup = setupByRepoId.get(worktree.repoId) + const setup = owners.forWorkspace(worktree) candidates.push( makeProjectCandidate({ source: 'worktree', @@ -183,7 +166,7 @@ function buildProjectCandidates( setup?.hostId, repo ? getRepoExecutionHostId(repo) : null ), - projectId: worktree.projectId ?? setup?.projectId ?? null, + projectId: owners.projectId(worktree) ?? null, repoId: worktree.repoId }) ) diff --git a/src/renderer/src/components/settings/settings-project-list.ts b/src/renderer/src/components/settings/settings-project-list.ts index 24e84fc93866..fc10d6c4d868 100644 --- a/src/renderer/src/components/settings/settings-project-list.ts +++ b/src/renderer/src/components/settings/settings-project-list.ts @@ -1,3 +1,4 @@ +import type { ProjectGroupingModel } from '../../../../shared/project-section-grouping' import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { @@ -61,8 +62,13 @@ export function getSettingsProjectRepresentativeRepoId( * One entry per project. Entries are derived from repos alone so the nav and * pane lists agree exactly. */ -export function buildSettingsProjectList(repos: readonly Repo[]): SettingsProject[] { - const projection = projectHostSetupProjectionFromRepos(repos) +export function buildSettingsProjectList( + repos: readonly Repo[], + grouping?: ProjectGroupingModel +): SettingsProject[] { + const projection = grouping + ? { projects: grouping.projects, setups: grouping.projectHostSetups } + : projectHostSetupProjectionFromRepos(repos) const projectById = new Map(projection.projects.map((project) => [project.id, project])) // Why: Settings metadata is rebuilt as repos refresh across hosts; index // setups once so many projects do not turn each refresh into an O(n²) scan. diff --git a/src/renderer/src/components/settings/settings-project-regrouping.test.ts b/src/renderer/src/components/settings/settings-project-regrouping.test.ts index e2557dc07795..d0b88109dc75 100644 --- a/src/renderer/src/components/settings/settings-project-regrouping.test.ts +++ b/src/renderer/src/components/settings/settings-project-regrouping.test.ts @@ -27,7 +27,6 @@ function makeRepo(id: string, path: string): Repo { const nativeA = makeRepo('native-a', 'C:\\projects\\app-a') const nativeB = makeRepo('native-b', 'C:\\projects\\app-b') const wslA = makeRepo('wsl-a', '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\app-a') -const wslB = makeRepo('wsl-b', '\\\\wsl.localhost\\Ubuntu\\home\\fixture\\app-b') function pickRepo( store: ReturnType, @@ -38,9 +37,7 @@ function pickRepo( if (!target) { throw new Error(`Missing Settings target for ${repo.id}`) } - store - .getState() - .setSettingsProjectHostSelection(target.selectionKey, target.hostId, target.setupId) + store.getState().setSettingsProjectHostSelection(target.projectId, target.hostId, target.setupId) } function selectedRepo(store: ReturnType, repos: Repo[], repo: Repo): Repo { @@ -72,12 +69,12 @@ describe('Settings project selection across local checkout regrouping', () => { expect(selectedRepo(store, [nativeA, wslA], nativeA).path).toBe(nativeA.path) }) - it('keeps a newer project-level WSL pick when an older checkout selection still exists', () => { + it('keeps the shared project setup pick when another native checkout arrives', () => { const store = createUIStore() pickRepo(store, buildSettingsProjectList([nativeA, nativeB, wslA]), nativeA) pickRepo(store, buildSettingsProjectList([nativeA, wslA]), wslA) - expect(selectedRepo(store, [nativeA, nativeB, wslA], nativeA).path).toBe(nativeA.path) + expect(selectedRepo(store, [nativeA, nativeB, wslA], nativeA).path).toBe(wslA.path) expect(selectedRepo(store, [nativeA, nativeB, wslA], wslA).path).toBe(wslA.path) expect(selectedRepo(store, [nativeA, wslA], nativeA).path).toBe(wslA.path) }) @@ -114,49 +111,36 @@ describe('Settings project selection across local checkout regrouping', () => { }) describe('Settings provided local setup grouping', () => { - it('keeps provided provisioned metadata aligned with the sidebar', () => { + it('uses current project membership for both Settings and sidebar, including independent setups', () => { const provisioned = makeRepo('provisioned-copy', 'C:\\projects\\provisioned') const repos = [nativeA, nativeB, provisioned] const projection = projectHostSetupProjectionFromRepos(repos) + const sharedId = projection.setups[0].projectId const grouping = { projects: projection.projects, projectHostSetups: projection.setups.map((setup) => - setup.repoId === provisioned.id ? { ...setup, setupMethod: 'provisioned' as const } : setup + setup.repoId === provisioned.id + ? { ...setup, projectId: sharedId, setupMethod: 'provisioned' as const } + : setup ) } + const entries = buildSettingsProjectList(repos, grouping) const sidebarEntry = getProjectGroupingForRepo( provisioned.id, new Map(repos.map((repo) => [repo.id, repo])), buildProjectGroupingIndex(grouping) ) - const entries = buildSettingsProjectList(repos, grouping) - const provisionedEntry = entries.find((entry) => - entry.setups.some((setup) => setup.repoId === provisioned.id) - ) - - expect(sidebarEntry.key).toBe(`project:${projection.projects[0].id}`) - expect(entries.filter((entry) => entry.checkoutLabel !== undefined)).toHaveLength(2) - expect(provisionedEntry?.checkoutLabel).toBeUndefined() - expect(provisionedEntry?.selectionKey).toBe(projection.projects[0].id) - }) - - it('classifies native and WSL setups separately even when their repo ids match', () => { - const twin = { ...wslA, id: nativeA.id } - const repos = [nativeA, nativeB, twin, wslB] - const projection = projectHostSetupProjectionFromRepos(repos) - const grouping = { - projects: projection.projects, - projectHostSetups: projection.setups.map((setup) => - setup.path === twin.path ? { ...setup, setupMethod: 'provisioned' as const } : setup - ) - } - const entries = buildSettingsProjectList(repos, grouping) - - expect(entries.find((entry) => entry.representativeRepoId === nativeA.id)?.checkoutLabel).toBe( - nativeA.displayName + const entry = entries.find((candidate) => + candidate.setups.some((setup) => setup.repoId === provisioned.id) ) + expect(entries).toHaveLength(2) + expect(sidebarEntry.key).toBe(`project:${sharedId}`) + expect(entry?.projectId).toBe(sharedId) + expect(entry?.setups.map((setup) => setup.repoId)).toEqual([nativeA.id, provisioned.id]) expect( - entries.find((entry) => entry.representativeRepoId === wslB.id)?.checkoutLabel - ).toBeUndefined() + entries + .find((candidate) => candidate.projectId === `repo:${nativeB.id}`) + ?.setups.map((setup) => setup.repoId) + ).toEqual([nativeB.id]) }) }) diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index c8c1de58c676..0a505321fb7d 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -20,6 +20,7 @@ import { import { useActiveProjectSkillRuntime } from '@/hooks/useActiveProjectSkillRuntime' import { useLinearProviderConnected } from '@/hooks/useLinearProviderConnected' import { useSkillFreshness } from '@/hooks/useSkillFreshness' +import { useProjectHostSetupProjection } from '@/store/selectors' import { getFallbackTerminalFonts, mergeFontSuggestions } from './SettingsConstants' import { useGhosttyImport } from './useGhosttyImport' import { useWarpThemeImport } from './useWarpThemeImport' @@ -68,7 +69,15 @@ export function useSettingsStoreModel() { const runtimeEnvironmentCatalogHydrated = useAppStore((s) => s.runtimeEnvironmentCatalogHydrated) // Why: same inputs as nav metadata so both lists agree — the source of truth for the pane list. - const settingsProjectList = useMemo(() => buildSettingsProjectList(repos), [repos]) + const projection = useProjectHostSetupProjection() + const settingsProjectList = useMemo( + () => + buildSettingsProjectList(repos, { + projects: projection.projects, + projectHostSetups: projection.setups + }), + [repos, projection] + ) const repoIdToRepresentative = useMemo( () => buildRepoIdToRepresentative(settingsProjectList), [settingsProjectList] diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts index a47451aaa15d..ec9c36076e56 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-project-host-setups.test.ts @@ -373,7 +373,7 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('splits same-host checkouts of one project into separate per-setup groups', () => { + it('keeps a split checkout under its current project identity', () => { const repoB: Repo = { ...repo, id: 'repo-2', path: '/tmp/orca-2', displayName: 'orca-2' } const worktreeB: Worktree = { ...worktree, @@ -385,6 +385,7 @@ describe('buildRows with pinned worktrees', () => { const localSetupB: ProjectHostSetup = { ...projectHostSetups[0]!, id: repoB.id, + projectId: `repo:${repoB.id}`, repoId: repoB.id, path: repoB.path, displayName: repoB.displayName @@ -414,7 +415,15 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [{ ...project, sourceRepoIds: [repo.id, repoB.id] }], + projects: [ + project, + { + ...project, + id: `repo:${repoB.id}`, + displayName: repoB.displayName, + sourceRepoIds: [repoB.id] + } + ], projectHostSetups: [projectHostSetups[0]!, localSetupB] } ) @@ -424,18 +433,18 @@ describe('buildRows with pinned worktrees', () => { expect(headers).toEqual( expect.arrayContaining([ expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-1', - label: 'orca' + key: 'project:github:stablyai/orca', + label: 'Orca' }), expect.objectContaining({ - key: 'project:github:stablyai/orca::setup:repo-2', + key: 'project:repo:repo-2', label: 'orca-2' }) ]) ) }) - it('splits only the surface with duplicate checkouts', () => { + it('keeps shared-host association while grouping an outclaimed checkout independently', () => { const localRepoB: Repo = { ...repo, id: 'repo-local-b', @@ -452,6 +461,7 @@ describe('buildRows with pinned worktrees', () => { const localSetupB: ProjectHostSetup = { ...projectHostSetups[0]!, id: localRepoB.id, + projectId: `repo:${localRepoB.id}`, repoId: localRepoB.id, path: localRepoB.path, displayName: localRepoB.displayName @@ -483,16 +493,23 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, remoteRepo.id] }], + projects: [ + project, + { + ...project, + id: `repo:${localRepoB.id}`, + displayName: localRepoB.displayName, + sourceRepoIds: [localRepoB.id] + } + ], projectHostSetups: [projectHostSetups[0]!, localSetupB, projectHostSetups[1]!] } ) const headers = rows.filter((row) => row.type === 'header') expect(headers.map((row) => row.key)).toEqual([ - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b', - 'project:github:stablyai/orca' + 'project:github:stablyai/orca', + 'project:repo:repo-local-b' ]) }) @@ -690,7 +707,7 @@ describe('buildRows with pinned worktrees', () => { }) }) - it('splits duplicate user checkouts while a provisioned copy nests, on one host', () => { + it('groups a provisioned copy by current association while keeping an outclaimed checkout separate', () => { // Why: guards the intersection of #5374 (real same-host checkouts split) and // #6320 (provisioned copies nest). Two legacy checkouts must each get their own // header while a provisioned copy of the same project stays under the plain @@ -711,6 +728,7 @@ describe('buildRows with pinned worktrees', () => { const localSetupB: ProjectHostSetup = { ...projectHostSetups[0]!, id: localRepoB.id, + projectId: `repo:${localRepoB.id}`, repoId: localRepoB.id, path: localRepoB.path, displayName: localRepoB.displayName @@ -763,7 +781,15 @@ describe('buildRows with pinned worktrees', () => { new Map(), [], { - projects: [{ ...project, sourceRepoIds: [repo.id, localRepoB.id, runtimeRepoB.id] }], + projects: [ + { ...project, sourceRepoIds: [repo.id, runtimeRepoB.id] }, + { + ...project, + id: `repo:${localRepoB.id}`, + displayName: localRepoB.displayName, + sourceRepoIds: [localRepoB.id] + } + ], projectHostSetups: [projectHostSetups[0]!, localSetupB, runtimeSetupB] } ) @@ -771,18 +797,11 @@ describe('buildRows with pinned worktrees', () => { const headers = rows.filter((row) => row.type === 'header') expect(headers.map((row) => row.key).sort()).toEqual([ 'project:github:stablyai/orca', - 'project:github:stablyai/orca::setup:repo-1', - 'project:github:stablyai/orca::setup:repo-local-b' + 'project:repo:repo-local-b' ]) - // The provisioned copy nests under the plain project key with only its own - // worktree; it never gets a path-scoped `::setup:` header like the real - // checkouts do, and that header keeps the project's own display name. - expect( - headers.some((row) => row.key === 'project:github:stablyai/orca::setup:repo-runtime-b') - ).toBe(false) expect(headers.find((row) => row.key === 'project:github:stablyai/orca')).toMatchObject({ label: 'Orca', - count: 1 + count: 2 }) }) }) diff --git a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts index 2220d38d4d7a..f6514aa46f20 100644 --- a/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts +++ b/src/renderer/src/components/sidebar/worktree-list/navigation/reveal-ancestors.ts @@ -7,9 +7,7 @@ function getProjectIdFromHeaderRowKey(rowKey: string): string | null { if (!rowKey.startsWith('project:')) { return null } - const withoutPrefix = rowKey.slice('project:'.length) - const setupSeparator = withoutPrefix.indexOf('::setup:') - return setupSeparator === -1 ? withoutPrefix : withoutPrefix.slice(0, setupSeparator) + return rowKey.slice('project:'.length) } function getRepoIdsFromHeaderRowKey( @@ -20,11 +18,6 @@ function getRepoIdsFromHeaderRowKey( if (rowKey.startsWith('repo:')) { return [rowKey.slice('repo:'.length)] } - const setupMarker = '::setup:' - const setupIndex = rowKey.indexOf(setupMarker) - if (rowKey.startsWith('project:') && setupIndex !== -1) { - return [rowKey.slice(setupIndex + setupMarker.length)] - } const projectId = getProjectIdFromHeaderRowKey(rowKey) if (!projectId) { return [] diff --git a/src/renderer/src/hooks/settings-navigation-build-options.ts b/src/renderer/src/hooks/settings-navigation-build-options.ts index 44f09a2c879c..fbac15468a94 100644 --- a/src/renderer/src/hooks/settings-navigation-build-options.ts +++ b/src/renderer/src/hooks/settings-navigation-build-options.ts @@ -1,3 +1,4 @@ +import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' import type { Repo } from '../../../shared/repo-types' export type SettingsNavigationBuildOptions = { @@ -13,4 +14,5 @@ export type SettingsNavigationBuildOptions = { isDev: boolean isLinearConnected: boolean repos: readonly Repo[] + projectGrouping?: ProjectGroupingModel } diff --git a/src/renderer/src/hooks/settings-navigation-remote-sections.ts b/src/renderer/src/hooks/settings-navigation-remote-sections.ts index 49ec473fa448..557b206f6fbb 100644 --- a/src/renderer/src/hooks/settings-navigation-remote-sections.ts +++ b/src/renderer/src/hooks/settings-navigation-remote-sections.ts @@ -24,7 +24,14 @@ import { import type { SettingsNavigationBuildOptions } from './settings-navigation-build-options' export function buildRemoteSettingsSections( - { isMac, isWindowsTerminalHost, isWebClient, isDev, repos }: SettingsNavigationBuildOptions, + { + isMac, + isWindowsTerminalHost, + isWebClient, + isDev, + repos, + projectGrouping + }: SettingsNavigationBuildOptions, runtimeEnvironmentsSearchEntry: SettingsNavSection['searchEntries'][number], reposById: ReadonlyMap ): SettingsNavSection[] { @@ -154,7 +161,7 @@ export function buildRemoteSettingsSections( // Why: one nav row per project, not per repo row — a project set up on // multiple hosts (local + a Remote Orca Server) collapses to a single // entry. Same inputs as the panes so this list matches them. - ...buildSettingsProjectList(repos).map((settingsProject) => { + ...buildSettingsProjectList(repos, projectGrouping).map((settingsProject) => { const { project, representativeRepoId, setups } = settingsProject const representativeRepo = reposById.get(representativeRepoId) ?? repos[0] const hostSummary = diff --git a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts index 4c23d73dd1dd..bfc4ddb0180d 100644 --- a/src/renderer/src/hooks/useSettingsNavigationMetadata.ts +++ b/src/renderer/src/hooks/useSettingsNavigationMetadata.ts @@ -19,6 +19,8 @@ import { useWindowsTerminalCapabilities } from '@/lib/windows-terminal-capabilities' import { defaultScopeHost } from '@/lib/default-creation-host' +import { useProjectHostSetupProjection } from '@/store/selectors' +import type { ProjectGroupingModel } from '../../../shared/project-section-grouping' import { useAppStore } from '@/store' import { useStructuredAgentSessionHostQueuesMessages } from '@/runtime/structured-agent-session-host-capability' import type { Repo } from '../../../shared/repo-types' @@ -46,7 +48,8 @@ export function buildSettingsNavigationMetadata({ mobileEmulatorCreationEnabled = !isWebClient, isDev = import.meta.env.DEV, isLinearConnected = false, - repos + repos, + projectGrouping }: { isMac: boolean isWindows: boolean @@ -60,6 +63,7 @@ export function buildSettingsNavigationMetadata({ isDev?: boolean isLinearConnected?: boolean repos: readonly Repo[] + projectGrouping?: ProjectGroupingModel }): SettingsNavSection[] { const terminalPaneSearchEntries = getTerminalPaneSearchEntries({ isWindows, @@ -87,7 +91,8 @@ export function buildSettingsNavigationMetadata({ mobileEmulatorCreationEnabled, isDev, isLinearConnected, - repos + repos, + projectGrouping } // Why: this array's order must mirror SETTINGS_NAV_GROUPS so the Settings @@ -110,6 +115,11 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { const { i18n } = useTranslation() const activeLocale = i18n.language const repos = useAppStore((state) => state.repos) + const projection = useProjectHostSetupProjection() + const projectGrouping = useMemo( + () => ({ projects: projection.projects, projectHostSetups: projection.setups }), + [projection] + ) const settings = useAppStore((state) => state.settings) const [managedBrowserCreationEnabled, mobileEmulatorCreationEnabled] = useAppStore( useShallow((state) => { @@ -171,7 +181,8 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isDev: import.meta.env.DEV, isLinearConnected, - repos + repos, + projectGrouping }), // oxlint-disable-next-line react-hooks/exhaustive-deps -- activeLocale is read implicitly by the translate() calls inside buildSettingsNavigationMetadata; without it the memo keeps the previous language's sections. [ @@ -186,6 +197,7 @@ export function useSettingsNavigationMetadata(): SettingsNavSection[] { mobileEmulatorCreationEnabled, isLinearConnected, repos, + projectGrouping, activeLocale ] ) diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 81057fca002a..2ac4170b865e 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -1,3 +1,8 @@ +import { + getProjectHostSetupLookup, + type WorkspaceProjectOwner +} from '../../../shared/project-host-setup-lookup' +import type { ProjectHostSetup } from '../../../shared/project-types' import { translate } from '@/i18n/i18n' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { @@ -45,6 +50,7 @@ type StoreAccessor = () => { createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown repos?: readonly LocalhostLinkRepo[] projects?: readonly LocalhostLinkProject[] + projectHostSetups?: readonly ProjectHostSetup[] worktreesByRepo?: Record allWorktrees?: () => LocalhostLinkWorktree[] workspacePortScan?: { result: WorkspacePortScanResult } | null @@ -68,10 +74,7 @@ type LocalhostLinkRepo = { type LocalhostLinkProject = LocalhostLinkRepo -type LocalhostLinkWorktree = { - id: string - projectId?: string -} +type LocalhostLinkWorktree = { id: string } & Partial // Why: store access is injected via registerHttpLinkStoreAccessor rather than // a direct `import '@/store'` to avoid a circular import — store/slices/editor.ts @@ -288,9 +291,13 @@ function localhostLabelRouteForTerminalLink( return null } const worktree = findWorktreeById(state, port.owner.worktreeId) + const projectId = getProjectHostSetupLookup(state.projectHostSetups).projectId({ + ...worktree, + repoId: port.owner.repoId + }) const project = - worktree?.projectId && state.projects - ? (state.projects.find((entry) => entry.id === worktree.projectId) ?? null) + projectId && state.projects + ? (state.projects.find((entry) => entry.id === projectId) ?? null) : null const projectSource = project ?? repo return { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index b51446f522b0..02c82ca75bba 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -14,7 +14,10 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope' import { projectMatchesRepo } from '../../../shared/project-repo-match' import { getIndexedRepoMap } from '@/store/worktree-repo-index' import { defaultScopeHost } from './default-creation-host' -import { getLocalProjectRuntimeWorkspace } from './local-project-runtime-workspace' +import { + getLocalPreflightProjectId, + getLocalProjectRuntimeWorkspace +} from './local-project-runtime-workspace' import { getActiveWorkspaceOwnerTarget } from './resolved-worktree-execution-host' import { getEnvironmentRuntimeContextKey } from './provider-runtime-context' import { getRendererAppPlatform } from './renderer-app-platform' @@ -42,7 +45,7 @@ type LocalProjectRuntimeState = Pick< AppState, 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' > & - Partial> + Partial> // Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` // or `[]` fallback would miss the cache on every read. @@ -318,17 +321,8 @@ function getLocalRuntimeProject( projectId: string, repoId: string ) { - return state.projects?.find( - (entry) => entry.id === projectId || projectMatchesRepo(entry, repoId) - ) -} - -function getLocalPreflightProjectId( - state: LocalProjectRuntimeState, - worktreeId?: string | null -): string { - const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) return ( - activeWorktree?.projectId ?? activeWorktree?.repoId ?? state.activeRepoId ?? 'local-project' + state.projects?.find((entry) => entry.id === projectId) ?? + state.projects?.find((entry) => projectMatchesRepo(entry, repoId)) ) } diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index 5d006191211a..b2210b400429 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -1,3 +1,4 @@ +import { getProjectHostSetupLookup } from '../../../shared/project-host-setup-lookup' import type { AppState } from '@/store/types' import { getIndexedWorktreeById } from '@/store/worktree-repo-index' import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' @@ -17,7 +18,16 @@ const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} export function getLocalProjectRuntimeWorkspace( state: RuntimeWorkspaceState, worktreeId?: string | null -): Pick | null { +): Pick< + Worktree, + | 'id' + | 'repoId' + | 'projectId' + | 'path' + | 'hostId' + | 'projectHostSetupId' + | 'runtimeOwnerEnvironmentId' +> | null { const targetWorktreeId = worktreeId ?? state.activeWorktreeId if (!targetWorktreeId) { return null @@ -58,3 +68,20 @@ export function getLocalProjectRuntimeWorkspace( } : null } + +export function getLocalPreflightProjectId( + state: RuntimeWorkspaceState & + Pick & + Partial>, + worktreeId?: string | null +): string { + const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) + return ( + (activeWorktree + ? getProjectHostSetupLookup(state.projectHostSetups).projectId(activeWorktree) + : undefined) ?? + activeWorktree?.repoId ?? + state.activeRepoId ?? + 'local-project' + ) +} diff --git a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts index c5ac96299b52..c80b0179bb8b 100644 --- a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts +++ b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts @@ -1,6 +1,10 @@ import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels' import type { GlobalSettings } from '../../../shared/global-settings-types' -import type { Project } from '../../../shared/project-types' +import { + getProjectHostSetupLookup, + type WorkspaceProjectOwner +} from '../../../shared/project-host-setup-lookup' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { Repo } from '../../../shared/repo-types' import type { WorkspacePort } from '../../../shared/workspace-ports' import { useAppStore } from '@/store' @@ -13,7 +17,8 @@ type LocalhostLabelLookupState = { settings?: Pick | null repos?: readonly Repo[] projects?: readonly Project[] - getKnownWorktreeById?: (worktreeId: string) => { projectId?: string | null } | null | undefined + projectHostSetups?: readonly ProjectHostSetup[] + getKnownWorktreeById?: (worktreeId: string) => Partial | null | undefined } export function resolveLocalhostLabelRouteForPort( @@ -25,8 +30,12 @@ export function resolveLocalhostLabelRouteForPort( } const repo = (state.repos ?? []).find((entry) => entry.id === port.owner.repoId) ?? null const worktree = state.getKnownWorktreeById?.(port.owner.worktreeId) ?? null - const project = worktree?.projectId - ? ((state.projects ?? []).find((entry) => entry.id === worktree.projectId) ?? null) + const projectId = getProjectHostSetupLookup(state.projectHostSetups).projectId({ + ...worktree, + repoId: port.owner.repoId + }) + const project = projectId + ? ((state.projects ?? []).find((entry) => entry.id === projectId) ?? null) : null return localhostWorktreeLabelRouteForPort({ port, repo, project, settings: state.settings }) } @@ -43,10 +52,16 @@ export function useLocalhostLabelRouteForPort( const worktree = useAppStore((s) => portWorktreeId ? (s.getKnownWorktreeById?.(portWorktreeId) ?? null) : null ) + const projectId = useAppStore((s) => + portRepoId + ? getProjectHostSetupLookup(s.projectHostSetups).projectId({ + ...worktree, + repoId: portRepoId + }) + : undefined + ) const project = useAppStore((s) => - worktree?.projectId - ? ((s.projects ?? []).find((entry) => entry.id === worktree.projectId) ?? null) - : null + projectId ? (s.projects.find((entry) => entry.id === projectId) ?? null) : null ) return localhostWorktreeLabelRouteForPort({ port, repo, project, settings }) } diff --git a/src/renderer/src/store/project-host-setup-selector.test.ts b/src/renderer/src/store/project-host-setup-selector.test.ts index c6de8f658202..5cc2980c2bab 100644 --- a/src/renderer/src/store/project-host-setup-selector.test.ts +++ b/src/renderer/src/store/project-host-setup-selector.test.ts @@ -22,9 +22,11 @@ function countCollectionReads(items: readonly T[]): { counters[property] += 1 return array[property].bind(array) } - if (property === Symbol.iterator) { + if (property === Symbol.iterator || property === 'entries') { counters.iterator += 1 - return array[Symbol.iterator].bind(array) + return property === 'entries' + ? array.entries.bind(array) + : array[Symbol.iterator].bind(array) } if (property === 'length') { counters.length += 1 diff --git a/src/shared/project-host-setup-lookup.test.ts b/src/shared/project-host-setup-lookup.test.ts new file mode 100644 index 000000000000..7dc42a7b1d20 --- /dev/null +++ b/src/shared/project-host-setup-lookup.test.ts @@ -0,0 +1,34 @@ +import { expect, it } from 'vitest' +import { getProjectHostSetupLookup } from './project-host-setup-lookup' + +it('keeps host twins, nested SSH and independent setups distinct while resolving current affiliation', () => { + const owners = getProjectHostSetupLookup([ + { id: 'repo', repoId: 'repo', hostId: 'local', projectId: 'local-project' }, + { id: 'repo', repoId: 'repo', hostId: 'runtime:hub', projectId: 'hub-project' }, + { + id: 'nested', + repoId: 'repo', + hostId: 'runtime:hub', + executionHostId: 'ssh:target', + projectId: 'nested-project' + }, + { id: 'independent', repoId: '', hostId: 'local', projectId: 'independent-project' } + ]) + const legacy = { repoId: 'repo', projectId: 'saved-project' } + expect(owners.projectId({ ...legacy, hostId: 'local' })).toBe('local-project') + expect(owners.projectId({ ...legacy, projectHostSetupId: 'repo', hostId: 'runtime:hub' })).toBe( + 'hub-project' + ) + expect( + owners.projectId({ ...legacy, hostId: 'ssh:target', runtimeOwnerEnvironmentId: 'hub' }) + ).toBe('nested-project') + expect(owners.projectId({ ...legacy, hostId: 'local', projectHostSetupId: 'independent' })).toBe( + 'independent-project' + ) + expect(owners.projectId(legacy)).toBe('saved-project') + expect(owners.projectId({ ...legacy, hostId: 'runtime:other' })).toBe('saved-project') + expect(owners.projectId({ ...legacy, hostId: 'not-a-host' })).toBe('saved-project') + expect( + owners.projectId({ ...legacy, hostId: 'local', projectHostSetupId: 'missing-independent' }) + ).toBe('saved-project') +}) diff --git a/src/shared/project-host-setup-lookup.ts b/src/shared/project-host-setup-lookup.ts index d7d59f4cef94..9babf61e66e6 100644 --- a/src/shared/project-host-setup-lookup.ts +++ b/src/shared/project-host-setup-lookup.ts @@ -1,9 +1,111 @@ -import { getRepoExecutionHostId } from './execution-host' +import { + getRepoExecutionHostId, + normalizeExecutionHostId, + toRuntimeExecutionHostId +} from './execution-host' import type { ProjectHostSetup } from './project-types' +import { getRepoHostIdentityForParts } from './repo-host-identity' import type { Repo } from './repo-types' import type { WorktreeMeta } from './worktree/meta-types' import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' +type SetupOwner = Pick & + Partial> + +export type WorkspaceProjectOwner = { + repoId: string + projectId?: string | null + projectHostSetupId?: string + hostId?: string | null + runtimeOwnerEnvironmentId?: string +} + +const ownerLookupCache = new WeakMap() +const EMPTY_SETUPS: readonly SetupOwner[] = [] + +type ProjectHostSetupLookup = { + forWorkspace: (workspace: WorkspaceProjectOwner) => SetupOwner | undefined + projectId: (workspace: WorkspaceProjectOwner) => string | undefined +} + +/** Current checkout ownership outranks a saved association, without guessing an absent owner. */ +export function getProjectHostSetupLookup( + setups: readonly SetupOwner[] = EMPTY_SETUPS +): ProjectHostSetupLookup { + const cached = ownerLookupCache.get(setups) + if (cached) { + return cached + } + const byRepo = new Map() + const bySetup = new Map() + const ownerKey = getRepoHostIdentityForParts + const scope = (setup: SetupOwner) => { + const host = setup.hostId ?? 'local' + const execution = getRepoExecutionHostId(setup) + return execution.startsWith('ssh:') && execution !== host ? `${host}\0${execution}` : host + } + const add = (index: Map, key: string, setup: SetupOwner) => { + const prior = index.get(key) + index.set( + key, + prior === undefined || + (prior && prior.projectId === setup.projectId && scope(prior) === scope(setup)) + ? setup + : null + ) + } + for (const setup of setups) { + for (const [index, id] of [ + [byRepo, setup.repoId], + [bySetup, setup.id ?? setup.repoId] + ] as const) { + if (!id) { + continue + } + add(index, id, setup) + add(index, ownerKey(id, scope(setup)), setup) + if (scope(setup) !== setup.hostId) { + add(index, ownerKey(id, setup.hostId ?? 'local'), setup) + } + } + } + const forWorkspace = (workspace: WorkspaceProjectOwner): SetupOwner | undefined => { + const host = normalizeExecutionHostId(workspace.hostId) + if (workspace.hostId && !host) { + return undefined + } + const transport = workspace.runtimeOwnerEnvironmentId + ? toRuntimeExecutionHostId(workspace.runtimeOwnerEnvironmentId) + : host + const ownerScope = + transport && host?.startsWith('ssh:') && transport !== host + ? `${transport}\0${host}` + : transport + const key = (id: string) => (ownerScope ? ownerKey(id, ownerScope) : id) + if (workspace.projectHostSetupId) { + const setupKey = key(workspace.projectHostSetupId) + if (bySetup.has(setupKey)) { + const setup = bySetup.get(setupKey) + return setup?.repoId === workspace.repoId || !setup?.repoId + ? (setup ?? undefined) + : undefined + } + // Independent setup metadata must not be reassigned to the repo's default setup. + if (workspace.projectHostSetupId !== workspace.repoId) { + return undefined + } + } + return byRepo.get(key(workspace.repoId)) ?? undefined + } + const lookup = { + forWorkspace, + projectId: (workspace: WorkspaceProjectOwner) => + forWorkspace(workspace)?.projectId ?? workspace.projectId ?? undefined + } + ownerLookupCache.set(setups, lookup) + return lookup +} + export function getProjectHostSetupForRepo( setups: readonly ProjectHostSetup[], repo: Repo diff --git a/src/shared/project-section-grouping.ts b/src/shared/project-section-grouping.ts index 29baa3a9f8a9..8bb32f5cd5ee 100644 --- a/src/shared/project-section-grouping.ts +++ b/src/shared/project-section-grouping.ts @@ -2,11 +2,8 @@ import type { ExecutionHostId } from './execution-host' import { orderProjectSections, type ActivityItem } from './project-section-order' import type { ProjectOrderBy } from './ui-chrome-types' -import { toSshExecutionHostId } from './execution-host' import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' -import { parseWslUncPath } from './wsl-paths' -import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' /** The project fields grouping reads. */ export type ProjectSectionProject = Pick @@ -32,7 +29,6 @@ export type ProjectSectionModel = { export type ProjectGroupingIndex = { projectById: Map setupByRepoId: Map - surfaceKeysRequiringSetupGroups: Set } /** The repo fields section grouping, labelling and ordering read. */ @@ -57,40 +53,6 @@ export type ProjectSectionEntry = { const projectGroupingIndexCache = new WeakMap() -// Why: provisioned and folder setups are not independent Git checkouts. -function isDistinctUserCheckout(setup: ProjectSectionSetup): boolean { - return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' -} - -// Why: execution target and filesystem namespace independently identify a surface. -function getProjectSetupSurfaceKey(setup: ProjectSectionSetup): string { - return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` -} - -function getExecutionSurface(setup: ProjectSectionSetup): string { - const connectionId = setup.connectionId?.trim() - if (connectionId) { - return toSshExecutionHostId(connectionId) - } - return setup.executionHostId?.trim() || setup.hostId -} - -// Why: projection twins differ by row identity, not checkout directory. -function getCheckoutIdentity(setup: ProjectSectionSetup): string { - return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id -} - -function getPathSurface(setup: ProjectSectionSetup): string { - const wslPath = parseWslUncPath(setup.path) - if (wslPath) { - return `wsl:${wslPath.distro.toLowerCase()}` - } - if (isWindowsAbsolutePathLike(setup.path)) { - return 'windows-host' - } - return 'default' -} - export function buildProjectGroupingIndex( model?: ProjectSectionModel ): ProjectGroupingIndex | null { @@ -107,45 +69,14 @@ export function buildProjectGroupingIndex( projectGroupingIndexCache.set(model, null) return null } - const checkoutsByProjectSurface = new Map>() - for (const setup of projectHostSetups) { - if (!isDistinctUserCheckout(setup)) { - continue - } - const key = getProjectSetupSurfaceKey(setup) - const existing = checkoutsByProjectSurface.get(key) - if (existing) { - existing.add(getCheckoutIdentity(setup)) - } else { - checkoutsByProjectSurface.set(key, new Set([getCheckoutIdentity(setup)])) - } - } - const surfaceKeysRequiringSetupGroups = new Set() - for (const [surfaceKey, checkouts] of checkoutsByProjectSurface) { - if (checkouts.size > 1) { - surfaceKeysRequiringSetupGroups.add(surfaceKey) - } - } const index = { projectById: new Map(projects.map((project) => [project.id, project])), - setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])), - surfaceKeysRequiringSetupGroups + setupByRepoId: new Map(projectHostSetups.map((setup) => [setup.repoId, setup])) } projectGroupingIndexCache.set(model, index) return index } -// Why: Settings splits projects by this same rule so it matches the sidebar headers. -export function isCheckoutScopedProjectSetup( - setup: ProjectSectionSetup, - projectIndex: ProjectGroupingIndex -): boolean { - return ( - projectIndex.surfaceKeysRequiringSetupGroups.has(getProjectSetupSurfaceKey(setup)) && - isDistinctUserCheckout(setup) - ) -} - export type ProjectHeaderRevealTarget = Repo> = { key: string label: string @@ -168,15 +99,6 @@ export function getProjectGroupingForRepo Date: Fri, 23 Oct 2026 05:30:11 -0700 Subject: [PATCH 05/29] fix: preserve concrete project ownership across catalogs and operations --- mobile/src/components/NewWorktreeModal.tsx | 24 +- .../new-workspace-project-targets.test.ts | 16 +- .../new-workspace-project-targets.ts | 43 +++- .../components/new-worktree-modal-types.ts | 2 + ...-vault-search-all-hosts-real-index.test.ts | 5 +- .../ipc/ai-vault-search-all-hosts.test.ts | 7 +- .../ipc/ai-vault-search-merged-cursor.test.ts | 5 +- src/main/ipc/ai-vault-search.ts | 5 +- .../profile-project-state-file.test.ts | 66 ++++++ .../profile-project-state-file.ts | 5 +- .../project-host-compatibility.ts | 3 +- .../automations/automation-project-groups.ts | 11 +- .../ai-vault-project-ownership.test.ts | 150 +++++++++++++ .../ai-vault-session-projects.test.ts | 76 ------- .../right-sidebar/use-ai-vault-search.ts | 19 +- ...RepositoryHostSetupsSection.claim.test.tsx | 99 +++++++++ .../settings/RepositoryHostSetupsSection.tsx | 7 +- .../settings/repository-host-setup-options.ts | 5 + .../task-page-default-repo-selection.ts | 22 +- .../store/project-host-setup-selector.test.ts | 2 +- .../src/store/projects/project-update.ts | 15 +- .../slices/repos-project-runtime.spec.ts | 62 ++++++ src/shared/ai-vault-project-search.test.ts | 207 ++++++++++++++++++ src/shared/ai-vault-project-search.ts | 194 ++++++++++++++++ .../ai-vault-search-merged-cursor.ts | 21 +- .../ai-vault-search-page-walk.ts} | 76 ++++--- src/shared/project-checkout-claim.ts | 39 +--- .../project-host-setup-hydration.test.ts | 91 ++++++++ src/shared/project-host-setup-hydration.ts | 166 ++++++-------- src/shared/project-host-setup-lookup.ts | 8 +- src/shared/project-host-setup-projection.ts | 17 +- src/shared/project-identity-succession.ts | 59 ++++- src/shared/repo-host-identity.ts | 16 +- 33 files changed, 1260 insertions(+), 283 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts create mode 100644 src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx create mode 100644 src/shared/ai-vault-project-search.test.ts create mode 100644 src/shared/ai-vault-project-search.ts rename src/{main/ipc => shared}/ai-vault-search-merged-cursor.ts (72%) rename src/{main/ipc/ai-vault-search-all-hosts.ts => shared/ai-vault-search-page-walk.ts} (82%) create mode 100644 src/shared/project-host-setup-hydration.test.ts diff --git a/mobile/src/components/NewWorktreeModal.tsx b/mobile/src/components/NewWorktreeModal.tsx index 45c87c9d0763..1215cfa8e4be 100644 --- a/mobile/src/components/NewWorktreeModal.tsx +++ b/mobile/src/components/NewWorktreeModal.tsx @@ -2,7 +2,7 @@ import { useMemo, useState } from 'react' import { Keyboard } from 'react-native' import { getComposerRepoWorktreeBranches } from '../../../src/shared/composer-branch-selection' import { getRepoExecutionHostId, parseExecutionHostId } from '../../../src/shared/execution-host' -import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection' +import { getRepoHostIdentity } from '../../../src/shared/repo-host-identity' import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source' import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes' import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent' @@ -22,6 +22,7 @@ import { type NewWorktreeModalProps } from './new-worktree-modal-types' import { + getNewWorkspaceProjectIds, buildNewWorkspaceProjectOptions, buildNewWorkspaceRunTargetOptions, getNewWorkspaceRunTarget @@ -187,13 +188,26 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) { })), [hostRepos] ) - const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos]) - const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null + const projectIds = useMemo(() => getNewWorkspaceProjectIds(repos), [repos]) + const projectPickerItems = useMemo( + () => buildNewWorkspaceProjectOptions(repos, projectIds), + [repos, projectIds] + ) + const selectedProjectId = selectedRepo + ? (projectIds.get(getRepoHostIdentity(selectedRepo)) ?? null) + : null const selectedProject = projectPickerItems.find((project) => project.id === selectedProjectId) ?? null const runTargetPickerItems = useMemo( - () => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById), - [hostLabelById, hostPlatform, repos, selectedProjectId] + () => + buildNewWorkspaceRunTargetOptions( + repos, + selectedProjectId, + hostPlatform, + hostLabelById, + projectIds + ), + [hostLabelById, hostPlatform, repos, selectedProjectId, projectIds] ) const selectedRunTarget = selectedRepo ? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById) diff --git a/mobile/src/components/new-workspace-project-targets.test.ts b/mobile/src/components/new-workspace-project-targets.test.ts index bfccb682ef95..993a4927aee7 100644 --- a/mobile/src/components/new-workspace-project-targets.test.ts +++ b/mobile/src/components/new-workspace-project-targets.test.ts @@ -75,7 +75,7 @@ describe('new workspace project targets', () => { }) }) - it('shows one target per host when the project has multiple local worktrees', () => { + it('keeps each same-host clone selectable with its concrete target', () => { const upstream = { owner: 'stablyai', repo: 'orca' } const repos = [ { id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream }, @@ -88,7 +88,10 @@ describe('new workspace project targets', () => { upstream } ] - const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null + const projects = buildNewWorkspaceProjectOptions(repos) + expect(projects.map((project) => project.id)).toEqual(['github:stablyai/orca', 'repo:local-b']) + expect(buildNewWorkspaceRunTargetOptions(repos, 'repo:local-b')[0]?.repo).toBe(repos[1]) + const projectId = projects[0]?.id ?? null expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([ expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }), @@ -99,4 +102,13 @@ describe('new workspace project targets', () => { }) ]) }) + it('uses the complete claim catalog before hiding ineligible create targets', () => { + const upstream = { owner: 'acme', repo: 'app' } + const repos = [ + { id: 'a', displayName: 'A', path: '', addedAt: 1, upstream }, + { id: 'b', displayName: 'B', path: '/b', addedAt: 2, upstream } + ] + expect(buildNewWorkspaceProjectOptions(repos).map((project) => project.id)).toEqual(['repo:b']) + expect(buildNewWorkspaceRunTargetOptions(repos, 'repo:b')[0]?.repo).toBe(repos[1]) + }) }) diff --git a/mobile/src/components/new-workspace-project-targets.ts b/mobile/src/components/new-workspace-project-targets.ts index c3f5d436a40f..092f7c2e2635 100644 --- a/mobile/src/components/new-workspace-project-targets.ts +++ b/mobile/src/components/new-workspace-project-targets.ts @@ -1,16 +1,42 @@ import type { Repo } from '../../../src/shared/repo-types' import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host' import { - getProjectIdentityKey, + projectHostSetupProjectionFromRepos, getProjectProviderIdentity } from '../../../src/shared/project-host-setup-projection' +import { getRepoHostIdentity } from '../../../src/shared/repo-host-identity' import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels' type WorkspaceRepo = Pick & Partial< - Pick + Pick< + Repo, + | 'connectionId' + | 'executionHostId' + | 'upstream' + | 'repoIcon' + | 'gitRemoteIdentity' + | 'addedAt' + | 'projectHostSetupMethod' + | 'kind' + > > +export function getNewWorkspaceProjectIds( + repos: readonly WorkspaceRepo[] +): ReadonlyMap { + const projection = projectHostSetupProjectionFromRepos( + repos.map((repo) => ({ + badgeColor: '', + addedAt: 0, + ...repo + })) + ) + return new Map( + projection.setups.map((setup, index) => [getRepoHostIdentity(repos[index]), setup.projectId]) + ) +} + export type NewWorkspaceProjectOption = { id: string label: string @@ -27,12 +53,16 @@ export type NewWorkspaceRunTargetOption = { } export function buildNewWorkspaceProjectOptions( - repos: readonly TRepo[] + repos: readonly TRepo[], + projectIds = getNewWorkspaceProjectIds(repos) ): NewWorkspaceProjectOption[] { const options = new Map>() const hostIdsByProject = new Map>() for (const repo of repos) { - const id = getProjectIdentityKey(repo) + if (!repo.path) { + continue + } + const id = projectIds.get(getRepoHostIdentity(repo))! const hostIds = hostIdsByProject.get(id) ?? new Set() hostIds.add(getRepoExecutionHostId(repo)) hostIdsByProject.set(id, hostIds) @@ -80,14 +110,15 @@ export function buildNewWorkspaceRunTargetOptions( repos: readonly TRepo[], projectId: string | null, localPlatform: NodeJS.Platform | null = null, - hostLabelById?: ReadonlyMap + hostLabelById?: ReadonlyMap, + projectIds = getNewWorkspaceProjectIds(repos) ): NewWorkspaceRunTargetOption[] { if (!projectId) { return [] } const options = new Map>() for (const repo of repos) { - if (getProjectIdentityKey(repo) !== projectId) { + if (!repo.path || projectIds.get(getRepoHostIdentity(repo)) !== projectId) { continue } const hostId = getRepoExecutionHostId(repo) diff --git a/mobile/src/components/new-worktree-modal-types.ts b/mobile/src/components/new-worktree-modal-types.ts index b943d06b6080..1bc89181938e 100644 --- a/mobile/src/components/new-worktree-modal-types.ts +++ b/mobile/src/components/new-worktree-modal-types.ts @@ -8,6 +8,8 @@ export type MobileWorkspaceRepo = Pick { ]) }) }) + +it('reconciles and reloads split and ordinary handoff choices consistently with profile rebuilding', () => { + const projectId = 'github:acme/app' + const repos = [ + makeRepo({ id: 'a', path: 'C:/a', addedAt: 1, upstream: { owner: 'acme', repo: 'app' } }), + makeRepo({ id: 'b', path: 'C:/b', addedAt: 2, upstream: { owner: 'acme', repo: 'app' } }), + makeRepo({ + id: 'c', + path: '/srv/c', + executionHostId: 'runtime:remote', + upstream: { owner: 'acme', repo: 'app' } + }) + ] + const projection = projectHostSetupProjectionFromRepos(repos) + const previous = makeState({ + repos, + projects: [ + makeProject({ + id: projectId, + sourceRepoIds: ['a', 'b', 'c'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ], + projectHostSetups: [ + ...projection.setups.map((setup) => ({ ...setup, projectId })), + makeSetup({ + id: 'pending', + projectId, + hostId: 'ssh:pending', + path: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + ] + }) + const split = { ...previous, ...mergeProjectHostSetupCompatibilityState(previous, repos) } + expect( + split.projects.find((project) => project.id === 'repo:b')?.localWindowsRuntimePreference + ).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) + expect(rebuildRepoBackedProjectState(previous).projects).toEqual(split.projects) + for (const preference of [undefined, { kind: 'windows-host' as const }]) { + const saved = { + ...split, + projects: split.projects.map((project) => + project.id === 'repo:b' + ? { ...project, localWindowsRuntimePreference: preference } + : project + ) + } + const reloaded = JSON.parse(JSON.stringify(saved)) + const remaining = repos.filter((repo) => repo.id !== 'a') + const handedOff = mergeProjectHostSetupCompatibilityState(reloaded, remaining) + expect( + handedOff.projects.find((project) => project.id === projectId)?.localWindowsRuntimePreference + ).toEqual(preference) + expect(handedOff.projectHostSetups?.filter((setup) => setup.id === 'pending')).toHaveLength(1) + expect(rebuildRepoBackedProjectState({ ...reloaded, repos: remaining }).projects).toEqual( + handedOff.projects + ) + expect( + mergeProjectHostSetupCompatibilityState(JSON.parse(JSON.stringify(handedOff)), remaining) + ).toEqual(handedOff) + } +}) diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index c956d159f04c..c3d0022b80d6 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -235,7 +235,10 @@ function isRepoBackedProjectHostSetup( export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { const projection = projectHostSetupProjectionFromRepos(state.repos) - const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects) + const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects, { + previous: state.projectHostSetups, + current: projection.setups + }) const currentRepoIds = new Set(state.repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id)) diff --git a/src/main/persistence/tracking-repos/project-host-compatibility.ts b/src/main/persistence/tracking-repos/project-host-compatibility.ts index 307cfd5044f2..640cec27a829 100644 --- a/src/main/persistence/tracking-repos/project-host-compatibility.ts +++ b/src/main/persistence/tracking-repos/project-host-compatibility.ts @@ -54,7 +54,8 @@ export function mergeProjectHostSetupCompatibilityState( const projection = projectHostSetupProjectionFromRepos(repos) const succession = carryProjectStateThroughIdentityChange( projection.projects, - state.projects ?? [] + state.projects ?? [], + { previous: state.projectHostSetups ?? [], current: projection.setups } ) const currentRepoIds = new Set(repos.map((repo) => repo.id)) const projectedProjectIds = new Set(projection.projects.map((project) => project.id)) diff --git a/src/renderer/src/components/automations/automation-project-groups.ts b/src/renderer/src/components/automations/automation-project-groups.ts index 4dd4c2a6c39f..92d760ac9d3e 100644 --- a/src/renderer/src/components/automations/automation-project-groups.ts +++ b/src/renderer/src/components/automations/automation-project-groups.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host' -import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { getRepoHostIdentity } from '../../../../shared/repo-host-identity' import type { Repo } from '../../../../shared/repo-types' export type AutomationProjectGroup = { @@ -12,9 +13,15 @@ export function getAutomationProjectGroups( repos: readonly Repo[], selectedRepoId: string ): AutomationProjectGroup[] { + const projectIds = new Map( + projectHostSetupProjectionFromRepos(repos).setups.map((setup, index) => [ + getRepoHostIdentity(repos[index]), + setup.projectId + ]) + ) const groupsByProject = new Map() for (const repo of repos) { - const projectKey = getProjectIdentityKey(repo) + const projectKey = projectIds.get(getRepoHostIdentity(repo))! const current = groupsByProject.get(projectKey) if (!current) { groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts new file mode 100644 index 000000000000..3a08137c8ba8 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts @@ -0,0 +1,150 @@ +import { expect, it } from 'vitest' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo } from '../../../../shared/repo-types' +import type { Worktree } from '../../../../shared/worktree/types' +import { buildAiVaultProjectContext } from './ai-vault-session-projects' +import { resolveSessionSearchScope } from '../../../../main/ai-vault-search/session-search-scope-resolution' +import { resolveProjectHostSetupProjection } from '../../../../shared/project-host-setup-hydration' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' + +const baseSession: AiVaultSession = { + id: 'claude:1', + executionHostId: 'local', + agent: 'claude', + sessionId: 'session-1', + title: 'Implement project history', + cwd: '/Users/ada/orca', + branch: 'feature/history', + model: 'claude-sonnet-4-5', + filePath: '/Users/ada/.claude/projects/session-1.jsonl', + codexHome: null, + createdAt: '2026-05-01T10:00:00.000Z', + updatedAt: '2026-05-01T10:10:00.000Z', + modifiedAt: '2026-05-01T10:10:00.000Z', + messageCount: 4, + totalTokens: 1200, + previewMessages: [], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: "cd '/Users/ada/orca' && claude --resume 'session-1'", + subagent: null +} + +// #28589: an imported folder whose copied .git/config points at another checkout's remote. +it("keeps a same-host folder that shares a remote out of the other checkout's project", () => { + const remote = { + canonicalKey: 'github.com/bennyoooo/open-social', + remoteName: 'origin', + remoteUrl: 'https://github.com/Bennyoooo/open-social.git' + } + const openSocial = makeRepo({ + id: 'open-social', + displayName: 'open-social', + path: '/Users/benny/orca/open-social', + addedAt: 100, + gitRemoteIdentity: remote + }) + const niw = makeRepo({ + id: 'niw', + displayName: 'niw', + path: '/Users/benny/orca/projects/niw', + addedAt: 200, + gitRemoteIdentity: remote + }) + const linkedPath = '/Users/benny/workspaces/niw-topic' + const oldProjectId = 'github:bennyoooo/open-social' + const worktree = makeWorktree({ + id: `niw::${linkedPath}`, + repoId: niw.id, + projectHostSetupId: niw.id, + projectId: oldProjectId, + path: linkedPath + }) + const niwSession = makeSession({ id: 'claude:niw', cwd: linkedPath }) + const derived = projectHostSetupProjectionFromRepos([niw, openSocial]) + const rawSetups = derived.setups.map((setup) => ({ ...setup, projectId: oldProjectId })) + const projection = resolveProjectHostSetupProjection( + [niw, openSocial], + [{ ...derived.projects[0], id: oldProjectId, sourceRepoIds: [niw.id, openSocial.id] }], + rawSetups + ) + expect(rawSetups.every((setup) => setup.projectId === oldProjectId)).toBe(true) + expect(projection.projects.find((project) => project.id === oldProjectId)?.sourceRepoIds).toEqual( + [openSocial.id] + ) + const openSocialSession = makeSession({ id: 'claude:open-social', cwd: openSocial.path }) + + const context = buildAiVaultProjectContext({ + repos: [niw, openSocial], + worktrees: [worktree], + projectHostSetupProjection: projection, + activeRepo: niw, + activeWorktree: worktree, + sessions: [niwSession, openSocialSession] + }) + + const catalog = { + repos: [niw, openSocial], + projects: projection.projects, + projectHostSetups: projection.setups, + worktreeMeta: { + [worktree.id]: { ...worktree, priorWorktreeIds: ['niw::/Users/benny/old-niw-topic'] } + }, + settings: { nestWorkspaces: false } + } + const ownerScope = resolveSessionSearchScope({ kind: 'project', projectKey: 'repo:niw' }, catalog) + expect(ownerScope).toEqual({ + kind: 'resolved', + paths: expect.arrayContaining([niw.path, linkedPath, '/Users/benny/old-niw-topic']) + }) + expect( + resolveSessionSearchScope({ kind: 'project', projectKey: `project:${oldProjectId}` }, catalog) + ).toEqual({ kind: 'resolved', paths: [openSocial.path] }) + expect(context.activeProjectKey).toBe('repo:niw') + expect(context.sessionProjectById.get(niwSession.id)).toMatchObject({ + key: 'repo:niw', + label: 'niw' + }) + expect(context.sessionProjectById.get(openSocialSession.id)).toMatchObject({ + key: 'project:github:bennyoooo/open-social', + label: 'open-social' + }) +}) + +function makeSession(overrides: Partial): AiVaultSession { + return { ...baseSession, ...overrides } +} + +function makeRepo(overrides: Partial): Repo { + return { + id: 'repo-1', + path: '/Users/ada/orca', + displayName: 'Orca', + badgeColor: '#737373', + addedAt: 1, + ...overrides + } +} + +function makeWorktree(overrides: Partial): Worktree { + return { + id: 'wt-1', + repoId: 'repo-1', + displayName: 'main', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + path: '/Users/ada/orca', + head: 'abc123', + branch: 'main', + isBare: false, + isMainWorktree: true, + ...overrides + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts index bdba42b4e02f..d60a4c9db78c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.test.ts @@ -9,8 +9,6 @@ import { buildAiVaultProjectContext, buildAiVaultSessionProjectById } from './ai-vault-session-projects' -import { resolveSessionSearchScope } from '../../../../main/ai-vault-search/session-search-scope-resolution' -import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { truncateAiVaultListResult } from '../../../../shared/ai-vault-session-depth' import { groupAiVaultSessions, @@ -41,80 +39,6 @@ const baseSession: AiVaultSession = { } describe('buildAiVaultProjectContext', () => { - // #28589: an imported folder whose copied .git/config points at another checkout's remote. - it("keeps a same-host folder that shares a remote out of the other checkout's project", () => { - const remote = { - canonicalKey: 'github.com/bennyoooo/open-social', - remoteName: 'origin', - remoteUrl: 'https://github.com/Bennyoooo/open-social.git' - } - const openSocial = makeRepo({ - id: 'open-social', - displayName: 'open-social', - path: '/Users/benny/orca/open-social', - addedAt: 100, - gitRemoteIdentity: remote - }) - const niw = makeRepo({ - id: 'niw', - displayName: 'niw', - path: '/Users/benny/orca/projects/niw', - addedAt: 200, - gitRemoteIdentity: remote - }) - const linkedPath = '/Users/benny/workspaces/niw-topic' - const oldProjectId = 'github:bennyoooo/open-social' - const worktree = makeWorktree({ - id: `niw::${linkedPath}`, - repoId: niw.id, - projectHostSetupId: niw.id, - projectId: oldProjectId, - path: linkedPath - }) - const niwSession = makeSession({ id: 'claude:niw', cwd: linkedPath }) - const projection = projectHostSetupProjectionFromRepos([niw, openSocial]) - const openSocialSession = makeSession({ id: 'claude:open-social', cwd: openSocial.path }) - - const context = buildAiVaultProjectContext({ - repos: [niw, openSocial], - worktrees: [worktree], - projectHostSetupProjection: projection, - activeRepo: niw, - activeWorktree: worktree, - sessions: [niwSession, openSocialSession] - }) - - const catalog = { - repos: [niw, openSocial], - projects: projection.projects, - projectHostSetups: projection.setups, - worktreeMeta: { - [worktree.id]: { ...worktree, priorWorktreeIds: ['niw::/Users/benny/old-niw-topic'] } - }, - settings: { nestWorkspaces: false } - } - const ownerScope = resolveSessionSearchScope( - { kind: 'project', projectKey: 'repo:niw' }, - catalog - ) - expect(ownerScope).toEqual({ - kind: 'resolved', - paths: expect.arrayContaining([niw.path, linkedPath, '/Users/benny/old-niw-topic']) - }) - expect( - resolveSessionSearchScope({ kind: 'project', projectKey: `project:${oldProjectId}` }, catalog) - ).toEqual({ kind: 'resolved', paths: [openSocial.path] }) - expect(context.activeProjectKey).toBe('repo:niw') - expect(context.sessionProjectById.get(niwSession.id)).toMatchObject({ - key: 'repo:niw', - label: 'niw' - }) - expect(context.sessionProjectById.get(openSocialSession.id)).toMatchObject({ - key: 'project:github:bennyoooo/open-social', - label: 'open-social' - }) - }) - it('uses durable worktree project ids before repo fallback', () => { const repo = makeRepo({ id: 'repo-1', displayName: 'Legacy Repo', path: '/Users/ada/orca' }) const project = makeProject({ id: 'project-1', displayName: 'Canonical Orca' }) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts index 3cb6e771cea7..8e7a2c1b04e0 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -20,6 +20,8 @@ import type { AiVaultSearchScopeIdentity } from '../../../../shared/ai-vault-sea import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings' import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' +import { searchCanonicalProject } from '../../../../shared/ai-vault-project-search' +import { getProjectHostSetupProjectionFromState } from '../../store/project-host-setup-selector' import { aiVaultSearchHitToSession } from './ai-vault-search-session' type SearchIdentity = { @@ -57,6 +59,19 @@ export function useAiVaultSearch( } let cancelled = false let pending = false + const search = (query: AiVaultSearchRequest, host: ExecutionHostScope) => { + const state = useAppStore.getState() + return searchCanonicalProject( + query, + host, + { + ...getProjectHostSetupProjectionFromState(state), + worktrees: state.worktrees, + rawSetups: state.projectHostSetups + }, + window.api.aiVault.searchSessions + ) + } async function run(cursor?: string) { if (pending || cancelled || !request || !scope) { return @@ -70,14 +85,14 @@ export function useAiVaultSearch( loading: true })) try { - let response = await window.api.aiVault.searchSessions({ ...request, cursor }, scope) + let response = await search({ ...request, cursor }, scope) let append = Boolean(cursor) if (cancelled) { return } if (response.kind === 'stale-cursor') { append = false - response = await window.api.aiVault.searchSessions(request, scope) + response = await search(request, scope) } if (cancelled) { return diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx new file mode 100644 index 000000000000..8e9630370a7f --- /dev/null +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx @@ -0,0 +1,99 @@ +// @vitest-environment happy-dom +import React, { act } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { useAppStore } from '../../store' +import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import type { Repo } from '../../../../shared/repo-types' + +it.each(['claimant', 'outclaimed', 'folder', 'remoteless'])( + '%s preserves the approved host actions', + async (kind) => { + const repos: Repo[] = (kind === 'claimant' || kind === 'outclaimed' ? ['a', 'b'] : [kind]).map( + (id, index) => ({ + id, + path: `/${id}`, + displayName: id, + addedAt: index + 1, + badgeColor: '', + kind: kind === 'folder' ? 'folder' : 'git', + ...(kind === 'claimant' || kind === 'outclaimed' + ? { upstream: { owner: 'acme', repo: 'app' } } + : {}) + }) + ) + const projection = projectHostSetupProjectionFromRepos(repos) + const repo = repos[kind === 'outclaimed' ? 1 : 0] + const projectId = projection.setups[kind === 'outclaimed' ? 1 : 0].projectId + const createProjectHostSetup = vi.fn().mockResolvedValue(null) + const existing = { + ...projection.setups[0], + id: 'pending', + repoId: '', + projectId, + hostId: 'ssh:configured' as const, + path: '', + setupMethod: 'provisioned' as const, + setupState: 'not-set-up' as const + } + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + repos, + projects: [...projection.projects], + projectHostSetups: [...projection.setups, existing], + sshTargetLabels: new Map([ + ['extra', 'extra'], + ['configured', 'configured'] + ]), + createProjectHostSetup + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + const button = (label: string) => + Array.from(container.querySelectorAll('button')).find((node) => + node.textContent?.includes(label) + ) + const click = async (label: string) => { + expect(button(label), label).toBeTruthy() + expect(button(label)?.disabled).toBe(false) + await act(async () => { + button(label)?.dispatchEvent(new MouseEvent('click', { bubbles: true })) + }) + } + try { + await act(async () => + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + ) + expect(container.textContent).toContain('configured') + expect(container.textContent).toContain('Not set up') + if (kind === 'outclaimed') { + expect(button('Add to another host')).toBeUndefined() + expect(createProjectHostSetup).not.toHaveBeenCalled() + } else { + await click('Add to another host') + await click('Add host placeholder') + await click('Add extra') + expect(createProjectHostSetup).toHaveBeenCalledWith({ + projectId, + hostId: 'ssh:extra', + displayName: repo.displayName, + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + } + } finally { + await act(async () => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + } + } +) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx index e552aaff6be0..4a9155fbfc60 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from 'react' import { getExecutionHostLabel, + getRepoExecutionHostId, parseExecutionHostId, toRuntimeExecutionHostId, type ExecutionHostId @@ -9,6 +10,7 @@ import { buildExecutionHostRegistry } from '../../../../shared/execution-host-re import { getHostDisplayLabelOverrides } from '../../../../shared/host-setting-overrides' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' +import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection' import { useAppStore } from '../../store' import { getProjectHostSetupProjectionFromState } from '../../store/selectors' import { cn } from '../../lib/utils' @@ -107,7 +109,7 @@ export function RepositoryHostSetupsSection({ getProjectHostSetupProjectionFromState(state) ) const repoProjectHostSetup = projectHostSetupProjection.setups.find( - (setup) => setup.repoId === repo.id + (setup) => setup.repoId === repo.id && setup.hostId === getRepoExecutionHostId(repo) ) const selectedProjectHostSetup = projectHostSetupProjection.setups.find( @@ -131,6 +133,9 @@ export function RepositoryHostSetupsSection({ selectedProjectHostSetup?.id ?? '' ) const setupHostOptions = buildSetupHostOptions({ + outclaimed: + repoProjectHostSetup?.projectId === `repo:${repo.id}` && + getProjectIdentityKey(repo) !== `repo:${repo.id}`, projectHostSetups: allProjectHostSetups, hostOptions }) diff --git a/src/renderer/src/components/settings/repository-host-setup-options.ts b/src/renderer/src/components/settings/repository-host-setup-options.ts index 6b2110963383..a74361366cc2 100644 --- a/src/renderer/src/components/settings/repository-host-setup-options.ts +++ b/src/renderer/src/components/settings/repository-host-setup-options.ts @@ -39,11 +39,16 @@ export function getSetupStateLabel(setupState: ProjectHostSetupState): string { export function buildSetupHostOptions({ projectHostSetups, + outclaimed = false, hostOptions }: { + outclaimed?: boolean projectHostSetups: ProjectHostSetup[] hostOptions: readonly ExecutionHostRegistryEntry[] }): SetupHostOption[] { + if (outclaimed) { + return [] + } const setupHostIds = new Set(projectHostSetups.map((setup) => setup.hostId)) return pickerExecutionHosts(hostOptions) .filter( diff --git a/src/renderer/src/components/task-page-default-repo-selection.ts b/src/renderer/src/components/task-page-default-repo-selection.ts index 96af4192883b..568596e2ee6e 100644 --- a/src/renderer/src/components/task-page-default-repo-selection.ts +++ b/src/renderer/src/components/task-page-default-repo-selection.ts @@ -1,10 +1,12 @@ import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { - getProjectIdentityKey, + projectHostSetupProjectionFromRepos, hasProjectRemoteIdentity, isProjectRemoteIdentityPending } from '../../../shared/project-host-setup-projection' import { isGitRepoKind } from '../../../shared/repo-kind' +import type { ProjectHostSetup } from '../../../shared/project-types' +import { getProjectHostSetupLookup } from '../../../shared/project-host-setup-lookup' import type { Repo } from '../../../shared/repo-types' export type TaskProjectPickerGroup = { @@ -25,9 +27,10 @@ export function getTaskEligibleRepos(repos: readonly Repo[]): Repo[] { } export function getDefaultTaskRepoSelection(repos: readonly Repo[]): Set { + const setups = projectHostSetupProjectionFromRepos(repos).setups const selectedByProject = new Map() for (const repo of repos) { - const projectKey = getTaskRepoProjectKey(repo) + const projectKey = getTaskRepoProjectKey(repo, setups) const current = selectedByProject.get(projectKey) if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { selectedByProject.set(projectKey, repo) @@ -40,9 +43,10 @@ export function getTaskProjectPickerGroups( repos: readonly Repo[], preferredSelection: ReadonlySet = new Set() ): TaskProjectPickerGroup[] { + const setups = projectHostSetupProjectionFromRepos(repos).setups const groupsByProject = new Map() for (const repo of repos) { - const projectKey = getTaskRepoProjectKey(repo) + const projectKey = getTaskRepoProjectKey(repo, setups) const current = groupsByProject.get(projectKey) if (!current) { groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] }) @@ -63,13 +67,14 @@ export function normalizeTaskRepoSelection( repos: readonly Repo[], selection: ReadonlySet ): Set { + const setups = projectHostSetupProjectionFromRepos(repos).setups const selectedByProject = new Map() const selectedIds = new Set(selection) for (const repo of repos) { if (!selectedIds.has(repo.id)) { continue } - const projectKey = getTaskRepoProjectKey(repo) + const projectKey = getTaskRepoProjectKey(repo, setups) const current = selectedByProject.get(projectKey) if (!current || compareDefaultTaskRepoCandidate(repo, current) < 0) { selectedByProject.set(projectKey, repo) @@ -81,8 +86,13 @@ export function normalizeTaskRepoSelection( return new Set([...selectedByProject.values()].map((repo) => repo.id)) } -export function getTaskRepoProjectKey(repo: Repo): string { - return getProjectIdentityKey(repo) +export function getTaskRepoProjectKey(repo: Repo, setups: readonly ProjectHostSetup[]): string { + return ( + getProjectHostSetupLookup(setups).projectId({ + repoId: repo.id, + hostId: getRepoExecutionHostId(repo) + }) ?? `repo:${repo.id}` + ) } function compareTaskProjectPickerCandidate( diff --git a/src/renderer/src/store/project-host-setup-selector.test.ts b/src/renderer/src/store/project-host-setup-selector.test.ts index 5cc2980c2bab..16595d8e877c 100644 --- a/src/renderer/src/store/project-host-setup-selector.test.ts +++ b/src/renderer/src/store/project-host-setup-selector.test.ts @@ -92,7 +92,7 @@ describe('project host setup selector', () => { const first = getProjectHostSetupProjectionFromState(state) expect(countedRepos.counters.map).toBeGreaterThan(0) expect(countedRepos.counters.iterator).toBeGreaterThan(0) - expect(countedProjects.counters.flatMap).toBeGreaterThan(0) + expect(countedProjects.counters.map).toBeGreaterThan(0) expect(countedSetups.counters.map).toBeGreaterThan(0) countedRepos.counters.map = 0 countedRepos.counters.flatMap = 0 diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index 8855c8cfeb98..cddacf664130 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -6,6 +6,7 @@ import { notifyInstalledAgentSkillsChanged } from '@/hooks/installed-agent-skill import type { RepoSlice } from '../repos/repo-state' import { getProjectSetupRuntimeTarget } from './project-host-routing' import { mergeUpdatedProjectCompatibilityProject } from '../../../../shared/project-compatibility-merge' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' import { normalizeProjectRow } from '../../../../shared/project-catalog-row-normalization' export function createProjectUpdateActions( @@ -16,15 +17,23 @@ export function createProjectUpdateActions( updateProject: async (projectId, updates, hostId) => { try { // Why: the caller names the host whose project row it edits; focus never picks it. + const state = get() + const owner = getProjectHostSetupProjectionFromState(state).setups.find( + (setup) => setup.projectId === projectId && setup.hostId === hostId + ) + const endpointProjectId = + state.projectHostSetups.find( + (setup) => owner && setup.id === owner.id && setup.hostId === owner.hostId + )?.projectId ?? projectId const target = getProjectSetupRuntimeTarget(hostId) const updatedProject = target.kind === 'local' - ? await window.api.projects.update({ projectId, updates }) + ? await window.api.projects.update({ projectId: endpointProjectId, updates }) : ( await callRuntimeRpc<{ project: Project }>( target, 'project.update', - { projectId, updates }, + { projectId: endpointProjectId, updates }, { timeoutMs: 15_000 } ) ).project @@ -36,7 +45,7 @@ export function createProjectUpdateActions( const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ projects: state.projects.map((project) => - project.id === projectId + project.id === endpointProjectId ? mergeUpdatedProjectCompatibilityProject(project, normalizedProject, updates) : project ), diff --git a/src/renderer/src/store/slices/repos-project-runtime.spec.ts b/src/renderer/src/store/slices/repos-project-runtime.spec.ts index d3fd6fe4e45f..16e167ed792e 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.spec.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.spec.ts @@ -6,6 +6,8 @@ import { createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest } from '../../runtime/runtime-compatibility-test-fixture' +import { getProjectHostSetupProjectionFromState } from '../project-host-setup-selector' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client' export function registerReposProjectRuntimeTests(): void { @@ -107,6 +109,66 @@ export function registerReposProjectRuntimeTests(): void { expectInstalledSkillRefreshEvent() }) + it('updates an old endpoint by its concrete advertised project and rehydrates the split view', async () => { + const repos = ['a', 'b'].map((id, index) => ({ + ...localRepo, + id, + path: `/${id}`, + addedAt: index + 1, + executionHostId: 'runtime:old' as const, + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(repos) + const rawProject = { + ...projection.projects[0], + id: 'github:acme/app', + sourceRepoIds: ['a', 'b'] + } + runtimeEnvironmentCall.mockImplementation(async (args) => { + expect(args).toMatchObject({ + selector: 'old', + method: 'project.update', + params: { + projectId: rawProject.id, + updates: { localWindowsRuntimePreference: { kind: 'windows-host' } } + } + }) + return { + id: 'update', + ok: true, + result: { + project: { ...rawProject, localWindowsRuntimePreference: { kind: 'windows-host' } } + } + } + }) + const store = createTestStore() + store.setState({ + repos, + projects: [rawProject], + projectHostSetups: projection.setups.map((setup) => ({ + ...setup, + projectId: rawProject.id + })) + }) + expect( + await store + .getState() + .updateProject( + 'repo:b', + { localWindowsRuntimePreference: { kind: 'windows-host' } }, + 'runtime:old' + ) + ).toBe(true) + const view = getProjectHostSetupProjectionFromState(store.getState()) + expect(view.setups.find((setup) => setup.repoId === 'b')?.projectId).toBe('repo:b') + expect( + view.projects.find((project) => project.id === 'repo:b')?.localWindowsRuntimePreference + ).toEqual({ kind: 'windows-host' }) + expect( + store.getState().projectHostSetups.every((setup) => setup.projectId === rawProject.id) + ).toBe(true) + }) + it('hydrates projects from local IPC when the project API is available', async () => { const project: Project = { id: 'project-1', diff --git a/src/shared/ai-vault-project-search.test.ts b/src/shared/ai-vault-project-search.test.ts new file mode 100644 index 000000000000..3a399eab1dd2 --- /dev/null +++ b/src/shared/ai-vault-project-search.test.ts @@ -0,0 +1,207 @@ +import { expect, it, vi } from 'vitest' +import { searchCanonicalProject } from './ai-vault-project-search' +import type { + AiVaultSearchHit, + AiVaultSearchRequest, + AiVaultSearchResponse +} from './ai-vault-search-types' +import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' +import { resolveProjectHostSetupProjection } from './project-host-setup-hydration' +import type { Repo } from './repo-types' + +const repos: Repo[] = ['a', 'b', 'provisioned'].map((id, index) => ({ + id, + path: index === 1 ? '/a/nested-b' : `/${id}`, + displayName: id, + badgeColor: '', + addedAt: index + 1, + upstream: { owner: 'acme', repo: 'app' }, + executionHostId: 'runtime:old' +})) +const derived = projectHostSetupProjectionFromRepos(repos) +const raw = derived.setups.map((setup) => ({ + ...setup, + projectId: 'github:acme/app', + ...(setup.repoId === 'provisioned' ? { setupMethod: 'provisioned' as const } : {}) +})) +const catalog = { + ...resolveProjectHostSetupProjection( + repos, + [{ ...derived.projects[0], sourceRepoIds: repos.map((repo) => repo.id) }], + raw + ), + worktrees: [], + rawSetups: raw +} +const hit = (id: string, cwd: string, score = 1): AiVaultSearchHit => ({ + sessionId: id, + title: id, + cwd, + score, + agent: 'codex', + branch: null, + updatedAt: '2026-10-01T00:00:00Z', + messageCount: 1, + source: { presence: 'unverifiable' }, + evidence: null +}) +const page = (hits: AiVaultSearchHit[], cursor: string | null): AiVaultSearchResponse => ({ + kind: 'results', + hits, + page: { cursor, hasMore: cursor !== null }, + generation: 7, + durationMs: 1, + truncated: { candidates: false, snippets: 0, query: false, freshness: false } +}) +const request: AiVaultSearchRequest = { + query: 'needle', + limit: 2, + within: { kind: 'project', projectKey: 'project:github:acme/app' } +} + +it('composes old-peer repo scopes on one endpoint, preserving filtered-page offsets and cursors', async () => { + const search = vi.fn(async (native: AiVaultSearchRequest, scope?: string) => { + expect(scope).toBe('runtime:old') + expect(native.within?.kind).toBe('project') + const key = native.within?.kind === 'project' ? native.within.projectKey : '' + if (key === 'repo:a') { + if (native.cursor === 'a2') { + return page([hit('a2', '/a/second', 3), hit('a3', '/a/third', 2)], null) + } + return page([hit('excluded-b', '/a/nested-b', 10)], 'a2') + } + expect(key).toBe('repo:provisioned') + return page([hit('p1', '/provisioned', 4), hit('p2', '/provisioned/next', 1)], null) + }) + const first = await searchCanonicalProject(request, 'runtime:old', catalog, search) + expect(first.kind).toBe('results') + if (first.kind !== 'results') { + return + } + expect(first.hits.map((row) => row.sessionId)).toEqual(['p1', 'a2']) + expect(first.hits.every((row) => row.executionHostId === 'runtime:old')).toBe(true) + expect(first.page.hasMore).toBe(true) + const next = await searchCanonicalProject( + { ...request, cursor: first.page.cursor! }, + 'runtime:old', + catalog, + search + ) + expect(next.kind).toBe('results') + if (next.kind !== 'results') { + return + } + expect(next.hits.map((row) => row.sessionId)).toEqual(['a3', 'p2']) + expect(next.page.hasMore).toBe(false) + expect( + await searchCanonicalProject( + { ...request, query: 'different', cursor: first.page.cursor! }, + 'runtime:old', + catalog, + search + ) + ).toEqual({ kind: 'stale-cursor', generation: 0 }) +}) + +it('continues a bounded walk after fully filtered native pages and preserves no-service fallback', async () => { + const search = vi.fn(async (native: AiVaultSearchRequest) => + page([hit('b', '/a/nested-b')], String(Number(native.cursor ?? 0) + 1)) + ) + const onlyA = { + ...catalog, + setups: catalog.setups.filter((setup) => setup.repoId !== 'provisioned') + } + const first = await searchCanonicalProject(request, 'runtime:old', onlyA, search) + expect(search).toHaveBeenCalledTimes(3) + expect(first).toMatchObject({ kind: 'results', hits: [], page: { hasMore: true } }) + if (first.kind !== 'results') { + return + } + search.mockImplementation(async () => page([hit('a', '/a/final')], null)) + expect( + await searchCanonicalProject( + { ...request, cursor: first.page.cursor! }, + 'runtime:old', + onlyA, + search + ) + ).toMatchObject({ kind: 'results', hits: [{ sessionId: 'a' }], page: { hasMore: false } }) + expect( + await searchCanonicalProject(request, 'runtime:old', onlyA, async () => ({ + kind: 'unavailable', + reason: 'no-service' + })) + ).toEqual({ kind: 'unavailable', reason: 'no-service' }) +}) + +it('bounds catalog reads when a real scoped search grows from N to 10N owners and hits', async () => { + const reads = [] + for (const size of [10, 100]) { + let pathReads = 0 + const projection = projectHostSetupProjectionFromRepos( + Array.from({ length: size }, (_, index) => ({ + ...repos[0], + id: `r${index}`, + path: `/r${index}`, + upstream: { owner: 'acme', repo: `app${index}` } + })) + ) + const setups = projection.setups.map((setup) => ({ + ...setup, + get path() { + pathReads++ + return setup.path + } + })) + const response = await searchCanonicalProject( + { + ...request, + limit: size, + within: { kind: 'project', projectKey: 'project:github:acme/app0' } + }, + 'runtime:old', + { ...projection, setups, worktrees: [] }, + async () => + page( + Array.from({ length: size }, (_, index) => hit(`s${index}`, `/r0/${index}`)), + null + ) + ) + expect(response.kind === 'results' && response.hits.length).toBe(size) + expect(pathReads).toBeLessThanOrEqual(size * 3) + reads.push(pathReads) + } + expect(reads[1]).toBeLessThanOrEqual(reads[0] * 10) +}) + +it('keeps one native project query on a correctly advertised endpoint at N and 10N', async () => { + for (const size of [10, 100]) { + const repos = Array.from({ length: size }, (_, index) => ({ + id: `r${index}`, + path: `/r${index}`, + displayName: `r${index}`, + addedAt: index, + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + })) + const original = projectHostSetupProjectionFromRepos(repos) + const setups = original.setups.map((setup) => ({ + ...setup, + projectId: 'github:acme/app', + setupMethod: 'provisioned' as const + })) + const projection = resolveProjectHostSetupProjection(repos, original.projects, setups) + const search = vi.fn(async (request: AiVaultSearchRequest) => { + expect(request.within).toEqual({ kind: 'project', projectKey: 'project:github:acme/app' }) + return page([hit('one', '/r0')], null) + }) + const response = await searchCanonicalProject( + request, + 'local', + { ...projection, rawSetups: setups, worktrees: [] }, + search + ) + expect(response).toMatchObject({ kind: 'results', hits: [{ sessionId: 'one' }] }) + expect(search).toHaveBeenCalledTimes(1) + } +}) diff --git a/src/shared/ai-vault-project-search.ts b/src/shared/ai-vault-project-search.ts new file mode 100644 index 000000000000..c80b0422dc78 --- /dev/null +++ b/src/shared/ai-vault-project-search.ts @@ -0,0 +1,194 @@ +import type { AiVaultSearchRequest, AiVaultSearchResponse } from './ai-vault-search-types' +import type { ExecutionHostScope } from './execution-host' +import type { ProjectHostSetupProjection } from './project-host-setup-projection' +import { getProjectHostSetupLookup } from './project-host-setup-lookup' +import { searchAllExecutionHosts } from './ai-vault-search-page-walk' +import { toAiVaultProjectKey } from './ai-vault-project-key' +import { normalizeRuntimePathForComparison } from './cross-platform-path' +import { splitWorktreeIdForFilesystem } from './worktree/id' +import type { ProjectHostSetup } from './project-types' +import type { Worktree } from './worktree/types' +import { parseWslUncPath } from './wsl-paths' +import { sessionFileExecutionNamespace } from './ai-vault-scope-cwd-match' + +type Catalog = ProjectHostSetupProjection & { + worktrees: readonly Worktree[] + rawSetups?: readonly ProjectHostSetup[] +} +type Search = ( + request: AiVaultSearchRequest, + scope?: ExecutionHostScope +) => Promise + +/** Client-only plan: native endpoints still receive their existing repo scope and cursor. */ +export async function searchCanonicalProject( + request: AiVaultSearchRequest, + scope: ExecutionHostScope, + catalog: Catalog, + search: Search +): Promise { + if (request.within?.kind !== 'project') { + return search(request, scope) + } + const projectKey = request.within.projectKey + const selectedByHost = new Map>() + const sources = new Map() + const roots = new Map() + const owners = getProjectHostSetupLookup(catalog.setups) + const addRoot = (host: string, path: string, source: string, priority = 'setup') => { + const normalized = normalizeRuntimePathForComparison(path) + const key = JSON.stringify([host, `${priority}:native`, normalized]) + roots.set(key, roots.has(key) && roots.get(key) !== source ? null : source) + const wsl = parseWslUncPath(path) + if (wsl) { + roots.set( + JSON.stringify([host, `${priority}:wsl:${wsl.distro.toLowerCase()}`, wsl.linuxPath]), + source + ) + } + } + for (const setup of catalog.setups) { + if (!setup.repoId || (scope !== 'all' && setup.hostId !== scope)) { + continue + } + const source = JSON.stringify([setup.hostId, setup.repoId]) + if (toAiVaultProjectKey(setup.projectId, setup.repoId) === projectKey) { + sources.set(source, setup) + const selected = selectedByHost.get(setup.hostId) ?? new Set() + selected.add(source) + selectedByHost.set(setup.hostId, selected) + } + if (setup.path) { + addRoot(setup.hostId, setup.path, source) + } + if (setup.worktreeBasePath) { + addRoot(setup.hostId, setup.worktreeBasePath, source) + } + } + const registered = new Set( + catalog.worktrees.map((workspace) => + JSON.stringify([owners.forWorkspace(workspace)?.hostId, workspace.id]) + ) + ) + // Registered workspace roots outrank checkout roots, including linked workspaces and prior paths. + for (const workspace of catalog.worktrees) { + const owner = owners.forWorkspace(workspace) + if (!owner?.hostId || !owner.repoId) { + continue + } + const source = JSON.stringify([owner.hostId, owner.repoId]) + for (const path of [ + workspace.path, + ...(workspace.priorWorktreeIds ?? []).flatMap((id) => { + if (registered.has(JSON.stringify([owner.hostId, id]))) { + return [] + } + const parsed = splitWorktreeIdForFilesystem(id) + return parsed?.repoId === workspace.repoId ? [parsed.worktreePath] : [] + }) + ]) { + addRoot(owner.hostId, path, source, 'workspace') + } + } + if (!sources.size) { + return search(request, scope) + } + const advertised = new Map }>() + for (const setup of catalog.rawSetups ?? []) { + if (!setup.repoId) { + continue + } + const scope = toAiVaultProjectKey(setup.projectId, setup.repoId)! + const key = JSON.stringify([setup.hostId, scope]) + const group = advertised.get(key) ?? { host: setup.hostId, scope, sources: new Set() } + group.sources.add(JSON.stringify([setup.hostId, setup.repoId])) + advertised.set(key, group) + } + const scopeByHost = new Map() + for (const group of advertised.values()) { + const selected = selectedByHost.get(group.host) + if ( + selected?.size === group.sources.size && + [...selected].every((source) => group.sources.has(source)) + ) { + scopeByHost.set(group.host, group.scope) + } + } + const nativeSources = new Map( + [...sources].map(([source, setup]) => [ + scopeByHost.has(setup.hostId) + ? JSON.stringify([setup.hostId, scopeByHost.get(setup.hostId)]) + : source, + setup + ]) + ) + const plan = JSON.stringify([ + request.query, + request.within, + request.filters, + request.scope, + request.freshness, + [...sources.keys()].sort(), + [...scopeByHost].sort(), + [...roots].sort() + ]) + const response = await searchAllExecutionHosts( + request, + [...nativeSources].map(([streamKey, setup]) => ({ + streamKey, + executionHostId: setup.hostId, + search: (nativeRequest) => + search( + { + ...nativeRequest, + within: { + kind: 'project', + projectKey: scopeByHost.get(setup.hostId) ?? `repo:${setup.repoId}` + } + }, + setup.hostId + ), + accept: (hit) => { + for (const priority of ['workspace', 'setup']) { + let path = hit.cwd ? normalizeRuntimePathForComparison(hit.cwd) : '' + while (path) { + const key = JSON.stringify([setup.hostId, `${priority}:native`, path]) + const wslKey = JSON.stringify([ + setup.hostId, + `${priority}:${sessionFileExecutionNamespace(hit.source.filePath ?? '')}`, + path + ]) + if (roots.has(key) || roots.has(wslKey)) { + return scopeByHost.has(setup.hostId) + ? selectedByHost + .get(setup.hostId)! + .has(roots.has(key) ? roots.get(key)! : roots.get(wslKey)!) + : (roots.has(key) ? roots.get(key) : roots.get(wslKey)) === streamKey + } + path = path.slice(0, path.lastIndexOf('/')) + } + } + return true // The endpoint may know a registered path this client's catalog has not loaded. + } + })), + plan + ) + const outcomes = response.kind === 'results' ? (response.hosts ?? []) : [] + const reason = outcomes[0]?.outcome + if ( + scope !== 'all' && + response.kind === 'results' && + !response.hits.length && + !response.page.hasMore && + reason && + outcomes.every((host) => host.outcome === reason) && + (reason === 'disabled' || + reason === 'no-service' || + reason === 'not-ready' || + reason === 'scope-unknown' || + reason === 'unsupported-agent') + ) { + return { kind: 'unavailable', reason } + } + return response +} diff --git a/src/main/ipc/ai-vault-search-merged-cursor.ts b/src/shared/ai-vault-search-merged-cursor.ts similarity index 72% rename from src/main/ipc/ai-vault-search-merged-cursor.ts rename to src/shared/ai-vault-search-merged-cursor.ts index 40f8aa88d405..20cfb305df19 100644 --- a/src/main/ipc/ai-vault-search-merged-cursor.ts +++ b/src/shared/ai-vault-search-merged-cursor.ts @@ -16,10 +16,12 @@ export type MergedSearchCursor = { /** Page size and sort the cursor was minted for; a cursor belongs to one query. */ limit: number sort: 'relevance' | 'newest' + plan?: string hosts: Record } const mergedCursorPayloadSchema = z.object({ + p: z.string().optional(), l: z.number().int().positive(), s: z.enum(['relevance', 'newest']), h: z.record( @@ -33,15 +35,26 @@ const mergedCursorPayloadSchema = z.object({ }) export function encodeMergedSearchCursor(cursor: MergedSearchCursor): string { - const payload = { l: cursor.limit, s: cursor.sort, h: cursor.hosts } - return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') + const payload = { l: cursor.limit, s: cursor.sort, h: cursor.hosts, p: cursor.plan } + return btoa( + Array.from(new TextEncoder().encode(JSON.stringify(payload)), (byte) => + String.fromCharCode(byte) + ).join('') + ) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, '') } /** Null for anything that is not a cursor this module minted. */ export function decodeMergedSearchCursor(raw: string): MergedSearchCursor | null { let parsed: unknown try { - parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) + parsed = JSON.parse( + new TextDecoder().decode( + Uint8Array.from(atob(raw.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)) + ) + ) } catch { return null } @@ -49,5 +62,5 @@ export function decodeMergedSearchCursor(raw: string): MergedSearchCursor | null if (!result.success) { return null } - return { limit: result.data.l, sort: result.data.s, hosts: result.data.h } + return { plan: result.data.p, limit: result.data.l, sort: result.data.s, hosts: result.data.h } } diff --git a/src/main/ipc/ai-vault-search-all-hosts.ts b/src/shared/ai-vault-search-page-walk.ts similarity index 82% rename from src/main/ipc/ai-vault-search-all-hosts.ts rename to src/shared/ai-vault-search-page-walk.ts index a2cddc72f059..40e007184c82 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.ts +++ b/src/shared/ai-vault-search-page-walk.ts @@ -1,11 +1,11 @@ -import { resolveSessionSearchLimit } from '../../shared/ai-vault-search-limit' +import { resolveSessionSearchLimit } from './ai-vault-search-limit' import type { AiVaultSearchHit, AiVaultSearchHostOutcome, AiVaultSearchRequest, AiVaultSearchResponse -} from '../../shared/ai-vault-search-types' -import type { ExecutionHostId } from '../../shared/execution-host' +} from './ai-vault-search-types' +import type { ExecutionHostId } from './execution-host' import { decodeMergedSearchCursor, encodeMergedSearchCursor, @@ -14,6 +14,8 @@ import { export type SessionSearchHostLeg = { executionHostId: ExecutionHostId + streamKey?: string + accept?: (hit: AiVaultSearchHit) => boolean /** Omitted for the in-process local leg, which has no transport to hang on. */ timeoutMs?: number search: (request: AiVaultSearchRequest) => Promise @@ -21,12 +23,7 @@ export type SessionSearchHostLeg = { type MergedSort = 'relevance' | 'newest' type HostOutcome = AiVaultSearchHostOutcome['outcome'] -type Truncation = { - candidates: boolean - snippets: number - query: boolean - freshness: boolean -} +type Truncation = Extract['truncated'] // One merged request reads at most this many pages from any single host. const MAX_HOST_PAGES_PER_REQUEST = 3 @@ -58,14 +55,18 @@ type HostWalk = { */ export async function searchAllExecutionHosts( request: AiVaultSearchRequest, - legs: readonly SessionSearchHostLeg[] + legs: readonly SessionSearchHostLeg[], + plan?: string ): Promise { const startedAt = Date.now() const limit = resolveSessionSearchLimit(request.limit) const sort: MergedSort = request.filters?.sort ?? 'relevance' const resumed = request.cursor === undefined ? null : decodeMergedSearchCursor(request.cursor) + if (resumed && resumed.plan !== plan) { + return { kind: 'stale-cursor', generation: 0 } + } if (request.cursor !== undefined) { - const known = new Set(legs.map((leg) => leg.executionHostId)) + const known = new Set(legs.map((leg) => streamKey(leg))) // A cursor belongs to one query over one host set; anything else is not ours. if ( !resumed || @@ -79,14 +80,22 @@ export async function searchAllExecutionHosts( // A host absent from the cursor either finished or joined mid-walk; either way // it contributes nothing to this page. Host-id order fixes every tiebreak. const walks = legs - .filter((leg) => !resumed || resumed.hosts[leg.executionHostId] !== undefined) + .filter((leg) => !resumed || resumed.hosts[streamKey(leg)] !== undefined) .map((leg) => newHostWalk(leg, legRequest(request, sort))) - .sort((left, right) => left.executionHostId.localeCompare(right.executionHostId)) + .sort( + (left, right) => + left.executionHostId.localeCompare(right.executionHostId) || + streamKey(left.leg).localeCompare(streamKey(right.leg)) + ) await Promise.all( - walks.map((walk) => fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null)) + walks.map((walk) => fetchHostPage(walk, resumed?.hosts[streamKey(walk.leg)] ?? null)) ) const hits = await drainMergedPage(walks, limit, sort) - return mergedSearchResponse(walks, { limit, sort }, hits, Date.now() - startedAt) + return mergedSearchResponse(walks, { limit, sort, plan }, hits, Date.now() - startedAt) +} + +function streamKey(leg: SessionSearchHostLeg): string { + return leg.streamKey ?? leg.executionHostId } /** Every leg answers in the merged order; the cursor and debug are this merge's own. */ @@ -196,9 +205,15 @@ async function drainMergedPage( if (!next) { return hits } - turn = walks.indexOf(next) + 1 - hits.push(next.pending.shift()!) + turn = walks.findIndex((walk) => walk.executionHostId.localeCompare(next.executionHostId) > 0) + if (turn < 0) { + turn = 0 + } + const hit = next.pending.shift()! next.emitted += 1 + if (!next.leg.accept || next.leg.accept(hit)) { + hits.push(hit) + } } return hits } @@ -208,7 +223,15 @@ function walkWithTurn(walks: readonly HostWalk[], turn: number): HostWalk | null for (let step = 0; step < walks.length; step++) { const walk = walks[(turn + step) % walks.length] if (walk && walk.pending.length > 0) { - return walk + return walks + .filter( + (source) => source.executionHostId === walk.executionHostId && source.pending.length + ) + .sort( + (a, b) => + b.pending[0]!.score - a.pending[0]!.score || + streamKey(a.leg).localeCompare(streamKey(b.leg)) + )[0]! } } return null @@ -228,7 +251,7 @@ function mostRecentWalk(walks: readonly HostWalk[]): HostWalk | null { function mergedSearchResponse( walks: readonly HostWalk[], - query: { limit: number; sort: MergedSort }, + query: { limit: number; sort: MergedSort; plan?: string }, hits: AiVaultSearchHit[], durationMs: number ): AiVaultSearchResponse { @@ -241,13 +264,16 @@ function mergedSearchResponse( freshness: false } for (const walk of walks) { - hosts.push({ - executionHostId: walk.executionHostId, - outcome: walk.outcome - }) + if ( + !hosts.some( + (host) => host.executionHostId === walk.executionHostId && host.outcome === walk.outcome + ) + ) { + hosts.push({ executionHostId: walk.executionHostId, outcome: walk.outcome }) + } const entry = nextCursorEntry(walk) if (entry) { - nextHosts[walk.executionHostId] = entry + nextHosts[streamKey(walk.leg)] = entry } truncated.candidates ||= walk.truncated.candidates truncated.snippets += walk.truncated.snippets @@ -260,7 +286,7 @@ function mergedSearchResponse( kind: 'results', hits, page: { cursor, hasMore }, - generation: 0, + generation: walks.length === 1 ? walks[0]!.generation : 0, truncated, durationMs, hosts diff --git a/src/shared/project-checkout-claim.ts b/src/shared/project-checkout-claim.ts index a2e9fff76c40..7db28137937b 100644 --- a/src/shared/project-checkout-claim.ts +++ b/src/shared/project-checkout-claim.ts @@ -1,15 +1,8 @@ -import { toSshExecutionHostId } from './execution-host' +import { getRepoSetupOwnerScope } from './repo-host-identity' import type { ProjectHostSetup } from './project-types' import { parseWslUncPath } from './wsl-paths' import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' -/** - * Setups that must not hold their remote-derived project id because another checkout on the - * same surface already does. A shared remote is the only evidence that checkouts on different - * hosts are one project, but two checkouts on one surface are separate clones (linked worktrees - * share a repo row), and a fork, template or copied `.git/config` shares the remote too. So per - * surface only the earliest-added checkout keeps the shared id. - */ export function getOutclaimedProjectSetups( setups: readonly ProjectHostSetup[] ): ReadonlySet { @@ -36,43 +29,29 @@ export function getOutclaimedProjectSetups( ) } -// Why: provisioned and folder setups are not independent Git checkouts. function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' } -// Why: execution target and filesystem namespace independently identify a surface, so a -// Windows checkout and a WSL checkout on one machine stay one project. +// Windows and WSL namespaces compete independently while retaining cross-surface association. function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { - return `${setup.projectId}::${setup.hostId}::${getExecutionSurface(setup)}::${getPathSurface(setup)}` -} - -function getExecutionSurface(setup: ProjectHostSetup): string { - const connectionId = setup.connectionId?.trim() - if (connectionId) { - return toSshExecutionHostId(connectionId) - } - return setup.executionHostId?.trim() || setup.hostId + return `${setup.projectId}::${getRepoSetupOwnerScope(setup)}::${getPathSurface(setup)}` } function getPathSurface(setup: ProjectHostSetup): string { const wslPath = parseWslUncPath(setup.path) - if (wslPath) { - return `wsl:${wslPath.distro.toLowerCase()}` - } - if (isWindowsAbsolutePathLike(setup.path)) { - return 'windows-host' - } - return 'default' + return wslPath + ? `wsl:${wslPath.distro.toLowerCase()}` + : isWindowsAbsolutePathLike(setup.path) + ? 'windows-host' + : 'default' } -// Why: projection twins differ by row identity, not checkout directory. function getCheckoutIdentity(setup: ProjectHostSetup): string { return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id } -// Why createdAt (the repo's addedAt) then repo id: repo order comes from disk/host enumeration, -// and a client and its paired server must pick the same claimant from the same rows. +// Stable addedAt/id order makes client and server claims agree despite enumeration order. function compareClaims(left: ProjectHostSetup, right: ProjectHostSetup): number { const byAddedAt = left.createdAt - right.createdAt return byAddedAt || (left.repoId < right.repoId ? -1 : left.repoId > right.repoId ? 1 : 0) diff --git a/src/shared/project-host-setup-hydration.test.ts b/src/shared/project-host-setup-hydration.test.ts new file mode 100644 index 000000000000..893ee75c3d81 --- /dev/null +++ b/src/shared/project-host-setup-hydration.test.ts @@ -0,0 +1,91 @@ +import { expect, it } from 'vitest' +import { resolveProjectHostSetupProjection } from './project-host-setup-hydration' +import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' +import { adoptEndpointProjectCatalog, adoptEndpointRepo } from './endpoint-catalog-adoption' +import type { Repo } from './repo-types' + +it('retains nested SSH execution ownership without duplicating derived fallback setups', () => { + const endpoint = { kind: 'environment', environmentId: 'server' } as const + const native: Repo[] = ['one', 'two'].map((connectionId) => ({ + id: 'same', + path: `/${connectionId}/app`, + connectionId, + displayName: connectionId, + addedAt: 1, + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + })) + const nativeProjection = projectHostSetupProjectionFromRepos(native) + const raw = adoptEndpointProjectCatalog( + endpoint, + nativeProjection.projects.map((project) => ({ ...project, id: 'repo:legacy' })), + nativeProjection.setups.map((setup) => ({ ...setup, projectId: 'repo:legacy' })) + ) + const repos = native.map((repo) => adoptEndpointRepo(endpoint, repo)) + const canonical = resolveProjectHostSetupProjection(repos, raw.projects, raw.setups) + expect(canonical.setups).toHaveLength(2) + expect( + canonical.setups.map((setup) => [setup.hostId, setup.executionHostId, setup.projectId]) + ).toEqual([ + ['runtime:server', 'ssh:one', 'github:acme/app'], + ['runtime:server', 'ssh:two', 'github:acme/app'] + ]) + expect(raw.setups.every((setup) => setup.projectId === 'repo:legacy')).toBe(true) +}) + +it('preserves unresolved legacy project rows during a partial repo-only catalog hydrate', () => { + const repo: Repo = { id: 'a', path: '/a', displayName: 'A', addedAt: 1, badgeColor: '' } + const legacy = { + id: 'legacy', + displayName: 'Legacy', + sourceRepoIds: ['not-loaded'], + createdAt: 1, + updatedAt: 1, + badgeColor: '' + } + expect(resolveProjectHostSetupProjection([repo], [legacy], []).projects).toContainEqual(legacy) +}) + +it('qualifies provisioned setup facts by nested execution owner while claims retain repo addedAt', () => { + const endpoint = { kind: 'environment', environmentId: 'server' } as const + const native: Repo[] = [ + { id: 'same', connectionId: 'one', path: '/one/copy', addedAt: 1 }, + { id: 'same', connectionId: 'two', path: '/two/user', addedAt: 1 }, + { id: 'user', connectionId: 'one', path: '/one/user', addedAt: 2 }, + { id: 'later', connectionId: 'one', path: '/one/later', addedAt: 3 } + ].map((repo) => ({ + ...repo, + displayName: repo.id, + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(native) + const raw = adoptEndpointProjectCatalog( + endpoint, + [ + { ...projection.projects[0], id: 'github:acme/app', sourceRepoIds: ['same', 'user', 'later'] } + ], + projection.setups.map((setup) => ({ + ...setup, + projectId: 'github:acme/app', + setupMethod: + setup.repoId === 'same' && setup.connectionId === 'one' + ? 'provisioned' + : 'imported-existing-folder', + createdAt: setup.repoId === 'later' ? 0 : 99 + })) + ) + const canonical = resolveProjectHostSetupProjection( + native.map((repo) => adoptEndpointRepo(endpoint, repo)), + raw.projects, + raw.setups + ) + expect(canonical.setups.find((setup) => setup.repoId === 'user')?.projectId).toBe( + 'github:acme/app' + ) + expect(canonical.setups.find((setup) => setup.repoId === 'later')?.projectId).toBe('repo:later') + expect( + canonical.setups.find((setup) => setup.repoId === 'same' && setup.executionHostId === 'ssh:one') + ?.setupMethod + ).toBe('provisioned') +}) diff --git a/src/shared/project-host-setup-hydration.ts b/src/shared/project-host-setup-hydration.ts index f1c8cf1bb111..4ccbc7d5e8e7 100644 --- a/src/shared/project-host-setup-hydration.ts +++ b/src/shared/project-host-setup-hydration.ts @@ -1,120 +1,92 @@ import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' +import { getProjectHostSetupLookup } from './project-host-setup-lookup' +import { + getRepoDerivedSetupKey, + mergeProjectHostSetupCompatibility +} from './project-compatibility-merge' import { - getProjectIdentityKey, projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from './project-host-setup-projection' -type NormalizedProjectHostSetupProjection = ProjectHostSetupProjection & { - changed: boolean -} - -function normalizeHydratedProjectHostSetupProjection( +/** Canonical client affiliation; the supplied endpoint catalog remains untouched. */ +export function resolveProjectHostSetupProjection( repos: readonly Repo[], projects: readonly Project[], setups: readonly ProjectHostSetup[], - derived: ProjectHostSetupProjection -): NormalizedProjectHostSetupProjection { - const repoById = new Map(repos.map((repo) => [repo.id, repo])) - const derivedProjectIds = new Set(derived.projects.map((project) => project.id)) - const projectIdByHydratedProjectId = new Map() - let changed = false + derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) +): ProjectHostSetupProjection { + if (setups.some((setup) => setup.setupMethod === 'provisioned' && setup.repoId)) { + derived = projectHostSetupProjectionFromRepos(repos, undefined, setups) + } + const owners = getProjectHostSetupLookup(derived.setups) + const projectById = new Map(projects.map((project) => [project.id, project])) + const replacedProjectIds = new Set() + const unresolved = new Set( + derived.projects.flatMap((project) => + project.providerIdentity || project.gitRemoteIdentity ? [] : [project.id] + ) + ) + const metadataByProject = new Map() const normalizedSetups = setups.map((setup) => { - const repo = repoById.get(setup.repoId) ?? repoById.get(setup.id) - if (!repo) { + const projectId = owners.projectId({ + repoId: setup.repoId, + hostId: setup.executionHostId ?? setup.hostId, + runtimeOwnerEnvironmentId: setup.runtimeOwnerEnvironmentId + }) + if (!projectId || unresolved.has(projectId)) { return setup } - const projectId = getProjectIdentityKey(repo) - if (projectId === setup.projectId || projectId === `repo:${repo.id}`) { + const metadata = projectById.get(setup.projectId) + if (metadata && !metadataByProject.has(projectId)) { + metadataByProject.set(projectId, metadata) + } + if (projectId === setup.projectId) { return setup } - changed = true - projectIdByHydratedProjectId.set(setup.projectId, projectId) + replacedProjectIds.add(setup.projectId) return { ...setup, projectId } }) - const normalizedProjects = projects.flatMap((project) => { - const projectId = projectIdByHydratedProjectId.get(project.id) - if (!projectId || projectId === project.id) { - return [project] - } - // Why: runtime-hosted copies of the same Git repo may hydrate path-scoped - // project ids. If the repo-derived project already exists, keep that bucket - // authoritative so VM copies group under the user's single project. - if (derivedProjectIds.has(projectId)) { - changed = true - return [] - } - changed = true - return [{ ...project, id: projectId }] - }) - return { projects: normalizedProjects, setups: normalizedSetups, changed } -} - -/** `overlay` rows replace `base` rows of the same id; new ids append in overlay order. */ -function mergeProjectionRowsById( - base: readonly T[], - overlay: readonly T[] -): T[] { - const merged = [...base] - const indexById = new Map(merged.map((entry, index) => [entry.id, index])) - for (const entry of overlay) { - const index = indexById.get(entry.id) - if (index === undefined) { - indexById.set(entry.id, merged.length) - merged.push(entry) - } else { - merged[index] = entry - } + const covered = new Set(setups.map(getRepoDerivedSetupKey)) + if ( + replacedProjectIds.size === 0 && + derived.setups.every((setup) => covered.has(getRepoDerivedSetupKey(setup))) + ) { + return { projects, setups } } - return merged -} - -/** Whether some repo has no host setup naming it, by setup repo id or setup id. */ -function hasReposWithoutProjectHostSetup( - repos: readonly Pick[], - setups: readonly ProjectHostSetup[] -): boolean { - const repoIds = new Set(repos.map((repo) => repo.id)) - const coveredRepoIds = new Set() - for (const setup of setups) { - const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' - if (repoIds.has(repoId)) { - coveredRepoIds.add(repoId) - } - if (repoIds.has(setup.id)) { - coveredRepoIds.add(setup.id) + const allSetups = mergeProjectHostSetupCompatibility( + { projects: derived.projects, projectHostSetups: derived.setups }, + { projects, setups: normalizedSetups } + ).projectHostSetups + const membership = new Map>() + for (const setup of allSetups) { + const ids = membership.get(setup.projectId) ?? new Set() + if (setup.repoId) { + ids.add(setup.repoId) } + membership.set(setup.projectId, ids) } - return repos.length > 0 && coveredRepoIds.size < repoIds.size -} - -/** The repo-derived rows overlaid with the hydrated ones, so a repo without a setup still groups. */ -function mergeDerivedProjectHostSetupProjection( - derived: ProjectHostSetupProjection, - normalized: ProjectHostSetupProjection -): ProjectHostSetupProjection { + const canonicalProjects = derived.projects + .filter((project) => membership.has(project.id)) + .map((project) => ({ + ...project, + ...metadataByProject.get(project.id), + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + sourceRepoIds: [...(membership.get(project.id) ?? [])] + })) + const canonicalIds = new Set(canonicalProjects.map((project) => project.id)) return { - projects: mergeProjectionRowsById(derived.projects, normalized.projects), - setups: mergeProjectionRowsById(derived.setups, normalized.setups) + projects: [ + ...canonicalProjects, + ...projects.filter( + (project) => + !canonicalIds.has(project.id) && + (!replacedProjectIds.has(project.id) || membership.has(project.id)) + ) + ], + setups: allSetups } } - -/** - * The projects and setups a catalog groups by: the hydrated rows, normalized onto the repos' - * identities, and backed by the repo-derived rows when any repo has no setup. The renderer's - * selector is a memo over this. - */ -export function resolveProjectHostSetupProjection( - repos: readonly Repo[], - projects: readonly Project[], - setups: readonly ProjectHostSetup[], - derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) -): ProjectHostSetupProjection { - const normalized = normalizeHydratedProjectHostSetupProjection(repos, projects, setups, derived) - // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays - // beside legacy repos. Keep repo-backed compatibility rows visible in that case. - return hasReposWithoutProjectHostSetup(repos, setups) || normalized.changed - ? mergeDerivedProjectHostSetupProjection(derived, normalized) - : { projects, setups } -} diff --git a/src/shared/project-host-setup-lookup.ts b/src/shared/project-host-setup-lookup.ts index 9babf61e66e6..1fee515042e8 100644 --- a/src/shared/project-host-setup-lookup.ts +++ b/src/shared/project-host-setup-lookup.ts @@ -4,7 +4,7 @@ import { toRuntimeExecutionHostId } from './execution-host' import type { ProjectHostSetup } from './project-types' -import { getRepoHostIdentityForParts } from './repo-host-identity' +import { getRepoHostIdentityForParts, getRepoSetupOwnerScope } from './repo-host-identity' import type { Repo } from './repo-types' import type { WorktreeMeta } from './worktree/meta-types' import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' @@ -39,11 +39,7 @@ export function getProjectHostSetupLookup( const byRepo = new Map() const bySetup = new Map() const ownerKey = getRepoHostIdentityForParts - const scope = (setup: SetupOwner) => { - const host = setup.hostId ?? 'local' - const execution = getRepoExecutionHostId(setup) - return execution.startsWith('ssh:') && execution !== host ? `${host}\0${execution}` : host - } + const scope = getRepoSetupOwnerScope const add = (index: Map, key: string, setup: SetupOwner) => { const prior = index.get(key) index.set( diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 20ed3ac76e99..385379bf1361 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -7,6 +7,7 @@ import { normalizeGitHubRemoteHost } from './git-remote-host-alias' import { githubRepoIdentityKey, isDefaultGitHubHost } from './github/repository-identity-key' import type { Project, ProjectHostSetup, ProjectProviderIdentity } from './project-types' import type { Repo } from './repo-types' +import { getRepoSetupOwnerScope } from './repo-host-identity' import { getOutclaimedProjectSetups } from './project-checkout-claim' /** @@ -313,16 +314,26 @@ function createSetupFromRepo(repo: Repo, projectId: string): ProjectHostSetup { export function projectHostSetupProjectionFromRepos( repos: readonly Repo[], - _now?: number + _now?: number, + setupFacts: readonly ProjectHostSetup[] = [] ): ProjectHostSetupProjection { const projectById = new Map() const setups: ProjectHostSetup[] = [] // Why normalize here: a repo row is untrusted persisted/wire data too, and these // constructors copy repo.path / repo.id straight onto fields consumers call .trim() on. - const remoteSetups = repos.map((repo) => - normalizeProjectHostSetupRow(createSetupFromRepo(repo, getProjectIdentityKey(repo))) + const factsByOwner = new Map( + setupFacts.map((setup) => [ + JSON.stringify([getRepoSetupOwnerScope(setup), setup.repoId]), + setup + ]) ) + const remoteSetups = repos.map((repo) => { + const setup = createSetupFromRepo(repo, getProjectIdentityKey(repo)) + const facts = factsByOwner.get(JSON.stringify([getRepoSetupOwnerScope(setup), repo.id])) + const setupMethod = facts?.setupMethod ?? setup.setupMethod + return normalizeProjectHostSetupRow({ ...setup, setupMethod }) + }) const outclaimed = getOutclaimedProjectSetups(remoteSetups) for (const [index, repo] of repos.entries()) { const remoteSetup = remoteSetups[index] diff --git a/src/shared/project-identity-succession.ts b/src/shared/project-identity-succession.ts index 6c35c9d5fc8f..5a10d72d2c72 100644 --- a/src/shared/project-identity-succession.ts +++ b/src/shared/project-identity-succession.ts @@ -1,4 +1,5 @@ -import type { Project } from './project-types' +import { getRepoExecutionHostId } from './execution-host' +import type { Project, ProjectHostSetup } from './project-types' export type ProjectIdentitySuccession = { /** Projected projects with user-set state carried forward from their predecessor row. */ @@ -27,14 +28,12 @@ function compareStrings(left: string, right: string): number { * `github:` promotion and remote re-probes rewrite them mid-session; matching only on id * would drop the user's runtime preference and leave a ghost row behind. * - * Predecessor rule when several prior rows overlap a surviving project: most shared - * `sourceRepoIds` wins, then newest `updatedAt`, then lowest prior id (then lowest new id). - * Each prior row is claimed by at most one surviving project, so two distinct user - * preferences are never merged into one — the unclaimed row keeps its own identity. + * Association remapping stays one-to-one; preference continuity follows concrete owners. */ export function carryProjectStateThroughIdentityChange( projectedProjects: readonly Project[], - previousProjects: readonly Project[] + previousProjects: readonly Project[], + membership?: { previous: readonly ProjectHostSetup[]; current: readonly ProjectHostSetup[] } ): ProjectIdentitySuccession { const previousById = new Map(previousProjects.map((project) => [project.id, project])) const projectedIds = new Set(projectedProjects.map((project) => project.id)) @@ -102,5 +101,53 @@ export function carryProjectStateThroughIdentityChange( remappedProjectIds.set(predecessor.id, project.id) return carryUserState(project, predecessor) }) + if (membership) { + const local = (setup: ProjectHostSetup) => + setup.hostId === 'local' && getRepoExecutionHostId(setup) === 'local' + const previousByRepo = new Map() + const recordedRepoIds = new Set(membership.previous.map((setup) => setup.repoId)) + for (const previous of previousProjects) { + for (const repoId of previous.sourceRepoIds) { + if (!recordedRepoIds.has(repoId)) { + previousByRepo.set(repoId, previous) + } + } + } + for (const setup of membership.previous) { + if (!local(setup)) { + continue + } + const previous = previousById.get(setup.projectId) + if (previous) { + previousByRepo.set(setup.repoId, previous) + } + } + const predecessors = new Map>() + for (const setup of membership.current) { + if (!local(setup)) { + continue + } + const previous = previousByRepo.get(setup.repoId) + if (!previous) { + continue + } + const owners = predecessors.get(setup.projectId) ?? new Set() + owners.add(previous) + predecessors.set(setup.projectId, owners) + } + for (const [index, project] of projects.entries()) { + const owners = predecessors.get(project.id) + // Only an unambiguous surviving local owner can carry a preference. + if (owners?.size !== 1) { + continue + } + const previous = [...owners][0]! + const inherited = { ...carryUserState(project, previous) } + if (!previous.localWindowsRuntimePreference) { + delete inherited.localWindowsRuntimePreference + } + projects[index] = inherited + } + } return { projects, remappedProjectIds } } diff --git a/src/shared/repo-host-identity.ts b/src/shared/repo-host-identity.ts index 0f8dfad70970..b115a1e5c87f 100644 --- a/src/shared/repo-host-identity.ts +++ b/src/shared/repo-host-identity.ts @@ -1,4 +1,8 @@ -import { getRepoExecutionHostId, type ExecutionHostId } from './execution-host' +import { + getRepoExecutionHostId, + toSshExecutionHostId, + type ExecutionHostId +} from './execution-host' import type { Repo } from './repo-types' declare const hostScopedRepoKeyBrand: unique symbol @@ -28,3 +32,13 @@ export function getRepoRegistrationHostIds( ): Set { return new Set(repos.filter((repo) => repo.id === repoId).map(getRepoExecutionHostId)) } + +export function getRepoSetupOwnerScope( + setup: Pick & { hostId?: string } +): string { + const host = setup.hostId ?? 'local' + const execution = setup.connectionId + ? toSshExecutionHostId(setup.connectionId) + : getRepoExecutionHostId(setup) + return execution.startsWith('ssh:') && execution !== host ? `${host}\0${execution}` : host +} From 250e28d9fcc3a8fdc1104099715e3f717848ef06 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 06:16:11 -0700 Subject: [PATCH 06/29] fix: combine recent project selector imports --- .../src/components/new-workspace/use-recent-project-ids.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts index 19439dc367ea..b226e35e290b 100644 --- a/src/renderer/src/components/new-workspace/use-recent-project-ids.ts +++ b/src/renderer/src/components/new-workspace/use-recent-project-ids.ts @@ -1,9 +1,8 @@ import { useMemo } from 'react' -import { useProjectHostSetupProjection } from '@/store/selectors' +import { useAllWorktrees, useProjectHostSetupProjection } from '@/store/selectors' import { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Worktree } from '../../../../shared/worktree/types' -import { useAllWorktrees } from '@/store/selectors' import { NEW_WORKSPACE_PROJECT_GROUP_OPTION_PREFIX } from '@/lib/new-workspace-project-options' /** From 72c2d2a8eb861a5752cf27ae188c63dfb7683fbd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:57:24 -0700 Subject: [PATCH 07/29] fix: enforce canonical catalog reads and preserve search continuity --- .oxlintrc.json | 43 +++ .../oxlint-plugins/app-store-performance.mjs | 24 ++ .../project-catalog-read-boundary.test.mjs | 78 ++++++ .../src/worktree/project-section-catalog.ts | 2 + ...space-list-sections-desktop-parity.test.ts | 32 +++ .../session-search-current-owner.test.ts | 43 +++ .../ipc/ai-vault-search-all-hosts.test.ts | 88 +++++-- .../profile-project-state-file.test.ts | 22 +- .../profile-project-state-file.ts | 8 +- src/preload/api/ai-vault-api.ts | 3 +- src/preload/api/ai-vault-bridge.ts | 7 +- .../src/app-shell/use-runtime-graph-sync.ts | 4 +- ...spaceComposerCard.quick-file-drop.test.tsx | 4 +- .../components/NewWorkspaceComposerCard.tsx | 3 +- ...utomationsPage.create-destination.test.tsx | 8 +- .../automations/automations-page-fixtures.ts | 2 +- .../automations-page-runtime-fixtures.ts | 4 +- .../use-automations-page-store-state.ts | 3 +- ...obile-emulator-agent-skill-target.test.tsx | 2 +- .../native-chat-skill-discovery-context.ts | 10 +- .../pull-request-page/checks/tab.tsx | 5 +- .../components/right-sidebar/AiVaultPanel.tsx | 3 +- .../ai-vault-project-ownership.test.ts | 18 -- .../right-sidebar/ai-vault-session-resume.ts | 11 +- .../project-search-lifetime.test.tsx | 154 +++++++++++ .../use-ai-vault-search.test.tsx | 104 +++++++- .../right-sidebar/use-ai-vault-search.ts | 11 +- ...RepositoryHostSetupsSection.claim.test.tsx | 105 +++++++- .../RepositoryHostSetupsSection.test.tsx | 48 ++-- ...ostSetupsSection.workspace-window.test.tsx | 8 +- .../settings/use-settings-navigation-model.ts | 7 +- .../settings/use-settings-store-model.ts | 8 +- .../AddRepoSteps.default-checkout.test.ts | 8 +- .../sidebar/DeleteWorktreeLineageNotice.tsx | 8 +- .../sidebar/NonGitFolderDialog.test.tsx | 8 +- .../sidebar/add-repo-store-upsert.ts | 4 +- ...ist-parity-project-catalog.test-fixture.ts | 13 +- .../sidebar/useAddRepoCloneFlow.test.ts | 8 +- .../useCreateRepo.default-checkout.test.ts | 12 +- ...worktree-list-groups-mobile-parity.test.ts | 13 +- .../TabBar.windows-shell-launch.test.ts | 32 ++- .../TabBar.worktree-write-gate.test.tsx | 6 +- ...abBar.worktree-write-gate.windows.test.tsx | 6 +- ...b-bar-windows-shell-launch-test-harness.ts | 19 +- ...-bar-runtime-model-worktree-write-probe.ts | 4 +- .../tab-bar/use-tab-bar-runtime-model.ts | 21 +- .../pty-connection-test-store-state.ts | 2 +- .../terminal-drop-handler.test.ts | 8 +- .../use-task-page-composer-actions.ts | 6 +- .../use-worktree-jump-palette-store-state.ts | 8 +- .../use-worktree-jump-palette-task-url.ts | 8 +- ...omposer-target-state.jira-mapping.test.tsx | 21 +- .../composer-state/composer-target-store.ts | 8 +- ...ira-mapped-owner-flow.integration.test.tsx | 4 +- .../composer-state/target-change-actions.ts | 3 +- .../useActiveProjectSkillRuntime.test.tsx | 2 +- .../src/hooks/useActiveProjectSkillRuntime.ts | 3 +- .../useComposerState.integration.test.ts | 75 +++++- src/renderer/src/i18n/locales/pt.json | 4 +- .../agent-background-session-test-state.ts | 8 +- ...i-vault-antigravity-reference-host.test.ts | 20 +- ...ai-vault-resume-command.drop-repin.test.ts | 4 +- ...ult-resume-command.resumable-agent.test.ts | 4 +- .../src/lib/ai-vault-resume-command.test.ts | 19 +- .../src/lib/ai-vault-resume-command.ts | 5 +- .../src/lib/ai-vault-resume-platform.ts | 5 +- .../src/lib/ai-vault-resume-shell.test.ts | 2 +- src/renderer/src/lib/ai-vault-resume-shell.ts | 18 +- .../lib/ai-vault-session-fork-startup.test.ts | 2 +- .../src/lib/codex-pane-selection-lane.test.ts | 19 +- .../src/lib/codex-pane-selection-lane.ts | 5 +- .../src/lib/execution-host-facts.test.ts | 2 +- src/renderer/src/lib/execution-host-facts.ts | 5 +- .../src/lib/http-link-routing.test.ts | 18 +- src/renderer/src/lib/http-link-routing.ts | 30 +-- .../launch-agent-background-session.test.ts | 2 +- .../src/lib/local-preflight-context.test.ts | 28 +- .../src/lib/local-preflight-context.ts | 14 +- .../lib/local-project-runtime-workspace.ts | 12 +- ...workspace-port-localhost-label-selector.ts | 27 +- .../project-section-catalog.ts | 5 +- .../project-groups/project-group-mutations.ts | 2 +- .../store/project-host-setup-selector.test.ts | 22 +- .../src/store/project-host-setup-selector.ts | 20 +- .../src/store/project-search-catalog.ts | 12 + .../projects/project-host-setup-actions.ts | 50 ++-- .../src/store/projects/project-update.ts | 4 +- .../repos/all-host-repo-catalog-actions.ts | 7 +- .../src/store/repos/repo-add-actions.ts | 12 +- .../src/store/repos/repo-catalog-actions.ts | 23 +- .../src/store/repos/repo-catalog-identity.ts | 12 +- src/renderer/src/store/repos/repo-removal.ts | 2 +- src/renderer/src/store/repos/repo-state.ts | 4 +- src/renderer/src/store/repos/repo-update.ts | 5 +- .../repos/runtime-repo-catalog-actions.ts | 7 +- .../slices/local-detected-agent-state.test.ts | 10 +- .../purge-stale-runtime-host-state.test.ts | 18 +- .../src/store/slices/repos-all-hosts.test.ts | 53 ++-- .../repos-cross-host-refresh-identity.spec.ts | 2 +- .../repos-host-identity-routing.test.ts | 8 +- .../repos-nested-ssh-projection.test.ts | 4 +- .../repos-project-host-capability.spec.ts | 2 +- .../repos-project-host-lifecycle.spec.ts | 38 +-- .../slices/repos-project-runtime.spec.ts | 56 ++-- .../slices/repos-refresh-identity.spec.ts | 148 +++++------ .../repos-shared-project-badge-color.spec.ts | 3 +- .../repos-ssh-host-reconciliation.test.ts | 23 +- src/renderer/src/store/slices/repos.test.ts | 14 +- src/renderer/src/store/slices/repos.ts | 4 +- .../worktrees-removal-state-cleanup.test.ts | 4 +- .../listing/worktree-host-ownership.ts | 8 +- .../orphaned-runtime-ssh-project-purge.ts | 2 +- .../purge-stale-runtime-host-state.ts | 6 +- .../src/web/preload-api/web-ai-vault-api.ts | 4 +- src/shared/ai-vault-project-search.test.ts | 44 +++- src/shared/ai-vault-project-search.ts | 9 +- src/shared/ai-vault-search-client.ts | 13 +- src/shared/ai-vault-search-host-page.ts | 152 +++++++++++ src/shared/ai-vault-search-page-walk.ts | 246 ++++++------------ src/shared/project-checkout-claim.ts | 32 ++- src/shared/project-identity-succession.ts | 30 +++ src/shared/project-section-catalog-claims.ts | 37 +++ .../project-section-parity.test-fixture.ts | 41 +-- 123 files changed, 1885 insertions(+), 748 deletions(-) create mode 100644 config/scripts/project-catalog-read-boundary.test.mjs create mode 100644 src/main/ai-vault-search/session-search-current-owner.test.ts create mode 100644 src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx create mode 100644 src/renderer/src/store/project-search-catalog.ts create mode 100644 src/shared/ai-vault-search-host-page.ts create mode 100644 src/shared/project-section-catalog-claims.ts diff --git a/.oxlintrc.json b/.oxlintrc.json index 52048d08f5b8..e56ae83bbf51 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -179,6 +179,7 @@ { "files": ["src/renderer/src/**/*.{ts,tsx}"], "rules": { + "app-store-performance/no-raw-project-catalog-computed": "error", "renderer-scrollbar-style/require-styled-vertical-scrollbar": "error", "ime-enter-guard/require-ime-guard": "error", "no-restricted-properties": [ @@ -186,6 +187,14 @@ { "property": "randomUUID", "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + }, + { + "property": "endpointProjects", + "message": "Read canonical affiliation through project-host-setup-selector; endpoint facts are reserved for ingestion and concrete operation adapters." + }, + { + "property": "endpointProjectHostSetups", + "message": "Read canonical affiliation through project-host-setup-selector; endpoint facts are reserved for ingestion and concrete operation adapters." } ] } @@ -254,6 +263,40 @@ "rules": { "max-lines": ["error", { "max": 800, "skipBlankLines": true, "skipComments": true }] } + }, + { + "files": [ + "src/renderer/src/store/project-host-setup-selector.ts", + "src/renderer/src/store/project-search-catalog.ts", + "src/renderer/src/store/repos/repo-catalog-identity.ts", + "src/renderer/src/store/repos/repo-catalog-actions.ts", + "src/renderer/src/store/repos/runtime-repo-catalog-actions.ts", + "src/renderer/src/store/repos/all-host-repo-catalog-actions.ts", + "src/renderer/src/store/repos/repo-add-actions.ts", + "src/renderer/src/store/repos/repo-update.ts", + "src/renderer/src/store/repos/repo-removal.ts", + "src/renderer/src/store/projects/project-host-setup-actions.ts", + "src/renderer/src/store/projects/project-update.ts", + "src/renderer/src/store/project-groups/project-group-mutations.ts", + "src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts", + "src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts", + "src/renderer/src/app-shell/use-runtime-graph-sync.ts", + "**/*.test.{ts,tsx}", + "**/*.spec.{ts,tsx}", + "**/*-test-*.ts", + "**/*.test-fixture.ts", + "src/renderer/src/components/automations/automations-page-runtime-fixtures.ts" + ], + "rules": { + "app-store-performance/no-raw-project-catalog-computed": "off", + "no-restricted-properties": [ + "error", + { + "property": "randomUUID", + "message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'." + } + ] + } } ], "ignorePatterns": [ diff --git a/config/oxlint-plugins/app-store-performance.mjs b/config/oxlint-plugins/app-store-performance.mjs index 263e8b04ac0c..e9337bf69720 100644 --- a/config/oxlint-plugins/app-store-performance.mjs +++ b/config/oxlint-plugins/app-store-performance.mjs @@ -361,6 +361,29 @@ function noNestedFreshUnderShallowRule(sourceCode) { }) } +function noRawProjectCatalogComputedRule(sourceCode) { + return { + MemberExpression(node) { + if (!node.computed || node.property.type !== 'Identifier') { + return + } + const definition = variableFor(node.property, { sourceCode })?.defs[0] + const declaration = definition?.node + if ( + definition?.type === 'Variable' && + declaration.parent.kind === 'const' && + declaration.init?.type === 'Literal' && + ['endpointProjects', 'endpointProjectHostSetups'].includes(declaration.init.value) + ) { + this.report({ + node, + message: 'Read canonical project affiliation through the project catalog selector.' + }) + } + } + } +} + function bindContext(createVisitors) { return (context) => { const visitors = createVisitors(context.sourceCode) @@ -375,6 +398,7 @@ export default { meta: { name: 'app-store-performance' }, rules: { 'require-selector': { create: bindContext(requireSelectorRule) }, + 'no-raw-project-catalog-computed': { create: bindContext(noRawProjectCatalogComputedRule) }, 'no-identity-selector': { create: bindContext(noIdentitySelectorRule) }, 'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) }, 'no-nested-fresh-under-shallow': { create: bindContext(noNestedFreshUnderShallowRule) } diff --git a/config/scripts/project-catalog-read-boundary.test.mjs b/config/scripts/project-catalog-read-boundary.test.mjs new file mode 100644 index 000000000000..a48de903fcf5 --- /dev/null +++ b/config/scripts/project-catalog-read-boundary.test.mjs @@ -0,0 +1,78 @@ +import { readFileSync, mkdtempSync, realpathSync, mkdirSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { resolve, join, dirname } from 'node:path' +import { spawnSync } from 'node:child_process' +import { expect, it } from 'vitest' + +it('enforces five raw catalog forms with the actual config and narrow file exceptions', () => { + const config = JSON.parse(readFileSync(resolve('.oxlintrc.json'), 'utf8')) + expect(config.ignorePatterns.every((pattern) => typeof pattern === 'string')).toBe(true) + const folder = realpathSync(mkdtempSync(join(tmpdir(), 'sta-10541-catalog-rule-'))) + const source = ` + const key = 'projects'; + function read(state) { + state.endpointProjects; + state?.endpointProjectHostSetups; + const { endpointProjects: projects } = state; + state['endpointProjectHostSetups']; + const key = 'endpointProjects'; state[key]; + { const key = 'projects'; state[key]; } + function shadow(key) { return state[key]; } + const unrelated = 'projectHostSetups'; state[unrelated]; + state.projects; + state.projectHostSetups; + return projects; + } + ` + try { + writeFileSync( + join(folder, 'config.json'), + JSON.stringify({ + ...config, + jsPlugins: config.jsPlugins.map((plugin) => ({ + ...plugin, + specifier: resolve(plugin.specifier) + })) + }) + ) + const lint = (file, content = source) => { + const target = join(folder, file) + mkdirSync(dirname(target), { recursive: true }) + writeFileSync(target, content) + const result = spawnSync( + resolve('node_modules/.bin/oxlint'), + ['--config', join(folder, 'config.json'), '--format', 'json', file], + { cwd: folder, encoding: 'utf8' } + ) + expect(result.error).toBeUndefined() + const diagnostics = JSON.parse(result.stdout).diagnostics + expect(result.stderr).toBe('') + expect(JSON.parse(result.stdout).number_of_files, result.stdout).toBe(1) + return diagnostics.filter( + (entry) => + entry.code.includes('no-restricted-properties') || + entry.code.includes('no-raw-project-catalog-computed') + ) + } + for (const file of [ + 'src/renderer/src/hooks/useActiveProjectSkillRuntime.ts', + 'src/renderer/src/store/repos/repo-state.ts' + ]) { + const forbidden = lint(file) + expect(forbidden).toHaveLength(5) + expect( + forbidden.filter((entry) => entry.code.includes('no-restricted-properties')) + ).toHaveLength(4) + expect( + forbidden.filter((entry) => entry.code.includes('no-raw-project-catalog-computed')) + ).toHaveLength(1) + } + const allowed = 'src/renderer/src/store/project-host-setup-selector.ts' + expect(lint(allowed)).toEqual([]) + const uuid = lint(allowed, 'crypto.randomUUID()') + expect(uuid).toHaveLength(1) + expect(uuid[0].message).toContain('randomUUID') + } finally { + rmSync(folder, { recursive: true, force: true }) + } +}) diff --git a/mobile/src/worktree/project-section-catalog.ts b/mobile/src/worktree/project-section-catalog.ts index 4e89b4d7d738..79d131582022 100644 --- a/mobile/src/worktree/project-section-catalog.ts +++ b/mobile/src/worktree/project-section-catalog.ts @@ -4,6 +4,7 @@ import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types' import type { ServedProjectSections } from './project-sections-operations' import { NO_PROJECT_SECTION_CATALOG, type ProjectSectionCatalog } from './workspace-list-sections' import type { SectionRepo } from './workspace-list-types' +import { resolveServedProjectSectionClaims } from '../../../src/shared/project-section-catalog-claims' /** * Project grouping's inputs: the desktop sidebar's own catalog when it serves one, else (an older @@ -28,6 +29,7 @@ export function buildProjectSectionCatalog( projectOrderBy: projectOrderBy ?? NO_PROJECT_SECTION_CATALOG.projectOrderBy } } + served = resolveServedProjectSectionClaims(served) // Why: the desktop withholds uploaded image icons from the snapshot; its repo.list still carries them. const iconKey = (repo: SectionRepo) => `${getRepoExecutionHostId(repo)}|${repo.id}` const iconByKey = new Map(desktopRepos.map((repo) => [iconKey(repo), repo.repoIcon])) diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts index 03c35d874c09..5b7a4efc4fe0 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -1,8 +1,10 @@ +import { resolveServedProjectSectionClaims } from '../../../src/shared/project-section-catalog-claims' import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters' import { describe, expect, it } from 'vitest' import { getRepoExecutionHostId } from '../../../src/shared/execution-host' import { PROJECT_SECTION_PARITY_HEADERS, + OLD_PROJECT_SECTION_PARITY_SERVED, PROJECT_SECTION_PARITY_ROWS, PROJECT_SECTION_PARITY_SERVED } from '../../../src/shared/project-section-parity.test-fixture' @@ -76,6 +78,36 @@ describe('Project grouping on the phone', () => { } ) + it.each(['recent', 'manual'] as const)( + 'normalizes an accepted old %s snapshot without losing independent clones or concrete targets', + (order) => { + const verdict = projectSectionsRead.interpret({ + id: 'old', + ok: true, + result: { catalog: OLD_PROJECT_SECTION_PARITY_SERVED } + }) + expect(verdict.accepted).toBe(true) + if (!verdict.accepted || !verdict.value) { + throw new Error('old catalog refused') + } + const canonical = resolveServedProjectSectionClaims(verdict.value) + expect(resolveServedProjectSectionClaims(canonical)).toBe(canonical) + const catalog = buildProjectSectionCatalog(verdict.value, [], null, order) + expect(headers(catalog)).toEqual(PROJECT_SECTION_PARITY_HEADERS[order]) + expect(catalog.repos.map((repo) => repo.id)).toEqual( + PROJECT_SECTION_PARITY_SERVED.repos.map((repo) => repo.id) + ) + expect( + canonical.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path })) + ).toEqual(verdict.value.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path }))) + expect( + canonical.setups + .filter((setup) => setup.projectId.startsWith('repo:')) + .map((setup) => setup.repoId) + ).toEqual(['local-assets', 'local-workspace', 'm4-orca-two']) + } + ) + it("groups by repo from an older desktop's own repos when it serves no catalog", () => { const desktopRepos = PROJECT_SECTION_PARITY_SERVED.repos.filter( (repo) => repo.executionHostId === 'local' diff --git a/src/main/ai-vault-search/session-search-current-owner.test.ts b/src/main/ai-vault-search/session-search-current-owner.test.ts new file mode 100644 index 000000000000..8ffac4e1a2a8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-current-owner.test.ts @@ -0,0 +1,43 @@ +import { expect, it } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' +import { resolveProjectHostSetupProjection } from '../../shared/project-host-setup-hydration' +import { resolveSessionSearchScope } from './session-search-scope-resolution' + +it('uses current checkout affiliation while retaining registered and prior native paths', () => { + const remote = { + canonicalKey: 'example.org/team/app', + remoteName: 'origin', + remoteUrl: 'https://example.org/team/app.git' + } + const a: Repo = { + id: 'a', + displayName: 'app', + path: '/repos/a', + addedAt: 1, + badgeColor: '', + gitRemoteIdentity: remote + } + const b: Repo = { ...a, id: 'b', path: '/repos/b', addedAt: 2 } + const derived = projectHostSetupProjectionFromRepos([a, b]) + const shared = derived.setups.find((s) => s.repoId === a.id)!.projectId + const projection = resolveProjectHostSetupProjection( + [a, b], + [{ ...derived.projects[0]!, id: shared, sourceRepoIds: ['a', 'b'] }], + derived.setups.map((s) => ({ ...s, projectId: shared })) + ) + const catalog = { + repos: [a, b], + projects: projection.projects, + projectHostSetups: projection.setups, + worktreeMeta: { 'b::/linked/b': { repoId: 'b', priorWorktreeIds: ['b::/old/b'] } }, + settings: { nestWorkspaces: false, workspaceDir: '' } + } + expect(resolveSessionSearchScope({ kind: 'project', projectKey: 'repo:b' }, catalog)).toEqual({ + kind: 'resolved', + paths: expect.arrayContaining(['/repos/b', '/linked/b', '/old/b']) + }) + expect( + resolveSessionSearchScope({ kind: 'project', projectKey: `project:${shared}` }, catalog) + ).toEqual({ kind: 'resolved', paths: ['/repos/a'] }) +}) diff --git a/src/main/ipc/ai-vault-search-all-hosts.test.ts b/src/main/ipc/ai-vault-search-all-hosts.test.ts index cd71de6f814c..5fdba3fb85b0 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.test.ts @@ -236,38 +236,76 @@ it('names an unavailable host by its reason without losing the other hosts', asy expect(response.page.hasMore).toBe(false) }) -it('calls a leg that times out unreachable and retries it on the next page', async () => { - vi.useFakeTimers() - try { - const stalled: SessionSearchHostLeg = { - executionHostId: 'ssh:slow', - timeoutMs: 50, - search: () => new Promise(() => undefined) +it.each(['relevance', 'newest'] as const)( + 'keeps available %s hits when another leg times out and retries it', + async (sort) => { + vi.useFakeTimers() + try { + const stalled: SessionSearchHostLeg = { + executionHostId: 'ssh:slow', + timeoutMs: 50, + search: () => new Promise(() => undefined) + } + const healthy = new StubHost('local', sessions('a', 2)) + const pending = searchAllExecutionHosts({ query: 'needle', filters: { sort } }, [ + healthy.leg(), + stalled + ]) + await vi.advanceTimersByTimeAsync(60) + const first = resultsOf(await pending) + expect(first.hosts).toContainEqual({ + executionHostId: 'ssh:slow', + outcome: 'unreachable' + }) + expect(first.page.hasMore).toBe(true) + expect(first.hits.map((hit) => hit.sessionId)).toEqual(['a-0', 'a-1']) + + const recovered = new StubHost('ssh:slow', sessions('s', 2)) + const second = resultsOf( + await searchAllExecutionHosts( + { query: 'needle', filters: { sort }, cursor: first.page.cursor! }, + [healthy.leg(), recovered.leg()] + ) + ) + // The healthy host finished, so only the retried host is still in the walk. + expect(second.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }]) + expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1']) + } finally { + vi.useRealTimers() } - const healthy = new StubHost('local', sessions('a', 2)) - const pending = searchAllExecutionHosts({ query: 'needle' }, [healthy.leg(), stalled]) - await vi.advanceTimersByTimeAsync(60) - const first = resultsOf(await pending) - expect(first.hosts).toContainEqual({ - executionHostId: 'ssh:slow', - outcome: 'unreachable' - }) - expect(first.page.hasMore).toBe(true) + } +) - const recovered = new StubHost('ssh:slow', sessions('s', 2)) - const second = resultsOf( - await searchAllExecutionHosts({ query: 'needle', cursor: first.page.cursor! }, [ +it.each(['relevance', 'newest'] as const)( + 'keeps available %s hits and the retry cursor when another leg rejects', + async (sort) => { + const healthy = new StubHost('local', sessions('a', 2)) + const first = resultsOf( + await searchAllExecutionHosts({ query: 'needle', filters: { sort } }, [ healthy.leg(), - recovered.leg() + { + executionHostId: 'ssh:down', + search: async () => { + throw new Error('connection refused') + } + } ]) ) - // The healthy host finished, so only the retried host is still in the walk. - expect(second.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }]) + expect(first.hits.map((hit) => hit.sessionId)).toEqual(['a-0', 'a-1']) + expect(first.hosts).toContainEqual({ executionHostId: 'ssh:down', outcome: 'unreachable' }) + expect(first.page.hasMore).toBe(true) + const recovered = new StubHost('ssh:down', sessions('s', 2)) + const second = resultsOf( + await searchAllExecutionHosts( + { query: 'needle', filters: { sort }, cursor: first.page.cursor! }, + [healthy.leg(), recovered.leg()] + ) + ) + expect(second.hosts).toEqual([{ executionHostId: 'ssh:down', outcome: 'searched' }]) expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1']) - } finally { - vi.useRealTimers() + expect(second.page.hasMore).toBe(false) } -}) +) it('reads at most three pages from one host per merged request', async () => { // Twelve hits behind pages of two is six host pages; the bound stops at three diff --git a/src/main/orca-profiles/profile-project-state-file.test.ts b/src/main/orca-profiles/profile-project-state-file.test.ts index b2be45640bfa..e386155d75e5 100644 --- a/src/main/orca-profiles/profile-project-state-file.test.ts +++ b/src/main/orca-profiles/profile-project-state-file.test.ts @@ -138,9 +138,9 @@ it('reconciles and reloads split and ordinary handoff choices consistently with projectHostSetups: [ ...projection.setups.map((setup) => ({ ...setup, projectId })), makeSetup({ - id: 'pending', - projectId, - hostId: 'ssh:pending', + id: 'pending-b', + projectId: 'repo:b', + hostId: 'ssh:extra', path: '', setupState: 'not-set-up', setupMethod: 'provisioned' @@ -155,6 +155,9 @@ it('reconciles and reloads split and ordinary handoff choices consistently with for (const preference of [undefined, { kind: 'windows-host' as const }]) { const saved = { ...split, + projectHostSetups: split.projectHostSetups.map((setup) => + setup.id === 'pending-b' ? { ...setup, projectId: 'repo:b' } : setup + ), projects: split.projects.map((project) => project.id === 'repo:b' ? { ...project, localWindowsRuntimePreference: preference } @@ -167,7 +170,18 @@ it('reconciles and reloads split and ordinary handoff choices consistently with expect( handedOff.projects.find((project) => project.id === projectId)?.localWindowsRuntimePreference ).toEqual(preference) - expect(handedOff.projectHostSetups?.filter((setup) => setup.id === 'pending')).toHaveLength(1) + expect(handedOff.projectHostSetups?.filter((setup) => setup.id === 'pending-b')).toEqual([ + expect.objectContaining({ + projectId, + hostId: 'ssh:extra', + repoId: '', + setupState: 'not-set-up', + setupMethod: 'provisioned' + }) + ]) + expect( + rebuildRepoBackedProjectState({ ...reloaded, repos: remaining }).projectHostSetups + ).toEqual(handedOff.projectHostSetups) expect(rebuildRepoBackedProjectState({ ...reloaded, repos: remaining }).projects).toEqual( handedOff.projects ) diff --git a/src/main/orca-profiles/profile-project-state-file.ts b/src/main/orca-profiles/profile-project-state-file.ts index c3d0022b80d6..b42d67e52192 100644 --- a/src/main/orca-profiles/profile-project-state-file.ts +++ b/src/main/orca-profiles/profile-project-state-file.ts @@ -1,3 +1,4 @@ +import { isRepoBackedProjectHostSetup } from '../persistence/tracking-repos/project-host-compatibility' import { existsSync, readFileSync } from 'node:fs' import { homedir } from 'node:os' import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants' @@ -226,13 +227,6 @@ export function writeSerializedProfileState( } } -function isRepoBackedProjectHostSetup( - setup: ProjectHostSetup, - currentRepoIds: ReadonlySet -): boolean { - return Boolean(setup.repoId && currentRepoIds.has(setup.repoId)) -} - export function rebuildRepoBackedProjectState(state: TransferProfileState): TransferProfileState { const projection = projectHostSetupProjectionFromRepos(state.repos) const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects, { diff --git a/src/preload/api/ai-vault-api.ts b/src/preload/api/ai-vault-api.ts index 280cd31602e9..c3f8a774e4db 100644 --- a/src/preload/api/ai-vault-api.ts +++ b/src/preload/api/ai-vault-api.ts @@ -33,7 +33,8 @@ export type AiVaultApi = { /** Omitted host means this host; `all` is merged by this desktop across every enumerated host. */ searchSessions: ( request: AiVaultSearchRequest, - executionHostScope?: ExecutionHostScope + executionHostScope?: ExecutionHostScope, + isActive?: () => boolean ) => Promise /** Status describes one index, so it never accepts the `all` scope. */ searchStatus: (executionHostScope?: ExecutionHostId) => Promise diff --git a/src/preload/api/ai-vault-bridge.ts b/src/preload/api/ai-vault-bridge.ts index c28de00481e1..b34303794225 100644 --- a/src/preload/api/ai-vault-bridge.ts +++ b/src/preload/api/ai-vault-bridge.ts @@ -34,8 +34,11 @@ function searchClient( } export const aiVaultApi = { - searchSessions: (request: AiVaultSearchRequest, executionHostScope?: ExecutionHostScope) => - searchClient(executionHostScope).searchSessions(request), + searchSessions: ( + request: AiVaultSearchRequest, + executionHostScope?: ExecutionHostScope, + isActive?: () => boolean + ) => searchClient(executionHostScope).searchSessions(request, isActive), searchStatus: (executionHostScope?: ExecutionHostId) => searchClient(executionHostScope).searchStatus(), setSearchEnabled: ( diff --git a/src/renderer/src/app-shell/use-runtime-graph-sync.ts b/src/renderer/src/app-shell/use-runtime-graph-sync.ts index f4aab50b1b99..27382db84170 100644 --- a/src/renderer/src/app-shell/use-runtime-graph-sync.ts +++ b/src/renderer/src/app-shell/use-runtime-graph-sync.ts @@ -59,8 +59,8 @@ export function useRuntimeGraphSync(): void { // Why: the graph carries the sidebar's Project catalog to paired phones; republish on change. if ( state.repos !== previousState.repos || - state.projects !== previousState.projects || - state.projectHostSetups !== previousState.projectHostSetups || + state.endpointProjects !== previousState.endpointProjects || + state.endpointProjectHostSetups !== previousState.endpointProjectHostSetups || state.projectOrderBy !== previousState.projectOrderBy ) { scheduleRuntimeGraphSync() diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx index 1fe178fa7031..a6e092dd4291 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.quick-file-drop.test.tsx @@ -50,9 +50,9 @@ beforeEach(() => { ], activeRepoId: 'local-project', activeModal: 'new-workspace-composer', - projects: [], + endpointProjects: [], projectGroups: [], - projectHostSetups: [], + endpointProjectHostSetups: [], newWorkspaceDraft: null, worktreesByRepo: {}, sparsePresetsByRepo: {} diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 213d2d0c78af..63a4f78947eb 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '@/store/project-host-setup-selector' import React from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' @@ -98,7 +99,7 @@ export default function NewWorkspaceComposerCard( (state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS ) const updateSettings = useAppStore((state) => state.updateSettings) - const projects = useAppStore((state) => state.projects) + const projects = useAppStore((state) => selectCanonicalProjects(state)) const repos = useAppStore((state) => state.repos) const nameInputFocusFrameRef = React.useRef(null) const branchNameInputId = React.useId() diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index 0635ecdb6ff1..fa15c7368f4c 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -74,8 +74,8 @@ function addSshProject(): void { mocks.repoMap.set(SSH_REPO_ID, repo) // Without a ready setup on that host the project has no run context, and a // save against it is refused before any destination is stated. - mocks.state.projectHostSetups = [ - ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + mocks.state.endpointProjectHostSetups = [ + ...(mocks.state.endpointProjectHostSetups as ProjectHostSetup[]), { id: 'setup-ssh', projectId: 'project-ssh', @@ -103,8 +103,8 @@ function addCollidingRuntimeProject(): void { executionHostId: `runtime:${RUNTIME_ID}` } as Repo mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] - mocks.state.projectHostSetups = [ - ...(mocks.state.projectHostSetups as ProjectHostSetup[]), + mocks.state.endpointProjectHostSetups = [ + ...(mocks.state.endpointProjectHostSetups as ProjectHostSetup[]), { id: 'setup-runtime-collision', projectId: 'project-runtime-collision', diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 81186c9953e4..fecf460a0bea 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -239,7 +239,7 @@ export function makeStoreState(): AutomationsPageStoreFixtures { return { state: { repos: [repo], - projectHostSetups: [makeProjectHostSetup()], + endpointProjectHostSetups: [makeProjectHostSetup()], worktreesByRepo: { [REPO_ID]: [worktree] }, unifiedTabsByWorktree: {}, terminalLayoutsByTabId: {}, diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts index 61d62d03f0ff..f144bcc12995 100644 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -55,10 +55,10 @@ export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string) updatedAt: 1 } const repos = mocks.state.repos as Repo[] - const setups = mocks.state.projectHostSetups as ProjectHostSetup[] + const setups = mocks.state.endpointProjectHostSetups as ProjectHostSetup[] const worktreesByRepo = mocks.state.worktreesByRepo as Record mocks.state.repos = [...repos, repo] - mocks.state.projectHostSetups = [...setups, setup] + mocks.state.endpointProjectHostSetups = [...setups, setup] mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] } mocks.repoMap.set(RUNTIME_REPO_ID, repo) mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree) diff --git a/src/renderer/src/components/automations/use-automations-page-store-state.ts b/src/renderer/src/components/automations/use-automations-page-store-state.ts index d817789bf762..fef24234d127 100644 --- a/src/renderer/src/components/automations/use-automations-page-store-state.ts +++ b/src/renderer/src/components/automations/use-automations-page-store-state.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import { useCallback } from 'react' import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection' import type { Repo } from '../../../../shared/repo-types' @@ -18,7 +19,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id) /** Store-backed values shared by the page's state and action hooks. */ export function useAutomationsPageStoreState() { const repos = useAppStore((s) => s.repos) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s)) const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s)) const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree) diff --git a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx index ec0342be2743..e499881bbbe5 100644 --- a/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx +++ b/src/renderer/src/components/emulator-pane/mobile-emulator-agent-skill-target.test.tsx @@ -50,7 +50,7 @@ beforeEach(() => { activeRepoId: null, activeWorktreeId: null, activeWorkspaceExecutionHostId: null, - projects: [], + endpointProjects: [], repos: [], worktreesByRepo: {}, folderWorkspaces: [], diff --git a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts index ecf51b957749..78e5678b6225 100644 --- a/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts +++ b/src/renderer/src/components/native-chat/native-chat-skill-discovery-context.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { AppState } from '../../store/types' import type { SkillDiscoveryTarget } from '../../../../shared/skills' import { parseExecutionHostId } from '../../../../shared/execution-host' @@ -25,7 +29,6 @@ export type NativeChatSkillStateInputs = Pick< | 'floatingWorkspacePath' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'restoredRuntimeHostIdByWorkspaceSessionKey' | 'settings' @@ -33,7 +36,8 @@ export type NativeChatSkillStateInputs = Pick< | 'tabsByWorktree' | 'unifiedTabsByWorktree' | 'worktreesByRepo' -> +> & + ProjectCatalogReadState type NativeChatSkillTab = { id: string; startupCwd?: string } @@ -59,7 +63,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki floatingWorkspacePath: state.floatingWorkspacePath, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, - projects: state.projects, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), repos: state.repos, restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey, settings: state.settings, diff --git a/src/renderer/src/components/pull-request-page/checks/tab.tsx b/src/renderer/src/components/pull-request-page/checks/tab.tsx index 9b94d3b3e084..22264fe99005 100644 --- a/src/renderer/src/components/pull-request-page/checks/tab.tsx +++ b/src/renderer/src/components/pull-request-page/checks/tab.tsx @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '@/store/project-host-setup-selector' import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react' import { CircleDashed } from 'lucide-react' import { useAppStore } from '@/store' @@ -68,7 +69,7 @@ export function ChecksTab({ targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null ) const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => s.projects) + const projects = useAppStore((s) => selectCanonicalProjects(s)) const [fixingChecks, setFixingChecks] = useState(false) const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState(null) const mountedRef = useMountedRef() @@ -136,7 +137,7 @@ export function ChecksTab({ { activeRepoId: null, activeWorktreeId: null, - projects, + projectHostSetupProjection: { projects, setups: [] }, repos, settings, worktreesByRepo: {} diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 3cf05c681341..6875bcb8f754 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '@/store/project-host-setup-selector' import { useCallback, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { toast } from 'sonner' @@ -69,7 +70,7 @@ export default function AiVaultPanel({ const projectHostSetupProjection = useProjectHostSetupProjection() const resumeTargetState = useAppStore( useShallow((state) => ({ - projects: state.projects, + projects: selectCanonicalProjects(state), settings: state.settings, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts index 3a08137c8ba8..06206655a7b4 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts @@ -3,7 +3,6 @@ import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { buildAiVaultProjectContext } from './ai-vault-session-projects' -import { resolveSessionSearchScope } from '../../../../main/ai-vault-search/session-search-scope-resolution' import { resolveProjectHostSetupProjection } from '../../../../shared/project-host-setup-hydration' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' @@ -83,23 +82,6 @@ it("keeps a same-host folder that shares a remote out of the other checkout's pr sessions: [niwSession, openSocialSession] }) - const catalog = { - repos: [niw, openSocial], - projects: projection.projects, - projectHostSetups: projection.setups, - worktreeMeta: { - [worktree.id]: { ...worktree, priorWorktreeIds: ['niw::/Users/benny/old-niw-topic'] } - }, - settings: { nestWorkspaces: false } - } - const ownerScope = resolveSessionSearchScope({ kind: 'project', projectKey: 'repo:niw' }, catalog) - expect(ownerScope).toEqual({ - kind: 'resolved', - paths: expect.arrayContaining([niw.path, linkedPath, '/Users/benny/old-niw-topic']) - }) - expect( - resolveSessionSearchScope({ kind: 'project', projectKey: `project:${oldProjectId}` }, catalog) - ).toEqual({ kind: 'resolved', paths: [openSocial.path] }) expect(context.activeProjectKey).toBe('repo:niw') expect(context.sessionProjectById.get(niwSession.id)).toMatchObject({ key: 'repo:niw', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 08bb37312252..73cb18401d22 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -26,7 +26,16 @@ export type AiVaultSessionResumeTargetState = Pick< AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo' > & - Partial> + Partial< + Pick< + AppState, + | 'activeRepoId' + | 'activeWorktreeId' + | 'endpointProjects' + | 'endpointProjectHostSetups' + | 'settings' + > + > export type AiVaultSessionResumeState = { blocked: boolean diff --git a/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx b/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx new file mode 100644 index 000000000000..b58d4c1374e9 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { + createSessionSearchClient, + unavailableSessionSearchStatus +} from '../../../../shared/ai-vault-search-client' +import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture' +import type { Repo } from '../../../../shared/repo-types' +import { AiVaultSearchRequestSchema } from '../../../../shared/ai-vault-search-contract' +import type { AiVaultSearchRequest } from '../../../../shared/ai-vault-search-types' +import { useAiVaultSearch } from './use-ai-vault-search' + +const original = useAppStore.getState() +afterEach(() => { + useAppStore.setState(original, true) + vi.useRealTimers() +}) + +it.each([10, 100])( + 'bounds and retires real hook admissions without dropping any of %i mixed sources', + async (size) => { + vi.useFakeTimers() + const repos: Repo[] = [ + 'a', + 'b', + ...Array.from({ length: size - 1 }, (_, i) => `copy-${i}`) + ].map((id, addedAt) => ({ + id, + path: `/${id}`, + displayName: id, + addedAt, + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' }, + executionHostId: 'runtime:old' + })) + const derived = projectHostSetupProjectionFromRepos(repos) + const projectId = derived.setups.find((setup) => setup.repoId === 'a')!.projectId + const setups = derived.setups.map((setup) => ({ + ...setup, + projectId, + ...(setup.repoId.startsWith('copy-') ? { setupMethod: 'provisioned' as const } : {}) + })) + useAppStore.setState({ + repos, + endpointProjects: [ + { ...derived.projects[0]!, id: projectId, sourceRepoIds: repos.map((repo) => repo.id) } + ], + endpointProjectHostSetups: setups, + worktreesByRepo: {} + }) + let statusCalls = 0, + nativeCalls = 0, + inFlight = 0, + maximum = 0 + let release: () => void = () => {} + let held = true + const firstResponses = new Promise((resolve) => { + release = resolve + }) + const client = createSessionSearchClient(async (method, params) => { + if (method === 'aiVault.searchStatus') { + statusCalls++ + return unavailableSessionSearchStatus() + } + nativeCalls++ + inFlight++ + maximum = Math.max(maximum, inFlight) + if (held) { + await firstResponses + } + await Promise.resolve() + inFlight-- + const native = AiVaultSearchRequestSchema.parse(params) + const key = native.within?.kind === 'project' ? native.within.projectKey : '' + expect(key).toMatch(/^repo:/) + const repoId = key.slice(5) + const offset = Number(native.cursor ?? 0) + return { + ...searchResults(), + hits: [{ ...searchHit(), sessionId: repoId, cwd: offset < 4 ? '/b' : `/${repoId}` }], + page: { hasMore: offset < 4, cursor: offset < 4 ? String(offset + 1) : null } + } + }, 'relay') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + searchSessions: ( + request: AiVaultSearchRequest, + _scope?: string, + isActive?: () => boolean + ) => client.searchSessions(request, isActive) + } + } + }) + const request: AiVaultSearchRequest = { + query: 'needle', + limit: size, + filters: { agents: ['codex'] }, + within: { kind: 'project', projectKey: `project:${projectId}` } + } + const retired = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(nativeCalls).toBe(4) + retired.unmount() + held = false + await act(async () => { + release() + await firstResponses + }) + expect(nativeCalls).toBe(4) + expect(statusCalls).toBe(4) + const live = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(live.result.current.response).toMatchObject({ + kind: 'results', + hits: [], + page: { hasMore: true } + }) + const cursorBytes = JSON.stringify(live.result.current.response).length + expect(cursorBytes).toBeLessThan(size * 2000) + const sessions: string[] = [] + for (let page = 0; page < 3; page++) { + if ( + live.result.current.response?.kind !== 'results' || + !live.result.current.response.page.hasMore + ) { + break + } + await act(async () => { + live.result.current.loadMore() + }) + sessions.splice(0, sessions.length, ...live.result.current.hits.map((hit) => hit.sessionId)) + } + expect(new Set(sessions).size).toBe(size) + expect(sessions).not.toContain('b') + expect(live.result.current.response).toMatchObject({ + kind: 'results', + page: { hasMore: false } + }) + expect(maximum).toBeLessThanOrEqual(4) + // Refetching a retained page is required by the existing cursor's consumed offset contract. + expect(nativeCalls).toBeLessThanOrEqual(size * 7 + 4) + expect(statusCalls).toBe(nativeCalls) + live.unmount() + } +) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx index f68334d03e02..c59f77626ba1 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -1,3 +1,8 @@ +import { useAppStore } from '@/store' +import type * as StoreModule from '@/store' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import type { Repo } from '../../../../shared/repo-types' // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -11,10 +16,16 @@ import { searchHit, searchResults } from '../../../../shared/ai-vault-search-tes import { useAiVaultPanelSearch, useAiVaultSearch } from './use-ai-vault-search' const mockSettings: { aiVaultSearch?: { enabled: boolean } } = {} -vi.mock('@/store', () => ({ - useAppStore: (select: (state: { settings: typeof mockSettings }) => unknown) => - select({ settings: mockSettings }) -})) +vi.mock('@/store', async (importOriginal) => { + const actual = await importOriginal() + return { + useAppStore: Object.assign( + (select: (state: { settings: typeof mockSettings }) => unknown) => + select({ settings: mockSettings }), + { getState: actual.useAppStore.getState, setState: actual.useAppStore.setState } + ) + } +}) const ALL_AGENTS = ['codex' as const] const ALL_REQUEST = { query: 'needle', filters: { agents: ['codex'] } } @@ -63,7 +74,8 @@ it('debounces, skips empty/disabled requests, and never substitutes local for an await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { query: 'latest', cursor: undefined }, - 'ssh:remote' + 'ssh:remote', + expect.any(Function) ) unmount() }) @@ -109,8 +121,12 @@ it('restarts page one after stale cursors without looping on a changing index', .mockResolvedValueOnce({ kind: 'stale-cursor', generation: 3 }) act(() => result.current.loadMore()) await debounce() - expect(searchSessions.mock.calls[1]).toEqual([{ ...request, cursor: 'page-2' }, 'runtime:owner']) - expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner']) + expect(searchSessions.mock.calls[1]).toEqual([ + { ...request, cursor: 'page-2' }, + 'runtime:owner', + expect.any(Function) + ]) + expect(searchSessions.mock.calls[2]).toEqual([request, 'runtime:owner', expect.any(Function)]) expect(searchSessions).toHaveBeenCalledTimes(3) expect(result.current.response?.kind).toBe('stale-cursor') unmount() @@ -160,7 +176,11 @@ it('discards pagination when a host is left and revisited, and replaces stale pa await debounce() rerender({ host: 'local' }) await debounce() - expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local']) + expect(searchSessions.mock.calls.at(-1)).toEqual([ + { ...request, cursor: undefined }, + 'local', + expect.any(Function) + ]) unmount() }) @@ -206,7 +226,11 @@ it('does not revive old results or cursors before debounce when returning from a act(() => result.current.loadMore()) expect(searchSessions).toHaveBeenCalledTimes(1) await debounce() - expect(searchSessions.mock.calls.at(-1)).toEqual([{ ...request, cursor: undefined }, 'local']) + expect(searchSessions.mock.calls.at(-1)).toEqual([ + { ...request, cursor: undefined }, + 'local', + expect.any(Function) + ]) unmount() }) @@ -241,7 +265,8 @@ it('searches every computer at once and keeps each hit on the computer that owns await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { ...ALL_REQUEST, cursor: undefined }, - 'all' + 'all', + expect.any(Function) ) expect(result.current.sessions.map((session) => session.executionHostId)).toEqual([ 'ssh:build-box', @@ -287,8 +312,12 @@ it('restarts page one under the all scope when the merged cursor goes stale', as .mockResolvedValueOnce({ ...searchResults(), generation: 0 }) act(() => result.current.loadMore()) await debounce() - expect(searchSessions.mock.calls[1]).toEqual([{ ...ALL_REQUEST, cursor: 'merged' }, 'all']) - expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all']) + expect(searchSessions.mock.calls[1]).toEqual([ + { ...ALL_REQUEST, cursor: 'merged' }, + 'all', + expect.any(Function) + ]) + expect(searchSessions.mock.calls[2]).toEqual([ALL_REQUEST, 'all', expect.any(Function)]) expect(result.current.sessions.map((session) => session.executionHostId)).toEqual(['local']) unmount() }) @@ -315,7 +344,8 @@ it('searches the local index with the same query once consent is on', async () = await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { ...ALL_REQUEST, cursor: undefined }, - 'local' + 'local', + expect.any(Function) ) expect(result.current.searching).toBe(true) expect(result.current.hasQuery).toBe(true) @@ -368,3 +398,51 @@ it('keeps full-text search for a host that is merely not ready', async () => { expect(result.current.titleOnly).toBe(false) unmount() }) + +it('reaches native Project search with the store workspace index and current independent owner', async () => { + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/${id}`, + displayName: id, + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(repos) + const shared = projection.setups.find((setup) => setup.repoId === 'a')!.projectId + useAppStore.setState({ + repos, + endpointProjects: [{ ...projection.projects[0]!, sourceRepoIds: ['a', 'b'] }], + endpointProjectHostSetups: projection.setups.map((setup) => ({ ...setup, projectId: shared })), + worktreesByRepo: { + b: [ + makeWorktree({ + id: 'b::/linked/b', + repoId: 'b', + path: '/linked/b', + projectId: shared, + hostId: 'local' + }) + ] + } + }) + searchSessions.mockResolvedValue({ + ...searchResults(), + hits: [{ ...searchHit(), cwd: '/linked/b', sessionId: 'b-session' }] + }) + const request: AiVaultSearchRequest = { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:b' } + } + const hook = renderHook(() => useAiVaultSearch(request, 'local', '')) + await debounce() + expect(searchSessions.mock.calls[0]?.[0].within).toEqual({ + kind: 'project', + projectKey: 'repo:b' + }) + expect(hook.result.current.hits.map((hit) => hit.sessionId)).toEqual(['b-session']) + expect( + useAppStore.getState().endpointProjectHostSetups.every((setup) => setup.projectId === shared) + ).toBe(true) + hook.unmount() +}) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts index 8e7a2c1b04e0..8b12c1823954 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -21,7 +21,7 @@ import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' import { searchCanonicalProject } from '../../../../shared/ai-vault-project-search' -import { getProjectHostSetupProjectionFromState } from '../../store/project-host-setup-selector' +import { selectProjectSearchCatalog } from '../../store/project-search-catalog' import { aiVaultSearchHitToSession } from './ai-vault-search-session' type SearchIdentity = { @@ -64,12 +64,9 @@ export function useAiVaultSearch( return searchCanonicalProject( query, host, - { - ...getProjectHostSetupProjectionFromState(state), - worktrees: state.worktrees, - rawSetups: state.projectHostSetups - }, - window.api.aiVault.searchSessions + selectProjectSearchCatalog(state), + (request, host) => window.api.aiVault.searchSessions(request, host, () => !cancelled), + () => !cancelled ) } async function run(cursor?: string) { diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx index 8e9630370a7f..3215284c9c96 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx @@ -5,6 +5,8 @@ import { expect, it, vi } from 'vitest' import { useAppStore } from '../../store' import { RepositoryHostSetupsSection } from './RepositoryHostSetupsSection' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import { buildSettingsProjectList } from './settings-project-list' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import type { Repo } from '../../../../shared/repo-types' it.each(['claimant', 'outclaimed', 'folder', 'remoteless'])( @@ -40,8 +42,8 @@ it.each(['claimant', 'outclaimed', 'folder', 'remoteless'])( useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ repos, - projects: [...projection.projects], - projectHostSetups: [...projection.setups, existing], + endpointProjects: [...projection.projects], + endpointProjectHostSetups: [...projection.setups, existing], sshTargetLabels: new Map([ ['extra', 'extra'], ['configured', 'configured'] @@ -97,3 +99,102 @@ it.each(['claimant', 'outclaimed', 'folder', 'remoteless'])( } } ) + +it('reaches and edits/removes a reloaded handoff row through B Settings and concrete setup addressing', async () => { + useAppStore.setState(useAppStore.getInitialState(), true) + const repo: Repo = { + id: 'b', + path: '/b', + displayName: 'b', + badgeColor: '', + addedAt: 2, + upstream: { owner: 'acme', repo: 'app' } + } + const remote: Repo = { ...repo, id: 'c', path: '/c', executionHostId: 'runtime:remote' } + const projection = projectHostSetupProjectionFromRepos([repo, remote]) + const project = projection.projects[0] + const pending = { + ...projection.setups[0], + id: 'pending-b', + repoId: '', + hostId: 'ssh:extra' as const, + path: '', + setupState: 'not-set-up' as const, + setupMethod: 'provisioned' as const + } + // Persistence/profile lifecycle pins this exact independent row's successor before serving it. + const saved = JSON.parse( + JSON.stringify({ projects: projection.projects, setups: [...projection.setups, pending] }) + ) + const updated = { ...pending, displayName: 'Saved pending host' } + const updateHostSetup = vi.fn().mockResolvedValue({ project, setup: updated }) + const deleteHostSetup = vi.fn().mockResolvedValue({ project, setup: updated }) + const api = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { projects: { updateHostSetup, deleteHostSetup } } + }) + useAppStore.setState({ + repos: [repo, remote], + endpointProjects: saved.projects, + endpointProjectHostSetups: saved.setups, + sshTargetLabels: new Map([['extra', 'Extra']]) + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + try { + const canonical = getProjectHostSetupProjectionFromState(useAppStore.getState()) + const entries = buildSettingsProjectList([repo, remote], { + projects: canonical.projects, + projectHostSetups: canonical.setups + }) + expect(entries).toHaveLength(1) + expect(entries[0].representativeRepoId).toBe('b') + expect(entries[0].setups.find((setup) => setup.id === 'pending-b')?.projectId).toBe(project.id) + await act(async () => + root.render( + React.createElement(RepositoryHostSetupsSection, { + repo, + forceVisible: true, + searchQuery: '', + searchEntries: [] + }) + ) + ) + expect(container.textContent).toContain('Extra') + expect(container.textContent).toContain('Not set up') + await act(async () => { + await useAppStore.getState().updateProjectHostSetup({ + setupId: 'pending-b', + updates: { displayName: updated.displayName } + }) + }) + expect(updateHostSetup).toHaveBeenCalledExactlyOnceWith({ + setupId: 'pending-b', + updates: { displayName: updated.displayName } + }) + expect( + useAppStore.getState().endpointProjectHostSetups.find((setup) => setup.id === 'pending-b') + ?.displayName + ).toBe(updated.displayName) + const remove = Array.from(container.querySelectorAll('button')).find( + (node) => node.textContent === 'Remove' + ) + expect(remove).toBeTruthy() + await act(async () => remove?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) + expect(deleteHostSetup).toHaveBeenCalledExactlyOnceWith({ setupId: 'pending-b' }) + expect( + useAppStore.getState().endpointProjectHostSetups.some((setup) => setup.id === 'pending-b') + ).toBe(false) + } finally { + await act(async () => root.unmount()) + container.remove() + useAppStore.setState(useAppStore.getInitialState(), true) + if (api) { + Object.defineProperty(window, 'api', api) + } else { + Reflect.deleteProperty(window, 'api') + } + } +}) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx index d8ea235b852c..7dd218779d2a 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.test.tsx @@ -132,8 +132,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo, remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -175,8 +175,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo, remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -228,8 +228,10 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], - projectHostSetups: [ + endpointProjects: [ + makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] }) + ], + endpointProjectHostSetups: [ makeSetup({ id: 'direct-setup', projectId: 'github:stablyai/orca', @@ -328,8 +330,10 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [remoteRepo], - projects: [makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] })], - projectHostSetups: [ + endpointProjects: [ + makeProject({ id: 'github:stablyai/orca', sourceRepoIds: ['remote-repo'] }) + ], + endpointProjectHostSetups: [ makeSetup({ id: 'hub-local-setup', projectId: 'github:stablyai/orca', @@ -369,8 +373,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -437,8 +441,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -514,8 +518,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -577,8 +581,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -628,8 +632,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -697,8 +701,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', @@ -771,8 +775,8 @@ describe('RepositoryHostSetupsSection', () => { }) useAppStore.setState({ repos: [localRepo], - projects: [makeProject({ id: 'github:stablyai/orca' })], - projectHostSetups: [ + endpointProjects: [makeProject({ id: 'github:stablyai/orca' })], + endpointProjectHostSetups: [ makeSetup({ id: 'local-repo', projectId: 'github:stablyai/orca', diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx index dee1b0e3a354..ac26e6c915cc 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.workspace-window.test.tsx @@ -68,8 +68,8 @@ function makeStatus(overrides: Partial): RuntimeStatus { function renderWithOwnerStatus(status: RuntimeStatus | null): void { useAppStore.setState({ repos: [repo], - projects: [project], - projectHostSetups: [setup], + endpointProjects: [project], + endpointProjectHostSetups: [setup], runtimeStatusByEnvironmentId: new Map([['hub', { checkedAt: 1, appVersion: '1.8.0', status }]]) }) act(() => { @@ -139,8 +139,8 @@ describe('RepositoryHostSetupsSection workspace window availability', () => { it('does not mark a transport-connected but unavailable runtime setup Ready', () => { useAppStore.setState({ repos: [repo], - projects: [project], - projectHostSetups: [setup], + endpointProjects: [project], + endpointProjectHostSetups: [setup], runtimeStatusByEnvironmentId: new Map([ [ 'hub', diff --git a/src/renderer/src/components/settings/use-settings-navigation-model.ts b/src/renderer/src/components/settings/use-settings-navigation-model.ts index b415d80596f3..af6ed1d6832c 100644 --- a/src/renderer/src/components/settings/use-settings-navigation-model.ts +++ b/src/renderer/src/components/settings/use-settings-navigation-model.ts @@ -7,7 +7,6 @@ import { getAgentSkillNavInstallStatus, getLinearAgentSkillNavInstallStatus } from '@/lib/agent-skill-nav-install-status' -import { getProjectHostSetupProjectionFromState } from '../../store/selectors' import { getSettingsSectionSearchEntries, rankSettingsSearchItems } from './settings-search' import { deriveNeededSectionIds } from './settings-load-performance' import { SCROLLBACK_PRESETS_ROWS } from './SettingsConstants' @@ -157,11 +156,7 @@ export function useSettingsNavigationModel( [visibleNavSections] ) const projectByRepoId = useMemo(() => { - const projection = getProjectHostSetupProjectionFromState({ - repos: model.repos, - projects: model.projects, - projectHostSetups: model.projectHostSetups - }) + const projection = { projects: model.projects, setups: model.projectHostSetups } const projectById = new Map(projection.projects.map((project) => [project.id, project])) const nextProjectByRepoId = new Map() for (const setup of projection.setups) { diff --git a/src/renderer/src/components/settings/use-settings-store-model.ts b/src/renderer/src/components/settings/use-settings-store-model.ts index 0a505321fb7d..c221405ab89d 100644 --- a/src/renderer/src/components/settings/use-settings-store-model.ts +++ b/src/renderer/src/components/settings/use-settings-store-model.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useCallback, useMemo, useState } from 'react' import type { OrcaHooks } from '../../../../shared/orca-yaml-hook-types' import type { ProjectHostSetup } from '../../../../shared/project-types' @@ -51,8 +55,8 @@ export function useSettingsStoreModel() { const fetchKeybindings = useAppStore((s) => s.fetchKeybindings) const closeSettingsPage = useAppStore((s) => s.closeSettingsPage) const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => s.projects) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projects = useAppStore((s) => selectCanonicalProjects(s)) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const updateProject = useAppStore((s) => s.updateProject) const updateRepo = useAppStore((s) => s.updateRepo) const removeProject = useAppStore((s) => s.removeProject) diff --git a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts index 30d0e1f604ef..48c4ee941c55 100644 --- a/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/AddRepoSteps.default-checkout.test.ts @@ -8,8 +8,8 @@ const mocks = vi.hoisted(() => ({ stateIndex: 0, storeState: { repos: [] as Repo[], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], clearOrcaHookTrustForRepo: vi.fn(), openModal: vi.fn(), sshStateByEnvironment: new Map() @@ -103,8 +103,8 @@ describe('useRemoteRepo', () => { mocks.stateSetters = [] mocks.stateValues = [[], 'ssh-1', '/srv/repo', null, false] mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] + mocks.storeState.endpointProjects = [] + mocks.storeState.endpointProjectHostSetups = [] mocks.listTargets.mockResolvedValue([ { id: 'ssh-1', label: 'Builder 1' }, { id: 'ssh-2', label: 'Builder 2' } diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx index 62870e3811fd..7cbed6b4ba27 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeLineageNotice.tsx @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { Workflow } from 'lucide-react' import { useMemo, type JSX } from 'react' import type { Repo } from '../../../../shared/repo-types' @@ -32,8 +36,8 @@ export function DeleteWorktreeLineageNotice({ dirtyChangePreviewsByWorktreeId, changeCheckStatesByWorktreeId }: DeleteWorktreeLineageNoticeProps): JSX.Element | null { - const projects = useAppStore((s) => s.projects) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projects = useAppStore((s) => selectCanonicalProjects(s)) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const projectIndex = useMemo( () => buildProjectGroupingIndex({ diff --git a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx index a12be5954212..a154973a0370 100644 --- a/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx +++ b/src/renderer/src/components/sidebar/NonGitFolderDialog.test.tsx @@ -21,8 +21,8 @@ const mocks = vi.hoisted(() => ({ addNonGitFolder: vi.fn(), runtimeEnvironments: [{ id: 'env-1', name: 'Remote Mac' }], repos: [] as Repo[], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} as Record, fetchWorktrees: vi.fn(), settings: {} @@ -130,8 +130,8 @@ describe('NonGitFolderDialog', () => { } mocks.state.runtimeEnvironments = [{ id: 'env-1', name: 'Remote Mac' }] mocks.state.repos = [] - mocks.state.projects = [] - mocks.state.projectHostSetups = [] + mocks.state.endpointProjects = [] + mocks.state.endpointProjectHostSetups = [] mocks.state.worktreesByRepo = {} mocks.state.fetchWorktrees.mockResolvedValue(true) mocks.onboardingGet.mockResolvedValue(null) diff --git a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts index c279dd26882f..6d3a89e74b63 100644 --- a/src/renderer/src/components/sidebar/add-repo-store-upsert.ts +++ b/src/renderer/src/components/sidebar/add-repo-store-upsert.ts @@ -41,8 +41,8 @@ export function upsertAddedRepoWithProjectHostSetup( // action that normally keeps the project-first compatibility model synced. useAppStore.setState({ repos, - projects: projection.projects, - projectHostSetups: projection.setups + endpointProjects: projection.projects, + endpointProjectHostSetups: projection.setups }) return { alreadyPresent, repo: ownedRepo } } diff --git a/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts index 25d6d012193a..009e50d4a3ce 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-parity-project-catalog.test-fixture.ts @@ -52,7 +52,10 @@ export function windowCatalogFromServerAnswers(base: Partial): Partial SAVED_REPO_ORDER ) const rows = mergeFetchedHostProjectCatalog({ - previous: { projects: base.projects ?? [], projectHostSetups: base.projectHostSetups ?? [] }, + previous: { + projects: base.endpointProjects ?? [], + projectHostSetups: base.endpointProjectHostSetups ?? [] + }, // An older server answers no project.list, so both sides derive its projection. fetched: projectHostSetupProjectionFromRepos(fetched), repos, @@ -60,8 +63,8 @@ export function windowCatalogFromServerAnswers(base: Partial): Partial }) return { repos, - projects: [...rows.projects], - projectHostSetups: [...rows.projectHostSetups], + endpointProjects: [...rows.projects], + endpointProjectHostSetups: [...rows.projectHostSetups], manualRepoOrder: SAVED_REPO_ORDER, projectGroups: [ ...(base.projectGroups ?? []), @@ -102,10 +105,10 @@ export async function projectCatalogFromWindowState(state: AppState) { { repos: own(state.repos), projectGroups: own(state.projectGroups ?? []), - projects: (state.projects ?? []).filter((project) => + projects: (state.endpointProjects ?? []).filter((project) => project.sourceRepoIds.some((id) => own(state.repos).some((entry) => entry.id === id)) ), - setups: (state.projectHostSetups ?? []).filter((setup) => !isServerHost(setup.hostId)) + setups: (state.endpointProjectHostSetups ?? []).filter((setup) => !isServerHost(setup.hostId)) }, [server], { manualRepoOrder: state.manualRepoOrder, projectOrderBy: state.projectOrderBy } diff --git a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts index 38ef53e739c7..1ee5c301291b 100644 --- a/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts +++ b/src/renderer/src/components/sidebar/useAddRepoCloneFlow.test.ts @@ -11,8 +11,8 @@ const mocks = vi.hoisted(() => ({ storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, repos: [] as Repo[], - projects: [], - projectHostSetups: [] + endpointProjects: [], + endpointProjectHostSetups: [] }, cloneLocal: vi.fn(), pickDirectory: vi.fn(), @@ -94,8 +94,8 @@ describe('useAddRepoCloneFlow', () => { mocks.refValues = [] mocks.stateValues = ['https://github.com/stablyai/orca.git', '/srv', false, null, null] mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] + mocks.storeState.endpointProjects = [] + mocks.storeState.endpointProjectHostSetups = [] vi.stubGlobal('window', { api: { repos: { diff --git a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts index 58677cbd3a1c..b7bcc35ab686 100644 --- a/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts +++ b/src/renderer/src/components/sidebar/useCreateRepo.default-checkout.test.ts @@ -9,8 +9,8 @@ const mocks = vi.hoisted(() => ({ storeState: { settings: { activeRuntimeEnvironmentId: null as string | null }, repos: [] as Repo[], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} as Record }, createRepo: vi.fn(), @@ -105,8 +105,8 @@ describe('useCreateRepo default-checkout handoff', () => { mocks.stateValues[STATE_ERROR_MESSAGE] = null mocks.stateValues[STATE_IS_CREATING] = false mocks.storeState.repos = [] - mocks.storeState.projects = [] - mocks.storeState.projectHostSetups = [] + mocks.storeState.endpointProjects = [] + mocks.storeState.endpointProjectHostSetups = [] mocks.storeState.worktreesByRepo = {} mocks.createRepo.mockReset() mocks.storeState.settings.activeRuntimeEnvironmentId = null @@ -137,10 +137,10 @@ describe('useCreateRepo default-checkout handoff', () => { expect(mocks.fetchWorktrees).toHaveBeenCalledWith(repo.id, { requireAuthoritative: true }) - expect(mocks.storeState.projects).toEqual( + expect(mocks.storeState.endpointProjects).toEqual( expect.arrayContaining([expect.objectContaining({ sourceRepoIds: [repo.id] })]) ) - expect(mocks.storeState.projectHostSetups).toEqual( + expect(mocks.storeState.endpointProjectHostSetups).toEqual( expect.arrayContaining([expect.objectContaining({ repoId: repo.id, path: repo.path })]) ) expect(mocks.onGitRepoReady).toHaveBeenCalledWith(repo.id, undefined) diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts index 3192d1cb4115..986455ac4896 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts @@ -110,7 +110,11 @@ async function desktopCatalog(): Promise<{ repos: readonly Repo[]; rows: Project async function desktopHeaders(projectOrderBy: ProjectOrderBy) { const { repos, rows } = await desktopCatalog() - const projection = getProjectHostSetupProjectionFromState({ repos, ...rows }) + const projection = getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: rows.projects, + endpointProjectHostSetups: rows.projectHostSetups + }) const worktrees: Worktree[] = PROJECT_SECTION_PARITY_ROWS.map((row) => ({ ...worktreeFixture, id: row.id, @@ -147,7 +151,12 @@ async function desktopHeaders(projectOrderBy: ProjectOrderBy) { */ async function servedCatalog(projectOrderBy: ProjectOrderBy): Promise { const { repos, rows } = await desktopCatalog() - const published = getProjectSectionCatalogFromState({ repos, ...rows, projectOrderBy }) + const published = getProjectSectionCatalogFromState({ + repos, + endpointProjects: rows.projects, + endpointProjectHostSetups: rows.projectHostSetups, + projectOrderBy + }) if (!published) { throw new Error('the renderer published no catalog') } diff --git a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts index 68ffa7d52246..adb5e24b72f1 100644 --- a/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.windows-shell-launch.test.ts @@ -109,13 +109,27 @@ describe('TabBar PowerShell launch wiring', () => { it('hides the WSL terminal row for local host-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' appStoreSnapshot.activeWorktreeId = 'wt-1' - appStoreSnapshot.projects = [ + appStoreSnapshot.endpointProjects = [ { id: 'project-1', localWindowsRuntimePreference: { kind: 'windows-host' }, sourceRepoIds: ['repo-1'] } ] + appStoreSnapshot.endpointProjectHostSetups = [ + { + id: 'repo-1', + repoId: 'repo-1', + projectId: 'project-1', + hostId: 'local', + path: 'C:/repo', + displayName: 'project', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ] appStoreSnapshot.repos = [{ id: 'repo-1' }] appStoreSnapshot.worktreesByRepo = { 'repo-1': [ @@ -177,13 +191,27 @@ describe('TabBar PowerShell launch wiring', () => { it('shows only the WSL terminal row for local WSL-runtime projects', async () => { appStoreSnapshot.activeRepoId = 'repo-1' - appStoreSnapshot.projects = [ + appStoreSnapshot.endpointProjects = [ { id: 'project-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' }, sourceRepoIds: ['repo-1'] } ] + appStoreSnapshot.endpointProjectHostSetups = [ + { + id: 'repo-1', + repoId: 'repo-1', + projectId: 'project-1', + hostId: 'local', + path: 'C:/repo', + displayName: 'project', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ] appStoreSnapshot.repos = [{ id: 'repo-1' }] appStoreSnapshot.worktreesByRepo = { 'repo-1': [ diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx index a33caa8aba15..37b86271caae 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx @@ -75,7 +75,11 @@ describe('TabBar worktree-write gate (non-Windows client)', () => { probeWindowsCapabilities.hostPlatform = 'darwin' tabBarSurfaceRenders.count = 0 localProjectRuntimeSpy.mockClear() - ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, projects: [], repos: [] }) + ;(await createTabBarProbeStore()).setState({ + worktreesByRepo: {}, + endpointProjects: [], + repos: [] + }) }) afterEach(() => { diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx index ae51456cdfd1..fe94aa5dd405 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx @@ -56,7 +56,11 @@ describe('TabBar worktree-write gate (Windows client)', () => { probeWindowsCapabilities.hostPlatform = null tabBarSurfaceRenders.count = 0 localProjectRuntimeSpy.mockClear() - ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, projects: [], repos: [] }) + ;(await createTabBarProbeStore()).setState({ + worktreesByRepo: {}, + endpointProjects: [], + repos: [] + }) }) afterEach(() => { diff --git a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts index aec837548645..5a98511fcf0a 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts +++ b/src/renderer/src/components/tab-bar/tab-bar-windows-shell-launch-test-harness.ts @@ -1,3 +1,4 @@ +import type { ProjectHostSetup } from '../../../../shared/project-types' import { vi } from 'vitest' export const appStoreSnapshot: { @@ -6,7 +7,8 @@ export const appStoreSnapshot: { activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeRuntimeEnvironmentId: string | null activeWorktreeId: string | null - projects: { + endpointProjectHostSetups: readonly ProjectHostSetup[] + endpointProjects: { id: string localWindowsRuntimePreference?: | { kind: 'inherit-global' | 'windows-host' } @@ -44,7 +46,8 @@ export const appStoreSnapshot: { activeTabType: null, activeRuntimeEnvironmentId: null, activeWorktreeId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], sshConnectionStates: new Map(), worktreesByRepo: {}, @@ -69,7 +72,8 @@ const useAppStoreMock = vi.fn( activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null activeWorktreeId: string | null gitStatusByWorktree: Record - projects: typeof appStoreSnapshot.projects + endpointProjectHostSetups: readonly ProjectHostSetup[] + endpointProjects: typeof appStoreSnapshot.endpointProjects repos: { id: string; connectionId?: string | null }[] sshConnectionStates: Map worktreesByRepo: typeof appStoreSnapshot.worktreesByRepo @@ -98,7 +102,8 @@ const useAppStoreMock = vi.fn( activeTabType: appStoreSnapshot.activeTabType, activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, - projects: appStoreSnapshot.projects, + endpointProjects: appStoreSnapshot.endpointProjects, + endpointProjectHostSetups: appStoreSnapshot.endpointProjectHostSetups, repos: appStoreSnapshot.repos, sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, @@ -130,7 +135,8 @@ useAppStoreExport.getState = vi.fn(() => ({ activeTabType: appStoreSnapshot.activeTabType, activeWorktreeId: appStoreSnapshot.activeWorktreeId, gitStatusByWorktree: {}, - projects: appStoreSnapshot.projects, + endpointProjects: appStoreSnapshot.endpointProjects, + endpointProjectHostSetups: appStoreSnapshot.endpointProjectHostSetups, repos: appStoreSnapshot.repos, sshConnectionStates: appStoreSnapshot.sshConnectionStates, worktreesByRepo: appStoreSnapshot.worktreesByRepo, @@ -159,7 +165,8 @@ export function resetAppStoreSnapshot(): void { appStoreSnapshot.activeTabType = null appStoreSnapshot.activeRuntimeEnvironmentId = null appStoreSnapshot.activeWorktreeId = null - appStoreSnapshot.projects = [] + appStoreSnapshot.endpointProjects = [] + appStoreSnapshot.endpointProjectHostSetups = [] appStoreSnapshot.repos = [] appStoreSnapshot.sshConnectionStates = new Map() appStoreSnapshot.worktreesByRepo = {} diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts index 4cc17f03ad75..a9007486f7dc 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts @@ -15,7 +15,7 @@ export type TabBarProbeState = { activeGroupIdByWorktree: Record activeRepoId: string | null activeWorktreeId: string | null - projects: unknown[] + endpointProjects: unknown[] repos: unknown[] worktreesByRepo: Record sshConnectionStates: Map @@ -47,7 +47,7 @@ export async function createTabBarProbeStore(): Promise { activeGroupIdByWorktree: {}, activeRepoId: null, activeWorktreeId: null, - projects: [], + endpointProjects: [], repos: [], worktreesByRepo: {}, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts index 096853d867bf..584d3a29601b 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useShallow } from 'zustand/react/shallow' import type { GitFileStatus } from '../../../../shared/git-status-types' @@ -35,7 +39,7 @@ type AppStoreState = ReturnType type GitStatusEntries = AppStoreState['gitStatusByWorktree'][string] const EMPTY_GIT_STATUS_ENTRIES: GitStatusEntries = [] const EMPTY_UNIFIED_TABS: readonly Tab[] = [] -const EMPTY_PROJECTS: AppStoreState['projects'] = [] +const EMPTY_PROJECTS: AppStoreState['endpointProjects'] = [] const EMPTY_REPOS: AppStoreState['repos'] = [] const EMPTY_WORKTREES_BY_REPO: AppStoreState['worktreesByRepo'] = {} @@ -172,7 +176,10 @@ export function useTabBarRuntimeModel({ // `worktreesByRepo` churns on every worktree write; ungated, each write re-renders every tab strip. const needsLocalProjectRuntime = showWindowsShellMenu && !activeRuntimeEnvironmentId?.trim() && !worktreeConnectionId - const projects = useAppStore((s) => (needsLocalProjectRuntime ? s.projects : EMPTY_PROJECTS)) + const projectHostSetups = useAppStore(selectCanonicalProjectHostSetups) + const projects = useAppStore((s) => + needsLocalProjectRuntime ? selectCanonicalProjects(s) : EMPTY_PROJECTS + ) const repos = useAppStore((s) => (needsLocalProjectRuntime ? s.repos : EMPTY_REPOS)) const worktreesByRepo = useAppStore((s) => needsLocalProjectRuntime ? selectWorktreesByRepo(s) : EMPTY_WORKTREES_BY_REPO @@ -182,7 +189,14 @@ export function useTabBarRuntimeModel({ return undefined } return getLocalProjectExecutionRuntimeContext( - { activeRepoId, activeWorktreeId, projects, repos, settings, worktreesByRepo }, + { + activeRepoId, + activeWorktreeId, + projectHostSetupProjection: { projects, setups: projectHostSetups }, + repos, + settings, + worktreesByRepo + }, worktreeId, 'win32', { @@ -199,6 +213,7 @@ export function useTabBarRuntimeModel({ activeWorktreeId, needsLocalProjectRuntime, projects, + projectHostSetups, repos, settings, windowsTerminalCapabilities.isLoading, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index eda161456bac..0e1d854d8232 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -55,7 +55,7 @@ export type StoreState = { displayName?: string executionHostId?: string | null }[] - projects: { + endpointProjects: { id: string localWindowsRuntimePreference?: | { kind: 'inherit-global' } diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index ceab7e0c2aff..ca9ec5b0e6bd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -19,7 +19,7 @@ const mocks = vi.hoisted(() => { activeRepoId: 'repo1', activeWorktreeId: 'wt-1', settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, - projects: [ + endpointProjects: [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } @@ -107,7 +107,7 @@ describe('handleTerminalFileDrop', () => { } }) mocks.storeState.settings = { activeRuntimeEnvironmentId: 'env-1' } - mocks.storeState.projects = [ + mocks.storeState.endpointProjects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' } @@ -374,7 +374,7 @@ describe('handleTerminalFileDrop', () => { it('pastes Linux-readable paths for local Windows-path projects forced to WSL', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: 'focused-runtime' } - mocks.storeState.projects = [ + mocks.storeState.endpointProjects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } @@ -432,7 +432,7 @@ describe('handleTerminalFileDrop', () => { it('pastes a spaced image from a Windows-path project forced to WSL with POSIX escaping', async () => { mocks.storeState.settings = { activeRuntimeEnvironmentId: null } - mocks.storeState.projects = [ + mocks.storeState.endpointProjects = [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } ] mocks.storeState.repos = [ diff --git a/src/renderer/src/components/use-task-page-composer-actions.ts b/src/renderer/src/components/use-task-page-composer-actions.ts index 16a732508a54..3f6987e8cb45 100644 --- a/src/renderer/src/components/use-task-page-composer-actions.ts +++ b/src/renderer/src/components/use-task-page-composer-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import type { TaskPageJiraListEffectsModel } from './use-task-page-jira-list-effects' import { useCallback } from 'react' import type { LinearIssue } from '../../../shared/linear/issue-types' @@ -220,7 +221,10 @@ export function useTaskPageComposerActions(model: TaskPageJiraListEffectsModel) taskSourceContext, initialRepoId: mappedRepo?.id, initialWorkspaceTarget: mappedRepo - ? getRepoWorkspaceCreationTargetHint(mappedRepo, useAppStore.getState().projectHostSetups) + ? getRepoWorkspaceCreationTargetHint( + mappedRepo, + selectCanonicalProjectHostSetups(useAppStore.getState()) + ) : undefined, prefilledName: getJiraIssueWorkspaceSeed(issue), telemetrySource: 'sidebar' diff --git a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts index 39358c99491e..4a2764ce841a 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-store-state.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-store-state.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { useShallow } from 'zustand/react/shallow' @@ -46,8 +50,8 @@ export function useWorktreeJumpPaletteStoreState({ ) const repos = useAppStore((state) => state.repos) const projectGroups = useAppStore((state) => state.projectGroups) - const projects = useAppStore((state) => state.projects) - const projectHostSetups = useAppStore((state) => state.projectHostSetups) + const projects = useAppStore((state) => selectCanonicalProjects(state)) + const projectHostSetups = useAppStore((state) => selectCanonicalProjectHostSetups(state)) const detectedWorktreesByRepo = useAppStore((state) => selectDetectedWorktreesByRepo(state)) const pendingWorktreeCreations = useAppStore((state) => state.pendingWorktreeCreations) const pluginCommands = usePluginCommands() diff --git a/src/renderer/src/components/use-worktree-jump-palette-task-url.ts b/src/renderer/src/components/use-worktree-jump-palette-task-url.ts index 7d4d735bb36b..500c530dc113 100644 --- a/src/renderer/src/components/use-worktree-jump-palette-task-url.ts +++ b/src/renderer/src/components/use-worktree-jump-palette-task-url.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useAppStore } from '@/store' import { @@ -39,8 +43,8 @@ function getComposerDefaultWorkspaceTarget(state: ReturnType { useAppStore.setState({ settings: null, repos, - projects: [...projection.projects], - projectHostSetups: [...projection.setups], + endpointProjects: [...projection.projects], + endpointProjectHostSetups: [...projection.setups], projectGroups: [], newWorkspaceDraft: null, worktreesByRepo: {}, @@ -158,6 +158,7 @@ function renderUnavailableMappedComposer( includeSetupHint?: boolean emptyRepoOverride?: boolean missingSshSetup?: boolean + missingSshRepo?: boolean canonicalSameHostSetup?: boolean } = {} ) { @@ -179,9 +180,9 @@ function renderUnavailableMappedComposer( } useAppStore.setState({ settings: null, - repos, - projects: [...projection.projects], - projectHostSetups: projection.setups.filter( + repos: options.missingSshRepo ? repos.filter((repo) => repo !== mappedRepo) : repos, + endpointProjects: [...projection.projects], + endpointProjectHostSetups: projection.setups.filter( (setup) => !options.missingSshSetup || setup.hostId !== 'ssh:builder' ), projectGroups: [], @@ -245,7 +246,7 @@ describe('unavailable mapped Jira destinations', () => { }) it('recovers the intended host when it becomes configured', () => { - const { result, mappedRepo } = renderUnavailableMappedComposer() + const { result, mappedRepo } = renderUnavailableMappedComposer({ missingSshSetup: true }) act(() => useAppStore.setState({ sshTargetLabels: new Map([['builder', 'Builder']]) })) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toMatchObject({ @@ -275,7 +276,10 @@ describe('unavailable mapped Jira destinations', () => { act(() => useAppStore.setState((state) => ({ - projectHostSetups: state.projectHostSetups.filter((setup) => setup.hostId !== 'ssh:builder') + endpointProjectHostSetups: state.endpointProjectHostSetups.filter( + (setup) => setup.hostId !== 'ssh:builder' + ), + repos: state.repos.filter((repo) => repo !== mappedRepo) })) ) @@ -288,7 +292,8 @@ describe('unavailable mapped Jira destinations', () => { const { result } = renderUnavailableMappedComposer({ repeatedId: true, configured: true, - missingSshSetup: true + missingSshSetup: true, + missingSshRepo: true }) expect(result.current.runtimeTargetSelection.selectedWorkspaceTarget).toEqual({ diff --git a/src/renderer/src/hooks/composer-state/composer-target-store.ts b/src/renderer/src/hooks/composer-state/composer-target-store.ts index 4f16dac76267..60c28e2d6c50 100644 --- a/src/renderer/src/hooks/composer-state/composer-target-store.ts +++ b/src/renderer/src/hooks/composer-state/composer-target-store.ts @@ -1,3 +1,7 @@ +import { + selectCanonicalProjects, + selectCanonicalProjectHostSetups +} from '@/store/project-host-setup-selector' import { useMemo } from 'react' import { useAppStore } from '@/store' import { useShallow } from 'zustand/react/shallow' @@ -93,11 +97,11 @@ export function useComposerTargetStore(options: ComposerStateInput, decisions: C const repos = useAppStore((s) => s.repos) - const projects = useAppStore((s) => s.projects) + const projects = useAppStore((s) => selectCanonicalProjects(s)) const projectGroups = useAppStore((s) => s.projectGroups) - const projectHostSetups = useAppStore((s) => s.projectHostSetups) + const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s)) const activeRepoId = useAppStore((s) => s.activeRepoId) diff --git a/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx b/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx index b6617b467fcd..5f5f1d9b6d53 100644 --- a/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx +++ b/src/renderer/src/hooks/composer-state/jira-mapped-owner-flow.integration.test.tsx @@ -83,8 +83,8 @@ function seedRepos(repos: readonly Repo[] = [localRepo, executionRepo], configur useAppStore.setState({ settings: null, repos, - projects: [...projection.projects], - projectHostSetups: [...projection.setups], + endpointProjects: [...projection.projects], + endpointProjectHostSetups: [...projection.setups], projectGroups: [], newWorkspaceDraft: null, worktreesByRepo: {}, diff --git a/src/renderer/src/hooks/composer-state/target-change-actions.ts b/src/renderer/src/hooks/composer-state/target-change-actions.ts index d1c06c7709c6..3659dc488d4b 100644 --- a/src/renderer/src/hooks/composer-state/target-change-actions.ts +++ b/src/renderer/src/hooks/composer-state/target-change-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector' import type { ComposerModel } from './composer-model' type TargetChangeActionsInput = Pick< @@ -252,7 +253,7 @@ export function useTargetChangeActions(input: TargetChangeActionsInput) { ? option : buildProjectHostSetupOptions({ projectId: selectedRepoProjectId, - projectHostSetups: useAppStore.getState().projectHostSetups, + projectHostSetups: selectCanonicalProjectHostSetups(useAppStore.getState()), eligibleRepos: getComposerEligibleRepos(useAppStore.getState().repos), hosts: hostOptions }).find( diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx index d7799b843240..15f8e3bdfc67 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.test.tsx @@ -40,7 +40,7 @@ describe('useActiveProjectSkillRuntime', () => { activeRepoId: null, activeWorktreeId: null, activeWorkspaceExecutionHostId: null, - projects: [], + endpointProjects: [], repos: [], worktreesByRepo: {}, folderWorkspaces: [], diff --git a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts index 58ebd634324c..705fe20a6964 100644 --- a/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts +++ b/src/renderer/src/hooks/useActiveProjectSkillRuntime.ts @@ -1,3 +1,4 @@ +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { useMemo, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import type { ProjectExecutionRuntimeResolution } from '../../../shared/project-execution-runtime' @@ -82,7 +83,7 @@ export function useActiveProjectSkillRuntime(): ActiveProjectSkillRuntime { useShallow((state) => ({ activeRepoId: state.activeRepoId, activeWorktreeId: state.activeWorktreeId, - projects: state.projects, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), repos: state.repos, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index 0c4b738953e9..5150c4375044 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -1,9 +1,11 @@ // @vitest-environment happy-dom -import { act, renderHook } from '@testing-library/react' +import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' import type { Repo } from '../../../shared/repo-types' import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { normalizeTaskSourceContext } from '../../../shared/task-source-context' @@ -84,8 +86,8 @@ describe('useComposerState integrated lifecycle', () => { const projection = projectHostSetupProjectionFromRepos([localRepo, runtimeRepo]) useAppStore.setState({ repos: [localRepo, runtimeRepo], - projects: [...projection.projects], - projectHostSetups: projection.setups.map((setup) => ({ + endpointProjects: [...projection.projects], + endpointProjectHostSetups: projection.setups.map((setup) => ({ ...setup, id: `${setup.hostId}:setup` })), @@ -123,12 +125,75 @@ describe('useComposerState integrated lifecycle', () => { composer.unmount() }) + it('creates in concrete B after a normal composer reads old shared endpoint metadata', async () => { + const original = useAppStore.getState() + useAppStore.setState(useAppStore.getInitialState(), true) + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/${id}`, + displayName: id, + badgeColor: '', + addedAt: index + 1, + upstream: { owner: 'acme', repo: 'app' } + })) + const projection = projectHostSetupProjectionFromRepos(repos) + const projectId = projection.setups[0].projectId + const endpointProjects = [{ ...projection.projects[0], sourceRepoIds: ['a', 'b'] }] + const endpointProjectHostSetups = projection.setups.map((setup) => ({ ...setup, projectId })) + const response = Promise.withResolvers() + const create = vi.fn().mockReturnValue(response.promise) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ...window.api, + gh: { repoSlug: vi.fn().mockResolvedValue(null) }, + worktrees: { ...window.api.worktrees, create } + } + }) + useAppStore.setState({ repos, endpointProjects, endpointProjectHostSetups }) + const composer = renderHook(() => + useComposerState({ initialRepoId: 'b', initialName: 'topic', persistDraft: false }) + ) + try { + expect(composer.result.current.cardProps.projectOptions.map((project) => project.id)).toEqual( + expect.arrayContaining([projectId, 'repo:b']) + ) + expect(composer.result.current.cardProps.selectedProjectId).toBe('repo:b') + act(() => composer.result.current.cardProps.onProjectChange(projectId)) + act(() => composer.result.current.cardProps.onProjectChange('repo:b')) + expect(composer.result.current.cardProps.selectedRepoPath).toBe('/b') + expect(composer.result.current.cardProps.selectedRepoExecutionHostId).toBe('local') + expect(useAppStore.getState().endpointProjects).toBe(endpointProjects) + expect(useAppStore.getState().endpointProjectHostSetups).toBe(endpointProjectHostSetups) + await act(async () => composer.result.current.submitQuick(null)) + await waitFor(() => expect(create).toHaveBeenCalledOnce()) + expect(create).toHaveBeenCalledWith(expect.objectContaining({ repoId: 'b' })) + const pending = Object.values(useAppStore.getState().pendingWorktreeCreations)[0] + expect(pending.request.workspaceRunContext).toMatchObject({ + repoId: 'b', + hostId: 'local', + path: '/b', + projectId: 'repo:b' + }) + act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId)) + await act(async () => + response.resolve({ + worktree: makeWorktree({ id: 'b::topic', repoId: 'b', path: '/b-topic' }) + }) + ) + await waitFor(() => expect(useAppStore.getState().worktreesByRepo.b[0].id).toBe('b::topic')) + } finally { + composer.unmount() + useAppStore.setState(original, true) + } + }) + it('composes two live composers and exposes the parent-worktree control state', () => { useAppStore.setState({ repos: [], - projects: [], + endpointProjects: [], projectGroups: [], - projectHostSetups: [], + endpointProjectHostSetups: [], newWorkspaceDraft: null, worktreesByRepo: {}, sparsePresetsByRepo: {} diff --git a/src/renderer/src/i18n/locales/pt.json b/src/renderer/src/i18n/locales/pt.json index e20b834d76c9..d1496530bbbb 100644 --- a/src/renderer/src/i18n/locales/pt.json +++ b/src/renderer/src/i18n/locales/pt.json @@ -4218,9 +4218,7 @@ "show": "Mostrar", "hide": "Ocultar", "externalWorktreesGlobal": "Padrão global: {{value0}}", - "useGlobal": "Usar global", - "removeProjectCheckout": "Remova este checkout do Orca. Outros checkouts deste projeto permanecem.", - "removeProjectKeepCheckouts": "Remova este projeto do Orca. Os checkouts que têm suas próprias configurações permanecem." + "useGlobal": "Usar global" }, "Settings": { "3bf149e873": "Configurações do Projeto > {{value0}}", diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index 64d2d942643b..732e21be1396 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -17,7 +17,7 @@ export type AgentBackgroundSessionTestState = { activeRuntimeEnvironmentId: string | null terminalMainSideEffectAuthority: boolean | undefined } - projects: { + endpointProjects: { id: string localWindowsRuntimePreference: | { kind: 'inherit-global' } @@ -78,7 +78,7 @@ export function createAgentBackgroundSessionTestState(mocks: { activeRuntimeEnvironmentId: null as string | null, terminalMainSideEffectAuthority: undefined as boolean | undefined }, - projects: [ + endpointProjects: [ { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' as const } @@ -138,7 +138,9 @@ export function resetAgentBackgroundSessionTestState(state: AgentBackgroundSessi activeRuntimeEnvironmentId: null, terminalMainSideEffectAuthority: undefined } - state.projects = [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] + state.endpointProjects = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } + ] state.repos = [{ id: 'repo-1', connectionId: null, path: '/repo' }] state.folderWorkspaces = [] state.projectGroups = [] diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts index f30739ae35cc..3f129d045bca 100644 --- a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts +++ b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts @@ -45,7 +45,21 @@ function stateFor(host: ExecutionHostId, distro?: string): State { executionHostId: host } ], - projects: [ + endpointProjectHostSetups: [ + { + id: 'repo', + projectId: 'repo', + repoId: 'repo', + hostId: host, + path: worktree.path, + displayName: 'project', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'repo', displayName: 'project', @@ -139,7 +153,7 @@ describe('Antigravity transcript reference ownership', () => { const state = stateFor('local') state.activeRepoId = null state.repos = [] - state.projects = [] + state.endpointProjects = [] state.activeWorktreeId = 'folder:folder-1' state.folderWorkspaces = [ makeFolderWorkspace({ folderPath: '//wsl.localhost/Debian/home/example/project' }) @@ -308,7 +322,7 @@ describe('Antigravity transcript reference ownership', () => { it('refuses local references while the project runtime requires repair', () => { const state = stateFor('local') - state.projects[0].localWindowsRuntimePreference = { kind: 'inherit-global' } + state.endpointProjects[0].localWindowsRuntimePreference = { kind: 'inherit-global' } state.settings = createGlobalSettingsFixture({ localWindowsRuntimeDefault: { kind: 'wsl', distro: null } }) diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 139b4e3fd97c..0dc21b3c80e8 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -15,7 +15,7 @@ type DropRepinState = Pick< | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' + | 'endpointProjects' | 'repos' | 'settings' | 'worktreesByRepo' @@ -28,7 +28,7 @@ function makeState(): DropRepinState { folderWorkspaces: [], projectGroups: [], repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + endpointProjects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], settings: { agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } diff --git a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts index b0cdda90fd36..a8cac5033c8b 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts @@ -15,7 +15,7 @@ type ResumableAgentState = Pick< | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' + | 'endpointProjects' | 'repos' | 'settings' | 'worktreesByRepo' @@ -28,7 +28,7 @@ function makeState(): ResumableAgentState { folderWorkspaces: [], projectGroups: [], repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + endpointProjects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], settings: { agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 2a3531bda91b..6ff35771a968 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -23,7 +23,8 @@ type AiVaultResumeCommandState = Pick< | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' + | 'endpointProjects' + | 'endpointProjectHostSetups' | 'repos' | 'settings' | 'worktreesByRepo' @@ -40,7 +41,21 @@ function makeState(args: { folderWorkspaces: [], projectGroups: [], repos: [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], - projects: [ + endpointProjectHostSetups: [ + { + id: 'repo-1', + projectId: 'repo-1', + repoId: 'repo-1', + hostId: 'local', + path: 'C:/Users/alice/repo', + displayName: 'repo', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'repo-1', sourceRepoIds: ['repo-1'], diff --git a/src/renderer/src/lib/ai-vault-resume-command.ts b/src/renderer/src/lib/ai-vault-resume-command.ts index 65c57f7d0203..782c069a958d 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { assertAntigravityReferenceTarget, buildAntigravityReferenceStartup @@ -33,11 +34,11 @@ export type AiVaultResumeWorktreeArgs = { | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - > + > & + ProjectCatalogReadState worktreeId?: string | null session: AiVaultResumeCommandSession commandOverride?: string | null diff --git a/src/renderer/src/lib/ai-vault-resume-platform.ts b/src/renderer/src/lib/ai-vault-resume-platform.ts index 58c5917f2ccf..676918226e8e 100644 --- a/src/renderer/src/lib/ai-vault-resume-platform.ts +++ b/src/renderer/src/lib/ai-vault-resume-platform.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { CLIENT_PLATFORM } from '@/lib/new-workspace' @@ -16,11 +17,11 @@ export function getAiVaultResumePlatform( | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - >, + > & + ProjectCatalogReadState, worktreeId?: string | null ): NodeJS.Platform { const targetWorktreeId = worktreeId ?? state.activeWorktreeId diff --git a/src/renderer/src/lib/ai-vault-resume-shell.test.ts b/src/renderer/src/lib/ai-vault-resume-shell.test.ts index f1538df180b7..8268127287be 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -16,7 +16,7 @@ function makeState(worktreeHostId?: string): ResumeShellState { folderWorkspaces: [], projectGroups: [], repos: [{ id: 'repo-1', path: '/home/alice/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + endpointProjects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], settings: { agentDefaultArgs: { codex: '' }, agentDefaultEnv: { codex: {} } diff --git a/src/renderer/src/lib/ai-vault-resume-shell.ts b/src/renderer/src/lib/ai-vault-resume-shell.ts index 657eaa9ebdc0..a2309533bbbd 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { getLocalProjectExecutionRuntimeContext } from '@/lib/local-preflight-context' import { getFolderWorkspaceCandidateRepos } from './folder-workspace-connection' @@ -19,11 +23,11 @@ type AiVaultResumeShellState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' -> +> & + ProjectCatalogReadState export function resolveAiVaultResumeStartupShell(args: { state: AiVaultResumeShellState @@ -82,12 +86,8 @@ export function getAiVaultResumeWorkspacePath( export function getAiVaultResumeWorkspaceWslDistro( state: Pick & - Partial< - Pick< - AppState, - 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'settings' | 'projectGroups' - > - >, + Partial> & + ProjectCatalogReadState, workspaceId: string | null | undefined ): string | null | undefined { const workspacePath = getAiVaultResumeWorkspacePath(state, workspaceId) @@ -95,7 +95,7 @@ export function getAiVaultResumeWorkspaceWslDistro( const runtimeState = { activeRepoId: state.activeRepoId ?? null, activeWorktreeId: state.activeWorktreeId ?? null, - projects: state.projects ?? [], + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), settings: state.settings ?? null, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups ?? [], diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts index 14f21a2c8571..02e994746278 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts @@ -12,7 +12,7 @@ type ForkState = Pick< | 'activeWorktreeId' | 'folderWorkspaces' | 'projectGroups' - | 'projects' + | 'endpointProjects' | 'repos' | 'settings' | 'worktreesByRepo' diff --git a/src/renderer/src/lib/codex-pane-selection-lane.test.ts b/src/renderer/src/lib/codex-pane-selection-lane.test.ts index ef4df3333512..f32282b78698 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.test.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.test.ts @@ -15,7 +15,8 @@ type LaneState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'projects' + | 'endpointProjects' + | 'endpointProjectHostSetups' | 'repos' | 'runtimeEnvironments' | 'settings' @@ -57,7 +58,21 @@ function laneState(args?: { terminalWindowsWslDistro: args?.terminalWindowsWslDistro ?? null }, repos: [{ id: 'repo1', path: 'C:\\code\\app' }], - projects: args?.projectWslDistro + endpointProjectHostSetups: [ + { + id: 'repo1', + projectId: 'proj1', + repoId: 'repo1', + hostId: 'local', + path: 'C:/code/app', + displayName: 'app', + setupMethod: 'legacy-repo', + setupState: 'ready', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: args?.projectWslDistro ? [ { id: 'proj1', diff --git a/src/renderer/src/lib/codex-pane-selection-lane.ts b/src/renderer/src/lib/codex-pane-selection-lane.ts index 48b1c1195cf2..4e60f81a1890 100644 --- a/src/renderer/src/lib/codex-pane-selection-lane.ts +++ b/src/renderer/src/lib/codex-pane-selection-lane.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import type { AppState } from '@/store' import type { RuntimeClientTarget } from '../../../shared/runtime-client-target' import { parseRemoteRuntimePtyId } from '../../../shared/remote-runtime-pty-id' @@ -32,12 +33,12 @@ type CodexPaneLaneState = Pick< | 'activeRepoId' | 'activeWorktreeId' | 'folderWorkspaces' - | 'projects' | 'repos' | 'runtimeEnvironments' | 'settings' | 'worktreesByRepo' -> +> & + ProjectCatalogReadState /** * Lane keys for panes whose Codex credentials come from another machine. diff --git a/src/renderer/src/lib/execution-host-facts.test.ts b/src/renderer/src/lib/execution-host-facts.test.ts index 69e703e18a5a..47f21981ab95 100644 --- a/src/renderer/src/lib/execution-host-facts.test.ts +++ b/src/renderer/src/lib/execution-host-facts.test.ts @@ -36,7 +36,7 @@ function state(): State { detectedWorktreesByRepo: {}, folderWorkspaces: [], projectGroups: [], - projects: [], + endpointProjects: [], activeRepoId: null, activeWorktreeId: null, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads no other setting. diff --git a/src/renderer/src/lib/execution-host-facts.ts b/src/renderer/src/lib/execution-host-facts.ts index 2096a7d1c57d..ece6b8f0d8e8 100644 --- a/src/renderer/src/lib/execution-host-facts.ts +++ b/src/renderer/src/lib/execution-host-facts.ts @@ -1,3 +1,4 @@ +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' import { getRepoExecutionHostId, parseExecutionHostId, @@ -38,11 +39,11 @@ type WorktreeHostFactsState = HostFactsState & | 'detectedWorktreesByRepo' | 'folderWorkspaces' | 'projectGroups' - | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' - > + > & + ProjectCatalogReadState const SSH_PLATFORM_WITHHELD = 'The SSH host has not reported its operating system yet. Connect to it and try again.' diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 93d9c61a44ed..0b58dd9f535b 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -8,6 +8,8 @@ import { resolveLocalhostHttpLinkDisplayUrl } from './http-link-routing' +import type { Repo } from '../../../shared/repo-types' +import type { Project } from '../../../shared/project-types' import type { ExecutionHostId } from '../../../shared/execution-host' const toastErrorMock = vi.hoisted(() => vi.fn()) @@ -40,8 +42,8 @@ const storeState = { setActiveWorktree: setActiveWorktreeMock, ...noActiveWorkspace(), createBrowserTab: createBrowserTabMock, - repos: [] as { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[], - projects: [] as { id: string; displayName: string; repoIcon?: null; badgeColor?: string }[], + repos: [] as Repo[], + endpointProjects: [] as Project[], worktreesByRepo: {} as Record< string, { id: string; projectId?: string; repoId?: string; displayName?: string }[] @@ -402,6 +404,8 @@ describe('openHttpLink', () => { storeState.repos = [ { id: 'repo-1', + path: '/repo', + addedAt: 0, displayName: 'snapstudio', repoIcon: null, badgeColor: '#f97316' @@ -464,6 +468,8 @@ describe('openHttpLink', () => { storeState.repos = [ { id: 'repo-1', + path: '/repo', + addedAt: 0, displayName: 'snapstudio', repoIcon: null, badgeColor: '#f97316' @@ -556,7 +562,9 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true, activeRuntimeEnvironmentId: null } - storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] + storeState.repos = [ + { id: 'repo-1', path: '/repo', addedAt: 0, badgeColor: '', displayName: 'snapstudio' } + ] storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } storeState.workspacePortScan = { result: { @@ -595,7 +603,9 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true, activeRuntimeEnvironmentId: 'env-other' } - storeState.repos = [{ id: 'repo-1', displayName: 'snapstudio' }] + storeState.repos = [ + { id: 'repo-1', path: '/repo', addedAt: 0, badgeColor: '', displayName: 'snapstudio' } + ] storeState.worktreesByRepo = { 'repo-1': [{ id: 'wt-main', projectId: 'repo-1' }] } storeState.workspacePortScansByKey = { 'local:all': { diff --git a/src/renderer/src/lib/http-link-routing.ts b/src/renderer/src/lib/http-link-routing.ts index 2ac4170b865e..d621a1f56c2d 100644 --- a/src/renderer/src/lib/http-link-routing.ts +++ b/src/renderer/src/lib/http-link-routing.ts @@ -1,8 +1,11 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import { getProjectHostSetupLookup, type WorkspaceProjectOwner } from '../../../shared/project-host-setup-lookup' -import type { ProjectHostSetup } from '../../../shared/project-types' import { translate } from '@/i18n/i18n' import { isFloatingWorkspaceId } from '../../../shared/floating-workspace-worktree' import { @@ -34,7 +37,7 @@ export type HttpLinkSourceOwner = | { kind: 'ssh'; connectionId: string } | { kind: 'unknown' } -type StoreAccessor = () => { +type StoreAccessor = () => ProjectCatalogReadState & { settings?: Partial< Pick< GlobalSettings, @@ -48,9 +51,6 @@ type StoreAccessor = () => { activeWorktreeId?: string | null activeWorkspaceExecutionHostId?: ExecutionHostId | null createBrowserTab: (worktreeId: string, url: string, opts: { activate: boolean }) => unknown - repos?: readonly LocalhostLinkRepo[] - projects?: readonly LocalhostLinkProject[] - projectHostSetups?: readonly ProjectHostSetup[] worktreesByRepo?: Record allWorktrees?: () => LocalhostLinkWorktree[] workspacePortScan?: { result: WorkspacePortScanResult } | null @@ -67,13 +67,6 @@ type WorkspaceHttpLinkBrowserRequest = { type WorkspaceHttpLinkBrowserOpener = (request: WorkspaceHttpLinkBrowserRequest) => Promise -type LocalhostLinkRepo = { - id: string - displayName: string -} - -type LocalhostLinkProject = LocalhostLinkRepo - type LocalhostLinkWorktree = { id: string } & Partial // Why: store access is injected via registerHttpLinkStoreAccessor rather than @@ -291,14 +284,17 @@ function localhostLabelRouteForTerminalLink( return null } const worktree = findWorktreeById(state, port.owner.worktreeId) - const projectId = getProjectHostSetupLookup(state.projectHostSetups).projectId({ + const projectId = getProjectHostSetupLookup( + getProjectHostSetupProjectionFromState(state).setups + ).projectId({ ...worktree, repoId: port.owner.repoId }) - const project = - projectId && state.projects - ? (state.projects.find((entry) => entry.id === projectId) ?? null) - : null + const project = projectId + ? (getProjectHostSetupProjectionFromState(state).projects.find( + (entry) => entry.id === projectId + ) ?? null) + : null const projectSource = project ?? repo return { targetUrl: parsed.toString(), diff --git a/src/renderer/src/lib/launch-agent-background-session.test.ts b/src/renderer/src/lib/launch-agent-background-session.test.ts index 2a21fa3ae684..626cf8e15916 100644 --- a/src/renderer/src/lib/launch-agent-background-session.test.ts +++ b/src/renderer/src/lib/launch-agent-background-session.test.ts @@ -321,7 +321,7 @@ describe('launchAgentBackgroundSession', () => { }) it('uses WSL launch quoting for Windows-path projects forced to WSL', async () => { - state.projects = [ + state.endpointProjects = [ { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 8a4df24577a0..3e134fe52c58 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -1,3 +1,4 @@ +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import { describe, expect, it } from 'vitest' import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shared/constants' import type { Repo } from '../../../shared/repo-types' @@ -33,7 +34,10 @@ function makeState(args: { ...args.repo } ] + const catalog = projectHostSetupProjectionFromRepos(repos) return { + endpointProjects: catalog.projects.map((project) => ({ ...project, id: repoId })), + endpointProjectHostSetups: catalog.setups.map((setup) => ({ ...setup, projectId: repoId })), activeRepoId: repoId, activeWorktreeId: args.worktreePath === undefined ? null : worktreeId, repos, @@ -425,7 +429,7 @@ describe('local preflight context', () => { localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + endpointProjects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] } as unknown as AppState const context = getLocalAgentPreflightContext(state, 'win32') @@ -470,12 +474,16 @@ describe('local preflight context', () => { } ] }, - projects: [ + endpointProjects: [ { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }, { id: 'repo-2', localWindowsRuntimePreference: { kind: 'windows-host' } } ] } as unknown as AppState + state.endpointProjectHostSetups = projectHostSetupProjectionFromRepos(state.repos).setups.map( + (setup) => ({ ...setup, projectId: setup.repoId }) + ) + const context = getLocalAgentPreflightContext(state, 'win32', {}, 'repo-2::worktree-1') expect(localPreflightContextKey(context)).toBe('repo-2:windows-host') @@ -489,7 +497,7 @@ describe('local preflight context', () => { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + endpointProjects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] } as unknown as AppState expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({ @@ -507,7 +515,9 @@ describe('local preflight context', () => { it('does not assign the active project runtime to the floating workspace', () => { const state = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] + endpointProjects: [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + ] } as unknown as AppState expect( @@ -543,7 +553,9 @@ describe('local preflight context', () => { worktrees: [{ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }] } }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] + endpointProjects: [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + ] } as unknown as AppState expect( @@ -575,7 +587,7 @@ describe('local preflight context', () => { it('resolves a local repo project WSL override before a worktree exists', () => { const state = { ...makeState({ repoPath: String.raw`C:\Users\alice\repo` }), - projects: [ + endpointProjects: [ { id: 'project-1', sourceRepoIds: ['repo-1'], @@ -675,7 +687,9 @@ describe('local preflight context', () => { settings: { localWindowsRuntimeDefault: { kind: 'windows-host' } }, - projects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }] + endpointProjects: [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + ] } as unknown as AppState expect( diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index 02c82ca75bba..e58b870b2c69 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -43,9 +47,10 @@ export { type LocalProjectRuntimeState = Pick< AppState, - 'activeRepoId' | 'activeWorktreeId' | 'projects' | 'repos' | 'settings' | 'worktreesByRepo' + 'activeRepoId' | 'activeWorktreeId' | 'repos' | 'settings' | 'worktreesByRepo' > & - Partial> + Partial> & + ProjectCatalogReadState // Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` // or `[]` fallback would miss the cache on every read. @@ -321,8 +326,9 @@ function getLocalRuntimeProject( projectId: string, repoId: string ) { + const { projects } = getProjectHostSetupProjectionFromState(state) return ( - state.projects?.find((entry) => entry.id === projectId) ?? - state.projects?.find((entry) => projectMatchesRepo(entry, repoId)) + projects.find((entry) => entry.id === projectId) ?? + projects.find((entry) => projectMatchesRepo(entry, repoId)) ) } diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index b2210b400429..7b8916a136e5 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import { getProjectHostSetupLookup } from '../../../shared/project-host-setup-lookup' import type { AppState } from '@/store/types' import { getIndexedWorktreeById } from '@/store/worktree-repo-index' @@ -70,15 +74,15 @@ export function getLocalProjectRuntimeWorkspace( } export function getLocalPreflightProjectId( - state: RuntimeWorkspaceState & - Pick & - Partial>, + state: RuntimeWorkspaceState & Pick & ProjectCatalogReadState, worktreeId?: string | null ): string { const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) return ( (activeWorktree - ? getProjectHostSetupLookup(state.projectHostSetups).projectId(activeWorktree) + ? getProjectHostSetupLookup(getProjectHostSetupProjectionFromState(state).setups).projectId( + activeWorktree + ) : undefined) ?? activeWorktree?.repoId ?? state.activeRepoId ?? diff --git a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts index c80b0179bb8b..dd353ac188a3 100644 --- a/src/renderer/src/lib/workspace-port-localhost-label-selector.ts +++ b/src/renderer/src/lib/workspace-port-localhost-label-selector.ts @@ -1,11 +1,13 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { LocalhostWorktreeLabelRoute } from '../../../shared/localhost-worktree-labels' import type { GlobalSettings } from '../../../shared/global-settings-types' import { getProjectHostSetupLookup, type WorkspaceProjectOwner } from '../../../shared/project-host-setup-lookup' -import type { Project, ProjectHostSetup } from '../../../shared/project-types' -import type { Repo } from '../../../shared/repo-types' import type { WorkspacePort } from '../../../shared/workspace-ports' import { useAppStore } from '@/store' import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-label' @@ -13,11 +15,8 @@ import { localhostWorktreeLabelRouteForPort } from './workspace-port-localhost-l // Why: the port → repo → worktree → project resolution feeding // localhostWorktreeLabelRouteForPort was duplicated across every ports surface; // this is the single source for both reactive and imperative call sites. -type LocalhostLabelLookupState = { +type LocalhostLabelLookupState = ProjectCatalogReadState & { settings?: Pick | null - repos?: readonly Repo[] - projects?: readonly Project[] - projectHostSetups?: readonly ProjectHostSetup[] getKnownWorktreeById?: (worktreeId: string) => Partial | null | undefined } @@ -30,12 +29,16 @@ export function resolveLocalhostLabelRouteForPort( } const repo = (state.repos ?? []).find((entry) => entry.id === port.owner.repoId) ?? null const worktree = state.getKnownWorktreeById?.(port.owner.worktreeId) ?? null - const projectId = getProjectHostSetupLookup(state.projectHostSetups).projectId({ + const projectId = getProjectHostSetupLookup( + getProjectHostSetupProjectionFromState(state).setups + ).projectId({ ...worktree, repoId: port.owner.repoId }) const project = projectId - ? ((state.projects ?? []).find((entry) => entry.id === projectId) ?? null) + ? (getProjectHostSetupProjectionFromState(state).projects.find( + (entry) => entry.id === projectId + ) ?? null) : null return localhostWorktreeLabelRouteForPort({ port, repo, project, settings: state.settings }) } @@ -54,14 +57,18 @@ export function useLocalhostLabelRouteForPort( ) const projectId = useAppStore((s) => portRepoId - ? getProjectHostSetupLookup(s.projectHostSetups).projectId({ + ? getProjectHostSetupLookup(getProjectHostSetupProjectionFromState(s).setups).projectId({ ...worktree, repoId: portRepoId }) : undefined ) const project = useAppStore((s) => - projectId ? (s.projects.find((entry) => entry.id === projectId) ?? null) : null + projectId + ? (getProjectHostSetupProjectionFromState(s).projects.find( + (entry) => entry.id === projectId + ) ?? null) + : null ) return localhostWorktreeLabelRouteForPort({ port, repo, project, settings }) } diff --git a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts index e375e04d9ead..b902d1278afa 100644 --- a/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts +++ b/src/renderer/src/runtime/sync-runtime-graph/project-section-catalog.ts @@ -17,7 +17,10 @@ let cachedCatalogSource: CatalogSource | null = null /** What the sidebar groups Project headers by: state.repos in its order and the selector's projection. */ export function getProjectSectionCatalogFromState( - state: Pick + state: Pick< + AppState, + 'repos' | 'endpointProjects' | 'endpointProjectHostSetups' | 'projectOrderBy' + > ): MobileProjectSectionCatalog | null { // Why: graph publication must not fail on a store slice that holds no repo catalog. if (!Array.isArray(state.repos)) { diff --git a/src/renderer/src/store/project-groups/project-group-mutations.ts b/src/renderer/src/store/project-groups/project-group-mutations.ts index fca06fdde006..ece5e0b5f730 100644 --- a/src/renderer/src/store/project-groups/project-group-mutations.ts +++ b/src/renderer/src/store/project-groups/project-group-mutations.ts @@ -297,7 +297,7 @@ export function createProjectGroupMutationActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: s, nextRepos, hostId: movedHostId }), diff --git a/src/renderer/src/store/project-host-setup-selector.test.ts b/src/renderer/src/store/project-host-setup-selector.test.ts index 16595d8e877c..596505cd7aeb 100644 --- a/src/renderer/src/store/project-host-setup-selector.test.ts +++ b/src/renderer/src/store/project-host-setup-selector.test.ts @@ -85,8 +85,8 @@ describe('project host setup selector', () => { const countedSetups = countCollectionReads(setups) const state = { repos: countedRepos.value, - projects: countedProjects.value, - projectHostSetups: countedSetups.value + endpointProjects: countedProjects.value, + endpointProjectHostSetups: countedSetups.value } const first = getProjectHostSetupProjectionFromState(state) @@ -123,15 +123,15 @@ describe('project host setup selector', () => { const setups = [makeSetup(repo.id, 'repo:repo-1')] const first = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups: setups + endpointProjects: projects, + endpointProjectHostSetups: setups }) const replacementSetups = [...setups] const next = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups: replacementSetups + endpointProjects: projects, + endpointProjectHostSetups: replacementSetups }) expect(next).not.toBe(first) @@ -144,13 +144,17 @@ describe('project host setup selector', () => { const repos = [repo] const projects = [makeProject('repo:repo-1', repo.id)] const setups = [makeSetup(repo.id, 'repo:repo-1')] - getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups: setups }) + getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: projects, + endpointProjectHostSetups: setups + }) const replacementRepos = [makeRepo('repo-2')] const next = getProjectHostSetupProjectionFromState({ repos: replacementRepos, - projects, - projectHostSetups: setups + endpointProjects: projects, + endpointProjectHostSetups: setups }) expect(next.projects.map((project) => project.id)).toContain('repo:repo-2') diff --git a/src/renderer/src/store/project-host-setup-selector.ts b/src/renderer/src/store/project-host-setup-selector.ts index ca51a218695b..70d1b6de9cd5 100644 --- a/src/renderer/src/store/project-host-setup-selector.ts +++ b/src/renderer/src/store/project-host-setup-selector.ts @@ -23,12 +23,20 @@ function getDerivedProjection(repos: AppState['repos']): ProjectHostSetupProject return projection } +export type ProjectCatalogReadState = Pick & + Partial> & { + projectHostSetupProjection?: ProjectHostSetupProjection + } + // Why: this is a zustand selector compared with Object.is, so the result must be // reference-stable per (repos, projects, setups) input or every render re-renders. export function getProjectHostSetupProjectionFromState( - state: Pick & Partial> + state: ProjectCatalogReadState ): ProjectHostSetupProjection { - const { repos, projects, projectHostSetups: setups } = state + if (state.projectHostSetupProjection) { + return state.projectHostSetupProjection + } + const { repos, endpointProjects: projects, endpointProjectHostSetups: setups } = state if (!projects || !setups) { return getDerivedProjection(repos) } @@ -55,3 +63,11 @@ export function getProjectHostSetupProjectionFromState( bySetups.set(setups, projection) return projection } + +export const selectCanonicalProjects = ( + state: Parameters[0] +) => getProjectHostSetupProjectionFromState(state).projects + +export const selectCanonicalProjectHostSetups = ( + state: Parameters[0] +) => getProjectHostSetupProjectionFromState(state).setups diff --git a/src/renderer/src/store/project-search-catalog.ts b/src/renderer/src/store/project-search-catalog.ts new file mode 100644 index 000000000000..d1ba44ca7757 --- /dev/null +++ b/src/renderer/src/store/project-search-catalog.ts @@ -0,0 +1,12 @@ +import type { AppState } from './types' +import { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' +import { selectWorktreesByRepo } from './workspace-record-selectors' +import { getIndexedAllWorktrees } from './worktree-repo-index' + +export function selectProjectSearchCatalog(state: AppState) { + return { + ...getProjectHostSetupProjectionFromState(state), + worktrees: getIndexedAllWorktrees(selectWorktreesByRepo(state)), + rawSetups: state.endpointProjectHostSetups + } +} diff --git a/src/renderer/src/store/projects/project-host-setup-actions.ts b/src/renderer/src/store/projects/project-host-setup-actions.ts index 6561d808bbfa..ddbe67deff7a 100644 --- a/src/renderer/src/store/projects/project-host-setup-actions.ts +++ b/src/renderer/src/store/projects/project-host-setup-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../project-host-setup-selector' import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' @@ -43,7 +44,8 @@ export function createProjectHostSetupActions( await assertProjectHostSetupMutationRuntimeCapabilities(target) const projectProviderIdentity = args.projectProviderIdentity ?? - get().projects.find((project) => project.id === args.projectId)?.providerIdentity + selectCanonicalProjects(get()).find((project) => project.id === args.projectId) + ?.providerIdentity // Why: the target host may not have a project record yet; carry the selected source-host identity across the boundary. const setupArgs = projectProviderIdentity ? { ...args, projectProviderIdentity } : args const result = @@ -75,16 +77,16 @@ export function createProjectHostSetupActions( repoMatchesHostIdentity(entry, repo.id, repoHostId) ? repo : entry ) : [...s.repos, repo] - const nextProjects = s.projects.some((entry) => entry.id === project.id) - ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...s.projects, project] - const nextSetups = s.projectHostSetups.some((entry) => entry.id === setup.id) - ? s.projectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) - : [...s.projectHostSetups, setup] + const nextProjects = s.endpointProjects.some((entry) => entry.id === project.id) + ? s.endpointProjects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.endpointProjects, project] + const nextSetups = s.endpointProjectHostSetups.some((entry) => entry.id === setup.id) + ? s.endpointProjectHostSetups.map((entry) => (entry.id === setup.id ? setup : entry)) + : [...s.endpointProjectHostSetups, setup] return { repos: nextRepos, - projects: nextProjects, - projectHostSetups: nextSetups + endpointProjects: nextProjects, + endpointProjectHostSetups: nextSetups } }) toast.success(translate('auto.store.slices.repos.8bb3ad7935', 'Project added'), { @@ -123,10 +125,10 @@ export function createProjectHostSetupActions( }) const project = normalizeProjectRow(result.project) set((s) => ({ - projects: s.projects.some((entry) => entry.id === project.id) - ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...s.projects, project], - projectHostSetups: upsertProjectHostSetup(s.projectHostSetups, setup) + endpointProjects: s.endpointProjects.some((entry) => entry.id === project.id) + ? s.endpointProjects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.endpointProjects, project], + endpointProjectHostSetups: upsertProjectHostSetup(s.endpointProjectHostSetups, setup) })) return { project, setup } } catch (err) { @@ -143,7 +145,7 @@ export function createProjectHostSetupActions( updateProjectHostSetup: async (args) => { try { const { target, hostArgs } = await prepareProjectHostSetupMutation( - get().projectHostSetups, + get().endpointProjectHostSetups, args ) const result = @@ -174,10 +176,10 @@ export function createProjectHostSetupActions( ) : [...s.repos, repo] : s.repos, - projects: s.projects.some((entry) => entry.id === project.id) - ? s.projects.map((entry) => (entry.id === project.id ? project : entry)) - : [...s.projects, project], - projectHostSetups: upsertProjectHostSetup(s.projectHostSetups, setup) + endpointProjects: s.endpointProjects.some((entry) => entry.id === project.id) + ? s.endpointProjects.map((entry) => (entry.id === project.id ? project : entry)) + : [...s.endpointProjects, project], + endpointProjectHostSetups: upsertProjectHostSetup(s.endpointProjectHostSetups, setup) })) return { ...result, project, repo, setup } } catch (err) { @@ -194,7 +196,7 @@ export function createProjectHostSetupActions( deleteProjectHostSetup: async (args) => { try { const { target, hostArgs } = await prepareProjectHostSetupMutation( - get().projectHostSetups, + get().endpointProjectHostSetups, args ) const result = @@ -217,7 +219,7 @@ export function createProjectHostSetupActions( row: result.setup }) set((s) => { - const projectHostSetups = s.projectHostSetups.filter( + const projectHostSetups = s.endpointProjectHostSetups.filter( (setup) => !isSameProjectHostSetup(setup, removedSetup) ) const repos = @@ -226,14 +228,14 @@ export function createProjectHostSetupActions( : s.repos const projects = repo && !projectHostSetups.some((setup) => setup.projectId === result.project.id) - ? s.projects.filter((project) => project.id !== result.project.id) - : s.projects + ? s.endpointProjects.filter((project) => project.id !== result.project.id) + : s.endpointProjects const survivingRepoIds = new Set(repos.map((r) => r.id)) const removedRepoIds = s.repos.filter((r) => !survivingRepoIds.has(r.id)).map((r) => r.id) return { repos, - projects, - projectHostSetups, + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups, ...omitSparsePresetsForRepos(s, removedRepoIds) } }) diff --git a/src/renderer/src/store/projects/project-update.ts b/src/renderer/src/store/projects/project-update.ts index cddacf664130..19ea419b67c7 100644 --- a/src/renderer/src/store/projects/project-update.ts +++ b/src/renderer/src/store/projects/project-update.ts @@ -22,7 +22,7 @@ export function createProjectUpdateActions( (setup) => setup.projectId === projectId && setup.hostId === hostId ) const endpointProjectId = - state.projectHostSetups.find( + state.endpointProjectHostSetups.find( (setup) => owner && setup.id === owner.id && setup.hostId === owner.hostId )?.projectId ?? projectId const target = getProjectSetupRuntimeTarget(hostId) @@ -44,7 +44,7 @@ export function createProjectUpdateActions( const normalizedProject = normalizeProjectRow(updatedProject) const runtimePreferenceChanged = 'localWindowsRuntimePreference' in updates set((state) => ({ - projects: state.projects.map((project) => + endpointProjects: state.endpointProjects.map((project) => project.id === endpointProjectId ? mergeUpdatedProjectCompatibilityProject(project, normalizedProject, updates) : project diff --git a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts index 90ee84f35abc..43e9af87f399 100644 --- a/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/all-host-repo-catalog-actions.ts @@ -68,9 +68,9 @@ export function createAllHostRepoCatalogActions( const finalizedRepos = applyManualRepoOrder(reconciliation.repos, s.manualRepoOrder) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.projects, + projects: s.endpointProjects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, + s.endpointProjectHostSetups, result.repos, finalizedRepos ) @@ -86,7 +86,8 @@ export function createAllHostRepoCatalogActions( repos: finalizedRepos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), - ...mergedProjectCompatibility, + endpointProjects: mergedProjectCompatibility.projects, + endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, ...(arrayElementsUnchanged(finalizedRepos, s.repos) ? {} : { folderWorkspacePathStatuses: {} }), diff --git a/src/renderer/src/store/repos/repo-add-actions.ts b/src/renderer/src/store/repos/repo-add-actions.ts index 1213b5242988..1497028e6a5f 100644 --- a/src/renderer/src/store/repos/repo-add-actions.ts +++ b/src/renderer/src/store/repos/repo-add-actions.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../project-host-setup-selector' import type { StateCreator } from 'zustand' import { toast } from 'sonner' import type { AppState } from '../types' @@ -108,7 +109,10 @@ export function createRepoAddActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: { + endpointProjects: s.endpointProjects, + endpointProjectHostSetups: s.endpointProjectHostSetups + }, nextRepos, hostId }), @@ -131,7 +135,11 @@ export function createRepoAddActions( // Why: the cross-profile advisory applies to SSH-added projects too; the presence lookup already keys on connection/host. await warnIfProjectKnownInAnotherProfile(repo, get().activeOrcaProfileId) // Why after the set(): the project row carrying the runtime override only exists once the repo is in state. - warnIfProjectCrossesWslFilesystemBoundary(repo, get().projects, get().settings) + warnIfProjectCrossesWslFilesystemBoundary( + repo, + selectCanonicalProjects(get()), + get().settings + ) } return repo } catch (err) { diff --git a/src/renderer/src/store/repos/repo-catalog-actions.ts b/src/renderer/src/store/repos/repo-catalog-actions.ts index 10b65166d717..b98ad26b2fd7 100644 --- a/src/renderer/src/store/repos/repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/repo-catalog-actions.ts @@ -54,11 +54,15 @@ export function createRepoCatalogActions( const reconciliation = reconcileReadoptedSshRepoRows(s.repos, pendingSshRepoReadoptions) const repos = reconciliation.repos const worktreeState = reconcileReadoptedSshWorktreeState(s, pendingSshRepoReadoptions) - const remainingSetups = filterSetupsForPrunedRepoRows(s.projectHostSetups, s.repos, repos) + const remainingSetups = filterSetupsForPrunedRepoRows( + s.endpointProjectHostSetups, + s.repos, + repos + ) const compatibility = mergeProjectHostSetupCompatibility( projectCompatibilityFromRepos(repos), { - projects: s.projects, + projects: s.endpointProjects, setups: remainingSetups } ) @@ -68,12 +72,12 @@ export function createRepoCatalogActions( // hands the store arrays straight back when nothing moved, so writing them // unconditionally still leaves identity-keyed selectors untouched. const projects = reconcileCatalogRows( - s.projects, + s.endpointProjects, compatibility.projects, (project) => project.id ) const projectHostSetups = reconcileCatalogRows( - s.projectHostSetups, + s.endpointProjectHostSetups, compatibility.projectHostSetups, getProjectHostSetupOwnerKey ) @@ -81,8 +85,8 @@ export function createRepoCatalogActions( repos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...worktreeState, - projects, - projectHostSetups + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups } }), @@ -114,9 +118,9 @@ export function createRepoCatalogActions( const validRepoHostIdentities = new Set(prunedRepos.map(getRepoHostIdentity)) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.projects, + projects: s.endpointProjects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, + s.endpointProjectHostSetups, result.repos, prunedRepos ) @@ -132,7 +136,8 @@ export function createRepoCatalogActions( repos: prunedRepos, pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), - ...mergedProjectCompatibility, + endpointProjects: mergedProjectCompatibility.projects, + endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, ...(arrayElementsUnchanged(prunedRepos, s.repos) ? {} : { folderWorkspacePathStatuses: {} }), diff --git a/src/renderer/src/store/repos/repo-catalog-identity.ts b/src/renderer/src/store/repos/repo-catalog-identity.ts index 1f0a215cd938..10d08c303968 100644 --- a/src/renderer/src/store/repos/repo-catalog-identity.ts +++ b/src/renderer/src/store/repos/repo-catalog-identity.ts @@ -30,14 +30,18 @@ export function mergeProjectCompatibilityForHostRepoChange({ nextRepos, hostId }: { - previous: Pick + previous: Pick nextRepos: readonly Repo[] hostId: string -}): Pick { - return mergeFetchedProjectCompatibilityForHost({ - previous, +}): Pick { + const merged = mergeFetchedProjectCompatibilityForHost({ + previous: { + projects: previous.endpointProjects, + projectHostSetups: previous.endpointProjectHostSetups + }, fetched: projectCompatibilityFromRepos(nextRepos), repos: nextRepos, hostId }) + return { endpointProjects: merged.projects, endpointProjectHostSetups: merged.projectHostSetups } } diff --git a/src/renderer/src/store/repos/repo-removal.ts b/src/renderer/src/store/repos/repo-removal.ts index 8a7b7b36b5d9..5459c798c4da 100644 --- a/src/renderer/src/store/repos/repo-removal.ts +++ b/src/renderer/src/store/repos/repo-removal.ts @@ -285,7 +285,7 @@ export function createRepoRemovalActions( // Why: drop removed repos' sparse-preset maps so they don't outlive the repo for the whole session. ...omitSparsePresetsForRepos(s, removedRepoIds), ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: s, nextRepos, hostId: ownerHostId }), diff --git a/src/renderer/src/store/repos/repo-state.ts b/src/renderer/src/store/repos/repo-state.ts index f35a23c6e370..33f7fd8a2273 100644 --- a/src/renderer/src/store/repos/repo-state.ts +++ b/src/renderer/src/store/repos/repo-state.ts @@ -157,8 +157,8 @@ export type RuntimeCatalogFetchOptions = { export type RepoSlice = { repos: readonly Repo[] - projects: readonly Project[] - projectHostSetups: readonly ProjectHostSetup[] + endpointProjects: readonly Project[] + endpointProjectHostSetups: readonly ProjectHostSetup[] projectGroups: readonly ProjectGroup[] folderWorkspaces: readonly FolderWorkspace[] folderWorkspacePathStatuses: Record diff --git a/src/renderer/src/store/repos/repo-update.ts b/src/renderer/src/store/repos/repo-update.ts index 8eae71827fb9..33015baff1e1 100644 --- a/src/renderer/src/store/repos/repo-update.ts +++ b/src/renderer/src/store/repos/repo-update.ts @@ -205,7 +205,10 @@ export function createRepoUpdateActions( return { repos: nextRepos, ...mergeProjectCompatibilityForHostRepoChange({ - previous: { projects: s.projects, projectHostSetups: s.projectHostSetups }, + previous: { + endpointProjects: s.endpointProjects, + endpointProjectHostSetups: s.endpointProjectHostSetups + }, nextRepos, hostId: ownerHostId }), diff --git a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts index ed9ea109d460..f15cb4e463e3 100644 --- a/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts +++ b/src/renderer/src/store/repos/runtime-repo-catalog-actions.ts @@ -57,9 +57,9 @@ export function createRuntimeRepoCatalogActions( const validRepoHostIdentities = new Set(finalizedRepos.map(getRepoHostIdentity)) const mergedProjectCompatibility = mergeFetchedHostProjectCatalog({ previous: { - projects: s.projects, + projects: s.endpointProjects, projectHostSetups: filterSetupsForPrunedRepoRows( - s.projectHostSetups, + s.endpointProjectHostSetups, result.repos, finalizedRepos ) @@ -106,7 +106,8 @@ export function createRuntimeRepoCatalogActions( : {}), pendingSshRepoReadoptions: reconciliation.pendingReadoptions, ...reconcileReadoptedSshWorktreeState(s, s.pendingSshRepoReadoptions), - ...mergedProjectCompatibility, + endpointProjects: mergedProjectCompatibility.projects, + endpointProjectHostSetups: mergedProjectCompatibility.projectHostSetups, activeRepoId: s.activeRepoId && validRepoIds.has(s.activeRepoId) ? s.activeRepoId : null, filterRepoIds: retainValidFilterRepoIds(s.filterRepoIds, validRepoIds), diff --git a/src/renderer/src/store/slices/local-detected-agent-state.test.ts b/src/renderer/src/store/slices/local-detected-agent-state.test.ts index 7ef9cb08664b..2313837b4f49 100644 --- a/src/renderer/src/store/slices/local-detected-agent-state.test.ts +++ b/src/renderer/src/store/slices/local-detected-agent-state.test.ts @@ -32,7 +32,7 @@ function createTestStore(repos: Repo[]) { ) store.setState({ repos, - projects: repos.map((repo) => ({ + endpointProjects: repos.map((repo) => ({ id: repo.id, displayName: repo.displayName, badgeColor: repo.badgeColor, @@ -96,7 +96,7 @@ describe('local detected agent context lifecycle', () => { it('keeps native provider inventory across WSL selection and refresh', async () => { const store = createTestStore([makeRepo('repo-1')]) - const projects = store.getState().projects + const projects = store.getState().endpointProjects detectAgents.mockResolvedValueOnce(['grok']).mockResolvedValueOnce([]) await store.getState().ensureNativeDetectedAgents() expect(detectAgents).toHaveBeenNthCalledWith(1, undefined) @@ -104,7 +104,7 @@ describe('local detected agent context lifecycle', () => { await store.getState().refreshDetectedAgents() expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) expect(store.getState().detectedAgentIds).toEqual(['codex']) - store.setState({ projects: [] }) + store.setState({ endpointProjects: [] }) refreshAgents.mockRejectedValueOnce(new Error('native refresh failed')) await expect(store.getState().refreshDetectedAgents()).resolves.toEqual(['grok']) expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) @@ -120,14 +120,14 @@ describe('local detected agent context lifecycle', () => { finish = resolve }) ) - store.setState({ projects }) + store.setState({ endpointProjects: projects }) const wslRefresh = store.getState().refreshDetectedAgents() expect(store.getState().detectedAgentIds).toBeNull() finish({ agents: ['codex'] }) await wslRefresh expect(store.getState().detectedAgentIds).toEqual(['codex']) expect(readNativeDetectedAgents(store.getState())).toEqual(['grok']) - store.setState({ projects: [] }) + store.setState({ endpointProjects: [] }) await store.getState().refreshDetectedAgents() expect(readNativeDetectedAgents(store.getState())).toEqual(['codex']) }) diff --git a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts index 208729f1132a..1950affb2096 100644 --- a/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts +++ b/src/renderer/src/store/slices/purge-stale-runtime-host-state.test.ts @@ -93,7 +93,7 @@ describe('purgeStaleRuntimeHostState', () => { executionHostId: RUNTIME_NEVER_SAVED } ], - projectHostSetups: [ + endpointProjectHostSetups: [ setup({ id: 's-local', repoId: 'repo1', hostId: 'local' }), setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A }), setup({ id: 's-a-repoless', repoId: '', hostId: RUNTIME_A }), @@ -122,7 +122,7 @@ describe('purgeStaleRuntimeHostState', () => { const s = store.getState() // Removed env's repo/setups/rows/detected/tab all gone. expect(s.repos.map((r) => r.id)).toEqual(['repo1', 'repoServing']) - expect(s.projectHostSetups.map((x) => x.id).sort()).toEqual(['s-local', 's-serving']) + expect(s.endpointProjectHostSetups.map((x) => x.id).sort()).toEqual(['s-local', 's-serving']) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.detectedWorktreesByRepo.repoA.worktrees).toEqual([]) expect(s.tabsByWorktree[WT_A]).toBeUndefined() @@ -174,7 +174,7 @@ describe('purgeStaleRuntimeHostState', () => { const store = createTestStore() seedStore(store, { repos: [TEST_REPO], - projectHostSetups: [setup({ id: 's-local', repoId: 'repo1', hostId: 'local' })], + endpointProjectHostSetups: [setup({ id: 's-local', repoId: 'repo1', hostId: 'local' })], worktreesByRepo: { repo1: [makeWorktree({ id: 'repo1::/wt', repoId: 'repo1', hostId: 'local' })] } @@ -183,7 +183,7 @@ describe('purgeStaleRuntimeHostState', () => { const epochBefore = before.sortEpoch const worktreesRef = before.worktreesByRepo const reposRef = before.repos - const setupsRef = before.projectHostSetups + const setupsRef = before.endpointProjectHostSetups store.getState().purgeStaleRuntimeHostState([]) store.getState().purgeStaleRuntimeHostState(['env-does-not-exist']) @@ -193,7 +193,7 @@ describe('purgeStaleRuntimeHostState', () => { // Nothing stale => the reducer returns state untouched (same references). expect(s.worktreesByRepo).toBe(worktreesRef) expect(s.repos).toBe(reposRef) - expect(s.projectHostSetups).toBe(setupsRef) + expect(s.endpointProjectHostSetups).toBe(setupsRef) }) it('same-repoId-on-two-hosts: keeps an ambiguous unhosted row and the repo key', () => { @@ -352,7 +352,7 @@ describe('purgeStaleRuntimeHostState', () => { const worktreeId = 'repoA::/legacy' seedStore(store, { repos: [], - projectHostSetups: [setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A })], + endpointProjectHostSetups: [setup({ id: 's-a', repoId: 'repoA', hostId: RUNTIME_A })], worktreesByRepo: { repoA: [makeWorktree({ id: worktreeId, repoId: 'repoA', hostId: undefined })] }, @@ -362,7 +362,7 @@ describe('purgeStaleRuntimeHostState', () => { store.getState().purgeStaleRuntimeHostState(['env-a']) const s = store.getState() - expect(s.projectHostSetups).toEqual([]) + expect(s.endpointProjectHostSetups).toEqual([]) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.tabsByWorktree[worktreeId]).toBeUndefined() }) @@ -448,7 +448,7 @@ describe('purgeStaleRuntimeHostState', () => { executionHostId: RUNTIME_A } ], - projectHostSetups: [setup({ id: 's-b', repoId: 'shared', hostId: RUNTIME_B })], + endpointProjectHostSetups: [setup({ id: 's-b', repoId: 'shared', hostId: RUNTIME_B })], worktreesByRepo: { shared: [makeWorktree({ id: worktreeId, repoId: 'shared', hostId: undefined })] }, @@ -459,7 +459,7 @@ describe('purgeStaleRuntimeHostState', () => { const s = store.getState() expect(s.repos).toEqual([]) - expect(s.projectHostSetups).toHaveLength(1) + expect(s.endpointProjectHostSetups).toHaveLength(1) expect(s.worktreesByRepo.shared).toHaveLength(1) expect(s.tabsByWorktree[worktreeId]).toBe(tabs) }) diff --git a/src/renderer/src/store/slices/repos-all-hosts.test.ts b/src/renderer/src/store/slices/repos-all-hosts.test.ts index 736b013a61ff..c6aed71a4fdc 100644 --- a/src/renderer/src/store/slices/repos-all-hosts.test.ts +++ b/src/renderer/src/store/slices/repos-all-hosts.test.ts @@ -378,8 +378,8 @@ describe('fetchReposForAllHosts', () => { .repos.map((repo) => repo.id) .sort() expect(ids).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects).toContainEqual(localProject) - expect(store.getState().projectHostSetups).toContainEqual(localProjectHostSetup) + expect(store.getState().endpointProjects).toContainEqual(localProject) + expect(store.getState().endpointProjectHostSetups).toContainEqual(localProjectHostSetup) }) it('preserves runtime-owned project metadata during a local-only repo refresh', async () => { @@ -442,10 +442,12 @@ describe('fetchReposForAllHosts', () => { expect( store .getState() - .projects.map((project) => project.id) + .endpointProjects.map((project) => project.id) .sort() ).toEqual(['local-project', 'repo:remote-repo']) - expect(store.getState().projects.find((project) => project.id === runtimeProject.id)).toEqual( + expect( + store.getState().endpointProjects.find((project) => project.id === runtimeProject.id) + ).toEqual( expect.objectContaining({ displayName: runtimeProject.displayName, badgeColor: runtimeProject.badgeColor @@ -462,10 +464,10 @@ describe('fetchReposForAllHosts', () => { expect( store .getState() - .projects.map((project) => project.id) + .endpointProjects.map((project) => project.id) .sort() ).toEqual(['local-project', 'repo:remote-repo']) - expect(store.getState().projects).toContainEqual(localProject) + expect(store.getState().endpointProjects).toContainEqual(localProject) }) it('preserves shared project metadata when the same project id is fetched from multiple hosts', async () => { @@ -475,8 +477,8 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() - expectSharedProjectMetadata(store.getState().projects, sharedProjectId) - const setups = store.getState().projectHostSetups + expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) expect(setups).toEqual( expect.arrayContaining([ @@ -504,7 +506,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() expect( - store.getState().projects.find((project) => project.id === sharedProjectId) + store.getState().endpointProjects.find((project) => project.id === sharedProjectId) ?.localWindowsRuntimePreference ).toEqual({ kind: 'windows-host' }) }) @@ -517,7 +519,7 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchReposForAllHosts() await store.getState().fetchRuntimeEnvironmentRepos('env-1') - expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) }) it('keeps the local side of a shared project when a runtime refresh removes its repos', async () => { @@ -563,10 +565,10 @@ describe('fetchReposForAllHosts', () => { const sharedProject = store .getState() - .projects.find((project) => project.id === sharedProjectId) + .endpointProjects.find((project) => project.id === sharedProjectId) expect(sharedProject?.sourceRepoIds).toEqual(['local-repo']) expect(sharedProject?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ projectId: sharedProjectId, hostId: 'local', @@ -583,7 +585,7 @@ describe('fetchReposForAllHosts', () => { store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never }) await store.getState().fetchReposForAllHosts() - expectSharedProjectMetadata(store.getState().projects, sharedProjectId) + expectSharedProjectMetadata(store.getState().endpointProjects, sharedProjectId) runtimeEnvironmentCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => { if (args.method === 'repo.list') { return { @@ -619,11 +621,12 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') - expect(store.getState().projects.map((project) => project.id)).toEqual([sharedProjectId]) + const { endpointProjects, endpointProjectHostSetups } = store.getState() + expect(endpointProjects.map((project) => project.id)).toEqual([sharedProjectId]) expect( - store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + endpointProjects.find((project) => project.id === sharedProjectId)?.sourceRepoIds ).toEqual(['local-repo']) - expect(store.getState().projectHostSetups).toEqual([ + expect(endpointProjectHostSetups).toEqual([ expect.objectContaining({ projectId: sharedProjectId, hostId: 'local', @@ -673,16 +676,15 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') + const { endpointProjects, endpointProjectHostSetups } = store.getState() expect( - store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + endpointProjects.find((project) => project.id === sharedProjectId)?.sourceRepoIds ).toEqual(['local-repo']) - expect( - store - .getState() - .projects.map((project) => project.id) - .sort() - ).toEqual(['github:stablyai/orca', 'repo:remote-repo']) - expect(store.getState().projectHostSetups).toEqual( + expect(endpointProjects.map((project) => project.id).sort()).toEqual([ + 'github:stablyai/orca', + 'repo:remote-repo' + ]) + expect(endpointProjectHostSetups).toEqual( expect.arrayContaining([ expect.objectContaining({ projectId: sharedProjectId, @@ -740,7 +742,8 @@ describe('fetchReposForAllHosts', () => { await store.getState().fetchRuntimeEnvironmentRepos('env-1') expect( - store.getState().projects.find((project) => project.id === sharedProjectId)?.sourceRepoIds + store.getState().endpointProjects.find((project) => project.id === sharedProjectId) + ?.sourceRepoIds ).toEqual(['local-repo']) }) diff --git a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts index 55785c57dfdc..c7d9088d4394 100644 --- a/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts +++ b/src/renderer/src/store/slices/repos-cross-host-refresh-identity.spec.ts @@ -90,7 +90,7 @@ export function registerReposCrossHostRefreshIdentityTests(): void { } function sharedProject(store: ReturnType): Project | undefined { - return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID) + return store.getState().endpointProjects.find((project) => project.id === SHARED_PROJECT_ID) } describe('cross-host project refresh identity', () => { diff --git a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts index 223594afd115..1431f50c7f6b 100644 --- a/src/renderer/src/store/slices/repos-host-identity-routing.test.ts +++ b/src/renderer/src/store/slices/repos-host-identity-routing.test.ts @@ -316,7 +316,7 @@ describe('repo slice host identity routing', () => { repos: [localDuplicate, remoteDuplicate], activeRepoId: 'same-repo', filterRepoIds: ['same-repo'], - projects: [ + endpointProjects: [ { id: 'repo:same-repo', displayName: 'Same Repo', @@ -326,7 +326,7 @@ describe('repo slice host identity routing', () => { updatedAt: 1 } satisfies Project ], - projectHostSetups: [ + endpointProjectHostSetups: [ projectHostSetup({ id: 'local-setup', hostId: 'local' }), projectHostSetup({ id: 'remote-setup', hostId: 'runtime:env-1' }) ], @@ -356,11 +356,11 @@ describe('repo slice host identity routing', () => { expect(store.getState().tabsByWorktree[remoteWorktree.id]).toEqual([ { id: 'remote-tab', worktreeId: remoteWorktree.id } ]) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ hostId: 'runtime:env-1', repoId: 'same-repo' }) ]) expect(store.getState().lastVisitedAtByWorktreeId).toEqual({ [remoteWorktree.id]: 20 }) - expect(store.getState().projects).toEqual([ + expect(store.getState().endpointProjects).toEqual([ expect.objectContaining({ id: 'repo:same-repo', sourceRepoIds: ['same-repo'] }) ]) // Why: the id also exists on runtime:env-1, so the local-side removal must be diff --git a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts index 8016f3546481..434d9c39de3d 100644 --- a/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts +++ b/src/renderer/src/store/slices/repos-nested-ssh-projection.test.ts @@ -74,7 +74,7 @@ it('preserves distinct SSH execution setups behind the same runtime owner', asyn await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) expect(setups).toEqual( expect.arrayContaining([ @@ -147,7 +147,7 @@ it('prefers an authoritative paired-runtime setup over its repo-derived fallback await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ { ...setup, hostId: 'runtime:env-1', diff --git a/src/renderer/src/store/slices/repos-project-host-capability.spec.ts b/src/renderer/src/store/slices/repos-project-host-capability.spec.ts index 6ca81212ce2e..7efa00184783 100644 --- a/src/renderer/src/store/slices/repos-project-host-capability.spec.ts +++ b/src/renderer/src/store/slices/repos-project-host-capability.spec.ts @@ -76,7 +76,7 @@ export function registerReposProjectHostCapabilityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) ]) expect(runtimeEnvironmentCall).toHaveBeenCalledTimes(1) diff --git a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts index e3af48123479..c3d575e35751 100644 --- a/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts +++ b/src/renderer/src/store/slices/repos-project-host-lifecycle.spec.ts @@ -92,8 +92,8 @@ export function registerReposProjectHostLifecycleTests(): void { }) ).resolves.toEqual({ project, setup }) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([setup]) expect(projectsCreateHostSetup).toHaveBeenCalledWith({ projectId: project.id, hostId: 'local', @@ -117,7 +117,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - projectHostSetups: [runtimeSetup], + endpointProjectHostSetups: [runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -158,8 +158,8 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [runtimeSetup], + endpointProjects: [project], + endpointProjectHostSetups: [runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -171,8 +171,8 @@ export function registerReposProjectHostLifecycleTests(): void { repo: undefined }) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([]) expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ selector: 'env-1', method: 'projectHostSetup.delete', @@ -194,7 +194,7 @@ export function registerReposProjectHostLifecycleTests(): void { }) const store = createTestStore() store.setState({ - projectHostSetups: [localSetup, runtimeSetup], + endpointProjectHostSetups: [localSetup, runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -209,7 +209,7 @@ export function registerReposProjectHostLifecycleTests(): void { updates: { displayName: 'Local renamed' } }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([updatedLocalSetup, runtimeSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([updatedLocalSetup, runtimeSetup]) }) it('a setup id shared with another route removes only its own row', async () => { @@ -221,8 +221,8 @@ export function registerReposProjectHostLifecycleTests(): void { projectsDeleteHostSetup.mockResolvedValue({ project, setup: localSetup }) const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup, runtimeSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup, runtimeSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -233,15 +233,15 @@ export function registerReposProjectHostLifecycleTests(): void { hostId: 'local' }) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([runtimeSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([runtimeSetup]) }) it('refuses a named host the catalog has no setup for instead of deleting by id', async () => { const localSetup: ProjectHostSetup = { ...runtimeSetup, hostId: 'local' } const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -250,7 +250,7 @@ export function registerReposProjectHostLifecycleTests(): void { ).resolves.toBeNull() expect(projectsDeleteHostSetup).not.toHaveBeenCalled() - expect(store.getState().projectHostSetups).toEqual([localSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([localSetup]) }) it('names the SSH twin of a local setup on this computer', async () => { @@ -259,8 +259,8 @@ export function registerReposProjectHostLifecycleTests(): void { projectsDeleteHostSetup.mockResolvedValue({ project, setup: sshSetup }) const store = createTestStore() store.setState({ - projects: [project], - projectHostSetups: [localSetup, sshSetup], + endpointProjects: [project], + endpointProjectHostSetups: [localSetup, sshSetup], settings: { activeRuntimeEnvironmentId: null } as never }) @@ -270,7 +270,7 @@ export function registerReposProjectHostLifecycleTests(): void { setupId: sshSetup.id, hostId: 'ssh:box' }) - expect(store.getState().projectHostSetups).toEqual([localSetup]) + expect(store.getState().endpointProjectHostSetups).toEqual([localSetup]) }) it('preserves runtime-fetched setup-only states during repo hydration', async () => { @@ -313,7 +313,7 @@ export function registerReposProjectHostLifecycleTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toEqual( + expect(store.getState().endpointProjectHostSetups).toEqual( expect.arrayContaining([ expect.objectContaining({ id: 'setup-pending', diff --git a/src/renderer/src/store/slices/repos-project-runtime.spec.ts b/src/renderer/src/store/slices/repos-project-runtime.spec.ts index 16e167ed792e..f01dac4a0364 100644 --- a/src/renderer/src/store/slices/repos-project-runtime.spec.ts +++ b/src/renderer/src/store/slices/repos-project-runtime.spec.ts @@ -89,7 +89,7 @@ export function registerReposProjectRuntimeTests(): void { }) const store = createTestStore() store.setState({ - projects: [project], + endpointProjects: [project], detectedAgentIds: ['claude'], isDetectingAgents: true, isRefreshingAgents: true @@ -144,8 +144,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ repos, - projects: [rawProject], - projectHostSetups: projection.setups.map((setup) => ({ + endpointProjects: [rawProject], + endpointProjectHostSetups: projection.setups.map((setup) => ({ ...setup, projectId: rawProject.id })) @@ -165,7 +165,9 @@ export function registerReposProjectRuntimeTests(): void { view.projects.find((project) => project.id === 'repo:b')?.localWindowsRuntimePreference ).toEqual({ kind: 'windows-host' }) expect( - store.getState().projectHostSetups.every((setup) => setup.projectId === rawProject.id) + store + .getState() + .endpointProjectHostSetups.every((setup) => setup.projectId === rawProject.id) ).toBe(true) }) @@ -197,8 +199,8 @@ export function registerReposProjectRuntimeTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([setup]) expect(projectsList).toHaveBeenCalled() expect(window.api.projects.listHostSetups).toHaveBeenCalled() }) @@ -237,11 +239,11 @@ export function registerReposProjectRuntimeTests(): void { window.api.projects.listHostSetups = vi.fn().mockResolvedValue([setup]) reposList.mockResolvedValue([localRepo]) const store = createTestStore() - store.setState({ projects: [staleProject], projectHostSetups: [setup] }) + store.setState({ endpointProjects: [staleProject], endpointProjectHostSetups: [setup] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() }) it('updates local project runtime preferences through the projects API', async () => { @@ -258,7 +260,7 @@ export function registerReposProjectRuntimeTests(): void { localWindowsRuntimePreference: { kind: 'windows-host' } }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -268,7 +270,7 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) expect(projectsUpdate).toHaveBeenCalledWith({ @@ -294,8 +296,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projects: [project], - projectHostSetups: [ + endpointProjects: [project], + endpointProjectHostSetups: [ { id: 'setup-1', projectId: project.id, @@ -349,8 +351,8 @@ export function registerReposProjectRuntimeTests(): void { const store = createTestStore() store.setState({ settings: { activeRuntimeEnvironmentId: 'env-1' } as never, - projects: [project], - projectHostSetups: [ + endpointProjects: [project], + endpointProjectHostSetups: [ { id: 'setup-1', projectId: project.id, @@ -374,7 +376,7 @@ export function registerReposProjectRuntimeTests(): void { 'runtime:env-1' ) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'wsl', distro: 'Ubuntu' }) @@ -409,7 +411,7 @@ export function registerReposProjectRuntimeTests(): void { localWindowsRuntimePreference: { kind: 'windows-host' } }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -419,8 +421,8 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.createdAt).toBe(100) - expect(store.getState().projects[0]?.updatedAt).toBe(100) + expect(store.getState().endpointProjects[0]?.createdAt).toBe(100) + expect(store.getState().endpointProjects[0]?.updatedAt).toBe(100) }) it('preserves shared project source repos when updating local runtime preferences', async () => { @@ -439,7 +441,7 @@ export function registerReposProjectRuntimeTests(): void { updatedAt: 3 }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -449,8 +451,11 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toEqual({ + expect(store.getState().endpointProjects[0]?.sourceRepoIds).toEqual([ + 'local-repo', + 'remote-repo' + ]) + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toEqual({ kind: 'windows-host' }) }) @@ -474,7 +479,7 @@ export function registerReposProjectRuntimeTests(): void { updatedAt: 3 }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await store.getState().updateProject( project.id, @@ -484,8 +489,11 @@ export function registerReposProjectRuntimeTests(): void { 'local' ) - expect(store.getState().projects[0]?.sourceRepoIds).toEqual(['local-repo', 'remote-repo']) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().endpointProjects[0]?.sourceRepoIds).toEqual([ + 'local-repo', + 'remote-repo' + ]) + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() }) }) diff --git a/src/renderer/src/store/slices/repos-refresh-identity.spec.ts b/src/renderer/src/store/slices/repos-refresh-identity.spec.ts index 7031e4a7820f..c3ee3c1f90f6 100644 --- a/src/renderer/src/store/slices/repos-refresh-identity.spec.ts +++ b/src/renderer/src/store/slices/repos-refresh-identity.spec.ts @@ -84,23 +84,23 @@ export function registerReposRefreshIdentityTests(): void { it('keeps the projects and host setups arrays and entries across a no-op refetch', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('lets a nested hookSettings change through', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups mockRepos({ ...repo, @@ -108,20 +108,20 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).not.toBe(setups) - expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + expect(store.getState().endpointProjectHostSetups).not.toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]?.hookSettings?.mode).toBe('override') }) it('lets a displayName change through on projects', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos({ ...repo, displayName: 'Renamed' }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects[0]?.displayName).toBe('Renamed') + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects[0]?.displayName).toBe('Renamed') }) it('lets an array-field change through', async () => { @@ -139,7 +139,7 @@ export function registerReposRefreshIdentityTests(): void { it('lets a nested gitRemoteIdentity change through', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos({ ...repo, @@ -147,39 +147,39 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects[0]?.gitRemoteIdentity?.remoteName).toBe('upstream') }) it('treats clearing localWindowsRuntimePreference as a change', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projectId = store.getState().projects[0]!.id + const projectId = store.getState().endpointProjects[0]!.id const withPreference: Project = { - ...store.getState().projects[0]!, + ...store.getState().endpointProjects[0]!, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } - store.setState({ projects: [withPreference] }) + store.setState({ endpointProjects: [withPreference] }) await store.getState().fetchRepos(catalogOwner(store)) // Why: a local-host refresh is authoritative; an absent key must not read as unchanged. - expect(store.getState().projects[0]).not.toBe(withPreference) - expect(store.getState().projects[0]?.id).toBe(projectId) - expect(store.getState().projects[0]?.localWindowsRuntimePreference).toBeUndefined() + expect(store.getState().endpointProjects[0]).not.toBe(withPreference) + expect(store.getState().endpointProjects[0]?.id).toBe(projectId) + expect(store.getState().endpointProjects[0]?.localWindowsRuntimePreference).toBeUndefined() }) it('reuses an unchanged setup element while a sibling changes', async () => { mockRepos(repo, secondRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) mockRepos(repo, { ...secondRepo, displayName: 'Repo 2 renamed' }) await store.getState().fetchRepos(catalogOwner(store)) - const next = store.getState().projectHostSetups + const next = store.getState().endpointProjectHostSetups expect(next).not.toBe(setups) expect(next[0]).toBe(setups[0]) expect(next[1]).not.toBe(setups[1]) @@ -188,7 +188,7 @@ export function registerReposRefreshIdentityTests(): void { it('does not reuse a setup that moved to a different execution host', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups[0]?.hostId).toBe('local') // Why: the repo-derived fallback sets setup.id = repo.id, so the same id on a second host is @@ -196,7 +196,7 @@ export function registerReposRefreshIdentityTests(): void { mockRepos({ ...repo, executionHostId: 'ssh:host-a', connectionId: 'host-a' }) await store.getState().fetchRepos(catalogOwner(store)) - const next = store.getState().projectHostSetups + const next = store.getState().endpointProjectHostSetups expect(next[0]).not.toBe(setups[0]) expect(next[0]?.hostId).toBe('ssh:host-a') }) @@ -209,29 +209,29 @@ export function registerReposRefreshIdentityTests(): void { mockRepos(repo, sshRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) expect(new Set(setups.map((setup) => setup.id)).size).toBe(1) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) - expect(store.getState().projectHostSetups[1]).toBe(setups[1]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjectHostSetups[1]).toBe(setups[1]) }) it('drops a project and its setup when its repo disappears', async () => { mockRepos(repo, secondRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos(repo) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects).toHaveLength(1) - expect(store.getState().projectHostSetups).toHaveLength(1) + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects).toHaveLength(1) + expect(store.getState().endpointProjectHostSetups).toHaveLength(1) }) it('keeps a project owned only through a repo on another host', async () => { @@ -251,13 +251,13 @@ export function registerReposRefreshIdentityTests(): void { updatedAt: 1 } store.setState({ - projects: [...store.getState().projects, sshOwned], - projectHostSetups: [] + endpointProjects: [...store.getState().endpointProjects, sshOwned], + endpointProjectHostSetups: [] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects.map((project) => project.id)).toContain('ssh-owned') + expect(store.getState().endpointProjects.map((project) => project.id)).toContain('ssh-owned') }) it('keeps a project whose repo id is cloned onto a second host', async () => { @@ -276,34 +276,36 @@ export function registerReposRefreshIdentityTests(): void { updatedAt: 1 } store.setState({ - projects: [...store.getState().projects, dualHostOwned], - projectHostSetups: [] + endpointProjects: [...store.getState().endpointProjects, dualHostOwned], + endpointProjectHostSetups: [] }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects.map((project) => project.id)).toContain('dual-host-owned') + expect(store.getState().endpointProjects.map((project) => project.id)).toContain( + 'dual-host-owned' + ) }) it('adds a project and its setup when a repo appears', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects + const projects = store.getState().endpointProjects mockRepos(repo, secondRepo) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).not.toBe(projects) - expect(store.getState().projects).toHaveLength(2) - expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().endpointProjects).not.toBe(projects) + expect(store.getState().endpointProjects).toHaveLength(2) + expect(store.getState().endpointProjectHostSetups).toHaveLength(2) }) it('keeps catalog identity when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) expect(projects[0]?.createdAt).toBe(0) @@ -311,18 +313,18 @@ export function registerReposRefreshIdentityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('keeps catalog identity when repo.addedAt is omitted', async () => { mockRepos(omitAddedAt(repo)) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) expect(projects[0]?.createdAt).toBe(0) @@ -330,17 +332,17 @@ export function registerReposRefreshIdentityTests(): void { await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('lets a nested hookSettings change through when repo.addedAt is 0', async () => { mockRepos({ ...repo, addedAt: 0 }) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups mockRepos({ ...repo, @@ -349,9 +351,9 @@ export function registerReposRefreshIdentityTests(): void { }) await store.getState().fetchRepos(catalogOwner(store)) - expect(store.getState().projectHostSetups).not.toBe(setups) - expect(store.getState().projectHostSetups[0]).not.toBe(setups[0]) - expect(store.getState().projectHostSetups[0]?.hookSettings?.mode).toBe('override') + expect(store.getState().endpointProjectHostSetups).not.toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).not.toBe(setups[0]) + expect(store.getState().endpointProjectHostSetups[0]?.hookSettings?.mode).toBe('override') }) }) @@ -423,8 +425,8 @@ export function registerReposRefreshIdentityTests(): void { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) const before = store.getState() - const projects = before.projects - const setups = before.projectHostSetups + const projects = before.endpointProjects + const setups = before.endpointProjectHostSetups expect(projects).toHaveLength(1) expect(setups).toHaveLength(1) @@ -434,26 +436,26 @@ export function registerReposRefreshIdentityTests(): void { // freshly allocated pendingSshRepoReadoptions alone would wake every store subscriber. expect(store.getState()).toBe(before) expect(store.getState().pendingSshRepoReadoptions).toBe(before.pendingSshRepoReadoptions) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('keeps catalog identity for a pending-only readoption while pending updates', async () => { const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const projects = store.getState().projects - const setups = store.getState().projectHostSetups + const projects = store.getState().endpointProjects + const setups = store.getState().endpointProjectHostSetups const readoption = { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } store.getState().recordSshRepoReadoptions([readoption]) expect(store.getState().pendingSshRepoReadoptions).toEqual([readoption]) - expect(store.getState().projects).toBe(projects) - expect(store.getState().projects[0]).toBe(projects[0]) - expect(store.getState().projectHostSetups).toBe(setups) - expect(store.getState().projectHostSetups[0]).toBe(setups[0]) + expect(store.getState().endpointProjects).toBe(projects) + expect(store.getState().endpointProjects[0]).toBe(projects[0]) + expect(store.getState().endpointProjectHostSetups).toBe(setups) + expect(store.getState().endpointProjectHostSetups[0]).toBe(setups[0]) }) it('replaces the moved setup on a real prune/rehome', async () => { @@ -470,7 +472,7 @@ export function registerReposRefreshIdentityTests(): void { mockRepos(oldHostRepo, newHostRepo) const store = createTestStore() await store.getState().fetchRepos(catalogOwner(store)) - const setups = store.getState().projectHostSetups + const setups = store.getState().endpointProjectHostSetups expect(setups).toHaveLength(2) const oldSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-old') const newSetup = setups.find((setup) => setup.hostId === 'ssh:ssh-new') @@ -483,7 +485,7 @@ export function registerReposRefreshIdentityTests(): void { { oldTargetId: 'ssh-old', newTargetId: 'ssh-new', repoIds: [repo.id] } ]) - const next = store.getState().projectHostSetups + const next = store.getState().endpointProjectHostSetups expect(next).not.toBe(setups) expect(next).toHaveLength(1) expect(next[0]).not.toBe(oldSetup) diff --git a/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts b/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts index 8f3222b9c77f..ab2ba035cc16 100644 --- a/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts +++ b/src/renderer/src/store/slices/repos-shared-project-badge-color.spec.ts @@ -132,7 +132,8 @@ export function registerReposSharedProjectBadgeColorTests(): void { }) function badgeColor(store: ReturnType): string | undefined { - return store.getState().projects.find((project) => project.id === SHARED_PROJECT_ID)?.badgeColor + return store.getState().endpointProjects.find((project) => project.id === SHARED_PROJECT_ID) + ?.badgeColor } describe('shared project badge color across hosts', () => { diff --git a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts index 58d694c84d06..1fd4d043ef64 100644 --- a/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts +++ b/src/renderer/src/store/slices/repos-ssh-host-reconciliation.test.ts @@ -157,7 +157,7 @@ describe('SSH repo host reconciliation', () => { const store = createTestStore() store.setState({ repos: [staleRepo], - projectHostSetups: [directSshSetup('ssh-old')], + endpointProjectHostSetups: [directSshSetup('ssh-old')], worktreesByRepo: { [repoId]: [ directSshWorktree('ssh-old', 'stale'), @@ -172,7 +172,7 @@ describe('SSH repo host reconciliation', () => { store.getState().recordSshRepoReadoptions([readoption]) expect(store.getState().repos).toEqual([liveRepo]) - expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().endpointProjectHostSetups).toEqual([directSshSetup('ssh-new')]) expect(store.getState().worktreesByRepo[repoId]).toEqual([ directSshWorktree('ssh-new', 'authoritative') ]) @@ -192,7 +192,7 @@ describe('SSH repo host reconciliation', () => { const store = createTestStore() store.setState({ repos: [directSshRepo('ssh-old')], - projectHostSetups: [directSshSetup('ssh-old'), runtimeSetup] + endpointProjectHostSetups: [directSshSetup('ssh-old'), runtimeSetup] }) store.getState().recordSshRepoReadoptions([readoption]) @@ -202,10 +202,10 @@ describe('SSH repo host reconciliation', () => { await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) expect(store.getState().repos).toEqual([liveRepo]) - expect(store.getState().projectHostSetups).toEqual( + expect(store.getState().endpointProjectHostSetups).toEqual( expect.arrayContaining([directSshSetup('ssh-new'), runtimeSetup]) ) - expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().endpointProjectHostSetups).toHaveLength(2) expect(store.getState().pendingSshRepoReadoptions).toEqual([]) }) @@ -229,7 +229,7 @@ describe('SSH repo host reconciliation', () => { }) .mockResolvedValueOnce([directSshSetup('ssh-new')]) const store = createTestStore() - store.setState({ repos: [staleRepo], projectHostSetups: [directSshSetup('ssh-old')] }) + store.setState({ repos: [staleRepo], endpointProjectHostSetups: [directSshSetup('ssh-old')] }) store.getState().recordSshRepoReadoptions([readoption]) const olderFetch = store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) @@ -240,7 +240,7 @@ describe('SSH repo host reconciliation', () => { await olderFetch expect(store.getState().repos).toEqual([liveRepo]) - expect(store.getState().projectHostSetups).toEqual([directSshSetup('ssh-new')]) + expect(store.getState().endpointProjectHostSetups).toEqual([directSshSetup('ssh-new')]) }) it('rejects an old-host worktree response that resolves after re-adoption', async () => { @@ -387,14 +387,17 @@ describe('SSH repo host reconciliation', () => { projectsList.mockResolvedValue([project]) setupsList.mockResolvedValue([directSshSetup('ssh-old')]) const store = createTestStore() - store.setState({ repos: [localRepo, oldRepo], projectHostSetups: [directSshSetup('ssh-old')] }) + store.setState({ + repos: [localRepo, oldRepo], + endpointProjectHostSetups: [directSshSetup('ssh-old')] + }) await store.getState().fetchReposForAllHosts({ remoteHosts: 'skip' }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }, oldRepo]) - expect(store.getState().projectHostSetups).toEqual( + expect(store.getState().endpointProjectHostSetups).toEqual( expect.arrayContaining([directSshSetup('ssh-old')]) ) - expect(store.getState().projectHostSetups).toHaveLength(2) + expect(store.getState().endpointProjectHostSetups).toHaveLength(2) }) }) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 4125fdb888a2..49d798cb4900 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -48,10 +48,10 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos({ runtimeEnvironmentId: null }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) - expect(store.getState().projects).toEqual([ + expect(store.getState().endpointProjects).toEqual([ expect.objectContaining({ id: 'repo:local-repo', sourceRepoIds: ['local-repo'] }) ]) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ id: 'local-repo', hostId: 'local' }) ]) expect(reposList).toHaveBeenCalled() @@ -115,10 +115,10 @@ describe('repo slice runtime routing', () => { await store.getState().fetchRepos({ runtimeEnvironmentId: 'env-1' }) expect(store.getState().repos).toEqual([{ ...remoteRepo, executionHostId: 'runtime:env-1' }]) - expect(store.getState().projects).toEqual([ + expect(store.getState().endpointProjects).toEqual([ expect.objectContaining({ id: 'repo:remote-repo', sourceRepoIds: ['remote-repo'] }) ]) - expect(store.getState().projectHostSetups).toEqual([ + expect(store.getState().endpointProjectHostSetups).toEqual([ expect.objectContaining({ id: 'remote-repo', hostId: 'runtime:env-1' }) ]) expect(store.getState().activeRepoId).toBeNull() @@ -279,7 +279,7 @@ describe('repo slice runtime routing', () => { } projectsSetupExistingFolder.mockResolvedValue({ project, setup, repo: localRepo }) const store = createTestStore() - store.setState({ projects: [project] }) + store.setState({ endpointProjects: [project] }) await expect( store.getState().setupProjectExistingFolder({ @@ -295,8 +295,8 @@ describe('repo slice runtime routing', () => { }) expect(store.getState().repos).toEqual([{ ...localRepo, executionHostId: 'local' }]) - expect(store.getState().projects).toEqual([project]) - expect(store.getState().projectHostSetups).toEqual([setup]) + expect(store.getState().endpointProjects).toEqual([project]) + expect(store.getState().endpointProjectHostSetups).toEqual([setup]) expect(projectsSetupExistingFolder).toHaveBeenCalledWith({ projectId: project.id, projectProviderIdentity: { provider: 'github', owner: 'stablyai', repo: 'orca' }, diff --git a/src/renderer/src/store/slices/repos.ts b/src/renderer/src/store/slices/repos.ts index 543607cd7a37..bddfec291068 100644 --- a/src/renderer/src/store/slices/repos.ts +++ b/src/renderer/src/store/slices/repos.ts @@ -35,8 +35,8 @@ export const createRepoSlice: StateCreator = (set, const repoOrdering = createRepoOrderingActions(set, get) return { repos: [], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], projectGroups: [], folderWorkspaces: [], folderWorkspacePathStatuses: {}, diff --git a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts index 79927eadddc0..721713d68836 100644 --- a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts +++ b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts @@ -177,11 +177,11 @@ describe('removeWorktree state cleanup', () => { const orphanedSetup = { id: 'setup-runtime-ssh', hostId: 'ssh:runtime-ssh-orca-1' - } as unknown as AppState['projectHostSetups'][number] + } as unknown as AppState['endpointProjectHostSetups'][number] const userSshSetup = { id: 'setup-user-ssh', hostId: 'ssh:my-server' - } as unknown as AppState['projectHostSetups'][number] + } as unknown as AppState['endpointProjectHostSetups'][number] const deleteProjectHostSetup = vi.fn().mockResolvedValue(null) store.setState({ worktreesByRepo: { repo1: [wt] }, diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index 748efb01ae94..845268663513 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -1,3 +1,7 @@ +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import type { AppState } from '../../../types' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { DetectedWorktreeListResult, Worktree } from '../../../../../../shared/worktree/types' @@ -122,11 +126,11 @@ export function toVisibleWorktrees( } export function getProjectHostSetupForRepoHost( - state: Partial>, + state: ProjectCatalogReadState, repoId: string, hostId: ExecutionHostId ): ProjectHostSetup | undefined { - return state.projectHostSetups?.find( + return getProjectHostSetupProjectionFromState(state).setups.find( (setup) => setup.repoId === repoId && setup.hostId === hostId ) } diff --git a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts index 8aa1f039e6ae..5aeb1d825837 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts @@ -20,7 +20,7 @@ export async function purgeOrphanedRuntimeSshProjects( purgeableSshTargetIds.map((id) => toSshExecutionHostId(id)) ) const orphanedSetups = get() - .projectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) + .endpointProjectHostSetups.filter((setup) => destroyedHostIds.has(setup.hostId)) .map((setup) => ({ setupId: setup.id, hostId: setup.hostId })) const purgedRepoIds = new Set() for (const target of orphanedSetups) { diff --git a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts index b2166040f45d..6cc48007ed44 100644 --- a/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts +++ b/src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts @@ -56,7 +56,7 @@ export function createPurgeStaleRuntimeHostState( // Why: a repoId-less setup on the removed host can still split a surviving project group, so drop every setup it owns. const survivingSetups: ProjectHostSetup[] = [] - for (const setup of s.projectHostSetups) { + for (const setup of s.endpointProjectHostSetups) { if (isRemovedRuntimeHostId(setup.hostId, removed)) { if (setup.repoId) { repoIdsWithRemovedOwners.add(setup.repoId) @@ -68,7 +68,7 @@ export function createPurgeStaleRuntimeHostState( } } } - const setupsChanged = survivingSetups.length !== s.projectHostSetups.length + const setupsChanged = survivingSetups.length !== s.endpointProjectHostSetups.length const projectGroups = dropRuntimeHostedCatalogRows(s.projectGroups, removed) const folderWorkspaces = dropRuntimeHostedCatalogRows(s.folderWorkspaces, removed) const groupsChanged = @@ -280,7 +280,7 @@ export function createPurgeStaleRuntimeHostState( return { ...purgeState, ...(reposChanged ? { repos: survivingRepos } : {}), - ...(setupsChanged ? { projectHostSetups: survivingSetups } : {}), + ...(setupsChanged ? { endpointProjectHostSetups: survivingSetups } : {}), ...(groupsChanged ? { projectGroups, folderWorkspaces } : {}), ...(worktreesChanged ? { worktreesByRepo: worktreeDrop.rowsByRepo } : {}), ...(detectedChanged ? { detectedWorktreesByRepo } : {}), diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index f85f7152d405..305ef00633a3 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -31,9 +31,9 @@ export function createWebAiVaultApi(): NonNullable['aiVault' ) return { // A browser searches only its selected paired runtime. - searchSessions: (request, executionHostScope) => + searchSessions: (request, executionHostScope, isActive) => addressesOwnRuntime(executionHostScope) - ? search.searchSessions(request) + ? search.searchSessions(request, isActive) : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }), searchStatus: (executionHostScope) => addressesOwnRuntime(executionHostScope) diff --git a/src/shared/ai-vault-project-search.test.ts b/src/shared/ai-vault-project-search.test.ts index 3a399eab1dd2..ad6a37ff03d9 100644 --- a/src/shared/ai-vault-project-search.test.ts +++ b/src/shared/ai-vault-project-search.test.ts @@ -135,7 +135,7 @@ it('continues a bounded walk after fully filtered native pages and preserves no- }) it('bounds catalog reads when a real scoped search grows from N to 10N owners and hits', async () => { - const reads = [] + const reads: number[] = [] for (const size of [10, 100]) { let pathReads = 0 const projection = projectHostSetupProjectionFromRepos( @@ -205,3 +205,45 @@ it('keeps one native project query on a correctly advertised endpoint at N and 1 expect(search).toHaveBeenCalledTimes(1) } }) + +it.each(['relevance', 'newest'] as const)( + 'retains unknown %s frontiers across a finite filtered walk and restarts native staleness', + async (sort) => { + const search = vi.fn(async (native: AiVaultSearchRequest) => { + const key = native.within?.kind === 'project' ? native.within.projectKey : '' + if (key === 'repo:provisioned') { + return page([hit('low', '/provisioned', 1)], null) + } + const offset = Number(native.cursor ?? 0) + return offset < 4 + ? page([hit('excluded', '/a/nested-b', 101)], String(offset + 1)) + : page([hit('high', '/a/high', 100)], null) + }) + const query = { ...request, filters: { sort } } + const first = await searchCanonicalProject(query, 'runtime:old', catalog, search) + expect(first).toMatchObject({ kind: 'results', hits: [], page: { hasMore: true } }) + if (first.kind !== 'results') { + return + } + const next = await searchCanonicalProject( + { ...query, cursor: first.page.cursor! }, + 'runtime:old', + catalog, + search + ) + expect(next).toMatchObject({ + kind: 'results', + hits: [{ sessionId: 'high' }, { sessionId: 'low' }], + page: { hasMore: false } + }) + search.mockImplementation(async () => ({ kind: 'stale-cursor', generation: 8 })) + expect( + await searchCanonicalProject( + { ...query, cursor: first.page.cursor! }, + 'runtime:old', + catalog, + search + ) + ).toEqual({ kind: 'stale-cursor', generation: 0 }) + } +) diff --git a/src/shared/ai-vault-project-search.ts b/src/shared/ai-vault-project-search.ts index c80b0422dc78..c59e8e6550e8 100644 --- a/src/shared/ai-vault-project-search.ts +++ b/src/shared/ai-vault-project-search.ts @@ -25,8 +25,12 @@ export async function searchCanonicalProject( request: AiVaultSearchRequest, scope: ExecutionHostScope, catalog: Catalog, - search: Search + search: Search, + isActive: () => boolean = () => true ): Promise { + if (!isActive()) { + throw new Error('Session search retired') + } if (request.within?.kind !== 'project') { return search(request, scope) } @@ -171,7 +175,8 @@ export async function searchCanonicalProject( return true // The endpoint may know a registered path this client's catalog has not loaded. } })), - plan + plan, + isActive ) const outcomes = response.kind === 'results' ? (response.hosts ?? []) : [] const reason = outcomes[0]?.outcome diff --git a/src/shared/ai-vault-search-client.ts b/src/shared/ai-vault-search-client.ts index 3ca5184ed1f1..bdb497398cbb 100644 --- a/src/shared/ai-vault-search-client.ts +++ b/src/shared/ai-vault-search-client.ts @@ -42,11 +42,17 @@ export function createSessionSearchClient( call: (method: string, params: Record) => Promise, transport: SessionSearchTransport ): { - searchSessions(request: AiVaultSearchRequest): Promise + searchSessions( + request: AiVaultSearchRequest, + isActive?: () => boolean + ): Promise searchStatus(): Promise } { return { - searchSessions: async (request) => { + searchSessions: async (request, isActive = () => true) => { + if (!isActive()) { + throw new Error('Session search retired') + } const parsed = AiVaultSearchRequestSchema.parse(request) let hostRequest = parsed let raw: unknown @@ -60,6 +66,9 @@ export function createSessionSearchClient( } hostRequest = { ...parsed, filters: { ...parsed.filters, agents } } } + if (!isActive()) { + throw new Error('Session search retired') + } raw = await call('aiVault.searchSessions', { ...hostRequest, supportedAgents: [...AI_VAULT_AGENTS], diff --git a/src/shared/ai-vault-search-host-page.ts b/src/shared/ai-vault-search-host-page.ts new file mode 100644 index 000000000000..2a41f41755b5 --- /dev/null +++ b/src/shared/ai-vault-search-host-page.ts @@ -0,0 +1,152 @@ +import type { + AiVaultSearchHit, + AiVaultSearchHostOutcome, + AiVaultSearchRequest, + AiVaultSearchResponse +} from './ai-vault-search-types' +import type { ExecutionHostId } from './execution-host' +import type { MergedSearchCursorEntry } from './ai-vault-search-merged-cursor' + +export type SessionSearchHostLeg = { + executionHostId: ExecutionHostId + streamKey?: string + accept?: (hit: AiVaultSearchHit) => boolean + /** Omitted for the in-process local leg, which has no transport to hang on. */ + timeoutMs?: number + search: (request: AiVaultSearchRequest) => Promise +} + +export type MergedSort = 'relevance' | 'newest' +type HostOutcome = AiVaultSearchHostOutcome['outcome'] +export type Truncation = Extract['truncated'] + +export type HostWalk = { + executionHostId: ExecutionHostId + leg: SessionSearchHostLeg + request: AiVaultSearchRequest + outcome: HostOutcome + cursor: string | null + emitted: number + generation: number + pending: AiVaultSearchHit[] + nextCursor: string | null + pages: number + /** This host still owes hits this request could not read; keep its entry. */ + carry: boolean + truncated: Truncation +} + +export function streamKey(leg: SessionSearchHostLeg): string { + return leg.streamKey ?? leg.executionHostId +} + +/** Every leg answers in the merged order; the cursor and debug are this merge's own. */ +export function legRequest(request: AiVaultSearchRequest, sort: MergedSort): AiVaultSearchRequest { + const { cursor: _cursor, debug: _debug, ...rest } = request + return { ...rest, filters: { ...request.filters, sort } } +} + +export function newHostWalk(leg: SessionSearchHostLeg, request: AiVaultSearchRequest): HostWalk { + return { + executionHostId: leg.executionHostId, + leg, + request, + outcome: 'unreachable', + cursor: null, + emitted: 0, + generation: 0, + pending: [], + nextCursor: null, + pages: 0, + carry: false, + truncated: { + candidates: false, + snippets: 0, + query: false, + freshness: false + } + } +} + +export async function fetchHostPage( + walk: HostWalk, + entry: MergedSearchCursorEntry | null +): Promise { + walk.cursor = entry?.c ?? null + walk.emitted = entry?.e ?? 0 + walk.generation = entry?.g ?? 0 + walk.pages += 1 + let response: AiVaultSearchResponse + try { + const { cursor } = walk + response = await withLegTimeout( + walk.leg.search(cursor === null ? walk.request : { ...walk.request, cursor }), + walk.leg.timeoutMs + ) + } catch (error) { + console.error(`[ai-vault-search] ${walk.executionHostId} leg failed:`, error) + // Keep its place: the next merged page retries this host from here. + endHostWalk(walk, 'unreachable', true) + return + } + if (response.kind === 'unavailable') { + endHostWalk(walk, response.reason, false) + return + } + // A cursor this merge minted can only be refused because the host's index + // moved, so both refusals mean the same thing: this host is done for now. + if (response.kind !== 'results') { + endHostWalk(walk, 'stale', false) + return + } + // `e` is an offset into one generation's ranked page, so it is only + // meaningful while that generation stands. Nothing emitted, nothing to fence. + if ( + (walk.emitted > 0 || walk.cursor !== null || walk.generation > 0) && + walk.generation !== response.generation + ) { + endHostWalk(walk, 'stale', false) + return + } + walk.outcome = 'searched' + walk.generation = response.generation + walk.pending = stampExecutionHost(response.hits, walk.executionHostId).slice(walk.emitted) + walk.nextCursor = response.page.hasMore ? response.page.cursor : null + walk.carry = false + walk.truncated.candidates ||= response.truncated.candidates + walk.truncated.snippets += response.truncated.snippets + walk.truncated.query ||= response.truncated.query + walk.truncated.freshness ||= response.truncated.freshness +} + +function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void { + walk.outcome = outcome + walk.pending = [] + walk.nextCursor = null + walk.carry = carry +} + +// This desktop owns which host it addressed; never trust an id the far side returned. +function stampExecutionHost( + hits: readonly AiVaultSearchHit[], + executionHostId: ExecutionHostId +): AiVaultSearchHit[] { + return hits.map((hit) => ({ ...hit, executionHostId })) +} + +async function withLegTimeout(pending: Promise, timeoutMs: number | undefined): Promise { + if (timeoutMs === undefined) { + return pending + } + let timer: ReturnType | undefined + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Session search host timed out.')), timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/shared/ai-vault-search-page-walk.ts b/src/shared/ai-vault-search-page-walk.ts index 40e007184c82..2a53031385e0 100644 --- a/src/shared/ai-vault-search-page-walk.ts +++ b/src/shared/ai-vault-search-page-walk.ts @@ -12,38 +12,20 @@ import { type MergedSearchCursorEntry } from './ai-vault-search-merged-cursor' -export type SessionSearchHostLeg = { - executionHostId: ExecutionHostId - streamKey?: string - accept?: (hit: AiVaultSearchHit) => boolean - /** Omitted for the in-process local leg, which has no transport to hang on. */ - timeoutMs?: number - search: (request: AiVaultSearchRequest) => Promise -} - -type MergedSort = 'relevance' | 'newest' -type HostOutcome = AiVaultSearchHostOutcome['outcome'] -type Truncation = Extract['truncated'] +export type { SessionSearchHostLeg } from './ai-vault-search-host-page' +import { + fetchHostPage, + newHostWalk, + legRequest, + streamKey, + type HostWalk, + type MergedSort, + type Truncation, + type SessionSearchHostLeg +} from './ai-vault-search-host-page' -// One merged request reads at most this many pages from any single host. const MAX_HOST_PAGES_PER_REQUEST = 3 -type HostWalk = { - executionHostId: ExecutionHostId - leg: SessionSearchHostLeg - request: AiVaultSearchRequest - outcome: HostOutcome - cursor: string | null - emitted: number - generation: number - pending: AiVaultSearchHit[] - nextCursor: string | null - pages: number - /** This host still owes hits this request could not read; keep its entry. */ - carry: boolean - truncated: Truncation -} - /** * Fans one query out to every execution host and merges the pages into one. * @@ -56,7 +38,8 @@ type HostWalk = { export async function searchAllExecutionHosts( request: AiVaultSearchRequest, legs: readonly SessionSearchHostLeg[], - plan?: string + plan?: string, + isActive: () => boolean = () => true ): Promise { const startedAt = Date.now() const limit = resolveSessionSearchLimit(request.limit) @@ -77,8 +60,6 @@ export async function searchAllExecutionHosts( return { kind: 'malformed-cursor' } } } - // A host absent from the cursor either finished or joined mid-walk; either way - // it contributes nothing to this page. Host-id order fixes every tiebreak. const walks = legs .filter((leg) => !resumed || resumed.hosts[streamKey(leg)] !== undefined) .map((leg) => newHostWalk(leg, legRequest(request, sort))) @@ -87,101 +68,47 @@ export async function searchAllExecutionHosts( left.executionHostId.localeCompare(right.executionHostId) || streamKey(left.leg).localeCompare(streamKey(right.leg)) ) + const nextByHost = new Map() + for (const walk of walks) { + const queue = nextByHost.get(walk.executionHostId) ?? [] + queue.push(walk) + nextByHost.set(walk.executionHostId, queue) + } await Promise.all( - walks.map((walk) => fetchHostPage(walk, resumed?.hosts[streamKey(walk.leg)] ?? null)) + [...nextByHost.values()].flatMap((queue) => { + let position = 0 + return Array.from({ length: Math.min(4, queue.length) }, async () => { + while (position < queue.length && isActive()) { + const walk = queue[position++]! + await fetchHostPage(walk, resumed?.hosts[streamKey(walk.leg)] ?? null) + await advanceHostWalk(walk, isActive) + } + }) + }) ) - const hits = await drainMergedPage(walks, limit, sort) - return mergedSearchResponse(walks, { limit, sort, plan }, hits, Date.now() - startedAt) -} - -function streamKey(leg: SessionSearchHostLeg): string { - return leg.streamKey ?? leg.executionHostId -} - -/** Every leg answers in the merged order; the cursor and debug are this merge's own. */ -function legRequest(request: AiVaultSearchRequest, sort: MergedSort): AiVaultSearchRequest { - const { cursor: _cursor, debug: _debug, ...rest } = request - return { ...rest, filters: { ...request.filters, sort } } -} - -function newHostWalk(leg: SessionSearchHostLeg, request: AiVaultSearchRequest): HostWalk { - return { - executionHostId: leg.executionHostId, - leg, - request, - outcome: 'unreachable', - cursor: null, - emitted: 0, - generation: 0, - pending: [], - nextCursor: null, - pages: 0, - carry: false, - truncated: { - candidates: false, - snippets: 0, - query: false, - freshness: false - } - } -} - -async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | null): Promise { - walk.cursor = entry?.c ?? null - walk.emitted = entry?.e ?? 0 - walk.generation = entry?.g ?? 0 - walk.pages += 1 - let response: AiVaultSearchResponse - try { - const { cursor } = walk - response = await withLegTimeout( - walk.leg.search(cursor === null ? walk.request : { ...walk.request, cursor }), - walk.leg.timeoutMs - ) - } catch (error) { - console.error(`[ai-vault-search] ${walk.executionHostId} leg failed:`, error) - // Keep its place: the next merged page retries this host from here. - endHostWalk(walk, 'unreachable', true) - return - } - if (response.kind === 'unavailable') { - endHostWalk(walk, response.reason, false) - return + if (!isActive()) { + throw new Error('Session search retired') } - // A cursor this merge minted can only be refused because the host's index - // moved, so both refusals mean the same thing: this host is done for now. - if (response.kind !== 'results') { - endHostWalk(walk, 'stale', false) - return + const hits = await drainMergedPage(walks, limit, sort, isActive, plan) + if (!isActive()) { + throw new Error('Session search retired') } - // `e` is an offset into one generation's ranked page, so it is only - // meaningful while that generation stands. Nothing emitted, nothing to fence. - if (walk.emitted > 0 && walk.generation !== response.generation) { - endHostWalk(walk, 'stale', false) - return + if (plan && walks.some((walk) => walk.outcome === 'stale')) { + return { kind: 'stale-cursor', generation: 0 } } - walk.outcome = 'searched' - walk.generation = response.generation - walk.pending = stampExecutionHost(response.hits, walk.executionHostId).slice(walk.emitted) - walk.nextCursor = response.page.hasMore ? response.page.cursor : null - walk.carry = false - walk.truncated.candidates ||= response.truncated.candidates - walk.truncated.snippets += response.truncated.snippets - walk.truncated.query ||= response.truncated.query - walk.truncated.freshness ||= response.truncated.freshness -} - -function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void { - walk.outcome = outcome - walk.pending = [] - walk.nextCursor = null - walk.carry = carry + return mergedSearchResponse(walks, { limit, sort, plan }, hits, Date.now() - startedAt) } -async function advanceHostWalk(walk: HostWalk): Promise { - while (walk.pending.length === 0 && walk.nextCursor !== null) { +async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise { + while (isActive()) { + while (walk.pending.length && walk.leg.accept && !walk.leg.accept(walk.pending[0]!)) { + walk.pending.shift() + walk.emitted += 1 + } + if (walk.pending.length || walk.nextCursor === null) { + return + } if (walk.pages >= MAX_HOST_PAGES_PER_REQUEST) { - // Budget spent; the unread page's cursor is already this walk's nextCursor. walk.carry = true return } @@ -192,46 +119,58 @@ async function advanceHostWalk(walk: HostWalk): Promise { async function drainMergedPage( walks: readonly HostWalk[], limit: number, - sort: MergedSort + sort: MergedSort, + isActive: () => boolean, + plan?: string ): Promise { const hits: AiVaultSearchHit[] = [] let turn = 0 - while (hits.length < limit) { - // Every head must be known before picking, so a lagging host is never skipped. - for (const walk of walks) { - await advanceHostWalk(walk) + while (hits.length < limit && isActive()) { + const frontiers = plan ? walks.filter((walk) => walk.carry && walk.outcome === 'searched') : [] + if (sort === 'newest' && frontiers.length > 0) { + break } - const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn) + const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn, frontiers) if (!next) { - return hits + break } turn = walks.findIndex((walk) => walk.executionHostId.localeCompare(next.executionHostId) > 0) if (turn < 0) { turn = 0 } - const hit = next.pending.shift()! + hits.push(next.pending.shift()!) next.emitted += 1 - if (!next.leg.accept || next.leg.accept(hit)) { - hits.push(hit) - } + await advanceHostWalk(next, isActive) } return hits } /** Round-robin in host-id order, resuming after whichever host answered last. */ -function walkWithTurn(walks: readonly HostWalk[], turn: number): HostWalk | null { +function walkWithTurn( + walks: readonly HostWalk[], + turn: number, + frontiers: readonly HostWalk[] +): HostWalk | null { for (let step = 0; step < walks.length; step++) { const walk = walks[(turn + step) % walks.length] - if (walk && walk.pending.length > 0) { - return walks - .filter( - (source) => source.executionHostId === walk.executionHostId && source.pending.length - ) - .sort( - (a, b) => - b.pending[0]!.score - a.pending[0]!.score || - streamKey(a.leg).localeCompare(streamKey(b.leg)) - )[0]! + if ( + walk && + walk.pending.length > 0 && + !frontiers.some((source) => source.executionHostId === walk.executionHostId) + ) { + let best = walk + for (const source of walks) { + if ( + source.executionHostId === walk.executionHostId && + source.pending.length && + (source.pending[0]!.score > best.pending[0]!.score || + (source.pending[0]!.score === best.pending[0]!.score && + streamKey(source.leg) < streamKey(best.leg))) + ) { + best = source + } + } + return best } } return null @@ -304,14 +243,6 @@ function nextCursorEntry(walk: HostWalk): MergedSearchCursorEntry | null { return walk.carry ? { c: walk.cursor, e: walk.emitted, g: walk.generation } : null } -// This desktop owns which host it addressed; never trust an id the far side returned. -function stampExecutionHost( - hits: readonly AiVaultSearchHit[], - executionHostId: ExecutionHostId -): AiVaultSearchHit[] { - return hits.map((hit) => ({ ...hit, executionHostId })) -} - function byRecencyDescending(left: AiVaultSearchHit, right: AiVaultSearchHit): number { const leftMs = updatedAtMs(left) const rightMs = updatedAtMs(right) @@ -325,20 +256,3 @@ function updatedAtMs(hit: AiVaultSearchHit): number | null { const parsed = hit.updatedAt === null ? Number.NaN : Date.parse(hit.updatedAt) return Number.isNaN(parsed) ? null : parsed } - -async function withLegTimeout(pending: Promise, timeoutMs: number | undefined): Promise { - if (timeoutMs === undefined) { - return pending - } - let timer: ReturnType | undefined - try { - return await Promise.race([ - pending, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error('Session search host timed out.')), timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } -} diff --git a/src/shared/project-checkout-claim.ts b/src/shared/project-checkout-claim.ts index 7db28137937b..13af343f3fc0 100644 --- a/src/shared/project-checkout-claim.ts +++ b/src/shared/project-checkout-claim.ts @@ -3,10 +3,24 @@ import type { ProjectHostSetup } from './project-types' import { parseWslUncPath } from './wsl-paths' import { isWindowsAbsolutePathLike, normalizeRuntimePathForComparison } from './cross-platform-path' -export function getOutclaimedProjectSetups( - setups: readonly ProjectHostSetup[] -): ReadonlySet { - const claimantBySurface = new Map() +export type ProjectCheckoutClaim = Pick< + ProjectHostSetup, + | 'id' + | 'projectId' + | 'hostId' + | 'repoId' + | 'path' + | 'kind' + | 'setupMethod' + | 'createdAt' + | 'connectionId' + | 'executionHostId' +> + +export function getOutclaimedProjectSetups( + setups: readonly T[] +): ReadonlySet { + const claimantBySurface = new Map() for (const setup of setups) { if (!isDistinctUserCheckout(setup)) { continue @@ -29,16 +43,16 @@ export function getOutclaimedProjectSetups( ) } -function isDistinctUserCheckout(setup: ProjectHostSetup): boolean { +function isDistinctUserCheckout(setup: ProjectCheckoutClaim): boolean { return setup.setupMethod !== 'provisioned' && setup.kind !== 'folder' } // Windows and WSL namespaces compete independently while retaining cross-surface association. -function getProjectSetupSurfaceKey(setup: ProjectHostSetup): string { +function getProjectSetupSurfaceKey(setup: ProjectCheckoutClaim): string { return `${setup.projectId}::${getRepoSetupOwnerScope(setup)}::${getPathSurface(setup)}` } -function getPathSurface(setup: ProjectHostSetup): string { +function getPathSurface(setup: ProjectCheckoutClaim): string { const wslPath = parseWslUncPath(setup.path) return wslPath ? `wsl:${wslPath.distro.toLowerCase()}` @@ -47,12 +61,12 @@ function getPathSurface(setup: ProjectHostSetup): string { : 'default' } -function getCheckoutIdentity(setup: ProjectHostSetup): string { +function getCheckoutIdentity(setup: ProjectCheckoutClaim): string { return normalizeRuntimePathForComparison(setup.path.trim()) || setup.repoId || setup.id } // Stable addedAt/id order makes client and server claims agree despite enumeration order. -function compareClaims(left: ProjectHostSetup, right: ProjectHostSetup): number { +function compareClaims(left: ProjectCheckoutClaim, right: ProjectCheckoutClaim): number { const byAddedAt = left.createdAt - right.createdAt return byAddedAt || (left.repoId < right.repoId ? -1 : left.repoId > right.repoId ? 1 : 0) } diff --git a/src/shared/project-identity-succession.ts b/src/shared/project-identity-succession.ts index 5a10d72d2c72..ac2458173e28 100644 --- a/src/shared/project-identity-succession.ts +++ b/src/shared/project-identity-succession.ts @@ -1,5 +1,6 @@ import { getRepoExecutionHostId } from './execution-host' import type { Project, ProjectHostSetup } from './project-types' +import { getRepoSetupOwnerScope } from './repo-host-identity' export type ProjectIdentitySuccession = { /** Projected projects with user-set state carried forward from their predecessor row. */ @@ -102,6 +103,35 @@ export function carryProjectStateThroughIdentityChange( return carryUserState(project, predecessor) }) if (membership) { + const ownerKey = (setup: ProjectHostSetup) => + `${getRepoSetupOwnerScope(setup)}\0${setup.repoId}` + const currentByOwner = new Map>() + for (const setup of membership.current) { + if (!setup.repoId) { + continue + } + const key = ownerKey(setup) + const ids = currentByOwner.get(key) ?? new Set() + ids.add(setup.projectId) + currentByOwner.set(key, ids) + } + const successors = new Map>() + for (const setup of membership.previous) { + if (projectedIds.has(setup.projectId) || !setup.repoId) { + continue + } + const ids = successors.get(setup.projectId) ?? new Set() + for (const id of currentByOwner.get(ownerKey(setup)) ?? []) { + ids.add(id) + } + successors.set(setup.projectId, ids) + } + for (const [previousId, ids] of successors) { + remappedProjectIds.delete(previousId) + if (ids.size === 1) { + remappedProjectIds.set(previousId, [...ids][0]!) + } + } const local = (setup: ProjectHostSetup) => setup.hostId === 'local' && getRepoExecutionHostId(setup) === 'local' const previousByRepo = new Map() diff --git a/src/shared/project-section-catalog-claims.ts b/src/shared/project-section-catalog-claims.ts new file mode 100644 index 000000000000..789a95050bd1 --- /dev/null +++ b/src/shared/project-section-catalog-claims.ts @@ -0,0 +1,37 @@ +import type { ServedProjectSections } from './project-section-catalog-schema' +import { getOutclaimedProjectSetups } from './project-checkout-claim' +import { getRepoSetupOwnerScope } from './repo-host-identity' +import { getRepoExecutionHostId } from './execution-host' + +/** Old publishers advertise association without the remote metadata used by full hydration. */ +export function resolveServedProjectSectionClaims( + catalog: ServedProjectSections +): ServedProjectSections { + const addedAtByOwner = new Map( + catalog.repos.map((repo) => [ + `${getRepoSetupOwnerScope({ ...repo, hostId: getRepoExecutionHostId(repo) })}\0${repo.id}`, + Number.isFinite(repo.addedAt) ? repo.addedAt! : 0 + ]) + ) + const claims = catalog.setups.map((setup) => ({ + ...setup, + createdAt: addedAtByOwner.get(`${getRepoSetupOwnerScope(setup)}\0${setup.repoId}`) ?? 0 + })) + const outclaimed = getOutclaimedProjectSetups(claims) + if (!outclaimed.size) { + return catalog + } + const projects = new Map(catalog.projects.map((project) => [project.id, project])) + const setups = catalog.setups.map((setup, index) => { + if (!outclaimed.has(claims[index]!)) { + return setup + } + const projectId = `repo:${setup.repoId}` + if (!projects.has(projectId)) { + projects.set(projectId, { id: projectId, displayName: setup.displayName }) + } + return { ...setup, projectId } + }) + const occupied = new Set(setups.map((setup) => setup.projectId)) + return { ...catalog, projects: [...projects.values()].filter((p) => occupied.has(p.id)), setups } +} diff --git a/src/shared/project-section-parity.test-fixture.ts b/src/shared/project-section-parity.test-fixture.ts index 07dd52ab2d08..09f72f8c6b8e 100644 --- a/src/shared/project-section-parity.test-fixture.ts +++ b/src/shared/project-section-parity.test-fixture.ts @@ -106,31 +106,18 @@ export const PROJECT_SECTION_PARITY_ROWS: readonly { { id: 'wt-m4', repoId: 'm4-orca', lastActivityAt: 10 } ] -// Same-host checkouts still need path prefixes; other machines do not. -const STABLY = { - key: 'project:github:stablyai/orca::setup:local-stably', - label: 'stably/orca' -} -const WORKSPACE = { - key: 'project:github:stablyai/orca::setup:local-workspace', - label: 'orca/orca' -} -// Why 'orca', not the server's 'Orca': the later host's derived project name wins the merge. -const WINDOWS = { key: 'project:github:stablyai/orca', label: 'orca' } -const M4 = { key: 'project:github:stablyai/orca::setup:m4-orca', label: 'orca/orca' } -const M4_TWO = { - key: 'project:github:stablyai/orca::setup:m4-orca-two', - label: 'orca-two/orca' -} +const SHARED = { key: 'project:github:stablyai/orca', label: 'stably/orca' } +const WORKSPACE = { key: 'project:repo:local-workspace', label: 'orca/orca' } +const M4_TWO = { key: 'project:repo:m4-orca-two', label: 'orca' } const ASSETS = { key: 'project:repo:local-assets', label: 'design-assets' } export const PROJECT_SECTION_PARITY_HEADERS = { - recent: [M4_TWO, WINDOWS, WORKSPACE, STABLY, M4, ASSETS], - manual: [STABLY, ASSETS, WORKSPACE, WINDOWS, M4, M4_TWO] + recent: [M4_TWO, SHARED, WORKSPACE, ASSETS], + manual: [SHARED, ASSETS, WORKSPACE, M4_TWO] } as const /** What main serves the phone for these answers (the renderer's published catalog). */ -export const PROJECT_SECTION_PARITY_SERVED: MobileProjectSectionCatalog = { +export const OLD_PROJECT_SECTION_PARITY_SERVED: MobileProjectSectionCatalog = { repos: [ { id: 'local-stably', @@ -247,3 +234,19 @@ export const PROJECT_SECTION_PARITY_SERVED: MobileProjectSectionCatalog = { ], projectOrderBy: 'manual' } + +/** Current publisher identities; the old snapshot above remains an accepted peer payload. */ +export const PROJECT_SECTION_PARITY_SERVED: MobileProjectSectionCatalog = { + ...OLD_PROJECT_SECTION_PARITY_SERVED, + projects: [ + { id: 'repo:local-assets', displayName: 'design-assets' }, + { id: 'repo:local-workspace', displayName: 'orca' }, + { id: 'github:stablyai/orca', displayName: 'orca' }, + { id: 'repo:m4-orca-two', displayName: 'orca' } + ], + setups: OLD_PROJECT_SECTION_PARITY_SERVED.setups.map((setup) => + setup.repoId === 'local-workspace' || setup.repoId === 'm4-orca-two' + ? { ...setup, projectId: `repo:${setup.repoId}` } + : setup + ) +} From fbdfb037483ce57601a35be9bd571361f89cb799 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:28:11 -0700 Subject: [PATCH 08/29] fix: scope catalog lint exceptions to renderer files --- .oxlintrc.json | 8 ++++---- config/scripts/project-catalog-read-boundary.test.mjs | 11 +++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/.oxlintrc.json b/.oxlintrc.json index e56ae83bbf51..7d5e048a7c43 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -281,10 +281,10 @@ "src/renderer/src/store/slices/worktrees/teardown/purge-stale-runtime-host-state.ts", "src/renderer/src/store/slices/worktrees/teardown/orphaned-runtime-ssh-project-purge.ts", "src/renderer/src/app-shell/use-runtime-graph-sync.ts", - "**/*.test.{ts,tsx}", - "**/*.spec.{ts,tsx}", - "**/*-test-*.ts", - "**/*.test-fixture.ts", + "src/renderer/src/**/*.test.{ts,tsx}", + "src/renderer/src/**/*.spec.{ts,tsx}", + "src/renderer/src/**/*-test-*.ts", + "src/renderer/src/**/*.test-fixture.ts", "src/renderer/src/components/automations/automations-page-runtime-fixtures.ts" ], "rules": { diff --git a/config/scripts/project-catalog-read-boundary.test.mjs b/config/scripts/project-catalog-read-boundary.test.mjs index a48de903fcf5..a696e96d512f 100644 --- a/config/scripts/project-catalog-read-boundary.test.mjs +++ b/config/scripts/project-catalog-read-boundary.test.mjs @@ -72,6 +72,17 @@ it('enforces five raw catalog forms with the actual config and narrow file excep const uuid = lint(allowed, 'crypto.randomUUID()') expect(uuid).toHaveLength(1) expect(uuid[0].message).toContain('randomUUID') + for (const file of [ + 'src/main/native-chat/agent-session-wire/structured-agent-session-host-test-harness.ts', + 'src/main/runtime/rpc/methods/structured-chat-tab-table.test-fixture.ts', + 'mobile/src/test-support/rpc-recording/native-mounting-substitutes.test.ts', + 'tests/e2e/worktree-smart-sort.spec.ts' + ]) { + expect(lint(file, 'crypto.randomUUID()')).toEqual([]) + } + const sharedUuid = lint('src/shared/tmux-status.test-fixture.ts', 'crypto.randomUUID()') + expect(sharedUuid).toHaveLength(1) + expect(sharedUuid[0].help).toContain('src/shared is compiled into the web bundle') } finally { rmSync(folder, { recursive: true, force: true }) } From ebcaed653d9edd1ea4a9b24a10003fb634ee15c0 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:59:45 -0700 Subject: [PATCH 09/29] Resolve project search ownership before native candidate limits --- .../session-search-current-owner.test.ts | 431 ++++++++++++++++-- .../session-search-engine-types.ts | 2 + .../ai-vault-search/session-search-engine.ts | 30 +- .../session-search-ownership-races.test.ts | 131 ++++++ .../session-search-page-cursor.ts | 21 +- .../session-search-project-ownership.ts | 254 +++++++++++ .../session-search-row-filter.ts | 8 + .../session-search-scope-catalog.ts | 15 +- .../session-search-scope-entry-points.test.ts | 7 +- .../session-search-scope-paths.ts | 41 +- .../session-search-scope-resolution.test.ts | 58 ++- .../session-search-scope-resolution.ts | 15 +- .../session-search-scope-service.test.ts | 7 +- .../session-search-service-registry.ts | 56 ++- .../ai-vault-search/session-search-service.ts | 15 +- ...session-search-store-scope-catalog.test.ts | 41 +- .../session-search-store-scope-catalog.ts | 24 +- .../runtime-session-search-call.test.ts | 41 +- .../ai-vault/runtime-session-search-call.ts | 27 +- .../runtime-session-search-lifetime.test.ts | 69 +++ ...ion-scanner-service-search-process.test.ts | 186 ++++++++ .../session-scanner-service-search.test.ts | 25 + src/main/ai-vault/session-scope-real-paths.ts | 5 +- ...-vault-search-all-hosts-real-index.test.ts | 5 +- .../ipc/ai-vault-search-all-hosts.test.ts | 20 +- src/main/ipc/ai-vault-search-all-hosts.ts | 327 +++++++++++++ .../ipc/ai-vault-search-merged-cursor.test.ts | 5 +- .../ipc}/ai-vault-search-merged-cursor.ts | 21 +- src/main/ipc/ai-vault-search.test.ts | 29 ++ src/main/ipc/ai-vault-search.ts | 11 +- .../register-core-handlers.ts | 7 +- .../mobile-relay-e2ee.integration.test.ts | 72 ++- .../runtime-rpc-device-key-dispatch.test.ts | 57 ++- src/main/sqlite/sync-database.ts | 4 + ...utomationsPage.create-destination.test.tsx | 5 +- .../automations/automations-page-fixtures.ts | 2 +- .../automations-page-runtime-fixtures.ts | 4 +- .../automations-page-test-harness.tsx | 5 +- .../AiVaultPanel.legacy-filter.test.tsx | 6 +- .../components/right-sidebar/AiVaultPanel.tsx | 19 +- .../right-sidebar/ai-vault-project-key.ts | 45 +- .../ai-vault-project-ownership.test.ts | 44 +- .../ai-vault-session-project-candidates.ts | 44 ++ .../ai-vault-session-projects.ts | 142 +++--- .../project-search-lifetime.test.tsx | 209 +++------ .../use-ai-vault-panel-workspace.ts | 18 +- .../use-ai-vault-search.test.tsx | 73 +-- .../right-sidebar/use-ai-vault-search.ts | 28 +- .../terminal-drop-handler.test.ts | 28 +- .../agent-background-session-test-state.ts | 16 +- ...ai-vault-resume-command.drop-repin.test.ts | 35 +- ...ult-resume-command.resumable-agent.test.ts | 35 +- .../src/lib/ai-vault-resume-command.test.ts | 31 +- .../src/lib/ai-vault-resume-shell.test.ts | 23 +- .../src/lib/http-link-routing.test.ts | 3 +- .../src/lib/local-preflight-context.test.ts | 194 +++++--- .../src/lib/local-preflight-context.ts | 29 +- .../lib/local-project-runtime-workspace.ts | 7 + .../src/store/project-host-setup-selector.ts | 26 +- .../src/store/project-search-catalog.ts | 12 - .../worktrees-removal-state-cleanup.test.ts | 29 +- .../listing/worktree-host-ownership.ts | 4 +- .../src/web/preload-api/web-ai-vault-api.ts | 43 +- .../web-ai-vault-search-compat.test.ts | 8 +- .../src/web/preload-api/web-runtime-calls.ts | 6 +- .../web/web-preload-api-runtime-calls.test.ts | 96 ++++ src/shared/ai-vault-project-search.test.ts | 249 ---------- src/shared/ai-vault-project-search.ts | 199 -------- src/shared/ai-vault-runtime-capabilities.ts | 4 + src/shared/ai-vault-search-host-page.ts | 152 ------ src/shared/ai-vault-search-page-walk.ts | 258 ----------- src/shared/ai-vault-search-scope.ts | 6 +- src/shared/git-remote-identity.ts | 10 + .../project-catalog-canonical-projection.ts | 92 ++++ .../project-host-setup-hydration.test.ts | 2 +- src/shared/project-host-setup-hydration.ts | 168 ++++--- src/shared/project-host-setup-lookup.ts | 13 +- .../project-host-setup-projection.test.ts | 6 +- src/shared/project-host-setup-projection.ts | 44 +- .../sidebar-list/sidebar-project-catalog.ts | 2 +- src/shared/worktree/ownership.ts | 30 +- .../e2e/ai-vault-project-native.unit.test.ts | 222 +++++++++ ...ault-project-source-lifecycle.unit.test.ts | 347 ++++++++++++++ 83 files changed, 3500 insertions(+), 1640 deletions(-) create mode 100644 src/main/ai-vault-search/session-search-ownership-races.test.ts create mode 100644 src/main/ai-vault-search/session-search-project-ownership.ts create mode 100644 src/main/ai-vault/runtime-session-search-lifetime.test.ts create mode 100644 src/main/ai-vault/session-scanner-service-search-process.test.ts create mode 100644 src/main/ipc/ai-vault-search-all-hosts.ts rename src/{shared => main/ipc}/ai-vault-search-merged-cursor.ts (72%) delete mode 100644 src/renderer/src/store/project-search-catalog.ts delete mode 100644 src/shared/ai-vault-project-search.test.ts delete mode 100644 src/shared/ai-vault-project-search.ts delete mode 100644 src/shared/ai-vault-search-host-page.ts delete mode 100644 src/shared/ai-vault-search-page-walk.ts create mode 100644 src/shared/project-catalog-canonical-projection.ts create mode 100644 tests/e2e/ai-vault-project-native.unit.test.ts create mode 100644 tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts diff --git a/src/main/ai-vault-search/session-search-current-owner.test.ts b/src/main/ai-vault-search/session-search-current-owner.test.ts index 8ffac4e1a2a8..182ad984ba0d 100644 --- a/src/main/ai-vault-search/session-search-current-owner.test.ts +++ b/src/main/ai-vault-search/session-search-current-owner.test.ts @@ -1,43 +1,408 @@ -import { expect, it } from 'vitest' +import { mkdtemp, mkdir, symlink, rm, realpath } from 'node:fs/promises' +import { RUNTIME_CAPABILITIES } from '../../shared/protocol-version' +import { AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY } from '../../shared/ai-vault-runtime-capabilities' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' import type { Repo } from '../../shared/repo-types' -import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection' -import { resolveProjectHostSetupProjection } from '../../shared/project-host-setup-hydration' -import { resolveSessionSearchScope } from './session-search-scope-resolution' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' +import { + compileSessionSearchProjectOwnership, + sessionSearchProjectOwns +} from './session-search-project-ownership' +import { sessionSearchScopeCatalogFromStore } from './session-search-store-scope-catalog' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from './session-search-scope-catalog' +import { createSessionSearchService } from './session-search-service' +import { createChildSessionSearchService } from './session-search-child-service' +import { searchSessionService, setSessionSearchService } from './session-search-service-registry' +import { + openSessionSearchHarness, + addSyntheticSession, + markFork, + type SessionSearchHarness +} from './session-search-engine-test-fixture' -it('uses current checkout affiliation while retaining registered and prior native paths', () => { - const remote = { +const a: Repo = { + id: 'a', + displayName: 'app', + path: '/repos/a', + addedAt: 1, + badgeColor: '', + gitRemoteIdentity: { canonicalKey: 'example.org/team/app', remoteName: 'origin', remoteUrl: 'https://example.org/team/app.git' } - const a: Repo = { - id: 'a', - displayName: 'app', - path: '/repos/a', - addedAt: 1, - badgeColor: '', - gitRemoteIdentity: remote - } - const b: Repo = { ...a, id: 'b', path: '/repos/b', addedAt: 2 } - const derived = projectHostSetupProjectionFromRepos([a, b]) - const shared = derived.setups.find((s) => s.repoId === a.id)!.projectId - const projection = resolveProjectHostSetupProjection( - [a, b], - [{ ...derived.projects[0]!, id: shared, sourceRepoIds: ['a', 'b'] }], - derived.setups.map((s) => ({ ...s, projectId: shared })) +} +const b: Repo = { ...a, id: 'b', path: '/repos/b', addedAt: 2 } +let harness: SessionSearchHarness | undefined +afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await harness?.close() + harness = undefined + vi.restoreAllMocks() +}) + +function store(repos = [a, b]) { + const projection = projectHostSetupCompatibilityFromRepos(repos) + return { + getRepos: () => repos, + getProjects: () => projection.projects, + getProjectHostSetups: () => projection.setups, + getAllWorktreeMeta: () => ({ + 'b::/linked/b': { + displayName: '', + comment: '', + linkedIssue: null, + linkedPR: null, + linkedLinearIssue: null, + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 0, + projectId: projection.setups[0].projectId, + projectHostSetupId: 'b', + priorWorktreeIds: ['b::/old/b'] + } + }), + getSettings: () => ({ nestWorkspaces: false, workspaceDir: '' }) + } +} + +it('selects current ownership before candidate pressure and fork folding through the store, registry and child boundary', async () => { + expect(RUNTIME_CAPABILITIES).toContain(AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY) + harness = await openSessionSearchHarness('sta10541-native', { sessionCandidateLimit: 2 }) + for (let id = 1; id <= 10; id++) { + addSyntheticSession(harness.db, { + id, + cwd: a.path, + updatedAt: '2026-10-22', + text: 'ownership needle' + }) + } + addSyntheticSession(harness.db, { + id: 11, + cwd: b.path, + updatedAt: '2026-10-21', + text: 'ownership needle' + }) + addSyntheticSession(harness.db, { + id: 12, + cwd: '/linked/b', + updatedAt: '2026-10-20', + text: 'ownership needle' + }) + markFork(harness.db, [1, 11], 'shared-opening') + const native = createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + setSessionSearchService( + createChildSessionSearchService({ + search: native.search, + reconcile: native.reconcile, + status: native.status + }) ) - const catalog = { - repos: [a, b], - projects: projection.projects, - projectHostSetups: projection.setups, - worktreeMeta: { 'b::/linked/b': { repoId: 'b', priorWorktreeIds: ['b::/old/b'] } }, - settings: { nestWorkspaces: false, workspaceDir: '' } - } - expect(resolveSessionSearchScope({ kind: 'project', projectKey: 'repo:b' }, catalog)).toEqual({ - kind: 'resolved', - paths: expect.arrayContaining(['/repos/b', '/linked/b', '/old/b']) + const source = store() + let catalog = sessionSearchScopeCatalogFromStore(source, 'local') + installSessionSearchScopeCatalogSource(() => catalog) + const projectKey = source.getProjects()[0].id + const request = { + query: 'ownership needle', + limit: 1, + within: { kind: 'project', projectKey: `project:${projectKey}`, canonicalProjectKey: 'repo:b' } + } + const first = await searchSessionService(request, 'ipc') + expect(first).toMatchObject({ + kind: 'results', + hits: [{ sessionId: '11' }], + page: { hasMore: true } + }) + if (first.kind !== 'results' || !first.page.cursor) { + throw new Error('missing native continuation') + } + expect( + await searchSessionService({ ...request, cursor: first.page.cursor }, 'ipc') + ).toMatchObject({ kind: 'results', hits: [{ sessionId: '12' }] }) + expect(await searchSessionService({ ...request, query: 'path:repos' }, 'ipc')).toMatchObject({ + kind: 'results', + hits: [{ sessionId: '11' }] }) expect( - resolveSessionSearchScope({ kind: 'project', projectKey: `project:${shared}` }, catalog) - ).toEqual({ kind: 'resolved', paths: ['/repos/a'] }) + await searchSessionService( + { ...request, query: 'different needle', cursor: first.page.cursor }, + 'ipc' + ) + ).toMatchObject({ kind: 'malformed-cursor' }) + expect( + await searchSessionService( + { + ...request, + within: { ...request.within, canonicalProjectKey: request.within.projectKey }, + cursor: first.page.cursor + }, + 'ipc' + ) + ).toMatchObject({ kind: 'malformed-cursor' }) + catalog = { + ...catalog, + worktreeMeta: { ...catalog.worktreeMeta, 'a::/linked/b': { projectHostSetupId: 'a' } } + } + expect( + await searchSessionService({ ...request, cursor: first.page.cursor }, 'ipc') + ).toMatchObject({ kind: 'stale-cursor' }) + const legacy = await searchSessionService( + { query: 'ownership needle', within: { kind: 'project', projectKey: `project:${projectKey}` } }, + 'ipc' + ) + expect(legacy).toMatchObject({ kind: 'results', truncated: { candidates: true } }) +}) + +it('uses literal nested regions and source namespaces without changing legacy cwd keys', async () => { + const repos: Repo[] = [ + { ...a, id: 'ubuntu', path: '//wsl.localhost/Ubuntu/home/ada/app' }, + { ...a, id: 'debian', gitRemoteIdentity: undefined, path: '//wsl$/Debian/home/ada/app' }, + { ...b, id: 'nested', path: '//wsl.localhost/Ubuntu/home/ada/app/nested' }, + { + ...a, + id: 'drive', + projectHostSetupMethod: 'provisioned', + path: '//wsl.localhost/Ubuntu/mnt/c/Case_%/app' + }, + { ...a, id: 'unicode', path: '/repos/Café_%', projectHostSetupMethod: 'provisioned' }, + { ...a, id: 'native-drive', path: 'C:/Native_%', projectHostSetupMethod: 'provisioned' } + ] + const source = store(repos) + const catalog = { ...sessionSearchScopeCatalogFromStore(source, 'local'), worktreeMeta: {} } + const project = source.getProjectHostSetups().find((row) => row.repoId === 'ubuntu')!.projectId + const selection = await compileSessionSearchProjectOwnership(catalog, `project:${project}`) + harness = await openSessionSearchHarness('sta10541-native-namespaces') + const cases = [ + ['/home/ada/app', '//wsl$/Ubuntu/home/ada/.claude/session', true], + ['/home/ada/app', '//wsl.localhost/Debian/home/ada/.claude/session', false], + ['/home/ada/app/nested/topic', '//wsl$/Ubuntu/session', false], + ['/home/ada/app-other', '//wsl$/Ubuntu/session', false], + ['/mnt/c/CASE_%/app/topic', '/native/session', true], + ['/mnt/c/CaseXY/app/topic', '/native/session', false], + ['c:/case_%/app/topic', '/native/session', false], + ['/repos/Café_%/topic', '/native/session', true], + ['/repos/café_%/topic', '/native/session', false], + ['/repos/CaféXY/topic', '/native/session', false], + ['c:/native_%/topic', '/native/session', true], + ['C:/NativeXY/topic', '/native/session', false], + ['//wsl$/Ubuntu/home/ada/app/topic', '/native/session', true] + ] as const + for (const [index, [cwd, sessionFilePath]] of cases.entries()) { + addSyntheticSession(harness.db, { id: index + 1, cwd, sessionFilePath }) + } + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => catalog) + for (const query of ['needle', 'path:/']) { + const response = await searchSessionService( + { + query, + within: { + kind: 'project', + projectKey: `project:${project}`, + canonicalProjectKey: `project:${project}` + } + }, + 'ipc' + ) + expect(response.kind === 'results' && response.hits.map((hit) => hit.sessionId).sort()).toEqual( + cases.flatMap((row, index) => (row[2] ? [String(index + 1)] : [])).sort() + ) + } + expect(sessionSearchProjectOwns(selection, '/home/ada/app', '//wsl$/Ubuntu/session')).toBe(true) +}) + +it('keeps layout history canonical-only and absent intent at actual legacy native results', async () => { + harness = await openSessionSearchHarness('sta10541-legacy-layout') + addSyntheticSession(harness.db, { id: 1, cwd: a.path }) + addSyntheticSession(harness.db, { id: 2, cwd: '/new/a/topic' }) + addSyntheticSession(harness.db, { id: 3, cwd: '/old/a/topic' }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + const source = store([a]) + const catalog = { + ...sessionSearchScopeCatalogFromStore(source, 'local'), + worktreeMeta: {}, + settings: { + workspaceDir: '/new', + nestWorkspaces: true, + workspaceDirHistory: [{ path: '/old', nestWorkspaces: true }] + } + } + installSessionSearchScopeCatalogSource(() => catalog) + const projectKey = `project:${source.getProjects()[0].id}` + const request = { query: 'needle', within: { kind: 'project', projectKey } } + const legacy = await searchSessionService(request, 'ipc') + expect(legacy.kind === 'results' && legacy.hits.map((hit) => hit.sessionId).sort()).toEqual([ + '1', + '2' + ]) + const canonical = await searchSessionService( + { ...request, within: { ...request.within, canonicalProjectKey: projectKey } }, + 'ipc' + ) + expect(canonical.kind === 'results' && canonical.hits.map((hit) => hit.sessionId).sort()).toEqual( + ['1', '2', '3'] + ) +}) + +it('gives a current workspace its native rows when another owner retains a prior symlink spelling', async () => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'sta10541-reuse-'))) + try { + const actual = join(root, 'current') + const alias = join(root, 'prior') + await mkdir(actual) + await symlink(actual, alias, 'junction') + const repos = [ + { ...a, path: join(root, 'a') }, + { ...b, path: join(root, 'b') } + ] + const source = store(repos) + const template = source.getAllWorktreeMeta()['b::/linked/b'] + const catalog = { + ...sessionSearchScopeCatalogFromStore(source, 'local'), + worktreeMeta: { + [`a::${repos[0].path}/new`]: { + ...template, + projectHostSetupId: 'a', + priorWorktreeIds: [`a::${alias}`] + }, + [`b::${actual}`]: { ...template, projectHostSetupId: 'b', priorWorktreeIds: [] } + } + } + harness = await openSessionSearchHarness('sta10541-current-alias') + addSyntheticSession(harness.db, { id: 1, cwd: `${actual}/topic` }) + addSyntheticSession(harness.db, { id: 2, cwd: `${alias}/topic` }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => catalog) + const within = { + kind: 'project', + projectKey: `project:${source.getProjects()[0].id}`, + canonicalProjectKey: 'repo:b' + } + const result = await searchSessionService({ query: 'needle', within }, 'ipc') + expect(result.kind === 'results' && result.hits.map((hit) => hit.sessionId).sort()).toEqual([ + '1', + '2' + ]) + const former = await searchSessionService( + { query: 'needle', within: { ...within, canonicalProjectKey: within.projectKey } }, + 'ipc' + ) + expect(former.kind === 'results' && former.hits).toEqual([]) + } finally { + await rm(root, { recursive: true, force: true }) + } +}) + +it('answers one complete provisioned Project above 64 roots before pressure and folds its forks once', async () => { + const eligible: Repo[] = Array.from({ length: 80 }, (_, index) => ({ + ...a, + id: `eligible-${index}`, + path: `/roots/eligible-${index}`, + addedAt: index, + ...(index ? { projectHostSetupMethod: 'provisioned' as const } : {}) + })) + const source = store([...eligible, { ...b, path: '/roots/independent', addedAt: 100 }]) + harness = await openSessionSearchHarness('sta10541-whole-union', { sessionCandidateLimit: 81 }) + for (let id = 1; id <= 80; id++) { + addSyntheticSession(harness.db, { + id, + cwd: eligible[id - 1].path, + text: 'ownership needle', + updatedAt: '2026-10-20' + }) + } + for (let id = 81; id <= 240; id++) { + addSyntheticSession(harness.db, { + id, + cwd: '/roots/independent', + text: 'ownership needle', + updatedAt: '2026-10-21' + }) + } + markFork(harness.db, [1, 2, 81], 'union-opening') + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => ({ + ...sessionSearchScopeCatalogFromStore(source, 'local'), + worktreeMeta: {} + })) + const projectKey = `project:${source.getProjects()[0].id}` + for (const query of [ + '"ownership needle"', + 'ownership needle', + 'ownership OR needle', + 'path:roots' + ]) { + const response = await searchSessionService( + { + query, + limit: 100, + within: { kind: 'project', projectKey, canonicalProjectKey: projectKey } + }, + 'ipc' + ) + expect(response.kind).toBe('results') + if (response.kind !== 'results') { + throw new Error('missing native result') + } + expect(response.hits).toHaveLength(79) + expect(response.hits.every((hit) => Number(hit.sessionId) <= 80)).toBe(true) + expect(response.page.hasMore).toBe(false) + expect(response.truncated.candidates).toBe(false) + } }) diff --git a/src/main/ai-vault-search/session-search-engine-types.ts b/src/main/ai-vault-search/session-search-engine-types.ts index 57229deb90ae..f82e4a2439d2 100644 --- a/src/main/ai-vault-search/session-search-engine-types.ts +++ b/src/main/ai-vault-search/session-search-engine-types.ts @@ -1,3 +1,4 @@ +import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import type { AiVaultAgent } from '../../shared/ai-vault-types' import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers' @@ -37,6 +38,7 @@ export type SessionSearchScope = 'conversation' | 'all' export type SessionSearchSort = 'relevance' | 'newest' export type SessionSearchFilters = { + ownership?: SessionSearchProjectOwnership agents?: readonly AiVaultAgent[] /** Only sessions whose cwd is that path or inside it. */ scopePaths?: readonly string[] diff --git a/src/main/ai-vault-search/session-search-engine.ts b/src/main/ai-vault-search/session-search-engine.ts index 499b7c71e9f5..5c8466bf81b5 100644 --- a/src/main/ai-vault-search/session-search-engine.ts +++ b/src/main/ai-vault-search/session-search-engine.ts @@ -38,6 +38,8 @@ import { import { sessionRowFilter } from './session-search-row-filter' import { ensureSessionSearchQuerySchema } from './session-search-query-schema' import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet' +import { sessionSearchProjectOwns } from './session-search-project-ownership' +import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import { sessionSourcePresence } from './session-search-source-presence' /** @@ -76,11 +78,22 @@ export type SessionSearchEngineOptions = { export class SessionSearchEngine { private readonly retrieval: SessionSearchRetrieval private readonly candidateLimit: number + private ownership?: SessionSearchProjectOwnership constructor( private readonly db: SyncDatabase, private readonly options: SessionSearchEngineOptions = {} ) { + this.db.function('canonical_project_owns', { deterministic: true }, (cwd, filePath) => + Number( + Boolean( + this.ownership && + typeof cwd === 'string' && + typeof filePath === 'string' && + sessionSearchProjectOwns(this.ownership, cwd, filePath) + ) + ) + ) this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT // Installed here and not on the first search, so the generation triggers are // watching before anything this engine will be asked to page over is @@ -94,6 +107,7 @@ export class SessionSearchEngine { } search(request: SessionSearchRequest): SessionSearchResponse { + this.ownership = request.filters?.ownership const startedAt = performance.now() ensureSessionSearchQuerySchema(this.db) const generation = readIndexGeneration(this.db) @@ -115,7 +129,13 @@ export class SessionSearchEngine { // cost a query, and the caller has to hear about it either way. const pageKey = sessionSearchPageKey(request) const offset = request.cursor - ? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation) + ? decodeSessionSearchCursor( + request.cursor, + generation, + pageKey, + incarnation, + this.ownership?.fingerprint + ) : 0 const plan = planSessionSearchQuery(split.text) @@ -142,7 +162,13 @@ export class SessionSearchEngine { page: { hasMore, cursor: hasMore - ? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation) + ? encodeSessionSearchCursor( + generation, + offset + limit, + pageKey, + incarnation, + this.ownership?.fingerprint + ) : null }, truncated: { diff --git a/src/main/ai-vault-search/session-search-ownership-races.test.ts b/src/main/ai-vault-search/session-search-ownership-races.test.ts new file mode 100644 index 000000000000..691ff4999ec8 --- /dev/null +++ b/src/main/ai-vault-search/session-search-ownership-races.test.ts @@ -0,0 +1,131 @@ +import * as fs from 'node:fs/promises' +import { afterEach, expect, it, vi } from 'vitest' +import type { Repo } from '../../shared/repo-types' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests, + type SessionSearchScopeCatalog +} from './session-search-scope-catalog' +import { createSessionSearchService } from './session-search-service' +import { searchSessionService, setSessionSearchService } from './session-search-service-registry' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from './session-search-engine-test-fixture' + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn(actual.realpath) } +}) +let harness: SessionSearchHarness | undefined +afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + vi.restoreAllMocks() + await harness?.close() + harness = undefined +}) +function latch() { + let release: () => void = () => undefined + const promise = new Promise((resolve) => { + release = resolve + }) + return { promise, release } +} + +it('captures after freshness and rejects governing ownership changes across alias and child awaits without new work', async () => { + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + })) + const raw = projectHostSetupCompatibilityFromRepos(repos) + let catalog: SessionSearchScopeCatalog = { + repos, + projects: raw.projects, + projectHostSetups: raw.setups, + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + } + installSessionSearchScopeCatalogSource(() => catalog) + harness = await openSessionSearchHarness('sta10541-ownership-await') + addSyntheticSession(harness.db, { id: 1, cwd: '/after-freshness' }) + const fresh = latch() + const reconciling = latch() + const native = createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => { + reconciling.release() + await fresh.promise + } + } + }) + const admission = vi.spyOn(native, 'search') + setSessionSearchService(native) + const request = { + query: 'needle', + within: { + kind: 'project', + projectKey: `project:${raw.projects[0].id}`, + canonicalProjectKey: 'repo:b' + } + } + const first = searchSessionService({ ...request, freshness: 'wait-until-current' }, 'ipc') + await reconciling.promise + catalog = { + ...catalog, + worktreeMeta: { + 'b::/after-freshness': { projectHostSetupId: 'b', projectId: raw.projects[0].id } + } + } + fresh.release() + expect(await first).toMatchObject({ kind: 'results', hits: [{ sessionId: '1' }] }) + expect(admission).toHaveBeenCalledTimes(1) + + const aliasEntered = latch() + const aliasRelease = latch() + const realpath = vi.mocked(fs.realpath) + const delegate = realpath.getMockImplementation()! + realpath.mockImplementationOnce(async (...args) => { + aliasEntered.release() + await aliasRelease.promise + return delegate(...args) + }) + const aliasRace = searchSessionService(request, 'ipc') + await aliasEntered.promise + repos[1].path = '/repos/b-moved' + aliasRelease.release() + expect(await aliasRace).toMatchObject({ kind: 'stale-cursor' }) + expect(admission).toHaveBeenCalledTimes(1) + + const childEntered = latch() + const childRelease = latch() + const child = { + ...native, + search: vi.fn(async (...args: Parameters) => { + const result = await native.search(...args) + childEntered.release() + await childRelease.promise + return result + }) + } + setSessionSearchService(child) + const childRace = searchSessionService(request, 'ipc') + await childEntered.promise + catalog = { + ...catalog, + worktreeMeta: { ...catalog.worktreeMeta, 'a::/after-freshness': { projectHostSetupId: 'a' } } + } + childRelease.release() + expect(await childRace).toMatchObject({ kind: 'stale-cursor' }) + expect(child.search).toHaveBeenCalledTimes(1) +}) diff --git a/src/main/ai-vault-search/session-search-page-cursor.ts b/src/main/ai-vault-search/session-search-page-cursor.ts index 8d5b60c73e5f..77d8e2144e6a 100644 --- a/src/main/ai-vault-search/session-search-page-cursor.ts +++ b/src/main/ai-vault-search/session-search-page-cursor.ts @@ -19,6 +19,7 @@ export class SessionSearchCursorError extends Error { type CursorPayload = { /** Database incarnation; changes when the index is rebuilt. */ + p?: string i: string /** Index generation. */ g: number @@ -47,7 +48,8 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string { filters.sort ?? 'relevance', filters.since ?? null, [...(filters.agents ?? [])].sort(), - [...(filters.scopePaths ?? [])].sort() + [...(filters.scopePaths ?? [])].sort(), + ...(filters.ownership ? [filters.ownership.projectKey] : []) ]) return createHash('sha256').update(identity).digest('base64url').slice(0, 16) } @@ -56,9 +58,16 @@ export function encodeSessionSearchCursor( generation: number, offset: number, key: string, - incarnation: string + incarnation: string, + ownership?: string ): string { - const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key } + const payload: CursorPayload = { + i: incarnation, + g: generation, + o: offset, + k: key, + ...(ownership ? { p: ownership } : {}) + } return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url') } @@ -74,7 +83,8 @@ export function decodeSessionSearchCursor( cursor: string, generation: number, key: string, - incarnation: string + incarnation: string, + ownership?: string ): number { let payload: CursorPayload try { @@ -108,6 +118,9 @@ export function decodeSessionSearchCursor( if (payload.i !== incarnation) { throw new SessionSearchCursorError('stale-generation', generation, claimed) } + if (payload.p !== ownership) { + throw new SessionSearchCursorError('stale-generation', generation, claimed) + } if (payload.k !== key) { throw new SessionSearchCursorError('different-query', generation, claimed) } diff --git a/src/main/ai-vault-search/session-search-project-ownership.ts b/src/main/ai-vault-search/session-search-project-ownership.ts new file mode 100644 index 000000000000..91d84c58abf1 --- /dev/null +++ b/src/main/ai-vault-search/session-search-project-ownership.ts @@ -0,0 +1,254 @@ +import { createHash } from 'node:crypto' +import { + normalizeRuntimePathForComparison, + isRuntimePathAbsolute +} from '../../shared/cross-platform-path' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { resolveProjectHostSetupProjection } from '../../shared/project-catalog-canonical-projection' +import { getProjectHostSetupLookup } from '../../shared/project-host-setup-lookup' +import { toAiVaultProjectKey } from '../../shared/ai-vault-project-key' +import { splitWorktreeIdForFilesystem } from '../../shared/worktree/id' +import { parseWslUncPath, toWindowsWslPath } from '../../shared/wsl-paths' +import { sessionFileExecutionNamespace } from '../../shared/ai-vault-scope-cwd-match' +import { resolveAiVaultScopeRealPaths } from '../ai-vault/session-scope-real-paths' +import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' +import { managedWorktreeDirectories } from './session-search-scope-paths' + +// Half-open BINARY intervals, including exact roots separately from descendants. +type Interval = [low: string, high: string, owner: string | null, priority: number] +type Rule = { + path: string + owner: string | null + workspace: boolean + prior?: boolean + namespace?: string +} +export type SessionSearchProjectOwnership = { + projectKey: string + fingerprint: string + planes: Record + ranges: [string, string][] +} +const compare = (a: string, b: string) => Buffer.compare(Buffer.from(a), Buffer.from(b)) +const key = (path: string) => normalizeRuntimePathForComparison(path).toWellFormed() +export const sessionSearchCatalogFingerprint = (catalog: SessionSearchScopeCatalog | null) => + createHash('sha256').update(JSON.stringify(catalog)).digest('base64url') + +export async function compileSessionSearchProjectOwnership( + catalog: SessionSearchScopeCatalog, + projectKey: string +): Promise { + const projection = resolveProjectHostSetupProjection( + catalog.repos, + catalog.projects, + catalog.projectHostSetups + ) + const owners = getProjectHostSetupLookup(projection.setups) + const rules: Rule[] = [] + const add = ( + path: string | undefined, + owner: string | null, + workspace = false, + prior = false + ) => { + if (path && isRuntimePathAbsolute(path)) { + rules.push({ path, owner, workspace, prior }) + } + } + const local = (host: string) => host === (catalog.executionHostId ?? 'local') + for (const setup of projection.setups) { + // Nested SSH is a separate filesystem even when its transport belongs to this runtime. + const host = setup.connectionId + ? `ssh:${encodeURIComponent(setup.connectionId)}` + : (setup.executionHostId ?? setup.hostId) + if (local(host)) { + add(setup.path, toAiVaultProjectKey(setup.projectId, setup.repoId)) + } + } + for (const repo of catalog.repos) { + const host = repo.connectionId + ? `ssh:${encodeURIComponent(repo.connectionId)}` + : getRepoExecutionHostId(repo) + if (!local(host)) { + continue + } + const owner = toAiVaultProjectKey(owners.projectId({ repoId: repo.id, hostId: host }), repo.id) + add(repo.path, owner) + for (const path of managedWorktreeDirectories(repo, catalog.settings)) { + add(path, owner) + } + } + const retained: { rule: Rule; prior: readonly string[]; repoId: string }[] = [] + for (const [id, meta] of Object.entries(catalog.worktreeMeta)) { + const parsed = splitWorktreeIdForFilesystem(id) + if (!parsed) { + continue + } + const owner = toAiVaultProjectKey( + owners.projectId({ + ...meta, + repoId: parsed.repoId, + hostId: meta.hostId ?? catalog.executionHostId ?? 'local' + }), + parsed.repoId + ) + const rule = { path: parsed.worktreePath, owner, workspace: true } + rules.push(rule) + retained.push({ rule, prior: meta.priorWorktreeIds ?? [], repoId: parsed.repoId }) + } + for (const { rule, prior, repoId } of retained) { + for (const id of prior) { + const parsed = splitWorktreeIdForFilesystem(id) + if (parsed?.repoId === repoId) { + add(parsed.worktreePath, rule.owner, true, true) + } + } + } + const pathByKey = new Map() + for (const rule of rules) { + const normalized = key(rule.path) + if (!pathByKey.has(normalized)) { + pathByKey.set(normalized, rule.path) + } + } + const aliases = await resolveAiVaultScopeRealPaths([...pathByKey.values()]) + const realByPath = new Map(aliases.links.map((link) => [key(link.scopePath), key(link.realPath)])) + const groups = new Map() + for (const rule of rules) { + const root = realByPath.get(key(rule.path)) ?? key(rule.path) + const entries = groups.get(root) ?? [] + entries.push(rule) + groups.set(root, entries) + } + rules.length = 0 + for (const [root, entries] of groups) { + const current = entries.filter((rule) => !rule.prior) + const active = current.length ? current : entries + rules.push(...active) + // Registered spellings share current occupancy; never resolve arbitrary session cwd. + const workspace = active.some((rule) => rule.workspace) + const owners = new Set( + active.filter((rule) => rule.workspace === workspace).map((rule) => rule.owner) + ) + const owner = owners.size === 1 ? owners.values().next().value! : null + for (const path of new Set([root, ...entries.map((rule) => key(rule.path))])) { + rules.push({ path, owner, workspace, namespace: 'native' }) + } + } + const planes = new Map>() + const insert = (plane: string, path: string, rule: Rule, mounted = false) => { + const root = key(path) + const roots = planes.get(plane) ?? new Map() + const priority = + (root.split('/').filter(Boolean).length + (mounted ? 1 : 0)) * 2 + Number(rule.workspace) + const prior = roots.get(root) + if (!prior || prior.priority < priority) { + roots.set(root, { owner: rule.owner, priority }) + } else if (prior.priority === priority && prior.owner !== rule.owner) { + prior.owner = null + } + planes.set(plane, roots) + } + for (const rule of rules) { + insert(rule.namespace ?? '', rule.path, rule) + const wsl = parseWslUncPath(rule.path) + if (!wsl) { + continue + } + if (/^\/mnt\/[A-Za-z](?:\/|$)/.test(wsl.linuxPath)) { + insert('mounted', toWindowsWslPath(wsl.linuxPath, wsl.distro), rule, true) + } else { + insert(`wsl:${wsl.distro.toLowerCase()}`, wsl.linuxPath, rule) + } + } + const compiled = Object.fromEntries( + [...planes].map(([plane, roots]) => [plane, intervals(roots)]) + ) + const ranges: [string, string][] = [] + for (const [plane, entries] of Object.entries(compiled)) { + for (const [low, high, owner] of entries) { + if (owner === projectKey && plane !== 'mounted') { + ranges.push([low, high]) + } + } + if (plane === 'mounted' && entries.some((entry) => entry[2] === projectKey)) { + ranges.push(['/mnt/', '/mnt0']) + } + } + const fingerprint = createHash('sha256') + .update(JSON.stringify([compiled, aliases.links])) + .digest('base64url') + return { projectKey, fingerprint, planes: compiled, ranges } +} + +function intervals(roots: Map): Interval[] { + const events: { at: string; end?: string; value?: Interval }[] = [] + for (const [root, { owner, priority }] of roots) { + const prefix = root.endsWith('/') ? root : `${root}/` + for (const [low, high] of [ + [root, `${root}\0`], + [prefix, `${prefix.slice(0, -1)}0`] + ]) { + events.push({ at: low, end: high, value: [low, high, owner, priority] }, { at: high }) + } + } + events.sort( + (a, b) => compare(a.at, b.at) || (a.end && b.end ? -compare(a.end, b.end) : a.end ? 1 : -1) + ) + const stack: Interval[] = [] + const result: Interval[] = [] + let previous = '' + for (const event of events) { + const active = stack.at(-1) + if (active && compare(previous, event.at) < 0) { + result.push([previous, event.at, active[2], active[3]]) + } + while (stack.length && compare(stack.at(-1)![1], event.at) <= 0) { + stack.pop() + } + if (event.value) { + stack.push(event.value) + } + previous = event.at + } + return result +} + +/** Binary searches precompiled ownership, never the catalog, for each indexed candidate. */ +export function sessionSearchProjectOwns( + selection: SessionSearchProjectOwnership, + cwd: string, + filePath: string +): boolean { + const find = (plane: string, value: string): Interval | undefined => { + const entries = selection.planes[plane] ?? [] + let low = 0, + high = entries.length + while (low < high) { + const middle = (low + high) >>> 1 + if (compare(entries[middle][0], value) <= 0) { + low = middle + 1 + } else { + high = middle + } + } + const found = entries[low - 1] + return found && compare(value, found[1]) < 0 ? found : undefined + } + const candidates = [find('', cwd), find(sessionFileExecutionNamespace(filePath), cwd)] + if (/^\/mnt\/[A-Za-z](?:\/|$)/.test(cwd)) { + candidates.push(find('mounted', key(toWindowsWslPath(cwd, '')))) + } + let best: Interval | undefined + for (const candidate of candidates) { + if (!candidate) { + continue + } + if (!best || candidate[3] > best[3]) { + best = candidate + } else if (candidate[3] === best[3] && candidate[2] !== best[2]) { + best = [best[0], best[1], null, best[3]] + } + } + return best?.[2] === selection.projectKey +} diff --git a/src/main/ai-vault-search/session-search-row-filter.ts b/src/main/ai-vault-search/session-search-row-filter.ts index a268a06ef8b7..e310c0d0ef95 100644 --- a/src/main/ai-vault-search/session-search-row-filter.ts +++ b/src/main/ai-vault-search/session-search-row-filter.ts @@ -58,6 +58,14 @@ export function sessionRowFilter( filter.values.push(JSON.stringify(keys.map(scopeRange))) } } + if (filters.ownership) { + filter.conditions.push(`id IN ( + SELECT scoped.id FROM json_each(?) AS scope JOIN sessions AS scoped + ON scoped.cwd_key >= scope.value ->> 0 AND scoped.cwd_key < scope.value ->> 1 + WHERE canonical_project_owns(scoped.cwd_key, scoped.file_path) + )`) + filter.values.push(JSON.stringify(filters.ownership.ranges)) + } return filter } diff --git a/src/main/ai-vault-search/session-search-scope-catalog.ts b/src/main/ai-vault-search/session-search-scope-catalog.ts index 3d93346d5df0..ee598f6bd8b6 100644 --- a/src/main/ai-vault-search/session-search-scope-catalog.ts +++ b/src/main/ai-vault-search/session-search-scope-catalog.ts @@ -1,3 +1,4 @@ +import type { ExecutionHostId } from '../../shared/execution-host' import type { GlobalSettings } from '../../shared/global-settings-types' import type { Project, ProjectHostSetup } from '../../shared/project-types' import type { Repo } from '../../shared/repo-types' @@ -7,16 +8,10 @@ import type { WorktreeMeta } from '../../shared/worktree/meta-types' // Persisted state, never a git scan: a handful of prefixes answers a search that // enumerating hundreds of working trees would not. export type SessionSearchScopeCatalog = { - repos: readonly Pick< - Repo, - 'id' | 'path' | 'kind' | 'worktreeBasePath' | 'connectionId' | 'executionHostId' - >[] - projects: readonly Pick[] - projectHostSetups: readonly (Pick< - ProjectHostSetup, - 'projectId' | 'repoId' | 'path' | 'worktreeBasePath' - > & - Partial>)[] + repos: readonly Repo[] + projects: readonly Project[] + projectHostSetups: readonly ProjectHostSetup[] + executionHostId?: ExecutionHostId /** Registered workspaces of this host, keyed by worktree id. */ worktreeMeta: Readonly< Record< diff --git a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts index 43b1b4ba8774..cda28b2a2165 100644 --- a/src/main/ai-vault-search/session-search-scope-entry-points.test.ts +++ b/src/main/ai-vault-search/session-search-scope-entry-points.test.ts @@ -7,12 +7,13 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime' import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault' import { installSessionSearchScopeCatalogSource, - resetSessionSearchScopeCatalogForTests + resetSessionSearchScopeCatalogForTests, + type SessionSearchScopeCatalog } from './session-search-scope-catalog' import { searchSessionService, setSessionSearchService } from './session-search-service-registry' -const CATALOG = { - repos: [{ id: 'repo-1', path: '/work/app' }], +const CATALOG: SessionSearchScopeCatalog = { + repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], projects: [], projectHostSetups: [], worktreeMeta: {}, diff --git a/src/main/ai-vault-search/session-search-scope-paths.ts b/src/main/ai-vault-search/session-search-scope-paths.ts index 8e0e6f4d3f91..74a5cf5840dc 100644 --- a/src/main/ai-vault-search/session-search-scope-paths.ts +++ b/src/main/ai-vault-search/session-search-scope-paths.ts @@ -1,16 +1,9 @@ import { - getRuntimePathBasename, isPathInsideOrEqual, isRuntimePathAbsolute, - normalizeRuntimePathForComparison, - resolveRuntimePath + normalizeRuntimePathForComparison } from '../../shared/cross-platform-path' -import { isFolderRepo } from '../../shared/repo-kind' -import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path' -import { buildKnownOrcaWorkspaceLayouts } from '../../shared/worktree/ownership' -import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' - -type ScopeRepo = SessionSearchScopeCatalog['repos'][number] +export { managedWorktreeDirectories } from '../../shared/worktree/ownership' /** Absolute paths in insertion order, deduplicated by the comparison key. */ export class ScopePathSet { @@ -39,33 +32,3 @@ export class ScopePathSet { ) } } - -/** - * The directories Orca creates this repo's worktrees in, past and present, where - * such a directory belongs to this repo alone. - * - * A global root counts only under nesting: flat placement makes it every - * project's, and claiming it would widen a project search to the whole machine. - * An empty result costs only the folding — every registered worktree is still listed. - */ -export function managedWorktreeDirectories( - repo: ScopeRepo, - settings: SessionSearchScopeCatalog['settings'] -): string[] { - if (isFolderRepo(repo)) { - return [] - } - const configured = new Set( - resolveConfiguredWorktreeBasePaths(repo).map(normalizeRuntimePathForComparison) - ) - const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/, '') - const directories: string[] = [] - for (const layout of buildKnownOrcaWorkspaceLayouts(settings, repo)) { - if (configured.has(normalizeRuntimePathForComparison(layout.path))) { - directories.push(layout.path) - } else if (layout.nestWorkspaces && repoName) { - directories.push(resolveRuntimePath(layout.path, repoName)) - } - } - return directories -} diff --git a/src/main/ai-vault-search/session-search-scope-resolution.test.ts b/src/main/ai-vault-search/session-search-scope-resolution.test.ts index c139fa8fcefa..5a9489bf2c5d 100644 --- a/src/main/ai-vault-search/session-search-scope-resolution.test.ts +++ b/src/main/ai-vault-search/session-search-scope-resolution.test.ts @@ -1,10 +1,11 @@ import { describe, expect, it } from 'vitest' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' import { resolveSessionSearchScope } from './session-search-scope-resolution' function catalog(overrides: Partial = {}): SessionSearchScopeCatalog { return { - repos: [{ id: 'repo-1', path: '/work/app' }], + repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], projects: [], projectHostSetups: [], worktreeMeta: {}, @@ -82,7 +83,18 @@ describe('workspace scope', () => { kind: 'workspace', worktreeId: 'folder-1::/work/notes::workspace:11111111-1111-4111-8111-111111111111' }, - catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] }) + catalog({ + repos: [ + { + id: 'folder-1', + path: '/work/notes', + kind: 'folder', + displayName: 'notes', + badgeColor: '', + addedAt: 0 + } + ] + }) ) expect(paths(resolution)).toEqual(['/work/notes']) }) @@ -137,7 +149,7 @@ describe('project scope', () => { it("uses a repo's own worktree base path instead of the global root", () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'repo:repo-1' }, - catalog({ repos: [{ id: 'repo-1', path: '/work/app', worktreeBasePath: '/trees/app' }] }) + catalog({ repos: [{ ...catalog().repos[0], worktreeBasePath: '/trees/app' }] }) ) // The global nested root stays in: a worktree created before the base path // was set still lives there, which is why ownership enumerates both. @@ -191,10 +203,20 @@ describe('project scope', () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'project:proj-1' }, catalog({ - repos: [{ id: 'repo-1', path: '/srv/app' }], - projects: [{ id: 'proj-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...catalog().repos[0], path: '/srv/app' }], + projects: [ + { + id: 'proj-1', + sourceRepoIds: ['repo-1'], + displayName: 'app', + badgeColor: '', + createdAt: 0, + updatedAt: 0 + } + ], projectHostSetups: [ { + ...projectHostSetupCompatibilityFromRepos(catalog().repos).setups[0], projectId: 'proj-1', repoId: 'repo-1', path: '/srv/app', @@ -211,7 +233,18 @@ describe('project scope', () => { it('takes a folder workspace project as its folder', () => { const resolution = resolveSessionSearchScope( { kind: 'project', projectKey: 'repo:folder-1' }, - catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] }) + catalog({ + repos: [ + { + id: 'folder-1', + path: '/work/notes', + kind: 'folder', + displayName: 'notes', + badgeColor: '', + addedAt: 0 + } + ] + }) ) expect(paths(resolution)).toEqual(['/work/notes']) }) @@ -226,7 +259,18 @@ describe('project scope', () => { expect( resolveSessionSearchScope( { kind: 'project', projectKey: 'project:proj-1' }, - catalog({ projects: [{ id: 'proj-1', sourceRepoIds: ['repo-elsewhere'] }] }) + catalog({ + projects: [ + { + id: 'proj-1', + sourceRepoIds: ['repo-elsewhere'], + displayName: 'app', + badgeColor: '', + createdAt: 0, + updatedAt: 0 + } + ] + }) ) ).toEqual({ kind: 'unknown' }) }) diff --git a/src/main/ai-vault-search/session-search-scope-resolution.ts b/src/main/ai-vault-search/session-search-scope-resolution.ts index 81b6d4c6eb6b..af3ddc7f5bab 100644 --- a/src/main/ai-vault-search/session-search-scope-resolution.ts +++ b/src/main/ai-vault-search/session-search-scope-resolution.ts @@ -1,4 +1,3 @@ -import { getProjectHostSetupLookup } from '../../shared/project-host-setup-lookup' import { toAiVaultProjectKey } from '../../shared/ai-vault-project-key' import type { AiVaultSearchScopeIdentity } from '../../shared/ai-vault-search-scope' import { @@ -10,7 +9,6 @@ import { import { areRuntimePathsEqual } from '../../shared/worktree/ownership' import type { SessionSearchScopeCatalog } from './session-search-scope-catalog' import type { SessionSearchHostScope } from './session-search-service' -import { getRepoExecutionHostId } from '../../shared/execution-host' import { managedWorktreeDirectories, ScopePathSet } from './session-search-scope-paths' type ScopeRepo = SessionSearchScopeCatalog['repos'][number] @@ -75,8 +73,6 @@ function registeredWorktreeId( function resolveProjectScope(projectKey: string, catalog: SessionSearchScopeCatalog): string[] { const paths = new ScopePathSet() const repoIds = new Set() - const owners = getProjectHostSetupLookup(catalog.projectHostSetups) - const reposById = new Map(catalog.repos.map((repo) => [repo.id, repo])) for (const setup of catalog.projectHostSetups) { if (toAiVaultProjectKey(setup.projectId, setup.repoId) === projectKey) { repoIds.add(setup.repoId) @@ -88,14 +84,7 @@ function resolveProjectScope(projectKey: string, catalog: SessionSearchScopeCata for (const project of catalog.projects) { if (project.id === projectId) { for (const repoId of project.sourceRepoIds) { - const repo = reposById.get(repoId) - const owner = owners.forWorkspace({ - repoId, - hostId: repo ? getRepoExecutionHostId(repo) : undefined - }) - if (!owner || toAiVaultProjectKey(owner.projectId, repoId) === projectKey) { - repoIds.add(repoId) - } + repoIds.add(repoId) } } } @@ -105,7 +94,7 @@ function resolveProjectScope(projectKey: string, catalog: SessionSearchScopeCata } for (const [worktreeId, meta] of Object.entries(catalog.worktreeMeta)) { const repoId = getRepoIdFromWorktreeId(worktreeId) - if (toAiVaultProjectKey(owners.projectId({ ...meta, repoId }) ?? null, repoId) === projectKey) { + if (toAiVaultProjectKey(meta.projectId ?? null, repoId) === projectKey) { repoIds.add(repoId) } } diff --git a/src/main/ai-vault-search/session-search-scope-service.test.ts b/src/main/ai-vault-search/session-search-scope-service.test.ts index a1239570be2c..05d4cdb8519b 100644 --- a/src/main/ai-vault-search/session-search-scope-service.test.ts +++ b/src/main/ai-vault-search/session-search-scope-service.test.ts @@ -3,12 +3,13 @@ import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contrac import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture' import { installSessionSearchScopeCatalogSource, - resetSessionSearchScopeCatalogForTests + resetSessionSearchScopeCatalogForTests, + type SessionSearchScopeCatalog } from './session-search-scope-catalog' import { searchSessionService, setSessionSearchService } from './session-search-service-registry' -const CATALOG = { - repos: [{ id: 'repo-1', path: '/work/app' }], +const CATALOG: SessionSearchScopeCatalog = { + repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }], projects: [], projectHostSetups: [], worktreeMeta: { 'repo-1::/work/app': {} }, diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index 02c448e11dac..926fa700a88c 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -6,6 +6,12 @@ import { } from '../../shared/ai-vault-search-contract' import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client' import { sessionSearchScopeCatalog } from './session-search-scope-catalog' +import { resolveProjectHostSetupProjection } from '../../shared/project-host-setup-hydration' +import { getRepoExecutionHostId } from '../../shared/execution-host' +import { + compileSessionSearchProjectOwnership, + sessionSearchCatalogFingerprint +} from './session-search-project-ownership' import { resolveSessionSearchScope } from './session-search-scope-resolution' import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' import { @@ -62,20 +68,56 @@ export async function searchSessionService( agents: compatibleAgents } } - const hostScope = within - ? resolveSessionSearchScope(within, sessionSearchScopeCatalog()) - : undefined - const retrievalScope = - compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' - ? { kind: 'resolved' as const, paths: [''] } - : hostScope const freshness = request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false + const catalog = sessionSearchScopeCatalog() + const snapshot = sessionSearchCatalogFingerprint(catalog) + const canonical = within?.kind === 'project' && within.canonicalProjectKey + const selection = + canonical && catalog ? await compileSessionSearchProjectOwnership(catalog, canonical) : null + const legacy = + catalog && + within && + !canonical && + resolveProjectHostSetupProjection(catalog.repos, catalog.projects, catalog.projectHostSetups) + const legacyCatalog = + catalog && legacy + ? { + ...catalog, + settings: { + workspaceDir: catalog.settings.workspaceDir, + nestWorkspaces: catalog.settings.nestWorkspaces + }, + projects: legacy.projects, + projectHostSetups: legacy.setups.filter( + (setup) => setup.hostId === (catalog.executionHostId ?? 'local') + ), + repos: catalog.repos.filter( + (repo) => getRepoExecutionHostId(repo) === (catalog.executionHostId ?? 'local') + ) + } + : null + const hostScope = selection + ? { kind: 'canonical' as const, selection } + : within + ? resolveSessionSearchScope(within, legacyCatalog) + : undefined + const retrievalScope = + compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' + ? { kind: 'resolved' as const, paths: [''] } + : hostScope + const moved = () => snapshot !== sessionSearchCatalogFingerprint(sessionSearchScopeCatalog()) + if (canonical && moved()) { + return { kind: 'stale-cursor', generation: 0 } + } const result = AiVaultSearchResponseSchema.parse( await current.search(compatibleRequest, retrievalScope) ) + if (canonical && moved()) { + return { kind: 'stale-cursor', generation: result.kind === 'results' ? result.generation : 0 } + } if (result.kind !== 'results') { return result } diff --git a/src/main/ai-vault-search/session-search-service.ts b/src/main/ai-vault-search/session-search-service.ts index fbf37394fe76..43d0715b8f2a 100644 --- a/src/main/ai-vault-search/session-search-service.ts +++ b/src/main/ai-vault-search/session-search-service.ts @@ -1,3 +1,4 @@ +import type { SessionSearchProjectOwnership } from './session-search-project-ownership' import type { AiVaultSearchRequest, AiVaultSearchResponse, @@ -17,6 +18,10 @@ import { SessionSearchCursorError } from './session-search-page-cursor' */ export type SessionSearchHostScope = | { kind: 'resolved'; paths: readonly string[] } + | { + kind: 'canonical' + selection: SessionSearchProjectOwnership + } | { kind: 'unknown' } export type SessionSearchService = { @@ -49,7 +54,15 @@ export function createSessionSearchService({ try { const result = engine.search( hostScope - ? { ...request, filters: { ...request.filters, scopePaths: hostScope.paths } } + ? { + ...request, + filters: { + ...request.filters, + ...(hostScope.kind === 'canonical' + ? { ownership: hostScope.selection } + : { scopePaths: hostScope.paths }) + } + } : request ) return { diff --git a/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts b/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts index 98bac3f46675..7c213c1d6821 100644 --- a/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts +++ b/src/main/ai-vault-search/session-search-store-scope-catalog.test.ts @@ -1,4 +1,10 @@ import { describe, expect, it } from 'vitest' +import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection' +import type { Repo } from '../../shared/repo-types' +import { + compileSessionSearchProjectOwnership, + sessionSearchProjectOwns +} from './session-search-project-ownership' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import { sessionSearchScopeCatalogFromStore } from './session-search-store-scope-catalog' @@ -19,16 +25,22 @@ function meta(hostId: WorktreeMeta['hostId']): WorktreeMeta { } function store() { + const repos: Repo[] = [ + { id: 'local-repo', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }, + { + id: 'ssh-repo', + path: '/srv/app', + connectionId: 'box', + displayName: 'app', + badgeColor: '', + addedAt: 1 + } + ] + const projection = projectHostSetupCompatibilityFromRepos(repos) return { - getRepos: () => [ - { id: 'local-repo', path: '/work/app' }, - { id: 'ssh-repo', path: '/srv/app', connectionId: 'box' } - ], - getProjects: () => [{ id: 'proj-1', sourceRepoIds: ['local-repo'] }], - getProjectHostSetups: () => [ - { projectId: 'proj-1', hostId: 'local' as const, repoId: 'local-repo', path: '/work/app' }, - { projectId: 'proj-1', hostId: 'ssh:box' as const, repoId: 'ssh-repo', path: '/srv/app' } - ], + getRepos: () => repos, + getProjects: () => projection.projects, + getProjectHostSetups: () => projection.setups, getAllWorktreeMeta: () => ({ 'local-repo::/work/one': meta('local'), 'ssh-repo::/srv/one': meta('ssh:box') @@ -38,16 +50,19 @@ function store() { } describe('scope catalog from the profile store', () => { - it('keeps only the rows the addressed execution host owns', () => { + it('captures every Repo/setup before projection and applies filesystem eligibility afterwards', async () => { const local = sessionSearchScopeCatalogFromStore(store(), 'local') - expect(local.repos.map((repo) => repo.id)).toEqual(['local-repo']) - expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo']) + expect(local.repos.map((repo) => repo.id)).toEqual(['local-repo', 'ssh-repo']) + expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo', 'ssh-repo']) + const selection = await compileSessionSearchProjectOwnership(local, 'repo:local-repo') + expect(sessionSearchProjectOwns(selection, '/work/app', '/native/session')).toBe(true) + expect(sessionSearchProjectOwns(selection, '/srv/app', '/native/session')).toBe(false) expect(Object.keys(local.worktreeMeta)).toEqual(['local-repo::/work/one']) }) it('answers for an SSH host from that host’s own rows, not the desktop’s', () => { const remote = sessionSearchScopeCatalogFromStore(store(), 'ssh:box') - expect(remote.repos.map((repo) => repo.id)).toEqual(['ssh-repo']) + expect(remote.repos.map((repo) => repo.id)).toEqual(['local-repo', 'ssh-repo']) expect(Object.keys(remote.worktreeMeta)).toEqual(['ssh-repo::/srv/one']) }) diff --git a/src/main/ai-vault-search/session-search-store-scope-catalog.ts b/src/main/ai-vault-search/session-search-store-scope-catalog.ts index cb9a1968dcdb..013dcfbe14b5 100644 --- a/src/main/ai-vault-search/session-search-store-scope-catalog.ts +++ b/src/main/ai-vault-search/session-search-store-scope-catalog.ts @@ -1,8 +1,4 @@ -import { - getRepoExecutionHostId, - normalizeExecutionHostId, - type ExecutionHostId -} from '../../shared/execution-host' +import type { ExecutionHostId } from '../../shared/execution-host' import type { ProjectHostSetup } from '../../shared/project-types' import type { WorktreeMeta } from '../../shared/worktree/meta-types' import type { GlobalSettings } from '../../shared/global-settings-types' @@ -21,23 +17,25 @@ export type SessionSearchScopeStore = { })[] getAllWorktreeMeta(): Record getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record - getSettings(): Pick + getSettings(): Pick } -// Filtered by host: a desktop's store also holds its SSH and runtime hosts' -// catalogs, whose directories do not exist on this machine. +// Claims need the complete catalog; filesystem eligibility is applied after projection. export function sessionSearchScopeCatalogFromStore( store: SessionSearchScopeStore, executionHostId: ExecutionHostId ): SessionSearchScopeCatalog { const settings = store.getSettings() return { - repos: store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === executionHostId), + repos: store.getRepos(), + executionHostId, projects: store.getProjects(), - projectHostSetups: store - .getProjectHostSetups() - .filter((setup) => normalizeExecutionHostId(setup.hostId) === executionHostId), + projectHostSetups: store.getProjectHostSetups(), worktreeMeta: readAllWorktreeMetaForHost(store, executionHostId), - settings: { workspaceDir: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces } + settings: { + workspaceDir: settings.workspaceDir, + nestWorkspaces: settings.nestWorkspaces, + workspaceDirHistory: settings.workspaceDirHistory + } } } diff --git a/src/main/ai-vault/runtime-session-search-call.test.ts b/src/main/ai-vault/runtime-session-search-call.test.ts index d70135448593..f1e329834223 100644 --- a/src/main/ai-vault/runtime-session-search-call.test.ts +++ b/src/main/ai-vault/runtime-session-search-call.test.ts @@ -1,11 +1,15 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const callRuntimeEnvironment = vi.hoisted(() => vi.fn()) +const { callRuntimeEnvironment, resolveEnvironment } = vi.hoisted(() => ({ + callRuntimeEnvironment: vi.fn(), + resolveEnvironment: vi.fn() +})) +vi.mock('../../shared/runtime-environment-store', () => ({ resolveEnvironment })) vi.mock('../ipc/runtime-environments/runtime-environment-transport-routing', () => ({ callRuntimeEnvironment })) -import { callRuntimeSessionSearch } from './runtime-session-search-call' +import { callRuntimeSessionSearch, bindRuntimeSessionSearch } from './runtime-session-search-call' beforeEach(() => { callRuntimeEnvironment.mockReset() @@ -37,3 +41,36 @@ describe('runtime session search transport', () => { ).rejects.toMatchObject({ code: 'method_not_found', message: 'unknown method' }) }) }) + +it('captures the selected pairing/runtime before status and reuses it for search and continuation', async () => { + const selected = { id: 'env-1', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-a' } + resolveEnvironment.mockReturnValue(selected) + const call = bindRuntimeSessionSearch('/user/data', 'env-1') + selected.pairingRevision = 8 + selected.runtimeId = 'runtime-b' + callRuntimeEnvironment.mockResolvedValue({ ok: true, result: {} }) + for (const [method, params] of [ + ['aiVault.searchStatus', {}], + [ + 'aiVault.searchSessions', + { + query: 'needle', + within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' } + } + ], + ['aiVault.searchSessions', { query: 'needle', cursor: 'next' }] + ] as const) { + await call(method, params) + expect(callRuntimeEnvironment).toHaveBeenLastCalledWith( + '/user/data', + 'env-1', + method, + params, + undefined, + 7, + undefined, + { expectedEnvironmentRuntimeId: 'runtime-a' } + ) + } + expect(resolveEnvironment).toHaveBeenCalledOnce() +}) diff --git a/src/main/ai-vault/runtime-session-search-call.ts b/src/main/ai-vault/runtime-session-search-call.ts index 3655bebc8d0f..ad2a5a8604e2 100644 --- a/src/main/ai-vault/runtime-session-search-call.ts +++ b/src/main/ai-vault/runtime-session-search-call.ts @@ -1,3 +1,4 @@ +import { resolveEnvironment } from '../../shared/runtime-environment-store' import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing' // Why: runtime RPC failures resolve as ok:false, but the shared search client @@ -7,11 +8,33 @@ export async function callRuntimeSessionSearch( userDataPath: string, environmentId: string, method: string, - params: Record + params: Record, + expected?: { pairingRevision: number; runtimeId?: string } ): Promise { - const response = await callRuntimeEnvironment(userDataPath, environmentId, method, params) + const response = expected + ? await callRuntimeEnvironment( + userDataPath, + environmentId, + method, + params, + undefined, + expected.pairingRevision, + undefined, + { expectedEnvironmentRuntimeId: expected.runtimeId } + ) + : await callRuntimeEnvironment(userDataPath, environmentId, method, params) if (response.ok === true) { return response.result } throw Object.assign(new Error(response.error.message), { code: response.error.code }) } + +export function bindRuntimeSessionSearch(userDataPath: string, environmentId: string) { + const environment = resolveEnvironment(userDataPath, environmentId) + const expected = { + pairingRevision: environment.pairingRevision ?? environment.createdAt, + runtimeId: environment.runtimeId ?? undefined + } + return (method: string, params: Record) => + callRuntimeSessionSearch(userDataPath, environment.id, method, params, expected) +} diff --git a/src/main/ai-vault/runtime-session-search-lifetime.test.ts b/src/main/ai-vault/runtime-session-search-lifetime.test.ts new file mode 100644 index 000000000000..a54e8df71bc9 --- /dev/null +++ b/src/main/ai-vault/runtime-session-search-lifetime.test.ts @@ -0,0 +1,69 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it, vi } from 'vitest' +import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' +import { encodePairingOffer } from '../../shared/pairing' +import { + addEnvironmentFromPairingCode, + markEnvironmentUsed, + updateEnvironmentFromPairingCode +} from '../../shared/runtime-environment-store' +import { enqueueRuntimeCall } from '../ipc/runtime-environments/runtime-environment-call-queue' +import { bindRuntimeSessionSearch } from './runtime-session-search-call' + +const send = vi.hoisted(() => vi.fn()) +vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } })) +vi.mock('../ipc/runtime-environments/runtime-environment-abortable-requests', () => ({ + sendRemoteRuntimeConnectionRequestAbortable: send, + sendRemoteRuntimeRequestAbortable: send +})) + +it.each(['pairing', 'runtime'] as const)( + 'refuses a queued search after the selected %s changes before dispatch', + async (change) => { + const root = await mkdtemp(join(tmpdir(), 'sta10541-search-queue-')) + let release: () => void = () => {} + const held = new Promise((resolve) => { + release = resolve + }) + const blockers: Promise[] = [] + try { + send.mockClear() + const pairingCode = encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:9', + deviceToken: 'a'.repeat(48), + publicKeyB64: publicKeyToBase64(generateKeyPair().publicKey) + }) + const env = addEnvironmentFromPairingCode(root, { name: 'search-host', pairingCode }) + markEnvironmentUsed(root, env.id, { runtimeId: 'runtime-a' }) + const call = bindRuntimeSessionSearch(root, env.id) + for (let index = 0; index < 8; index++) { + blockers.push(enqueueRuntimeCall(env.id, 'aiVault.searchSessions', () => held)) + } + const result = call('aiVault.searchSessions', { + query: 'needle', + within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' } + }).then( + () => { + throw new Error('changed target accepted') + }, + (error: unknown) => error + ) + if (change === 'pairing') { + updateEnvironmentFromPairingCode(root, env.id, { pairingCode }) + } else { + markEnvironmentUsed(root, env.id, { runtimeId: 'runtime-b' }) + } + release() + await Promise.all(blockers) + expect(await result).toMatchObject({ code: 'runtime_environment_changed' }) + expect(send).not.toHaveBeenCalled() + } finally { + release() + await Promise.all(blockers) + await rm(root, { recursive: true, force: true }) + } + } +) diff --git a/src/main/ai-vault/session-scanner-service-search-process.test.ts b/src/main/ai-vault/session-scanner-service-search-process.test.ts new file mode 100644 index 000000000000..2469246909eb --- /dev/null +++ b/src/main/ai-vault/session-scanner-service-search-process.test.ts @@ -0,0 +1,186 @@ +import { spawnProcess } from '@orca/process-host' +import type { ChildProcessHandle } from '@orca/process-host/process-spec' +import { once } from 'node:events' +import { createRequire } from 'node:module' +import { mkdir } from 'node:fs/promises' +import { join, resolve } from 'node:path' +import { expect, it } from 'vitest' +import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' +import type { Repo } from '../../shared/repo-types' +import { SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT } from '../ai-vault-search/session-search-engine' +import { + addSyntheticSession, + markFork +} from '../ai-vault-search/session-search-engine-test-fixture' +import { openSessionSearchIndexerHarness } from '../ai-vault-search/session-search-indexer-test-fixture' +import { compileSessionSearchProjectOwnership } from '../ai-vault-search/session-search-project-ownership' +import { openSessionSearchDatabase } from '../ai-vault-search/session-search-schema' +import { AiVaultScannerServiceClient } from './session-scanner-service-client' +import { buildAiVaultServiceEnv } from './session-scanner-service-env' +import { isAiVaultServiceChildMessage } from './session-scanner-service-protocol' + +it('serializes canonical ownership into a separate production scanner before native candidate caps and fork folding', async () => { + const harness = await openSessionSearchIndexerHarness('sta10541-scanner-process') + const home = join(harness.root, 'child-home') + await mkdir(home) + const executable = + process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.env.npm_node_execpath ?? process.execPath + const entry = resolve('src/main/ai-vault/session-scanner-service-entry.ts') + const loader = createRequire(import.meta.url).resolve('tsx') + let child: ChildProcessHandle | undefined + let readyPid: number | undefined + let closed: Promise | undefined + let stderr = '' + const client = new AiVaultScannerServiceClient({ + processFactory: () => { + expect(child).toBeUndefined() + const startedAt = new Date().toISOString() + child = spawnProcess({ + program: executable, + args: ['--max-old-space-size=384', '--conditions=orca-source', '--import', loader, entry], + serialization: 'json', + stdio: ['ignore', 'ignore', 'pipe', 'ipc'], + env: { + ...buildAiVaultServiceEnv({ PATH: process.env.PATH, HOME: home, USERPROFILE: home }), + ORCA_BACKGROUND_LAUNCH: '1', + ORCA_DISABLE_MACOS_LOGIN_SHELL: '1', + ORCA_E2E_USER_DATA_DIR: join(home, 'user-data'), + ORCA_E2E_HOME_DIR: home, + XDG_CONFIG_HOME: join(home, 'config'), + XDG_CACHE_HOME: join(home, 'cache'), + XDG_DATA_HOME: join(home, 'data'), + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEX_HOME: join(home, '.codex') + } + }) + closed = once(child, 'close') + child.on('message', (message: unknown) => { + if (isAiVaultServiceChildMessage(message) && message.type === 'ready') { + readyPid = message.pid + } + }) + console.info('scanner child ownership', { + pid: child.pid, + parentPid: process.pid, + startedAt, + executable, + entry + }) + return child + }, + resolveSessionSearchRoots: async () => harness.roots, + init: () => ({ + sessionParseCache: null, + sessionSearch: { + databasePath: harness.databasePath, + roots: harness.roots, + settings: { enabled: true, historyDays: null } + } + }), + onStderr: (text) => { + stderr += text + } + }) + try { + await client.request({ type: 'request', operation: 'searchReconcile' }) + expect(readyPid, stderr).toBe(child?.pid) + expect(readyPid).toBeGreaterThan(0) + expect(readyPid).not.toBe(process.pid) + const repos: Repo[] = ['a', 'b'].map((id, index) => ({ + id, + path: `/repos/${id}`, + displayName: id, + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' } + })) + const selection = await compileSessionSearchProjectOwnership( + { + repos, + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + }, + 'repo:b' + ) + // Seed only after the child's initial real reconciliation; no parser or engine is replaced. + const db = openSessionSearchDatabase(harness.databasePath) + const target = SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT + 2 + try { + db.exec('BEGIN') + for (let id = 1; id < target; id++) { + addSyntheticSession(db, { + id, + cwd: '/repos/a', + text: 'childboundary', + updatedAt: '2026-10-23' + }) + } + addSyntheticSession(db, { + id: target, + cwd: '/repos/b', + text: 'childboundary', + updatedAt: '2026-10-21' + }) + addSyntheticSession(db, { + id: target + 1, + cwd: '/repos/b/topic', + text: 'childboundary', + updatedAt: '2026-10-20' + }) + markFork(db, [1, target], 'shared-child-opening') + db.exec('COMMIT') + } finally { + db.close() + } + const request = { query: 'childboundary', limit: 1, filters: { sort: 'newest' as const } } + const legacy = await client.request({ + type: 'request', + operation: 'searchSessions', + request + }) + expect(legacy).toMatchObject({ kind: 'results', hits: [{ sessionId: '1' }] }) + const scoped = await client.request({ + type: 'request', + operation: 'searchSessions', + request, + hostScope: { kind: 'canonical', selection } + }) + expect(scoped).toMatchObject({ + kind: 'results', + hits: [{ sessionId: String(target) }], + page: { hasMore: true } + }) + if (scoped.kind !== 'results' || !scoped.page.cursor) { + throw new Error(`Missing canonical child continuation: ${stderr}`) + } + expect( + await client.request({ + type: 'request', + operation: 'searchSessions', + request: { ...request, cursor: scoped.page.cursor }, + hostScope: { kind: 'canonical', selection } + }) + ).toMatchObject({ + kind: 'results', + hits: [{ sessionId: String(target + 1) }], + page: { hasMore: false } + }) + } finally { + client.dispose() + const force = setTimeout(() => { + if (child && child.exitCode === null && child.signalCode === null) { + child.kill('SIGKILL') + } + }, 5_000) + try { + await closed + expect(child?.exitCode, stderr).toBe(0) + expect(child?.signalCode, stderr).toBeNull() + } finally { + clearTimeout(force) + await harness.cleanup() + } + } +}) diff --git a/src/main/ai-vault/session-scanner-service-search.test.ts b/src/main/ai-vault/session-scanner-service-search.test.ts index 2508556d47ef..ad25a4967ee4 100644 --- a/src/main/ai-vault/session-scanner-service-search.test.ts +++ b/src/main/ai-vault/session-scanner-service-search.test.ts @@ -1,3 +1,4 @@ +import { compileSessionSearchProjectOwnership } from '../ai-vault-search/session-search-project-ownership' import { existsSync, rmSync } from 'node:fs' import { join } from 'node:path' import { afterAll, beforeAll, expect, it, vi } from 'vitest' @@ -139,6 +140,30 @@ it('answers a search and a reconcile over the protocol', async () => { } }) +it('executes the canonical selection through the actual child message executor and native index', async () => { + const selection = await compileSessionSearchProjectOwnership( + { + repos: [{ id: 'actual', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + }, + 'repo:actual' + ) + const response = await call({ + type: 'request', + operation: 'searchSessions', + request: { query: 'distinctive' }, + hostScope: { kind: 'canonical', selection } + }) + expect(response.operation).toBe('searchSessions') + if (response.operation !== 'searchSessions') { + throw new Error('wrong operation') + } + expect(response.value).toMatchObject({ kind: 'results', hits: [{ sessionId: SESSION_ID }] }) +}) + it('discovers a new root through the parent exchange on manual reconciliation', async () => { const lateHome = join(harness.root, 'late-home') const id = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff' diff --git a/src/main/ai-vault/session-scope-real-paths.ts b/src/main/ai-vault/session-scope-real-paths.ts index 5e335e0c8e62..c2c3af0ac9a2 100644 --- a/src/main/ai-vault/session-scope-real-paths.ts +++ b/src/main/ai-vault/session-scope-real-paths.ts @@ -38,8 +38,11 @@ export async function resolveAiVaultScopeRealPaths( } } const scanScopePaths = [...scopePaths] + const seen = new Set(scopePaths.map(normalizeRuntimePathForComparison)) for (const { realPath } of links) { - if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) { + const key = normalizeRuntimePathForComparison(realPath) + if (!seen.has(key)) { + seen.add(key) scanScopePaths.push(realPath) } } diff --git a/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts b/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts index 935c8adb9ce9..1d26150e5202 100644 --- a/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts-real-index.test.ts @@ -11,10 +11,7 @@ import { } from '../ai-vault-search/session-search-indexer-test-fixture' import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { - searchAllExecutionHosts, - type SessionSearchHostLeg -} from '../../shared/ai-vault-search-page-walk' +import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' /** * Three real indexes over real transcripts, wired as three legs. Everything a diff --git a/src/main/ipc/ai-vault-search-all-hosts.test.ts b/src/main/ipc/ai-vault-search-all-hosts.test.ts index 5fdba3fb85b0..9a67c6e4f6bd 100644 --- a/src/main/ipc/ai-vault-search-all-hosts.test.ts +++ b/src/main/ipc/ai-vault-search-all-hosts.test.ts @@ -5,11 +5,8 @@ import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types' import type { ExecutionHostId } from '../../shared/execution-host' -import { - searchAllExecutionHosts, - type SessionSearchHostLeg -} from '../../shared/ai-vault-search-page-walk' -import { encodeMergedSearchCursor } from '../../shared/ai-vault-search-merged-cursor' +import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' +import { encodeMergedSearchCursor } from './ai-vault-search-merged-cursor' /** A host that ranks a fixed list and fences its own cursors on a generation change. */ class StubHost { @@ -130,20 +127,29 @@ it('answers an empty merge when no host is reachable to ask', async () => { }) }) -it('asks every leg for the merged order and passes freshness through unchanged', async () => { +it('asks every leg for the merged order and passes both Project keys and freshness unchanged', async () => { const host = new StubHost('local', sessions('a', 2)) + const remote = new StubHost('runtime:remote', sessions('b', 2)) + const within = { + kind: 'project' as const, + projectKey: 'project:P', + canonicalProjectKey: 'repo:b' + } await searchAllExecutionHosts( { query: 'needle', freshness: 'wait-until-current', + within, filters: { sort: 'newest' }, debug: true }, - [host.leg()] + [host.leg(), remote.leg()] ) + expect(remote.lastRequests[0]).toEqual(host.lastRequests[0]) expect(host.lastRequests[0]).toEqual({ query: 'needle', freshness: 'wait-until-current', + within, filters: { sort: 'newest' } }) }) diff --git a/src/main/ipc/ai-vault-search-all-hosts.ts b/src/main/ipc/ai-vault-search-all-hosts.ts new file mode 100644 index 000000000000..f1e6cbe0b99e --- /dev/null +++ b/src/main/ipc/ai-vault-search-all-hosts.ts @@ -0,0 +1,327 @@ +import { resolveSessionSearchLimit } from '../../shared/ai-vault-search-limit' +import type { + AiVaultSearchHit, + AiVaultSearchHostOutcome, + AiVaultSearchRequest, + AiVaultSearchResponse +} from '../../shared/ai-vault-search-types' +import type { ExecutionHostId } from '../../shared/execution-host' +import { + decodeMergedSearchCursor, + encodeMergedSearchCursor, + type MergedSearchCursorEntry +} from './ai-vault-search-merged-cursor' + +export type SessionSearchHostLeg = { + executionHostId: ExecutionHostId + /** Omitted for the in-process local leg, which has no transport to hang on. */ + timeoutMs?: number + search: (request: AiVaultSearchRequest) => Promise +} + +type MergedSort = 'relevance' | 'newest' +type HostOutcome = AiVaultSearchHostOutcome['outcome'] +type Truncation = { + candidates: boolean + snippets: number + query: boolean + freshness: boolean +} + +// One merged request reads at most this many pages from any single host. +const MAX_HOST_PAGES_PER_REQUEST = 3 + +type HostWalk = { + executionHostId: ExecutionHostId + leg: SessionSearchHostLeg + request: AiVaultSearchRequest + outcome: HostOutcome + cursor: string | null + emitted: number + generation: number + pending: AiVaultSearchHit[] + nextCursor: string | null + pages: number + /** This host still owes hits this request could not read; keep its entry. */ + carry: boolean + truncated: Truncation +} + +/** + * Fans one query out to every execution host and merges the pages into one. + * + * Relevance scores come from independent indexes and are not comparable, so the + * two orders are the only two that mean anything across hosts: recency, which + * every host can be asked for directly, and round-robin over each host's own + * ranking. Legs are walked page by page, so a hit that lost the cut on one page + * is emitted on the next instead of being dropped. + */ +export async function searchAllExecutionHosts( + request: AiVaultSearchRequest, + legs: readonly SessionSearchHostLeg[], + isActive: () => boolean = () => true +): Promise { + const startedAt = Date.now() + const limit = resolveSessionSearchLimit(request.limit) + const sort: MergedSort = request.filters?.sort ?? 'relevance' + const resumed = request.cursor === undefined ? null : decodeMergedSearchCursor(request.cursor) + if (request.cursor !== undefined) { + const known = new Set(legs.map((leg) => leg.executionHostId)) + // A cursor belongs to one query over one host set; anything else is not ours. + if ( + !resumed || + resumed.limit !== limit || + resumed.sort !== sort || + Object.keys(resumed.hosts).some((executionHostId) => !known.has(executionHostId)) + ) { + return { kind: 'malformed-cursor' } + } + } + // A host absent from the cursor either finished or joined mid-walk; either way + // it contributes nothing to this page. Host-id order fixes every tiebreak. + const walks = legs + .filter((leg) => !resumed || resumed.hosts[leg.executionHostId] !== undefined) + .map((leg) => newHostWalk(leg, legRequest(request, sort))) + .sort((left, right) => left.executionHostId.localeCompare(right.executionHostId)) + await Promise.all( + walks.map((walk) => + isActive() + ? fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null) + : Promise.resolve() + ) + ) + const hits = await drainMergedPage(walks, limit, sort, isActive) + return mergedSearchResponse(walks, { limit, sort }, hits, Date.now() - startedAt) +} + +/** Every leg answers in the merged order; the cursor and debug are this merge's own. */ +function legRequest(request: AiVaultSearchRequest, sort: MergedSort): AiVaultSearchRequest { + const { cursor: _cursor, debug: _debug, ...rest } = request + return { ...rest, filters: { ...request.filters, sort } } +} + +function newHostWalk(leg: SessionSearchHostLeg, request: AiVaultSearchRequest): HostWalk { + return { + executionHostId: leg.executionHostId, + leg, + request, + outcome: 'unreachable', + cursor: null, + emitted: 0, + generation: 0, + pending: [], + nextCursor: null, + pages: 0, + carry: false, + truncated: { + candidates: false, + snippets: 0, + query: false, + freshness: false + } + } +} + +async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | null): Promise { + walk.cursor = entry?.c ?? null + walk.emitted = entry?.e ?? 0 + walk.generation = entry?.g ?? 0 + walk.pages += 1 + let response: AiVaultSearchResponse + try { + const { cursor } = walk + response = await withLegTimeout( + walk.leg.search(cursor === null ? walk.request : { ...walk.request, cursor }), + walk.leg.timeoutMs + ) + } catch (error) { + console.error(`[ai-vault-search] ${walk.executionHostId} leg failed:`, error) + // Keep its place: the next merged page retries this host from here. + endHostWalk(walk, 'unreachable', true) + return + } + if (response.kind === 'unavailable') { + endHostWalk(walk, response.reason, false) + return + } + // A cursor this merge minted can only be refused because the host's index + // moved, so both refusals mean the same thing: this host is done for now. + if (response.kind !== 'results') { + endHostWalk(walk, 'stale', false) + return + } + // `e` is an offset into one generation's ranked page, so it is only + // meaningful while that generation stands. Nothing emitted, nothing to fence. + if ( + (walk.emitted > 0 || walk.cursor !== null || walk.pages > 1) && + walk.generation !== response.generation + ) { + endHostWalk(walk, 'stale', false) + return + } + walk.outcome = 'searched' + walk.generation = response.generation + walk.pending = stampExecutionHost(response.hits, walk.executionHostId).slice(walk.emitted) + walk.nextCursor = response.page.hasMore ? response.page.cursor : null + walk.carry = false + walk.truncated.candidates ||= response.truncated.candidates + walk.truncated.snippets += response.truncated.snippets + walk.truncated.query ||= response.truncated.query + walk.truncated.freshness ||= response.truncated.freshness +} + +function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void { + walk.outcome = outcome + walk.pending = [] + walk.nextCursor = null + walk.carry = carry +} + +async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise { + while (isActive() && walk.pending.length === 0 && walk.nextCursor !== null) { + if (walk.pages >= MAX_HOST_PAGES_PER_REQUEST) { + // Budget spent; the unread page's cursor is already this walk's nextCursor. + walk.carry = true + return + } + await fetchHostPage(walk, { c: walk.nextCursor, e: 0, g: walk.generation }) + } +} + +async function drainMergedPage( + walks: readonly HostWalk[], + limit: number, + sort: MergedSort, + isActive: () => boolean +): Promise { + const hits: AiVaultSearchHit[] = [] + let turn = 0 + while (isActive() && hits.length < limit) { + // Every head must be known before picking, so a lagging host is never skipped. + for (const walk of walks) { + await advanceHostWalk(walk, isActive) + } + const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn) + if (!next) { + return hits + } + turn = walks.indexOf(next) + 1 + hits.push(next.pending.shift()!) + next.emitted += 1 + } + return hits +} + +/** Round-robin in host-id order, resuming after whichever host answered last. */ +function walkWithTurn(walks: readonly HostWalk[], turn: number): HostWalk | null { + for (let step = 0; step < walks.length; step++) { + const walk = walks[(turn + step) % walks.length] + if (walk && walk.pending.length > 0) { + return walk + } + } + return null +} + +function mostRecentWalk(walks: readonly HostWalk[]): HostWalk | null { + let best: HostWalk | null = null + for (const walk of walks) { + const head = walk.pending[0] + // Walks are in host-id order, so a strict comparison keeps the first host on a tie. + if (head && (!best || byRecencyDescending(head, best.pending[0]!) < 0)) { + best = walk + } + } + return best +} + +function mergedSearchResponse( + walks: readonly HostWalk[], + query: { limit: number; sort: MergedSort }, + hits: AiVaultSearchHit[], + durationMs: number +): AiVaultSearchResponse { + const hosts: AiVaultSearchHostOutcome[] = [] + const nextHosts: Record = {} + const truncated: Truncation = { + candidates: false, + snippets: 0, + query: false, + freshness: false + } + for (const walk of walks) { + hosts.push({ + executionHostId: walk.executionHostId, + outcome: walk.outcome + }) + const entry = nextCursorEntry(walk) + if (entry) { + nextHosts[walk.executionHostId] = entry + } + truncated.candidates ||= walk.truncated.candidates + truncated.snippets += walk.truncated.snippets + truncated.query ||= walk.truncated.query + truncated.freshness ||= walk.truncated.freshness + } + const hasMore = Object.keys(nextHosts).length > 0 + const cursor = hasMore ? encodeMergedSearchCursor({ ...query, hosts: nextHosts }) : null + return { + kind: 'results', + hits, + page: { cursor, hasMore }, + generation: 0, + truncated, + durationMs, + hosts + } +} + +/** Resume where this request stopped: mid-page by skip count, else the unread page. */ +function nextCursorEntry(walk: HostWalk): MergedSearchCursorEntry | null { + if (walk.pending.length > 0) { + return { c: walk.cursor, e: walk.emitted, g: walk.generation } + } + if (walk.nextCursor !== null) { + return { c: walk.nextCursor, e: 0, g: walk.generation } + } + return walk.carry ? { c: walk.cursor, e: walk.emitted, g: walk.generation } : null +} + +// This desktop owns which host it addressed; never trust an id the far side returned. +function stampExecutionHost( + hits: readonly AiVaultSearchHit[], + executionHostId: ExecutionHostId +): AiVaultSearchHit[] { + return hits.map((hit) => ({ ...hit, executionHostId })) +} + +function byRecencyDescending(left: AiVaultSearchHit, right: AiVaultSearchHit): number { + const leftMs = updatedAtMs(left) + const rightMs = updatedAtMs(right) + if (leftMs === rightMs) { + return 0 + } + return leftMs === null ? 1 : rightMs === null ? -1 : rightMs - leftMs +} + +function updatedAtMs(hit: AiVaultSearchHit): number | null { + const parsed = hit.updatedAt === null ? Number.NaN : Date.parse(hit.updatedAt) + return Number.isNaN(parsed) ? null : parsed +} + +async function withLegTimeout(pending: Promise, timeoutMs: number | undefined): Promise { + if (timeoutMs === undefined) { + return pending + } + let timer: ReturnType | undefined + try { + return await Promise.race([ + pending, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('Session search host timed out.')), timeoutMs) + }) + ]) + } finally { + clearTimeout(timer) + } +} diff --git a/src/main/ipc/ai-vault-search-merged-cursor.test.ts b/src/main/ipc/ai-vault-search-merged-cursor.test.ts index 99e5c9bdd933..ab49b3091c12 100644 --- a/src/main/ipc/ai-vault-search-merged-cursor.test.ts +++ b/src/main/ipc/ai-vault-search-merged-cursor.test.ts @@ -1,8 +1,5 @@ import { expect, it } from 'vitest' -import { - decodeMergedSearchCursor, - encodeMergedSearchCursor -} from '../../shared/ai-vault-search-merged-cursor' +import { decodeMergedSearchCursor, encodeMergedSearchCursor } from './ai-vault-search-merged-cursor' const cursor = { limit: 20, diff --git a/src/shared/ai-vault-search-merged-cursor.ts b/src/main/ipc/ai-vault-search-merged-cursor.ts similarity index 72% rename from src/shared/ai-vault-search-merged-cursor.ts rename to src/main/ipc/ai-vault-search-merged-cursor.ts index 20cfb305df19..40f8aa88d405 100644 --- a/src/shared/ai-vault-search-merged-cursor.ts +++ b/src/main/ipc/ai-vault-search-merged-cursor.ts @@ -16,12 +16,10 @@ export type MergedSearchCursor = { /** Page size and sort the cursor was minted for; a cursor belongs to one query. */ limit: number sort: 'relevance' | 'newest' - plan?: string hosts: Record } const mergedCursorPayloadSchema = z.object({ - p: z.string().optional(), l: z.number().int().positive(), s: z.enum(['relevance', 'newest']), h: z.record( @@ -35,26 +33,15 @@ const mergedCursorPayloadSchema = z.object({ }) export function encodeMergedSearchCursor(cursor: MergedSearchCursor): string { - const payload = { l: cursor.limit, s: cursor.sort, h: cursor.hosts, p: cursor.plan } - return btoa( - Array.from(new TextEncoder().encode(JSON.stringify(payload)), (byte) => - String.fromCharCode(byte) - ).join('') - ) - .replace(/\+/g, '-') - .replace(/\//g, '_') - .replace(/=+$/, '') + const payload = { l: cursor.limit, s: cursor.sort, h: cursor.hosts } + return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url') } /** Null for anything that is not a cursor this module minted. */ export function decodeMergedSearchCursor(raw: string): MergedSearchCursor | null { let parsed: unknown try { - parsed = JSON.parse( - new TextDecoder().decode( - Uint8Array.from(atob(raw.replace(/-/g, '+').replace(/_/g, '/')), (c) => c.charCodeAt(0)) - ) - ) + parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) } catch { return null } @@ -62,5 +49,5 @@ export function decodeMergedSearchCursor(raw: string): MergedSearchCursor | null if (!result.success) { return null } - return { plan: result.data.p, limit: result.data.l, sort: result.data.s, hosts: result.data.h } + return { limit: result.data.l, sort: result.data.s, hosts: result.data.h } } diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index aeef2a74d624..57bb436700fe 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -273,3 +273,32 @@ describe('local search hits owned by a native chat', () => { warn.mockRestore() }) }) + +it('binds the desktop leg once before awaited agent status and reuses the producer address', async () => { + const bound = vi.fn(async (method: string) => + method === 'aiVault.searchStatus' + ? { ...unavailableSessionSearchStatus(), supportedAgents: ['codex'] } + : searchResults() + ) + const bindRuntimeSearch = vi.fn(() => bound) + registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch, bindRuntimeSearch }) + const within = { + kind: 'project' as const, + projectKey: 'project:P', + canonicalProjectKey: 'repo:b' + } + await aiVaultApi.searchSessions( + { query: 'needle', filters: { agents: ['codex'] }, within }, + 'runtime:selected' + ) + expect(bindRuntimeSearch).toHaveBeenCalledExactlyOnceWith('selected') + expect(bound.mock.calls.map(([method]) => method)).toEqual([ + 'aiVault.searchStatus', + 'aiVault.searchSessions' + ]) + expect(bound).toHaveBeenLastCalledWith( + 'aiVault.searchSessions', + expect.objectContaining({ within }) + ) + expect(runtimeSearch).not.toHaveBeenCalled() +}) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 761e0e06738e..496e04946eb9 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -27,10 +27,7 @@ import { } from '../../shared/execution-host' import { redactStatusForTransport } from '../../shared/ai-vault-search-transport' import { clearSessionSearchInService } from '../ai-vault/session-scanner-service-spawn' -import { - searchAllExecutionHosts, - type SessionSearchHostLeg -} from '../../shared/ai-vault-search-page-walk' +import { searchAllExecutionHosts, type SessionSearchHostLeg } from './ai-vault-search-all-hosts' import { getActiveRuntimeAiVaultHostInfosResult } from './ai-vault' import { AI_VAULT_ALL_HOST_TIMEOUT_MS } from './ai-vault-all-host-timeouts' import { @@ -46,6 +43,9 @@ export type RuntimeSessionSearchCall = ( export type AiVaultSearchHandlerOptions = { callRuntimeSearch?: RuntimeSessionSearchCall + bindRuntimeSearch?: ( + environmentId: string + ) => (method: string, params: Record) => Promise ensureStructuredSessionOwnership?: () => Promise } @@ -232,8 +232,9 @@ function remoteSearchClient( ): ReturnType | null { if (host.kind === 'runtime' && call) { const { environmentId } = host + const bound = handlerOptions.bindRuntimeSearch?.(environmentId) return createSessionSearchClient( - (method, params) => call(environmentId, method, params), + bound ?? ((method, params) => call(environmentId, method, params)), 'relay' ) } diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index 2694ef932750..c949cc64b61c 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -101,7 +101,10 @@ import { resolveRuntimeAiVaultSessionTitles, scanRuntimeAiVaultSessions } from '../../ai-vault/runtime-session-scanner' -import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call' +import { + callRuntimeSessionSearch, + bindRuntimeSessionSearch +} from '../../ai-vault/runtime-session-search-call' import { ensureStructuredAgentSessionHostUnlessRefused } from '../../runtime/structured-agent-session-host-refusal' import { getAgentSessionAttachmentStore } from '../../native-chat/agent-session-attachments/agent-session-attachment-store-registry' import type { PluginService } from '../../plugins/plugin-service' @@ -273,6 +276,8 @@ export function registerCoreHandlers( registerAiVaultSearchHandlers({ callRuntimeSearch: (environmentId, method, params) => callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params), + bindRuntimeSearch: (environmentId) => + bindRuntimeSessionSearch(app.getPath('userData'), environmentId), ensureStructuredSessionOwnership }) registerAiVaultHandlers({ diff --git a/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts b/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts index bf9ec2ce4315..de934643a961 100644 --- a/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts +++ b/src/main/runtime/mobile-cloud-relay/mobile-relay-e2ee.integration.test.ts @@ -1,3 +1,18 @@ +import '../rpc/unused-default-rpc-methods.test-fixture' +import { RpcDispatcher } from '../rpc/dispatcher' +import { OrcaRuntimeService } from '../orca-runtime' +import { AI_VAULT_METHODS } from '../rpc/methods/ai-vault' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from '../../ai-vault-search/session-search-engine-test-fixture' +import { createSessionSearchService } from '../../ai-vault-search/session-search-service' +import { setSessionSearchService } from '../../ai-vault-search/session-search-service-registry' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../../ai-vault-search/session-search-scope-catalog' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -41,8 +56,12 @@ describe('desktop relay E2EE integration', () => { const servers: WebSocketServer[] = [] const transports: CloudRelayTransport[] = [] const userDataPaths: string[] = [] + let searchIndex: SessionSearchHarness | undefined afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await searchIndex?.close() await Promise.all(transports.splice(0).map((transport) => transport.stop())) await Promise.all( servers.splice(0).map( @@ -128,6 +147,40 @@ describe('desktop relay E2EE integration', () => { const receivedText = deferred() const receivedBinary = deferred() const phoneBinary = deferred() + searchIndex = await openSessionSearchHarness('sta10541-relay-native') + addSyntheticSession(searchIndex.db, { id: 1, cwd: '/repo/app' }) + addSyntheticSession(searchIndex.db, { id: 2, cwd: '/other' }) + setSessionSearchService( + createSessionSearchService({ + engine: searchIndex.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => ({ + repos: [{ id: 'app', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + const dispatcher = new RpcDispatcher({ + runtime: new OrcaRuntimeService(), + methods: AI_VAULT_METHODS + }) + const request = { + id: 'rpc-1', + authToken: 'test', + method: 'aiVault.searchSessions', + params: { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:app', canonicalProjectKey: 'repo:app' } + } + } const wiring = new MobileSocketWiring({ deviceRegistry: registry, e2eeKeypair: { @@ -144,7 +197,10 @@ describe('desktop relay E2EE integration', () => { credentialKind: 'invite' }) receivedText.resolve(plaintext) - reply(JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } })) + expect(JSON.parse(plaintext)).toEqual(request) + void dispatcher + .dispatch(request, { clientKind: 'mobile' }) + .then((result) => reply(JSON.stringify(result))) sendBinary(new Uint8Array([4, 5, 6])) }, onBinary: (_socket, bytes) => receivedBinary.resolve(new Uint8Array(bytes)), @@ -226,16 +282,16 @@ describe('desktop relay E2EE integration', () => { }) phone.send(JSON.stringify(phoneSession.hello)) await authenticated.promise - phone.send(phoneSession.sealText(JSON.stringify({ id: 'rpc-1', method: 'status.get' }))) + phone.send(phoneSession.sealText(JSON.stringify(request))) phone.send(phoneSession.sealBinary(new Uint8Array([1, 2, 3]))) - await expect(receivedText.promise).resolves.toBe( - JSON.stringify({ id: 'rpc-1', method: 'status.get' }) - ) + await expect(receivedText.promise).resolves.toBe(JSON.stringify(request)) await expect(receivedBinary.promise).resolves.toEqual(new Uint8Array([1, 2, 3])) - await expect(phoneText.promise).resolves.toBe( - JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } }) - ) + expect(JSON.parse(await phoneText.promise)).toMatchObject({ + id: 'rpc-1', + ok: true, + result: { kind: 'results', hits: [{ sessionId: '1' }] } + }) await expect(phoneBinary.promise).resolves.toEqual(new Uint8Array([4, 5, 6])) phone.terminate() diff --git a/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts index 17b7fea336d5..958926d35a4e 100644 --- a/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts +++ b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts @@ -1,4 +1,14 @@ -import { mkdtempSync } from 'node:fs' +import { + openSessionSearchHarness, + addSyntheticSession +} from '../ai-vault-search/session-search-engine-test-fixture' +import { createSessionSearchService } from '../ai-vault-search/session-search-service' +import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../ai-vault-search/session-search-scope-catalog' +import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import nacl from 'tweetnacl' @@ -50,7 +60,7 @@ function keyedServer() { const reply: unknown = JSON.parse(replies.at(-1)!) return reply } - return { server, registry, keyed, legacy, send } + return { server, registry, keyed, legacy, send, userDataPath } } describe('requests on a key-authenticated socket', () => { @@ -135,3 +145,46 @@ describe('pairing.registerDeviceKey over the dispatcher', () => { } ) }) + +it('carries canonical intent through the actual key-authenticated dispatch into native results', async () => { + const { send, userDataPath, server } = keyedServer() + const index = await openSessionSearchHarness('sta10541-key-native') + try { + addSyntheticSession(index.db, { id: 1, cwd: '/repo/app' }) + addSyntheticSession(index.db, { id: 2, cwd: '/other' }) + setSessionSearchService( + createSessionSearchService({ + engine: index.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => ({ + repos: [{ id: 'app', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + expect( + await send({ + id: 'key-search', + method: 'aiVault.searchSessions', + params: { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:app', canonicalProjectKey: 'repo:app' } + } + }) + ).toMatchObject({ ok: true, result: { kind: 'results', hits: [{ sessionId: '1' }] } }) + } finally { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await index.close() + await server.stop() + rmSync(userDataPath, { recursive: true, force: true }) + } +}) diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 90c07baeb6c6..40c639190d87 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -92,6 +92,10 @@ class SyncDatabase { this.db.exec(sql) } + function(...args: Parameters): void { + this.db.function(...args) + } + prepare(sql: string): SqliteStatement { const cached = this.statementCache.get(sql) if (cached) { diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index fa15c7368f4c..77bbcd178cb7 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -32,7 +32,6 @@ import { import { listedRow } from './automations-page-listed-items' import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' import type { Repo } from '../../../../shared/repo-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' installAutomationsPageHarness() @@ -75,7 +74,7 @@ function addSshProject(): void { // Without a ready setup on that host the project has no run context, and a // save against it is refused before any destination is stated. mocks.state.endpointProjectHostSetups = [ - ...(mocks.state.endpointProjectHostSetups as ProjectHostSetup[]), + ...mocks.state.endpointProjectHostSetups, { id: 'setup-ssh', projectId: 'project-ssh', @@ -104,7 +103,7 @@ function addCollidingRuntimeProject(): void { } as Repo mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] mocks.state.endpointProjectHostSetups = [ - ...(mocks.state.endpointProjectHostSetups as ProjectHostSetup[]), + ...mocks.state.endpointProjectHostSetups, { id: 'setup-runtime-collision', projectId: 'project-runtime-collision', diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index fecf460a0bea..5fb1fa0bce68 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -226,7 +226,7 @@ export function makeWorktree(overrides: Partial = {}): Worktree { } export type AutomationsPageStoreFixtures = { - state: Record + state: Record & { endpointProjectHostSetups: ProjectHostSetup[] } repoMap: Map worktreeMap: Map } diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts index f144bcc12995..8f1635d4601c 100644 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -19,7 +19,7 @@ export function selfScopedList(automations: Automation[]): Record + state: Record & { endpointProjectHostSetups: ProjectHostSetup[] } repoMap: Map worktreeMap: Map } @@ -55,7 +55,7 @@ export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string) updatedAt: 1 } const repos = mocks.state.repos as Repo[] - const setups = mocks.state.endpointProjectHostSetups as ProjectHostSetup[] + const setups = mocks.state.endpointProjectHostSetups const worktreesByRepo = mocks.state.worktreesByRepo as Record mocks.state.repos = [...repos, repo] mocks.state.endpointProjectHostSetups = [...setups, setup] diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index f23b0b421ca2..ec6d0c85e4d2 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -1,3 +1,4 @@ +import type { ProjectHostSetup } from '../../../../shared/project-types' /* oxlint-disable anti-slop/no-module-mocking -- Vitest support module for the 10 AutomationsPage specs, not shipped code, and it falls outside the *.test / *.spec / tests glob set. Inlining these 13 stubs would duplicate them into all 10 specs and push the largest past the max-lines ratchet. */ @@ -103,7 +104,7 @@ export type DeleteDialogProps = { } type AutomationsPageMocks = { - state: Record + state: Record & { endpointProjectHostSetups: ProjectHostSetup[] } repoMap: Map worktreeMap: Map listPanel: ListPanelProps | null @@ -122,7 +123,7 @@ type AutomationsPageMocks = { // Not `vi.hoisted`: the mock factories below only close over this, and a hoisted // binding cannot be exported. export const mocks: AutomationsPageMocks = { - state: {}, + state: { endpointProjectHostSetups: [] }, repoMap: new Map(), worktreeMap: new Map(), listPanel: null, diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx index b4e6b09539e2..990b04fba197 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -158,7 +158,11 @@ it('switches to index search with the same query once indexing is enabled', asyn aiVaultSearch: { enabled: true, historyDays: null } }) await waitFor(() => - expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local') + expect(searchSessions).toHaveBeenCalledWith( + expect.objectContaining({ query: 'foo' }), + 'local', + expect.any(Function) + ) ) expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull() // The index answered with no hits, so the title filter's own row must not linger. diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 6875bcb8f754..8d2ad5b6263c 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -63,7 +63,8 @@ export default function AiVaultPanel({ const { workspaceId: activeWorktreeId, worktree: activeWorktree, - repo: activeRepo + repo: activeRepo, + legacyProjectKey } = useAiVaultPanelWorkspace(workspace) const repos = useRepos() const allWorktrees = useAllWorktrees() @@ -155,11 +156,18 @@ export default function AiVaultPanel({ aiVaultSearchScopeIdentity({ scope, activeWorktreeId: activeWorktree?.id, - activeProjectKey + activeProjectKey: legacyProjectKey }), - [activeProjectKey, activeWorktree?.id, scope] + [legacyProjectKey, activeWorktree?.id, scope] + ) + const search = useAiVaultPanelSearch( + query, + agents, + searchWithin, + executionHostScope, + searchSort, + activeProjectKey ?? undefined ) - const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort) const { searching, searchHits } = search const sessions = searching ? search.sessions : history const requestedScanKey = aiVaultLocalScanKey({ @@ -178,9 +186,10 @@ export default function AiVaultPanel({ repos, worktrees: allWorktrees, projectHostSetupProjection, + settings, sessions }), - [allWorktrees, projectHostSetupProjection, repos, sessions] + [allWorktrees, projectHostSetupProjection, repos, sessions, settings] ) const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null const { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts index 826710faeb7a..e89bd1c048c9 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-key.ts @@ -1,5 +1,10 @@ -import { getRepoExecutionHostId } from '../../../../shared/execution-host' +import { + getRepoExecutionHostId, + parseExecutionHostId, + toSshExecutionHostId +} from '../../../../shared/execution-host' import type { getProjectHostSetupLookup } from '../../../../shared/project-host-setup-lookup' +import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' @@ -9,13 +14,49 @@ export function resolveActiveProjectKey( activeWorktree: Worktree | null, owners: ReturnType ): string | null { + const execution = parseExecutionHostId(activeRepo ? getRepoExecutionHostId(activeRepo) : null) const owner = activeWorktree ?? (activeRepo ? { repoId: activeRepo.id, - hostId: getRepoExecutionHostId(activeRepo) + hostId: activeRepo.connectionId + ? toSshExecutionHostId(activeRepo.connectionId) + : getRepoExecutionHostId(activeRepo), + runtimeOwnerEnvironmentId: + execution?.kind === 'runtime' ? execution.environmentId : undefined } : null) return toAiVaultProjectKey(owner ? owners.projectId(owner) : null, owner?.repoId ?? null) } + +export function resolveLegacyActiveProjectKey( + activeRepo: Repo | null, + activeWorktree: Worktree | null, + setupByRepoId: ReadonlyMap +): string | null { + if (activeWorktree?.projectId) { + return toAiVaultProjectKey(activeWorktree.projectId, activeWorktree.repoId) + } + + const setup = + (activeRepo ? setupByRepoId.get(activeRepo.id) : null) ?? + (activeWorktree ? setupByRepoId.get(activeWorktree.repoId) : null) + if (setup) { + return toAiVaultProjectKey(setup.projectId, setup.repoId || activeRepo?.id) + } + + return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null) +} + +export function buildSetupByRepoId( + setups: readonly ProjectHostSetup[] +): ReadonlyMap { + const setupByRepoId = new Map() + for (const setup of setups) { + if (setup.repoId && !setupByRepoId.has(setup.repoId)) { + setupByRepoId.set(setup.repoId, setup) + } + } + return setupByRepoId +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts index 06206655a7b4..3d60a3362e7e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-project-ownership.test.ts @@ -2,8 +2,11 @@ import { expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import { buildAiVaultProjectContext } from './ai-vault-session-projects' -import { resolveProjectHostSetupProjection } from '../../../../shared/project-host-setup-hydration' +import { + buildAiVaultProjectContext, + buildAiVaultSessionProjectById +} from './ai-vault-session-projects' +import { resolveProjectHostSetupProjection } from '../../../../shared/project-catalog-canonical-projection' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' const baseSession: AiVaultSession = { @@ -130,3 +133,40 @@ function makeWorktree(overrides: Partial): Worktree { ...overrides } } + +it('retains both hosts of a same-id repo for indexing-off managed history and prior workspace ownership', () => { + const local = makeRepo({ id: 'same', path: '/local/app', addedAt: 1 }) + const remote = makeRepo({ + ...local, + path: '/remote/app', + executionHostId: 'runtime:remote', + addedAt: 2 + }) + const repos = [local, remote] + const projection = resolveProjectHostSetupProjection(repos, [], []) + const sessions = [ + makeSession({ id: 'local-managed', cwd: '/managed/app/topic' }), + makeSession({ + id: 'remote-managed', + cwd: '/managed/app/topic', + executionHostId: 'runtime:remote' + }), + makeSession({ id: 'prior', cwd: '/prior/topic' }) + ] + const rows = buildAiVaultSessionProjectById({ + repos, + projectHostSetupProjection: projection, + sessions, + worktrees: [ + makeWorktree({ + id: 'same::/current', + repoId: 'same', + hostId: 'local', + path: '/current', + priorWorktreeIds: ['same::/prior'] + }) + ], + settings: { workspaceDir: '/managed', nestWorkspaces: true } + }) + expect([...rows.values()].map((row) => row.key)).toEqual(['repo:same', 'repo:same', 'repo:same']) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts index a0790f2eaa8f..0b330a79a371 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts @@ -1,3 +1,9 @@ +import { createNormalizedAiVaultScopeCwdMatcher } from '../../../../shared/ai-vault-scope-cwd-match' +import { + LOCAL_EXECUTION_HOST_ID, + normalizeExecutionHostId, + type ExecutionHostId +} from '../../../../shared/execution-host' import { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' @@ -55,3 +61,41 @@ export function matchAiVaultProjectCandidates( compare(left, right) ) } + +export type SessionProjectCandidate = { + source: 'worktree' | 'setup' + normalizedPath: string + hostKey: ExecutionHostId + projectId: string | null + repoId: string | null + // Precomputed so a 500-session pass doesn't re-normalize every root per session. + ownsNormalizedCwd: ReturnType +} + +export function makeProjectCandidate( + fields: Omit & { path: string } +): SessionProjectCandidate { + const { path, ...rest } = fields + const normalizedPath = normalizeRuntimePathForComparison(path) + return { + ...rest, + normalizedPath, + ownsNormalizedCwd: createNormalizedAiVaultScopeCwdMatcher([path]) + } +} + +export function resolveCandidateHostId( + ...values: readonly (string | null | undefined)[] +): ExecutionHostId { + for (const value of values) { + const hostId = normalizeExecutionHostId(value) + if (hostId) { + return hostId + } + } + return LOCAL_EXECUTION_HOST_ID +} + +export function hasCandidatePath(pathValue: string): boolean { + return pathValue.trim().length > 0 +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index 9f0c42d4036d..ecc642384217 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -1,9 +1,14 @@ -import { createNormalizedAiVaultScopeCwdMatcher } from '../../../../shared/ai-vault-scope-cwd-match' +import { + getRepoHostIdentity, + getRepoHostIdentityForParts +} from '../../../../shared/repo-host-identity' +import type { GlobalSettings } from '../../../../shared/global-settings-types' +import { managedWorktreeDirectories } from '../../../../shared/worktree/ownership' +import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { resolveActiveProjectKey } from './ai-vault-project-key' import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' import { getRepoExecutionHostId, - LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' @@ -15,7 +20,11 @@ import type { Worktree } from '../../../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { aiVaultSessionProjectCwdKey, - matchAiVaultProjectCandidates + matchAiVaultProjectCandidates, + makeProjectCandidate, + resolveCandidateHostId, + hasCandidatePath, + type SessionProjectCandidate } from './ai-vault-session-project-candidates' import { folderGroupKey, @@ -33,16 +42,6 @@ export type AiVaultProjectContext = { sessionProjectById: Map } -type SessionProjectCandidate = { - source: 'worktree' | 'setup' - normalizedPath: string - hostKey: ExecutionHostId - projectId: string | null - repoId: string | null - // Precomputed so a 500-session pass doesn't re-normalize every root per session. - ownsNormalizedCwd: ReturnType -} - type ProjectResolverArgs = { repos: readonly Repo[] worktrees: readonly Worktree[] @@ -50,6 +49,7 @@ type ProjectResolverArgs = { activeRepo: Repo | null activeWorktree: Worktree | null sessions: readonly AiVaultSession[] + settings?: Pick } export function buildAiVaultProjectContext({ @@ -83,12 +83,19 @@ export function buildAiVaultSessionProjectById({ repos, worktrees, projectHostSetupProjection, - sessions + sessions, + settings }: Omit): Map { - const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const repoById = new Map(repos.map((repo) => [getRepoHostIdentity(repo), repo])) const owners = getProjectHostSetupLookup(projectHostSetupProjection.setups) const projectLabelByKey = buildProjectLabelByKey(repos, projectHostSetupProjection) - const candidates = buildProjectCandidates(worktrees, projectHostSetupProjection, repoById, owners) + const candidates = buildProjectCandidates( + worktrees, + projectHostSetupProjection, + repoById, + owners, + settings + ) const sessionProjectById = new Map() const projectsByHostAndCwd = new Map< ExecutionHostId | null, @@ -146,36 +153,53 @@ function buildProjectCandidates( worktrees: readonly Worktree[], projection: ProjectHostSetupProjection, repoById: ReadonlyMap, - owners: ReturnType + owners: ReturnType, + settings?: ProjectResolverArgs['settings'] ): SessionProjectCandidate[] { const candidates: SessionProjectCandidate[] = [] const setupRepoIds = new Set() + const pathKey = (host: string, path: string) => + JSON.stringify([host, normalizeRuntimePathForComparison(path)]) + const occupied = new Set([ + ...worktrees.map((row) => pathKey(row.hostId ?? 'local', row.path)), + ...projection.setups.map((row) => pathKey(row.executionHostId ?? row.hostId, row.path)) + ]) for (const worktree of worktrees) { if (!hasCandidatePath(worktree.path)) { continue } - const repo = repoById.get(worktree.repoId) - const setup = owners.forWorkspace(worktree) - candidates.push( - makeProjectCandidate({ - source: 'worktree', - path: worktree.path, - hostKey: resolveCandidateHostId( - worktree.hostId, - setup?.hostId, - repo ? getRepoExecutionHostId(repo) : null - ), - projectId: owners.projectId(worktree) ?? null, - repoId: worktree.repoId - }) + const repo = repoById.get( + getRepoHostIdentityForParts(worktree.repoId, worktree.hostId ?? 'local') ) + const setup = owners.forWorkspace(worktree) + const candidate = makeProjectCandidate({ + source: 'worktree', + path: worktree.path, + hostKey: resolveCandidateHostId( + worktree.hostId, + setup?.hostId, + repo ? getRepoExecutionHostId(repo) : null + ), + projectId: owners.projectId(worktree) ?? null, + repoId: worktree.repoId + }) + candidates.push(candidate) + for (const id of worktree.priorWorktreeIds ?? []) { + const prior = splitWorktreeIdForFilesystem(id) + if ( + prior?.repoId === worktree.repoId && + !occupied.has(pathKey(candidate.hostKey, prior.worktreePath)) + ) { + candidates.push(makeProjectCandidate({ ...candidate, path: prior.worktreePath })) + } + } } for (const setup of projection.setups) { if (setup.repoId) { if (hasCandidatePath(setup.path)) { - setupRepoIds.add(setup.repoId) + setupRepoIds.add(getRepoHostIdentityForParts(setup.repoId, getRepoExecutionHostId(setup))) } } if (!hasCandidatePath(setup.path)) { @@ -193,7 +217,21 @@ function buildProjectCandidates( } for (const repo of repoById.values()) { - if (setupRepoIds.has(repo.id)) { + if (settings) { + const hostKey = getRepoExecutionHostId(repo) + for (const path of managedWorktreeDirectories(repo, settings)) { + candidates.push( + makeProjectCandidate({ + source: 'setup', + path, + hostKey, + repoId: repo.id, + projectId: owners.projectId({ repoId: repo.id, hostId: hostKey }) ?? null + }) + ) + } + } + if (setupRepoIds.has(getRepoHostIdentity(repo))) { continue } if (!hasCandidatePath(repo.path)) { @@ -213,34 +251,6 @@ function buildProjectCandidates( return candidates } -function makeProjectCandidate( - fields: Omit & { path: string } -): SessionProjectCandidate { - const { path, ...rest } = fields - const normalizedPath = normalizeRuntimePathForComparison(path) - return { - ...rest, - normalizedPath, - ownsNormalizedCwd: createNormalizedAiVaultScopeCwdMatcher([path]) - } -} - -function resolveCandidateHostId( - ...values: readonly (string | null | undefined)[] -): ExecutionHostId { - for (const value of values) { - const hostId = normalizeExecutionHostId(value) - if (hostId) { - return hostId - } - } - return LOCAL_EXECUTION_HOST_ID -} - -function hasCandidatePath(pathValue: string): boolean { - return pathValue.trim().length > 0 -} - function resolveSessionProject( session: AiVaultSession, candidates: readonly SessionProjectCandidate[], @@ -268,7 +278,15 @@ function resolveSessionProject( } const bestCandidate = hostMatches[0] - if (!bestCandidate) { + if ( + !bestCandidate || + hostMatches.some( + (candidate) => + compareCandidates(candidate, bestCandidate) === 0 && + toAiVaultProjectKey(candidate.projectId, candidate.repoId) !== + toAiVaultProjectKey(bestCandidate.projectId, bestCandidate.repoId) + ) + ) { return folderProject(cwd) } diff --git a/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx b/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx index b58d4c1374e9..8c2c43380988 100644 --- a/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx +++ b/src/renderer/src/components/right-sidebar/project-search-lifetime.test.tsx @@ -1,154 +1,89 @@ // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { afterEach, expect, it, vi } from 'vitest' -import { useAppStore } from '@/store' -import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { createSessionSearchClient, unavailableSessionSearchStatus } from '../../../../shared/ai-vault-search-client' import { searchHit, searchResults } from '../../../../shared/ai-vault-search-test-fixture' -import type { Repo } from '../../../../shared/repo-types' -import { AiVaultSearchRequestSchema } from '../../../../shared/ai-vault-search-contract' import type { AiVaultSearchRequest } from '../../../../shared/ai-vault-search-types' import { useAiVaultSearch } from './use-ai-vault-search' -const original = useAppStore.getState() -afterEach(() => { - useAppStore.setState(original, true) - vi.useRealTimers() -}) +afterEach(() => vi.useRealTimers()) -it.each([10, 100])( - 'bounds and retires real hook admissions without dropping any of %i mixed sources', - async (size) => { - vi.useFakeTimers() - const repos: Repo[] = [ - 'a', - 'b', - ...Array.from({ length: size - 1 }, (_, i) => `copy-${i}`) - ].map((id, addedAt) => ({ - id, - path: `/${id}`, - displayName: id, - addedAt, - badgeColor: '', - upstream: { owner: 'acme', repo: 'app' }, - executionHostId: 'runtime:old' - })) - const derived = projectHostSetupProjectionFromRepos(repos) - const projectId = derived.setups.find((setup) => setup.repoId === 'a')!.projectId - const setups = derived.setups.map((setup) => ({ - ...setup, - projectId, - ...(setup.repoId.startsWith('copy-') ? { setupMethod: 'provisioned' as const } : {}) - })) - useAppStore.setState({ - repos, - endpointProjects: [ - { ...derived.projects[0]!, id: projectId, sourceRepoIds: repos.map((repo) => repo.id) } - ], - endpointProjectHostSetups: setups, - worktreesByRepo: {} - }) - let statusCalls = 0, - nativeCalls = 0, - inFlight = 0, - maximum = 0 - let release: () => void = () => {} - let held = true - const firstResponses = new Promise((resolve) => { - release = resolve - }) - const client = createSessionSearchClient(async (method, params) => { - if (method === 'aiVault.searchStatus') { - statusCalls++ - return unavailableSessionSearchStatus() - } - nativeCalls++ - inFlight++ - maximum = Math.max(maximum, inFlight) - if (held) { - await firstResponses - } - await Promise.resolve() - inFlight-- - const native = AiVaultSearchRequestSchema.parse(params) - const key = native.within?.kind === 'project' ? native.within.projectKey : '' - expect(key).toMatch(/^repo:/) - const repoId = key.slice(5) - const offset = Number(native.cursor ?? 0) - return { - ...searchResults(), - hits: [{ ...searchHit(), sessionId: repoId, cwd: offset < 4 ? '/b' : `/${repoId}` }], - page: { hasMore: offset < 4, cursor: offset < 4 ? String(offset + 1) : null } - } - }, 'relay') - Object.defineProperty(window, 'api', { - configurable: true, - value: { - aiVault: { - searchSessions: ( - request: AiVaultSearchRequest, - _scope?: string, - isActive?: () => boolean - ) => client.searchSessions(request, isActive) - } +it('retires the direct Project chain before new work and allows one stale restart with finite continuation', async () => { + vi.useFakeTimers() + let release: () => void = () => {} + let statusHeld = true + const statusReady = new Promise((resolve) => { + release = resolve + }) + const calls: { method: string; params: Record }[] = [] + let stale = true + const client = createSessionSearchClient(async (method, params) => { + calls.push({ method, params }) + if (method === 'aiVault.searchStatus') { + if (statusHeld) { + await statusReady } - }) - const request: AiVaultSearchRequest = { - query: 'needle', - limit: size, - filters: { agents: ['codex'] }, - within: { kind: 'project', projectKey: `project:${projectId}` } + return { ...unavailableSessionSearchStatus(), supportedAgents: ['codex'] } } - const retired = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) - await act(async () => { - await vi.advanceTimersByTimeAsync(250) - }) - expect(nativeCalls).toBe(4) - retired.unmount() - held = false - await act(async () => { - release() - await firstResponses - }) - expect(nativeCalls).toBe(4) - expect(statusCalls).toBe(4) - const live = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) - await act(async () => { - await vi.advanceTimersByTimeAsync(250) - }) - expect(live.result.current.response).toMatchObject({ - kind: 'results', - hits: [], - page: { hasMore: true } - }) - const cursorBytes = JSON.stringify(live.result.current.response).length - expect(cursorBytes).toBeLessThan(size * 2000) - const sessions: string[] = [] - for (let page = 0; page < 3; page++) { - if ( - live.result.current.response?.kind !== 'results' || - !live.result.current.response.page.hasMore - ) { - break + if (params.cursor && stale) { + stale = false + return { kind: 'stale-cursor', generation: 2 } + } + return { + ...searchResults(), + hits: [{ ...searchHit(), agent: 'codex', sessionId: params.cursor ? 'second' : 'first' }], + page: { cursor: params.cursor ? null : 'next', hasMore: !params.cursor } + } + }, 'relay') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + searchSessions: (request: AiVaultSearchRequest, _scope?: string, active?: () => boolean) => + client.searchSessions(request, active) } - await act(async () => { - live.result.current.loadMore() - }) - sessions.splice(0, sessions.length, ...live.result.current.hits.map((hit) => hit.sessionId)) } - expect(new Set(sessions).size).toBe(size) - expect(sessions).not.toContain('b') - expect(live.result.current.response).toMatchObject({ - kind: 'results', - page: { hasMore: false } - }) - expect(maximum).toBeLessThanOrEqual(4) - // Refetching a retained page is required by the existing cursor's consumed offset contract. - expect(nativeCalls).toBeLessThanOrEqual(size * 7 + 4) - expect(statusCalls).toBe(nativeCalls) - live.unmount() + }) + const request: AiVaultSearchRequest = { + query: 'needle', + filters: { agents: ['codex'] }, + within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' } } -) + const retired = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + expect(calls.map((call) => call.method)).toEqual(['aiVault.searchStatus']) + retired.unmount() + statusHeld = false + await act(async () => { + release() + await statusReady + }) + expect(calls).toHaveLength(1) + const live = renderHook(() => useAiVaultSearch(request, 'runtime:old', '')) + await act(async () => { + await vi.advanceTimersByTimeAsync(250) + }) + await act(async () => { + live.result.current.loadMore() + }) + expect(live.result.current.hits.map((hit) => hit.sessionId)).toEqual(['first']) + await act(async () => { + live.result.current.loadMore() + }) + expect(live.result.current.hits.map((hit) => hit.sessionId)).toEqual(['first', 'second']) + expect(live.result.current.response).toMatchObject({ kind: 'results', page: { hasMore: false } }) + expect( + calls + .filter((call) => call.method === 'aiVault.searchSessions') + .map((call) => call.params.cursor) + ).toEqual([undefined, 'next', undefined, 'next']) + for (const call of calls.filter((call) => call.method === 'aiVault.searchSessions')) { + expect(call.params.within).toEqual(request.within) + } + live.unmount() +}) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts index 9e7317babef6..c44ba52df274 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-panel-workspace.ts @@ -1,3 +1,6 @@ +import { useMemo } from 'react' +import { getProjectHostSetupCompatibilityFromState } from '@/store/project-host-setup-selector' +import { buildSetupByRepoId, resolveLegacyActiveProjectKey } from './ai-vault-project-key' import { useShallow } from 'zustand/react/shallow' import { useAppStore } from '@/store' import { useActiveRepo, useActiveWorktree, useActiveWorktreeId } from '@/store/selectors' @@ -12,14 +15,25 @@ const NO_ROWS: AiVaultPanelWorkspaceRows = { worktree: null, repo: null } /** The panel's workspace: the one it was opened for, else the active one. */ export function useAiVaultPanelWorkspace( workspace: AiVaultPanelWorkspace | undefined -): AiVaultPanelWorkspaceRows & { workspaceId: string | null } { +): AiVaultPanelWorkspaceRows & { workspaceId: string | null; legacyProjectKey: string | null } { const activeWorktreeId = useActiveWorktreeId() const activeWorktree = useActiveWorktree() const activeRepo = useActiveRepo() const bound = useAppStore( useShallow((state) => (workspace ? selectAiVaultPanelWorkspace(state, workspace) : NO_ROWS)) ) - return workspace + const selected = workspace ? { workspaceId: workspace.worktreeId, ...bound } : { workspaceId: activeWorktreeId, worktree: activeWorktree, repo: activeRepo } + const compatibility = useAppStore(getProjectHostSetupCompatibilityFromState) + const legacyProjectKey = useMemo( + () => + resolveLegacyActiveProjectKey( + selected.repo, + selected.worktree, + buildSetupByRepoId(compatibility.setups) + ), + [selected.repo, selected.worktree, compatibility] + ) + return { ...selected, legacyProjectKey } } diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx index c59f77626ba1..d03ba709b442 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.test.tsx @@ -1,8 +1,6 @@ -import { useAppStore } from '@/store' +import { createSessionSearchClient } from '../../../../shared/ai-vault-search-client' +import { AiVaultSearchRequestSchema } from '../../../../shared/ai-vault-search-contract' import type * as StoreModule from '@/store' -import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' -import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' -import type { Repo } from '../../../../shared/repo-types' // @vitest-environment happy-dom import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' @@ -399,50 +397,29 @@ it('keeps full-text search for a host that is merely not ready', async () => { unmount() }) -it('reaches native Project search with the store workspace index and current independent owner', async () => { - const repos: Repo[] = ['a', 'b'].map((id, index) => ({ - id, - path: `/${id}`, - displayName: id, - badgeColor: '', - addedAt: index, - upstream: { owner: 'acme', repo: 'app' } - })) - const projection = projectHostSetupProjectionFromRepos(repos) - const shared = projection.setups.find((setup) => setup.repoId === 'a')!.projectId - useAppStore.setState({ - repos, - endpointProjects: [{ ...projection.projects[0]!, sourceRepoIds: ['a', 'b'] }], - endpointProjectHostSetups: projection.setups.map((setup) => ({ ...setup, projectId: shared })), - worktreesByRepo: { - b: [ - makeWorktree({ - id: 'b::/linked/b', - repoId: 'b', - path: '/linked/b', - projectId: shared, - hostId: 'local' - }) - ] +// The real client parses before transport; top-level intent would disappear here. +it('carries both Project identities through the actual panel parser and continuation', async () => { + const requests: AiVaultSearchRequest[] = [] + const client = createSessionSearchClient(async (_method, params) => { + requests.push(AiVaultSearchRequestSchema.parse(params)) + return { + ...empty, + page: { cursor: requests.length === 1 ? 'next' : null, hasMore: requests.length === 1 } } - }) - searchSessions.mockResolvedValue({ - ...searchResults(), - hits: [{ ...searchHit(), cwd: '/linked/b', sessionId: 'b-session' }] - }) - const request: AiVaultSearchRequest = { - query: 'needle', - within: { kind: 'project', projectKey: 'repo:b' } - } - const hook = renderHook(() => useAiVaultSearch(request, 'local', '')) + }, 'ipc') + searchSessions.mockImplementation((request) => client.searchSessions(request)) + const within = { kind: 'project', projectKey: 'project:shared' } as const + const { result, unmount } = renderHook(() => + useAiVaultPanelSearch('needle', ALL_AGENTS, within, 'all', 'relevance', 'repo:b') + ) await debounce() - expect(searchSessions.mock.calls[0]?.[0].within).toEqual({ - kind: 'project', - projectKey: 'repo:b' - }) - expect(hook.result.current.hits.map((hit) => hit.sessionId)).toEqual(['b-session']) - expect( - useAppStore.getState().endpointProjectHostSetups.every((setup) => setup.projectId === shared) - ).toBe(true) - hook.unmount() + act(() => result.current.loadMore()) + await debounce() + expect(requests.map((request) => request.within)).toEqual([ + { ...within, canonicalProjectKey: 'repo:b' }, + { ...within, canonicalProjectKey: 'repo:b' } + ]) + expect(requests.map((request) => request.cursor)).toEqual([undefined, 'next']) + expect(within).toEqual({ kind: 'project', projectKey: 'project:shared' }) + unmount() }) diff --git a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts index 8b12c1823954..62c8dcdc8a91 100644 --- a/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts +++ b/src/renderer/src/components/right-sidebar/use-ai-vault-search.ts @@ -20,8 +20,6 @@ import type { AiVaultSearchScopeIdentity } from '../../../../shared/ai-vault-sea import { resolveAiVaultSearchSettings } from '../../../../shared/ai-vault-search-settings' import { isWebClientLocation } from '@/lib/web-client-location' import { useAppStore } from '@/store' -import { searchCanonicalProject } from '../../../../shared/ai-vault-project-search' -import { selectProjectSearchCatalog } from '../../store/project-search-catalog' import { aiVaultSearchHitToSession } from './ai-vault-search-session' type SearchIdentity = { @@ -59,16 +57,8 @@ export function useAiVaultSearch( } let cancelled = false let pending = false - const search = (query: AiVaultSearchRequest, host: ExecutionHostScope) => { - const state = useAppStore.getState() - return searchCanonicalProject( - query, - host, - selectProjectSearchCatalog(state), - (request, host) => window.api.aiVault.searchSessions(request, host, () => !cancelled), - () => !cancelled - ) - } + const search = (query: AiVaultSearchRequest, host: ExecutionHostScope) => + window.api.aiVault.searchSessions(query, host, () => !cancelled) async function run(cursor?: string) { if (pending || cancelled || !request || !scope) { return @@ -159,7 +149,8 @@ export function useAiVaultPanelSearch( /** Which scope the host resolves; undefined searches everything it has. */ within: AiVaultSearchScopeIdentity | undefined, executionHostScope: ExecutionHostScope, - sort: AiVaultSearchSort + sort: AiVaultSearchSort, + canonicalProjectKey?: string ) { const settings = useAppStore((state) => state.settings?.aiVaultSearch) const policy = resolveAiVaultSearchSettings({ aiVaultSearch: settings }) @@ -187,10 +178,17 @@ export function useAiVaultPanelSearch( query: trimmed, // Relevance is the host's own default, so only the other order travels. filters: { agents: [...agents], ...(sort === 'relevance' ? {} : { sort }) }, - ...(within ? { within } : {}) + ...(within + ? { + within: + within.kind === 'project' && canonicalProjectKey + ? { ...within, canonicalProjectKey } + : within + } + : {}) } : null, - [searching, scope, agents, trimmed, within, sort] + [searching, scope, agents, trimmed, within, sort, canonicalProjectKey] ) const search = useAiVaultSearch(request, scope, JSON.stringify(policy)) const hostSearchOff = diff --git a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts index ca9ec5b0e6bd..6f6aca04f6d0 100644 --- a/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-drop-handler.test.ts @@ -8,6 +8,13 @@ const mocks = vi.hoisted(() => { > = { repo1: [{ id: 'wt-1', repoId: 'repo1', path: '/remote/repo', hostId: 'runtime:env-1' }] } + const endpointProjects: { + id: string + localWindowsRuntimePreference: + | { kind: 'inherit-global' } + | { kind: 'windows-host' } + | { kind: 'wsl'; distro: string | null } + }[] = [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'inherit-global' } }] return { toastLoading: vi.fn(() => 'toast-1'), toastDismiss: vi.fn(), @@ -19,18 +26,21 @@ const mocks = vi.hoisted(() => { activeRepoId: 'repo1', activeWorktreeId: 'wt-1', settings: { activeRuntimeEnvironmentId: 'env-1' as string | null }, - endpointProjects: [ + endpointProjects, + endpointProjectHostSetups: [ { id: 'repo1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } + repoId: 'repo1', + projectId: 'repo1', + hostId: 'local' as const, + path: '/remote/repo', + displayName: 'repo', + setupState: 'ready' as const, + setupMethod: 'legacy-repo' as const, + createdAt: 0, + updatedAt: 0 } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], + ], repos: [ { id: 'repo1', diff --git a/src/renderer/src/lib/agent-background-session-test-state.ts b/src/renderer/src/lib/agent-background-session-test-state.ts index 732e21be1396..ec9cbf0cdf45 100644 --- a/src/renderer/src/lib/agent-background-session-test-state.ts +++ b/src/renderer/src/lib/agent-background-session-test-state.ts @@ -69,6 +69,9 @@ export function createAgentBackgroundSessionTestState(mocks: { setTabLayout: TestMock registerAgentLaunchConfig: TestMock }): AgentBackgroundSessionTestState { + const endpointProjects: AgentBackgroundSessionTestState['endpointProjects'] = [ + { id: 'repo-1', localWindowsRuntimePreference: { kind: 'inherit-global' } } + ] const state = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', @@ -78,18 +81,7 @@ export function createAgentBackgroundSessionTestState(mocks: { activeRuntimeEnvironmentId: null as string | null, terminalMainSideEffectAuthority: undefined as boolean | undefined }, - endpointProjects: [ - { - id: 'repo-1', - localWindowsRuntimePreference: { kind: 'inherit-global' as const } - } - ] as { - id: string - localWindowsRuntimePreference: - | { kind: 'inherit-global' } - | { kind: 'windows-host' } - | { kind: 'wsl'; distro: string | null } - }[], + endpointProjects, repos: [{ id: 'repo-1', connectionId: null as string | null, path: '/repo' }], worktreesByRepo: { 'repo-1': [ diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 0dc21b3c80e8..28347f218b1c 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' import { buildAiVaultDropRepinStartup } from './ai-vault-resume-command' vi.mock('@/lib/new-workspace', () => ({ @@ -9,17 +10,7 @@ vi.mock('@/lib/new-workspace', () => ({ const RECORDED_HOME = '/tmp/orca/codex-accounts/aaaa/home' const SELECTED_HOME = '/tmp/orca/codex-accounts/bbbb/home' -type DropRepinState = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'endpointProjects' - | 'repos' - | 'settings' - | 'worktreesByRepo' -> +type DropRepinState = Parameters[0]['state'] function makeState(): DropRepinState { return { @@ -27,16 +18,28 @@ function makeState(): DropRepinState { activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - endpointProjects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: ['repo-1'] + } + ], settings: { + ...getDefaultSettings('/tmp'), agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { - 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + 'repo-1': [ + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }) + ] } - } as unknown as DropRepinState + } } function payload(overrides: { diff --git a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts index a8cac5033c8b..0215c3e85bf1 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' import { buildAiVaultResumeCopyCommandForWorktree, buildAiVaultResumeStartupForWorktree @@ -9,17 +10,7 @@ vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' })) -type ResumableAgentState = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'endpointProjects' - | 'repos' - | 'settings' - | 'worktreesByRepo' -> +type ResumableAgentState = Parameters[0]['state'] function makeState(): ResumableAgentState { return { @@ -27,16 +18,28 @@ function makeState(): ResumableAgentState { activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - endpointProjects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: ['repo-1'] + } + ], settings: { + ...getDefaultSettings('/tmp'), agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { - 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + 'repo-1': [ + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }) + ] } - } as unknown as ResumableAgentState + } } const KIMI_SESSION = { diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 6ff35771a968..6c5106f7ebc0 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { afterEach, describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' import { getAiVaultAgentProviderSession, buildAiVaultResumeCopyCommandForWorktree, @@ -17,18 +18,9 @@ afterEach(() => { type RuntimePreference = { kind: 'windows-host' } | { kind: 'wsl'; distro: string } -type AiVaultResumeCommandState = Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'folderWorkspaces' - | 'projectGroups' - | 'endpointProjects' - | 'endpointProjectHostSetups' - | 'repos' - | 'settings' - | 'worktreesByRepo' -> +type AiVaultResumeCommandState = Parameters< + typeof buildAiVaultResumeCopyCommandForWorktree +>[0]['state'] function makeState(args: { worktreePath: string @@ -40,7 +32,7 @@ function makeState(args: { activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\repo' }], endpointProjectHostSetups: [ { id: 'repo-1', @@ -58,6 +50,10 @@ function makeState(args: { endpointProjects: [ { id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, sourceRepoIds: ['repo-1'], ...(args.localWindowsRuntimePreference ? { localWindowsRuntimePreference: args.localWindowsRuntimePreference } @@ -65,6 +61,7 @@ function makeState(args: { } ], settings: { + ...getDefaultSettings('/tmp'), localWindowsRuntimeDefault: { kind: 'windows-host' }, ...(args.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), agentDefaultArgs: { claude: '', codex: '' }, @@ -72,14 +69,14 @@ function makeState(args: { }, worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: args.worktreePath - } + }) ] } - } as unknown as AiVaultResumeCommandState + } } function buildQueuedAiVaultResumeCommand( diff --git a/src/renderer/src/lib/ai-vault-resume-shell.test.ts b/src/renderer/src/lib/ai-vault-resume-shell.test.ts index 8268127287be..4ad40718b919 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -1,5 +1,6 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' -import type { AppState } from '@/store/types' vi.mock('@/lib/new-workspace', () => ({ CLIENT_PLATFORM: 'darwin' @@ -15,23 +16,33 @@ function makeState(worktreeHostId?: string): ResumeShellState { activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/home/alice/repo' }], - endpointProjects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/home/alice/repo' }], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: ['repo-1'] + } + ], settings: { + ...getDefaultSettings('/tmp'), agentDefaultArgs: { codex: '' }, agentDefaultEnv: { codex: {} } }, worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/home/alice/repo', ...(worktreeHostId ? { hostId: worktreeHostId } : {}) - } + }) ] } - } as unknown as AppState + } } describe('copied real-home Codex resume command', () => { diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 0b58dd9f535b..92999a232b75 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -29,6 +29,7 @@ function noActiveWorkspace(): { return { activeWorktreeId: null, activeWorkspaceExecutionHostId: null } } +const endpointProjects: Project[] = [] const storeState = { settings: undefined as | { @@ -43,7 +44,7 @@ const storeState = { ...noActiveWorkspace(), createBrowserTab: createBrowserTabMock, repos: [] as Repo[], - endpointProjects: [] as Project[], + endpointProjects, worktreesByRepo: {} as Record< string, { id: string; projectId?: string; repoId?: string; displayName?: string }[] diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 3e134fe52c58..c8bc8a816984 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -4,6 +4,8 @@ import { FLOATING_TERMINAL_WORKTREE_ID, getDefaultSettings } from '../../../shar import type { Repo } from '../../../shared/repo-types' import type { Worktree } from '../../../shared/worktree/types' import type { AppState } from '@/store/types' +import { makeWorktree, TEST_REPO } from '../store/slices/worktrees-slice-test-fixtures' +import type { Project } from '../../../shared/project-types' import { getLocalAgentPreflightContext, getLocalPreflightContext, @@ -13,12 +15,31 @@ import { localPreflightContextKey } from './local-preflight-context' +function legacySettings() { + const settings = getDefaultSettings('/tmp') + delete settings.localWindowsRuntimeDefault + return settings +} + +type PreflightState = Parameters[0] & + Pick +function project(overrides: Pick & Partial): Project { + return { + displayName: '', + badgeColor: '', + createdAt: 0, + updatedAt: 0, + sourceRepoIds: [], + ...overrides + } +} + function makeState(args: { repoPath?: string - worktreePath?: string | null + worktreePath?: string repo?: Partial worktree?: Partial -}): AppState { +}): PreflightState { const repoId = 'repo-1' const worktreeId = `${repoId}::worktree-1` const repos: AppState['repos'] = @@ -36,6 +57,7 @@ function makeState(args: { ] const catalog = projectHostSetupProjectionFromRepos(repos) return { + settings: legacySettings(), endpointProjects: catalog.projects.map((project) => ({ ...project, id: repoId })), endpointProjectHostSetups: catalog.setups.map((setup) => ({ ...setup, projectId: repoId })), activeRepoId: repoId, @@ -46,15 +68,15 @@ function makeState(args: { ? {} : { [repoId]: [ - { + makeWorktree({ id: worktreeId, repoId, path: args.worktreePath, ...args.worktree - } + }) ] } - } as AppState + } } describe('local preflight context', () => { @@ -114,14 +136,15 @@ describe('local preflight context', () => { }) it('keys preflight by active runtime before local WSL or host context', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: String.raw`\\wsl.localhost\Ubuntu\home\alice\repo` }), settings: { + ...legacySettings(), activeRuntimeEnvironmentId: 'runtime-1' } - } as AppState + } const context = getLocalPreflightContext(state) expect(context?.runtimeContextKey).toMatch(/^runtime:runtime-1#\d+$/) @@ -135,7 +158,7 @@ describe('local preflight context', () => { worktreePath: '/Users/alice/repo', repo: { executionHostId: 'local' } }), - settings: { ...getDefaultSettings('/tmp'), activeRuntimeEnvironmentId: 'runtime-1' } + settings: { ...legacySettings(), activeRuntimeEnvironmentId: 'runtime-1' } } expect(localPreflightContextKey(getLocalPreflightContext(localFocused))).toBe('host') @@ -194,12 +217,13 @@ describe('local preflight context', () => { }) it('returns repair context for Windows local preflight when the selected WSL distro is missing', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } const context = getLocalPreflightContext(state, 'win32', { wslAvailable: true, @@ -222,13 +246,14 @@ describe('local preflight context', () => { }) it('uses the migrated WSL runtime for local agent checks when WSL is the default shell', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' } - } as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -250,14 +275,15 @@ describe('local preflight context', () => { }) it('migrates explicit legacy agent location to the project runtime default', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', localAgentRuntime: 'host' } - } as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -277,14 +303,15 @@ describe('local preflight context', () => { }) it('lets explicit agent location choose a WSL distro independent of the terminal shell', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'powershell.exe', localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } - } as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -306,37 +333,40 @@ describe('local preflight context', () => { }) it('ignores stale terminal WSL settings when no Windows project runtime is available', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' } - } as AppState + } expect(getLocalAgentPreflightContext(state, 'linux')).toBeUndefined() }) it('ignores stale explicit agent runtime settings on non-Windows hosts', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } - } as AppState + } expect(getLocalAgentPreflightContext(state, 'darwin')).toBeUndefined() }) it('returns repair context for explicit WSL agent location without a selected distro', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'powershell.exe', localAgentRuntime: 'wsl' } - } as unknown as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -356,14 +386,15 @@ describe('local preflight context', () => { }) it('uses the global WSL runtime for local agent checks without an active project', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: undefined }), activeRepoId: null, activeWorktreeId: null, settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -385,15 +416,16 @@ describe('local preflight context', () => { }) it('uses the global runtime default over stale legacy agent location without an active project', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: undefined }), activeRepoId: null, activeWorktreeId: null, settings: { + ...legacySettings(), localAgentRuntime: 'host', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -402,7 +434,7 @@ describe('local preflight context', () => { // Why (#18837): the Windows host is neither this machine's default nor the SSH host's context. it('uses the global runtime default for this machine while an SSH project is active', () => { - const state: AppState = { + const state: PreflightState = { ...makeState({ repoPath: '/home/alice/repo', worktreePath: '/home/alice/repo', @@ -422,15 +454,18 @@ describe('local preflight context', () => { }) it('uses the project override over legacy agent location for local agent checks', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' }, - endpointProjects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }) + ] + } const context = getLocalAgentPreflightContext(state, 'win32') @@ -449,36 +484,36 @@ describe('local preflight context', () => { }) it('uses the target worktree runtime for local agent checks', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\active', worktreePath: 'C:\\Users\\alice\\active' }), repos: [ - { id: 'repo-1', path: 'C:\\Users\\alice\\active' }, - { id: 'repo-2', path: 'C:\\Users\\alice\\target' } + { ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\active' }, + { ...TEST_REPO, id: 'repo-2', path: 'C:\\Users\\alice\\target' } ], worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: 'C:\\Users\\alice\\active' - } + }) ], 'repo-2': [ - { + makeWorktree({ id: 'repo-2::worktree-1', repoId: 'repo-2', path: 'C:\\Users\\alice\\target' - } + }) ] }, endpointProjects: [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }, - { id: 'repo-2', localWindowsRuntimePreference: { kind: 'windows-host' } } + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }), + project({ id: 'repo-2', localWindowsRuntimePreference: { kind: 'windows-host' } }) ] - } as unknown as AppState + } state.endpointProjectHostSetups = projectHostSetupProjectionFromRepos(state.repos).setups.map( (setup) => ({ ...setup, projectId: setup.repoId }) @@ -490,15 +525,18 @@ describe('local preflight context', () => { }) it('resolves a project host override for a specific worktree over a WSL default', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - endpointProjects: [{ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }] - } as unknown as AppState + endpointProjects: [ + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'windows-host' } }) + ] + } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32')).toEqual({ status: 'resolved', @@ -513,12 +551,12 @@ describe('local preflight context', () => { }) it('does not assign the active project runtime to the floating workspace', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), endpointProjects: [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) ] - } as unknown as AppState + } expect( getLocalProjectExecutionRuntimeContext(state, FLOATING_TERMINAL_WORKTREE_ID, 'win32') @@ -526,16 +564,17 @@ describe('local preflight context', () => { }) it('keeps Floating on the host despite global and explicit WSL agent settings', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } - } as AppState + } expect( getLocalAgentPreflightContext(state, 'win32', {}, FLOATING_TERMINAL_WORKTREE_ID) @@ -543,20 +582,20 @@ describe('local preflight context', () => { }) it('keeps the active project runtime fallback for a detected-only worktree', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), detectedWorktreesByRepo: { 'repo-1': { repoId: 'repo-1', - authoritative: true, - source: 'git', - worktrees: [{ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }] + worktrees: [ + makeWorktree({ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }) + ] } }, endpointProjects: [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) ] - } as unknown as AppState + } expect( getLocalProjectExecutionRuntimeContext(state, 'repo-1::detected', 'win32') @@ -585,16 +624,16 @@ describe('local preflight context', () => { }) it('resolves a local repo project WSL override before a worktree exists', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: String.raw`C:\Users\alice\repo` }), endpointProjects: [ - { + project({ id: 'project-1', sourceRepoIds: ['repo-1'], localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } + }) ] - } as unknown as AppState + } expect(getLocalRepoProjectExecutionRuntimeContext(state, 'repo-1', 'win32')).toEqual({ status: 'resolved', @@ -639,18 +678,20 @@ describe('local preflight context', () => { }) it('uses the requested worktree repo owner before resolving a local project runtime', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), repos: [ { + ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\repo', executionHostId: 'local' }, { + ...TEST_REPO, id: 'repo-ssh', path: '/remote/repo', connectionId: 'builder', @@ -659,22 +700,22 @@ describe('local preflight context', () => { ], worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: 'C:\\Users\\alice\\repo' - } + }) ], 'repo-ssh': [ - { + makeWorktree({ id: 'repo-ssh::worktree-1', repoId: 'repo-ssh', path: '/remote/repo', hostId: 'ssh:builder' - } + }) ] } - } as unknown as AppState + } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-ssh::worktree-1', 'win32')).toBe( undefined @@ -682,15 +723,16 @@ describe('local preflight context', () => { }) it('returns repair when the resolved project WSL distro is unavailable', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'windows-host' } }, endpointProjects: [ - { id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } + project({ id: 'repo-1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } }) ] - } as unknown as AppState + } expect( getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32', { @@ -710,12 +752,13 @@ describe('local preflight context', () => { }) it('defers WSL repair while capability loading leaves distro availability unknown', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } } - } as unknown as AppState + } expect( getLocalProjectExecutionRuntimeContext(state, 'repo-1::worktree-1', 'win32', { @@ -736,7 +779,7 @@ describe('local preflight context', () => { }) it('resolves the requested worktree instead of the active worktree', () => { - const state = { + const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' @@ -744,22 +787,23 @@ describe('local preflight context', () => { activeWorktreeId: 'repo-1::worktree-1', worktreesByRepo: { 'repo-1': [ - { + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '\\\\wsl.localhost\\Ubuntu\\home\\alice\\repo' - }, - { + }), + makeWorktree({ id: 'repo-1::host-worktree', repoId: 'repo-1', path: 'C:\\Users\\alice\\repo' - } + }) ] }, settings: { + ...legacySettings(), localWindowsRuntimeDefault: { kind: 'windows-host' } } - } as unknown as AppState + } expect(getLocalProjectExecutionRuntimeContext(state, 'repo-1::host-worktree', 'win32')).toEqual( { diff --git a/src/renderer/src/lib/local-preflight-context.ts b/src/renderer/src/lib/local-preflight-context.ts index e58b870b2c69..104661b3d168 100644 --- a/src/renderer/src/lib/local-preflight-context.ts +++ b/src/renderer/src/lib/local-preflight-context.ts @@ -1,7 +1,5 @@ -import { - getProjectHostSetupProjectionFromState, - type ProjectCatalogReadState -} from '@/store/project-host-setup-selector' +import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import type { AppState } from '@/store/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { parseWslUncPath } from '../../../shared/wsl-paths' @@ -20,7 +18,8 @@ import { getIndexedRepoMap } from '@/store/worktree-repo-index' import { defaultScopeHost } from './default-creation-host' import { getLocalPreflightProjectId, - getLocalProjectRuntimeWorkspace + getLocalProjectRuntimeWorkspace, + type LocalProjectRuntimeState } from './local-project-runtime-workspace' import { getActiveWorkspaceOwnerTarget } from './resolved-worktree-execution-host' import { getEnvironmentRuntimeContextKey } from './provider-runtime-context' @@ -45,13 +44,6 @@ export { resetLocalPreflightContextCachesForTests } from './local-preflight-context-cache' -type LocalProjectRuntimeState = Pick< - AppState, - 'activeRepoId' | 'activeWorktreeId' | 'repos' | 'settings' | 'worktreesByRepo' -> & - Partial> & - ProjectCatalogReadState - // Why: the shared indexes are WeakMap-keyed on slice identity, so a fresh `{}` // or `[]` fallback would miss the cache on every read. const EMPTY_REPOS: AppState['repos'] = [] @@ -165,7 +157,7 @@ export function getLocalRepoProjectExecutionRuntimeContext( } export function getLocalPreflightContext( - state: AppState, + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, appPlatform: NodeJS.Platform = getRendererAppPlatform(), wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext() ): LocalPreflightContext { @@ -188,7 +180,7 @@ export function getLocalPreflightContext( } export function getLocalAgentPreflightContext( - state: AppState, + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, appPlatform: NodeJS.Platform = getRendererAppPlatform(), wslContext: LocalProjectRuntimeWslContext = getCachedLocalProjectRuntimeWslContext(), worktreeId?: string | null @@ -268,7 +260,9 @@ export function getLocalAgentPreflightContext( return undefined } -function isActiveWorkspaceOnAnotherHost(state: AppState): boolean { +function isActiveWorkspaceOnAnotherHost( + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState +): boolean { const activeWorktree = getLocalProjectRuntimeWorkspace(state, undefined) const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) return Boolean(repo) && (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) @@ -287,7 +281,10 @@ function getCachedLocalProjectRuntimeWslContext(): LocalProjectRuntimeWslContext } } -function getLocalPreflightWslDistro(state: AppState, worktreeId?: string | null): string | null { +function getLocalPreflightWslDistro( + state: LocalProjectRuntimeState & WorktreeRuntimeOwnerState, + worktreeId?: string | null +): string | null { const activeWorktree = getLocalProjectRuntimeWorkspace(state, worktreeId) const repo = getLocalRuntimeRepoForWorktree(state, activeWorktree) if (!isLocalRuntimeRepo(repo) || !isLocalRuntimeWorktree(activeWorktree)) { diff --git a/src/renderer/src/lib/local-project-runtime-workspace.ts b/src/renderer/src/lib/local-project-runtime-workspace.ts index 7b8916a136e5..8e4f89b71193 100644 --- a/src/renderer/src/lib/local-project-runtime-workspace.ts +++ b/src/renderer/src/lib/local-project-runtime-workspace.ts @@ -17,6 +17,13 @@ import { selectWorktreesByRepo } from '@/store/workspace-record-selectors' type RuntimeWorkspaceState = Pick & Partial> +export type LocalProjectRuntimeState = Pick< + AppState, + 'activeRepoId' | 'activeWorktreeId' | 'repos' | 'settings' | 'worktreesByRepo' +> & + Partial> & + ProjectCatalogReadState + const EMPTY_WORKTREES_BY_REPO: AppState['worktreesByRepo'] = {} export function getLocalProjectRuntimeWorkspace( diff --git a/src/renderer/src/store/project-host-setup-selector.ts b/src/renderer/src/store/project-host-setup-selector.ts index 70d1b6de9cd5..61a2c8496723 100644 --- a/src/renderer/src/store/project-host-setup-selector.ts +++ b/src/renderer/src/store/project-host-setup-selector.ts @@ -3,7 +3,8 @@ import { projectHostSetupProjectionFromRepos, type ProjectHostSetupProjection } from '../../../shared/project-host-setup-projection' -import { resolveProjectHostSetupProjection } from '../../../shared/project-host-setup-hydration' +import { resolveProjectHostSetupProjection } from '../../../shared/project-catalog-canonical-projection' +import { resolveProjectHostSetupProjection as resolveCompatibility } from '../../../shared/project-host-setup-hydration' import type { AppState } from './types' const derivedProjectionByRepos = new WeakMap() @@ -71,3 +72,26 @@ export const selectCanonicalProjects = ( export const selectCanonicalProjectHostSetups = ( state: Parameters[0] ) => getProjectHostSetupProjectionFromState(state).setups + +const compatibilityByCanonical = new WeakMap< + ProjectHostSetupProjection, + ProjectHostSetupProjection +>() +export function getProjectHostSetupCompatibilityFromState( + state: ProjectCatalogReadState +): ProjectHostSetupProjection { + const canonical = getProjectHostSetupProjectionFromState({ + ...state, + projectHostSetupProjection: undefined + }) + let compatibility = compatibilityByCanonical.get(canonical) + if (!compatibility) { + compatibility = resolveCompatibility( + state.repos, + state.endpointProjects ?? [], + state.endpointProjectHostSetups ?? [] + ) + compatibilityByCanonical.set(canonical, compatibility) + } + return compatibility +} diff --git a/src/renderer/src/store/project-search-catalog.ts b/src/renderer/src/store/project-search-catalog.ts deleted file mode 100644 index d1ba44ca7757..000000000000 --- a/src/renderer/src/store/project-search-catalog.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { AppState } from './types' -import { getProjectHostSetupProjectionFromState } from './project-host-setup-selector' -import { selectWorktreesByRepo } from './workspace-record-selectors' -import { getIndexedAllWorktrees } from './worktree-repo-index' - -export function selectProjectSearchCatalog(state: AppState) { - return { - ...getProjectHostSetupProjectionFromState(state), - worktrees: getIndexedAllWorktrees(selectWorktreesByRepo(state)), - rawSetups: state.endpointProjectHostSetups - } -} diff --git a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts index 721713d68836..eceed11ef763 100644 --- a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts +++ b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts @@ -15,6 +15,25 @@ import { resetWorktreeSliceModuleMemory } from './worktrees-slice-test-harness' +function makeOrphanedSetup(): AppState['endpointProjectHostSetups'][number] { + const row = { + id: '', + projectId: '', + repoId: '', + hostId: 'local' as const, + path: '', + displayName: '', + setupState: 'ready' as const, + setupMethod: 'legacy-repo' as const, + createdAt: 0, + updatedAt: 0 + } + for (const property of Object.keys(row)) { + Reflect.deleteProperty(row, property) + } + return row +} + const requestWorktreeBaseFallbackNotice = vi.hoisted(() => vi.fn()) vi.mock('sonner', () => ({ @@ -174,18 +193,18 @@ describe('removeWorktree state cleanup', () => { it('purges the orphaned project that pointed at a destroyed runtime-owned SSH target', async () => { const store = createTestStore() const wt = makeWorktree({ id: 'repo1::/path/wt1', repoId: 'repo1', path: '/path/wt1' }) - const orphanedSetup = { + const orphanedSetup = Object.assign(makeOrphanedSetup(), { id: 'setup-runtime-ssh', hostId: 'ssh:runtime-ssh-orca-1' - } as unknown as AppState['endpointProjectHostSetups'][number] - const userSshSetup = { + }) + const userSshSetup = Object.assign(makeOrphanedSetup(), { id: 'setup-user-ssh', hostId: 'ssh:my-server' - } as unknown as AppState['endpointProjectHostSetups'][number] + }) const deleteProjectHostSetup = vi.fn().mockResolvedValue(null) store.setState({ worktreesByRepo: { repo1: [wt] }, - projectHostSetups: [orphanedSetup, userSshSetup], + endpointProjectHostSetups: [orphanedSetup, userSshSetup], deleteProjectHostSetup } as unknown as Partial) mockApi.ephemeralVm.listRuntimes.mockResolvedValueOnce([ diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index 845268663513..3f834b3c3989 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -1,5 +1,5 @@ import { - getProjectHostSetupProjectionFromState, + getProjectHostSetupCompatibilityFromState, type ProjectCatalogReadState } from '@/store/project-host-setup-selector' import type { AppState } from '../../../types' @@ -130,7 +130,7 @@ export function getProjectHostSetupForRepoHost( repoId: string, hostId: ExecutionHostId ): ProjectHostSetup | undefined { - return getProjectHostSetupProjectionFromState(state).setups.find( + return getProjectHostSetupCompatibilityFromState(state).setups.find( (setup) => setup.repoId === repoId && setup.hostId === hostId ) } diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index 305ef00633a3..5216aa2e08c9 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -19,25 +19,48 @@ import { toRuntimeExecutionHostId } from '../../../../shared/execution-host' import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host' -import { callRuntimeResult } from './web-runtime-calls' +import { callRuntimeResult, callEnvironmentEnvelope } from './web-runtime-calls' import { requireActiveEnvironment } from './web-runtime-session' import { noopUnsubscribe } from './web-storage' import { translate } from '@/i18n/i18n' export function createWebAiVaultApi(): NonNullable['aiVault']> { - const search = createSessionSearchClient( - (method, params) => callRuntimeResult(method, params), - 'relay' - ) return { // A browser searches only its selected paired runtime. - searchSessions: (request, executionHostScope, isActive) => - addressesOwnRuntime(executionHostScope) - ? search.searchSessions(request, isActive) - : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }), + searchSessions: (request, executionHostScope, isActive = () => true) => { + if (!addressesOwnRuntime(executionHostScope)) { + return Promise.resolve({ kind: 'unavailable', reason: 'no-service' }) + } + const environment = requireActiveEnvironment() + const pairingRevision = environment.pairingRevision ?? environment.createdAt + const runtimeId = environment.runtimeId ?? undefined + const active = () => isActive() && requireActiveEnvironment().id === environment.id + const search = createSessionSearchClient(async (method, params) => { + if (!active()) { + throw new Error('Session search retired') + } + const response = await callEnvironmentEnvelope( + environment.id, + method, + params, + undefined, + pairingRevision, + runtimeId, + active + ) + if (!response.ok) { + throw Object.assign(new Error(response.error.message), { code: response.error.code }) + } + return response.result + }, 'relay') + return search.searchSessions(request, active) + }, searchStatus: (executionHostScope) => addressesOwnRuntime(executionHostScope) - ? search.searchStatus() + ? createSessionSearchClient( + (method, params) => callRuntimeResult(method, params), + 'relay' + ).searchStatus() : Promise.resolve(unavailableSessionSearchStatus()), // Why refused and not forwarded: consent for a host's index is an operator action, // and the browser client has no desktop settings surface to reconcile it against. diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts index 6e0ff9520c09..5c2cb2fca160 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -4,7 +4,13 @@ import { unavailableSessionSearchStatus } from '../../../../shared/ai-vault-sear import { AI_VAULT_AGENTS } from '../../../../shared/ai-vault-types' const callRuntimeResult = vi.hoisted(() => vi.fn()) -vi.mock('./web-runtime-calls', () => ({ callRuntimeResult })) +vi.mock('./web-runtime-calls', () => ({ + callRuntimeResult, + callEnvironmentEnvelope: async (_selector: string, method: string, params: unknown) => ({ + ok: true, + result: await callRuntimeResult(method, params) + }) +})) vi.mock('./web-runtime-session', () => ({ requireActiveEnvironment: () => ({ id: 'owning-host' }) })) diff --git a/src/renderer/src/web/preload-api/web-runtime-calls.ts b/src/renderer/src/web/preload-api/web-runtime-calls.ts index 423e6308b9a9..a07862fca62f 100644 --- a/src/renderer/src/web/preload-api/web-runtime-calls.ts +++ b/src/renderer/src/web/preload-api/web-runtime-calls.ts @@ -124,7 +124,8 @@ export async function callEnvironmentEnvelope( params?: unknown, timeoutMs?: number, expectedEnvironmentPairingRevision?: number, - expectedEnvironmentRuntimeId?: string + expectedEnvironmentRuntimeId?: string, + isActive: () => boolean = () => true ): Promise> { const environment = resolveEnvironment(selector) const assertPairing = () => assertEnvironmentPairing(selector, expectedEnvironmentPairingRevision) @@ -133,6 +134,9 @@ export async function callEnvironmentEnvelope( return manuallyDisconnectedResponse(environment) } const response = await runtimeCallQueuePool.enqueue(environment.id, method, () => { + if (!isActive()) { + throw new Error('Session search retired') + } assertPairing() if (manuallyDisconnectedEnvironmentIds.has(environment.id)) { return Promise.resolve(manuallyDisconnectedResponse(environment)) diff --git a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts index c47608bdca0e..64f36aca0702 100644 --- a/src/renderer/src/web/web-preload-api-runtime-calls.test.ts +++ b/src/renderer/src/web/web-preload-api-runtime-calls.test.ts @@ -275,3 +275,99 @@ describe('web preload runtime calls', () => { expect(call).toHaveBeenLastCalledWith('runtime.queued-timeout', undefined, { timeoutMs: 25 }) }) }) + +describe('direct History search lifetime on the real browser queue', () => { + it('retires queued work and preserves captured pairing across an awaited status response', async () => { + vi.resetModules() + const calls: string[] = [] + let release: () => void = () => {} + let held = new Promise((resolve) => { + release = resolve + }) + vi.doMock('./web-runtime-client', () => ({ + WebRuntimeClient: class { + connectionEpoch = 1 + async call(method: string): Promise> { + calls.push(method) + if (method === 'hold' || method === 'aiVault.searchStatus') { + await held + } + const { searchResults } = await import('../../../shared/ai-vault-search-test-fixture') + const { unavailableSessionSearchStatus } = + await import('../../../shared/ai-vault-search-client') + return { + id: method, + ok: true, + result: + method === 'aiVault.searchStatus' + ? { ...unavailableSessionSearchStatus(), supportedAgents: ['codex'] } + : searchResults(), + _meta: { runtimeId: 'runtime-a' } + } + } + close(): void {} + } + })) + const globals = installBrowserGlobals('Linux') + writeStoredRuntimeEnvironment(globals.storage, 'selected') + const { createWebAiVaultApi } = await import('./preload-api/web-ai-vault-api') + const { webRuntimeState, runtimeCallQueuePool } = + await import('./preload-api/web-runtime-session') + const api = createWebAiVaultApi() + const blockers = Array.from({ length: 8 }, () => + runtimeCallQueuePool.enqueue('selected', 'hold', () => held) + ) + let active = true + const queued = api + .searchSessions({ query: 'needle' }, 'runtime:selected', () => active) + .then( + () => { + throw new Error('retired work accepted') + }, + (error: unknown) => error + ) + active = false + release() + await Promise.all(blockers) + expect(await queued).toBeInstanceOf(Error) + expect(calls).not.toContain('aiVault.searchSessions') + calls.length = 0 + held = new Promise((resolve) => { + release = resolve + }) + const waiting = api + .searchSessions({ query: 'needle', filters: { agents: ['codex'] } }, 'runtime:selected') + .then( + () => { + throw new Error('replaced pairing accepted') + }, + (error: unknown) => error + ) + await vi.waitFor(() => expect(calls).toContain('aiVault.searchStatus')) + const selected = webRuntimeState.activeEnvironment! + webRuntimeState.activeEnvironment = { ...selected, pairingRevision: 2 } + release() + expect(await waiting).toBeInstanceOf(Error) + expect(calls).not.toContain('aiVault.searchSessions') + // Already-issued work may settle, but a selected-environment switch cannot launch its search. + calls.length = 0 + held = new Promise((resolve) => { + release = resolve + }) + const switched = api + .searchSessions({ query: 'needle', filters: { agents: ['codex'] } }, 'runtime:selected') + .then( + () => { + throw new Error('switched target accepted') + }, + (error: unknown) => error + ) + await vi.waitFor(() => expect(calls).toContain('aiVault.searchStatus')) + webRuntimeState.activeEnvironment = { ...selected, id: 'other' } + release() + expect(await switched).toBeInstanceOf(Error) + expect(calls).not.toContain('aiVault.searchSessions') + vi.unstubAllGlobals() + vi.doUnmock('./web-runtime-client') + }) +}) diff --git a/src/shared/ai-vault-project-search.test.ts b/src/shared/ai-vault-project-search.test.ts deleted file mode 100644 index ad6a37ff03d9..000000000000 --- a/src/shared/ai-vault-project-search.test.ts +++ /dev/null @@ -1,249 +0,0 @@ -import { expect, it, vi } from 'vitest' -import { searchCanonicalProject } from './ai-vault-project-search' -import type { - AiVaultSearchHit, - AiVaultSearchRequest, - AiVaultSearchResponse -} from './ai-vault-search-types' -import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' -import { resolveProjectHostSetupProjection } from './project-host-setup-hydration' -import type { Repo } from './repo-types' - -const repos: Repo[] = ['a', 'b', 'provisioned'].map((id, index) => ({ - id, - path: index === 1 ? '/a/nested-b' : `/${id}`, - displayName: id, - badgeColor: '', - addedAt: index + 1, - upstream: { owner: 'acme', repo: 'app' }, - executionHostId: 'runtime:old' -})) -const derived = projectHostSetupProjectionFromRepos(repos) -const raw = derived.setups.map((setup) => ({ - ...setup, - projectId: 'github:acme/app', - ...(setup.repoId === 'provisioned' ? { setupMethod: 'provisioned' as const } : {}) -})) -const catalog = { - ...resolveProjectHostSetupProjection( - repos, - [{ ...derived.projects[0], sourceRepoIds: repos.map((repo) => repo.id) }], - raw - ), - worktrees: [], - rawSetups: raw -} -const hit = (id: string, cwd: string, score = 1): AiVaultSearchHit => ({ - sessionId: id, - title: id, - cwd, - score, - agent: 'codex', - branch: null, - updatedAt: '2026-10-01T00:00:00Z', - messageCount: 1, - source: { presence: 'unverifiable' }, - evidence: null -}) -const page = (hits: AiVaultSearchHit[], cursor: string | null): AiVaultSearchResponse => ({ - kind: 'results', - hits, - page: { cursor, hasMore: cursor !== null }, - generation: 7, - durationMs: 1, - truncated: { candidates: false, snippets: 0, query: false, freshness: false } -}) -const request: AiVaultSearchRequest = { - query: 'needle', - limit: 2, - within: { kind: 'project', projectKey: 'project:github:acme/app' } -} - -it('composes old-peer repo scopes on one endpoint, preserving filtered-page offsets and cursors', async () => { - const search = vi.fn(async (native: AiVaultSearchRequest, scope?: string) => { - expect(scope).toBe('runtime:old') - expect(native.within?.kind).toBe('project') - const key = native.within?.kind === 'project' ? native.within.projectKey : '' - if (key === 'repo:a') { - if (native.cursor === 'a2') { - return page([hit('a2', '/a/second', 3), hit('a3', '/a/third', 2)], null) - } - return page([hit('excluded-b', '/a/nested-b', 10)], 'a2') - } - expect(key).toBe('repo:provisioned') - return page([hit('p1', '/provisioned', 4), hit('p2', '/provisioned/next', 1)], null) - }) - const first = await searchCanonicalProject(request, 'runtime:old', catalog, search) - expect(first.kind).toBe('results') - if (first.kind !== 'results') { - return - } - expect(first.hits.map((row) => row.sessionId)).toEqual(['p1', 'a2']) - expect(first.hits.every((row) => row.executionHostId === 'runtime:old')).toBe(true) - expect(first.page.hasMore).toBe(true) - const next = await searchCanonicalProject( - { ...request, cursor: first.page.cursor! }, - 'runtime:old', - catalog, - search - ) - expect(next.kind).toBe('results') - if (next.kind !== 'results') { - return - } - expect(next.hits.map((row) => row.sessionId)).toEqual(['a3', 'p2']) - expect(next.page.hasMore).toBe(false) - expect( - await searchCanonicalProject( - { ...request, query: 'different', cursor: first.page.cursor! }, - 'runtime:old', - catalog, - search - ) - ).toEqual({ kind: 'stale-cursor', generation: 0 }) -}) - -it('continues a bounded walk after fully filtered native pages and preserves no-service fallback', async () => { - const search = vi.fn(async (native: AiVaultSearchRequest) => - page([hit('b', '/a/nested-b')], String(Number(native.cursor ?? 0) + 1)) - ) - const onlyA = { - ...catalog, - setups: catalog.setups.filter((setup) => setup.repoId !== 'provisioned') - } - const first = await searchCanonicalProject(request, 'runtime:old', onlyA, search) - expect(search).toHaveBeenCalledTimes(3) - expect(first).toMatchObject({ kind: 'results', hits: [], page: { hasMore: true } }) - if (first.kind !== 'results') { - return - } - search.mockImplementation(async () => page([hit('a', '/a/final')], null)) - expect( - await searchCanonicalProject( - { ...request, cursor: first.page.cursor! }, - 'runtime:old', - onlyA, - search - ) - ).toMatchObject({ kind: 'results', hits: [{ sessionId: 'a' }], page: { hasMore: false } }) - expect( - await searchCanonicalProject(request, 'runtime:old', onlyA, async () => ({ - kind: 'unavailable', - reason: 'no-service' - })) - ).toEqual({ kind: 'unavailable', reason: 'no-service' }) -}) - -it('bounds catalog reads when a real scoped search grows from N to 10N owners and hits', async () => { - const reads: number[] = [] - for (const size of [10, 100]) { - let pathReads = 0 - const projection = projectHostSetupProjectionFromRepos( - Array.from({ length: size }, (_, index) => ({ - ...repos[0], - id: `r${index}`, - path: `/r${index}`, - upstream: { owner: 'acme', repo: `app${index}` } - })) - ) - const setups = projection.setups.map((setup) => ({ - ...setup, - get path() { - pathReads++ - return setup.path - } - })) - const response = await searchCanonicalProject( - { - ...request, - limit: size, - within: { kind: 'project', projectKey: 'project:github:acme/app0' } - }, - 'runtime:old', - { ...projection, setups, worktrees: [] }, - async () => - page( - Array.from({ length: size }, (_, index) => hit(`s${index}`, `/r0/${index}`)), - null - ) - ) - expect(response.kind === 'results' && response.hits.length).toBe(size) - expect(pathReads).toBeLessThanOrEqual(size * 3) - reads.push(pathReads) - } - expect(reads[1]).toBeLessThanOrEqual(reads[0] * 10) -}) - -it('keeps one native project query on a correctly advertised endpoint at N and 10N', async () => { - for (const size of [10, 100]) { - const repos = Array.from({ length: size }, (_, index) => ({ - id: `r${index}`, - path: `/r${index}`, - displayName: `r${index}`, - addedAt: index, - badgeColor: '', - upstream: { owner: 'acme', repo: 'app' } - })) - const original = projectHostSetupProjectionFromRepos(repos) - const setups = original.setups.map((setup) => ({ - ...setup, - projectId: 'github:acme/app', - setupMethod: 'provisioned' as const - })) - const projection = resolveProjectHostSetupProjection(repos, original.projects, setups) - const search = vi.fn(async (request: AiVaultSearchRequest) => { - expect(request.within).toEqual({ kind: 'project', projectKey: 'project:github:acme/app' }) - return page([hit('one', '/r0')], null) - }) - const response = await searchCanonicalProject( - request, - 'local', - { ...projection, rawSetups: setups, worktrees: [] }, - search - ) - expect(response).toMatchObject({ kind: 'results', hits: [{ sessionId: 'one' }] }) - expect(search).toHaveBeenCalledTimes(1) - } -}) - -it.each(['relevance', 'newest'] as const)( - 'retains unknown %s frontiers across a finite filtered walk and restarts native staleness', - async (sort) => { - const search = vi.fn(async (native: AiVaultSearchRequest) => { - const key = native.within?.kind === 'project' ? native.within.projectKey : '' - if (key === 'repo:provisioned') { - return page([hit('low', '/provisioned', 1)], null) - } - const offset = Number(native.cursor ?? 0) - return offset < 4 - ? page([hit('excluded', '/a/nested-b', 101)], String(offset + 1)) - : page([hit('high', '/a/high', 100)], null) - }) - const query = { ...request, filters: { sort } } - const first = await searchCanonicalProject(query, 'runtime:old', catalog, search) - expect(first).toMatchObject({ kind: 'results', hits: [], page: { hasMore: true } }) - if (first.kind !== 'results') { - return - } - const next = await searchCanonicalProject( - { ...query, cursor: first.page.cursor! }, - 'runtime:old', - catalog, - search - ) - expect(next).toMatchObject({ - kind: 'results', - hits: [{ sessionId: 'high' }, { sessionId: 'low' }], - page: { hasMore: false } - }) - search.mockImplementation(async () => ({ kind: 'stale-cursor', generation: 8 })) - expect( - await searchCanonicalProject( - { ...query, cursor: first.page.cursor! }, - 'runtime:old', - catalog, - search - ) - ).toEqual({ kind: 'stale-cursor', generation: 0 }) - } -) diff --git a/src/shared/ai-vault-project-search.ts b/src/shared/ai-vault-project-search.ts deleted file mode 100644 index c59e8e6550e8..000000000000 --- a/src/shared/ai-vault-project-search.ts +++ /dev/null @@ -1,199 +0,0 @@ -import type { AiVaultSearchRequest, AiVaultSearchResponse } from './ai-vault-search-types' -import type { ExecutionHostScope } from './execution-host' -import type { ProjectHostSetupProjection } from './project-host-setup-projection' -import { getProjectHostSetupLookup } from './project-host-setup-lookup' -import { searchAllExecutionHosts } from './ai-vault-search-page-walk' -import { toAiVaultProjectKey } from './ai-vault-project-key' -import { normalizeRuntimePathForComparison } from './cross-platform-path' -import { splitWorktreeIdForFilesystem } from './worktree/id' -import type { ProjectHostSetup } from './project-types' -import type { Worktree } from './worktree/types' -import { parseWslUncPath } from './wsl-paths' -import { sessionFileExecutionNamespace } from './ai-vault-scope-cwd-match' - -type Catalog = ProjectHostSetupProjection & { - worktrees: readonly Worktree[] - rawSetups?: readonly ProjectHostSetup[] -} -type Search = ( - request: AiVaultSearchRequest, - scope?: ExecutionHostScope -) => Promise - -/** Client-only plan: native endpoints still receive their existing repo scope and cursor. */ -export async function searchCanonicalProject( - request: AiVaultSearchRequest, - scope: ExecutionHostScope, - catalog: Catalog, - search: Search, - isActive: () => boolean = () => true -): Promise { - if (!isActive()) { - throw new Error('Session search retired') - } - if (request.within?.kind !== 'project') { - return search(request, scope) - } - const projectKey = request.within.projectKey - const selectedByHost = new Map>() - const sources = new Map() - const roots = new Map() - const owners = getProjectHostSetupLookup(catalog.setups) - const addRoot = (host: string, path: string, source: string, priority = 'setup') => { - const normalized = normalizeRuntimePathForComparison(path) - const key = JSON.stringify([host, `${priority}:native`, normalized]) - roots.set(key, roots.has(key) && roots.get(key) !== source ? null : source) - const wsl = parseWslUncPath(path) - if (wsl) { - roots.set( - JSON.stringify([host, `${priority}:wsl:${wsl.distro.toLowerCase()}`, wsl.linuxPath]), - source - ) - } - } - for (const setup of catalog.setups) { - if (!setup.repoId || (scope !== 'all' && setup.hostId !== scope)) { - continue - } - const source = JSON.stringify([setup.hostId, setup.repoId]) - if (toAiVaultProjectKey(setup.projectId, setup.repoId) === projectKey) { - sources.set(source, setup) - const selected = selectedByHost.get(setup.hostId) ?? new Set() - selected.add(source) - selectedByHost.set(setup.hostId, selected) - } - if (setup.path) { - addRoot(setup.hostId, setup.path, source) - } - if (setup.worktreeBasePath) { - addRoot(setup.hostId, setup.worktreeBasePath, source) - } - } - const registered = new Set( - catalog.worktrees.map((workspace) => - JSON.stringify([owners.forWorkspace(workspace)?.hostId, workspace.id]) - ) - ) - // Registered workspace roots outrank checkout roots, including linked workspaces and prior paths. - for (const workspace of catalog.worktrees) { - const owner = owners.forWorkspace(workspace) - if (!owner?.hostId || !owner.repoId) { - continue - } - const source = JSON.stringify([owner.hostId, owner.repoId]) - for (const path of [ - workspace.path, - ...(workspace.priorWorktreeIds ?? []).flatMap((id) => { - if (registered.has(JSON.stringify([owner.hostId, id]))) { - return [] - } - const parsed = splitWorktreeIdForFilesystem(id) - return parsed?.repoId === workspace.repoId ? [parsed.worktreePath] : [] - }) - ]) { - addRoot(owner.hostId, path, source, 'workspace') - } - } - if (!sources.size) { - return search(request, scope) - } - const advertised = new Map }>() - for (const setup of catalog.rawSetups ?? []) { - if (!setup.repoId) { - continue - } - const scope = toAiVaultProjectKey(setup.projectId, setup.repoId)! - const key = JSON.stringify([setup.hostId, scope]) - const group = advertised.get(key) ?? { host: setup.hostId, scope, sources: new Set() } - group.sources.add(JSON.stringify([setup.hostId, setup.repoId])) - advertised.set(key, group) - } - const scopeByHost = new Map() - for (const group of advertised.values()) { - const selected = selectedByHost.get(group.host) - if ( - selected?.size === group.sources.size && - [...selected].every((source) => group.sources.has(source)) - ) { - scopeByHost.set(group.host, group.scope) - } - } - const nativeSources = new Map( - [...sources].map(([source, setup]) => [ - scopeByHost.has(setup.hostId) - ? JSON.stringify([setup.hostId, scopeByHost.get(setup.hostId)]) - : source, - setup - ]) - ) - const plan = JSON.stringify([ - request.query, - request.within, - request.filters, - request.scope, - request.freshness, - [...sources.keys()].sort(), - [...scopeByHost].sort(), - [...roots].sort() - ]) - const response = await searchAllExecutionHosts( - request, - [...nativeSources].map(([streamKey, setup]) => ({ - streamKey, - executionHostId: setup.hostId, - search: (nativeRequest) => - search( - { - ...nativeRequest, - within: { - kind: 'project', - projectKey: scopeByHost.get(setup.hostId) ?? `repo:${setup.repoId}` - } - }, - setup.hostId - ), - accept: (hit) => { - for (const priority of ['workspace', 'setup']) { - let path = hit.cwd ? normalizeRuntimePathForComparison(hit.cwd) : '' - while (path) { - const key = JSON.stringify([setup.hostId, `${priority}:native`, path]) - const wslKey = JSON.stringify([ - setup.hostId, - `${priority}:${sessionFileExecutionNamespace(hit.source.filePath ?? '')}`, - path - ]) - if (roots.has(key) || roots.has(wslKey)) { - return scopeByHost.has(setup.hostId) - ? selectedByHost - .get(setup.hostId)! - .has(roots.has(key) ? roots.get(key)! : roots.get(wslKey)!) - : (roots.has(key) ? roots.get(key) : roots.get(wslKey)) === streamKey - } - path = path.slice(0, path.lastIndexOf('/')) - } - } - return true // The endpoint may know a registered path this client's catalog has not loaded. - } - })), - plan, - isActive - ) - const outcomes = response.kind === 'results' ? (response.hosts ?? []) : [] - const reason = outcomes[0]?.outcome - if ( - scope !== 'all' && - response.kind === 'results' && - !response.hits.length && - !response.page.hasMore && - reason && - outcomes.every((host) => host.outcome === reason) && - (reason === 'disabled' || - reason === 'no-service' || - reason === 'not-ready' || - reason === 'scope-unknown' || - reason === 'unsupported-agent') - ) { - return { kind: 'unavailable', reason } - } - return response -} diff --git a/src/shared/ai-vault-runtime-capabilities.ts b/src/shared/ai-vault-runtime-capabilities.ts index 78157a26b4ee..f9e84964fb5a 100644 --- a/src/shared/ai-vault-runtime-capabilities.ts +++ b/src/shared/ai-vault-runtime-capabilities.ts @@ -5,10 +5,14 @@ import { AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY } from './ai-vault-resume-preparat // STATIC capability advertised by getStatus() automatically — NOT a runtime // conditional like browser.headless.v1. export const AI_VAULT_RUNTIME_CAPABILITY = 'aiVault.v1' as const +// Canonical Project intent is resolved before native candidate limits; absent intent stays legacy. +export const AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY = + 'aiVault.search.canonical-project.v1' as const export const AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY = 'aiVault.session-titles.v1' as const export const AI_VAULT_RUNTIME_CAPABILITIES = [ AI_VAULT_RUNTIME_CAPABILITY, + AI_VAULT_CANONICAL_PROJECT_SEARCH_CAPABILITY, AI_VAULT_SESSION_TITLES_RUNTIME_CAPABILITY, AI_VAULT_CHAT_FORK_SOURCE_CAPABILITY ] as const diff --git a/src/shared/ai-vault-search-host-page.ts b/src/shared/ai-vault-search-host-page.ts deleted file mode 100644 index 2a41f41755b5..000000000000 --- a/src/shared/ai-vault-search-host-page.ts +++ /dev/null @@ -1,152 +0,0 @@ -import type { - AiVaultSearchHit, - AiVaultSearchHostOutcome, - AiVaultSearchRequest, - AiVaultSearchResponse -} from './ai-vault-search-types' -import type { ExecutionHostId } from './execution-host' -import type { MergedSearchCursorEntry } from './ai-vault-search-merged-cursor' - -export type SessionSearchHostLeg = { - executionHostId: ExecutionHostId - streamKey?: string - accept?: (hit: AiVaultSearchHit) => boolean - /** Omitted for the in-process local leg, which has no transport to hang on. */ - timeoutMs?: number - search: (request: AiVaultSearchRequest) => Promise -} - -export type MergedSort = 'relevance' | 'newest' -type HostOutcome = AiVaultSearchHostOutcome['outcome'] -export type Truncation = Extract['truncated'] - -export type HostWalk = { - executionHostId: ExecutionHostId - leg: SessionSearchHostLeg - request: AiVaultSearchRequest - outcome: HostOutcome - cursor: string | null - emitted: number - generation: number - pending: AiVaultSearchHit[] - nextCursor: string | null - pages: number - /** This host still owes hits this request could not read; keep its entry. */ - carry: boolean - truncated: Truncation -} - -export function streamKey(leg: SessionSearchHostLeg): string { - return leg.streamKey ?? leg.executionHostId -} - -/** Every leg answers in the merged order; the cursor and debug are this merge's own. */ -export function legRequest(request: AiVaultSearchRequest, sort: MergedSort): AiVaultSearchRequest { - const { cursor: _cursor, debug: _debug, ...rest } = request - return { ...rest, filters: { ...request.filters, sort } } -} - -export function newHostWalk(leg: SessionSearchHostLeg, request: AiVaultSearchRequest): HostWalk { - return { - executionHostId: leg.executionHostId, - leg, - request, - outcome: 'unreachable', - cursor: null, - emitted: 0, - generation: 0, - pending: [], - nextCursor: null, - pages: 0, - carry: false, - truncated: { - candidates: false, - snippets: 0, - query: false, - freshness: false - } - } -} - -export async function fetchHostPage( - walk: HostWalk, - entry: MergedSearchCursorEntry | null -): Promise { - walk.cursor = entry?.c ?? null - walk.emitted = entry?.e ?? 0 - walk.generation = entry?.g ?? 0 - walk.pages += 1 - let response: AiVaultSearchResponse - try { - const { cursor } = walk - response = await withLegTimeout( - walk.leg.search(cursor === null ? walk.request : { ...walk.request, cursor }), - walk.leg.timeoutMs - ) - } catch (error) { - console.error(`[ai-vault-search] ${walk.executionHostId} leg failed:`, error) - // Keep its place: the next merged page retries this host from here. - endHostWalk(walk, 'unreachable', true) - return - } - if (response.kind === 'unavailable') { - endHostWalk(walk, response.reason, false) - return - } - // A cursor this merge minted can only be refused because the host's index - // moved, so both refusals mean the same thing: this host is done for now. - if (response.kind !== 'results') { - endHostWalk(walk, 'stale', false) - return - } - // `e` is an offset into one generation's ranked page, so it is only - // meaningful while that generation stands. Nothing emitted, nothing to fence. - if ( - (walk.emitted > 0 || walk.cursor !== null || walk.generation > 0) && - walk.generation !== response.generation - ) { - endHostWalk(walk, 'stale', false) - return - } - walk.outcome = 'searched' - walk.generation = response.generation - walk.pending = stampExecutionHost(response.hits, walk.executionHostId).slice(walk.emitted) - walk.nextCursor = response.page.hasMore ? response.page.cursor : null - walk.carry = false - walk.truncated.candidates ||= response.truncated.candidates - walk.truncated.snippets += response.truncated.snippets - walk.truncated.query ||= response.truncated.query - walk.truncated.freshness ||= response.truncated.freshness -} - -function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void { - walk.outcome = outcome - walk.pending = [] - walk.nextCursor = null - walk.carry = carry -} - -// This desktop owns which host it addressed; never trust an id the far side returned. -function stampExecutionHost( - hits: readonly AiVaultSearchHit[], - executionHostId: ExecutionHostId -): AiVaultSearchHit[] { - return hits.map((hit) => ({ ...hit, executionHostId })) -} - -async function withLegTimeout(pending: Promise, timeoutMs: number | undefined): Promise { - if (timeoutMs === undefined) { - return pending - } - let timer: ReturnType | undefined - try { - return await Promise.race([ - pending, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error('Session search host timed out.')), timeoutMs) - }) - ]) - } finally { - clearTimeout(timer) - } -} diff --git a/src/shared/ai-vault-search-page-walk.ts b/src/shared/ai-vault-search-page-walk.ts deleted file mode 100644 index 2a53031385e0..000000000000 --- a/src/shared/ai-vault-search-page-walk.ts +++ /dev/null @@ -1,258 +0,0 @@ -import { resolveSessionSearchLimit } from './ai-vault-search-limit' -import type { - AiVaultSearchHit, - AiVaultSearchHostOutcome, - AiVaultSearchRequest, - AiVaultSearchResponse -} from './ai-vault-search-types' -import type { ExecutionHostId } from './execution-host' -import { - decodeMergedSearchCursor, - encodeMergedSearchCursor, - type MergedSearchCursorEntry -} from './ai-vault-search-merged-cursor' - -export type { SessionSearchHostLeg } from './ai-vault-search-host-page' -import { - fetchHostPage, - newHostWalk, - legRequest, - streamKey, - type HostWalk, - type MergedSort, - type Truncation, - type SessionSearchHostLeg -} from './ai-vault-search-host-page' - -const MAX_HOST_PAGES_PER_REQUEST = 3 - -/** - * Fans one query out to every execution host and merges the pages into one. - * - * Relevance scores come from independent indexes and are not comparable, so the - * two orders are the only two that mean anything across hosts: recency, which - * every host can be asked for directly, and round-robin over each host's own - * ranking. Legs are walked page by page, so a hit that lost the cut on one page - * is emitted on the next instead of being dropped. - */ -export async function searchAllExecutionHosts( - request: AiVaultSearchRequest, - legs: readonly SessionSearchHostLeg[], - plan?: string, - isActive: () => boolean = () => true -): Promise { - const startedAt = Date.now() - const limit = resolveSessionSearchLimit(request.limit) - const sort: MergedSort = request.filters?.sort ?? 'relevance' - const resumed = request.cursor === undefined ? null : decodeMergedSearchCursor(request.cursor) - if (resumed && resumed.plan !== plan) { - return { kind: 'stale-cursor', generation: 0 } - } - if (request.cursor !== undefined) { - const known = new Set(legs.map((leg) => streamKey(leg))) - // A cursor belongs to one query over one host set; anything else is not ours. - if ( - !resumed || - resumed.limit !== limit || - resumed.sort !== sort || - Object.keys(resumed.hosts).some((executionHostId) => !known.has(executionHostId)) - ) { - return { kind: 'malformed-cursor' } - } - } - const walks = legs - .filter((leg) => !resumed || resumed.hosts[streamKey(leg)] !== undefined) - .map((leg) => newHostWalk(leg, legRequest(request, sort))) - .sort( - (left, right) => - left.executionHostId.localeCompare(right.executionHostId) || - streamKey(left.leg).localeCompare(streamKey(right.leg)) - ) - const nextByHost = new Map() - for (const walk of walks) { - const queue = nextByHost.get(walk.executionHostId) ?? [] - queue.push(walk) - nextByHost.set(walk.executionHostId, queue) - } - await Promise.all( - [...nextByHost.values()].flatMap((queue) => { - let position = 0 - return Array.from({ length: Math.min(4, queue.length) }, async () => { - while (position < queue.length && isActive()) { - const walk = queue[position++]! - await fetchHostPage(walk, resumed?.hosts[streamKey(walk.leg)] ?? null) - await advanceHostWalk(walk, isActive) - } - }) - }) - ) - if (!isActive()) { - throw new Error('Session search retired') - } - const hits = await drainMergedPage(walks, limit, sort, isActive, plan) - if (!isActive()) { - throw new Error('Session search retired') - } - if (plan && walks.some((walk) => walk.outcome === 'stale')) { - return { kind: 'stale-cursor', generation: 0 } - } - return mergedSearchResponse(walks, { limit, sort, plan }, hits, Date.now() - startedAt) -} - -async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise { - while (isActive()) { - while (walk.pending.length && walk.leg.accept && !walk.leg.accept(walk.pending[0]!)) { - walk.pending.shift() - walk.emitted += 1 - } - if (walk.pending.length || walk.nextCursor === null) { - return - } - if (walk.pages >= MAX_HOST_PAGES_PER_REQUEST) { - walk.carry = true - return - } - await fetchHostPage(walk, { c: walk.nextCursor, e: 0, g: walk.generation }) - } -} - -async function drainMergedPage( - walks: readonly HostWalk[], - limit: number, - sort: MergedSort, - isActive: () => boolean, - plan?: string -): Promise { - const hits: AiVaultSearchHit[] = [] - let turn = 0 - while (hits.length < limit && isActive()) { - const frontiers = plan ? walks.filter((walk) => walk.carry && walk.outcome === 'searched') : [] - if (sort === 'newest' && frontiers.length > 0) { - break - } - const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn, frontiers) - if (!next) { - break - } - turn = walks.findIndex((walk) => walk.executionHostId.localeCompare(next.executionHostId) > 0) - if (turn < 0) { - turn = 0 - } - hits.push(next.pending.shift()!) - next.emitted += 1 - await advanceHostWalk(next, isActive) - } - return hits -} - -/** Round-robin in host-id order, resuming after whichever host answered last. */ -function walkWithTurn( - walks: readonly HostWalk[], - turn: number, - frontiers: readonly HostWalk[] -): HostWalk | null { - for (let step = 0; step < walks.length; step++) { - const walk = walks[(turn + step) % walks.length] - if ( - walk && - walk.pending.length > 0 && - !frontiers.some((source) => source.executionHostId === walk.executionHostId) - ) { - let best = walk - for (const source of walks) { - if ( - source.executionHostId === walk.executionHostId && - source.pending.length && - (source.pending[0]!.score > best.pending[0]!.score || - (source.pending[0]!.score === best.pending[0]!.score && - streamKey(source.leg) < streamKey(best.leg))) - ) { - best = source - } - } - return best - } - } - return null -} - -function mostRecentWalk(walks: readonly HostWalk[]): HostWalk | null { - let best: HostWalk | null = null - for (const walk of walks) { - const head = walk.pending[0] - // Walks are in host-id order, so a strict comparison keeps the first host on a tie. - if (head && (!best || byRecencyDescending(head, best.pending[0]!) < 0)) { - best = walk - } - } - return best -} - -function mergedSearchResponse( - walks: readonly HostWalk[], - query: { limit: number; sort: MergedSort; plan?: string }, - hits: AiVaultSearchHit[], - durationMs: number -): AiVaultSearchResponse { - const hosts: AiVaultSearchHostOutcome[] = [] - const nextHosts: Record = {} - const truncated: Truncation = { - candidates: false, - snippets: 0, - query: false, - freshness: false - } - for (const walk of walks) { - if ( - !hosts.some( - (host) => host.executionHostId === walk.executionHostId && host.outcome === walk.outcome - ) - ) { - hosts.push({ executionHostId: walk.executionHostId, outcome: walk.outcome }) - } - const entry = nextCursorEntry(walk) - if (entry) { - nextHosts[streamKey(walk.leg)] = entry - } - truncated.candidates ||= walk.truncated.candidates - truncated.snippets += walk.truncated.snippets - truncated.query ||= walk.truncated.query - truncated.freshness ||= walk.truncated.freshness - } - const hasMore = Object.keys(nextHosts).length > 0 - const cursor = hasMore ? encodeMergedSearchCursor({ ...query, hosts: nextHosts }) : null - return { - kind: 'results', - hits, - page: { cursor, hasMore }, - generation: walks.length === 1 ? walks[0]!.generation : 0, - truncated, - durationMs, - hosts - } -} - -/** Resume where this request stopped: mid-page by skip count, else the unread page. */ -function nextCursorEntry(walk: HostWalk): MergedSearchCursorEntry | null { - if (walk.pending.length > 0) { - return { c: walk.cursor, e: walk.emitted, g: walk.generation } - } - if (walk.nextCursor !== null) { - return { c: walk.nextCursor, e: 0, g: walk.generation } - } - return walk.carry ? { c: walk.cursor, e: walk.emitted, g: walk.generation } : null -} - -function byRecencyDescending(left: AiVaultSearchHit, right: AiVaultSearchHit): number { - const leftMs = updatedAtMs(left) - const rightMs = updatedAtMs(right) - if (leftMs === rightMs) { - return 0 - } - return leftMs === null ? 1 : rightMs === null ? -1 : rightMs - leftMs -} - -function updatedAtMs(hit: AiVaultSearchHit): number | null { - const parsed = hit.updatedAt === null ? Number.NaN : Date.parse(hit.updatedAt) - return Number.isNaN(parsed) ? null : parsed -} diff --git a/src/shared/ai-vault-search-scope.ts b/src/shared/ai-vault-search-scope.ts index c84e6b51676f..87f10af31bd0 100644 --- a/src/shared/ai-vault-search-scope.ts +++ b/src/shared/ai-vault-search-scope.ts @@ -4,7 +4,11 @@ import { z } from 'zod' // catalog; `scope` on the request already means the search tier. export const AiVaultSearchScopeIdentitySchema = z.discriminatedUnion('kind', [ z.object({ kind: z.literal('workspace'), worktreeId: z.string().min(1).max(8192) }), - z.object({ kind: z.literal('project'), projectKey: z.string().min(1).max(1024) }) + z.object({ + kind: z.literal('project'), + projectKey: z.string().min(1).max(1024), + canonicalProjectKey: z.string().min(1).max(1024).optional() + }) ]) export type AiVaultSearchScopeIdentity = z.infer diff --git a/src/shared/git-remote-identity.ts b/src/shared/git-remote-identity.ts index c4c215e125da..09328a51584f 100644 --- a/src/shared/git-remote-identity.ts +++ b/src/shared/git-remote-identity.ts @@ -139,3 +139,13 @@ export function deriveGitRemoteIdentity(stdout: string): GitRemoteIdentity | nul } : null } + +export function normalizeSavedGitRemoteIdentity( + identity: GitRemoteIdentity | null | undefined +): GitRemoteIdentity | null { + const canonicalKey = + typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : '' + const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : '' + const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : '' + return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null +} diff --git a/src/shared/project-catalog-canonical-projection.ts b/src/shared/project-catalog-canonical-projection.ts new file mode 100644 index 000000000000..4ccbc7d5e8e7 --- /dev/null +++ b/src/shared/project-catalog-canonical-projection.ts @@ -0,0 +1,92 @@ +import type { Project, ProjectHostSetup } from './project-types' +import type { Repo } from './repo-types' +import { getProjectHostSetupLookup } from './project-host-setup-lookup' +import { + getRepoDerivedSetupKey, + mergeProjectHostSetupCompatibility +} from './project-compatibility-merge' +import { + projectHostSetupProjectionFromRepos, + type ProjectHostSetupProjection +} from './project-host-setup-projection' + +/** Canonical client affiliation; the supplied endpoint catalog remains untouched. */ +export function resolveProjectHostSetupProjection( + repos: readonly Repo[], + projects: readonly Project[], + setups: readonly ProjectHostSetup[], + derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) +): ProjectHostSetupProjection { + if (setups.some((setup) => setup.setupMethod === 'provisioned' && setup.repoId)) { + derived = projectHostSetupProjectionFromRepos(repos, undefined, setups) + } + const owners = getProjectHostSetupLookup(derived.setups) + const projectById = new Map(projects.map((project) => [project.id, project])) + const replacedProjectIds = new Set() + const unresolved = new Set( + derived.projects.flatMap((project) => + project.providerIdentity || project.gitRemoteIdentity ? [] : [project.id] + ) + ) + const metadataByProject = new Map() + const normalizedSetups = setups.map((setup) => { + const projectId = owners.projectId({ + repoId: setup.repoId, + hostId: setup.executionHostId ?? setup.hostId, + runtimeOwnerEnvironmentId: setup.runtimeOwnerEnvironmentId + }) + if (!projectId || unresolved.has(projectId)) { + return setup + } + const metadata = projectById.get(setup.projectId) + if (metadata && !metadataByProject.has(projectId)) { + metadataByProject.set(projectId, metadata) + } + if (projectId === setup.projectId) { + return setup + } + replacedProjectIds.add(setup.projectId) + return { ...setup, projectId } + }) + const covered = new Set(setups.map(getRepoDerivedSetupKey)) + if ( + replacedProjectIds.size === 0 && + derived.setups.every((setup) => covered.has(getRepoDerivedSetupKey(setup))) + ) { + return { projects, setups } + } + const allSetups = mergeProjectHostSetupCompatibility( + { projects: derived.projects, projectHostSetups: derived.setups }, + { projects, setups: normalizedSetups } + ).projectHostSetups + const membership = new Map>() + for (const setup of allSetups) { + const ids = membership.get(setup.projectId) ?? new Set() + if (setup.repoId) { + ids.add(setup.repoId) + } + membership.set(setup.projectId, ids) + } + const canonicalProjects = derived.projects + .filter((project) => membership.has(project.id)) + .map((project) => ({ + ...project, + ...metadataByProject.get(project.id), + id: project.id, + displayName: project.displayName, + badgeColor: project.badgeColor, + sourceRepoIds: [...(membership.get(project.id) ?? [])] + })) + const canonicalIds = new Set(canonicalProjects.map((project) => project.id)) + return { + projects: [ + ...canonicalProjects, + ...projects.filter( + (project) => + !canonicalIds.has(project.id) && + (!replacedProjectIds.has(project.id) || membership.has(project.id)) + ) + ], + setups: allSetups + } +} diff --git a/src/shared/project-host-setup-hydration.test.ts b/src/shared/project-host-setup-hydration.test.ts index 893ee75c3d81..c39df74cd384 100644 --- a/src/shared/project-host-setup-hydration.test.ts +++ b/src/shared/project-host-setup-hydration.test.ts @@ -1,5 +1,5 @@ import { expect, it } from 'vitest' -import { resolveProjectHostSetupProjection } from './project-host-setup-hydration' +import { resolveProjectHostSetupProjection } from './project-catalog-canonical-projection' import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' import { adoptEndpointProjectCatalog, adoptEndpointRepo } from './endpoint-catalog-adoption' import type { Repo } from './repo-types' diff --git a/src/shared/project-host-setup-hydration.ts b/src/shared/project-host-setup-hydration.ts index 4ccbc7d5e8e7..b2b431f0fbd0 100644 --- a/src/shared/project-host-setup-hydration.ts +++ b/src/shared/project-host-setup-hydration.ts @@ -1,92 +1,120 @@ import type { Project, ProjectHostSetup } from './project-types' import type { Repo } from './repo-types' -import { getProjectHostSetupLookup } from './project-host-setup-lookup' import { - getRepoDerivedSetupKey, - mergeProjectHostSetupCompatibility -} from './project-compatibility-merge' -import { - projectHostSetupProjectionFromRepos, + getProjectIdentityKey, + projectHostSetupCompatibilityFromRepos, type ProjectHostSetupProjection } from './project-host-setup-projection' -/** Canonical client affiliation; the supplied endpoint catalog remains untouched. */ -export function resolveProjectHostSetupProjection( +type NormalizedProjectHostSetupProjection = ProjectHostSetupProjection & { + changed: boolean +} + +function normalizeHydratedProjectHostSetupProjection( repos: readonly Repo[], projects: readonly Project[], setups: readonly ProjectHostSetup[], - derived: ProjectHostSetupProjection = projectHostSetupProjectionFromRepos(repos) -): ProjectHostSetupProjection { - if (setups.some((setup) => setup.setupMethod === 'provisioned' && setup.repoId)) { - derived = projectHostSetupProjectionFromRepos(repos, undefined, setups) - } - const owners = getProjectHostSetupLookup(derived.setups) - const projectById = new Map(projects.map((project) => [project.id, project])) - const replacedProjectIds = new Set() - const unresolved = new Set( - derived.projects.flatMap((project) => - project.providerIdentity || project.gitRemoteIdentity ? [] : [project.id] - ) - ) - const metadataByProject = new Map() + derived: ProjectHostSetupProjection +): NormalizedProjectHostSetupProjection { + const repoById = new Map(repos.map((repo) => [repo.id, repo])) + const derivedProjectIds = new Set(derived.projects.map((project) => project.id)) + const projectIdByHydratedProjectId = new Map() + let changed = false const normalizedSetups = setups.map((setup) => { - const projectId = owners.projectId({ - repoId: setup.repoId, - hostId: setup.executionHostId ?? setup.hostId, - runtimeOwnerEnvironmentId: setup.runtimeOwnerEnvironmentId - }) - if (!projectId || unresolved.has(projectId)) { + const repo = repoById.get(setup.repoId) ?? repoById.get(setup.id) + if (!repo) { return setup } - const metadata = projectById.get(setup.projectId) - if (metadata && !metadataByProject.has(projectId)) { - metadataByProject.set(projectId, metadata) - } - if (projectId === setup.projectId) { + const projectId = getProjectIdentityKey(repo) + if (projectId === setup.projectId || projectId === `repo:${repo.id}`) { return setup } - replacedProjectIds.add(setup.projectId) + changed = true + projectIdByHydratedProjectId.set(setup.projectId, projectId) return { ...setup, projectId } }) - const covered = new Set(setups.map(getRepoDerivedSetupKey)) - if ( - replacedProjectIds.size === 0 && - derived.setups.every((setup) => covered.has(getRepoDerivedSetupKey(setup))) - ) { - return { projects, setups } + const normalizedProjects = projects.flatMap((project) => { + const projectId = projectIdByHydratedProjectId.get(project.id) + if (!projectId || projectId === project.id) { + return [project] + } + // Why: runtime-hosted copies of the same Git repo may hydrate path-scoped + // project ids. If the repo-derived project already exists, keep that bucket + // authoritative so VM copies group under the user's single project. + if (derivedProjectIds.has(projectId)) { + changed = true + return [] + } + changed = true + return [{ ...project, id: projectId }] + }) + return { projects: normalizedProjects, setups: normalizedSetups, changed } +} + +/** `overlay` rows replace `base` rows of the same id; new ids append in overlay order. */ +function mergeProjectionRowsById( + base: readonly T[], + overlay: readonly T[] +): T[] { + const merged = [...base] + const indexById = new Map(merged.map((entry, index) => [entry.id, index])) + for (const entry of overlay) { + const index = indexById.get(entry.id) + if (index === undefined) { + indexById.set(entry.id, merged.length) + merged.push(entry) + } else { + merged[index] = entry + } } - const allSetups = mergeProjectHostSetupCompatibility( - { projects: derived.projects, projectHostSetups: derived.setups }, - { projects, setups: normalizedSetups } - ).projectHostSetups - const membership = new Map>() - for (const setup of allSetups) { - const ids = membership.get(setup.projectId) ?? new Set() - if (setup.repoId) { - ids.add(setup.repoId) + return merged +} + +/** Whether some repo has no host setup naming it, by setup repo id or setup id. */ +function hasReposWithoutProjectHostSetup( + repos: readonly Pick[], + setups: readonly ProjectHostSetup[] +): boolean { + const repoIds = new Set(repos.map((repo) => repo.id)) + const coveredRepoIds = new Set() + for (const setup of setups) { + const repoId = typeof setup.repoId === 'string' ? setup.repoId : '' + if (repoIds.has(repoId)) { + coveredRepoIds.add(repoId) + } + if (repoIds.has(setup.id)) { + coveredRepoIds.add(setup.id) } - membership.set(setup.projectId, ids) } - const canonicalProjects = derived.projects - .filter((project) => membership.has(project.id)) - .map((project) => ({ - ...project, - ...metadataByProject.get(project.id), - id: project.id, - displayName: project.displayName, - badgeColor: project.badgeColor, - sourceRepoIds: [...(membership.get(project.id) ?? [])] - })) - const canonicalIds = new Set(canonicalProjects.map((project) => project.id)) + return repos.length > 0 && coveredRepoIds.size < repoIds.size +} + +/** The repo-derived rows overlaid with the hydrated ones, so a repo without a setup still groups. */ +function mergeDerivedProjectHostSetupProjection( + derived: ProjectHostSetupProjection, + normalized: ProjectHostSetupProjection +): ProjectHostSetupProjection { return { - projects: [ - ...canonicalProjects, - ...projects.filter( - (project) => - !canonicalIds.has(project.id) && - (!replacedProjectIds.has(project.id) || membership.has(project.id)) - ) - ], - setups: allSetups + projects: mergeProjectionRowsById(derived.projects, normalized.projects), + setups: mergeProjectionRowsById(derived.setups, normalized.setups) } } + +/** + * The projects and setups a catalog groups by: the hydrated rows, normalized onto the repos' + * identities, and backed by the repo-derived rows when any repo has no setup. The renderer's + * selector is a memo over this. + */ +export function resolveProjectHostSetupProjection( + repos: readonly Repo[], + projects: readonly Project[], + setups: readonly ProjectHostSetup[], + derived: ProjectHostSetupProjection = projectHostSetupCompatibilityFromRepos(repos) +): ProjectHostSetupProjection { + const normalized = normalizeHydratedProjectHostSetupProjection(repos, projects, setups, derived) + // Why: older runtimes/profiles may hydrate empty or partial project/setup arrays + // beside legacy repos. Keep repo-backed compatibility rows visible in that case. + return hasReposWithoutProjectHostSetup(repos, setups) || normalized.changed + ? mergeDerivedProjectHostSetupProjection(derived, normalized) + : { projects, setups } +} diff --git a/src/shared/project-host-setup-lookup.ts b/src/shared/project-host-setup-lookup.ts index 1fee515042e8..e9c7681c554c 100644 --- a/src/shared/project-host-setup-lookup.ts +++ b/src/shared/project-host-setup-lookup.ts @@ -8,6 +8,7 @@ import { getRepoHostIdentityForParts, getRepoSetupOwnerScope } from './repo-host import type { Repo } from './repo-types' import type { WorktreeMeta } from './worktree/meta-types' import { projectHostSetupProjectionFromRepos } from './project-host-setup-projection' +import { resolveProjectHostSetupProjection } from './project-host-setup-hydration' type SetupOwner = Pick & Partial> @@ -119,10 +120,20 @@ export function getProjectHostSetupWorktreeMeta( setups: readonly ProjectHostSetup[], repo: Repo ): Pick { - const setup = getProjectHostSetupForRepo(setups, repo) + const setup = getProjectHostSetupForRepo( + resolveProjectHostSetupProjection([repo], [], setups).setups, + repo + ) return { projectId: setup.projectId, hostId: setup.hostId, projectHostSetupId: setup.id } } + +export function getProjectHostSetupsForProject( + setups: readonly ProjectHostSetup[], + projectId: string +): readonly ProjectHostSetup[] { + return setups.filter((setup) => setup.projectId === projectId) +} diff --git a/src/shared/project-host-setup-projection.test.ts b/src/shared/project-host-setup-projection.test.ts index 008e6510677e..1f2edf259264 100644 --- a/src/shared/project-host-setup-projection.test.ts +++ b/src/shared/project-host-setup-projection.test.ts @@ -1,12 +1,14 @@ import { describe, expect, it } from 'vitest' import { projectHostSetupProjectionFromRepos, - getProjectHostSetupsForProject, isGitHubBackedRepo, getProjectIdForProviderIdentity, isProjectRemoteIdentityPending } from './project-host-setup-projection' -import { getProjectHostSetupWorktreeMeta } from './project-host-setup-lookup' +import { + getProjectHostSetupsForProject, + getProjectHostSetupWorktreeMeta +} from './project-host-setup-lookup' import type { Repo } from './repo-types' function repo(overrides: Partial & Pick): Repo { diff --git a/src/shared/project-host-setup-projection.ts b/src/shared/project-host-setup-projection.ts index 385379bf1361..4c69a8404651 100644 --- a/src/shared/project-host-setup-projection.ts +++ b/src/shared/project-host-setup-projection.ts @@ -1,3 +1,4 @@ +import { normalizeSavedGitRemoteIdentity } from './git-remote-identity' import { getRepoExecutionHostId } from './execution-host' import { normalizeProjectHostSetupRow, @@ -76,17 +77,6 @@ export function getGitRemoteProviderIdentity( ) } -function getProjectGitRemoteIdentity( - repo: Pick -): NonNullable | null { - const identity = repo.gitRemoteIdentity - const canonicalKey = - typeof identity?.canonicalKey === 'string' ? identity.canonicalKey.trim() : '' - const remoteName = typeof identity?.remoteName === 'string' ? identity.remoteName.trim() : '' - const remoteUrl = typeof identity?.remoteUrl === 'string' ? identity.remoteUrl.trim() : '' - return canonicalKey && remoteName && remoteUrl ? { canonicalKey, remoteName, remoteUrl } : null -} - /** True when the repo resolves to a GitHub provider identity (via explicit * upstream or a GitHub-sourced avatar icon). Used to scope GitHub-CLI setup * prompts to users who actually have GitHub-backed projects. */ @@ -99,7 +89,10 @@ export function isGitHubBackedRepo( export function hasProjectRemoteIdentity( repo: Pick ): boolean { - return getProjectProviderIdentity(repo) !== null || getProjectGitRemoteIdentity(repo) !== null + return ( + getProjectProviderIdentity(repo) !== null || + normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) !== null + ) } /** True while nothing has settled the repo's remote identity yet: the background @@ -123,7 +116,7 @@ export function getProjectIdentityKey( if (identity) { return getProjectIdForProviderIdentity(identity) } - const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) + const gitRemoteIdentity = normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) if (gitRemoteIdentity) { return `${GIT_REMOTE_PROJECT_ID_PREFIX}${gitRemoteIdentity.canonicalKey}` } @@ -259,7 +252,7 @@ export function mergeCatalogUpdatedAt(left: number, right: number): number { function createProjectFromRepo(repo: Repo, projectId: string): Project { const identity = getProjectProviderIdentity(repo) - const gitRemoteIdentity = getProjectGitRemoteIdentity(repo) + const gitRemoteIdentity = normalizeSavedGitRemoteIdentity(repo.gitRemoteIdentity) const addedAt = catalogTimestampFromAddedAt(repo.addedAt) return { id: projectId, @@ -316,6 +309,20 @@ export function projectHostSetupProjectionFromRepos( repos: readonly Repo[], _now?: number, setupFacts: readonly ProjectHostSetup[] = [] +): ProjectHostSetupProjection { + return projectHostSetupProjection(repos, setupFacts, true) +} + +export function projectHostSetupCompatibilityFromRepos( + repos: readonly Repo[] +): ProjectHostSetupProjection { + return projectHostSetupProjection(repos, [], false) +} + +function projectHostSetupProjection( + repos: readonly Repo[], + setupFacts: readonly ProjectHostSetup[], + claims: boolean ): ProjectHostSetupProjection { const projectById = new Map() const setups: ProjectHostSetup[] = [] @@ -334,7 +341,7 @@ export function projectHostSetupProjectionFromRepos( const setupMethod = facts?.setupMethod ?? setup.setupMethod return normalizeProjectHostSetupRow({ ...setup, setupMethod }) }) - const outclaimed = getOutclaimedProjectSetups(remoteSetups) + const outclaimed = claims ? getOutclaimedProjectSetups(remoteSetups) : new Set() for (const [index, repo] of repos.entries()) { const remoteSetup = remoteSetups[index] const setup = outclaimed.has(remoteSetup) @@ -355,10 +362,3 @@ export function projectHostSetupProjectionFromRepos( setups } } - -export function getProjectHostSetupsForProject( - setups: readonly ProjectHostSetup[], - projectId: string -): readonly ProjectHostSetup[] { - return setups.filter((setup) => setup.projectId === projectId) -} diff --git a/src/shared/sidebar-list/sidebar-project-catalog.ts b/src/shared/sidebar-list/sidebar-project-catalog.ts index 26e31b51b935..3547ab753681 100644 --- a/src/shared/sidebar-list/sidebar-project-catalog.ts +++ b/src/shared/sidebar-list/sidebar-project-catalog.ts @@ -14,7 +14,7 @@ import type { ProjectCatalogRows } from '../project-compatibility-merge' import { mergeFetchedHostProjectCatalog } from '../project-compatibility-host-merge' import type { ProjectGroup } from '../project-group-types' import type { ProjectHostSetupProjection } from '../project-host-setup-projection' -import { resolveProjectHostSetupProjection } from '../project-host-setup-hydration' +import { resolveProjectHostSetupProjection } from '../project-catalog-canonical-projection' import type { Project, ProjectHostSetup } from '../project-types' import type { PersistedUIState } from '../persisted-ui-state-types' import type { Repo } from '../repo-types' diff --git a/src/shared/worktree/ownership.ts b/src/shared/worktree/ownership.ts index 5784b0183154..0510e93102b9 100644 --- a/src/shared/worktree/ownership.ts +++ b/src/shared/worktree/ownership.ts @@ -1,4 +1,10 @@ -import { normalizeRuntimePathForComparison, relativePathInsideRoot } from '../cross-platform-path' +import { isFolderRepo } from '../repo-kind' +import { + getRuntimePathBasename, + resolveRuntimePath, + normalizeRuntimePathForComparison, + relativePathInsideRoot +} from '../cross-platform-path' import { parseWslUncPath } from '../wsl-paths' import { isRuntimePathAbsoluteForRepo, @@ -269,3 +275,25 @@ function canClassifyAsExternal( } return true } + +export function managedWorktreeDirectories( + repo: Pick, + settings: Pick +): string[] { + if (isFolderRepo(repo)) { + return [] + } + const configured = new Set( + resolveConfiguredWorktreeBasePaths(repo).map(normalizeRuntimePathForComparison) + ) + const repoName = getRuntimePathBasename(repo.path).replace(/\.git$/, '') + const directories: string[] = [] + for (const layout of buildKnownOrcaWorkspaceLayouts(settings, repo)) { + if (configured.has(normalizeRuntimePathForComparison(layout.path))) { + directories.push(layout.path) + } else if (layout.nestWorkspaces && repoName) { + directories.push(resolveRuntimePath(layout.path, repoName)) + } + } + return directories +} diff --git a/tests/e2e/ai-vault-project-native.unit.test.ts b/tests/e2e/ai-vault-project-native.unit.test.ts new file mode 100644 index 000000000000..65402da4ece6 --- /dev/null +++ b/tests/e2e/ai-vault-project-native.unit.test.ts @@ -0,0 +1,222 @@ +// @vitest-environment happy-dom +import { mkdtemp, realpath, mkdir, symlink, rm } from 'node:fs/promises' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { DatabaseSync } from 'node:sqlite' +import { act, renderHook, waitFor } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import type { Repo } from '../../src/shared/repo-types' +import type { SqliteBindings, SqliteStatement } from '../../src/main/sqlite/sqlite-statement' +import SyncDatabase from '../../src/main/sqlite/sync-database' +import { NodeSqliteStatement } from '../../src/main/sqlite/node-sqlite-statement' +import * as ownership from '../../src/main/ai-vault-search/session-search-project-ownership' +import * as claims from '../../src/shared/project-checkout-claim' +import * as projection from '../../src/shared/project-catalog-canonical-projection' +import { projectHostSetupCompatibilityFromRepos } from '../../src/shared/project-host-setup-projection' +import { sessionSearchScopeCatalogFromStore } from '../../src/main/ai-vault-search/session-search-store-scope-catalog' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../../src/main/ai-vault-search/session-search-scope-catalog' +import { createSessionSearchService } from '../../src/main/ai-vault-search/session-search-service' +import { setSessionSearchService } from '../../src/main/ai-vault-search/session-search-service-registry' +import { + openSessionSearchHarness, + addSyntheticSession, + markFork, + type SessionSearchHarness +} from '../../src/main/ai-vault-search/session-search-engine-test-fixture' +import { registerAiVaultSearchHandlers } from '../../src/main/ipc/ai-vault-search' +import { aiVaultApi } from '../../src/preload/api/ai-vault-bridge' +import { useAiVaultPanelSearch } from '../../src/renderer/src/components/right-sidebar/use-ai-vault-search' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map Promise>() +})) +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, realpath: vi.fn(actual.realpath) } +}) +vi.mock('electron', () => ({ + ipcMain: { + handle: (name: string, handler: (...args: unknown[]) => Promise) => + handlers.set(name, handler) + }, + ipcRenderer: { invoke: (name: string, ...args: unknown[]) => handlers.get(name)!(null, ...args) } +})) +vi.mock('../../src/main/ipc/ai-vault', () => ({ + getActiveRuntimeAiVaultHostInfosResult: () => ({ hostInfos: [] }) +})) +vi.mock('../../src/main/ai-vault/session-scanner-service-spawn', () => ({ + clearSessionSearchInService: async () => undefined +})) + +let harness: SessionSearchHarness | undefined +let root: string | undefined +const agents = ['claude'] as const + +afterEach(async () => { + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + vi.restoreAllMocks() + await harness?.close() + if (root) { + await rm(root, { recursive: true, force: true }) + } + harness = undefined + root = undefined +}) + +it('bounds actual panel-to-IPC canonical preparation and native cwd seeks at N and 10N', async () => { + const observations = [] + for (const size of [8, 80]) { + root = await realpath(await mkdtemp(join(tmpdir(), 'sta10541-native-trigger-'))) + const repos: Repo[] = [] + for (let index = 0; index < size; index++) { + const actual = join(root, `real-${index}`) + const path = join(root, `linked-${index}`) + await mkdir(actual) + await symlink(actual, path, 'junction') + repos.push({ + id: `repo-${index}`, + path, + displayName: 'app', + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' }, + ...(index ? { projectHostSetupMethod: 'provisioned' } : {}) + }) + } + repos.push({ ...repos[0], id: 'independent', path: join(root, 'independent'), addedAt: 1000 }) + const raw = projectHostSetupCompatibilityFromRepos(repos) + const source = { + getRepos: () => repos, + getProjects: () => raw.projects, + getProjectHostSetups: () => raw.setups, + getAllWorktreeMeta: () => ({}), + getSettings: () => ({ workspaceDir: '', nestWorkspaces: false }) + } + harness = await openSessionSearchHarness('sta10541-trigger-scaling', { + sessionCandidateLimit: 1000 + }) + for (let id = 1; id <= size * 3; id++) { + addSyntheticSession(harness.db, { + id, + cwd: join(root, `real-${(id - 1) % size}`), + text: 'ownership needle', + updatedAt: '2026-10-20' + }) + } + for (let id = size * 3 + 1; id <= size * 3 + 100; id++) { + addSyntheticSession(harness.db, { + id, + cwd: repos.at(-1)!.path, + text: 'ownership needle', + updatedAt: '2026-10-21' + }) + } + markFork(harness.db, [1, 2, size * 3 + 1], 'same-opening') + const native = createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + const admissions = vi.spyOn(native, 'search') + setSessionSearchService(native) + const catalogRead = vi.fn(() => sessionSearchScopeCatalogFromStore(source, 'local')) + installSessionSearchScopeCatalogSource(catalogRead) + const compile = vi.spyOn(ownership, 'compileSessionSearchProjectOwnership') + const namespace = vi.spyOn(ownership, 'sessionSearchProjectOwns') + const claim = vi.spyOn(claims, 'getOutclaimedProjectSetups') + const project = vi.spyOn(projection, 'resolveProjectHostSetupProjection') + const aliases = vi.mocked(fs.realpath) + aliases.mockClear() + const prepare = vi.spyOn(DatabaseSync.prototype, 'prepare') + const sqlByStatement = new WeakMap() + const prepared = SyncDatabase.prototype.prepare + vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql + ) { + const statement = prepared.call(this, sql) + sqlByStatement.set(statement, sql) + return statement + }) + const statements: { sql: string; bindings: SqliteBindings }[] = [] + const all = NodeSqliteStatement.prototype.all + vi.spyOn(NodeSqliteStatement.prototype, 'all').mockImplementation(function ( + this: NodeSqliteStatement, + ...bindings + ) { + const sql = sqlByStatement.get(this) + if (sql?.includes('canonical_project_owns')) { + statements.push({ sql, bindings }) + } + return all.apply(this, bindings) + }) + registerAiVaultSearchHandlers() + Object.defineProperty(window, 'api', { configurable: true, value: { aiVault: aiVaultApi } }) + const projectKey = `project:${raw.projects[0].id}` + const within = { kind: 'project', projectKey } as const + const hook = renderHook( + ({ query }) => useAiVaultPanelSearch(query, agents, within, 'all', 'relevance', projectKey), + { initialProps: { query: 'ownership needle' } } + ) + await waitFor(() => expect(hook.result.current.hits).toHaveLength(20)) + act(() => hook.result.current.loadMore()) + await waitFor(() => expect(hook.result.current.hits).toHaveLength(Math.min(40, size * 3 - 1))) + expect(hook.result.current.hits.every((hit) => Number(hit.sessionId) <= size * 3)).toBe(true) + hook.rerender({ query: 'path:real-' }) + await waitFor(() => expect(hook.result.current.hits).toHaveLength(20)) + expect(hook.result.current.hits.every((hit) => Number(hit.sessionId) <= size * 3)).toBe(true) + hook.unmount() + const selections = await Promise.all(compile.mock.results.map((result) => result.value)) + observations.push({ + size, + catalog: catalogRead.mock.calls.length, + admissions: admissions.mock.calls.length, + compilations: compile.mock.calls.length, + projections: project.mock.calls.length, + claims: claim.mock.calls.length, + claimFacts: claim.mock.calls.reduce((sum, [rows]) => sum + rows.length, 0), + aliases: aliases.mock.calls.length, + namespace: namespace.mock.calls.length, + preparations: prepare.mock.calls.length, + executions: statements.length, + intervalBytes: selections.reduce((sum, value) => sum + JSON.stringify(value.ranges).length, 0) + }) + expect(statements.some(({ sql }) => sql.includes('WITH matched AS MATERIALIZED'))).toBe(true) + expect( + statements.some(({ sql }) => sql.includes('ORDER BY updated_at DESC, id DESC LIMIT')) + ).toBe(true) + expect(statements.some(({ sql }) => sql.includes('WHERE id IN ('))).toBe(true) + vi.restoreAllMocks() + for (const { sql, bindings } of statements) { + const plan = harness.db.prepare(`EXPLAIN QUERY PLAN ${sql}`).all(...bindings) + expect(plan.map((row) => row.detail).join('\n')).toMatch( + /SEARCH scoped USING (?:COVERING )?INDEX sessions_cwd_key/ + ) + } + await harness.close() + harness = undefined + await rm(root, { recursive: true, force: true }) + root = undefined + } + const [small, large] = observations + expect(large.admissions).toBe(small.admissions) + expect(large.compilations).toBe(small.compilations) + expect(large.catalog).toBe(small.catalog) + expect(large.claims).toBe(small.claims) + expect(large.aliases).toBeLessThanOrEqual(small.aliases * 10) + expect(large.claimFacts).toBeLessThanOrEqual(small.claimFacts * 10) + expect(large.intervalBytes).toBeLessThanOrEqual(small.intervalBytes * 11) + expect(large.namespace).toBeLessThanOrEqual(small.namespace * 12) + expect(large.preparations).toBeLessThanOrEqual(small.preparations + 2) + expect(large.executions).toBeLessThanOrEqual(small.executions + 2) + console.info('native-trigger-operation-counts', JSON.stringify(observations)) +}) diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts new file mode 100644 index 000000000000..16f2f4f8fdb5 --- /dev/null +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -0,0 +1,347 @@ +import { mkdtemp, realpath, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { getRepoExecutionHostId } from '../../src/shared/execution-host' +import { hostWorktreeTarget } from '../../src/shared/worktree/host-qualified-identity' +import { + closeTestStores, + createSqliteTestStore, + writePersistedStateJson +} from '../../src/main/persistence-test-harness' +import { Store } from '../../src/main/persistence/loading-store/store' +import { getDefaultPersistedState } from '../../src/shared/constants' +import type { Repo } from '../../src/shared/repo-types' +import { projectHostSetupCompatibilityFromRepos } from '../../src/shared/project-host-setup-projection' +import { resolveProjectHostSetupProjection as legacyProjection } from '../../src/shared/project-host-setup-hydration' +import { getProjectHostSetupProjectionFromState } from '../../src/renderer/src/store/project-host-setup-selector' +import { resolveWorktreeMetaWithDiscoveryBackfill } from '../../src/main/ipc/worktrees/listing/worktree-discovery-metadata' +import { + getProjectHostSetupForRepoHost, + withRepoHostOwnership +} from '../../src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership' +import { makeWorktree } from '../../src/renderer/src/store/slices/worktrees-slice-test-fixtures' +import { buildAiVaultSessionProjectById } from '../../src/renderer/src/components/right-sidebar/ai-vault-session-projects' +import { + buildSetupByRepoId, + resolveActiveProjectKey, + resolveLegacyActiveProjectKey +} from '../../src/renderer/src/components/right-sidebar/ai-vault-project-key' +import { getProjectHostSetupLookup } from '../../src/shared/project-host-setup-lookup' +import { + adoptEndpointRepo, + adoptEndpointProjectCatalog +} from '../../src/shared/endpoint-catalog-adoption' +import { searchHit } from '../../src/shared/ai-vault-search-test-fixture' +import { aiVaultSearchHitToSession } from '../../src/renderer/src/components/right-sidebar/ai-vault-search-session' +import { sessionSearchScopeCatalogFromStore } from '../../src/main/ai-vault-search/session-search-store-scope-catalog' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from '../../src/main/ai-vault-search/session-search-scope-catalog' +import { + searchSessionService, + setSessionSearchService +} from '../../src/main/ai-vault-search/session-search-service-registry' +import { createSessionSearchService } from '../../src/main/ai-vault-search/session-search-service' +import { + openSessionSearchHarness, + addSyntheticSession, + type SessionSearchHarness +} from '../../src/main/ai-vault-search/session-search-engine-test-fixture' +import { AiVaultSearchRequestSchema } from '../../src/shared/ai-vault-search-contract' +import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../../src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import { getLocalWorktreeScanGeneration } from '../../src/main/local-worktree-scan-generation' + +const a: Repo = { + id: 'a', + path: '/clones/a/app', + displayName: 'app', + badgeColor: '', + addedAt: 1, + upstream: { owner: 'acme', repo: 'app' } +} +const b: Repo = { ...a, id: 'b', path: '/clones/b/app', addedAt: 2 } +let root: string | undefined +let harness: SessionSearchHarness | undefined + +afterEach(async () => { + vi.restoreAllMocks() + setSessionSearchService(null) + resetSessionSearchScopeCatalogForTests() + await harness?.close() + await closeTestStores() + if (root) { + await rm(root, { recursive: true, force: true }) + } + harness = undefined + root = undefined +}) + +async function seededStore(name: string, repos: Repo[] = [a, b]) { + root ??= await realpath(await mkdtemp(join(tmpdir(), 'sta10541-source-'))) + const path = join(root, name, 'state.json') + const state = getDefaultPersistedState(root) + state.repos = repos + const raw = projectHostSetupCompatibilityFromRepos(state.repos) + state.projects = [...raw.projects] + state.projectHostSetups = [...raw.setups] + state.settings.workspaceDir = join(root, 'workspaces') + state.settings.nestWorkspaces = false + writePersistedStateJson(path, JSON.stringify(state)) + return createSqliteTestStore(Store, { dataFile: path }) +} + +function caller(store: Store, repo: Repo, path: string) { + const meta = resolveWorktreeMetaWithDiscoveryBackfill(store, repo, `${repo.id}::${path}`) + const readState = { + repos: store.getRepos(), + endpointProjects: store.getProjects(), + endpointProjectHostSetups: store.getProjectHostSetups() + } + const worktree = withRepoHostOwnership( + makeWorktree({ id: `${repo.id}::${path}`, repoId: repo.id, path, ...meta }), + getRepoExecutionHostId(repo), + getProjectHostSetupForRepoHost(readState, repo.id, getRepoExecutionHostId(repo)) + ) + const old = legacyProjection( + readState.repos, + readState.endpointProjects, + readState.endpointProjectHostSetups + ) + return { + meta, + worktree, + legacy: resolveLegacyActiveProjectKey(repo, worktree, buildSetupByRepoId(old.setups)), + canonical: resolveActiveProjectKey( + repo, + worktree, + getProjectHostSetupLookup(getProjectHostSetupProjectionFromState(readState).setups) + ) + } +} + +// The integrated-base within schema: unknown Project properties are stripped. +const baseWithin = z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('workspace'), worktreeId: z.string().min(1).max(8192) }), + z.object({ kind: z.literal('project'), projectKey: z.string().min(1).max(1024) }) +]) +const baseRequest = AiVaultSearchRequestSchema.safeExtend({ within: baseWithin.optional() }) + +it('pairs old discovery with canonical persistence, compatibility backfill, selected callers and actual legacy/native results', async () => { + const oldStore = await seededStore('old-source') + const newStore = await seededStore('new-source') + const raw = projectHostSetupCompatibilityFromRepos([a, b]) + const projectKey = `project:${raw.projects[0].id}` + const oldSetups = vi.spyOn(oldStore, 'getProjectHostSetups').mockReturnValue([...raw.setups]) + harness = await openSessionSearchHarness('sta10541-source-results') + addSyntheticSession(harness.db, { id: 1, cwd: a.path }) + addSyntheticSession(harness.db, { id: 2, cwd: '/external/b' }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + const lastActivityAt = Date.now() + for (const foreign of [false, true]) { + const id = 'b::/external/b' + oldStore.setWorktreeMeta(hostWorktreeTarget({ id, hostId: 'local' }), { + projectId: foreign ? 'saved:S' : raw.projects[0].id, + projectHostSetupId: foreign ? 'foreign-setup' : 'b', + instanceId: 'existing-b', + lastActivityAt + }) + newStore.setWorktreeMeta(hostWorktreeTarget({ id, hostId: 'local' }), { + projectId: foreign ? 'saved:S' : 'repo:b', + projectHostSetupId: foreign ? 'foreign-setup' : 'b', + instanceId: 'existing-b', + lastActivityAt + }) + const before = caller(oldStore, b, '/external/b') + const after = caller(newStore, b, '/external/b') + expect(after.legacy).toBe(before.legacy) + expect(after.legacy).toBe(foreign ? 'project:saved:S' : projectKey) + expect(after.meta.lastActivityAt).toBe(lastActivityAt) + expect(after.meta.projectHostSetupId).toBe(foreign ? 'foreign-setup' : 'b') + expect(after.canonical).toBe(foreign ? 'project:saved:S' : 'repo:b') + installSessionSearchScopeCatalogSource(() => + sessionSearchScopeCatalogFromStore(newStore, 'local') + ) + const request = { + query: 'needle', + within: { + kind: 'project' as const, + projectKey: after.legacy!, + canonicalProjectKey: after.canonical! + } + } + const stripped = baseRequest.parse(request) + expect(stripped.within).toEqual({ kind: 'project', projectKey: before.legacy }) + const legacy = await searchSessionService(stripped, 'ipc') + expect(legacy.kind === 'results' && legacy.hits.map((hit) => hit.sessionId).sort()).toEqual( + foreign ? ['2'] : ['1', '2'] + ) + const exact = await searchSessionService(request, 'ipc') + expect(exact.kind === 'results' && exact.hits.map((hit) => hit.sessionId)).toEqual(['2']) + } + oldSetups.mockRestore() + newStore.flushOrThrow() + await newStore.freezeWritesAsync() + const reloaded = createSqliteTestStore(Store, { + dataFile: join(root!, 'new-source', 'state.json') + }) + expect(caller(reloaded, b, '/external/b').legacy).toBe('project:saved:S') +}) + +it('retains browser nested execution facts, omitted exact setup reconstruction and global first-setup ordering', () => { + const target = { kind: 'environment', environmentId: 'hub' } as const + const nested: Repo = { ...b, id: a.id, connectionId: 'jump', executionHostId: 'runtime:hub' } + const repos = [a, adoptEndpointRepo(target, nested)] + const raw = projectHostSetupCompatibilityFromRepos([ + a, + { ...nested, executionHostId: 'ssh:jump' } + ]) + const adopted = adoptEndpointProjectCatalog( + target, + raw.projects, + raw.setups.filter((setup) => setup.connectionId) + ) + const state = { + repos, + endpointProjects: adopted.projects, + endpointProjectHostSetups: adopted.setups + } + const canonical = getProjectHostSetupProjectionFromState(state) + const selected = resolveActiveProjectKey( + repos[1], + null, + getProjectHostSetupLookup(canonical.setups) + ) + expect(selected).toBe(`project:${raw.projects[0].id}`) + expect(resolveActiveProjectKey(a, null, getProjectHostSetupLookup(canonical.setups))).toBe( + selected + ) + const compatibility = legacyProjection(repos, adopted.projects, adopted.setups) + const first = buildSetupByRepoId(compatibility.setups) + expect(first.get(a.id)).toBe(compatibility.setups.find((setup) => setup.repoId === a.id)) + const foreign = makeWorktree({ + id: 'a::/foreign', + repoId: a.id, + path: '/foreign', + projectId: 'saved:S', + projectHostSetupId: 'foreign' + }) + expect(resolveLegacyActiveProjectKey(a, foreign, first)).toBe('project:saved:S') +}) + +it('keeps erased never-opened external history unscoped after authoritative prune while retaining an empty-tab owner', async () => { + const store = await seededStore('erased-source') + store.updateSettings({ nestWorkspaces: true }) + const repo = store.getRepos().find((row) => row.id === a.id)! + const erased = `${a.id}::${root}/workspaces/app/topic` + const retained = `${a.id}::${root}/workspaces/app/retained` + // Actual discovery creates locator/instance metadata; no tab is ever opened for topic. + resolveWorktreeMetaWithDiscoveryBackfill(store, repo, erased) + resolveWorktreeMetaWithDiscoveryBackfill(store, repo, retained) + store.setWorkspaceSession({ ...store.getWorkspaceSession(), tabsByWorktree: { [retained]: [] } }) + const scan = store.captureNativeLocalWorktreeMetadataScanExpectation(repo) + const pruned = await pruneMetadataMissingFromAuthoritativeLocalScan({ + store, + repo, + scan, + scanGeneration: getLocalWorktreeScanGeneration(repo.id), + gitWorktrees: [ + { path: repo.path, head: 'same-commit', branch: '', isBare: false, isMainWorktree: true } + ], + pathsExistOrAreUnverifiable: async (paths) => new Map(paths.map((path) => [path, false])) + }) + expect(pruned.removedWorktreeIds).toContain(erased) + expect(pruned.removedWorktreeIds).not.toContain(retained) + expect(store.getWorktreeMeta(erased)).toBeUndefined() + harness = await openSessionSearchHarness('sta10541-erased-source') + addSyntheticSession(harness.db, { id: 1, cwd: `${root}/workspaces/app/topic` }) + addSyntheticSession(harness.db, { id: 2, cwd: `${root}/workspaces/app/retained` }) + setSessionSearchService( + createSessionSearchService({ + engine: harness.engine, + indexer: { + status: () => { + throw new Error('not called') + }, + reconcile: async () => undefined + } + }) + ) + installSessionSearchScopeCatalogSource(() => sessionSearchScopeCatalogFromStore(store, 'local')) + const projectKey = `project:${projectHostSetupCompatibilityFromRepos([a, b]).projects[0].id}` + const shared = await searchSessionService( + { query: 'needle', within: { kind: 'project', projectKey, canonicalProjectKey: projectKey } }, + 'ipc' + ) + const independent = await searchSessionService( + { query: 'needle', within: { kind: 'project', projectKey, canonicalProjectKey: 'repo:b' } }, + 'ipc' + ) + const all = await searchSessionService({ query: 'needle' }, 'ipc') + expect(shared.kind === 'results' && shared.hits.map((hit) => hit.sessionId)).toEqual(['2']) + expect(independent.kind === 'results' && independent.hits).toEqual([]) + expect(all.kind === 'results' && all.hits.map((hit) => hit.sessionId).sort()).toEqual(['1', '2']) + const read = { + repos: store.getRepos(), + endpointProjects: store.getProjects(), + endpointProjectHostSetups: store.getProjectHostSetups() + } + const rows = buildAiVaultSessionProjectById({ + repos: read.repos, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(read), + worktrees: [ + makeWorktree({ + id: retained, + repoId: a.id, + path: `${root}/workspaces/app/retained`, + ...store.getWorktreeMeta(retained) + }) + ], + settings: store.getSettings(), + sessions: [ + aiVaultSearchHitToSession( + { ...searchHit(), sessionId: 'erased', cwd: `${root}/workspaces/app/topic` }, + 'local' + ) + ] + }) + expect([...rows.values()][0].key).toMatch(/^folder:/) +}) + +it('preserves actual discovered selected-source legacy keys across supported catalog ingress controls', async () => { + const cases: Repo[][] = [ + [a], + [a, b], + [a, { ...b, projectHostSetupMethod: 'provisioned' }], + [{ ...a, kind: 'folder' }], + [{ ...a, upstream: undefined }], + [a, { ...b, projectGroupId: 'manual-group' }], + [a, { ...b, executionHostId: 'runtime:pending' }], + [a, { ...b, executionHostId: 'runtime:unresolved-owner' }] + ] + for (const [index, repos] of cases.entries()) { + const old = await seededStore(`old-ingress-${index}`, repos) + const current = await seededStore(`new-ingress-${index}`, repos) + const raw = projectHostSetupCompatibilityFromRepos(repos) + const original = vi.spyOn(old, 'getProjectHostSetups').mockReturnValue([...raw.setups]) + const repo = repos.at(-1)! + const before = caller(old, repo, repo.path) + const after = caller(current, repo, repo.path) + expect(after.legacy, `ingress ${index}`).toBe(before.legacy) + expect(after.worktree.path).toBe(repo.path) + expect(after.worktree.repoId).toBe(repo.id) + original.mockRestore() + } +}) From 970c85800d8d69673745b3e4d2086a6cb5695cc3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 14:00:50 -0700 Subject: [PATCH 10/29] Combine duplicate session scope imports --- .../right-sidebar/ai-vault-session-project-candidates.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts index 0b330a79a371..e2e48399e32c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts @@ -1,10 +1,12 @@ -import { createNormalizedAiVaultScopeCwdMatcher } from '../../../../shared/ai-vault-scope-cwd-match' +import { + createNormalizedAiVaultScopeCwdMatcher, + sessionFileExecutionNamespace +} from '../../../../shared/ai-vault-scope-cwd-match' import { LOCAL_EXECUTION_HOST_ID, normalizeExecutionHostId, type ExecutionHostId } from '../../../../shared/execution-host' -import { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' From 13107388acc30cf5a0520001edf8a9e14a1eef77 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:03:49 -0700 Subject: [PATCH 11/29] fix: derive returned history row ownership on the host --- .../session-search-project-ownership.ts | 51 ++++-- .../session-search-service-registry.test.ts | 34 ++++ .../session-search-service-registry.ts | 14 +- src/main/ai-vault/cached-session-list.ts | 45 ++++- .../ai-vault/runtime-session-scanner.test.ts | 2 + src/main/ai-vault/runtime-session-scanner.ts | 1 + .../session-list-result-validation.ts | 2 + src/main/ipc/ai-vault-runtime-scan.ts | 3 + src/main/ipc/ai-vault.ts | 1 + .../register-core-handlers.ts | 4 + src/main/orcad/orcad-entry.ts | 8 +- src/main/orcad/orcad-session-search.ts | 23 +++ .../runtime-construction-dependencies.ts | 2 + .../construction/runtime-construction.ts | 7 +- src/main/runtime/rpc/methods/ai-vault.test.ts | 63 +++++++ src/main/runtime/rpc/methods/ai-vault.ts | 1 + .../startup/main-process-runtime-service.ts | 2 + ...-session-conditional-refresh-hook.test.tsx | 14 ++ .../ai-vault-session-list-request.ts | 1 + .../ai-vault-session-project-candidates.ts | 13 ++ .../ai-vault-session-projects.ts | 29 +-- .../src/web/preload-api/web-ai-vault-api.ts | 1 + .../web-ai-vault-search-compat.test.ts | 17 ++ src/shared/ai-vault-project-key.ts | 6 + src/shared/ai-vault-types.ts | 3 + src/shared/rpc-contract/ai-vault-params.ts | 1 + .../e2e/ai-vault-project-native.unit.test.ts | 82 +++++++++ ...ault-project-source-lifecycle.unit.test.ts | 173 ++++++++++++++++-- 28 files changed, 544 insertions(+), 59 deletions(-) diff --git a/src/main/ai-vault-search/session-search-project-ownership.ts b/src/main/ai-vault-search/session-search-project-ownership.ts index 91d84c58abf1..c68a7eb999c0 100644 --- a/src/main/ai-vault-search/session-search-project-ownership.ts +++ b/src/main/ai-vault-search/session-search-project-ownership.ts @@ -31,13 +31,12 @@ export type SessionSearchProjectOwnership = { } const compare = (a: string, b: string) => Buffer.compare(Buffer.from(a), Buffer.from(b)) const key = (path: string) => normalizeRuntimePathForComparison(path).toWellFormed() -export const sessionSearchCatalogFingerprint = (catalog: SessionSearchScopeCatalog | null) => - createHash('sha256').update(JSON.stringify(catalog)).digest('base64url') - -export async function compileSessionSearchProjectOwnership( - catalog: SessionSearchScopeCatalog, - projectKey: string -): Promise { +export function materializeSessionSearchOwnership( + catalog: SessionSearchScopeCatalog | null +): Rule[] | null { + if (!catalog) { + return null + } const projection = resolveProjectHostSetupProjection( catalog.repos, catalog.projects, @@ -104,6 +103,15 @@ export async function compileSessionSearchProjectOwnership( } } } + return rules +} + +export async function compileSessionSearchProjectOwnership( + catalog: SessionSearchScopeCatalog, + projectKey: string, + input = materializeSessionSearchOwnership(catalog) ?? [] +): Promise { + const rules = [...input] const pathByKey = new Map() for (const rule of rules) { const normalized = key(rule.path) @@ -175,10 +183,22 @@ export async function compileSessionSearchProjectOwnership( ranges.push(['/mnt/', '/mnt0']) } } + ranges.sort((a, b) => compare(a[0], b[0]) || compare(a[1], b[1])) + const union: [string, string][] = [] + for (const range of ranges) { + const prior = union.at(-1) + if (prior && compare(range[0], prior[1]) <= 0) { + if (compare(range[1], prior[1]) > 0) { + prior[1] = range[1] + } + } else { + union.push([...range]) + } + } const fingerprint = createHash('sha256') .update(JSON.stringify([compiled, aliases.links])) .digest('base64url') - return { projectKey, fingerprint, planes: compiled, ranges } + return { projectKey, fingerprint, planes: compiled, ranges: union } } function intervals(roots: Map): Interval[] { @@ -215,11 +235,12 @@ function intervals(roots: Map { const entries = selection.planes[plane] ?? [] let low = 0, @@ -250,5 +271,13 @@ export function sessionSearchProjectOwns( best = [best[0], best[1], null, best[3]] } } - return best?.[2] === selection.projectKey + return best?.[2] ?? null +} + +export function sessionSearchProjectOwns( + selection: SessionSearchProjectOwnership, + cwd: string, + filePath: string +): boolean { + return sessionSearchProjectOwner(selection, cwd, filePath) === selection.projectKey } diff --git a/src/main/ai-vault-search/session-search-service-registry.test.ts b/src/main/ai-vault-search/session-search-service-registry.test.ts index 8e172e5f2c1e..13c9b72fbaa9 100644 --- a/src/main/ai-vault-search/session-search-service-registry.test.ts +++ b/src/main/ai-vault-search/session-search-service-registry.test.ts @@ -4,6 +4,11 @@ import { unavailableSessionSearchStatus, createSessionSearchClient } from '../../shared/ai-vault-search-client' +import { + installSessionSearchScopeCatalogSource, + resetSessionSearchScopeCatalogForTests +} from './session-search-scope-catalog' +import * as ownership from './session-search-project-ownership' import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types' import { setSessionSearchService, @@ -12,6 +17,8 @@ import { } from './session-search-service-registry' afterEach(() => { + resetSessionSearchScopeCatalogForTests() + vi.restoreAllMocks() setSessionSearchService(null) vi.useRealTimers() }) @@ -132,6 +139,33 @@ describe('session search service registry', () => { ) } ) + it('does no canonical preparation for unscoped or legacy requests', async () => { + const source = vi.fn(() => ({ + repos: [], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + installSessionSearchScopeCatalogSource(source) + const materialize = vi.spyOn(ownership, 'materializeSessionSearchOwnership') + const service = fakeSearchService() + setSessionSearchService(service) + for (const reason of ['disabled', 'not-ready'] as const) { + service.search.mockResolvedValue({ kind: 'unavailable', reason }) + expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({ + kind: 'unavailable', + reason + }) + } + expect(source).not.toHaveBeenCalled() + await searchSessionService( + { query: 'needle', within: { kind: 'project', projectKey: 'repo:absent' } }, + 'ipc' + ) + expect(source).toHaveBeenCalledTimes(1) + expect(materialize).not.toHaveBeenCalled() + }) it('waits for reconcile before search, and clears its timeout', async () => { vi.useFakeTimers() const service = fakeSearchService() diff --git a/src/main/ai-vault-search/session-search-service-registry.ts b/src/main/ai-vault-search/session-search-service-registry.ts index 926fa700a88c..89972a977f62 100644 --- a/src/main/ai-vault-search/session-search-service-registry.ts +++ b/src/main/ai-vault-search/session-search-service-registry.ts @@ -10,7 +10,7 @@ import { resolveProjectHostSetupProjection } from '../../shared/project-host-set import { getRepoExecutionHostId } from '../../shared/execution-host' import { compileSessionSearchProjectOwnership, - sessionSearchCatalogFingerprint + materializeSessionSearchOwnership } from './session-search-project-ownership' import { resolveSessionSearchScope } from './session-search-scope-resolution' import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types' @@ -72,11 +72,14 @@ export async function searchSessionService( request.freshness === 'wait-until-current' ? await reconcileWithin(current, freshnessTimeoutMs) : false - const catalog = sessionSearchScopeCatalog() - const snapshot = sessionSearchCatalogFingerprint(catalog) const canonical = within?.kind === 'project' && within.canonicalProjectKey + const catalog = within ? sessionSearchScopeCatalog() : null + const input = canonical ? materializeSessionSearchOwnership(catalog) : null + const snapshot = JSON.stringify(input) const selection = - canonical && catalog ? await compileSessionSearchProjectOwnership(catalog, canonical) : null + canonical && catalog && input + ? await compileSessionSearchProjectOwnership(catalog, canonical, input) + : null const legacy = catalog && within && @@ -108,7 +111,8 @@ export async function searchSessionService( compatibleAgents.length === 0 && hostScope?.kind !== 'unknown' ? { kind: 'resolved' as const, paths: [''] } : hostScope - const moved = () => snapshot !== sessionSearchCatalogFingerprint(sessionSearchScopeCatalog()) + const moved = () => + snapshot !== JSON.stringify(materializeSessionSearchOwnership(sessionSearchScopeCatalog())) if (canonical && moved()) { return { kind: 'stale-cursor', generation: 0 } } diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index e3af8239cdc2..48b7760007ba 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -1,4 +1,10 @@ import { join } from 'node:path' +import type { SessionSearchScopeCatalogSource } from '../ai-vault-search/session-search-scope-catalog' +import { + compileSessionSearchProjectOwnership, + sessionSearchProjectOwner +} from '../ai-vault-search/session-search-project-ownership' +import { conditionalAiVaultListResult } from './session-list-fingerprint' import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { clearAiVaultBackgroundRestartCircuit, @@ -51,6 +57,7 @@ export class AiVaultLocalScanError extends Error { // path — `orca serve` never runs that path, so sourcing it there would silently // drop managed-Codex sessions from remote/SSH results. export type AiVaultSessionSources = { + getProjectOwnershipCatalog?: SessionSearchScopeCatalogSource getAdditionalCodexHomePaths?: () => readonly string[] } @@ -125,14 +132,17 @@ export async function listAiVaultSessions( cachedList.expiresAt > now && aiVaultCachedDepthServes(cachedList.depth, depth, args?.scopePaths) ) { - return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths) + return annotateHistoryOwnership( + truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths), + args + ) } // Captured here, not inside start(): the coordinator defers start() by a // microtask, so an invalidation landing in that gap would otherwise be read // as having happened before this scan and leave the stale result cacheable. const startGeneration = cacheGeneration let progress: LocalAiVaultScanProgressHandle | null = null - return scanCoordinator.run({ + const result = await scanCoordinator.run({ key: scanKey, force: args?.force, signal: options.signal, @@ -182,6 +192,37 @@ export async function listAiVaultSessions( return result } }) + return annotateHistoryOwnership(result, args) +} + +async function annotateHistoryOwnership( + result: AiVaultListResult, + args?: AiVaultListArgs +): Promise { + if (!args?.historyOwnership) { + return result + } + try { + const catalog = sources.getProjectOwnershipCatalog?.() + if (catalog) { + const ownership = await compileSessionSearchProjectOwnership( + catalog, + args.historyOwnership.projectKey ?? '' + ) + result = { + ...result, + sessions: result.sessions.map((session) => ({ + ...session, + projectOwnerKey: session.cwd + ? sessionSearchProjectOwner(ownership, session.cwd, session.filePath) + : null + })) + } + } + } catch (error) { + console.error('[ai-vault] history ownership unavailable:', error) + } + return conditionalAiVaultListResult(result, null) } // Exported for the subagent-transcript IPC path, which validates diff --git a/src/main/ai-vault/runtime-session-scanner.test.ts b/src/main/ai-vault/runtime-session-scanner.test.ts index 8f866c23c02c..42e0fded1285 100644 --- a/src/main/ai-vault/runtime-session-scanner.test.ts +++ b/src/main/ai-vault/runtime-session-scanner.test.ts @@ -53,6 +53,7 @@ describe('runtime AI Vault session scanner', () => { { limit: 25, force: true, + historyOwnership: {}, scopePaths: ['/srv/app'] }, { timeoutMs: 3000 } @@ -65,6 +66,7 @@ describe('runtime AI Vault session scanner', () => { { limit: 25, force: true, + historyOwnership: {}, scopePaths: ['/srv/app'], executionHostId: 'runtime:env-1' }, diff --git a/src/main/ai-vault/runtime-session-scanner.ts b/src/main/ai-vault/runtime-session-scanner.ts index 3436c07eae15..ce77bcc83f84 100644 --- a/src/main/ai-vault/runtime-session-scanner.ts +++ b/src/main/ai-vault/runtime-session-scanner.ts @@ -68,6 +68,7 @@ export async function scanRuntimeAiVaultSessions( environmentId, 'aiVault.listSessions', { + historyOwnership: args.historyOwnership, includeAntigravityIdeSessions: args.includeAntigravityIdeSessions, limit: args.limit, unlimited: args.unlimited, diff --git a/src/main/ai-vault/session-list-result-validation.ts b/src/main/ai-vault/session-list-result-validation.ts index fcf139739d11..79eeeb10c32f 100644 --- a/src/main/ai-vault/session-list-result-validation.ts +++ b/src/main/ai-vault/session-list-result-validation.ts @@ -1,4 +1,5 @@ import { z } from 'zod' +import { isAiVaultProjectKey } from '../../shared/ai-vault-project-key' import { AI_VAULT_AGENTS, AI_VAULT_SCOPE_PATHS_MAX_COUNT, @@ -58,6 +59,7 @@ const aiVaultSessionSchema = z.object({ .max(AI_VAULT_SCOPE_PATHS_MAX_COUNT) .optional() .catch(undefined), + projectOwnerKey: z.string().refine(isAiVaultProjectKey).nullable().optional().catch(undefined), branch: z.string().nullable(), model: z.string().nullable(), filePath: z.string(), diff --git a/src/main/ipc/ai-vault-runtime-scan.ts b/src/main/ipc/ai-vault-runtime-scan.ts index 1cb6863366b2..41a0bd13701b 100644 --- a/src/main/ipc/ai-vault-runtime-scan.ts +++ b/src/main/ipc/ai-vault-runtime-scan.ts @@ -70,6 +70,9 @@ function runtimeScanArgs( listArgs: AiVaultListArgs | undefined ): AiVaultListArgs { const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId } + if (listArgs?.historyOwnership !== undefined) { + scanArgs.historyOwnership = listArgs.historyOwnership + } if (listArgs?.afterFingerprint !== undefined) { scanArgs.afterFingerprint = listArgs.afterFingerprint } diff --git a/src/main/ipc/ai-vault.ts b/src/main/ipc/ai-vault.ts index 53dddff9daa3..d89aaaaf866c 100644 --- a/src/main/ipc/ai-vault.ts +++ b/src/main/ipc/ai-vault.ts @@ -96,6 +96,7 @@ async function listAiVaultSessions( ? [...new Set(scopePaths)].sort() : scopePaths, executionHostScope, + historyOwnership: args?.historyOwnership, includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true }) const depth = requestedAiVaultSessionDepth(args) diff --git a/src/main/ipc/register-core-handlers/register-core-handlers.ts b/src/main/ipc/register-core-handlers/register-core-handlers.ts index c949cc64b61c..9f1078fe61f9 100644 --- a/src/main/ipc/register-core-handlers/register-core-handlers.ts +++ b/src/main/ipc/register-core-handlers/register-core-handlers.ts @@ -1,4 +1,6 @@ import { app } from 'electron' +import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' +import { sessionSearchScopeCatalogFromStore } from '../../ai-vault-search/session-search-store-scope-catalog' import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch' import { registerAppHandlers } from '../app' import { registerCliHandlers } from '../cli' @@ -282,6 +284,8 @@ export function registerCoreHandlers( }) registerAiVaultHandlers({ ensureStructuredSessionOwnership, + getProjectOwnershipCatalog: () => + sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID), getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths, prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume, getActiveRuntimeAiVaultHostInfos: () => diff --git a/src/main/orcad/orcad-entry.ts b/src/main/orcad/orcad-entry.ts index bf00bc037fb3..6411d6fc2cf8 100644 --- a/src/main/orcad/orcad-entry.ts +++ b/src/main/orcad/orcad-entry.ts @@ -219,15 +219,14 @@ async function startOrcadRuntime( clientCodexHomes: codexAccountHomes }) + const { orcadAiVaultSessionSources, installOrcadSessionSearchService } = + await import('./orcad-session-search') const { loadAgentSessionClaimSigner } = await import('../runtime/agent-session-claim-identity') const runtime = new OrcaRuntimeService(profileStore, stats, { // Why persistent: the terminal daemon outlives orcad, so a per-process key would make a // restarted orcad unable to recognise (and adopt) the agent PTYs it already owns. agentSessionClaimSigner: loadAgentSessionClaimSigner(runtimeUserDataPath, runtimeUserDataPath), - getAdditionalAiVaultCodexHomePaths: () => [ - ...codexAccountHomes.listSessionHomes(), - ...(accountLaunch?.listCodexSessionHomes() ?? []) - ], + ...orcadAiVaultSessionSources(profileStore, codexAccountHomes, accountLaunch), // Why absent on a WSL host: a resolver overrides the desktop-pushed CODEX_HOME. ...accountLaunch?.runtimeDeps, // Why here too: orcad serves `worktree.ps` and `agentSession.*`; unwired it lists no agents. @@ -263,7 +262,6 @@ async function startOrcadRuntime( const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store') restoreOrcadHostAccountLaunchEnv(runtime, runtimeUserDataPath) - const { installOrcadSessionSearchService } = await import('./orcad-session-search') sessionSearch = await installOrcadSessionSearchService({ userDataPath: runtimeUserDataPath, getSettings: () => profileStore.getSettings() diff --git a/src/main/orcad/orcad-session-search.ts b/src/main/orcad/orcad-session-search.ts index 66468f38df07..6d371939762a 100644 --- a/src/main/orcad/orcad-session-search.ts +++ b/src/main/orcad/orcad-session-search.ts @@ -1,3 +1,8 @@ +import type { RuntimeConstructionDependencies } from '../runtime/construction/runtime-construction-dependencies' +import { + sessionSearchScopeCatalogFromStore, + type SessionSearchScopeStore +} from '../ai-vault-search/session-search-store-scope-catalog' import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host' import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings' import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings' @@ -24,3 +29,21 @@ export async function installOrcadSessionSearchService(args: { onError: (error) => console.error('[orcad] session search:', error) }) } + +export function orcadAiVaultSessionSources( + store: SessionSearchScopeStore, + client: { listSessionHomes(): readonly string[] }, + accounts: { listCodexSessionHomes(): readonly string[] } | null +): Pick< + RuntimeConstructionDependencies, + 'getProjectOwnershipCatalog' | 'getAdditionalAiVaultCodexHomePaths' +> { + return { + getProjectOwnershipCatalog: () => + sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID), + getAdditionalAiVaultCodexHomePaths: () => [ + ...client.listSessionHomes(), + ...(accounts?.listCodexSessionHomes() ?? []) + ] + } +} diff --git a/src/main/runtime/construction/runtime-construction-dependencies.ts b/src/main/runtime/construction/runtime-construction-dependencies.ts index 4d1e2c5f4166..08387cf699e6 100644 --- a/src/main/runtime/construction/runtime-construction-dependencies.ts +++ b/src/main/runtime/construction/runtime-construction-dependencies.ts @@ -1,3 +1,4 @@ +import type { AiVaultSessionSources } from '../../ai-vault/cached-session-list' import type { CliWriterObservation } from '../../agent-hooks/server/cli-writer-observation' import type { ExecutionHostScope } from '../../../shared/execution-host' import type { IPtyProvider } from '../../providers/pty-provider-contract' @@ -59,6 +60,7 @@ export type RuntimeConstructionDependencies = { dropAgentStatusForRemovedWorktree?: (worktreeId: string, host?: ExecutionHostScope) => void canRecoverPersistentLocalPtys?: () => boolean getPairedDeviceName?: (pairedDeviceId: string) => string | null + getProjectOwnershipCatalog?: AiVaultSessionSources['getProjectOwnershipCatalog'] getAdditionalAiVaultCodexHomePaths?: () => readonly string[] prepareAiVaultSessionResume?: ( args: AiVaultPrepareSessionResumeArgs diff --git a/src/main/runtime/construction/runtime-construction.ts b/src/main/runtime/construction/runtime-construction.ts index 08d33298ef98..de9b27f35943 100644 --- a/src/main/runtime/construction/runtime-construction.ts +++ b/src/main/runtime/construction/runtime-construction.ts @@ -232,10 +232,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo // Why: configure the shared AiVault scan cache from a serve-mode-reachable // seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions // even on headless `orca serve` hosts where registerCoreHandlers never runs. + configureAiVaultSessionSources({ + getProjectOwnershipCatalog: deps?.getProjectOwnershipCatalog, + getAdditionalCodexHomePaths: deps?.getAdditionalAiVaultCodexHomePaths + }) if (deps?.getAdditionalAiVaultCodexHomePaths) { - configureAiVaultSessionSources({ - getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths - }) configureHostReadableTranscriptPathSources({ getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths }) diff --git a/src/main/runtime/rpc/methods/ai-vault.test.ts b/src/main/runtime/rpc/methods/ai-vault.test.ts index 3549b12ed148..ed1e83a3fa1d 100644 --- a/src/main/runtime/rpc/methods/ai-vault.test.ts +++ b/src/main/runtime/rpc/methods/ai-vault.test.ts @@ -30,6 +30,7 @@ import { AiVaultListSessionsParams, AiVaultPrepareSessionResumeParams } from './ai-vault' +import { orcadAiVaultSessionSources } from '../../../orcad/orcad-session-search' import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals' import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal' import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry' @@ -677,6 +678,68 @@ describe('aiVault.listSessions handler + shared cache', () => { expect(options?.wslHomeDirs).toEqual([]) }) + it('constructs ordinary ownership independently of Codex homes or search enablement and forwards RPC intent', async () => { + const capture = vi.fn(() => ({ + repos: [{ id: 'source', path: '/tmp', displayName: 'source', badgeColor: '', addedAt: 1 }], + projects: [], + projectHostSetups: [], + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + })) + const orcadStore = { + getRepos: () => capture().repos, + getProjects: () => [], + getProjectHostSetups: () => [], + getAllWorktreeMeta: () => ({}), + getSettings: () => capture().settings + } + const clientHomes = { listSessionHomes: () => ['/client'] } + const deps = orcadAiVaultSessionSources(orcadStore, clientHomes, null) + expect(deps.getAdditionalAiVaultCodexHomePaths?.()).toEqual(['/client']) + expect( + orcadAiVaultSessionSources(orcadStore, clientHomes, { + listCodexSessionHomes: () => ['/managed'] + }).getAdditionalAiVaultCodexHomePaths?.() + ).toEqual(['/client', '/managed']) + new OrcaRuntimeService(null, undefined, { + getProjectOwnershipCatalog: deps.getProjectOwnershipCatalog + }) + scanAiVaultSessionsInService.mockResolvedValue({ + sessions: [makeSession()], + issues: [], + scannedAt: SCANNED_AT + }) + const dispatcher = makeDispatcher() + expect( + await dispatcher.dispatch( + makeRequest('aiVault.listSessions', { + historyOwnership: {}, + limit: 2, + executionHostId: 'runtime:addressed', + afterFingerprint: null + }) + ) + ).toMatchObject({ + ok: true, + result: { + sessions: [ + { + projectOwnerKey: 'repo:source', + executionHostId: 'runtime:addressed', + cwd: '/tmp', + filePath: '/tmp/t.jsonl' + } + ], + fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/) + } + }) + expect(capture).toHaveBeenCalledTimes(2) + expect( + await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 2 })) + ).toMatchObject({ ok: true, result: { sessions: [makeSession()] } }) + expect(capture).toHaveBeenCalledTimes(2) + expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1) + }) it('forwards codex-home through the real OrcaRuntimeService construction path', async () => { // Why: the dispatcher test above seeds the cache module directly, so it would // still pass if OrcaRuntimeService stopped forwarding the codex-home source. diff --git a/src/main/runtime/rpc/methods/ai-vault.ts b/src/main/runtime/rpc/methods/ai-vault.ts index b10732e375f4..066c706f70eb 100644 --- a/src/main/runtime/rpc/methods/ai-vault.ts +++ b/src/main/runtime/rpc/methods/ai-vault.ts @@ -101,6 +101,7 @@ export const AI_VAULT_METHODS = [ limit: params.unlimited ? undefined : params.limit, unlimited: params.unlimited, force: params.force, + historyOwnership: params.historyOwnership, scopePaths: params.scopePaths, includeAntigravityIdeSessions: params.includeAntigravityIdeSessions }, diff --git a/src/main/startup/main-process-runtime-service.ts b/src/main/startup/main-process-runtime-service.ts index e6d683fc29fc..5de631415576 100644 --- a/src/main/startup/main-process-runtime-service.ts +++ b/src/main/startup/main-process-runtime-service.ts @@ -115,6 +115,8 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService { getPairedDeviceName: (pairedDeviceId) => state.runtimeRpc?.getDeviceRegistry()?.getDevice(pairedDeviceId)?.name ?? null, // Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only. + getProjectOwnershipCatalog: () => + sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID), getAdditionalAiVaultCodexHomePaths: () => state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [], prepareAiVaultSessionResume: codexAiVaultSessionResumePreparation(() => ({ diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx index ef655fa8d021..9f79b864b4e7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-conditional-refresh-hook.test.tsx @@ -323,6 +323,20 @@ describe('conditional panel publication', () => { structuredSession: { sessionId: 'owner' } }) expect(latest?.sessions[0]?.previewMessages[0]?.text).toBe('New prompt') + list.mockResolvedValue({ + ...first, + fingerprint: 'c'.repeat(64), + sessions: [{ ...session, projectOwnerKey: 'repo:current' }] + }) + await refresh() + expect(latest?.sessions[0]?.projectOwnerKey).toBe('repo:current') + list.mockResolvedValue({ + ...first, + fingerprint: 'd'.repeat(64), + sessions: [{ ...session, projectOwnerKey: null }] + }) + await refresh() + expect(latest?.sessions[0]?.projectOwnerKey).toBeNull() }) it('does not reuse proofs across host, depth or project switches', async () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts index 62a14290d9e3..f12ee5c79eeb 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-list-request.ts @@ -23,6 +23,7 @@ export function aiVaultSessionListArgs( options: { force?: boolean; requestToken: string } ): AiVaultListArgs { return { + historyOwnership: {}, includeAntigravityIdeSessions: true, limit: request.sessionLimit === 'unlimited' ? undefined : request.sessionLimit, unlimited: request.sessionLimit === 'unlimited', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts index e2e48399e32c..1d344d542640 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts @@ -1,3 +1,8 @@ +import { + folderGroupKey, + folderLabel, + type AiVaultSessionProject +} from '../../../../shared/ai-vault-session-filters' import { createNormalizedAiVaultScopeCwdMatcher, sessionFileExecutionNamespace @@ -101,3 +106,11 @@ export function resolveCandidateHostId( export function hasCandidatePath(pathValue: string): boolean { return pathValue.trim().length > 0 } + +export function folderProject(cwd: string): AiVaultSessionProject { + return { + kind: 'folder', + key: folderGroupKey(cwd), + label: folderLabel(cwd) + } +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index ecc642384217..9ccd27eded85 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -6,7 +6,7 @@ import type { GlobalSettings } from '../../../../shared/global-settings-types' import { managedWorktreeDirectories } from '../../../../shared/worktree/ownership' import { splitWorktreeIdForFilesystem } from '../../../../shared/worktree/id' import { resolveActiveProjectKey } from './ai-vault-project-key' -import { toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' +import { isAiVaultProjectKey, toAiVaultProjectKey } from '../../../../shared/ai-vault-project-key' import { getRepoExecutionHostId, normalizeExecutionHostId, @@ -19,6 +19,7 @@ import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { + folderProject, aiVaultSessionProjectCwdKey, matchAiVaultProjectCandidates, makeProjectCandidate, @@ -26,11 +27,7 @@ import { hasCandidatePath, type SessionProjectCandidate } from './ai-vault-session-project-candidates' -import { - folderGroupKey, - folderLabel, - type AiVaultSessionProject -} from '../../../../shared/ai-vault-session-filters' +import type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' // Preserve renderer imports of the renderer-free descriptor. export type { AiVaultSessionProject } from '../../../../shared/ai-vault-session-filters' @@ -102,6 +99,18 @@ export function buildAiVaultSessionProjectById({ Map >() for (const session of sessions) { + if (session.projectOwnerKey === null || isAiVaultProjectKey(session.projectOwnerKey)) { + const key = session.projectOwnerKey + sessionProjectById.set( + session.id, + key === null + ? session.cwd + ? folderProject(session.cwd) + : { kind: 'unknown', key: 'unknown', label: '' } + : { kind: 'repo', key, label: projectLabelByKey.get(key) ?? key } + ) + continue + } const host = normalizeExecutionHostId(session.executionHostId) let projectsByCwd = projectsByHostAndCwd.get(host) if (!projectsByCwd) { @@ -315,11 +324,3 @@ function compareCandidates(left: SessionProjectCandidate, right: SessionProjectC } return left.source === 'worktree' ? -1 : 1 } - -function folderProject(cwd: string): AiVaultSessionProject { - return { - kind: 'folder', - key: folderGroupKey(cwd), - label: folderLabel(cwd) - } -} diff --git a/src/renderer/src/web/preload-api/web-ai-vault-api.ts b/src/renderer/src/web/preload-api/web-ai-vault-api.ts index 5216aa2e08c9..9dab47f9c39a 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-api.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-api.ts @@ -84,6 +84,7 @@ export function createWebAiVaultApi(): NonNullable['aiVault' ...(requestedScope === executionHostId && args?.afterFingerprint !== undefined ? { afterFingerprint: args.afterFingerprint } : {}), + historyOwnership: args?.historyOwnership, scopePaths: args?.scopePaths, executionHostId }) diff --git a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts index 5c2cb2fca160..cf84faa4cf10 100644 --- a/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts +++ b/src/renderer/src/web/preload-api/web-ai-vault-search-compat.test.ts @@ -39,6 +39,23 @@ describe('web session search preload compatibility', () => { expect(await api.searchStatus()).toEqual(unavailableSessionSearchStatus()) expect(callRuntimeResult).toHaveBeenLastCalledWith('aiVault.searchStatus', {}) }) + it('forwards returned-row ownership intent without changing the concrete list scope', async () => { + callRuntimeResult.mockResolvedValue({ sessions: [], issues: [], scannedAt: 'same' }) + await createWebAiVaultApi().listSessions({ + historyOwnership: {}, + scopePaths: ['/repo'], + limit: 25 + }) + expect(callRuntimeResult).toHaveBeenCalledExactlyOnceWith( + 'aiVault.listSessions', + expect.objectContaining({ + historyOwnership: {}, + scopePaths: ['/repo'], + limit: 25, + executionHostId: 'runtime:owning-host' + }) + ) + }) it('treats an old host as unavailable but propagates transport failures', async () => { const api = createWebAiVaultApi() callRuntimeResult.mockRejectedValue( diff --git a/src/shared/ai-vault-project-key.ts b/src/shared/ai-vault-project-key.ts index bf665e4e1c32..cf43593b24a3 100644 --- a/src/shared/ai-vault-project-key.ts +++ b/src/shared/ai-vault-project-key.ts @@ -9,3 +9,9 @@ export function toAiVaultProjectKey( } return repoId ? `repo:${repoId}` : null } + +export function isAiVaultProjectKey(value: unknown): value is string { + return ( + typeof value === 'string' && value.length <= 1024 && /^(?:project|repo):[\s\S]+$/.test(value) + ) +} diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index e4606b5c3ec7..ee54b1a74a89 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -120,6 +120,8 @@ export type AiVaultSession = { cwd: string | null /** The host's spellings of `cwd` under a symlinked scope path. Scope matching only; resume reads `cwd`. */ cwdAliases?: string[] + /** Host-derived affiliation; null is unknown, absence uses legacy attribution. */ + projectOwnerKey?: string | null branch: string | null model: string | null filePath: string @@ -231,6 +233,7 @@ export type AiVaultScanIssue = { } export type AiVaultListArgs = { + historyOwnership?: { projectKey?: string } /** Opts a selected runtime-host refresh into content-conditional replies. */ afterFingerprint?: string | null /** Opt-in promises this client starts IDE history in a new CLI conversation. */ diff --git a/src/shared/rpc-contract/ai-vault-params.ts b/src/shared/rpc-contract/ai-vault-params.ts index a832a1bb7668..5666031d7b92 100644 --- a/src/shared/rpc-contract/ai-vault-params.ts +++ b/src/shared/rpc-contract/ai-vault-params.ts @@ -32,6 +32,7 @@ export const AiVaultListSessionsParams = z ) .pipe(z.union([z.number().int(), z.undefined()])) .optional(), + historyOwnership: z.object({ projectKey: z.string().min(1).max(1024).optional() }).optional(), includeAntigravityIdeSessions: OptionalBoolean, unlimited: OptionalBoolean, force: OptionalBoolean, diff --git a/tests/e2e/ai-vault-project-native.unit.test.ts b/tests/e2e/ai-vault-project-native.unit.test.ts index 65402da4ece6..a48cafbb6d4e 100644 --- a/tests/e2e/ai-vault-project-native.unit.test.ts +++ b/tests/e2e/ai-vault-project-native.unit.test.ts @@ -220,3 +220,85 @@ it('bounds actual panel-to-IPC canonical preparation and native cwd seeks at N a expect(large.executions).toBeLessThanOrEqual(small.executions + 2) console.info('native-trigger-operation-counts', JSON.stringify(observations)) }) + +it('does not revisit one SQL cwd range per WSL namespace at N and 10N', async () => { + const counts: number[] = [] + for (const size of [2, 20]) { + const repos: Repo[] = Array.from({ length: size }, (_, index) => ({ + id: `wsl-${index}`, + path: `//wsl$/D${index}/home/ada/app`, + displayName: 'app', + badgeColor: '', + addedAt: index, + upstream: { owner: 'acme', repo: 'app' }, + ...(index ? { projectHostSetupMethod: 'provisioned' as const } : {}) + })) + const raw = projectHostSetupCompatibilityFromRepos(repos) + harness = await openSessionSearchHarness('sta10541-namespace-union') + for (let id = 1; id <= 20; id++) { + addSyntheticSession(harness.db, { + id, + cwd: '/home/ada/app/topic', + sessionFilePath: `//wsl$/D${id <= 10 ? 0 : 'outside'}/home/ada/.claude/session${id}` + }) + } + const projectKey = `project:${raw.projects[0].id}` + const selection = await ownership.compileSessionSearchProjectOwnership( + { + repos, + projects: raw.projects, + projectHostSetups: raw.setups, + worktreeMeta: {}, + settings: { workspaceDir: '', nestWorkspaces: false } + }, + projectKey + ) + const predicate = vi.spyOn(ownership, 'sessionSearchProjectOwns') + const sqlByStatement = new WeakMap() + const prepare = SyncDatabase.prototype.prepare + vi.spyOn(SyncDatabase.prototype, 'prepare').mockImplementation(function ( + this: SyncDatabase, + sql + ) { + const statement = prepare.call(this, sql) + sqlByStatement.set(statement, sql) + return statement + }) + const statements: { sql: string; bindings: SqliteBindings }[] = [] + const all = NodeSqliteStatement.prototype.all + vi.spyOn(NodeSqliteStatement.prototype, 'all').mockImplementation(function ( + this: NodeSqliteStatement, + ...bindings + ) { + const sql = sqlByStatement.get(this) + if (sql?.includes('canonical_project_owns')) { + statements.push({ sql, bindings }) + } + return all.apply(this, bindings) + }) + for (const query of ['needle', 'path:app']) { + const result = harness.engine.search({ query, limit: 20, filters: { ownership: selection } }) + expect(result.hits.map((hit) => Number(hit.sessionId)).sort((a, b) => a - b)).toEqual( + Array.from({ length: 10 }, (_, i) => i + 1) + ) + expect(result.page.hasMore).toBe(false) + } + counts.push(predicate.mock.calls.length) + vi.restoreAllMocks() + expect(statements.length).toBeGreaterThan(0) + for (const { sql, bindings } of statements) { + expect( + harness.db + .prepare(`EXPLAIN QUERY PLAN ${sql}`) + .all(...bindings) + .map((row) => row.detail) + .join('\n') + ).toMatch(/SEARCH scoped USING (?:COVERING )?INDEX sessions_cwd_key/) + } + await harness.close() + harness = undefined + } + expect(counts[0]).toBeGreaterThan(0) + expect(counts[1]).toBe(counts[0]) + console.info('namespace-union-scalar-visits', JSON.stringify(counts)) +}) diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts index 16f2f4f8fdb5..db57102d31aa 100644 --- a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -52,8 +52,34 @@ import { } from '../../src/main/ai-vault-search/session-search-engine-test-fixture' import { AiVaultSearchRequestSchema } from '../../src/shared/ai-vault-search-contract' import { pruneMetadataMissingFromAuthoritativeLocalScan } from '../../src/main/ipc/worktrees/listing/authoritative-local-worktree-metadata-pruning' +import { + configureAiVaultSessionSources, + listAiVaultSessions, + resetAiVaultSessionListCacheForTests +} from '../../src/main/ai-vault/cached-session-list' +import { parseAiVaultListResult } from '../../src/main/ai-vault/session-list-result-validation' +import { + restampAiVaultListResult, + mergeAiVaultListResults +} from '../../src/main/ai-vault/session-list-results' +import { filterAiVaultSessions } from '../../src/shared/ai-vault-session-filters' +import type { AiVaultListResult } from '../../src/shared/ai-vault-types' import { getLocalWorktreeScanGeneration } from '../../src/main/local-worktree-scan-generation' +const { scanTranscripts } = vi.hoisted(() => ({ + scanTranscripts: vi.fn<() => Promise>() +})) +vi.mock('../../src/main/ai-vault/session-scanner-service-spawn', async (importOriginal) => ({ + ...(await importOriginal()), + scanAiVaultSessionsInService: scanTranscripts +})) +vi.mock('../../src/main/claude-accounts/claude-profile-installed-router', () => ({ + claudeProfileHistoryDirs: () => [] +})) +vi.mock('../../src/main/ai-vault/opencode-wsl-runtime-preparation', () => ({ + prepareOpenCodeWslReaders: async () => [] +})) + const a: Repo = { id: 'a', path: '/clones/a/app', @@ -67,6 +93,8 @@ let root: string | undefined let harness: SessionSearchHarness | undefined afterEach(async () => { + resetAiVaultSessionListCacheForTests() + scanTranscripts.mockReset() vi.restoreAllMocks() setSessionSearchService(null) resetSessionSearchScopeCatalogForTests() @@ -298,26 +326,133 @@ it('keeps erased never-opened external history unscoped after authoritative prun endpointProjects: store.getProjects(), endpointProjectHostSetups: store.getProjectHostSetups() } - const rows = buildAiVaultSessionProjectById({ - repos: read.repos, - projectHostSetupProjection: getProjectHostSetupProjectionFromState(read), - worktrees: [ - makeWorktree({ - id: retained, - repoId: a.id, - path: `${root}/workspaces/app/retained`, - ...store.getWorktreeMeta(retained) - }) - ], - settings: store.getSettings(), - sessions: [ - aiVaultSearchHitToSession( - { ...searchHit(), sessionId: 'erased', cwd: `${root}/workspaces/app/topic` }, - 'local' - ) - ] + const transcriptRows = ['topic', 'retained'].map((name) => + aiVaultSearchHitToSession( + { ...searchHit(), sessionId: name, cwd: `${root}/workspaces/app/${name}` }, + 'local' + ) + ) + scanTranscripts.mockResolvedValue({ + sessions: transcriptRows, + issues: [], + scannedAt: 'same-scan' + }) + const capture = vi.fn(() => sessionSearchScopeCatalogFromStore(store, 'local')) + configureAiVaultSessionSources({ getProjectOwnershipCatalog: capture }) + // Non-opted list shares the parse cache but neither captures nor annotates ownership. + const legacy = await listAiVaultSessions({ limit: 2 }) + expect(legacy.sessions).toEqual(transcriptRows) + expect(capture).not.toHaveBeenCalled() + const opted = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) + expect(opted.sessions.map((row) => row.projectOwnerKey)).toEqual([null, projectKey]) + expect(opted.fingerprint).toMatch(/^[0-9a-f]{64}$/) + const render = (result: AiVaultListResult) => + buildAiVaultSessionProjectById({ + repos: read.repos, + projectHostSetupProjection: getProjectHostSetupProjectionFromState(read), + worktrees: [], // Successful Git listing has no retained actionable workspace. + sessions: result.sessions + }) + const decoded = parseAiVaultListResult(restampAiVaultListResult(opted, 'runtime:answering')) + const merged = mergeAiVaultListResults([decoded], 2) + const rows = render(merged) + expect([...rows.values()].map((row) => row.key)).toEqual([ + expect.stringMatching(/^folder:/), + projectKey + ]) + const filter = (activeProjectKey: string) => + filterAiVaultSessions(merged.sessions, { + query: '', + agents: ['codex'], + scope: 'project', + sort: 'updated', + activeWorktreePaths: [], + activeProjectKey, + sessionProjectById: rows, + hideEmptySessions: false + }) + expect(filter(projectKey).map((row) => row.sessionId)).toEqual(['retained']) + expect(filter('repo:b')).toEqual([]) + expect(merged.sessions[1]).toMatchObject({ + cwd: transcriptRows[1].cwd, + filePath: transcriptRows[1].filePath, + executionHostId: 'runtime:answering' + }) + store.setWorktreeMeta(hostWorktreeTarget({ id: retained, hostId: 'local' }), { + projectHostSetupId: 'b', + projectId: 'repo:b' + }) + const changed = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) + expect(changed.scannedAt).toBe(opted.scannedAt) + expect(changed.fingerprint).not.toBe(opted.fingerprint) + expect(changed.sessions[1].projectOwnerKey).toBe('repo:b') + expect(scanTranscripts).toHaveBeenCalledTimes(1) + expect((await listAiVaultSessions({ limit: 2 })).sessions).toEqual(transcriptRows) + // A malformed optional owner costs its annotation, never the transcript. + const malformed = parseAiVaultListResult({ + ...opted, + sessions: [{ ...transcriptRows[0], projectOwnerKey: 42 }] + }) + expect(malformed.sessions).toHaveLength(1) + expect(malformed.sessions[0].projectOwnerKey).toBeUndefined() + const report = vi.spyOn(console, 'error').mockImplementation(() => undefined) + capture.mockImplementationOnce(() => { + throw new Error('ownership source unavailable') }) - expect([...rows.values()][0].key).toMatch(/^folder:/) + const fallback = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) + expect(fallback.sessions).toEqual(transcriptRows) + expect(report).toHaveBeenCalledOnce() + + const explicitUnknown = render({ + ...opted, + sessions: [{ ...transcriptRows[0], cwd: a.path, projectOwnerKey: null }] + }) + expect([...explicitUnknown.values()][0].kind).toBe('folder') +}) + +it('ignores real Store activity during a held native reply while preserving governing changes', async () => { + const store = await seededStore('activity') + const target = hostWorktreeTarget({ id: `b::${b.path}`, hostId: 'local' }) + store.setWorktreeMeta(target, { lastActivityAt: 100 }) + let entered = () => {}, + release = () => {} + const reached = new Promise((resolve) => { + entered = resolve + }) + const held = new Promise((resolve) => { + release = resolve + }) + setSessionSearchService({ + reconcile: async () => {}, + status: async () => { + throw new Error('unused') + }, + search: async () => { + entered() + await held + return { + kind: 'results', + hits: [], + page: { cursor: null, hasMore: false }, + generation: 1, + durationMs: 0, + truncated: { candidates: false, snippets: 0, query: false, freshness: false } + } + } + }) + installSessionSearchScopeCatalogSource(() => sessionSearchScopeCatalogFromStore(store, 'local')) + const pending = searchSessionService( + { + query: 'needle', + within: { kind: 'project', projectKey: 'repo:b', canonicalProjectKey: 'repo:b' } + }, + 'ipc' + ) + await reached + store.setWorktreeMeta(target, { lastActivityAt: 101 }) + expect(store.getWorktreeMeta(target.id)?.lastActivityAt).toBe(101) + release() + expect(await pending).toMatchObject({ kind: 'results' }) }) it('preserves actual discovered selected-source legacy keys across supported catalog ingress controls', async () => { From 0c0aaf2882642c07944d0bb4b463a273b94b2c36 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 15:55:37 -0700 Subject: [PATCH 12/29] Reconcile full session history replies by content --- src/main/ai-vault/cached-session-list.ts | 3 +- .../ai-vault-session-refresh.test.ts | 11 +- .../right-sidebar/ai-vault-session-refresh.ts | 34 +- .../ai-vault-history-publication.unit.test.ts | 296 ++++++++++++++++++ ...ault-project-source-lifecycle.unit.test.ts | 6 +- .../e2e/project-runtime-handoff.unit.test.ts | 135 ++++++++ 6 files changed, 442 insertions(+), 43 deletions(-) create mode 100644 tests/e2e/ai-vault-history-publication.unit.test.ts create mode 100644 tests/e2e/project-runtime-handoff.unit.test.ts diff --git a/src/main/ai-vault/cached-session-list.ts b/src/main/ai-vault/cached-session-list.ts index 48b7760007ba..e2362d402e20 100644 --- a/src/main/ai-vault/cached-session-list.ts +++ b/src/main/ai-vault/cached-session-list.ts @@ -4,7 +4,6 @@ import { compileSessionSearchProjectOwnership, sessionSearchProjectOwner } from '../ai-vault-search/session-search-project-ownership' -import { conditionalAiVaultListResult } from './session-list-fingerprint' import { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router' import { clearAiVaultBackgroundRestartCircuit, @@ -222,7 +221,7 @@ async function annotateHistoryOwnership( } catch (error) { console.error('[ai-vault] history ownership unavailable:', error) } - return conditionalAiVaultListResult(result, null) + return result } // Exported for the subagent-transcript IPC path, which validates diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts index 12d7819b3a94..e9b49a15450b 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.test.ts @@ -433,7 +433,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { await flushMicrotasks() const firstResult = latest?.scanResult - // Same scannedAt = the snapshot already on screen was replayed. + // Repeated stamp and equal content reuse the snapshot already on screen. listSessionsMock.mockResolvedValueOnce({ ...EMPTY_RESULT }) await advance(THROTTLE_MS + 1) await fireWindowFocused() @@ -451,8 +451,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { // An 'all' result is a merge of legs on independent clocks, stamped with the // newest leg. A paired host whose clock lags the desktop can return new - // sessions while the merged stamp repeats, so the stamp guard above must not - // decide anything under 'all' — only the structural reconcile may. + // sessions while the merged stamp repeats; final content decides publication. it('applies a changed all-host result whose merged scannedAt stood still', async () => { const pinned = '2026-07-01T00:00:00.000Z' listSessionsMock.mockResolvedValueOnce({ @@ -477,8 +476,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { }) // The main process routes an empty or unrecognized scope to the same all-host - // merge, so the renderer has to read those as merged too or the guard returns - // for exactly the results it cannot reason about. + // merge; its changed content must also reach reconciliation. it('treats a scope that normalizes to all-host as merged', async () => { const pinned = '2026-07-01T00:00:00.000Z' listSessionsMock.mockResolvedValueOnce({ @@ -514,8 +512,7 @@ describe('useAiVaultSessionRefresh refocus behavior', () => { const applied = latest?.scanResult const appliedSessions = latest?.sessions - // Dropping the stamp guard for 'all' must not reintroduce the churn: the - // reconcile still has to recognise an independently cloned identical body. + // Reconciliation recognises an independently cloned identical body. listSessionsMock.mockResolvedValueOnce(structuredClone(first)) await advance(THROTTLE_MS + 1) await fireWindowFocused() diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts index 280164f55855..4c3edb1ac3c7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-refresh.ts @@ -5,11 +5,7 @@ import { type AiVaultSession } from '../../../../shared/ai-vault-types' import { describeAiVaultScanError } from '../../../../shared/ai-vault-scan-error-message' -import { - ALL_EXECUTION_HOSTS_SCOPE, - requestedExecutionHostScope, - type ExecutionHostScope -} from '../../../../shared/execution-host' +import type { ExecutionHostScope } from '../../../../shared/execution-host' import { useAppStore } from '@/store' import type { AiVaultSessionLimit } from './ai-vault-session-limit' import { AiVaultSessionPublicationGate } from './ai-vault-session-publication-gate' @@ -61,15 +57,6 @@ export const isAiVaultScanCancellation = isAiVaultScanCancelledError type AiVaultRefreshArgs = { force?: boolean; background?: boolean; reuseLoadedDepth?: boolean } -// Shares main's request resolver, so this is exactly the scope that fans out on -// the desktop IPC path. Deliberately over-inclusive: the paired web transport -// drops the scope and serves one host, so 'all' there costs a redundant -// reconcile. Erring the other way would re-enable the stamp fast-path on a real -// merge, which is the bug this guard exists to prevent. -function isMergedAiVaultHostScope(scope: ExecutionHostScope): boolean { - return requestedExecutionHostScope(scope) === ALL_EXECUTION_HOSTS_SCOPE -} - export function useAiVaultSessionRefresh( scopePaths: readonly string[], executionHostScope: ExecutionHostScope, @@ -97,7 +84,6 @@ export function useAiVaultSessionRefresh( const pendingRefreshRef = useRef(false) const pendingForceRef = useRef(false) const pendingBackgroundRef = useRef(true) - const lastAppliedScanRef = useRef<{ scopeKey: string; scannedAt: string } | null>(null) const conditionalSnapshotRef = useRef<(AiVaultConditionalSnapshot & { scopeKey: string }) | null>( null ) @@ -139,7 +125,6 @@ export function useAiVaultSessionRefresh( : null if (cachedResult) { const scanKey = `${baseKey}\n${selectedLimit}` - lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: cachedResult.scannedAt } setFailure(null) setTerminalScopeKey(null) publicationGateRef.current.publish(cachedResult, (published) => { @@ -209,22 +194,7 @@ export function useAiVaultSessionRefresh( return } setFailure(null) - // A cache hit returns the snapshot already on screen; skip the state - // updates so refocus flips don't force pointless re-renders. - // Single-host results carry one scanner's stamp minted when that scan - // finished, so an equal stamp does mean equal content. An 'all' result - // is a merge of legs on independent clocks stamped with the newest leg, - // so a lagging host's leg can change while the merged stamp stands - // still — there, only the structural reconcile below may decide. - if ( - !isMergedAiVaultHostScope(hostScope) && - lastAppliedScanRef.current?.scopeKey === scanKey && - lastAppliedScanRef.current.scannedAt === result.scannedAt && - result.fingerprint === undefined - ) { - return - } - lastAppliedScanRef.current = { scopeKey: scanKey, scannedAt: result.scannedAt } + // Post-scan projection can change content without changing the transcript scan stamp. cacheAiVaultSessionList(request, result, { replaceHostEntries: args.force === true }) publicationGateRef.current.publish(result, (published) => { if (mountedRef.current && scanKey === currentScanScopeKey()) { diff --git a/tests/e2e/ai-vault-history-publication.unit.test.ts b/tests/e2e/ai-vault-history-publication.unit.test.ts new file mode 100644 index 000000000000..06d9025622e6 --- /dev/null +++ b/tests/e2e/ai-vault-history-publication.unit.test.ts @@ -0,0 +1,296 @@ +// @vitest-environment happy-dom +import * as crypto from 'node:crypto' +import * as fs from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { act, createElement } from 'react' +import { createRoot } from 'react-dom/client' +import { expect, it, vi } from 'vitest' +import { + closeTestStores, + createSqliteTestStore, + writePersistedStateJson +} from '../../src/main/persistence-test-harness' +import { Store } from '../../src/main/persistence/loading-store/store' +import { getDefaultPersistedState } from '../../src/shared/constants' +import { hostWorktreeTarget } from '../../src/shared/worktree/host-qualified-identity' +import { sessionSearchScopeCatalogFromStore } from '../../src/main/ai-vault-search/session-search-store-scope-catalog' +import { OrcaRuntimeService } from '../../src/main/runtime/orca-runtime' +import { + AI_VAULT_METHODS, + AiVaultListSessionsParams +} from '../../src/main/runtime/rpc/methods/ai-vault' +import { + configureAiVaultSessionSources, + resetAiVaultSessionListCacheForTests +} from '../../src/main/ai-vault/cached-session-list' +import * as ownership from '../../src/main/ai-vault-search/session-search-project-ownership' +import * as fingerprints from '../../src/main/ai-vault/session-list-fingerprint' +import type { + AiVaultListArgs, + AiVaultListResult, + AiVaultSession +} from '../../src/shared/ai-vault-types' +import * as equality from '../../src/shared/structural-value-equality' +import * as identity from '../../src/renderer/src/components/right-sidebar/ai-vault-session-identity' +import { + useAiVaultSessionRefresh, + resetAiVaultForcedRescanThrottleForTest +} from '../../src/renderer/src/components/right-sidebar/ai-vault-session-refresh' +import { resetInputQuietSchedulerForTest } from '../../src/renderer/src/lib/input-quiet-scheduler' +import { useAppStore } from '../../src/renderer/src/store' +import { aiVaultSearchHitToSession } from '../../src/renderer/src/components/right-sidebar/ai-vault-search-session' +import { searchHit } from '../../src/shared/ai-vault-search-test-fixture' + +const { scan } = vi.hoisted(() => ({ scan: vi.fn<() => Promise>() })) +vi.mock('node:crypto', async (original) => { + const actual = await original() + return { ...actual, createHash: vi.fn(actual.createHash) } +}) +vi.mock('node:fs/promises', async (original) => ({ + ...(await original()), + realpath: async (path: string) => path +})) +vi.mock('../../src/main/ai-vault/session-scanner-service-spawn', async (original) => ({ + ...(await original()), + scanAiVaultSessionsInService: scan +})) +vi.mock('../../src/main/claude-accounts/claude-profile-installed-router', () => ({ + claudeProfileHistoryDirs: () => [] +})) +vi.mock('../../src/main/ai-vault/opencode-wsl-runtime-preparation', () => ({ + prepareOpenCodeWslReaders: async () => [] +})) + +function transcript(index: number): AiVaultSession { + return { + ...aiVaultSearchHitToSession( + { ...searchHit(), sessionId: `session-${index}`, cwd: '/retained' }, + 'local' + ), + previewMessages: [ + { role: 'user', text: `question-${index}`, timestamp: null }, + { role: 'assistant', text: `answer-${index}`, timestamp: null } + ] + } +} + +async function mountedHistory(count: number) { + vi.useFakeTimers() + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true) + resetAiVaultForcedRescanThrottleForTest() + resetInputQuietSchedulerForTest() + resetAiVaultSessionListCacheForTests() + const initial = useAppStore.getInitialState() + useAppStore.setState(initial, true) + const directory = await fs.mkdtemp(join(tmpdir(), 'sta10541-publication-')) + const dataFile = join(directory, 'state.json') + const state = getDefaultPersistedState(directory) + state.repos = ['a', 'b', 'c'].map((id, index) => ({ + id, + path: `/clones/${id}`, + addedAt: index + 1, + displayName: id, + badgeColor: '' + })) + writePersistedStateJson(dataFile, JSON.stringify(state)) + const store = createSqliteTestStore(Store, { dataFile }) + const target = hostWorktreeTarget({ id: 'a::/retained', hostId: 'local' }) + const setOwner = (owner: string) => + store.setWorktreeMeta(target, { + projectId: owner, + projectHostSetupId: owner.slice('repo:'.length) + }) + setOwner('repo:a') + const capture = vi.fn(() => sessionSearchScopeCatalogFromStore(store, 'local')) + scan.mockResolvedValue({ + sessions: Array.from({ length: count }, (_, index) => transcript(index)), + issues: [], + scannedAt: 'same-scan' + }) + const compile = vi.spyOn(ownership, 'compileSessionSearchProjectOwnership') + const reconcile = vi.spyOn(identity, 'reuseAiVaultListResult') + const compare = vi.spyOn(equality, 'structuralValuesEqualIgnoringUndefined') + const hashes = vi.mocked(crypto.createHash) + let listHashCalls = 0 + const finalize = fingerprints.conditionalAiVaultListResult + const conditional = vi + .spyOn(fingerprints, 'conditionalAiVaultListResult') + .mockImplementation((...args) => { + const before = hashes.mock.calls.length + const result = finalize(...args) + listHashCalls += hashes.mock.calls.length - before + return result + }) + const runtime = new OrcaRuntimeService() + vi.spyOn(runtime, 'ensureStructuredAgentSessionHost').mockResolvedValue(undefined) + configureAiVaultSessionSources({ getProjectOwnershipCatalog: capture }) + hashes.mockClear() + const method = AI_VAULT_METHODS.find((entry) => entry.name === 'aiVault.listSessions') + if (!method) { + throw new Error('Missing History RPC') + } + const outputs: AiVaultListResult[] = [] + let payloadReads = 0 + const api = Object.getOwnPropertyDescriptor(window, 'api') + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + listSessions: async (args: AiVaultListArgs) => { + const params = AiVaultListSessionsParams.parse({ + ...args, + executionHostId: 'runtime:one' + }) + // The real handler performs structured projection, runtime restamping and conditional transfer. + const result = structuredClone(await method.handler(params, { runtime })) + outputs.push(result) + // Observe nested reads by the real comparator after the transport clone. + for (const row of result.sessions) { + for (const message of row.previewMessages) { + const text = message.text + Object.defineProperty(message, 'text', { + enumerable: true, + get: () => { + payloadReads += 1 + return text + } + }) + } + } + return result + }, + cancelListSessions: async () => {}, + onWindowFocused: () => () => {} + } + } + }) + const container = document.createElement('div') + document.body.appendChild(container) + const root = createRoot(container) + let latest: ReturnType | undefined + function Probe({ limit }: { limit: number | 'unlimited' }) { + latest = useAiVaultSessionRefresh([], 'runtime:one', limit) + return null + } + const settle = async () => { + await act(async () => vi.advanceTimersByTimeAsync(1200)) + } + return { + capture, + compile, + reconcile, + compare, + conditional, + listHashCalls: () => listHashCalls, + outputs, + payloadReads: () => payloadReads, + setOwner, + current: () => { + if (!latest) { + throw new Error('History is not mounted') + } + return latest + }, + mount: async (limit: number | 'unlimited' = 'unlimited') => { + await act(async () => root.render(createElement(Probe, { limit }))) + await settle() + }, + refresh: async () => { + await act(async () => latest?.refresh({ background: true })) + await settle() + }, + close: async () => { + await act(async () => root.unmount()) + container.remove() + if (api) { + Object.defineProperty(window, 'api', api) + } else { + Reflect.deleteProperty(window, 'api') + } + useAppStore.setState(initial, true) + resetAiVaultSessionListCacheForTests() + configureAiVaultSessionSources({}) + resetAiVaultForcedRescanThrottleForTest() + resetInputQuietSchedulerForTest() + scan.mockReset() + vi.restoreAllMocks() + vi.unstubAllGlobals() + vi.useRealTimers() + await closeTestStores() + await fs.rm(directory, { recursive: true, force: true }) + } + } +} + +it('publishes post-cache ownership at an unchanged stamp through Unlimited RPC and keeps nested unchanged identities', async () => { + const history = await mountedHistory(1) + try { + await history.mount() + expect(history.current().sessions[0].projectOwnerKey).toBe('repo:a') + history.setOwner('repo:b') + await history.refresh() + expect(history.outputs.at(-1)?.sessions[0].projectOwnerKey).toBe('repo:b') + expect(history.outputs.at(-1)?.scannedAt).toBe(history.outputs[0].scannedAt) + expect(history.outputs.at(-1)?.fingerprint).toBeUndefined() + expect(history.current().sessions[0].projectOwnerKey).toBe('repo:b') + expect(history.current().sessions[0].executionHostId).toBe('runtime:one') + expect(scan).toHaveBeenCalledTimes(1) + const applied = history.current().scanResult + const sessions = history.current().sessions + const row = sessions[0] + await history.refresh() + expect(history.outputs.at(-1)?.sessions[0].previewMessages).not.toBe(row.previewMessages) + expect(history.current().scanResult).toBe(applied) + expect(history.current().sessions).toBe(sessions) + expect(history.current().sessions[0]).toBe(row) + expect(scan).toHaveBeenCalledTimes(1) + expect(history.capture).toHaveBeenCalledTimes(3) + expect(history.compile).toHaveBeenCalledTimes(3) + expect(history.listHashCalls()).toBe(0) + expect(history.conditional.mock.calls.every(([, after]) => after === undefined)).toBe(true) + + await history.mount(250) + history.setOwner('repo:c') + await history.refresh() + expect(history.outputs.at(-1)?.fingerprint).toMatch(/^[0-9a-f]{64}$/) + expect(history.current().sessions[0].projectOwnerKey).toBe('repo:c') + expect(history.listHashCalls()).toBe(1) + } finally { + await history.close() + } +}) + +it.each([20, 200])( + 'bounds repeated actual full refresh work for %i unique nested rows', + async (count) => { + const history = await mountedHistory(count) + try { + await history.mount() + const applied = history.current().scanResult + const sessions = history.current().sessions + const before = history.payloadReads() + history.reconcile.mockClear() + history.compare.mockClear() + for (let repeat = 0; repeat < 3; repeat++) { + await history.refresh() + expect(history.current().scanResult).toBe(applied) + expect(history.current().sessions).toBe(sessions) + expect(history.current().sessions.every((row, index) => row === sessions[index])).toBe(true) + } + expect(history.outputs).toHaveLength(4) + expect(history.reconcile).toHaveBeenCalledTimes(3) + expect(history.compare).toHaveBeenCalledTimes(3 * count) + expect(history.payloadReads() - before).toBe(3 * count * 4) + expect(history.capture).toHaveBeenCalledTimes(4) + expect(history.compile).toHaveBeenCalledTimes(4) + expect(history.listHashCalls()).toBe(0) + expect(history.conditional).toHaveBeenCalledTimes(4) + expect(history.conditional.mock.calls.every(([, after]) => after === undefined)).toBe(true) + expect(history.outputs.every((result) => result.fingerprint === undefined)).toBe(true) + expect(scan).toHaveBeenCalledTimes(1) + } finally { + await history.close() + } + } +) diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts index db57102d31aa..a0caef3c5cd5 100644 --- a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -345,7 +345,8 @@ it('keeps erased never-opened external history unscoped after authoritative prun expect(capture).not.toHaveBeenCalled() const opted = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) expect(opted.sessions.map((row) => row.projectOwnerKey)).toEqual([null, projectKey]) - expect(opted.fingerprint).toMatch(/^[0-9a-f]{64}$/) + expect(opted.fingerprint).toBeUndefined() + expect(scanTranscripts).toHaveBeenCalledTimes(1) const render = (result: AiVaultListResult) => buildAiVaultSessionProjectById({ repos: read.repos, @@ -384,7 +385,8 @@ it('keeps erased never-opened external history unscoped after authoritative prun }) const changed = await listAiVaultSessions({ limit: 2, historyOwnership: {} }) expect(changed.scannedAt).toBe(opted.scannedAt) - expect(changed.fingerprint).not.toBe(opted.fingerprint) + expect(changed.fingerprint).toBeUndefined() + expect(opted.sessions[1].projectOwnerKey).toBe(projectKey) expect(changed.sessions[1].projectOwnerKey).toBe('repo:b') expect(scanTranscripts).toHaveBeenCalledTimes(1) expect((await listAiVaultSessions({ limit: 2 })).sessions).toEqual(transcriptRows) diff --git a/tests/e2e/project-runtime-handoff.unit.test.ts b/tests/e2e/project-runtime-handoff.unit.test.ts new file mode 100644 index 000000000000..60d95fc3a94e --- /dev/null +++ b/tests/e2e/project-runtime-handoff.unit.test.ts @@ -0,0 +1,135 @@ +import { mkdtemp, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { expect, it } from 'vitest' +import { + closeTestStores, + createSqliteTestStore, + withPlatform, + writePersistedStateJson +} from '../../src/main/persistence-test-harness' +import { Store } from '../../src/main/persistence/loading-store/store' +import { rebuildRepoBackedProjectState } from '../../src/main/orca-profiles/profile-project-state-file' +import { resolveLocalWorkspaceRuntime } from '../../src/main/local-project-runtime-resolution' +import { getDefaultPersistedState } from '../../src/shared/constants' +import type { PersistedState } from '../../src/shared/persisted-state-types' +import type { Repo } from '../../src/shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../src/shared/project-host-setup-projection' +import { getLocalRepoProjectExecutionRuntimeContext } from '../../src/renderer/src/lib/local-preflight-context' +import { useAppStore } from '../../src/renderer/src/store' + +it('keeps the exact surviving project runtime through a three-local-checkout handoff, profile rebuild and reload', async () => { + const root = await mkdtemp(join(tmpdir(), 'sta10541-runtime-handoff-')) + const dataFile = join(root, 'state.json') + const exported = join(root, 'export.json') + const projectId = 'github:acme/app' + const repo = (id: string, path: string, addedAt: number): Repo => ({ + id, + path, + addedAt, + displayName: 'app', + badgeColor: '', + upstream: { owner: 'acme', repo: 'app' } + }) + const repos = [ + repo('a', 'C:/app-a', 1), + repo('b', 'C:/app-b', 2), + repo('c', '\\\\wsl.localhost\\Ubuntu\\home\\me\\app-wsl', 3) + ] + const projection = projectHostSetupProjectionFromRepos(repos) + const state = getDefaultPersistedState(root) + state.repos = repos + state.projects = [...projection.projects] + state.projectHostSetups = [ + ...projection.setups, + { + id: 'pending-b', + projectId: 'repo:b', + repoId: '', + hostId: 'ssh:extra', + path: '', + displayName: 'extra', + setupState: 'not-set-up', + setupMethod: 'provisioned', + createdAt: 1, + updatedAt: 1 + } + ] + writePersistedStateJson(dataFile, JSON.stringify(state)) + try { + let store = createSqliteTestStore(Store, { dataFile }) + expect(store.getProjects().map((row) => [row.id, row.sourceRepoIds])).toEqual([ + [projectId, ['a', 'c']], + ['repo:b', ['b']] + ]) + store.updateProject(projectId, { + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + store.updateProject('repo:b', { localWindowsRuntimePreference: { kind: 'windows-host' } }) + store.writeProfileStateJsonExport(exported) + await closeTestStores() + store = createSqliteTestStore(Store, { dataFile }) + expect( + store.getProjects().find((row) => row.id === 'repo:b')?.localWindowsRuntimePreference + ).toEqual({ kind: 'windows-host' }) + const before: PersistedState = JSON.parse(await readFile(exported, 'utf8')) + const concrete = store.getRepos().filter((row) => row.id !== 'a') + store.removeProjectForHost({ id: 'a', executionHostId: 'local' }) + const remaining = repos.slice(1) + const rebuilt = rebuildRepoBackedProjectState({ ...before, repos: remaining }) + expect(store.getProjects()).toEqual(rebuilt.projects) + expect(store.getProjectHostSetups()).toEqual(rebuilt.projectHostSetups) + const projects = store.getProjects() + const setups = store.getProjectHostSetups() + expect(projects).toEqual([ + expect.objectContaining({ + id: projectId, + sourceRepoIds: ['b', 'c'], + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + }) + ]) + expect(setups.filter((row) => row.id === 'pending-b')).toEqual([ + expect.objectContaining({ + projectId, + repoId: '', + hostId: 'ssh:extra', + path: '', + setupState: 'not-set-up' + }) + ]) + expect(store.getRepos()).toEqual(concrete) + expect( + setups.filter((row) => row.repoId).map((row) => [row.repoId, row.path, row.hostId]) + ).toEqual(remaining.map((row) => [row.id, row.path, 'local'])) + store.writeProfileStateJsonExport(exported) + await closeTestStores() + store = createSqliteTestStore(Store, { dataFile }) + expect(store.getProjects()).toEqual(projects) + expect(store.getProjectHostSetups()).toEqual(setups) + expect(store.getRepos()).toEqual(concrete) + const saved: PersistedState = JSON.parse(await readFile(exported, 'utf8')) + expect(rebuildRepoBackedProjectState(saved).projects).toEqual(projects) + await withPlatform('win32', async () => { + const renderer = { + ...useAppStore.getInitialState(), + repos: store.getRepos(), + settings: store.getSettings(), + endpointProjects: store.getProjects(), + endpointProjectHostSetups: store.getProjectHostSetups() + } + for (const target of store.getRepos()) { + const main = resolveLocalWorkspaceRuntime(store, target, target.path) + expect(main).toMatchObject({ + status: 'resolved', + runtime: { kind: 'wsl', distro: 'Ubuntu', projectId, reason: 'project-override' } + }) + expect(getLocalRepoProjectExecutionRuntimeContext(renderer, target.id, 'win32')).toEqual( + main + ) + } + }) + } finally { + await closeTestStores() + await rm(root, { recursive: true, force: true }) + } +}) From 768759bf8df26562194a0278c7976aa5dd27274d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 16:59:29 -0700 Subject: [PATCH 13/29] Require authoritative project catalogs for terminal snapshots --- .../AiVaultPanel.legacy-filter.test.tsx | 4 + .../AiVaultPanel.resume-catalog.test.tsx | 180 ++++++++++++++++++ .../components/right-sidebar/AiVaultPanel.tsx | 4 +- .../right-sidebar/ai-vault-host-scope.test.ts | 27 ++- ...t-session-resume-in-chat-workspace.test.ts | 9 +- .../ai-vault-session-resume.test.ts | 4 +- .../right-sidebar/ai-vault-session-resume.ts | 23 +-- .../runtime-target-selection.ts | 3 +- .../useComposerState.integration.test.ts | 124 ++++++++++++ ...i-vault-antigravity-reference-host.test.ts | 5 +- ...ult-resume-command.resumable-agent.test.ts | 1 + .../src/lib/ai-vault-resume-command.test.ts | 2 +- .../src/lib/ai-vault-resume-shell.test.ts | 1 + .../src/lib/execution-host-facts.test.ts | 1 + .../src/lib/http-link-routing.test.ts | 4 +- .../src/lib/local-preflight-context.test.ts | 2 +- .../src/store/project-host-setup-selector.ts | 23 ++- .../listing/worktree-host-ownership.ts | 7 +- 18 files changed, 373 insertions(+), 51 deletions(-) create mode 100644 src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx index 990b04fba197..d6857b6f1792 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.legacy-filter.test.tsx @@ -11,6 +11,8 @@ const mockState: { folderWorkspaces: never[] projectGroups: never[] repos: never[] + endpointProjects: never[] + endpointProjectHostSetups: never[] worktreesByRepo: Record } = { settings: {}, @@ -18,6 +20,8 @@ const mockState: { folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} } const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => { diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx new file mode 100644 index 000000000000..7ae5a0bd73d5 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.resume-catalog.test.tsx @@ -0,0 +1,180 @@ +// @vitest-environment happy-dom +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import type * as NewWorkspace from '@/lib/new-workspace' +import { getDefaultSettings } from '../../../../shared/constants' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import type { Repo } from '../../../../shared/repo-types' +import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' +import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal' +import { resetAiVaultForcedRescanThrottleForTest } from './ai-vault-session-refresh' +import AiVaultPanel from './AiVaultPanel' +import { TooltipProvider } from '@/components/ui/tooltip' +import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context' + +vi.mock('@/lib/new-workspace', async (importOriginal) => ({ + ...(await importOriginal()), + CLIENT_PLATFORM: 'win32' +})) +vi.mock('@/lib/launch-ai-vault-session-and-reveal', () => ({ + launchAiVaultSessionAndReveal: vi.fn().mockResolvedValue(undefined) +})) +// happy-dom has no viewport measurements; retain the actual list, rows and actions. +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 80, + end: (index + 1) * 80, + size: 80 + })), + getTotalSize: () => count * 80, + measureElement: () => {}, + scrollToIndex: () => {}, + scrollOffset: 0 + }) +})) + +const original = useAppStore.getState() +const originalApi = Object.getOwnPropertyDescriptor(window, 'api') +afterEach(() => { + cleanup() + useAppStore.setState(original, true) + if (originalApi) { + Object.defineProperty(window, 'api', originalApi) + } else { + Reflect.deleteProperty(window, 'api') + } + vi.clearAllMocks() +}) + +it('uses the mounted panel catalog for Resume eligibility and the concrete action target', async () => { + useAppStore.setState(useAppStore.getInitialState(), true) + resetAiVaultForcedRescanThrottleForTest() + const repo: Repo = { id: 'b', path: 'C:/app-b', displayName: 'B', badgeColor: '', addedAt: 1 } + const worktree = makeWorktree({ + id: 'b::C:/app-b', + repoId: 'b', + path: 'C:/app-b', + hostId: 'local' + }) + const catalog = projectHostSetupProjectionFromRepos([repo]) + useAppStore.setState({ + repos: [repo], + activeRepoId: 'b', + activeWorktreeId: worktree.id, + endpointProjects: catalog.projects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + })), + endpointProjectHostSetups: [...catalog.setups], + worktreesByRepo: { b: [worktree] }, + settings: { + ...getDefaultSettings('/tmp'), + localWindowsRuntimeDefault: { kind: 'windows-host' }, + terminalWindowsShell: 'powershell.exe' + } + }) + const wsl: AiVaultSession = { + id: 'claude:wsl', + executionHostId: 'local', + agent: 'claude', + sessionId: 'wsl-session', + title: 'Ubuntu conversation', + cwd: 'C:/app-b', + branch: null, + model: null, + filePath: String.raw`\\wsl.localhost\Ubuntu\home\alice\.claude\projects\app\wsl-session.jsonl`, + codexHome: null, + createdAt: '2026-10-23T10:00:00Z', + updatedAt: '2026-10-23T10:00:00Z', + modifiedAt: '2026-10-23T10:00:00Z', + messageCount: 2, + totalTokens: 10, + previewMessages: [{ role: 'user', text: 'continue this work', timestamp: null }], + queuedMessageCount: 0, + subagentTranscriptCount: 0, + resumeCommand: '', + subagent: null + } + const native: AiVaultSession = { + ...wsl, + id: 'claude:native', + sessionId: 'native-session', + title: 'Windows conversation', + filePath: 'C:/Users/alice/.claude/projects/app/native-session.jsonl' + } + const prepare = vi + .fn() + .mockResolvedValue({ useRealCodexHome: false }) + const listSessions = vi.fn().mockResolvedValue({ + sessions: [wsl, native], + issues: [], + scannedAt: '2026-10-23T10:00:00Z' + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + aiVault: { + listSessions, + prepareSessionResume: prepare, + cancelListSessions: vi.fn().mockResolvedValue(undefined) + } + } + }) + render( + false}> + + + + + ) + const resumeButton = (title: string) => { + const row = screen.getByText(title).closest('.ai-vault-session-row') + if (!(row instanceof HTMLElement)) { + throw new Error('Expected the real session row') + } + return within(row).getByRole('button', { name: /^Resume in / }) + } + await waitFor(() => expect(screen.getByText(wsl.title)).toBeTruthy()) + expect(resumeButton(wsl.title).hasAttribute('disabled')).toBe(false) + expect(resumeButton(native.title).hasAttribute('disabled')).toBe(true) + fireEvent.click(resumeButton(wsl.title)) + await waitFor(() => expect(launchAiVaultSessionAndReveal).toHaveBeenCalledOnce()) + expect(prepare).toHaveBeenCalledWith({ + agent: 'claude', + sessionId: wsl.sessionId, + executionHostId: 'local', + filePath: wsl.filePath, + codexHome: null + }) + expect(launchAiVaultSessionAndReveal).toHaveBeenCalledWith( + expect.objectContaining({ + agent: 'claude', + worktreeId: worktree.id, + command: expect.stringContaining("'wsl-session'") + }), + expect.any(Function) + ) + expect(useAppStore.getState().repos[0]).toBe(repo) + expect(useAppStore.getState().worktreesByRepo.b[0]).toMatchObject({ + path: 'C:/app-b', + hostId: 'local' + }) + act(() => + useAppStore.setState((state) => ({ + endpointProjects: state.endpointProjects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + })) + })) + ) + await waitFor(() => expect(resumeButton(wsl.title).hasAttribute('disabled')).toBe(true)) + expect(resumeButton(native.title).hasAttribute('disabled')).toBe(false) + fireEvent.click(resumeButton(wsl.title)) + expect(launchAiVaultSessionAndReveal).toHaveBeenCalledOnce() +}) diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index 8d2ad5b6263c..ec5e37ad7cb9 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -1,4 +1,4 @@ -import { selectCanonicalProjects } from '@/store/project-host-setup-selector' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { useCallback, useMemo, useRef, useState } from 'react' import { useShallow } from 'zustand/react/shallow' import { toast } from 'sonner' @@ -71,7 +71,7 @@ export default function AiVaultPanel({ const projectHostSetupProjection = useProjectHostSetupProjection() const resumeTargetState = useAppStore( useShallow((state) => ({ - projects: selectCanonicalProjects(state), + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state), settings: state.settings, folderWorkspaces: selectFolderWorkspaces(state), projectGroups: state.projectGroups, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts index da55500cd517..df24894aa2ca 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-host-scope.test.ts @@ -3,14 +3,14 @@ import { act, createElement } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, describe, expect, it } from 'vitest' -import type { AppState } from '@/store/types' +import { TEST_REPO, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume' import { buildAiVaultHostScopeOptions, buildRuntimeAiVaultHostScopeOptions, useAiVaultExecutionHostScope } from './ai-vault-host-scope' -import type { ExecutionHostScope } from '../../../../shared/execution-host' +import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host' type HostScopeResult = ReturnType @@ -44,14 +44,17 @@ async function renderHook(props: { function stateForWorktree(args: { worktreeId: string repoId: string - executionHostId?: string | null - hostId?: string | null + executionHostId?: ExecutionHostId | null + hostId?: ExecutionHostId | null }): AiVaultSessionResumeTargetState { return { folderWorkspaces: [], projectGroups: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [ { + ...TEST_REPO, id: args.repoId, connectionId: null, executionHostId: args.executionHostId ?? 'local' @@ -59,14 +62,14 @@ function stateForWorktree(args: { ], worktreesByRepo: { [args.repoId]: [ - { + makeWorktree({ id: args.worktreeId, repoId: args.repoId, - hostId: args.hostId ?? null - } + hostId: args.hostId ?? undefined + }) ] } - } as unknown as Pick + } } afterEach(() => { @@ -114,8 +117,10 @@ describe('useAiVaultExecutionHostScope', () => { folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} - } as unknown as AiVaultSessionResumeTargetState + } }) expect(latest?.executionHostScope).not.toBe('local') @@ -129,8 +134,10 @@ describe('useAiVaultExecutionHostScope', () => { folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {} - } as unknown as AiVaultSessionResumeTargetState + } }) expect(latest?.executionHostScope).toBe('local') diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts index d8387ddb90c0..bc1f7a0d30d1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts @@ -40,7 +40,14 @@ function resumeInChat() { session: codexSession, resumeState: { blocked: true, worktreeId: null, usesSessionWorktree: false }, activeWorkspaceId: PAIRED_WORKSPACE, - targetState: { folderWorkspaces: [], projectGroups: [], repos: [], worktreesByRepo: {} }, + targetState: { + folderWorkspaces: [], + projectGroups: [], + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], + worktreesByRepo: {} + }, settings: { experimentalNativeChat: true } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts index aa8f8c45b7e8..301de2e2a8d1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.test.ts @@ -53,9 +53,11 @@ function makeTargetState( folderWorkspaces: [], projectGroups: [], repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], worktreesByRepo: {}, ...overrides - } as AiVaultSessionResumeTargetState + } } function makeFolderTargetState( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts index 73cb18401d22..2de953f46227 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume.ts @@ -10,6 +10,8 @@ import { isAiVaultSessionResumableContent, type AiVaultSession } from '../../../../shared/ai-vault-types' +import type { ProjectCatalogReadState } from '@/store/project-host-setup-selector' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import type { AppState } from '@/store/types' import { getIndexedWorktreeMap } from '@/store/worktree-repo-index' import { translate } from '@/i18n/i18n' @@ -22,20 +24,9 @@ import { } from './ai-vault-session-worktree' import { selectFolderWorkspaces, selectWorktreesByRepo } from '@/store/workspace-record-selectors' -export type AiVaultSessionResumeTargetState = Pick< - AppState, - 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo' -> & - Partial< - Pick< - AppState, - | 'activeRepoId' - | 'activeWorktreeId' - | 'endpointProjects' - | 'endpointProjectHostSetups' - | 'settings' - > - > +export type AiVaultSessionResumeTargetState = ProjectCatalogReadState & + Pick & + Partial> export type AiVaultSessionResumeState = { blocked: boolean @@ -236,10 +227,12 @@ function resolveAiVaultResumeTargetState(args: { for (const worktree of args.worktrees) { worktreesByRepo[worktree.repoId] = [...(worktreesByRepo[worktree.repoId] ?? []), worktree] } + const repos = [...args.repos] return { folderWorkspaces: [], projectGroups: [], - repos: [...args.repos], + repos, + projectHostSetupProjection: projectHostSetupProjectionFromRepos(repos), worktreesByRepo } } diff --git a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts index 0a2c8f325dae..c3eb30334bf7 100644 --- a/src/renderer/src/hooks/composer-state/runtime-target-selection.ts +++ b/src/renderer/src/hooks/composer-state/runtime-target-selection.ts @@ -167,7 +167,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe { activeRepoId, activeWorktreeId: null, - projects, + projectHostSetupProjection: { projects, setups: projectHostSetups }, repos, settings, worktreesByRepo @@ -178,6 +178,7 @@ export function useComposerRuntimeTargetSelection(input: ComposerRuntimeTargetSe ) }, [ activeRepoId, + projectHostSetups, projects, repos, runtimeStatusByEnvironmentId, diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index 5150c4375044..30453462b42b 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -3,6 +3,13 @@ import { act, renderHook, waitFor } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' +import type * as NewWorkspace from '@/lib/new-workspace' +import { getDefaultSettings } from '../../../shared/constants' + +vi.mock('@/lib/new-workspace', async (importOriginal) => ({ + ...(await importOriginal()), + CLIENT_PLATFORM: 'win32' +})) import { EMPTY_RETIRED_NAME_REGISTRY } from '../../../shared/worktree/retired-name-registry' import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures' import type { CreateWorktreeResult } from '../../../shared/worktree/create-types' @@ -188,6 +195,123 @@ describe('useComposerState integrated lifecycle', () => { } }) + it.each(['claude', 'codex'] as const)( + 'uses the live Project runtime for the %s quick-create startup request', + async (agent) => { + const original = useAppStore.getState() + useAppStore.setState(useAppStore.getInitialState(), true) + const repo: Repo = { id: 'b', path: 'C:/app-b', displayName: 'B', badgeColor: '', addedAt: 1 } + const catalog = projectHostSetupProjectionFromRepos([repo]) + const settings = { + ...getDefaultSettings('/tmp'), + terminalWindowsShell: 'powershell.exe', + localWindowsRuntimeDefault: { kind: 'windows-host' as const }, + agentDefaultArgs: { claude: '', codex: '' }, + agentDefaultEnv: { claude: {}, codex: {} } + } + useAppStore.setState({ + repos: [repo], + endpointProjects: catalog.projects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + })), + endpointProjectHostSetups: [...catalog.setups], + settings, + sparsePresetsByRepo: { b: [] } + }) + const responses: ReturnType>[] = [] + const create = vi.fn().mockImplementation(() => { + const response = Promise.withResolvers() + responses.push(response) + return response.promise + }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { + ...window.api, + gh: { repoSlug: vi.fn().mockResolvedValue(null) }, + worktrees: { ...window.api.worktrees, create } + } + }) + const composer = renderHook(() => + useComposerState({ + initialRepoId: 'b', + initialName: 'topic', + persistDraft: false, + initialLinkedWorkItem: { + provider: 'linear', + type: 'issue', + number: 0, + title: 'Typed task', + url: '' + } + }) + ) + try { + act(() => composer.result.current.cardProps.onNoteChange(`fix it's "quoted" $HOME`)) + for (const windows of [false, true]) { + if (windows) { + act(() => + useAppStore.setState((state) => ({ + endpointProjects: state.endpointProjects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'windows-host' } + })), + endpointProjectHostSetups: state.endpointProjectHostSetups.map((setup) => ({ + ...setup, + updatedAt: setup.updatedAt + 1 + })), + settings: { + ...settings, + localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' } + } + })) + ) + } + expect(composer.result.current.cardProps.selectedRepo).toBe(repo) + expect(composer.result.current.cardProps.selectedProjectId).toBe('repo:b') + expect(composer.result.current.cardProps.selectedRepoExecutionHostId).toBe('local') + expect(composer.result.current.cardProps.selectedRepoPath).toBe('C:/app-b') + await act(async () => composer.result.current.submitQuick(agent)) + await waitFor(() => expect(create).toHaveBeenCalledTimes(windows ? 2 : 1)) + const pending = Object.values(useAppStore.getState().pendingWorktreeCreations)[0] + expect(pending.request.workspaceRunContext).toMatchObject({ + repoId: 'b', + projectId: 'repo:b', + projectHostSetupId: catalog.setups[0].id, + hostId: 'local', + path: 'C:/app-b' + }) + const quoted = windows ? `'fix it''s "quoted" $HOME'` : `'fix it'"'"'s "quoted" $HOME'` + expect(pending.request.startup?.command).toBe( + agent === 'claude' ? `claude ${quoted}` : `codex ${quoted}` + ) + expect(pending.request.startupPlan?.launchCommand).toBe(pending.request.startup?.command) + act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId)) + await act(async () => + responses[windows ? 1 : 0].resolve({ + worktree: makeWorktree({ + id: `b::topic-${windows}`, + repoId: 'b', + path: 'C:/app-b-topic' + }) + }) + ) + } + } finally { + composer.unmount() + await act(async () => { + for (const response of responses) { + response.resolve({ + worktree: makeWorktree({ id: 'b::cleanup', repoId: 'b', path: 'C:/app-b-topic' }) + }) + } + }) + useAppStore.setState(original, true) + } + } + ) + it('composes two live composers and exposes the parent-worktree control state', () => { useAppStore.setState({ repos: [], diff --git a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts index 3f129d045bca..d8459cc8fccb 100644 --- a/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts +++ b/src/renderer/src/lib/ai-vault-antigravity-reference-host.test.ts @@ -21,7 +21,10 @@ const linuxFile = '/home/example/.gemini/antigravity-ide/brain/copied-id/transcr const debianFile = '//wsl.localhost/Debian/home/example/.gemini/antigravity-ide/brain/copied-id/transcript_full.jsonl' -function stateFor(host: ExecutionHostId, distro?: string): State { +function stateFor( + host: ExecutionHostId, + distro?: string +): Extract { const worktree = makeWorktree({ id: 'repo::workspace', repoId: 'repo', diff --git a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts index 0215c3e85bf1..82521b70a5ea 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.resumable-agent.test.ts @@ -19,6 +19,7 @@ function makeState(): ResumableAgentState { folderWorkspaces: [], projectGroups: [], repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjectHostSetups: [], endpointProjects: [ { id: 'repo-1', diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index 6c5106f7ebc0..e513ebab0d56 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -26,7 +26,7 @@ function makeState(args: { worktreePath: string localWindowsRuntimePreference?: RuntimePreference terminalWindowsShell?: string -}): AiVaultResumeCommandState { +}): Extract { return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', diff --git a/src/renderer/src/lib/ai-vault-resume-shell.test.ts b/src/renderer/src/lib/ai-vault-resume-shell.test.ts index 4ad40718b919..589619f410e4 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -17,6 +17,7 @@ function makeState(worktreeHostId?: string): ResumeShellState { folderWorkspaces: [], projectGroups: [], repos: [{ ...TEST_REPO, id: 'repo-1', path: '/home/alice/repo' }], + endpointProjectHostSetups: [], endpointProjects: [ { id: 'repo-1', diff --git a/src/renderer/src/lib/execution-host-facts.test.ts b/src/renderer/src/lib/execution-host-facts.test.ts index 47f21981ab95..527b1e321ed8 100644 --- a/src/renderer/src/lib/execution-host-facts.test.ts +++ b/src/renderer/src/lib/execution-host-facts.test.ts @@ -37,6 +37,7 @@ function state(): State { folderWorkspaces: [], projectGroups: [], endpointProjects: [], + endpointProjectHostSetups: [], activeRepoId: null, activeWorktreeId: null, // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the resolver reads no other setting. diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index 92999a232b75..2479151c5b23 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -9,7 +9,7 @@ import { } from './http-link-routing' import type { Repo } from '../../../shared/repo-types' -import type { Project } from '../../../shared/project-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { ExecutionHostId } from '../../../shared/execution-host' const toastErrorMock = vi.hoisted(() => vi.fn()) @@ -30,6 +30,7 @@ function noActiveWorkspace(): { } const endpointProjects: Project[] = [] +const endpointProjectHostSetups: ProjectHostSetup[] = [] const storeState = { settings: undefined as | { @@ -45,6 +46,7 @@ const storeState = { createBrowserTab: createBrowserTabMock, repos: [] as Repo[], endpointProjects, + endpointProjectHostSetups, worktreesByRepo: {} as Record< string, { id: string; projectId?: string; repoId?: string; displayName?: string }[] diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index c8bc8a816984..52a7c22a2aa1 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -22,7 +22,7 @@ function legacySettings() { } type PreflightState = Parameters[0] & - Pick + Pick function project(overrides: Pick & Partial): Project { return { displayName: '', diff --git a/src/renderer/src/store/project-host-setup-selector.ts b/src/renderer/src/store/project-host-setup-selector.ts index 61a2c8496723..950aebf7cc07 100644 --- a/src/renderer/src/store/project-host-setup-selector.ts +++ b/src/renderer/src/store/project-host-setup-selector.ts @@ -25,22 +25,20 @@ function getDerivedProjection(repos: AppState['repos']): ProjectHostSetupProject } export type ProjectCatalogReadState = Pick & - Partial> & { - projectHostSetupProjection?: ProjectHostSetupProjection - } + ( + | { projectHostSetupProjection: ProjectHostSetupProjection } + | Pick + ) // Why: this is a zustand selector compared with Object.is, so the result must be // reference-stable per (repos, projects, setups) input or every render re-renders. export function getProjectHostSetupProjectionFromState( state: ProjectCatalogReadState ): ProjectHostSetupProjection { - if (state.projectHostSetupProjection) { + if ('projectHostSetupProjection' in state) { return state.projectHostSetupProjection } const { repos, endpointProjects: projects, endpointProjectHostSetups: setups } = state - if (!projects || !setups) { - return getDerivedProjection(repos) - } let byProjects = resolvedProjectionByCatalog.get(repos) if (!byProjects) { byProjects = new WeakMap() @@ -78,18 +76,19 @@ const compatibilityByCanonical = new WeakMap< ProjectHostSetupProjection >() export function getProjectHostSetupCompatibilityFromState( - state: ProjectCatalogReadState + state: Pick ): ProjectHostSetupProjection { const canonical = getProjectHostSetupProjectionFromState({ - ...state, - projectHostSetupProjection: undefined + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups }) let compatibility = compatibilityByCanonical.get(canonical) if (!compatibility) { compatibility = resolveCompatibility( state.repos, - state.endpointProjects ?? [], - state.endpointProjectHostSetups ?? [] + state.endpointProjects, + state.endpointProjectHostSetups ) compatibilityByCanonical.set(canonical, compatibility) } diff --git a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts index 3f834b3c3989..1d256dcb73cd 100644 --- a/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts +++ b/src/renderer/src/store/slices/worktrees/listing/worktree-host-ownership.ts @@ -1,7 +1,4 @@ -import { - getProjectHostSetupCompatibilityFromState, - type ProjectCatalogReadState -} from '@/store/project-host-setup-selector' +import { getProjectHostSetupCompatibilityFromState } from '@/store/project-host-setup-selector' import type { AppState } from '../../../types' import type { ProjectHostSetup } from '../../../../../../shared/project-types' import type { DetectedWorktreeListResult, Worktree } from '../../../../../../shared/worktree/types' @@ -126,7 +123,7 @@ export function toVisibleWorktrees( } export function getProjectHostSetupForRepoHost( - state: ProjectCatalogReadState, + state: Parameters[0], repoId: string, hostId: ExecutionHostId ): ProjectHostSetup | undefined { From 15e319aca43cefa47c86ebc6d9b507d5771d17e2 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:26:29 -0700 Subject: [PATCH 14/29] Preserve registered project claims and partial history results --- ...space-list-sections-desktop-parity.test.ts | 92 +++++++++++++++++++ src/main/ipc/ai-vault-search.test.ts | 70 ++++++++++++-- src/main/ipc/ai-vault-search.ts | 25 +++-- ...worktree-list-groups-mobile-parity.test.ts | 45 ++++++++- .../useComposerState.integration.test.ts | 28 ++++-- src/shared/project-section-catalog-claims.ts | 4 +- 6 files changed, 239 insertions(+), 25 deletions(-) diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts index 5b7a4efc4fe0..e6f103de91c6 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -1,3 +1,11 @@ +import { buildMobileProjectSectionCatalog } from '../../../src/shared/mobile-project-sections-contract' +import { resolveProjectHostSetupProjection } from '../../../src/shared/project-catalog-canonical-projection' +import { projectHostSetupProjectionFromRepos } from '../../../src/shared/project-host-setup-projection' +import { + buildProjectGroupingIndex, + getProjectGroupingForRepo +} from '../../../src/shared/project-section-grouping' +import type { Repo } from '../../../src/shared/repo-types' import { resolveServedProjectSectionClaims } from '../../../src/shared/project-section-catalog-claims' import { NO_WORKSPACE_ROW_FILTERS } from '../../../src/shared/workspace-view/workspace-row-filters' import { describe, expect, it } from 'vitest' @@ -178,3 +186,87 @@ describe('readServedProjectSections', () => { expect(readServedProjectSections(null)).toEqual({ kind: 'kept' }) }) }) + +it('keeps published desktop affiliation through phone parsing when a setup has no registered owner', () => { + const repo: Repo = { + id: 'a', + path: '/a', + displayName: 'A', + badgeColor: '', + addedAt: 1, + gitRemoteIdentity: { + canonicalKey: 'github.com/acme/app', + remoteName: 'origin', + remoteUrl: 'https://github.com/acme/app.git' + } + } + const derived = projectHostSetupProjectionFromRepos([repo]) + const firstSetup = derived.setups[0] + if (!firstSetup) { + throw new Error('no derived setup') + } + const orphan = { + ...firstSetup, + id: 'z', + repoId: 'z', + path: '/z', + displayName: 'Z', + createdAt: 0, + updatedAt: 0 + } + const projection = resolveProjectHostSetupProjection([repo], derived.projects, [ + ...derived.setups, + orphan + ]) + const published = buildMobileProjectSectionCatalog([repo], projection, 'manual') + const repoMap = new Map([[repo.id, repo]]) + const desktop = getProjectGroupingForRepo( + 'a', + repoMap, + buildProjectGroupingIndex({ + projects: published.projects, + projectHostSetups: published.setups + }) + ) + expect(desktop.key).toBe(`project:${derived.projects[0].id}`) + for (const extraRepos of [[], [{ id: 'z', displayName: 'Z', path: 42, addedAt: 0 }]]) { + const verdict = projectSectionsRead.interpret({ + id: 'sections', + ok: true, + result: { catalog: { ...published, repos: [...published.repos, ...extraRepos] } } + }) + if (!verdict.accepted || !verdict.value) { + throw new Error('catalog refused') + } + expect(verdict.value.repos.map((row) => row.id)).toEqual(['a']) + expect(verdict.value.setups.map((row) => row.repoId)).toEqual(['a', 'z']) + const phone = buildProjectSectionCatalog(verdict.value, [], null, 'manual') + expect( + getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key + ).toBe(desktop.key) + expect( + phone.grouping?.projectHostSetups?.map(({ repoId, hostId, path }) => ({ + repoId, + hostId, + path + })) + ).toEqual(verdict.value.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path }))) + } + const old = projectSectionsRead.interpret({ + id: 'old', + ok: true, + result: { + catalog: { + ...published, + repos: [...published.repos, { id: 'z', displayName: 'Z', path: '/z' }] + } + } + }) + if (!old.accepted || !old.value) { + throw new Error('old catalog refused') + } + const phone = buildProjectSectionCatalog(old.value, [], null, 'manual') + expect( + getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key + ).toBe('project:repo:a') +}) diff --git a/src/main/ipc/ai-vault-search.test.ts b/src/main/ipc/ai-vault-search.test.ts index 57bb436700fe..ad504b3bcd3b 100644 --- a/src/main/ipc/ai-vault-search.test.ts +++ b/src/main/ipc/ai-vault-search.test.ts @@ -1,12 +1,17 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({ - clearSearch: vi.fn(), - handlers: new Map Promise>(), - sshSearch: vi.fn(), - sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []), - runtimeSearch: vi.fn() -})) +const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch, runtimeHostInfos } = + vi.hoisted(() => ({ + clearSearch: vi.fn(), + handlers: new Map Promise>(), + sshSearch: vi.fn(), + sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []), + runtimeSearch: vi.fn(), + runtimeHostInfos: vi.fn<() => readonly RuntimeAiVaultHostInfo[]>(() => []) + })) vi.mock('../ai-vault/session-scanner-service-spawn', () => ({ clearSessionSearchInService: clearSearch })) @@ -17,11 +22,23 @@ vi.mock('electron', () => ({ }, ipcRenderer: { invoke: (name: string, ...args: unknown[]) => handlers.get(name)!(null, ...args) } })) +vi.mock('./ai-vault', () => ({ + getActiveRuntimeAiVaultHostInfosResult: () => ({ hostInfos: runtimeHostInfos() }) +})) vi.mock('./ssh/ssh', () => ({ requestActiveSshSessionSearch: sshSearch, getActiveSshAiVaultHostInfos: sshHostInfos })) +import type { RuntimeAiVaultHostInfo } from './ai-vault-runtime-scan' +import { getSavedRuntimeAiVaultHostInfos } from '../ai-vault/runtime-session-scanner' +import { bindRuntimeSessionSearch } from '../ai-vault/runtime-session-search-call' +import { + addEnvironmentFromPairingCode, + removeEnvironment +} from '../../shared/runtime-environment-store' +import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto' +import { encodePairingOffer } from '../../shared/pairing' import { registerAiVaultSearchHandlers } from './ai-vault-search' import { aiVaultApi } from '../../preload/api/ai-vault-bridge' import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry' @@ -41,6 +58,7 @@ beforeEach(() => { sshHostInfos.mockReset() sshHostInfos.mockReturnValue([]) runtimeSearch.mockReset() + runtimeHostInfos.mockReset().mockReturnValue([]) clearSearch.mockReset() registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch @@ -302,3 +320,41 @@ it('binds the desktop leg once before awaited agent status and reuses the produc ) expect(runtimeSearch).not.toHaveBeenCalled() }) + +it('keeps healthy All results when a persisted runtime is removed between enumeration and eager binding', async () => { + const root = await mkdtemp(join(tmpdir(), 'sta10541-all-bind-')) + try { + const environment = addEnvironmentFromPairingCode(root, { + name: 'search-host', + pairingCode: encodePairingOffer({ + v: 2, + endpoint: 'ws://127.0.0.1:9', + deviceToken: 'a'.repeat(48), + publicKeyB64: publicKeyToBase64(generateKeyPair().publicKey) + }) + }) + runtimeHostInfos.mockImplementationOnce(() => { + const advertised = getSavedRuntimeAiVaultHostInfos(root) + removeEnvironment(root, environment.id) + return advertised + }) + const local = fakeSearchService() + setSessionSearchService(local) + const bindRuntimeSearch = vi.fn((id: string) => bindRuntimeSessionSearch(root, id)) + registerAiVaultSearchHandlers({ callRuntimeSearch: runtimeSearch, bindRuntimeSearch }) + const result = await aiVaultApi.searchSessions({ query: 'needle' }, 'all') + expect(bindRuntimeSearch).toHaveBeenCalledExactlyOnceWith(environment.id) + expect(local.search).toHaveBeenCalledOnce() + expect(result).toMatchObject({ kind: 'results', hits: [{ executionHostId: 'local' }] }) + if (result.kind !== 'results') { + throw new Error('healthy results lost') + } + expect(result.hosts).toContainEqual({ + executionHostId: `runtime:${environment.id}`, + outcome: 'unreachable' + }) + expect(runtimeSearch).not.toHaveBeenCalled() + } finally { + await rm(root, { recursive: true, force: true }) + } +}) diff --git a/src/main/ipc/ai-vault-search.ts b/src/main/ipc/ai-vault-search.ts index 496e04946eb9..6bfd23a00e5f 100644 --- a/src/main/ipc/ai-vault-search.ts +++ b/src/main/ipc/ai-vault-search.ts @@ -181,14 +181,23 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] { } function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg { - const client = remoteSearchClient(host, handlerOptions.callRuntimeSearch) - return { - executionHostId: host.id, - timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.search, - search: (request) => - client - ? client.searchSessions(request) - : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }) + try { + const client = remoteSearchClient(host, handlerOptions.callRuntimeSearch) + return { + executionHostId: host.id, + timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.search, + search: (request) => + client + ? client.searchSessions(request) + : Promise.resolve({ kind: 'unavailable', reason: 'no-service' }) + } + } catch (error) { + return { + executionHostId: host.id, + search: async () => { + throw error + } + } } } diff --git a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts index 986455ac4896..6646b176c014 100644 --- a/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list-groups-mobile-parity.test.ts @@ -1,10 +1,14 @@ +import { ProjectSectionCatalogSchema } from '../../../../shared/project-section-catalog-schema' +import { resolveServedProjectSectionClaims } from '../../../../shared/project-section-catalog-claims' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { afterEach, describe, expect, it, vi } from 'vitest' import { applyManualRepoOrder } from '../../../../shared/manual-repo-order' import { mergeFetchedHostProjectCatalog } from '../../../../shared/project-compatibility-host-merge' import type { ProjectCatalogRows } from '../../../../shared/project-compatibility-merge' import { buildProjectGroupingIndex, - buildProjectSections + buildProjectSections, + getProjectGroupingForRepo } from '../../../../shared/project-section-grouping' import { getLogicalRepoOrderRankById, @@ -202,3 +206,42 @@ describe('the phone groups by Project exactly as the paired desktop sidebar', () } ) }) + +it('publishes unresolved setups without letting their absent owners demote registered phone checkouts', () => { + const repo: Repo = { + id: 'a', + path: '/a', + displayName: 'A', + badgeColor: '', + addedAt: 1, + gitRemoteIdentity: { + canonicalKey: 'github.com/acme/app', + remoteName: 'origin', + remoteUrl: 'https://github.com/acme/app.git' + } + } + const derived = projectHostSetupProjectionFromRepos([repo]) + const setup = derived.setups[0] + if (!setup) { + throw new Error('no derived setup') + } + const published = getProjectSectionCatalogFromState({ + repos: [repo], + endpointProjects: derived.projects, + endpointProjectHostSetups: [...derived.setups, { ...setup, id: 'z', repoId: 'z', path: '/z' }], + projectOrderBy: 'manual' + }) + const parsed = ProjectSectionCatalogSchema.parse(JSON.parse(JSON.stringify(published))) + expect(parsed.setups.map((row) => row.repoId)).toEqual(['a', 'z']) + const phone = resolveServedProjectSectionClaims(parsed) + expect( + getProjectGroupingForRepo( + 'a', + new Map([[repo.id, repo]]), + buildProjectGroupingIndex({ + projects: phone.projects, + projectHostSetups: phone.setups + }) + ).key + ).toBe(`project:${derived.projects[0].id}`) +}) diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index 30453462b42b..bb6d4265488a 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -230,7 +230,11 @@ describe('useComposerState integrated lifecycle', () => { value: { ...window.api, gh: { repoSlug: vi.fn().mockResolvedValue(null) }, - worktrees: { ...window.api.worktrees, create } + worktrees: { + ...window.api.worktrees, + create, + updateMeta: vi.fn().mockResolvedValue(undefined) + } } }) const composer = renderHook(() => @@ -242,8 +246,9 @@ describe('useComposerState integrated lifecycle', () => { provider: 'linear', type: 'issue', number: 0, - title: 'Typed task', - url: '' + title: 'Linked task', + url: 'https://linear.app/acme/issue/ENG-123', + linearIdentifier: 'ENG-123' } }) ) @@ -282,11 +287,18 @@ describe('useComposerState integrated lifecycle', () => { hostId: 'local', path: 'C:/app-b' }) - const quoted = windows ? `'fix it''s "quoted" $HOME'` : `'fix it'"'"'s "quoted" $HOME'` - expect(pending.request.startup?.command).toBe( - agent === 'claude' ? `claude ${quoted}` : `codex ${quoted}` - ) - expect(pending.request.startupPlan?.launchCommand).toBe(pending.request.startup?.command) + const draft = `fix it's "quoted" $HOME\n\nLinked Linear issue: ENG-123\nhttps://linear.app/acme/issue/ENG-123\n` + if (agent === 'claude') { + const quoted = windows + ? '"fix it\'s `"quoted`" `$HOME`n`nLinked Linear issue: ENG-123`nhttps://linear.app/acme/issue/ENG-123"' + : `'${draft.trim().replaceAll("'", "'\"'\"'")}'` + expect(pending.request.startup?.command).toBe(`claude --prefill ${quoted}`) + expect(pending.request.startupPlan?.draftPrompt).toBeUndefined() + } else { + expect(pending.request.startup).toBeUndefined() + expect(pending.request.startupPlan?.launchCommand).toBe('codex') + expect(pending.request.startupPlan?.draftPrompt).toBe(draft) + } act(() => useAppStore.getState().removePendingWorktreeCreation(pending.creationId)) await act(async () => responses[windows ? 1 : 0].resolve({ diff --git a/src/shared/project-section-catalog-claims.ts b/src/shared/project-section-catalog-claims.ts index 789a95050bd1..3c604c01246d 100644 --- a/src/shared/project-section-catalog-claims.ts +++ b/src/shared/project-section-catalog-claims.ts @@ -15,7 +15,9 @@ export function resolveServedProjectSectionClaims( ) const claims = catalog.setups.map((setup) => ({ ...setup, - createdAt: addedAtByOwner.get(`${getRepoSetupOwnerScope(setup)}\0${setup.repoId}`) ?? 0 + createdAt: + addedAtByOwner.get(`${getRepoSetupOwnerScope(setup)}\0${setup.repoId}`) ?? + Number.POSITIVE_INFINITY })) const outclaimed = getOutclaimedProjectSetups(claims) if (!outclaimed.size) { From d48531bcd0593c33826cd2c9476a198f202dae2b Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 17:41:08 -0700 Subject: [PATCH 15/29] test: check orphaned setup cleanup state without assertions --- .../src/store/slices/worktrees-removal-state-cleanup.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts index eceed11ef763..9e57617263b2 100644 --- a/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts +++ b/src/renderer/src/store/slices/worktrees-removal-state-cleanup.test.ts @@ -206,7 +206,7 @@ describe('removeWorktree state cleanup', () => { worktreesByRepo: { repo1: [wt] }, endpointProjectHostSetups: [orphanedSetup, userSshSetup], deleteProjectHostSetup - } as unknown as Partial) + }) mockApi.ephemeralVm.listRuntimes.mockResolvedValueOnce([ { id: 'runtime-1', From 1489c6f37c5a1ab1beb5f4e87862ae2c681fb840 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:16:33 -0700 Subject: [PATCH 16/29] fix: align catalog callers and native search fixture contracts --- .../session-search-current-owner.test.ts | 24 ++++++----- .../session-search-project-ownership.ts | 7 ++-- src/main/sqlite/sync-database.ts | 16 ++++++-- .../useDashboardPopoutBridge.test.tsx | 5 ++- .../dashboard/useDashboardPopoutBridge.ts | 4 +- .../useLiveDashboardSnapshot.test.ts | 6 ++- ...nPreview.link-routing.interaction.test.tsx | 3 ++ ...rich-markdown-editor-click-routing.test.ts | 3 ++ .../use-native-chat-skills.test.ts | 3 +- .../components/right-sidebar/AiVaultPanel.tsx | 2 +- .../ai-vault-session-worktree-map.test.tsx | 2 + ...bar-list-golden-name-order.test-fixture.ts | 4 +- .../sidebar-list-golden-store.test-fixture.ts | 4 +- .../edge-wrapped-terminal-http-links.test.ts | 3 ++ ...framed-overlay-terminal-http-links.test.ts | 3 ++ .../pty-connection-fresh-spawn-guards.spec.ts | 20 +++++++++- .../pty-connection-test-store-fixtures.ts | 3 +- .../pty-connection-test-store-state.ts | 1 + .../repro-8832-url-next-line.test.ts | 3 ++ .../terminal-link-action-routing.test.ts | 3 ++ .../terminal-link-handlers-test-fixtures.ts | 9 ++++- ...terminal-runtime-host-link-routing.test.ts | 3 ++ .../terminal-url-link-click.test.ts | 3 ++ ...rapped-terminal-http-link-provider.test.ts | 9 +++++ .../src/lib/agent-launch-route-input.test.ts | 24 +++++++++++ .../src/lib/agent-launch-route-input.ts | 25 ++++++++---- ...ai-vault-resume-command.drop-repin.test.ts | 1 + .../src/lib/ai-vault-resume-shell.test.ts | 5 ++- .../lib/ai-vault-session-fork-startup.test.ts | 26 +++++++++--- .../lib/http-link-modifier-routing.test.ts | 3 ++ .../src/lib/http-link-routing.test.ts | 4 +- .../src/lib/local-preflight-context.test.ts | 7 ++-- .../project-catalog-null-field-ingest.test.ts | 8 ++-- src/renderer/src/store/selectors.test.ts | 40 +++++++++++++------ ...-runtime-environments-purge-wiring.test.ts | 4 +- ...-active-worktree-terminal-creation.test.ts | 32 ++++++++++++++- .../e2e/ai-vault-project-native.unit.test.ts | 12 ++++-- ...ault-project-source-lifecycle.unit.test.ts | 20 +++++++--- 38 files changed, 275 insertions(+), 79 deletions(-) diff --git a/src/main/ai-vault-search/session-search-current-owner.test.ts b/src/main/ai-vault-search/session-search-current-owner.test.ts index 182ad984ba0d..367e74e52610 100644 --- a/src/main/ai-vault-search/session-search-current-owner.test.ts +++ b/src/main/ai-vault-search/session-search-current-owner.test.ts @@ -47,12 +47,17 @@ afterEach(async () => { vi.restoreAllMocks() }) -function store(repos = [a, b]) { +function store(repos = [a, b], provisioned: readonly string[] = []) { const projection = projectHostSetupCompatibilityFromRepos(repos) return { getRepos: () => repos, getProjects: () => projection.projects, - getProjectHostSetups: () => projection.setups, + getProjectHostSetups: () => + projection.setups.map((setup) => + provisioned.includes(setup.repoId) + ? { ...setup, setupMethod: 'provisioned' as const } + : setup + ), getAllWorktreeMeta: () => ({ 'b::/linked/b': { displayName: '', @@ -177,13 +182,12 @@ it('uses literal nested regions and source namespaces without changing legacy cw { ...a, id: 'drive', - projectHostSetupMethod: 'provisioned', path: '//wsl.localhost/Ubuntu/mnt/c/Case_%/app' }, - { ...a, id: 'unicode', path: '/repos/Café_%', projectHostSetupMethod: 'provisioned' }, - { ...a, id: 'native-drive', path: 'C:/Native_%', projectHostSetupMethod: 'provisioned' } + { ...a, id: 'unicode', path: '/repos/Café_%' }, + { ...a, id: 'native-drive', path: 'C:/Native_%' } ] - const source = store(repos) + const source = store(repos, ['drive', 'unicode', 'native-drive']) const catalog = { ...sessionSearchScopeCatalogFromStore(source, 'local'), worktreeMeta: {} } const project = source.getProjectHostSetups().find((row) => row.repoId === 'ubuntu')!.projectId const selection = await compileSessionSearchProjectOwnership(catalog, `project:${project}`) @@ -344,10 +348,12 @@ it('answers one complete provisioned Project above 64 roots before pressure and ...a, id: `eligible-${index}`, path: `/roots/eligible-${index}`, - addedAt: index, - ...(index ? { projectHostSetupMethod: 'provisioned' as const } : {}) + addedAt: index })) - const source = store([...eligible, { ...b, path: '/roots/independent', addedAt: 100 }]) + const source = store( + [...eligible, { ...b, path: '/roots/independent', addedAt: 100 }], + eligible.slice(1).map((repo) => repo.id) + ) harness = await openSessionSearchHarness('sta10541-whole-union', { sessionCandidateLimit: 81 }) for (let id = 1; id <= 80; id++) { addSyntheticSession(harness.db, { diff --git a/src/main/ai-vault-search/session-search-project-ownership.ts b/src/main/ai-vault-search/session-search-project-ownership.ts index c68a7eb999c0..d0b14363224b 100644 --- a/src/main/ai-vault-search/session-search-project-ownership.ts +++ b/src/main/ai-vault-search/session-search-project-ownership.ts @@ -3,7 +3,7 @@ import { normalizeRuntimePathForComparison, isRuntimePathAbsolute } from '../../shared/cross-platform-path' -import { getRepoExecutionHostId } from '../../shared/execution-host' +import { getRepoExecutionHostId, getRepoRoutingConnectionId } from '../../shared/execution-host' import { resolveProjectHostSetupProjection } from '../../shared/project-catalog-canonical-projection' import { getProjectHostSetupLookup } from '../../shared/project-host-setup-lookup' import { toAiVaultProjectKey } from '../../shared/ai-vault-project-key' @@ -65,8 +65,9 @@ export function materializeSessionSearchOwnership( } } for (const repo of catalog.repos) { - const host = repo.connectionId - ? `ssh:${encodeURIComponent(repo.connectionId)}` + const connectionId = getRepoRoutingConnectionId(repo) + const host = connectionId + ? `ssh:${encodeURIComponent(connectionId)}` : getRepoExecutionHostId(repo) if (!local(host)) { continue diff --git a/src/main/sqlite/sync-database.ts b/src/main/sqlite/sync-database.ts index 40c639190d87..2ec334786cea 100644 --- a/src/main/sqlite/sync-database.ts +++ b/src/main/sqlite/sync-database.ts @@ -1,5 +1,11 @@ import { existsSync } from 'node:fs' -import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite' +import type { + backup, + BackupOptions, + DatabaseSync, + FunctionOptions, + SQLInputValue +} from 'node:sqlite' import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api' import { NodeSqliteStatement } from './node-sqlite-statement' import type { SqliteStatement } from './sqlite-statement' @@ -92,8 +98,12 @@ class SyncDatabase { this.db.exec(sql) } - function(...args: Parameters): void { - this.db.function(...args) + function( + name: string, + options: FunctionOptions, + fn: Parameters[1] + ): void { + this.db.function(name, options, fn) } prepare(sql: string): SqliteStatement { diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx index ce491b9f19b0..85b5cfa6f332 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.test.tsx @@ -91,7 +91,8 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState { activeWorktreeId: null, activeWorkspaceExecutionHostId: null, activeRepoId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], removedRuntimeEnvironmentIds: new Set() } } @@ -286,6 +287,8 @@ describe('useDashboardPopoutBridge', () => { // A folder workspace is not a git worktree; its host resolves through these. { folderWorkspaces: [] }, { projectGroups: [] }, + { endpointProjects: [] }, + { endpointProjectHostSetups: [] }, { restoredRuntimeHostIdByWorkspaceSessionKey: {} }, { runtimeEnvironments: [] }, { runtimeEnvironmentCatalogHydrated: true }, diff --git a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts index 14ce45b462b3..6e4b618ca3d5 100644 --- a/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts +++ b/src/renderer/src/components/dashboard/useDashboardPopoutBridge.ts @@ -3,7 +3,7 @@ import { useEffect } from 'react' import { useAppStore, type AppState } from '@/store' import { subscribeChatProgramFacts } from '@/lib/host-chat-program' import { - type AgentLaunchRouteView, + type AgentLaunchRouteWatchState, agentLaunchRouteInputsChanged, selectAgentLaunchRouteView } from '@/lib/agent-launch-route-input' @@ -47,7 +47,7 @@ export function repoIconsUnchanged( type DashboardSnapshotWatchState = DashboardSnapshotState & Pick & - AgentLaunchRouteView + AgentLaunchRouteWatchState export function dashboardSnapshotInputsChanged( state: DashboardSnapshotWatchState, diff --git a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts index eee624e52d2e..f60237a245ac 100644 --- a/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts +++ b/src/renderer/src/components/dashboard/useLiveDashboardSnapshot.test.ts @@ -232,7 +232,11 @@ it('publishes real chat options to main and popout, retaining card candidates an expect(view.result.current.launchableAgentsByWorktreeId?.['wt-2']).toEqual(['grok']) const lastPublished = () => publishSnapshot.mock.calls.at(-1)?.[0].launchableAgentsByWorktreeId await vi.waitFor(() => expect(lastPublished()?.['wt-1']).toEqual(['claude', 'grok'])) - for (const route of [{ projects: [] }, { activeWorkspaceExecutionHostId: 'local' as const }]) { + for (const route of [ + { endpointProjects: [] }, + { endpointProjectHostSetups: [] }, + { activeWorkspaceExecutionHostId: 'local' as const } + ]) { const [live, published] = [view.result.current, publishSnapshot.mock.calls.length] act(() => useAppStore.setState(route)) expect(view.result.current).not.toBe(live) diff --git a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx index 468c2fdcddcd..0a1a737eca67 100644 --- a/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx +++ b/src/renderer/src/components/editor/MarkdownPreview.link-routing.interaction.test.tsx @@ -121,6 +121,9 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => { } // openHttpLink reads the store through this injected accessor, not @/store. registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null }, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock diff --git a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts index 3eeef905789b..02d2cf4fa74f 100644 --- a/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts +++ b/src/renderer/src/components/editor/rich-markdown-editor-click-routing.test.ts @@ -54,6 +54,9 @@ beforeEach(() => { } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: routing.settings, setActiveWorktree: vi.fn(), createBrowserTab diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts index 91cc49b4add3..a5633f579bea 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.test.ts @@ -215,7 +215,8 @@ describe('floating workspace skill discovery', () => { floatingWorkspacePath: '/home/me/scratch', folderWorkspaces: [], projectGroups: [], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], restoredRuntimeHostIdByWorkspaceSessionKey: {}, // Why a focused runtime: floating must stay local even when one is selected. diff --git a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx index ec5e37ad7cb9..582ac33bcf59 100644 --- a/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx +++ b/src/renderer/src/components/right-sidebar/AiVaultPanel.tsx @@ -186,7 +186,7 @@ export default function AiVaultPanel({ repos, worktrees: allWorktrees, projectHostSetupProjection, - settings, + settings: settings ?? undefined, sessions }), [allWorktrees, projectHostSetupProjection, repos, sessions, settings] diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx index a9fc861ecd89..102568bd8207 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx @@ -313,6 +313,8 @@ describe('folder workspace resume targets', () => { } const folderKey = folderWorkspaceKey(folder.id) const targetState = { + endpointProjects: [], + endpointProjectHostSetups: [], folderWorkspaces: [folder], projectGroups: [], repos, diff --git a/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts index 9c8ba113cad2..33c8d5465e47 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-golden-name-order.test-fixture.ts @@ -44,8 +44,8 @@ export function goldenNameOrderState(): Partial { [sshRepo.id]: sshWorktrees }, worktreeLineageById: {}, - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], // Equal tabOrder everywhere, so group names decide the order. projectGroups: [ folderGroup('pg-zebra', 'zebra'), diff --git a/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts b/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts index 2abf1fb539e1..2e23f406f6bf 100644 --- a/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts +++ b/src/renderer/src/components/sidebar/sidebar-list-golden-store.test-fixture.ts @@ -153,8 +153,8 @@ export function buildGoldenStoreState( // Cross-project lineage: a lab workspace spawned from orca's feat-a. [lMr.id]: lineage(lMr, oA) }, - projects, - projectHostSetups: [setup(orca, 'local'), setup(orcaSsh, 'ssh:gpu-vm')], + endpointProjects: projects, + endpointProjectHostSetups: [setup(orca, 'local'), setup(orcaSsh, 'ssh:gpu-vm')], projectGroups: [ group('pg-work', 'Work', { tabOrder: 0 }), group('pg-nested', 'Nested', { parentGroupId: 'pg-work', tabOrder: 0 }), diff --git a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts index f8193e89cb50..3b574af15aff 100644 --- a/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/edge-wrapped-terminal-http-links.test.ts @@ -64,6 +64,9 @@ describe('edge-wrapped terminal HTTP links', () => { beforeEach(() => { vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts b/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts index 72bfdcd5892a..f7bf1b042cc7 100644 --- a/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts +++ b/src/renderer/src/components/terminal-pane/framed-overlay-terminal-http-links.test.ts @@ -41,6 +41,9 @@ describe('framed overlay terminal HTTP links', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts index 2c9f41fb2e22..d5357b83b2d8 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts @@ -1,5 +1,6 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { flushAsyncTicks } from './pty-connection-test-async' import { sendTerminalInputThroughPane } from './pty-connection-test-dom' @@ -253,7 +254,22 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - projects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] + repos: [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }], + endpointProjectHostSetups: [ + { + id: 'repo1', + projectId: 'repo1', + repoId: 'repo1', + hostId: 'local', + path: '/tmp/wt-1', + displayName: 'orca', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [{ id: 'repo1', localWindowsRuntimePreference: { kind: 'windows-host' } }] } connectPanePty(createPane(1) as never, createManager(1) as never, createDeps() as never) @@ -307,7 +323,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { { id: 'tab-floating-agent', ptyId: null, launchAgent: 'codex' } ] }, - projects: [ + endpointProjects: [ { id: 'repo1', localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } ], settings: { diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts index f564044b2ebb..9e10d10d3c18 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-fixtures.ts @@ -29,7 +29,8 @@ export function createInitialStoreState(getState: () => StoreState): StoreState }, runtimeStatusByEnvironmentId: new Map(), repos: [{ id: 'repo1', connectionId: null, displayName: 'orca' }], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], sshConnectionStates: new Map(), transientClearedAgentStatusConnectionIds: {}, cacheTimerByKey: {}, diff --git a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts index 0e1d854d8232..828dde263dea 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-test-store-state.ts @@ -55,6 +55,7 @@ export type StoreState = { displayName?: string executionHostId?: string | null }[] + endpointProjectHostSetups: AppState['endpointProjectHostSetups'] endpointProjects: { id: string localWindowsRuntimePreference?: diff --git a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts index 067fc46f94c9..67994e2268ea 100644 --- a/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts +++ b/src/renderer/src/components/terminal-pane/repro-8832-url-next-line.test.ts @@ -90,6 +90,9 @@ describe('#8832 hard-wrapped path candidates glue next-line text into URLs', () vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts index ef4aec667ae7..21a47caf0cbb 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-action-routing.test.ts @@ -50,6 +50,9 @@ beforeEach(() => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree, createBrowserTab diff --git a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts index 47a1bc8d6f9d..e87ce84e727d 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts +++ b/src/renderer/src/components/terminal-pane/terminal-link-handlers-test-fixtures.ts @@ -1,5 +1,6 @@ import { vi, type Mock } from 'vitest' import type { ExecutionHostId } from '../../../../shared/execution-host' +import type { AppState } from '@/store/types' import type { Tab, TabGroup } from '../../../../shared/tab-types' export type TerminalLinkStoreSettings = { @@ -8,7 +9,10 @@ export type TerminalLinkStoreSettings = { activeRuntimeEnvironmentId?: string | null } -export type TerminalLinkStoreState = { +export type TerminalLinkStoreState = Pick< + AppState, + 'repos' | 'endpointProjects' | 'endpointProjectHostSetups' +> & { settings: TerminalLinkStoreSettings | undefined setActiveWorktree: Mock createBrowserTab: Mock @@ -97,6 +101,9 @@ export function createTerminalLinkTestDoubles(): TerminalLinkTestDoubles { } ) const storeState: TerminalLinkStoreState = { + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: undefined as TerminalLinkStoreSettings | undefined, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock, diff --git a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts index 6ceec37e0864..1463bd0034b7 100644 --- a/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-runtime-host-link-routing.test.ts @@ -104,6 +104,9 @@ beforeEach(() => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null }, setActiveWorktree: setActiveWorktreeMock, createBrowserTab: createBrowserTabMock diff --git a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts index c1c7658691f1..c418876348b8 100644 --- a/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-url-link-click.test.ts @@ -169,6 +169,9 @@ describe('hard-wrapped terminal HTTP clicks', () => { vi.stubGlobal('navigator', { userAgent: 'Macintosh' }) vi.stubGlobal('window', { api: { shell: { openUrl: openUrlMock } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts b/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts index ebde32ea15d3..68c9a04a3c6f 100644 --- a/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts +++ b/src/renderer/src/components/terminal-pane/wrapped-terminal-http-link-provider.test.ts @@ -23,6 +23,9 @@ describe('wrapped terminal HTTP link provider', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() @@ -131,6 +134,9 @@ describe('wrapped terminal HTTP link provider', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() @@ -199,6 +205,9 @@ describe('wrapped terminal HTTP link provider', () => { const openUrl = vi.fn() vi.stubGlobal('window', { api: { shell: { openUrl } } }) registerHttpLinkStoreAccessor(() => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: { openLinksInApp: false }, setActiveWorktree: vi.fn(), createBrowserTab: vi.fn() diff --git a/src/renderer/src/lib/agent-launch-route-input.test.ts b/src/renderer/src/lib/agent-launch-route-input.test.ts index 9c7e23eea7d3..a767b37043b2 100644 --- a/src/renderer/src/lib/agent-launch-route-input.test.ts +++ b/src/renderer/src/lib/agent-launch-route-input.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import { createTestStore } from '@/store/slices/store-test-helpers' +import { getProjectHostSetupProjectionFromState } from '@/store/project-host-setup-selector' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { STRUCTURED_AGENT_SESSION_CLIENT_LAUNCH_MODE_CAPABILITY, @@ -43,6 +45,7 @@ vi.mock('@/lib/structured-agent-launch-settlement', () => ({ import { folderWorkspaceKey } from '../../../shared/workspace-scope' import { buildAgentLaunchRouteInput, + selectAgentLaunchRouteView, workspaceKindForWorktreeId, type AgentLaunchRouteArgs, type AgentLaunchRouteStore @@ -504,3 +507,24 @@ describe('a cwd that names the workspace root', () => { expect(at('/srv/notes/drafts')).toBe(true) }) }) + +it('captures both actual endpoint catalogs and invalidates the route view for either write', () => { + const store = createTestStore() + const first = selectAgentLaunchRouteView(store.getState()) + expect(first.projectHostSetupProjection).toBe( + getProjectHostSetupProjectionFromState(store.getState()) + ) + expect(selectAgentLaunchRouteView(store.getState())).toBe(first) + store.setState({ endpointProjects: [...store.getState().endpointProjects] }) + const projectsChanged = selectAgentLaunchRouteView(store.getState()) + expect(projectsChanged).not.toBe(first) + expect(projectsChanged.projectHostSetupProjection).toBe( + getProjectHostSetupProjectionFromState(store.getState()) + ) + store.setState({ endpointProjectHostSetups: [...store.getState().endpointProjectHostSetups] }) + const setupsChanged = selectAgentLaunchRouteView(store.getState()) + expect(setupsChanged).not.toBe(projectsChanged) + expect(setupsChanged.projectHostSetupProjection).toBe( + getProjectHostSetupProjectionFromState(store.getState()) + ) +}) diff --git a/src/renderer/src/lib/agent-launch-route-input.ts b/src/renderer/src/lib/agent-launch-route-input.ts index c0c94359ab7e..d4a503b8444c 100644 --- a/src/renderer/src/lib/agent-launch-route-input.ts +++ b/src/renderer/src/lib/agent-launch-route-input.ts @@ -4,6 +4,10 @@ import { toRuntimeExecutionHostId } from '../../../shared/execution-host' import type { AppState } from '@/store/types' +import { + getProjectHostSetupProjectionFromState, + type ProjectCatalogReadState +} from '@/store/project-host-setup-selector' import { findWorktreeById } from '@/store/slices/worktree-helpers' import { requestsCwdOutsideWorkspaceRootForWorkspace } from '../../../shared/terminal-startup-cwd' import type { TuiAgent } from '../../../shared/tui-agent' @@ -76,21 +80,26 @@ const AGENT_LAUNCH_ROUTE_KEYS = [ 'runtimeEnvironments', 'runtimeEnvironmentCatalogHydrated', 'removedRuntimeEnvironmentIds', - 'activeRepoId', - 'projects' -] as const satisfies readonly (keyof AgentLaunchRouteStore)[] + 'activeRepoId' +] as const satisfies readonly (keyof AppState)[] type AgentLaunchRouteKey = (typeof AGENT_LAUNCH_ROUTE_KEYS)[number] // Completeness, not just membership: a reader input missing from the list fails to compile. type AssertNoMissingRouteKey = T export type AgentLaunchRouteKeysAreComplete = AssertNoMissingRouteKey< Exclude > -export type AgentLaunchRouteView = { [K in AgentLaunchRouteKey]: AppState[K] } +export type AgentLaunchRouteWatchState = Pick & + ProjectCatalogReadState +export type AgentLaunchRouteView = Pick & { + projectHostSetupProjection: ReturnType +} export const agentLaunchRouteInputsChanged = ( - next: AgentLaunchRouteView, - previous: AgentLaunchRouteView -): boolean => AGENT_LAUNCH_ROUTE_KEYS.some((key) => next[key] !== previous[key]) + next: AgentLaunchRouteWatchState, + previous: AgentLaunchRouteWatchState +): boolean => + AGENT_LAUNCH_ROUTE_KEYS.some((key) => next[key] !== previous[key]) || + getProjectHostSetupProjectionFromState(next) !== getProjectHostSetupProjectionFromState(previous) let lastRouteView: AgentLaunchRouteView | undefined /** Same object until a route slice changes, so an unrelated write allocates nothing. */ @@ -111,7 +120,7 @@ export function selectAgentLaunchRouteView(state: AppState): AgentLaunchRouteVie runtimeEnvironmentCatalogHydrated: state.runtimeEnvironmentCatalogHydrated, removedRuntimeEnvironmentIds: state.removedRuntimeEnvironmentIds, activeRepoId: state.activeRepoId, - projects: state.projects + projectHostSetupProjection: getProjectHostSetupProjectionFromState(state) } } return lastRouteView diff --git a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts index 28347f218b1c..58ffcec99eb4 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.drop-repin.test.ts @@ -19,6 +19,7 @@ function makeState(): DropRepinState { folderWorkspaces: [], projectGroups: [], repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjectHostSetups: [], endpointProjects: [ { id: 'repo-1', diff --git a/src/renderer/src/lib/ai-vault-resume-shell.test.ts b/src/renderer/src/lib/ai-vault-resume-shell.test.ts index 589619f410e4..5112490c65c8 100644 --- a/src/renderer/src/lib/ai-vault-resume-shell.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-shell.test.ts @@ -10,7 +10,9 @@ import { buildAiVaultResumeCopyCommandForWorktree } from './ai-vault-resume-comm type ResumeShellState = Parameters[0]['state'] -function makeState(worktreeHostId?: string): ResumeShellState { +function makeState( + worktreeHostId?: Parameters[0]['hostId'] +): ResumeShellState { return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', @@ -30,6 +32,7 @@ function makeState(worktreeHostId?: string): ResumeShellState { ], settings: { ...getDefaultSettings('/tmp'), + agentPermissionOverrides: { codex: 'own' }, agentDefaultArgs: { codex: '' }, agentDefaultEnv: { codex: {} } }, diff --git a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts index 02e994746278..e288e4fbd60d 100644 --- a/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts +++ b/src/renderer/src/lib/ai-vault-session-fork-startup.test.ts @@ -1,3 +1,5 @@ +import { getDefaultSettings } from '../../../shared/constants' +import { TEST_REPO, makeWorktree } from '../store/slices/worktrees-slice-test-fixtures' import { describe, expect, it, vi } from 'vitest' import type { AppState } from '@/store/types' import { buildAiVaultForkStartupForWorktree } from './ai-vault-session-fork-startup' @@ -13,28 +15,42 @@ type ForkState = Pick< | 'folderWorkspaces' | 'projectGroups' | 'endpointProjects' + | 'endpointProjectHostSetups' | 'repos' | 'settings' | 'worktreesByRepo' > function makeState(): ForkState { - // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the fork builder reads only these slices; the rest of AppState is never reached. return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', folderWorkspaces: [], projectGroups: [], - repos: [{ id: 'repo-1', path: '/Users/ada/repo' }], - projects: [{ id: 'repo-1', sourceRepoIds: ['repo-1'] }], + repos: [{ ...TEST_REPO, id: 'repo-1', path: '/Users/ada/repo' }], + endpointProjects: [ + { + id: 'repo-1', + displayName: 'repo', + badgeColor: '', + sourceRepoIds: ['repo-1'], + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjectHostSetups: [], settings: { + ...getDefaultSettings('/tmp'), + agentPermissionDefault: 'own', agentDefaultArgs: { claude: '', codex: '' }, agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { - 'repo-1': [{ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }] + 'repo-1': [ + makeWorktree({ id: 'repo-1::worktree-1', repoId: 'repo-1', path: '/Users/ada/repo' }) + ] } - } as unknown as ForkState + } } const SESSION_ID = '019fd532-7c11-7a90-b6de-4e1a2c3d5f60' diff --git a/src/renderer/src/lib/http-link-modifier-routing.test.ts b/src/renderer/src/lib/http-link-modifier-routing.test.ts index fbfb50b99e4b..78114f8ca1f0 100644 --- a/src/renderer/src/lib/http-link-modifier-routing.test.ts +++ b/src/renderer/src/lib/http-link-modifier-routing.test.ts @@ -11,6 +11,9 @@ describe('modifier routing across link source owners', () => { const createBrowserTabMock = vi.fn() const openRuntimeBrowserTabMock = vi.fn((): Promise => Promise.resolve(null)) const storeState = { + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], settings: {} as { openLinksInApp?: boolean openLinksInAppModifierInverts?: boolean diff --git a/src/renderer/src/lib/http-link-routing.test.ts b/src/renderer/src/lib/http-link-routing.test.ts index dfac0da29607..6a0d3ad1eb3b 100644 --- a/src/renderer/src/lib/http-link-routing.test.ts +++ b/src/renderer/src/lib/http-link-routing.test.ts @@ -372,8 +372,8 @@ describe('openHttpLink', () => { localhostWorktreeLabelsEnabled: true } storeState.repos = [ - { id: 'repo-local', displayName: 'Local' }, - { id: 'repo-remote', displayName: 'Remote' } + { id: 'repo-local', displayName: 'Local', path: '/local', badgeColor: '', addedAt: 0 }, + { id: 'repo-remote', displayName: 'Remote', path: '/remote', badgeColor: '', addedAt: 0 } ] storeState.worktreesByRepo = { 'repo-local': [{ id: 'wt-local', projectId: 'repo-local' }], diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 52a7c22a2aa1..cbc366da7e4a 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -17,12 +17,12 @@ import { function legacySettings() { const settings = getDefaultSettings('/tmp') - delete settings.localWindowsRuntimeDefault + Reflect.deleteProperty(settings, 'localWindowsRuntimeDefault') return settings } type PreflightState = Parameters[0] & - Pick + Pick function project(overrides: Pick & Partial): Project { return { displayName: '', @@ -152,7 +152,7 @@ describe('local preflight context', () => { }) it('keys preflight by the focused worktree host over the default host', () => { - const localFocused: AppState = { + const localFocused: PreflightState = { ...makeState({ repoPath: '/Users/alice/repo', worktreePath: '/Users/alice/repo', @@ -586,7 +586,6 @@ describe('local preflight context', () => { ...makeState({ repoPath: 'C:\\Users\\alice\\repo' }), detectedWorktreesByRepo: { 'repo-1': { - repoId: 'repo-1', worktrees: [ makeWorktree({ id: 'repo-1::detected', repoId: 'repo-1', path: 'C:\\detected' }) ] diff --git a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts index c808ff5f4ab1..f14a585d967d 100644 --- a/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts +++ b/src/renderer/src/store/projects/project-catalog-null-field-ingest.test.ts @@ -103,8 +103,8 @@ describe('project catalog ingest with non-string row fields', () => { const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) const projection = getProjectHostSetupProjectionFromState({ repos, - projects: [...ingested.projects], - projectHostSetups: [...ingested.setups] + endpointProjects: [...ingested.projects], + endpointProjectHostSetups: [...ingested.setups] }) expect(() => buildProjectByRepoIdLikeSettings(projection.setups, projection.projects) @@ -146,8 +146,8 @@ describe('project catalog ingest with non-string row fields', () => { const ingested = await fetchProjectHostSetupCompatibility({ kind: 'local' }, repos) const args = { repos, - projects: [...ingested.projects], - projectHostSetups: [...ingested.setups] + endpointProjects: [...ingested.projects], + endpointProjectHostSetups: [...ingested.setups] } expect(getProjectHostSetupProjectionFromState(args)).toBe( getProjectHostSetupProjectionFromState(args) diff --git a/src/renderer/src/store/selectors.test.ts b/src/renderer/src/store/selectors.test.ts index f9ab9e48b5cf..f40c1bb182df 100644 --- a/src/renderer/src/store/selectors.test.ts +++ b/src/renderer/src/store/selectors.test.ts @@ -264,7 +264,9 @@ describe('store selectors', () => { displayName: 'orca' }) ] - const state = { repos } + const endpointProjects: AppState['endpointProjects'] = [] + const endpointProjectHostSetups: AppState['endpointProjectHostSetups'] = [] + const state = { repos, endpointProjects, endpointProjectHostSetups } const projection = getProjectHostSetupProjectionFromState(state) @@ -274,8 +276,10 @@ describe('store selectors', () => { projectId: 'repo:repo-1', hostId: 'local' }) - expect(getProjectHostSetupProjectionFromState({ repos })).toBe(projection) - expect(getProjectHostSetupProjectionFromState({ repos: [...repos] })).not.toBe(projection) + expect(getProjectHostSetupProjectionFromState({ ...state, repos })).toBe(projection) + expect(getProjectHostSetupProjectionFromState({ ...state, repos: [...repos] })).not.toBe( + projection + ) }) it('does not fall back to a same-ID local repo when the active runtime row is absent', () => { @@ -488,7 +492,13 @@ describe('store selectors', () => { } ] - expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toEqual({ + expect( + getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups + }) + ).toEqual({ projects, setups: projectHostSetups }) @@ -557,8 +567,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups }) expect(projection.projects.map((project) => project.id)).toEqual(['github:stablyai/orca']) @@ -582,8 +592,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - projects: [], - projectHostSetups: [] + endpointProjects: [], + endpointProjectHostSetups: [] }) expect(projection.projects).toEqual([ @@ -638,8 +648,8 @@ describe('store selectors', () => { const projection = getProjectHostSetupProjectionFromState({ repos, - projects, - projectHostSetups + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups }) expect(projection.projects.map((project) => project.id)).toEqual([ @@ -647,9 +657,13 @@ describe('store selectors', () => { 'cloud-project' ]) expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm']) - expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toBe( - projection - ) + expect( + getProjectHostSetupProjectionFromState({ + repos, + endpointProjects: projects, + endpointProjectHostSetups: projectHostSetups + }) + ).toBe(projection) }) }) diff --git a/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts b/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts index 41afe5a067f7..bae0bb32dbaa 100644 --- a/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts +++ b/src/renderer/src/store/slices/set-runtime-environments-purge-wiring.test.ts @@ -45,7 +45,7 @@ function seedStale(store: ReturnType): void { executionHostId: RUNTIME_A } ], - projectHostSetups: [ + endpointProjectHostSetups: [ { id: 's-a', projectId: 'p', @@ -76,7 +76,7 @@ describe('setRuntimeEnvironments removal diff fires the stale-runtime purge', () const s = store.getState() expect(s.repos.map((r) => r.id)).toEqual(['repo1']) - expect(s.projectHostSetups).toEqual([]) + expect(s.endpointProjectHostSetups).toEqual([]) expect(s.worktreesByRepo.repoA).toEqual([]) expect(s.tabsByWorktree['repoA::/wt-a']).toBeUndefined() // The saved list itself was updated. diff --git a/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts b/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts index 7c129c36b1f0..2da01503cfc9 100644 --- a/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts +++ b/src/renderer/src/store/slices/store-active-worktree-terminal-creation.test.ts @@ -273,7 +273,21 @@ describe('setActiveWorktree', () => { terminalWindowsShell: 'wsl.exe', terminalWindowsWslDistro: 'Debian' }, - projects: [ + endpointProjectHostSetups: [ + { + id: 'setup-project-1', + projectId: 'project-1', + repoId: 'repo1', + hostId: 'local', + path: '/repo1', + displayName: 'Repo 1', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'project-1', displayName: 'Project', @@ -318,7 +332,21 @@ describe('setActiveWorktree', () => { seedStore(store, { settings: { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe' }, - projects: [ + endpointProjectHostSetups: [ + { + id: 'setup-project-1', + projectId: 'project-1', + repoId: 'repo1', + hostId: 'local', + path: '/repo1', + displayName: 'Repo 1', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 0, + updatedAt: 0 + } + ], + endpointProjects: [ { id: 'project-1', displayName: 'Project', diff --git a/tests/e2e/ai-vault-project-native.unit.test.ts b/tests/e2e/ai-vault-project-native.unit.test.ts index a48cafbb6d4e..fed9caba75a9 100644 --- a/tests/e2e/ai-vault-project-native.unit.test.ts +++ b/tests/e2e/ai-vault-project-native.unit.test.ts @@ -84,12 +84,14 @@ it('bounds actual panel-to-IPC canonical preparation and native cwd seeks at N a displayName: 'app', badgeColor: '', addedAt: index, - upstream: { owner: 'acme', repo: 'app' }, - ...(index ? { projectHostSetupMethod: 'provisioned' } : {}) + upstream: { owner: 'acme', repo: 'app' } }) } repos.push({ ...repos[0], id: 'independent', path: join(root, 'independent'), addedAt: 1000 }) const raw = projectHostSetupCompatibilityFromRepos(repos) + raw.setups = raw.setups.map((setup, index) => + index > 0 && index < size ? { ...setup, setupMethod: 'provisioned' } : setup + ) const source = { getRepos: () => repos, getProjects: () => raw.projects, @@ -230,10 +232,12 @@ it('does not revisit one SQL cwd range per WSL namespace at N and 10N', async () displayName: 'app', badgeColor: '', addedAt: index, - upstream: { owner: 'acme', repo: 'app' }, - ...(index ? { projectHostSetupMethod: 'provisioned' as const } : {}) + upstream: { owner: 'acme', repo: 'app' } })) const raw = projectHostSetupCompatibilityFromRepos(repos) + raw.setups = raw.setups.map((setup, index) => + index ? { ...setup, setupMethod: 'provisioned' } : setup + ) harness = await openSessionSearchHarness('sta10541-namespace-union') for (let id = 1; id <= 20; id++) { addSyntheticSession(harness.db, { diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts index a0caef3c5cd5..508b2801bdc6 100644 --- a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -107,14 +107,20 @@ afterEach(async () => { root = undefined }) -async function seededStore(name: string, repos: Repo[] = [a, b]) { +async function seededStore( + name: string, + repos: Repo[] = [a, b], + provisioned: readonly string[] = [] +) { root ??= await realpath(await mkdtemp(join(tmpdir(), 'sta10541-source-'))) const path = join(root, name, 'state.json') const state = getDefaultPersistedState(root) state.repos = repos const raw = projectHostSetupCompatibilityFromRepos(state.repos) state.projects = [...raw.projects] - state.projectHostSetups = [...raw.setups] + state.projectHostSetups = raw.setups.map((setup) => + provisioned.includes(setup.repoId) ? { ...setup, setupMethod: 'provisioned' } : setup + ) state.settings.workspaceDir = join(root, 'workspaces') state.settings.nestWorkspaces = false writePersistedStateJson(path, JSON.stringify(state)) @@ -461,7 +467,7 @@ it('preserves actual discovered selected-source legacy keys across supported cat const cases: Repo[][] = [ [a], [a, b], - [a, { ...b, projectHostSetupMethod: 'provisioned' }], + [a, b], [{ ...a, kind: 'folder' }], [{ ...a, upstream: undefined }], [a, { ...b, projectGroupId: 'manual-group' }], @@ -469,9 +475,13 @@ it('preserves actual discovered selected-source legacy keys across supported cat [a, { ...b, executionHostId: 'runtime:unresolved-owner' }] ] for (const [index, repos] of cases.entries()) { - const old = await seededStore(`old-ingress-${index}`, repos) - const current = await seededStore(`new-ingress-${index}`, repos) + const provisioned = index === 2 ? ['b'] : [] + const old = await seededStore(`old-ingress-${index}`, repos, provisioned) + const current = await seededStore(`new-ingress-${index}`, repos, provisioned) const raw = projectHostSetupCompatibilityFromRepos(repos) + raw.setups = raw.setups.map((setup) => + provisioned.includes(setup.repoId) ? { ...setup, setupMethod: 'provisioned' } : setup + ) const original = vi.spyOn(old, 'getProjectHostSetups').mockReturnValue([...raw.setups]) const repo = repos.at(-1)! const before = caller(old, repo, repo.path) From 93d61b40abfd6883b4c84b8e2acadd2b5ff5f2dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 19:43:13 -0700 Subject: [PATCH 17/29] test: check full repo shapes in runtime routing fixtures --- .../terminal-pane/pty-connection-fresh-spawn-guards.spec.ts | 3 ++- .../terminal-link-other-workspace-hint.sonner.test.tsx | 4 +++- src/renderer/src/lib/local-preflight-context.test.ts | 4 ++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts index d5357b83b2d8..a1b1bbf6a2d4 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts @@ -1,6 +1,7 @@ import { bindPtyConnectionTestMocks } from './pty-connection-mocks.spec' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { TEST_REPO } from '@/store/slices/worktrees-slice-test-fixtures' +import type { AppState } from '@/store/types' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' import { flushAsyncTicks } from './pty-connection-test-async' import { sendTerminalInputThroughPane } from './pty-connection-test-dom' @@ -254,7 +255,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - repos: [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }], + repos: [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }] satisfies AppState['repos'], endpointProjectHostSetups: [ { id: 'repo1', diff --git a/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx b/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx index d2905aa0d357..d5703e808d76 100644 --- a/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-link-other-workspace-hint.sonner.test.tsx @@ -50,7 +50,9 @@ async function settle(): Promise { beforeEach(() => { vi.clearAllMocks() storeState.openFiles = [] - storeState.repos = [{ id: 'repo-b', displayName: 'project-b' }] + storeState.repos = [ + { id: 'repo-b', displayName: 'project-b', path: '/b', badgeColor: '', addedAt: 0 } + ] // Records each open as the store does, so the hint sees whether the tab is read-only. openFileMock.mockImplementation( (file: { filePath: string; worktreeId: string; readOnly?: boolean }) => { diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index cbc366da7e4a..03423f713eda 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -492,7 +492,7 @@ describe('local preflight context', () => { repos: [ { ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\active' }, { ...TEST_REPO, id: 'repo-2', path: 'C:\\Users\\alice\\target' } - ], + ] satisfies AppState['repos'], worktreesByRepo: { 'repo-1': [ makeWorktree({ @@ -696,7 +696,7 @@ describe('local preflight context', () => { connectionId: 'builder', executionHostId: 'ssh:builder' } - ], + ] satisfies AppState['repos'], worktreesByRepo: { 'repo-1': [ makeWorktree({ From 36c0245382b2d9895ecccae04d8872fcff352433 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:43:12 -0700 Subject: [PATCH 18/29] Fix checked catalog inputs in compiler regression fixtures --- ...space-list-sections-desktop-parity.test.ts | 6 ++ .../pty-connection-fresh-spawn-guards.spec.ts | 3 +- .../src/lib/local-preflight-context.test.ts | 40 +++++---- .../e2e/ai-vault-project-native.unit.test.ts | 15 +++- tests/e2e/helpers/wsl-golden-stub-agent.ts | 64 +++++++++----- ...rkspace-linked-item-project-switch.spec.ts | 53 ++++++----- .../project-group-creation-visibility.spec.ts | 21 +++-- tests/e2e/sidebar-measured-row-scroll.spec.ts | 31 +++++-- ...inal-windows-shell-paste-ownership.spec.ts | 19 +++- .../e2e/windows-project-runtime-smoke.spec.ts | 88 ++++++++++++------- 10 files changed, 228 insertions(+), 112 deletions(-) diff --git a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts index e6f103de91c6..821ba4bbac35 100644 --- a/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts +++ b/mobile/src/worktree/workspace-list-sections-desktop-parity.test.ts @@ -241,6 +241,9 @@ it('keeps published desktop affiliation through phone parsing when a setup has n expect(verdict.value.repos.map((row) => row.id)).toEqual(['a']) expect(verdict.value.setups.map((row) => row.repoId)).toEqual(['a', 'z']) const phone = buildProjectSectionCatalog(verdict.value, [], null, 'manual') + if (!phone.grouping) { + throw new Error('No served project grouping') + } expect( getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key ).toBe(desktop.key) @@ -266,6 +269,9 @@ it('keeps published desktop affiliation through phone parsing when a setup has n throw new Error('old catalog refused') } const phone = buildProjectSectionCatalog(old.value, [], null, 'manual') + if (!phone.grouping) { + throw new Error('No served project grouping') + } expect( getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key ).toBe('project:repo:a') diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts index a1b1bbf6a2d4..24523a6e4b73 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts @@ -247,6 +247,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transportFactoryQueue.push(transport) + const repos: AppState['repos'] = [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }] mockStoreState = { ...mockStoreState, settings: { @@ -255,7 +256,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { terminalWindowsWslDistro: 'Debian', localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Debian' } }, - repos: [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }] satisfies AppState['repos'], + repos, endpointProjectHostSetups: [ { id: 'repo1', diff --git a/src/renderer/src/lib/local-preflight-context.test.ts b/src/renderer/src/lib/local-preflight-context.test.ts index 03423f713eda..5deaa67935fa 100644 --- a/src/renderer/src/lib/local-preflight-context.test.ts +++ b/src/renderer/src/lib/local-preflight-context.test.ts @@ -484,15 +484,16 @@ describe('local preflight context', () => { }) it('uses the target worktree runtime for local agent checks', () => { + const repos: AppState['repos'] = [ + { ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\active' }, + { ...TEST_REPO, id: 'repo-2', path: 'C:\\Users\\alice\\target' } + ] const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\active', worktreePath: 'C:\\Users\\alice\\active' }), - repos: [ - { ...TEST_REPO, id: 'repo-1', path: 'C:\\Users\\alice\\active' }, - { ...TEST_REPO, id: 'repo-2', path: 'C:\\Users\\alice\\target' } - ] satisfies AppState['repos'], + repos, worktreesByRepo: { 'repo-1': [ makeWorktree({ @@ -677,26 +678,27 @@ describe('local preflight context', () => { }) it('uses the requested worktree repo owner before resolving a local project runtime', () => { + const repos: AppState['repos'] = [ + { + ...TEST_REPO, + id: 'repo-1', + path: 'C:\\Users\\alice\\repo', + executionHostId: 'local' + }, + { + ...TEST_REPO, + id: 'repo-ssh', + path: '/remote/repo', + connectionId: 'builder', + executionHostId: 'ssh:builder' + } + ] const state: PreflightState = { ...makeState({ repoPath: 'C:\\Users\\alice\\repo', worktreePath: 'C:\\Users\\alice\\repo' }), - repos: [ - { - ...TEST_REPO, - id: 'repo-1', - path: 'C:\\Users\\alice\\repo', - executionHostId: 'local' - }, - { - ...TEST_REPO, - id: 'repo-ssh', - path: '/remote/repo', - connectionId: 'builder', - executionHostId: 'ssh:builder' - } - ] satisfies AppState['repos'], + repos, worktreesByRepo: { 'repo-1': [ makeWorktree({ diff --git a/tests/e2e/ai-vault-project-native.unit.test.ts b/tests/e2e/ai-vault-project-native.unit.test.ts index fed9caba75a9..b7cb6bdecc93 100644 --- a/tests/e2e/ai-vault-project-native.unit.test.ts +++ b/tests/e2e/ai-vault-project-native.unit.test.ts @@ -69,7 +69,20 @@ afterEach(async () => { }) it('bounds actual panel-to-IPC canonical preparation and native cwd seeks at N and 10N', async () => { - const observations = [] + const observations: { + size: number + catalog: number + admissions: number + compilations: number + projections: number + claims: number + claimFacts: number + aliases: number + namespace: number + preparations: number + executions: number + intervalBytes: number + }[] = [] for (const size of [8, 80]) { root = await realpath(await mkdtemp(join(tmpdir(), 'sta10541-native-trigger-'))) const repos: Repo[] = [] diff --git a/tests/e2e/helpers/wsl-golden-stub-agent.ts b/tests/e2e/helpers/wsl-golden-stub-agent.ts index 3294d6df3ecb..1560c11aea6a 100644 --- a/tests/e2e/helpers/wsl-golden-stub-agent.ts +++ b/tests/e2e/helpers/wsl-golden-stub-agent.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../../../src/renderer/src/store/project-host-setup-selector' import { execFileSync } from 'node:child_process' import type { Page } from '@stablyai/playwright-test' import { buildWslExecArgs } from '../../../src/shared/wsl-login-shell-command' @@ -133,31 +134,46 @@ export function removeWslGoldenStubAgent(distro: string, stage: WslGoldenStubAge /** Retargets project agent detection and terminal spawning to WSL. */ export async function useWslRuntimeForActiveProject(page: Page, distro: string): Promise { - await page.evaluate(async (wslDistro) => { - const store = window.__store - if (!store) { + const catalog = await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { throw new Error('Orca store is unavailable') } - const state = store.getState() - const worktreeId = state.activeWorktreeId - if (!worktreeId) { - throw new Error('No active worktree') + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups } - const activeWorktree = Object.values(state.worktreesByRepo) - .flat() - .find((worktree) => worktree.id === worktreeId) - const activeProject = state.projects.find((project) => - activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false - ) - if (!activeProject) { - throw new Error('No active project') - } - await state.updateProject( - activeProject.id, - { - localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } - }, - 'local' - ) - }, distro) + }) + const projects = selectCanonicalProjects(catalog) + await page.evaluate( + async ({ wslDistro, projects }) => { + const store = window.__store + if (!store) { + throw new Error('Orca store is unavailable') + } + const state = store.getState() + const worktreeId = state.activeWorktreeId + if (!worktreeId) { + throw new Error('No active worktree') + } + const activeWorktree = Object.values(state.worktreesByRepo) + .flat() + .find((worktree) => worktree.id === worktreeId) + const activeProject = projects.find((project) => + activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false + ) + if (!activeProject) { + throw new Error('No active project') + } + await state.updateProject( + activeProject.id, + { + localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } + }, + 'local' + ) + }, + { wslDistro: distro, projects } + ) } diff --git a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts index 9970f3696f01..77e7e19fb8a5 100644 --- a/tests/e2e/new-workspace-linked-item-project-switch.spec.ts +++ b/tests/e2e/new-workspace-linked-item-project-switch.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '../../src/renderer/src/store/project-host-setup-selector' /** * E2E: linked work item behavior when switching the composer project. * @@ -74,30 +75,42 @@ async function openComposerWithLinkedWorkItem( } async function getJiraSourceContext(page: Page): Promise { - return page.evaluate(() => { + const state = await page.evaluate(() => { const state = window.__store?.getState() - const activeWorktreeId = state?.activeWorktreeId - const setup = state?.projectHostSetups.find((candidate) => - state.worktreesByRepo[candidate.repoId]?.some((worktree) => worktree.id === activeWorktreeId) + return state + ? { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups, + activeWorktreeId: state.activeWorktreeId, + worktreesByRepo: state.worktreesByRepo + } + : null + }) + const setup = + state && + selectCanonicalProjectHostSetups(state).find((candidate) => + state.worktreesByRepo[candidate.repoId]?.some( + (worktree) => worktree.id === state.activeWorktreeId + ) ) - if (!setup) { - throw new Error('Active project host setup is unavailable') - } - return { - kind: 'task-source', + if (!setup) { + throw new Error('Active project host setup is unavailable') + } + return { + kind: 'task-source', + provider: 'jira', + projectId: setup.projectId, + hostId: setup.hostId, + projectHostSetupId: setup.id, + repoId: setup.repoId, + providerIdentity: { provider: 'jira', - projectId: setup.projectId, - hostId: setup.hostId, - projectHostSetupId: setup.id, - repoId: setup.repoId, - providerIdentity: { - provider: 'jira', - siteId: 'e2e-jira-site', - siteUrl: 'https://example.atlassian.net', - projectKey: 'RDG' - } + siteId: 'e2e-jira-site', + siteUrl: 'https://example.atlassian.net', + projectKey: 'RDG' } - }) + } } async function switchComposerProject(page: Page, projectName: string): Promise { diff --git a/tests/e2e/project-group-creation-visibility.spec.ts b/tests/e2e/project-group-creation-visibility.spec.ts index 710b201ac611..9177e559b7dc 100644 --- a/tests/e2e/project-group-creation-visibility.spec.ts +++ b/tests/e2e/project-group-creation-visibility.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '../../src/renderer/src/store/project-host-setup-selector' import { cpSync, mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -122,7 +123,7 @@ for (const delayCreateResponse of [false, true]) { if (!createdGroup) { throw new Error('Group creation failed') } - await orcaPage.evaluate( + const catalog = await orcaPage.evaluate( async ({ repoIds, groupId }) => { const store = window.__store! for (const repoId of repoIds.slice(0, 2)) { @@ -132,14 +133,22 @@ for (const delayCreateResponse of [false, true]) { } await store.getState().moveProjectToGroup(repo, groupId) } - const collapsedGroups = store - .getState() - .projectHostSetups.map((setup) => `project:${setup.projectId}`) - await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) - store.setState({ collapsedGroups: new Set(collapsedGroups) }) + const state = store.getState() + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } }, { repoIds, groupId: createdGroup.id } ) + const collapsedGroups = selectCanonicalProjectHostSetups(catalog).map( + (setup) => `project:${setup.projectId}` + ) + await orcaPage.evaluate(async (collapsedGroups) => { + await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) + window.__store!.setState({ collapsedGroups: new Set(collapsedGroups) }) + }, collapsedGroups) const scroller = orcaPage.locator('[data-worktree-sidebar]') const group = scroller.locator(`[data-project-group-header-id="${createdGroup.id}"]`) diff --git a/tests/e2e/sidebar-measured-row-scroll.spec.ts b/tests/e2e/sidebar-measured-row-scroll.spec.ts index bd6481b177fb..7ab77f9edf38 100644 --- a/tests/e2e/sidebar-measured-row-scroll.spec.ts +++ b/tests/e2e/sidebar-measured-row-scroll.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjectHostSetups } from '../../src/renderer/src/store/project-host-setup-selector' import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import os from 'node:os' import path from 'node:path' @@ -103,7 +104,7 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( } } - const fixture = await orcaPage.evaluate(async (repoPaths): Promise => { + const fixture = await orcaPage.evaluate(async (repoPaths) => { const store = window.__store! for (const repoPath of repoPaths) { await window.api.repos.add({ path: repoPath }) @@ -131,21 +132,35 @@ test('keeps a collapsed group visible when an idle card below it grows', async ( const state = store.getState() state.setGroupBy('repo') state.setProjectOrderBy('manual') - const collapsedSetup = state.projectHostSetups.find((setup) => setup.repoId === collapsed.id) const worktree = state.worktreesByRepo[expanded.id]?.find((entry) => entry.isMainWorktree) - if (!collapsedSetup || !worktree) { + if (!worktree) { throw new Error('Fixture project setup or worktree is missing') } - const collapsedGroups = [`project-group:${group.id}`, `project:${collapsedSetup.projectId}`] - await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) - store.setState({ collapsedGroups: new Set(collapsedGroups) }) return { groupId: group.id, collapsedRepoId: collapsed.id, expandedRepoId: expanded.id, - worktreeId: worktree.id - } + worktreeId: worktree.id, + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } satisfies SidebarFixture & + Pick }, paths) + const collapsedSetup = selectCanonicalProjectHostSetups(fixture).find( + (setup) => setup.repoId === fixture.collapsedRepoId + ) + if (!collapsedSetup) { + throw new Error('Fixture project setup or worktree is missing') + } + const collapsedGroups = [ + `project-group:${fixture.groupId}`, + `project:${collapsedSetup.projectId}` + ] + await orcaPage.evaluate(async (collapsedGroups) => { + await window.api.ui.set({ groupBy: 'repo', collapsedGroups }) + window.__store!.setState({ collapsedGroups: new Set(collapsedGroups) }) + }, collapsedGroups) const sidebar = orcaPage.locator('[data-worktree-sidebar]') const group = sidebar.locator(`[data-project-group-header-id="${fixture.groupId}"]`) diff --git a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts index e7b5014e5ac1..02497d99d840 100644 --- a/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts +++ b/tests/e2e/terminal-windows-shell-paste-ownership.spec.ts @@ -1,3 +1,4 @@ +import { selectCanonicalProjects } from '../../src/renderer/src/store/project-host-setup-selector' import { randomUUID } from 'node:crypto' import { execFileSync } from 'node:child_process' import { rmSync, writeFileSync } from 'node:fs' @@ -114,7 +115,19 @@ async function createWindowsDefaultShellTerminalTab( } async function configureActiveProjectWslRuntime(page: Page): Promise { - return page.evaluate(async () => { + const catalog = await page.evaluate(() => { + const state = window.__store?.getState() + if (!state) { + throw new Error('Store unavailable') + } + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } + }) + const projects = selectCanonicalProjects(catalog) + return page.evaluate(async (projects) => { const store = window.__store if (!store) { throw new Error('Store unavailable') @@ -131,7 +144,7 @@ async function configureActiveProjectWslRuntime(page: Page): Promise worktree.id === worktreeId) - const activeProject = state.projects.find((project) => + const activeProject = projects.find((project) => activeWorktree ? project.sourceRepoIds.includes(activeWorktree.repoId) : false ) if (!activeProject) { @@ -148,7 +161,7 @@ async function configureActiveProjectWslRuntime(page: Page): Promise { const wslDistro = wsl.distros[0]! const wslRepoPath = await createGitRepo('orca-e2e-project-runtime-', 'wsl-runtime-project') - const smoke = await orcaPage.evaluate( - async ({ hostRepoPath, wslRepoPath, wslDistro }) => { + const catalog = await orcaPage.evaluate( + async ({ hostRepoPath, wslRepoPath }) => { const store = window.__store if (!store) { throw new Error('window.__store is not available') @@ -109,42 +110,61 @@ test.describe('Windows project runtime smoke', () => { throw new Error('Expected host and WSL smoke repos to be loaded') } - const hostProject = state.projects.find((project) => - project.sourceRepoIds.includes(hostRepo.id) - ) - const wslProject = state.projects.find((project) => - project.sourceRepoIds.includes(wslRepo.id) - ) - if (!hostProject || !wslProject) { - throw new Error('Expected host and WSL smoke projects to be loaded') + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups, + hostRepo, + wslRepo } - + }, + { hostRepoPath: testRepoPath, wslRepoPath } + ) + const projects = selectCanonicalProjects(catalog) + const hostProject = projects.find((project) => + project.sourceRepoIds.includes(catalog.hostRepo.id) + ) + const wslProject = projects.find((project) => + project.sourceRepoIds.includes(catalog.wslRepo.id) + ) + if (!hostProject || !wslProject) { + throw new Error('Expected host and WSL smoke projects to be loaded') + } + const smoke = await orcaPage.evaluate( + async ({ hostProjectId, wslProjectId, hostRepoId, wslRepoId, wslDistro }) => { + const state = window.__store!.getState() await state.updateProject( - hostProject.id, + hostProjectId, { localWindowsRuntimePreference: { kind: 'windows-host' } }, 'local' ) await state.updateProject( - wslProject.id, + wslProjectId, { localWindowsRuntimePreference: { kind: 'wsl', distro: wslDistro } }, 'local' ) - const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepo.id }) - const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepo.id }) + const hostWorktrees = await window.api.worktrees.listDetected({ repoId: hostRepoId }) + const wslWorktrees = await window.api.worktrees.listDetected({ repoId: wslRepoId }) return { - hostRepoId: hostRepo.id, - wslRepoId: wslRepo.id, + hostRepoId, + wslRepoId, wslDistro, hostWorktreeCount: hostWorktrees.worktrees.length, wslWorktreeCount: wslWorktrees.worktrees.length } }, - { hostRepoPath: testRepoPath, wslRepoPath, wslDistro } + { + hostProjectId: hostProject.id, + wslProjectId: wslProject.id, + hostRepoId: catalog.hostRepo.id, + wslRepoId: catalog.wslRepo.id, + wslDistro + } ) expect(smoke.hostWorktreeCount).toBeGreaterThan(0) @@ -158,24 +178,32 @@ test.describe('Windows project runtime smoke', () => { await expect( hostSection.getByText(`This project runs in ${smoke.wslDistro} via WSL.`) ).toBeVisible() - const hostAfterWslUiSwitch = await orcaPage.evaluate((hostRepoId) => { + const afterWslCatalog = await orcaPage.evaluate(() => { const state = window.__store!.getState() - const hostProject = state.projects.find((project) => - project.sourceRepoIds.includes(hostRepoId) - ) - return hostProject?.localWindowsRuntimePreference - }, smoke.hostRepoId) + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } + }) + const hostAfterWslUiSwitch = selectCanonicalProjects(afterWslCatalog).find((project) => + project.sourceRepoIds.includes(smoke.hostRepoId) + )?.localWindowsRuntimePreference expect(hostAfterWslUiSwitch).toEqual({ kind: 'wsl', distro: smoke.wslDistro }) await chooseProjectRuntime(orcaPage, smoke.hostRepoId, 'Windows') await expect(hostSection.getByText('This project runs on Windows.')).toBeVisible() - const hostAfterWindowsUiSwitch = await orcaPage.evaluate((hostRepoId) => { + const afterWindowsCatalog = await orcaPage.evaluate(() => { const state = window.__store!.getState() - const hostProject = state.projects.find((project) => - project.sourceRepoIds.includes(hostRepoId) - ) - return hostProject?.localWindowsRuntimePreference - }, smoke.hostRepoId) + return { + repos: state.repos, + endpointProjects: state.endpointProjects, + endpointProjectHostSetups: state.endpointProjectHostSetups + } + }) + const hostAfterWindowsUiSwitch = selectCanonicalProjects(afterWindowsCatalog).find((project) => + project.sourceRepoIds.includes(smoke.hostRepoId) + )?.localWindowsRuntimePreference expect(hostAfterWindowsUiSwitch).toEqual({ kind: 'windows-host' }) await openRepoSettings(orcaPage, smoke.wslRepoId) From cd44b090573e53c1cae3008f96ddb215a0902937 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 20:57:22 -0700 Subject: [PATCH 19/29] Keep checked PTY fixture repos mutable --- .../terminal-pane/pty-connection-fresh-spawn-guards.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts index 24523a6e4b73..1d734d41f277 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection-fresh-spawn-guards.spec.ts @@ -247,7 +247,7 @@ export function registerPtyConnectionFreshSpawnGuardsTests(): void { const { connectPanePty } = await import('./pty-connection') const transport = createMockTransport() transportFactoryQueue.push(transport) - const repos: AppState['repos'] = [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }] + const repos: AppState['repos'][number][] = [{ ...TEST_REPO, id: 'repo1', path: '/tmp/wt-1' }] mockStoreState = { ...mockStoreState, settings: { From 43f13a6b098f8a74890bc48d2ff4be93de2ca957 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:10:34 -0700 Subject: [PATCH 20/29] test: align catalog fixtures with canonical readers --- .../project-catalog-read-boundary.test.mjs | 13 ++++- .../runtime-composition-parity.test.ts | 4 ++ ...ewWorkspaceComposerCard.file-drop.test.tsx | 3 +- ...aceComposerCard.set-location-warm.test.tsx | 3 +- ...orkspaceComposerCard.set-location.test.tsx | 3 +- ...wWorkspaceComposerCard.start-from.test.tsx | 3 +- .../NewWorkspaceComposerCard.test.tsx | 3 +- ...utomationsPage.create-destination.test.tsx | 30 +++++++++-- .../automations/automations-page-fixtures.ts | 17 ++++++- .../ConnectIntegrationsList.test.tsx | 4 ++ .../use-native-chat-skills.react.test.tsx | 3 +- ...ojectLocationDialog.host-identity.test.tsx | 14 ++++-- .../ai-vault-search-scope-identity.test.tsx | 12 ++++- .../settings/EphemeralVmsPane.test.tsx | 3 +- .../task-tracker-integration-cards.test.tsx | 6 +++ .../sidebar/DeleteWorktreeDialog.test.tsx | 2 + .../WorktreeCard.compact-hover.test.tsx | 3 ++ ....compact-ports-hover-independence.test.tsx | 3 ++ .../sidebar/WorktreeCardPorts.test.tsx | 3 ++ .../WorktreeList.card-memo-stability.test.tsx | 2 + .../WorktreeList.empty-project-rows.test.ts | 2 + ...WorktreeList.folder-workspace-rows.test.ts | 2 + .../WorktreeList.group-headers.test.ts | 2 + ....lineage-agent-expansion-coupling.test.tsx | 2 + ...ktreeList.lineage-child-real-card.test.tsx | 2 + ...treeList.status-lane-lineage-drop.test.tsx | 2 + ...e-worktree-dirty-change-hydration.test.tsx | 2 + ...worktree-list-lineage-card-test-harness.ts | 2 + .../grouping/cross-project-lineage.test.ts | 13 ++++- .../tab-bar/TabBar.context-menu.test.ts | 6 +++ .../TabBar.worktree-write-gate.test.tsx | 1 + ...abBar.worktree-write-gate.windows.test.tsx | 1 + .../tab-session-history-switch.test.ts | 1 + ...-bar-runtime-model-worktree-write-probe.ts | 2 + .../terminal-agent-session-fork.test.ts | 42 +++++++++------- .../useComposerState.integration.test.ts | 1 + .../src/lib/ai-vault-resume-command.test.ts | 2 + .../launch-parity-renderer.test-fixture.ts | 27 ++++------ ...nch-work-item-direct-host-platform.test.ts | 28 +++++++++-- .../src/lib/launch-work-item-direct.test.ts | 21 ++++++-- .../lib/local-preflight-context-cache.test.ts | 4 ++ ...ync-runtime-graph-project-sections.test.ts | 4 +- .../src/store/slices/detected-agents.test.ts | 14 ++++-- .../src/store/slices/preflight.test.ts | 7 ++- .../worktrees-create-base-status.test.ts | 2 + .../worktrees-fetch-owner-routing.test.ts | 50 ++++++++++++++++--- ...-fetch-persisted-metadata-fallback.test.ts | 36 +++++++++---- ...worktrees-fetch-refresh-coalescing.test.ts | 18 +++++-- .../worktrees-fetch-remote-lineage.test.ts | 10 +++- .../slices/worktrees-hydration-purge.test.ts | 11 +++- .../worktrees-remote-runtime-create.test.ts | 2 + .../slices/worktrees-slice-test-harness.ts | 3 +- ...unch-parity-window-request.test-fixture.ts | 6 +-- 53 files changed, 361 insertions(+), 101 deletions(-) diff --git a/config/scripts/project-catalog-read-boundary.test.mjs b/config/scripts/project-catalog-read-boundary.test.mjs index a696e96d512f..6f0584295e41 100644 --- a/config/scripts/project-catalog-read-boundary.test.mjs +++ b/config/scripts/project-catalog-read-boundary.test.mjs @@ -3,6 +3,7 @@ import { tmpdir } from 'node:os' import { resolve, join, dirname } from 'node:path' import { spawnSync } from 'node:child_process' import { expect, it } from 'vitest' +import { resolveOxcCliInvocation } from './oxc-cli-invocation.mjs' it('enforces five raw catalog forms with the actual config and narrow file exceptions', () => { const config = JSON.parse(readFileSync(resolve('.oxlintrc.json'), 'utf8')) @@ -39,9 +40,17 @@ it('enforces five raw catalog forms with the actual config and narrow file excep const target = join(folder, file) mkdirSync(dirname(target), { recursive: true }) writeFileSync(target, content) + const invocation = resolveOxcCliInvocation('oxlint', 'oxlint') const result = spawnSync( - resolve('node_modules/.bin/oxlint'), - ['--config', join(folder, 'config.json'), '--format', 'json', file], + invocation.command, + [ + ...invocation.prefixArgs, + '--config', + join(folder, 'config.json'), + '--format', + 'json', + file + ], { cwd: folder, encoding: 'utf8' } ) expect(result.error).toBeUndefined() diff --git a/src/main/runtime/construction/runtime-composition-parity.test.ts b/src/main/runtime/construction/runtime-composition-parity.test.ts index ea586de05c57..7cb75737df3e 100644 --- a/src/main/runtime/construction/runtime-composition-parity.test.ts +++ b/src/main/runtime/construction/runtime-composition-parity.test.ts @@ -207,6 +207,10 @@ const NOT_HOST_PORTS = new Set(['setTimeout', 'setInterval', 'setImmediate']) /** A spread with no inline object literal names the source of the object it spreads. */ const SPREAD_SOURCES: Record = { + 'orcadAiVaultSessionSources(': { + file: 'src/main/orcad/orcad-session-search.ts', + anchor: 'return {' + }, 'agentHookRuntimeDeps(': { file: 'src/main/agent-hooks/agent-hook-runtime-deps.ts', anchor: 'return {' diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx index c1092a5294b9..054a6264f21c 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.file-drop.test.tsx @@ -16,7 +16,8 @@ vi.mock('@/runtime/runtime-file-import-client', () => ({ vi.mock('@/store', () => ({ useAppStore: Object.assign( - (selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], repos: [] }), + (selector: (state: unknown) => unknown) => + selector({ settings: {}, endpointProjects: [], endpointProjectHostSetups: [], repos: [] }), { getState: () => ({ sshConnectionStates: connections, diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx index fd1a16f7f0a8..5165f7521526 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location-warm.test.tsx @@ -32,7 +32,8 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }), { getState: () => ({}) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx index 06718fab91b2..5919a4638681 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.set-location.test.tsx @@ -25,7 +25,8 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }), { getState: () => ({}) } diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx index 15edfd8680ed..431fc3edd910 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.start-from.test.tsx @@ -17,7 +17,8 @@ vi.mock('@/store', () => ({ activeModal: 'new-workspace-composer', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {} }), diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx index feae366dbcb5..f07d893ce6dc 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.test.tsx @@ -32,7 +32,8 @@ vi.mock('@/store', () => ({ activeModal: 'none', settings: { defaultTuiAgent: null, disabledTuiAgents: [] }, updateSettings: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }), { diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index 77bbcd178cb7..8f4eb618cdc9 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -60,7 +60,7 @@ function addSshHost(): void { /** A project checked out on that SSH host rather than locally. */ function addSshProject(): void { - const repo = { + const repo: Repo = { id: SSH_REPO_ID, displayName: 'orca', path: '/repos/orca', @@ -68,11 +68,22 @@ function addSshProject(): void { addedAt: 1, worktreeBaseRef: 'main', connectionId: SSH_TARGET_ID - } as Repo + } mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] mocks.repoMap.set(SSH_REPO_ID, repo) // Without a ready setup on that host the project has no run context, and a // save against it is refused before any destination is stated. + mocks.state.endpointProjects = [ + ...mocks.state.endpointProjects, + { + id: 'project-ssh', + displayName: 'orca', + badgeColor: '#222222', + sourceRepoIds: [SSH_REPO_ID], + createdAt: 1, + updatedAt: 1 + } + ] mocks.state.endpointProjectHostSetups = [ ...mocks.state.endpointProjectHostSetups, { @@ -92,7 +103,7 @@ function addSshProject(): void { /** A runtime project with the same host-local id as the desktop project. */ function addCollidingRuntimeProject(): void { - const repo = { + const repo: Repo = { id: REPO_ID, displayName: 'orca on m4-air', path: '/repos/orca', @@ -100,8 +111,19 @@ function addCollidingRuntimeProject(): void { addedAt: 1, worktreeBaseRef: 'main', executionHostId: `runtime:${RUNTIME_ID}` - } as Repo + } mocks.state.repos = [...(mocks.state.repos as Repo[]), repo] + mocks.state.endpointProjects = [ + ...mocks.state.endpointProjects, + { + id: 'project-runtime-collision', + displayName: 'orca on m4-air', + badgeColor: '#111111', + sourceRepoIds: [REPO_ID], + createdAt: 1, + updatedAt: 1 + } + ] mocks.state.endpointProjectHostSetups = [ ...mocks.state.endpointProjectHostSetups, { diff --git a/src/renderer/src/components/automations/automations-page-fixtures.ts b/src/renderer/src/components/automations/automations-page-fixtures.ts index 5fb1fa0bce68..6196a16f4c5e 100644 --- a/src/renderer/src/components/automations/automations-page-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-fixtures.ts @@ -13,7 +13,7 @@ import type { AutomationRunUsage, ExternalAutomationManager } from '../../../../shared/automations-types' -import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import type { @@ -226,7 +226,10 @@ export function makeWorktree(overrides: Partial = {}): Worktree { } export type AutomationsPageStoreFixtures = { - state: Record & { endpointProjectHostSetups: ProjectHostSetup[] } + state: Record & { + endpointProjects: Project[] + endpointProjectHostSetups: ProjectHostSetup[] + } repoMap: Map worktreeMap: Map } @@ -239,6 +242,16 @@ export function makeStoreState(): AutomationsPageStoreFixtures { return { state: { repos: [repo], + endpointProjects: [ + { + id: 'project-1', + displayName: repo.displayName, + badgeColor: repo.badgeColor, + sourceRepoIds: [repo.id], + createdAt: 1, + updatedAt: 1 + } + ], endpointProjectHostSetups: [makeProjectHostSetup()], worktreesByRepo: { [REPO_ID]: [worktree] }, unifiedTabsByWorktree: {}, diff --git a/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx b/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx index 898504c36cb2..f1a7a5e46c02 100644 --- a/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx +++ b/src/renderer/src/components/feature-wall/ConnectIntegrationsList.test.tsx @@ -8,6 +8,8 @@ type StoreState = { activeRepoId: string | null activeWorktreeId: string | null worktreesByRepo: Record + endpointProjects: never[] + endpointProjectHostSetups: never[] repos: unknown[] settings: { activeRuntimeEnvironmentId?: string | null } preflightStatus: PreflightStatus | null @@ -90,6 +92,8 @@ function installStore(preflightStatus: PreflightStatus): void { activeRepoId: null, activeWorktreeId: null, worktreesByRepo: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], settings, preflightStatus, diff --git a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx index 4e5e35d1cd89..d37b507e4061 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx +++ b/src/renderer/src/components/native-chat/use-native-chat-skills.react.test.tsx @@ -35,7 +35,8 @@ function stateForHost(hostId: string) { activeWorktreeId: 'worktree-1', folderWorkspaces: [], projectGroups: [], - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [ { id: 'repo-1', diff --git a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx index 29a4ba1b2d9d..d9dea9661a00 100644 --- a/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx +++ b/src/renderer/src/components/new-workspace/SetProjectLocationDialog.host-identity.test.tsx @@ -4,7 +4,7 @@ import { cleanup, render, renderHook, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { ExecutionHostRegistryEntry } from '../../../../shared/execution-host-registry' -import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import { @@ -17,10 +17,12 @@ import { SetProjectLocationDialog } from './SetProjectLocationDialog' const storeMock = vi.hoisted(() => { const repos: Repo[] = [] - const projectHostSetups: ProjectHostSetup[] = [] + const endpointProjects: Project[] = [] + const endpointProjectHostSetups: ProjectHostSetup[] = [] return { repos, - projectHostSetups, + endpointProjects, + endpointProjectHostSetups, setupProjectExistingFolder: vi.fn(), setupProjectClone: vi.fn() } @@ -125,12 +127,14 @@ function createInput(): Parameters[0] { beforeEach(() => { storeMock.repos = [localRepo] - storeMock.projectHostSetups = [...localProjection.setups] + storeMock.endpointProjects = [...localProjection.projects] + storeMock.endpointProjectHostSetups = [...localProjection.setups] storeMock.setupProjectExistingFolder.mockReset() storeMock.setupProjectClone.mockReset() const publishRuntimeSetup = async () => { storeMock.repos = [localRepo, runtimeRepo] - storeMock.projectHostSetups = [...localProjection.setups, ...runtimeProjection.setups] + storeMock.endpointProjects = [...localProjection.projects, ...runtimeProjection.projects] + storeMock.endpointProjectHostSetups = [...localProjection.setups, ...runtimeProjection.setups] return { project: runtimeProjection.projects[0], repo: runtimeRepo, diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx index 2da2a191fb28..f6e2f328337a 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-scope-identity.test.tsx @@ -20,7 +20,11 @@ const AGENTS = ['codex' as const] const WORKSPACE = { kind: 'workspace', worktreeId: 'repo-1::/work/app' } as const const searchSessions = vi.fn< - (request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise + ( + request: AiVaultSearchRequest, + scope?: ExecutionHostScope, + isCurrent?: () => boolean + ) => Promise >() beforeEach(() => { @@ -90,9 +94,13 @@ describe('the panel hook under a scope identity', () => { await debounce() expect(searchSessions).toHaveBeenCalledExactlyOnceWith( { query: 'needle', filters: { agents: ['codex'] }, within, cursor: undefined }, - 'ssh:build-box' + 'ssh:build-box', + expect.any(Function) ) + const isCurrent = searchSessions.mock.calls[0][2] + expect(isCurrent?.()).toBe(true) unmount() + expect(isCurrent?.()).toBe(false) }) it('does not restart the search while the identity holds', async () => { diff --git a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx index 543db2dc88e9..230845736ee8 100644 --- a/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx +++ b/src/renderer/src/components/settings/EphemeralVmsPane.test.tsx @@ -24,7 +24,8 @@ const mockStoreState = { activeWorktreeId: null, openModal: storeMocks.openModal, recordFeatureInteraction: vi.fn(), - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], settings: null, worktreesByRepo: {} diff --git a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx index 7ab86a728e39..60f7a3b99927 100644 --- a/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx +++ b/src/renderer/src/components/settings/task-tracker-integration-cards.test.tsx @@ -12,6 +12,9 @@ import { LinearIntegrationCard } from './task-tracker-integration-cards' const LOCAL_HOST_LABEL = getExecutionHostLabel('local') type StoreState = { + repos: never[] + endpointProjects: never[] + endpointProjectHostSetups: never[] linearStatus: { connected: boolean workspaces?: { id: string; organizationName: string; displayName: string; email?: string }[] @@ -59,6 +62,9 @@ function installStore( settings: StoreState['settings'] = { activeRuntimeEnvironmentId: null } ): StoreState { const state: StoreState = { + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], linearStatus: { connected, workspaces: connected diff --git a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx index 41b69e8d99a4..292816568034 100644 --- a/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx +++ b/src/renderer/src/components/sidebar/DeleteWorktreeDialog.test.tsx @@ -18,6 +18,8 @@ const mocks = vi.hoisted(() => { removeWorktree: vi.fn(), clearWorktreeDeleteState: vi.fn(), allWorktrees: vi.fn<() => Worktree[]>(() => []), + endpointProjects: [], + endpointProjectHostSetups: [], repos: [] as Repo[], worktreeLineageById: {} as Record, updateSettings: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx index 71236d95b41f..9d865281e5e7 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-hover.test.tsx @@ -33,6 +33,9 @@ let mockInlineAgentRows: DashboardAgentRowData[] = [] vi.mock('@/store', () => { const getState = () => ({ + repos: [makeRepo()], + endpointProjects: [], + endpointProjectHostSetups: [], browserTabsByWorktree: {}, agentActivityDisplayMode, createBrowserTab: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx index 33f1aee30ebd..70e55dad2456 100644 --- a/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCard.compact-ports-hover-independence.test.tsx @@ -24,6 +24,9 @@ let settings: Partial | null = { compactWorktreeCards: true } vi.mock('@/store', () => { const getState = () => ({ + repos: [makeRepo()], + endpointProjects: [], + endpointProjectHostSetups: [], browserTabsByWorktree: {}, agentActivityDisplayMode: undefined, createBrowserTab: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx b/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx index db23d18dbc22..4156b0227f0c 100644 --- a/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeCardPorts.test.tsx @@ -6,6 +6,9 @@ import type { WorkspacePort } from '../../../../shared/workspace-ports' vi.mock('@/store', () => ({ useAppStore: (selector: (state: unknown) => unknown) => selector({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], createBrowserTab: vi.fn(), setRemoteBrowserPageHandle: vi.fn(), replaceWorkspacePortScans: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx index 72ff893571bb..d374704899fd 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.card-memo-stability.test.tsx @@ -215,6 +215,8 @@ function setFlatWorktreeState(): void { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts b/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts index 74d6387b1504..fc2a9ecdd35b 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.empty-project-rows.test.ts @@ -64,6 +64,8 @@ function setEmptyUngroupedProjectState(filterRepoIds: string[] = []): void { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), retainedAgentsByPaneKey: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], runtimePaneTitlesByTabId: {}, setFilterRepoIds: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts b/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts index 60f50880f73a..fc6c51fc93bd 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.folder-workspace-rows.test.ts @@ -124,6 +124,8 @@ function setFolderWorkspaceFixtureState( reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), retainedAgentsByPaneKey: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], runtimePaneTitlesByTabId: {}, setFilterRepoIds: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts b/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts index 2be1d1654c7d..b9306a9a4367 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts +++ b/src/renderer/src/components/sidebar/WorktreeList.group-headers.test.ts @@ -82,6 +82,8 @@ function setProjectGroupWithoutWorktreeRowsState( reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), retainedAgentsByPaneKey: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], runtimePaneTitlesByTabId: {}, setFilterRepoIds: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx index 68165d560b33..761750719ddf 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-agent-expansion-coupling.test.tsx @@ -329,6 +329,8 @@ function setAgentLineageState(options: { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx index 1af8aa3511ee..9f61355d6897 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.lineage-child-real-card.test.tsx @@ -308,6 +308,8 @@ function setLineageState( reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx index d091d8213deb..ae5d44f76969 100644 --- a/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx +++ b/src/renderer/src/components/sidebar/WorktreeList.status-lane-lineage-drop.test.tsx @@ -277,6 +277,8 @@ function setStatusLaneState(): void { reorderRepos: vi.fn(), reportVisibleGitHubPRRefreshCandidates: vi.fn(), setVisibleReviewCardWorktreeIds: mockStore.setVisibleReviewCardWorktreeIds, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [repo], retainedAgentsByPaneKey: {}, revealWorktreeInSidebar: vi.fn(), diff --git a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx index a3cb16e27be0..8906b7a354f2 100644 --- a/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx +++ b/src/renderer/src/components/sidebar/delete-worktree-dirty-change-hydration.test.tsx @@ -20,6 +20,8 @@ import { getRuntimeGitStatus } from '@/runtime/runtime-git-status-client' import { getWorktreeHostIdentity } from '../../../../shared/worktree/host-qualified-identity' const state = vi.hoisted(() => ({ + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], settings: null, activeWorktreeId: null, diff --git a/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts b/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts index 6b122a41ada8..bd6466654266 100644 --- a/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts +++ b/src/renderer/src/components/sidebar/worktree-list-lineage-card-test-harness.ts @@ -35,6 +35,8 @@ export async function renderWorktreeListMarkup(): Promise { export function createAppStoreModuleMock(): Record { const getMockState = (): Record => ({ detectedWorktreesByRepo: {}, + endpointProjects: [], + endpointProjectHostSetups: [], ...mockStore.state }) const useAppStore = ((selector: (state: Record) => unknown) => diff --git a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts index bad2645ee053..6c12acc55eaa 100644 --- a/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts +++ b/src/renderer/src/components/sidebar/worktree-list/grouping/cross-project-lineage.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest' +import { resolveProjectHostSetupProjection } from '../../../../../../shared/project-catalog-canonical-projection' import type { WorktreeLineage } from '../../../../../../shared/worktree/lineage-types' import { getWorktreeHostIdentity } from '../../../../../../shared/worktree/host-qualified-identity' import type { Worktree } from '../../../../../../shared/worktree/types' @@ -50,7 +51,7 @@ const worktreeMap = new Map([ [child.id, child] ]) -const grouping = { +const endpoint = { projects: [project], projectHostSetups: [repo, childRepo].map((checkout) => ({ ...projectHostSetups[0]!, @@ -61,6 +62,13 @@ const grouping = { })) } +const canonical = resolveProjectHostSetupProjection( + [repo, childRepo], + endpoint.projects, + endpoint.projectHostSetups +) +const grouping = { projects: canonical.projects, projectHostSetups: canonical.setups } + describe('cross-project sidebar lineage', () => { it('ranks by actual owner and keeps notices/actions there while counting displayed children', () => { const gammaRepo = { ...repo, id: 'gamma', displayName: 'Gamma' } @@ -128,7 +136,8 @@ describe('cross-project sidebar lineage', () => { } } const worktrees = [grandchild, child, pinnedParent] - const parentSection = `project:${project.id}::setup:${repo.id}` + const parentProjectId = canonical.setups.find((setup) => setup.repoId === repo.id)!.projectId + const parentSection = `project:${parentProjectId}` const plan = buildSidebarRowPlan( 'repo', worktrees, diff --git a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts index f19091f0249d..90ce98f0c9bd 100644 --- a/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts +++ b/src/renderer/src/components/tab-bar/TabBar.context-menu.test.ts @@ -35,6 +35,8 @@ const useAppStoreMock = vi.fn( activeTabId: string | null activeTabType: 'terminal' | 'editor' | 'browser' | 'simulator' | null gitStatusByWorktree: Record + endpointProjects: never[] + endpointProjectHostSetups: never[] repos: never[] worktreesByRepo: Record unifiedTabsByWorktree: Record @@ -51,6 +53,8 @@ const useAppStoreMock = vi.fn( activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, @@ -95,6 +99,8 @@ useAppStoreExport.getState = vi.fn(() => ({ activeTabId: appStoreSnapshot.activeTabId, activeTabType: appStoreSnapshot.activeTabType, gitStatusByWorktree: {}, + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, unifiedTabsByWorktree: appStoreSnapshot.unifiedTabsByWorktree, diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx index 37b86271caae..d12efe23ba6f 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.test.tsx @@ -78,6 +78,7 @@ describe('TabBar worktree-write gate (non-Windows client)', () => { ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }) }) diff --git a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx index fe94aa5dd405..ace7eb5153a3 100644 --- a/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx +++ b/src/renderer/src/components/tab-bar/TabBar.worktree-write-gate.windows.test.tsx @@ -59,6 +59,7 @@ describe('TabBar worktree-write gate (Windows client)', () => { ;(await createTabBarProbeStore()).setState({ worktreesByRepo: {}, endpointProjects: [], + endpointProjectHostSetups: [], repos: [] }) }) diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts index 2ca6065c8eb6..1581645fff13 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.test.ts @@ -602,6 +602,7 @@ describe('lookupTabSessionHistoryRow', () => { }) expect(listSessions).toHaveBeenCalledTimes(1) expect(listSessions).toHaveBeenCalledWith({ + historyOwnership: {}, includeAntigravityIdeSessions: true, limit: 250, unlimited: false, diff --git a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts index a9007486f7dc..b2cf982c68d9 100644 --- a/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts +++ b/src/renderer/src/components/tab-bar/use-tab-bar-runtime-model-worktree-write-probe.ts @@ -16,6 +16,7 @@ export type TabBarProbeState = { activeRepoId: string | null activeWorktreeId: string | null endpointProjects: unknown[] + endpointProjectHostSetups: unknown[] repos: unknown[] worktreesByRepo: Record sshConnectionStates: Map @@ -48,6 +49,7 @@ export async function createTabBarProbeStore(): Promise { activeRepoId: null, activeWorktreeId: null, endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, sshConnectionStates: new Map(), diff --git a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts index 18707aaa0deb..555668f538dd 100644 --- a/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-agent-session-fork.test.ts @@ -1,4 +1,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../../shared/repo-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants' @@ -13,15 +16,15 @@ const mockToast = { const mockWriteClipboardText = vi.fn(async () => undefined) const LEAF_ID = '11111111-1111-4111-8111-111111111111' +const repos: Repo[] = [] +const endpointProjects: Project[] = [] +const endpointProjectHostSetups: ProjectHostSetup[] = [] const store = { activeRepoId: 'repo-1', activeWorktreeId: 'wt-1', - projects: [] as { - id: string - sourceRepoIds: string[] - localWindowsRuntimePreference?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } - }[], - repos: [] as { id: string; kind?: 'git' | 'folder'; connectionId?: string | null }[], + endpointProjects, + endpointProjectHostSetups, + repos, settings: {} as { localWindowsRuntimeDefault?: { kind: 'windows-host' } | { kind: 'wsl'; distro: string } }, @@ -78,13 +81,19 @@ describe('forkAgentSessionFromPane', () => { vi.clearAllMocks() store.activeRepoId = 'repo-1' store.activeWorktreeId = 'wt-1' - store.projects = [ + store.repos = [ { id: 'repo-1', - sourceRepoIds: ['repo-1'] + path: 'C:\\repo', + displayName: 'Repo', + badgeColor: '', + addedAt: 0, + kind: 'git' } ] - store.repos = [{ id: 'repo-1', kind: 'git' }] + const catalog = projectHostSetupProjectionFromRepos(store.repos) + store.endpointProjects = [...catalog.projects] + store.endpointProjectHostSetups = [...catalog.setups] store.settings = { localWindowsRuntimeDefault: { kind: 'windows-host' } } store.worktreesByRepo = { 'repo-1': [ @@ -282,7 +291,7 @@ describe('forkAgentSessionFromPane', () => { ) it('uses Linux startup quoting for SSH workspaces', async () => { - store.repos = [{ id: 'repo-1', kind: 'git', connectionId: 'ssh-1' }] + store.repos = [{ ...store.repos[0], connectionId: 'ssh-1' }] store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'codex' } } @@ -339,13 +348,10 @@ describe('forkAgentSessionFromPane', () => { }) it('uses Linux startup quoting when a Windows-path project is forced to WSL', async () => { - store.projects = [ - { - id: 'repo-1', - sourceRepoIds: ['repo-1'], - localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } - } - ] + store.endpointProjects = store.endpointProjects.map((project) => ({ + ...project, + localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } + })) store.agentStatusByPaneKey = { [`tab-1:${LEAF_ID}`]: { agentType: 'pi' } } @@ -483,7 +489,7 @@ describe('forkAgentSessionFromPane', () => { }) it('does not create a worktree from a folder-only source workspace', async () => { - store.repos = [{ id: 'repo-1', kind: 'folder' }] + store.repos = [{ ...store.repos[0], kind: 'folder' }] store.getKnownWorktreeById.mockReturnValue({ id: 'wt-1', repoId: 'repo-1', diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index bb6d4265488a..6e695e73939e 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -207,6 +207,7 @@ describe('useComposerState integrated lifecycle', () => { terminalWindowsShell: 'powershell.exe', localWindowsRuntimeDefault: { kind: 'windows-host' as const }, agentDefaultArgs: { claude: '', codex: '' }, + agentPermissionDefault: 'own', agentDefaultEnv: { claude: {}, codex: {} } } useAppStore.setState({ diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index e513ebab0d56..ab0a60187dc9 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -65,6 +65,7 @@ function makeState(args: { localWindowsRuntimeDefault: { kind: 'windows-host' }, ...(args.terminalWindowsShell ? { terminalWindowsShell: args.terminalWindowsShell } : {}), agentDefaultArgs: { claude: '', codex: '' }, + agentPermissionDefault: 'own', agentDefaultEnv: { claude: {}, codex: {} } }, worktreesByRepo: { @@ -212,6 +213,7 @@ describe('ai vault resume command runtime', () => { it('follows the live Windows shell for Cursor resume', () => { const state = makeState({ worktreePath: 'C:\\Users\\alice\\repo' }) + state.settings.agentPermissionOverrides = { cursor: 'full-access' } expect( buildQueuedAiVaultResumeCommand({ diff --git a/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts index b42581fa0a69..ddab1c720094 100644 --- a/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts +++ b/src/renderer/src/lib/launch-parity-renderer.test-fixture.ts @@ -1,6 +1,8 @@ // Test-only harness shared by the renderer launch-parity suites: per-client module loading and a // real store seeded with one workspace. Each suite still declares its own vi.mock calls. import { vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../shared/project-host-setup-projection' import type * as StoreHelpersNamespace from '@/store/slices/store-test-helpers' import type * as CapabilitiesNamespace from '@/runtime/local-runtime-capabilities' import { @@ -71,7 +73,7 @@ export function launchWorkspaceState( settings: Record ): Record { const repoPath = workspace.kind === 'repo' ? workspace.path : workspace.repoPath - const repo = repoPath + const repo: Repo | null = repoPath ? { id: LAUNCH_REPO_ID, path: repoPath, @@ -102,24 +104,17 @@ export function launchWorkspaceState( sortOrder: 0, lastActivityAt: 0 } + const catalog = projectHostSetupProjectionFromRepos(repo ? [repo] : []) return { settings, repos: repo ? [repo] : [], - projects: repo - ? [ - { - id: LAUNCH_REPO_ID, - displayName: 'repo', - badgeColor: '#000000', - sourceRepoIds: [LAUNCH_REPO_ID], - createdAt: 0, - updatedAt: 0, - ...(workspace.projectRuntime - ? { localWindowsRuntimePreference: workspace.projectRuntime } - : {}) - } - ] - : [], + endpointProjects: catalog.projects.map((project) => ({ + ...project, + ...(workspace.projectRuntime + ? { localWindowsRuntimePreference: workspace.projectRuntime } + : {}) + })), + endpointProjectHostSetups: catalog.setups, worktreesByRepo: repo ? { [LAUNCH_REPO_ID]: [worktree] } : {}, folderWorkspaces: workspace.kind === 'folder' diff --git a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts index 3d9494de9879..a859ad6a1b39 100644 --- a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts @@ -1,4 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Repo } from '../../../shared/repo-types' +import type { Project, ProjectHostSetup } from '../../../shared/project-types' import type { RuntimeEnvironmentStatus } from '../../../shared/runtime-host-status' import type { SshConnectionState } from '../../../shared/ssh-types' import type * as TuiAgentStartupModule from '@/lib/tui-agent-startup' @@ -85,11 +87,15 @@ type HostCase = { function setStore(hostCase: HostCase): void { mocks.clientPlatform = hostCase.client + const repos: Repo[] = [ + { id: 'repo-1', displayName: 'Repo', badgeColor: '#000000', addedAt: 1, ...hostCase.repo } + ] mocks.store = { - repos: [{ id: 'repo-1', displayName: 'Repo', addedAt: 1, ...hostCase.repo }], + repos, activeRepoId: null, activeWorktreeId: null, - projects: [], + endpointProjects: [], + endpointProjectHostSetups: [], projectGroups: [], folderWorkspaces: [], worktreesByRepo: {}, @@ -221,7 +227,21 @@ describe('launchWorkItemDirect quotes for the execution host (#27971)', () => { it("quotes a local Windows project's WSL runtime for that distro", async () => { setStore({ name: 'wsl', client: 'win32', repo: { path: 'C:\\repo' }, expected: 'linux' }) - mocks.store.projects = [ + mocks.store.endpointProjectHostSetups = [ + { + id: 'repo-1', + projectId: 'repo-1', + repoId: 'repo-1', + path: 'C:\\repo', + displayName: 'Repo', + hostId: 'local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] satisfies ProjectHostSetup[] + mocks.store.endpointProjects = [ { id: 'repo-1', displayName: 'Repo', @@ -231,7 +251,7 @@ describe('launchWorkItemDirect quotes for the execution host (#27971)', () => { updatedAt: 1, localWindowsRuntimePreference: { kind: 'wsl', distro: 'Ubuntu' } } - ] + ] satisfies Project[] mocks.createWorktree.mockResolvedValue({ worktree: { id: 'repo-1::C:\\repo-wt', path: 'C:\\repo-wt' } }) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index a5084518e482..a947a5346617 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -177,12 +177,27 @@ describe('launchWorkItemDirect', () => { id: 'repo-1', path: '/repo', displayName: 'Repo', + badgeColor: '#000000', addedAt: 1 } - ], + ] satisfies AppState['repos'], activeRepoId: 'repo-1', activeWorktreeId: null, - projects: [ + endpointProjectHostSetups: [ + { + id: 'repo-1', + projectId: 'repo-1', + repoId: 'repo-1', + path: '/repo', + displayName: 'Repo', + hostId: 'local', + setupState: 'ready', + setupMethod: 'legacy-repo', + createdAt: 1, + updatedAt: 1 + } + ] satisfies AppState['endpointProjectHostSetups'], + endpointProjects: [ { id: 'repo-1', displayName: 'Repo', @@ -191,7 +206,7 @@ describe('launchWorkItemDirect', () => { createdAt: 1, updatedAt: 1 } - ], + ] satisfies AppState['endpointProjects'], worktreesByRepo: {}, settings: { defaultTuiAgent: 'codex', diff --git a/src/renderer/src/lib/local-preflight-context-cache.test.ts b/src/renderer/src/lib/local-preflight-context-cache.test.ts index 4a9cec278121..9f26048caade 100644 --- a/src/renderer/src/lib/local-preflight-context-cache.test.ts +++ b/src/renderer/src/lib/local-preflight-context-cache.test.ts @@ -22,6 +22,8 @@ function makeRepos(id: string, path: string): AppState['repos'] { function makeWslState(distro: string): AppState { return { + endpointProjects: [], + endpointProjectHostSetups: [], activeRepoId: 'repo-1', activeWorktreeId: null, repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`), @@ -31,6 +33,8 @@ function makeWslState(distro: string): AppState { function makeWindowsProjectState(projectId: string): AppState { return { + endpointProjects: [], + endpointProjectHostSetups: [], activeRepoId: projectId, activeWorktreeId: null, repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`), diff --git a/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts b/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts index 4c8b812c7892..32ca36eafa7b 100644 --- a/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts +++ b/src/renderer/src/runtime/sync-runtime-graph-project-sections.test.ts @@ -40,8 +40,8 @@ const BASE_STATE = { agentStatusByPaneKey: {}, browserTabsByWorktree: {}, repos: [repo], - projects: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], projectOrderBy: 'recent' } diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index a343fe2c59c9..49e29e921c3a 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import { create } from 'zustand' import type { AppState } from '../types' import type { Repo } from '../../../../shared/repo-types' +import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection' import type { Worktree } from '../../../../shared/worktree/types' import { _getRemoteDetectPromiseCountForTest, createDetectedAgentsSlice } from './detected-agents' import { @@ -45,6 +46,8 @@ function createTestStore(initial?: Partial) { }) as AppState ) store.setState({ + endpointProjects: [], + endpointProjectHostSetups: [], repos: [], worktreesByRepo: {}, activeRepoId: null, @@ -132,10 +135,10 @@ describe('createDetectedAgentsSlice WSL context', () => { runtime: { kind: 'wsl', hostPlatform: 'wsl', - projectId: 'repo-1', + projectId: 'repo:repo-1', distro: 'Ubuntu', reason: 'project-override', - cacheKey: 'repo-1:wsl:Ubuntu' + cacheKey: 'repo:repo-1:wsl:Ubuntu' } } }) @@ -373,13 +376,16 @@ describe('createDetectedAgentsSlice WSL context', () => { }) it('detects agents in the project override runtime instead of legacy agent location', async () => { + const repos = [makeRepo({ id: 'repo-1', path: 'C:\\repo' })] + const { setups } = projectHostSetupProjectionFromRepos(repos) const store = createTestStore({ settings: { localWindowsRuntimeDefault: { kind: 'wsl', distro: 'Ubuntu' }, localAgentRuntime: 'wsl', localAgentWslDistro: 'Ubuntu' } as AppState['settings'], - projects: [ + endpointProjectHostSetups: setups.map((setup) => ({ ...setup, projectId: 'repo-1' })), + endpointProjects: [ { id: 'repo-1', displayName: 'repo-1', @@ -390,7 +396,7 @@ describe('createDetectedAgentsSlice WSL context', () => { localWindowsRuntimePreference: { kind: 'windows-host' } } ], - repos: [makeRepo({ id: 'repo-1', path: 'C:\\repo' })], + repos, activeRepoId: 'repo-1', activeWorktreeId: null } as Partial) diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index 3f6de017adfb..a06bf4bc49a6 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -49,6 +49,9 @@ function createTestStore() { return create()( (...a) => ({ + repos: [], + endpointProjects: [], + endpointProjectHostSetups: [], ...createPreflightSlice(...a), ...createRuntimeStatusSlice(...a) }) as AppState @@ -247,10 +250,10 @@ describe('createPreflightSlice', () => { runtime: { kind: 'wsl', hostPlatform: 'wsl', - projectId: 'repo-1', + projectId: 'repo:repo-1', distro: 'Ubuntu', reason: 'project-override', - cacheKey: 'repo-1:wsl:Ubuntu' + cacheKey: 'repo:repo-1:wsl:Ubuntu' } }, wslDistro: 'Ubuntu' diff --git a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts index 59a8bbc05e9a..e8e1f5293274 100644 --- a/src/renderer/src/store/slices/worktrees-create-base-status.test.ts +++ b/src/renderer/src/store/slices/worktrees-create-base-status.test.ts @@ -273,6 +273,8 @@ describe('createWorktree base status merge', () => { expect(mockApi.worktrees.create).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['repo-remote']?.[0]).toEqual({ ...created, + projectId: 'repo:repo-remote', + projectHostSetupId: 'repo-remote', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts index b93bfd9b4e4c..76093016fd7b 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts @@ -164,7 +164,10 @@ describe('fetchWorktrees', () => { }) ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().worktreesByRepo['same-repo']).toEqual([remote, local]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + remote, + { ...local, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' } + ]) expect(store.getState().detectedWorktreesByRepo['same-repo']?.worktrees).toEqual( expect.arrayContaining([ expect.objectContaining({ id: remote.id, hostId: 'runtime:env-1' }), @@ -210,7 +213,12 @@ describe('fetchWorktrees', () => { expect(runtimeEnvironmentCall).not.toHaveBeenCalled() // Why: SSH worktrees are fetched via local IPC but belong to the SSH host, so they carry the repo's ssh host id. expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { + ...sshWorktree, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) }) @@ -255,7 +263,9 @@ describe('fetchWorktrees', () => { }) ) expect(runtimeEnvironmentCall).not.toHaveBeenCalled() - expect(store.getState().worktreesByRepo['same-repo']).toEqual([localWorktree]) + expect(store.getState().worktreesByRepo['same-repo']).toEqual([ + { ...localWorktree, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' } + ]) }) it('honors an explicit runtime owner before the repo catalog is hydrated', async () => { @@ -290,7 +300,11 @@ describe('fetchWorktrees', () => { }) expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() expect(store.getState().worktreesByRepo['repo-missing']).toEqual([ - { ...remote, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + { + ...remote, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1' + } ]) }) @@ -438,7 +452,13 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('repo-remote', { forceLocalOwner: true }) expect(store.getState().worktreesByRepo['repo-remote']).toEqual([ - { ...remote, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + { + ...remote, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1', + projectId: 'repo:repo-remote', + projectHostSetupId: 'repo-remote' + } ]) }) @@ -499,7 +519,13 @@ describe('fetchWorktrees', () => { await expect(staleRefresh).resolves.toBe(false) expect(store.getState().worktreesByRepo['repo-remote']).toEqual([ - { ...fresh, hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } + { + ...fresh, + hostId: 'runtime:env-1', + runtimeOwnerEnvironmentId: 'env-1', + projectId: 'repo:repo-remote', + projectHostSetupId: 'repo-remote' + } ]) expect(mockApi.worktrees.listDetected).not.toHaveBeenCalled() }) @@ -524,7 +550,17 @@ describe('fetchWorktrees', () => { executionHostId: 'runtime:env-1' } ], - projectHostSetups: [ + endpointProjects: [ + { + id: 'github:stablyai/orca', + displayName: 'orca', + badgeColor: '#000', + sourceRepoIds: ['repo-remote'], + createdAt: 1, + updatedAt: 1 + } + ], + endpointProjectHostSetups: [ { id: 'repo-remote', projectId: 'github:stablyai/orca', diff --git a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts index ca4cb4584b32..7b0c3351e023 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-persisted-metadata-fallback.test.ts @@ -84,7 +84,7 @@ describe('fetchWorktrees', () => { await expect(store.getState().fetchWorktrees(sshRepo.id)).resolves.toBe(false) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...queued, hostId: 'ssh:ssh-1' } + { ...queued, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } ]) expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ repoId: sshRepo.id, @@ -136,7 +136,7 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ existing, - { ...queued, hostId: 'ssh:ssh-1' } + { ...queued, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } ]) }) @@ -187,7 +187,12 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ sshExisting, - { ...queued, hostId: 'ssh:ssh-1' }, + { + ...queued, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-shared', + projectHostSetupId: 'repo-shared' + }, localExisting ]) }) @@ -286,14 +291,16 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(sshRepo.id) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...stale, hostId: 'ssh:ssh-1' } + { ...stale, hostId: 'ssh:ssh-1', projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } ]) worktreeListMock.mockResolvedValueOnce([live]) store.setState({ sshConnectionStates: connectedStates } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + ]) }) it('keeps the repo detection entry authoritative while appending metadata rows', async () => { @@ -381,21 +388,30 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(sshRepo.id) expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...deletedOnRemote, hostId: 'ssh:ssh-1' } + { + ...deletedOnRemote, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) // The host connects and an authoritative scan proves the worktree is gone. worktreeListMock.mockResolvedValueOnce([live]) store.setState({ sshConnectionStates: connectedStates } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + ]) // The host drops again; persisted metadata still lists the deleted worktree. listKnownForExecutionHostMock.mockResolvedValueOnce(metadataResult()) store.setState({ sshConnectionStates: new Map() } as Partial) await store.getState().fetchWorktrees(sshRepo.id) - expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([live]) + expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ + { ...live, projectId: 'repo:repo-ssh', projectHostSetupId: 'repo-ssh' } + ]) }) it('retires persisted metadata for worktrees an authoritative SSH scan proved gone', async () => { @@ -474,7 +490,9 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees(localRepo.id) - expect(store.getState().worktreesByRepo[localRepo.id]).toEqual([survivor]) + expect(store.getState().worktreesByRepo[localRepo.id]).toEqual([ + { ...survivor, projectId: 'repo:repo-local', projectHostSetupId: 'repo-local' } + ]) // Local metas are GC-eligible on their own; only the SSH exemption needs this IPC. expect(forgetRemovedForExecutionHostMock).not.toHaveBeenCalled() }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts index 2c294dc9d4c7..2668f0968f4a 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-refresh-coalescing.test.ts @@ -352,8 +352,8 @@ describe('fetchWorktrees', () => { await refresh expect(store.getState().worktreesByRepo.repo1).toEqual([ - localWorktree, - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { ...localWorktree, projectId: 'repo:repo1', projectHostSetupId: 'repo1' }, + { ...sshWorktree, hostId: 'ssh:ssh-1', projectId: 'repo:repo1', projectHostSetupId: 'repo1' } ]) expect(mockApi.worktrees.listDetected).toHaveBeenCalledTimes(2) }) @@ -402,7 +402,12 @@ describe('fetchWorktrees', () => { expect(visibleResult).toBe(true) expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { + ...sshWorktree, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) expect(store.getState().detectedWorktreesByRepo['repo-ssh']?.worktrees).toEqual([ expect.objectContaining({ id: sshWorktree.id, hostId: 'ssh:ssh-1' }) @@ -454,7 +459,12 @@ describe('fetchWorktrees', () => { expect(visibleResult).toBe(true) expect(store.getState().worktreesByRepo['repo-ssh']).toEqual([ - { ...sshWorktree, hostId: 'ssh:ssh-1' } + { + ...sshWorktree, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) expect(store.getState().detectedWorktreesByRepo['repo-ssh']?.worktrees).toEqual([ expect.objectContaining({ id: sshWorktree.id, hostId: 'ssh:ssh-1' }) diff --git a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts index 284f41983581..f71743aea7af 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-remote-lineage.test.ts @@ -82,7 +82,9 @@ describe('fetchWorktrees', () => { await store.getState().fetchWorktrees('repo1') - expect(store.getState().worktreesByRepo.repo1).toEqual([refreshed]) + expect(store.getState().worktreesByRepo.repo1).toEqual([ + { ...refreshed, projectId: 'repo:repo1', projectHostSetupId: 'repo1' } + ]) expect(store.getState().worktreeLineageById).toEqual({ [lineage.worktreeId]: lineage }) expect(store.getState().sortEpoch).toBe(8) }) @@ -116,7 +118,9 @@ describe('fetchWorktrees', () => { path: '/remote/wt1', branch: 'refs/heads/remote', hostId: 'runtime:env-1', - runtimeOwnerEnvironmentId: 'env-1' + runtimeOwnerEnvironmentId: 'env-1', + projectId: 'repo:repo1', + projectHostSetupId: 'repo1' }) const staleLineage = makeLineage({ worktreeId: worktree.id, @@ -528,6 +532,8 @@ describe('fetchWorktrees', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([ { ...refreshed, + projectId: 'repo:repo1', + projectHostSetupId: 'repo1', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } diff --git a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts index b5fef2ccb326..d09591186fa7 100644 --- a/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts +++ b/src/renderer/src/store/slices/worktrees-hydration-purge.test.ts @@ -104,7 +104,12 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { await store.getState().fetchAllWorktrees() expect(store.getState().worktreesByRepo[sshRepo.id]).toEqual([ - { ...queued, hostId: 'ssh:ssh-1' } + { + ...queued, + hostId: 'ssh:ssh-1', + projectId: 'repo:repo-ssh', + projectHostSetupId: 'repo-ssh' + } ]) expect(listKnownForExecutionHostMock).toHaveBeenCalledWith({ repoId: sshRepo.id, @@ -477,9 +482,11 @@ describe('fetchAllWorktrees hydration-time purge (design §4.4)', () => { expect(refreshed).toHaveLength(2) expect(refreshed).toEqual( expect.arrayContaining([ - localWorktree, + { ...localWorktree, projectId: 'repo:same-repo', projectHostSetupId: 'same-repo' }, { ...refreshedRemoteWorktree, + projectId: 'repo:same-repo', + projectHostSetupId: 'same-repo', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } diff --git a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts index 242c6ad30eb8..8d9dda5263f2 100644 --- a/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts +++ b/src/renderer/src/store/slices/worktrees-remote-runtime-create.test.ts @@ -104,6 +104,8 @@ describe('worktree remote runtime mutations', () => { expect(store.getState().worktreesByRepo.repo1).toEqual([ { ...wt, + projectId: 'repo:repo1', + projectHostSetupId: 'repo1', hostId: 'runtime:env-1', runtimeOwnerEnvironmentId: 'env-1' } diff --git a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts index cff49aff22ca..9a6053fd2488 100644 --- a/src/renderer/src/store/slices/worktrees-slice-test-harness.ts +++ b/src/renderer/src/store/slices/worktrees-slice-test-harness.ts @@ -150,7 +150,8 @@ export function createTestStore() { ] ]), repos: [], - projectHostSetups: [], + endpointProjects: [], + endpointProjectHostSetups: [], deleteProjectHostSetup: vi.fn().mockResolvedValue(null), updateFolderWorkspace: vi.fn().mockResolvedValue(true), updateSettings: vi.fn().mockResolvedValue(undefined), diff --git a/src/shared/launch-parity-window-request.test-fixture.ts b/src/shared/launch-parity-window-request.test-fixture.ts index 49d6896e501f..58d2125a63f0 100644 --- a/src/shared/launch-parity-window-request.test-fixture.ts +++ b/src/shared/launch-parity-window-request.test-fixture.ts @@ -103,7 +103,7 @@ const AGENT_ARGS = { } as const function sentRuntime(runtime: ProjectRuntimeSent): Record { - const base = { projectId: LAUNCH_REPO_ID, reason: runtime.reason } + const base = { projectId: `repo:${LAUNCH_REPO_ID}`, reason: runtime.reason } return { status: 'resolved', runtime: @@ -113,13 +113,13 @@ function sentRuntime(runtime: ProjectRuntimeSent): Record { kind: 'wsl', hostPlatform: 'wsl', distro: runtime.distro, - cacheKey: `${LAUNCH_REPO_ID}:wsl:${runtime.distro}` + cacheKey: `repo:${LAUNCH_REPO_ID}:wsl:${runtime.distro}` } : { ...base, kind: 'windows-host', hostPlatform: 'win32', - cacheKey: `${LAUNCH_REPO_ID}:windows-host` + cacheKey: `repo:${LAUNCH_REPO_ID}:windows-host` } } } From 54b072d2a589419937a9edec88a3e1e29061cabc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Fri, 23 Oct 2026 22:21:51 -0700 Subject: [PATCH 21/29] test: check catalog and runtime fixture inputs --- .../automations-page-test-harness.tsx | 9 ++++++--- .../hooks/useComposerState.integration.test.ts | 6 ++++-- .../src/lib/ai-vault-resume-command.test.ts | 4 +++- ...aunch-work-item-direct-host-platform.test.ts | 2 +- .../lib/local-preflight-context-cache.test.ts | 13 ++++++++----- src/renderer/src/store/slices/preflight.test.ts | 17 +---------------- 6 files changed, 23 insertions(+), 28 deletions(-) diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index de6a9c16f560..bb3505f49261 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -1,4 +1,4 @@ -import type { ProjectHostSetup } from '../../../../shared/project-types' +import type { Project, ProjectHostSetup } from '../../../../shared/project-types' /* oxlint-disable anti-slop/no-module-mocking -- Vitest support module for the 10 AutomationsPage specs, not shipped code, and it falls outside the *.test / *.spec / tests glob set. Inlining these 13 stubs would duplicate them into all 10 specs and push the largest past the max-lines ratchet. */ @@ -105,7 +105,10 @@ export type DeleteDialogProps = { } type AutomationsPageMocks = { - state: Record & { endpointProjectHostSetups: ProjectHostSetup[] } + state: Record & { + endpointProjects: Project[] + endpointProjectHostSetups: ProjectHostSetup[] + } repoMap: Map worktreeMap: Map listPanel: ListPanelProps | null @@ -124,7 +127,7 @@ type AutomationsPageMocks = { // Not `vi.hoisted`: the mock factories below only close over this, and a hoisted // binding cannot be exported. export const mocks: AutomationsPageMocks = { - state: { endpointProjectHostSetups: [] }, + state: { endpointProjects: [], endpointProjectHostSetups: [] }, repoMap: new Map(), worktreeMap: new Map(), listPanel: null, diff --git a/src/renderer/src/hooks/useComposerState.integration.test.ts b/src/renderer/src/hooks/useComposerState.integration.test.ts index 6e695e73939e..b165e6698282 100644 --- a/src/renderer/src/hooks/useComposerState.integration.test.ts +++ b/src/renderer/src/hooks/useComposerState.integration.test.ts @@ -5,6 +5,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { useAppStore } from '@/store' import type * as NewWorkspace from '@/lib/new-workspace' import { getDefaultSettings } from '../../../shared/constants' +import type { GlobalSettings } from '../../../shared/global-settings-types' +import type { AppState } from '@/store/types' vi.mock('@/lib/new-workspace', async (importOriginal) => ({ ...(await importOriginal()), @@ -202,7 +204,7 @@ describe('useComposerState integrated lifecycle', () => { useAppStore.setState(useAppStore.getInitialState(), true) const repo: Repo = { id: 'b', path: 'C:/app-b', displayName: 'B', badgeColor: '', addedAt: 1 } const catalog = projectHostSetupProjectionFromRepos([repo]) - const settings = { + const settings: GlobalSettings = { ...getDefaultSettings('/tmp'), terminalWindowsShell: 'powershell.exe', localWindowsRuntimeDefault: { kind: 'windows-host' as const }, @@ -258,7 +260,7 @@ describe('useComposerState integrated lifecycle', () => { for (const windows of [false, true]) { if (windows) { act(() => - useAppStore.setState((state) => ({ + useAppStore.setState((state): Partial => ({ endpointProjects: state.endpointProjects.map((project) => ({ ...project, localWindowsRuntimePreference: { kind: 'windows-host' } diff --git a/src/renderer/src/lib/ai-vault-resume-command.test.ts b/src/renderer/src/lib/ai-vault-resume-command.test.ts index ab0a60187dc9..221284a466d7 100644 --- a/src/renderer/src/lib/ai-vault-resume-command.test.ts +++ b/src/renderer/src/lib/ai-vault-resume-command.test.ts @@ -26,7 +26,9 @@ function makeState(args: { worktreePath: string localWindowsRuntimePreference?: RuntimePreference terminalWindowsShell?: string -}): Extract { +}): Extract & { + settings: NonNullable +} { return { activeRepoId: 'repo-1', activeWorktreeId: 'repo-1::worktree-1', diff --git a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts index a859ad6a1b39..f8c35c33295e 100644 --- a/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct-host-platform.test.ts @@ -79,7 +79,7 @@ function sshState(remotePlatform: 'linux' | 'win32'): SshConnectionState { type HostCase = { name: string client: NodeJS.Platform - repo: { path: string; connectionId?: string; executionHostId?: string } + repo: Pick ssh?: Map runtime?: Map expected: NodeJS.Platform diff --git a/src/renderer/src/lib/local-preflight-context-cache.test.ts b/src/renderer/src/lib/local-preflight-context-cache.test.ts index 9f26048caade..b2007a702503 100644 --- a/src/renderer/src/lib/local-preflight-context-cache.test.ts +++ b/src/renderer/src/lib/local-preflight-context-cache.test.ts @@ -20,27 +20,30 @@ function makeRepos(id: string, path: string): AppState['repos'] { return [{ id, path, displayName: id, badgeColor: 'blue', addedAt: 1 }] } -function makeWslState(distro: string): AppState { +function makeWslState(distro: string): Parameters[0] { return { endpointProjects: [], endpointProjectHostSetups: [], activeRepoId: 'repo-1', activeWorktreeId: null, repos: makeRepos('repo-1', `\\\\wsl.localhost\\${distro}\\home\\alice\\repo`), + settings: null, worktreesByRepo: {} - } as AppState + } } -function makeWindowsProjectState(projectId: string): AppState { +function makeWindowsProjectState( + projectId: string +): Parameters[0] { return { endpointProjects: [], endpointProjectHostSetups: [], activeRepoId: projectId, activeWorktreeId: null, repos: makeRepos(projectId, `C:\\Users\\alice\\${projectId}`), - settings: {}, + settings: null, worktreesByRepo: {} - } as AppState + } } describe('local preflight context caches', () => { diff --git a/src/renderer/src/store/slices/preflight.test.ts b/src/renderer/src/store/slices/preflight.test.ts index a06bf4bc49a6..50e8e9cc2f03 100644 --- a/src/renderer/src/store/slices/preflight.test.ts +++ b/src/renderer/src/store/slices/preflight.test.ts @@ -1,12 +1,10 @@ import { describe, expect, it, vi } from 'vitest' -import { create } from 'zustand' import type { PreflightStatus } from '../../../../preload/api-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getDefaultSettings } from '../../../../shared/constants' import type { AppState } from '../types' -import { createPreflightSlice } from './preflight' -import { createRuntimeStatusSlice } from './runtime-status' +import { createTestStore } from './store-test-helpers' import { resetRendererAppPlatformCacheForTests } from '@/lib/renderer-app-platform' const preflightCheck = vi.fn() @@ -45,19 +43,6 @@ globalThis.window = { } as unknown as Window['api'] } as Window & typeof globalThis -function createTestStore() { - return create()( - (...a) => - ({ - repos: [], - endpointProjects: [], - endpointProjectHostSetups: [], - ...createPreflightSlice(...a), - ...createRuntimeStatusSlice(...a) - }) as AppState - ) -} - function resetPreflightMocks(): void { preflightCheck.mockReset() callRuntimeRpc.mockReset() From 0aa1dd25a6a28403f00c800f15cb28ce7475d8dd Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:25:43 -0700 Subject: [PATCH 22/29] test: check catalog fixtures without outer assertions --- src/renderer/src/lib/launch-work-item-direct.test.ts | 2 +- src/renderer/src/store/slices/detected-agents.test.ts | 4 ++-- .../src/store/slices/worktrees-fetch-owner-routing.test.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/renderer/src/lib/launch-work-item-direct.test.ts b/src/renderer/src/lib/launch-work-item-direct.test.ts index a947a5346617..497d18a83309 100644 --- a/src/renderer/src/lib/launch-work-item-direct.test.ts +++ b/src/renderer/src/lib/launch-work-item-direct.test.ts @@ -221,7 +221,7 @@ describe('launchWorkItemDirect', () => { seedNativeChatLaunchPrompt: mocks.seedNativeChatLaunchPrompt, seedNativeChatLaunchDraft: mocks.seedNativeChatLaunchDraft, markNativeChatLaunchPromptFailed: mocks.markNativeChatLaunchPromptFailed - } as typeof mocks.store + } // Why: SSH fixtures use target `ssh-1`, which reports a Linux host once connected. mocks.store.sshConnectionStates = new Map([ [ diff --git a/src/renderer/src/store/slices/detected-agents.test.ts b/src/renderer/src/store/slices/detected-agents.test.ts index 49e29e921c3a..93296213c91e 100644 --- a/src/renderer/src/store/slices/detected-agents.test.ts +++ b/src/renderer/src/store/slices/detected-agents.test.ts @@ -53,7 +53,7 @@ function createTestStore(initial?: Partial) { activeRepoId: null, activeWorktreeId: null, ...initial - } as Partial) + }) return store } @@ -399,7 +399,7 @@ describe('createDetectedAgentsSlice WSL context', () => { repos, activeRepoId: 'repo-1', activeWorktreeId: null - } as Partial) + }) await expect(store.getState().ensureDetectedAgents()).resolves.toEqual(['claude']) diff --git a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts index 76093016fd7b..154999e56fd6 100644 --- a/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts +++ b/src/renderer/src/store/slices/worktrees-fetch-owner-routing.test.ts @@ -574,7 +574,7 @@ describe('fetchWorktrees', () => { updatedAt: 1 } ] - } as Partial) + }) runtimeEnvironmentCall.mockResolvedValue({ id: 'rpc-runtime-worktree', ok: true, From 460e437e195c5044d90248e2abcbeaa33895149e Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sat, 24 Oct 2026 13:46:05 -0700 Subject: [PATCH 23/29] test: follow persistence harness relocation --- tests/e2e/ai-vault-history-publication.unit.test.ts | 2 +- tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts | 2 +- tests/e2e/project-runtime-handoff.unit.test.ts | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/e2e/ai-vault-history-publication.unit.test.ts b/tests/e2e/ai-vault-history-publication.unit.test.ts index 06d9025622e6..6cde04a12363 100644 --- a/tests/e2e/ai-vault-history-publication.unit.test.ts +++ b/tests/e2e/ai-vault-history-publication.unit.test.ts @@ -10,7 +10,7 @@ import { closeTestStores, createSqliteTestStore, writePersistedStateJson -} from '../../src/main/persistence-test-harness' +} from '../../src/main/persistence/persistence-test-harness' import { Store } from '../../src/main/persistence/loading-store/store' import { getDefaultPersistedState } from '../../src/shared/constants' import { hostWorktreeTarget } from '../../src/shared/worktree/host-qualified-identity' diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts index 508b2801bdc6..f84b1d78e6ad 100644 --- a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -9,7 +9,7 @@ import { closeTestStores, createSqliteTestStore, writePersistedStateJson -} from '../../src/main/persistence-test-harness' +} from '../../src/main/persistence/persistence-test-harness' import { Store } from '../../src/main/persistence/loading-store/store' import { getDefaultPersistedState } from '../../src/shared/constants' import type { Repo } from '../../src/shared/repo-types' diff --git a/tests/e2e/project-runtime-handoff.unit.test.ts b/tests/e2e/project-runtime-handoff.unit.test.ts index 60d95fc3a94e..2293863919fc 100644 --- a/tests/e2e/project-runtime-handoff.unit.test.ts +++ b/tests/e2e/project-runtime-handoff.unit.test.ts @@ -7,7 +7,7 @@ import { createSqliteTestStore, withPlatform, writePersistedStateJson -} from '../../src/main/persistence-test-harness' +} from '../../src/main/persistence/persistence-test-harness' import { Store } from '../../src/main/persistence/loading-store/store' import { rebuildRepoBackedProjectState } from '../../src/main/orca-profiles/profile-project-state-file' import { resolveLocalWorkspaceRuntime } from '../../src/main/local-project-runtime-resolution' From 697b6c916e7eb7e13ed5c83f4cdf797c5bb37e56 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:18:22 -0700 Subject: [PATCH 24/29] Adapt catalog test inputs to integrated main checks --- config/oxlint-js-plugins.json | 1 - .../ai-vault/session-scanner-service-search-process.test.ts | 4 ++-- src/renderer/src/store/slices/repos.test.ts | 4 ++-- tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts | 1 + 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/config/oxlint-js-plugins.json b/config/oxlint-js-plugins.json index 78ce2da58252..a6c8cf584cc3 100644 --- a/config/oxlint-js-plugins.json +++ b/config/oxlint-js-plugins.json @@ -54,7 +54,6 @@ { "files": [ "src/renderer/src/store/project-host-setup-selector.ts", - "src/renderer/src/store/project-search-catalog.ts", "src/renderer/src/store/repos/repo-catalog-identity.ts", "src/renderer/src/store/repos/repo-catalog-actions.ts", "src/renderer/src/store/repos/runtime-repo-catalog-actions.ts", diff --git a/src/main/ai-vault/session-scanner-service-search-process.test.ts b/src/main/ai-vault/session-scanner-service-search-process.test.ts index 2469246909eb..343728f0c403 100644 --- a/src/main/ai-vault/session-scanner-service-search-process.test.ts +++ b/src/main/ai-vault/session-scanner-service-search-process.test.ts @@ -34,7 +34,7 @@ it('serializes canonical ownership into a separate production scanner before nat const client = new AiVaultScannerServiceClient({ processFactory: () => { expect(child).toBeUndefined() - const startedAt = new Date().toISOString() + const startedAtMonotonicNs = process.hrtime.bigint().toString() child = spawnProcess({ program: executable, args: ['--max-old-space-size=384', '--conditions=orca-source', '--import', loader, entry], @@ -62,7 +62,7 @@ it('serializes canonical ownership into a separate production scanner before nat console.info('scanner child ownership', { pid: child.pid, parentPid: process.pid, - startedAt, + startedAtMonotonicNs, executable, entry }) diff --git a/src/renderer/src/store/slices/repos.test.ts b/src/renderer/src/store/slices/repos.test.ts index 7f151e39328c..2d7504003a62 100644 --- a/src/renderer/src/store/slices/repos.test.ts +++ b/src/renderer/src/store/slices/repos.test.ts @@ -336,8 +336,8 @@ describe('repo slice runtime routing', () => { expect(projectsSetupExistingFolder).not.toHaveBeenCalled() expect(runtimeEnvironmentCall).not.toHaveBeenCalled() expect(store.getState().repos).toBe(before.repos) - expect(store.getState().projects).toBe(before.projects) - expect(store.getState().projectHostSetups).toBe(before.projectHostSetups) + expect(store.getState().endpointProjects).toBe(before.endpointProjects) + expect(store.getState().endpointProjectHostSetups).toBe(before.endpointProjectHostSetups) expect(toast.error).toHaveBeenCalledWith('Failed to add project', { description: SERVER_SSH_REFUSAL, duration: expect.any(Number) diff --git a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts index f84b1d78e6ad..edef9d91ccc2 100644 --- a/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts +++ b/tests/e2e/ai-vault-project-source-lifecycle.unit.test.ts @@ -164,6 +164,7 @@ const baseWithin = z.discriminatedUnion('kind', [ const baseRequest = AiVaultSearchRequestSchema.safeExtend({ within: baseWithin.optional() }) it('pairs old discovery with canonical persistence, compatibility backfill, selected callers and actual legacy/native results', async () => { + vi.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000) const oldStore = await seededStore('old-source') const newStore = await seededStore('new-source') const raw = projectHostSetupCompatibilityFromRepos([a, b]) From a4e4e8d54a773dc162904320e31d868a94f39ef3 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:34:13 -0700 Subject: [PATCH 25/29] Test History hashing across cached depth revalidation --- tests/e2e/ai-vault-history-publication.unit.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/e2e/ai-vault-history-publication.unit.test.ts b/tests/e2e/ai-vault-history-publication.unit.test.ts index 6cde04a12363..e23a740c29fc 100644 --- a/tests/e2e/ai-vault-history-publication.unit.test.ts +++ b/tests/e2e/ai-vault-history-publication.unit.test.ts @@ -251,11 +251,12 @@ it('publishes post-cache ownership at an unchanged stamp through Unlimited RPC a expect(history.conditional.mock.calls.every(([, after]) => after === undefined)).toBe(true) await history.mount(250) + expect(history.listHashCalls()).toBe(1) history.setOwner('repo:c') await history.refresh() expect(history.outputs.at(-1)?.fingerprint).toMatch(/^[0-9a-f]{64}$/) expect(history.current().sessions[0].projectOwnerKey).toBe('repo:c') - expect(history.listHashCalls()).toBe(1) + expect(history.listHashCalls()).toBe(2) } finally { await history.close() } From cad26c0e4cad45f9cccd3d7c609a9293c59314dc Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 03:57:56 -0700 Subject: [PATCH 26/29] test(settings): expect host-qualified setup removal --- .../settings/RepositoryHostSetupsSection.claim.test.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx index 50f5a1b3e55c..f06863eb56d9 100644 --- a/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx +++ b/src/renderer/src/components/settings/RepositoryHostSetupsSection.claim.test.tsx @@ -183,7 +183,10 @@ it('reaches and edits/removes a reloaded handoff row through B Settings and conc ) expect(remove).toBeTruthy() await act(async () => remove?.dispatchEvent(new MouseEvent('click', { bubbles: true }))) - expect(deleteHostSetup).toHaveBeenCalledExactlyOnceWith({ setupId: 'pending-b' }) + expect(deleteHostSetup).toHaveBeenCalledExactlyOnceWith({ + setupId: 'pending-b', + hostId: pending.hostId + }) expect( useAppStore.getState().endpointProjectHostSetups.some((setup) => setup.id === 'pending-b') ).toBe(false) From 0c8bd2efc3b368179fb832898b51a4754118426d Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:01:04 -0700 Subject: [PATCH 27/29] test: capture host split listings and scope relay recovery --- .../helpers/host-split-and-sleep-journeys.ts | 102 +++++++++++++++--- .../e2e/ssh-orcad-legacy-relay-bridge.spec.ts | 4 +- 2 files changed, 91 insertions(+), 15 deletions(-) diff --git a/tests/e2e/helpers/host-split-and-sleep-journeys.ts b/tests/e2e/helpers/host-split-and-sleep-journeys.ts index 797adcd00359..d8fcd781d58b 100644 --- a/tests/e2e/helpers/host-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/host-split-and-sleep-journeys.ts @@ -40,6 +40,15 @@ export async function addHostCreatedSplitWorktree( activate: false, setupDecision: 'skip' }) + console.log( + '[host-split-created]', + JSON.stringify({ + repoId: added.result.repo.id, + worktreeId: created.result.worktree.id, + path: created.result.worktree.path, + branch: created.result.worktree.branch + }) + ) const terminals = await client.call('terminal.list', { worktree: `id:${created.result.worktree.id}` }) @@ -62,21 +71,86 @@ export async function addHostCreatedSplitWorktree( export async function openHostSplitWorktree(page: Page, userDataDir: string): Promise { let setupWorktreeId: string | undefined - await expect - .poll(async () => { - setupWorktreeId = await page.evaluate(async () => { - const store = window.__store! - await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) - for (const repo of store.getState().repos) { - await store.getState().fetchWorktrees(repo.id) - } - return Object.values(store.getState().worktreesByRepo) - .flat() - .find((worktree) => worktree.branch === 'refs/heads/parity-setup')?.id + let lastListing: unknown + try { + await expect + .poll(async () => { + const observed = await page.evaluate(async () => { + const store = window.__store! + await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) + const listings = [] + for (const repo of store.getState().repos) { + listings.push({ + repoId: repo.id, + fetchResult: await store.getState().fetchWorktrees(repo.id) + }) + } + const state = store.getState() + const worktrees = Object.values(state.worktreesByRepo).flat() + return { + setupWorktreeId: worktrees.find( + (worktree) => worktree.branch === 'refs/heads/parity-setup' + )?.id, + listings, + repos: state.repos.map(({ id, path, executionHostId, connectionId }) => ({ + id, + path, + executionHostId, + connectionId + })), + endpointProjects: state.endpointProjects.map(({ id, sourceRepoIds }) => ({ + id, + sourceRepoIds + })), + endpointSetups: state.endpointProjectHostSetups.map( + ({ id, repoId, projectId, hostId, executionHostId }) => ({ + id, + repoId, + projectId, + hostId, + executionHostId + }) + ), + detected: Object.entries(state.detectedWorktreesByRepo).map(([repoId, result]) => ({ + repoId, + authoritative: result.authoritative, + source: result.source, + catalogVersion: result.catalogVersion, + failureKind: result.failureKind, + worktrees: result.worktrees.map( + ({ id, branch, visible, ownership, hostId, projectId, projectHostSetupId }) => ({ + id, + branch, + visible, + ownership, + hostId, + projectId, + projectHostSetupId + }) + ) + })), + stored: worktrees.map( + ({ id, repoId, branch, hostId, projectId, projectHostSetupId }) => ({ + id, + repoId, + branch, + hostId, + projectId, + projectHostSetupId + }) + ), + catalogVersions: state.worktreeCatalogVersionByRepoHost + } + }) + setupWorktreeId = observed.setupWorktreeId + lastListing = observed + return setupWorktreeId }) - return setupWorktreeId - }) - .toBeDefined() + .toBeDefined() + } catch (error) { + console.log('[host-split-listing-failure]', JSON.stringify(lastListing)) + throw error + } const client = new RuntimeClient(userDataDir, 30_000) // Main has spawned the first terminal and its manual split before the user opens the worktree. let hostPtyIds: string[] = [] diff --git a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts index e775a164081d..98751b0b66ae 100644 --- a/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts +++ b/tests/e2e/ssh-orcad-legacy-relay-bridge.spec.ts @@ -216,7 +216,9 @@ async function verifyRunningRelayUpgrade( await expect .poll(() => managedServer(page, targetId), { timeout: 120_000 }) .toMatchObject({ kind: 'relay', reason: 'refused', canLeaveBehind: true }) - const move = page.getByRole('button', { name: 'Move anyway…', exact: true }) + const move = page + .locator('[data-terminal-ssh-reconnect-banner]') + .getByRole('button', { name: 'Move anyway…', exact: true }) await expect(move).toBeVisible() await page.screenshot({ path: testInfo.outputPath('unreadable-history-recovery-offered.png') From 97cb0f498c7690758a62d9cd7122d9d42630ec74 Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 16:31:25 -0700 Subject: [PATCH 28/29] ci: regenerate merge ref From 08577327e92e601bb582b0ac45707140a103884c Mon Sep 17 00:00:00 2001 From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com> Date: Mon, 26 Oct 2026 18:36:47 -0700 Subject: [PATCH 29/29] test: type host-split listing diagnostics --- tests/e2e/helpers/host-split-and-sleep-journeys.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/e2e/helpers/host-split-and-sleep-journeys.ts b/tests/e2e/helpers/host-split-and-sleep-journeys.ts index 91be17911789..cbeaed7f15d5 100644 --- a/tests/e2e/helpers/host-split-and-sleep-journeys.ts +++ b/tests/e2e/helpers/host-split-and-sleep-journeys.ts @@ -79,7 +79,7 @@ export async function openHostSplitWorktree(page: Page, userDataDir: string): Pr const observed = await page.evaluate(async () => { const store = window.__store! await store.getState().fetchRepos({ runtimeEnvironmentId: null, throwOnError: true }) - const listings = [] + const listings: { repoId: string; fetchResult: boolean }[] = [] for (const repo of store.getState().repos) { listings.push({ repoId: repo.id,