Skip to content
407 changes: 407 additions & 0 deletions frontend/src/features/workflows/api.test.tsx

Large diffs are not rendered by default.

211 changes: 209 additions & 2 deletions frontend/src/features/workflows/api.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import { replaceEqualDeep, useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
import type {
Error as ApiError,
GetApiV1TenantsTidWorkflowsWfidRuns200,
Workflow as CloudWorkflow,
WorkflowRun,
WorkflowRunNodeStates,
WorkflowRunStatus,
WorkflowSnapshot,
} from '@/api/generated.schemas'
import {
Expand All @@ -18,6 +21,8 @@ import {
putApiV1TenantsTidWorkflowsWfid,
putApiV1TenantsTidWorkflowsWfidSnapshotsSnapshotIdRestore,
} from '@/api/workflows/workflows'
import { asJsonRecord, isJsonRecord } from '@/features/workflows/runtime/json-record'
import { isTerminalRunStatus } from '@/features/workflows/runtime/types'
import { mutationHeaders, useIdempotencyKeys } from '@/features/spaces/api'
import type { ErrorType } from '@/lib/api-client'

Expand Down Expand Up @@ -308,11 +313,203 @@ export interface RestoreWorkflowSnapshotInput {
version: number
}

/**
* Poll interval while a run can still change.
*
* A workflow run publishes no space event, so nothing pushes its progress to an
* open page; a bounded poll is the honest stand-in until the backend owns one.
* The cadence matches the issue-run list's settling poll, which covers the same
* "nothing is published when a run settles" gap.
*/
export const WORKFLOW_RUN_POLL_MS = 5000

/**
* How often a run read polls, given the outcome of the last attempt.
*
* A settled run stops the poll: nothing can change it again, so re-reading it
* would only spend requests. A status this build does not recognise keeps
* polling on purpose — freezing on a value the viewer cannot classify would
* silently stop updating a run the server is still advancing. A failed read
* keeps polling too: the failure says nothing about whether the run settled,
* only that this attempt did not see it, so the next attempt must still happen.
*
* @param status - Status of the last successful read, or `undefined` when no read has succeeded yet.
* @param failed - Whether the last attempt failed instead of returning a run.
* @returns The interval in milliseconds, or `false` when there is nothing to keep up with.
*/
export function runPollInterval(
status: WorkflowRunStatus | undefined,
failed: boolean,
): number | false {
if (status === undefined) return failed ? WORKFLOW_RUN_POLL_MS : false
return isTerminalRunStatus(status) ? false : WORKFLOW_RUN_POLL_MS
}

/**
* How often the run history polls: while any listed run can still change.
*
* A failed read keeps polling for the same reason the single-run read does:
* the failure says nothing about whether the listed runs settled, so the next
* attempt must still happen.
*
* @param items - Runs of the last successful page, or `undefined` when no read has succeeded yet.
* @param failed - Whether the last attempt failed instead of returning a page.
* @returns The interval in milliseconds, or `false` when every listed run is settled.
*/
export function runsPollInterval(
items: WorkflowRun[] | undefined,
failed: boolean,
): number | false {
if (items === undefined) return failed ? WORKFLOW_RUN_POLL_MS : false
return items.some((run) => !isTerminalRunStatus(run.status)) ? WORKFLOW_RUN_POLL_MS : false
}

/**
* Merges a run read into the copy already held, so a late or stale response
* cannot undo a newer one.
*
* A run row is the evidence for what happened, and a read is only a snapshot of
* it: a poll can overlap a write, or be answered by a replica that has not caught
* up, and then arrive after a newer read already landed. Two rules make that
* harmless — a settled run is never reopened by an unsettled read, and between
* reads of the same kind the newer `updatedAt` wins. Node states are then merged
* node by node on top of the winning record, so a node the page is already
* showing is never blanked by a read that raced a partial write.
*
* @param previous - The run already cached, if any.
* @param next - The run the latest read returned.
* @returns The run to keep: one of the two inputs, or a copy of the winner
* carrying the merged node states.
*/
export function mergeRunRead(previous: WorkflowRun | undefined, next: WorkflowRun): WorkflowRun {
if (previous === undefined) return next
const keepNext = prefersNextRead(previous, next)
const winner = keepNext ? next : previous
const other = keepNext ? previous : next
const nodeStates = mergeNodeStates(winner.nodeStates, other.nodeStates)
return nodeStates === winner.nodeStates ? winner : { ...winner, nodeStates }
}

/** Whether `next` is the read the cache should keep. */
function prefersNextRead(previous: WorkflowRun, next: WorkflowRun): boolean {
const settled = isTerminalRunStatus(next.status)
if (settled !== isTerminalRunStatus(previous.status)) return settled
return runUpdatedAt(next) >= runUpdatedAt(previous)
}

/** The run's `updatedAt` as an epoch, so ordering does not lean on the server's string format. */
function runUpdatedAt(run: WorkflowRun): number {
const parsed = Date.parse(run.updatedAt)
return Number.isNaN(parsed) ? Number.NEGATIVE_INFINITY : parsed
}

/**
* Merges two node-state maps into the winning record's, node by node.
*
* The winner keeps its own entry for every node it knows, because its node
* states were written with the row the comparison chose. Entries only the other
* read carried are kept: node ids come from the run's frozen snapshot, so a node
* never disappears, and a read that raced a node's write must not blank what the
* page is already showing. The winner's map is returned unchanged when the other
* read had nothing extra, so an idle poll does not churn the cache.
*/
function mergeNodeStates(
winner: WorkflowRunNodeStates,
other: WorkflowRunNodeStates,
): WorkflowRunNodeStates {
const extra = Object.keys(other).filter((nodeId) => !(nodeId in winner))
if (extra.length === 0) return winner
const merged: WorkflowRunNodeStates = { ...winner }
for (const nodeId of extra) merged[nodeId] = other[nodeId]
return merged
}

/**
* Merges a run-history page into the one already held, so one run cannot read
* differently in the list and on its detail page.
*
* Membership follows the incoming page: the server decides which runs exist, and
* the page is capped, so keeping rows the server no longer returns would grow
* the list without bound. What each surviving run says about itself is merged
* with the same rule the detail page uses, which is what keeps the two surfaces
* agreeing.
*
* @param previous - The page already cached, if any.
* @param next - The page the latest read returned.
* @returns The page to keep.
*/
export function mergeRunPage(
previous: GetApiV1TenantsTidWorkflowsWfidRuns200 | undefined,
next: GetApiV1TenantsTidWorkflowsWfidRuns200,
): GetApiV1TenantsTidWorkflowsWfidRuns200 {
if (previous === undefined) return next
const held = new Map(previous.items.map((run) => [run.id, run]))
return { ...next, items: next.items.map((run) => mergeRunRead(held.get(run.id), run)) }
}

/**
* True when a value is a run row the merge can read.
*
* TanStack types `structuralSharing` over `unknown`, so its arguments need
* narrowing before the merge can take them. What it passes is what this module's
* own `queryFn` returned — never wire data parsed somewhere else — so the check
* only has to cover the fields the merge reads, not the whole row.
*/
function isMergeableRun(value: unknown): value is WorkflowRun {
const record = asJsonRecord(value)
if (record === undefined) return false
return (
typeof record['id'] === 'string' &&
typeof record['status'] === 'string' &&
typeof record['updatedAt'] === 'string' &&
isJsonRecord(record['nodeStates'])
)
}

/** True when a value is a run-history page whose rows the merge can read. */
function isMergeableRunPage(value: unknown): value is GetApiV1TenantsTidWorkflowsWfidRuns200 {
const record = asJsonRecord(value)
if (record === undefined) return false
const { items, nextCursor } = record
if (!Array.isArray(items) || typeof nextCursor !== 'string') return false
const rows: unknown[] = items
return rows.every(isMergeableRun)
}

/**
* The `structuralSharing` adapter for one run read.
*
* TanStack calls this as it writes the read into the cache, which is the only
* point where the comparison sees the cache's current value; merging after the
* fact could interleave with a concurrent read's write. A value this module did
* not produce is passed straight through, so an unexpected cache entry replaces
* itself rather than being dropped.
*
* A custom function replaces TanStack's default structural sharing rather than
* adding to it, so the merge is followed by `replaceEqualDeep` — the default —
* to keep the identity of an unchanged run. Without it every poll would hand the
* page a new object, and the frozen graph would be re-laid out every few seconds
* for a run that has not moved.
*/
function shareRunRead(previous: unknown, next: unknown): unknown {
if (!isMergeableRun(next)) return next
const held = isMergeableRun(previous) ? previous : undefined
return replaceEqualDeep(held, mergeRunRead(held, next))
}

/** The `structuralSharing` adapter for the run history, over {@link mergeRunPage}'s rule. */
function shareRunPage(previous: unknown, next: unknown): unknown {
if (!isMergeableRunPage(next)) return next
const held = isMergeableRunPage(previous) ? previous : undefined
return replaceEqualDeep(held, mergeRunPage(held, next))
}

/**
* A workflow's run history, newest first.
*
* The backend pages runs by row UUID, so the query fetches up to 100 rows and
* sorts by `createdAt` client-side.
* sorts by `createdAt` client-side. It keeps re-reading while a listed run is
* still moving, so the popover shows a run settling without being reopened.
*
* @param tenantId - Tenant the workflow belongs to.
* @param workflowId - Workflow whose runs to read.
Expand All @@ -331,12 +528,19 @@ export function useWorkflowRuns(tenantId: string | undefined, workflowId: string
),
enabled: !!tenantId && !!workflowId,
select: (page) => [...page.items].toSorted((a, b) => b.createdAt.localeCompare(a.createdAt)),
refetchInterval: (query) =>
runsPollInterval(query.state.data?.items, query.state.status === 'error'),
structuralSharing: shareRunPage,
})
}

/**
* One workflow run by id, including its frozen snapshot graph and node states.
*
* The read keeps refreshing while the run is still moving, so an open detail
* page follows a run from `pending` to its terminal status without a reload, and
* stops on its own once the run settles.
*
* @param tenantId - Tenant the run belongs to.
* @param workflowId - Workflow the run belongs to.
* @param runId - Run to read.
Expand All @@ -358,6 +562,9 @@ export function useWorkflowRun(
signal,
),
enabled: !!tenantId && !!workflowId && !!runId,
refetchInterval: (query) =>
runPollInterval(query.state.data?.status, query.state.status === 'error'),
structuralSharing: shareRunRead,
})
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'
import type {
WorkflowAgentConfig,
WorkflowIterationConfig,
WorkflowLoopConfig,
WorkflowNodeData,
Expand Down Expand Up @@ -118,4 +119,21 @@ describe('NodeParameterSummary', () => {
expect(container.querySelectorAll('dt')).toHaveLength(0)
expect(screen.queryByText('初始提示词')).not.toBeInTheDocument()
})

it('does not crash on an agent node saved under the executor-slot schema', () => {
// #154 ships `executorSlot` where this build still summarizes the legacy
// `executor` binding. The stale type cannot name that wire shape, so the
// cast reproduces the exact payload the summary must tolerate without a crash.
// oxlint-disable-next-line typescript/no-unsafe-type-assertion -- the executor-slot payload has no `executor`, which is precisely what the guard under test tolerates.
const agentConfig = {
schemaVersion: 1,
executorSlot: 'writer',
prompt: 'review the change',
} as unknown as WorkflowAgentConfig
const { container } = renderWithProviders(
<NodeParameterSummary data={nodeData({ kind: 'agent', agentConfig })} />,
)

expect(container.querySelectorAll('dt')).toHaveLength(0)
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,16 @@ function agentParameters(
if (config === undefined) {
return []
}
// A node saved under the executor-slot schema carries `executorSlot` instead of
// the legacy `executor` binding this build still summarizes. There is no model
// row to read from it, so leave the config section empty rather than crash on
// the missing `executor`; slot display belongs to the editor (#154).
const executor = config.executor
if (executor === undefined) {
return []
}
const role = capabilities.roles.find((choice) => choice.value === config.roleId)
const model = `${config.executor.agentCli} · ${config.executor.modelId}`
const model = `${executor.agentCli} · ${executor.modelId}`
return [
{
label: translate('workflows.inspector.field.role'),
Expand Down
58 changes: 57 additions & 1 deletion frontend/src/features/workflows/run/run-detail-page.test.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { fireEvent, screen, waitFor, within } from '@testing-library/react'
import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'
import { http, HttpResponse } from 'msw'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorkflowRun } from '@/api/generated.schemas'
import { WORKFLOW_RUN_POLL_MS } from '@/features/workflows/api'
import { WorkflowRunDetailPage } from '@/features/workflows/run/run-detail-page'
import { TEST_TENANT_ID, installCloudSpaceHandlers } from '@/test/cloud-handlers'
import { server } from '@/test/msw-server'
import { settleUntil } from '@/test/polling'
import { RUN_SNAPSHOT as SNAPSHOT } from '@/test/run-snapshot'
import { stubViewportGeometry } from '@/test/viewport'
import { renderAtRoute } from '@/test/render'
Expand Down Expand Up @@ -63,6 +65,7 @@ beforeEach(() => {
})

afterEach(() => {
vi.useRealTimers()
vi.restoreAllMocks()
})

Expand Down Expand Up @@ -94,6 +97,19 @@ describe('WorkflowRunDetailPage', () => {
expect(screen.getByText('在画布上选择一个节点查看详情')).toBeInTheDocument()
})

it('hides the started and finished times while the run has not set them', async () => {
renderRun(async () =>
HttpResponse.json(
runFixture({ status: 'pending', startedAt: '', finishedAt: '', nodeStates: {} }),
),
)

await screen.findByRole('heading', { name: '巡检运行' })
expect(screen.getByText('创建于', { exact: false })).toBeInTheDocument()
expect(screen.queryByText('开始于', { exact: false })).not.toBeInTheDocument()
expect(screen.queryByText('结束于', { exact: false })).not.toBeInTheDocument()
})

it('opens a read-only inspector when a node is clicked', async () => {
renderRun(async () => HttpResponse.json(runFixture()))
await screen.findByRole('heading', { name: '巡检运行' })
Expand All @@ -106,4 +122,44 @@ describe('WorkflowRunDetailPage', () => {
expect(within(inspector).getByRole('region', { name: '输出' })).toBeInTheDocument()
expect(within(inspector).queryByRole('button')).not.toBeInTheDocument()
})

it('follows a running run to its terminal status without a reload', async () => {
// Only the query's interval is faked, so the retries below flush real timers.
vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] })
let reads = 0
renderRun(async () => {
reads += 1
return HttpResponse.json(
reads === 1
? runFixture({
status: 'running',
finishedAt: null,
updatedAt: '2026-09-20T02:00:05.000Z',
nodeStates: { 'agent-1': { status: 'running' } },
})
: runFixture({
nodeStates: { 'agent-1': { status: 'succeeded', output: '审查通过' } },
}),
)
})

await settleUntil(() =>
expect(runCard('agent-1')).toHaveAttribute('aria-label', 'Agent 1: 运行中'),
)
expect(screen.getAllByText('运行中').length).toBeGreaterThan(0)

await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS))

await settleUntil(() =>
expect(runCard('agent-1')).toHaveAttribute('aria-label', 'Agent 1: 成功'),
)
// The node the run never reached still reads as unrun: the statuses are
// overlaid on the frozen snapshot, not on a re-read definition.
expect(runCard('start-1')).toHaveAttribute('aria-label', '开始: 未执行')

// The output the poll delivered is the node's evidence, one click away.
fireEvent.click(runCard('agent-1'))
const inspector = screen.getByRole('complementary', { name: '节点检视' })
expect(within(inspector).getByRole('region', { name: '输出' })).toHaveTextContent('审查通过')
})
})
Loading
Loading