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

* WIP: same-host same-remote checkouts keep their own project id

* WIP: claim the shared project id per checkout surface; drop the display-only clone split

* WIP: index derived ids by repo in the hydration normalizer

* Resolve workspace project affiliation from current checkout owners

* fix: preserve concrete project ownership across catalogs and operations

* fix: combine recent project selector imports

* fix: enforce canonical catalog reads and preserve search continuity

* fix: scope catalog lint exceptions to renderer files

* Resolve project search ownership before native candidate limits

* Combine duplicate session scope imports

* fix: derive returned history row ownership on the host

* Reconcile full session history replies by content

* Require authoritative project catalogs for terminal snapshots

* Preserve registered project claims and partial history results

* test: check orphaned setup cleanup state without assertions

* fix: align catalog callers and native search fixture contracts

* test: check full repo shapes in runtime routing fixtures

* Fix checked catalog inputs in compiler regression fixtures

* Keep checked PTY fixture repos mutable

* test: align catalog fixtures with canonical readers

* test: check catalog and runtime fixture inputs

* test: check catalog fixtures without outer assertions

* test: follow persistence harness relocation

* Adapt catalog test inputs to integrated main checks

* Test History hashing across cached depth revalidation

* test(settings): expect host-qualified setup removal

* test: capture host split listings and scope relay recovery

* ci: regenerate merge ref

* test: type host-split listing diagnostics
This commit is contained in:
Brennan Benson
2026-10-26 19:07:06 -07:00
committed by GitHub
parent cf381255c4
commit 29a5f89b8a
314 changed files with 8188 additions and 2068 deletions
+41
View File
@@ -153,6 +153,14 @@
{
"property": "randomUUID",
"message": "crypto.randomUUID is missing in non-secure contexts (Remote Web over plain HTTP), which white-screens the app. Use createBrowserUuid() from '@/lib/browser-uuid'."
},
{
"property": "endpointProjects",
"message": "Read canonical affiliation through project-host-setup-selector; endpoint facts are reserved for ingestion and concrete operation adapters."
},
{
"property": "endpointProjectHostSetups",
"message": "Read canonical affiliation through project-host-setup-selector; endpoint facts are reserved for ingestion and concrete operation adapters."
}
]
}
@@ -207,6 +215,39 @@
"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,6 +38,7 @@
{
"files": ["**/src/renderer/src/**/*.{ts,tsx}"],
"rules": {
"app-store-performance/no-raw-project-catalog-computed": "error",
"renderer-scrollbar-style/require-styled-vertical-scrollbar": "error",
"ime-enter-guard/require-ime-guard": "error"
}
@@ -49,6 +50,32 @@
"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"]
@@ -354,12 +354,39 @@ 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 (!STORE_HOOK_TEXT.test(context.sourceCode.text) && !context.sourceCode.text.includes('\\')) {
if (
createVisitors !== noRawProjectCatalogComputedRule &&
!STORE_HOOK_TEXT.test(context.sourceCode.text) &&
!context.sourceCode.text.includes('\\')
) {
return {}
}
const visitors = createVisitors(context.sourceCode)
@@ -374,6 +401,7 @@ export default {
meta: { name: 'app-store-performance' },
rules: {
'require-selector': { create: bindContext(requireSelectorRule) },
'no-raw-project-catalog-computed': { create: bindContext(noRawProjectCatalogComputedRule) },
'no-identity-selector': { create: bindContext(noIdentitySelectorRule) },
'no-fresh-selector-result': { create: bindContext(noFreshSelectorResultRule) },
'no-nested-fresh-under-shallow': { create: bindContext(noNestedFreshUnderShallowRule) }
@@ -0,0 +1,102 @@
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 })
}
})
+19 -5
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 { getProjectIdentityKey } from '../../../src/shared/project-host-setup-projection'
import { getRepoHostIdentity } from '../../../src/shared/repo-host-identity'
import { shouldPreserveWorkspaceSourceOnRepoChange } from '../../../src/shared/new-workspace/workspace-source'
import type { SmartModeAvailabilityInput } from '../tasks/mobile-smart-source-modes'
import { deriveRepoSlug, type PasteRepoCandidate } from '../tasks/smart-source-paste-intent'
@@ -22,6 +22,7 @@ import {
type NewWorktreeModalProps
} from './new-worktree-modal-types'
import {
getNewWorkspaceProjectIds,
buildNewWorkspaceProjectOptions,
buildNewWorkspaceRunTargetOptions,
getNewWorkspaceRunTarget
@@ -178,13 +179,26 @@ function NewWorktreeModalContent(props: NewWorktreeModalProps) {
})),
[hostRepos]
)
const projectPickerItems = useMemo(() => buildNewWorkspaceProjectOptions(repos), [repos])
const selectedProjectId = selectedRepo ? getProjectIdentityKey(selectedRepo) : null
const projectIds = useMemo(() => getNewWorkspaceProjectIds(repos), [repos])
const projectPickerItems = useMemo(
() => buildNewWorkspaceProjectOptions(repos, projectIds),
[repos, projectIds]
)
const selectedProjectId = selectedRepo
? (projectIds.get(getRepoHostIdentity(selectedRepo)) ?? null)
: null
const selectedProject =
projectPickerItems.find((project) => project.id === selectedProjectId) ?? null
const runTargetPickerItems = useMemo(
() => buildNewWorkspaceRunTargetOptions(repos, selectedProjectId, hostPlatform, hostLabelById),
[hostLabelById, hostPlatform, repos, selectedProjectId]
() =>
buildNewWorkspaceRunTargetOptions(
repos,
selectedProjectId,
hostPlatform,
hostLabelById,
projectIds
),
[hostLabelById, hostPlatform, repos, selectedProjectId, projectIds]
)
const selectedRunTarget = selectedRepo
? getNewWorkspaceRunTarget(selectedRepo, hostPlatform, hostLabelById)
@@ -75,7 +75,7 @@ describe('new workspace project targets', () => {
})
})
it('shows one target per host when the project has multiple local worktrees', () => {
it('keeps each same-host clone selectable with its concrete target', () => {
const upstream = { owner: 'stablyai', repo: 'orca' }
const repos = [
{ id: 'local-a', displayName: 'orca-a', path: '/src/orca-a', upstream },
@@ -88,7 +88,10 @@ describe('new workspace project targets', () => {
upstream
}
]
const projectId = buildNewWorkspaceProjectOptions(repos)[0]?.id ?? null
const projects = buildNewWorkspaceProjectOptions(repos)
expect(projects.map((project) => project.id)).toEqual(['github:stablyai/orca', 'repo:local-b'])
expect(buildNewWorkspaceRunTargetOptions(repos, 'repo:local-b')[0]?.repo).toBe(repos[1])
const projectId = projects[0]?.id ?? null
expect(buildNewWorkspaceRunTargetOptions(repos, projectId, 'darwin')).toEqual([
expect.objectContaining({ id: 'local', label: LOCAL_HOST_LABEL, detail: '/src/orca-a' }),
@@ -99,4 +102,13 @@ describe('new workspace project targets', () => {
})
])
})
it('uses the complete claim catalog before hiding ineligible create targets', () => {
const upstream = { owner: 'acme', repo: 'app' }
const repos = [
{ id: 'a', displayName: 'A', path: '', addedAt: 1, upstream },
{ id: 'b', displayName: 'B', path: '/b', addedAt: 2, upstream }
]
expect(buildNewWorkspaceProjectOptions(repos).map((project) => project.id)).toEqual(['repo:b'])
expect(buildNewWorkspaceRunTargetOptions(repos, 'repo:b')[0]?.repo).toBe(repos[1])
})
})
@@ -1,16 +1,42 @@
import type { Repo } from '../../../src/shared/repo-types'
import { getRepoExecutionHostId, type ExecutionHostId } from '../../../src/shared/execution-host'
import {
getProjectIdentityKey,
projectHostSetupProjectionFromRepos,
getProjectProviderIdentity
} from '../../../src/shared/project-host-setup-projection'
import { getRepoHostIdentity } from '../../../src/shared/repo-host-identity'
import { getHostContextLabel } from '../../../src/shared/worktree/host-context-labels'
type WorkspaceRepo = Pick<Repo, 'id' | 'displayName' | 'path'> &
Partial<
Pick<Repo, 'connectionId' | 'executionHostId' | 'upstream' | 'repoIcon' | 'gitRemoteIdentity'>
Pick<
Repo,
| 'connectionId'
| 'executionHostId'
| 'upstream'
| 'repoIcon'
| 'gitRemoteIdentity'
| 'addedAt'
| 'projectHostSetupMethod'
| 'kind'
>
>
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
@@ -27,12 +53,16 @@ export type NewWorkspaceRunTargetOption<TRepo extends WorkspaceRepo> = {
}
export function buildNewWorkspaceProjectOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[]
repos: readonly TRepo[],
projectIds = getNewWorkspaceProjectIds(repos)
): NewWorkspaceProjectOption<TRepo>[] {
const options = new Map<string, NewWorkspaceProjectOption<TRepo>>()
const hostIdsByProject = new Map<string, Set<string>>()
for (const repo of repos) {
const id = getProjectIdentityKey(repo)
if (!repo.path) {
continue
}
const id = projectIds.get(getRepoHostIdentity(repo))!
const hostIds = hostIdsByProject.get(id) ?? new Set<string>()
hostIds.add(getRepoExecutionHostId(repo))
hostIdsByProject.set(id, hostIds)
@@ -80,14 +110,15 @@ export function buildNewWorkspaceRunTargetOptions<TRepo extends WorkspaceRepo>(
repos: readonly TRepo[],
projectId: string | null,
localPlatform: NodeJS.Platform | null = null,
hostLabelById?: ReadonlyMap<ExecutionHostId, string>
hostLabelById?: ReadonlyMap<ExecutionHostId, string>,
projectIds = getNewWorkspaceProjectIds(repos)
): NewWorkspaceRunTargetOption<TRepo>[] {
if (!projectId) {
return []
}
const options = new Map<string, NewWorkspaceRunTargetOption<TRepo>>()
for (const repo of repos) {
if (getProjectIdentityKey(repo) !== projectId) {
if (!repo.path || projectIds.get(getRepoHostIdentity(repo)) !== projectId) {
continue
}
const hostId = getRepoExecutionHostId(repo)
@@ -8,6 +8,8 @@ export type MobileWorkspaceRepo = Pick<SharedRepo, 'id' | 'displayName' | 'path'
Partial<
Pick<
SharedRepo,
| 'addedAt'
| 'projectHostSetupMethod'
| 'badgeColor'
| 'connectionId'
| 'executionHostId'
@@ -4,6 +4,7 @@ import type { ProjectOrderBy } from '../../../src/shared/ui-chrome-types'
import type { ServedProjectSections } from './project-sections-operations'
import { NO_PROJECT_SECTION_CATALOG, type ProjectSectionCatalog } from './workspace-list-sections'
import type { SectionRepo } from './workspace-list-types'
import { resolveServedProjectSectionClaims } from '../../../src/shared/project-section-catalog-claims'
// Use served all-host project grouping; older desktops fall back to repo grouping.
// Only visible hosts’ repos open empty sections.
@@ -24,6 +25,7 @@ export function buildProjectSectionCatalog(
projectOrderBy: projectOrderBy ?? NO_PROJECT_SECTION_CATALOG.projectOrderBy
}
}
served = resolveServedProjectSectionClaims(served)
// Why: the desktop withholds uploaded image icons from the snapshot; its repo.list still carries them.
const iconKey = (repo: SectionRepo) => `${getRepoExecutionHostId(repo)}|${repo.id}`
const iconByKey = new Map(desktopRepos.map((repo) => [iconKey(repo), repo.repoIcon]))
@@ -1,8 +1,18 @@
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'
@@ -74,6 +84,36 @@ describe('Project grouping on the phone', () => {
}
)
it.each(['recent', 'manual'] as const)(
'normalizes an accepted old %s snapshot without losing independent clones or concrete targets',
(order) => {
const verdict = projectSectionsRead.interpret({
id: 'old',
ok: true,
result: { catalog: OLD_PROJECT_SECTION_PARITY_SERVED }
})
expect(verdict.accepted).toBe(true)
if (!verdict.accepted || !verdict.value) {
throw new Error('old catalog refused')
}
const canonical = resolveServedProjectSectionClaims(verdict.value)
expect(resolveServedProjectSectionClaims(canonical)).toBe(canonical)
const catalog = buildProjectSectionCatalog(verdict.value, [], null, order)
expect(headers(catalog)).toEqual(PROJECT_SECTION_PARITY_HEADERS[order])
expect(catalog.repos.map((repo) => repo.id)).toEqual(
PROJECT_SECTION_PARITY_SERVED.repos.map((repo) => repo.id)
)
expect(
canonical.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path }))
).toEqual(verdict.value.setups.map(({ repoId, hostId, path }) => ({ repoId, hostId, path })))
expect(
canonical.setups
.filter((setup) => setup.projectId.startsWith('repo:'))
.map((setup) => setup.repoId)
).toEqual(['local-assets', 'local-workspace', 'm4-orca-two'])
}
)
it("groups by repo from an older desktop's own repos when it serves no catalog", () => {
const desktopRepos = PROJECT_SECTION_PARITY_SERVED.repos.filter(
(repo) => repo.executionHostId === 'local'
@@ -144,3 +184,93 @@ 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')
})
@@ -0,0 +1,414 @@
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,3 +1,4 @@
import type { SessionSearchProjectOwnership } from './session-search-project-ownership'
import type { AiVaultAgent } from '../../shared/ai-vault-types'
import type { TranscriptMessageRole } from '../ai-vault/session-transcript-consumers'
@@ -19,6 +20,7 @@ export type SessionSearchScope = 'conversation' | 'all'
export type SessionSearchSort = 'relevance' | 'newest'
export type SessionSearchFilters = {
ownership?: SessionSearchProjectOwnership
agents?: readonly AiVaultAgent[]
/** Only sessions whose cwd is that path or inside it. */
scopePaths?: readonly string[]
@@ -38,6 +38,8 @@ import {
import { sessionRowFilter } from './session-search-row-filter'
import { ensureSessionSearchQuerySchema } from './session-search-query-schema'
import { EMPTY_SNIPPET, sessionSearchSnippet } from './session-search-snippet'
import { sessionSearchProjectOwns } from './session-search-project-ownership'
import type { SessionSearchProjectOwnership } from './session-search-project-ownership'
import { sessionSourcePresence } from './session-search-source-presence'
/** Candidate-limit tuning and measurements: docs/reference/agent-session-search-query-tuning.md. */
@@ -60,11 +62,22 @@ export type SessionSearchEngineOptions = {
export class SessionSearchEngine {
private readonly retrieval: SessionSearchRetrieval
private readonly candidateLimit: number
private ownership?: SessionSearchProjectOwnership
constructor(
private readonly db: SyncDatabase,
private readonly options: SessionSearchEngineOptions = {}
) {
this.db.function('canonical_project_owns', { deterministic: true }, (cwd, filePath) =>
Number(
Boolean(
this.ownership &&
typeof cwd === 'string' &&
typeof filePath === 'string' &&
sessionSearchProjectOwns(this.ownership, cwd, filePath)
)
)
)
this.candidateLimit = options.sessionCandidateLimit ?? SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT
// Install tables and generation triggers before preparing retrieval or accepting writes.
ensureSessionSearchQuerySchema(this.db)
@@ -76,6 +89,7 @@ export class SessionSearchEngine {
}
search(request: SessionSearchRequest): SessionSearchResponse {
this.ownership = request.filters?.ownership
const startedAt = performance.now()
ensureSessionSearchQuerySchema(this.db)
const generation = readIndexGeneration(this.db)
@@ -94,7 +108,13 @@ export class SessionSearchEngine {
// Reject invalid cursors before paying for retrieval.
const pageKey = sessionSearchPageKey(request)
const offset = request.cursor
? decodeSessionSearchCursor(request.cursor, generation, pageKey, incarnation)
? decodeSessionSearchCursor(
request.cursor,
generation,
pageKey,
incarnation,
this.ownership?.fingerprint
)
: 0
const plan = planSessionSearchQuery(split.text)
@@ -121,7 +141,13 @@ export class SessionSearchEngine {
page: {
hasMore,
cursor: hasMore
? encodeSessionSearchCursor(generation, offset + limit, pageKey, incarnation)
? encodeSessionSearchCursor(
generation,
offset + limit,
pageKey,
incarnation,
this.ownership?.fingerprint
)
: null
},
truncated: {
@@ -0,0 +1,131 @@
import * as fs from 'node:fs/promises'
import { afterEach, expect, it, vi } from 'vitest'
import type { Repo } from '../../shared/repo-types'
import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests,
type SessionSearchScopeCatalog
} from './session-search-scope-catalog'
import { createSessionSearchService } from './session-search-service'
import { searchSessionService, setSessionSearchService } from './session-search-service-registry'
import {
openSessionSearchHarness,
addSyntheticSession,
type SessionSearchHarness
} from './session-search-engine-test-fixture'
vi.mock('node:fs/promises', async (importOriginal) => {
const actual = await importOriginal<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,6 +17,7 @@ export class SessionSearchCursorError extends Error {
type CursorPayload = {
/** Database incarnation; changes when the index is rebuilt. */
p?: string
i: string
/** Index generation. */
g: number
@@ -35,7 +36,8 @@ export function sessionSearchPageKey(request: SessionSearchRequest): string {
filters.sort ?? 'relevance',
filters.since ?? null,
[...(filters.agents ?? [])].sort(),
[...(filters.scopePaths ?? [])].sort()
[...(filters.scopePaths ?? [])].sort(),
...(filters.ownership ? [filters.ownership.projectKey] : [])
])
return createHash('sha256').update(identity).digest('base64url').slice(0, 16)
}
@@ -44,9 +46,16 @@ export function encodeSessionSearchCursor(
generation: number,
offset: number,
key: string,
incarnation: string
incarnation: string,
ownership?: string
): string {
const payload: CursorPayload = { i: incarnation, g: generation, o: offset, k: key }
const payload: CursorPayload = {
i: incarnation,
g: generation,
o: offset,
k: key,
...(ownership ? { p: ownership } : {})
}
return Buffer.from(JSON.stringify(payload), 'utf-8').toString('base64url')
}
@@ -55,7 +64,8 @@ export function decodeSessionSearchCursor(
cursor: string,
generation: number,
key: string,
incarnation: string
incarnation: string,
ownership?: string
): number {
let payload: CursorPayload
try {
@@ -84,6 +94,9 @@ export function decodeSessionSearchCursor(
if (payload.i !== incarnation) {
throw new SessionSearchCursorError('stale-generation', generation, claimed)
}
if (payload.p !== ownership) {
throw new SessionSearchCursorError('stale-generation', generation, claimed)
}
if (payload.k !== key) {
throw new SessionSearchCursorError('different-query', generation, claimed)
}
@@ -0,0 +1,284 @@
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,6 +38,14 @@ export function sessionRowFilter(
filter.values.push(JSON.stringify(keys.map(scopeRange)))
}
}
if (filters.ownership) {
filter.conditions.push(`id IN (
SELECT scoped.id FROM json_each(?) AS scope JOIN sessions AS scoped
ON scoped.cwd_key >= scope.value ->> 0 AND scoped.cwd_key < scope.value ->> 1
WHERE canonical_project_owns(scoped.cwd_key, scoped.file_path)
)`)
filter.values.push(JSON.stringify(filters.ownership.ranges))
}
return filter
}
@@ -1,20 +1,24 @@
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 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'>>>
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'>>
>
>
settings: Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
}
@@ -7,12 +7,13 @@ import { OrcaRuntimeService } from '../runtime/orca-runtime'
import { AI_VAULT_METHODS } from '../runtime/rpc/methods/ai-vault'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests
resetSessionSearchScopeCatalogForTests,
type SessionSearchScopeCatalog
} from './session-search-scope-catalog'
import { searchSessionService, setSessionSearchService } from './session-search-service-registry'
const CATALOG = {
repos: [{ id: 'repo-1', path: '/work/app' }],
const CATALOG: SessionSearchScopeCatalog = {
repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
@@ -1,16 +1,9 @@
import {
getRuntimePathBasename,
isPathInsideOrEqual,
isRuntimePathAbsolute,
normalizeRuntimePathForComparison,
resolveRuntimePath
normalizeRuntimePathForComparison
} from '../../shared/cross-platform-path'
import { isFolderRepo } from '../../shared/repo-kind'
import { resolveConfiguredWorktreeBasePaths } from '../../shared/worktree/configured-worktree-base-path'
import { buildKnownOrcaWorkspaceLayouts } from '../../shared/worktree/ownership'
import type { SessionSearchScopeCatalog } from './session-search-scope-catalog'
type ScopeRepo = SessionSearchScopeCatalog['repos'][number]
export { managedWorktreeDirectories } from '../../shared/worktree/ownership'
export class ScopePathSet {
private readonly seen = new Set<string>()
@@ -37,26 +30,3 @@ 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,10 +1,11 @@
import { describe, expect, it } from 'vitest'
import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection'
import type { SessionSearchScopeCatalog } from './session-search-scope-catalog'
import { resolveSessionSearchScope } from './session-search-scope-resolution'
function catalog(overrides: Partial<SessionSearchScopeCatalog> = {}): SessionSearchScopeCatalog {
return {
repos: [{ id: 'repo-1', path: '/work/app' }],
repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
@@ -82,7 +83,18 @@ describe('workspace scope', () => {
kind: 'workspace',
worktreeId: 'folder-1::/work/notes::workspace:11111111-1111-4111-8111-111111111111'
},
catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] })
catalog({
repos: [
{
id: 'folder-1',
path: '/work/notes',
kind: 'folder',
displayName: 'notes',
badgeColor: '',
addedAt: 0
}
]
})
)
expect(paths(resolution)).toEqual(['/work/notes'])
})
@@ -137,7 +149,7 @@ describe('project scope', () => {
it("uses a repo's own worktree base path instead of the global root", () => {
const resolution = resolveSessionSearchScope(
{ kind: 'project', projectKey: 'repo:repo-1' },
catalog({ repos: [{ id: 'repo-1', path: '/work/app', worktreeBasePath: '/trees/app' }] })
catalog({ repos: [{ ...catalog().repos[0], worktreeBasePath: '/trees/app' }] })
)
// 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'])
@@ -188,10 +200,20 @@ describe('project scope', () => {
const resolution = resolveSessionSearchScope(
{ kind: 'project', projectKey: 'project:proj-1' },
catalog({
repos: [{ id: 'repo-1', path: '/srv/app' }],
projects: [{ id: 'proj-1', sourceRepoIds: ['repo-1'] }],
repos: [{ ...catalog().repos[0], path: '/srv/app' }],
projects: [
{
id: 'proj-1',
sourceRepoIds: ['repo-1'],
displayName: 'app',
badgeColor: '',
createdAt: 0,
updatedAt: 0
}
],
projectHostSetups: [
{
...projectHostSetupCompatibilityFromRepos(catalog().repos).setups[0],
projectId: 'proj-1',
repoId: 'repo-1',
path: '/srv/app',
@@ -206,7 +228,18 @@ describe('project scope', () => {
it('takes a folder workspace project as its folder', () => {
const resolution = resolveSessionSearchScope(
{ kind: 'project', projectKey: 'repo:folder-1' },
catalog({ repos: [{ id: 'folder-1', path: '/work/notes', kind: 'folder' }] })
catalog({
repos: [
{
id: 'folder-1',
path: '/work/notes',
kind: 'folder',
displayName: 'notes',
badgeColor: '',
addedAt: 0
}
]
})
)
expect(paths(resolution)).toEqual(['/work/notes'])
})
@@ -221,7 +254,18 @@ describe('project scope', () => {
expect(
resolveSessionSearchScope(
{ kind: 'project', projectKey: 'project:proj-1' },
catalog({ projects: [{ id: 'proj-1', sourceRepoIds: ['repo-elsewhere'] }] })
catalog({
projects: [
{
id: 'proj-1',
sourceRepoIds: ['repo-elsewhere'],
displayName: 'app',
badgeColor: '',
createdAt: 0,
updatedAt: 0
}
]
})
)
).toEqual({ kind: 'unknown' })
})
@@ -3,12 +3,13 @@ import { AiVaultSearchRequestSchema } from '../../shared/ai-vault-search-contrac
import { fakeSearchService } from '../../shared/ai-vault-search-test-fixture'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests
resetSessionSearchScopeCatalogForTests,
type SessionSearchScopeCatalog
} from './session-search-scope-catalog'
import { searchSessionService, setSessionSearchService } from './session-search-service-registry'
const CATALOG = {
repos: [{ id: 'repo-1', path: '/work/app' }],
const CATALOG: SessionSearchScopeCatalog = {
repos: [{ id: 'repo-1', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 }],
projects: [],
projectHostSetups: [],
worktreeMeta: { 'repo-1::/work/app': {} },
@@ -4,6 +4,11 @@ import {
unavailableSessionSearchStatus,
createSessionSearchClient
} from '../../shared/ai-vault-search-client'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests
} from './session-search-scope-catalog'
import * as ownership from './session-search-project-ownership'
import { AI_VAULT_AGENTS } from '../../shared/ai-vault-types'
import {
setSessionSearchService,
@@ -12,6 +17,8 @@ import {
} from './session-search-service-registry'
afterEach(() => {
resetSessionSearchScopeCatalogForTests()
vi.restoreAllMocks()
setSessionSearchService(null)
vi.useRealTimers()
})
@@ -132,6 +139,33 @@ describe('session search service registry', () => {
)
}
)
it('does no canonical preparation for unscoped or legacy requests', async () => {
const source = vi.fn(() => ({
repos: [],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
settings: { workspaceDir: '', nestWorkspaces: false }
}))
installSessionSearchScopeCatalogSource(source)
const materialize = vi.spyOn(ownership, 'materializeSessionSearchOwnership')
const service = fakeSearchService()
setSessionSearchService(service)
for (const reason of ['disabled', 'not-ready'] as const) {
service.search.mockResolvedValue({ kind: 'unavailable', reason })
expect(await searchSessionService({ query: 'needle' }, 'ipc')).toEqual({
kind: 'unavailable',
reason
})
}
expect(source).not.toHaveBeenCalled()
await searchSessionService(
{ query: 'needle', within: { kind: 'project', projectKey: 'repo:absent' } },
'ipc'
)
expect(source).toHaveBeenCalledTimes(1)
expect(materialize).not.toHaveBeenCalled()
})
it('waits for reconcile before search, and clears its timeout', async () => {
vi.useFakeTimers()
const service = fakeSearchService()
@@ -6,6 +6,12 @@ import {
} from '../../shared/ai-vault-search-contract'
import { unavailableSessionSearchStatus } from '../../shared/ai-vault-search-client'
import { sessionSearchScopeCatalog } from './session-search-scope-catalog'
import { resolveProjectHostSetupProjection } from '../../shared/project-host-setup-hydration'
import { getRepoExecutionHostId } from '../../shared/execution-host'
import {
compileSessionSearchProjectOwnership,
materializeSessionSearchOwnership
} from './session-search-project-ownership'
import { resolveSessionSearchScope } from './session-search-scope-resolution'
import type { AiVaultSearchResponse, AiVaultSearchStatus } from '../../shared/ai-vault-search-types'
import {
@@ -60,20 +66,60 @@ export async function searchSessionService(
agents: compatibleAgents
}
}
const hostScope = within
? resolveSessionSearchScope(within, sessionSearchScopeCatalog())
: undefined
const retrievalScope =
compatibleAgents.length === 0 && hostScope?.kind !== 'unknown'
? { kind: 'resolved' as const, paths: [''] }
: hostScope
const freshness =
request.freshness === 'wait-until-current'
? await reconcileWithin(current, freshnessTimeoutMs)
: false
const 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 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 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,3 +1,4 @@
import type { SessionSearchProjectOwnership } from './session-search-project-ownership'
import type {
AiVaultSearchRequest,
AiVaultSearchResponse,
@@ -10,6 +11,10 @@ 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 = {
@@ -42,7 +47,15 @@ export function createSessionSearchService({
try {
const result = engine.search(
hostScope
? { ...request, filters: { ...request.filters, scopePaths: hostScope.paths } }
? {
...request,
filters: {
...request.filters,
...(hostScope.kind === 'canonical'
? { ownership: hostScope.selection }
: { scopePaths: hostScope.paths })
}
}
: request
)
return {
@@ -1,4 +1,10 @@
import { describe, expect, it } from 'vitest'
import { projectHostSetupCompatibilityFromRepos } from '../../shared/project-host-setup-projection'
import type { Repo } from '../../shared/repo-types'
import {
compileSessionSearchProjectOwnership,
sessionSearchProjectOwns
} from './session-search-project-ownership'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import { sessionSearchScopeCatalogFromStore } from './session-search-store-scope-catalog'
@@ -19,16 +25,22 @@ function meta(hostId: WorktreeMeta['hostId']): WorktreeMeta {
}
function store() {
const repos: Repo[] = [
{ id: 'local-repo', path: '/work/app', displayName: 'app', badgeColor: '', addedAt: 0 },
{
id: 'ssh-repo',
path: '/srv/app',
connectionId: 'box',
displayName: 'app',
badgeColor: '',
addedAt: 1
}
]
const projection = projectHostSetupCompatibilityFromRepos(repos)
return {
getRepos: () => [
{ id: 'local-repo', path: '/work/app' },
{ id: 'ssh-repo', path: '/srv/app', connectionId: 'box' }
],
getProjects: () => [{ id: 'proj-1', sourceRepoIds: ['local-repo'] }],
getProjectHostSetups: () => [
{ projectId: 'proj-1', hostId: 'local' as const, repoId: 'local-repo', path: '/work/app' },
{ projectId: 'proj-1', hostId: 'ssh:box' as const, repoId: 'ssh-repo', path: '/srv/app' }
],
getRepos: () => repos,
getProjects: () => projection.projects,
getProjectHostSetups: () => projection.setups,
getAllWorktreeMeta: () => ({
'local-repo::/work/one': meta('local'),
'ssh-repo::/srv/one': meta('ssh:box')
@@ -38,16 +50,19 @@ function store() {
}
describe('scope catalog from the profile store', () => {
it('keeps only the rows the addressed execution host owns', () => {
it('captures every Repo/setup before projection and applies filesystem eligibility afterwards', async () => {
const local = sessionSearchScopeCatalogFromStore(store(), 'local')
expect(local.repos.map((repo) => repo.id)).toEqual(['local-repo'])
expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo'])
expect(local.repos.map((repo) => repo.id)).toEqual(['local-repo', 'ssh-repo'])
expect(local.projectHostSetups.map((setup) => setup.repoId)).toEqual(['local-repo', 'ssh-repo'])
const selection = await compileSessionSearchProjectOwnership(local, 'repo:local-repo')
expect(sessionSearchProjectOwns(selection, '/work/app', '/native/session')).toBe(true)
expect(sessionSearchProjectOwns(selection, '/srv/app', '/native/session')).toBe(false)
expect(Object.keys(local.worktreeMeta)).toEqual(['local-repo::/work/one'])
})
it('answers for an SSH host from that host’s own rows, not the desktop’s', () => {
const remote = sessionSearchScopeCatalogFromStore(store(), 'ssh:box')
expect(remote.repos.map((repo) => repo.id)).toEqual(['ssh-repo'])
expect(remote.repos.map((repo) => repo.id)).toEqual(['local-repo', 'ssh-repo'])
expect(Object.keys(remote.worktreeMeta)).toEqual(['ssh-repo::/srv/one'])
})
@@ -1,8 +1,4 @@
import {
getRepoExecutionHostId,
normalizeExecutionHostId,
type ExecutionHostId
} from '../../shared/execution-host'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { ProjectHostSetup } from '../../shared/project-types'
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
import type { GlobalSettings } from '../../shared/global-settings-types'
@@ -20,22 +16,25 @@ export type SessionSearchScopeStore = {
})[]
getAllWorktreeMeta(): Record<string, WorktreeMeta>
getAllWorktreeMetaForHost?: (executionHostId: ExecutionHostId) => Record<string, WorktreeMeta>
getSettings(): Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces'>
getSettings(): Pick<GlobalSettings, 'workspaceDir' | 'nestWorkspaces' | 'workspaceDirHistory'>
}
// The store includes other hosts’ paths; expose only the answering host’s registrations.
// Claims need the complete catalog; filesystem eligibility is applied after projection.
export function sessionSearchScopeCatalogFromStore(
store: SessionSearchScopeStore,
executionHostId: ExecutionHostId
): SessionSearchScopeCatalog {
const settings = store.getSettings()
return {
repos: store.getRepos().filter((repo) => getRepoExecutionHostId(repo) === executionHostId),
repos: store.getRepos(),
executionHostId,
projects: store.getProjects(),
projectHostSetups: store
.getProjectHostSetups()
.filter((setup) => normalizeExecutionHostId(setup.hostId) === executionHostId),
projectHostSetups: store.getProjectHostSetups(),
worktreeMeta: readAllWorktreeMetaForHost(store, executionHostId),
settings: { workspaceDir: settings.workspaceDir, nestWorkspaces: settings.nestWorkspaces }
settings: {
workspaceDir: settings.workspaceDir,
nestWorkspaces: settings.nestWorkspaces,
workspaceDirHistory: settings.workspaceDirHistory
}
}
}
+42 -2
View File
@@ -1,4 +1,9 @@
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,
@@ -47,6 +52,7 @@ 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[]
}
@@ -116,12 +122,15 @@ export async function listAiVaultSessions(
cachedList.expiresAt > now &&
aiVaultCachedDepthServes(cachedList.depth, depth, args?.scopePaths)
) {
return truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths)
return annotateHistoryOwnership(
truncateAiVaultListResult(cachedList.result, depth, args?.scopePaths),
args
)
}
// Capture before the coordinator's start microtask so intervening invalidation still rejects the result.
const startGeneration = cacheGeneration
let progress: LocalAiVaultScanProgressHandle | null = null
return scanCoordinator.run({
const result = await scanCoordinator.run({
key: scanKey,
force: args?.force,
signal: options.signal,
@@ -169,6 +178,37 @@ 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,6 +59,7 @@ describe('runtime AI Vault session scanner', () => {
{
limit: 25,
force: true,
historyOwnership: {},
scopePaths: ['/srv/app']
},
{ timeoutMs: 3000 }
@@ -71,6 +72,7 @@ describe('runtime AI Vault session scanner', () => {
{
limit: 25,
force: true,
historyOwnership: {},
scopePaths: ['/srv/app'],
executionHostId: 'runtime:env-1'
},
@@ -77,6 +77,7 @@ export async function scanRuntimeAiVaultSessions(
environmentId,
'aiVault.listSessions',
{
historyOwnership: args.historyOwnership,
includeAntigravityIdeSessions: args.includeAntigravityIdeSessions,
limit: args.limit,
unlimited: args.unlimited,
@@ -1,11 +1,15 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const callRuntimeEnvironment = vi.hoisted(() => vi.fn())
const { callRuntimeEnvironment, resolveEnvironment } = vi.hoisted(() => ({
callRuntimeEnvironment: vi.fn(),
resolveEnvironment: vi.fn()
}))
vi.mock('../../shared/runtime-environment-store', () => ({ resolveEnvironment }))
vi.mock('../ipc/runtime-environments/runtime-environment-transport-routing', () => ({
callRuntimeEnvironment
}))
import { callRuntimeSessionSearch } from './runtime-session-search-call'
import { callRuntimeSessionSearch, bindRuntimeSessionSearch } from './runtime-session-search-call'
beforeEach(() => {
callRuntimeEnvironment.mockReset()
@@ -37,3 +41,36 @@ describe('runtime session search transport', () => {
).rejects.toMatchObject({ code: 'method_not_found', message: 'unknown method' })
})
})
it('captures the selected pairing/runtime before status and reuses it for search and continuation', async () => {
const selected = { id: 'env-1', createdAt: 1, pairingRevision: 7, runtimeId: 'runtime-a' }
resolveEnvironment.mockReturnValue(selected)
const call = bindRuntimeSessionSearch('/user/data', 'env-1')
selected.pairingRevision = 8
selected.runtimeId = 'runtime-b'
callRuntimeEnvironment.mockResolvedValue({ ok: true, result: {} })
for (const [method, params] of [
['aiVault.searchStatus', {}],
[
'aiVault.searchSessions',
{
query: 'needle',
within: { kind: 'project', projectKey: 'project:P', canonicalProjectKey: 'repo:b' }
}
],
['aiVault.searchSessions', { query: 'needle', cursor: 'next' }]
] as const) {
await call(method, params)
expect(callRuntimeEnvironment).toHaveBeenLastCalledWith(
'/user/data',
'env-1',
method,
params,
undefined,
7,
undefined,
{ expectedEnvironmentRuntimeId: 'runtime-a' }
)
}
expect(resolveEnvironment).toHaveBeenCalledOnce()
})
@@ -1,3 +1,4 @@
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.
@@ -5,11 +6,33 @@ export async function callRuntimeSessionSearch(
userDataPath: string,
environmentId: string,
method: string,
params: Record<string, unknown>
params: Record<string, unknown>,
expected?: { pairingRevision: number; runtimeId?: string }
): Promise<unknown> {
const response = await callRuntimeEnvironment(userDataPath, environmentId, method, params)
const response = expected
? await callRuntimeEnvironment(
userDataPath,
environmentId,
method,
params,
undefined,
expected.pairingRevision,
undefined,
{ expectedEnvironmentRuntimeId: expected.runtimeId }
)
: await callRuntimeEnvironment(userDataPath, environmentId, method, params)
if (response.ok === true) {
return response.result
}
throw Object.assign(new Error(response.error.message), { code: response.error.code })
}
export function bindRuntimeSessionSearch(userDataPath: string, environmentId: string) {
const environment = resolveEnvironment(userDataPath, environmentId)
const expected = {
pairingRevision: environment.pairingRevision ?? environment.createdAt,
runtimeId: environment.runtimeId ?? undefined
}
return (method: string, params: Record<string, unknown>) =>
callRuntimeSessionSearch(userDataPath, environment.id, method, params, expected)
}
@@ -0,0 +1,69 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { expect, it, vi } from 'vitest'
import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto'
import { encodePairingOffer } from '../../shared/pairing'
import {
addEnvironmentFromPairingCode,
markEnvironmentUsed,
updateEnvironmentFromPairingCode
} from '../../shared/runtime-environment-store'
import { enqueueRuntimeCall } from '../ipc/runtime-environments/runtime-environment-call-queue'
import { bindRuntimeSessionSearch } from './runtime-session-search-call'
const send = vi.hoisted(() => vi.fn())
vi.mock('electron', () => ({ BrowserWindow: { getAllWindows: () => [] } }))
vi.mock('../ipc/runtime-environments/runtime-environment-abortable-requests', () => ({
sendRemoteRuntimeConnectionRequestAbortable: send,
sendRemoteRuntimeRequestAbortable: send
}))
it.each(['pairing', 'runtime'] as const)(
'refuses a queued search after the selected %s changes before dispatch',
async (change) => {
const root = await mkdtemp(join(tmpdir(), 'sta10541-search-queue-'))
let release: () => void = () => {}
const held = new Promise<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,4 +1,5 @@
import { z } from 'zod'
import { isAiVaultProjectKey } from '../../shared/ai-vault-project-key'
import {
AI_VAULT_AGENTS,
AI_VAULT_SCOPE_PATHS_MAX_COUNT,
@@ -58,6 +59,7 @@ const aiVaultSessionSchema = z.object({
.max(AI_VAULT_SCOPE_PATHS_MAX_COUNT)
.optional()
.catch(undefined),
projectOwnerKey: z.string().refine(isAiVaultProjectKey).nullable().optional().catch(undefined),
branch: z.string().nullable(),
model: z.string().nullable(),
filePath: z.string(),
@@ -0,0 +1,186 @@
import { spawnProcess } from '@orca/process-host'
import type { ChildProcessHandle } from '@orca/process-host/process-spec'
import { once } from 'node:events'
import { createRequire } from 'node:module'
import { mkdir } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { expect, it } from 'vitest'
import type { AiVaultSearchResponse } from '../../shared/ai-vault-search-types'
import type { Repo } from '../../shared/repo-types'
import { SESSION_SEARCH_CANDIDATE_LIMIT_DEFAULT } from '../ai-vault-search/session-search-engine'
import {
addSyntheticSession,
markFork
} from '../ai-vault-search/session-search-engine-test-fixture'
import { openSessionSearchIndexerHarness } from '../ai-vault-search/session-search-indexer-test-fixture'
import { compileSessionSearchProjectOwnership } from '../ai-vault-search/session-search-project-ownership'
import { openSessionSearchDatabase } from '../ai-vault-search/session-search-schema'
import { AiVaultScannerServiceClient } from './session-scanner-service-client'
import { buildAiVaultServiceEnv } from './session-scanner-service-env'
import { isAiVaultServiceChildMessage } from './session-scanner-service-protocol'
it('serializes canonical ownership into a separate production scanner before native candidate caps and fork folding', async () => {
const harness = await openSessionSearchIndexerHarness('sta10541-scanner-process')
const home = join(harness.root, 'child-home')
await mkdir(home)
const executable =
process.env.ORCA_TEST_NODE_EXECUTABLE ?? process.env.npm_node_execpath ?? process.execPath
const entry = resolve('src/main/ai-vault/session-scanner-service-entry.ts')
const loader = createRequire(import.meta.url).resolve('tsx')
let child: ChildProcessHandle | undefined
let readyPid: number | undefined
let closed: Promise<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,3 +1,4 @@
import { compileSessionSearchProjectOwnership } from '../ai-vault-search/session-search-project-ownership'
import { existsSync, rmSync } from 'node:fs'
import { join } from 'node:path'
import { afterAll, beforeAll, expect, it, vi } from 'vitest'
@@ -132,6 +133,30 @@ it('answers a search and a reconcile over the protocol', async () => {
}
})
it('executes the canonical selection through the actual child message executor and native index', async () => {
const selection = await compileSessionSearchProjectOwnership(
{
repos: [{ id: 'actual', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
settings: { workspaceDir: '', nestWorkspaces: false }
},
'repo:actual'
)
const response = await call({
type: 'request',
operation: 'searchSessions',
request: { query: 'distinctive' },
hostScope: { kind: 'canonical', selection }
})
expect(response.operation).toBe('searchSessions')
if (response.operation !== 'searchSessions') {
throw new Error('wrong operation')
}
expect(response.value).toMatchObject({ kind: 'results', hits: [{ sessionId: SESSION_ID }] })
})
it('discovers a new root through the parent exchange on manual reconciliation', async () => {
const lateHome = join(harness.root, 'late-home')
const id = 'bbbbbbbb-cccc-4ddd-8eee-ffffffffffff'
@@ -32,8 +32,11 @@ export async function resolveAiVaultScopeRealPaths(
}
}
const scanScopePaths = [...scopePaths]
const seen = new Set(scopePaths.map(normalizeRuntimePathForComparison))
for (const { realPath } of links) {
if (!scanScopePaths.some((path) => sameComparisonPath(path, realPath))) {
const key = normalizeRuntimePathForComparison(realPath)
if (!seen.has(key)) {
seen.add(key)
scanScopePaths.push(realPath)
}
}
+3
View File
@@ -66,6 +66,9 @@ function runtimeScanArgs(
listArgs: AiVaultListArgs | undefined
): AiVaultListArgs {
const scanArgs: AiVaultListArgs = { executionHostScope: hostInfo.executionHostId }
if (listArgs?.historyOwnership !== undefined) {
scanArgs.historyOwnership = listArgs.historyOwnership
}
if (listArgs?.afterFingerprint !== undefined) {
scanArgs.afterFingerprint = listArgs.afterFingerprint
}
+76 -28
View File
@@ -127,20 +127,29 @@ it('answers an empty merge when no host is reachable to ask', async () => {
})
})
it('asks every leg for the merged order and passes freshness through unchanged', async () => {
it('asks every leg for the merged order and passes both Project keys and freshness unchanged', async () => {
const host = new StubHost('local', sessions('a', 2))
const remote = new StubHost('runtime:remote', sessions('b', 2))
const within = {
kind: 'project' as const,
projectKey: 'project:P',
canonicalProjectKey: 'repo:b'
}
await searchAllExecutionHosts(
{
query: 'needle',
freshness: 'wait-until-current',
within,
filters: { sort: 'newest' },
debug: true
},
[host.leg()]
[host.leg(), remote.leg()]
)
expect(remote.lastRequests[0]).toEqual(host.lastRequests[0])
expect(host.lastRequests[0]).toEqual({
query: 'needle',
freshness: 'wait-until-current',
within,
filters: { sort: 'newest' }
})
})
@@ -232,37 +241,76 @@ it('names an unavailable host by its reason without losing the other hosts', asy
expect(response.page.hasMore).toBe(false)
})
it('calls a leg that times out unreachable and retries it on the next page', async () => {
vi.useFakeTimers()
try {
const stalled: SessionSearchHostLeg = {
executionHostId: 'ssh:slow',
timeoutMs: 50,
search: () => new Promise(() => undefined)
}
const healthy = new StubHost('local', sessions('a', 2))
const pending = searchAllExecutionHosts({ query: 'needle' }, [healthy.leg(), stalled])
await vi.advanceTimersByTimeAsync(60)
const first = resultsOf(await pending)
expect(first.hosts).toContainEqual({
executionHostId: 'ssh:slow',
outcome: 'unreachable'
})
expect(first.page.hasMore).toBe(true)
const recovered = new StubHost('ssh:slow', sessions('s', 2))
const second = resultsOf(
await searchAllExecutionHosts({ query: 'needle', cursor: first.page.cursor! }, [
it.each(['relevance', 'newest'] as const)(
'keeps available %s hits 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(),
recovered.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.each(['relevance', 'newest'] as const)(
'keeps available %s hits and the retry cursor when another leg rejects',
async (sort) => {
const healthy = new StubHost('local', sessions('a', 2))
const first = resultsOf(
await searchAllExecutionHosts({ query: 'needle', filters: { sort } }, [
healthy.leg(),
{
executionHostId: 'ssh:down',
search: async () => {
throw new Error('connection refused')
}
}
])
)
expect(second.hosts).toEqual([{ executionHostId: 'ssh:slow', outcome: 'searched' }])
expect(first.hits.map((hit) => hit.sessionId)).toEqual(['a-0', 'a-1'])
expect(first.hosts).toContainEqual({ executionHostId: 'ssh:down', outcome: 'unreachable' })
expect(first.page.hasMore).toBe(true)
const recovered = new StubHost('ssh:down', sessions('s', 2))
const second = resultsOf(
await searchAllExecutionHosts(
{ query: 'needle', filters: { sort }, cursor: first.page.cursor! },
[healthy.leg(), recovered.leg()]
)
)
expect(second.hosts).toEqual([{ executionHostId: 'ssh:down', outcome: 'searched' }])
expect(second.hits.map((hit) => hit.sessionId)).toEqual(['s-0', 's-1'])
} finally {
vi.useRealTimers()
expect(second.page.hasMore).toBe(false)
}
})
)
it('reads at most three pages from one host per merged request', async () => {
// Three-page budget stops a six-page host walk; retain its unread cursor for the next merged page.
+20 -10
View File
@@ -50,7 +50,8 @@ 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[]
legs: readonly SessionSearchHostLeg[],
isActive: () => boolean = () => true
): Promise<AiVaultSearchResponse> {
const startedAt = Date.now()
const limit = resolveSessionSearchLimit(request.limit)
@@ -74,9 +75,13 @@ 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) => fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null))
walks.map((walk) =>
isActive()
? fetchHostPage(walk, resumed?.hosts[walk.executionHostId] ?? null)
: Promise.resolve()
)
)
const hits = await drainMergedPage(walks, limit, sort)
const hits = await drainMergedPage(walks, limit, sort, isActive)
return mergedSearchResponse(walks, { limit, sort }, hits, Date.now() - startedAt)
}
@@ -135,8 +140,12 @@ async function fetchHostPage(walk: HostWalk, entry: MergedSearchCursorEntry | nu
endHostWalk(walk, 'stale', false)
return
}
// Emitted offsets are generation-specific; fence only after emitting hits.
if (walk.emitted > 0 && walk.generation !== response.generation) {
// `e` is an offset into one generation's ranked page, so it is only
// meaningful while that generation stands. Nothing emitted, nothing to fence.
if (
(walk.emitted > 0 || walk.cursor !== null || walk.pages > 1) &&
walk.generation !== response.generation
) {
endHostWalk(walk, 'stale', false)
return
}
@@ -158,8 +167,8 @@ function endHostWalk(walk: HostWalk, outcome: HostOutcome, carry: boolean): void
walk.carry = carry
}
async function advanceHostWalk(walk: HostWalk): Promise<void> {
while (walk.pending.length === 0 && walk.nextCursor !== null) {
async function advanceHostWalk(walk: HostWalk, isActive: () => boolean): Promise<void> {
while (isActive() && walk.pending.length === 0 && walk.nextCursor !== null) {
if (walk.pages >= MAX_HOST_PAGES_PER_REQUEST) {
// Budget spent; the unread page's cursor is already this walk's nextCursor.
walk.carry = true
@@ -172,14 +181,15 @@ async function advanceHostWalk(walk: HostWalk): Promise<void> {
async function drainMergedPage(
walks: readonly HostWalk[],
limit: number,
sort: MergedSort
sort: MergedSort,
isActive: () => boolean
): Promise<AiVaultSearchHit[]> {
const hits: AiVaultSearchHit[] = []
let turn = 0
while (hits.length < limit) {
while (isActive() && hits.length < limit) {
// Every head must be known before picking, so a lagging host is never skipped.
for (const walk of walks) {
await advanceHostWalk(walk)
await advanceHostWalk(walk, isActive)
}
const next = sort === 'newest' ? mostRecentWalk(walks) : walkWithTurn(walks, turn)
if (!next) {
+92 -7
View File
@@ -1,13 +1,18 @@
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 } = vi.hoisted(() => ({
clearSearch: vi.fn(),
handlers: new Map<string, (...args: unknown[]) => Promise<unknown>>(),
sshSearch: vi.fn(),
sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []),
runtimeSearch: vi.fn()
}))
const { clearSearch, handlers, sshSearch, sshHostInfos, runtimeSearch, runtimeHostInfos } =
vi.hoisted(() => ({
clearSearch: vi.fn(),
handlers: new Map<string, (...args: unknown[]) => Promise<unknown>>(),
sshSearch: vi.fn(),
sshHostInfos: vi.fn<() => { targetId: string }[]>(() => []),
runtimeSearch: vi.fn(),
runtimeHostInfos: vi.fn<() => readonly RuntimeAiVaultHostInfo[]>(() => [])
}))
vi.mock('../ai-vault/session-scanner-service-spawn', () => ({
clearSessionSearchInService: clearSearch
}))
@@ -18,11 +23,23 @@ vi.mock('electron', () => ({
},
ipcRenderer: { invoke: (name: string, ...args: unknown[]) => handlers.get(name)!(null, ...args) }
}))
vi.mock('./ai-vault', () => ({
getActiveRuntimeAiVaultHostInfosResult: () => ({ hostInfos: runtimeHostInfos() })
}))
vi.mock('./ssh/ssh', () => ({
requestActiveSshSessionSearch: sshSearch,
getActiveSshAiVaultHostInfos: sshHostInfos
}))
import type { RuntimeAiVaultHostInfo } from './ai-vault-runtime-scan'
import { getSavedRuntimeAiVaultHostInfos } from '../ai-vault/runtime-session-scanner'
import { bindRuntimeSessionSearch } from '../ai-vault/runtime-session-search-call'
import {
addEnvironmentFromPairingCode,
removeEnvironment
} from '../../shared/runtime-environment-store'
import { generateKeyPair, publicKeyToBase64 } from '../../shared/e2ee-crypto'
import { encodePairingOffer } from '../../shared/pairing'
import { registerAiVaultSearchHandlers } from './ai-vault-search'
import { aiVaultApi } from '../../preload/api/ai-vault-bridge'
import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry'
@@ -42,6 +59,7 @@ beforeEach(() => {
sshHostInfos.mockReset()
sshHostInfos.mockReturnValue([])
runtimeSearch.mockReset()
runtimeHostInfos.mockReset().mockReturnValue([])
clearSearch.mockReset()
registerAiVaultSearchHandlers({
callRuntimeSearch: runtimeSearch
@@ -276,3 +294,70 @@ 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 })
}
})
+22 -9
View File
@@ -43,6 +43,9 @@ export type RuntimeSessionSearchCall = (
export type AiVaultSearchHandlerOptions = {
callRuntimeSearch?: RuntimeSessionSearchCall
bindRuntimeSearch?: (
environmentId: string
) => (method: string, params: Record<string, unknown>) => Promise<unknown>
ensureStructuredSessionOwnership?: () => Promise<void>
}
@@ -166,14 +169,23 @@ function allExecutionHostLegs(): SessionSearchHostLeg[] {
}
function remoteHostLeg(host: ParsedExecutionHost): SessionSearchHostLeg {
const client = remoteSearchClient(host, handlerOptions.callRuntimeSearch)
return {
executionHostId: host.id,
timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.search,
search: (request) =>
client
? client.searchSessions(request)
: Promise.resolve({ kind: 'unavailable', reason: 'no-service' })
try {
const client = remoteSearchClient(host, handlerOptions.callRuntimeSearch)
return {
executionHostId: host.id,
timeoutMs: AI_VAULT_ALL_HOST_TIMEOUT_MS.search,
search: (request) =>
client
? client.searchSessions(request)
: Promise.resolve({ kind: 'unavailable', reason: 'no-service' })
}
} catch (error) {
return {
executionHostId: host.id,
search: async () => {
throw error
}
}
}
}
@@ -214,8 +226,9 @@ function remoteSearchClient(
): ReturnType<typeof createSessionSearchClient> | null {
if (host.kind === 'runtime' && call) {
const { environmentId } = host
const bound = handlerOptions.bindRuntimeSearch?.(environmentId)
return createSessionSearchClient(
(method, params) => call(environmentId, method, params),
bound ?? ((method, params) => call(environmentId, method, params)),
'relay'
)
}
+2 -4
View File
@@ -117,13 +117,11 @@ async function listAiVaultSessions(
? [...new Set(scopePaths)].sort()
: scopePaths,
executionHostScope,
historyOwnership: args?.historyOwnership,
includeAntigravityIdeSessions: args?.includeAntigravityIdeSessions === true
})
const depth = requestedAiVaultSessionDepth(args)
const afterFingerprint =
parseRoutableExecutionHostId(executionHostScope)?.kind === 'runtime'
? args?.afterFingerprint
: undefined
const afterFingerprint = parsedHost?.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,4 +1,6 @@
import { app } from 'electron'
import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host'
import { sessionSearchScopeCatalogFromStore } from '../../ai-vault-search/session-search-store-scope-catalog'
import { ORCA_LAYOUT_OWNER_IS_MAIN } from '../../../shared/layout-owner-switch'
import { registerAppHandlers } from '../app'
import { registerCliHandlers } from '../cli'
@@ -102,7 +104,10 @@ import {
resolveRuntimeAiVaultSessionTitles,
scanRuntimeAiVaultSessions
} from '../../ai-vault/runtime-session-scanner'
import { callRuntimeSessionSearch } from '../../ai-vault/runtime-session-search-call'
import {
callRuntimeSessionSearch,
bindRuntimeSessionSearch
} from '../../ai-vault/runtime-session-search-call'
import { 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'
@@ -270,11 +275,15 @@ 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: () =>
@@ -292,9 +301,7 @@ export function registerCoreHandlers(
registerNativeChatHandlers()
registerClipboardHandlers(store, {
localChatAttachmentStore: async () => {
await ensureStructuredAgentSessionHostUnlessRefused(() =>
runtime.ensureStructuredAgentSessionHost()
)
await ensureStructuredSessionOwnership()
return getAgentSessionAttachmentStore()
}
})
@@ -3,6 +3,8 @@ 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 = {
@@ -110,3 +112,81 @@ 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,3 +1,4 @@
import { isRepoBackedProjectHostSetup } from '../persistence/tracking-repos/project-host-compatibility'
import { existsSync, readFileSync } from 'node:fs'
import { homedir } from 'node:os'
import { getDefaultPersistedState, getDefaultWorkspaceSession } from '../../shared/constants'
@@ -219,16 +220,12 @@ 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)
const succession = carryProjectStateThroughIdentityChange(projection.projects, state.projects, {
previous: state.projectHostSetups,
current: projection.setups
})
const currentRepoIds = new Set(state.repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
const projectedSetupIds = new Set(projection.setups.map((setup) => setup.id))
+4 -6
View File
@@ -182,16 +182,15 @@ 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),
getAdditionalAiVaultCodexHomePaths: () => [
...codexAccountHomes.listSessionHomes(),
...(accountLaunch?.listCodexSessionHomes() ?? [])
],
// WSL must retain desktop-pushed CODEX_HOME instead of host-selected resolvers.
...orcadAiVaultSessionSources(profileStore, codexAccountHomes, accountLaunch),
// Why absent on a WSL host: a resolver overrides the desktop-pushed CODEX_HOME.
...accountLaunch?.runtimeDeps,
...agentHookRuntimeDeps(agentHookServer, observedPaneIdentities, () =>
profileStore.getSettings()
@@ -220,7 +219,6 @@ async function startOrcadRuntime(
const { restoreOrcadHostAccountLaunchEnv } = await import('./orcad-host-account-launch-env-store')
restoreOrcadHostAccountLaunchEnv(runtime, runtimeUserDataPath)
const { installOrcadSessionSearchService } = await import('./orcad-session-search')
sessionSearch = await installOrcadSessionSearchService({
userDataPath: runtimeUserDataPath,
getSettings: () => profileStore.getSettings()
+23
View File
@@ -1,3 +1,8 @@
import type { RuntimeConstructionDependencies } from '../runtime/construction/runtime-construction-dependencies'
import {
sessionSearchScopeCatalogFromStore,
type SessionSearchScopeStore
} from '../ai-vault-search/session-search-store-scope-catalog'
import { LOCAL_EXECUTION_HOST_ID } from '../../shared/execution-host'
import type { AiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
import { resolveAiVaultSearchSettings } from '../../shared/ai-vault-search-settings'
@@ -18,3 +23,21 @@ export async function installOrcadSessionSearchService(args: {
onError: (error) => console.error('[orcad] session search:', error)
})
}
export function orcadAiVaultSessionSources(
store: SessionSearchScopeStore,
client: { listSessionHomes(): readonly string[] },
accounts: { listCodexSessionHomes(): readonly string[] } | null
): Pick<
RuntimeConstructionDependencies,
'getProjectOwnershipCatalog' | 'getAdditionalAiVaultCodexHomePaths'
> {
return {
getProjectOwnershipCatalog: () =>
sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID),
getAdditionalAiVaultCodexHomePaths: () => [
...client.listSessionHomes(),
...(accounts?.listCodexSessionHomes() ?? [])
]
}
}
@@ -53,7 +53,8 @@ export function mergeProjectHostSetupCompatibilityState(
const projection = projectHostSetupProjectionFromRepos(repos)
const succession = carryProjectStateThroughIdentityChange(
projection.projects,
state.projects ?? []
state.projects ?? [],
{ previous: state.projectHostSetups ?? [], current: projection.setups }
)
const currentRepoIds = new Set(repos.map((repo) => repo.id))
const projectedProjectIds = new Set(projection.projects.map((project) => project.id))
@@ -192,6 +192,10 @@ 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,3 +1,4 @@
import type { AiVaultSessionSources } from '../../ai-vault/cached-session-list'
import type { CliWriterObservation } from '../../agent-hooks/server/cli-writer-observation'
import type { ExecutionHostScope } from '../../../shared/execution-host'
import type { IPtyProvider } from '../../providers/pty-provider-contract'
@@ -58,6 +59,7 @@ export type RuntimeConstructionDependencies = {
dropAgentStatusForRemovedWorktree?: (worktreeId: string, host?: ExecutionHostScope) => void
canRecoverPersistentLocalPtys?: () => boolean
getPairedDeviceName?: (pairedDeviceId: string) => string | null
getProjectOwnershipCatalog?: AiVaultSessionSources['getProjectOwnershipCatalog']
getAdditionalAiVaultCodexHomePaths?: () => readonly string[]
prepareAiVaultSessionResume?: (
args: AiVaultPrepareSessionResumeArgs
@@ -229,11 +229,14 @@ export abstract class OrcaRuntimeWithStateFields extends OrcaRuntimeWithLinearCo
this.dropAgentStatusForRemovedWorktreeFn = deps?.dropAgentStatusForRemovedWorktree ?? null
this.canRecoverPersistentLocalPtysFn = deps?.canRecoverPersistentLocalPtys ?? (() => true)
this.getPairedDeviceNameFn = deps?.getPairedDeviceName ?? (() => null)
// Headless serve skips registerCoreHandlers but still needs managed-Codex/WSL AiVault sources.
// Why: configure the shared AiVault scan cache from a serve-mode-reachable
// seam so the aiVault.listSessions RPC includes managed-Codex + WSL sessions
// even on headless `orca serve` hosts where registerCoreHandlers never runs.
configureAiVaultSessionSources({
getProjectOwnershipCatalog: deps?.getProjectOwnershipCatalog,
getAdditionalCodexHomePaths: deps?.getAdditionalAiVaultCodexHomePaths
})
if (deps?.getAdditionalAiVaultCodexHomePaths) {
configureAiVaultSessionSources({
getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths
})
configureHostReadableTranscriptPathSources({
getAdditionalCodexHomePaths: deps.getAdditionalAiVaultCodexHomePaths
})
@@ -1,3 +1,18 @@
import '../rpc/unused-default-rpc-methods.test-fixture'
import { RpcDispatcher } from '../rpc/dispatcher'
import { OrcaRuntimeService } from '../orca-runtime'
import { AI_VAULT_METHODS } from '../rpc/methods/ai-vault'
import {
openSessionSearchHarness,
addSyntheticSession,
type SessionSearchHarness
} from '../../ai-vault-search/session-search-engine-test-fixture'
import { createSessionSearchService } from '../../ai-vault-search/session-search-service'
import { setSessionSearchService } from '../../ai-vault-search/session-search-service-registry'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests
} from '../../ai-vault-search/session-search-scope-catalog'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -41,8 +56,12 @@ describe('desktop relay E2EE integration', () => {
const servers: WebSocketServer[] = []
const transports: CloudRelayTransport[] = []
const userDataPaths: string[] = []
let searchIndex: SessionSearchHarness | undefined
afterEach(async () => {
setSessionSearchService(null)
resetSessionSearchScopeCatalogForTests()
await searchIndex?.close()
await Promise.all(transports.splice(0).map((transport) => transport.stop()))
await Promise.all(
servers.splice(0).map(
@@ -128,6 +147,40 @@ describe('desktop relay E2EE integration', () => {
const receivedText = deferred<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: {
@@ -144,7 +197,10 @@ describe('desktop relay E2EE integration', () => {
credentialKind: 'invite'
})
receivedText.resolve(plaintext)
reply(JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } }))
expect(JSON.parse(plaintext)).toEqual(request)
void dispatcher
.dispatch(request, { clientKind: 'mobile' })
.then((result) => reply(JSON.stringify(result)))
sendBinary(new Uint8Array([4, 5, 6]))
},
onBinary: (_socket, bytes) => receivedBinary.resolve(new Uint8Array(bytes)),
@@ -226,16 +282,16 @@ describe('desktop relay E2EE integration', () => {
})
phone.send(JSON.stringify(phoneSession.hello))
await authenticated.promise
phone.send(phoneSession.sealText(JSON.stringify({ id: 'rpc-1', method: 'status.get' })))
phone.send(phoneSession.sealText(JSON.stringify(request)))
phone.send(phoneSession.sealBinary(new Uint8Array([1, 2, 3])))
await expect(receivedText.promise).resolves.toBe(
JSON.stringify({ id: 'rpc-1', method: 'status.get' })
)
await expect(receivedText.promise).resolves.toBe(JSON.stringify(request))
await expect(receivedBinary.promise).resolves.toEqual(new Uint8Array([1, 2, 3]))
await expect(phoneText.promise).resolves.toBe(
JSON.stringify({ id: 'rpc-1', ok: true, result: { path: 'relay' } })
)
expect(JSON.parse(await phoneText.promise)).toMatchObject({
id: 'rpc-1',
ok: true,
result: { kind: 'results', hits: [{ sessionId: '1' }] }
})
await expect(phoneBinary.promise).resolves.toEqual(new Uint8Array([4, 5, 6]))
phone.terminate()
@@ -30,6 +30,7 @@ import {
AiVaultListSessionsParams,
AiVaultPrepareSessionResumeParams
} from './ai-vault'
import { orcadAiVaultSessionSources } from '../../../orcad/orcad-session-search'
import { agentSessionRefusalError } from '../../../../shared/agent-session-wire-refusals'
import { recordStructuredAgentSessionHostInstallRefusal } from '../../structured-agent-session-host-refusal'
import { setStructuredAgentSessionHost } from '../../../native-chat/agent-session-wire/structured-agent-session-registry'
@@ -662,6 +663,68 @@ describe('aiVault.listSessions handler + shared cache', () => {
expect(options?.wslHomeDirs).toEqual([])
})
it('constructs ordinary ownership independently of Codex homes or search enablement and forwards RPC intent', async () => {
const capture = vi.fn(() => ({
repos: [{ id: 'source', path: '/tmp', displayName: 'source', badgeColor: '', addedAt: 1 }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
settings: { workspaceDir: '', nestWorkspaces: false }
}))
const orcadStore = {
getRepos: () => capture().repos,
getProjects: () => [],
getProjectHostSetups: () => [],
getAllWorktreeMeta: () => ({}),
getSettings: () => capture().settings
}
const clientHomes = { listSessionHomes: () => ['/client'] }
const deps = orcadAiVaultSessionSources(orcadStore, clientHomes, null)
expect(deps.getAdditionalAiVaultCodexHomePaths?.()).toEqual(['/client'])
expect(
orcadAiVaultSessionSources(orcadStore, clientHomes, {
listCodexSessionHomes: () => ['/managed']
}).getAdditionalAiVaultCodexHomePaths?.()
).toEqual(['/client', '/managed'])
new OrcaRuntimeService(null, undefined, {
getProjectOwnershipCatalog: deps.getProjectOwnershipCatalog
})
scanAiVaultSessionsInService.mockResolvedValue({
sessions: [makeSession()],
issues: [],
scannedAt: SCANNED_AT
})
const dispatcher = makeDispatcher()
expect(
await dispatcher.dispatch(
makeRequest('aiVault.listSessions', {
historyOwnership: {},
limit: 2,
executionHostId: 'runtime:addressed',
afterFingerprint: null
})
)
).toMatchObject({
ok: true,
result: {
sessions: [
{
projectOwnerKey: 'repo:source',
executionHostId: 'runtime:addressed',
cwd: '/tmp',
filePath: '/tmp/t.jsonl'
}
],
fingerprint: expect.stringMatching(/^[0-9a-f]{64}$/)
}
})
expect(capture).toHaveBeenCalledTimes(2)
expect(
await dispatcher.dispatch(makeRequest('aiVault.listSessions', { limit: 2 }))
).toMatchObject({ ok: true, result: { sessions: [makeSession()] } })
expect(capture).toHaveBeenCalledTimes(2)
expect(scanAiVaultSessionsInService).toHaveBeenCalledTimes(1)
})
it('forwards codex-home through the real OrcaRuntimeService construction path', async () => {
// 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,6 +106,7 @@ export const AI_VAULT_METHODS = [
limit: params.unlimited ? undefined : params.limit,
unlimited: params.unlimited,
force: params.force,
historyOwnership: params.historyOwnership,
scopePaths: params.scopePaths,
includeAntigravityIdeSessions: params.includeAntigravityIdeSessions
},
@@ -1,4 +1,14 @@
import { mkdtempSync } from 'node:fs'
import {
openSessionSearchHarness,
addSyntheticSession
} from '../ai-vault-search/session-search-engine-test-fixture'
import { createSessionSearchService } from '../ai-vault-search/session-search-service'
import { setSessionSearchService } from '../ai-vault-search/session-search-service-registry'
import {
installSessionSearchScopeCatalogSource,
resetSessionSearchScopeCatalogForTests
} from '../ai-vault-search/session-search-scope-catalog'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import nacl from 'tweetnacl'
@@ -50,7 +60,7 @@ function keyedServer() {
const reply: unknown = JSON.parse(replies.at(-1)!)
return reply
}
return { server, registry, keyed, legacy, send }
return { server, registry, keyed, legacy, send, userDataPath }
}
describe('requests on a key-authenticated socket', () => {
@@ -135,3 +145,46 @@ describe('pairing.registerDeviceKey over the dispatcher', () => {
}
)
})
it('carries canonical intent through the actual key-authenticated dispatch into native results', async () => {
const { send, userDataPath, server } = keyedServer()
const index = await openSessionSearchHarness('sta10541-key-native')
try {
addSyntheticSession(index.db, { id: 1, cwd: '/repo/app' })
addSyntheticSession(index.db, { id: 2, cwd: '/other' })
setSessionSearchService(
createSessionSearchService({
engine: index.engine,
indexer: {
status: () => {
throw new Error('not called')
},
reconcile: async () => undefined
}
})
)
installSessionSearchScopeCatalogSource(() => ({
repos: [{ id: 'app', path: '/repo/app', displayName: 'app', badgeColor: '', addedAt: 1 }],
projects: [],
projectHostSetups: [],
worktreeMeta: {},
settings: { workspaceDir: '', nestWorkspaces: false }
}))
expect(
await send({
id: 'key-search',
method: 'aiVault.searchSessions',
params: {
query: 'needle',
within: { kind: 'project', projectKey: 'repo:app', canonicalProjectKey: 'repo:app' }
}
})
).toMatchObject({ ok: true, result: { kind: 'results', hits: [{ sessionId: '1' }] } })
} finally {
setSessionSearchService(null)
resetSessionSearchScopeCatalogForTests()
await index.close()
await server.stop()
rmSync(userDataPath, { recursive: true, force: true })
}
})
+15 -1
View File
@@ -1,5 +1,11 @@
import { existsSync } from 'node:fs'
import type { backup, BackupOptions, DatabaseSync, SQLInputValue } from 'node:sqlite'
import type {
backup,
BackupOptions,
DatabaseSync,
FunctionOptions,
SQLInputValue
} from 'node:sqlite'
import { hasNodeSqliteReaderApi } from './node-sqlite-reader-api'
import { NodeSqliteStatement } from './node-sqlite-statement'
import type { SqliteStatement } from './sqlite-statement'
@@ -91,6 +97,14 @@ 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) {
@@ -113,7 +113,9 @@ 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,
// Provide managed Codex homes here for desktop and headless session discovery; core IPC registration is window-only.
// Why: source codex-home here (runs in window AND serve) so aiVault.listSessions includes managed-Codex sessions; registerCoreHandlers is window-only.
getProjectOwnershipCatalog: () =>
sessionSearchScopeCatalogFromStore(store, LOCAL_EXECUTION_HOST_ID),
getAdditionalAiVaultCodexHomePaths: () =>
state.codexRuntimeHome?.getHostCodexHomePathsForSessionDiscovery() ?? [],
prepareAiVaultSessionResume: codexAiVaultSessionResumePreparation(() => ({
+2 -1
View File
@@ -33,7 +33,8 @@ export type AiVaultApi = {
/** Omitted host means this host; `all` is merged by this desktop across every enumerated host. */
searchSessions: (
request: AiVaultSearchRequest,
executionHostScope?: ExecutionHostScope
executionHostScope?: ExecutionHostScope,
isActive?: () => boolean
) => Promise<AiVaultSearchResponse>
/** Status describes one index, so it never accepts the `all` scope. */
searchStatus: (executionHostScope?: ExecutionHostId) => Promise<AiVaultSearchStatus>
+5 -2
View File
@@ -34,8 +34,11 @@ function searchClient(
}
export const aiVaultApi = {
searchSessions: (request: AiVaultSearchRequest, executionHostScope?: ExecutionHostScope) =>
searchClient(executionHostScope).searchSessions(request),
searchSessions: (
request: AiVaultSearchRequest,
executionHostScope?: ExecutionHostScope,
isActive?: () => boolean
) => searchClient(executionHostScope).searchSessions(request, isActive),
searchStatus: (executionHostScope?: ExecutionHostId) =>
searchClient(executionHostScope).searchStatus(),
setSearchEnabled: (
@@ -59,8 +59,8 @@ export function useRuntimeGraphSync(): void {
// Publish project-catalog changes to paired phones.
if (
state.repos !== previousState.repos ||
state.projects !== previousState.projects ||
state.projectHostSetups !== previousState.projectHostSetups ||
state.endpointProjects !== previousState.endpointProjects ||
state.endpointProjectHostSetups !== previousState.endpointProjectHostSetups ||
state.projectOrderBy !== previousState.projectOrderBy
) {
scheduleRuntimeGraphSync()
@@ -16,7 +16,8 @@ vi.mock('@/runtime/runtime-file-import-client', () => ({
vi.mock('@/store', () => ({
useAppStore: Object.assign(
(selector: (state: unknown) => unknown) => selector({ settings: {}, projects: [], repos: [] }),
(selector: (state: unknown) => unknown) =>
selector({ settings: {}, endpointProjects: [], endpointProjectHostSetups: [], repos: [] }),
{
getState: () => ({
sshConnectionStates: connections,
@@ -50,9 +50,9 @@ beforeEach(() => {
],
activeRepoId: 'local-project',
activeModal: 'new-workspace-composer',
projects: [],
endpointProjects: [],
projectGroups: [],
projectHostSetups: [],
endpointProjectHostSetups: [],
newWorkspaceDraft: null,
worktreesByRepo: {},
sparsePresetsByRepo: {}
@@ -30,7 +30,8 @@ vi.mock('@/store', () => ({
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: []
}),
{ getState: () => ({}) }
@@ -25,7 +25,8 @@ vi.mock('@/store', () => ({
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: []
}),
{ getState: () => ({}) }
@@ -17,7 +17,8 @@ vi.mock('@/store', () => ({
activeModal: 'new-workspace-composer',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: [],
worktreesByRepo: {}
}),
@@ -32,7 +32,8 @@ vi.mock('@/store', () => ({
activeModal: 'none',
settings: { defaultTuiAgent: null, disabledTuiAgents: [] },
updateSettings: vi.fn(),
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: []
}),
{
@@ -1,3 +1,4 @@
import { selectCanonicalProjects } from '@/store/project-host-setup-selector'
import React from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
@@ -96,7 +97,7 @@ export default function NewWorkspaceComposerCard(
(state) => state.settings?.disabledTuiAgents ?? DEFAULT_DISABLED_TUI_AGENTS
)
const updateSettings = useAppStore((state) => state.updateSettings)
const projects = useAppStore((state) => state.projects)
const projects = useAppStore((state) => selectCanonicalProjects(state))
const repos = useAppStore((state) => state.repos)
const nameInputFocusFrameRef = React.useRef<number | null>(null)
const branchNameInputId = React.useId()
@@ -24,7 +24,6 @@ import {
import { listedRow } from './automations-page-listed-items'
import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures'
import type { Repo } from '../../../../shared/repo-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
installAutomationsPageHarness()
@@ -53,7 +52,7 @@ function addSshHost(): void {
/** A project checked out on that SSH host rather than locally. */
function addSshProject(): void {
const repo = {
const repo: Repo = {
id: SSH_REPO_ID,
displayName: 'orca',
path: '/repos/orca',
@@ -61,12 +60,24 @@ function addSshProject(): void {
addedAt: 1,
worktreeBaseRef: 'main',
connectionId: SSH_TARGET_ID
} as Repo
}
mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
mocks.repoMap.set(SSH_REPO_ID, repo)
// The host needs a ready setup or save refuses the project before choosing a destination.
mocks.state.projectHostSetups = [
...(mocks.state.projectHostSetups as ProjectHostSetup[]),
// 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,
{
id: 'setup-ssh',
projectId: 'project-ssh',
@@ -84,7 +95,7 @@ function addSshProject(): void {
/** A runtime project with the same host-local id as the desktop project. */
function addCollidingRuntimeProject(): void {
const repo = {
const repo: Repo = {
id: REPO_ID,
displayName: 'orca on m4-air',
path: '/repos/orca',
@@ -92,10 +103,21 @@ function addCollidingRuntimeProject(): void {
addedAt: 1,
worktreeBaseRef: 'main',
executionHostId: `runtime:${RUNTIME_ID}`
} as Repo
}
mocks.state.repos = [...(mocks.state.repos as Repo[]), repo]
mocks.state.projectHostSetups = [
...(mocks.state.projectHostSetups as ProjectHostSetup[]),
mocks.state.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,
{
id: 'setup-runtime-collision',
projectId: 'project-runtime-collision',
@@ -1,5 +1,6 @@
import { getRepoExecutionHostId, LOCAL_EXECUTION_HOST_ID } from '../../../../shared/execution-host'
import { getProjectIdentityKey } from '../../../../shared/project-host-setup-projection'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import { getRepoHostIdentity } from '../../../../shared/repo-host-identity'
import type { Repo } from '../../../../shared/repo-types'
export type AutomationProjectGroup = {
@@ -12,9 +13,15 @@ export function getAutomationProjectGroups(
repos: readonly Repo[],
selectedRepoId: string
): AutomationProjectGroup[] {
const projectIds = new Map(
projectHostSetupProjectionFromRepos(repos).setups.map((setup, index) => [
getRepoHostIdentity(repos[index]),
setup.projectId
])
)
const groupsByProject = new Map<string, AutomationProjectGroup>()
for (const repo of repos) {
const projectKey = getProjectIdentityKey(repo)
const projectKey = projectIds.get(getRepoHostIdentity(repo))!
const current = groupsByProject.get(projectKey)
if (!current) {
groupsByProject.set(projectKey, { projectKey, repo, sources: [repo] })
@@ -6,7 +6,7 @@ import type {
AutomationRunUsage,
ExternalAutomationManager
} from '../../../../shared/automations-types'
import type { ProjectHostSetup } from '../../../../shared/project-types'
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import type { Worktree } from '../../../../shared/worktree/types'
import type {
@@ -219,7 +219,10 @@ export function makeWorktree(overrides: Partial<Worktree> = {}): Worktree {
}
export type AutomationsPageStoreFixtures = {
state: Record<string, unknown>
state: Record<string, unknown> & {
endpointProjects: Project[]
endpointProjectHostSetups: ProjectHostSetup[]
}
repoMap: Map<string, Repo>
worktreeMap: Map<string, Worktree>
}
@@ -232,7 +235,17 @@ export function makeStoreState(): AutomationsPageStoreFixtures {
return {
state: {
repos: [repo],
projectHostSetups: [makeProjectHostSetup()],
endpointProjects: [
{
id: 'project-1',
displayName: repo.displayName,
badgeColor: repo.badgeColor,
sourceRepoIds: [repo.id],
createdAt: 1,
updatedAt: 1
}
],
endpointProjectHostSetups: [makeProjectHostSetup()],
worktreesByRepo: { [REPO_ID]: [worktree] },
unifiedTabsByWorktree: {},
terminalLayoutsByTabId: {},
@@ -19,7 +19,7 @@ export function selfScopedList(automations: Automation[]): Record<string, unknow
}
type RuntimeFixtureMocks = {
state: Record<string, unknown>
state: Record<string, unknown> & { endpointProjectHostSetups: ProjectHostSetup[] }
repoMap: Map<string, unknown>
worktreeMap: Map<string, unknown>
}
@@ -55,10 +55,10 @@ export function addRuntimeProject(mocks: RuntimeFixtureMocks, runtimeId: string)
updatedAt: 1
}
const repos = mocks.state.repos as Repo[]
const setups = mocks.state.projectHostSetups as ProjectHostSetup[]
const setups = mocks.state.endpointProjectHostSetups
const worktreesByRepo = mocks.state.worktreesByRepo as Record<string, Worktree[]>
mocks.state.repos = [...repos, repo]
mocks.state.projectHostSetups = [...setups, setup]
mocks.state.endpointProjectHostSetups = [...setups, setup]
mocks.state.worktreesByRepo = { ...worktreesByRepo, [RUNTIME_REPO_ID]: [worktree] }
mocks.repoMap.set(RUNTIME_REPO_ID, repo)
mocks.worktreeMap.set(RUNTIME_WORKSPACE_ID, worktree)
@@ -1,3 +1,4 @@
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'
@@ -89,7 +90,10 @@ export type DeleteDialogProps = {
}
type AutomationsPageMocks = {
state: Record<string, unknown>
state: Record<string, unknown> & {
endpointProjects: Project[]
endpointProjectHostSetups: ProjectHostSetup[]
}
repoMap: Map<string, unknown>
worktreeMap: Map<string, unknown>
listPanel: ListPanelProps | null
@@ -107,7 +111,7 @@ type AutomationsPageMocks = {
// Mock factories only close over this exported binding; vi.hoisted bindings cannot be exported.
export const mocks: AutomationsPageMocks = {
state: {},
state: { endpointProjects: [], endpointProjectHostSetups: [] },
repoMap: new Map<string, unknown>(),
worktreeMap: new Map<string, unknown>(),
listPanel: null,
@@ -1,3 +1,4 @@
import { selectCanonicalProjectHostSetups } from '@/store/project-host-setup-selector'
import { useCallback } from 'react'
import { filterEnabledTuiAgents, isTuiAgentEnabled } from '../../../../shared/tui-agent-selection'
import type { Repo } from '../../../../shared/repo-types'
@@ -18,7 +19,7 @@ const AGENTS = getAgentCatalog().map((agent) => agent.id)
/** Store-backed values shared by the page's state and action hooks. */
export function useAutomationsPageStoreState() {
const repos = useAppStore((s) => s.repos)
const projectHostSetups = useAppStore((s) => s.projectHostSetups)
const projectHostSetups = useAppStore((s) => selectCanonicalProjectHostSetups(s))
const worktreesByRepo = useAppStore((s) => selectWorktreesByRepo(s))
const folderWorkspaces = useAppStore((s) => selectFolderWorkspaces(s))
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
@@ -516,25 +516,6 @@ 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,7 +95,8 @@ function makeSnapshotWatchState(): DashboardSnapshotWatchState {
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
activeRepoId: null,
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
removedRuntimeEnvironmentIds: new Set()
}
}
@@ -286,6 +287,8 @@ describe('useDashboardPopoutBridge', () => {
// A folder workspace is not a git worktree; its host resolves through these.
{ folderWorkspaces: [] },
{ projectGroups: [] },
{ endpointProjects: [] },
{ endpointProjectHostSetups: [] },
{ restoredRuntimeHostIdByWorkspaceSessionKey: {} },
{ runtimeEnvironments: [] },
{ runtimeEnvironmentCatalogHydrated: true },
@@ -7,7 +7,7 @@ import {
} from '@/runtime/structured-chat-name-observation'
import { subscribeChatProgramFacts } from '@/lib/host-chat-program'
import {
type AgentLaunchRouteView,
type AgentLaunchRouteWatchState,
agentLaunchRouteInputsChanged,
selectAgentLaunchRouteView
} from '@/lib/agent-launch-route-input'
@@ -37,7 +37,7 @@ export function repoIconsUnchanged(
type DashboardSnapshotWatchState = DashboardSnapshotState &
Pick<AppState, 'agentStatusEpoch'> &
AgentLaunchRouteView
AgentLaunchRouteWatchState
export function dashboardSnapshotInputsChanged(
state: DashboardSnapshotWatchState,
@@ -226,7 +226,11 @@ it('publishes real chat options to main and popout, retaining card candidates an
expect(view.result.current.launchableAgentsByWorktreeId?.['wt-2']).toEqual(['grok'])
const lastPublished = () => publishSnapshot.mock.calls.at(-1)?.[0].launchableAgentsByWorktreeId
await vi.waitFor(() => expect(lastPublished()?.['wt-1']).toEqual(['claude', 'grok']))
for (const route of [{ projects: [] }, { activeWorkspaceExecutionHostId: 'local' as const }]) {
for (const route of [
{ endpointProjects: [] },
{ endpointProjectHostSetups: [] },
{ activeWorkspaceExecutionHostId: 'local' as const }
]) {
const [live, published] = [view.result.current, publishSnapshot.mock.calls.length]
act(() => useAppStore.setState(route))
expect(view.result.current).not.toBe(live)
@@ -121,6 +121,9 @@ describe('MarkdownPreview http link routing (Cmd vs Cmd+Shift click)', () => {
}
// openHttpLink reads the store through this injected accessor, not @/store.
registerHttpLinkStoreAccessor(() => ({
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
settings: { openLinksInApp: true, activeRuntimeEnvironmentId: null },
setActiveWorktree: setActiveWorktreeMock,
createBrowserTab: createBrowserTabMock
@@ -54,6 +54,9 @@ beforeEach(() => {
}
})
registerHttpLinkStoreAccessor(() => ({
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
settings: routing.settings,
setActiveWorktree: vi.fn(),
createBrowserTab
@@ -50,7 +50,7 @@ beforeEach(() => {
activeRepoId: null,
activeWorktreeId: null,
activeWorkspaceExecutionHostId: null,
projects: [],
endpointProjects: [],
repos: [],
worktreesByRepo: {},
folderWorkspaces: [],
@@ -8,6 +8,8 @@ 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
@@ -90,6 +92,8 @@ function installStore(preflightStatus: PreflightStatus): void {
activeRepoId: null,
activeWorktreeId: null,
worktreesByRepo: {},
endpointProjects: [],
endpointProjectHostSetups: [],
repos: [],
settings,
preflightStatus,
@@ -1,3 +1,7 @@
import {
getProjectHostSetupProjectionFromState,
type ProjectCatalogReadState
} from '@/store/project-host-setup-selector'
import type { AppState } from '../../store/types'
import type { SkillDiscoveryTarget } from '../../../../shared/skills'
import { parseExecutionHostId } from '../../../../shared/execution-host'
@@ -25,7 +29,6 @@ export type NativeChatSkillStateInputs = Pick<
| 'floatingWorkspacePath'
| 'folderWorkspaces'
| 'projectGroups'
| 'projects'
| 'repos'
| 'restoredRuntimeHostIdByWorkspaceSessionKey'
| 'settings'
@@ -33,7 +36,8 @@ export type NativeChatSkillStateInputs = Pick<
| 'tabsByWorktree'
| 'unifiedTabsByWorktree'
| 'worktreesByRepo'
>
> &
ProjectCatalogReadState
type NativeChatSkillTab = { id: string; startupCwd?: string }
@@ -59,7 +63,7 @@ export function selectNativeChatSkillStateInputs(state: AppState): NativeChatSki
floatingWorkspacePath: state.floatingWorkspacePath,
folderWorkspaces: selectFolderWorkspaces(state),
projectGroups: state.projectGroups,
projects: state.projects,
projectHostSetupProjection: getProjectHostSetupProjectionFromState(state),
repos: state.repos,
restoredRuntimeHostIdByWorkspaceSessionKey: state.restoredRuntimeHostIdByWorkspaceSessionKey,
settings: state.settings,
@@ -112,7 +112,7 @@ function corpus(scale: number, dense = false) {
worktreesByRepo: {},
detectedWorktreesByRepo: {},
projectGroups: [],
projects: [],
endpointProjects: [],
runtimeEnvironments: [],
runtimeEnvironmentCatalogHydrated: true,
removedRuntimeEnvironmentIds: new Set(),
@@ -35,7 +35,8 @@ function stateForHost(hostId: string) {
activeWorktreeId: 'worktree-1',
folderWorkspaces: [],
projectGroups: [],
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: [
{
id: 'repo-1',
@@ -213,7 +213,8 @@ describe('floating workspace skill discovery', () => {
floatingWorkspacePath: '/home/me/scratch',
folderWorkspaces: [],
projectGroups: [],
projects: [],
endpointProjects: [],
endpointProjectHostSetups: [],
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 { ProjectHostSetup } from '../../../../shared/project-types'
import type { Project, ProjectHostSetup } from '../../../../shared/project-types'
import type { Repo } from '../../../../shared/repo-types'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import {
@@ -17,10 +17,12 @@ import { SetProjectLocationDialog } from './SetProjectLocationDialog'
const storeMock = vi.hoisted(() => {
const repos: Repo[] = []
const projectHostSetups: ProjectHostSetup[] = []
const endpointProjects: Project[] = []
const endpointProjectHostSetups: ProjectHostSetup[] = []
return {
repos,
projectHostSetups,
endpointProjects,
endpointProjectHostSetups,
setupProjectExistingFolder: vi.fn(),
setupProjectClone: vi.fn()
}
@@ -125,12 +127,14 @@ function createInput(): Parameters<typeof useTargetChangeActions>[0] {
beforeEach(() => {
storeMock.repos = [localRepo]
storeMock.projectHostSetups = [...localProjection.setups]
storeMock.endpointProjects = [...localProjection.projects]
storeMock.endpointProjectHostSetups = [...localProjection.setups]
storeMock.setupProjectExistingFolder.mockReset()
storeMock.setupProjectClone.mockReset()
const publishRuntimeSetup = async () => {
storeMock.repos = [localRepo, runtimeRepo]
storeMock.projectHostSetups = [...localProjection.setups, ...runtimeProjection.setups]
storeMock.endpointProjects = [...localProjection.projects, ...runtimeProjection.projects]
storeMock.endpointProjectHostSetups = [...localProjection.setups, ...runtimeProjection.setups]
return {
project: runtimeProjection.projects[0],
repo: runtimeRepo,
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import type { Worktree } from '../../../../shared/worktree/types'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import { orderProjectIdsByRecency } from './use-recent-project-ids'
function worktree(partial: Partial<Worktree>): Worktree {
@@ -58,3 +59,35 @@ describe('orderProjectIdsByRecency', () => {
expect(ids[0]).toBe('beta')
})
})
it('indexes current workspace ownership linearly at N and 10N and reuses an unchanged catalog', () => {
const counts = [10, 100].map((count) => {
let reads = 0
const repos = Array.from({ length: count }, (_, i) => ({
id: `checkout-${i}`,
path: `/checkout/${i}`,
displayName: `${i}`,
badgeColor: '',
addedAt: i
}))
const setups = projectHostSetupProjectionFromRepos(repos).setups.map((setup) => ({
...setup,
get repoId() {
reads++
return setup.repoId
}
}))
const workspaces = repos.map((repo, i) =>
worktree({ repoId: repo.id, projectId: 'old-shared-project', createdAt: i })
)
const ids = orderProjectIdsByRecency(workspaces, setups)
expect(ids).toHaveLength(count * 2)
expect(ids[0]).toBe(`repo:checkout-${count - 1}`)
const firstReads = reads
expect(orderProjectIdsByRecency(workspaces, setups)).toEqual(ids)
expect(reads).toBe(firstReads)
expect(firstReads).toBeLessThanOrEqual(count * 8)
return firstReads
})
expect(counts[1]).toBeLessThanOrEqual(counts[0] * 10)
})
@@ -1,13 +1,25 @@
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'
/** 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[] {
/**
* 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)
const newestByProject = new Map<string, number>()
for (const worktree of worktrees) {
const projectId = worktree.projectId
const projectId = owners.projectId(worktree)
// Legacy repo-only workspaces have no projectId and can't be attributed.
if (projectId === undefined || projectId === '') {
continue
}
@@ -28,5 +40,6 @@ export function orderProjectIdsByRecency(worktrees: readonly Worktree[]): string
export function useRecentProjectIds(): string[] {
const worktrees = useAllWorktrees()
return useMemo(() => orderProjectIdsByRecency(worktrees), [worktrees])
const { setups } = useProjectHostSetupProjection()
return useMemo(() => orderProjectIdsByRecency(worktrees, setups), [worktrees, setups])
}
@@ -1,3 +1,4 @@
import { selectCanonicalProjects } from '@/store/project-host-setup-selector'
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { CircleDashed } from 'lucide-react'
import { useAppStore } from '@/store'
@@ -68,7 +69,7 @@ export function ChecksTab({
targetRepoId ? (s.repos.find((candidate) => candidate.id === targetRepoId) ?? null) : null
)
const repos = useAppStore((s) => s.repos)
const projects = useAppStore((s) => s.projects)
const projects = useAppStore((s) => selectCanonicalProjects(s))
const [fixingChecks, setFixingChecks] = useState(false)
const [fixChecksComposerPrompt, setFixChecksComposerPrompt] = useState<string | null>(null)
const mountedRef = useMountedRef()
@@ -136,7 +137,7 @@ export function ChecksTab({
{
activeRepoId: null,
activeWorktreeId: null,
projects,
projectHostSetupProjection: { projects, setups: [] },
repos,
settings,
worktreesByRepo: {}
@@ -11,6 +11,8 @@ const mockState: {
folderWorkspaces: never[]
projectGroups: never[]
repos: never[]
endpointProjects: never[]
endpointProjectHostSetups: never[]
worktreesByRepo: Record<string, never>
} = {
settings: {},
@@ -18,6 +20,8 @@ const mockState: {
folderWorkspaces: [],
projectGroups: [],
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
worktreesByRepo: {}
}
const updateSettingsOrThrow = vi.fn(async (next: { aiVaultSearch: { enabled: boolean } }) => {
@@ -156,7 +160,11 @@ it('switches to index search with the same query once indexing is enabled', asyn
aiVaultSearch: { enabled: true, historyDays: null }
})
await waitFor(() =>
expect(searchSessions).toHaveBeenCalledWith(expect.objectContaining({ query: 'foo' }), 'local')
expect(searchSessions).toHaveBeenCalledWith(
expect.objectContaining({ query: 'foo' }),
'local',
expect.any(Function)
)
)
expect(screen.queryByRole('button', { name: 'Enable' })).toBeNull()
await waitFor(() => expect(screen.queryByText('Fix the foo pipeline')).toBeNull())
@@ -0,0 +1,180 @@
// @vitest-environment happy-dom
import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'
import { afterEach, expect, it, vi } from 'vitest'
import { useAppStore } from '@/store'
import type * as NewWorkspace from '@/lib/new-workspace'
import { getDefaultSettings } from '../../../../shared/constants'
import { projectHostSetupProjectionFromRepos } from '../../../../shared/project-host-setup-projection'
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
import type { Repo } from '../../../../shared/repo-types'
import { makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import { launchAiVaultSessionAndReveal } from '@/lib/launch-ai-vault-session-and-reveal'
import { resetAiVaultForcedRescanThrottleForTest } from './ai-vault-session-refresh'
import AiVaultPanel from './AiVaultPanel'
import { TooltipProvider } from '@/components/ui/tooltip'
import { ConfirmationDialogContext } from '@/components/confirmation-dialog-context'
vi.mock('@/lib/new-workspace', async (importOriginal) => ({
...(await importOriginal<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,3 +1,4 @@
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'
@@ -63,14 +64,15 @@ export default function AiVaultPanel({
const {
workspaceId: activeWorktreeId,
worktree: activeWorktree,
repo: activeRepo
repo: activeRepo,
legacyProjectKey
} = useAiVaultPanelWorkspace(workspace)
const repos = useRepos()
const allWorktrees = useAllWorktrees()
const projectHostSetupProjection = useProjectHostSetupProjection()
const resumeTargetState = useAppStore(
useShallow((state) => ({
projects: state.projects,
projectHostSetupProjection: getProjectHostSetupProjectionFromState(state),
settings: state.settings,
folderWorkspaces: selectFolderWorkspaces(state),
projectGroups: state.projectGroups,
@@ -154,11 +156,18 @@ export default function AiVaultPanel({
aiVaultSearchScopeIdentity({
scope,
activeWorktreeId: activeWorktree?.id,
activeProjectKey
activeProjectKey: legacyProjectKey
}),
[activeProjectKey, activeWorktree?.id, scope]
[legacyProjectKey, activeWorktree?.id, scope]
)
const search = useAiVaultPanelSearch(
query,
agents,
searchWithin,
executionHostScope,
searchSort,
activeProjectKey ?? undefined
)
const search = useAiVaultPanelSearch(query, agents, searchWithin, executionHostScope, searchSort)
const { searching, searchHits } = search
const searchSessions = useMemo(
() => withAiVaultHistoryPreviews(search.sessions, history),
@@ -180,9 +189,10 @@ export default function AiVaultPanel({
repos,
worktrees: allWorktrees,
projectHostSetupProjection,
settings: settings ?? undefined,
sessions
}),
[allWorktrees, projectHostSetupProjection, repos, sessions]
[allWorktrees, projectHostSetupProjection, repos, sessions, settings]
)
const effectiveActiveWorktreeId = activeWorktreeId ?? activeWorktree?.id ?? null
const {
@@ -3,14 +3,14 @@
import { act, createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it } from 'vitest'
import type { AppState } from '@/store/types'
import { TEST_REPO, makeWorktree } from '@/store/slices/worktrees-slice-test-fixtures'
import type { AiVaultSessionResumeTargetState } from './ai-vault-session-resume'
import {
buildAiVaultHostScopeOptions,
buildRuntimeAiVaultHostScopeOptions,
useAiVaultExecutionHostScope
} from './ai-vault-host-scope'
import type { ExecutionHostScope } from '../../../../shared/execution-host'
import type { ExecutionHostId, ExecutionHostScope } from '../../../../shared/execution-host'
type HostScopeResult = ReturnType<typeof useAiVaultExecutionHostScope>
@@ -44,14 +44,17 @@ async function renderHook(props: {
function stateForWorktree(args: {
worktreeId: string
repoId: string
executionHostId?: string | null
hostId?: string | null
executionHostId?: ExecutionHostId | null
hostId?: ExecutionHostId | null
}): AiVaultSessionResumeTargetState {
return {
folderWorkspaces: [],
projectGroups: [],
endpointProjects: [],
endpointProjectHostSetups: [],
repos: [
{
...TEST_REPO,
id: args.repoId,
connectionId: null,
executionHostId: args.executionHostId ?? 'local'
@@ -59,14 +62,14 @@ function stateForWorktree(args: {
],
worktreesByRepo: {
[args.repoId]: [
{
makeWorktree({
id: args.worktreeId,
repoId: args.repoId,
hostId: args.hostId ?? null
}
hostId: args.hostId ?? undefined
})
]
}
} as unknown as Pick<AppState, 'folderWorkspaces' | 'projectGroups' | 'repos' | 'worktreesByRepo'>
}
}
afterEach(() => {
@@ -113,8 +116,10 @@ describe('useAiVaultExecutionHostScope', () => {
folderWorkspaces: [],
projectGroups: [],
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
worktreesByRepo: {}
} as unknown as AiVaultSessionResumeTargetState
}
})
expect(latest?.executionHostScope).not.toBe('local')
@@ -128,8 +133,10 @@ describe('useAiVaultExecutionHostScope', () => {
folderWorkspaces: [],
projectGroups: [],
repos: [],
endpointProjects: [],
endpointProjectHostSetups: [],
worktreesByRepo: {}
} as unknown as AiVaultSessionResumeTargetState
}
})
expect(latest?.executionHostScope).toBe('local')
@@ -1,9 +1,36 @@
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>
@@ -21,3 +48,15 @@ export function resolveActiveProjectKey(
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
}
@@ -0,0 +1,172 @@
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,7 +20,11 @@ const AGENTS = ['codex' as const]
const WORKSPACE = { kind: 'workspace', worktreeId: 'repo-1::/work/app' } as const
const searchSessions =
vi.fn<
(request: AiVaultSearchRequest, scope?: ExecutionHostScope) => Promise<AiVaultSearchResponse>
(
request: AiVaultSearchRequest,
scope?: ExecutionHostScope,
isCurrent?: () => boolean
) => Promise<AiVaultSearchResponse>
>()
beforeEach(() => {
@@ -90,9 +94,13 @@ describe('the panel hook under a scope identity', () => {
await debounce()
expect(searchSessions).toHaveBeenCalledExactlyOnceWith(
{ query: 'needle', filters: { agents: ['codex'] }, within, cursor: undefined },
'ssh:build-box'
'ssh:build-box',
expect.any(Function)
)
const isCurrent = searchSessions.mock.calls[0][2]
expect(isCurrent?.()).toBe(true)
unmount()
expect(isCurrent?.()).toBe(false)
})
it('does not restart the search while the identity holds', async () => {
@@ -337,6 +337,20 @@ describe('conditional panel publication', () => {
structuredSession: { sessionId: 'owner' }
})
expect(latest?.sessions[0]?.previewMessages[0]?.text).toBe('New prompt')
list.mockResolvedValue({
...first,
fingerprint: 'c'.repeat(64),
sessions: [{ ...session, projectOwnerKey: 'repo:current' }]
})
await refresh()
expect(latest?.sessions[0]?.projectOwnerKey).toBe('repo:current')
list.mockResolvedValue({
...first,
fingerprint: 'd'.repeat(64),
sessions: [{ ...session, projectOwnerKey: null }]
})
await refresh()
expect(latest?.sessions[0]?.projectOwnerKey).toBeNull()
})
it('does not reuse proofs across host, depth or project switches', async () => {
@@ -22,6 +22,7 @@ export function aiVaultSessionListArgs(
options: { force?: boolean; requestToken: string }
): AiVaultListArgs {
return {
historyOwnership: {},
includeAntigravityIdeSessions: true,
limit: request.sessionLimit === 'unlimited' ? undefined : request.sessionLimit,
unlimited: request.sessionLimit === 'unlimited',
@@ -1,4 +1,17 @@
import { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match'
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 type { AiVaultSession } from '../../../../shared/ai-vault-types'
import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path'
@@ -51,3 +64,49 @@ 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