Harden project host compatibility projection

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-12 22:50:16 -07:00
co-authored by Orca
parent 0e3b7789b2
commit ed6cf82183
3 changed files with 155 additions and 1 deletions
@@ -22,7 +22,7 @@ The target model is:
- [x] Add a shared run-target type or selector for RunHostContext / WorkspaceRunContext so workspace creation, agent launch, automations, and remote execution use the same vocabulary.
- [~] Audit user-facing copy so the product says **Host** for Local Mac, SSH hosts, remote servers, and future cloud VMs, while preserving technical labels only where useful.
- [x] Document the invariant that task source context and run host are separate choices.
- [~] Add compatibility adapters for existing repo-id/path-based callers so old data can be read while new code moves to explicit contexts.
- [x] Add compatibility adapters for existing repo-id/path-based callers so old data can be read while new code moves to explicit contexts.
- [x] Add schema migration notes for old automations, cached task selections, pending worktree requests, and any persisted task drawer state.
Done means: a new agent can read the shared types and understand exactly where work item data comes from versus where execution happens.
@@ -256,3 +256,4 @@ Done means: reviewers and future agents can understand the vision, implementatio
- [x] 2026-06-12: Marked task drawer source-context persistence complete based on UI slice coverage that records GitHub, GitLab, Linear, and Jira task-detail history entries with their exact `TaskSourceContext`, plus direct-open prefetch coverage for source-scoped GitHub and Linear reads.
- [x] 2026-06-12: Marked PR/MR base resolution complete based on `useComposerState-host-context-boundaries.test.ts`, which asserts GitHub PR and GitLab MR base lookup uses the selected run repo instead of the provider item's source repo while preserving source item metadata separately.
- [x] 2026-06-12: Extracted and tested external automation source availability copy so local missing tooling, disconnected SSH sources, connecting SSH sources, and connected SSH hosts with missing remote tooling get distinct states; verified with focused automation availability tests, targeted oxlint, and full `pnpm run typecheck`.
- [x] 2026-06-12: Marked repo-id/path compatibility adapters complete after hardening the renderer project-host selector to merge repo-derived compatibility rows when hydrated project/setup arrays are empty or partial, alongside existing shared projection, persistence backfill/sync, automation context migration, and pending worktree context coverage. Verified with selector/projection tests, persistence tests, targeted oxlint, full `pnpm run typecheck`, and `git diff --check`.
+83
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it } from 'vitest'
import type { Repo, Worktree } from '../../../shared/types'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
import { toRuntimeExecutionHostId } from '../../../shared/execution-host'
import type { AppState } from './types'
import {
getAllWorktreesFromState,
@@ -227,4 +228,86 @@ describe('store selectors', () => {
setups: projectHostSetups
})
})
it('falls back to repo compatibility projection when hydrated setup state is empty', () => {
const repos = [
makeRepo({
id: 'repo-1',
path: '/Users/alice/orca',
displayName: 'orca',
upstream: { owner: 'stablyai', repo: 'orca' }
})
]
const projection = getProjectHostSetupProjectionFromState({
repos,
projects: [],
projectHostSetups: []
})
expect(projection.projects).toEqual([
expect.objectContaining({
id: 'github:stablyai/orca',
sourceRepoIds: ['repo-1']
})
])
expect(projection.setups).toEqual([
expect.objectContaining({
id: 'repo-1',
projectId: 'github:stablyai/orca',
repoId: 'repo-1',
hostId: 'local',
path: '/Users/alice/orca'
})
])
})
it('merges missing repo compatibility rows with independent hydrated setups', () => {
const repos = [
makeRepo({
id: 'repo-1',
path: '/Users/alice/orca',
displayName: 'orca'
})
]
const projects = [
{
id: 'cloud-project',
displayName: 'Cloud Project',
badgeColor: '#737373',
sourceRepoIds: [],
createdAt: 1,
updatedAt: 1
}
]
const projectHostSetups = [
{
id: 'cloud-project::gpu-vm',
projectId: 'cloud-project',
hostId: toRuntimeExecutionHostId('gpu-vm'),
repoId: '',
path: '/srv/cloud-project',
displayName: 'GPU VM',
setupState: 'ready' as const,
setupMethod: 'provisioned' as const,
createdAt: 1,
updatedAt: 1
}
]
const projection = getProjectHostSetupProjectionFromState({
repos,
projects,
projectHostSetups
})
expect(projection.projects.map((project) => project.id)).toEqual([
'repo:repo-1',
'cloud-project'
])
expect(projection.setups.map((setup) => setup.id)).toEqual(['repo-1', 'cloud-project::gpu-vm'])
expect(getProjectHostSetupProjectionFromState({ repos, projects, projectHostSetups })).toBe(
projection
)
})
})
+70
View File
@@ -40,6 +40,10 @@ const providedProjectHostSetupProjectionCache = new WeakMap<
Project[],
WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>
>()
const mergedProjectHostSetupProjectionCache = new WeakMap<
AppState['repos'],
WeakMap<Project[], WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>>
>()
let floatingVisibleTabCountCache: FloatingVisibleTabCountCache | null = null
function getWorktreeSnapshot(worktreesByRepo: AppState['worktreesByRepo']): WorktreeSnapshot {
@@ -134,6 +138,54 @@ function getCachedProvidedProjectHostSetupProjection(
return projection
}
function mergeById<T extends { id: string }>(base: readonly T[], overlay: readonly T[]): T[] {
const merged = [...base]
const indexById = new Map(merged.map((entry, index) => [entry.id, index]))
for (const entry of overlay) {
const index = indexById.get(entry.id)
if (index === undefined) {
indexById.set(entry.id, merged.length)
merged.push(entry)
} else {
merged[index] = entry
}
}
return merged
}
function mergeProjectHostSetupProjection(
repos: AppState['repos'],
projects: Project[],
setups: ProjectHostSetup[]
): ProjectHostSetupProjection {
const cachedByProjects = mergedProjectHostSetupProjectionCache.get(repos)
const cachedBySetups = cachedByProjects?.get(projects)
const cachedProjection = cachedBySetups?.get(setups)
if (cachedProjection) {
return cachedProjection
}
const derived = getCachedProjectHostSetupProjection(repos)
// Why: older runtimes/profiles may hydrate empty or partial project/setup arrays
// beside legacy repos. Keep repo-backed compatibility rows visible in that case.
const projection = {
projects: mergeById(derived.projects, projects),
setups: mergeById(derived.setups, setups)
}
const nextCachedByProjects =
cachedByProjects ??
new WeakMap<Project[], WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>>()
const nextCachedBySetups =
cachedBySetups ?? new WeakMap<ProjectHostSetup[], ProjectHostSetupProjection>()
nextCachedBySetups.set(setups, projection)
if (!cachedBySetups) {
nextCachedByProjects.set(projects, nextCachedBySetups)
}
if (!cachedByProjects) {
mergedProjectHostSetupProjectionCache.set(repos, nextCachedByProjects)
}
return projection
}
export function selectFloatingVisibleTabCount(state: FloatingVisibleTabCountState): number {
const terminalTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TABS
const browserTabs =
@@ -213,6 +265,24 @@ export function getProjectHostSetupProjectionFromState(
state: Pick<AppState, 'repos'> & Partial<Pick<AppState, 'projects' | 'projectHostSetups'>>
): ProjectHostSetupProjection {
if (state.projects && state.projectHostSetups) {
const repoIds = new Set(state.repos.map((repo) => repo.id))
const coveredRepoIds = new Set<string>()
for (const setup of state.projectHostSetups) {
const repoId = typeof setup.repoId === 'string' ? setup.repoId : ''
if (repoIds.has(repoId)) {
coveredRepoIds.add(repoId)
}
if (repoIds.has(setup.id)) {
coveredRepoIds.add(setup.id)
}
}
if (state.repos.length > 0 && coveredRepoIds.size < repoIds.size) {
return mergeProjectHostSetupProjection(
state.repos,
state.projects as Project[],
state.projectHostSetups as ProjectHostSetup[]
)
}
return getCachedProvidedProjectHostSetupProjection(
state.projects as Project[],
state.projectHostSetups as ProjectHostSetup[]