From e4ba5d405f55543dd5d3ca432c24648171ebdda4 Mon Sep 17 00:00:00 2001 From: whyour Date: Fri, 4 Sep 2026 04:27:42 +0800 Subject: [PATCH] feat(ql3): manage canonical task runs from the existing panel --- docs/QINGLONG_3_0_ARCHITECTURE_RFC.md | 4 +- ...R-0533-native-console-ledger-pagination.md | 6 +- ...R-0534-native-console-session-isolation.md | 2 + .../ADR-0535-canonical-panel-run-control.md | 30 ++ docs/adr/README.md | 3 +- docs/operations/ql3-local-web-console.md | 5 +- .../panelBootstrapRoute.ts | 1 + .../test/panelBootstrapRoute.test.cjs | 1 + src/components/qinglong3/runControl.ts | 372 +++++++++++++++++ src/components/qinglong3/runControlModal.tsx | 384 ++++++++++++++++++ src/pages/crontab/index.tsx | 27 +- src/utils/qinglong3.ts | 10 + test/back/ql3PanelRunControl.test.cjs | 360 ++++++++++++++++ 13 files changed, 1199 insertions(+), 6 deletions(-) create mode 100644 docs/adr/ADR-0535-canonical-panel-run-control.md create mode 100644 src/components/qinglong3/runControl.ts create mode 100644 src/components/qinglong3/runControlModal.tsx create mode 100644 test/back/ql3PanelRunControl.test.cjs diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index a503bb8b..010278d7 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,8 +1,10 @@ # QingLong 3.0 Architecture RFC +- D-433/ADR-0535(现有面板执行管理源码候选):通过可选 `panel.runControl=task_run_v1` 开放逐项 Task start 和确切 Run cancellation,不恢复旧 Cron 写接口。确认前分别展示定时绑定版本与当前任务版本;每次项目 Run 窗口最多 64 条,只显示当前 Task 匹配项,支持显式选择、状态刷新和该次执行首片日志。确认绑定 mutation UUID,结果不明时只显式重试同一请求;关闭或重登后的响应不得继续操作。客户端 14 项含规范 body parser 与新旧 capability 回归,Node 20 面板构建与 240 files / 11,993,647 bytes 裁剪审计通过;全量前端类型诊断与基线同为 41 项,无新增。此切片仍待同源远端 CI/双架构实物,不代表旧面板已完整迁移,见 [ADR-0535](./adr/ADR-0535-canonical-panel-run-control.md)。 + - D-432/ADR-0534(原生 Console 源码候选):修复断开/重连后的异步结果污染。每次连接使用独立内存代次,统一请求与编辑/执行链拒绝旧成功、失败和 finally;旧响应不得重开编辑器、恢复 Secret catalog、覆盖 proof 或在新连接下发出后续请求。16 项针对性回归、完整 Local API 130/130、后端 1683 pass/2 环境 skip/0 fail,静态资产合计 114,042 bytes。断开不撤销已提交的服务端写入,重连须核对 durable 状态;不新增包、后台服务或后端权限。此修复尚未通过同源远端 CI/阶段实物验收,见 [ADR-0534](./adr/ADR-0534-native-console-session-isolation.md)。 -- D-431/ADR-0533(原生 Console 源码候选):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。远端 CI、浏览器证据和 exact 阶段实物分别核验,不借用 D-430 archive。 +- D-431/ADR-0533(exact 双架构 Console 阶段实物已交付):Task、Trigger、Run 列表接入既有 keyset API,开放下一页、回到首页和当前页刷新,每次最多 64 条并替换 DOM,不缓存历史页、不轮询或扩大后端查询。验证规范 cursor、页内/跨页顺序与继续边界;请求/选择代次防止旧列表、错误、详情和 Secret catalog 覆盖当前页面。客户端与生产 HTTP parser 集成 15/15、完整 Local API 114/114,静态资产共 111,857 bytes;默认 headless/Cluster 不变。提交 `ce8c3a7d` 的主 CI `33796190607` 与 Console artifact `33797144811` 已成功;amd64/arm64/milestone 实际下载校验、bundle/milestone auditor 与 archive 页面源码逐字节复核均通过,不借用 D-430 archive。它不包含后续 D-432 会话修复或 D-433 执行管理。 - D-430/ADR-0532(exact Console 双架构阶段实物已交付):在原生运行详情中开放日志下一片段、回到开头与当前片段刷新,固定 32 KiB 替换窗口,不累计全文或后台轮询。严格验证响应身份、字节范围与前进 cursor,按不含凭据的选择代次丢弃过期响应,防止切换或断开后覆盖新详情。复用既有 API/Policy/Audit,不新增包、依赖、迁移或后端 authority;默认 headless 与 Cluster 不变。提交 `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5` 主 CI run `33793623200` 为 42 success/3 expected skip/0 fail,独立 Kubernetes run `33793623184` 成功;Console artifact run `33793687627` 为 43 success/2 expected skip/0 fail。amd64/arm64/milestone artifact `9909229721`/`9909131639`/`9909265138` 实际下载后的 checksum、bundle v8 与 milestone v7 auditor 均通过;两个 archive 内的原生 JS 都与该提交逐字节一致。该实物不包含后续 D-431 列表分页,不等同完整 3.0 或 Public Release。 diff --git a/docs/adr/ADR-0533-native-console-ledger-pagination.md b/docs/adr/ADR-0533-native-console-ledger-pagination.md index 6c4df44b..a3b74296 100644 --- a/docs/adr/ADR-0533-native-console-ledger-pagination.md +++ b/docs/adr/ADR-0533-native-console-ledger-pagination.md @@ -1,6 +1,6 @@ # ADR-0533:原生 Console 的任务、定时与运行列表分页 -- 状态:Accepted(D-431 远端 CI 与产物构建通过;下载实物复核待完成) +- 状态:Accepted(D-431 exact 双架构 Console 阶段实物已交付并复核) - 日期:2026-09-04 - 关联:QL-RFC-0001 D-431、ADR-0532 @@ -29,4 +29,6 @@ 真实 Chromium 使用当前原生静态资产与合成 HTTP 数据验证了 Task、Trigger、Run 的第 65 条可达,任务详情可从第二页打开,DOM 每次只保留当前页。请求记录包含三个规范 continuation URL,浏览期间没有自动列表轮询。该浏览器证据验证交互,不冒充真实数据库、Owner 写入或双架构镜像验收。 -提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`(239,038,659 bytes)、arm64 `9910153237`(234,438,339 bytes)及 milestone `9910185111`(6,797 bytes);本记录仅确认远端成功与上传存在,下载后的 checksum、离线 auditor 和 archive 源码复核尚未完成。这些归档不包含后续 ADR-0534 的客户端连接代次修复。 +提交 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 的主 CI [run 33796190607](https://github.com/whyour/qinglong/actions/runs/33796190607) 与 Console artifact [run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 均已完成并成功。上传清单包含 amd64 `9910148271`(239,038,659 bytes)、arm64 `9910153237`(234,438,339 bytes)及 milestone `9910185111`(6,797 bytes)。三份已实际下载,SHA256SUMS、双 bundle v8 auditor、milestone v7 auditor 均通过,两份 bundle manifest digest 与 milestone 一致。Docker archive 内 application 最终层的 `console.js` 均为 80,336 bytes、SHA256 `febac949f0f72ebeda41fe743909074762d5add0804895ca6802b54d0d51a6a5`,与该提交逐字节一致。amd64 archive SHA256 为 `dffa8606ee30e6cbf45bd1166ad0c716450e15ac21c07e7e15e539db140d3281`,arm64 为 `25cad723858afea09f2787cf42160db4150e48690e03afdbb0ac396eb1bcb8c9`。 + +因本机空间不足,完成审计后已删除本轮两份临时 Docker archive,保留 manifest 等小文件;归档可从上述 run 重新下载。这些实物仍为 Alpha,不包含后续 ADR-0534 的客户端连接代次修复或 ADR-0535 的旧面板执行管理。 diff --git a/docs/adr/ADR-0534-native-console-session-isolation.md b/docs/adr/ADR-0534-native-console-session-isolation.md index d3a5e4a1..eff684a7 100644 --- a/docs/adr/ADR-0534-native-console-session-isolation.md +++ b/docs/adr/ADR-0534-native-console-session-isolation.md @@ -33,3 +33,5 @@ 真实 Chromium 使用当前静态资产和仅监听本机的合成 HTTP 服务完成连接、创建 Task、确认 Secret GET 挂起、断开、放行旧响应的交互;页面保持等待凭据且没有重开编辑器。测试没有真实数据库、生产 credential 或写入 authority,不能代替镜像端到端验收。 上述本地验证不代表修复已进入既有下载产物。D-431 的 `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53` 产物也不包含本修复;本切片必须以自己的 source revision、CI 和阶段归档证据验收。 + +提交 `03afd7e8` 的独立 Kubernetes deployment 验证通过,但主 CI [run 33799350582](https://github.com/whyour/qinglong/actions/runs/33799350582) 在 CloudNativePG live gate 失败:rollout 已完成,旧 operator Pod 仍为 Terminating,`verifyImageIds` 纳入其空 imageID 后拒绝。`2005cb6b` 单独修正 operator 取样,排除 deletionTimestamp 已设置的 Pod;存活 Pod 仍执行原摘要校验,空集合仍失败,针对性回归 11/11。此本地修复不改写旧 run 的失败结论,须由后续同源 CI 验证。 diff --git a/docs/adr/ADR-0535-canonical-panel-run-control.md b/docs/adr/ADR-0535-canonical-panel-run-control.md new file mode 100644 index 00000000..c6d3de8b --- /dev/null +++ b/docs/adr/ADR-0535-canonical-panel-run-control.md @@ -0,0 +1,30 @@ +# ADR-0535:现有面板的规范 Task/Run 执行管理 + +- 状态:Accepted(D-433 源码候选;同源 CI 与实物待验证) +- 日期:2026-09-04 +- 关联:QL-RFC-0001 D-433、ADR-0530、ADR-0531、ADR-0534 + +## 背景 + +现有面板已可登录、查看定时条目和定时执行日志,但不能实际管理运行。旧 Cron 的运行状态与 ID 不是 3.0 Task revision 或 durable Run authority;直接恢复旧运行/停止按钮会混淆定时绑定版本、当前任务版本,以及多个并发运行。 + +## 决策 + +- Local capability v1 增加可选 `panel.runControl: task_run_v1`。缺省保持旧只读体验,未知值拒绝,旧客户端可忽略新增字段。`cronList=bounded_read_only` 和 `legacyMutations=false` 不变:不开放旧 Cron 写接口、Task 编辑、Trigger 编辑、批量操作或其他旧页面。 +- 定时行新增“执行管理”,复用当前 Ant Design 页面和同源 API。在确认前读取 Task 当前 revision/content digest,分别展示定时绑定版本和本次运行版本;显式确认后才 POST `/api/v3/projects/:project/tasks/:task/runs`,不自动重绑 Trigger。 +- Run 浏览复用规范项目 keyset API,每个显式动作至多读取 64 条并仅展示当前 Task 的匹配项。页内及跨页顺序、重复条目、next cursor 和 canonical ID 均校验;翻页替换窗口,没有总数扫描、全文累积、自动查找或轮询。空页不证明更早没有匹配运行。 +- 必须读取并选择具体 Run 才能请求取消,取消 POST 指向该 Run,不按 Cron 或“最新一次”猜测目标。取消 receipt 只证明请求已登记;运行终态仍以手动刷新后的 durable projection 为准。 +- 手动启动返回的 Run 可直接选择、刷新并读取 latest Attempt 首片日志;不使用定时 Trigger 匹配,以免看错另外一次运行。Edge 16 KiB、Standalone 32 KiB,日志不累计或轮询。 +- 每份确认绑定一个 mutation UUID、确切版本或 Run ID。传输结果不明时保留原确认,显式重试复用同一请求体;禁止并发确认,不静默创建另一份启动请求。关闭或重连不撤销已发送的服务端操作。 +- 新客户端使用不含 credential 的连接代次和窗口生命周期标记,关闭、登出或同 credential 重登后的响应不得继续产生操作。凭据仍只保留内存,不写 URL、Cookie 或 Web Storage;服务端 `run.start`/`run.stop` Policy、audit 与 mutation fence 不变。 +- 新模块位于 `src/components/qinglong3`,避免 Umi 将辅助模块注册为独立页面。不新建 package、依赖、listener、timer 或数据库权威;默认 headless 和 Cluster 不携带这个 Local 面板。 + +## 验证与边界 + +客户端测试执行真实 TypeScript 模块,覆盖确认前无 POST、current revision 与旧 pin 区分、实际规范 body parser、相同 mutation 重试、并发确认拒绝、权限/版本错误、错误 receipt、准确 Run 取消、分页顺序和预算、关闭/重登、延迟解码、明确 Run 日志与新旧 capability 兼容。 + +客户端专项 14/14、Local API 130/130,最终后端 1700 项(1698 pass / 2 环境条件 skip / 0 fail);Local image 与 18-package boundary audit 均 compatible。真实 Chromium 在构建后的裁剪面板和合成服务上完成登录、版本 1/2 差异展示、确认运行、选中返回 Run、读取该次日志、确认取消与 cancelled 展示。服务记录只有一个启动 POST 和一个针对相同 Run 的取消 POST;整个交互期间只有一次 Cron 列表和一次项目 Run 列表,没有后台轮询。桌面 1280 与手机 390 宽度截图复核了弹窗滚动、表格横向窗口与底部关闭入口。上述浏览器证据不包含真实执行进程或数据库写入。 + +Node 20 legacy migration toolchain 的 production panel build 通过;裁剪包为 240 files / 11,993,647 bytes,仍在 256 files / 13 MiB 边界。全量前端 TypeScript 检查存在 41 项错误;通过 compiler host 读取提交前源码作对比,确认本次没有新增诊断,不将存量类型错误称为通过。 + +本切片不是完整旧面板迁移:任务/定时创建编辑、脚本、订阅、环境变量、依赖与多用户远程 Web 会话仍未闭合。Local 仍仅 loopback/SSH tunnel;不可将本地 mock 或 unit tests 当成实际 Owner 写入、公开发布或双架构镜像证据。D-431 实物不含本切片,交付必须绑定自己的 source revision。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 2c3debc8..2d82e0c2 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -536,8 +536,9 @@ | [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Accepted(D-428 exact Console 双架构阶段实物已交付) | | [ADR-0531](./ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | 现有面板到规范 Run 日志的有界桥接 | Accepted(D-429 exact Console 双架构阶段实物已交付) | | [ADR-0532](./ADR-0532-native-console-log-window-navigation.md) | 原生 Console 日志窗口导航 | Accepted(D-430 exact Console 双架构阶段实物已交付) | -| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 源码候选;实物待验证) | +| [ADR-0533](./ADR-0533-native-console-ledger-pagination.md) | 原生 Console 任务、定时与运行列表分页 | Accepted(D-431 exact 双架构 Console 阶段实物已交付并复核) | | [ADR-0534](./ADR-0534-native-console-session-isolation.md) | 原生 Console 异步操作的连接代次隔离 | Accepted(D-432 源码候选;同源 CI 与实物待验证) | +| [ADR-0535](./ADR-0535-canonical-panel-run-control.md) | 现有面板的规范 Task/Run 执行管理 | Accepted(D-433 源码候选;同源 CI 与实物待验证) | ## 规则 diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index ebb5a0c8..f198e12b 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -21,10 +21,13 @@ D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一 | --- | --- | --- | | D-429 | 原生 Task/Trigger/Secret/Run 操作;适配后旧面板登录、只读 Cron 列表与首片日志 | `09ef1745226c05521a9a44accb9d9ef95dd46c85`;[Console milestone run 33789576578](https://github.com/whyour/qinglong/actions/runs/33789576578),已交付,见 [ADR-0531](../adr/ADR-0531-canonical-run-log-bridge-for-legacy-panel.md) | | D-430 | 原生 `/console` 日志“下一片段”“回到开头”“刷新当前片段” | `12ed38b7d72fe6366ad8f1ac84aebb1bb772f3b5`;[Console artifact run 33793687627](https://github.com/whyour/qinglong/actions/runs/33793687627) 已交付,两个下载 bundle 与 milestone 均通过离线审计,见 [ADR-0532](../adr/ADR-0532-native-console-log-window-navigation.md) | +| D-431 | 原生 Task/Trigger/Run 列表下一页、回到首页与当前页刷新 | `ce8c3a7d2afdbb11b2a33f4884702454d1d22a53`;[Console artifact run 33797144811](https://github.com/whyour/qinglong/actions/runs/33797144811) 已下载并完成双架构 checksum、auditor 和 archive 源码复核,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md) | 上表的旧面板是 bundle 自带的适配版本,不是未修改的 2.x 静态页面;旧账号密码/JWT、现有 2.x 数据和 Cluster 管理能力不会因页面可打开而自动接通。 -D-431 源码候选进一步给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首页/刷新当前页”。每次只显示最多 64 条,翻页替换旧记录和详情,不累积全文;顶部刷新与切换栏目回首页,页脚刷新保留当前边界。浏览期间数据可能变化,因此不提供固定总页数或快照承诺。Secret 目录和 Run Event/Step 仍维持现有窗口限制。此功能尚未交付到上表的 D-429 或 D-430 archive,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md)。 +D-431 给原生 Task、Trigger、Run 列表增加页脚“下一页/回到首页/刷新当前页”。每次只显示最多 64 条,翻页替换旧记录和详情,不累积全文;顶部刷新与切换栏目回首页,页脚刷新保留当前边界。浏览期间数据可能变化,因此不提供固定总页数或快照承诺。Secret 目录和 Run Event/Step 仍维持现有窗口限制。此功能不在 D-429 或 D-430 archive 中,见 [ADR-0533](../adr/ADR-0533-native-console-ledger-pagination.md)。 + +D-433 源码候选为旧面板定时行增加“执行管理”:先展示当前 Task revision,再显式确认运行;选择确切 Run 后可以刷新、读该次日志或请求取消。它不恢复旧 Cron 写接口,也不修改定时绑定版本。网络结果不明时只重试同一 mutation,关闭窗口不会撤销已提交请求。此功能需服务端 capability 显式开放,不包含在上表任何旧归档中,见 [ADR-0535](../adr/ADR-0535-canonical-panel-run-control.md)。 D-426b2c 又补齐了 Console 镜像的 adopted-target 入口证据:切换演练使用 `ql3-local-api --cutover-probe --config `,同时绑定外层 API 配置、内层 Application 配置与 exact mounts,但该模式不会启动本页使用的 listener、credential、scheduler 或 mutation surface。正常启动仍使用下文不带 `--cutover-probe` 的命令;提交 `229c3cb4e826866a0c7c4d81cb5e52cdc3975eec` 的 [artifact run 33463415938](https://github.com/whyour/qinglong/actions/runs/33463415938) 已交付 exact amd64/arm64 Console Trial Kit 与 milestone,三份下载产物的离线 auditor 均为 `compatible=true`。 diff --git a/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts b/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts index 3a11e12f..685ee1c7 100644 --- a/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts +++ b/packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute.ts @@ -51,6 +51,7 @@ export function panelCapabilities( panel: Object.freeze({ bootstrap: true, cronList: 'bounded_read_only', + runControl: 'task_run_v1', taskRead: true, triggerRead: true, runRead: true, diff --git a/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs b/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs index 235f9679..1ac2eb1b 100644 --- a/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs +++ b/packages/ql3-local-api/test/panelBootstrapRoute.test.cjs @@ -33,6 +33,7 @@ test('publishes an exact profile-aware capability contract', () => { panel: { bootstrap: true, cronList: 'bounded_read_only', + runControl: 'task_run_v1', taskRead: true, triggerRead: true, runRead: true, diff --git a/src/components/qinglong3/runControl.ts b/src/components/qinglong3/runControl.ts new file mode 100644 index 00000000..6867ea6b --- /dev/null +++ b/src/components/qinglong3/runControl.ts @@ -0,0 +1,372 @@ +import { + qingLong3Credential, + qingLong3Session, + type QingLong3Capabilities, +} from '@/utils/qinglong3'; + +const ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const DIGEST = /^[a-f0-9]{64}$/; +const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); +const STATUSES = new Set([ + ...TERMINAL, + 'created', + 'queued', + 'dispatching', + 'running', + 'waiting_approval', + 'retry_wait', + 'lost', +]); + +export interface PanelTask { + taskId: string; + name: string; + revision: number; + contentDigest: string; + enabled: boolean; +} +export interface PanelRun { + id: string; + taskId: string; + taskRevision: string; + status: string; + createdAtMs: number; + latestAttempt?: { id: string; logAvailable: boolean } | null; +} +export interface RunCursor { + createdAtMs: number; + runId: string; +} +export interface RunPage { + runs: PanelRun[]; + next?: RunCursor; + scanned: number; +} +export interface PreparedRunAction { + kind: 'start' | 'cancel'; + target: string; + mutationId: string; + execute(): Promise<{ runId: string; status: string }>; +} + +export class PanelRunControlError extends Error { + constructor(readonly code: string, readonly uncertain = false) { + super(code); + } +} + +function validId(value: unknown): value is string { + return typeof value === 'string' && ID.test(value); +} +function validTime(value: unknown): value is number { + return Number.isSafeInteger(value) && Number(value) >= 0; +} +function cursorOf(value: any): RunCursor { + if ( + !value || + !validTime(value.createdAtMs) || + !validId(value.runId) || + Object.keys(value).sort().join(',') !== 'createdAtMs,runId' + ) { + throw new PanelRunControlError('invalid_run_page'); + } + return Object.freeze({ createdAtMs: value.createdAtMs, runId: value.runId }); +} +function follows(left: RunCursor, right: RunCursor): boolean { + return ( + right.createdAtMs < left.createdAtMs || + (right.createdAtMs === left.createdAtMs && right.runId < left.runId) + ); +} +function runOf(value: any): PanelRun { + if ( + !value || + !validId(value.id) || + !validId(value.taskId) || + typeof value.taskRevision !== 'string' || + value.taskRevision.length > 255 || + !value.taskRevision || + !STATUSES.has(value.status) || + !validTime(value.createdAtMs) + ) { + throw new PanelRunControlError('invalid_run_response'); + } + if ( + value.latestAttempt != null && + (!validId(value.latestAttempt.id) || + typeof value.latestAttempt.logAvailable !== 'boolean') + ) { + throw new PanelRunControlError('invalid_run_response'); + } + return Object.freeze({ + id: value.id, + taskId: value.taskId, + taskRevision: value.taskRevision, + status: value.status, + createdAtMs: value.createdAtMs, + ...(value.latestAttempt === undefined + ? {} + : { + latestAttempt: + value.latestAttempt === null + ? null + : Object.freeze({ + id: value.latestAttempt.id, + logAvailable: value.latestAttempt.logAvailable, + }), + }), + }); +} + +export function createPanelRunControl( + cron: any, + capabilities: Readonly, +) { + const projectId = cron?.ql3?.projectId, + taskId = cron?.ql3?.taskId; + if ( + !validId(projectId) || + !validId(taskId) || + capabilities.panel.runControl !== 'task_run_v1' + ) { + throw new PanelRunControlError('run_control_unavailable'); + } + const session = qingLong3Session(); + let disposed = false, + writing = false; + let currentTask: PanelTask | null = null, + currentRun: PanelRun | null = null; + const base = `/api/v3/projects/${projectId}`; + const isCurrent = () => + !disposed && + session === qingLong3Session() && + Boolean(qingLong3Credential()); + const assertCurrent = () => { + if (!isCurrent()) throw new PanelRunControlError('session_changed'); + }; + const request = async ( + path: string, + body?: object, + accepted: number[] = [], + ) => { + assertCurrent(); + let response: Response, value: any; + try { + response = await fetch(path, { + method: body ? 'POST' : 'GET', + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + headers: { + accept: 'application/json', + authorization: `Bearer ${qingLong3Credential()}`, + ...(body ? { 'content-type': 'application/json' } : {}), + }, + ...(body ? { body: JSON.stringify(body) } : {}), + }); + value = await response.json(); + } catch { + assertCurrent(); + throw new PanelRunControlError('request_unavailable', Boolean(body)); + } + assertCurrent(); + if (!response.ok && !accepted.includes(response.status)) { + throw new PanelRunControlError( + typeof value?.code === 'string' ? value.code : 'request_unavailable', + Boolean(body) && response.status >= 500, + ); + } + return { value, status: response.status }; + }; + const prepared = ( + kind: 'start' | 'cancel', + target: string, + fields: object, + validate: (value: any) => boolean, + ): PreparedRunAction => { + const mutationId = crypto.randomUUID(); + const body = Object.freeze({ ...fields, mutationId }); + const path = + kind === 'start' + ? `${base}/tasks/${taskId}/runs` + : `${base}/runs/${target}/cancellation`; + return Object.freeze({ + kind, + target, + mutationId, + async execute() { + assertCurrent(); + if (writing) throw new PanelRunControlError('operation_pending'); + writing = true; + try { + const { value } = await request(path, body); + if (!validate(value)) + throw new PanelRunControlError('invalid_mutation_response', true); + return Object.freeze({ + runId: value.runId as string, + status: value.status as string, + }); + } finally { + writing = false; + } + }, + }); + }; + return Object.freeze({ + isCurrent, + dispose() { + disposed = true; + currentTask = null; + currentRun = null; + }, + async readTask(): Promise { + const { value } = await request(`${base}/tasks/${taskId}`); + const task = value?.task; + if ( + !task || + task.taskId !== taskId || + typeof task.name !== 'string' || + task.name.length > 512 || + !Number.isSafeInteger(task.revision) || + task.revision < 1 || + !DIGEST.test(task.contentDigest) || + typeof task.enabled !== 'boolean' + ) { + throw new PanelRunControlError('invalid_task_response'); + } + currentTask = Object.freeze({ + taskId, + name: task.name, + revision: task.revision, + contentDigest: task.contentDigest, + enabled: task.enabled, + }); + return currentTask; + }, + async listRuns(after?: RunCursor): Promise { + const cursor = after ? cursorOf(after) : undefined; + // Canonical IDs contain no query separators; the HTTP parser rejects encoded colon aliases. + const query = cursor + ? `&after_created_at_ms=${cursor.createdAtMs}&after_run_id=${cursor.runId}` + : ''; + const { value } = await request(`${base}/runs?limit=64${query}`); + if ( + !Array.isArray(value?.runs) || + value.runs.length > 64 || + typeof value.hasMore !== 'boolean' || + value.hasMore !== Boolean(value.next) + ) + throw new PanelRunControlError('invalid_run_page'); + const runs: PanelRun[] = value.runs.map(runOf); + let previous = cursor; + for (const run of runs) { + const next = { createdAtMs: run.createdAtMs, runId: run.id }; + if (previous && !follows(previous, next)) + throw new PanelRunControlError('invalid_run_page'); + previous = next; + } + const next = value.next ? cursorOf(value.next) : undefined; + if ( + next && + (!runs.length || + next.runId !== previous?.runId || + next.createdAtMs !== previous?.createdAtMs) + ) { + throw new PanelRunControlError('invalid_run_page'); + } + return { + runs: runs.filter((run) => run.taskId === taskId), + scanned: runs.length, + next, + }; + }, + async readRun(runId: string): Promise { + if (!validId(runId)) + throw new PanelRunControlError('invalid_run_identity'); + const { value } = await request(`${base}/runs/${runId}`); + const run = runOf(value?.run); + if ( + run.id !== runId || + run.taskId !== taskId || + value.run.projectId !== projectId + ) { + throw new PanelRunControlError('invalid_run_identity'); + } + currentRun = run; + return run; + }, + prepareStart(task: PanelTask) { + assertCurrent(); + if (task !== currentTask || !task.enabled) + throw new PanelRunControlError('task_not_ready'); + return prepared( + 'start', + taskId, + { + schema: 'qinglong/task-start@v1', + expectedRevision: task.revision, + expectedContentDigest: task.contentDigest, + }, + (value) => + value?.schema === 'qinglong/task-start@v1' && + value.projectId === projectId && + value.taskId === taskId && + value.taskRevision === task.revision && + value.taskContentDigest === task.contentDigest && + validId(value.runId) && + ['accepted', 'existing'].includes(value.status), + ); + }, + prepareCancel(run: PanelRun) { + assertCurrent(); + if (run !== currentRun || TERMINAL.has(run.status)) + throw new PanelRunControlError('run_not_active'); + return prepared( + 'cancel', + run.id, + { schema: 'qinglong/run-cancellation@v1' }, + (value) => + value?.schema === 'qinglong/run-cancellation@v1' && + value.projectId === projectId && + value.runId === run.id && + ['accepted', 'already_requested', 'already_terminal'].includes( + value.status, + ), + ); + }, + async readLog(run: PanelRun): Promise { + if (run !== currentRun) + throw new PanelRunControlError('invalid_run_identity'); + if (!run.latestAttempt?.logAvailable) + return '当前运行尚无可读日志,请刷新运行状态后重试。'; + const length = capabilities.limits.logChunkBytes; + const attemptId = run.latestAttempt.id; + const { value, status } = await request( + `${base}/runs/${run.id}/attempts/${attemptId}/log?offset=0&length=${length}`, + undefined, + [202, 410], + ); + if (status === 202) return '日志尚未就绪。'; + if (status === 410) return '日志已按保留策略清理。'; + if ( + value?.status !== 'available' || + value.encoding !== 'base64' || + value.runId !== run.id || + value.attemptId !== attemptId || + typeof value.content !== 'string' || + value.content.length > Math.ceil(length / 3) * 4 + ) { + throw new PanelRunControlError('invalid_log_response'); + } + const bytes = Uint8Array.from(atob(value.content), (c) => + c.charCodeAt(0), + ); + if (bytes.length > length) + throw new PanelRunControlError('invalid_log_response'); + return `${new TextDecoder().decode( + bytes, + )}\n\n[本次仅显示首个至多 ${length} 字节;刷新不自动续读]`; + }, + }); +} diff --git a/src/components/qinglong3/runControlModal.tsx b/src/components/qinglong3/runControlModal.tsx new file mode 100644 index 00000000..ee557d85 --- /dev/null +++ b/src/components/qinglong3/runControlModal.tsx @@ -0,0 +1,384 @@ +import React, { useEffect, useRef, useState } from 'react'; +import { + Alert, + Button, + Descriptions, + Modal, + Space, + Table, + Typography, +} from 'antd'; +import type { QingLong3Capabilities } from '@/utils/qinglong3'; +import { + createPanelRunControl, + PanelRunControlError, + type PanelTask, + type PanelRun, + type PreparedRunAction, + type RunPage, + type RunCursor, +} from './runControl'; + +const terminal = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); +const labels: Record = { + succeeded: '成功', + failed: '失败', + cancelled: '已取消', + timed_out: '已超时', + created: '已创建', + queued: '排队中', + dispatching: '派发中', + running: '运行中', + waiting_approval: '等待审批', + retry_wait: '等待重试', + lost: '执行状态待核对', +}; +const errors: Record = { + authentication_required: '凭据已失效,请重新登录。', + authorization_denied: '当前身份没有执行该操作的权限。', + task_start_fence_rejected: + '任务版本或权限在确认期间已改变。本次启动被拒绝,请刷新任务后重新确认。', + session_changed: '连接已改变,请关闭窗口并重新登录。', + task_not_ready: '请先读取当前任务,已停用的任务不能启动。', + run_not_active: '请选择并刷新一个尚未结束的运行。', +}; + +export default function QingLong3RunControlModal({ + cron, + capabilities, + onClose, +}: { + cron: any; + capabilities: Readonly; + onClose(): void; +}) { + const clientRef = useRef>(); + const busyRef = useRef(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(''); + const [notice, setNotice] = useState(''); + const [task, setTask] = useState(); + const [page, setPage] = useState(); + const [cursor, setCursor] = useState(); + const [run, setRun] = useState(); + const [log, setLog] = useState(''); + const [prepared, setPrepared] = useState(); + const [uncertain, setUncertain] = useState(false); + + const work = async ( + action: (client: ReturnType) => Promise, + ) => { + const client = clientRef.current; + if (!client || busyRef.current) return; + busyRef.current = true; + setBusy(true); + setError(''); + try { + await action(client); + } catch (caught) { + if (clientRef.current !== client) return; + const failure = caught instanceof PanelRunControlError ? caught : null; + if (!client.isCurrent()) { + setTask(undefined); + setRun(undefined); + setPage(undefined); + setLog(''); + setPrepared(undefined); + setError(errors.session_changed); + } else { + setError( + failure?.uncertain + ? '服务端可能已收到请求。请重试同一请求以核对结果;不要新建另一份启动请求。' + : errors[failure?.code || ''] || + '读取或操作失败,请检查服务与权限后手动重试。', + ); + setUncertain(Boolean(failure?.uncertain)); + } + } finally { + if (clientRef.current === client) { + busyRef.current = false; + setBusy(false); + } + } + }; + const loadRuns = async ( + client: ReturnType, + after?: RunCursor, + ) => { + const result = await client.listRuns(after); + if (!client.isCurrent()) return; + setPage(result); + setCursor(after); + setRun(undefined); + setLog(''); + }; + const selectRun = async ( + client: ReturnType, + id: string, + ) => { + const result = await client.readRun(id); + if (client.isCurrent()) { + setRun(result); + setLog(''); + } + }; + useEffect(() => { + let client: ReturnType; + try { + client = createPanelRunControl(cron, capabilities); + } catch { + setError('当前服务未开放执行管理,或条目标识无效。'); + return; + } + clientRef.current = client; + void work(async () => { + const current = await client.readTask(); + if (client.isCurrent()) setTask(current); + await loadRuns(client); + }); + return () => { + client.dispose(); + clientRef.current = undefined; + busyRef.current = false; + }; + }, [cron, capabilities]); + + const prepare = (kind: 'start' | 'cancel') => + void work(async (client) => { + const action = + kind === 'start' + ? client.prepareStart(task!) + : client.prepareCancel(run!); + setPrepared(action); + setNotice(''); + setUncertain(false); + }); + const execute = () => + void work(async (client) => { + if (!prepared) return; + const receipt = await prepared.execute(); + if (!client.isCurrent()) return; + setPrepared(undefined); + setUncertain(false); + setNotice( + prepared.kind === 'start' + ? `运行已登记:${receipt.runId}。排队不代表已经执行成功。` + : `取消请求结果:${receipt.status}。只有运行进入终态才表示已结束。`, + ); + await selectRun(client, receipt.runId); + }); + const close = () => { + if (busy || uncertain) { + Modal.confirm({ + title: '关闭执行管理?', + content: + '关闭窗口不会撤销已发送的请求。再次打开时请先核对运行记录,避免重复启动。', + onOk: onClose, + }); + } else onClose(); + }; + const blocked = busy || Boolean(prepared); + return ( + 关闭} + destroyOnClose + > + + + {error && } + {notice && } + + + {task?.name || cron.name} ·{' '} + {cron.ql3.taskId} + + + {cron.ql3.taskRevision} + + + {task + ? `${task.revision}${task.enabled ? '' : '(已停用)'}` + : '未读取'} + + + + + + + {prepared && ( + + + 请求 ID:{prepared.mutationId}。重试会复用此 ID。 + + + + + + + } + /> + )} + + 运行记录 + + + 每页扫描项目最近至多 64 + 条,仅显示本任务匹配项;空页不表示没有更早的运行。翻页替换当前窗口。 + + + size="small" + rowKey="id" + pagination={false} + loading={busy} + dataSource={page?.runs || []} + scroll={{ x: 600 }} + locale={{ + emptyText: '当前窗口没有匹配运行,可继续查找或回到最近记录。', + }} + columns={[ + { + title: 'Run ID', + dataIndex: 'id', + render: (id: string) => ( + + ), + }, + { title: '版本', dataIndex: 'taskRevision' }, + { + title: '状态', + dataIndex: 'status', + render: (status: string) => labels[status], + }, + ]} + /> + + + + + + {run && ( + <> + + + {run.id} + + + {labels[run.status]} + + + + + + + + + )} + {log && ( +
+            {log}
+          
+ )} + +
+ ); +} diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index a8cae45f..4668bd8d 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -53,6 +53,7 @@ import { getScheduleType } from './const'; import CronDetailModal from './detail'; import './index.less'; import CronLogModal from './logModal'; +import QingLong3RunControlModal from '@/components/qinglong3/runControlModal'; import CronModal, { CronLabelModal } from './modal'; import { CrontabStatus, @@ -305,6 +306,16 @@ const Crontab = () => { const isPc = !isPhone; return ( + {qingLong3?.panel.runControl === 'task_run_v1' && ( + { + e.stopPropagation(); + setRunControlCron(record); + }} + > + 执行管理 + + )} {!qingLong3ReadOnly && record.status === CrontabStatus.idle && ( { @@ -350,6 +361,7 @@ const Crontab = () => { const [searchText, setSearchText] = useState(''); const [isLogModalVisible, setIsLogModalVisible] = useState(false); const [logCron, setLogCron] = useState(); + const [runControlCron, setRunControlCron] = useState(); const [selectedRowIds, setSelectedRowIds] = useState([]); const [pageConf, setPageConf] = useState<{ page: number; @@ -992,7 +1004,13 @@ const Crontab = () => { title={intl.get('定时任务')} extra={ qingLong3ReadOnly - ? [QingLong 3.0 · 有界只读] + ? [ + + {qingLong3?.panel.runControl === 'task_run_v1' + ? 'QingLong 3.0 · 定时只读 / 执行可管理' + : 'QingLong 3.0 · 有界只读'} + , + ] : [ { qingLong3={qingLong3} /> )} + {runControlCron && qingLong3?.panel.runControl === 'task_run_v1' && ( + setRunControlCron(undefined)} + /> + )} {!qingLong3ReadOnly && isModalVisible && ( )} diff --git a/src/utils/qinglong3.ts b/src/utils/qinglong3.ts index a2262f21..c28a31ba 100644 --- a/src/utils/qinglong3.ts +++ b/src/utils/qinglong3.ts @@ -15,6 +15,7 @@ export interface QingLong3Capabilities { readonly panel: Readonly<{ bootstrap: true; cronList: 'bounded_read_only'; + runControl?: 'task_run_v1'; legacyMutations: false; legacyLogin: false; subscriptions: false; @@ -34,6 +35,11 @@ const CREDENTIAL_PATTERN = let capabilities: Readonly | null = null; let credential: string | null = null; +let session: Readonly = Object.freeze({}); + +export function qingLong3Session(): Readonly { + return session; +} function validCapabilities(value: any): value is QingLong3Capabilities { const profile = value?.deployment?.profile; @@ -50,6 +56,8 @@ function validCapabilities(value: any): value is QingLong3Capabilities { value?.authentication?.loginEndpoint === null && value?.panel?.bootstrap === true && value?.panel?.cronList === 'bounded_read_only' && + (value?.panel?.runControl === undefined || + value.panel.runControl === 'task_run_v1') && value?.panel?.legacyMutations === false && value?.panel?.legacyLogin === false && value?.panel?.subscriptions === false && @@ -96,6 +104,7 @@ export function qingLong3Capabilities(): Readonly | null export function setQingLong3Credential(value: string): boolean { if (!CREDENTIAL_PATTERN.test(value)) return false; + session = Object.freeze({}); credential = value; return true; } @@ -105,6 +114,7 @@ export function qingLong3Credential(): string | null { } export function clearQingLong3Credential(): void { + session = Object.freeze({}); credential = null; } diff --git a/test/back/ql3PanelRunControl.test.cjs b/test/back/ql3PanelRunControl.test.cjs new file mode 100644 index 00000000..df24c31a --- /dev/null +++ b/test/back/ql3PanelRunControl.test.cjs @@ -0,0 +1,360 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const vm = require('node:vm'); +const ts = require('typescript'); +const { test } = require('node:test'); +require('ts-node/register/transpile-only'); + +const credential = `ql3c_test_${'A'.repeat(43)}`; +const task = { + taskId: 'task:a', + revision: 2, + contentDigest: 'a'.repeat(64), + enabled: true, + name: 'Task A', +}; +const run = { + id: 'run:a', + projectId: 'default', + taskId: 'task:a', + taskRevision: '2', + status: 'running', + createdAtMs: 100, + latestAttempt: { id: 'attempt:a', logAvailable: true }, +}; +const cron = { + ql3: { projectId: 'default', taskId: task.taskId, taskRevision: 1 }, +}; +const capabilities = { + panel: { runControl: 'task_run_v1' }, + limits: { logChunkBytes: 16384 }, +}; +const startReceipt = { + schema: 'qinglong/task-start@v1', + projectId: 'default', + taskId: task.taskId, + taskRevision: 2, + taskContentDigest: task.contentDigest, + runId: run.id, + status: 'accepted', +}; +function fixture(response) { + const calls = []; + const context = vm.createContext({ + TextDecoder, + Uint8Array, + atob, + crypto: require('node:crypto').webcrypto, + fetch: async (url, options) => { + calls.push({ url, options }); + const value = await response(url, options, calls.length); + return { + ok: !value.httpStatus || value.httpStatus < 400, + status: value.httpStatus || 200, + json: value.json || (async () => value), + }; + }, + }); + const load = (relative, imports = {}) => { + const code = ts.transpileModule( + fs.readFileSync(path.resolve(__dirname, '../..', relative), 'utf8'), + { + compilerOptions: { + target: ts.ScriptTarget.ES2022, + module: ts.ModuleKind.CommonJS, + }, + }, + ).outputText; + const mod = { exports: {} }; + vm.runInContext(`(function(require,exports,module){${code}\n})`, context)( + (id) => { + assert.ok(imports[id], `Unexpected import ${id}`); + return imports[id]; + }, + mod.exports, + mod, + ); + return mod.exports; + }; + const auth = load('src/utils/qinglong3.ts'); + auth.setQingLong3Credential(credential); + const control = load('src/components/qinglong3/runControl.ts', { + '@/utils/qinglong3': auth, + }); + return { + auth, + control, + calls, + client: control.createPanelRunControl(cron, capabilities), + }; +} +const deferred = () => { + let resolve, reject; + const promise = new Promise((yes, no) => { + resolve = yes; + reject = no; + }); + return { promise, resolve, reject }; +}; + +test('preparing explicit current revision does not send a write and start uses only canonical fields', async () => { + const f = fixture((_url, options) => + options.method === 'GET' ? { task } : startReceipt, + ); + const current = await f.client.readTask(); + const action = f.client.prepareStart(current); + assert.equal(f.calls.length, 1); + await action.execute(); + assert.equal(f.calls[1].url, '/api/v3/projects/default/tasks/task:a/runs'); + const body = JSON.parse(f.calls[1].options.body); + assert.deepEqual(body, { + schema: 'qinglong/task-start@v1', + mutationId: action.mutationId, + expectedRevision: 2, + expectedContentDigest: task.contentDigest, + }); + assert.equal(f.calls[1].options.credentials, 'omit'); + assert.equal(f.calls[1].options.redirect, 'error'); + assert.equal( + f.calls[1].options.headers.authorization, + `Bearer ${credential}`, + ); +}); + +test('ambiguous transport failure retries exactly the same mutation rather than creating another run', async () => { + const f = fixture((_url, options, count) => { + if (options.method === 'GET') return { task }; + if (count === 2) throw new Error('response lost after commit'); + return { ...startReceipt, status: 'existing' }; + }); + const action = f.client.prepareStart(await f.client.readTask()); + await assert.rejects(action.execute(), (e) => e.uncertain === true); + assert.equal((await action.execute()).status, 'existing'); + assert.equal(f.calls[1].options.body, f.calls[2].options.body); +}); + +test('concurrent confirmation is rejected without a second POST', async () => { + const response = deferred(); + const f = fixture((_url, options) => + options.method === 'GET' ? { task } : response.promise, + ); + const action = f.client.prepareStart(await f.client.readTask()); + const first = action.execute(); + await assert.rejects(action.execute(), (e) => e.code === 'operation_pending'); + response.resolve(startReceipt); + await first; + assert.equal(f.calls.length, 2); +}); + +test('stale revision and denied identity never fall back to legacy mutation endpoints', async () => { + for (const code of ['task_start_fence_rejected', 'authorization_denied']) { + const f = fixture((_url, options) => + options.method === 'GET' ? { task } : { httpStatus: 409, code }, + ); + const action = f.client.prepareStart(await f.client.readTask()); + await assert.rejects( + action.execute(), + (e) => e.code === code && !e.uncertain, + ); + assert.equal(f.calls.length, 2); + assert.ok( + f.calls.every((x) => x.url.startsWith('/api/v3/projects/default/')), + ); + } +}); + +test('mismatched mutation receipts remain uncertain rather than reporting false success', async () => { + const f = fixture((_url, options) => + options.method === 'GET' ? { task } : { ...startReceipt, taskRevision: 99 }, + ); + await assert.rejects( + f.client.prepareStart(await f.client.readTask()).execute(), + (e) => e.uncertain, + ); +}); + +test('cancellation names a previously read Run and terminal or foreign runs cannot be submitted', async () => { + const f = fixture((_url, options) => + options.method === 'GET' + ? { run } + : { + schema: 'qinglong/run-cancellation@v1', + projectId: 'default', + runId: run.id, + status: 'accepted', + }, + ); + const current = await f.client.readRun(run.id); + const action = f.client.prepareCancel(current); + assert.equal(f.calls.length, 1); + await action.execute(); + assert.equal( + f.calls[1].url, + '/api/v3/projects/default/runs/run:a/cancellation', + ); + assert.deepEqual(Object.keys(JSON.parse(f.calls[1].options.body)).sort(), [ + 'mutationId', + 'schema', + ]); + assert.throws(() => + f.client.prepareCancel({ ...current, id: 'someone:else' }), + ); + const terminal = fixture(() => ({ run: { ...run, status: 'succeeded' } })); + const terminalRun = await terminal.client.readRun(run.id); + assert.throws(() => terminal.client.prepareCancel(terminalRun)); + const foreign = fixture(() => ({ run: { ...run, taskId: 'task:other' } })); + await assert.rejects(foreign.client.readRun(run.id)); +}); + +test('project paging filters task records after validating monotonic canonical cursors', async () => { + const f = fixture(() => ({ + runs: [run, { ...run, id: 'run:0', taskId: 'another' }], + hasMore: true, + next: { createdAtMs: 100, runId: 'run:0' }, + })); + const page = await f.client.listRuns(); + assert.equal(page.scanned, 2); + assert.equal(page.runs.length, 1); + await assert.rejects(f.client.listRuns(page.next)); // repeated page cannot advance + assert.equal( + f.calls[1].url, + '/api/v3/projects/default/runs?limit=64&after_created_at_ms=100&after_run_id=run:0', + ); + await assert.rejects( + f.client.listRuns({ createdAtMs: 100, runId: 'x&limit=999' }), + ); + assert.equal(f.calls.length, 2); +}); + +test('over-budget pages and forged next cursors fail closed', async () => { + for (const body of [ + { runs: Array(65).fill(run), hasMore: false }, + { runs: [run], hasMore: true, next: { createdAtMs: 99, runId: 'wrong' } }, + { runs: [run, run], hasMore: false }, + ]) + await assert.rejects(fixture(() => body).client.listRuns()); +}); + +test('closing or reconnecting with the same credential rejects pending results and prepared writes', async () => { + for (const finish of [ + (f) => f.client.dispose(), + (f) => f.auth.setQingLong3Credential(credential), + (f) => f.auth.clearQingLong3Credential(), + ]) { + const response = deferred(); + const f = fixture((_url, _options, count) => + count === 1 ? { task } : response.promise, + ); + const action = f.client.prepareStart(await f.client.readTask()); + const reading = f.client.readTask(); + finish(f); + response.resolve({ task }); + await assert.rejects(reading, (e) => e.code === 'session_changed'); + await assert.rejects(action.execute(), (e) => e.code === 'session_changed'); + assert.equal(f.calls.length, 2); + } +}); + +test('same-credential reconnect while JSON decoding discards response and never follows up', async () => { + const body = deferred(), + decoding = deferred(); + const f = fixture(() => ({ + json() { + decoding.resolve(); + return body.promise; + }, + })); + const reading = f.client.readTask(); + await decoding.promise; + f.auth.setQingLong3Credential(credential); + body.resolve({ task }); + await assert.rejects(reading, (e) => e.code === 'session_changed'); + assert.equal(f.calls.length, 1); +}); + +test('explicit Run log reads one bounded first chunk without using scheduled Trigger matching', async () => { + const f = fixture((url) => + url.includes('/log?') + ? { + status: 'available', + encoding: 'base64', + projectId: 'default', + runId: run.id, + attemptId: run.latestAttempt.id, + content: Buffer.from('manual execution 日志').toString('base64'), + } + : { run }, + ); + const current = await f.client.readRun(run.id); + assert.match(await f.client.readLog(current), /manual execution 日志/); + assert.equal( + f.calls[1].url, + '/api/v3/projects/default/runs/run:a/attempts/attempt:a/log?offset=0&length=16384', + ); + assert.equal(f.calls.length, 2); +}); + +test('old capabilities do not silently enable canonical writes', () => { + const f = fixture(() => ({})); + assert.throws(() => + f.control.createPanelRunControl(cron, { + panel: {}, + limits: capabilities.limits, + }), + ); +}); + +test('capability discovery supports old read-only servers and rejects an unknown execution contract', async () => { + const { + panelCapabilities, + } = require('../../packages/ql3-local-api/src/panel-compatibility/panelBootstrapRoute'); + for (const supported of [undefined, 'task_run_v1']) { + const value = panelCapabilities('edge'); + const f = fixture(() => ({ + capabilities: { + ...value, + panel: { ...value.panel, runControl: supported }, + }, + })); + const discovered = await f.auth.discoverQingLong3('/api/v3/capabilities'); + assert.ok(discovered); + assert.equal(discovered.panel.runControl, supported); + assert.equal(discovered.panel.legacyMutations, false); + } + const value = panelCapabilities('edge'); + const f = fixture(() => ({ + capabilities: { + ...value, + panel: { ...value.panel, runControl: 'allow_everything' }, + }, + })); + assert.equal(await f.auth.discoverQingLong3('/api/v3/capabilities'), null); +}); + +test('browser mutation bodies pass the actual profile-neutral request parsers', async () => { + const { + parseTaskStartRequestBody, + } = require('../../packages/ql3-runtime-core/src/task-start/taskStart'); + const { + parseRunCancellationRequestBody, + } = require('../../packages/ql3-runtime-core/src/run/clusterRunCancellation'); + const f = fixture((url, options) => { + if (options.method === 'GET') + return url.includes('/tasks/') ? { task } : { run }; + const body = JSON.parse(options.body); + if (url.endsWith('/cancellation')) { + assert.deepEqual(parseRunCancellationRequestBody(body), body); + return { + schema: 'qinglong/run-cancellation@v1', + projectId: 'default', + runId: run.id, + status: 'accepted', + }; + } + assert.deepEqual(parseTaskStartRequestBody(body), body); + return startReceipt; + }); + await f.client.prepareStart(await f.client.readTask()).execute(); + await f.client.prepareCancel(await f.client.readRun(run.id)).execute(); +});