diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts index e7cede86afd..7efb1800084 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.test.ts @@ -190,6 +190,23 @@ describe('hydrateRuntimeEnvironmentSshState', () => { ]) }) + it('reads state for a labelled target the bucket never recorded a state for', async () => { + const envId = nextEnvId() + // A failed-connect resync can label a target without ever reading its state. + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-new', label: 'new box' }]) + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'new box' }], + states: { 'ssh-new': connState('ssh-new') } + }) + + await refreshRuntimeEnvironmentSshTargetMetadata(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected') + }) + it('prunes removed targets and fetches state only for newly discovered targets', async () => { const envId = nextEnvId() useAppStore.getState().setEnvironmentSshTargetsMetadata(envId, [ @@ -558,4 +575,22 @@ describe('resyncRuntimeEnvironmentSshTargets', () => { expect(bucket?.targetLabels.get('ssh-live')).toBe('devbox') expect(bucket?.targetsHydrated).toBe(true) }) + + it('reads the connection state of a host re-added under a new target id', async () => { + const envId = nextEnvId() + useAppStore + .getState() + .setEnvironmentSshTargetsMetadata(envId, [{ id: 'ssh-old', label: 'devbox' }]) + installRpcResponses({ + targets: [{ id: 'ssh-new', label: 'devbox' }], + labels: { 'ssh-old': 'devbox' }, + states: { 'ssh-new': connState('ssh-new') } + }) + + await resyncRuntimeEnvironmentSshTargets(envId) + + const bucket = useAppStore.getState().sshStateByEnvironment.get(envId) + expect(bucket?.targetLabels.get('ssh-new')).toBe('devbox') + expect(bucket?.connectionStates.get('ssh-new')?.status).toBe('connected') + }) }) diff --git a/src/renderer/src/runtime/runtime-environment-ssh-state.ts b/src/renderer/src/runtime/runtime-environment-ssh-state.ts index d15f71da649..689b6ad88c6 100644 --- a/src/renderer/src/runtime/runtime-environment-ssh-state.ts +++ b/src/renderer/src/runtime/runtime-environment-ssh-state.ts @@ -130,16 +130,19 @@ async function runEnvironmentSshTargetMetadataRefresh(environmentId: string): Pr const metadataChanged = !bucket?.targetsHydrated || !sshTargetLabelsEqual(bucket.targetLabels, targets) useAppStore.getState().setEnvironmentSshTargetsMetadata(environmentId, targets, generation) + const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? []) + // Why: read states after the write — a resync racing this fetch can label a target that never had one read. + const knownStates = useAppStore.getState().sshStateByEnvironment.get(environmentId) + ?.connectionStates + const needStateRead = targets.filter( + (target) => !priorTargetIds.has(target.id) || !knownStates?.has(target.id) + ) if (!metadataChanged) { + await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation) return } await syncEnvironmentRemovedSshTargetLabels(environmentId, generation) - const priorTargetIds = new Set(bucket?.targetLabels.keys() ?? []) - await fetchEnvironmentSshConnectionStates( - environmentId, - targets.filter((target) => !priorTargetIds.has(target.id)), - generation - ) + await fetchEnvironmentSshConnectionStates(environmentId, needStateRead, generation) } function requestSshRefreshRerun(entry: SshRefreshEntry, kind: SshRefreshKind): void { @@ -287,10 +290,9 @@ export async function connectRuntimeEnvironmentSshTarget( } /** Resyncs the environment's target metadata after a failed connect so a - * stale overlay converges to the ghost/re-adopted state (STA-1468). */ + * stale overlay converges to the ghost/re-adopted state (STA-1468). Full + * hydration, not metadata alone: a host re-added under a new target id must + * land with a connection state or its chip and mutation gate stay dead. */ export async function resyncRuntimeEnvironmentSshTargets(environmentId: string): Promise { - await syncEnvironmentSshTargetMetadata( - environmentId, - getEnvironmentSshStateGeneration(environmentId) - ) + await hydrateRuntimeEnvironmentSshState(environmentId, { force: true }) }