Skip to content

feat(workflows): 让运行详情与历史随运行状态更新(有界轮询 + 单调合并) - #82

Merged
EricWvi merged 7 commits into
ora-space:mainfrom
zhangshilong123:feat/203-run-live-status
Oct 20, 2026
Merged

EricWvi merged 7 commits into
ora-space:mainfrom
zhangshilong123:feat/203-run-live-status

Conversation

@zhangshilong123

@zhangshilong123 zhangshilong123 commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Summary

运行详情页与历史列表此前只读一次 REST 快照:历史列表只在 popover 打开时 refetch(),详情页完全不刷新——一个运行从 pending 走到终态,打开着的页面永远停在第一次读到的状态,节点输出也不会出现。

这个 PR 让读取跟着运行走,按 ora-space/circus#203 正文允许的有界轮询 + 单调合并:

  • 有界轮询:pending / running / awaiting_input 每 5 秒重读一次;进入终态即停;本版本不认识的状态(后端新增枚举)继续轮询,避免把服务端还在推进的运行静默冻住。标签页不可见、组件卸载时由 TanStack 既有默认行为停轮询。
  • 单调合并:一次读可能是过期读(副本延迟、乱序、与写入重叠)。读取结果不再直接覆盖缓存,而是与已有副本合并——终态运行不会被非终态的读拉回去;同类读之间 updatedAt 新的胜出;nodeStates 按节点合并,只补不删,避免与节点写入重叠的读把页面已经显示的输出抹掉。历史列表与详情页用同一条规则,同一个 Run 在两处不会互相矛盾。
  • 合并放在 structuralSharing:这是唯一能看到缓存当前值、且与写入同一时刻的位置,放在读取返回之后再合并会和并发读交错。自定义函数会替换 TanStack 的默认结构共享,所以合并后再走一次 replaceEqualDeep,让内容未变的读保持对象身份——否则每 5 秒都会给页面一个新对象,冻结的图画布会跟着重排。

为什么不用事件:SpaceEventType 里没有工作流运行的事件类型,事件名要有后端发布者才有意义,而发布者属于 #70 / #75 的实现,在这里先立一套就是另立契约(#203 正文与 @Zpc001 的分工都禁止)。SSE 重连时的全租户失效、refetchOnReconnect / refetchOnWindowFocus 的既有底座继续生效,重连后运行查询会被顺带刷新。

改动文件(都是交付物):

