mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 17:17:38 +00:00
Refresh automation run history before opening a run (#32788)
* Refresh automation run history before navigating to a run - Prevent stale cached runs from opening or reporting missing history - Show loading and preserve dashboard navigation during refresh * Update run history inputs after layout commit * Resolve workspace run links on their host and preserve cancellation * fix test
This commit is contained in:
@@ -0,0 +1,275 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { within } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import {
|
||||
api,
|
||||
installAutomationsPageHarness,
|
||||
mocks,
|
||||
renderPage,
|
||||
runtimeHost,
|
||||
scopedList,
|
||||
settleHostQueries
|
||||
} from './automations-page-test-harness'
|
||||
import { makeAutomation, makeRun } from './automations-page-fixtures'
|
||||
|
||||
vi.mock('@tanstack/react-virtual', async () => {
|
||||
const { createVirtualizerStub } = await import('./virtualizer-test-stub')
|
||||
return { useVirtualizer: createVirtualizerStub() }
|
||||
})
|
||||
|
||||
installAutomationsPageHarness()
|
||||
|
||||
async function loadCachedHistory() {
|
||||
const automation = makeAutomation()
|
||||
const oldRun = makeRun({ id: 'run-26', runNumber: 26 })
|
||||
api.automations.list.mockResolvedValue([automation])
|
||||
scopedList([automation])
|
||||
api.automations.listRuns.mockResolvedValue([oldRun])
|
||||
mocks.state.selectedAutomationId = automation.id
|
||||
mocks.state.setPendingAutomationRunNavigation = mocks.setPendingRunNavigation
|
||||
mocks.setPendingRunNavigation.mockImplementation((navigation) => {
|
||||
mocks.state.pendingAutomationRunNavigation = navigation
|
||||
})
|
||||
const page = await renderPage()
|
||||
await settleHostQueries()
|
||||
return { ...page, automation, oldRun }
|
||||
}
|
||||
|
||||
async function openDashboardRun(
|
||||
container: HTMLElement,
|
||||
newRun: AutomationRun,
|
||||
oldRun: AutomationRun
|
||||
) {
|
||||
const fresh = Promise.withResolvers<AutomationRun[]>()
|
||||
api.automations.listRuns.mockImplementation((args?: { limit?: number }) =>
|
||||
args?.limit ? Promise.resolve([newRun, oldRun]) : fresh.promise
|
||||
)
|
||||
await act(async () => within(container).getByRole('button', { name: 'Runs' }).click())
|
||||
await act(async () => {
|
||||
within(container)
|
||||
.getByRole('button', { name: new RegExp(newRun.title) })
|
||||
.click()
|
||||
})
|
||||
return fresh
|
||||
}
|
||||
|
||||
describe('AutomationsPage run navigation', () => {
|
||||
it('opens the automation overview without rereading its cached history', async () => {
|
||||
const { container, rerender, automation, oldRun } = await loadCachedHistory()
|
||||
const fresh = Promise.withResolvers<AutomationRun[]>()
|
||||
api.automations.listRuns.mockClear()
|
||||
api.automations.listRuns.mockReturnValue(fresh.promise)
|
||||
mocks.state.pendingAutomationRunNavigation = {
|
||||
automationId: automation.id,
|
||||
runId: null
|
||||
}
|
||||
|
||||
await rerender()
|
||||
await settleHostQueries()
|
||||
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
expect(within(container).getByTestId('detail-pane')).toBeTruthy()
|
||||
expect(mocks.detailPane?.selectedRuns).toEqual([oldRun])
|
||||
expect(api.automations.listRuns).not.toHaveBeenCalled()
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
await act(async () => fresh.resolve([oldRun]))
|
||||
})
|
||||
|
||||
it('opens a new dashboard run after refreshing an already-cached automation history', async () => {
|
||||
const { container, oldRun } = await loadCachedHistory()
|
||||
await act(async () => within(container).getByRole('button', { name: 'Nightly' }).click())
|
||||
expect(within(container).getByTestId('detail-run-count').textContent).toBe('1')
|
||||
expect(mocks.detailPane?.selectedRuns.map((run) => run.id)).toEqual([oldRun.id])
|
||||
const breadcrumb = within(container).getByRole('navigation', {
|
||||
name: 'Automations breadcrumb'
|
||||
})
|
||||
await act(async () => within(breadcrumb).getByRole('button', { name: 'Automations' }).click())
|
||||
const newRun = makeRun({
|
||||
id: 'run-27',
|
||||
runNumber: 27,
|
||||
title: 'Nightly run 27',
|
||||
outputSnapshot: {
|
||||
format: 'plain_text',
|
||||
content: 'Fresh run output',
|
||||
capturedAt: 20,
|
||||
truncated: false
|
||||
}
|
||||
})
|
||||
const fresh = await openDashboardRun(container, newRun, oldRun)
|
||||
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toMatchObject({ runId: newRun.id })
|
||||
expect(within(container).getByRole('status').textContent).toContain('Loading runs')
|
||||
expect(within(container).queryByText('Fresh run output')).toBeNull()
|
||||
await act(async () => fresh.resolve([newRun, oldRun]))
|
||||
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
expect(within(container).queryByRole('status')).toBeNull()
|
||||
expect(within(container).getByText('Fresh run output')).toBeTruthy()
|
||||
expect(within(container).getByRole('button', { name: 'Back to runs' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('keeps the runs dashboard open when the user backs out of a pending read', async () => {
|
||||
const { container, oldRun } = await loadCachedHistory()
|
||||
const newRun = makeRun({ id: 'run-27', title: 'Nightly run 27' })
|
||||
const fresh = await openDashboardRun(container, newRun, oldRun)
|
||||
const breadcrumb = within(container).getByRole('navigation', {
|
||||
name: 'Automations breadcrumb'
|
||||
})
|
||||
|
||||
await act(async () => within(breadcrumb).getByRole('button', { name: 'Runs' }).click())
|
||||
await act(async () => fresh.resolve([newRun, oldRun]))
|
||||
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
expect(within(container).getByRole('textbox', { name: 'Search runs…' })).toBeTruthy()
|
||||
expect(container.querySelector('[aria-label="Run context"]')).toBeNull()
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('shows loading for a workspace run link and cancels it with Escape', async () => {
|
||||
const { container, rerender, automation, oldRun } = await loadCachedHistory()
|
||||
const fresh = Promise.withResolvers<AutomationRun[]>()
|
||||
api.automations.listRuns.mockReturnValue(fresh.promise)
|
||||
mocks.state.pendingAutomationRunNavigation = {
|
||||
automationId: automation.id,
|
||||
runId: oldRun.id
|
||||
}
|
||||
|
||||
await rerender()
|
||||
|
||||
expect(within(container).getByRole('status').textContent).toContain('Loading runs')
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
await act(async () => fresh.resolve([oldRun]))
|
||||
|
||||
expect(within(container).getByRole('textbox', { name: 'Search runs…' })).toBeTruthy()
|
||||
expect(container.querySelector('[aria-label="Run context"]')).toBeNull()
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns to automation details when cancelling a workspace run link from there', async () => {
|
||||
const { container, rerender, automation, oldRun } = await loadCachedHistory()
|
||||
await act(async () => within(container).getByRole('button', { name: 'Nightly' }).click())
|
||||
const fresh = Promise.withResolvers<AutomationRun[]>()
|
||||
api.automations.listRuns.mockReturnValue(fresh.promise)
|
||||
mocks.state.pendingAutomationRunNavigation = {
|
||||
automationId: automation.id,
|
||||
runId: oldRun.id
|
||||
}
|
||||
await rerender()
|
||||
|
||||
expect(within(container).getByRole('status')).toBeTruthy()
|
||||
await act(async () => {
|
||||
window.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
||||
)
|
||||
})
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
await act(async () => fresh.resolve([oldRun]))
|
||||
|
||||
expect(within(container).getByTestId('detail-pane')).toBeTruthy()
|
||||
expect(container.querySelector('[aria-label="Run context"]')).toBeNull()
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reports a missing run only after the fresh history confirms its absence', async () => {
|
||||
const { rerender, automation, oldRun } = await loadCachedHistory()
|
||||
const fresh = Promise.withResolvers<AutomationRun[]>()
|
||||
api.automations.listRuns.mockReturnValue(fresh.promise)
|
||||
mocks.state.pendingAutomationRunNavigation = {
|
||||
automationId: automation.id,
|
||||
runId: 'pruned-run'
|
||||
}
|
||||
|
||||
await rerender()
|
||||
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.setPendingRunNavigation).not.toHaveBeenCalled()
|
||||
await act(async () => fresh.resolve([oldRun]))
|
||||
|
||||
expect(mocks.toastMessage).toHaveBeenCalledWith('Run history no longer available.')
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
})
|
||||
|
||||
it('shows the history error when opening a run from the dashboard fails', async () => {
|
||||
const { container, oldRun } = await loadCachedHistory()
|
||||
const newRun = makeRun({ id: 'run-27', title: 'Nightly run 27' })
|
||||
const fresh = await openDashboardRun(container, newRun, oldRun)
|
||||
|
||||
await act(async () => fresh.reject(new Error('Host disconnected')))
|
||||
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
expect(container.querySelector('[data-testid="detail-pane"]')).not.toBeNull()
|
||||
expect(mocks.detailPane?.selectedRunsNotice?.message).toContain('Host disconnected')
|
||||
})
|
||||
|
||||
it.each(['all', 'desktop'] as const)(
|
||||
'resolves a workspace run link on its host with colliding ids under the %s filter',
|
||||
async (filter) => {
|
||||
const remoteRun = makeRun({
|
||||
id: 'run-26',
|
||||
outputSnapshot: {
|
||||
format: 'plain_text',
|
||||
content: 'Output from the selected remote host',
|
||||
capturedAt: 20,
|
||||
truncated: false
|
||||
}
|
||||
})
|
||||
runtimeHost([makeAutomation({ name: 'Remote nightly' })], [remoteRun])
|
||||
if (filter === 'desktop') {
|
||||
mocks.state.automationHostFilter = {
|
||||
kind: 'host',
|
||||
host: { authority: { kind: 'desktop' }, selector: { kind: 'self' } }
|
||||
}
|
||||
}
|
||||
mocks.setAutomationHostFilter.mockImplementation((next) => {
|
||||
mocks.state.automationHostFilter = next
|
||||
})
|
||||
const { container, rerender, automation } = await loadCachedHistory()
|
||||
const fresh = Promise.withResolvers<{ runs: AutomationRun[] }>()
|
||||
const originalRpc = mocks.callRuntimeRpc.getMockImplementation()
|
||||
mocks.callRuntimeRpc.mockImplementation(
|
||||
(target: unknown, method: unknown, ...args: unknown[]) => {
|
||||
if (
|
||||
method === 'automation.runs' &&
|
||||
typeof target === 'object' &&
|
||||
target !== null &&
|
||||
'kind' in target &&
|
||||
target.kind === 'environment' &&
|
||||
'environmentId' in target &&
|
||||
target.environmentId === 'gpu'
|
||||
) {
|
||||
return fresh.promise
|
||||
}
|
||||
return originalRpc?.(target, method, ...args)
|
||||
}
|
||||
)
|
||||
await act(async () => {
|
||||
mocks.state.pendingAutomationRunNavigation = {
|
||||
automationId: automation.id,
|
||||
runId: remoteRun.id,
|
||||
hostId: 'runtime:gpu'
|
||||
}
|
||||
})
|
||||
await rerender()
|
||||
await rerender()
|
||||
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
expect(mocks.state.pendingAutomationRunNavigation).not.toBeNull()
|
||||
await act(async () => fresh.resolve({ runs: [remoteRun] }))
|
||||
|
||||
expect(mocks.state.pendingAutomationRunNavigation).toBeNull()
|
||||
expect(container.textContent).toContain('Output from the selected remote host')
|
||||
expect(mocks.toastMessage).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { useMemo } from 'react'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { LoadingSpinner } from '@/components/LoadingSpinner'
|
||||
import { AutomationEditorDialog } from './AutomationEditorDialog'
|
||||
import { AutomationsDetailPane } from './AutomationsDetailPane'
|
||||
import { AutomationsPageSkeleton } from './AutomationsPageSkeleton'
|
||||
@@ -116,18 +117,21 @@ export function AutomationsPageSurface({
|
||||
setPageView('run')
|
||||
}
|
||||
const showAutomationsList = (): void => {
|
||||
setPendingAutomationRunNavigation(null)
|
||||
setPageView('automations')
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setIsDetailOpen(false)
|
||||
setActivePaneTab('overview')
|
||||
}
|
||||
const showRunsDashboard = (): void => {
|
||||
setPendingAutomationRunNavigation(null)
|
||||
setPageView('runs')
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setIsDetailOpen(false)
|
||||
setActivePaneTab('overview')
|
||||
}
|
||||
const showAutomationDetails = (): void => {
|
||||
setPendingAutomationRunNavigation(null)
|
||||
setPageView('automations')
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setIsDetailOpen(true)
|
||||
@@ -228,6 +232,17 @@ export function AutomationsPageSurface({
|
||||
setPendingAutomationRunNavigation={setPendingAutomationRunNavigation}
|
||||
setIsDetailOpen={setIsDetailOpen}
|
||||
/>
|
||||
) : pageView === 'run' && store.pendingAutomationRunNavigation?.runId ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex min-h-0 flex-1 items-center justify-center gap-2 text-sm text-muted-foreground"
|
||||
>
|
||||
<LoadingSpinner className="size-4" />
|
||||
{translate(
|
||||
'auto.components.automations.AutomationRunsDashboard.loading',
|
||||
'Loading runs…'
|
||||
)}
|
||||
</div>
|
||||
) : pageView === 'run' && selectedAutomationRunPage ? (
|
||||
<AutomationRunDetailsPage
|
||||
automation={selected}
|
||||
|
||||
@@ -134,6 +134,7 @@ describe('shouldHandleAutomationDetailEscapeKey', () => {
|
||||
function makeEvent(
|
||||
overrides: Partial<{
|
||||
key: string
|
||||
defaultPrevented: boolean
|
||||
altKey: boolean
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
@@ -169,6 +170,10 @@ describe('shouldHandleAutomationDetailEscapeKey', () => {
|
||||
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ key: 'ArrowLeft' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores Escape already consumed while returning from a run', () => {
|
||||
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ defaultPrevented: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores modified or composing Escape keys', () => {
|
||||
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ metaKey: true }))).toBe(false)
|
||||
expect(shouldHandleAutomationDetailEscapeKey(makeEvent({ ctrlKey: true }))).toBe(false)
|
||||
|
||||
@@ -46,6 +46,7 @@ export function shouldHandleAutomationDetailTabArrowKey(event: {
|
||||
|
||||
export function shouldHandleAutomationDetailEscapeKey(event: {
|
||||
key: string
|
||||
defaultPrevented?: boolean
|
||||
altKey: boolean
|
||||
ctrlKey: boolean
|
||||
metaKey: boolean
|
||||
@@ -54,6 +55,7 @@ export function shouldHandleAutomationDetailEscapeKey(event: {
|
||||
target?: EventTarget | null
|
||||
}): boolean {
|
||||
if (
|
||||
event.defaultPrevented ||
|
||||
event.key !== 'Escape' ||
|
||||
Boolean(event.nativeEvent?.isComposing) ||
|
||||
event.altKey ||
|
||||
|
||||
@@ -6,7 +6,7 @@ import type {
|
||||
AutomationRun,
|
||||
AutomationUpdateInput
|
||||
} from '../../../../shared/automations-types'
|
||||
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import type { StableAutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import { parseExecutionHostId } from '../../../../shared/execution-host'
|
||||
import type { GlobalSettings } from '../../../../shared/global-settings-types'
|
||||
|
||||
@@ -52,7 +52,7 @@ export function getAutomationTargetFromHostId(
|
||||
}
|
||||
|
||||
export function getAutomationAuthorityTarget(
|
||||
authority: AutomationAuthorityRef
|
||||
authority: StableAutomationAuthorityRef
|
||||
): AutomationHostTarget {
|
||||
return authority.kind === 'runtime'
|
||||
? { kind: 'environment', environmentId: authority.environmentId }
|
||||
|
||||
@@ -65,6 +65,7 @@ export type ListPanelProps = {
|
||||
selectExternalKey: (entryKey: string | null) => void
|
||||
onOpenDetail: () => void
|
||||
onRefresh: () => void
|
||||
onOpenRuns: () => void
|
||||
runNow: (row: AutomationListRow) => void
|
||||
openEditDialog: (row: AutomationListRow) => void
|
||||
toggleAutomation: (row: AutomationListRow) => void
|
||||
@@ -199,6 +200,7 @@ vi.mock('./AutomationsListPanel', () => ({
|
||||
return (
|
||||
<div data-testid="list-panel">
|
||||
<button aria-label="Refresh automations" onClick={props.onRefresh} />
|
||||
<button onClick={props.onOpenRuns}>Runs</button>
|
||||
{props.sortedListItems.map((item) =>
|
||||
item.kind === 'local' ? (
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { getAutomationHostTargetKey, getAutomationTargetFromHostId } from './automation-host-client'
|
||||
import {
|
||||
getAutomationAuthorityTarget,
|
||||
getAutomationHostTargetKey,
|
||||
getAutomationTargetFromHostId
|
||||
} from './automation-host-client'
|
||||
import { ALL_AUTOMATION_HOSTS_FILTER } from '../../../../shared/automation-host-filter'
|
||||
import { useAppStore } from '@/store'
|
||||
import {
|
||||
getAutomationRunOwnerEnvironmentId,
|
||||
@@ -42,7 +47,6 @@ export function useAutomationRunPageState({
|
||||
isLoading,
|
||||
automationHostTargetKey,
|
||||
automations,
|
||||
selectedAutomationRuns,
|
||||
setSelectedAutomationRunPageId,
|
||||
setActivePaneTab,
|
||||
rerunRunIdsInFlight,
|
||||
@@ -50,22 +54,30 @@ export function useAutomationRunPageState({
|
||||
selectExternalKey,
|
||||
pageView,
|
||||
setPageView,
|
||||
setRunPageOrigin,
|
||||
isDetailOpen,
|
||||
setIsDetailOpen
|
||||
} = local
|
||||
const { selected, selectedRow } = list
|
||||
const { selectedRuns, selectedAutomationRunPage } = setup
|
||||
const {
|
||||
selected,
|
||||
selectedRow,
|
||||
visibleRows,
|
||||
externalAutomationEntries,
|
||||
hostCatalog,
|
||||
selectAutomationRow
|
||||
} = list
|
||||
const { selectedRuns, selectedRunsReady, selectedRunsNotice, selectedAutomationRunPage } = setup
|
||||
|
||||
useEffect(() => {
|
||||
if (!isDetailOpen || pendingAutomationRunNavigation) {
|
||||
return
|
||||
}
|
||||
const hasSelectedLocal = selectedRow?.key
|
||||
? list.visibleRows.some((row) => row.key === selectedRow.key)
|
||||
? visibleRows.some((row) => row.key === selectedRow.key)
|
||||
: selectedId !== null && selectedRow !== null
|
||||
const hasSelectedExternal =
|
||||
selectedExternalKey !== null &&
|
||||
list.externalAutomationEntries.some((entry) => entry.key === selectedExternalKey)
|
||||
externalAutomationEntries.some((entry) => entry.key === selectedExternalKey)
|
||||
if (hasSelectedLocal || hasSelectedExternal) {
|
||||
return
|
||||
}
|
||||
@@ -74,8 +86,8 @@ export function useAutomationRunPageState({
|
||||
setActivePaneTab('overview')
|
||||
}, [
|
||||
isDetailOpen,
|
||||
list.externalAutomationEntries,
|
||||
list.visibleRows,
|
||||
externalAutomationEntries,
|
||||
visibleRows,
|
||||
pendingAutomationRunNavigation,
|
||||
selectedExternalKey,
|
||||
selectedId,
|
||||
@@ -85,10 +97,17 @@ export function useAutomationRunPageState({
|
||||
setSelectedAutomationRunPageId
|
||||
])
|
||||
useEffect(() => {
|
||||
if (!pendingAutomationRunNavigation || isLoading) {
|
||||
if (!pendingAutomationRunNavigation) {
|
||||
return
|
||||
}
|
||||
const pending = pendingAutomationRunNavigation
|
||||
if (pending.runId && pageView !== 'run') {
|
||||
setRunPageOrigin(pageView === 'automations' && isDetailOpen ? 'automation' : 'runs')
|
||||
setPageView('run')
|
||||
}
|
||||
if (isLoading) {
|
||||
return
|
||||
}
|
||||
const pendingTargetKey = getAutomationHostTargetKey(
|
||||
getAutomationTargetFromHostId(pending.hostId)
|
||||
)
|
||||
@@ -116,8 +135,21 @@ export function useAutomationRunPageState({
|
||||
)
|
||||
return
|
||||
}
|
||||
if (selectedId !== pending.automationId) {
|
||||
setSelectedId(pending.automationId)
|
||||
const pendingRow = visibleRows.find(
|
||||
(row) =>
|
||||
row.automation.id === pending.automationId &&
|
||||
(!row.catalogRef ||
|
||||
getAutomationHostTargetKey(getAutomationAuthorityTarget(row.catalogRef.authority)) ===
|
||||
pendingTargetKey)
|
||||
)
|
||||
if (!pendingRow) {
|
||||
if (hostCatalog.resolution.effective.kind !== 'all') {
|
||||
hostCatalog.selectHost(ALL_AUTOMATION_HOSTS_FILTER)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (selectedId !== pending.automationId || selectedRow?.key !== pendingRow.key) {
|
||||
selectAutomationRow(pendingRow.key)
|
||||
setIsDetailOpen(true)
|
||||
return
|
||||
}
|
||||
@@ -129,20 +161,15 @@ export function useAutomationRunPageState({
|
||||
setPageView('automations')
|
||||
return
|
||||
}
|
||||
if (
|
||||
selectedAutomationRuns.notice &&
|
||||
selectedAutomationRuns.automationId === pending.automationId
|
||||
) {
|
||||
if (!selectedRunsReady) {
|
||||
return
|
||||
}
|
||||
if (selectedRunsNotice) {
|
||||
setIsDetailOpen(true)
|
||||
setActivePaneTab('runs')
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setPendingAutomationRunNavigation(null)
|
||||
if (pageView === 'run') {
|
||||
setPageView('runs')
|
||||
}
|
||||
return
|
||||
}
|
||||
if (selectedAutomationRuns.automationId !== pending.automationId) {
|
||||
setPageView('automations')
|
||||
return
|
||||
}
|
||||
setIsDetailOpen(true)
|
||||
@@ -169,18 +196,24 @@ export function useAutomationRunPageState({
|
||||
automations,
|
||||
automationHostTargetKey,
|
||||
isLoading,
|
||||
isDetailOpen,
|
||||
hostCatalog,
|
||||
selectAutomationRow,
|
||||
visibleRows,
|
||||
pendingAutomationRunNavigation,
|
||||
pageView,
|
||||
selectExternalKey,
|
||||
selectedAutomationRuns.automationId,
|
||||
selectedAutomationRuns.notice,
|
||||
selectedExternalKey,
|
||||
selectedId,
|
||||
selectedRow,
|
||||
selectedRuns,
|
||||
selectedRunsReady,
|
||||
selectedRunsNotice,
|
||||
setActivePaneTab,
|
||||
setIsDetailOpen,
|
||||
setPendingAutomationRunNavigation,
|
||||
setPageView,
|
||||
setRunPageOrigin,
|
||||
setSelectedAutomationRunPageId,
|
||||
setSelectedId
|
||||
])
|
||||
|
||||
@@ -11,7 +11,7 @@ export function useAutomationsPageEscape({
|
||||
store: AutomationsPageStoreState
|
||||
local: AutomationsPageLocalState
|
||||
}): void {
|
||||
const { activeModal, closeAutomationsPage } = store
|
||||
const { activeModal, closeAutomationsPage, setPendingAutomationRunNavigation } = store
|
||||
const {
|
||||
createOpen,
|
||||
deleteTarget,
|
||||
@@ -65,6 +65,7 @@ export function useAutomationsPageEscape({
|
||||
|
||||
if (pageView === 'run') {
|
||||
event.preventDefault()
|
||||
setPendingAutomationRunNavigation(null)
|
||||
setSelectedAutomationRunPageId(null)
|
||||
setPageView(runPageOrigin === 'automation' ? 'automations' : 'runs')
|
||||
setIsDetailOpen(runPageOrigin === 'automation')
|
||||
@@ -108,6 +109,7 @@ export function useAutomationsPageEscape({
|
||||
setActivePaneTab,
|
||||
setIsDetailOpen,
|
||||
setSelectedAutomationRunPageId,
|
||||
setPendingAutomationRunNavigation,
|
||||
setPageView
|
||||
])
|
||||
}
|
||||
|
||||
@@ -36,6 +36,8 @@ export function useAutomationsPageLocalState(store: AutomationsPageStoreState) {
|
||||
automationId: null,
|
||||
rowKey: null,
|
||||
ownerKey: null,
|
||||
navigation: null,
|
||||
reloadToken: -1,
|
||||
runs: [],
|
||||
notice: null
|
||||
})
|
||||
|
||||
@@ -26,13 +26,15 @@ export function useAutomationsPageSetupState({
|
||||
local: AutomationsPageLocalState
|
||||
list: AutomationsPageListState
|
||||
}) {
|
||||
const { repos, settings, projectHostSetups, worktreesByRepo } = store
|
||||
const { repos, settings, projectHostSetups, worktreesByRepo, pendingAutomationRunNavigation } =
|
||||
store
|
||||
const {
|
||||
automationYamlHooksByRepoKey,
|
||||
automationHookCheckPromisesRef,
|
||||
setAutomationYamlHooksByRepoKey,
|
||||
setupDecisionTouchedRef,
|
||||
selectedAutomationRuns,
|
||||
runHistoryReloadToken,
|
||||
draft,
|
||||
automationHostTargetKey,
|
||||
selectedAutomationRunPageId
|
||||
@@ -160,7 +162,12 @@ export function useAutomationsPageSetupState({
|
||||
selectedAutomationRuns.rowKey === selectedRow.key &&
|
||||
selectedAutomationRuns.ownerKey ===
|
||||
capturedAutomationOwnerKey(capturedAutomationOwner(capturedAutomationOwners, selectedRow.key))
|
||||
const selectedRunsNotice = selectedRunsMatchSelection ? selectedAutomationRuns.notice : null
|
||||
const selectedRunsReady =
|
||||
selectedRunsMatchSelection &&
|
||||
selectedAutomationRuns.reloadToken === runHistoryReloadToken &&
|
||||
(!pendingAutomationRunNavigation ||
|
||||
selectedAutomationRuns.navigation === pendingAutomationRunNavigation)
|
||||
const selectedRunsNotice = selectedRunsReady ? selectedAutomationRuns.notice : null
|
||||
const selectedRuns = useMemo(
|
||||
() =>
|
||||
selected && selectedRunsMatchSelection
|
||||
@@ -187,6 +194,7 @@ export function useAutomationsPageSetupState({
|
||||
getDraftSetupDecisionDefaultSignature,
|
||||
markSetupDecisionTouched,
|
||||
selectedRuns,
|
||||
selectedRunsReady,
|
||||
selectedRunsNotice,
|
||||
selectedAutomationRunPage,
|
||||
worktrees,
|
||||
|
||||
+121
-2
@@ -15,6 +15,7 @@ import type { AutomationOwnerRef } from '../../../../shared/automation-owner-ref
|
||||
import { ownerKey } from '../../../../shared/automation-owner-key'
|
||||
import { makeAutomation, makeRun } from './automations-page-fixtures'
|
||||
import { automationListRowKey, type AutomationListRow } from './automation-list-row-identity'
|
||||
import * as automationHostClient from './automation-host-client'
|
||||
import {
|
||||
useSelectedAutomationRunHistory,
|
||||
type SelectedAutomationRunHistoryInput,
|
||||
@@ -102,6 +103,8 @@ describe('useSelectedAutomationRunHistory', () => {
|
||||
automationId: 'a-1',
|
||||
rowKey: DESKTOP_ROW.key,
|
||||
ownerKey: 'uncaptured',
|
||||
navigation: null,
|
||||
reloadToken: 0,
|
||||
runs: [],
|
||||
notice: { message: 'web-01 is not connected', recovery: 'reconnect', severity: 'failure' }
|
||||
}
|
||||
@@ -133,7 +136,15 @@ describe('useSelectedAutomationRunHistory', () => {
|
||||
await render(input)
|
||||
|
||||
expect(settled(input)).toEqual([
|
||||
{ automationId: 'a-1', rowKey: DESKTOP_ROW.key, ownerKey: 'uncaptured', runs, notice: null }
|
||||
{
|
||||
automationId: 'a-1',
|
||||
rowKey: DESKTOP_ROW.key,
|
||||
ownerKey: 'uncaptured',
|
||||
navigation: null,
|
||||
reloadToken: 0,
|
||||
runs,
|
||||
notice: null
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
@@ -155,6 +166,8 @@ describe('useSelectedAutomationRunHistory', () => {
|
||||
automationId: 'a-1',
|
||||
rowKey: DESKTOP_ROW.key,
|
||||
ownerKey: 'uncaptured',
|
||||
navigation: null,
|
||||
reloadToken: 1,
|
||||
runs: [],
|
||||
notice: null
|
||||
})
|
||||
@@ -180,14 +193,120 @@ describe('useSelectedAutomationRunHistory', () => {
|
||||
expect(settled(input).at(-1)?.ownerKey).toBe(ownerKey(owner))
|
||||
})
|
||||
|
||||
it('keeps the requested overview host for a row without a captured owner', async () => {
|
||||
const runs = [makeRun()]
|
||||
const listRuns = vi
|
||||
.spyOn(automationHostClient, 'listAutomationRunsForTarget')
|
||||
.mockResolvedValueOnce(runs)
|
||||
mocks.dispatch.mockImplementationOnce(
|
||||
async (_context: unknown, _row: unknown, legacy: () => Promise<AutomationRun[]>) => ({
|
||||
ok: true,
|
||||
value: await legacy()
|
||||
})
|
||||
)
|
||||
const input = makeInput({
|
||||
navigation: { automationId: 'a-1', runId: null, hostId: 'runtime:gpu' }
|
||||
})
|
||||
|
||||
try {
|
||||
await render(input)
|
||||
|
||||
expect(listRuns).toHaveBeenCalledWith({ kind: 'environment', environmentId: 'gpu' }, 'a-1')
|
||||
expect(settled(input).at(-1)?.runs).toEqual(runs)
|
||||
} finally {
|
||||
listRuns.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('clears the pane when nothing is selected', async () => {
|
||||
const input = makeInput({ selected: null })
|
||||
|
||||
await render(input)
|
||||
|
||||
expect(settled(input)).toEqual([
|
||||
{ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null }
|
||||
{
|
||||
automationId: null,
|
||||
rowKey: null,
|
||||
ownerKey: null,
|
||||
navigation: null,
|
||||
reloadToken: 0,
|
||||
runs: [],
|
||||
notice: null
|
||||
}
|
||||
])
|
||||
expect(mocks.dispatch).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes each navigation and keeps its answer when navigation is consumed', async () => {
|
||||
mocks.dispatch.mockResolvedValue({ ok: true, value: [] })
|
||||
const input = makeInput()
|
||||
const rerender = await render(input)
|
||||
const navigation = { automationId: 'a-1', runId: 'run-27' }
|
||||
const runs = [makeRun({ id: navigation.runId })]
|
||||
mocks.dispatch.mockResolvedValue({ ok: true, value: runs })
|
||||
|
||||
await rerender({ ...input, navigation })
|
||||
|
||||
expect(settled(input).at(-1)?.runs).toEqual(runs)
|
||||
expect(settled(input).at(-1)?.navigation).toBe(navigation)
|
||||
expect(mocks.dispatch).toHaveBeenCalledTimes(2)
|
||||
|
||||
await rerender(input)
|
||||
expect(mocks.dispatch).toHaveBeenCalledTimes(2)
|
||||
|
||||
const repeatedNavigation = { ...navigation }
|
||||
await rerender({ ...input, navigation: repeatedNavigation })
|
||||
expect(mocks.dispatch).toHaveBeenCalledTimes(3)
|
||||
expect(settled(input).at(-1)?.navigation).toBe(repeatedNavigation)
|
||||
})
|
||||
|
||||
it('ignores a previous navigation that finishes after a newer navigation', async () => {
|
||||
const obsolete = Promise.withResolvers<{ ok: true; value: AutomationRun[] }>()
|
||||
const current = Promise.withResolvers<{ ok: true; value: AutomationRun[] }>()
|
||||
mocks.dispatch.mockReturnValueOnce(obsolete.promise).mockReturnValueOnce(current.promise)
|
||||
const firstNavigation = { automationId: 'a-1', runId: 'run-26' }
|
||||
const input = makeInput({ navigation: firstNavigation })
|
||||
const rerender = await render(input)
|
||||
const secondNavigation = { automationId: 'a-1', runId: 'run-27' }
|
||||
await rerender({ ...input, navigation: secondNavigation })
|
||||
|
||||
await act(async () => {
|
||||
current.resolve({ ok: true, value: [makeRun({ id: secondNavigation.runId })] })
|
||||
})
|
||||
await act(async () => {
|
||||
obsolete.resolve({ ok: true, value: [makeRun({ id: firstNavigation.runId })] })
|
||||
})
|
||||
|
||||
expect(settled(input)).toHaveLength(1)
|
||||
expect(settled(input)[0]?.navigation).toBe(secondNavigation)
|
||||
expect(settled(input)[0]?.runs[0]?.id).toBe('run-27')
|
||||
})
|
||||
|
||||
it('refreshes a reselected row after an intervening read was cancelled', async () => {
|
||||
const obsolete = Promise.withResolvers<{ ok: true; value: AutomationRun[] }>()
|
||||
const oldRun = makeRun({ id: 'run-26' })
|
||||
const freshRun = makeRun({ id: 'run-27' })
|
||||
mocks.dispatch
|
||||
.mockResolvedValueOnce({ ok: true, value: [oldRun] })
|
||||
.mockReturnValueOnce(obsolete.promise)
|
||||
.mockResolvedValueOnce({ ok: true, value: [freshRun, oldRun] })
|
||||
const input = makeInput()
|
||||
const rerender = await render(input)
|
||||
|
||||
await rerender({
|
||||
...input,
|
||||
selected: {
|
||||
...DESKTOP_ROW,
|
||||
key: automationListRowKey('host:desktop|self', 'a-2'),
|
||||
automation: makeAutomation({ id: 'a-2' })
|
||||
}
|
||||
})
|
||||
await rerender(input)
|
||||
await act(async () => {
|
||||
obsolete.resolve({ ok: true, value: [makeRun({ automationId: 'a-2' })] })
|
||||
})
|
||||
|
||||
expect(mocks.dispatch).toHaveBeenCalledTimes(3)
|
||||
expect(settled(input).at(-1)?.runs).toEqual([freshRun, oldRun])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
/**
|
||||
* Fetches only the selected automation's history, through its captured owner.
|
||||
*
|
||||
* The refetch policy is a string key rather than the objects the fetch reads:
|
||||
* the dispatch context is rebuilt on every cache reprojection, and keying an
|
||||
* effect on it re-asks the same host for the same history in a loop. What
|
||||
* actually decides the request is the row, the owner it was listed under, and
|
||||
* the host a navigation named — so those, and only those, are the key.
|
||||
*/
|
||||
/** Reads selected history through its captured owner, with a fresh answer for each run navigation. */
|
||||
|
||||
import { useEffect, useMemo, useRef } from 'react'
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { AutomationRun } from '../../../../shared/automations-types'
|
||||
import type { AutomationAuthorityRef } from '../../../../shared/automation-owner-ref'
|
||||
import { capturedAutomationOwner, capturedAutomationOwnerKey } from './automation-captured-owner'
|
||||
@@ -19,6 +11,8 @@ import {
|
||||
dispatchAutomationRunHistory
|
||||
} from './automation-row-action-dispatch'
|
||||
import {
|
||||
getAutomationAuthorityTarget,
|
||||
getAutomationHostTargetKey,
|
||||
getAutomationTargetFromHostId,
|
||||
listAutomationRunsForTarget,
|
||||
type AutomationHostTarget
|
||||
@@ -26,6 +20,7 @@ import {
|
||||
|
||||
export type SelectedAutomationRunNavigation = {
|
||||
automationId: string
|
||||
runId: string | null
|
||||
hostId?: string | null
|
||||
} | null
|
||||
|
||||
@@ -35,6 +30,9 @@ export type SelectedAutomationRunHistoryOutcome = {
|
||||
rowKey: string | null
|
||||
/** Which incarnation answered; a re-pairing makes the same row's answer stale. */
|
||||
ownerKey: string | null
|
||||
/** The exact navigation this read answered, including repeated clicks on the same run. */
|
||||
navigation: SelectedAutomationRunNavigation
|
||||
reloadToken: number
|
||||
runs: AutomationRun[]
|
||||
/**
|
||||
* Non-null when the host refused or failed the read. The runs are then unknown
|
||||
@@ -62,20 +60,19 @@ function navigationHostId(
|
||||
}
|
||||
|
||||
export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHistoryInput): void {
|
||||
const inputRef = useRef(input)
|
||||
inputRef.current = input
|
||||
const settledRequestRef = useRef<{
|
||||
fetchKey: string
|
||||
reloadToken: number
|
||||
authority: AutomationAuthorityRef
|
||||
} | null>(null)
|
||||
|
||||
const automationId = input.selected?.automation.id ?? null
|
||||
const rowKey = input.selected?.key ?? null
|
||||
const reloadToken = input.reloadToken
|
||||
const fetchKey =
|
||||
automationId && rowKey
|
||||
? [
|
||||
rowKey,
|
||||
capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)),
|
||||
navigationHostId(input.navigation, automationId) ?? ''
|
||||
].join('|')
|
||||
: ''
|
||||
const navigation =
|
||||
input.navigation?.runId && input.navigation.automationId === automationId
|
||||
? input.navigation
|
||||
: null
|
||||
const captured = input.selected
|
||||
? capturedAutomationOwner(input.context.capturedOwners, input.selected.key).owner
|
||||
: null
|
||||
@@ -84,40 +81,78 @@ export function useSelectedAutomationRunHistory(input: SelectedAutomationRunHist
|
||||
() => (authority?.kind === 'runtime' ? authority : { kind: 'desktop' }),
|
||||
[authority]
|
||||
)
|
||||
const navigationHost = automationId ? navigationHostId(input.navigation, automationId) : null
|
||||
const target = captured
|
||||
? getAutomationAuthorityTarget(captured.authority)
|
||||
: navigationHost
|
||||
? getAutomationTargetFromHostId(navigationHost)
|
||||
: input.selected
|
||||
? (input.legacyTarget(input.selected) ?? { kind: 'local' as const })
|
||||
: { kind: 'local' as const }
|
||||
const fetchKey =
|
||||
automationId && rowKey
|
||||
? [
|
||||
rowKey,
|
||||
capturedAutomationOwnerKey(capturedAutomationOwner(input.context.capturedOwners, rowKey)),
|
||||
getAutomationHostTargetKey(target)
|
||||
].join('|')
|
||||
: ''
|
||||
const inputRef = useRef({ ...input, target })
|
||||
useLayoutEffect(() => {
|
||||
inputRef.current = { ...input, target }
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
const { selected, context, legacyTarget, navigation, onSettled } = inputRef.current
|
||||
const { selected, context, onSettled, target: requestTarget } = inputRef.current
|
||||
if (!selected || !automationId || !rowKey) {
|
||||
onSettled({ automationId: null, rowKey: null, ownerKey: null, runs: [], notice: null })
|
||||
settledRequestRef.current = null
|
||||
onSettled({
|
||||
automationId: null,
|
||||
rowKey: null,
|
||||
ownerKey: null,
|
||||
navigation: null,
|
||||
reloadToken,
|
||||
runs: [],
|
||||
notice: null
|
||||
})
|
||||
return
|
||||
}
|
||||
const settled = settledRequestRef.current
|
||||
if (
|
||||
!navigation &&
|
||||
settled?.fetchKey === fetchKey &&
|
||||
settled.reloadToken === reloadToken &&
|
||||
settled.authority === rowAuthority
|
||||
) {
|
||||
return
|
||||
}
|
||||
settledRequestRef.current = null
|
||||
let cancelled = false
|
||||
const owner = capturedAutomationOwnerKey(
|
||||
capturedAutomationOwner(context.capturedOwners, rowKey)
|
||||
)
|
||||
const navigationHost = navigationHostId(navigation, automationId)
|
||||
const target = navigationHost
|
||||
? getAutomationTargetFromHostId(navigationHost)
|
||||
: (legacyTarget(selected) ?? { kind: 'local' })
|
||||
void dispatchAutomationRunHistory(
|
||||
context,
|
||||
{ rowKey, automationId },
|
||||
() => listAutomationRunsForTarget(target, automationId),
|
||||
() => listAutomationRunsForTarget(requestTarget, automationId),
|
||||
rowAuthority
|
||||
).then((result) => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
// A refused or failed read still settles: leaving the previous automation's
|
||||
// ID in place strands anything waiting on this one and shows its runs as zero.
|
||||
onSettled(
|
||||
result.ok
|
||||
? { automationId, rowKey, ownerKey: owner, runs: result.value, notice: null }
|
||||
: { automationId, rowKey, ownerKey: owner, runs: [], notice: result.notice }
|
||||
)
|
||||
settledRequestRef.current = { fetchKey, reloadToken, authority: rowAuthority }
|
||||
onSettled({
|
||||
automationId,
|
||||
rowKey,
|
||||
ownerKey: owner,
|
||||
navigation,
|
||||
reloadToken,
|
||||
runs: result.ok ? result.value : [],
|
||||
notice: result.ok ? null : result.notice
|
||||
})
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [automationId, rowAuthority, rowKey, fetchKey, reloadToken])
|
||||
}, [automationId, rowAuthority, rowKey, fetchKey, reloadToken, navigation])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user