Repository navigation
feat(workflows): 运行记录独立成页,运行历史按时间倒序完整分页 - #176
Conversation
c7a8b51 to
7b1d444
Compare
…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.
7b1d444 to
de6fea5
Compare
creegon2
left a comment
There was a problem hiding this comment.
审查对象: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状态下改成确认后放行。
请补一条回归:保存失败、第一次导航留下后,用户确认放弃,第二次导航能真正离开。启动运行后跳转详情的路径也请顺带覆盖。
建议
-
保存一直不返回时,导航会无反馈地卡住。
DraftNavigationGuard一直await flushDraft(),PUT 挂住时点击任何链接都没有反应,只显示"保存中…"。可以和上面的确认框一起处理,比如等待超过几秒就让用户选择继续等还是离开。不阻塞。 -
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,这一条是按查询和索引定义推断的,请用真实库确认一下计划。 - 现有索引
-
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去重仍然有用,保护的是插入新运行后、已结束的记录换了页的情况,但注释写的原因不对,后来的人可能按错误的理解去改。建议改正注释。 -
「版本」页对读取失败的处理没有跟运行记录页统一。
workflow-versions-page.tsx:31和workflow-editor-page.tsx:38在详情读取出现任何失败(403、500)时,都显示"未找到该工作流"。运行记录页这一轮已经改成按错误码区分。「版本」是本 PR 新加的独立路由,建议复用WorkflowReadFault的判断方式。#620 的状态要求是针对运行记录写的,所以不阻塞。 -
useWorkflowDraft.reset现在只剩测试在用。 生产代码里已经没有调用方,只有 #179 的workflow-output-fields.test.tsx:53还在用。可以接受;如果想去掉,可以让那条测试改为重新挂载编辑器来模拟重新加载。 -
集成测试注释和代码不符。
integration/workflow_run_history_test.go的TestWorkflowRowsCarryTheirNewestRun注释写"Written newer first",但代码先写 older 再写 newer。按这个顺序,"取最后插入的一行"这种错误实现也能通过。建议对调写入顺序,或者给两条运行相同的created_at,用 id 决定先后,这样同时覆盖latestRun和历史第一行在同一时刻时是否一致。 -
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
left a comment
There was a problem hiding this comment.
复审 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,这次没改。PUT 一直挂着时,点链接没有反应,也不会弹对话框,界面上只有"保存中…"。仍然不阻塞,可以以后再加超时,或者在等待期间就显示对话框。
-
测试没有覆盖
saveFailed这条分支。 我把else setSaveFailed(true)改回原来的else blocker.reset(),7 条导航测试仍然全部通过。原因是保存失败时status会先变成error,对话框靠这个状态就能打开,测试只检查了对话框出现过,没检查 flush 结束之后对话框还在。建议在第一次失败后等 PUT 结束、再等一个渲染周期,然后断言对话框仍然打开、路径不变。 -
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 已恢复干净。
Summary
运行历史原先藏在编辑器图标弹层里,且只取一页 100 条;现在可从工作流列表直接打开有独立地址的历史,查看任意一次运行后返回。
游标只在同一租户、同一工作流、未删除的运行中解析;
latestRun只出现在列表行。前端轮询所有已加载页,跨页按运行 ID 保留终态,区分加载、空、失败和无权限。刷新失败重试原读取,下一页失败重试下一页;开始前取消的运行即使没有开始时间,也显示已有结束时间。编辑器切换标签页或通过工具栏离开时,先完成未结算的草稿保存;保存失败时提供「留在编辑器」与「放弃未保存修改并离开」两种选择。选择放弃会取消待保存状态,不再在卸载时重复 PUT;已经创建运行后跳运行详情也受同一选择保护。查看历史不启动运行。首次详情读取失败保留错误语义:403 显示无权限,临时失败重新读取详情,只有
not_found显示工作流不存在。ADR:不涉及 ADR。本次未改变工作流调度根决策的承诺,运行历史排序没有新增公共 ADR 契约。
Refs ora-space/circus#635
Evidence
3f25b252用 merge commit 纳入 cloudupstream/main@6626b823(含 cloud#184),没有改写 PR 历史。工作流 README 冲突保留双方约束;合并后的workflows模块 68 个测试文件、778 项测试及类型检查通过。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。这是隔离样本,不代表生产延迟。task check的格式、lint、proto 与internal/core通过;全量 Go 测试在未改动的凭据文件模式/目录同步、缺少git http-backend与 Unixenv命令的用例失败。head a1ddcc0 的远端 frontend、packages、integration-0..3 与汇总 check 全部通过。Merge Danger
Door: two-way
无数据迁移;
latestRun是可选列表字段。回退恢复原页面与排序,已有运行数据保持可读。Blast Radius: workflows