文件 改动
frontend/src/features/workflows/api.ts runPollInterval / runsPollInterval、mergeRunRead / mergeRunPage 及其 structuralSharing 适配,接到 useWorkflowRun / useWorkflowRuns
frontend/src/features/workflows/api.test.tsx 新增:轮询边界、合并规则(终态不可回退、updatedAt 定序、节点只增)、陈旧读被丢弃、未变读保持身份、历史行不回退
frontend/src/features/workflows/run/run-detail-page.test.tsx 新增:运行中的页面跟着轮询走到终态,节点输出一次点击可见,未走到的节点仍显示未执行;时间未设置时隐藏开始/结束
frontend/src/features/workflows/run/run-detail-page.tsx run 级 startedAt/finishedAt 同时排除空串(#105 合并前仍返回 "")
frontend/src/features/workflows/run/run-node-state.ts + .test.ts 解析 nodeStates 时空时间戳按未发生,不存进 RunNodeEntry
frontend/src/features/workflows/run/run-inspector.test.tsx 未跑到的节点不显示执行时间
frontend/src/features/workflows/editor/node-parameter-summary.tsx + .test.tsx 兜住 executor-slot 节点(executor 缺失)不崩溃,配置区留空
frontend/src/test/polling.ts + polling.test.ts + 两份 README 新增 settleUntil:setInterval 被伪造时 Testing Library 的 waitFor 自己也在轮询、无法工作,轮询测试用它重试断言

提交按单一目的拆分:轮询与合并(含其测试)、页面级测试、读序用解析后的时间戳、读失败后继续轮询、空时间戳按未发生、executor-slot 兜底、cancelRequestedAt fixture 对齐。

本 PR 不做的

ADR 状态:实现 ora-space/specs 的 decisions/cloud/workflow/0-cloud-side-scheduler-owns-workflow-execution.md(status: approved)中 D7 的读取一半——「节点产出在首期落在运行的节点状态里,供运行详情展示」。本 PR 不带 specs 改动:执行侧(真实执行与结果回报)是 ora-space/circus#70(已关闭),ADR 由承接它的实现改动统一改为 implemented;本 PR 只读展示,不翻转 ADR 状态。

Evidence

  • npm run check 通过(在 rebase 到 upstream/main 5953506 之后):format:check、lint、typecheck、test(140 文件 / 1165 用例,覆盖率 lines 91.47% / branches 82.6% / functions 89.33% / statements 90.4%,阈值 80%)、check:modules、check:docs、check:dead、check:dup、build。
  • npm run check:modules -- --base origin/main:Module hygiene OK(4 modules)。
  • 反例核对(改完还原):去掉两处 structuralSharing 接线 → 陈旧读与历史行回退两个用例失败;去掉合并后的 replaceEqualDeep → 未变读保持身份的用例失败。三条规则各自有用例兜住,不是空转。
  • 已 rebase 到最新 upstream/main(f405823 → 27e7f48,含 feat(core): declare and resolve workflow globals and sys.* variables #104 workflow 全局变量、fix(core): keep unset workflow run timestamps null #105 空时间戳为 null、feat(core): cancel workflow runs by request and settle them at the scheduler #109 workflow 取消给 WorkflowRun 加的必填 cancelRequestedAt 字段),并给本 PR 新增的 fixture 补上 cancelRequestedAt: null;rebase 后重跑上面全部门禁仍全绿。

Merge Danger

Door: two-way

只改前端读取与展示,没有 schema、迁移、OpenAPI 或生成客户端的改动;回滚即恢复"读一次快照"的行为。

Blast Radius: frontend(features/workflows 的运行详情与历史列表、features/workflows/editor 的节点参数摘要、src/test 脚手架)

运行处于非终态时页面每 5 秒发一次读请求;后端若不返回终态,轮询会持续到页面关闭。历史列表在有未终态运行时同样每 5 秒重读一次。

Refs ora-space/circus#203

@Zpc001 Zpc001 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.

复审 f7bb828,通过。

核对项:

  • 范围:7 个文件都是交付物(api.ts、两个测试、src/test 的轮询测试脚手架与 README),没有进度记录或无关改动;基于 main 10fd6dc,mergeable/CLEAN,frontend CI 绿。
  • 轮询边界:非终态 5 秒;终态停;不认识的枚举继续轮询(避免冻住服务端仍在推进的运行);undefined 状态不轮询。标签页不可见/组件卸载由 TanStack 默认行为停止。
  • 合并规则:终态不被非终态读拉回;同类读 updatedAt 新的胜出;nodeStates 按节点只补不删;历史页以服务端返回的成员为准,并复用同一合并规则,列表与详情不会互相矛盾。
  • 身份保持:合并接在 structuralSharing,之后走 replaceEqualDeep;未变读保持对象身份,避免每 5 秒让冻结画布重排。
  • 反例有效:测试覆盖终态回退、陈旧读丢弃、历史行回退、未变读身份;PR 描述里的「去掉接线即失败」与用例对得上。
  • 本地验证:npx vitest run src/features/workflows(55 文件 / 591 用例)通过,npm run typecheck 通过。

ADR 状态正确:实现的是 D7 的读取一半,真实执行与结果回报属 #70,ADR 保持 approved。

非阻塞建议(不要求本 PR 修改):

  1. 初次读取失败时 query.state.data 仍是 undefined,refetchInterval 返回 false,查询自身重试耗尽后页面不会自行恢复;如果希望错误态也自愈,可以在 error 状态保持轮询。
  2. updatedAt 用字符串比较依赖服务端时间戳格式一致。当前 Postgres ISO 输出可用,但若以后出现 Z 与 +00:00 混用或精度变化,建议改成比较解析后的时间。

真实 Run 的页内证据、断线重连与租户/轮次边界按 preflight 约定等 #70 接通后补。

@zhangshilong123
zhangshilong123 force-pushed the feat/203-run-live-status branch from f7bb828 to 1f1f574 Compare October 20, 2026 09:14

@Zpc001 Zpc001 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 1f1f574(基于 upstream/main f405823e),代码通过。

  • 两条非阻塞建议都已正确落实:keep polling after a failed run read(failed 参数让初读失败继续轮询、错误态自愈,已持有的终态 run 不会被重新拉起来);order run reads by parsed timestamp(Date.parse,解析失败按更旧处理)。
  • 合并规则正确:终态不可回退、同类读按 updatedAt 取新、nodeStates 按节点只补不删、structuralSharing 用 replaceEqualDeep 保持未变读引用稳定。
  • rebase 干净、CI 绿、mergeable CLEAN。

原生 Approve。运行 issue ora-space/circus#203 的 review:lgtm 会等真实 Run 页内证据(第 3 条)路径定后再按最终 head 打。

@zhangshilong123
zhangshilong123 force-pushed the feat/203-run-live-status branch from 2f64b07 to 263e183 Compare October 20, 2026 11:27

@Zpc001 Zpc001 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 263e183058):Approve。

在 rebase 后的新 head 上复核了全部 6 个 commit:

  • 原始 4 个(有界轮询 + 合并、页面跟随测试、失败读后继续轮询、按解析时间戳排序)逻辑不变,仍成立。
  • ce6c3f3「空时间戳按未发生」:readTimestamp 把 "" 当 absent 丢弃,run 级显示同时排除 null 与 "",未跑到的节点不再渲染空时间行。正确。
  • 263e183「容忍无 executor 绑定的 agent 节点」:executor 缺失时配置区留空不崩溃,覆盖 executor-slot 谱系({"schemaVersion":1,"executorSlot":…})的真实 payload,run inspector 与编辑器卡片共用的同一函数都受益。正确。
  • rebase 到 main 5953506 干净,frontend CI 绿(head 263e183058)。

无阻塞。

zhangshilong123 and others added 7 commits October 20, 2026 20:41
An open run detail page showed whatever one read returned and never moved
again: the history re-read only when its popover opened, and the detail page
not at all. Nothing publishes a workflow run — `SpaceEventType` carries no run
event, and naming one here would fix a backend contract this change does not
own — so the read follows the run instead: `pending`, `running` and
`awaiting_input` re-read every five seconds, a terminal status stops, and a
status this build does not recognise keeps polling rather than freezing a run
the server is still advancing.

A poll can be answered late, or by a replica that has not caught up, so a read
no longer overwrites the cache: it merges into the copy already held. A settled
run is never reopened by an unsettled read, the newer `updatedAt` wins between
reads of the same kind, and node states merge node by node so a read that raced
a node's write cannot blank what the page already shows. The history and the
detail page merge by the same rule, so one run cannot read two ways.

`structuralSharing` is where that merge belongs: it is the only point that sees
the cache's current value as a read is written, so a concurrent read cannot
interleave between the comparison and the write. A custom function replaces
TanStack's default rather than adding to it, so the merge is followed by
`replaceEqualDeep` to keep the identity of an unchanged run — without it every
poll would hand the page a new object and re-lay out the frozen graph for a run
that has not moved.

Co-Authored-By: Claude Code <noreply@anthropic.com>
The page is what the issue is about, so its test mounts a run that is still
running, advances the poll's interval, and expects the node card, the run
status and the node's output to arrive without a reload — with a node the run
never reached still reading as unrun, because the statuses are overlaid on the
frozen snapshot rather than on a re-read definition.

Only the query's interval is faked, so the waits flush real timers.

Co-Authored-By: Claude Code <noreply@anthropic.com>
A read is the only channel the page has to a run, so a failed read says
nothing about whether the run settled. Before this change the poll interval
stopped at the first error: `runPollInterval` returns `false` for an
undefined status, and a failed read leaves no status, so an error on the
first read froze the page forever.

The poll now also fires while the last read failed. A run the page already
holds is unaffected: a settled run stays settled, and an unsettled one keeps
polling, whatever the last attempt returned.
The merge compared `updatedAt` strings, which leans on the server keeping
one timestamp format forever. Compare the parsed epoch instead, so the
comparison holds however the string is written. A read whose timestamp does
not parse is treated as older than any that does, keeping a known-good read
over an unorderable one.
The backend writes startedAt/finishedAt as "" for a node that never ran (and, before cloud#105, for the run row too). The viewer checked only for null, so the blank rendered as an empty time row: an inactive node showed "开始 —". Treat "" as absent at the boundary — drop it when parsing node states and hide the run header rows while the value is blank.
The executor-slot schema (ora-space#154) stores executorSlot where this build still
summarizes the legacy executor binding. Reading executor.agentCli from such a
node crashes the card summary; leave the config section empty instead.
cloud#109 added `cancelRequestedAt: string | null` to the generated
WorkflowRun type; the fixture this PR adds must supply it or main's typecheck
fails after merge.
@zhangshilong123
zhangshilong123 force-pushed the feat/203-run-live-status branch from 263e183 to a492316 Compare October 20, 2026 12:43

@Zpc001 Zpc001 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 a492316:rebase 到 main 27e7f48,api.test.tsx 的 runFixture 补上 cancelRequestedAt: null,run-detail-page.test.tsx 的 fixture 由 main 带入;frontend CI 绿。批准。

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