fix(folders): folder workspaces can answer "paired server" (#26958)

* fix(folders): folder workspaces can answer "paired server"

* fix(folders): name a server folder's nested target only when its id is unambiguous

* fix(folders): read the folder group only when the answer needs it

---------

Co-authored-by: m4air <m4air@Mac.localdomain>
Co-authored-by: m4air <m4air@m4airs-MacBook-Air.local>
This commit is contained in:
OrcaWin
2026-10-09 13:50:36 -07:00
committed by GitHub
co-authored by m4air m4air
parent 4a964633f4
commit 62878c3b0c
7 changed files with 187 additions and 32 deletions
@@ -0,0 +1,80 @@
import { describe, expect, it, vi } from 'vitest'
import type { ExecutionHostId } from '../../../../shared/execution-host'
import type { FolderWorkspace } from '../../../../shared/folder-workspace-types'
import type { ProjectGroup } from '../../../../shared/project-group-types'
import { getFileExplorerOperationOwnerFromState } from './file-explorer-operation-owner'
vi.mock('@/store', () => ({ useAppStore: { getState: vi.fn() } }))
function folder(
projectGroupId: string,
executionHostId: ExecutionHostId,
connectionId: string | null
): FolderWorkspace {
return {
id: 'same-folder',
projectGroupId,
name: 'Notes',
folderPath: '/notes',
executionHostId,
connectionId,
linkedTask: null,
comment: '',
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 0,
createdAt: 0,
updatedAt: 0
}
}
function group(
id: string,
executionHostId: ExecutionHostId,
connectionId: string | null
): ProjectGroup {
return {
id,
name: id,
parentPath: '/',
parentGroupId: null,
createdFrom: 'manual',
tabOrder: 0,
isCollapsed: false,
color: null,
connectionId,
executionHostId,
createdAt: 0,
updatedAt: 0
}
}
describe('getFileExplorerOperationOwnerFromState', () => {
it("never hands a selected local folder the same-id server folder's nested SSH target", () => {
const owner = getFileExplorerOperationOwnerFromState(
{
settings: null,
repos: [],
worktreesByRepo: {},
detectedWorktreesByRepo: {},
restoredRuntimeHostIdByWorkspaceSessionKey: {},
activeWorktreeId: 'folder:same-folder',
activeWorkspaceExecutionHostId: 'local',
// The server's copy comes first, as a catalog merge can order it.
folderWorkspaces: [
folder('remote-group', 'runtime:env-a', 'nested-box'),
folder('local-group', 'local', null)
],
projectGroups: [
group('remote-group', 'runtime:env-a', 'nested-box'),
group('local-group', 'local', null)
]
},
'folder:same-folder'
)
expect(owner).toEqual({ kind: 'local' })
})
})
@@ -80,7 +80,7 @@ export async function prepareAutomationDispatchWorkspace(args: {
folderWorkspaceHost && automationWorktree
? folderWorkspaceConnectionId
? toSshExecutionHostId(folderWorkspaceConnectionId)
: folderWorkspaceHost.kind === 'local'
: folderWorkspaceHost.kind === 'local' || folderWorkspaceHost.kind === 'runtime'
? getResolvedExecutionHostIdForWorktree(state, automationWorktree.id)
: null
: null
@@ -20,8 +20,17 @@ export function getFolderWorkspaceConnectionId(
folderWorkspaceId: string
): string | null | undefined {
const host = resolveFolderWorkspaceHost(state, folderWorkspaceId)
if (host.kind === 'ssh') {
return host.targetId
switch (host.kind) {
case 'ssh':
return host.targetId
// Why the server's own target: like a project row there (`getRepoSshConnectionId`), so its
// SSH state is read inside that server rather than reading as local.
case 'runtime':
return host.sshTargetId
case 'local':
return null
case 'missing':
case 'ambiguous':
return undefined
}
return host.kind === 'local' ? null : undefined
}
@@ -197,6 +197,38 @@ describe('selectWorktreeHostConnectionPhase', () => {
})
})
it("reads a paired server's folder workspace on its nested target like a project there", () => {
const state = makeState({
// As fetched from the server: its own SSH target, stamped with the server's host.
folderWorkspaces: [
{
id: 'fw-runtime',
projectGroupId: 'group-runtime',
folderPath: '/srv/notes',
connectionId: 'ssh-nested',
executionHostId: 'runtime:env-a'
}
],
projectGroups: [
{
id: 'group-runtime',
kind: 'folder',
connectionId: 'ssh-nested',
executionHostId: 'runtime:env-a'
}
],
runtimeStatusByEnvironmentId: new Map([
['env-a', { status: { runtimeId: 'runtime-a' }, checkedAt: 1 }]
])
})
expect(selectWorktreeHostConnectionPhase(state, 'folder:fw-runtime')).toMatchObject({
phase: 'unverifiable',
targetId: 'ssh-nested',
environmentId: 'env-a'
})
})
it('names a new connection on every reconnect', () => {
const connected = makeSshState('connected')
const reconnected = makeSshState('connected', {
+5 -3
View File
@@ -14,6 +14,7 @@
import type { Automation } from './automations-types'
import type { AutomationUsageSummary } from './automation-usage-summary'
import type { FolderWorkspaceHost } from './folder-workspace-execution-host'
import { getAutomationRunRepoId } from './automation-run-identity'
import { parseExecutionHostId } from './execution-host'
import { sanitizeSshTargetGeneration } from './ssh-target-generation'
@@ -67,9 +68,7 @@ export const AUTOMATION_ORPHAN_ISSUES = {
/** Where a record's workspace executes; `unpinned` leaves the repo to decide. */
export type AutomationWorkspaceHost =
| { kind: 'unpinned' }
| { kind: 'local' }
| { kind: 'ssh'; targetId: string }
| { kind: 'ambiguous' }
| Exclude<FolderWorkspaceHost, { kind: 'missing' }>
export type AutomationProjectionContext = {
/** The process that owns this store; runtime scheduling markers are local only on a runtime. */
@@ -164,6 +163,9 @@ export function projectAutomationSelector(
if (workspaceHost.kind === 'ambiguous') {
return orphan(AUTOMATION_ORPHAN_ISSUES.workspaceHostAmbiguous)
}
if (workspaceHost.kind === 'runtime') {
return orphan(AUTOMATION_ORPHAN_ISSUES.scheduledElsewhere)
}
// A pinned workspace is where the run actually goes, so the row belongs to that host, not Self.
if (workspaceHost.kind === 'ssh') {
return workspacePinSelector(automation, workspaceHost.targetId, context)
@@ -273,15 +273,35 @@ describe('folder workspace execution host', () => {
expect(resolved).toEqual({ kind: 'ssh', targetId: 'nested-box' })
})
it('still answers local for a runtime pin, which the type cannot express otherwise', () => {
const resolved = resolveFolderWorkspaceHost(
state({
folderWorkspaces: [workspace({ executionHostId: 'runtime:env-1' })]
}),
'fw-1'
)
expect(resolved).toEqual({ kind: 'local' })
it("answers a runtime pin with its server, paired with that server's own SSH target", () => {
expect(
resolveFolderWorkspaceHost(
state({ folderWorkspaces: [workspace({ executionHostId: 'runtime:env-1' })] }),
'fw-1'
)
).toEqual({ kind: 'runtime', environmentId: 'env-1', sshTargetId: null })
expect(
resolveFolderWorkspaceHost(
state({
folderWorkspaces: [
workspace({ executionHostId: 'runtime:env-1', connectionId: 'server-box' })
]
}),
'fw-1'
)
).toEqual({ kind: 'runtime', environmentId: 'env-1', sshTargetId: 'server-box' })
// A same-id copy on another host leaves the selected one unknown here, so no target is named.
expect(
resolveFolderWorkspaceHost(
state({
folderWorkspaces: [
workspace({ executionHostId: 'runtime:env-1', connectionId: 'server-box' }),
workspace({ executionHostId: 'local' })
]
}),
'fw-1'
)
).toEqual({ kind: 'runtime', environmentId: 'env-1', sshTargetId: null })
})
// The candidate FILTER decides which rows reach the resolver, and it read `repo.connectionId` raw
+28 -16
View File
@@ -29,6 +29,8 @@ export type FolderWorkspaceHost =
| { kind: 'missing' }
| { kind: 'local' }
| { kind: 'ssh'; targetId: string }
// A paired server's folder; `sshTargetId` is that server's own target, addressable only with it.
| { kind: 'runtime'; environmentId: string; sshTargetId: string | null }
| { kind: 'ambiguous' }
/** Reads a stored connection id: blank is local, since no write path normalizes it. */
@@ -46,8 +48,7 @@ export function normalizeConnectionId(value: string | null | undefined): string
* reading raw in either place drops the row before the resolver can classify it.
*
* A non-SSH host falls back to the raw field so a `runtime:` row keeps contributing its nested
* target exactly as it does today. That target is not this client's to dial, but changing it is a
* separate defect with its own reasoning — see the note in `resolveFolderWorkspaceHost`.
* target, which is not this client's to dial.
*/
function getRepoScopeConnectionId(repo: Repo): string | null {
const host = parseExecutionHostId(getRepoExecutionHostId(repo))
@@ -118,22 +119,33 @@ export function resolveFolderWorkspaceHost(
if (!workspace) {
return { kind: 'missing' }
}
const readScopeConnectionId = (): string | null =>
normalizeConnectionId(
workspace.connectionId ??
state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId
)
const explicitHost = parseExecutionHostId(workspace.executionHostId)
if (explicitHost) {
// A `runtime:` workspace deliberately answers `local`, and `FolderWorkspaceHost` has no runtime
// variant to answer with instead. That omission is known: a runtime environment's own server
// normalizes its work to `local`, and the nested SSH target on such a row is addressable only as
// the pair (environmentId, targetId) — handing it to this client's SSH table would dial a
// same-named box in the wrong namespace. Widening the type is its own change, not an oversight
// here.
return explicitHost.kind === 'ssh'
? { kind: 'ssh', targetId: explicitHost.targetId }
: { kind: 'local' }
switch (explicitHost?.kind) {
case 'ssh':
return { kind: 'ssh', targetId: explicitHost.targetId }
case 'runtime': {
// Why: another host may hold a folder with this id, and this lookup cannot tell which one is
// selected, so the server's target is named only when the id is unambiguous.
const isOnlyOwner = state.folderWorkspaces.every(
(entry) => entry.id !== folderWorkspaceId || entry === workspace
)
return {
kind: 'runtime',
environmentId: explicitHost.environmentId,
sshTargetId: isOnlyOwner ? readScopeConnectionId() : null
}
}
case 'local':
return { kind: 'local' }
case undefined:
break
}
const scopeConnectionId = normalizeConnectionId(
workspace.connectionId ??
state.projectGroups.find((entry) => entry.id === workspace.projectGroupId)?.connectionId
)
const scopeConnectionId = readScopeConnectionId()
const candidateRepos = findFolderWorkspaceCandidateRepos(state, folderWorkspaceId)
let hasLocalRepo = false
const connectionIds = new Set<string>()