Revert "Keep same-machine folders with matching Git remotes in separate projects (#32272)" (#34662)

* Revert "Keep same-machine folders with matching Git remotes in separate projects (#32272)"

This reverts commit 29a5f89b8a.

On a machine with several clones of one repo, #32272 moved the oldest clone
into the shared cross-host project. Shared Windows QA machines have several
orca clones, so everyone's sidebar "orca" project suddenly gained that
machine's main checkout and its worktrees. Revert until the intended
grouping is decided.

Conflicts resolved against later main: Settings files moved into
settings/repository (imports adjusted), and two test files later deleted on
main stay deleted.

* Use checked types in restored test fixtures

* Combine restored fixture type imports
This commit is contained in:
Brennan Benson
2026-10-27 20:38:35 -07:00
committed by GitHub
parent 44e26301e2
commit 8d908764dc
312 changed files with 2054 additions and 8018 deletions
-41
View File
@@ -153,14 +153,6 @@
{
"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."
}
]
}
@@ -215,39 +207,6 @@
"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",
"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": {
"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": [
-27
View File
@@ -38,7 +38,6 @@
{
"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"
}
@@ -50,32 +49,6 @@
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "off",
"ime-enter-guard/require-ime-guard": "off"
}
},
{
"files": [
"src/renderer/src/store/project-host-setup-selector.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",
"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": {
"app-store-performance/no-raw-project-catalog-computed": "off"
}
}
],
"ignorePatterns": ["**/node_modules", "**/dist", "**/out"]
@@ -426,39 +426,12 @@ 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.'
})
}
}
}
}
// Skip JS AST deserialization in files that cannot name a store hook.
const STORE_HOOK_TEXT = /use[A-Z][A-Za-z0-9]*Store/
function bindContext(createVisitors) {
return (context) => {
if (
createVisitors !== noRawProjectCatalogComputedRule &&
!STORE_HOOK_TEXT.test(context.sourceCode.text) &&
!context.sourceCode.text.includes('\\')
) {
if (!STORE_HOOK_TEXT.test(context.sourceCode.text) && !context.sourceCode.text.includes('\\')) {
return {}
}
if (
@@ -479,7 +452,6 @@ 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-whole-hot-map-selector': { create: bindContext(noWholeHotMapSelectorRule) },
@@ -1,102 +0,0 @@
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'
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'))
const jsPluginConfig = JSON.parse(readFileSync(resolve('config/oxlint-js-plugins.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))
writeFileSync(
join(folder, 'js-config.json'),
JSON.stringify({
...jsPluginConfig,
jsPlugins: jsPluginConfig.jsPlugins.map((plugin) => ({
...plugin,
specifier: resolve('config', plugin.specifier)
}))
})
)
const lint = (file, content = source) => {
const target = join(folder, file)
mkdirSync(dirname(target), { recursive: true })
writeFileSync(target, content)
return ['config.json', 'js-config.json'].flatMap((configFile) => {
const invocation = resolveOxcCliInvocation('oxlint', 'oxlint')
const result = spawnSync(
invocation.command,
[
...invocation.prefixArgs,
'--config',
join(folder, configFile),
'--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')
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 })
}
})
+5 -19
View File
@@ -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 { getRepoHostIdentity } from '../../../src/shared/repo-host-identity'
import { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
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,7 +22,6 @@ import {
type NewWorktreeModalProps
} from './new-worktree-modal-types'
import {
getNewWorkspaceProjectIds,
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
@@ -180,26 +179,13 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
})),
[hostRepos]
)
const projectIds = useMemo(() => getNewWorkspaceProjectIds(repos), [repos])
const projectPickerItems = useMemo(
() => buildNewWorkspaceProjectOptions(repos, projectIds),
[repos, projectIds]
)
const selectedProjectId = selectedRepo
? (projectIds.get(getRepoHostIdentity(selectedRepo)) ?? null)
: null
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
const selectedProject =
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
const runTargetPickerItems = useMemo(
() =>
buildNewWorkspaceRunTargetOptions(
repos,
selectedProjectId,
hostPlatform,
hostLabelById,
projectIds
),
[hostLabelById, hostPlatform, repos, selectedProjectId, projectIds]
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById),
[hostLabelById, hostPlatform, repos, selectedProjectId]
)
const selectedRunTarget = selectedRepo
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById)
@@ -75,7 +75,7 @@ describe('new workspace project targets', () => {
})
})
it('keeps each same-host clone selectable with its concrete target', () => {
it('shows one target per host when the project has multiple local worktrees', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const repos = [
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
@@ -88,10 +88,7 @@ describe('new workspace project targets', () => {
upstream
}
]
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
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
@@ -102,13 +99,4 @@ 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])
})
})
@@ -1,42 +1,16 @@
import type { Repo } from '../../../src/shared/repo-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import {
projectHostSetupProjectionFromRepos,
getProjectIdentityKey,
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<Repo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<
Repo,
| 'connectionId'
| 'executionHostId'
| 'upstream'
| 'repoIcon'
| 'gitRemoteIdentity'
| 'addedAt'
| 'projectHostSetupMethod'
| 'kind'
>
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
>
export function getNewWorkspaceProjectIds(
repos: readonly WorkspaceRepo[]
): ReadonlyMap<string, string> {
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<TRepo extends WorkspaceRepo> = {
id: string
label: string
@@ -53,16 +27,12 @@ export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
}
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectIds = getNewWorkspaceProjectIds(repos)
repos: readonly TRepo[]
): NewWorkspaceProjectOption<TRepo>[] {
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
const hostIdsByProject = new Map<string, Set<string>>()
for (const repo of repos) {
if (!repo.path) {
continue
}
const id = projectIds.get(getRepoHostIdentity(repo))!
const id = getProjectIdentityKey(repo)
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
hostIds.add(getRepoExecutionHostId(repo))
hostIdsByProject.set(id, hostIds)
@@ -110,15 +80,14 @@ export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null,
localPlatform: NodeJS.Platform | null = null,
hostLabelById?: ReadonlyMap<ExecutionHostId, string>,
projectIds = getNewWorkspaceProjectIds(repos)
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
}
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
for (const repo of repos) {
if (!repo.path || projectIds.get(getRepoHostIdentity(repo)) !== projectId) {
if (getProjectIdentityKey(repo) !== projectId) {
continue
}
const hostId = getRepoExecutionHostId(repo)
@@ -8,8 +8,6 @@ export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'
Partial<
Pick<
SharedRepo,
| 'addedAt'
| 'projectHostSetupMethod'
| 'badgeColor'
| 'connectionId'
| 'executionHostId'
@@ -4,7 +4,6 @@ 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'
// Use served all-host project grouping; older desktops fall back to repo grouping.
// Only visible hosts’ repos open empty sections.
@@ -25,7 +24,6 @@ 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]))
@@ -1,18 +1,8 @@
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'
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'
@@ -84,36 +74,6 @@ 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'
@@ -184,93 +144,3 @@ 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')
if (!phone.grouping) {
throw new Error('No served project grouping')
}
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')
if (!phone.grouping) {
throw new Error('No served project grouping')
}
expect(
getProjectGroupingForRepo('a', repoMap, buildProjectGroupingIndex(phone.grouping)).key
).toBe('project:repo:a')
})
@@ -1,414 +0,0 @@
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 { 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'
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 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], provisioned: readonly string[] = []) {
const projection = projectHostSetupCompatibilityFromRepos(repos)
return {
getRepos: () => repos,
getProjects: () => projection.projects,
getProjectHostSetups: () =>
projection.setups.map((setup) =>
provisioned.includes(setup.repoId)
? { ...setup, setupMethod: 'provisioned' as const }
: setup
),
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 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(
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',
path: '//wsl.localhost/Ubuntu/mnt/c/Case_%/app'
},
{ ...a, id: 'unicode', path: '/repos/Café_%' },
{ ...a, id: 'native-drive', path: 'C:/Native_%' }
]
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}`)
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
}))
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, {
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)
}
})
@@ -1,4 +1,3 @@
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'
@@ -20,7 +19,6 @@ 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[]
@@ -38,8 +38,6 @@ 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'
/** Candidate-limit tuning and measurements: docs/reference/agent-session-search-query-tuning.md. */
@@ -62,22 +60,11 @@ 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
// Install tables and generation triggers before preparing retrieval or accepting writes.
ensureSessionSearchQuerySchema(this.db)
@@ -89,7 +76,6 @@ export class SessionSearchEngine {
}
search(request: SessionSearchRequest): SessionSearchResponse {
this.ownership = request.filters?.ownership
const startedAt = performance.now()
ensureSessionSearchQuerySchema(this.db)
const generation = readIndexGeneration(this.db)
@@ -108,13 +94,7 @@ export class SessionSearchEngine {
// Reject invalid cursors before paying for retrieval.
const pageKey = sessionSearchPageKey(request)
const offset = request.cursor
? decodeSessionSearchCursor(
request.cursor,
generation,
pageKey,
incarnation,
this.ownership?.fingerprint
)
? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation)
: 0
const plan = planSessionSearchQuery(split.text)
@@ -141,13 +121,7 @@ export class SessionSearchEngine {
page: {
hasMore,
cursor: hasMore
? encodeSessionSearchCursor(
generation,
offset + limit,
pageKey,
incarnation,
this.ownership?.fingerprint
)
? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation)
: null
},
truncated: {
@@ -1,131 +0,0 @@
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<typeof fs>()
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<void>((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<typeof native.search>) => {
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)
})
@@ -17,7 +17,6 @@ export class SessionSearchCursorError extends Error {
type CursorPayload = {
/** Database incarnation; changes when the index is rebuilt. */
p?: string
i: string
/** Index generation. */
g: number
@@ -36,8 +35,7 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string {
filters.sort ?? 'relevance',
filters.since ?? null,
[...(filters.agents ?? [])].sort(),
[...(filters.scopePaths ?? [])].sort(),
...(filters.ownership ? [filters.ownership.projectKey] : [])
[...(filters.scopePaths ?? [])].sort()
])
return createHash('sha256').update(identity).digest('base64url').slice(0, 16)
}
@@ -46,16 +44,9 @@ export function encodeSessionSearchCursor(
generation: number,
offset: number,
key: string,
incarnation: string,
ownership?: string
incarnation: string
): string {
const payload: CursorPayload = {
i: incarnation,
g: generation,
o: offset,
k: key,
...(ownership ? { p: ownership } : {})
}
const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key }
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url')
}
@@ -64,8 +55,7 @@ export function decodeSessionSearchCursor(
cursor: string,
generation: number,
key: string,
incarnation: string,
ownership?: string
incarnation: string
): number {
let payload: CursorPayload
try {
@@ -94,9 +84,6 @@ 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)
}
@@ -1,284 +0,0 @@
import { createHash } from 'node:crypto'
import {
normalizeRuntimePathForComparison,
isRuntimePathAbsolute
} from '../../shared/cross-platform-path'
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'
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<string, Interval[]>
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 function materializeSessionSearchOwnership(
catalog: SessionSearchScopeCatalog | null
): Rule[] | null {
if (!catalog) {
return null
}
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 connectionId = getRepoRoutingConnectionId(repo)
const host = connectionId
? `ssh:${encodeURIComponent(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)
}
}
}
return rules
}
export async function compileSessionSearchProjectOwnership(
catalog: SessionSearchScopeCatalog,
projectKey: string,
input = materializeSessionSearchOwnership(catalog) ?? []
): Promise<SessionSearchProjectOwnership> {
const rules = [...input]
const pathByKey = new Map<string, string>()
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<string, Rule[]>()
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<string, Map<string, { owner: string | null; priority: number }>>()
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'])
}
}
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: union }
}
function intervals(roots: Map<string, { owner: string | null; priority: number }>): 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 sessionSearchProjectOwner(
selection: SessionSearchProjectOwnership,
cwd: string,
filePath: string
): string | null {
cwd = key(cwd)
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] ?? null
}
export function sessionSearchProjectOwns(
selection: SessionSearchProjectOwnership,
cwd: string,
filePath: string
): boolean {
return sessionSearchProjectOwner(selection, cwd, filePath) === selection.projectKey
}
@@ -38,14 +38,6 @@ 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
}
@@ -1,24 +1,20 @@
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'
import type { WorkspaceProjectOwner } from '../../shared/project-host-setup-lookup'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
// Use the answering host’s persisted registrations to avoid Git scans.
export type SessionSearchScopeCatalog = {
repos: readonly Repo[]
projects: readonly Project[]
projectHostSetups: readonly ProjectHostSetup[]
executionHostId?: ExecutionHostId
/** Registered workspaces of this host, keyed by worktree id. */
worktreeMeta: Readonly<
Record<
string,
Pick<WorktreeMeta, 'projectId' | 'priorWorktreeIds'> &
Partial<Omit<WorkspaceProjectOwner, 'repoId'>>
>
>
repos: readonly Pick<
Repo,
'id' | 'path' | 'kind' | 'worktreeBasePath' | 'connectionId' | 'executionHostId'
>[]
projects: readonly Pick<Project, 'id' | 'sourceRepoIds'>[]
projectHostSetups: readonly Pick<
ProjectHostSetup,
'projectId' | 'repoId' | 'path' | 'worktreeBasePath'
>[]
worktreeMeta: Readonly<Record<string, Pick<WorktreeMeta, 'projectId' | 'priorWorktreeIds'>>>
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
}
@@ -7,13 +7,12 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests,
type SessionSearchScopeCatalog
resetSessionSearchScopeCatalogForTests
} from './session-search-scope-catalog'
import { searchSessionService, setSessionSearchService } from './session-search-service-registry'
const CATALOG: SessionSearchScopeCatalog = {
repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }],
const CATALOG = {
repos: [{ id: 'repo-1', path: '/work/app' }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
@@ -1,9 +1,16 @@
import {
getRuntimePathBasename,
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison
normalizeRuntimePathForComparison,
resolveRuntimePath
} from '../../shared/cross-platform-path'
export { managedWorktreeDirectories } from '../../shared/worktree/ownership'
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 class ScopePathSet {
private readonly seen = new Set<string>()
@@ -30,3 +37,26 @@ export class ScopePathSet {
)
}
}
/** Flat global roots belong to all projects; only repo-specific directories can narrow scope. */
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
}
@@ -1,11 +1,10 @@
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> = {}): SessionSearchScopeCatalog {
return {
repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }],
repos: [{ id: 'repo-1', path: '/work/app' }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
@@ -83,18 +82,7 @@ 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',
displayName: 'notes',
badgeColor: '',
addedAt: 0
}
]
})
catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] })
)
expect(paths(resolution)).toEqual(['/work/notes'])
})
@@ -149,7 +137,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: [{ ...catalog().repos[0], worktreeBasePath: '/trees/app' }] })
catalog({ repos: [{ id: 'repo-1', path: '/work/app', worktreeBasePath: '/trees/app' }] })
)
// Include older worktrees created before the repo’s base path was configured.
expect(paths(resolution)).toEqual(['/home/me/orca/workspaces/app', '/trees/app', '/work/app'])
@@ -200,20 +188,10 @@ describe('project scope', () => {
const resolution = resolveSessionSearchScope(
{ kind: 'project', projectKey: 'project:proj-1' },
catalog({
repos: [{ ...catalog().repos[0], path: '/srv/app' }],
projects: [
{
id: 'proj-1',
sourceRepoIds: ['repo-1'],
displayName: 'app',
badgeColor: '',
createdAt: 0,
updatedAt: 0
}
],
repos: [{ id: 'repo-1', path: '/srv/app' }],
projects: [{ id: 'proj-1', sourceRepoIds: ['repo-1'] }],
projectHostSetups: [
{
...projectHostSetupCompatibilityFromRepos(catalog().repos).setups[0],
projectId: 'proj-1',
repoId: 'repo-1',
path: '/srv/app',
@@ -228,18 +206,7 @@ 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',
displayName: 'notes',
badgeColor: '',
addedAt: 0
}
]
})
catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] })
)
expect(paths(resolution)).toEqual(['/work/notes'])
})
@@ -254,18 +221,7 @@ describe('project scope', () => {
expect(
resolveSessionSearchScope(
{ kind: 'project', projectKey: 'project:proj-1' },
catalog({
projects: [
{
id: 'proj-1',
sourceRepoIds: ['repo-elsewhere'],
displayName: 'app',
badgeColor: '',
createdAt: 0,
updatedAt: 0
}
]
})
catalog({ projects: [{ id: 'proj-1', sourceRepoIds: ['repo-elsewhere'] }] })
)
).toEqual({ kind: 'unknown' })
})
@@ -3,13 +3,12 @@ import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contrac
import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests,
type SessionSearchScopeCatalog
resetSessionSearchScopeCatalogForTests
} from './session-search-scope-catalog'
import { searchSessionService, setSessionSearchService } from './session-search-service-registry'
const CATALOG: SessionSearchScopeCatalog = {
repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }],
const CATALOG = {
repos: [{ id: 'repo-1', path: '/work/app' }],
projects: [],
projectHostSetups: [],
worktreeMeta: { 'repo-1::/work/app': {} },
@@ -4,11 +4,6 @@ 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,
@@ -17,8 +12,6 @@ import {
} from './session-search-service-registry'
afterEach(() => {
resetSessionSearchScopeCatalogForTests()
vi.restoreAllMocks()
setSessionSearchService(null)
vi.useRealTimers()
})
@@ -139,33 +132,6 @@ 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()
@@ -6,12 +6,6 @@ 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,
materializeSessionSearchOwnership
} from './session-search-project-ownership'
import { resolveSessionSearchScope } from './session-search-scope-resolution'
import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
import {
@@ -66,60 +60,20 @@ export async function searchSessionService(
agents: compatibleAgents
}
}
const freshness =
request.freshness === 'wait-until-current'
? await reconcileWithin(current, freshnessTimeoutMs)
: false
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 && input
? await compileSessionSearchProjectOwnership(catalog, canonical, input)
: 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 hostScope = within
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
: undefined
const retrievalScope =
compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
? { kind: 'resolved' as const, paths: [''] }
: hostScope
const moved = () =>
snapshot !== JSON.stringify(materializeSessionSearchOwnership(sessionSearchScopeCatalog()))
if (canonical && moved()) {
return { kind: 'stale-cursor', generation: 0 }
}
const freshness =
request.freshness === 'wait-until-current'
? await reconcileWithin(current, freshnessTimeoutMs)
: false
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
}
@@ -1,4 +1,3 @@
import type { SessionSearchProjectOwnership } from './session-search-project-ownership'
import type {
AiVaultSearchRequest,
AiVaultSearchResponse,
@@ -11,10 +10,6 @@ import { SessionSearchCursorError } from './session-search-page-cursor'
/** Host scope bypasses the client path cap; absent scope leaves request filters unchanged. */
export type SessionSearchHostScope =
| { kind: 'resolved'; paths: readonly string[] }
| {
kind: 'canonical'
selection: SessionSearchProjectOwnership
}
| { kind: 'unknown' }
export type SessionSearchService = {
@@ -47,15 +42,7 @@ export function createSessionSearchService({
try {
const result = engine.search(
hostScope
? {
...request,
filters: {
...request.filters,
...(hostScope.kind === 'canonical'
? { ownership: hostScope.selection }
: { scopePaths: hostScope.paths })
}
}
? { ...request, filters: { ...request.filters, scopePaths: hostScope.paths } }
: request
)
return {
@@ -1,10 +1,4 @@
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'
@@ -25,22 +19,16 @@ 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: () => repos,
getProjects: () => projection.projects,
getProjectHostSetups: () => projection.setups,
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' }
],
getAllWorktreeMeta: () => ({
'local-repo::/work/one': meta('local'),
'ssh-repo::/srv/one': meta('ssh:box')
@@ -50,19 +38,16 @@ function store() {
}
describe('scope catalog from the profile store', () => {
it('captures every Repo/setup before projection and applies filesystem eligibility afterwards', async () => {
it('keeps only the rows the addressed execution host owns', () => {
const local = sessionSearchScopeCatalogFromStore(store(), 'local')
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(local.repos.map((repo) => repo.id)).toEqual(['local-repo'])
expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo'])
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(['local-repo', 'ssh-repo'])
expect(remote.repos.map((repo) => repo.id)).toEqual(['ssh-repo'])
expect(Object.keys(remote.worktreeMeta)).toEqual(['ssh-repo::/srv/one'])
})
@@ -1,4 +1,8 @@
import type { ExecutionHostId } from '../../shared/execution-host'
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
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'
@@ -16,25 +20,22 @@ export type SessionSearchScopeStore = {
})[]
getAllWorktreeMeta(): Record<string, WorktreeMeta>
getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record<string, WorktreeMeta>
getSettings(): Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
getSettings(): Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>
}
// Claims need the complete catalog; filesystem eligibility is applied after projection.
// The store includes other hosts’ paths; expose only the answering host’s registrations.
export function sessionSearchScopeCatalogFromStore(
store: SessionSearchScopeStore,
executionHostId: ExecutionHostId
): SessionSearchScopeCatalog {
const settings = store.getSettings()
return {
repos: store.getRepos(),
executionHostId,
repos: store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === executionHostId),
projects: store.getProjects(),
projectHostSetups: store.getProjectHostSetups(),
projectHostSetups: store
.getProjectHostSetups()
.filter((setup) => normalizeExecutionHostId(setup.hostId) === executionHostId),
worktreeMeta: readAllWorktreeMetaForHost(store, executionHostId),
settings: {
workspaceDir: settings.workspaceDir,
nestWorkspaces: settings.nestWorkspaces,
workspaceDirHistory: settings.workspaceDirHistory
}
settings: { workspaceDir: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces }
}
}
+2 -42
View File
@@ -1,9 +1,4 @@
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 { claudeProfileHistoryDirs } from '../claude-accounts/claude-profile-installed-router'
import {
clearAiVaultBackgroundRestartCircuit,
@@ -52,7 +47,6 @@ export class AiVaultLocalScanError extends Error {
// Configure sources through the runtime service; headless serve never runs window-only IPC registration.
export type AiVaultSessionSources = {
getProjectOwnershipCatalog?: SessionSearchScopeCatalogSource
getAdditionalCodexHomePaths?: () => readonly string[]
}
@@ -122,15 +116,12 @@ export async function listAiVaultSessions(
cachedList.expiresAt > now &&
aiVaultCachedDepthServes(cachedList.depth, depth, args?.scopePaths)
) {
return annotateHistoryOwnership(
truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths),
args
)
return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths)
}
// Capture before the coordinator's start microtask so intervening invalidation still rejects the result.
const startGeneration = cacheGeneration
let progress: LocalAiVaultScanProgressHandle | null = null
const result = await scanCoordinator.run({
return scanCoordinator.run({
key: scanKey,
force: args?.force,
signal: options.signal,
@@ -178,37 +169,6 @@ export async function listAiVaultSessions(
return result
}
})
return annotateHistoryOwnership(result, args)
}
async function annotateHistoryOwnership(
result: AiVaultListResult,
args?: AiVaultListArgs
): Promise<AiVaultListResult> {
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 result
}
// Subagent transcript validation shares these WSL-aware roots with discovery.
@@ -59,7 +59,6 @@ describe('runtime AI Vault session scanner', () => {
{
limit: 25,
force: true,
historyOwnership: {},
scopePaths: ['/srv/app']
},
{ timeoutMs: 3000 }
@@ -72,7 +71,6 @@ describe('runtime AI Vault session scanner', () => {
{
limit: 25,
force: true,
historyOwnership: {},
scopePaths: ['/srv/app'],
executionHostId: 'runtime:env-1'
},
@@ -77,7 +77,6 @@ export async function scanRuntimeAiVaultSessions(
environmentId,
'aiVault.listSessions',
{
historyOwnership: args.historyOwnership,
includeAntigravityIdeSessions: args.includeAntigravityIdeSessions,
limit: args.limit,
unlimited: args.unlimited,
@@ -1,15 +1,11 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { callRuntimeEnvironment, resolveEnvironment } = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('../../shared/runtime-environment-store', () => ({ resolveEnvironment }))
const callRuntimeEnvironment = vi.hoisted(() => vi.fn())
vi.mock('../ipc/runtime-environments/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment
}))
import { callRuntimeSessionSearch, bindRuntimeSessionSearch } from './runtime-session-search-call'
import { callRuntimeSessionSearch } from './runtime-session-search-call'
beforeEach(() => {
callRuntimeEnvironment.mockReset()
@@ -41,36 +37,3 @@ 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()
})
@@ -1,4 +1,3 @@
import { resolveEnvironment } from '../../shared/runtime-environment-store'
import { callRuntimeEnvironment } from '../ipc/runtime-environments/runtime-environment-transport-routing'
// Preserve RPC refusal codes so search recognizes older hosts that lack the method.
@@ -6,33 +5,11 @@ export async function callRuntimeSessionSearch(
userDataPath: string,
environmentId: string,
method: string,
params: Record<string, unknown>,
expected?: { pairingRevision: number; runtimeId?: string }
params: Record<string, unknown>
): Promise<unknown> {
const response = expected
? await callRuntimeEnvironment(
userDataPath,
environmentId,
method,
params,
undefined,
expected.pairingRevision,
undefined,
{ expectedEnvironmentRuntimeId: expected.runtimeId }
)
: await callRuntimeEnvironment(userDataPath, environmentId, method, params)
const response = 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<string, unknown>) =>
callRuntimeSessionSearch(userDataPath, environment.id, method, params, expected)
}
@@ -1,69 +0,0 @@
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<void>((resolve) => {
release = resolve
})
const blockers: Promise<void>[] = []
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 })
}
}
)
@@ -1,5 +1,4 @@
import { z } from 'zod'
import { isAiVaultProjectKey } from '../../shared/ai-vault-project-key'
import {
AI_VAULT_AGENTS,
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
@@ -59,7 +58,6 @@ 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(),
@@ -1,186 +0,0 @@
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<unknown> | undefined
let stderr = ''
const client = new AiVaultScannerServiceClient({
processFactory: () => {
expect(child).toBeUndefined()
const startedAtMonotonicNs = process.hrtime.bigint().toString()
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,
startedAtMonotonicNs,
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<AiVaultSearchResponse>({
type: 'request',
operation: 'searchSessions',
request
})
expect(legacy).toMatchObject({ kind: 'results', hits: [{ sessionId: '1' }] })
const scoped = await client.request<AiVaultSearchResponse>({
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<AiVaultSearchResponse>({
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()
}
}
})
@@ -1,4 +1,3 @@
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'
@@ -133,30 +132,6 @@ 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'
@@ -32,11 +32,8 @@ export async function resolveAiVaultScopeRealPaths(
}
}
const scanScopePaths = [...scopePaths]
const seen = new Set(scopePaths.map(normalizeRuntimePathForComparison))
for (const { realPath } of links) {
const key = normalizeRuntimePathForComparison(realPath)
if (!seen.has(key)) {
seen.add(key)
if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) {
scanScopePaths.push(realPath)
}
}
-3
View File
@@ -66,9 +66,6 @@ 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
}
+26 -74
View File
@@ -127,29 +127,20 @@ it('answers an empty merge when no host is reachable to ask', async () => {
})
})
it('asks every leg for the merged order and passes both Project keys and freshness unchanged', async () => {
it('asks every leg for the merged order and passes freshness through 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(), remote.leg()]
[host.leg()]
)
expect(remote.lastRequests[0]).toEqual(host.lastRequests[0])
expect(host.lastRequests[0]).toEqual({
query: 'needle',
freshness: 'wait-until-current',
within,
filters: { sort: 'newest' }
})
})
@@ -241,76 +232,37 @@ it('names an unavailable host by its reason without losing the other hosts', asy
expect(response.page.hasMore).toBe(false)
})
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()
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 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 } }, [
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! }, [
healthy.leg(),
{
executionHostId: 'ssh:down',
search: async () => {
throw new Error('connection refused')
}
}
recovered.leg()
])
)
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.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }])
expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1'])
expect(second.page.hasMore).toBe(false)
} finally {
vi.useRealTimers()
}
)
})
it('reads at most three pages from one host per merged request', async () => {
// Three-page budget stops a six-page host walk; retain its unread cursor for the next merged page.
+10 -20
View File
@@ -50,8 +50,7 @@ type HostWalk = {
/** Independent relevance scores cannot be compared: merge by recency or host round-robin, retaining page overflow. */
export async function searchAllExecutionHosts(
request: AiVaultSearchRequest,
legs: readonly SessionSearchHostLeg[],
isActive: () => boolean = () => true
legs: readonly SessionSearchHostLeg[]
): Promise<AiVaultSearchResponse> {
const startedAt = Date.now()
const limit = resolveSessionSearchLimit(request.limit)
@@ -75,13 +74,9 @@ export async function searchAllExecutionHosts(
.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()
)
walks.map((walk) => fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null))
)
const hits = await drainMergedPage(walks, limit, sort, isActive)
const hits = await drainMergedPage(walks, limit, sort)
return mergedSearchResponse(walks, { limit, sort }, hits, Date.now() - startedAt)
}
@@ -140,12 +135,8 @@ async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | nu
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
) {
// Emitted offsets are generation-specific; fence only after emitting hits.
if (walk.emitted > 0 && walk.generation !== response.generation) {
endHostWalk(walk, 'stale', false)
return
}
@@ -167,8 +158,8 @@ function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void
walk.carry = carry
}
async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise<void> {
while (isActive() && walk.pending.length === 0 && walk.nextCursor !== null) {
async function advanceHostWalk(walk: HostWalk): Promise<void> {
while (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
@@ -181,15 +172,14 @@ async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise
async function drainMergedPage(
walks: readonly HostWalk[],
limit: number,
sort: MergedSort,
isActive: () => boolean
sort: MergedSort
): Promise<AiVaultSearchHit[]> {
const hits: AiVaultSearchHit[] = []
let turn = 0
while (isActive() && hits.length < limit) {
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, isActive)
await advanceHostWalk(walk)
}
const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn)
if (!next) {
+7 -92
View File
@@ -1,18 +1,13 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { agentSessionConversationView } from '../../shared/agent-session-conversation-view'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch, runtimeHostInfos } =
vi.hoisted(() => ({
clearSearch: vi.fn(),
handlers: new Map<string, (...args: unknown[]) => Promise<unknown>>(),
sshSearch: vi.fn(),
sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []),
runtimeSearch: vi.fn(),
runtimeHostInfos: vi.fn<() => readonly RuntimeAiVaultHostInfo[]>(() => [])
}))
const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch } = vi.hoisted(() => ({
clearSearch: vi.fn(),
handlers: new Map<string, (...args: unknown[]) => Promise<unknown>>(),
sshSearch: vi.fn(),
sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []),
runtimeSearch: vi.fn()
}))
vi.mock('../ai-vault/session-scanner-service-spawn', () => ({
clearSessionSearchInService: clearSearch
}))
@@ -23,23 +18,11 @@ 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'
@@ -59,7 +42,6 @@ beforeEach(() => {
sshHostInfos.mockReset()
sshHostInfos.mockReturnValue([])
runtimeSearch.mockReset()
runtimeHostInfos.mockReset().mockReturnValue([])
clearSearch.mockReset()
registerAiVaultSearchHandlers({
callRuntimeSearch: runtimeSearch
@@ -294,70 +276,3 @@ 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()
})
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 })
}
})
+9 -22
View File
@@ -43,9 +43,6 @@ export type RuntimeSessionSearchCall = (
export type AiVaultSearchHandlerOptions = {
callRuntimeSearch?: RuntimeSessionSearchCall
bindRuntimeSearch?: (
environmentId: string
) => (method: string, params: Record<string, unknown>) => Promise<unknown>
ensureStructuredSessionOwnership?: () => Promise<void>
}
@@ -169,23 +166,14 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] {
}
function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg {
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
}
}
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' })
}
}
@@ -226,9 +214,8 @@ function remoteSearchClient(
): ReturnType<typeof createSessionSearchClient> | null {
if (host.kind === 'runtime' && call) {
const { environmentId } = host
const bound = handlerOptions.bindRuntimeSearch?.(environmentId)
return createSessionSearchClient(
bound ?? ((method, params) => call(environmentId, method, params)),
(method, params) => call(environmentId, method, params),
'relay'
)
}
+4 -2
View File
@@ -117,11 +117,13 @@ async function listAiVaultSessions(
? [...new Set(scopePaths)].sort()
: scopePaths,
executionHostScope,
historyOwnership: args?.historyOwnership,
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
})
const depth = requestedAiVaultSessionDepth(args)
const afterFingerprint = parsedHost?.kind === 'runtime' ? args?.afterFingerprint : undefined
const afterFingerprint =
parseRoutableExecutionHostId(executionHostScope)?.kind === 'runtime'
? args?.afterFingerprint
: undefined
// Why: every renderer request carries its own cancellation signal, so
// coalescing has to survive them — the coordinator hands all same-key callers
// one scan and only aborts it once every one of them has cancelled.
@@ -1,6 +1,4 @@
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'
@@ -104,10 +102,7 @@ import {
resolveRuntimeAiVaultSessionTitles,
scanRuntimeAiVaultSessions
} from '../../ai-vault/runtime-session-scanner'
import {
callRuntimeSessionSearch,
bindRuntimeSessionSearch
} from '../../ai-vault/runtime-session-search-call'
import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call'
import { configureRuntimeAiVaultSessionDelete } from '../ai-vault-delete'
import { ensureStructuredAgentSessionHostUnlessRefused } from '../../runtime/structured-agent-session-host-refusal'
import { getAgentSessionAttachmentStore } from '../../native-chat/agent-session-attachments/agent-session-attachment-store-registry'
@@ -275,15 +270,11 @@ export function registerCoreHandlers(
registerAiVaultSearchHandlers({
callRuntimeSearch: (environmentId, method, params) =>
callRuntimeSessionSearch(app.getPath('userData'), environmentId, method, params),
bindRuntimeSearch: (environmentId) =>
bindRuntimeSessionSearch(app.getPath('userData'), environmentId),
ensureStructuredSessionOwnership
})
registerAiVaultHandlers({
getUserDataPath: () => app.getPath('userData'),
ensureStructuredSessionOwnership,
getProjectOwnershipCatalog: () =>
sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID),
getAdditionalCodexHomePaths: lifecycleOptions.getAdditionalAiVaultCodexHomePaths,
prepareSessionResume: lifecycleOptions.prepareAiVaultSessionResume,
getActiveRuntimeAiVaultHostInfos: () =>
@@ -301,7 +292,9 @@ export function registerCoreHandlers(
registerNativeChatHandlers()
registerClipboardHandlers(store, {
localChatAttachmentStore: async () => {
await ensureStructuredSessionOwnership()
await ensureStructuredAgentSessionHostUnlessRefused(() =>
runtime.ensureStructuredAgentSessionHost()
)
return getAgentSessionAttachmentStore()
}
})
@@ -3,8 +3,6 @@ import { getDefaultPersistedState } from '../../shared/constants'
import type { PersistedState } from '../../shared/persisted-state-types'
import type { Project, ProjectHostSetup } from '../../shared/project-types'
import type { Repo } from '../../shared/repo-types'
import { mergeProjectHostSetupCompatibilityState } from '../persistence/tracking-repos/project-host-compatibility'
import { projectHostSetupProjectionFromRepos } from '../../shared/project-host-setup-projection'
import { rebuildRepoBackedProjectState } from './profile-project-state-file'
const upstreamIdentity = {
@@ -112,81 +110,3 @@ describe('rebuildRepoBackedProjectState', () => {
])
})
})
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-b',
projectId: 'repo:b',
hostId: 'ssh:extra',
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,
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 }
: 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-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
)
expect(
mergeProjectHostSetupCompatibilityState(JSON.parse(JSON.stringify(handedOff)), remaining)
).toEqual(handedOff)
}
})
@@ -1,4 +1,3 @@
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'
@@ -220,12 +219,16 @@ export function writeSerializedProfileState(
}
}
function isRepoBackedProjectHostSetup(
setup: ProjectHostSetup,
currentRepoIds: ReadonlySet<string>
): 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, {
previous: state.projectHostSetups,
current: projection.setups
})
const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects)
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))
+6 -4
View File
@@ -182,15 +182,16 @@ async function startOrcadRuntime(
clientCodexHomes: codexAccountHomes
})
const { orcadAiVaultSessionSources, installOrcadSessionSearchService } =
await import('./orcad-session-search')
const { loadAgentSessionClaimSigner } =
await import('../runtime/agent-session/ownership/agent-session-claim-identity')
const runtime = new OrcaRuntimeService(profileStore, stats, {
// Persistent signatures let restarted runtimes adopt PTYs owned before restart.
agentSessionClaimSigner: loadAgentSessionClaimSigner(runtimeUserDataPath, runtimeUserDataPath),
...orcadAiVaultSessionSources(profileStore, codexAccountHomes, accountLaunch),
// Why absent on a WSL host: a resolver overrides the desktop-pushed CODEX_HOME.
getAdditionalAiVaultCodexHomePaths: () => [
...codexAccountHomes.listSessionHomes(),
...(accountLaunch?.listCodexSessionHomes() ?? [])
],
// WSL must retain desktop-pushed CODEX_HOME instead of host-selected resolvers.
...accountLaunch?.runtimeDeps,
...agentHookRuntimeDeps(agentHookServer, observedPaneIdentities, () =>
profileStore.getSettings()
@@ -219,6 +220,7 @@ 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()
-23
View File
@@ -1,8 +1,3 @@
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'
@@ -23,21 +18,3 @@ 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() ?? [])
]
}
}
@@ -53,8 +53,7 @@ export function mergeProjectHostSetupCompatibilityState(
const projection = projectHostSetupProjectionFromRepos(repos)
const succession = carryProjectStateThroughIdentityChange(
projection.projects,
state.projects ?? [],
{ previous: state.projectHostSetups ?? [], current: projection.setups }
state.projects ?? []
)
const currentRepoIds = new Set(repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
@@ -197,10 +197,6 @@ const HOST_PORT_SOURCES: Record<Root, readonly string[]> = {
const NOT_HOST_PORTS = new Set(['setTimeout', 'setInterval', 'setImmediate'])
const SPREAD_SOURCES: Record<string, { file: string; anchor: string }> = {
'orcadAiVaultSessionSources(': {
file: 'src/main/orcad/orcad-session-search.ts',
anchor: 'return {'
},
'agentHookRuntimeDeps(': {
file: 'src/main/agent-hooks/agent-hook-runtime-deps.ts',
anchor: 'return {'
@@ -1,4 +1,3 @@
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'
@@ -60,7 +59,6 @@ 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
@@ -232,14 +232,11 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null
this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true)
this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null)
// 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
})
// Headless serve skips registerCoreHandlers but still needs managed-Codex/WSL AiVault sources.
if (deps?.getAdditionalAiVaultCodexHomePaths) {
configureAiVaultSessionSources({
getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths
})
configureHostReadableTranscriptPathSources({
getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths
})
@@ -1,18 +1,3 @@
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'
@@ -56,12 +41,8 @@ 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(
@@ -147,40 +128,6 @@ describe('desktop relay E2EE integration', () => {
const receivedText = deferred<string>()
const receivedBinary = deferred<Uint8Array>()
const phoneBinary = deferred<Uint8Array>()
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: {
@@ -197,10 +144,7 @@ describe('desktop relay E2EE integration', () => {
credentialKind: 'invite'
})
receivedText.resolve(plaintext)
expect(JSON.parse(plaintext)).toEqual(request)
void dispatcher
.dispatch(request, { clientKind: 'mobile' })
.then((result) => reply(JSON.stringify(result)))
reply(JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } }))
sendBinary(new Uint8Array([4, 5, 6]))
},
onBinary: (_socket, bytes) => receivedBinary.resolve(new Uint8Array(bytes)),
@@ -282,16 +226,16 @@ describe('desktop relay E2EE integration', () => {
})
phone.send(JSON.stringify(phoneSession.hello))
await authenticated.promise
phone.send(phoneSession.sealText(JSON.stringify(request)))
phone.send(phoneSession.sealText(JSON.stringify({ id: 'rpc-1', method: 'status.get' })))
phone.send(phoneSession.sealBinary(new Uint8Array([1, 2, 3])))
await expect(receivedText.promise).resolves.toBe(JSON.stringify(request))
await expect(receivedText.promise).resolves.toBe(
JSON.stringify({ id: 'rpc-1', method: 'status.get' })
)
await expect(receivedBinary.promise).resolves.toEqual(new Uint8Array([1, 2, 3]))
expect(JSON.parse(await phoneText.promise)).toMatchObject({
id: 'rpc-1',
ok: true,
result: { kind: 'results', hits: [{ sessionId: '1' }] }
})
await expect(phoneText.promise).resolves.toBe(
JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } })
)
await expect(phoneBinary.promise).resolves.toEqual(new Uint8Array([4, 5, 6]))
phone.terminate()
@@ -30,7 +30,6 @@ 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'
@@ -663,68 +662,6 @@ 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 () => {
// Use a real runtime to prove constructor-to-cache forwarding; directly seeding cache would miss a broken link.
const runtime = new OrcaRuntimeService(null, undefined, {
-1
View File
@@ -106,7 +106,6 @@ 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
},
@@ -1,14 +1,4 @@
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 { mkdtempSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import nacl from 'tweetnacl'
@@ -60,7 +50,7 @@ function keyedServer() {
const reply: unknown = JSON.parse(replies.at(-1)!)
return reply
}
return { server, registry, keyed, legacy, send, userDataPath }
return { server, registry, keyed, legacy, send }
}
describe('requests on a key-authenticated socket', () => {
@@ -145,46 +135,3 @@ 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 })
}
})
+1 -15
View File
@@ -1,11 +1,5 @@
import { existsSync } from 'node:fs'
import type {
backup,
BackupOptions,
DatabaseSync,
FunctionOptions,
SQLInputValue
} from 'node:sqlite'
import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite'
import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api'
import { NodeSqliteStatement } from './node-sqlite-statement'
import type { SqliteStatement } from './sqlite-statement'
@@ -97,14 +91,6 @@ class SyncDatabase {
this.db.exec(sql)
}
function(
name: string,
options: FunctionOptions,
fn: Parameters<DatabaseSync['function']>[1]
): void {
this.db.function(name, options, fn)
}
prepare(sql: string): SqliteStatement {
const cached = this.statementCache.get(sql)
if (cached) {
@@ -115,9 +115,7 @@ export function initializeMainProcessRuntime(): OrcaRuntimeService {
// Resolve per call: the RPC server/device registry is constructed later with this runtime.
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),
// Provide managed Codex homes here for desktop and headless session discovery; core IPC registration is window-only.
getAdditionalAiVaultCodexHomePaths: () =>
state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [],
prepareAiVaultSessionResume: codexAiVaultSessionResumePreparation(() => ({
+1 -2
View File
@@ -33,8 +33,7 @@ export type AiVaultApi = {
/** Omitted host means this host; `all` is merged by this desktop across every enumerated host. */
searchSessions: (
request: AiVaultSearchRequest,
executionHostScope?: ExecutionHostScope,
isActive?: () => boolean
executionHostScope?: ExecutionHostScope
) => Promise<AiVaultSearchResponse>
/** Status describes one index, so it never accepts the `all` scope. */
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
+2 -5
View File
@@ -34,11 +34,8 @@ function searchClient(
}
export const aiVaultApi = {
searchSessions: (
request: AiVaultSearchRequest,
executionHostScope?: ExecutionHostScope,
isActive?: () => boolean
) => searchClient(executionHostScope).searchSessions(request, isActive),
searchSessions: (request: AiVaultSearchRequest, executionHostScope?: ExecutionHostScope) =>
searchClient(executionHostScope).searchSessions(request),
searchStatus: (executionHostScope?: ExecutionHostId) =>
searchClient(executionHostScope).searchStatus(),
setSearchEnabled: (
@@ -59,8 +59,8 @@ export function useRuntimeGraphSync(): void {
// Publish project-catalog changes to paired phones.
if (
state.repos !== previousState.repos ||
state.endpointProjects !== previousState.endpointProjects ||
state.endpointProjectHostSetups !== previousState.endpointProjectHostSetups ||
state.projects !== previousState.projects ||
state.projectHostSetups !== previousState.projectHostSetups ||
state.projectOrderBy !== previousState.projectOrderBy
) {
scheduleRuntimeGraphSync()
@@ -16,8 +16,7 @@ vi.mock('@/runtime/runtime-file-import-client', () => ({
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) =>
selector({ settings: {}, endpointProjects: [], endpointProjectHostSetups: [], repos: [] }),
(selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], repos: [] }),
{
getState: () => ({
sshConnectionStates: connections,
@@ -50,9 +50,9 @@ beforeEach(() => {
],
activeRepoId: 'local-project',
activeModal: 'new-workspace-composer',
endpointProjects: [],
projects: [],
projectGroups: [],
endpointProjectHostSetups: [],
projectHostSetups: [],
newWorkspaceDraft: null,
worktreesByRepo: {},
sparsePresetsByRepo: {}
@@ -30,8 +30,7 @@ vi.mock('@/store', () => ({
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
repos: []
}),
{ getState: () => ({}) }
@@ -25,8 +25,7 @@ vi.mock('@/store', () => ({
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
repos: []
}),
{ getState: () => ({}) }
@@ -17,8 +17,7 @@ vi.mock('@/store', () => ({
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
repos: [],
worktreesByRepo: {}
}),
@@ -32,8 +32,7 @@ vi.mock('@/store', () => ({
activeModal: 'none',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
repos: []
}),
{
@@ -1,4 +1,3 @@
import { selectCanonicalProjects } from '@/store/project-host-setup-selector'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -97,7 +96,7 @@ export default function NewWorkspaceComposerCard(
(state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS
)
const updateSettings = useAppStore((state) => state.updateSettings)
const projects = useAppStore((state) => selectCanonicalProjects(state))
const projects = useAppStore((state) => state.projects)
const repos = useAppStore((state) => state.repos)
const nameInputFocusFrameRef = React.useRef<number | null>(null)
const branchNameInputId = React.useId()
@@ -63,21 +63,9 @@ function addSshProject(): void {
}
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,
// The host needs a ready setup or save refuses the project before choosing a destination.
mocks.state.projectHostSetups = [
...mocks.state.projectHostSetups,
{
id: 'setup-ssh',
projectId: 'project-ssh',
@@ -105,19 +93,8 @@ function addCollidingRuntimeProject(): void {
executionHostId: `runtime:${RUNTIME_ID}`
}
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,
mocks.state.projectHostSetups = [
...mocks.state.projectHostSetups,
{
id: 'setup-runtime-collision',
projectId: 'project-runtime-collision',
@@ -1,6 +1,5 @@
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import { getRepoHostIdentity } from '../../../../shared/repo-host-identity'
import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection'
import type { Repo } from '../../../../shared/repo-types'
export type AutomationProjectGroup = {
@@ -13,15 +12,9 @@ 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<string, AutomationProjectGroup>()
for (const repo of repos) {
const projectKey = projectIds.get(getRepoHostIdentity(repo))!
const projectKey = getProjectIdentityKey(repo)
const current = groupsByProject.get(projectKey)
if (!current) {
groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] })
@@ -6,7 +6,8 @@ import type {
AutomationRunUsage,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { AppState } from '@/store/types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type {
@@ -218,11 +219,11 @@ export function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
}
}
export type AutomationsPageFixtureState = Record<string, unknown> &
Pick<AppState, 'repos' | 'projectHostSetups' | 'worktreesByRepo'>
export type AutomationsPageStoreFixtures = {
state: Record<string, unknown> & {
endpointProjects: Project[]
endpointProjectHostSetups: ProjectHostSetup[]
}
state: AutomationsPageFixtureState
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
}
@@ -235,17 +236,7 @@ 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()],
projectHostSetups: [makeProjectHostSetup()],
worktreesByRepo: { [REPO_ID]: [worktree] },
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {},
@@ -1,3 +1,4 @@
import type { AutomationsPageFixtureState } from './automations-page-fixtures'
import type { Automation } from '../../../../shared/automations-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
@@ -19,7 +20,7 @@ export function selfScopedList(automations: Automation[]): Record<string, unknow
}
type RuntimeFixtureMocks = {
state: Record<string, unknown> & { endpointProjectHostSetups: ProjectHostSetup[] }
state: AutomationsPageFixtureState
repoMap: Map<string, unknown>
worktreeMap: Map<string, unknown>
}
@@ -55,10 +56,10 @@ export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string)
updatedAt: 1
}
const repos = mocks.state.repos as Repo[]
const setups = mocks.state.endpointProjectHostSetups
const setups = mocks.state.projectHostSetups
const worktreesByRepo = mocks.state.worktreesByRepo as Record<string, Worktree[]>
mocks.state.repos = [...repos, repo]
mocks.state.endpointProjectHostSetups = [...setups, setup]
mocks.state.projectHostSetups = [...setups, setup]
mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] }
mocks.repoMap.set(RUNTIME_REPO_ID, repo)
mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree)
@@ -1,4 +1,3 @@
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
/** Record child props to test page orchestration rather than presentation. Import this harness first so shared vi.mock registrations precede AutomationsPage loading. */
import { act, createElement, StrictMode, type ReactNode } from 'react'
@@ -90,10 +89,7 @@ export type DeleteDialogProps = {
}
type AutomationsPageMocks = {
state: Record<string, unknown> & {
endpointProjects: Project[]
endpointProjectHostSetups: ProjectHostSetup[]
}
state: AutomationsPageFixtureState
repoMap: Map<string, unknown>
worktreeMap: Map<string, unknown>
listPanel: ListPanelProps | null
@@ -111,7 +107,7 @@ type AutomationsPageMocks = {
// Mock factories only close over this exported binding; vi.hoisted bindings cannot be exported.
export const mocks: AutomationsPageMocks = {
state: { endpointProjects: [], endpointProjectHostSetups: [] },
state: { repos: [], projectHostSetups: [], worktreesByRepo: {} },
repoMap: new Map<string, unknown>(),
worktreeMap: new Map<string, unknown>(),
listPanel: null,
@@ -248,7 +244,12 @@ vi.mock('./AutomationDeleteDialogs', () => ({
}))
import AutomationsPage from './AutomationsPage'
import { makeAutomation, makeRun, makeStoreState } from './automations-page-fixtures'
import {
makeAutomation,
makeRun,
makeStoreState,
type AutomationsPageFixtureState
} from './automations-page-fixtures'
function mockGroup<K extends string>(...names: K[]): Record<K, Mock> {
return Object.fromEntries(names.map((name) => [name, vi.fn()])) as Record<K, Mock>
@@ -1,4 +1,3 @@
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'
@@ -19,7 +18,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) => selectCanonicalProjectHostSetups(s))
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s))
const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s))
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
@@ -516,6 +516,25 @@ describe('Cmd+J project and repo-group search', () => {
])
})
it('splits independent same-host checkouts of one project into per-setup keys', () => {
// Independent cloned checkouts retain distinct jump targets, matching sidebar grouping.
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', () => {
// Provisioned ephemeral copies nest with the real checkout under one project key, matching the sidebar.
const results = searchCmdJProjectResults({
@@ -95,8 +95,7 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeRepoId: null,
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
removedRuntimeEnvironmentIds: new Set()
}
}
@@ -287,8 +286,6 @@ describe('useDashboardPopoutBridge', () => {
// A folder workspace is not a git worktree; its host resolves through these.
{ folderWorkspaces: [] },
{ projectGroups: [] },
{ endpointProjects: [] },
{ endpointProjectHostSetups: [] },
{ restoredRuntimeHostIdByWorkspaceSessionKey: {} },
{ runtimeEnvironments: [] },
{ runtimeEnvironmentCatalogHydrated: true },
@@ -7,7 +7,7 @@ import {
} from '@/runtime/structured-chat-name-observation'
import { subscribeChatProgramFacts } from '@/lib/host-chat-program'
import {
type AgentLaunchRouteWatchState,
type AgentLaunchRouteView,
agentLaunchRouteInputsChanged,
selectAgentLaunchRouteView
} from '@/lib/agent-launch-route-input'
@@ -37,7 +37,7 @@ export function repoIconsUnchanged(
type DashboardSnapshotWatchState = DashboardSnapshotState &
Pick<AppState, 'agentStatusEpoch'> &
AgentLaunchRouteWatchState
AgentLaunchRouteView
export function dashboardSnapshotInputsChanged(
state: DashboardSnapshotWatchState,
@@ -226,11 +226,7 @@ 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 [
{ endpointProjects: [] },
{ endpointProjectHostSetups: [] },
{ activeWorkspaceExecutionHostId: 'local' as const }
]) {
for (const route of [{ projects: [] }, { 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)
@@ -121,9 +121,6 @@ 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
@@ -54,9 +54,6 @@ beforeEach(() => {
}
})
registerHttpLinkStoreAccessor(() => ({
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
settings: routing.settings,
setActiveWorktree: vi.fn(),
createBrowserTab
@@ -50,7 +50,7 @@ beforeEach(() => {
activeRepoId: null,
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
endpointProjects: [],
projects: [],
repos: [],
worktreesByRepo: {},
folderWorkspaces: [],
@@ -8,8 +8,6 @@ type StoreState = {
activeRepoId: string | null
activeWorktreeId: string | null
worktreesByRepo: Record<string, unknown[]>
endpointProjects: never[]
endpointProjectHostSetups: never[]
repos: unknown[]
settings: { activeRuntimeEnvironmentId?: string | null }
preflightStatus: PreflightStatus | null
@@ -92,8 +90,6 @@ function installStore(preflightStatus: PreflightStatus): void {
activeRepoId: null,
activeWorktreeId: null,
worktreesByRepo: {},
endpointProjects: [],
endpointProjectHostSetups: [],
repos: [],
settings,
preflightStatus,
@@ -1,7 +1,3 @@
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'
@@ -29,6 +25,7 @@ export type NativeChatSkillStateInputs = Pick<
| 'floatingWorkspacePath'
| 'folderWorkspaces'
| 'projectGroups'
| 'projects'
| 'repos'
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
| 'settings'
@@ -36,8 +33,7 @@ export type NativeChatSkillStateInputs = Pick<
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'worktreesByRepo'
> &
ProjectCatalogReadState
>
type NativeChatSkillTab = { id: string; startupCwd?: string }
@@ -63,7 +59,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki
floatingWorkspacePath: state.floatingWorkspacePath,
folderWorkspaces: selectFolderWorkspaces(state),
projectGroups: state.projectGroups,
projectHostSetupProjection: getProjectHostSetupProjectionFromState(state),
projects: state.projects,
repos: state.repos,
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey,
settings: state.settings,
@@ -137,7 +137,7 @@ function corpus(scale: number, dense = false) {
worktreesByRepo: {},
detectedWorktreesByRepo: {},
projectGroups: [],
endpointProjects: [],
projects: [],
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
removedRuntimeEnvironmentIds: new Set(),
@@ -35,8 +35,7 @@ function stateForHost(hostId: string) {
activeWorktreeId: 'worktree-1',
folderWorkspaces: [],
projectGroups: [],
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
repos: [
{
id: 'repo-1',
@@ -103,8 +103,7 @@ describe('floating workspace skill discovery', () => {
floatingWorkspacePath: '/home/me/scratch',
folderWorkspaces: [],
projectGroups: [],
endpointProjects: [],
endpointProjectHostSetups: [],
projects: [],
repos: [],
restoredRuntimeHostIdByWorkspaceSessionKey: {},
// Floating panels stay local even with a focused paired runtime.
@@ -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 { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import {
@@ -17,12 +17,10 @@ import { SetProjectLocationDialog } from './SetProjectLocationDialog'
const storeMock = vi.hoisted(() => {
const repos: Repo[] = []
const endpointProjects: Project[] = []
const endpointProjectHostSetups: ProjectHostSetup[] = []
const projectHostSetups: ProjectHostSetup[] = []
return {
repos,
endpointProjects,
endpointProjectHostSetups,
projectHostSetups,
setupProjectExistingFolder: vi.fn(),
setupProjectClone: vi.fn()
}
@@ -127,14 +125,12 @@ function createInput(): Parameters<typeof useTargetChangeActions>[0] {
beforeEach(() => {
storeMock.repos = [localRepo]
storeMock.endpointProjects = [...localProjection.projects]
storeMock.endpointProjectHostSetups = [...localProjection.setups]
storeMock.projectHostSetups = [...localProjection.setups]
storeMock.setupProjectExistingFolder.mockReset()
storeMock.setupProjectClone.mockReset()
const publishRuntimeSetup = async () => {
storeMock.repos = [localRepo, runtimeRepo]
storeMock.endpointProjects = [...localProjection.projects, ...runtimeProjection.projects]
storeMock.endpointProjectHostSetups = [...localProjection.setups, ...runtimeProjection.setups]
storeMock.projectHostSetups = [...localProjection.setups, ...runtimeProjection.setups]
return {
project: runtimeProjection.projects[0],
repo: runtimeRepo,
@@ -1,6 +1,5 @@
import { expect, it } from 'vitest'
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>): Worktree {
@@ -18,34 +17,44 @@ function worktree(partial: Partial<Worktree>): Worktree {
} as Worktree
}
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
describe('orderProjectIdsByRecency', () => {
it('orders projects by their newest workspace, newest first', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: 'alpha', createdAt: 10 }),
worktree({ projectId: 'beta', createdAt: 30 }),
worktree({ projectId: 'gamma', createdAt: 20 })
])
expect(ids.filter((id) => !id.includes(':'))).toEqual(['beta', 'gamma', 'alpha'])
})
it('uses the newest workspace per project, not the oldest', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: 'alpha', createdAt: 1 }),
worktree({ projectId: 'alpha', createdAt: 99 }),
worktree({ projectId: 'beta', createdAt: 50 })
])
expect(ids[0]).toBe('alpha')
})
it('skips legacy repo-only workspaces that carry no project identity', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: undefined, createdAt: 99 }),
worktree({ projectId: 'beta', createdAt: 1 })
])
expect(ids).toContain('beta')
expect(ids).not.toContain(undefined)
})
it('emits the folder-group option id too, so grouped targets resolve', () => {
const ids = orderProjectIdsByRecency([worktree({ projectId: 'alpha', createdAt: 5 })])
expect(ids).toContain('project-group:alpha')
})
it('treats a missing createdAt as oldest rather than crashing', () => {
const ids = orderProjectIdsByRecency([
worktree({ projectId: 'alpha' }),
worktree({ projectId: 'beta', createdAt: 5 })
])
expect(ids[0]).toBe('beta')
})
expect(counts[1]).toBeLessThanOrEqual(counts[0] * 10)
})
@@ -1,25 +1,13 @@
import { useMemo } from 'react'
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'
/**
* Most-recently-used project option ids, newest first, derived from when the
* user last created a workspace in each project. Orca stores no explicit
* 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[],
setups?: readonly ProjectHostSetup[]
): string[] {
const owners = getProjectHostSetupLookup(setups)
/** Use each project’s newest workspace creation as recency; it matches this picker’s action without separate persisted state. */
export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string[] {
const newestByProject = new Map<string, number>()
for (const worktree of worktrees) {
const projectId = owners.projectId(worktree)
// Legacy repo-only workspaces have no projectId and can't be attributed.
const projectId = worktree.projectId
if (projectId === undefined || projectId === '') {
continue
}
@@ -40,6 +28,5 @@ export function orderProjectIdsByRecency(
export function useRecentProjectIds(): string[] {
const worktrees = useAllWorktrees()
const { setups } = useProjectHostSetupProjection()
return useMemo(() => orderProjectIdsByRecency(worktrees, setups), [worktrees, setups])
return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees])
}
@@ -1,4 +1,3 @@
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'
@@ -69,7 +68,7 @@ export function ChecksTab({
targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null
)
const repos = useAppStore((s) => s.repos)
const projects = useAppStore((s) => selectCanonicalProjects(s))
const projects = useAppStore((s) => s.projects)
const [fixingChecks, setFixingChecks] = useState(false)
const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState<string | null>(null)
const mountedRef = useMountedRef()
@@ -137,7 +136,7 @@ export function ChecksTab({
{
activeRepoId: null,
activeWorktreeId: null,
projectHostSetupProjection: { projects, setups: [] },
projects,
repos,
settings,
worktreesByRepo: {}
@@ -11,8 +11,6 @@ const mockState: {
folderWorkspaces: never[]
projectGroups: never[]
repos: never[]
endpointProjects: never[]
endpointProjectHostSetups: never[]
worktreesByRepo: Record<string, never>
} = {
settings: {},
@@ -20,8 +18,6 @@ const mockState: {
folderWorkspaces: [],
projectGroups: [],
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
worktreesByRepo: {}
}
const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => {
@@ -160,11 +156,7 @@ 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.any(Function)
)
expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local')
)
expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull()
await waitFor(() => expect(screen.queryByText('Fix the foo pipeline')).toBeNull())
@@ -1,180 +0,0 @@
// @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<typeof NewWorkspace>()),
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<Window['api']['aiVault']['prepareSessionResume']>()
.mockResolvedValue({ useRealCodexHome: false })
const listSessions = vi.fn<Window['api']['aiVault']['listSessions']>().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(
<ConfirmationDialogContext value={async () => false}>
<TooltipProvider>
<AiVaultPanel />
</TooltipProvider>
</ConfirmationDialogContext>
)
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()
})
@@ -1,4 +1,3 @@
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'
@@ -64,15 +63,14 @@ export default function AiVaultPanel({
const {
workspaceId: activeWorktreeId,
worktree: activeWorktree,
repo: activeRepo,
legacyProjectKey
repo: activeRepo
} = useAiVaultPanelWorkspace(workspace)
const repos = useRepos()
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
const resumeTargetState = useAppStore(
useShallow((state) => ({
projectHostSetupProjection: getProjectHostSetupProjectionFromState(state),
projects: state.projects,
settings: state.settings,
folderWorkspaces: selectFolderWorkspaces(state),
projectGroups: state.projectGroups,
@@ -156,18 +154,11 @@ export default function AiVaultPanel({
aiVaultSearchScopeIdentity({
scope,
activeWorktreeId: activeWorktree?.id,
activeProjectKey: legacyProjectKey
activeProjectKey
}),
[legacyProjectKey, activeWorktree?.id, scope]
)
const search = useAiVaultPanelSearch(
query,
agents,
searchWithin,
executionHostScope,
searchSort,
activeProjectKey ?? undefined
[activeProjectKey, activeWorktree?.id, scope]
)
const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort)
const { searching, searchHits } = search
const searchSessions = useMemo(
() => withAiVaultHistoryPreviews(search.sessions, history),
@@ -189,10 +180,9 @@ export default function AiVaultPanel({
repos,
worktrees: allWorktrees,
projectHostSetupProjection,
settings: settings ?? undefined,
sessions
}),
[allWorktrees, projectHostSetupProjection, repos, sessions, settings]
[allWorktrees, projectHostSetupProjection, repos, sessions]
)
const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null
const {
@@ -50,8 +50,6 @@ function stateForWorktree(args: {
return {
folderWorkspaces: [],
projectGroups: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: [
{
...TEST_REPO,
@@ -116,8 +114,6 @@ describe('useAiVaultExecutionHostScope', () => {
folderWorkspaces: [],
projectGroups: [],
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
worktreesByRepo: {}
}
})
@@ -133,8 +129,6 @@ describe('useAiVaultExecutionHostScope', () => {
folderWorkspaces: [],
projectGroups: [],
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
worktreesByRepo: {}
}
})
@@ -1,36 +1,9 @@
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'
export function resolveActiveProjectKey(
activeRepo: Repo | null,
activeWorktree: Worktree | null,
owners: ReturnType<typeof getProjectHostSetupLookup>
): string | null {
const execution = parseExecutionHostId(activeRepo ? getRepoExecutionHostId(activeRepo) : null)
const owner =
activeWorktree ??
(activeRepo
? {
repoId: activeRepo.id,
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, ProjectHostSetup>
@@ -48,15 +21,3 @@ export function resolveLegacyActiveProjectKey(
return toAiVaultProjectKey(null, activeRepo?.id ?? activeWorktree?.repoId ?? null)
}
export function buildSetupByRepoId(
setups: readonly ProjectHostSetup[]
): ReadonlyMap<string, ProjectHostSetup> {
const setupByRepoId = new Map<string, ProjectHostSetup>()
for (const setup of setups) {
if (setup.repoId && !setupByRepoId.has(setup.repoId)) {
setupByRepoId.set(setup.repoId, setup)
}
}
return setupByRepoId
}
@@ -1,172 +0,0 @@
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,
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 = {
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]
})
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>): AiVaultSession {
return { ...baseSession, ...overrides }
}
function makeRepo(overrides: Partial<Repo>): Repo {
return {
id: 'repo-1',
path: '/Users/ada/orca',
displayName: 'Orca',
badgeColor: '#737373',
addedAt: 1,
...overrides
}
}
function makeWorktree(overrides: Partial<Worktree>): 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
}
}
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'])
})
@@ -20,11 +20,7 @@ const AGENTS = ['codex' as const]
const WORKSPACE = { kind: 'workspace', worktreeId: 'repo-1::/work/app' } as const
const searchSessions =
vi.fn<
(
request: AiVaultSearchRequest,
scope?: ExecutionHostScope,
isCurrent?: () => boolean
) => Promise<AiVaultSearchResponse>
(request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise<AiVaultSearchResponse>
>()
beforeEach(() => {
@@ -94,13 +90,9 @@ describe('the panel hook under a scope identity', () => {
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
{ query: 'needle', filters: { agents: ['codex'] }, within, cursor: undefined },
'ssh:build-box',
expect.any(Function)
'ssh:build-box'
)
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 () => {
@@ -337,20 +337,6 @@ 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 () => {
@@ -22,7 +22,6 @@ export function aiVaultSessionListArgs(
options: { force?: boolean; requestToken: string }
): AiVaultListArgs {
return {
historyOwnership: {},
includeAntigravityIdeSessions: true,
limit: request.sessionLimit === 'unlimited' ? undefined : request.sessionLimit,
unlimited: request.sessionLimit === 'unlimited',
@@ -1,17 +1,4 @@
import {
folderGroupKey,
folderLabel,
type AiVaultSessionProject
} from '../../../../shared/ai-vault-session-filters'
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'
@@ -64,49 +51,3 @@ export function matchAiVaultProjectCandidates<T extends ProjectCandidate>(
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<typeof createNormalizedAiVaultScopeCwdMatcher>
}
export function makeProjectCandidate(
fields: Omit<SessionProjectCandidate, 'normalizedPath' | 'ownsNormalizedCwd'> & { 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
}
export function folderProject(cwd: string): AiVaultSessionProject {
return {
kind: 'folder',
key: folderGroupKey(cwd),
label: folderLabel(cwd)
}
}

Some files were not shown because too many files have changed in this diff Show More