diff --git a/api/openapi.json b/api/openapi.json index 5c5ae5cb..ffe57b47 100644 --- a/api/openapi.json +++ b/api/openapi.json @@ -5788,6 +5788,58 @@ "format": "uuid", "type": "string" }, + "latestRun": { + "additionalProperties": false, + "description": "List rows only. The workflow's most recent run (the order the run history walks), or null when it has never run.", + "nullable": true, + "properties": { + "createdAt": { + "format": "date-time", + "type": "string" + }, + "finishedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "type": "string" + }, + "name": { + "type": "string" + }, + "snapshotId": { + "format": "uuid", + "type": "string" + }, + "startedAt": { + "format": "date-time", + "nullable": true, + "type": "string" + }, + "status": { + "enum": [ + "pending", + "running", + "succeeded", + "failed", + "cancelled" + ], + "type": "string" + } + }, + "required": [ + "id", + "name", + "status", + "snapshotId", + "createdAt", + "startedAt", + "finishedAt" + ], + "type": "object" + }, "name": { "type": "string" }, diff --git a/docs/workflows/workflows.en.md b/docs/workflows/workflows.en.md index 9bcbf2a7..b7b20c00 100644 --- a/docs/workflows/workflows.en.md +++ b/docs/workflows/workflows.en.md @@ -23,6 +23,9 @@ and importing workflows from plugins. checks nothing about completeness: a half-written draft is a legitimate document. Only **publishing** judges whether a graph can execute, returning problems together with the node or slot at fault so the editor can mark them. A frozen published version is a graph Cloud has agreed to execute. + Switching tabs or leaving through an editor toolbar link waits for an outstanding draft save. If it fails, the + author may stay in the editor or discard unsaved changes and leave, avoiding both a stale canvas on return and a + failed draft that traps them in the editor. - **Versions are immutable.** Published versions (`workflow_snapshots`) are stored under a per-workflow increasing version number and are never modified or soft-deleted, so the graph a run rendered never drifts with the document. Publishing locks the workflow row, so two concurrent publishes never get the same version number. A snapshot's @@ -138,6 +141,23 @@ was named. A run Workspace belongs to the run that created it: `workspaces.workf with an IssueRun's `issue_run_id`, and the `maintenance` reserved for it in runtime control is recorded against the same run identity (see [Runtime control](../runtime/runtime-control.en.md)). +**The run history pages by `(created_at, id)`, newest first.** `GET .../workflows/:wfid/runs` returns the newest run +first, caps `limit` at 100, and takes the last row's WorkflowRun UUID as `after`: the server resolves that row's +`created_at` and continues strictly below `(created_at, id)`, so several runs created in the same instant are ordered by +`id` and paging neither repeats nor skips one. The cursor is only a row UUID and carries no timestamp; a cursor that +resolves to no row (another workflow's, or a purged one) answers an empty page instead of restarting from the top, and a +value that is not a UUID answers `400 invalid_cursor`. Every workflow **list** row carries `latestRun`, the first run +of that same order — `null` when the workflow has never run. It holds the status, snapshot and timestamps only; a history +row is what carries node states and inputs. The detail read carries no such field: a run starting must not rewrite every +read of the workflow resource, so a reader that wants the label asks the list or the history. + +The run-history page distinguishes loading, no runs, a retryable read failure, and missing permission. On initial entry, +workflow detail and run history are independent reads: a detail `403` also shows missing permission, retrying a transient +detail failure reads the detail again, and only `not_found` says the workflow is missing. A later paging or refresh +failure keeps the loaded history, and retry repeats the read that failed. History rows show start and finish +independently: a run cancelled before starting has no start time but still shows its finish time. Viewing history and +retrying a read never create a run. + **Cancellation is a request, not a transition.** `POST .../workflows/:wfid/runs/:rid/cancel` records the request on the run row and answers `202` with the instant it was recorded; repeating the request (even under a fresh idempotency key) returns the same instant. The request writes only `workflow_runs.cancel_requested_at` — never the run's status, diff --git a/docs/workflows/workflows.md b/docs/workflows/workflows.md index 4933a2bb..00c0bdbf 100644 --- a/docs/workflows/workflows.md +++ b/docs/workflows/workflows.md @@ -18,6 +18,7 @@ - **草稿与已发布分开。** 保存只要求请求体里的图是一个 JSON 对象,不检查完整性:写到一半的草稿是合法的文档。只有**发布**判断图能否执行, 把问题连同出错的节点或槽位一起返回,让编辑器标出来。被冻结的已发布版本就是 Cloud 同意执行的图。 + 编辑器中切换标签页或通过工具栏离开时,会等未完成的草稿保存结束;保存失败时让作者选择留在编辑器,或放弃未保存的修改并离开,避免返回时从旧缓存重建画布,也不会把作者困在失败的草稿里。 - **版本不可变。** 已发布版本(`workflow_snapshots`)按工作流内递增的版本号保存,从不修改、从不软删除;运行渲染的图因此不会随文档变化而 漂移。发布时锁定工作流行,两次并发发布不会拿到同一个版本号。快照名称默认取工作流名称。 - **恢复**把当前文档换成某个版本的图,并像其他编辑一样推进文档版本;它不改动任何已发布版本。 @@ -98,6 +99,18 @@ Project 缺失时统一返回 `409 workflow_run_project_required`,不可用、 `workspaces.workflow_run_id` 与 IssueRun 的 `issue_run_id` 互斥,运行时控制里为它预留的 `maintenance` 同样按运行身份记录(见 [运行时控制](../runtime/runtime-control.md))。 +**运行历史按 `(created_at, id)` 倒序分页。** `GET .../workflows/:wfid/runs` 返回最新在前,`limit` 上限 100, +`after` 是上一页最后一行的工作流运行 UUID:服务端用它定位该行的 `created_at`,再按 `(created_at, id)` 严格小于它继续取, +所以同一时刻的多条运行由 `id` 决胜负,翻页不重不漏。游标只是行 UUID,不含时间戳;解析不到该行的游标(例如别的工作流或 +已不存在的行)返回空页而不是从头部重来,非法 UUID 返回 `400 invalid_cursor`。工作流**列表**的每一行带 `latestRun`, +是这条顺序上的第一条运行,从未运行时为 `null`;它只含状态、快照与时间,运行历史行才带节点状态和输入。详情读不带这个 +字段:运行开始不应该改写工作流资源的每一次读取,需要标签的读者读列表或运行历史。 + +运行记录页分别呈现加载中、没有记录、读取失败可重试与无权限。首次进入时,工作流详情和运行历史是独立的读取: +详情返回 `403` 也显示无权限,临时失败的重试重新读取详情,只有 `not_found` 才显示工作流不存在。 +后续翻页或刷新失败保留已加载的历史,重试会重新执行失败的那次读取。历史行独立展示开始和结束时刻:开始前取消的运行没有开始时刻, +但仍显示已有的结束时刻。查看记录和重试读取都不创建运行。 + **取消是请求,不是状态切换。** `POST .../workflows/:wfid/runs/:rid/cancel` 在运行行上记录请求,返回 `202` 和记录的时刻;重复请求(即使换一个幂等键) 返回同一个时刻。请求只写 `workflow_runs.cancel_requested_at`,不改动运行的状态、节点状态、会话或 Workspace——结算由调度器在下一轮完成(见下文)。 `pending` 和 `running` 的运行接受请求;已经进入终态的运行返回 `409 run_not_cancellable`。授权按运行上冻结的模型绑定判断:带绑定的运行只有发起人和 diff --git a/frontend/src/api/generated.schemas.ts b/frontend/src/api/generated.schemas.ts index cf96e5ea..db59e028 100644 --- a/frontend/src/api/generated.schemas.ts +++ b/frontend/src/api/generated.schemas.ts @@ -2563,6 +2563,33 @@ export interface User { */ export type WorkflowGraph = { [key: string]: unknown }; +export type WorkflowLatestRunStatus = typeof WorkflowLatestRunStatus[keyof typeof WorkflowLatestRunStatus]; + + +export const WorkflowLatestRunStatus = { + pending: 'pending', + running: 'running', + succeeded: 'succeeded', + failed: 'failed', + cancelled: 'cancelled', +} as const; + +/** + * List rows only. The workflow's most recent run (the order the run history walks), or null when it has never run. + * @nullable + */ +export type WorkflowLatestRun = { + createdAt: string; + /** @nullable */ + finishedAt: string | null; + id: string; + name: string; + snapshotId: string; + /** @nullable */ + startedAt: string | null; + status: WorkflowLatestRunStatus; +} | null; + export interface Workflow { createdAt: string; /** @nullable */ @@ -2571,6 +2598,11 @@ export interface Workflow { /** The authored graph document: nodes, edges, viewport, editor annotations and global variables. Stored and returned whole; the editor is its only reader. */ graph: WorkflowGraph; id: string; + /** + * List rows only. The workflow's most recent run (the order the run history walks), or null when it has never run. + * @nullable + */ + latestRun?: WorkflowLatestRun; name: string; tenantId: string; updatedAt: string; diff --git a/frontend/src/features/workflows/README.en.md b/frontend/src/features/workflows/README.en.md index 498e9e85..62b8c64a 100644 --- a/frontend/src/features/workflows/README.en.md +++ b/frontend/src/features/workflows/README.en.md @@ -8,4 +8,6 @@ This module composes workflow listing, editing, and run pages and owns API queri | `*-page.tsx` | Route page composition | | `*translations.ts` | Feature copy and localized publication guidance | -Depends on generated APIs, primitives, identity, and space context; application routes consume this module. Publish errors map stable admission codes to actionable guidance without displaying raw server messages. Tests verify query boundaries and page behavior; known admission reasons must not collapse into generic guidance. The run page shows Loop and Iteration rounds: `run/run-rounds.ts` is the only place that projects the round document in `node_states` (archive plus the top-level active round) into rounds, so the final round appears once and members that did not run read as not reached; a successful Loop's recorded `reason` (`condition_satisfied` / `max_iterations` / `loop_exit`) is used when present; without it only committed facts are used, so at the ceiling under `succeed` it only says "completed". +Depends on generated APIs, primitives, identity, and space context; application routes consume this module. Publish errors map stable admission codes to actionable guidance without displaying raw server messages. Tests verify query boundaries and page behavior; known admission reasons must not collapse into generic guidance. +Leaving the editor waits for its draft save; after a failure the canvas remains visible while the author chooses to stay or discard changes and leave. Navigation must not retry a rejected save indefinitely. +The run page shows Loop and Iteration rounds: `run/run-rounds.ts` is the only place that projects the round document in `node_states` (archive plus the top-level active round) into rounds, so the final round appears once and members that did not run read as not reached; a successful Loop's recorded `reason` (`condition_satisfied` / `max_iterations` / `loop_exit`) is used when present; without it only committed facts are used, so at the ceiling under `succeed` it only says "completed". diff --git a/frontend/src/features/workflows/README.md b/frontend/src/features/workflows/README.md index ae7d6aac..ca430059 100644 --- a/frontend/src/features/workflows/README.md +++ b/frontend/src/features/workflows/README.md @@ -8,4 +8,6 @@ | `*-page.tsx` | 路由页面组合 | | `*translations.ts` | 功能文案与发布门禁的本地化指导 | -依赖生成 API、基础组件、身份与空间上下文;应用路由消费本模块。发布错误按稳定门禁码显示具体、可操作的原因,不展示原始服务端消息。测试通过查询边界和页面行为验证契约,不能用通用提示掩盖已知门禁原因。运行页按轮展示 Loop 与 Iteration:`run/run-rounds.ts` 是把 `node_states` 里的轮次文档(归档 + 顶层活动轮)投影成轮次的唯一入口,最后一轮只出现一次,未执行的成员显示为未到达;成功 Loop 的轮次文档里有 `reason`(`condition_satisfied` / `max_iterations` / `loop_exit`)就直接用;没有时只按已提交事实推断,到达上限且策略为 succeed 时只显示「已完成」。 +依赖生成 API、基础组件、身份与空间上下文;应用路由消费本模块。发布错误按稳定门禁码显示具体、可操作的原因,不展示原始服务端消息。测试通过查询边界和页面行为验证契约,不能用通用提示掩盖已知门禁原因。 +编辑页离开前等待草稿保存;失败时保留画布并让作者选择留下或放弃修改后离开,不能反复自动重试而困住导航。 +运行页按轮展示 Loop 与 Iteration:`run/run-rounds.ts` 是把 `node_states` 里的轮次文档(归档 + 顶层活动轮)投影成轮次的唯一入口,最后一轮只出现一次,未执行的成员显示为未到达;成功 Loop 的轮次文档里有 `reason`(`condition_satisfied` / `max_iterations` / `loop_exit`)就直接用;没有时只按已提交事实推断,到达上限且策略为 succeed 时只显示「已完成」。 diff --git a/frontend/src/features/workflows/api.test.tsx b/frontend/src/features/workflows/api.test.tsx index fdb2a6cd..20890f4f 100644 --- a/frontend/src/features/workflows/api.test.tsx +++ b/frontend/src/features/workflows/api.test.tsx @@ -1,4 +1,4 @@ -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { QueryClient, QueryClientProvider, type InfiniteData } from '@tanstack/react-query' import { act, renderHook, waitFor } from '@testing-library/react' import { http, HttpResponse } from 'msw' import type { ReactNode } from 'react' @@ -6,8 +6,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { GetApiV1TenantsTidWorkflowsWfidRuns200, WorkflowRun } from '@/api/generated.schemas' import { WORKFLOW_RUN_POLL_MS, - mergeRunPage, mergeRunRead, + runHistoryRows, runPollInterval, runsPollInterval, useWorkflowRun, @@ -48,12 +48,13 @@ function runFixture(overrides: Partial = {}): WorkflowRun { } /** The same run after it settled, with the node output the poll is meant to deliver. */ -function settledRun(): WorkflowRun { +function settledRun(overrides: Partial = {}): 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: '审查通过' } }, + ...overrides, }) } @@ -77,9 +78,17 @@ 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 +/** The cached history walk, as {@link cachedRun} reads the cached run. */ +function cachedHistory( + queryClient: QueryClient, +): InfiniteData | undefined { + return queryClient.getQueryState>([RUNS_URL]) + ?.data +} + +/** The first (newest) row of the cached history. */ +function cachedFirstRow(queryClient: QueryClient): WorkflowRun | undefined { + return cachedHistory(queryClient)?.pages[0]?.items[0] } /** Mounts a hook under the query client, with no other provider it does not need. */ @@ -89,6 +98,23 @@ function wrapperFor(queryClient: QueryClient) { } } +/** + * Walks a mounted history hook to its second page. + * + * Several cases assert on how a read refreshes a page below the head, and each has to load that page + * the same way: wait for the head to report a cursor, then fetch it. + * + * @param result - The mounted hook, read at call time so the wait sees the newest value. + */ +async function loadSecondPage(result: { + current: ReturnType +}): Promise { + await settleUntil(() => expect(result.current.hasNextPage).toBe(true)) + await act(async () => { + await result.current.fetchNextPage() + }) +} + afterEach(() => { vi.useRealTimers() }) @@ -180,26 +206,6 @@ describe('mergeRunRead', () => { }) }) -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. @@ -362,17 +368,63 @@ describe('useWorkflowRuns', () => { wrapper: wrapperFor(newQueryClient()), }) - await settleUntil(() => expect(result.current.data?.[0]?.status).toBe('running')) + await settleUntil(() => expect(result.current.data?.pages[0]?.items[0]?.status).toBe('running')) await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) - await settleUntil(() => expect(result.current.data?.[0]?.status).toBe('succeeded')) + await settleUntil(() => + expect(result.current.data?.pages[0]?.items[0]?.status).toBe('succeeded'), + ) expect(reads).toBe(2) await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS * 3)) expect(reads).toBe(2) }) + it('walks the history by cursor instead of stopping at the first page', async () => { + const requested: string[] = [] + server.use( + http.get(RUNS_URL, ({ request }) => { + const after = new URL(request.url).searchParams.get('after') ?? '' + requested.push(after) + return HttpResponse.json( + after === '' + ? { + items: [runFixture({ id: 'run-1', name: '第一页' })], + nextCursor: 'run-1', + } + : { + items: [runFixture({ id: 'run-2', name: '第二页' })], + nextCursor: '', + }, + ) + }), + ) + const { result } = renderHook(() => useWorkflowRuns(TEST_TENANT_ID, WORKFLOW_ID), { + wrapper: wrapperFor(newQueryClient()), + }) + + await loadSecondPage(result) + + // The second request carries the cursor the first page reported, and the walk ends when the + // server stops offering one — that is what makes every run reachable, not just the first page. + expect(requested).toEqual(['', 'run-1']) + await settleUntil(() => expect(result.current.hasNextPage).toBe(false)) + expect(result.current.data?.pages).toHaveLength(2) + }) + + it('flattens loaded pages newest first without repeating a run', () => { + const run = runFixture({ id: 'run-1' }) + const rows = runHistoryRows([ + { items: [run, runFixture({ id: 'run-2' })], nextCursor: 'run-2' }, + // A refetch answered from a page that overlaps the previous one must not render twice. + { items: [runFixture({ id: 'run-2' }), runFixture({ id: 'run-3' })], nextCursor: '' }, + ]) + + expect(rows.map((row) => row.id)).toEqual(['run-1', 'run-2', 'run-3']) + expect(runHistoryRows(undefined)).toEqual([]) + }) + it('keeps a settled history row from flipping back on a stale page', async () => { vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) const queryClient = newQueryClient() @@ -386,7 +438,7 @@ describe('useWorkflowRuns', () => { const { result } = renderHook(() => useWorkflowRuns(TEST_TENANT_ID, WORKFLOW_ID), { wrapper: wrapperFor(queryClient), }) - const firstRow = () => cachedPage(queryClient)?.items[0] + const firstRow = () => cachedFirstRow(queryClient) await settleUntil(() => expect(firstRow()?.status).toBe('running')) await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) @@ -405,4 +457,102 @@ describe('useWorkflowRuns', () => { expect(settled?.status).toBe('succeeded') expect(firstRow()).toBe(settled) }) + + it('refreshes a running run on a later page even when the newest page is settled', async () => { + // The head page being terminal says nothing about the pages below it: the run that is still + // moving is wherever the cursor walk put it. Reading only the head would freeze that row. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + const laterRunId = 'run-later' + let laterReads = 0 + server.use( + http.get(RUNS_URL, ({ request }) => { + const after = new URL(request.url).searchParams.get('after') ?? '' + if (after === '') { + return HttpResponse.json({ items: [settledRun()], nextCursor: RUN_ID }) + } + laterReads += 1 + return HttpResponse.json({ + items: [ + laterReads === 1 ? runFixture({ id: laterRunId }) : settledRun({ id: laterRunId }), + ], + nextCursor: '', + }) + }), + ) + const { result } = renderHook(() => useWorkflowRuns(TEST_TENANT_ID, WORKFLOW_ID), { + wrapper: wrapperFor(newQueryClient()), + }) + + await loadSecondPage(result) + await settleUntil(() => expect(result.current.data?.pages[1]?.items[0]?.status).toBe('running')) + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + + await settleUntil(() => + expect(result.current.data?.pages[1]?.items[0]?.status).toBe('succeeded'), + ) + expect(laterReads).toBe(2) + + // The whole walk is terminal now, so the interval cleared instead of spending reads. + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS * 2)) + expect(laterReads).toBe(2) + }) + + it('keeps a settled run terminal when a new run above it moves it onto another page', async () => { + // A run created at the head shifts every page boundary below it. The page that now holds the + // moved run is a different page than the one that held it before, so merging by page position + // would compare it against another run entirely and reopen it with the stale read. + const firstPageRun = 'run-a' + const secondPageRun = 'run-b' + const insertedRun = 'run-new' + let shifted = false + server.use( + http.get(RUNS_URL, ({ request }) => { + const after = new URL(request.url).searchParams.get('after') ?? '' + if (!shifted) { + if (after === '') { + return HttpResponse.json({ + items: [settledRun({ id: firstPageRun })], + nextCursor: firstPageRun, + }) + } + return HttpResponse.json({ items: [settledRun({ id: secondPageRun })], nextCursor: '' }) + } + // A new run was created, so the first page holds it alone and the settled run it displaced + // is now the second page — read back by a copy that has not caught up with the settlement. + if (after === '') { + return HttpResponse.json({ + items: [settledRun({ id: insertedRun })], + nextCursor: insertedRun, + }) + } + if (after === insertedRun) { + return HttpResponse.json({ + items: [runFixture({ id: firstPageRun, updatedAt: '2026-09-20T02:00:00.000Z' })], + nextCursor: firstPageRun, + }) + } + return HttpResponse.json({ items: [], nextCursor: '' }) + }), + ) + const queryClient = newQueryClient() + const { result } = renderHook(() => useWorkflowRuns(TEST_TENANT_ID, WORKFLOW_ID), { + wrapper: wrapperFor(queryClient), + }) + + await loadSecondPage(result) + await settleUntil(() => expect(result.current.data?.pages).toHaveLength(2)) + + shifted = true + await act(async () => { + await result.current.refetch() + }) + + // Membership follows the server, but the moved run keeps the settled status the list already + // showed rather than the running status of the stale copy behind it. The cache is read rather + // than the hook result, because the observer hands the new value to React a macrotask later. + const rows = runHistoryRows(cachedHistory(queryClient)?.pages) + expect(rows.map((row) => row.id)).toEqual([insertedRun, firstPageRun]) + expect(rows.map((row) => row.status)).toEqual(['succeeded', 'succeeded']) + }) }) diff --git a/frontend/src/features/workflows/api.ts b/frontend/src/features/workflows/api.ts index d8defcb9..8105b582 100644 --- a/frontend/src/features/workflows/api.ts +++ b/frontend/src/features/workflows/api.ts @@ -1,4 +1,10 @@ -import { replaceEqualDeep, useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { + replaceEqualDeep, + useInfiniteQuery, + useMutation, + useQuery, + useQueryClient, +} from '@tanstack/react-query' import type { Error as ApiError, GetApiV1TenantsTidWorkflowsWfidRuns200, @@ -323,6 +329,15 @@ export interface RestoreWorkflowSnapshotInput { */ export const WORKFLOW_RUN_POLL_MS = 5000 +/** + * Rows per page of the run history. + * + * The backend caps `limit` at 100 and the list stays below that: the first screen arrives quickly + * and "load more" is a small step. The size only decides how many clicks a long history takes — + * paging is by cursor, so it never decides which runs are reachable. + */ +export const WORKFLOW_RUN_PAGE_SIZE = 50 + /** * How often a run read polls, given the outcome of the last attempt. * @@ -346,22 +361,23 @@ export function runPollInterval( } /** - * How often the run history polls: while any listed run can still change. + * How often the run history polls: while any run of the loaded walk 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. + * The rows come from every loaded page, not from the head alone: a running run + * is wherever the cursor walk put it, and the newest page being settled says + * nothing about the older ones. + * + * @param rows - Every run of the loaded history, 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 +export function runsPollInterval(rows: WorkflowRun[] | undefined, failed: boolean): number | false { + if (rows === undefined) return failed ? WORKFLOW_RUN_POLL_MS : false + return rows.some((run) => !isTerminalRunStatus(run.status)) ? WORKFLOW_RUN_POLL_MS : false } /** @@ -425,8 +441,7 @@ function mergeNodeStates( } /** - * Merges a run-history page into the one already held, so one run cannot read - * differently in the list and on its detail page. + * Merges one history page into the copies already held for the runs it returns. * * 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 @@ -434,16 +449,20 @@ function mergeNodeStates( * with the same rule the detail page uses, which is what keeps the two surfaces * agreeing. * - * @param previous - The page already cached, if any. + * The copies are looked up by run id across the whole loaded walk rather than at + * the same page position: a run created at the head shifts every boundary below + * it, so a settled run can move from one page to the next between two reads. + * Pairing pages positionally would then compare a row against a different run, + * and a stale copy of the moved row could reopen the settled one. + * + * @param held - Every row already held, keyed by run id. * @param next - The page the latest read returned. * @returns The page to keep. */ -export function mergeRunPage( - previous: GetApiV1TenantsTidWorkflowsWfidRuns200 | undefined, +function mergeRunPage( + held: Map, 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)) } } @@ -497,43 +516,110 @@ function shareRunRead(previous: unknown, next: unknown): unknown { 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)) +/** + * The `structuralSharing` adapter for the run history, over {@link mergeRunPage}'s rule. + * + * A refresh derives each later page's cursor from the newly read previous page, so a new run can + * move an existing row across page boundaries. The held copies are therefore indexed across the + * whole walk before the pages are merged, and pages the walk gained have no copy of their own — + * `mergeRunRead` answers those with the incoming rows. + */ +function shareRunHistory(previous: unknown, next: unknown): unknown { + if (!isMergeableRunHistory(next)) return next + const held = isMergeableRunHistory(previous) ? previous : undefined + const heldRows = new Map(runHistoryRows(held?.pages).map((run) => [run.id, run])) + const pages = next.pages.map((page) => mergeRunPage(heldRows, page)) + return replaceEqualDeep(held, { ...next, pages }) +} + +/** The cache shape TanStack hands `structuralSharing` for a paginated history. */ +interface RunHistoryCache { + pages: GetApiV1TenantsTidWorkflowsWfidRuns200[] + pageParams: unknown[] +} + +/** True when a value is the paginated history whose pages the merge can read. */ +function isMergeableRunHistory(value: unknown): value is RunHistoryCache { + const record = asJsonRecord(value) + if (record === undefined) return false + const { pages, pageParams } = record + if (!Array.isArray(pages) || !Array.isArray(pageParams)) return false + const loaded: unknown[] = pages + return loaded.every(isMergeableRunPage) } /** - * A workflow's run history, newest first. + * A workflow's run history, newest first, read as a cursor walk. + * + * The list pages by `nextCursor` instead of asking for one capped window: the backend orders runs + * by `(created_at, id)`, so every page continues strictly below the previous one and a workflow + * with hundreds of runs stays fully browsable. The backend sorts, so the page arrives in display + * order; re-sorting here would only hide a disagreement between the two sides. * - * The backend pages runs by row UUID, so the query fetches up to 100 rows and - * 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. + * A run's status is the one thing in the walk that still moves, so the query keeps re-reading + * while any run of the loaded walk is unsettled and stops once the whole walk is terminal. Every + * loaded page is refreshed in order, deriving each later cursor from the newly read previous page, + * so a running run stays live wherever the walk put it, not only on the newest page. `pages` is + * flattened by {@link runHistoryRows}, which keeps a stale cached overlap from rendering twice. * * @param tenantId - Tenant the workflow belongs to. * @param workflowId - Workflow whose runs to read. - * @returns The runs, newest `createdAt` first, or `undefined` while resolving. + * @returns The paginated history: `data.pages` is the loaded walk, plus `fetchNextPage` until + * `hasNextPage` is false. */ export function useWorkflowRuns(tenantId: string | undefined, workflowId: string | undefined) { - return useQuery({ + return useInfiniteQuery({ queryKey: workflowRunsKey(tenantId, workflowId), - queryFn: ({ signal }) => + queryFn: ({ pageParam, signal }) => getApiV1TenantsTidWorkflowsWfidRuns( tenantId ?? '', workflowId ?? '', - { limit: 100 }, + { limit: WORKFLOW_RUN_PAGE_SIZE, ...(pageParam === '' ? {} : { after: pageParam }) }, undefined, signal, ), + initialPageParam: '', + getNextPageParam: (page) => (page.nextCursor === '' ? undefined : page.nextCursor), 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, + refetchInterval: (query) => { + const pages = query.state.data?.pages + return runsPollInterval( + // No successful read yet is not the same as an empty history: a failed first read keeps + // polling, because another attempt is how a transient failure recovers. + pages === undefined ? undefined : runHistoryRows(pages), + query.state.status === 'error', + ) + }, + structuralSharing: shareRunHistory, }) } +/** + * The runs of every loaded page, newest first and with no run twice. + * + * Deduplication is by run id rather than by position because a poll refreshes every loaded page at + * its own cursor: a run created between two reads shifts the head page, and the boundary it shifts + * across could otherwise be rendered from two pages at once. + * + * @param pages - The loaded pages, oldest page last. + * @returns One row per run, in the order the backend returned them. + */ +export function runHistoryRows( + pages: GetApiV1TenantsTidWorkflowsWfidRuns200[] | undefined, +): WorkflowRun[] { + if (pages === undefined) return [] + const seen = new Set() + const rows: WorkflowRun[] = [] + for (const page of pages) { + for (const run of page.items) { + if (seen.has(run.id)) continue + seen.add(run.id) + rows.push(run) + } + } + return rows +} + /** * One workflow run by id, including its frozen snapshot graph and node states. * diff --git a/frontend/src/features/workflows/editor/README.en.md b/frontend/src/features/workflows/editor/README.en.md index 9d158d5f..2c8f182a 100644 --- a/frontend/src/features/workflows/editor/README.en.md +++ b/frontend/src/features/workflows/editor/README.en.md @@ -17,6 +17,7 @@ Depends on runtime, UI components, i18n and workflow API adapters; workflow page Case ids and connection ports persist together; different ports may join one target. Output retains authored bindings and incomplete draft rows, leaving publication validation to reject invalid data. Tests exercise real React Flow ports, controlled inspectors and draft save/reload behavior; network -tests replace the shared test boundary. +tests replace the shared test boundary. Leaving the editor waits for the draft write; after a failure the author +chooses to stay or discard changes and leave, cancelling pending draft state on discard. Legacy empty condition ports project to ELSE on the canvas without mutating drafts. Loop initial selectors list outer variables; round-end selectors also list body roots and carried values. New members immediately use their parent and relative coordinates; deleting a Loop removes its whole body and edges in one undoable step without a confirmation, and outer nodes dropped onto the frame return. diff --git a/frontend/src/features/workflows/editor/README.md b/frontend/src/features/workflows/editor/README.md index e662ff19..47e5b654 100644 --- a/frontend/src/features/workflows/editor/README.md +++ b/frontend/src/features/workflows/editor/README.md @@ -14,6 +14,6 @@ 依赖 runtime、UI 组件、i18n 与工作流 API 适配器;工作流页面消费本模块。分支 id 与连线端口共同持久化, 不同端口可以汇入同一节点。Output 保存原始命名绑定,未完成的行保留为草稿,发布校验负责拒绝。 -测试通过真实 React Flow 端口、受控检查器及草稿保存重载验证行为,网络测试使用共享测试边界。 +测试通过真实 React Flow 端口、受控检查器及草稿保存重载验证行为,网络测试使用共享测试边界。编辑页离开时先等草稿写入;失败后由作者选择留下或放弃修改并离开,放弃时取消待保存状态。 历史 condition 空端口仅在画布投影为 ELSE,不改写草稿。Loop 初值选择器只列外层变量,轮末选择器包含本轮成员与 carried;新建成员立即使用父容器与相对坐标;删 Loop 一步删掉全部成员与连线(不弹确认,可撤销),拖入框的外部节点弹回。 diff --git a/frontend/src/features/workflows/editor/use-workflow-draft.ts b/frontend/src/features/workflows/editor/use-workflow-draft.ts index 3d3b21d9..8fd302b2 100644 --- a/frontend/src/features/workflows/editor/use-workflow-draft.ts +++ b/frontend/src/features/workflows/editor/use-workflow-draft.ts @@ -101,11 +101,7 @@ export interface WorkflowDraft { replaceLaunchFields: (fields: WorkflowLaunchField[]) => void /** Re-arranges the graph into execution order. */ organize: () => void - /** - * Replaces the whole draft with a fresh workflow's graph, an external replace - * such as a version restore rather than an edit. The restored graph becomes - * the new base, so nothing left over from the discarded draft persists. - */ + /** Replaces the whole draft from a fresh workflow record, e.g. after reloading the document. */ reset: (workflow: CloudWorkflow) => void /** * Snapshots the current authored content for a history step. The viewport is @@ -207,8 +203,6 @@ export function useWorkflowDraft( }) } - // Reseeds the whole draft for an external replace; the seed closure reads - // only from `next`, so the reset carries no stale session state with it. const reset = (next: CloudWorkflow): void => { replaceState(seedDraft(next)) } diff --git a/frontend/src/features/workflows/editor/use-workflow-editor-state.ts b/frontend/src/features/workflows/editor/use-workflow-editor-state.ts index 78c1d708..6b30e5df 100644 --- a/frontend/src/features/workflows/editor/use-workflow-editor-state.ts +++ b/frontend/src/features/workflows/editor/use-workflow-editor-state.ts @@ -102,10 +102,10 @@ export interface WorkflowEditorState extends WorkflowEditorHandlers { * Publish calls this first so a released version is never stale. */ flushDraft: () => Promise + /** Drops an unsaved draft after the member explicitly chooses to leave. */ + discardDraft: () => void /** The document version the server last confirmed; its value guards a restore. */ committedVersion: number - /** Replaces the draft with a fresh workflow, e.g. after a version restore. */ - resetDraft: (workflow: CloudWorkflow) => void /** Serializes the live draft, unsaved edits included, into the graph document. */ draftGraph: () => Record /** Replaces the workflow-wide declarations as one history step. */ @@ -192,18 +192,6 @@ export function useWorkflowEditorState( // edits included, so what the download previews is what the file will hold. const draftGraph = useCallback(() => snapshot(viewportRef.current), [snapshot]) - // An external replace (a version restore) reseeds the draft and adopts the new - // version as the guard for later writes and a later restore. - const resetDraft = useCallback( - (next: CloudWorkflow) => { - draft.reset(next) - versionRef.current = next.version - setCommittedVersion(next.version) - cancel() - }, - [cancel, draft], - ) - return { ...useWorkflowDraftHandlers(draft, markDirty, history, t), ...globalVariablesSaver, @@ -218,8 +206,8 @@ export function useWorkflowEditorState( status, saveNow, flushDraft: flush, + discardDraft: cancel, committedVersion, - resetDraft, draftGraph, onViewportChange, history: workflowHistoryControls(draft, history), diff --git a/frontend/src/features/workflows/editor/workflow-editor.test.tsx b/frontend/src/features/workflows/editor/workflow-editor.test.tsx index 8b6cfacd..e01945f6 100644 --- a/frontend/src/features/workflows/editor/workflow-editor.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-editor.test.tsx @@ -465,8 +465,7 @@ describe('WorkflowEditor', () => { await waitFor(() => expect(screen.getByText('版本 2')).toBeInTheDocument()) }) - it('rolls the canvas back to a published snapshot from history', async () => { - const restored = workflowFixture({ version: 5, graph: EMPTY_GRAPH }) + it('points at the version list instead of rolling back from a popover', async () => { const published: WorkflowSnapshot = { id: 'snap-1', tenantId: TEST_TENANT_ID, @@ -476,24 +475,16 @@ describe('WorkflowEditor', () => { graph: EMPTY_GRAPH, createdAt: '2026-09-20T10:00:00+08:00', } - // Only the PUT needs a bespoke handler; the snapshots GET comes from - // renderEditor's own mock, because registering it here (before renderEditor) - // would be shadowed by the later-registered one and the popover would open - // onto an empty history. - server.use( - http.put( - `/api/v1/tenants/${TEST_TENANT_ID}/workflows/:wfid/snapshots/:snapshotId/restore`, - () => HttpResponse.json(restored), - ), - ) renderEditor({ snapshots: [published] }) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) - fireEvent.click(await screen.findByRole('button', { name: '回滚到版本 1' })) - fireEvent.click(screen.getByRole('button', { name: '回滚' })) - - await waitFor(() => expect(screen.getByText('版本 5')).toBeInTheDocument()) - expect(screen.queryByText('开始 1')).not.toBeInTheDocument() + // Rolling back replaces the live document, which is a decision about a version rather than an + // edit on the canvas; the toolbar therefore leaves it to the versions tab it links to. The + // editor mounts bare here, so the path's prefix is whatever slug context it was given and only + // the destination is asserted. + const link = screen.getByRole('link', { name: '版本历史' }) + expect(link.getAttribute('href')).toMatch( + new RegExp(`/workflows/${published.workflowId}/versions$`), + ) }) it('renames a node from the inspector header, and saves the new title', async () => { @@ -1027,7 +1018,7 @@ describe('WorkflowEditor structured output', () => { }) describe('WorkflowEditor run tools', () => { - it('disables the run button and shows an empty history before any snapshot exists', async () => { + it('disables the run button and links the run list before any snapshot exists', async () => { renderEditor() // The button only drops out once the empty snapshot list resolves; until @@ -1035,8 +1026,11 @@ describe('WorkflowEditor run tools', () => { const runButton = screen.getByRole('button', { name: '运行' }) await waitFor(() => expect(runButton).toBeDisabled()) - fireEvent.click(screen.getByRole('button', { name: '运行历史' })) - expect(screen.getByText('还没有运行记录。')).toBeInTheDocument() + // History is its own tab now, so the toolbar offers the way there and never renders a second, + // popover-shaped copy of the same list. + const link = screen.getByRole('link', { name: '运行记录' }) + expect(link.getAttribute('href')).toMatch(/\/workflows\/[0-9a-f-]+\/runs$/) + expect(screen.queryByText('还没有运行记录。')).not.toBeInTheDocument() }) it('starts the published version in the Project the member picks', async () => { diff --git a/frontend/src/features/workflows/editor/workflow-editor.tsx b/frontend/src/features/workflows/editor/workflow-editor.tsx index 6ab687fc..782d29d9 100644 --- a/frontend/src/features/workflows/editor/workflow-editor.tsx +++ b/frontend/src/features/workflows/editor/workflow-editor.tsx @@ -1,8 +1,19 @@ import { useCallback, useEffect, useRef, useState, type ReactNode, type RefObject } from 'react' import { useTranslation } from 'react-i18next' import { Save } from 'lucide-react' +import { useBlocker } from 'react-router-dom' import type { Workflow as CloudWorkflow } from '@/api/generated.schemas' import { Badge } from '@/components/ui/badge' +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Dialog, @@ -19,7 +30,10 @@ import { type ResizablePanelHandle, } from '@/components/ui/resizable' import type { WorkflowDraftSaveStatus } from '@/features/workflows/editor/use-workflow-draft-autosave' -import { useWorkflowEditorState } from '@/features/workflows/editor/use-workflow-editor-state' +import { + useWorkflowEditorState, + type WorkflowEditorState, +} from '@/features/workflows/editor/use-workflow-editor-state' import { WorkflowCanvas } from '@/features/workflows/editor/workflow-canvas' import { WorkflowDraftSaveStatusLabel } from '@/features/workflows/editor/workflow-draft-save-status' import { WorkflowHistoryTools } from '@/features/workflows/editor/workflow-history-controls' @@ -51,6 +65,8 @@ export interface WorkflowEditorProps { workflow: CloudWorkflow /** Receives the created workflow after an import; the page navigates to it. */ onImported?: (workflowId: string) => void + /** Enable route-exit protection when this editor is mounted by the application page. */ + guardNavigation?: boolean } /** @@ -62,7 +78,8 @@ export interface WorkflowEditorProps { * * @param props - The tenant to write to and the workflow to open. */ -export function WorkflowEditor({ tenantId, workflow, onImported }: WorkflowEditorProps) { +export function WorkflowEditor(props: WorkflowEditorProps) { + const { tenantId, workflow, onImported, guardNavigation } = props const state = useWorkflowEditorState(tenantId, workflow) const inspectorRef = useRef(null) const iterationDeletion = useIterationDeletionConfirmation() @@ -71,6 +88,7 @@ export function WorkflowEditor({ tenantId, workflow, onImported }: WorkflowEdito return (
+ {guardNavigation && } } transferTools={ @@ -162,6 +178,73 @@ export function WorkflowEditor({ tenantId, workflow, onImported }: WorkflowEdito ) } +/** Keeps the page editor mounted until its draft reaches the query cache or a failed save is shown. */ +function DraftNavigationGuard({ + draft, +}: { + draft: Pick +}) { + const { status, flushDraft, discardDraft } = draft + const { t } = useTranslation() + const blocker = useBlocker(status !== 'clean') + const settlingNavigation = useRef(false) + const [saveFailed, setSaveFailed] = useState(false) + + useEffect(() => { + if (blocker.state !== 'blocked' || settlingNavigation.current) return + if (status === 'error') return + settlingNavigation.current = true + // Every in-app exit first waits for the write. A failed write needs a choice: silently + // resetting the blocker would trap the member on every later navigation as well. + void (async () => { + try { + if (await flushDraft()) blocker.proceed() + else setSaveFailed(true) + } catch { + setSaveFailed(true) + } finally { + settlingNavigation.current = false + } + })() + }, [blocker, flushDraft, status]) + + return ( + { + if (!open) stay() + }} + > + + + {t('workflows.draftNavigation.title')} + + {t('workflows.draftNavigation.description')} + + + + {t('workflows.draftNavigation.stay')} + + {t('workflows.draftNavigation.leave')} + + + + + ) + + function stay(): void { + setSaveFailed(false) + if (blocker.state === 'blocked') blocker.reset() + } + + function leave(): void { + if (blocker.state !== 'blocked') return + discardDraft() + setSaveFailed(false) + blocker.proceed() + } +} + /** * Turns the region-deletion question into something the canvas can wait on. * diff --git a/frontend/src/features/workflows/editor/workflow-history-controls.test.tsx b/frontend/src/features/workflows/editor/workflow-history-controls.test.tsx index da59c87d..faaff710 100644 --- a/frontend/src/features/workflows/editor/workflow-history-controls.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-history-controls.test.tsx @@ -61,9 +61,14 @@ function renderTools(history: WorkflowHistoryControls) { return renderWithProviders() } -/** Opens the change-history popover from the toolbar's history button. */ +/** + * Opens the change-history popover from the toolbar's history button. + * + * The control reads 「编辑历史」 rather than 「历史记录」: a workflow also carries a run history and + * a version list, and this is the session's edits — the one that is neither of those. + */ async function openPanel(): Promise { - fireEvent.click(screen.getByRole('button', { name: '历史记录' })) + fireEvent.click(screen.getByRole('button', { name: '编辑历史' })) await screen.findByRole('dialog', { hidden: true }) } diff --git a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx index 2d84a670..522edde1 100644 --- a/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-tools.test.tsx @@ -1,5 +1,5 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' -import { fireEvent, renderHook, screen, waitFor } from '@testing-library/react' +import { fireEvent, renderHook, screen, waitFor, within } from '@testing-library/react' import { http, HttpResponse } from 'msw' import { beforeEach, describe, expect, it } from 'vitest' import type { WorkflowRun, WorkflowSnapshot } from '@/api/generated.schemas' @@ -206,22 +206,15 @@ describe('WorkflowRunTools', () => { }) it('lists run history and opens a run from a row', async () => { - const router = renderRunTools({ + renderRunTools({ runs: [runFixture('run-1', '巡检运行'), runFixture('run-2', '发布检查')], }) - fireEvent.click(await screen.findByRole('button', { name: '运行历史' })) - - fireEvent.click(await screen.findByRole('button', { name: '打开运行 巡检运行' })) - await waitFor(() => expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-1`)) - }) - - it('shows the quiet empty state when nothing has ever run', async () => { - renderRunTools({ runs: [] }) - - fireEvent.click(await screen.findByRole('button', { name: '运行历史' })) - - expect(await screen.findByText('还没有运行记录。')).toBeInTheDocument() + // The history is its own tab now: the toolbar links to it rather than rendering a popover, and + // the link carries the workspace slug the browser route uses. + const link = await screen.findByRole('link', { name: '运行记录' }) + expect(link).toHaveAttribute('href', `${EDITOR_PATH}/runs`) + expect(screen.queryByText('巡检运行')).not.toBeInTheDocument() }) }) @@ -263,25 +256,30 @@ describe('workflow run navigation through the app routes', () => { expect(bodies).toHaveLength(1) }) - it('opens an existing run from the history with the same slug and creates nothing', async () => { + it('opens an existing run from the run list with the same slug and creates nothing', async () => { const { router, bodies } = renderAppAt(EDITOR_PATH, [runFixture('run-1', '巡检运行')]) - fireEvent.click(await screen.findByRole('button', { name: '运行历史' })) - fireEvent.click(await screen.findByRole('button', { name: '打开运行 巡检运行' })) + // The history is a tab: the editor's tab bar is the canonical way there, and the run is + // opened from the list rather than from a popover inside the editor. + const tabs = await screen.findByRole('navigation', { name: '工作流的分区' }) + fireEvent.click(within(tabs).getByRole('link', { name: '运行记录' })) + expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs`) - expect(await screen.findByRole('heading', { name: '运行详情' })).toBeInTheDocument() - expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-1`) + fireEvent.click(await screen.findByRole('link', { name: '打开运行 巡检运行' })) + + await waitFor(() => expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-1`)) expect(bodies).toHaveLength(0) }) - it('opens and reloads the run URL directly, with a breadcrumb back to the editor', async () => { + it('opens and reloads the run URL directly, with a breadcrumb back to the run list', async () => { const { router } = renderAppAt(`${EDITOR_PATH}/runs/run-1`, [runFixture('run-1', '巡检运行')]) expect(await screen.findByRole('heading', { name: '运行详情' })).toBeInTheDocument() expect(router.state.location.pathname).toBe(`${EDITOR_PATH}/runs/run-1`) - expect(await screen.findByRole('link', { name: '工作流' })).toHaveAttribute( + // Browsing history returns to the history, not to the editor, so the next run is one click away. + expect(await screen.findByRole('link', { name: '安全审查 · 运行记录' })).toHaveAttribute( 'href', - `/w/${WORKSPACE_SLUG}/workflows`, + `${EDITOR_PATH}/runs`, ) }) }) diff --git a/frontend/src/features/workflows/editor/workflow-run-tools.tsx b/frontend/src/features/workflows/editor/workflow-run-tools.tsx index 76f60787..13ffc3df 100644 --- a/frontend/src/features/workflows/editor/workflow-run-tools.tsx +++ b/frontend/src/features/workflows/editor/workflow-run-tools.tsx @@ -1,25 +1,23 @@ -import { format } from 'date-fns' import { Play, ScrollText } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import { useNavigate, useParams } from 'react-router-dom' -import type { WorkflowRun, WorkflowRunStatus } from '@/api/generated.schemas' -import { Button } from '@/components/ui/button' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { ScrollArea } from '@/components/ui/scroll-area' -import { useWorkflowRuns, useWorkflowSnapshots } from '@/features/workflows/api' +import { Link, useNavigate, useParams } from 'react-router-dom' +import type { WorkflowRun } from '@/api/generated.schemas' +import { Button, buttonVariants } from '@/components/ui/button' +import { useWorkflowSnapshots } from '@/features/workflows/api' import { WorkflowRunDialog } from '@/features/workflows/editor/workflow-run-dialog' -import { RunStatusBadge } from '@/features/workflows/run/run-status-mark' import { workspacePaths } from '@/lib/paths' /** - * The toolbar's run controls: start one run, or browse the run history. + * The toolbar's run controls: start one run, or open the run history. * - * A run executes against a published snapshot, so the primary button is disabled while - * the workflow has none — there is nothing honest to execute a draft against. Starting a - * run opens {@link WorkflowRunDialog}, which is where the version and the run's own input - * are chosen; a started run then navigates to its read-only detail page, while browsing - * history opens the popover whose rows lead to each run's page. + * A run executes against a published snapshot, so the primary button is disabled while the + * workflow has none — there is nothing honest to execute a draft against. Starting a run opens + * {@link WorkflowRunDialog}, which is where the version and the run's own input are chosen; a + * started run then navigates to its read-only detail page. + * + * Browsing history is a link, not a popover: the history has its own tab, so the same URL a member + * can share is the one this control opens, and opening it never starts anything. * * @param props.tenantId - Tenant the workflow belongs to; scopes the API calls only, never a * browser path segment (the route's workspace slug is the one paths are built from). @@ -40,8 +38,6 @@ export function WorkflowRunTools({ const { workspaceSlug } = useParams<{ workspaceSlug: string }>() const paths = workspacePaths(workspaceSlug ?? tenantId) const snapshots = useWorkflowSnapshots(tenantId, workflowId) - const runs = useWorkflowRuns(tenantId, workflowId) - const [historyOpen, setHistoryOpen] = useState(false) const [startOpen, setStartOpen] = useState(false) const noSnapshot = snapshots.data !== undefined && snapshots.data.length === 0 @@ -58,6 +54,14 @@ export function WorkflowRunTools({ {t('workflows.run.runNow')} + + + {t('workflows.run.history')} + - { - setHistoryOpen(next) - if (next) { - void runs.refetch() - } - }} - > - - } - > - - - -
-

{t('workflows.run.history')}

-
- -
-
) /** Leaves the dialog for the run the server just created. */ function openStartedRun(run: WorkflowRun): void { setStartOpen(false) - setHistoryOpen(false) void navigate(paths.workflowRun(run.workflowId, run.id)) } - - /** Navigates to a run's read-only detail page. */ - function openRun(runId: string): void { - setHistoryOpen(false) - void navigate(paths.workflowRun(workflowId, runId)) - } -} - -/** The run history rows: loading / empty / the runs newest first. */ -function RunHistoryList({ - runs, - isPending, - onOpen, -}: { - runs: Array<{ id: string; name: string; status: WorkflowRunStatus; createdAt: string }> - isPending: boolean - onOpen: (runId: string) => void -}) { - const { t } = useTranslation() - if (runs.length === 0) { - return ( - -

- {isPending ? t('workflows.detail.loading') : t('workflows.run.historyEmpty')} -

-
- ) - } - return ( - -
    - {runs.map((run) => ( -
  • - -
  • - ))} -
-
- ) } diff --git a/frontend/src/features/workflows/editor/workflow-version-history.tsx b/frontend/src/features/workflows/editor/workflow-version-history.tsx index bb5d4516..4bcd14a4 100644 --- a/frontend/src/features/workflows/editor/workflow-version-history.tsx +++ b/frontend/src/features/workflows/editor/workflow-version-history.tsx @@ -1,5 +1,5 @@ import { format } from 'date-fns' -import { History, RotateCcw, Search } from 'lucide-react' +import { RotateCcw, Search } from 'lucide-react' import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import type { Workflow as CloudWorkflow, WorkflowSnapshot } from '@/api/generated.schemas' @@ -14,25 +14,26 @@ import { } from '@/components/ui/alert-dialog' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' -import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover' -import { ScrollArea } from '@/components/ui/scroll-area' +import { Skeleton } from '@/components/ui/skeleton' import { useRestoreWorkflowSnapshot, useWorkflowSnapshots } from '@/features/workflows/api' import { workflowErrorKey } from '@/features/workflows/error-messages' /** * The workflow's published versions, newest first, each with a rollback action. * - * Every snapshot is an immutable freeze of the graph at publish time; there is - * no active-version mark, so "rolling back" means restoring that graph over the - * live document — an edit that bumps the document version like any other write. - * A confirmation guards the action because it replaces whatever is on the - * canvas right now, unsaved changes included. + * Every snapshot is an immutable freeze of the graph at publish time; there is no active-version + * mark, so "rolling back" means restoring that graph over the live document — an edit that bumps + * the document version like any other write. A confirmation guards the action because it replaces + * whatever is on the canvas right now, unsaved changes included; this panel is the 「版本」 tab, so + * the member reaches it after the editor has written its draft on the way out. + * + * A published version is what a run executes, which is why this surface stays separate from the + * run history: one lists what the workflow *can* run, the other what it *did* run. * * @param props.tenantId - Tenant the workflow belongs to. - * @param props.workflowId - Workflow whose history is shown. - * @param props.committedVersion - Latest document version the server confirmed; - * a stale workflow report makes the restore 409 rather than clobbering another - * editor. + * @param props.workflowId - Workflow whose versions are shown. + * @param props.committedVersion - Document version the server confirmed; a stale value makes the + * restore a 409 rather than clobbering another editor. * @param props.onRestored - Receives the workflow once its graph was replaced. */ export function WorkflowVersionHistory({ @@ -46,10 +47,8 @@ export function WorkflowVersionHistory({ committedVersion: number onRestored: (workflow: CloudWorkflow) => void }) { - const { t } = useTranslation() const snapshots = useWorkflowSnapshots(tenantId, workflowId) const restore = useRestoreWorkflowSnapshot(tenantId) - const [open, setOpen] = useState(false) const [query, setQuery] = useState('') const [restoreTarget, setRestoreTarget] = useState(null) @@ -66,37 +65,13 @@ export function WorkflowVersionHistory({ }, [query, snapshots.data]) return ( - <> - { - setOpen(next) - if (!next) { - setQuery('') - } - }} - > - - } - > - - - - - - - +
+ + void confirmRestore(snapshot)} /> - +
) - /** Writes the chosen snapshot's graph over the live document, then reloads. */ + /** Writes the chosen snapshot's graph over the live document, then reports the result. */ async function confirmRestore(snapshot: WorkflowSnapshot): Promise { try { const restored = await restore.mutateAsync({ @@ -116,7 +91,6 @@ export function WorkflowVersionHistory({ version: committedVersion, }) setRestoreTarget(null) - setOpen(false) onRestored(restored) } catch { // the fault line above the dialog footer already reports it @@ -124,7 +98,7 @@ export function WorkflowVersionHistory({ } } -/** The popover's heading and the version-name search box. */ +/** The version-name search box above the list. */ function VersionHistorySearch({ query, onQueryChange, @@ -134,23 +108,16 @@ function VersionHistorySearch({ }) { const { t } = useTranslation() return ( - <> -
-

{t('workflows.version.history')}

-
-
-
- - onQueryChange(event.target.value)} - aria-label={t('workflows.version.historySearch')} - placeholder={t('workflows.version.historySearch')} - className="h-8 pl-8 text-xs" - /> -
-
- +
+ + onQueryChange(event.target.value)} + aria-label={t('workflows.version.historySearch')} + placeholder={t('workflows.version.historySearch')} + className="h-8 pl-8 text-xs" + /> +
) } @@ -165,43 +132,41 @@ function VersionHistoryList({ onRollback: (snapshot: WorkflowSnapshot) => void }) { const { t } = useTranslation() + if (isPending) { + return + } if (snapshots.length === 0) { return ( - -

- {isPending ? t('workflows.detail.loading') : t('workflows.version.historyEmpty')} -

-
+
+

{t('workflows.version.historyEmpty')}

+
) } return ( - -
    - {snapshots.map((snapshot) => ( -
  • - -
  • - ))} -
-
+ + + + + ))} + ) } diff --git a/frontend/src/features/workflows/editor/workflow-version-tools.test.tsx b/frontend/src/features/workflows/editor/workflow-version-tools.test.tsx index 4a3969e1..3c319203 100644 --- a/frontend/src/features/workflows/editor/workflow-version-tools.test.tsx +++ b/frontend/src/features/workflows/editor/workflow-version-tools.test.tsx @@ -10,24 +10,14 @@ import { installWorkflowPublish, TEST_TENANT_ID, workflowFixture, + workflowSnapshotFixture, } from '@/test/cloud-handlers' import { server } from '@/test/msw-server' -import { renderWithProviders } from '@/test/render' +import { renderAtRoute, renderWithProviders } from '@/test/render' const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' - -/** A published snapshot as the backend stores it. */ -function snapshotFixture(version: number, name = `版本 ${version}`): WorkflowSnapshot { - return { - id: `snap-${version}`, - tenantId: TEST_TENANT_ID, - workflowId: WORKFLOW_ID, - name, - version, - graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, - createdAt: '2026-09-20T10:00:00+08:00', - } -} +/** The workspace slug the browser route carries, which every link must be built from. */ +const WORKSPACE_SLUG = 'cloud-dev' /** Installs the version endpoints: history listing plus the rollback write. */ function installHistory( @@ -56,7 +46,7 @@ function installHistory( /** Installs the publish endpoint against the shared test handler. */ function installPublish(onBody: (body: unknown) => void, status = 200): void { - installWorkflowPublish(onBody, snapshotFixture(1, '发布 v1'), status) + installWorkflowPublish(onBody, workflowSnapshotFixture(1, { name: '发布 v1' }), status) } /** Mounts the publish dialog, always open, with the given close callback. */ @@ -72,8 +62,11 @@ function renderPublishDialog(onOpenChange: (open: boolean) => void): void { } /** - * Mounts the history popover with the given snapshots behind it, returning the + * Mounts the version panel with the given snapshots behind it, returning the * rollback spy the workflow is handed once a restore lands. + * + * The panel is the 「版本」 tab body, so it is mounted directly: there is no + * popover left to open before the list is reachable. */ function renderHistory( snapshots: WorkflowSnapshot[], @@ -266,13 +259,11 @@ describe('WorkflowPublishDialog', () => { describe('WorkflowVersionHistory', () => { it('lists published versions newest first', async () => { renderHistory([ - snapshotFixture(3, '第三版'), - snapshotFixture(1, '首发'), - snapshotFixture(2, '第二版'), + workflowSnapshotFixture(3, { name: '第三版' }), + workflowSnapshotFixture(1, { name: '首发' }), + workflowSnapshotFixture(2, { name: '第二版' }), ]) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) - const list = await screen.findByRole('list') const labels = within(list) .getAllByRole('button') @@ -283,15 +274,15 @@ describe('WorkflowVersionHistory', () => { it('shows the empty state when nothing was published', async () => { renderHistory([]) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) - expect(await screen.findByText('还没有已发布的版本。发布一个以随时回滚。')).toBeInTheDocument() }) it('filters the list by a version name', async () => { - renderHistory([snapshotFixture(2, '巡检报告'), snapshotFixture(1, '每周发布')]) + renderHistory([ + workflowSnapshotFixture(2, { name: '巡检报告' }), + workflowSnapshotFixture(1, { name: '每周发布' }), + ]) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) await screen.findByRole('button', { name: '回滚到版本 2' }) fireEvent.change(screen.getByLabelText('搜索版本名称或编号'), { target: { value: '巡检' }, @@ -303,9 +294,8 @@ describe('WorkflowVersionHistory', () => { it('restores a snapshot over the live document', async () => { const restored = workflowFixture({ version: 4 }) - const onRestored = renderHistory([snapshotFixture(1, '首发')], restored) + const onRestored = renderHistory([workflowSnapshotFixture(1, { name: '首发' })], restored) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) fireEvent.click(await screen.findByRole('button', { name: '回滚到版本 1' })) expect(await screen.findByRole('heading', { name: '回滚到版本 1?' })).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '回滚' })) @@ -314,9 +304,8 @@ describe('WorkflowVersionHistory', () => { }) it('cancelling the confirmation leaves the document untouched', async () => { - const onRestored = renderHistory([snapshotFixture(1, '首发')]) + const onRestored = renderHistory([workflowSnapshotFixture(1, { name: '首发' })]) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) fireEvent.click(await screen.findByRole('button', { name: '回滚到版本 1' })) fireEvent.click(screen.getByRole('button', { name: '取消' })) @@ -325,9 +314,8 @@ describe('WorkflowVersionHistory', () => { }) it('reports a rejected restore over the confirmation', async () => { - const onRestored = renderHistory([snapshotFixture(1, '首发')], undefined, 409) + const onRestored = renderHistory([workflowSnapshotFixture(1, { name: '首发' })], undefined, 409) - fireEvent.click(screen.getByRole('button', { name: '版本历史' })) fireEvent.click(await screen.findByRole('button', { name: '回滚到版本 1' })) fireEvent.click(screen.getByRole('button', { name: '回滚' })) @@ -337,18 +325,26 @@ describe('WorkflowVersionHistory', () => { }) describe('WorkflowVersionTools', () => { - it('blocks the publish dialog when the draft could not be flushed', async () => { - installHistory([snapshotFixture(1)]) - const flushDraft = vi.fn<() => Promise>().mockResolvedValue(false) - renderWithProviders( + /** + * Mounts the toolbar controls at the real editor route, so the versions link is built from the + * workspace slug the browser carries rather than from the tenant id the API is scoped by. + */ + function renderVersionTools(flushDraft: () => Promise) { + return renderAtRoute( + '/w/:workspaceSlug/workflows/:workflowId', void>()} />, + `/w/${WORKSPACE_SLUG}/workflows/${WORKFLOW_ID}`, ) + } + + it('blocks the publish dialog when the draft could not be flushed', async () => { + installHistory([workflowSnapshotFixture(1)]) + const flushDraft = vi.fn<() => Promise>().mockResolvedValue(false) + renderVersionTools(flushDraft) fireEvent.click(screen.getByRole('button', { name: '发布' })) @@ -357,20 +353,22 @@ describe('WorkflowVersionTools', () => { }) it('opens the publish dialog once the draft is flushed', async () => { - installHistory([snapshotFixture(1)]) + installHistory([workflowSnapshotFixture(1)]) installPublish(() => {}, 200) - renderWithProviders( - Promise>().mockResolvedValue(true)} - onRestored={vi.fn<(workflow: CloudWorkflow) => void>()} - />, - ) + renderVersionTools(vi.fn<() => Promise>().mockResolvedValue(true)) fireEvent.click(screen.getByRole('button', { name: '发布' })) expect(await screen.findByRole('heading', { name: '发布版本' })).toBeInTheDocument() }) + + it('links the version list on its own tab instead of a popover', () => { + installHistory([workflowSnapshotFixture(1)]) + renderVersionTools(vi.fn<() => Promise>().mockResolvedValue(true)) + + expect(screen.getByRole('link', { name: '版本历史' })).toHaveAttribute( + 'href', + `/w/${WORKSPACE_SLUG}/workflows/${WORKFLOW_ID}/versions`, + ) + }) }) diff --git a/frontend/src/features/workflows/editor/workflow-version-tools.tsx b/frontend/src/features/workflows/editor/workflow-version-tools.tsx index dc051282..b80649d2 100644 --- a/frontend/src/features/workflows/editor/workflow-version-tools.tsx +++ b/frontend/src/features/workflows/editor/workflow-version-tools.tsx @@ -1,42 +1,35 @@ import { Rocket } from 'lucide-react' import { useState } from 'react' import { useTranslation } from 'react-i18next' -import type { Workflow as CloudWorkflow } from '@/api/generated.schemas' -import { Button } from '@/components/ui/button' +import { Link, useParams } from 'react-router-dom' +import { Button, buttonVariants } from '@/components/ui/button' import { WorkflowPublishDialog } from '@/features/workflows/editor/workflow-publish-dialog' -import { WorkflowVersionHistory } from '@/features/workflows/editor/workflow-version-history' +import { workspacePaths } from '@/lib/paths' /** - * The toolbar's publish and history controls, kept together because they are - * one story — a version is a freeze of the draft, and history is where a member - * goes to roll back to one. + * The toolbar's publish control, plus the way to the 「版本」 tab. * - * Publishing first flushes the draft so the frozen version is never stale: a - * save that could not persist (a version conflict, a dropped connection) blocks - * the publish dialog instead of freezing the previous content. + * Publishing first flushes the draft so the frozen version is never stale: a save that could not + * persist (a version conflict, a dropped connection) blocks the publish dialog instead of freezing + * the previous content. The version list itself lives on its own tab rather than in a popover, so + * one place answers "what versions exist" and the same place rolls back to them. * * @param props.tenantId - Tenant the workflow belongs to. * @param props.workflowId - Workflow being edited. - * @param props.committedVersion - Latest document version the server confirmed; - * it guards a restore. * @param props.flushDraft - Writes the draft to completion before publishing. - * @param props.onRestored - Receives the workflow once a rollback replaced the - * graph. */ export function WorkflowVersionTools({ tenantId, workflowId, - committedVersion, flushDraft, - onRestored, }: { tenantId: string workflowId: string - committedVersion: number flushDraft: () => Promise - onRestored: (workflow: CloudWorkflow) => void }) { const { t } = useTranslation() + const { workspaceSlug } = useParams<{ workspaceSlug?: string }>() + const paths = workspacePaths(workspaceSlug ?? tenantId) const [publishOpen, setPublishOpen] = useState(false) return ( @@ -50,12 +43,13 @@ export function WorkflowVersionTools({ {t('workflows.version.publish')} - + + {t('workflows.version.history')} +
@@ -125,7 +128,7 @@ function WorkflowRunDetailBody({ )} - {shortId(run.snapshotId)} + {shortRunId(run.snapshotId)}
@@ -177,7 +180,7 @@ function RunTimestamp({ label, iso }: { label: string; iso: string }) { {label}{' '} ) @@ -243,24 +246,3 @@ function memberTitles( ): Map { return new Map(nodes.map((node) => [node.id, node.data.title])) } - -/** Local `YYYY-MM-DD HH:MM` rendering for the header strip. */ -function formatDateTime(iso: string): string { - const date = new Date(iso) - if (Number.isNaN(date.getTime())) { - return '—' - } - return date.toLocaleString([], { - year: 'numeric', - month: '2-digit', - day: '2-digit', - hour: '2-digit', - minute: '2-digit', - hour12: false, - }) -} - -/** Shortens a uuid to its 8-character prefix for compact display. */ -function shortId(id: string): string { - return id.length > 8 ? id.slice(0, 8) : id -} diff --git a/frontend/src/features/workflows/run/run-history-row.tsx b/frontend/src/features/workflows/run/run-history-row.tsx new file mode 100644 index 00000000..6cba561d --- /dev/null +++ b/frontend/src/features/workflows/run/run-history-row.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import type { WorkflowRun } from '@/api/generated.schemas' +import { RunStatusBadge } from '@/features/workflows/run/run-status-mark' +import { formatRunInstant, shortRunId } from '@/features/workflows/run/run-time' + +/** + * One run in the history: when it started and finished, how it ended, which version it executed, + * and the way in. + * + * The row shows the run's whole span, not just the instant it became visible: "when did this take + * how long" is most of why a member reads a history, and a finished run's start alone cannot answer + * it. A run that has not started yet is stated as such, with its creation time, rather than looking + * like one that started at that instant. + * + * The whole row is a link, so a member does not have to aim at a small control to open a run, and + * the snapshot is shown as the frozen version label the rest of the surface uses — the run detail + * page is that same frozen document, never the workflow's current draft. + * + * @param props.run - The run to render. + * @param props.href - Destination of the row's link, the run's read-only detail page. + */ +export function RunHistoryRow({ run, href }: { run: WorkflowRun; href: string }) { + const { t } = useTranslation() + // Both columns are nullable: a run that has not been picked up has no start, and one still going + // has no finish. They are read once here so the two labels cannot disagree about the same field. + const startedAt = run.startedAt ?? '' + const finishedAt = run.finishedAt ?? '' + return ( +
  • + + + {run.name} + + {startedAt === '' + ? t('workflows.runs.notStarted', { time: formatRunInstant(run.createdAt) }) + : t('workflows.runs.started', { time: formatRunInstant(startedAt) })} + + {(startedAt !== '' || finishedAt !== '') && ( + + {finishedAt === '' + ? t('workflows.runs.notFinished') + : t('workflows.runs.finished', { time: formatRunInstant(finishedAt) })} + + )} + + {t('workflows.runs.version', { id: shortRunId(run.snapshotId) })} + + +
  • + ) +} diff --git a/frontend/src/features/workflows/run/run-time.ts b/frontend/src/features/workflows/run/run-time.ts new file mode 100644 index 00000000..a873542a --- /dev/null +++ b/frontend/src/features/workflows/run/run-time.ts @@ -0,0 +1,43 @@ +/** + * How the run surfaces render an instant and an id. + * + * One implementation because a run's timestamps and snapshot ids appear on the history row, in the + * detail header and in the inspector; three copies would drift the moment one of them is adjusted. + */ + +/** + * A run instant as local `YYYY-MM-DD HH:MM`, or an em dash. + * + * An unparseable value renders as a dash rather than `Invalid Date`: the raw string came from the + * server, and a page that cannot read it must still be readable. + * + * @param iso - Timestamp the API returned. + * @returns The local date-time with minute precision. + */ +export function formatRunInstant(iso: string): string { + const date = new Date(iso) + if (Number.isNaN(date.getTime())) { + return '—' + } + return date.toLocaleString([], { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }) +} + +/** + * A uuid shortened to its 8-character prefix. + * + * Snapshot ids identify a frozen version and are never typed by hand, so the prefix is enough to + * tell two of them apart on screen while staying compact enough to sit in a row. + * + * @param id - The full identifier. + * @returns At most eight characters of it. + */ +export function shortRunId(id: string): string { + return id.length > 8 ? id.slice(0, 8) : id +} diff --git a/frontend/src/features/workflows/translations.ts b/frontend/src/features/workflows/translations.ts index 6f417319..d47e1303 100644 --- a/frontend/src/features/workflows/translations.ts +++ b/frontend/src/features/workflows/translations.ts @@ -1,6 +1,7 @@ import { publishErrorTranslations } from '@/features/workflows/publish-error-translations' import { historyTranslations } from '@/features/workflows/history-translations' import { launchFieldTranslations } from '@/features/workflows/launch-field-translations' +import { runAndVersionTranslations } from '@/features/workflows/run-and-version-translations' import { aggregatorTranslations } from '@/features/workflows/aggregator-translations' import { loopTranslations } from '@/features/workflows/loop-translations' import { runRoundTranslations } from '@/features/workflows/run-round-translations' @@ -26,6 +27,7 @@ import type { TranslationBundle } from '@/i18n/resource-bundle' */ export const workflowTranslations = { 'zh-CN': { + ...runAndVersionTranslations['zh-CN'], ...historyTranslations['zh-CN'], ...publishErrorTranslations['zh-CN'], ...launchFieldTranslations['zh-CN'], @@ -57,6 +59,10 @@ export const workflowTranslations = { 'workflows.list.open': '打开{{name}}', 'workflows.list.nodeCount': '{{count}} 个节点', 'workflows.list.updatedAt': '更新于 {{time}}', + 'workflows.list.neverRan': '从未运行', + 'workflows.list.latestRunAt': '{{time}} 运行', + 'workflows.list.openLatestRun': '打开{{name}}最近一次运行', + 'workflows.list.runsHint': '不进入编辑器,直接查看这个工作流的运行记录', 'workflows.list.deleteNamed': '删除{{name}}', 'workflows.list.deleteTitle': '删除“{{name}}”?', 'workflows.list.deleteDescription': '该工作流将从列表中移除,其名称可以被重新使用。', @@ -252,25 +258,6 @@ export const workflowTranslations = { '文件只引用节点与变量本身,不包含凭据、模型绑定或环境特定信息。', 'workflows.transfer.confirmExport': '导出', - // Version history and publishing. - 'workflows.version.publish': '发布', - 'workflows.version.publishHint': '把当前画布冻结为一个不可变版本', - 'workflows.version.history': '版本历史', - 'workflows.version.historyHint': '查看已发布的版本并回滚', - 'workflows.version.publishTitle': '发布版本', - 'workflows.version.publishDescription': - '发布会把当前文档冻结为一个不可变版本;之后可以随时回滚到这里。', - 'workflows.version.versionName': '版本名称(可选)', - 'workflows.version.versionPlaceholder': '例如:v1.0 发布说明', - 'workflows.version.publishedAt': '发布于 {{time}}', - 'workflows.version.historyEmpty': '还没有已发布的版本。发布一个以随时回滚。', - 'workflows.version.historySearch': '搜索版本名称或编号', - 'workflows.version.restore': '回滚', - 'workflows.version.restoreTitle': '回滚到版本 {{version}}?', - 'workflows.version.restoreDescription': - '回滚会用该版本冻结的图替换当前画布,未保存的修改会丢失。', - 'workflows.version.rollbackNamed': '回滚到版本 {{version}}', - // Workflow-wide variable declarations edited from the variables dialog. 'workflows.globalVariables.title': '全局变量', 'workflows.globalVariables.description': @@ -376,8 +363,8 @@ export const workflowTranslations = { 'workflows.run.start': '开始运行', 'workflows.run.noInput': '这个版本没有需要填写的输入。', 'workflows.run.missingRequired': '还需要填写:{{fields}}', - 'workflows.run.history': '运行历史', - 'workflows.run.historyHint': '查看这个工作流的运行历史', + 'workflows.run.history': '运行记录', + 'workflows.run.historyHint': '查看这个工作流的历次运行记录', 'workflows.run.historyEmpty': '还没有运行记录。', 'workflows.run.openRun': '打开运行 {{name}}', 'workflows.run.startedAt': '{{time}} 开始', @@ -418,6 +405,7 @@ export const workflowTranslations = { 'workflows.errors.unknown': '操作失败,请稍后重试。', }, 'en-US': { + ...runAndVersionTranslations['en-US'], ...historyTranslations['en-US'], ...publishErrorTranslations['en-US'], ...launchFieldTranslations['en-US'], @@ -448,6 +436,10 @@ export const workflowTranslations = { 'workflows.list.open': 'Open {{name}}', 'workflows.list.nodeCount': '{{count}} nodes', 'workflows.list.updatedAt': 'Updated {{time}}', + 'workflows.list.neverRan': 'Never run', + 'workflows.list.latestRunAt': 'Ran {{time}}', + 'workflows.list.openLatestRun': 'Open the latest run of {{name}}', + 'workflows.list.runsHint': 'See this workflow’s runs without opening the editor', 'workflows.list.deleteNamed': 'Delete {{name}}', 'workflows.list.deleteTitle': 'Delete “{{name}}”?', 'workflows.list.deleteDescription': @@ -647,25 +639,6 @@ export const workflowTranslations = { 'The file references only nodes and variables themselves, never credentials, model bindings, or environment-specific details.', 'workflows.transfer.confirmExport': 'Export', - // Version history and publishing. - 'workflows.version.publish': 'Publish', - 'workflows.version.publishHint': 'Freeze the current canvas as an immutable version', - 'workflows.version.history': 'Version history', - 'workflows.version.historyHint': 'View published versions and roll back', - 'workflows.version.publishTitle': 'Publish version', - 'workflows.version.publishDescription': - 'Publishing freezes the current document as an immutable version you can roll back to at any time.', - 'workflows.version.versionName': 'Version name (optional)', - 'workflows.version.versionPlaceholder': 'e.g. v1.0 release notes', - 'workflows.version.publishedAt': 'Published {{time}}', - 'workflows.version.historyEmpty': 'No published versions yet. Publish one to enable rollbacks.', - 'workflows.version.historySearch': 'Search version name or number', - 'workflows.version.restore': 'Roll back', - 'workflows.version.restoreTitle': 'Roll back to version {{version}}?', - 'workflows.version.restoreDescription': - 'Rolling back replaces the current canvas with the graph frozen in that version; unsaved changes are lost.', - 'workflows.version.rollbackNamed': 'Roll back to version {{version}}', - 'workflows.globalVariables.title': 'Global variables', 'workflows.globalVariables.description': 'Global variables are available to every node. sys.workflow_id and sys.timestamp are supplied by the runtime.', @@ -776,8 +749,8 @@ export const workflowTranslations = { 'workflows.run.start': 'Start run', 'workflows.run.noInput': 'This version asks for no input.', 'workflows.run.missingRequired': 'Still needed: {{fields}}', - 'workflows.run.history': 'Run history', - 'workflows.run.historyHint': 'Browse this workflow’s run history', + 'workflows.run.history': 'Runs', + 'workflows.run.historyHint': 'Browse every run of this workflow', 'workflows.run.historyEmpty': 'No runs yet.', 'workflows.run.openRun': 'Open run {{name}}', 'workflows.run.startedAt': 'Started {{time}}', diff --git a/frontend/src/features/workflows/workflow-detail-shell.tsx b/frontend/src/features/workflows/workflow-detail-shell.tsx new file mode 100644 index 00000000..3a26c361 --- /dev/null +++ b/frontend/src/features/workflows/workflow-detail-shell.tsx @@ -0,0 +1,78 @@ +import type { ReactNode } from 'react' +import { useTranslation } from 'react-i18next' +import { NavLink, useParams } from 'react-router-dom' +import { PageHeader } from '@/components/layout/page-header' +import type { TranslationKey } from '@/i18n/i18n-instance' +import { workspacePaths } from '@/lib/paths' +import { cn } from '@/lib/utils' + +/** One tab of the workflow detail page: its route and the key naming it. */ +interface WorkflowTabLink { + id: string + to: string + labelKey: TranslationKey +} + +/** + * The frame every workflow tab shares: the header, the three tab links, and the body. + * + * The tabs are real routes rather than local state, so each one has a URL a member can share, + * bookmark or reach from a run's breadcrumb — the discoverability this page exists to provide. + * They also keep the three surfaces apart by construction: the editor is unmounted while a member + * browses runs, so no draft state leaks into a history read. The editor guards navigation until + * any outstanding autosave completes, including navigation through these tabs. + * + * The current tab is decided by the router, not by a prop: one source for the highlight keeps the + * header from disagreeing with the address bar after a navigation. + * + * @param props.title - Name of the workflow, or the list title while it is still resolving. + * @param props.workflowId - Workflow whose tabs these are. + * @param props.children - Body of the tab the route selected. + */ +export function WorkflowDetailShell({ + title, + workflowId, + children, +}: { + title: ReactNode + workflowId: string + children: ReactNode +}) { + const { t } = useTranslation() + const { workspaceSlug } = useParams<{ workspaceSlug?: string }>() + const paths = workspacePaths(workspaceSlug ?? '') + const tabs: WorkflowTabLink[] = [ + { id: 'editor', to: paths.workflowDetail(workflowId), labelKey: 'workflows.tabs.editor' }, + { id: 'runs', to: paths.workflowRuns(workflowId), labelKey: 'workflows.run.history' }, + { id: 'versions', to: paths.workflowVersions(workflowId), labelKey: 'workflows.tabs.versions' }, + ] + + return ( +
    + + +
    {children}
    +
    + ) +} diff --git a/frontend/src/features/workflows/workflow-editor-page.tsx b/frontend/src/features/workflows/workflow-editor-page.tsx index 4cffd2c7..68136cee 100644 --- a/frontend/src/features/workflows/workflow-editor-page.tsx +++ b/frontend/src/features/workflows/workflow-editor-page.tsx @@ -1,14 +1,13 @@ -import type { ReactNode } from 'react' import { useTranslation } from 'react-i18next' import { useNavigate, useParams } from 'react-router-dom' -import { PageHeader } from '@/components/layout/page-header' import { Skeleton } from '@/components/ui/skeleton' import { useWorkflow } from '@/features/workflows/api' +import { WorkflowDetailShell } from '@/features/workflows/workflow-detail-shell' import { WorkflowEditor } from '@/features/workflows/editor/workflow-editor' import { workspacePaths } from '@/lib/paths' /** - * The workflow a member opened, in the editor. + * The 「编辑」 tab of a workflow: the canvas, under the workflow's own name. * * The page owns the fetch and nothing else: it resolves the workflow, then hands * the loaded record to the editor, which owns the draft from that point on. The @@ -24,52 +23,35 @@ export function WorkflowEditorPage({ slug }: { slug: string }) { const paths = workspacePaths(workspaceSlug ?? slug) const workflow = useWorkflow(slug, workflowId) const title = t('workflows.list.title') - const breadcrumb = { label: title, to: paths.workflows } + const id = workflowId ?? '' if (workflow.isPending) { return ( - +
    -
    + ) } if (!workflow.data) { return ( - +

    {t('workflows.detail.notFound')}

    -
    + ) } return ( - + navigate(paths.workflowDetail(importedId))} /> - - ) -} - -/** The header and full-height column every state of the page shares. */ -function WorkflowPageShell({ - title, - breadcrumb, - children, -}: { - title: string - breadcrumb: { label: string; to: string } - children: ReactNode -}) { - return ( -
    - - {children} -
    + ) } diff --git a/frontend/src/features/workflows/workflow-navigation.test.tsx b/frontend/src/features/workflows/workflow-navigation.test.tsx new file mode 100644 index 00000000..ed2ef937 --- /dev/null +++ b/frontend/src/features/workflows/workflow-navigation.test.tsx @@ -0,0 +1,223 @@ +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { http, HttpResponse } from 'msw' +import { beforeEach, describe, expect, it } from 'vitest' +import { CurrentSpaceProvider } from '@/features/spaces/current-space' +import { WorkflowEditorPage } from '@/features/workflows/workflow-editor-page' +import { WorkflowRunsPage } from '@/features/workflows/workflow-runs-page' +import { WorkflowVersionsPage } from '@/features/workflows/workflow-versions-page' +import { isJsonRecord } from '@/features/workflows/runtime/json-record' +import { + installCloudSpaceHandlers, + installSpaceProjectHandlers, + TEST_TENANT_ID, + chooseProject, + workflowFixture, + workflowSnapshotFixture, +} from '@/test/cloud-handlers' +import { server } from '@/test/msw-server' +import { renderRoutes } from '@/test/render' +import { stubViewportGeometry } from '@/test/viewport' + +const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' +const BASE = `/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}` +const PATH = `/w/cloud-dev/workflows/${WORKFLOW_ID}` + +beforeEach(() => stubViewportGeometry()) + +/** Mounts the real editor with a rejected write and destinations for the blocked routes. */ +function renderFailedSave(status: 404 | 409) { + installCloudSpaceHandlers('admin') + installSpaceProjectHandlers() + let writes = 0 + let createdRuns = 0 + server.use( + http.get(BASE, () => HttpResponse.json(workflowFixture({ id: WORKFLOW_ID }))), + http.get(`${BASE}/snapshots`, () => + HttpResponse.json({ items: [workflowSnapshotFixture(1)], nextCursor: '' }), + ), + http.get(`${BASE}/runs`, () => HttpResponse.json({ items: [], nextCursor: '' })), + http.put(BASE, () => { + writes += 1 + return HttpResponse.json( + { code: status === 404 ? 'not_found' : 'workflow_conflict' }, + { status }, + ) + }), + http.post(`${BASE}/runs`, () => { + createdRuns += 1 + return HttpResponse.json({ resource: { id: 'run-new', workflowId: WORKFLOW_ID } }) + }), + ) + const { router } = renderRoutes( + [ + { + path: '/w/:workspaceSlug/workflows/:workflowId', + element: ( + + + + ), + }, + { path: '/w/:workspaceSlug/workflows/:workflowId/runs', element:

    运行记录已打开

    }, + { + path: '/w/:workspaceSlug/workflows/:workflowId/runs/:runId', + element:

    运行详情已打开

    , + }, + { path: '/w/:workspaceSlug/issues', element:

    议题列表已打开

    }, + ], + PATH, + ) + return { router, writes: () => writes, createdRuns: () => createdRuns } +} + +/** Makes one unsaved edit and asks to open the run history. */ +async function attemptHistoryNavigation(): Promise { + const palette = await screen.findByRole('toolbar', { name: '添加工作流节点' }) + fireEvent.click(within(palette).getByRole('button', { name: '输出' })) + fireEvent.click( + within(screen.getByRole('navigation', { name: '工作流的分区' })).getByRole('link', { + name: '运行记录', + }), + ) +} + +describe('workflow draft navigation', () => { + it.each([ + { entry: '运行记录标签', label: '运行记录', destination: 'runs' }, + { entry: '版本标签', label: '版本', destination: 'versions' }, + { entry: '运行记录工具栏', label: '查看这个工作流的历次运行记录', destination: 'runs' }, + { entry: '版本工具栏', label: '查看已发布的版本并回滚', destination: 'versions' }, + ])( + 'keeps the edited canvas through $entry until its save completes', + async ({ entry, label, destination }) => { + installCloudSpaceHandlers('admin') + installSpaceProjectHandlers() + let stored = workflowFixture({ id: WORKFLOW_ID }) + let releaseSave: (() => void) | undefined + const saveGate = new Promise((resolve) => { + releaseSave = () => resolve() + }) + let writes = 0 + server.use( + http.get(BASE, () => HttpResponse.json(stored)), + http.get(`${BASE}/snapshots`, () => HttpResponse.json({ items: [], nextCursor: '' })), + http.get(`${BASE}/runs`, () => HttpResponse.json({ items: [], nextCursor: '' })), + http.put(BASE, async ({ request }) => { + const body: unknown = await request.json() + if (!isJsonRecord(body) || !isJsonRecord(body['graph'])) { + return HttpResponse.json({ code: 'invalid_request' }, { status: 400 }) + } + writes += 1 + await saveGate + stored = workflowFixture({ id: WORKFLOW_ID, graph: body['graph'], version: 2 }) + return HttpResponse.json(stored) + }), + ) + const { router } = renderRoutes( + [ + { + path: '/w/:workspaceSlug/workflows/:workflowId', + element: ( + + + + ), + }, + { + path: '/w/:workspaceSlug/workflows/:workflowId/runs', + element: ( + + + + ), + }, + { + path: '/w/:workspaceSlug/workflows/:workflowId/versions', + element: ( + + + + ), + }, + ], + PATH, + ) + + const palette = await screen.findByRole('toolbar', { name: '添加工作流节点' }) + fireEvent.click(within(palette).getByRole('button', { name: '输出' })) + expect(screen.getByText(/1 个节点/)).toBeInTheDocument() + + const link = entry.endsWith('标签') + ? within(screen.getByRole('navigation', { name: '工作流的分区' })).getByRole('link', { + name: label, + }) + : screen.getByTitle(label) + fireEvent.click(link) + await waitFor(() => expect(writes).toBe(1)) + expect(router.state.location.pathname).toBe(PATH) + expect(screen.getByText(/1 个节点/)).toBeInTheDocument() + + await act(async () => releaseSave?.()) + await waitFor(() => expect(router.state.location.pathname).toBe(`${PATH}/${destination}`)) + fireEvent.click( + within(screen.getByRole('navigation', { name: '工作流的分区' })).getByRole('link', { + name: '编辑', + }), + ) + await screen.findByRole('toolbar', { name: '添加工作流节点' }) + expect(stored.graph).toMatchObject({ + nodes: [expect.objectContaining({ id: 'output-1' })], + }) + expect(screen.getByText(/1 个节点/)).toBeInTheDocument() + }, + ) + + it.each([409, 404] as const)( + 'offers a way out after a %i save failure without repeating the rejected write', + async (status) => { + const { router, writes } = renderFailedSave(status) + await attemptHistoryNavigation() + + expect(await screen.findByRole('heading', { name: '草稿保存失败' })).toBeInTheDocument() + expect(writes()).toBe(1) + fireEvent.click(screen.getByRole('button', { name: '留在编辑器' })) + expect(router.state.location.pathname).toBe(PATH) + expect(screen.getByText(/1 个节点/)).toBeInTheDocument() + + await act(async () => { + await router.navigate('/w/cloud-dev/issues') + }) + expect(await screen.findByRole('heading', { name: '草稿保存失败' })).toBeInTheDocument() + expect(writes()).toBe(1) + fireEvent.click(screen.getByRole('button', { name: '放弃修改并离开' })) + expect(await screen.findByText('议题列表已打开')).toBeInTheDocument() + expect(router.state.location.pathname).toBe('/w/cloud-dev/issues') + expect(writes()).toBe(1) + }, + ) + + it('opens the created run after the member discards a draft that cannot be saved', async () => { + const user = userEvent.setup() + const { router, writes, createdRuns } = renderFailedSave(409) + await attemptHistoryNavigation() + await screen.findByRole('heading', { name: '草稿保存失败' }) + fireEvent.click(screen.getByRole('button', { name: '留在编辑器' })) + + const runButton = screen.getByRole('button', { name: '运行' }) + await waitFor(() => expect(runButton).toBeEnabled()) + await user.click(runButton) + await chooseProject() + await user.click(await screen.findByRole('button', { name: '开始运行' })) + + expect(await screen.findByRole('heading', { name: '草稿保存失败' })).toBeInTheDocument() + expect(router.state.location.pathname).toBe(PATH) + expect(createdRuns()).toBe(1) + expect(writes()).toBe(1) + fireEvent.click(screen.getByRole('button', { name: '放弃修改并离开' })) + expect(await screen.findByText('运行详情已打开')).toBeInTheDocument() + expect(router.state.location.pathname).toBe(`${PATH}/runs/run-new`) + expect(createdRuns()).toBe(1) + expect(writes()).toBe(1) + }) +}) diff --git a/frontend/src/features/workflows/workflow-row.tsx b/frontend/src/features/workflows/workflow-row.tsx index bb14733a..b84efedc 100644 --- a/frontend/src/features/workflows/workflow-row.tsx +++ b/frontend/src/features/workflows/workflow-row.tsx @@ -1,10 +1,10 @@ import { format } from 'date-fns' -import { MoreHorizontal, Pencil, Trash2, Workflow } from 'lucide-react' +import { MoreHorizontal, Pencil, ScrollText, Trash2, Workflow } from 'lucide-react' import { useMemo } from 'react' import { useTranslation } from 'react-i18next' import { Link } from 'react-router-dom' import type { Workflow as CloudWorkflow } from '@/api/generated.schemas' -import { Button } from '@/components/ui/button' +import { Button, buttonVariants } from '@/components/ui/button' import { DropdownMenu, DropdownMenuContent, @@ -12,29 +12,40 @@ import { DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { parseWorkflowGraphValue } from '@/features/workflows/runtime/graph-codec' +import { RunStatusBadge } from '@/features/workflows/run/run-status-mark' +import { cn } from '@/lib/utils' /** - * One workflow in the list: its name, what it does, and the actions a member - * can take on it. The row is a link, so the whole target is clickable; the - * actions menu stops propagation so opening it never navigates. + * One workflow in the list: its name, its newest run, and the actions a member can take on it. * - * The node count comes from the stored graph document rather than a column, - * because the graph is opaque to the API by design — only the editor reads - * inside it, and this count is a preview of what the editor will open. + * The row used to lead only into the editor, which is why "what did this workflow do last" was + * invisible from the list. The last-run label answers that here, and the run-history control opens + * the workflow's run list directly, so browsing runs does not require opening the editor first. + * Both are read-only paths: neither starts a run. * - * @param props.workflow - The workflow to render. - * @param props.href - Destination of the row's link. + * Each of the two actions is a link of its own rather than a click handler on the row, so a member + * can open the editor, the runs, or a specific run in a new tab — and the row's own link keeps + * pointing at the editor, the destination it always had. + * + * @param props.workflow - The workflow to render, including its newest run when it has one. + * @param props.href - Destination of the row's main link, the workflow's editor. + * @param props.runsHref - Destination of the run-history control. + * @param props.runHref - Builds a run's detail destination from its id. * @param props.onRename - Opens the rename dialog for this workflow. * @param props.onDelete - Opens the delete confirmation for this workflow. */ export function WorkflowRow({ workflow, href, + runsHref, + runHref, onRename, onDelete, }: { workflow: CloudWorkflow href: string + runsHref: string + runHref: (runId: string) => string onRename: (workflow: CloudWorkflow) => void onDelete: (workflow: CloudWorkflow) => void }) { @@ -43,6 +54,7 @@ export function WorkflowRow({ () => parseWorkflowGraphValue(workflow.graph).nodes.length, [workflow.graph], ) + const latestRun = workflow.latestRun ?? undefined return (
    @@ -59,7 +71,33 @@ export function WorkflowRow({ {workflow.description || t('workflows.list.nodeCount', { count: nodeCount })}

    - + {latestRun === undefined ? ( + + {t('workflows.list.neverRan')} + + ) : ( + + + + {t('workflows.list.latestRunAt', { + time: format(new Date(latestRun.startedAt ?? latestRun.createdAt), 'M月d日 HH:mm'), + })} + + + )} + + + {t('workflows.run.history')} + + {t('workflows.list.updatedAt', { time: format(new Date(workflow.updatedAt), 'M月d日') })} diff --git a/frontend/src/features/workflows/workflow-runs-page.test.tsx b/frontend/src/features/workflows/workflow-runs-page.test.tsx new file mode 100644 index 00000000..63d70530 --- /dev/null +++ b/frontend/src/features/workflows/workflow-runs-page.test.tsx @@ -0,0 +1,385 @@ +import { act, fireEvent, screen, waitFor, within } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { afterEach, describe, expect, it, vi } from 'vitest' +import type { WorkflowRun } from '@/api/generated.schemas' +import { WORKFLOW_RUN_PAGE_SIZE, WORKFLOW_RUN_POLL_MS } from '@/features/workflows/api' +import { WorkflowRunsPage } from '@/features/workflows/workflow-runs-page' +import { + installCloudSpaceHandlers, + installWorkflowHandlers, + TEST_TENANT_ID, + workflowFixture, +} from '@/test/cloud-handlers' +import { server } from '@/test/msw-server' +import { renderAtRoute } from '@/test/render' + +const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' +const WORKSPACE_SLUG = 'cloud-dev' +const WORKFLOW_URL = `/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}` +const RUNS_URL = `/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}/runs` +const RUNS_PATH = `/w/${WORKSPACE_SLUG}/workflows/${WORKFLOW_ID}/runs` + +/** A run row the history endpoints return; ids stay distinct so a repeat would be visible. */ +function runFixture(index: number, overrides: Partial = {}): WorkflowRun { + return { + id: `run-${String(index).padStart(3, '0')}`, + tenantId: TEST_TENANT_ID, + workflowId: WORKFLOW_ID, + snapshotId: 'snap-1', + name: `第 ${index} 次运行`, + workflowName: '安全审查', + status: 'succeeded', + input: {}, + nodeStates: {}, + rounds: [], + error: '', + definitionSnapshot: null, + startedAt: '2026-09-20T02:00:00.000Z', + finishedAt: '2026-09-20T02:00:10.000Z', + cancelRequestedAt: null, + pluginVersion: '1.0.0', + createdAt: '2026-09-20T02:00:00.000Z', + updatedAt: '2026-09-20T02:00:10.000Z', + ...overrides, + } +} + +/** + * Serves a newest-first history of `total` runs, `limit` rows at a time, using the row id as the + * cursor exactly like the backend's opaque UUID cursor. `after` is exclusive, so a page boundary + * that repeated or skipped a row would show up as a duplicate or a missing name on screen. + */ +function installHistory(total: number, onCursor?: (after: string) => void): void { + const runs = Array.from({ length: total }, (_, index) => runFixture(index + 1)) + server.use( + http.get(RUNS_URL, ({ request }) => { + const params = new URL(request.url).searchParams + const after = params.get('after') ?? '' + onCursor?.(after) + const limit = Number(params.get('limit') ?? '50') + const start = after === '' ? 0 : runs.findIndex((run) => run.id === after) + 1 + const page = runs.slice(start, start + limit) + const more = start + limit < runs.length + return HttpResponse.json({ items: page, nextCursor: more ? page[page.length - 1]?.id : '' }) + }), + ) +} + +/** + * Serves a two-row head page and answers every later page with `code` at `status`. + * + * The two footer failures differ only in what the later page refused with, so the shape of the read + * stays in one place: a test says how the walk fails, not how the walk is built. + */ +function installHeadThenRefusal(status: number, code: string): void { + server.use( + http.get(RUNS_URL, ({ request }) => { + const after = new URL(request.url).searchParams.get('after') ?? '' + if (after === '') { + return HttpResponse.json({ + items: [runFixture(1), runFixture(2)], + nextCursor: 'run-002', + }) + } + return HttpResponse.json({ code }, { status }) + }), + ) +} + +/** Mounts the runs tab behind a real route match, so the workspace slug comes from the URL. */ +function renderRunsPage(configureReads?: () => void) { + installCloudSpaceHandlers('admin') + installWorkflowHandlers([workflowFixture({ id: WORKFLOW_ID })]) + configureReads?.() + return renderAtRoute( + '/w/:workspaceSlug/workflows/:workflowId/runs', + , + RUNS_PATH, + ) +} + +/** The run names currently rendered, in display order. */ +function renderedRunNames(): string[] { + return screen + .getAllByRole('listitem') + .map((row) => within(row).getByRole('link').getAttribute('aria-label') ?? '') +} + +/** + * Presses "load older" until the list holds `total` rows. + * + * Recursive rather than a loop because each step has to wait for the page to render before the + * next click: awaiting inside a loop would race the render it just triggered. + */ +async function loadEveryPage(total: number): Promise { + const loaded = screen.getAllByRole('listitem').length + if (loaded >= total) return + fireEvent.click(screen.getByRole('button', { name: '加载更早的运行' })) + await waitFor(() => expect(screen.getAllByRole('listitem').length).toBeGreaterThan(loaded)) + await loadEveryPage(total) +} + +describe('WorkflowRunsPage', () => { + it('reports denied workflow and history reads as forbidden, not missing', async () => { + renderRunsPage(() => + server.use( + http.get(WORKFLOW_URL, () => HttpResponse.json({ code: 'forbidden' }, { status: 403 })), + http.get(RUNS_URL, () => HttpResponse.json({ code: 'forbidden' }, { status: 403 })), + ), + ) + + expect(await screen.findByText('没有查看运行记录的权限')).toBeInTheDocument() + expect(screen.queryByText('未找到该工作流。')).not.toBeInTheDocument() + expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument() + }) + + it('retries a failed workflow read and restores the history without starting a run', async () => { + let detailReads = 0 + const historyReads: string[] = [] + installHistory(1, (cursor) => historyReads.push(cursor)) + renderRunsPage(() => + server.use( + http.get(WORKFLOW_URL, () => { + detailReads += 1 + return detailReads === 1 + ? HttpResponse.json({ code: 'internal' }, { status: 500 }) + : HttpResponse.json(workflowFixture({ id: WORKFLOW_ID })) + }), + ), + ) + + expect(await screen.findByText('运行记录读取失败')).toBeInTheDocument() + expect(screen.queryByText('未找到该工作流。')).not.toBeInTheDocument() + await waitFor(() => expect(historyReads).toEqual([''])) + fireEvent.click(screen.getByRole('button', { name: '重试' })) + + expect(await screen.findByText('第 1 次运行')).toBeInTheDocument() + expect(detailReads).toBe(2) + expect(historyReads).toEqual(['']) + expect(screen.queryByText('运行记录读取失败')).not.toBeInTheDocument() + }) + + it('reports a genuinely missing workflow without offering a read retry', async () => { + installHistory(0) + renderRunsPage(() => + server.use( + http.get(WORKFLOW_URL, () => HttpResponse.json({ code: 'not_found' }, { status: 404 })), + ), + ) + + expect(await screen.findByText('未找到该工作流。')).toBeInTheDocument() + expect(screen.queryByRole('button', { name: '重试' })).not.toBeInTheDocument() + expect(screen.queryByText('运行记录读取失败')).not.toBeInTheDocument() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('lists the newest run first and links into its detail page', async () => { + installHistory(3) + renderRunsPage() + + await screen.findByText('第 1 次运行') + expect(renderedRunNames()).toEqual([ + '打开运行 第 1 次运行', + '打开运行 第 2 次运行', + '打开运行 第 3 次运行', + ]) + const firstRow = screen.getAllByRole('listitem')[0] + expect(firstRow === undefined ? null : within(firstRow).getByRole('link')).toHaveAttribute( + 'href', + `${RUNS_PATH}/run-001`, + ) + }) + + it('says the workflow has no runs instead of showing an empty list', async () => { + installHistory(0) + renderRunsPage() + + expect(await screen.findByText('还没有运行记录')).toBeInTheDocument() + expect(screen.queryByRole('listitem')).not.toBeInTheDocument() + }) + + it('offers a retry when the history cannot be read, rather than calling it empty', async () => { + server.use(http.get(RUNS_URL, () => HttpResponse.json({ code: 'internal' }, { status: 500 }))) + renderRunsPage() + + expect(await screen.findByText('运行记录读取失败')).toBeInTheDocument() + expect(screen.queryByText('还没有运行记录')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: '重试' })).toBeInTheDocument() + }) + + it('reports a missing permission as its own state', async () => { + server.use(http.get(RUNS_URL, () => HttpResponse.json({ code: 'forbidden' }, { status: 403 }))) + renderRunsPage() + + expect(await screen.findByText('没有查看运行记录的权限')).toBeInTheDocument() + expect(screen.queryByText('运行记录读取失败')).not.toBeInTheDocument() + }) + + it('loads every page past the first hundred runs without repeating or skipping one', async () => { + // The old surface asked for one page of 100 and stopped; a history longer than that has to + // stay fully reachable, so this walks the whole thing the way a member would. + const total = 120 + const cursors: string[] = [] + installHistory(total, (after) => cursors.push(after)) + renderRunsPage() + + await screen.findByText('第 1 次运行') + expect(screen.getAllByRole('listitem')).toHaveLength(WORKFLOW_RUN_PAGE_SIZE) + + await loadEveryPage(total) + + expect(renderedRunNames()).toHaveLength(total) + expect(new Set(renderedRunNames()).size).toBe(total) + expect(renderedRunNames()[0]).toBe('打开运行 第 1 次运行') + expect(renderedRunNames()[total - 1]).toBe(`打开运行 第 ${total} 次运行`) + // Each request continued below the row the previous page ended on: the first asks for the head, + // every later one carries a cursor, and no cursor is repeated. + expect(cursors[0]).toBe('') + expect(new Set(cursors.slice(1)).size).toBe(cursors.length - 1) + expect(await screen.findByText('已经到最早的一次运行。')).toBeInTheDocument() + }) + + it('keeps the rows it already loaded when a later page fails', async () => { + // The head answers with two rows and a cursor; every later page fails. The loaded history + // must survive, and the failure has to read as a failed read rather than as the end of it. + installHeadThenRefusal(500, 'internal') + renderRunsPage() + await screen.findByText('第 1 次运行') + + fireEvent.click(screen.getByRole('button', { name: '加载更早的运行' })) + + expect(await screen.findByText('更早的运行读取失败。')).toBeInTheDocument() + expect(screen.getByText('第 1 次运行')).toBeInTheDocument() + expect(screen.getByText('第 2 次运行')).toBeInTheDocument() + // The control that failed is still there, so the retry is the same button. + expect(screen.getByRole('button', { name: '加载更早的运行' })).toBeInTheDocument() + }) + + it('reports a permission loss on a later page as a permission state, not a paging error', async () => { + // Losing the role mid-walk is not a transient read failure: the rest of the history is now + // refused, and telling the member it "could not be loaded" would invite a pointless retry. + installHeadThenRefusal(403, 'forbidden') + renderRunsPage() + await screen.findByText('第 1 次运行') + + fireEvent.click(screen.getByRole('button', { name: '加载更早的运行' })) + + expect(await screen.findByText('没有查看运行记录的权限')).toBeInTheDocument() + expect(screen.queryByText('更早的运行读取失败。')).not.toBeInTheDocument() + // The rows already read stay: they are still this member's history. + expect(screen.getByText('第 1 次运行')).toBeInTheDocument() + expect(screen.getByText('第 2 次运行')).toBeInTheDocument() + }) + + it('offers a retry when a refresh fails after the walk reached its end', async () => { + // One page ends the walk, so there is no "load older" control to double as the retry. A failed + // refresh must still be reported and retryable; calling it the end of the history would say + // "this list is complete" about a list the page just failed to read. + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let reads = 0 + server.use( + http.get(RUNS_URL, () => { + reads += 1 + if (reads === 1) { + return HttpResponse.json({ + items: [runFixture(1, { status: 'running', finishedAt: null })], + nextCursor: '', + }) + } + return HttpResponse.json({ code: 'internal' }, { status: 500 }) + }), + ) + renderRunsPage() + await screen.findByText('第 1 次运行') + + // The running row keeps the history polling, and the next read fails. + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + + expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument() + expect(screen.queryByText('已经到最早的一次运行。')).not.toBeInTheDocument() + expect(screen.getByText('第 1 次运行')).toBeInTheDocument() + }) + + it('retries the head after its refresh fails before the last page', async () => { + vi.useFakeTimers({ toFake: ['setInterval', 'clearInterval'] }) + let headReads = 0 + let recovered = false + server.use( + http.get(RUNS_URL, ({ request }) => { + if (new URL(request.url).searchParams.has('after')) { + return HttpResponse.json({ items: [runFixture(2)], nextCursor: '' }) + } + headReads += 1 + if (headReads > 1 && !recovered) { + return HttpResponse.json({ code: 'internal' }, { status: 500 }) + } + return HttpResponse.json({ + items: [ + runFixture(1, { + status: recovered ? 'succeeded' : 'running', + finishedAt: recovered ? '2026-09-20T02:00:10.000Z' : null, + }), + ], + nextCursor: 'run-001', + }) + }), + ) + renderRunsPage() + await screen.findByText('第 1 次运行') + + await act(() => vi.advanceTimersByTimeAsync(WORKFLOW_RUN_POLL_MS)) + expect(await screen.findByRole('button', { name: '重试' })).toBeInTheDocument() + expect(screen.queryByText('更早的运行读取失败。')).not.toBeInTheDocument() + expect(headReads).toBe(2) + + recovered = true + fireEvent.click(screen.getByRole('button', { name: '重试' })) + await waitFor(() => expect(headReads).toBe(3)) + expect(screen.getByText('第 1 次运行')).toBeInTheDocument() + expect(screen.getByRole('button', { name: '加载更早的运行' })).toBeInTheDocument() + }) + + it('shows when each run started and finished, and marks one that has not started', async () => { + server.use( + http.get(RUNS_URL, () => + HttpResponse.json({ + items: [ + runFixture(1), + runFixture(2, { status: 'pending', startedAt: null, finishedAt: null }), + ], + nextCursor: '', + }), + ), + ) + renderRunsPage() + await screen.findByText('第 1 次运行') + + // A finished run names both ends of its span; the instant it was created is not the answer to + // "how long did this take", so the row carries the start and the finish. + expect(screen.getAllByText(/^开始 /)).toHaveLength(1) + expect(screen.getAllByText(/^结束 /)).toHaveLength(1) + // A run that has not been picked up yet is stated as such, with its creation time, rather than + // looking like one that started at that instant. + expect(screen.getByText(/^未开始(创建于 .*)$/)).toBeInTheDocument() + expect(screen.queryByText('尚未结束')).not.toBeInTheDocument() + }) + + it('shows the finish time when a run was cancelled before it started', async () => { + server.use( + http.get(RUNS_URL, () => + HttpResponse.json({ + items: [runFixture(1, { status: 'cancelled', startedAt: null })], + nextCursor: '', + }), + ), + ) + renderRunsPage() + + await screen.findByText('第 1 次运行') + expect(screen.getByText(/^未开始(创建于 /)).toBeInTheDocument() + expect(screen.getByText(/^结束 /)).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/workflows/workflow-runs-page.tsx b/frontend/src/features/workflows/workflow-runs-page.tsx new file mode 100644 index 00000000..e8f87c7d --- /dev/null +++ b/frontend/src/features/workflows/workflow-runs-page.tsx @@ -0,0 +1,216 @@ +import { useMemo } from 'react' +import { useTranslation } from 'react-i18next' +import { useParams } from 'react-router-dom' +import { Button } from '@/components/ui/button' +import { Skeleton } from '@/components/ui/skeleton' +import { useWorkflow, useWorkflowRuns, runHistoryRows } from '@/features/workflows/api' +import { RunHistoryRow } from '@/features/workflows/run/run-history-row' +import { WorkflowDetailShell } from '@/features/workflows/workflow-detail-shell' +import { faultCode, isForbiddenError } from '@/lib/api-client' +import { workspacePaths } from '@/lib/paths' + +/** + * The 「运行记录」 tab: every run this workflow has had, newest first. + * + * Reading history never starts anything — the only writes on this page are the retry and the + * "load more" request, both reads underneath. Each state the member can land in is stated as + * itself: a pending read, a workflow that has never run, a failed read that can be retried, and a + * missing permission. Reporting the last two as "no runs" would tell a member who lost access, or + * whose request failed, that their history is empty. + * + * @param props.slug - Tenant id the workflow belongs to. + */ +export function WorkflowRunsPage({ slug }: { slug: string }) { + const { t } = useTranslation() + const { workspaceSlug, workflowId } = useParams<{ workspaceSlug: string; workflowId: string }>() + const paths = workspacePaths(workspaceSlug ?? slug) + const id = workflowId ?? '' + const workflow = useWorkflow(slug, workflowId) + const history = useWorkflowRuns(slug, workflowId) + const rows = useMemo(() => runHistoryRows(history.data?.pages), [history.data]) + + return ( + + {workflow.isPending ? ( +
    + +
    + ) : ( +
    + {workflow.data === undefined ? ( + void workflow.refetch().catch(() => undefined)} + /> + ) : ( + paths.workflowRun(id, runId)} + /> + )} +
    + )} +
    + ) +} + +/** Missing data alone does not mean missing workflow: only the public not_found fault does. */ +function WorkflowReadFault({ error, onRetry }: { error: unknown; onRetry: () => void }) { + const { t } = useTranslation() + if (faultCode(error) === 'not_found') { + return

    {t('workflows.detail.notFound')}

    + } + return +} + +/** What the history query can be, resolved into one of the states a member can act on. */ +type HistoryQuery = ReturnType + +/** + * The history body: loading, empty, unreadable, forbidden, or the rows with the way to go deeper. + * + * A failed read is only a whole-page failure when nothing was loaded: once rows are on screen they + * stay, and the failure is reported by the footer instead. Replacing a readable history with an + * error page would lose the runs the member was already looking at, which is the opposite of what a + * history is for. + */ +function RunHistoryBody({ + history, + rows, + hrefFor, +}: { + history: HistoryQuery + rows: ReturnType + hrefFor: (runId: string) => string +}) { + const { t } = useTranslation() + const loaded = rows.length > 0 + + if (history.isPending) { + return

    {t('workflows.runs.loading')}

    + } + if (history.isError && !loaded) { + return ( + void history.refetch().catch(() => undefined)} + /> + ) + } + if (!loaded) { + return ( +
    +

    {t('workflows.runs.emptyTitle')}

    +

    {t('workflows.runs.emptyDescription')}

    +
    + ) + } + + return ( +
    +
      + {rows.map((run) => ( + + ))} +
    + +
    + ) +} + +/** + * The paging footer: a failed refresh, the next page, its failure, or the end of the walk. + * + * The failure reads here rather than on a fault page, because the rows already loaded stay: a + * history is not less readable because its next page failed. A failed read is reported whether or + * not a next page exists — once the walk reached its end, a failure is a failed *refresh* of rows + * that are already on screen, and it still needs a retry, so it must not be reported as the end of + * the history. + * + * A 403 keeps its own meaning in both cases: the caller cannot read the rest of the history, and a + * retry cannot change that, so it reads as the permission state rather than as a loading failure. + */ +function HistoryFooter({ history }: { history: HistoryQuery }) { + const { t } = useTranslation() + const failed = history.isError + const forbidden = failed && isForbiddenError(history.error) + + if (failed && forbidden) { + return ( +
    + +
    + ) + } + if (failed && !history.isFetchNextPageError) { + return ( +
    + void history.refetch().catch(() => undefined)} + /> +
    + ) + } + if (!history.hasNextPage) { + return

    {t('workflows.runs.endOfHistory')}

    + } + return ( +
    + + {history.isFetchNextPageError && ( +

    {t('workflows.runs.loadMoreFailed')}

    + )} +
    + ) +} + +/** + * A failed history read: a permission problem reads differently from a retryable failure. + * + * The retry is only offered for a failure a retry can fix — a refused read stays refused until the + * member's role changes, and a button that cannot work is worse than no button. + * + * @param props.forbidden - Whether the read was refused as unpermitted. + * @param props.onRetry - Repeats the failed read; required when `forbidden` is false. + */ +function HistoryFault({ + forbidden, + onRetry, +}: { + forbidden: boolean + onRetry?: (() => void) | undefined +}) { + const { t } = useTranslation() + if (forbidden) { + return ( +
    +

    {t('workflows.runs.forbiddenTitle')}

    +

    + {t('workflows.runs.forbiddenDescription')} +

    +
    + ) + } + return ( +
    +

    {t('workflows.runs.failedTitle')}

    +

    {t('workflows.runs.failedDescription')}

    + {onRetry !== undefined && ( + + )} +
    + ) +} diff --git a/frontend/src/features/workflows/workflow-versions-page.test.tsx b/frontend/src/features/workflows/workflow-versions-page.test.tsx new file mode 100644 index 00000000..2fa64e2b --- /dev/null +++ b/frontend/src/features/workflows/workflow-versions-page.test.tsx @@ -0,0 +1,80 @@ +import { fireEvent, screen, waitFor } from '@testing-library/react' +import { http, HttpResponse } from 'msw' +import { describe, expect, it } from 'vitest' +import type { Workflow as CloudWorkflow, WorkflowSnapshot } from '@/api/generated.schemas' +import { WorkflowVersionsPage } from '@/features/workflows/workflow-versions-page' +import { + installCloudSpaceHandlers, + installWorkflowHandlers, + TEST_TENANT_ID, + workflowFixture, + workflowSnapshotFixture, +} from '@/test/cloud-handlers' +import { server } from '@/test/msw-server' +import { renderRoutes } from '@/test/render' + +const WORKFLOW_ID = '44444444-4444-4444-4444-444444444444' +const WORKSPACE_SLUG = 'cloud-dev' +const VERSIONS_PATH = `/w/${WORKSPACE_SLUG}/workflows/${WORKFLOW_ID}/versions` +const EDITOR_PATH = `/w/${WORKSPACE_SLUG}/workflows/${WORKFLOW_ID}` + +/** + * Mounts the versions tab and a stand-in for the editor it rolls back into, behind real route + * matches so the page reads its workspace slug from the URL and its links are the app's own. + */ +function renderVersionsPage(snapshots: WorkflowSnapshot[], restored?: CloudWorkflow) { + installCloudSpaceHandlers('admin') + installWorkflowHandlers([workflowFixture({ id: WORKFLOW_ID, version: 2 })]) + server.use( + http.get(`/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}/snapshots`, () => + HttpResponse.json({ items: snapshots, nextCursor: '' }), + ), + http.put( + `/api/v1/tenants/${TEST_TENANT_ID}/workflows/${WORKFLOW_ID}/snapshots/:snapshotId/restore`, + () => HttpResponse.json(restored ?? workflowFixture({ id: WORKFLOW_ID, version: 3 })), + ), + ) + return renderRoutes( + [ + { + path: '/w/:workspaceSlug/workflows/:workflowId/versions', + element: , + }, + { path: '/w/:workspaceSlug/workflows/:workflowId', element:

    编辑器

    }, + ], + VERSIONS_PATH, + ) +} + +describe('WorkflowVersionsPage', () => { + it('marks the versions tab as the current one', async () => { + const { router } = renderVersionsPage([workflowSnapshotFixture(1)]) + + const tabs = await screen.findByRole('navigation', { name: '工作流的分区' }) + const current = tabs.querySelector('[aria-current="page"]') + expect(current?.textContent).toBe('版本') + // Every tab is a real route, which is what makes the three surfaces shareable and bookmarkable. + expect(router.state.location.pathname).toBe(VERSIONS_PATH) + }) + + it('lists the published versions with a rollback action each', async () => { + renderVersionsPage([ + workflowSnapshotFixture(1, { name: '首发' }), + workflowSnapshotFixture(2, { name: '第二版' }), + ]) + + expect(await screen.findByRole('button', { name: '回滚到版本 2' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '回滚到版本 1' })).toBeInTheDocument() + }) + + it('returns to the editor once a rollback replaced the live document', async () => { + const { router } = renderVersionsPage([workflowSnapshotFixture(1, { name: '首发' })]) + + fireEvent.click(await screen.findByRole('button', { name: '回滚到版本 1' })) + fireEvent.click(await screen.findByRole('button', { name: '回滚' })) + + // The restored graph is what the editor now edits, so the page hands the member to it. + await waitFor(() => expect(router.state.location.pathname).toBe(EDITOR_PATH)) + expect(await screen.findByText('编辑器')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/features/workflows/workflow-versions-page.tsx b/frontend/src/features/workflows/workflow-versions-page.tsx new file mode 100644 index 00000000..5386c484 --- /dev/null +++ b/frontend/src/features/workflows/workflow-versions-page.tsx @@ -0,0 +1,45 @@ +import { useTranslation } from 'react-i18next' +import { useNavigate, useParams } from 'react-router-dom' +import { Skeleton } from '@/components/ui/skeleton' +import { useWorkflow } from '@/features/workflows/api' +import { WorkflowVersionHistory } from '@/features/workflows/editor/workflow-version-history' +import { WorkflowDetailShell } from '@/features/workflows/workflow-detail-shell' +import { workspacePaths } from '@/lib/paths' + +/** + * The 「版本」 tab: every frozen version of this workflow, with rollback. + * + * A restore replaces the live document, so a successful rollback lands on the 「编辑」 tab — the + * member sees the graph they just restored instead of staying on a list that would have to describe + * the result. The document version the restore guards on is the one this page read from the server, + * which is the right authority here: the editor is not mounted, so there is no draft to flush. + * + * @param props.slug - Tenant id the workflow belongs to. + */ +export function WorkflowVersionsPage({ slug }: { slug: string }) { + const { t } = useTranslation() + const navigate = useNavigate() + const { workspaceSlug, workflowId } = useParams<{ workspaceSlug: string; workflowId: string }>() + const paths = workspacePaths(workspaceSlug ?? slug) + const id = workflowId ?? '' + const workflow = useWorkflow(slug, workflowId) + + return ( + +
    + {workflow.isPending && } + {!workflow.isPending && !workflow.data && ( +

    {t('workflows.detail.notFound')}

    + )} + {workflow.data && ( + void navigate(paths.workflowDetail(workflow.data.id))} + /> + )} +
    +
    + ) +} diff --git a/frontend/src/features/workflows/workflows-page.test.tsx b/frontend/src/features/workflows/workflows-page.test.tsx index 60d8f14b..8177d83b 100644 --- a/frontend/src/features/workflows/workflows-page.test.tsx +++ b/frontend/src/features/workflows/workflows-page.test.tsx @@ -53,6 +53,41 @@ describe('WorkflowsPage', () => { expect(await screen.findByText('还没有工作流')).toBeInTheDocument() }) + it('labels each row with its newest run and links straight into the run list', async () => { + installWorkflowHandlers([ + workflowFixture({ + name: '安全审查', + latestRun: { + id: '77777777-7777-7777-7777-777777777777', + name: '巡检运行', + status: 'failed', + snapshotId: '88888888-8888-8888-8888-888888888888', + createdAt: '2026-10-24T02:00:00.000Z', + startedAt: '2026-10-24T02:00:01.000Z', + finishedAt: '2026-10-24T02:00:09.000Z', + }, + }), + workflowFixture({ id: '55555555-5555-5555-5555-555555555555', name: '发布流程' }), + ]) + renderList() + + // The last run is part of the workflow row, so "did it work last time" is answerable without + // opening the editor or the history. + expect(await screen.findByText('失败')).toBeInTheDocument() + expect(screen.getByRole('link', { name: '打开安全审查最近一次运行' })).toHaveAttribute( + 'href', + '/w/cloud-dev/workflows/44444444-4444-4444-4444-444444444444/runs/77777777-7777-7777-7777-777777777777', + ) + // A workflow that never ran says so instead of rendering a status for nothing. + expect(screen.getByText('从未运行')).toBeInTheDocument() + // Both rows offer the run list without going through the editor first. + const runsLinks = screen.getAllByRole('link', { name: '运行记录' }) + expect(runsLinks.map((link) => link.getAttribute('href'))).toEqual([ + '/w/cloud-dev/workflows/44444444-4444-4444-4444-444444444444/runs', + '/w/cloud-dev/workflows/55555555-5555-5555-5555-555555555555/runs', + ]) + }) + it('filters rows and reports a search that matches nothing', async () => { installWorkflowHandlers([ workflowFixture({ name: '安全审查' }), diff --git a/frontend/src/features/workflows/workflows-page.tsx b/frontend/src/features/workflows/workflows-page.tsx index 9ab17a39..2b9c958a 100644 --- a/frontend/src/features/workflows/workflows-page.tsx +++ b/frontend/src/features/workflows/workflows-page.tsx @@ -38,6 +38,8 @@ function WorkflowListBody({ workflows, query, hrefFor, + runsHrefFor, + runHrefFor, onRename, onDelete, }: { @@ -45,6 +47,8 @@ function WorkflowListBody({ workflows: CloudWorkflow[] query: string hrefFor: (id: string) => string + runsHrefFor: (id: string) => string + runHrefFor: (workflowId: string, runId: string) => string onRename: (workflow: CloudWorkflow) => void onDelete: (workflow: CloudWorkflow) => void }) { @@ -82,6 +86,8 @@ function WorkflowListBody({ key={workflow.id} workflow={workflow} href={hrefFor(workflow.id)} + runsHref={runsHrefFor(workflow.id)} + runHref={(runId) => runHrefFor(workflow.id, runId)} onRename={onRename} onDelete={onDelete} /> @@ -145,6 +151,8 @@ export function WorkflowsPage({ slug }: { slug: string }) { workflows={workflows} query={query} hrefFor={paths.workflowDetail} + runsHrefFor={paths.workflowRuns} + runHrefFor={paths.workflowRun} onRename={setRenaming} onDelete={setDeleting} /> diff --git a/frontend/src/lib/README.en.md b/frontend/src/lib/README.en.md index ffbe3d64..1913e685 100644 --- a/frontend/src/lib/README.en.md +++ b/frontend/src/lib/README.en.md @@ -17,7 +17,7 @@ Utilities shared by generated code and components that contain neither React nor | `navigation.test.ts` | Verifies the replace-and-restore semantics. | | `pagination.ts` / `pagination.test.ts` | Walks every cursor page so spaces, members, and applications are not silently truncated; tests cross-page ordering. | | `paths.test.ts` | Verifies `safeReturnTo`'s rejections, narrowing of private join links, `loginPath` encoding and the `/w/` prefix in every workspace route. | -| `paths.ts` | The reserved workspace prefix `WORKSPACE_ROUTE_PREFIX` (`/w`) with its router pattern `WORKSPACE_ROUTE_PATTERN`, the workspace route builder `workspacePaths` (including the resource detail builders `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`, plus the plugin route and the settings routes `gitCredentials` and `gitIdentity`), the login route `loginPath`, narrowing of an untrusted `returnTo` (`safeReturnTo`: single leading slash, no `//`, no backslash or control character, at most 2048 chars), the space address preview `workspaceUrlPrefix` (`host/w/`, the fixed part shown in front of the slug input), and construction/validation of same-origin invitation and application links (`joinUrl` / `joinedLinkPath`). | +| `paths.ts` | The reserved workspace prefix `WORKSPACE_ROUTE_PREFIX` (`/w`) with its router pattern `WORKSPACE_ROUTE_PATTERN`, the workspace route builder `workspacePaths` (including the resource detail builders `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`, the workflow detail tabs `workflowRuns` and `workflowVersions` with the run detail `workflowRun`, plus the plugin route and the settings routes `gitCredentials` and `gitIdentity`), the login route `loginPath`, narrowing of an untrusted `returnTo` (`safeReturnTo`: single leading slash, no `//`, no backslash or control character, at most 2048 chars), the space address preview `workspaceUrlPrefix` (`host/w/`, the fixed part shown in front of the slug input), and construction/validation of same-origin invitation and application links (`joinUrl` / `joinedLinkPath`). | | `mock-api-client.ts` | Axios client for the MSW-mocked domain (`/mock-api/*`), kept separate from the real-backend generated client. It rewrites the real space slug to the seeded demo workspace so pages without a backend keep showing demo data in any space until they gain real API counterparts. The mock domain has no authentication. | | `utils.ts` | Re-exports `cn` (Tailwind-aware class merging); shadcn components import it via `@/lib/utils`. | @@ -38,3 +38,4 @@ Third-party libraries and sibling modules in this directory only. **Never** impo `workspacePaths` also owns the session list (`sessions`) and Agent detail (`agentSession`) paths. `paths.test.ts` verifies both remain below the selected workspace prefix. The mock API boundary rejects string responses, including SPA HTML before the worker takes control, rather than caching them as domain objects; failed queries remain recoverable. + diff --git a/frontend/src/lib/README.md b/frontend/src/lib/README.md index 27fe4c0a..b0a21206 100644 --- a/frontend/src/lib/README.md +++ b/frontend/src/lib/README.md @@ -17,7 +17,7 @@ | `navigation.test.ts` | 验证替换与还原语义。 | | `pagination.ts` / `pagination.test.ts` | 顺序读取所有游标页,确保空间、成员和申请列表不会只显示首页;测试跨页顺序。 | | `paths.test.ts` | 验证 `safeReturnTo` 的拒绝规则、私有加入链接的收窄、`loginPath` 的编码与所有工作区路由的 `/w/` 前缀。 | -| `paths.ts` | 保留的工作区前缀 `WORKSPACE_ROUTE_PREFIX`(`/w`)及其路由模式 `WORKSPACE_ROUTE_PATTERN`、工作区路由构造 `workspacePaths`(含资源详情页构造器 `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`,以及插件路由、设置下的凭据路由 `gitCredentials` 与 Git 身份路由 `gitIdentity`)、登录路由 `loginPath`、不受信 `returnTo` 的收窄 `safeReturnTo`(单个前导 `/`、不允许 `//`、反斜杠或控制字符、最长 2048)、空间地址预览 `workspaceUrlPrefix`(`host/w/`,显示在 slug 输入框前的固定部分),以及同源邀请/申请链接的构造 `joinUrl` 与校验 `joinedLinkPath`。 | +| `paths.ts` | 保留的工作区前缀 `WORKSPACE_ROUTE_PREFIX`(`/w`)及其路由模式 `WORKSPACE_ROUTE_PATTERN`、工作区路由构造 `workspacePaths`(含资源详情页构造器 `issueDetail` / `projectDetail` / `agentDetail` / `squadDetail` / `workflowDetail`,工作流详情页三个标签页的路由 `workflowRuns`、`workflowVersions` 与运行详情 `workflowRun`,以及插件路由、设置下的凭据路由 `gitCredentials` 与 Git 身份路由 `gitIdentity`)、登录路由 `loginPath`、不受信 `returnTo` 的收窄 `safeReturnTo`(单个前导 `/`、不允许 `//`、反斜杠或控制字符、最长 2048)、空间地址预览 `workspaceUrlPrefix`(`host/w/`,显示在 slug 输入框前的固定部分),以及同源邀请/申请链接的构造 `joinUrl` 与校验 `joinedLinkPath`。 | | `mock-api-client.ts` | MSW mock 域(`/mock-api/*`)的 axios 客户端,与真实后端生成客户端分离。把真实 space slug 重写为 demo 种子 workspace,使尚无后端的页面在任意 Space 下继续显示演示数据,直到它们接入真实 API。mock 域没有认证。 | | `utils.ts` | 重新导出 `cn`(Tailwind 感知的类名合并),shadcn 组件通过 `@/lib/utils` 引用。 | @@ -38,3 +38,5 @@ `workspacePaths` 还负责会话列表(`sessions`)和 Agent 详情(`agentSession`)路径;`paths.test.ts` 验证它们始终位于选定工作区前缀之下。 mock API 边界拒绝字符串响应(包括 worker 尚未接管时的 SPA HTML 回退),不把它缓存为领域对象;请求失败可由查询恢复。 + + diff --git a/frontend/src/lib/paths.test.ts b/frontend/src/lib/paths.test.ts index 1f66156d..ec291caa 100644 --- a/frontend/src/lib/paths.test.ts +++ b/frontend/src/lib/paths.test.ts @@ -46,6 +46,11 @@ describe('workspacePaths', () => { expect(p.issueDetail('42')).toBe('/w/acme/issues/42') expect(p.scheduledTasks).toBe('/w/acme/scheduled-tasks') expect(p.workflowDetail('42')).toBe('/w/acme/workflows/42') + // The run list and the version list are tabs of the same workflow, so each is a sibling route of + // the editor rather than a query parameter — they have to be linkable on their own. + expect(p.workflowRuns('42')).toBe('/w/acme/workflows/42/runs') + expect(p.workflowVersions('42')).toBe('/w/acme/workflows/42/versions') + expect(p.workflowRun('42', 'run-1')).toBe('/w/acme/workflows/42/runs/run-1') expect(p.spaces).toBe('/w/acme/spaces') expect(p.skills).toBe('/w/acme/skills') expect(p.plugins).toBe('/w/acme/plugins') diff --git a/frontend/src/lib/paths.ts b/frontend/src/lib/paths.ts index 51c3aaa8..345d882c 100644 --- a/frontend/src/lib/paths.ts +++ b/frontend/src/lib/paths.ts @@ -33,6 +33,8 @@ export function workspacePaths(slug: string) { projectDetail: (id: string) => `${base}/projects/${id}`, workflows: `${base}/workflows`, workflowDetail: (id: string) => `${base}/workflows/${id}`, + workflowRuns: (workflowId: string) => `${base}/workflows/${workflowId}/runs`, + workflowVersions: (workflowId: string) => `${base}/workflows/${workflowId}/versions`, workflowRun: (workflowId: string, runId: string) => `${base}/workflows/${workflowId}/runs/${runId}`, spaces: `${base}/spaces`, diff --git a/frontend/src/routes.tsx b/frontend/src/routes.tsx index f14f4b9f..084c1555 100644 --- a/frontend/src/routes.tsx +++ b/frontend/src/routes.tsx @@ -29,6 +29,8 @@ import { SquadsPage } from '@/features/squads/squads-page' import { useCurrentSpace } from '@/features/spaces/current-space' import { WorkflowRunDetailPage } from '@/features/workflows/run/run-detail-page' import { WorkflowEditorPage } from '@/features/workflows/workflow-editor-page' +import { WorkflowRunsPage } from '@/features/workflows/workflow-runs-page' +import { WorkflowVersionsPage } from '@/features/workflows/workflow-versions-page' import { WorkflowsPage } from '@/features/workflows/workflows-page' import { SessionListPage } from '@/features/sessions/session-list-page' import { SessionDetailPage } from '@/features/sessions/session-detail-page' @@ -131,6 +133,14 @@ export const routes: RouteObject[] = [ path: 'workflows/:workflowId', element: , }, + { + path: 'workflows/:workflowId/runs', + element: , + }, + { + path: 'workflows/:workflowId/versions', + element: , + }, { path: 'workflows/:workflowId/runs/:runId', element: , diff --git a/frontend/src/test/cloud-handlers.ts b/frontend/src/test/cloud-handlers.ts index 1831e4fb..c98ee1a9 100644 --- a/frontend/src/test/cloud-handlers.ts +++ b/frontend/src/test/cloud-handlers.ts @@ -8,6 +8,7 @@ import type { ProjectRepository, RepositoryOperation, Workflow, + WorkflowSnapshot, WorkspaceBranch, WorkspaceListItem, } from '@/api/generated.schemas' @@ -122,6 +123,28 @@ export function workflowFixture(overrides: Partial = {}): Workflow { } } +/** + * A published version of the fixture workflow, as the snapshot endpoints return it. + * + * Versions are numbered per workflow and immutable, so the id and the default name both derive + * from the number; a test that cares about the label passes one. + */ +export function workflowSnapshotFixture( + version: number, + overrides: Partial = {}, +): WorkflowSnapshot { + return { + id: `snap-${version}`, + tenantId: TEST_TENANT_ID, + workflowId: '44444444-4444-4444-4444-444444444444', + name: `版本 ${version}`, + version, + graph: { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }, + createdAt: '2026-09-20T10:00:00+08:00', + ...overrides, + } +} + /** * Installs the tenant workflow endpoints for tests of the workflow surface: * the list, and one detail route resolving against `workflows` by id — an id diff --git a/integration/workflow_run_history_test.go b/integration/workflow_run_history_test.go new file mode 100644 index 00000000..56768122 --- /dev/null +++ b/integration/workflow_run_history_test.go @@ -0,0 +1,247 @@ +package integration + +import ( + "slices" + "sort" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/wanglongan587/cloud/internal/core" +) + +// seededRun is one run row written straight into the store: the id the read must return and the +// instant the walk must order it by. +type seededRun struct { + id string + createdAt time.Time +} + +// seedRun writes a terminal run row at a chosen instant into the fixture tenant's workflow. The run +// list is a read path, so the acceptance below goes through real HTTP; only the history itself is +// arranged directly, because a hundred real executions would add runtime without adding evidence +// about the walk. +func seedRun(t *testing.T, f *fixture, wid, snapID string, run seededRun, name string) { + t.Helper() + seedRunFor(t, f, f.tid, wid, snapID, run, name) +} + +// seedRunFor is seedRun for an explicit tenant, so a case can arrange a run the caller may reach +// through a second tenant membership without that run belonging to the history under test. +func seedRunFor(t *testing.T, f *fixture, tid, wid, snapID string, run seededRun, name string) { + t.Helper() + _, err := f.store.Pool.ExecContext(t.Context(), + `INSERT INTO workflow_runs(id,tenant_id,workflow_id,snapshot_id,name,status,created_at,updated_at,started_at,finished_at) + VALUES($1,$2,$3,$4,$5,'succeeded',$6,$6,$6,$6)`, + run.id, tid, wid, snapID, name, run.createdAt) + must(t, err) +} + +// purgeRun soft-deletes a seeded run the way the reclaim path does: the row and its id stay, so only +// the query's own deleted filter can keep the id from resolving as a cursor anchor. +func purgeRun(t *testing.T, f *fixture, runID string) { + t.Helper() + _, err := f.store.Pool.ExecContext(t.Context(), `UPDATE workflow_runs SET deleted_at=now() WHERE id=$1`, runID) + must(t, err) +} + +// TestWorkflowRunHistoryWalksNewestFirst pins the run list's order and its cursor. +// +// The list is a paged history, so two things have to hold at once: the newest run comes first, and +// paging reaches every run exactly once. Row ids are random UUIDs, which is why ordering by id +// (what the list used to do) cannot satisfy either — this seeds ids that are deliberately unrelated +// to time, with several runs sharing one instant so the (created_at, id) tiebreaker is exercised. +func TestWorkflowRunHistoryWalksNewestFirst(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + wf := f.call("POST", f.path("/workflows"), core.Object{"name": "History", "graph": runGraph()}, "hist-wf", 200).O("resource") + wfid := wf.S("id") + snapID := f.call("POST", f.path("/workflows/"+wfid+"/publish"), core.Object{}, "hist-pub", 200).O("resource").S("id") + + const total = 120 + base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + seeded := make([]seededRun, 0, total) + for i := range total { + // Two runs per instant: created_at alone is not a total order, so the id must break the tie. + run := seededRun{id: uuid.NewString(), createdAt: base.Add(time.Duration(i/2) * time.Minute)} + seedRun(t, f, wfid, snapID, run, "run "+run.id[:8]) + seeded = append(seeded, run) + } + // The order the read must produce, computed independently of the endpoint. + want := slices.Clone(seeded) + sort.Slice(want, func(i, j int) bool { + if !want[i].createdAt.Equal(want[j].createdAt) { + return want[i].createdAt.After(want[j].createdAt) + } + return want[i].id > want[j].id + }) + + var got []string + cursor := "" + for pages := 0; ; pages++ { + require(t, pages < total, "the walk did not terminate") + query := "?limit=7" + if cursor != "" { + query += "&after=" + cursor + } + page := f.call("GET", f.path("/workflows/"+wfid+"/runs")+query, nil, "", 200) + items := issueItems(page) + require(t, len(items) > 0, "a page in the middle of the walk must not be empty") + for _, item := range items { + got = append(got, item.S("id")) + } + if page.S("nextCursor") == "" { + break + } + // The cursor is the page's own last row, so the next request continues strictly below it. + require(t, page.S("nextCursor") == items[len(items)-1].S("id"), "the cursor must be the last row of the page") + cursor = page.S("nextCursor") + } + + gotIDs := make([]string, 0, len(want)) + for _, run := range want { + gotIDs = append(gotIDs, run.id) + } + if !slices.Equal(got, gotIDs) { + t.Fatalf("the walk returned %d rows in the wrong order or with repeats; want %d newest first", len(got), total) + } + + // The newest run is on the first page, which is the whole point of ordering server-side: the + // page the member sees first has to be the one they opened the history for. + newest := f.call("GET", f.path("/workflows/"+wfid+"/runs")+"?limit=1", nil, "", 200) + if first := issueItems(newest)[0].S("id"); first != want[0].id { + t.Fatalf("the first page starts at %s; want the newest run %s", first, want[0].id) + } + + // A cursor that resolves to no row of this workflow ends the walk with an empty page instead of + // repeating the history from the top. + stranger := f.call("GET", f.path("/workflows/"+wfid+"/runs")+"?after="+uuid.NewString(), nil, "", 200) + if len(issueItems(stranger)) != 0 || stranger.S("nextCursor") != "" { + t.Fatalf("a cursor outside the workflow must end the walk, got %v", stranger) + } + // A cursor that is not a row id at all is refused, not silently treated as the start. + f.call("GET", f.path("/workflows/"+wfid+"/runs")+"?after=not-a-cursor", nil, "", 400) +} + +// TestWorkflowRunHistoryOnlyAnchorsInItsOwnHistory pins which rows a cursor may resolve against. +// +// The cursor is an opaque run UUID, so the endpoint resolves the page boundary from that row's +// created_at. That lookup has to stay inside the history being read: an id naming another workflow's +// run, another tenant's run, or a purged run would otherwise position the page at that row's +// creation instant — a creation-time side channel across tenants, and a way to page a history the +// caller's own rows cannot describe. Each of those cursors ends the walk with an empty page, the +// same answer an unknown cursor gets, while a cursor of this workflow still continues below itself. +func TestWorkflowRunHistoryOnlyAnchorsInItsOwnHistory(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + wfid := createWorkflow(t, f, "Scoped history", "hist-anchor-wf", nil) + snapID := publishWorkflow(t, f, wfid, "hist-anchor-pub").S("id") + base := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC) + older := seededRun{id: uuid.NewString(), createdAt: base} + newer := seededRun{id: uuid.NewString(), createdAt: base.Add(time.Hour)} + seedRun(t, f, wfid, snapID, older, "older run") + seedRun(t, f, wfid, snapID, newer, "newer run") + + // A run of another workflow in the same tenant, at an instant strictly inside this history: + // resolved without a workflow filter it would anchor between the two runs above and answer the + // older one; scoped, it anchors nothing. + otherWid := createWorkflow(t, f, "Other history", "hist-anchor-wf-2", nil) + otherSnap := publishWorkflow(t, f, otherWid, "hist-anchor-pub-2").S("id") + otherRun := seededRun{id: uuid.NewString(), createdAt: base.Add(30 * time.Minute)} + seedRun(t, f, otherWid, otherSnap, otherRun, "other workflow run") + + // A run of a workflow in a second tenant the caller administers: the membership check passes, so + // only the cursor's own scope can refuse it. + foreign := f.moreTenant() + foreignWid := f.call("POST", "/api/v1/tenants/"+foreign+"/workflows", core.Object{ + "name": "Foreign history", "graph": startInputGraph(nil), + }, "hist-anchor-foreign-wf", 200).O("resource").S("id") + foreignSnap := f.call("POST", "/api/v1/tenants/"+foreign+"/workflows/"+foreignWid+"/publish", core.Object{}, "hist-anchor-foreign-pub", 200).O("resource").S("id") + foreignRun := seededRun{id: uuid.NewString(), createdAt: base.Add(45 * time.Minute)} + seedRunFor(t, f, foreign, foreignWid, foreignSnap, foreignRun, "foreign tenant run") + + // A purged run of this workflow: the row keeps its id, so only the deleted filter can keep it + // from anchoring a history it is no longer part of. + purged := seededRun{id: uuid.NewString(), createdAt: base.Add(50 * time.Minute)} + seedRun(t, f, wfid, snapID, purged, "purged run") + purgeRun(t, f, purged.id) + + for _, c := range []struct{ name, cursor string }{ + {"another workflow's run", otherRun.id}, + {"another tenant's run", foreignRun.id}, + {"a purged run of this workflow", purged.id}, + {"an unknown run", uuid.NewString()}, + } { + got := f.call("GET", f.path("/workflows/"+wfid+"/runs")+"?after="+c.cursor, nil, "", 200) + if items := issueItems(got); len(items) != 0 || got.S("nextCursor") != "" { + t.Fatalf("%s anchored the history (%d rows): %v", c.name, len(items), got) + } + } + + // The same walk still accepts a cursor of this workflow: the page continues strictly below the + // row it names, so scoping the anchor did not turn valid cursors into empty pages. + got := issueItems(f.call("GET", f.path("/workflows/"+wfid+"/runs")+"?after="+newer.id, nil, "", 200)) + if len(got) != 1 || got[0].S("id") != older.id { + t.Fatalf("a cursor of this workflow returned %v; want only the older run %s", got, older.id) + } +} + +// TestWorkflowRowsCarryTheirNewestRun proves a workflow read labels when it last ran. +// +// The label answers "did this workflow work last time" from the list, so it has to be the same run +// the history would show first — not one run on the list and another on the detail page. +func TestWorkflowRowsCarryTheirNewestRun(t *testing.T) { + f := setup(t) + seedPersonalModel(t, f) + never := f.call("POST", f.path("/workflows"), core.Object{"name": "Never ran", "graph": runGraph()}, "lbl-wf-0", 200).O("resource") + wf := f.call("POST", f.path("/workflows"), core.Object{"name": "Ran twice", "graph": runGraph()}, "lbl-wf", 200).O("resource") + wfid := wf.S("id") + snapID := f.call("POST", f.path("/workflows/"+wfid+"/publish"), core.Object{}, "lbl-pub", 200).O("resource").S("id") + + older := seededRun{id: uuid.NewString(), createdAt: time.Date(2026, 9, 1, 9, 0, 0, 0, time.UTC)} + newer := seededRun{id: uuid.NewString(), createdAt: time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)} + // Written newer first so a "last inserted wins" implementation would answer with the older one. + seedRun(t, f, wfid, snapID, newer, "newer run") + seedRun(t, f, wfid, snapID, older, "older run") + + rows := issueItems(f.call("GET", f.path("/workflows")+"?limit=100", nil, "", 200)) + byName := map[string]core.Object{} + for _, row := range rows { + byName[row.S("name")] = row + } + ran, ok := byName["Ran twice"] + require(t, ok, "the list must contain the workflow that ran") + latest, ok := ran["latestRun"].(map[string]any) + require(t, ok, "a workflow that ran must carry its newest run") + if got := core.Object(latest); got.S("id") != newer.id || got.S("status") != "succeeded" || got.S("snapshotId") != snapID { + t.Fatalf("latestRun = %v; want the newer run %s", got, newer.id) + } + // The label belongs to the list, not to the resource: a run starting must not rewrite every read + // of a workflow, so the detail read keeps projecting the row alone. + if detail := f.call("GET", f.path("/workflows/"+wfid), nil, "", 200); detail["latestRun"] != nil { + t.Fatalf("the detail read must not carry latestRun, got %v", detail["latestRun"]) + } + // Never having run is null on the list row rather than an absent field or a made-up run. + empty, ok := byName["Never ran"] + require(t, ok, "the list must contain the workflow that never ran") + if _, ok := empty["latestRun"]; !ok { + t.Fatal("a workflow that never ran must still carry latestRun, as null") + } + if empty["latestRun"] != nil { + t.Fatalf("a workflow that never ran reports %v; want null", empty["latestRun"]) + } + // The create response is the resource, so it carries no label at all — the same split the detail + // read above pins. + if never["latestRun"] != nil { + t.Fatalf("a create response must not carry latestRun, got %v", never["latestRun"]) + } +} + +// require fails the test with msg unless ok, keeping the assertions above readable. +func require(t *testing.T, ok bool, msg string) { + t.Helper() + if !ok { + t.Fatal(msg) + } +} diff --git a/internal/contract/openapi.go b/internal/contract/openapi.go index cdb9a852..17896a9c 100644 --- a/internal/contract/openapi.go +++ b/internal/contract/openapi.go @@ -126,7 +126,27 @@ func Document() map[string]any { commentProps["seq"] = number() s["Label"] = resource("id tenantId name color version createdAt updatedAt deletedAt", "deletedAt") s["Workflow"] = resource("id tenantId name description graph version createdAt updatedAt deletedAt", "deletedAt") - properties(s, "Workflow")["graph"] = obj{"type": "object", "additionalProperties": true, "description": "The authored graph document: nodes, edges, viewport, editor annotations and global variables. Stored and returned whole; the editor is its only reader."} + workflowProps := properties(s, "Workflow") + workflowProps["graph"] = obj{"type": "object", "additionalProperties": true, "description": "The authored graph document: nodes, edges, viewport, editor annotations and global variables. Stored and returned whole; the editor is its only reader."} + // The newest run belongs to a workflow *list* row, so a member can say when a workflow last ran + // without a request per row. It is an inline nullable object rather than a $ref: OpenAPI 3.0 + // ignores every sibling of $ref, so a referenced schema could not also be nullable. Null means + // the workflow has never run, which is different from a run still pending. The detail read does + // not carry it — a run starting must not rewrite every read of the workflow resource. + workflowProps["latestRun"] = optional(obj{ + "type": "object", "additionalProperties": false, + "description": "List rows only. The workflow's most recent run (the order the run history walks), or null when it has never run.", + "properties": obj{ + "id": uuid(), + "name": str(), + "status": enumeration("pending", "running", "succeeded", "failed", "cancelled"), + "snapshotId": uuid(), + "createdAt": timestamp(), + "startedAt": optional(timestamp()), + "finishedAt": optional(timestamp()), + }, + "required": []string{"id", "name", "status", "snapshotId", "createdAt", "startedAt", "finishedAt"}, + }) s["WorkflowSnapshot"] = resource("id tenantId workflowId version name graph createdAt", "") properties(s, "WorkflowSnapshot")["graph"] = obj{"type": "object", "additionalProperties": true, "description": "The graph document frozen at publish time; restoring this snapshot writes it back to the workflow's live graph."} s["WorkflowRun"] = resource("id tenantId workflowId snapshotId name status input nodeStates rounds error startedAt finishedAt createdAt updatedAt workflowName cancelRequestedAt pluginVersion", "startedAt finishedAt cancelRequestedAt pluginVersion") diff --git a/internal/core/workflow.go b/internal/core/workflow.go index 8660ada8..2815d412 100644 --- a/internal/core/workflow.go +++ b/internal/core/workflow.go @@ -3,6 +3,11 @@ package core import "strings" // workflow loads a live workflow scoped to the tenant. +// +// It deliberately projects only the row: the newest run a workflow has is part of listing workflows, +// not of the resource, because "when it last ran" changes while the document does not — a run +// starting would otherwise rewrite every read of a workflow, including the ones that only gate a +// write. Readers that need the label ask the list, or read the run history. func workflow(t *transaction, tid, wid string) Object { require(validID(wid), 404, "not_found") w := t.one("SELECT * FROM workflows WHERE id=$1 AND tenant_id=$2 AND deleted_at IS NULL", wid, tid) @@ -10,8 +15,21 @@ func workflow(t *transaction, tid, wid string) Object { return w } +// workflowList pages the tenant's workflows and labels every row with its newest run in one extra +// query, so showing "last run" per row does not turn the list into one request per workflow. func workflowList(t *transaction, r *PublicRequest) Object { - return page(t, "SELECT * FROM workflows WHERE tenant_id=$1 AND deleted_at IS NULL", []any{r.TenantID}, "id", r) + out := page(t, "SELECT * FROM workflows WHERE tenant_id=$1 AND deleted_at IS NULL", []any{r.TenantID}, "id", r) + // window builds its items in-process as []Object (not []any), so assert that exact type. + rows, _ := out["items"].([]Object) + ids := make([]string, 0, len(rows)) + for _, row := range rows { + ids = append(ids, row.S("id")) + } + latest := latestWorkflowRuns(t, r.TenantID, ids) + for _, row := range rows { + row["latestRun"] = latest[row.S("id")] + } + return out } // graphText renders the graph body field. A workflow created without one stores the empty document diff --git a/internal/core/workflow_run.go b/internal/core/workflow_run.go index cdbd416c..672c21fc 100644 --- a/internal/core/workflow_run.go +++ b/internal/core/workflow_run.go @@ -1,5 +1,7 @@ package core +import "strings" + // workflowRun loads a live run scoped to its workflow and tenant; a foreign, deleted // or missing run is 404, and the workflow gate keeps archived workflows (and with them // their whole run history) out of every read. @@ -11,9 +13,18 @@ func workflowRun(t *transaction, tid, wid, rid string) Object { return composeWorkflowRun(t, o) } +// workflowRunList reads a workflow's run history newest first. +// +// The walk is ordered by (created_at, id) rather than by id: run ids are random UUIDs, so an +// id-ordered page hands the reader an arbitrary window of the history — the newest run need not +// be in it at all, and the frontend cannot repair that by sorting the page it received. created_at +// is not unique (two runs can share an instant, and a batch of runs created in one test or one +// import frequently does), so id breaks the tie and keeps the order total. The public cursor stays +// the opaque row UUID the page envelope reports; the anchor row's created_at is what resolves the +// position, the same shape the member tenant list uses (pageByCreation). func workflowRunList(t *transaction, r *PublicRequest) Object { w := workflow(t, r.TenantID, r.WorkflowID) - pageOut := page(t, "SELECT * FROM workflow_runs WHERE tenant_id=$1 AND workflow_id=$2 AND deleted_at IS NULL", []any{r.TenantID, r.WorkflowID}, "id", r) + pageOut := pageNewestRunFirst(t, runHistoryScope{tenantID: r.TenantID, workflowID: w.S("id")}, r) // window builds its items in-process as []Object (not []any), so assert that exact type. raw, _ := pageOut["items"].([]Object) items := make([]any, 0, len(raw)) @@ -27,6 +38,88 @@ func workflowRunList(t *transaction, r *PublicRequest) Object { return Object{"items": items, "nextCursor": pageOut.S("nextCursor")} } +// runHistoryScope is the one history a cursor may position: the walk of a single workflow inside +// the tenant that owns it. The cursor is an opaque run UUID, so the page boundary has to be +// resolved from the run it names; resolving that run without this scope would let an id from +// another workflow, another tenant or a purged row move the boundary of the history being read, +// which is a creation-time side channel out of the caller's own history. +type runHistoryScope struct { + tenantID string + workflowID string +} + +// pageNewestRunFirst reads one page of a workflow's run history, newest first, and reports the row +// that continues it. +// +// The walk is ordered by (created_at, id) rather than by id: run ids are random UUIDs, so an +// id-ordered page is an arbitrary window of the history. created_at is not unique (a batch of runs +// created in one import or one test frequently shares an instant), so id breaks the tie and keeps +// the order total. +// +// The public cursor stays the opaque row UUID; the anchor row's created_at is what resolves its +// position. The tuple comparison is strict, so a page boundary that falls inside one created_at +// instant neither repeats the anchor row nor skips the rows after it: the tie is decided by id on +// both sides. The anchor is resolved only inside the scope being paged, so an id that names no live +// run of this workflow compares as NULL and answers an empty page — the same answer as an unknown +// cursor — rather than anchoring the walk outside the history it belongs to. +func pageNewestRunFirst(t *transaction, scope runHistoryScope, r *PublicRequest) Object { + args := []any{scope.tenantID, scope.workflowID} + q := "SELECT r.* FROM workflow_runs r WHERE r.tenant_id=$1 AND r.workflow_id=$2 AND r.deleted_at IS NULL" + if r.After != "" { + require(validID(r.After), 400, "invalid_cursor") + args = append(args, r.After) + after := itoa(len(args)) + q += " AND (r.created_at, r.id) < ((SELECT a.created_at FROM workflow_runs a" + + " WHERE a.id=$" + after + "::uuid AND a.tenant_id=$1 AND a.workflow_id=$2 AND a.deleted_at IS NULL)," + + " $" + after + "::uuid)" + } + return window(t, q+" ORDER BY r.created_at DESC, r.id DESC", args, r) +} + +// latestWorkflowRuns labels a page of workflows with their newest run in one query, so a list of +// workflows costs one extra read rather than one per row. Each lateral lookup takes one row from +// workflow_run_list in the same (created_at, id) order the run history walks, without sorting or +// materializing every run of the page's workflows (including their large JSON fields). +func latestWorkflowRuns(t *transaction, tid string, wids []string) map[string]any { + out := make(map[string]any, len(wids)) + if len(wids) == 0 { + return out + } + args := []any{tid} + values := make([]string, len(wids)) + for i, wid := range wids { + args = append(args, wid) + values[i] = "($" + itoa(len(args)) + "::uuid)" + } + q := "SELECT w.id AS workflow_id, r.id, r.name, r.status, r.snapshot_id," + + " r.created_at, r.started_at, r.finished_at FROM (VALUES " + strings.Join(values, ",") + ") AS w(id)" + + " JOIN LATERAL (SELECT id, name, status, snapshot_id, created_at, started_at, finished_at" + + " FROM workflow_runs WHERE workflow_id=w.id AND tenant_id=$1 AND deleted_at IS NULL" + + " ORDER BY created_at DESC, id DESC LIMIT 1) AS r ON true" + for _, row := range t.list(q, args...) { + out[row.S("workflowId")] = workflowLatestRunSummary(row) + } + return out +} + +// workflowLatestRunSummary is the compact run projection a workflow row carries: enough to label +// when the workflow last ran, how that run ended and which version it executed, and to link to it. +// The history list answers a different question and keeps the node states and inputs per row; a +// workflow row must not pay for them. +func workflowLatestRunSummary(row Object) Object { + // startedAt and finishedAt are nullable columns and the label shows "not yet" for them, so the + // raw value passes through rather than becoming an empty string that is not a date-time. + return Object{ + "id": row.S("id"), + "name": row.S("name"), + "status": row.S("status"), + "snapshotId": row.S("snapshotId"), + "createdAt": row.S("createdAt"), + "startedAt": row["startedAt"], + "finishedAt": row["finishedAt"], + } +} + // workflowRunSummary is the row projection shared by list and detail: the run's own fields, // kept identical so a history row and a detail page never disagree about what a run is. func workflowRunSummary(run Object) Object {