diff --git a/src/renderer/src/components/automations/AutomationListLocalRow.tsx b/src/renderer/src/components/automations/AutomationListLocalRow.tsx index a9c1a5bc8b6..6e53f66302d 100644 --- a/src/renderer/src/components/automations/AutomationListLocalRow.tsx +++ b/src/renderer/src/components/automations/AutomationListLocalRow.tsx @@ -30,7 +30,7 @@ import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' @@ -70,10 +70,7 @@ export type AutomationListLocalRowProps = { worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined projectHostSetups: readonly ProjectHostSetup[] sshConnectionStates: ReadonlyMap> - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > + runtimeStatusByEnvironmentId: ReadonlyMap hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null automationSourceHostAvailabilityByRowKey: ReadonlyMap hostLabelById?: ReadonlyMap diff --git a/src/renderer/src/components/automations/AutomationsListPanel.tsx b/src/renderer/src/components/automations/AutomationsListPanel.tsx index 783eee57da6..3460aafeb2a 100644 --- a/src/renderer/src/components/automations/AutomationsListPanel.tsx +++ b/src/renderer/src/components/automations/AutomationsListPanel.tsx @@ -11,7 +11,7 @@ import type { SshConnectionState } from '../../../../shared/ssh-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' import type { TaskSourceHostAvailability } from '../task-source-context-summary' import type { AutomationRowAction } from './automation-captured-owner' import type { AutomationHostTarget } from './automation-host-client' @@ -53,7 +53,7 @@ import { AutomationListToolbar } from './AutomationListToolbar' const TEMPLATE_EMPTY_STATES: ReadonlySet = new Set(['host-empty', 'all-hosts-empty']) const EMPTY_AUTOMATION_RUNS: ReadonlyMap = new Map() -type AutomationsListPanelProps = { +export type AutomationsListPanelProps = { hasListItems: boolean hasFilteredListItems: boolean listSearchQuery: string @@ -83,10 +83,7 @@ type AutomationsListPanelProps = { worktreeForRow?: (row: AutomationListRow, repo: Repo | undefined) => Worktree | undefined projectHostSetups: readonly ProjectHostSetup[] sshConnectionStates: ReadonlyMap> - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > + runtimeStatusByEnvironmentId: ReadonlyMap hostTargetFor: (row: AutomationListRow) => AutomationHostTarget | null automationSourceHostAvailabilityByRowKey: ReadonlyMap hostLabelById?: ReadonlyMap diff --git a/src/renderer/src/components/automations/automation-source-context.ts b/src/renderer/src/components/automations/automation-source-context.ts index e8a05ebabc5..c57a3bc4f20 100644 --- a/src/renderer/src/components/automations/automation-source-context.ts +++ b/src/renderer/src/components/automations/automation-source-context.ts @@ -1,7 +1,7 @@ import { TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' import { parseExecutionHostId } from '../../../../shared/execution-host' import type { Automation } from '../../../../shared/automations-types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' import type { TaskSourceContext } from '../../../../shared/task-source-context' import type { TaskSourceHostAvailability } from '../task-source-context-summary' @@ -20,10 +20,7 @@ export function getRepoBackedAutomationSourceContext( export function getRuntimeSourceHostAvailability( context: TaskSourceContext, - runtimeStatusByEnvironmentId: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > + runtimeStatusByEnvironmentId: ReadonlyMap ): TaskSourceHostAvailability | null { const parsed = parseExecutionHostId(context.hostId) if (parsed?.kind !== 'runtime') { diff --git a/src/renderer/src/components/automations/automation-target-availability.ts b/src/renderer/src/components/automations/automation-target-availability.ts index 7fe53db94a0..a745b3d87b8 100644 --- a/src/renderer/src/components/automations/automation-target-availability.ts +++ b/src/renderer/src/components/automations/automation-target-availability.ts @@ -11,9 +11,9 @@ import { import type { AutomationHostTarget } from './automation-host-client' import type { SshConnectionState } from '../../../../shared/ssh-types' import type { TaskSourceContext } from '../../../../shared/task-source-context' -import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' import type { Worktree } from '../../../../shared/worktree/types' import type { TaskSourceHostAvailability } from '../task-source-context-summary' @@ -51,10 +51,7 @@ type AutomationTargetAvailabilityArgs = { workspace: Worktree | null | undefined projectHostSetups: readonly ProjectHostSetup[] sshConnectionStates: ReadonlyMap> - runtimeStatusByEnvironmentId?: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > + runtimeStatusByEnvironmentId?: ReadonlyMap automationHostTarget?: AutomationHostTarget | null sourceHostAvailability?: readonly TaskSourceHostAvailability[] } @@ -267,9 +264,7 @@ function getAutomationSourceProviderLabel(provider: TaskSourceContext['provider' export function getRuntimeAutomationAvailability( environmentId: string, - runtimeStatusByEnvironmentId: - | ReadonlyMap - | undefined + runtimeStatusByEnvironmentId: ReadonlyMap | undefined ): AutomationTargetAvailability { const entry = runtimeStatusByEnvironmentId?.get(environmentId) if (!entry) { diff --git a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts index 8d15ff8d034..586c3d54641 100644 --- a/src/renderer/src/components/cmd-j/palette-host-badge.test.ts +++ b/src/renderer/src/components/cmd-j/palette-host-badge.test.ts @@ -88,6 +88,7 @@ describe('getPaletteHostBadge', () => { [ 'env-1', { + checkedAt: 0, status: { runtimeId: 'rt', rendererGraphEpoch: 0, diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts index 6fe8d0d5901..f527857bb0f 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.test.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.test.ts @@ -115,6 +115,7 @@ describe('sidebar host options', () => { [ 'runtime-1', { + checkedAt: 0, status: { runtimeId: 'rt', rendererGraphEpoch: 0, @@ -147,6 +148,7 @@ describe('sidebar host options', () => { [ 'runtime-1', { + checkedAt: 0, status: { runtimeId: 'rt', rendererGraphEpoch: 0, diff --git a/src/renderer/src/components/sidebar/sidebar-host-options.ts b/src/renderer/src/components/sidebar/sidebar-host-options.ts index 29d03ec1419..fb6ba2808ab 100644 --- a/src/renderer/src/components/sidebar/sidebar-host-options.ts +++ b/src/renderer/src/components/sidebar/sidebar-host-options.ts @@ -12,8 +12,8 @@ import { } from '../../../../shared/execution-host-registry' import type { RuntimeCompatVerdict } from '../../../../shared/protocol-compat' import type { SshConnectionState, SshConnectionStatus } from '../../../../shared/ssh-types' -import type { RuntimeStatus } from '../../../../shared/runtime-types' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' +import type { RuntimeEnvironmentStatus } from '../../../../shared/runtime-host-status' import { translate } from '@/i18n/i18n' export type SidebarHostOption = { @@ -43,10 +43,7 @@ export function buildSidebarHostOptions(args: { settings: Pick | null | undefined // Why: live per-environment runtime status lets the registry surface compat // verdicts and blocked health in the sidebar without re-probing servers. - runtimeStatusByEnvironmentId?: ReadonlyMap< - string, - { status?: RuntimeStatus | null; appVersion?: string | null } - > + runtimeStatusByEnvironmentId?: ReadonlyMap runtimeEnvironments?: readonly Pick[] // Why: per-host display-label overrides rename hosts everywhere the sidebar // options feed (host headers, scope picker, focus menu). diff --git a/src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts b/src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts new file mode 100644 index 00000000000..63c3de2e708 --- /dev/null +++ b/src/renderer/src/store/slices/runtime-host-status-entry-parity.test.ts @@ -0,0 +1,56 @@ +// The store's host-status entry is one shape, and every reader of the map has to accept exactly +// it. A narrower local copy still typechecks against the store — the fields it is missing are all +// optional — so it freezes at the vintage it was written at and never fails. #17710 added +// `remoteControl` and #20003 added `snapshot`; the copies these assertions cover were not updated +// either time. Like the other typed-contract tests, they fail at typecheck, not at runtime. +import { describe, expectTypeOf, it } from 'vitest' +import type { buildExecutionHostRegistry } from '../../../../shared/execution-host-registry' +import type { buildSidebarHostOptions } from '@/components/sidebar/sidebar-host-options' +import type { getRuntimeAutomationAvailability } from '@/components/automations/automation-target-availability' +import type { getRuntimeSourceHostAvailability } from '@/components/automations/automation-source-context' +import type { AutomationListLocalRowProps } from '@/components/automations/AutomationListLocalRow' +import type { AutomationsListPanelProps } from '@/components/automations/AutomationsListPanel' +import type { RuntimeEnvironmentStatus } from './runtime-status-types' + +// Compare the entry each reader accepts rather than the surrounding map, so a mismatch names the +// field that drifted instead of every ReadonlyMap method signature. +type AcceptedEntry = NonNullable extends ReadonlyMap ? TEntry : never + +type RegistryEntry = AcceptedEntry< + Parameters[0]['runtimeStatusByEnvironmentId'] +> +type SidebarEntry = AcceptedEntry< + Parameters[0]['runtimeStatusByEnvironmentId'] +> +type AutomationEntry = AcceptedEntry[1]> +type SourceContextEntry = AcceptedEntry[1]> +type LocalRowEntry = AcceptedEntry +type ListPanelEntry = AcceptedEntry + +describe('every reader of the runtime host status map accepts the store entry', () => { + it('holds for the execution-host registry', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('holds for the sidebar host options', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('holds for runtime automation availability', () => { + expectTypeOf().toEqualTypeOf() + }) + + // These three only forward the map, which is exactly how a stale copy survives: nothing they + // do with it can fail, so the drift surfaces in whatever they hand it to. + it('holds for the automation source-context reader', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('holds for the automation list row', () => { + expectTypeOf().toEqualTypeOf() + }) + + it('holds for the automations list panel', () => { + expectTypeOf().toEqualTypeOf() + }) +}) diff --git a/src/renderer/src/store/slices/runtime-status-types.ts b/src/renderer/src/store/slices/runtime-status-types.ts index 78e47123cf5..b9ea9411d93 100644 --- a/src/renderer/src/store/slices/runtime-status-types.ts +++ b/src/renderer/src/store/slices/runtime-status-types.ts @@ -1,15 +1,10 @@ -import type { RuntimeHostStatusSnapshot } from '../../../../shared/runtime-host-status' +import type { + RuntimeEnvironmentStatus, + RuntimeHostStatusSnapshot +} from '../../../../shared/runtime-host-status' import type { PublicKnownRuntimeEnvironment } from '../../../../shared/runtime-environments' -import type { RuntimeStatus } from '../../../../shared/runtime-types' -export type RuntimeEnvironmentStatus = { - snapshot?: RuntimeHostStatusSnapshot - status: RuntimeStatus | null - remoteControl?: RuntimeStatus['remoteControl'] | null - appVersion?: string | null - checkedAt: number - connectionGeneration?: number -} +export type { RuntimeEnvironmentStatus } export type RuntimeStatusSlice = { readRuntimeHostStatusSnapshots: () => Promise diff --git a/src/shared/execution-host-registry.test.ts b/src/shared/execution-host-registry.test.ts index e397e8ed5bf..9aefe7cfe7e 100644 --- a/src/shared/execution-host-registry.test.ts +++ b/src/shared/execution-host-registry.test.ts @@ -121,6 +121,7 @@ describe('execution host registry', () => { [ 'builder', { + checkedAt: 0, appVersion: '1.8.0', status: { runtimeId: 'runtime-builder', @@ -139,6 +140,7 @@ describe('execution host registry', () => { [ 'old-server', { + checkedAt: 0, appVersion: '1.6.0', status: { runtimeId: 'runtime-old', @@ -187,6 +189,7 @@ describe('execution host registry', () => { [ 'dev-box', { + checkedAt: 0, status: { runtimeId: 'runtime-dev', rendererGraphEpoch: 1, @@ -229,6 +232,7 @@ describe('execution host registry', () => { [ 'dev-box', { + checkedAt: 0, status: null, remoteControl: { state: 'ready', @@ -264,6 +268,7 @@ describe('execution host registry', () => { [ 'vm-runtime', { + checkedAt: 0, status: { runtimeId: 'runtime-vm', rendererGraphEpoch: 1, @@ -340,6 +345,7 @@ describe('execution host registry', () => { [ 'gpu', { + checkedAt: 0, appVersion: '1.8.0', status: { runtimeId: 'runtime-gpu', @@ -381,6 +387,7 @@ it('keeps an initial unknown-transport verification connecting', () => { [ 'host', { + checkedAt: 0, status: null, snapshot: { environmentId: 'host', diff --git a/src/shared/execution-host-registry.ts b/src/shared/execution-host-registry.ts index bad6b40f257..5602e115f9f 100644 --- a/src/shared/execution-host-registry.ts +++ b/src/shared/execution-host-registry.ts @@ -1,4 +1,4 @@ -import type { RuntimeHostStatusSnapshot } from './runtime-host-status' +import type { RuntimeEnvironmentStatus } from './runtime-host-status' import { LOCAL_EXECUTION_HOST_ID, getLocalExecutionHostLabel, @@ -49,14 +49,7 @@ type RuntimeEnvironmentSummary = { source?: RuntimeEnvironmentSource } -type RuntimeHostStatus = { - snapshot?: RuntimeHostStatusSnapshot - status?: RuntimeStatus | null - remoteControl?: RuntimeStatus['remoteControl'] | null - appVersion?: string | null -} - -type RuntimeStatusByEnvironmentId = ReadonlyMap +type RuntimeStatusByEnvironmentId = ReadonlyMap export type ExecutionHostSource = 'configured-only' | 'include-references' diff --git a/src/shared/runtime-host-status.ts b/src/shared/runtime-host-status.ts index 4dd7ff7cc22..750f260e796 100644 --- a/src/shared/runtime-host-status.ts +++ b/src/shared/runtime-host-status.ts @@ -17,6 +17,21 @@ export type RuntimeHostStatusSnapshot = { retired?: true } +/** + * One client-side record of a host's last status probe: the projected `status`, and the + * `snapshot` evidence for what that projection is worth. Declared once rather than duck-typed + * per consumer — every field added here has been optional, so a local structural copy keeps + * typechecking against the store while silently missing whatever landed after it was written. + */ +export type RuntimeEnvironmentStatus = { + snapshot?: RuntimeHostStatusSnapshot + status: RuntimeStatus | null + remoteControl?: RuntimeStatus['remoteControl'] | null + appVersion?: string | null + checkedAt: number + connectionGeneration?: number +} + export type RuntimeHostStatusResponse = RuntimeRpcResponse export function runtimeHostStatusFailure(code: string, message: string): RuntimeRpcFailure {