Skip to content

feat(workflows): 运行记录独立成页,运行历史按时间倒序完整分页 - #176

Merged
EricWvi merged 18 commits into
ora-space:mainfrom
wanglongan587:feat/635-run-history-tabs
Oct 27, 2026
Merged

EricWvi merged 18 commits into
ora-space:mainfrom
wanglongan587:feat/635-run-history-tabs

Conversation

@wanglongan587

@wanglongan587 wanglongan587 commented Oct 25, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

运行历史原先藏在编辑器图标弹层里,且只取一页 100 条;现在可从工作流列表直接打开有独立地址的历史,查看任意一次运行后返回。

- 编辑器里的运行历史 / 版本弹层;随机 UUID 顺序的一页运行
+ 编辑 / 运行记录 / 版本三个路由;(created_at, id) 倒序游标分页
+ 列表行展示最近运行;历史行展示状态、起止时间与快照

游标只在同一租户、同一工作流、未删除的运行中解析;latestRun 只出现在列表行。前端轮询所有已加载页,跨页按运行 ID 保留终态,区分加载、空、失败和无权限。刷新失败重试原读取,下一页失败重试下一页;开始前取消的运行即使没有开始时间,也显示已有结束时间。

编辑器切换标签页或通过工具栏离开时,先完成未结算的草稿保存;保存失败时提供「留在编辑器」与「放弃未保存修改并离开」两种选择。选择放弃会取消待保存状态,不再在卸载时重复 PUT;已经创建运行后跳运行详情也受同一选择保护。查看历史不启动运行。首次详情读取失败保留错误语义:403 显示无权限,临时失败重新读取详情,只有 not_found 显示工作流不存在。

ADR:不涉及 ADR。本次未改变工作流调度根决策的承诺,运行历史排序没有新增公共 ADR 契约。

Refs ora-space/circus#635

