diff --git a/frontend/src/features/workflows/api.test.tsx b/frontend/src/features/workflows/api.test.tsx new file mode 100644 index 000000000..cab7cf476 --- /dev/null +++ b/frontend/src/features/workflows/api.test.tsx @@ -0,0 +1,407 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import type { ReactNode } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { GetApiV1TenantsTidWorkflowsWfidRuns200, WorkflowRun } from '@/api/generated.schemas' +import { + WORKFLOW_RUN_POLL_MS, + mergeRunPage, + mergeRunRead, + runPollInterval, + runsPollInterval, + useWorkflowRun, + useWorkflowRuns, +} from '@/features/workflows/api' +import { TEST_TENANT_ID } from '@/test/cloud-handlers' +import { server } from '@/test/msw-server' +import { settleUntil } from '@/test/polling' + +const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' +const RUN_ID = '55555555-5555-5555-5555-555555555555' +const RUNS_URL = `/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}/runs` +const RUN_URL = `${RUNS_URL}/${RUN_ID}` + +/** A run row as the read endpoints return it; `updatedAt` orders competing reads. */ +function runFixture(overrides: Partial = {}): WorkflowRun { + return { + id: RUN_ID, + tenantId: TEST_TENANT_ID, + workflowId: WORKFLOW_ID, + snapshotId: 'snap-1', + name: '巡检运行', + workflowName: '安全审查', + status: 'running', + input: {}, + nodeStates: { 'agent-1': { status: 'running', startedAt: '2026-09-20T02:00:00.000Z' } }, + rounds: [], + error: '', + definitionSnapshot: null, + startedAt: '2026-09-20T02:00:00.000Z', + finishedAt: null, + cancelRequestedAt: null, + createdAt: '2026-09-20T02:00:00.000Z', + updatedAt: '2026-09-20T02:00:05.000Z', + ...overrides, + } +} + +/** The same run after it settled, with the node output the poll is meant to deliver. */ +function settledRun(): WorkflowRun { + return runFixture({ + status: 'succeeded', + finishedAt: '2026-09-20T02:00:10.000Z', + updatedAt: '2026-09-20T02:00:10.000Z', + nodeStates: { 'agent-1': { status: 'succeeded', output: '审查通过' } }, + }) +} + +/** A run-history page as the read endpoint returns it. */ +function pageFixture(items: WorkflowRun[]): GetApiV1TenantsTidWorkflowsWfidRuns200 { + return { items, nextCursor: '' } +} + +/** A fresh client per test, with retries off so a failed read surfaces at once. */ +function newQueryClient(): QueryClient { + return new QueryClient({ defaultOptions: { queries: { retry: false } } }) +} + +/** + * What the cache currently holds for each read the hooks poll. Tests read the + * cache rather than the hook result wherever a read must be rejected: the cache + * takes the read synchronously, while the observer hands the new value to React + * a macrotask later, which would let a stale read look as if it never landed. + */ +function cachedRun(queryClient: QueryClient): WorkflowRun | undefined { + return queryClient.getQueryState([RUN_URL])?.data +} + +/** The cached history page, as {@link cachedRun} reads the cached run. */ +function cachedPage(queryClient: QueryClient): GetApiV1TenantsTidWorkflowsWfidRuns200 | undefined { + return queryClient.getQueryState([RUNS_URL])?.data +} + +/** Mounts a hook under the query client, with no other provider it does not need. */ +function wrapperFor(queryClient: QueryClient) { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +afterEach(() => { + vi.useRealTimers() +}) + +describe('runPollInterval', () => { + it('polls a run that can still change and stops once it settles', () => { + expect(WORKFLOW_RUN_POLL_MS).toBe(5000) + expect(runPollInterval(undefined, false)).toBe(false) + for (const status of ['pending', 'running', 'awaiting_input'] as const) { + expect(runPollInterval(status, false)).toBe(WORKFLOW_RUN_POLL_MS) + } + for (const status of ['succeeded', 'failed', 'cancelled'] as const) { + expect(runPollInterval(status, false)).toBe(false) + } + }) + + it('keeps polling after a failed read so the next attempt can recover', () => { + expect(runPollInterval(undefined, true)).toBe(WORKFLOW_RUN_POLL_MS) + }) +}) + +describe('runsPollInterval', () => { + it('polls the history while any listed run can still change', () => { + expect(runsPollInterval(undefined, false)).toBe(false) + expect(runsPollInterval([], false)).toBe(false) + expect(runsPollInterval([settledRun(), runFixture()], false)).toBe(WORKFLOW_RUN_POLL_MS) + expect(runsPollInterval([settledRun(), { ...settledRun(), id: 'run-2' }], false)).toBe(false) + }) + + it('keeps polling after a failed read so the next attempt can recover', () => { + expect(runsPollInterval(undefined, true)).toBe(WORKFLOW_RUN_POLL_MS) + }) +}) + +describe('mergeRunRead', () => { + it('takes the first read as it comes', () => { + const first = runFixture() + + expect(mergeRunRead(undefined, first)).toBe(first) + }) + + it('never reopens a settled run with a read that says it is running again', () => { + const settled = settledRun() + // Newer by `updatedAt`, so only the settled rule can reject it: a run that + // reached a terminal status has no way back, whatever a read claims. + const late = runFixture({ status: 'running', updatedAt: '2026-09-20T02:00:20.000Z' }) + + expect(mergeRunRead(settled, late)).toBe(settled) + }) + + it('keeps the newer of two reads of a run that is still moving', () => { + const held = runFixture({ status: 'pending', updatedAt: '2026-09-20T02:00:05.000Z' }) + const next = runFixture({ status: 'running', updatedAt: '2026-09-20T02:00:06.000Z' }) + + expect(mergeRunRead(held, next)).toBe(next) + }) + + it('keeps the newer of two settled reads', () => { + const held = runFixture({ status: 'failed', updatedAt: '2026-09-20T02:00:10.000Z' }) + const next = runFixture({ status: 'succeeded', updatedAt: '2026-09-20T02:00:11.000Z' }) + + expect(mergeRunRead(held, next)).toBe(next) + }) + + it('treats a read whose timestamp does not parse as older', () => { + const held = runFixture({ status: 'running', updatedAt: '2026-09-20T02:00:10.000Z' }) + const next = runFixture({ status: 'running', updatedAt: 'not-a-timestamp' }) + + expect(mergeRunRead(held, next)).toBe(held) + }) + + it('keeps a node entry only the losing read carried', () => { + const held = runFixture({ + updatedAt: '2026-09-20T02:00:05.000Z', + nodeStates: { 'start-1': { status: 'succeeded', output: '收到输入' } }, + }) + const next = runFixture({ + updatedAt: '2026-09-20T02:00:06.000Z', + nodeStates: { 'agent-1': { status: 'running' } }, + }) + + expect(mergeRunRead(held, next)).toEqual({ + ...next, + nodeStates: { + 'agent-1': { status: 'running' }, + 'start-1': { status: 'succeeded', output: '收到输入' }, + }, + }) + }) +}) + +describe('mergeRunPage', () => { + it('follows the incoming page for membership and merges each run it returns', () => { + const settled = settledRun() + const held = pageFixture([settled, runFixture({ id: 'run-2' })]) + const next = pageFixture([ + runFixture({ status: 'running', updatedAt: '2026-09-20T02:00:00.000Z' }), + runFixture({ id: 'run-3' }), + ]) + + const merged = mergeRunPage(held, next) + + // The server decides which runs exist: the dropped row is gone and the new + // one is in, while the settled run keeps what the list already showed. + expect(merged.items.map((run) => run.id)).toEqual([RUN_ID, 'run-3']) + expect(merged.items[0]).toBe(settled) + expect(merged.items[1]).toEqual(next.items[1]) + expect(merged.nextCursor).toBe('') + }) +}) + +describe('useWorkflowRun', () => { + it('follows a run to its terminal status and then stops reading it', async () => { + // Only the query's interval is faked: the retries below flush real timers. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let reads = 0 + server.use( + http.get(RUN_URL, () => { + reads += 1 + return HttpResponse.json(reads === 1 ? runFixture() : settledRun()) + }), + ) + const { result } = renderHook(() => useWorkflowRun(TEST_TENANT_ID, WORKFLOW_ID, RUN_ID), { + wrapper: wrapperFor(newQueryClient()), + }) + + await settleUntil(() => expect(result.current.data?.status).toBe('running')) + expect(reads).toBe(1) + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + + await settleUntil(() => expect(result.current.data?.status).toBe('succeeded')) + expect(reads).toBe(2) + expect(result.current.data?.nodeStates).toEqual({ + 'agent-1': { status: 'succeeded', output: '审查通过' }, + }) + + // The terminal status cleared the interval: further ticks issue no request. + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS * 3)) + expect(reads).toBe(2) + }) + + it('keeps reading a run whose status this build does not know', async () => { + // A newer backend can add a status. Stopping on a value the viewer cannot + // classify would silently freeze a run the server is still advancing. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let reads = 0 + server.use( + http.get(RUN_URL, () => { + reads += 1 + return HttpResponse.json({ ...runFixture(), status: 'paused' }) + }), + ) + renderHook(() => useWorkflowRun(TEST_TENANT_ID, WORKFLOW_ID, RUN_ID), { + wrapper: wrapperFor(newQueryClient()), + }) + + await settleUntil(() => expect(reads).toBe(1)) + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + + await settleUntil(() => expect(reads).toBe(2)) + }) + + it('keeps polling after a failed read so the next attempt can recover', async () => { + // A transient failure is not evidence the run settled: the poll must + // survive it, or an error on the first read would freeze the page forever. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let reads = 0 + server.use( + http.get(RUN_URL, () => { + reads += 1 + if (reads === 1) return HttpResponse.json({ message: 'boom' }, { status: 500 }) + return HttpResponse.json(settledRun()) + }), + ) + const { result } = renderHook(() => useWorkflowRun(TEST_TENANT_ID, WORKFLOW_ID, RUN_ID), { + wrapper: wrapperFor(newQueryClient()), + }) + + await settleUntil(() => expect(result.current.isError).toBe(true)) + expect(result.current.data).toBeUndefined() + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + + await settleUntil(() => expect(result.current.data?.status).toBe('succeeded')) + expect(reads).toBe(2) + }) + + it('leaves the cached run alone when a read returns the same thing', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const queryClient = newQueryClient() + let reads = 0 + server.use( + http.get(RUN_URL, () => { + reads += 1 + return HttpResponse.json(runFixture()) + }), + ) + renderHook(() => useWorkflowRun(TEST_TENANT_ID, WORKFLOW_ID, RUN_ID), { + wrapper: wrapperFor(queryClient), + }) + await settleUntil(() => expect(reads).toBe(1)) + const first = cachedRun(queryClient) + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + await settleUntil(() => expect(reads).toBe(2)) + + // The identity is the contract: the detail page re-lays out the frozen graph + // whenever this object changes, so an unchanged read must not replace it. + expect(first).toBeDefined() + expect(cachedRun(queryClient)).toBe(first) + }) + + it('keeps what it already showed when a later read is stale', async () => { + const queryClient = newQueryClient() + let reads = 0 + server.use( + http.get(RUN_URL, () => { + reads += 1 + return HttpResponse.json(reads === 2 ? settledRun() : runFixture()) + }), + ) + const { result } = renderHook(() => useWorkflowRun(TEST_TENANT_ID, WORKFLOW_ID, RUN_ID), { + wrapper: wrapperFor(queryClient), + }) + await waitFor(() => expect(result.current.data?.status).toBe('running')) + + // A reconnect or an invalidation refetches the run; this one is answered by + // a copy that has not caught up. + await act(async () => { + await result.current.refetch() + }) + await waitFor(() => expect(result.current.data?.status).toBe('succeeded')) + const settled = cachedRun(queryClient) + await act(async () => { + await result.current.refetch() + }) + + // The stale read was answered and then dropped: the cache still holds the + // settled run, down to the object identity the canvas keys its layout on. + expect(reads).toBe(3) + expect(settled?.status).toBe('succeeded') + expect(cachedRun(queryClient)).toBe(settled) + }) + + it('replaces a cache entry it cannot read instead of merging into it', async () => { + // An entry this build did not produce (an older shape, a hand-written seed) + // has nothing the merge can order, so the read replaces it. + const queryClient = newQueryClient() + queryClient.setQueryData([RUN_URL], { unexpected: true }) + server.use(http.get(RUN_URL, () => HttpResponse.json(runFixture()))) + const { result } = renderHook(() => useWorkflowRun(TEST_TENANT_ID, WORKFLOW_ID, RUN_ID), { + wrapper: wrapperFor(queryClient), + }) + + await waitFor(() => expect(result.current.data?.status).toBe('running')) + }) +}) + +describe('useWorkflowRuns', () => { + it('refreshes the history while a listed run can still change and stops once it settles', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let reads = 0 + server.use( + http.get(RUNS_URL, () => { + reads += 1 + return HttpResponse.json(pageFixture([reads === 1 ? runFixture() : settledRun()])) + }), + ) + const { result } = renderHook(() => useWorkflowRuns(TEST_TENANT_ID, WORKFLOW_ID), { + wrapper: wrapperFor(newQueryClient()), + }) + + await settleUntil(() => expect(result.current.data?.[0]?.status).toBe('running')) + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + + await settleUntil(() => expect(result.current.data?.[0]?.status).toBe('succeeded')) + expect(reads).toBe(2) + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS * 3)) + expect(reads).toBe(2) + }) + + it('keeps a settled history row from flipping back on a stale page', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const queryClient = newQueryClient() + let reads = 0 + server.use( + http.get(RUNS_URL, () => { + reads += 1 + return HttpResponse.json(pageFixture([reads === 2 ? settledRun() : runFixture()])) + }), + ) + const { result } = renderHook(() => useWorkflowRuns(TEST_TENANT_ID, WORKFLOW_ID), { + wrapper: wrapperFor(queryClient), + }) + const firstRow = () => cachedPage(queryClient)?.items[0] + + await settleUntil(() => expect(firstRow()?.status).toBe('running')) + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + await settleUntil(() => expect(firstRow()?.status).toBe('succeeded')) + const settled = firstRow() + + // Polling stopped when the row settled; an invalidation brings one more + // read, answered by a copy that has not caught up. + await act(async () => { + await result.current.refetch() + }) + + // The history is a list of runs, not of reads: a row the list already + // showed as settled keeps that status, and its identity with it. + expect(reads).toBe(3) + expect(settled?.status).toBe('succeeded') + expect(firstRow()).toBe(settled) + }) +}) diff --git a/frontend/src/features/workflows/api.ts b/frontend/src/features/workflows/api.ts index 29e04d35e..88df80a81 100644 --- a/frontend/src/features/workflows/api.ts +++ b/frontend/src/features/workflows/api.ts @@ -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 { @@ -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' @@ -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. @@ -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. @@ -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, }) } diff --git a/frontend/src/features/workflows/editor/node-parameter-summary.test.tsx b/frontend/src/features/workflows/editor/node-parameter-summary.test.tsx index 7fe1f25a7..d501dbfd5 100644 --- a/frontend/src/features/workflows/editor/node-parameter-summary.test.tsx +++ b/frontend/src/features/workflows/editor/node-parameter-summary.test.tsx @@ -1,6 +1,7 @@ import { screen } from '@testing-library/react' import { describe, expect, it } from 'vitest' import type { + WorkflowAgentConfig, WorkflowIterationConfig, WorkflowLoopConfig, WorkflowNodeData, @@ -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( + , + ) + + expect(container.querySelectorAll('dt')).toHaveLength(0) + }) }) diff --git a/frontend/src/features/workflows/editor/node-parameter-summary.tsx b/frontend/src/features/workflows/editor/node-parameter-summary.tsx index 76509ffb1..77b1b0f3f 100644 --- a/frontend/src/features/workflows/editor/node-parameter-summary.tsx +++ b/frontend/src/features/workflows/editor/node-parameter-summary.tsx @@ -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'), diff --git a/frontend/src/features/workflows/run/run-detail-page.test.tsx b/frontend/src/features/workflows/run/run-detail-page.test.tsx index d62ae32a2..7129db4de 100644 --- a/frontend/src/features/workflows/run/run-detail-page.test.tsx +++ b/frontend/src/features/workflows/run/run-detail-page.test.tsx @@ -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' @@ -63,6 +65,7 @@ beforeEach(() => { }) afterEach(() => { + vi.useRealTimers() vi.restoreAllMocks() }) @@ -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: '巡检运行' }) @@ -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('审查通过') + }) }) diff --git a/frontend/src/features/workflows/run/run-detail-page.tsx b/frontend/src/features/workflows/run/run-detail-page.tsx index ee2dfbb44..114d1de0e 100644 --- a/frontend/src/features/workflows/run/run-detail-page.tsx +++ b/frontend/src/features/workflows/run/run-detail-page.tsx @@ -92,10 +92,10 @@ function WorkflowRunDetailBody({
- {run.startedAt !== null && ( + {run.startedAt !== null && run.startedAt !== '' && ( )} - {run.finishedAt !== null && ( + {run.finishedAt !== null && run.finishedAt !== '' && ( )} diff --git a/frontend/src/features/workflows/run/run-inspector.test.tsx b/frontend/src/features/workflows/run/run-inspector.test.tsx index 5c28c8701..1e5ddbc1c 100644 --- a/frontend/src/features/workflows/run/run-inspector.test.tsx +++ b/frontend/src/features/workflows/run/run-inspector.test.tsx @@ -55,6 +55,14 @@ describe('RunInspector', () => { expect(screen.queryByRole('region', { name: '错误' })).not.toBeInTheDocument() }) + it('shows no execution times for a node the run never reached', () => { + // An inactive node still has a trace row, but its blank timestamps are not + // evidence of a start or finish, so the execution section stays hidden. + renderWithProviders() + + expect(screen.queryByRole('region', { name: '执行信息' })).not.toBeInTheDocument() + }) + it('notes the display name the trace labelled the node with', () => { const entry: RunNodeEntry = { status: 'succeeded', displayName: 'Agent 伪名' } renderWithProviders() diff --git a/frontend/src/features/workflows/run/run-node-state.test.ts b/frontend/src/features/workflows/run/run-node-state.test.ts index cb19d342e..468474b50 100644 --- a/frontend/src/features/workflows/run/run-node-state.test.ts +++ b/frontend/src/features/workflows/run/run-node-state.test.ts @@ -46,4 +46,16 @@ describe('parseRunNodeStates', () => { const states = parseRunNodeStates({ 'a-1': { status: 'idle' } }) expect(states['a-1']).toEqual({ status: 'idle' } satisfies RunNodeEntry) }) + + it('treats a blank timestamp as absent', () => { + const states = parseRunNodeStates({ + 'a-1': { status: 'inactive', startedAt: '', finishedAt: '' }, + 'a-2': { status: 'running', startedAt: '2026-09-20T10:00:00+08:00', finishedAt: '' }, + }) + expect(states['a-1']).toEqual({ status: 'inactive' } satisfies RunNodeEntry) + expect(states['a-2']).toEqual({ + status: 'running', + startedAt: '2026-09-20T10:00:00+08:00', + } satisfies RunNodeEntry) + }) }) diff --git a/frontend/src/features/workflows/run/run-node-state.ts b/frontend/src/features/workflows/run/run-node-state.ts index 9b3575a95..2f7de93f2 100644 --- a/frontend/src/features/workflows/run/run-node-state.ts +++ b/frontend/src/features/workflows/run/run-node-state.ts @@ -64,12 +64,12 @@ function readNodeEntry(value: unknown): RunNodeEntry | undefined { return undefined } const entry: RunNodeEntry = { status } - const startedAt = value['startedAt'] - if (typeof startedAt === 'string') { + const startedAt = readTimestamp(value['startedAt']) + if (startedAt !== undefined) { entry.startedAt = startedAt } - const finishedAt = value['finishedAt'] - if (typeof finishedAt === 'string') { + const finishedAt = readTimestamp(value['finishedAt']) + if (finishedAt !== undefined) { entry.finishedAt = finishedAt } const error = value['error'] @@ -91,6 +91,16 @@ function isRecord(value: unknown): value is Record { return value !== null && typeof value === 'object' && !Array.isArray(value) } +/** + * A timestamp worth showing, or `undefined` when the field is absent or blank. + * + * The backend writes `startedAt`/`finishedAt` as `""` for a node that never ran + * (or has not reached that instant), so the empty string is "unset", not a time. + */ +function readTimestamp(value: unknown): string | undefined { + return typeof value === 'string' && value !== '' ? value : undefined +} + /** Narrows a persisted status string to one this viewer can render. */ function isRunNodeStatus(value: string): value is GraphWorkflowNodeStatus { return (NODE_STATUSES as ReadonlySet).has(value) diff --git a/frontend/src/test/README.en.md b/frontend/src/test/README.en.md index c536a7c43..49434f603 100644 --- a/frontend/src/test/README.en.md +++ b/frontend/src/test/README.en.md @@ -18,11 +18,13 @@ Environment setup and test doubles shared by every test. The goal is that each t | `msw-server.ts` | MSW node server: the mock-domain handlers plus a baseline `GET /api/v1/me → 401` (every render starts signed out) and `GET /auth/providers → ['github']` (a production-shaped gateway; tests of the developer login override it). | | `cloud-handlers.ts` | Shared MSW doubles for the session probe and the `cloud-dev` tenant space returned by `/me/spaces`, plus fixtures such as `TEST_USER`. | | `navigation.ts` | `installFakeNavigation()`: swaps external navigation and new-tab opening, recording `destinations` and `openedTabs`; restored when the test finishes. | +| `polling.ts` | `settleUntil(check)`: retries an assertion while `setInterval` is faked (a polling test advances its query that way) — Testing Library's `waitFor` polls on `setInterval` itself and cannot run then. | +| `polling.test.ts` | Verifies that it retries until the assertion holds and rethrows the last failure once the attempts run out, which other polling tests rely on. | | `render.tsx` | `renderWithProviders` / `renderAtRoute` / `renderRoutes`: render entries that wire QueryClient, `SessionProvider`, Sidebar and (for the first two) `CurrentSpaceProvider`; `renderAtRoute` resolves the slug from the initial path with the app's `WORKSPACE_ROUTE_PATTERN` (`/w/:workspaceSlug`). | ## Dependency direction -Depends on `@/lib/api-client` (to swap its adapter), `@/lib/navigation` (to swap external navigation), `@/features/auth/session` and `vitest`. Production code **never** imports this directory. +Depends on `@/lib/api-client` (to swap its adapter), `@/lib/navigation` (to swap external navigation), `@/features/auth/session`, `@testing-library/react` and `vitest`. Production code **never** imports this directory. ## Conventions diff --git a/frontend/src/test/README.md b/frontend/src/test/README.md index 5930ae96c..ed185e5cd 100644 --- a/frontend/src/test/README.md +++ b/frontend/src/test/README.md @@ -18,11 +18,13 @@ | `msw-server.ts` | MSW node server:mock 域 handler 加上基线 `GET /api/v1/me → 401`(任何渲染默认未登录)与 `GET /auth/providers → ['github']`(生产形态的网关;开发者登录的测试会覆盖它)。 | | `cloud-handlers.ts` | 云流程共享 MSW 替身:会话探测及 `/me/spaces` 返回的 `cloud-dev` 租户空间,并提供 `TEST_USER` 等 fixture。 | | `navigation.ts` | `installFakeNavigation()`:替换外部跳转与新标签页打开,记录 `destinations` 与 `openedTabs`;测试结束自动还原。 | +| `polling.ts` | `settleUntil(check)`:在 `setInterval` 被伪造(轮询测试要推进查询)时重试断言——此时 Testing Library 的 `waitFor` 自己也在用 `setInterval`,无法工作。 | +| `polling.test.ts` | 验证重试到成立、以及用尽重试后抛出最后一次失败,其它轮询测试依赖这些行为。 | | `render.tsx` | `renderWithProviders` / `renderAtRoute` / `renderRoutes`:包好 QueryClient、`SessionProvider`、Sidebar 与(前两者)`CurrentSpaceProvider` 的渲染入口;`renderAtRoute` 用应用的 `WORKSPACE_ROUTE_PATTERN`(`/w/:workspaceSlug`)从初始路径解析 slug。 | ## 依赖方向 -依赖 `@/lib/api-client`(替换其 adapter)、`@/lib/navigation`(替换外部跳转)、`@/features/auth/session` 与 `vitest`。业务代码**禁止** import 本目录。 +依赖 `@/lib/api-client`(替换其 adapter)、`@/lib/navigation`(替换外部跳转)、`@/features/auth/session`、`@testing-library/react` 与 `vitest`。业务代码**禁止** import 本目录。 ## 约定 diff --git a/frontend/src/test/polling.test.ts b/frontend/src/test/polling.test.ts new file mode 100644 index 000000000..ddf8ffc0b --- /dev/null +++ b/frontend/src/test/polling.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, it } from 'vitest' +import { settleUntil } from '@/test/polling' + +describe('settleUntil', () => { + it('retries the assertion until it holds', async () => { + let calls = 0 + + await settleUntil(() => { + calls += 1 + expect(calls).toBeGreaterThanOrEqual(3) + }) + + // The first two attempts failed and were retried, not swallowed. + expect(calls).toBe(3) + }) + + it('rethrows the last failure once the attempts run out', async () => { + let calls = 0 + const never = (): void => { + calls += 1 + expect(calls).toBeGreaterThan(99) + } + + // The last failure is the one the caller sees, not a timeout of its own. + await expect(settleUntil(never, 2)).rejects.toThrow('expected 3 to be greater than 99') + // Three calls: the initial attempt plus the two retries. + expect(calls).toBe(3) + }) +}) diff --git a/frontend/src/test/polling.ts b/frontend/src/test/polling.ts new file mode 100644 index 000000000..9c41751f1 --- /dev/null +++ b/frontend/src/test/polling.ts @@ -0,0 +1,31 @@ +import { act } from '@testing-library/react' + +/** How long {@link settleUntil} waits between attempts, in milliseconds. */ +const RETRY_MS = 5 + +/** + * Waits for an assertion to hold while a query polls on a faked `setInterval`. + * + * Faking `setInterval` is how a polling test advances its query, but Testing + * Library's `waitFor` polls on `setInterval` too, so it cannot run alongside the + * fake clock. This helper retries the assertion on a real `setTimeout` instead, + * flushing React between attempts. + * + * @param check - Assertion that throws while its condition does not hold yet. + * @param attempts - Retries left before the last failure is rethrown. + * @throws The last failure `check` produced, once the attempts run out. + */ +export async function settleUntil(check: () => void, attempts = 50): Promise { + try { + check() + return + } catch (error) { + if (attempts <= 0) { + throw error + } + } + await act(async () => { + await new Promise((resolve) => setTimeout(resolve, RETRY_MS)) + }) + await settleUntil(check, attempts - 1) +}