mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
fix: resolve terminal authority from workspace owner evidence (#29565)
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
This commit is contained in:
@@ -225,7 +225,6 @@
|
||||
1 src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx
|
||||
1 src/renderer/src/components/terminal-pane/pending-split-close-test-fixture.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/agent-idle-working-handlers.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/cold-restore-resume-startup.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/interrupt-input-intent.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/pane-agent-identity.ts
|
||||
1 src/renderer/src/components/terminal-pane/pty-connection/pane-host-capability-flags.ts
|
||||
@@ -264,9 +263,7 @@
|
||||
2 src/renderer/src/components/use-task-page-store-bindings.ts
|
||||
2 src/renderer/src/components/use-terminal-activation-actions.ts
|
||||
2 src/renderer/src/components/use-terminal-bulk-close-actions.ts
|
||||
1 src/renderer/src/components/use-terminal-controller.ts
|
||||
8 src/renderer/src/components/use-terminal-create-actions.ts
|
||||
1 src/renderer/src/components/use-terminal-watcher-effects.ts
|
||||
2 src/renderer/src/components/use-worktree-jump-palette-controller.ts
|
||||
1 src/renderer/src/components/use-worktree-jump-palette-create-action.ts
|
||||
2 src/renderer/src/components/use-worktree-jump-palette-filter.ts
|
||||
@@ -339,13 +336,11 @@
|
||||
2 src/renderer/src/lib/remap-open-editor-tabs-for-path-change.ts
|
||||
1 src/renderer/src/lib/remote-workspace-drop-test-fixtures.ts
|
||||
2 src/renderer/src/lib/repo-runtime-owner.ts
|
||||
2 src/renderer/src/lib/resume-sleeping-agent-session.ts
|
||||
1 src/renderer/src/lib/runtime-session-mirror-owners.ts
|
||||
1 src/renderer/src/lib/runtime-session-mirror-targets.ts
|
||||
1 src/renderer/src/lib/runtime-workspace-file-route.ts
|
||||
1 src/renderer/src/lib/single-runtime-legacy-owner.ts
|
||||
1 src/renderer/src/lib/sleeping-agent-session-launch.ts
|
||||
1 src/renderer/src/lib/sleeping-record-execution-host-scope.ts
|
||||
1 src/renderer/src/lib/structured-agent-session-launch.ts
|
||||
1 src/renderer/src/lib/structured-agent-session-provisional-tab.ts
|
||||
4 src/renderer/src/lib/structured-agent-session-tab-activation.ts
|
||||
@@ -360,11 +355,9 @@
|
||||
1 src/renderer/src/lib/workspace-file-drag-source.ts
|
||||
1 src/renderer/src/lib/workspace-tab-commands.ts
|
||||
1 src/renderer/src/lib/workspace-tab-palette-activation.ts
|
||||
3 src/renderer/src/lib/workspace-terminal-host-authority.ts
|
||||
1 src/renderer/src/lib/worktree-activation-pty-inventory.ts
|
||||
3 src/renderer/src/lib/worktree-activation.ts
|
||||
1 src/renderer/src/lib/worktree-creation-agent-seeds.ts
|
||||
1 src/renderer/src/lib/worktree-initial-terminal-seeding.ts
|
||||
1 src/renderer/src/lib/worktree-jump-navigation.ts
|
||||
2 src/renderer/src/lib/worktree-operation-route.ts
|
||||
1 src/renderer/src/lib/worktree-row-open-in-owner.ts
|
||||
|
||||
@@ -119,7 +119,12 @@ describe('sleepingRecordNamesAnotherExecutionHost', () => {
|
||||
// yours is the inverse of the defect and worse.
|
||||
const record = makeRecord({ connectionId })
|
||||
expect(
|
||||
sleepingRecordNamesAnotherExecutionHost(record, { repos: [], worktreesByRepo: {} })
|
||||
sleepingRecordNamesAnotherExecutionHost(record, {
|
||||
repos: [],
|
||||
worktreesByRepo: {},
|
||||
settings: { activeRuntimeEnvironmentId: 'unrelated' },
|
||||
runtimeEnvironments: [{ id: 'unrelated' }]
|
||||
})
|
||||
).toBe(false)
|
||||
}
|
||||
)
|
||||
@@ -129,7 +134,9 @@ describe('sleepingRecordNamesAnotherExecutionHost', () => {
|
||||
expect(
|
||||
sleepingRecordNamesAnotherExecutionHost(record, {
|
||||
repos: [{ id: 'repo-1' }],
|
||||
worktreesByRepo: {}
|
||||
worktreesByRepo: {},
|
||||
settings: { activeRuntimeEnvironmentId: 'unrelated' },
|
||||
runtimeEnvironments: [{ id: 'unrelated' }]
|
||||
})
|
||||
).toBe(true)
|
||||
})
|
||||
@@ -242,3 +249,131 @@ describe('the resume sweep under execution-host scope', () => {
|
||||
expect(state.sleepingAgentSessionsByPaneKey[native.paneKey]).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const ownerEvidenceCases: {
|
||||
name: string
|
||||
state: WorktreeRuntimeOwnerState
|
||||
connectionId: string | null
|
||||
worktreeId?: string
|
||||
foreign: boolean
|
||||
}[] = [
|
||||
{
|
||||
name: 'the active local host over an SSH catalog',
|
||||
state: {
|
||||
...catalogOwnedBy('ssh:openclaw'),
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'local'
|
||||
},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: true
|
||||
},
|
||||
{
|
||||
name: 'the matching active SSH host over a local catalog',
|
||||
state: {
|
||||
...catalogOwnedBy('local'),
|
||||
activeWorktreeId: WORKTREE_ID,
|
||||
activeWorkspaceExecutionHostId: 'ssh:openclaw'
|
||||
},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: false
|
||||
},
|
||||
{
|
||||
name: 'a foreign detected-only SSH owner',
|
||||
state: {
|
||||
detectedWorktreesByRepo: {
|
||||
remote: { worktrees: [{ id: WORKTREE_ID, repoId: 'remote', hostId: 'ssh:other' }] }
|
||||
}
|
||||
},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: true
|
||||
},
|
||||
{
|
||||
name: 'conflicting detected owners',
|
||||
state: {
|
||||
detectedWorktreesByRepo: {
|
||||
a: { worktrees: [{ id: WORKTREE_ID, repoId: 'a', hostId: 'ssh:other' }] },
|
||||
b: { worktrees: [{ id: WORKTREE_ID, repoId: 'b', hostId: 'local' }] }
|
||||
}
|
||||
},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: false
|
||||
},
|
||||
...[TARGET_ID, 'other'].map((connectionId) => ({
|
||||
name: `physical SSH ownership through a runtime for ${connectionId}`,
|
||||
state: {
|
||||
repos: [{ id: 'repo-1', connectionId: TARGET_ID, executionHostId: 'runtime:hub' as const }],
|
||||
worktreesByRepo: {
|
||||
'repo-1': [{ id: WORKTREE_ID, repoId: 'repo-1', hostId: 'ssh:openclaw' as const }]
|
||||
}
|
||||
},
|
||||
connectionId,
|
||||
foreign: connectionId !== TARGET_ID
|
||||
})),
|
||||
{
|
||||
name: 'a direct SSH folder for a locally captured session',
|
||||
worktreeId: 'folder:f',
|
||||
state: { folderWorkspaces: [{ id: 'f', projectGroupId: 'g', connectionId: TARGET_ID }] },
|
||||
connectionId: null,
|
||||
foreign: true
|
||||
},
|
||||
{
|
||||
name: 'a known unstamped local folder',
|
||||
worktreeId: 'folder:f',
|
||||
state: { folderWorkspaces: [{ id: 'f', projectGroupId: 'g' }] },
|
||||
connectionId: TARGET_ID,
|
||||
foreign: true
|
||||
},
|
||||
{
|
||||
name: 'a restored runtime folder before its catalog',
|
||||
worktreeId: 'folder:f',
|
||||
state: { restoredRuntimeHostIdByWorkspaceSessionKey: { 'folder:f': 'runtime:restored' } },
|
||||
connectionId: TARGET_ID,
|
||||
foreign: false
|
||||
},
|
||||
{
|
||||
name: 'a missing folder',
|
||||
worktreeId: 'folder:f',
|
||||
state: {},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: false
|
||||
},
|
||||
{
|
||||
name: 'conflicting folder owners',
|
||||
worktreeId: 'folder:f',
|
||||
state: {
|
||||
folderWorkspaces: [
|
||||
{ id: 'f', projectGroupId: 'g', executionHostId: 'local' },
|
||||
{ id: 'f', projectGroupId: 'g', executionHostId: 'ssh:other' }
|
||||
]
|
||||
},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: false
|
||||
},
|
||||
{
|
||||
name: 'conflicting inherited folder group owners',
|
||||
worktreeId: 'folder:f',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'f', projectGroupId: 'g' }],
|
||||
projectGroups: [
|
||||
{ id: 'g', executionHostId: 'local' },
|
||||
{ id: 'g', executionHostId: 'ssh:other' }
|
||||
]
|
||||
},
|
||||
connectionId: TARGET_ID,
|
||||
foreign: false
|
||||
}
|
||||
]
|
||||
|
||||
it.each(ownerEvidenceCases)(
|
||||
'compares capture against $name',
|
||||
({ state, connectionId, worktreeId, foreign }) => {
|
||||
const record = makeRecord({ connectionId, ...(worktreeId ? { worktreeId } : {}) })
|
||||
expect(
|
||||
sleepingRecordNamesAnotherExecutionHost(record, {
|
||||
settings: { activeRuntimeEnvironmentId: 'unrelated' },
|
||||
runtimeEnvironments: [{ id: 'unrelated' }],
|
||||
...state
|
||||
})
|
||||
).toBe(foreign)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -3,16 +3,24 @@ import {
|
||||
getRepoExecutionHostId,
|
||||
parseExecutionHostId,
|
||||
toSshExecutionHostId,
|
||||
toRuntimeExecutionHostId,
|
||||
type ExecutionHostId
|
||||
} from '../../../shared/execution-host'
|
||||
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants'
|
||||
import { getRepoIdFromWorktreeId } from '@/store/slices/worktree-helpers'
|
||||
import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import {
|
||||
findFolderWorkspaceOwner,
|
||||
getExplicitRuntimeEnvironmentIdForFolderWorkspace,
|
||||
type FolderWorkspaceRuntimeOwnerState
|
||||
} from './folder-workspace-runtime-owner'
|
||||
import { findIndexedRepoOwner, findIndexedWorktreeOwner } from './worktree-runtime-owner-index'
|
||||
import { getKnownExecutionHostIdForWorktree } from './worktree-runtime-owner'
|
||||
import {
|
||||
findIndexedRepoOwner,
|
||||
findIndexedWorktreeOwner,
|
||||
findIndexedProjectGroupOwner,
|
||||
hasIndexedDetectedWorktree
|
||||
} from './worktree-runtime-owner-index'
|
||||
import { resolveExplicitWorktreeOperationRouteResult } from './worktree-operation-catalog-route'
|
||||
import type { WorktreeRuntimeOwnerState } from './worktree-runtime-owner-state'
|
||||
|
||||
/**
|
||||
@@ -79,12 +87,74 @@ export function sleepingRecordNamesAnotherExecutionHost(
|
||||
): boolean {
|
||||
return agentResumeOriginNamesAnotherExecutionHost(
|
||||
record.connectionId,
|
||||
getKnownExecutionHostIdForWorktree(state, record.worktreeId)
|
||||
currentExecutionHostIdForRecord(record, state)
|
||||
)
|
||||
}
|
||||
|
||||
type SleepingRecordOwnerState = WorktreeRuntimeOwnerState & FolderWorkspaceRuntimeOwnerState
|
||||
|
||||
// Current routing selection is identity evidence; a focused runtime is not.
|
||||
function currentExecutionHostIdForRecord(
|
||||
record: SleepingAgentSessionRecord,
|
||||
state: SleepingRecordOwnerState
|
||||
): ExecutionHostId | null {
|
||||
const worktreeId = record.worktreeId
|
||||
if (worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return 'local'
|
||||
}
|
||||
const activeHost =
|
||||
state.activeWorktreeId === worktreeId
|
||||
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
|
||||
: null
|
||||
if (activeHost) {
|
||||
return activeHost.id
|
||||
}
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (scope?.type === 'folder') {
|
||||
const environmentId = getExplicitRuntimeEnvironmentIdForFolderWorkspace(
|
||||
state,
|
||||
scope.folderWorkspaceId
|
||||
)
|
||||
if (environmentId) {
|
||||
return toRuntimeExecutionHostId(environmentId)
|
||||
}
|
||||
const folder = findFolderWorkspaceOwner(state, scope.folderWorkspaceId)
|
||||
if (!folder) {
|
||||
return null
|
||||
}
|
||||
if (
|
||||
!folder.executionHostId &&
|
||||
!folder.connectionId &&
|
||||
state.projectGroups?.some((group) => group.id === folder.projectGroupId) &&
|
||||
!findIndexedProjectGroupOwner(state.projectGroups, folder.projectGroupId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return ownerExecutionHostIdForRecord(record, state) ?? 'local'
|
||||
}
|
||||
const resolution = resolveExplicitWorktreeOperationRouteResult(state, worktreeId)
|
||||
if (resolution.kind === 'resolved') {
|
||||
return (
|
||||
resolution.route.executionHostId ??
|
||||
(resolution.route.runtimeEnvironmentId
|
||||
? toRuntimeExecutionHostId(resolution.route.runtimeEnvironmentId)
|
||||
: null)
|
||||
)
|
||||
}
|
||||
if (
|
||||
resolution.kind === 'ambiguous' ||
|
||||
hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
const worktree = findIndexedWorktreeOwner(state.worktreesByRepo, worktreeId)
|
||||
const repo = findIndexedRepoOwner(
|
||||
state.repos,
|
||||
worktree?.repoId ?? getRepoIdFromWorktreeId(worktreeId)
|
||||
)
|
||||
return repo ? getRepoExecutionHostId(repo) : null
|
||||
}
|
||||
|
||||
// Why owner rows only: two records' machines are a property of their own workspaces, never of the
|
||||
// focused server, so this reads no routing default and an unknown owner stays unknown.
|
||||
function ownerExecutionHostIdForRecord(
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
* The seeding predicate answers one question for both remote flavors: does some other party own
|
||||
* terminal creation for this workspace right now? Three answers, and only `none` seeds.
|
||||
*/
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type * as AgentStatusModule from '@/lib/agent-status'
|
||||
import { createTestStore, makeWorktree } from '@/store/slices/store-test-helpers'
|
||||
import { ensureWorktreeHasInitialTerminal } from '@/lib/worktree-initial-terminal-seeding'
|
||||
import { resolveWorkspaceTerminalHostAuthority } from '@/lib/workspace-terminal-host-authority'
|
||||
import {
|
||||
resolveWorkspaceTerminalHostAuthority,
|
||||
type WorkspaceTerminalHostAuthorityState
|
||||
} from '@/lib/workspace-terminal-host-authority'
|
||||
import { makeFolderWorkspace } from '@/store/slices/worktrees-slice-test-fixtures'
|
||||
|
||||
vi.mock('sonner', () => ({ toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() } }))
|
||||
vi.mock('@/lib/agent-status', async (importOriginal) => {
|
||||
@@ -95,6 +99,38 @@ describe('workspace terminal seeding authority', () => {
|
||||
expect(terminalTabCount(store, LOCAL_WORKTREE_ID)).toBe(1)
|
||||
})
|
||||
|
||||
it.each(['local', 'unstamped', 'missing', 'folder'] as const)(
|
||||
'seeds a %s workspace while an unrelated runtime is selected',
|
||||
(ownership) => {
|
||||
const store = createTestStore()
|
||||
seedLocal(store)
|
||||
if (ownership === 'unstamped') {
|
||||
store.setState({
|
||||
worktreesByRepo: {
|
||||
repoLocal: [makeWorktree({ id: LOCAL_WORKTREE_ID, repoId: 'repoLocal' })]
|
||||
}
|
||||
})
|
||||
} else if (ownership === 'missing') {
|
||||
store.setState({ repos: [], worktreesByRepo: {} })
|
||||
}
|
||||
if (ownership === 'folder') {
|
||||
store.setState({ folderWorkspaces: [makeFolderWorkspace()] })
|
||||
}
|
||||
const worktreeId = ownership === 'folder' ? 'folder:folder-1' : LOCAL_WORKTREE_ID
|
||||
const state = {
|
||||
...store.getState(),
|
||||
settings: {
|
||||
...store.getState().settings,
|
||||
activeRuntimeEnvironmentId: ENVIRONMENT_ID
|
||||
},
|
||||
runtimeEnvironments: [{ id: ENVIRONMENT_ID }]
|
||||
}
|
||||
|
||||
expect(ensureWorktreeHasInitialTerminal(state, worktreeId)).toBeTruthy()
|
||||
expect(terminalTabCount(store, worktreeId)).toBe(1)
|
||||
}
|
||||
)
|
||||
|
||||
it('seeds once the SSH target connects, with no workspace snapshot to wait for', () => {
|
||||
// The relay that served host workspace snapshots is gone (#27214), so nothing will ever answer
|
||||
// one; waiting for it left a reconnected target unable to seed or resume for good.
|
||||
@@ -220,3 +256,139 @@ describe('workspace terminal seeding authority', () => {
|
||||
expect(terminalTabCount(store, PAIRED_WORKTREE_ID)).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
const WT = 'repo::/workspace'
|
||||
const FOLDER = 'folder:folder-1'
|
||||
const TARGET = 'ssh:target' as const
|
||||
const base = {
|
||||
settings: { activeRuntimeEnvironmentId: 'unrelated' },
|
||||
runtimeEnvironments: [{ id: 'unrelated' }],
|
||||
runtimeEnvironmentCatalogHydrated: false
|
||||
} satisfies WorkspaceTerminalHostAuthorityState
|
||||
const sshRow = { id: WT, repoId: 'repo', hostId: TARGET }
|
||||
const hubA = { id: 'repo', connectionId: 'target', executionHostId: 'runtime:hub-a' as const }
|
||||
const hubB = { id: 'repo', connectionId: 'target', executionHostId: 'runtime:hub-b' as const }
|
||||
|
||||
const cases: {
|
||||
name: string
|
||||
id: string
|
||||
state: WorkspaceTerminalHostAuthorityState
|
||||
expected: 'live' | 'unverifiable' | 'none'
|
||||
}[] = [
|
||||
{
|
||||
name: 'repo-owned runtime before worktree hydration',
|
||||
id: WT,
|
||||
expected: 'live',
|
||||
state: { repos: [{ id: 'repo', executionHostId: 'runtime:owner' }] }
|
||||
},
|
||||
{
|
||||
name: 'runtime HUB behind an SSH worktree',
|
||||
id: WT,
|
||||
expected: 'live',
|
||||
state: { repos: [hubA], worktreesByRepo: { repo: [sshRow] } }
|
||||
},
|
||||
{
|
||||
name: 'runtime project group owns a folder',
|
||||
id: FOLDER,
|
||||
expected: 'live',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group' }],
|
||||
projectGroups: [{ id: 'group', executionHostId: 'runtime:owner' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'restored runtime folder before catalog hydration',
|
||||
id: FOLDER,
|
||||
expected: 'live',
|
||||
state: {
|
||||
folderWorkspaces: [],
|
||||
projectGroups: [],
|
||||
restoredRuntimeHostIdByWorkspaceSessionKey: { [FOLDER]: 'runtime:restored' }
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'disconnected direct SSH folder is outside replacement scope',
|
||||
id: FOLDER,
|
||||
expected: 'none',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group', connectionId: 'target' }]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'conflicting folder owners stay unverifiable',
|
||||
id: FOLDER,
|
||||
expected: 'unverifiable',
|
||||
state: {
|
||||
folderWorkspaces: [
|
||||
{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'local' },
|
||||
{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'runtime:owner' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'conflicting folder project-group owners stay unverifiable',
|
||||
id: FOLDER,
|
||||
expected: 'unverifiable',
|
||||
state: {
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group' }],
|
||||
projectGroups: [
|
||||
{ id: 'group', executionHostId: 'local' },
|
||||
{ id: 'group', executionHostId: 'runtime:owner' }
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'unroutable detected publication stays unverifiable',
|
||||
id: WT,
|
||||
expected: 'unverifiable',
|
||||
state: { detectedWorktreesByRepo: { repo: { worktrees: [{ id: WT, repoId: 'repo' }] } } }
|
||||
}
|
||||
]
|
||||
it.each(cases)('$name', ({ id, state, expected }) => {
|
||||
expect(resolveWorkspaceTerminalHostAuthority({ ...base, ...state }, id)).toBe(expected)
|
||||
})
|
||||
|
||||
it('lets the explicit same-workspace host disambiguate rival catalog owners', () => {
|
||||
const state: WorkspaceTerminalHostAuthorityState = {
|
||||
...base,
|
||||
activeWorktreeId: WT,
|
||||
repos: [
|
||||
{ id: 'repo', executionHostId: 'local' },
|
||||
{ id: 'repo', executionHostId: 'runtime:owner' }
|
||||
]
|
||||
}
|
||||
expect(
|
||||
resolveWorkspaceTerminalHostAuthority({ ...state, activeWorkspaceExecutionHostId: 'local' }, WT)
|
||||
).toBe('none')
|
||||
expect(
|
||||
resolveWorkspaceTerminalHostAuthority(
|
||||
{ ...state, activeWorkspaceExecutionHostId: 'runtime:owner' },
|
||||
WT
|
||||
)
|
||||
).toBe('live')
|
||||
})
|
||||
|
||||
it('waits for rival SSH HUB owners, then accepts the surviving runtime owner', () => {
|
||||
const state: WorkspaceTerminalHostAuthorityState = {
|
||||
...base,
|
||||
activeWorktreeId: WT,
|
||||
activeWorkspaceExecutionHostId: TARGET,
|
||||
worktreesByRepo: { repo: [sshRow] }
|
||||
}
|
||||
expect(resolveWorkspaceTerminalHostAuthority({ ...state, repos: [hubA, hubB] }, WT)).toBe(
|
||||
'unverifiable'
|
||||
)
|
||||
expect(resolveWorkspaceTerminalHostAuthority({ ...state, repos: [hubA] }, WT)).toBe('live')
|
||||
})
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals())
|
||||
it.each([WT, FOLDER])('leaves local terminal ownership with the real paired web host: %s', (id) => {
|
||||
vi.stubGlobal('__ORCA_WEB_CLIENT__', true)
|
||||
const state: WorkspaceTerminalHostAuthorityState = {
|
||||
settings: { activeRuntimeEnvironmentId: null },
|
||||
runtimeEnvironments: [{ id: 'paired-host' }],
|
||||
worktreesByRepo: { repo: [{ id: WT, repoId: 'repo', hostId: 'local' }] },
|
||||
folderWorkspaces: [{ id: 'folder-1', projectGroupId: 'group', executionHostId: 'local' }]
|
||||
}
|
||||
expect(resolveWorkspaceTerminalHostAuthority(state, id)).toBe('live')
|
||||
})
|
||||
|
||||
@@ -4,10 +4,17 @@ import { parseWorkspaceKey } from '../../../shared/workspace-scope'
|
||||
import type { HostLiveTerminalProbeVerdict } from '@/runtime/host-live-terminal-probe'
|
||||
import type { SshConnectionState } from '../../../shared/ssh-types'
|
||||
import { isWebRuntimeSessionActive } from '@/runtime/web-runtime-session-environment'
|
||||
import { resolveExplicitWorktreeOperationRouteResult } from '@/lib/worktree-operation-catalog-route'
|
||||
import { resolveStrictWorktreeOperationRouteResultForHost } from '@/lib/worktree-operation-route'
|
||||
import { getPairedWebClientEnvironmentId } from '@/lib/paired-web-client-host'
|
||||
import {
|
||||
getExecutionHostIdForWorktree,
|
||||
getRuntimeEnvironmentIdForWorktree
|
||||
} from '@/lib/worktree-runtime-owner'
|
||||
findFolderWorkspaceOwner,
|
||||
getExplicitRuntimeEnvironmentIdForFolderWorkspace
|
||||
} from '@/lib/folder-workspace-runtime-owner'
|
||||
import {
|
||||
findIndexedProjectGroupOwner,
|
||||
hasIndexedDetectedWorktree
|
||||
} from '@/lib/worktree-runtime-owner-index'
|
||||
import type { WorktreeRuntimeOwnerState } from '@/lib/worktree-runtime-owner-state'
|
||||
|
||||
/**
|
||||
@@ -64,16 +71,54 @@ export function resolveWorkspaceTerminalHostAuthority(
|
||||
if (!worktreeId || worktreeId === FLOATING_TERMINAL_WORKTREE_ID) {
|
||||
return 'none'
|
||||
}
|
||||
if (isWebRuntimeSessionActive(getRuntimeEnvironmentIdForWorktree(state, worktreeId))) {
|
||||
return 'live'
|
||||
const selectedHost =
|
||||
state.activeWorktreeId === worktreeId
|
||||
? parseExecutionHostId(state.activeWorkspaceExecutionHostId)
|
||||
: null
|
||||
const scope = parseWorkspaceKey(worktreeId)
|
||||
if (!selectedHost && scope?.type === 'folder') {
|
||||
const folderId = scope.folderWorkspaceId
|
||||
const owner = findFolderWorkspaceOwner(state, folderId)
|
||||
if (!owner && state.folderWorkspaces?.some((folder) => folder.id === folderId)) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (
|
||||
owner &&
|
||||
!owner.executionHostId &&
|
||||
!owner.connectionId &&
|
||||
state.projectGroups?.some((group) => group.id === owner.projectGroupId) &&
|
||||
!findIndexedProjectGroupOwner(state.projectGroups, owner.projectGroupId)
|
||||
) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
const environmentId =
|
||||
getExplicitRuntimeEnvironmentIdForFolderWorkspace(state, folderId) ??
|
||||
getPairedWebClientEnvironmentId(state)
|
||||
// Folder workspaces were never host-replaced, so direct SSH keeps `none`.
|
||||
return isWebRuntimeSessionActive(environmentId) ? 'live' : 'none'
|
||||
}
|
||||
const host = parseExecutionHostId(getExecutionHostIdForWorktree(state, worktreeId))
|
||||
if (host?.kind === 'runtime') {
|
||||
// A runtime host we could not name (rival detected publications, unhydrated catalog) is unasked.
|
||||
const resolution = selectedHost
|
||||
? resolveStrictWorktreeOperationRouteResultForHost(state, worktreeId, selectedHost.id)
|
||||
: resolveExplicitWorktreeOperationRouteResult(state, worktreeId)
|
||||
if (
|
||||
resolution.kind === 'ambiguous' ||
|
||||
(resolution.kind === 'missing' &&
|
||||
hasIndexedDetectedWorktree(state.detectedWorktreesByRepo, worktreeId))
|
||||
) {
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (host?.kind === 'ssh' && parseWorkspaceKey(worktreeId)?.type !== 'folder') {
|
||||
// Folder workspaces were never host-replaced, so they keep `none`.
|
||||
const route = resolution.kind === 'resolved' ? resolution.route : null
|
||||
// A focused runtime routes legacy operations; only owner evidence grants terminal authority.
|
||||
if (
|
||||
isWebRuntimeSessionActive(route?.runtimeEnvironmentId ?? getPairedWebClientEnvironmentId(state))
|
||||
) {
|
||||
return 'live'
|
||||
}
|
||||
const host = parseExecutionHostId(route?.executionHostId)
|
||||
if (host?.kind === 'runtime') {
|
||||
return 'unverifiable'
|
||||
}
|
||||
if (host?.kind === 'ssh' && scope?.type !== 'folder') {
|
||||
return resolveDirectSshAuthority(state, host.targetId)
|
||||
}
|
||||
// Local, or outside the host's replace scope: this client is the execution host and its own rows are
|
||||
|
||||
Reference in New Issue
Block a user