Merge remote-tracking branch 'origin/main' into brennanb2025/native-chat-current-main

This commit is contained in:
Brennan Benson
2026-08-26 14:32:11 -07:00
27 changed files with 915 additions and 287 deletions
@@ -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(
<TooltipProvider>
<AutomationCreateDestinationField control={control(projects)} />
<AutomationCreateDestinationField control={control(projects, entries)} />
</TooltipProvider>
)
})
@@ -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()
})
})
@@ -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({
<SelectItem
key={entry.stableKey}
value={entry.stableKey}
disabled={!automationCreateHostEligible(entry)}
data-host-stable-key={entry.stableKey}
>
<span className="flex min-w-0 items-center gap-2">
@@ -91,6 +97,19 @@ export function AutomationCreateDestinationField({
)}
</p>
)}
{updateRequiredAuthorities.length > 0 ? (
// A disabled row's tooltip is unreachable, so the repair is stated here.
<p
className="text-xs text-muted-foreground"
data-testid="automation-create-update-required"
>
{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(', '))}
</p>
) : null}
</Field>
)
}
@@ -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<string, unknown>),
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()
})
})
@@ -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<void> {
await act(async () => {
mocks.listPanel?.openCreateDialog()
})
await act(async () => {
mocks.editorDialog?.onDraftChange((current) => ({
...(current as Record<string, unknown>),
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<string, unknown>),
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'])
})
})
@@ -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)
})
})
@@ -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}
@@ -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<string>; confirmedAt: number }
>()
const inFlightCapabilityProbes = new Map<string, Promise<{ capabilities?: string[] }>>()
function capabilityProbeKey(authority: AutomationAuthorityRef & { kind: 'runtime' }): string {
return `${authority.environmentId}:${authority.pairingRevision}`
}
function rememberAuthorityCapabilities(
key: string,
capabilities: Set<string>,
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<void> {
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<void> {
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
}
@@ -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
)
}
@@ -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,
@@ -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 {
@@ -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<Pick<Repo, 'connectionId' | 'executionHostId'>> = {}
@@ -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'
})
})
})
@@ -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<Repo, 'connectionId' | 'executionHostId'>
): ExternalAutomationTarget {
const parsed = parseExecutionHostId(getRepoExecutionHostId(repo))
return parsed?.kind === 'ssh' ? { type: 'ssh', connectionId: parsed.targetId } : { type: 'local' }
}
@@ -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()
@@ -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')] })
@@ -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.
@@ -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 () => {
@@ -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<Automation> {
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,
@@ -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 })
})
})
@@ -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`. */
@@ -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<void> } {
function harness(): {
cache: AutomationHostCache
refresh: (options?: { force?: boolean }) => Promise<void>
} {
const cache = createAutomationHostCache({
catalogGeneration: () => 0,
connectionGeneration: () => 0
@@ -265,7 +269,7 @@ function harness(): { cache: AutomationHostCache; refresh: () => Promise<void> }
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', () => {
@@ -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()
@@ -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<TResult>(
})
}
/**
* 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<void> {
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<void> {
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<Automation> {
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
@@ -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> = {}): Automation {
return {
@@ -88,21 +87,6 @@ function makeRuntimeStatus(overrides: Partial<RuntimeStatus> = {}): 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({
@@ -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<string, unknown> {
return {
automations,
items: automations.map((automation) => ({
automationId: automation.id,
selector: { kind: 'self' }
})),
orphanCount: 0
}
}
type RuntimeFixtureMocks = {
state: Record<string, unknown>
repoMap: Map<string, unknown>
@@ -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<string, unknown> {
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<void>
}> {
@@ -389,7 +380,12 @@ export async function renderPage(): Promise<{
roots.push(root)
const rerender = async (): Promise<void> => {
await act(async () => {
root.render(<AutomationsPage />)
// 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)
@@ -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<AutomationHostQueryController>(() =>
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<AutomationHostQueryController>(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),
+2 -2
View File
@@ -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": {