- 删除 docs/plan 下 00–18 的计划、评审、验收、发布记录与升级证据,docs/spikes 与 spikes 验证代码
(保留 spikes/movieclaw-src 本地克隆),以及 4 份一次性审查 / 调研;全部可在 ceb66f8 查到
- MP 对照移到 docs/porting/moviepilot-v3-comparison.md;经验教训改为 docs/plan/lessons.md(补第 25 条)
- docs/plan/README.md:文档放哪、当前状态、待办
- 引用改写:代码注释指向在维护的文档,其余注明已删除与 git 位置;清掉原有 6 个失效链接
- README 开头改为当前状态与入口;CHANGELOG 补齐 0.3.8–0.3.14 与工程变更
Co-Authored-By: Claude Opus 5.5 <[email protected]>
18 KiB
OpenWorkflow 面板调研 → /console/engine 复刻方案
- 调研对象:本机锁定版本
[email protected](spikes/openworkflow/node_modules/openworkflow,node_modules/.pnpm/[email protected]*)+ GitHubopenworkflowdev/openworkflow(main 分支源码,含apps/dashboard、apps/cli)+npm view。 - 关联:ADR-002(
docs/adr/ADR-002-task-engine.md)、spike 记录w0b-openworkflow(已删除,见 gitceb66f8)。 - 结论先说:核心库(Apache-2.0)不含任何面板代码;面板是两个独立、无开源许可的 npm 包;面板架构极简(2 个页面、无 workers 表、无鉴权、无手动重试原语);我们要做的是"参考信息架构 + 直连同一份 schema + 用自己的组件重写",不是"抄源码"。
1. 面板怎么来的、技术栈、鉴权现状
spikes/openworkflow/node_modules/openworkflow/dist/ 下只有 client/ core/ worker/ postgres/ sqlite/ 几个目录,纯 TS 库,没有 bin 字段、没有 dashboard 相关文件,grep -r "dashboard" 全库零命中。README 里那张 dashboard.png 配图对应的是另外两个包:
@openworkflow/cli(npm 最新 0.5.2,bin: openworkflow)—— scaffold /worker start/dashboard三个子命令的入口。@openworkflow/dashboard(npm 最新 0.1.10,bin: dashboard,解包 3.2MB)—— 面板本体。
启动方式:npx @openworkflow/cli dashboard [--port N]。源码 apps/cli/commands.ts::getDashboardSpawnOptions 显示它只是 spawn("npx", ["@openworkflow/dashboard"], { env: { ...process.env, PORT } }) 的一层包装;也可以跳过 CLI 直接 npx @openworkflow/dashboard。运行前必须在项目根目录有 openworkflow.config.ts(否则报错提示先 init),面板从里面读 config.backend,连到同一个 Postgres、同一个 openworkflow schema。默认端口 3000(与其 vite dev --port 3000 脚本一致)。
技术栈:@tanstack/react-start(基于 Vite 的全栈 React 19 SSR 框架,文件路由 + server functions,不是独立跑的 REST API 服务)+ shadcn/ui(@base-ui/react)+ Tailwind CSS v4 + @monaco-editor/react(只读 JSON 查看器)+ @phosphor-icons/react + prom-client(暴露 /metrics Prometheus 文本端点,不在导航里)。面板"后端"就是 TanStack Start 的 createServerFn,在服务端直接 import type { Backend } from "openworkflow/internal" 调用,没有对外 REST API,前端也是通过同构的 server function 调用,不是 fetch 一个公开 JSON 接口。
无认证的事实:遍历 apps/dashboard/src 下 48 个源文件,没有一个与 auth / login / session 相关;AppLayout 只有 logo + 明暗主题切换。任何能访问该端口的人都能看到全部 run 的 input/output/error(可能含业务敏感数据)、发起新 run、取消/重跑任意 run。生产环境必须自己在前面套反向代理鉴权,面板自身零门禁——这也是我们必须把复刻版做成 admin-only 的直接依据。
2. 页面与功能清单
路由只有两个文件路由:/(runs 列表)、/runs/$runId(run 详情)。没有 workers 页、没有队列页、没有单独的统计页(统计卡片嵌在列表页顶部),原因见第 3 节:引擎压根没有持久化的 worker 表。另有非 UI 路由 /metrics(Prometheus)。
2.1 / Workflow Runs 列表页
- 标题 + "New Run" 按钮(打开 Dialog,见下)。
WorkflowStats:5 张统计卡片,固定顺序 pending / running / completed / failed / canceled(类型上用Omit<..., "succeeded"|"sleeping">强制排除这两个 legacy 状态),每张显示 count + 状态图标(pending=时钟/暖色,running=转圈图标叠加 spin 动画/蓝色,completed=对勾/绿色,failed=叉/红色,canceled=禁止符/灰色)。数据来自countWorkflowRuns()——全表GROUP BY status,不受列表分页/筛选影响。RunList:卡片列表,每行 = 状态图标(running 会转)+ workflow 名 + version 徽章 + run id(等宽全量展示,可复制)+ 状态 badge + 若为子工作流则显示父 run 关系徽章([父workflow名] 父runId,可点击)+ 右侧 Duration(finishedAt-startedAt,进行中用now-startedAt)+ Started(相对时间,如 "3m ago")。整行可点进详情。没有状态筛选下拉、没有按 workflow 名筛选、没有搜索框——后端listWorkflowRuns支持status/workflowName参数,但列表页 UI 目前没接筛选控件,只做了分页。- 分页:
CursorPaginationControls,基于{next, prev}双向不透明游标(不是 offset),页大小 25/50/100(默认 25)。 - "New Run" 弹窗(
CreateRunForm→createWorkflowRunServerFn):手填 workflowName、version(可选)、input(JSON 文本框,前端JSON.parse校验)、availableAt/deadlineAt(可选)。本质是运维手工投递工作流的后门,不校验 workflowName 是否有对应 worker 注册。 - 每 2 秒轮询(
usePolling:setInterval+router.invalidate()),切到后台标签页暂停,回前台立即刷新一次再继续。
2.2 /runs/$runId Run 详情页
- 头部:返回按钮 + workflow 名 + version 徽章 + 若有父 run 则显示 "Parent Workflow Run" 关系条(可跳转)+ 两个动作按钮:
- Rerun(仅终态 run 可见,即 completed/succeeded/failed/canceled):确认弹窗后调用
rerunWorkflowRun(backend, workflowRunId, fromStep?),创建新 run,携带原始 input 和 version;不指定fromStep则全部步骤重跑,指定则复用该步骤之前"已成功"的历史结果,该步骤本身/之后步骤/之前失败或未完成的步骤会重新执行,成功后跳到新 run 详情页。 重要发现:rerunWorkflowRun是从openworkflow/internal导出的,但本项目锁定的[email protected](dist/internal.d.ts)里没有这个导出(只有isWorkflow、backend 相关类型、WorkflowRun/StepAttempt类型)。这是 0.10.1 之后新增的能力,GitHub main 分支的迁移脚本比对下来(0→6 号迁移,新增的是"6 - add step index to steps",没有新表),说明这是纯代码层面加的功能,不涉及 schema 变更,但当前锁定版本用不了,重跑要自己实现(见第 4 节)。 - Cancel(仅 pending/running/legacy sleeping 可见):确认弹窗,调用
cancelWorkflowRun。
- Rerun(仅终态 run 可见,即 completed/succeeded/failed/canceled):确认弹窗后调用
RunOverviewPanel:Run ID(等宽可复制)、Status(badge,hover 有 tooltip 解释含义)、Started/Duration/Attempts 三个 metadata 字段、Available At/Deadline At(相对时间 + hover 看 ISO 绝对时间)/Worker ID(当前或最后持有该 run 租约的 worker 标识,无则 "-")。折叠区块 "Workflow Payloads":Input(只读 JSON,Monaco 渲染)、Output(若有 error 则显示 error 且标红,而不是 output;run 失败时该折叠区块默认展开)。- 左栏 "Steps" 面板:该 run 的
step_attempts列表(同款游标分页组件),做成可键盘导航的listbox(上下键/Home/End),每行 = 状态图标 + step 名 + kind 徽章(function/sleep/workflow/signal-send/signal-wait)+ 耗时。选中后右栏刷出详情。 - 右栏 "Step Inspector":Step Attempt ID、"Rerun from step" 按钮(同 Rerun 但带
fromStep)、Attempts for Step Name(同名 step 出现过几次,用于区分重试序号)、Started At/Finished At、若kind==="workflow"且有childWorkflowRunId则显示子 run 关系行、Output(同 run 级"有 error 显示 error"逻辑)。没有单独展示 step 的 input/config——面板选择不暴露这两个内部字段。 - 非终态 run 同样启用 2 秒轮询。
2.3 容易想当然但确实没有的东西
没有 workers/队列/并发页;没有全局搜索、按 workflowName 筛选下拉、导出、signal 发送/查看 UI;没有登录/多租户/RBAC;没有"原地重试单个失败 step"的按钮(Rerun 是整 run 级别产生新 run,不是原地重试)。
3. 数据来源:表结构与状态枚举
面板不经过公开 REST API,是服务端函数直接调用 Backend(Postgres 实现用 postgres.js,不是 pg/Kysely)。以下建表 SQL 摘自 dist/postgres/postgres.js 的迁移脚本(与 GitHub main 分支比对,0.10.1 之后只加了一个索引迁移,表结构未变),可以直接用 Kysely 只读查询同一份数据。
workflow_runs(PK: namespace_id + id)
| 列 | 类型 | 说明 |
|---|---|---|
| namespace_id, id | TEXT | 默认 namespace 是 "default" |
| workflow_name, version | TEXT(version 可空) | 注册表键是 (name, version) |
| status | TEXT | 见下方枚举 |
| idempotency_key | TEXT 可空 | 24h 去重窗口(DEFAULT_RUN_IDEMPOTENCY_PERIOD_MS) |
| config / context / input / output / error | JSONB 可空 | error 是 SerializedError |
| attempts | INT | 重试是整个 run 重新排期,这个数会随之增长 |
| parent_step_attempt_namespace_id / id | TEXT 可空 | 子工作流指回父 step |
| worker_id | TEXT 可空 | 唯一的"worker 信息",当前/最后持有租约的 worker 标识;running 且被主动 park 等待时会置空 |
| available_at / deadline_at / started_at / finished_at / created_at / updated_at | TIMESTAMPTZ |
状态枚举 WorkflowRunStatus:pending | running | sleeping(legacy) | succeeded(legacy) | completed | failed | canceled。等待信号/sleep 期间 run 仍是 running,只是 worker_id 为空、available_at 指向唤醒时间(ADR-002 已验证)。
step_attempts(PK: namespace_id + id)
| 列 | 类型 | 说明 |
|---|---|---|
| workflow_run_id, step_name, kind | TEXT | kind ∈ function|sleep|workflow|signal-send|signal-wait |
| status | TEXT | running|succeeded(legacy)|completed|failed |
| config / context / output / error | JSONB 可空 | context 按 kind 判别:sleep 有 resumeAt,workflow 有 timeoutAt,signal-wait 有 signal+timeoutAt |
| child_workflow_run_namespace_id / id | TEXT 可空 | kind=workflow 时指向子 run |
| started_at / finished_at / created_at / updated_at | TIMESTAMPTZ |
关键索引:step_attempts_signal_wait_idx ON (namespace_id, (context->>'signal')) WHERE kind='signal-wait' AND status='running'——排查"卡在等信号"时可以直接按这个条件查。
workflow_signals、openworkflow_migrations
workflow_signals(namespace_id, id, signal, data, sender_idempotency_key, workflow_run_id, step_attempt_id, created_at):只记录成功投递的信号(没有等待者时 sendSignal 直接丢弃、不落库,ADR-002 已验证)。openworkflow_migrations(version):单列版本表。
没有 workers 表
对照 GitHub main 分支当前全部迁移(0~6 号)确认:引擎从未有过持久化的 worker 注册表/心跳表。worker_id 只是 workflow_runs 上的一个字符串快照,没有独立生命周期、没有心跳时间戳、没有主机名/版本/并发数信息。面板因此也没有 workers 页——这不是面板功能缺失,是引擎架构层面就没有这个数据源。想在自己面板做"活跃 worker"视图,只能退而求其次:SELECT DISTINCT worker_id, count(*), min(started_at) FROM workflow_runs WHERE status='running' AND worker_id IS NOT NULL GROUP BY worker_id,并且要在 UI 上明确标注"这是当前持有租约的快照,不是权威 worker 注册表"。
Backend 接口(等价于我们要复刻的查询能力)
listWorkflowRuns({status?, workflowName?, limit?, after?, before?}) → {data, pagination:{next,prev}};countWorkflowRuns() → 按 status 分组计数(不含 succeeded/sleeping);getWorkflowRun({workflowRunId});listStepAttempts({workflowRunId, limit?, after?, before?});getStepAttempt({stepAttemptId});cancelWorkflowRun({workflowRunId});createWorkflowRun(...)。没有 retryWorkflowRun/retryStepAttempt 这类原语——0.10.1 里失败重试完全是引擎内部按 retryPolicy 自动做的,不可从外部触发。分页游标是不透明字符串,不要自己臆测格式,原样透传。
4. 本项目复刻方案:/console/engine
路由(apps/web,沿用现有 app/routes/console/*.tsx 扁平命名)
app/routes/console/engine.tsx:runs 列表 + 统计卡片 + "当前活跃 worker" 小条(见上,标注非权威)。app/routes/console/engine.$runId.tsx:run 详情(steps 列表 + step inspector,两栏布局)。- 不单独做 "workers" 三级路由——数据源太弱,做成列表页里的一个衍生区块即可,避免暗示它是一个可信的运维视图。
服务端 API(apps/server,Hono,挂在既有 API_PREFIX = "/api/v1",鉴权用现成的 requireScope("admin"))
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /api/v1/engine/runs |
query: status?, workflowName?, cursor?, limit?;内部转换成 openworkflow 的 before/after 双游标 |
| GET | /api/v1/engine/runs/:id |
run 详情 |
| GET | /api/v1/engine/runs/:id/steps |
该 run 的 step_attempts,分页 |
| GET | /api/v1/engine/counts |
等价 countWorkflowRuns(),给统计卡片 |
| GET | /api/v1/engine/workers |
见上,DISTINCT worker_id 快照,响应里显式带 note: "非权威注册表" 字段或注释 |
| POST | /api/v1/engine/runs/:id/cancel |
走 TaskEngine.cancel(ADR-002 端口),不直连 openworkflow,保持"先写业务侧 cancel_requested 再调引擎 cancel"的既有约定 |
不做 retry/rerun 端点:0.10.1 没有这个引擎原语,失败重试是自动的。如果确实需要面板级"重跑",要自己实现——读目标 run 的 input/version + 已完成 step 历史,调用 createWorkflowRun 生成新 run——并且要在 UI 上明确标成"引擎诊断专用重跑,不是业务重试",避免和业务层显式配置的 retryPolicy/幂等键混淆(ADR-002 §2.6)。
字段映射(引擎表列 → API 字段 → 展示位置)
引擎列(workflow_runs) |
API 字段 | 展示位置 |
|---|---|---|
| id / workflow_name / version | id / workflowName / version | 列表行标题、详情页头部 |
| status | status | 状态徽章 + 统计卡片分组键 |
| attempts | attempts | 详情页 metadata |
| worker_id | workerId | 详情页 metadata;列表页顶部"活跃 worker"聚合源 |
| available_at / deadline_at | availableAt / deadlineAt | 详情页 metadata(相对时间 + hover 绝对时间) |
| started_at / finished_at | startedAt / finishedAt | 列表 Duration 计算、详情页字段 |
| input / output / error | input / output / error | 详情页折叠区"Workflow Payloads"(有 error 优先显示 error) |
| parent_step_attempt_id | parentStepAttemptId → 反查父 run | 详情页"父工作流"关系条 |
| (不映射)config / context | 不对外暴露为"输入" | 仅 context->>'signal' 用于排查 signal-wait 卡住原因 |
step_attempts 同理:step_name/kind/status/started_at/finished_at/output/error/child_workflow_run_id 映射到 Steps 列表行 + Step Inspector,config 不展示。
需要注意的点
- 诊断视图与业务视图分开:ADR-002 已指出
step_attempts会有停在running的孤儿行(worker 被杀、租约未到期)。engine 面板可以照实展示——这正是它的价值(暴露孤儿 attempt);但面向用户的业务任务页(下载进度等)绝不能读这张表,必须读业务表。两者要物理上分开路由、分开权限,不要合并成一个页面。 - 权限仅 admin:理由——官方面板本身零门禁,而 run 的 input/output/error 可能含下载路径、账号、TMDB key 等业务敏感数据,且有 cancel/新建 run 的写操作,风险等同于直接操作引擎。
- 只读连接单独开:openworkflow 自己的 postgres.js 连接池不能塞进 Kysely 事务(ADR-002 已验证),诊断查询建议用独立的只读 pg 连接指向
openworkflowschema,不占业务写路径的连接数;两边连接数都算进 PGmax_connections。 - 不要把 config/context 当输入展示:这两个字段是引擎内部状态(重试策略、continuation),对运维暴露用
input/output/error三个字段即可,context只在排查 signal-wait 卡住时挖context->>'signal'(注意同一信号名同一时间只能有一个等待者,卡住时先怀疑撞了这个限制)。 - 统计卡片的轮询代价:官方面板每 2 秒
router.invalidate()一次,其中countWorkflowRuns()是全表GROUP BY。已有索引workflow_runs_status_created_at_desc_idx兜底,但表到百万级后不建议照抄 2 秒轮询频率,建议拉长间隔或改用 SSE(项目已有EventBus/sse.ts)只推增量,不要无脑复制这个轮询模式。 - 游标透传:openworkflow 的
{next, prev}是双向不透明游标,与本项目packages/contracts里CursorQuery/cursorPage(单向cursor+nextCursor)的约定不完全一致。engine 相关端点建议单开一套双向游标类型,不要硬塞进现有单向cursorPage泛型,否则"上一页"功能会丢失。
5. 许可证
openworkflow(核心库,npmopenworkflow,GitHub 目录packages/openworkflow):Apache-2.0,仓库根LICENSE.md是标准 Apache-2.0 全文。我们已经在用这个库,符合 ADR-002。@openworkflow/cli与@openworkflow/dashboard(面板本体及其 CLI 入口):npm 明确标记 "Proprietary"——两个包各自的package.json都没有license字段(npm 对缺失 license 字段的展示约定就是 Proprietary),且apps/cli、apps/dashboard目录下没有单独 LICENSE 文件覆盖仓库根的 Apache-2.0。也就是说:源码虽在同一个公开仓库可读,但面板和 CLI 不是开源许可,不能按 Apache-2.0 处理。- 因此不是"照抄需要保留 NOTICE"这么宽松,而是面板 UI 源码本身没有对外许可,不能复制/改写它的
.tsx/.ts源文件去用,哪怕保留版权声明也不构成许可。可以做、也应该做的:- 参考它的信息架构、字段取舍、状态配色语义(功能设计层面,不受版权限制,本文档第 2、3 节即用于此)。
- 直接查询
openworkflowschema 的数据(表结构由 Apache-2.0 的核心库定义,数据访问不受面板许可证约束)。 - UI 组件、路由、页面代码完全用本项目既有设计系统(
uhd_web迁移过来的一套)重新实现,不引用、不拷贝apps/dashboard/src下任何源文件。 - 若未来想直接复用
@openworkflow/dashboard本体(不改造,只套反代鉴权跑起来),需要先向 OpenWorkflow 官方确认商用许可条款(proprietary 包条款未公开),不能假设"内部用、不对外分发"就自动免责。