mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 00:02:33 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/native-chat-current-main
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
+81
-4
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
+150
@@ -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),
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user