Evidence

  • 3f25b252 用 merge commit 纳入 cloud upstream/main@6626b823(含 cloud#184),没有改写 PR 历史。工作流 README 冲突保留双方约束;合并后的 workflows 模块 68 个测试文件、778 项测试及类型检查通过。
  • reviewer 指出的导航锁死已补正式回归:PUT 409/404 失败后选择留下,第二次导航不再重试 PUT;确认放弃可到其他模块。运行已经创建后确认放弃可到运行详情,且不会重复创建运行或写草稿。导航与运行记录目标测试 21 项通过。
  • latestRun 从 DISTINCT ON SELECT * 改为每个工作流取最新一条的 LATERAL 查询,只读取摘要列;真实 HTTP/PostgreSQL 的 TestWorkflowRowsCarryTheirNewestRun 通过。隔离 PostgreSQL 17 的 EXPLAIN 样本为 50 个工作流、25,000 条运行:旧查询扫描并排序所有行,约 95.5 ms;新查询按 workflow_run_list 索引反向扫描 50 次、每次取 1 行,约 0.35 ms。这是隔离样本,不代表生产延迟。
  • 前端完整测试 165 个文件、1466 项通过;格式、lint、类型、覆盖率、模块文档、死代码和生成客户端检查通过。一次重复代码检查失败已通过改用用户交互测试消除;重复检查、目标测试、lint 和生产构建随后通过。Windows 本地 task check 的格式、lint、proto 与 internal/core 通过;全量 Go 测试在未改动的凭据文件模式/目录同步、缺少 git http-backend 与 Unix env 命令的用例失败。head a1ddcc0 的远端 frontend、packages、integration-0..3 与汇总 check 全部通过。
  • 原有真实 Cloud HTTP + PostgreSQL 验证覆盖 120 条倒序分页、同时间戳、游标范围、列表最近运行、运行生命周期与快照恢复;真实浏览器 + 隔离 PostgreSQL 验证 121→122 条历史、旧运行详情往返、确认后才启动、快照不混入活稿,以及首次 403/500、末页刷新 500。本轮新增导航边界由新 head 的直接回归验证,未将旧浏览器证据冒充新 head 的浏览器复验。

Merge Danger

Door: two-way

无数据迁移;latestRun 是可选列表字段。回退恢复原页面与排序,已有运行数据保持可读。

Blast Radius: workflows

…t run

The run list ordered by row UUID, so the first page was an arbitrary slice of the
history: the newest run need not be in it, and a workflow with more than 100 runs
could not reach the rest at all. Walk it by (created_at, id) instead, newest
first, keeping the cursor an opaque row UUID that resolves to the anchor row's
created_at — the shape the member tenant list already uses. Ties inside one
instant are decided by id on both sides of the comparison, so paging neither
repeats nor skips a row.

A workflow also answers "when did it last run": both the list and the detail read
carry latestRun, the first run of that same order, null when it has never run. It
is the compact projection (status, snapshot, timestamps), not a history row.

Evidence: integration/workflow_run_history_test.go walks 120 seeded runs through
real HTTP, asserts the full order against an independently computed one, the
cursor shape, the end-of-walk cases and the latestRun label on both reads.
A workflow's page was the editor, and the two things a member opens a workflow to
check were buried in toolbar popovers: the runs it has done and the versions it
can run. Neither had an address, neither could be linked to, and the workflow list
led nowhere else.

The detail page now has three tabs on real routes — `…/workflows/:id`,
`…/workflows/:id/runs`, `…/workflows/:id/versions` — so each surface is
shareable, bookmarkable, and reachable from a run's breadcrumb. The editor keeps
the session's edit history (renamed 编辑历史) and links to the other two instead of
rendering second copies of them in popovers; a rollback therefore happens on the
versions tab and hands the restored document back to the editor.

The run list is the new 「运行记录」 tab: it pages by `nextCursor` with 加载更早的运行
instead of one capped window, opens each run's existing detail page, and states
loading, no-runs, unreadable-with-retry and no-permission separately — a failed
read is not reported as an empty history. The workflow list labels every row with
its newest run and links straight to the run list, so "did it work last time" no
longer requires opening the editor.

Evidence: workflow-runs-page.test.tsx walks a 120-run history to the end through
the rendered page (no repeats, no omissions, cursors advancing one page at a
time), and covers each of the four states; workflow-versions-page.test.tsx covers
the tab and the rollback hand-off; api.test.tsx covers the cursor walk and the
flattening rule.
A run starting must not rewrite every read of a workflow resource, and the detail
read is also the gate every write goes through — projecting the label there cost a
query per workflow read and made an existing import test see a document that
changed when nothing about it had. The label belongs to the list, which is the
surface that shows "last run" per row; a reader that wants it on its own reads the
run history.

Also fixes the misspelling golangci-lint reports in the same comment.
The cursor is an opaque run UUID, so the page boundary is resolved from that
row's created_at. That lookup named no tenant, workflow or deletion filter, so an
id from another workflow, another tenant, or a purged run moved the boundary of
the history being read — a creation-time side channel across tenants, and a way
to page a history the caller's own rows cannot describe.

The anchor now resolves only inside the scope being paged, and any other id
compares as NULL: the walk ends with an empty page, the same answer an unknown
cursor gets. The integration test seeds a run in a second workflow, one in a
second tenant, and a soft-deleted run of the workflow itself, and asserts each
cursor answers an empty page while a cursor of this workflow still continues
below itself.
Two read paths were still written for a one-page list. The poll asked the first
page's rows whether anything was still moving, so a running run on a later page
was never refreshed; and the merge paired pages by position, so a run created at
the head moved a settled row across a boundary and the stale copy on its new page
reopened it.

The poll now decides on every loaded row, and the merge indexes the held copies
by run id across the whole walk before merging each page. Both regressions are
pinned at the hook: a running row on the second page settles on the next poll,
and a settled row pushed to the second page keeps its status while the read
behind it is stale.
The footer treated every failure as a paging error: a 403 on a later page read as
"could not be loaded" and invited a retry that cannot work, while a failed
refresh after the walk reached its end showed the end-of-history note — telling
the member the list was complete when the read had just failed. A refused read
now reads as the permission state, and a failure with no next page offers the
retry it needs; the rows already loaded stay either way.

A history row showed one instant (`startedAt ?? createdAt`), so a finished run
never said when it ended and a run that had not started looked like one that
started at its creation time. The row now carries start and end, and marks a run
that has not started with its creation time.
Treat missing workflow data as a query outcome rather than not_found. Show forbidden and retryable failures, and retry the detail request that failed; retain the real not_found state.
@wanglongan587
wanglongan587 force-pushed the feat/635-run-history-tabs branch from 7b1d444 to de6fea5 Compare October 26, 2026 03:36

@creegon2 creegon2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

审查对象:head 3e52280(基线 main ecf0d23,已含 cloud#179)。这是本 PR 第一次独立审查,看的是整个 PR(ecf0d23...3e52280 和合并后的净差异),不只是最后一轮。

结论:Request changes。阻塞项只有一条,是本轮新加的离开编辑器前等待保存(P2 修复 a)带出来的。其余部分,包括与 #179 的合并结果、另外两个 P2 修复、后端游标和 latestRun,我都核对过,没有发现问题。

应修

1. 草稿保存一旦失败,所有站内导航都会被拦住,用户离不开编辑器

位置:frontend/src/features/workflows/editor/workflow-editor.tsx:178(useBlocker(status !== 'clean'))和 :186-188(flush 失败就 blocker.reset())。

问题出在这两点叠加:

  • use-workflow-editor-state.ts:156 的注释写明,保存被拒(基本都是版本冲突 409)时重试解决不了,"only a reload can catch up"。
  • 保存失败后 status 一直是 'error'。于是之后每一次站内导航都会被拦下,再 flush 一次,又得到 409,然后 reset(),路由留在原地。

被拦下的不只是本 PR 新加的三个标签页和工具栏入口,而是这个编辑器挂载期间的所有站内导航:面包屑回工作流列表、侧边栏去别的模块、浏览器后退,还有 workflow-run-tools.tsx:78 启动运行成功后跳转运行详情的 navigate。最后这种情况下,运行已经在服务端创建了,页面却停在编辑器里。界面上只有一个"保存失败"状态,没有任何说明,也没有让用户放弃修改后离开的选项。能出去的办法只有整页刷新或者手动改地址栏。

复现场景:两个成员同时编辑同一个工作流,或者同一个人开了两个标签页。B 先保存,A 的下一次自动保存就是 409。另外,工作流在别处被删除后,PUT 返回 404,结果一样。

我在 worktree 里写了一个临时测试验证(已删除)。用和 workflow-navigation.test.tsx 相同的写法,PUT 固定返回 409:加一个节点,等到"保存失败",然后调用 3 次 router.navigate('/w/cloud-dev/workflows'),再调用 1 次 router.navigate('/w/cloud-dev/issues')。结果是 PUT 共发了 5 次(1 次自动保存,加上 4 次导航各重试一次),router.state.location.pathname 始终停在编辑器路径。合并前的 main 没有这个拦截,保存失败时离开只是丢掉未保存的修改,回来后会按服务端最新文档重建画布,这正好是 409 唯一的恢复办法。

现有回归 keeps the editor open when the draft save fails 只断言第一次导航被留下,所以发现不了"之后永远出不去"。

修改建议(任选一种,关键是给出离开的出路):

  • flush 失败时不要直接静默 reset(),弹出确认框,提供"留在编辑器"和"放弃未保存的修改并离开"两个选项(blocker.proceed());
  • 或者只在 dirty / saving 时拦截,error 状态下改成确认后放行。

请补一条回归:保存失败、第一次导航留下后,用户确认放弃,第二次导航能真正离开。启动运行后跳转详情的路径也请顺带覆盖。

建议

  1. 保存一直不返回时,导航会无反馈地卡住。 DraftNavigationGuard 一直 await flushDraft(),PUT 挂住时点击任何链接都没有反应,只显示"保存中…"。可以和上面的确认框一起处理,比如等待超过几秒就让用户选择继续等还是离开。不阻塞。

  2. latestRun 的查询会读这页工作流的全部运行。 internal/core/workflow_run.go:94-96 用 SELECT DISTINCT ON (workflow_id) * ... ORDER BY workflow_id, created_at DESC, id DESC。没有 N+1,一页列表只多一次查询,这一点没问题。但是:

    • 现有索引 workflow_run_list(workflow_id, created_at, id) 全是升序,workflow_id ASC, created_at DESC 这种方向混合的排序用不上它,结果是要把这 50 个工作流的全部运行行取出来排序;
    • * 会把 node_states、rounds、input 这些 jsonb 一起读出来,而每次打开工作流列表都会执行这条查询。

    运行多了以后,这条查询的开销会随运行总数线性增长。建议改成 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),每个工作流只走一次反向索引扫描。如果保留 DISTINCT ON,至少把排序改成 workflow_id DESC, created_at DESC, id DESC,让方向和索引一致,并且只取摘要需要的列。我没有连库跑 EXPLAIN,这一条是按查询和索引定义推断的,请用真实库确认一下计划。

  3. api.ts 的注释和 TanStack v5 的实际刷新方式不一致。 api.ts:522、:561 写的是"Every loaded page is refreshed at its own cursor"。实际上 v5 的 infiniteQueryBehavior 刷新时只有第一页用存下来的参数,后面每页的游标都由刚取回的上一页通过 getNextPageParam 重新算出(node_modules/@tanstack/query-core/build/modern/infiniteQueryBehavior.js 第 54 行)。所以刷新后各页是连续的,不会出现跨页重复。现在的按 ID 合并和 runHistoryRows 去重仍然有用,保护的是插入新运行后、已结束的记录换了页的情况,但注释写的原因不对,后来的人可能按错误的理解去改。建议改正注释。

  4. 「版本」页对读取失败的处理没有跟运行记录页统一。 workflow-versions-page.tsx:31 和 workflow-editor-page.tsx:38 在详情读取出现任何失败(403、500)时,都显示"未找到该工作流"。运行记录页这一轮已经改成按错误码区分。「版本」是本 PR 新加的独立路由,建议复用 WorkflowReadFault 的判断方式。#620 的状态要求是针对运行记录写的,所以不阻塞。

  5. useWorkflowDraft.reset 现在只剩测试在用。 生产代码里已经没有调用方,只有 #179 的 workflow-output-fields.test.tsx:53 还在用。可以接受;如果想去掉,可以让那条测试改为重新挂载编辑器来模拟重新加载。

  6. 集成测试注释和代码不符。 integration/workflow_run_history_test.go 的 TestWorkflowRowsCarryTheirNewestRun 注释写"Written newer first",但代码先写 older 再写 newer。按这个顺序,"取最后插入的一行"这种错误实现也能通过。建议对调写入顺序,或者给两条运行相同的 created_at,用 id 决定先后,这样同时覆盖 latestRun 和历史第一行在同一时刻时是否一致。

  7. translations.ts 里 // Version history and publishing. 下面的键已经移到 run-and-version-translations.ts,留下了一个空注释,可以删掉。

已核对

  • 需求对照 #620 和 owner 的意见:
    • 编辑 / 运行记录 / 版本是三个真实路由的标签页;
    • 列表行显示最近一次运行,并有带文字的运行记录入口;
    • 运行详情的面包屑返回运行记录;
    • 编辑历史的文案已改成"编辑历史",和运行记录、版本区分开;
    • 「运行」按钮仍然是表单、确认、进详情,浏览历史不会启动运行;
    • owner 列出的 5 类问题和"补结束时间"都已经在代码里落实。
  • 与 #179 的合并:
    • translations.ts 的冲突解决是对的:本 PR 的 runAndVersionTranslations 和 #179 拆出来的 aggregator / loop / node / operator 四组在中英两个 bundle 里都有展开;
    • use-workflow-draft.ts 里 #179 的 addLoopExit、appendLoopExitNode、条件分支删边逻辑都在;
    • use-workflow-editor-state.ts 里 recordNodeAddition、onAddLoopExit 都在;
    • workflow-editor.tsx 仍然把 onAddLoopExit 传给 inspector;
    • 删掉的 resetDraft / cancel 只被原来编辑器里的版本恢复弹层使用,#179 没有依赖它们;
    • 版本页恢复后 useRestoreWorkflowSnapshot 会写入详情缓存,再跳回编辑器,编辑器按恢复后的文档和版本号重新建立草稿,恢复流程是通的;
    • 生成契约和双语文档的自动合并结果同时保留了两边的内容。
    • 我在 worktree 跑了 npx vitest run src/features/workflows:64 个文件、707 项全部通过,包括 #179 的 output-fields、aggregator-fields、loop / node-factory 相关测试。npm run typecheck 也通过。
  • P2 修复 b(开始前取消的运行显示结束时间):run-history-row.tsx 改为按 finishedAt 自己的值决定显不显示结束时间,逻辑正确。
  • P2 修复 c(未到末页时刷新失败):用 isFetchNextPageError 区分刷新失败和翻下一页失败,刷新失败时调用 refetch()。先刷新失败、再翻页失败这个顺序我也看过:刷新失败时"加载更早的运行"按钮会隐藏,只保留刷新的重试;重试成功后才回到翻页状态。两种错误不会混在一起。
  • 测试有效性:我把下面这些逻辑逐一临时改回去,每次对应的回归都失败了,测完已全部还原:
    • 关掉 guardNavigation:导航测试 5 项失败;
    • 结束时间改回只在已开始时显示:1 项失败;
    • 页脚改回按 hasNextPage 判断:1 项失败;
    • shareRunHistory 改回按页下标合并:1 项失败;
    • 轮询改回只看第一页:1 项失败;
    • 后续页 403 不再单独识别:1 项失败。
  • 后端游标:
    • pageNewestRunFirst 的锚点子查询已经限定 tenant、workflow 和 deleted_at IS NULL;
    • 用 (created_at, id) 做严格比较,同一时刻的多条运行由 id 决定顺序,翻页不会重复也不会漏;
    • window 多取一行来判断是否还有下一页,末页判断正确;
    • 历史查询能用 workflow_run_list 索引做反向扫描,锚点查询走主键;
    • 目前没有任何代码会单独软删一条运行,所以"浏览过程中锚点运行被删除,导致提前显示到底"这种情况现在不会发生。
  • latestRun 的权限:工作流列表和运行列表都在同一个租户成员检查之后读取,latestRun 限定在本租户、本页列出的工作流里,看到的不会比运行列表多。详情读取和创建响应都不带这个字段,这一点有集成测试固定。
  • 我没有运行:没有跑 Go 集成测试,没有连接任何数据库或容器。后端行为是读代码得出的,加上作者在 #635 贴出的隔离 PostgreSQL 结果和 CI。

@creegon2 creegon2 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

复审 head a1ddcc0,相对 3e52280

审查范围:4102c28、44d00fa、4efb158、a1ddcc0 四个提交,以及合并提交 3f25b25 和 cloud#184 重叠的三个文件。main 带进来的其他内容没有审。

结论:Approve。上一轮的阻塞项已修复,这一轮没有发现新的应修项。

上一轮的阻塞项:已修复

上一轮的问题是:保存失败后,所有站内导航都被拦截,用户无法离开编辑器。

workflow-editor.tsx 的 DraftNavigationGuard 改成了下面的行为:

  • 保存失败后,弹出"草稿保存失败"对话框,提供"留在编辑器"和"放弃修改并离开"两个选项;
  • 已经处在 error 状态时,再次导航直接弹出对话框,不会把被拒绝的 PUT 再发一遍;
  • 选择离开时,先调用 discardDraft()(也就是 cancel()),再调用 blocker.proceed(),卸载时不会再写一次草稿。

验证方式:

  • 作者的回归:409 和 404 各一条,覆盖"留下、再导航、离开",并断言 PUT 始终只有 1 次;另一条覆盖启动运行后跳转运行详情,断言运行只创建一次、离开后能进入详情页。
  • 我补的临时测试(已删除):
    • 选"留下"后再改一处、再导航:会重新发一次 PUT(共 2 次),失败后对话框再次出现,路径不变;
    • 浏览器后退:保存失败后用 router.navigate(-1) 触发后退,对话框出现,选离开后正确回到上一页;
    • 导航时有一个自动保存正在进行,它返回 500:flush 会自动重试一次(第 2 次 PUT)。重试期间对话框不会出现,重试成功后正常跳转。这里我原本担心"对话框已经弹出、用户选了留下之后重试又成功"会产生冲突,但这个顺序实际不会发生:第一次失败到重试开始之间,状态会立刻从 error 回到 saving,对话框来不及打开。
  • 把逻辑改回去,测试是否失败:
    • 去掉 if (status === 'error') return:3 条失败;
    • 去掉 discardDraft():3 条失败。

应修

无。

建议

  1. 保存一直不返回时,导航仍然没有反馈。 这是上一轮建议 1,这次没改。PUT 一直挂着时,点链接没有反应,也不会弹对话框,界面上只有"保存中…"。仍然不阻塞,可以以后再加超时,或者在等待期间就显示对话框。

  2. 测试没有覆盖 saveFailed 这条分支。 我把 else setSaveFailed(true) 改回原来的 else blocker.reset(),7 条导航测试仍然全部通过。原因是保存失败时 status 会先变成 error,对话框靠这个状态就能打开,测试只检查了对话框出现过,没检查 flush 结束之后对话框还在。建议在第一次失败后等 PUT 结束、再等一个渲染周期,然后断言对话框仍然打开、路径不变。

  3. 409 时放弃修改并离开,再回来编辑,大概率还会 409。 编辑器重新挂载时,useWorkflow 先把缓存里的旧文档(旧版本号)交给编辑器,useWorkflowDraft 只在挂载时建立一次草稿,versionRef 也不会跟着后台刷新更新,所以回来后的第一次保存仍然用旧版本号。这个问题合并前就有,不是本 PR 引入的,我也只读了代码,没有写测试验证。建议在"放弃修改并离开"时(至少在 409 的情况下)清掉或作废这个工作流的详情缓存,让回到编辑器时读取服务端的最新版本。

已核对

  • latestRun 查询(internal/core/workflow_run.go):
    • 改成了 VALUES (...) JOIN LATERAL (... ORDER BY created_at DESC, id DESC LIMIT 1);
    • 子查询限定了 tenant_id=$1 和 deleted_at IS NULL,排序和运行历史第一页的排序完全一致;
    • 只选摘要需要的七列,可以在 workflow_run_list(workflow_id, created_at, id) 上反向扫描;
    • 内连接会让从没运行过的工作流不出现在结果里,latestRun 照旧是 null;
    • 列别名 workflow_id 映射成 workflowId,和原来 SELECT * 的取值方式一致。
    • 我没有连库,作者给的 EXPLAIN 数字(95.5 ms 降到 0.35 ms)没有复核。
  • 集成测试写入顺序:TestWorkflowRowsCarryTheirNewestRun 现在先写 newer 再写 older,注释和代码一致了。
  • 注释修正(4efb158):api.ts 两处已改成"后面每页的游标由刚读到的上一页推出",符合 TanStack v5 的实际行为;translations.ts 的空注释已删除。
  • 与 cloud#184 的合并:
    • 我比较了 6626b82..a1ddcc0 在 use-workflow-draft.ts、use-workflow-editor-state.ts、translations.ts、workflow-editor.tsx 上的差异,只有本 PR 自己的改动,#184 的内容都保留着,包括 runRoundTranslations 的展开和迭代成员提示的新文案;
    • runRoundTranslations 和 runAndVersionTranslations 没有重复的键。
  • 测试和类型检查:在 a1ddcc0 上跑 npx vitest run src/features/workflows,68 个文件、778 项全部通过;npm run typecheck 通过。
  • 和最新 main 的合并:git merge-tree a1ddcc0 3815b4a 没有冲突。合并 cloud#187 之后的结果我没有单独跑测试。
  • 我没有运行的:Go 和数据库测试都没跑,也没有连接任何数据库或容器。worktree 已恢复干净。

@EricWvi
EricWvi merged commit a4dfee1 into ora-space:main Oct 27, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants