mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
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:
@@ -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": [
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
+32
-10
@@ -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
|
||||
|
||||
+1
-1
@@ -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,
|
||||
|
||||
+1
-1
@@ -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.
|
||||
|
||||
+9
-5
@@ -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 () => {
|
||||
|
||||
+14
@@ -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
Reference in New Issue
Block a user