diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx index 1df0a7519bf..e32e6500d5f 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.test.tsx @@ -42,9 +42,29 @@ const ENTRY: AutomationHostCatalogEntry = { querySupport: 'scoped' } -function control(projects: Repo[]): AutomationCreateDestinationControl { +const LEGACY_ENTRY: AutomationHostCatalogEntry = { + stableRef: { authority: { kind: 'runtime', environmentId: 'r1' }, selector: { kind: 'self' } }, + owner: { + authority: { kind: 'runtime', environmentId: 'r1', pairingRevision: 1 }, + selector: { kind: 'self' } + }, + stableKey: 'host:runtime:r1:self', + label: 'legacy-box', + authorityLabel: 'legacy-box', + kind: 'self', + catalogState: 'authoritative', + authorityHealth: 'fresh', + executionHealth: 'connected', + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' +} + +function control( + projects: Repo[], + entries: AutomationHostCatalogEntry[] = [ENTRY] +): AutomationCreateDestinationControl { return { - entries: [ENTRY], + entries, resolution: { status: 'ready', authority: OWNER.authority, @@ -56,11 +76,11 @@ function control(projects: Repo[]): AutomationCreateDestinationControl { } } -function render(projects: Repo[]): void { +function render(projects: Repo[], entries?: AutomationHostCatalogEntry[]): void { act(() => { root.render( - + ) }) @@ -83,4 +103,17 @@ describe('AutomationCreateDestinationField', () => { expect(emptyNote()).toBeNull() expect(container.textContent).toContain('Local Mac') }) + + it('names the hosts a server update would let create, instead of hiding them', () => { + render([{ id: 'repo-1' } as Repo], [ENTRY, LEGACY_ENTRY]) + + const note = container.querySelector('[data-testid="automation-create-update-required"]') + expect(note?.textContent).toContain('legacy-box') + }) + + it('says nothing about updates when every offered host is eligible', () => { + render([{ id: 'repo-1' } as Repo]) + + expect(container.querySelector('[data-testid="automation-create-update-required"]')).toBeNull() + }) }) diff --git a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx index 8ba017b0ff7..43a4f2cc08c 100644 --- a/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx +++ b/src/renderer/src/components/automations/AutomationCreateDestinationField.tsx @@ -11,7 +11,11 @@ import { import { translate } from '@/i18n/i18n' import { AutomationHostLabel, AutomationHostStatusBadges } from './AutomationHostBadges' import { Field } from './automation-page-parts' -import { automationCreateHostEligible } from './automation-create-destination' +import { + automationCreateHostEligible, + automationCreateHostOffered, + automationCreateUpdateRequiredAuthorityLabels +} from './automation-create-destination' import { groupAutomationHostEntriesByAuthority } from './automation-host-picker-groups' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' @@ -32,11 +36,12 @@ export function AutomationCreateDestinationField({ labelClassName?: string }): React.JSX.Element { const selected = control.resolution.status === 'ready' ? control.resolution.entry : null - // Offered and resolved by the same predicate, so the list never contains a - // host that selecting would refuse (e.g. a view-only entry). + // Ineligible hosts stay listed but disabled: hiding them read as the host + // being gone, and it hid every connected host on a pre-host-scoping server. const groups = groupAutomationHostEntriesByAuthority( - control.entries.filter(automationCreateHostEligible) + control.entries.filter(automationCreateHostOffered) ) + const updateRequiredAuthorities = automationCreateUpdateRequiredAuthorityLabels(control.entries) const label = translate('auto.components.automations.createDestination.label', 'Create on') return ( @@ -58,6 +63,7 @@ export function AutomationCreateDestinationField({ @@ -91,6 +97,19 @@ export function AutomationCreateDestinationField({ )}

)} + {updateRequiredAuthorities.length > 0 ? ( + // A disabled row's tooltip is unreachable, so the repair is stated here. +

+ {translate( + 'auto.components.automations.createDestination.updateRequired', + 'Update the Orca server on {hosts} to create automations there.' + // Replacer fn: a literal replacement would expand `$` patterns in host labels. + ).replace('{hosts}', () => updateRequiredAuthorities.join(', '))} +

+ ) : null} ) } diff --git a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx index d13ab127e21..da04a8cd58f 100644 --- a/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.create-destination.test.tsx @@ -11,7 +11,7 @@ */ import { act } from 'react' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import type { Automation } from '../../../../shared/automations-types' import { hostStableKey } from '../../../../shared/automation-owner-key' import { unscopedAutomationListRows, type AutomationListRow } from './automation-list-row-identity' @@ -131,7 +131,8 @@ describe('AutomationsPage create destination', () => { expect(api.automations.create).not.toHaveBeenCalled() expect(runtimeCreateCalls()).toHaveLength(1) expect(runtimeCreateCalls()[0]?.[2]).toMatchObject({ - repo: RUNTIME_REPO_ID, + repo: `id:${RUNTIME_REPO_ID}`, + workspace: `id:${RUNTIME_WORKSPACE_ID}`, destination: { selector: { kind: 'self' } } }) }) @@ -152,7 +153,7 @@ describe('AutomationsPage create destination', () => { expect(api.automations.create).not.toHaveBeenCalled() expect(runtimeCreateCalls()).toHaveLength(0) - expect(mocks.toastError).toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() expect(mocks.editorDialog?.open).toBe(true) }) @@ -169,7 +170,7 @@ describe('AutomationsPage create destination', () => { // A repo with no connection ID is not evidence of local: this one is the // runtime's, and the desktop's Self host cannot hold an automation for it. expect(api.automations.create).not.toHaveBeenCalled() - expect(mocks.toastError).toHaveBeenCalled() + expect(mocks.editorDialog?.notice?.message).toBeTruthy() expect(mocks.editorDialog?.open).toBe(true) }) @@ -368,3 +369,79 @@ describe('AutomationsPage edit fencing', () => { expect(mocks.editorDialog?.notice?.message).toBeTruthy() }) }) + +describe('AutomationsPage edit dialog projects', () => { + it('offers the edited row’s own host projects, not the create destination’s', async () => { + const automation = makeAutomation({ + id: 'a-runtime', + projectId: RUNTIME_REPO_ID, + workspaceId: RUNTIME_WORKSPACE_ID + }) + // The ambient list is the desktop's and never held this record, so an + // id lookup there answers with nothing and the row's owner is all there is. + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([automation], []) + addRuntimeProject() + + await renderPage() + await settleHostQueries() + await act(async () => { + void mocks.listPanel?.openEditDialog(listedRow(automation.id)) + }) + + expect(mocks.editorDialog?.isEditing).toBe(true) + expect(mocks.editorDialog?.repos?.map((repo) => repo.id)).toEqual([RUNTIME_REPO_ID]) + }) +}) + +describe('AutomationsPage create admission', () => { + it('keeps the create button available while every offered host is ineligible', async () => { + // No host has answered, so nothing resolves ready — but the dialog is where + // an ineligible host's repair is stated, so the button must still open it. + api.automations.listScoped.mockRejectedValue(new Error('offline')) + api.automations.list.mockResolvedValue([]) + + await renderPage() + await settleHostQueries() + + expect(mocks.listPanel?.canCreateAutomation).toBe(true) + }) + + it('refuses a mismatched create destination before the hooks trust prompt', async () => { + // A runtime-owned project under the sole desktop destination: refused, and + // refused before the user is asked to trust that project's setup hooks. + api.automations.list.mockResolvedValue([]) + scopedList([]) + addRuntimeProject() + // A setup hook whose default policy is run-by-default, so the old save + // order would have raised the trust prompt before refusing the create. + const { checkRuntimeHooks } = await import('@/runtime/runtime-hooks-client') + vi.mocked(checkRuntimeHooks).mockResolvedValue({ + status: 'ok', + hooks: { scripts: { setup: 'pnpm install' } } + } as never) + + await renderPage() + await settleHostQueries() + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: RUNTIME_REPO_ID, + workspaceMode: 'new_per_run', + workspaceId: '' + })) + }) + await save() + + expect(mocks.editorDialog?.notice?.message).toBeTruthy() + const { ensureHooksConfirmed } = await import('@/lib/ensure-hooks-confirmed') + expect(vi.mocked(ensureHooksConfirmed)).not.toHaveBeenCalled() + expect(api.automations.create).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx b/src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx new file mode 100644 index 00000000000..e1387e88631 --- /dev/null +++ b/src/renderer/src/components/automations/AutomationsPage.strict-mode-save-visibility.test.tsx @@ -0,0 +1,150 @@ +// @vitest-environment happy-dom + +/** + * Save visibility under a StrictMode mount, which is how the dev app runs. + * + * StrictMode's simulated unmount disposes the host query controller after its + * first effect cycle. A controller that stays disposed drops every later + * invalidation — the list keeps its initial rows and a create never appears + * until the app is reloaded. These pin the revive: after the double mount, a + * write and the authority's change event must still refetch the written host. + */ + +import { act } from 'react' +import { describe, expect, it } from 'vitest' +import type { Automation } from '../../../../shared/automations-types' +import { hostStableKey } from '../../../../shared/automation-owner-key' +import { AUTOMATIONS_CHANGED_EVENT } from '@/lib/automations-changed-window-event' +import { + addRuntimeProject, + api, + installAutomationsPageHarness, + mocks, + renderPage, + rows, + runtimeHost, + RUNTIME_ID, + RUNTIME_REPO_ID, + RUNTIME_WORKSPACE_ID, + scopedList, + settleHostQueries +} from './automations-page-test-harness' +import { makeAutomation, REPO_ID, WORKSPACE_ID } from './automations-page-fixtures' + +installAutomationsPageHarness() + +const CREATED = makeAutomation({ id: 'a-new', name: 'Sweep' }) + +const RUNTIME_SELF_KEY = hostStableKey({ + authority: { kind: 'runtime', environmentId: RUNTIME_ID }, + selector: { kind: 'self' } +}) + +function desktopStoreHolds(automations: Automation[]): void { + api.automations.list.mockResolvedValue(automations) + scopedList(automations) +} + +async function createSweep(): Promise { + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: REPO_ID, + workspaceMode: 'existing', + workspaceId: WORKSPACE_ID + })) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + await settleHostQueries() +} + +describe('AutomationsPage save visibility under StrictMode', () => { + it('lists a newly created automation without a reload', async () => { + desktopStoreHolds([]) + api.automations.create.mockResolvedValue(CREATED) + + const { container } = await renderPage({ strict: true }) + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + desktopStoreHolds([CREATED]) + await createSweep() + + expect(api.automations.create).toHaveBeenCalledTimes(1) + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('still refetches when the authority publishes its change event', async () => { + desktopStoreHolds([]) + + const { container } = await renderPage({ strict: true }) + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + // A write that lands elsewhere (CLI, another window) only reaches this page + // through the event; a disposed controller would drop it on the floor. + desktopStoreHolds([CREATED]) + await act(async () => { + window.dispatchEvent( + new CustomEvent(AUTOMATIONS_CHANGED_EVENT, { + detail: { reason: 'definition', selector: { kind: 'self' } } + }) + ) + await new Promise((resolve) => setTimeout(resolve, 0)) + }) + + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) + + it('lists a create on a runtime-host destination without a reload', async () => { + api.automations.list.mockResolvedValue([]) + scopedList([]) + runtimeHost([], []) + addRuntimeProject() + const previous = mocks.callRuntimeRpc.getMockImplementation() + mocks.callRuntimeRpc.mockImplementation( + async (target: unknown, method: string, params: unknown, options: unknown) => { + if (method === 'automation.create') { + // The runtime store now holds the row; later list reads must say so. + mocks.state.runtimeAnswers = { automations: [CREATED], runs: [] } + return { automation: CREATED } + } + return await previous?.(target, method, params, options) + } + ) + + const { container } = await renderPage({ strict: true }) + await settleHostQueries() + expect(rows(container, 'automation-row')).toEqual([]) + + await act(async () => { + mocks.listPanel?.openCreateDialog() + }) + await act(async () => { + mocks.editorDialog?.createDestination?.onSelect(RUNTIME_SELF_KEY) + }) + await act(async () => { + mocks.editorDialog?.onDraftChange((current) => ({ + ...(current as Record), + name: 'Sweep', + prompt: 'Do the sweep', + projectId: RUNTIME_REPO_ID, + workspaceMode: 'existing', + workspaceId: RUNTIME_WORKSPACE_ID + })) + }) + await act(async () => { + await mocks.editorDialog?.onSave() + }) + await settleHostQueries() + + expect(rows(container, 'automation-row')).toEqual(['Sweep']) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.test.tsx b/src/renderer/src/components/automations/AutomationsPage.test.tsx index 1efff6d98f8..768d3250d80 100644 --- a/src/renderer/src/components/automations/AutomationsPage.test.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.test.tsx @@ -626,3 +626,16 @@ describe('AutomationsPage owner conflicts', () => { expect(mocks.listPanel?.isActionEnabled(listedRow(automation.id), 'delete')).toBe(true) }) }) + +describe('AutomationsPage refresh independence', () => { + // The external-manager probe is per host and can hang on a dead provider; the + // automation list must settle without waiting for it. + it('settles the list even when the external manager probe never answers', async () => { + api.automations.listExternalManagerForOwner.mockReturnValue(new Promise(() => undefined)) + + const { container } = await renderPage() + + expect(rows(container, 'automation-row')).toEqual(['Nightly']) + expect((mocks.listPanel as { isRefreshing?: boolean } | null)?.isRefreshing).toBe(false) + }) +}) diff --git a/src/renderer/src/components/automations/AutomationsPage.tsx b/src/renderer/src/components/automations/AutomationsPage.tsx index 0180ad2ac52..7c908d41af1 100644 --- a/src/renderer/src/components/automations/AutomationsPage.tsx +++ b/src/renderer/src/components/automations/AutomationsPage.tsx @@ -61,7 +61,6 @@ import { } from './automation-setup-decision' import type { AutomationTemplate } from './automation-templates' import { getAutomationTargetAvailability } from './automation-target-availability' -import { getAutomationCreateAvailability } from './automation-create-admission' import { buildAutomationRunContextForRepo } from './automation-run-context' import { repoMatchesExternalAutomationTarget } from './automation-external-target-match' import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed' @@ -140,6 +139,7 @@ import { groupReposByAutomationAuthority } from './automation-authority-identity' import { + automationCreateHostOffered, automationCreateHostStableKey, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, @@ -680,9 +680,9 @@ export default function AutomationsPage(): React.JSX.Element { // The row's own host, from its captured owner. A page-level target cannot // speak for a list spanning authorities, and the legacy arm below it is only // ever reached by rows the desktop's unscoped list produced. - const automationHostTargetFor = useCallback( - (row: AutomationListRow): AutomationHostTarget | null => { - const owner = capturedAutomationOwner(capturedAutomationOwners, row.key).owner + const automationHostTargetForRowKey = useCallback( + (rowKey: string | null): AutomationHostTarget | null => { + const owner = capturedAutomationOwner(capturedAutomationOwners, rowKey).owner if (owner?.authority.kind === 'runtime') { return { kind: 'environment', environmentId: owner.authority.environmentId } } @@ -690,6 +690,10 @@ export default function AutomationsPage(): React.JSX.Element { }, [automationHostTarget, capturedAutomationOwners] ) + const automationHostTargetFor = useCallback( + (row: AutomationListRow): AutomationHostTarget | null => automationHostTargetForRowKey(row.key), + [automationHostTargetForRowKey] + ) const automationDispatchContext = useMemo( () => ({ capturedOwners: capturedAutomationOwners, authority: automationAuthority }), [automationAuthority, capturedAutomationOwners] @@ -981,17 +985,41 @@ export default function AutomationsPage(): React.JSX.Element { } }, [activeWorktreeId, editorProjects, repoMap, worktreeMap, worktreesByRepo]) - const canCreateAutomation = getAutomationCreateAvailability({ - automationHostTarget: getAutomationListTarget(settings), - runtimeStatusByEnvironmentId - }).canRunNow - const editingAutomation = editingAutomationId - ? (automations.find((automation) => automation.id === editingAutomationId) ?? null) + // Gated on what the picker offers, not on eligibility: with every offered + // host ineligible (e.g. all pre-host-scoping servers), the dialog is where + // the repair is stated, so the button must still open it. + const canCreateAutomation = hostCatalog.entries.some(automationCreateHostOffered) + // The edited row's own captured owner names the host, not the ambient list + // target: a remote row need not appear in `automations` at all, and looking it + // up by id there would answer with whichever authority the page last listed. + // An uncaptured row resolves to the same host its save addresses. + const editingRow = editingRowKey + ? (visibleRows.find((row) => row.key === editingRowKey) ?? null) : null - const automationDialogTarget = editingAutomation - ? getAutomationOwnerTarget(editingAutomation, automationHostTarget) - : getAutomationListTarget(settings) + const editingRowCapturedOwner = capturedAutomationOwner( + capturedAutomationOwners, + editingRowKey + ).owner + const automationDialogTarget = ((): AutomationHostTarget => { + if (editingAutomationId === null) { + return getAutomationListTarget(settings) + } + // Uncaptured: the host the legacy save addresses, so a runtime row the + // desktop list produced stops offering desktop projects. + if (editingRow && !editingRowCapturedOwner) { + return getAutomationOwnerTarget(editingRow.automation, automationHostTarget) + } + return ( + automationHostTargetForRowKey(editingRowKey) ?? + getAutomationTargetFromHostId(editingRow?.automation.runContext?.hostId) + ) + })() const isOrcaForm = createTarget === 'orca' && editingExternalTarget === null + const dialogRepos = isOrcaForm + ? editingAutomationId !== null + ? getAutomationCreateRepos(repos, automationDialogTarget) + : editorProjects + : getAutomationCreateRepos(repos, { kind: 'local' }) const destinationForProject = useCallback( (projectId: string): AutomationCreateDestination | null => { @@ -1015,40 +1043,47 @@ export default function AutomationsPage(): React.JSX.Element { const reloadExternalManagers = scopedExternal.reload - const refresh = useCallback(async () => { - setIsLoading(true) - const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation - // The desktop unless navigation named a host: this arm exists for rows the - // per-host reads have not answered for, and the client's own authority is - // the only one it can address without guessing which server is meant. - const automationHostTarget: AutomationHostTarget = pendingNavigation - ? getAutomationTargetFromHostId(pendingNavigation.hostId) - : { kind: 'local' } - const authorityKey = automationAuthorityCatalogKey( - automationHostTarget.kind === 'environment' - ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } - : { kind: 'desktop' } - ) - try { - const [nextAutomations] = await Promise.all([ - listAutomationsForTarget(automationHostTarget), - // Managers are per host and failures are per provider, so this settles - // on its own and must not be able to fail the automation list with it. - reloadExternalManagers().catch(() => undefined) - ]) - // Selection and run history are deliberately not written here: this call - // addressed one authority, and the selected row may belong to another. - setAutomations(nextAutomations) - setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) - setFailedAuthorityKeys((current) => withoutKey(current, authorityKey)) - } catch { - // Why not a toast and not a rethrow: the list keeps whatever it had, and - // the host's own status row is where the failure and its Retry belong. - setFailedAuthorityKeys((current) => new Set(current).add(authorityKey)) - } finally { - setIsLoading(false) - } - }, [reloadExternalManagers]) + const refresh = useCallback( + async (options?: { awaitExternalManagers?: boolean }) => { + setIsLoading(true) + const pendingNavigation = useAppStore.getState().pendingAutomationRunNavigation + // The desktop unless navigation named a host: this arm exists for rows the + // per-host reads have not answered for, and the client's own authority is + // the only one it can address without guessing which server is meant. + const automationHostTarget: AutomationHostTarget = pendingNavigation + ? getAutomationTargetFromHostId(pendingNavigation.hostId) + : { kind: 'local' } + const authorityKey = automationAuthorityCatalogKey( + automationHostTarget.kind === 'environment' + ? { kind: 'runtime', environmentId: automationHostTarget.environmentId } + : { kind: 'desktop' } + ) + // Managers are per host and failures are per provider: the probe settles + // into its own state on its own time, and must neither fail the automation + // list nor keep the list loading while a slow provider answers. An explicit + // external mutation opts in below, so the row set it re-reads reflects the + // write before its success toast lands. + const managersSettled = reloadExternalManagers().catch(() => undefined) + try { + const nextAutomations = await listAutomationsForTarget(automationHostTarget) + // Selection and run history are deliberately not written here: this call + // addressed one authority, and the selected row may belong to another. + setAutomations(nextAutomations) + setAutomationHostTargetKey(getAutomationHostTargetKey(automationHostTarget)) + setFailedAuthorityKeys((current) => withoutKey(current, authorityKey)) + } catch { + // Why not a toast and not a rethrow: the list keeps whatever it had, and + // the host's own status row is where the failure and its Retry belong. + setFailedAuthorityKeys((current) => new Set(current).add(authorityKey)) + } finally { + setIsLoading(false) + } + if (options?.awaitExternalManagers) { + await managersSettled + } + }, + [reloadExternalManagers] + ) useEffect(() => { if (!pendingAutomationRunNavigation || isLoading) { @@ -1514,7 +1549,7 @@ export default function AutomationsPage(): React.JSX.Element { if (!editingExternalTarget) { useAppStore.getState().recordFeatureInteraction('automation-created') } - await refresh() + await refresh({ awaitExternalManagers: true }) setCreateOpen(false) setEditingExternalTarget(null) // Same helper and same captured scope the row's key was built from, so the @@ -1537,7 +1572,11 @@ export default function AutomationsPage(): React.JSX.Element { ) return } - if (isOrcaForm && !editorProjects.some((repo) => repo.id === draft.projectId)) { + if ( + editingAutomationId !== null && + isOrcaForm && + !dialogRepos.some((repo) => repo.id === draft.projectId) + ) { toast.error( translate( 'auto.components.automations.AutomationsPage.destinationProjectUnavailable', @@ -1546,6 +1585,17 @@ export default function AutomationsPage(): React.JSX.Element { ) return } + // Refused here, before the side-effectful steps below (hooks load, trust + // prompt): the user must not answer a trust dialog for a create that the + // destination was always going to reject. `createDraftAutomation` checks + // again after those awaits, which is the fence that actually gates the send. + if (editingAutomationId === null) { + const earlyDestination = createDestination.check(draft.projectId) + if (!earlyDestination.ok) { + setEditorNotice(earlyDestination.notice) + return + } + } const now = Date.now() const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone const rrule = @@ -1962,7 +2012,7 @@ export default function AutomationsPage(): React.JSX.Element { if (action === 'run') { useAppStore.getState().recordFeatureInteraction('automation-run') } - await refresh() + await refresh({ awaitExternalManagers: true }) // Why: full-page detail keeps selection when the deleted external was open; // without this, detail can fall through to an unrelated local automation. if (action === 'delete') { @@ -2224,7 +2274,7 @@ export default function AutomationsPage(): React.JSX.Element { canSave={canSaveDraft} isEditingExternal={editingExternalTarget !== null} createTarget={createTarget} - repos={editorProjects} + repos={dialogRepos} projectHostSetups={projectHostSetups} automationYamlHooksByRepoKey={automationYamlHooksByRepoKey} getAutomationHooksCacheKey={getAutomationHooksCacheKey} diff --git a/src/renderer/src/components/automations/automation-capability-probe.ts b/src/renderer/src/components/automations/automation-capability-probe.ts new file mode 100644 index 00000000000..38cf3c69326 --- /dev/null +++ b/src/renderer/src/components/automations/automation-capability-probe.ts @@ -0,0 +1,135 @@ +import { getRuntimeEnvironmentStatus } from '@/runtime/runtime-rpc-client' +import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref' +import { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + type RuntimeCapability +} from '../../../../shared/protocol-version' +import { automationAuthorityCatalogKey } from './automation-host-catalog-types' +import { automationHostDiagnostics } from './automation-host-diagnostics' + +export const REQUEST_TIMEOUT_MS = 15_000 + +export class AutomationHostScopeUnsupportedError extends Error { + readonly code = 'unsupported_host_scope' + + constructor(message: string) { + super(message) + this.name = 'AutomationHostScopeUnsupportedError' + } +} + +export const AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS = 60_000 +export const AUTHORITY_CAPABILITY_CONFIRMATION_MAX = 32 +const confirmedAuthorityCapabilities = new Map< + string, + { capabilities: Set; confirmedAt: number } +>() +const inFlightCapabilityProbes = new Map>() + +function capabilityProbeKey(authority: AutomationAuthorityRef & { kind: 'runtime' }): string { + return `${authority.environmentId}:${authority.pairingRevision}` +} + +function rememberAuthorityCapabilities( + key: string, + capabilities: Set, + confirmedAt: number +): void { + confirmedAuthorityCapabilities.delete(key) + confirmedAuthorityCapabilities.set(key, { capabilities, confirmedAt }) + while (confirmedAuthorityCapabilities.size > AUTHORITY_CAPABILITY_CONFIRMATION_MAX) { + const oldest = confirmedAuthorityCapabilities.keys().next() + if (oldest.done) { + break + } + confirmedAuthorityCapabilities.delete(oldest.value) + } +} + +/** Test seam: capability state is module-level and must not leak between tests. */ +export function resetAutomationCapabilityProbes(): void { + confirmedAuthorityCapabilities.clear() + inFlightCapabilityProbes.clear() +} + +export async function assertAuthorityCapability( + authority: AutomationAuthorityRef, + capability: RuntimeCapability, + message: string, + options: { cacheConfirmation?: boolean } = {} +): Promise { + if (authority.kind !== 'runtime') { + return + } + const useCache = options.cacheConfirmation !== false + const key = capabilityProbeKey(authority) + if (useCache) { + const confirmation = confirmedAuthorityCapabilities.get(key) + if ( + confirmation && + Date.now() - confirmation.confirmedAt >= AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS + ) { + confirmedAuthorityCapabilities.delete(key) + } else if (confirmation?.capabilities.has(capability)) { + return + } + } + // Fencing checks never join an in-flight probe: it may predate an in-place runtime replacement. + const status = useCache + ? await sharedCapabilityProbe(authority, key) + : await startCapabilityProbe(authority) + if (useCache && status.capabilities?.length) { + rememberAuthorityCapabilities(key, new Set(status.capabilities), Date.now()) + } + if (!status.capabilities?.includes(capability)) { + throw new AutomationHostScopeUnsupportedError(message) + } +} + +function startCapabilityProbe( + authority: AutomationAuthorityRef & { kind: 'runtime' } +): Promise<{ capabilities?: string[] }> { + automationHostDiagnostics.recordCapabilityProbe({ + authorityKey: automationAuthorityCatalogKey(authority) + }) + return getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) +} + +function sharedCapabilityProbe( + authority: AutomationAuthorityRef & { kind: 'runtime' }, + key: string +): Promise<{ capabilities?: string[] }> { + const existing = inFlightCapabilityProbes.get(key) + if (existing) { + return existing + } + const started = startCapabilityProbe(authority) + inFlightCapabilityProbes.set(key, started) + void started + .catch(() => undefined) + .finally(() => { + if (inFlightCapabilityProbes.get(key) === started) { + inFlightCapabilityProbes.delete(key) + } + }) + return started +} + +export async function assertOwnerFencingSupported( + authority: AutomationAuthorityRef +): Promise { + await assertAuthorityCapability( + authority, + AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, + AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, + { cacheConfirmation: false } + ) +} + +export { + AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, + AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE +} diff --git a/src/renderer/src/components/automations/automation-create-admission.ts b/src/renderer/src/components/automations/automation-create-admission.ts deleted file mode 100644 index b69a597663d..00000000000 --- a/src/renderer/src/components/automations/automation-create-admission.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { RuntimeStatus } from '../../../../shared/runtime-types' -import type { AutomationHostTarget } from './automation-host-client' -import { - getRuntimeAutomationAvailability, - type AutomationTargetAvailability -} from './automation-target-availability' - -export function getAutomationCreateAvailability(args: { - automationHostTarget: AutomationHostTarget - runtimeStatusByEnvironmentId?: ReadonlyMap< - string, - { status: RuntimeStatus | null; checkedAt: number } - > -}): AutomationTargetAvailability { - if (args.automationHostTarget.kind === 'local') { - return { canRunNow: true, reason: 'available', message: null } - } - return getRuntimeAutomationAvailability( - args.automationHostTarget.environmentId, - args.runtimeStatusByEnvironmentId - ) -} diff --git a/src/renderer/src/components/automations/automation-create-destination.test.ts b/src/renderer/src/components/automations/automation-create-destination.test.ts index bcf20e61c39..faf0905ee37 100644 --- a/src/renderer/src/components/automations/automation-create-destination.test.ts +++ b/src/renderer/src/components/automations/automation-create-destination.test.ts @@ -4,8 +4,10 @@ import type { AutomationHostCatalogEntry } from './automation-host-catalog-types' import { + automationCreateHostOffered, automationCreateHostStableKey, automationCreateProjectMismatch, + automationCreateUpdateRequiredAuthorityLabels, preselectAutomationCreateHost, resolveAutomationCreateDestination, revalidateAutomationCreateDestination, @@ -116,6 +118,76 @@ describe('create destination revalidation', () => { }) }) +describe('offered create hosts', () => { + it('offers an ineligible host rather than hiding it', () => { + // The regression this pins: a connected host on a pre-host-scoping server + // is ineligible, and hiding it removed every connected host from the picker. + expect( + automationCreateHostOffered( + entry({ + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' + }) + ) + ).toBe(true) + expect(automationCreateHostOffered(entry({ owner: null, catalogState: 'unhydrated' }))).toBe( + true + ) + }) + + it('hides only rows that can never become destinations', () => { + expect(automationCreateHostOffered(entry({ kind: 'orphan' }))).toBe(false) + expect(automationCreateHostOffered(entry({ owner: null, catalogState: 'removed' }))).toBe(false) + }) + + it('names the authorities a server update would repair, once each', () => { + const legacySelf = entry({ + stableKey: 'runtime:r1:self', + authorityLabel: 'legacy-box', + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' + }) + const legacySshChild = entry({ + stableKey: 'runtime:r1:ssh:box', + kind: 'ssh', + authorityLabel: 'legacy-box', + querySupport: 'legacy-unscoped', + scopeGap: 'authority-unscoped' + }) + const incompatible = entry({ + stableKey: 'runtime:r2:self', + authorityLabel: 'older-box', + querySupport: 'incompatible' + }) + expect( + automationCreateUpdateRequiredAuthorityLabels([ + entry(), + legacySelf, + legacySshChild, + incompatible + ]) + ).toEqual(['legacy-box', 'older-box']) + }) + + it('does not blame the server for hosts another repair or none would fix', () => { + // Unverified since disconnect: the repair is a reconnect, not an update. + const unverified = entry({ + stableKey: 'desktop:ssh:cold', + kind: 'ssh', + querySupport: 'legacy-unscoped', + scopeGap: 'target-unverified' + }) + // Scoped but not yet hydrated: disabled, and no repair to name. + const unhydrated = entry({ + stableKey: 'desktop:ssh:warm', + kind: 'ssh', + owner: null, + catalogState: 'unhydrated' + }) + expect(automationCreateUpdateRequiredAuthorityLabels([unverified, unhydrated])).toEqual([]) + }) +}) + describe('sole create host', () => { const hydrated: AutomationCatalogHydrationEvidence = { runtimeCatalogSettled: true, diff --git a/src/renderer/src/components/automations/automation-create-destination.ts b/src/renderer/src/components/automations/automation-create-destination.ts index 8eff3749059..97a35d78a81 100644 --- a/src/renderer/src/components/automations/automation-create-destination.ts +++ b/src/renderer/src/components/automations/automation-create-destination.ts @@ -29,6 +29,7 @@ import { repoConnectionIdIn, type AutomationAuthorityRepoTables } from './automation-authority-identity' +import { automationHostRecoveryActions } from './automation-host-status-descriptors' export type AutomationCreateDestinationChoiceReason = /** No host is selected and nothing may be assumed. */ @@ -61,6 +62,35 @@ export function automationCreateHostEligible( return entry.kind !== 'orphan' && entry.owner !== null && entry.querySupport === 'scoped' } +/** + * What the picker lists: a superset of eligibility on purpose. An ineligible + * host renders disabled with its status stated, because a host the rest of the + * app still shows must not silently vanish here — omission reads as the host + * being gone. Only rows that can never become destinations stay hidden: the + * orphan bucket and removed targets. + */ +export function automationCreateHostOffered(entry: AutomationHostCatalogEntry): boolean { + return entry.kind !== 'orphan' && entry.catalogState !== 'removed' +} + +/** + * The authorities whose ineligibility a server update would repair, named so + * the field can say which machines need updating. A disabled row cannot explain + * itself — its tooltip sits behind pointer-events: none — and not every + * disabled host is repaired this way (an unverified target needs a reconnect), + * so the hint names exactly the update-repairable ones. + */ +export function automationCreateUpdateRequiredAuthorityLabels( + entries: readonly AutomationHostCatalogEntry[] +): string[] { + const labels = entries + .filter(automationCreateHostOffered) + .filter((entry) => !automationCreateHostEligible(entry)) + .filter((entry) => automationHostRecoveryActions(entry).authority === 'update-server') + .map((entry) => entry.authorityLabel) + return [...new Set(labels)] +} + export function resolveAutomationCreateDestination( entry: AutomationHostCatalogEntry | null | undefined ): AutomationCreateDestinationResolution { diff --git a/src/renderer/src/components/automations/automation-external-target-match.test.ts b/src/renderer/src/components/automations/automation-external-target-match.test.ts index 71a7ff8fd4b..25cdaa65eb8 100644 --- a/src/renderer/src/components/automations/automation-external-target-match.test.ts +++ b/src/renderer/src/components/automations/automation-external-target-match.test.ts @@ -2,10 +2,7 @@ import { describe, expect, it } from 'vitest' import type { ExternalAutomationTarget } from '../../../../shared/automations-types' import { toSshExecutionHostId } from '../../../../shared/execution-host' import type { Repo } from '../../../../shared/repo-types' -import { - repoMatchesExternalAutomationTarget, - getExternalAutomationTargetForRepo -} from './automation-external-target-match' +import { repoMatchesExternalAutomationTarget } from './automation-external-target-match' function repo( overrides: Partial> = {} @@ -51,34 +48,3 @@ describe('repoMatchesExternalAutomationTarget', () => { ).toBe(false) }) }) - -describe('getExternalAutomationTargetForRepo', () => { - it('prefers an explicit executionHostId over a disagreeing connectionId', () => { - expect( - getExternalAutomationTargetForRepo( - repo({ connectionId: 'conn-1', executionHostId: toSshExecutionHostId('conn-2') }) - ) - ).toEqual({ type: 'ssh', connectionId: 'conn-2' }) - }) - - it('derives the ssh target from connectionId when executionHostId is unset', () => { - expect(getExternalAutomationTargetForRepo(repo({ connectionId: 'conn-1' }))).toEqual({ - type: 'ssh', - connectionId: 'conn-1' - }) - }) - - it('round-trips connection ids that need host-id encoding', () => { - expect(getExternalAutomationTargetForRepo(repo({ connectionId: 'user@host:22' }))).toEqual({ - type: 'ssh', - connectionId: 'user@host:22' - }) - }) - - it('returns the local target for a plain local repo and for non-ssh hosts', () => { - expect(getExternalAutomationTargetForRepo(repo())).toEqual({ type: 'local' }) - expect(getExternalAutomationTargetForRepo(repo({ executionHostId: 'runtime:env-1' }))).toEqual({ - type: 'local' - }) - }) -}) diff --git a/src/renderer/src/components/automations/automation-external-target-match.ts b/src/renderer/src/components/automations/automation-external-target-match.ts index 417453d8550..f544c324eb1 100644 --- a/src/renderer/src/components/automations/automation-external-target-match.ts +++ b/src/renderer/src/components/automations/automation-external-target-match.ts @@ -18,12 +18,3 @@ export function repoMatchesExternalAutomationTarget( } return repoHostId === toSshExecutionHostId(target.connectionId) } - -// Why: the manager target must name the repo's authoritative execution host, -// not a possibly-stale connectionId. -export function getExternalAutomationTargetForRepo( - repo: Pick -): ExternalAutomationTarget { - const parsed = parseExecutionHostId(getRepoExecutionHostId(repo)) - return parsed?.kind === 'ssh' ? { type: 'ssh', connectionId: parsed.targetId } : { type: 'local' } -} diff --git a/src/renderer/src/components/automations/automation-host-cache-controller.ts b/src/renderer/src/components/automations/automation-host-cache-controller.ts index 5e50c41a317..fda1e267ab6 100644 --- a/src/renderer/src/components/automations/automation-host-cache-controller.ts +++ b/src/renderer/src/components/automations/automation-host-cache-controller.ts @@ -71,6 +71,8 @@ export type AutomationHostQueryController = { handleAuthorityEvent: (event: AutomationAuthorityChangeEvent) => void /** Latest orphan count the authority reported, so the next catalog can show its orphan entry. */ authorityOrphanCount: (authority: StableAutomationAuthorityRef) => number | null + /** True once disposed. A disposed controller drops every event and refresh, so a mounted owner must replace it. */ + isDisposed: () => boolean dispose: () => void } @@ -130,6 +132,7 @@ export function createAutomationHostQueryController( }) let latest: AutomationHostCatalog | null = null let selected: string | null = null + let disposed = false const targetsFor = ( catalog: AutomationHostCatalog, @@ -161,8 +164,10 @@ export function createAutomationHostQueryController( } }) - // Subscribed here rather than by the page: an unsubscribed controller silently - // stops refreshing on writes, which looks like stale data, not a missing wire. + // Subscribed here by default: an unsubscribed controller silently stops + // refreshing on writes, which looks like stale data, not a missing wire. A + // caller that opts out with `eventTarget: null` (the React hook, whose + // StrictMode-safe lifecycle owns the subscription) takes that duty on itself. const eventTarget = options.eventTarget === undefined ? (globalThis.window ?? null) : options.eventTarget const unsubscribe = eventTarget @@ -211,7 +216,9 @@ export function createAutomationHostQueryController( } return newest?.orphanCount ?? null }, + isDisposed: () => disposed, dispose: () => { + disposed = true unsubscribe?.() invalidation.dispose() scheduler.dispose() diff --git a/src/renderer/src/components/automations/automation-host-cache.test.ts b/src/renderer/src/components/automations/automation-host-cache.test.ts index b68f3144607..f1d87df29f5 100644 --- a/src/renderer/src/components/automations/automation-host-cache.test.ts +++ b/src/renderer/src/components/automations/automation-host-cache.test.ts @@ -157,6 +157,18 @@ describe('automation host cache invalidation scope', () => { expect(cache.commit(desktopFence, { rows: [row('c')] })).toBe(true) }) + it('drops the payload at retirement, so a revived host refetches over showing old rows', () => { + const cache = createCache() + cache.commit(cache.beginRequest(RUNTIME_SELF), { rows: [row('b')], orphanCount: 3 }) + cache.evict(hostStableKey(RUNTIME_SELF)) + cache.beginRequest(RUNTIME_SELF) + const revived = cache.getByKey(hostStableKey(RUNTIME_SELF)) + expect(revived?.data).toEqual([]) + expect(revived?.fetchedAt).toBeNull() + expect(revived?.orphanCount).toBeNull() + expect(cache.freshness(RUNTIME_SELF)).toBe('missing') + }) + it('evicts a departed entry and caps the retired pool', () => { const cache = createCache({ retiredLimit: 1 }) cache.commit(cache.beginRequest(DESKTOP_SELF), { rows: [row('a')] }) diff --git a/src/renderer/src/components/automations/automation-host-cache.ts b/src/renderer/src/components/automations/automation-host-cache.ts index d8b3da1525b..2cf3e31ef0c 100644 --- a/src/renderer/src/components/automations/automation-host-cache.ts +++ b/src/renderer/src/components/automations/automation-host-cache.ts @@ -277,6 +277,13 @@ export function createAutomationHostCache( return } bump(record) + // Retirement exists to fence, not to display: the generations stay, the + // payload goes, or the retired pool holds up to 256 stale row arrays. A + // revived host reads as missing and refetches instead of showing old rows. + record.data = [] + record.fetchedAt = null + record.error = null + record.orphanCount = null records.delete(stableKey) retired.set(stableKey, record) // Insertion order is LRU order here: reviving re-inserts at the end. diff --git a/src/renderer/src/components/automations/automation-host-client.test.ts b/src/renderer/src/components/automations/automation-host-client.test.ts index 266fbe62f92..a8a3eea7da1 100644 --- a/src/renderer/src/components/automations/automation-host-client.test.ts +++ b/src/renderer/src/components/automations/automation-host-client.test.ts @@ -1,10 +1,10 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import type { Automation, AutomationCreateInput } from '../../../../shared/automations-types' import { - createAutomationForTarget, listAutomationRunsForTarget, listAutomationsForTarget, runAutomationNowForTarget, + toRuntimeAutomationCreateInput, updateAutomationForTarget } from './automation-host-client' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' @@ -127,7 +127,7 @@ describe('automation host client', () => { ) }) - it('uses an exact machine selector for an existing runtime workspace', async () => { + it('encodes exact machine selectors for the create wire input', () => { const automation = makeAutomation({ workspaceMode: 'existing', workspaceId: 'repo-1::/srv/orca' @@ -146,19 +146,15 @@ describe('automation host client', () => { rrule: automation.rrule, dtstart: automation.dtstart } - vi.mocked(callRuntimeRpc).mockResolvedValueOnce({ automation }) - await createAutomationForTarget(input) - - expect(callRuntimeRpc).toHaveBeenCalledWith( - { kind: 'environment', environmentId: 'gpu' }, - 'automation.create', - expect.objectContaining({ - repo: 'id:repo-1', - workspace: 'id:repo-1::/srv/orca' - }), - { timeoutMs: 15_000 } - ) + expect(toRuntimeAutomationCreateInput(input)).toMatchObject({ + repo: 'id:repo-1', + workspace: 'id:repo-1::/srv/orca' + }) + // A per-run workspace states no workspace selector at all. + expect( + toRuntimeAutomationCreateInput({ ...input, workspaceMode: 'new_per_run', workspaceId: null }) + ).toMatchObject({ repo: 'id:repo-1', workspace: undefined }) }) it('updates and manually runs SSH-host automations through the remote server that listed them', async () => { diff --git a/src/renderer/src/components/automations/automation-host-client.ts b/src/renderer/src/components/automations/automation-host-client.ts index 52c2d85e341..2b7c3b7c102 100644 --- a/src/renderer/src/components/automations/automation-host-client.ts +++ b/src/renderer/src/components/automations/automation-host-client.ts @@ -66,11 +66,8 @@ export function getAutomationOwnerTarget( return getAutomationTargetFromHostId(automation.runContext?.hostId) } -export function getAutomationCreateTarget(input: AutomationCreateInput): AutomationHostTarget { - return getAutomationTargetFromHostId(input.runContext?.hostId) -} - -function toRuntimeAutomationCreateInput( +/** Renames the desktop input's target fields to the wire contract every authority speaks. */ +export function toRuntimeAutomationCreateInput( input: AutomationCreateInput ): RuntimeAutomationCreateInput { const { projectId, workspaceId, ...rest } = input @@ -125,17 +122,6 @@ export async function listAutomationRunsForTarget( return result.runs } -export async function createAutomationForTarget(input: AutomationCreateInput): Promise { - const target = getAutomationCreateTarget(input) - const result = await callRuntimeRpc<{ automation: Automation }>( - target, - 'automation.create', - toRuntimeAutomationCreateInput(input), - { timeoutMs: 15_000 } - ) - return result.automation -} - export async function updateAutomationForTarget( automation: Automation, updates: AutomationUpdateInput, diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.test.ts b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts index cc49fee491c..3c13a79ec85 100644 --- a/src/renderer/src/components/automations/automation-host-diagnostics.test.ts +++ b/src/renderer/src/components/automations/automation-host-diagnostics.test.ts @@ -345,15 +345,18 @@ describe('automation host request counters', () => { }) describe('unpooled capability probes', () => { - beforeEach(() => { + beforeEach(async () => { callRuntimeRpc.mockReset() getRuntimeEnvironmentStatus.mockReset() automationHostDiagnostics.reset() + // Confirmed capabilities are module-level and must not leak between tests. + ;(await import('./automation-scoped-list-client')).resetAutomationCapabilityProbes() }) - // The probe rides outside the four-slot pool and always re-fetches, so an - // instrument blind to it would report half the relay traffic a refresh costs. - it('counts the status.get probe every runtime-scoped list rides on', async () => { + // The probe rides outside the four-slot pool, so an instrument blind to it + // would under-report relay traffic. It dedupes per authority incarnation: + // both hosts of this authority ride on one status.get. + it('counts the status.get probe runtime-scoped lists ride on', async () => { const { cache } = harness() getRuntimeEnvironmentStatus.mockResolvedValue({ capabilities: [AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY] @@ -380,9 +383,9 @@ describe('unpooled capability probes', () => { const authority = automationHostDiagnostics.snapshot().byAuthority[authorityKeyOf(RUNTIME_AUTHORITY)] - expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) - // Two pooled list calls, and two round trips the pool never sees. - expect(authority).toMatchObject({ requests: 2, capabilityProbes: 2 }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(1) + // Two pooled list calls, and the one shared round trip the pool never sees. + expect(authority).toMatchObject({ requests: 2, capabilityProbes: 1 }) }) }) diff --git a/src/renderer/src/components/automations/automation-host-diagnostics.ts b/src/renderer/src/components/automations/automation-host-diagnostics.ts index 7428cf7d21a..b514c9c7986 100644 --- a/src/renderer/src/components/automations/automation-host-diagnostics.ts +++ b/src/renderer/src/components/automations/automation-host-diagnostics.ts @@ -25,8 +25,9 @@ export type AutomationHostKeyCounters = { legacyRequests: number /** * Unpooled `status.get` capability probes, which ride outside the four-slot - * pool and always re-fetch. Relay traffic is `requests + capabilityProbes`; - * the in-flight ceiling is stated over `requests` alone. + * pool; probes dedupe per authority incarnation, so this counts what was + * actually sent. Relay traffic is `requests + capabilityProbes`; the + * in-flight ceiling is stated over `requests` alone. */ capabilityProbes: number /** Callers that joined a request already in flight, keyed like `requests`. */ diff --git a/src/renderer/src/components/automations/automation-host-scale-gates.test.ts b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts index e85ed6d1ae5..5bec80e92fa 100644 --- a/src/renderer/src/components/automations/automation-host-scale-gates.test.ts +++ b/src/renderer/src/components/automations/automation-host-scale-gates.test.ts @@ -34,6 +34,7 @@ import { type AutomationHostFetchTarget } from './automation-host-scheduler' import { AUTOMATION_HOST_REQUEST_CONCURRENCY } from './automation-host-scheduler-queue' +import { resetAutomationCapabilityProbes } from './automation-scoped-list-client' const callRuntimeRpc = vi.fn() const getRuntimeEnvironmentStatus = vi.fn() @@ -254,7 +255,10 @@ function watchLongTasks(): () => number { } } -function harness(): { cache: AutomationHostCache; refresh: () => Promise } { +function harness(): { + cache: AutomationHostCache + refresh: (options?: { force?: boolean }) => Promise +} { const cache = createAutomationHostCache({ catalogGeneration: () => 0, connectionGeneration: () => 0 @@ -265,7 +269,7 @@ function harness(): { cache: AutomationHostCache; refresh: () => Promise } isVisible: () => true, scheduleRetry: () => () => {} }) - return { cache, refresh: () => scheduler.refresh(ALL_REFS.map(targetFor)) } + return { cache, refresh: (options) => scheduler.refresh(ALL_REFS.map(targetFor), options) } } function snapshot(): AutomationHostDiagnosticsSnapshot { @@ -278,6 +282,8 @@ function authorityCounters(snap: AutomationHostDiagnosticsSnapshot, index: numbe beforeEach(() => { automationHostDiagnostics.reset() + // Confirmed capabilities are module-level and must not leak between tests. + resetAutomationCapabilityProbes() wire = { inFlight: 0, maxInFlight: 0, @@ -384,28 +390,41 @@ describe('one refresh of 50 hosts carrying 1,000 automations', () => { }) describe('relay cost the request pool cannot see', () => { - // The probe is deliberate and unpooled, so the honest number for a 50-host - // refresh is requests plus probes — not the pooled count on its own. - it('counts one capability probe for every scoped list, and none for a legacy one', async () => { + // The probe is deliberate and unpooled, but it dedupes per authority + // incarnation: concurrent callers share the in-flight status.get and a + // confirmed capability is never re-asked, so a 50-host refresh probes each + // scoped authority once — not once per host. + it('counts one capability probe per scoped authority, and none for a legacy one', async () => { const { refresh } = harness() await refresh() const snap = snapshot() const scopedEntries = SCOPED_AUTHORITIES * ENTRIES_PER_AUTHORITY - expect(wire.probes).toBe(scopedEntries) - expect(snap.totals.capabilityProbes).toBe(scopedEntries) + expect(wire.probes).toBe(SCOPED_AUTHORITIES) + expect(snap.totals.capabilityProbes).toBe(SCOPED_AUTHORITIES) for (let index = 0; index < AUTHORITY_COUNT; index += 1) { - expect(authorityCounters(snap, index).capabilityProbes).toBe( - isLegacyAuthority(index) ? 0 : ENTRIES_PER_AUTHORITY - ) + expect(authorityCounters(snap, index).capabilityProbes).toBe(isLegacyAuthority(index) ? 0 : 1) } - // Scoped hosts cost two round trips each, so the pooled count alone reports - // barely half the traffic this refresh actually put on the relay. expect(snap.totals.requests + snap.totals.capabilityProbes).toBe( - scopedEntries * 2 + (AUTHORITY_COUNT - SCOPED_AUTHORITIES) + scopedEntries + SCOPED_AUTHORITIES + (AUTHORITY_COUNT - SCOPED_AUTHORITIES) ) }) + + // The cache is per incarnation, so even a forced second refresh — which + // re-fetches every list — costs no probes at all. + it('does not probe again on a later refresh of the same incarnations', async () => { + const { refresh } = harness() + + await refresh() + const probesAfterFirst = wire.probes + const scopedCallsAfterFirst = wire.scopedCalls + await refresh({ force: true }) + + expect(probesAfterFirst).toBe(SCOPED_AUTHORITIES) + expect(wire.probes).toBe(probesAfterFirst) + expect(wire.scopedCalls).toBeGreaterThan(scopedCallsAfterFirst) + }) }) describe('the commit fence under real concurrency', () => { diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts index c5e2efe1b07..e1bf8873e97 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.test.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.test.ts @@ -31,9 +31,11 @@ const ALL_CAPABILITIES = { ] } -beforeEach(() => { +beforeEach(async () => { callRuntimeRpc.mockReset() getRuntimeEnvironmentStatus.mockReset() + // Confirmed capabilities are module-level and must not leak between tests. + ;(await client()).resetAutomationCapabilityProbes() }) async function client() { @@ -133,6 +135,113 @@ describe('listScopedAutomations', () => { }) }) +describe('capability probe dedupe', () => { + it('shares one in-flight probe across concurrent calls to the same incarnation', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockImplementation(async () => { + await new Promise((resolve) => setTimeout(resolve, 0)) + return ALL_CAPABILITIES + }) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await Promise.all([ + listScopedAutomations(RUNTIME, { kind: 'self' }), + listScopedAutomations(RUNTIME, { kind: 'orphan' }) + ]) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(1) + }) + + it('never re-asks about a capability the incarnation already confirmed', async () => { + const { listScopedAutomations, updateAutomationForOwner } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ + automations: [], + items: [], + orphanCount: 0, + automation: { id: 'a1' } + }) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + await listScopedAutomations(RUNTIME, { kind: 'orphan' }) + // A different capability confirmed by the same status answer is also cached. + await updateAutomationForOwner(SSH_OWNER, 'a1', { enabled: false }) + // Mutations intentionally re-probe: owner fencing must not trust a positive + // answer after an in-place runtime replacement under the same pairing. + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + it('re-asks after a re-pair rather than trusting the old incarnation', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + await listScopedAutomations({ ...RUNTIME, pairingRevision: 5 }, { kind: 'self' }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + it('does not cache an absence, so an upgraded server recovers without a re-pair', async () => { + const { listScopedAutomations, AutomationHostScopeUnsupportedError } = await client() + getRuntimeEnvironmentStatus.mockResolvedValueOnce({ capabilities: [] }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toBeInstanceOf( + AutomationHostScopeUnsupportedError + ) + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).resolves.toBeTruthy() + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + // The fence depends on this: a server downgraded in place ignores + // `expectedOwner`, so a confirmation must not outlive its observation window. + it('re-asks once a confirmation ages out', async () => { + vi.useFakeTimers() + try { + const { listScopedAutomations, AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + vi.advanceTimersByTime(AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS) + await listScopedAutomations(RUNTIME, { kind: 'self' }) + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + } finally { + vi.useRealTimers() + } + }) + + it('does not cache a failed probe, so the next call retries the host', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockRejectedValueOnce(new Error('runtime_unavailable')) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).rejects.toThrow( + 'runtime_unavailable' + ) + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + await expect(listScopedAutomations(RUNTIME, { kind: 'self' })).resolves.toBeTruthy() + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(2) + }) + + it('bounds confirmed capability entries across retired environment incarnations', async () => { + const { listScopedAutomations } = await client() + getRuntimeEnvironmentStatus.mockResolvedValue(ALL_CAPABILITIES) + callRuntimeRpc.mockResolvedValue({ automations: [], items: [], orphanCount: 0 }) + + for (let index = 0; index < 32; index += 1) { + await listScopedAutomations( + { kind: 'runtime', environmentId: `env-${index}`, pairingRevision: 1 }, + { kind: 'self' } + ) + } + await listScopedAutomations( + { kind: 'runtime', environmentId: 'env-overflow', pairingRevision: 1 }, + { kind: 'self' } + ) + await listScopedAutomations( + { kind: 'runtime', environmentId: 'env-0', pairingRevision: 1 }, + { kind: 'self' } + ) + + expect(getRuntimeEnvironmentStatus).toHaveBeenCalledTimes(34) + }) +}) + describe('owner-fenced mutations', () => { it('sends the captured owner with the mutation', async () => { const { updateAutomationForOwner } = await client() diff --git a/src/renderer/src/components/automations/automation-scoped-list-client.ts b/src/renderer/src/components/automations/automation-scoped-list-client.ts index 3f649dcbdc0..ec44ac74ece 100644 --- a/src/renderer/src/components/automations/automation-scoped-list-client.ts +++ b/src/renderer/src/components/automations/automation-scoped-list-client.ts @@ -9,11 +9,7 @@ * that would attribute other hosts' automations to the selected one. */ -import { - callRuntimeRpc, - getRuntimeEnvironmentStatus, - type RuntimeClientTarget -} from '@/runtime/runtime-rpc-client' +import { callRuntimeRpc, type RuntimeClientTarget } from '@/runtime/runtime-rpc-client' import type { Automation, AutomationCreateInput, @@ -36,26 +32,24 @@ import type { AutomationOwnerPrecondition } from '../../../../shared/automation-owner-precondition' import { + toRuntimeAutomationCreateInput, + toRuntimeAutomationUpdateInput +} from './automation-host-client' +import { + assertAuthorityCapability, + assertOwnerFencingSupported, AUTOMATION_LIST_HOST_SCOPE_RUNTIME_CAPABILITY, AUTOMATION_LIST_HOST_SCOPE_UPDATE_REQUIRED_MESSAGE, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE, - type RuntimeCapability -} from '../../../../shared/protocol-version' -import { automationAuthorityCatalogKey } from './automation-host-catalog-types' -import { toRuntimeAutomationUpdateInput } from './automation-host-client' -import { automationHostDiagnostics } from './automation-host-diagnostics' + AutomationHostScopeUnsupportedError, + REQUEST_TIMEOUT_MS +} from './automation-capability-probe' -const REQUEST_TIMEOUT_MS = 15_000 - -export class AutomationHostScopeUnsupportedError extends Error { - readonly code = 'unsupported_host_scope' - - constructor(message: string) { - super(message) - this.name = 'AutomationHostScopeUnsupportedError' - } -} +export { + AutomationHostScopeUnsupportedError, + AUTHORITY_CAPABILITY_CONFIRMATION_TTL_MS, + AUTHORITY_CAPABILITY_CONFIRMATION_MAX, + resetAutomationCapabilityProbes +} from './automation-capability-probe' export class AutomationListResponseError extends Error { readonly code = 'invalid_response' @@ -119,41 +113,6 @@ async function callAuthority( }) } -/** - * Fails closed on a missing capability, but only on a *known* absence: an - * unreachable authority must classify as unavailable and retry, not as an old - * server the user is told to upgrade. - * - * The probe is counted here because it is counted nowhere else: it deliberately - * re-fetches on every call and rides outside the scheduler's four-slot pool, so - * an instrument that saw only pooled work would report half the relay traffic a - * 50-host refresh actually costs. - */ -async function assertAuthorityCapability( - authority: AutomationAuthorityRef, - capability: RuntimeCapability, - message: string -): Promise { - if (authority.kind !== 'runtime') { - return - } - automationHostDiagnostics.recordCapabilityProbe({ - authorityKey: automationAuthorityCatalogKey(authority) - }) - const status = await getRuntimeEnvironmentStatus(authority.environmentId, REQUEST_TIMEOUT_MS) - if (!status.capabilities?.includes(capability)) { - throw new AutomationHostScopeUnsupportedError(message) - } -} - -async function assertOwnerFencingSupported(authority: AutomationAuthorityRef): Promise { - await assertAuthorityCapability( - authority, - AUTOMATION_OWNER_FENCING_RUNTIME_CAPABILITY, - AUTOMATION_OWNER_FENCING_UPDATE_REQUIRED_MESSAGE - ) -} - function validated(raw: unknown, selector: AutomationListScopeSelector): ScopedAutomationList { const validation = validateAutomationListResponse(raw, selector) if (!validation.ok) { @@ -328,11 +287,8 @@ export async function createAutomationForDestination( destination: AutomationDestination ): Promise { await assertOwnerFencingSupported(authority) - const { projectId, workspaceId, ...rest } = input const result = await callAuthority<{ automation: Automation }>(authority, 'automation.create', { - ...rest, - repo: projectId, - workspace: input.workspaceMode === 'existing' ? (workspaceId ?? undefined) : undefined, + ...toRuntimeAutomationCreateInput(input), destination }) return result.automation diff --git a/src/renderer/src/components/automations/automation-target-availability.test.ts b/src/renderer/src/components/automations/automation-target-availability.test.ts index 23264de4b53..2b94251144a 100644 --- a/src/renderer/src/components/automations/automation-target-availability.test.ts +++ b/src/renderer/src/components/automations/automation-target-availability.test.ts @@ -5,7 +5,6 @@ import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' import { getAutomationTargetAvailability } from './automation-target-availability' -import { getAutomationCreateAvailability } from './automation-create-admission' function makeAutomation(overrides: Partial = {}): Automation { return { @@ -88,21 +87,6 @@ function makeRuntimeStatus(overrides: Partial = {}): RuntimeStatu } describe('automation target availability', () => { - it('blocks create admission for a missing or degraded runtime', () => { - const target = { kind: 'environment' as const, environmentId: 'env-1' } - expect(getAutomationCreateAvailability({ automationHostTarget: target }).reason).toBe( - 'runtime-checking' - ) - expect( - getAutomationCreateAvailability({ - automationHostTarget: target, - runtimeStatusByEnvironmentId: new Map([ - ['env-1', { status: makeRuntimeStatus({ graphStatus: 'unavailable' }), checkedAt: 1 }] - ]) - }).reason - ).toBe('runtime-unavailable') - }) - it('allows local automations with an available existing workspace', () => { expect( getAutomationTargetAvailability({ diff --git a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts index 6b71a2364e2..61d62d03f0f 100644 --- a/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts +++ b/src/renderer/src/components/automations/automations-page-runtime-fixtures.ts @@ -1,3 +1,4 @@ +import type { Automation } from '../../../../shared/automations-types' import type { ProjectHostSetup } from '../../../../shared/project-types' import type { Repo } from '../../../../shared/repo-types' import type { Worktree } from '../../../../shared/worktree/types' @@ -5,6 +6,18 @@ import type { Worktree } from '../../../../shared/worktree/types' export const RUNTIME_REPO_ID = 'repo-2' export const RUNTIME_WORKSPACE_ID = 'workspace-2' +/** Builds the host-scoped response used by the page harness. */ +export function selfScopedList(automations: Automation[]): Record { + return { + automations, + items: automations.map((automation) => ({ + automationId: automation.id, + selector: { kind: 'self' } + })), + orphanCount: 0 + } +} + type RuntimeFixtureMocks = { state: Record repoMap: Map diff --git a/src/renderer/src/components/automations/automations-page-test-harness.tsx b/src/renderer/src/components/automations/automations-page-test-harness.tsx index c7d3b8ac158..815338927c6 100644 --- a/src/renderer/src/components/automations/automations-page-test-harness.tsx +++ b/src/renderer/src/components/automations/automations-page-test-harness.tsx @@ -12,7 +12,7 @@ * guarantees. */ -import { act, type ReactNode } from 'react' +import { act, createElement, StrictMode, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' import { afterEach, beforeEach, type Mock, vi } from 'vitest' import type { Automation, AutomationRun } from '../../../../shared/automations-types' @@ -25,10 +25,12 @@ import type { AutomationHostCatalogView } from './use-automation-host-catalog' import type { AutomationCreateDestinationControl } from './use-automation-create-destination' import type { ExternalAutomationListEntry } from './external-automation-list-entries' import type { AutomationListRow } from './automation-list-row-identity' +import { resetAutomationCapabilityProbes } from './automation-scoped-list-client' import { addRuntimeProject as addRuntimeProjectFixture, RUNTIME_REPO_ID as RUNTIME_REPO_ID_FIXTURE, - RUNTIME_WORKSPACE_ID as RUNTIME_WORKSPACE_ID_FIXTURE + RUNTIME_WORKSPACE_ID as RUNTIME_WORKSPACE_ID_FIXTURE, + selfScopedList } from './automations-page-runtime-fixtures' export const RUNTIME_REPO_ID = RUNTIME_REPO_ID_FIXTURE @@ -69,6 +71,7 @@ export type ListPanelProps = { toggleAutomation: (row: AutomationListRow) => void requestDeleteAutomation: (row: AutomationListRow) => void openCreateDialog: () => void + canCreateAutomation: boolean } export type DetailPaneProps = { @@ -309,18 +312,6 @@ export const RUNTIME_SELF_FILTER = { } } -/** The scoped-list shape every self-owned host answers with. */ -function selfScopedList(automations: Automation[]): Record { - return { - automations, - items: automations.map((automation) => ({ - automationId: automation.id, - selector: { kind: 'self' } - })), - orphanCount: 0 - } -} - /** The local authority's automation RPC surface, served from the same programmable * `api.automations` mocks; an environment target answers from `runtimeHost()` state. */ async function answerAutomationRpc( @@ -379,7 +370,7 @@ export function scopedList(automations: Automation[]): void { const roots: Root[] = [] -export async function renderPage(): Promise<{ +export async function renderPage(options?: { strict?: boolean }): Promise<{ container: HTMLDivElement rerender: () => Promise }> { @@ -389,7 +380,12 @@ export async function renderPage(): Promise<{ roots.push(root) const rerender = async (): Promise => { await act(async () => { - root.render() + // Strict mounts double-invoke effects the way the dev app does, which is + // where a dispose-without-revive lifecycle bug becomes visible. + const page = options?.strict + ? createElement(StrictMode, null, createElement(AutomationsPage)) + : createElement(AutomationsPage) + root.render(page) }) } await rerender() @@ -434,6 +430,8 @@ export function installAutomationsPageHarness(): void { beforeEach(() => { globalThis.IS_REACT_ACT_ENVIRONMENT = true vi.clearAllMocks() + // Confirmed capabilities are module-level and must not leak between tests. + resetAutomationCapabilityProbes() // A prior test's wholesale mockImplementation must not leak forward. mocks.callRuntimeRpc.mockReset() mocks.callRuntimeRpc.mockImplementation(answerAutomationRpc) diff --git a/src/renderer/src/components/automations/use-automation-host-catalog.ts b/src/renderer/src/components/automations/use-automation-host-catalog.ts index 3d358e44584..bd7cd8dbb67 100644 --- a/src/renderer/src/components/automations/use-automation-host-catalog.ts +++ b/src/renderer/src/components/automations/use-automation-host-catalog.ts @@ -5,6 +5,7 @@ import { getLocalExecutionHostLabel } from '../../../../shared/execution-host' import type { AutomationHostFilter } from '../../../../shared/automation-host-filter' import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref' import { buildAutomationHostCatalog } from './automation-host-catalog' +import { subscribeAutomationHostInvalidation } from './automation-host-invalidation-window-events' import { buildAutomationHostCatalogSource } from './automation-host-catalog-source' import { automationHostLoadCounts, @@ -100,20 +101,42 @@ export function useAutomationHostCatalog( const repoTables = useMemo(() => groupReposByAutomationAuthority(repos), [repos]) const repoTablesRef = useRef(repoTables) repoTablesRef.current = repoTables - const [controller] = useState(() => - createAutomationHostQueryController({ - // Scoped to the answering authority: another host's identically named repo - // is not evidence about this one, and its absence is not evidence either. - legacyPartitionContext: (authority) => - automationAuthorityPartitionContext(repoTablesRef.current, authority) - }) + const makeController = useCallback( + () => + createAutomationHostQueryController({ + // Scoped to the answering authority: another host's identically named repo + // is not evidence about this one, and its absence is not evidence either. + legacyPartitionContext: (authority) => + automationAuthorityPartitionContext(repoTablesRef.current, authority), + // Wired by the lifecycle effect below instead: a controller built during + // render must hold no window subscription, or StrictMode's doubled + // initializer leaks a live listener per mount. + eventTarget: null + }), + [] ) + const [controller, setController] = useState(makeController) + // Create-in-render, dispose-in-cleanup is asymmetric under StrictMode's + // simulated unmount: the cleanup disposes the only controller, and a disposed + // controller silently drops every write invalidation — the list then never + // shows a create until the app reloads. The effect owns the whole lifecycle: + // it replaces a disposed controller and unsubscribes before disposing. + useEffect(() => { + if (controller.isDisposed()) { + setController(makeController()) + return + } + const unsubscribe = subscribeAutomationHostInvalidation(controller.handleAuthorityEvent) + return () => { + unsubscribe() + controller.dispose() + } + }, [controller, makeController]) const [cacheVersion, setCacheVersion] = useState(0) useEffect(() => { return controller.cache.subscribe(() => setCacheVersion((version) => version + 1)) }, [controller]) - useEffect(() => () => controller.dispose(), [controller]) const orphanCount = useCallback( (authority: StableAutomationAuthorityRef) => controller.authorityOrphanCount(authority), diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 7febb60f14d..43b17210bd5 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -15412,7 +15412,6 @@ "rowActions": "Automation actions", "tableLastRun": "Last run", "noListMatches": "No automations match.", - "destinationUnavailable": "The selected automation destination is not ready. Reconnect it and try again.", "destinationProjectUnavailable": "Choose a project owned by the selected automation destination." }, "AutomationsPageSkeleton": { @@ -15676,7 +15675,8 @@ "unavailable": "That host cannot hold a new automation yet. Choose another host to create this one on.", "stale": "{host} changed while this form was open. Choose it again before saving.", "projectMismatch": "That project is not on {host}. Choose a project on that host, or another host.", - "noProjects": "No projects are set up on {host}. Add one there, or choose another host." + "noProjects": "No projects are set up on {host}. Add one there, or choose another host.", + "updateRequired": "Update the Orca server on {hosts} to create automations there." } }, "agent": {