diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0ad5873d..2ab3aef9 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,6 +1,8 @@ # QingLong 3.0 Architecture RFC -- D-428/ADR-0530(源码候选,等待可下载同源装配与双架构阶段实物):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health`、`GET /api/system`、`GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user`、`GET /api/system/config` 仍经过 Bearer、`task.read` Policy、durable audit 与 credential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timer、watcher 或后台进程;默认 headless、Cluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。Local API 12-package closure 与 89/89、18-package 完整测试、package/import/dependency audit、旧面板 production build 均通过;Playwright 同源源码旅程验证了登录、只读列表和刷新清凭据,最终 0 console error。仍须把改造静态资源装入可下载 Console、在 exact Console + Local API + SQLite 上重跑真实 credential journey、通过远端 CI 和双架构 artifact,才能声明阶段页面产物可直接部署。 +- D-429/ADR-0531(源码候选,等待远端 CI 与 exact 双架构 Console 实物):现有面板的 Cron “日志”入口不恢复 2.x `/api/crons/:id/log` 或文件系统日志权威,而是复用规范 v3 Run/Attempt/Artifact 链。`BoundedRunListItem` 仅增加经 128 字节无控制字符验证的可选 `triggerId`;页面按 `triggerId + taskId` 在 Edge 64/Standalone 256 条 Run 总预算中定位最新 Run,再读取 detail 的 latest Attempt 与 16/32 KiB 首个日志片段,明确呈现 pending/retired/truncated。所有请求继续走 Bearer、Project Policy、durable audit、credential reconfirm 与 Artifact retention;不暴露路径、Artifact id、Secret 或其他 Run 私有字段。QL3 Action 列只开放日志,列表关闭旧 10 秒轮询,日志关闭旧 2 秒轮询,仅由初次进入、点击与手动刷新驱动;2.x 行为保持不变。没有新增 package、dependency、migration、schema、listener、连接或后台进程,headless 与 Cluster authority 不变。本地 runtime-core 594/594、兼容回归 4/4、Node 20 production build、240 files / 11,965,017 bytes 闭包审计和真实 Chromium journey 已通过;浏览器稳定 15 秒只有一次 Cron list,日志稳定 5 秒只有 Run list/detail/log 各一次。 + +- D-428/ADR-0530(exact 双架构阶段实物已交付):在 D-427 的只读 Cron Adapter 上增加显式 Local 面板能力发现和启动边界,而不是伪造完整 2.x 后端。公开、无敏感数据的 exact `GET /api/health`、`GET /api/system`、`GET /api/v3/capabilities` 只接受无 body 与可选单个数字 `t`;认证后的 `GET /api/user`、`GET /api/system/config` 仍经过 Bearer、`task.read` Policy、durable audit 与 credential reconfirm,并只接受 User principal。capability v1 区分 Edge/Standalone 的 64/256 Cron 行与 16/32 KiB 日志块预算,并明确关闭 legacy login/mutation、Subscription、Script、Env 和 WebSocket。现有面板源码只在严格发现 `qinglong3` capability 后进入 3.0 模式:登录页改收 `ql3c_` API Credential,凭据仅驻留模块内存且刷新即丢失;认证成功只进入 `/crontab`;菜单、请求和 Modal 降级为名称/命令描述符/状态/schedule 的有界只读列表,不请求 View、Subscription、非空搜索/排序,也不建立 WebSocket。2.x 模式保持原行为。该切片不新增 package、production dependency、migration、数据库连接、listener、timer、watcher 或后台进程;默认 headless、Cluster authority 与 Node 24 runtime 均不变,旧 Umi 暂用 Node 20 只作为 legacy migration build toolchain。提交 `fa730da0912d0e3c503697e7816aa0b14fa02165` 的普通主 CI run `33597913690` 为 42 success / 3 expected condition skip / 0 fail,Console artifact run `33597938992` 为 43 success / 2 expected skip / 0 fail;amd64/arm64/milestone artifact `9834695619`/`9834657575`/`9834716653` 保留至 2026-10-02。下载后的 v7 milestone `SHA256SUMS` 与离线 auditor 均返回 `compatible=true`,D-428 因此已经是可直接试运行的阶段页面产物。 - D-427/ADR-0529(双架构阶段实物已交付):开始把现有 2.x 面板从“不能零改直连”推进为受控页面复用,而不是把旧 Express Service 或数据库写 authority 搬回 3.0。第一切片只在既有 `@qinglong/local-api/panel-compatibility` 子域开放认证后的 `GET /api/crons`:正式 Bearer credential、`task.read` Policy、durable audit、credential reconfirm 后,按 Edge 64/Standalone 256 行上限读取 `qinglong/cron@v1` Trigger 前缀,并以 Trigger 固定的 `taskId/revision/contentDigest` 读取 pinned Task revision。每个 Trigger 投影成一条旧 Cron 包络,字符串 `triggerId` 保持稳定身份,schedule/timezone/misfire 来自规范化 Trigger;command 只返回 `ql3::@` 描述符,不暴露 argv、环境、Secret、label、mutation 或 digest。缺失 pinned Task、identity 漂移、未知 Trigger schema、异常页和超预算均整体失败关闭。首切片只接受旧页面初始空 search/filter 与有界 page/size;搜索、排序、View query 和全部写操作明确拒绝。它不新增 package、dependency、migration、连接、listener、timer 或 watcher;headless 与 Cluster 路径不变,也不提供 2.x login/JWT、静态面板、WebSocket、Cron View、Subscription、Script、Env 或 Run/Log 兼容。本地真实 SQLite/credential/Policy/HTTP 集成、Local API 83/83、18-package clean build/test 和 Edge/Standalone Application API 资源门已通过。提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功;headless amd64/arm64/milestone artifact `9827434236`/`9827259756`/`9827553099`,Console `9828792297`/`9828714089`/`9828941802`,均未过期并保留至 2026-10-02。D-427 因此闭合为后端阶段实物;它仍不包含改造后的 2.x 静态面板,页面交付由 D-428 独立闭合。 diff --git a/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md b/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md index f538102e..95ceb030 100644 --- a/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md +++ b/docs/adr/ADR-0529-bounded-read-only-local-panel-cron-adapter.md @@ -58,4 +58,4 @@ QingLong 2.x 面板以 `/api/crons`、数值型 Cron 行和 `{code,data}` 包络 提交 `83966a128705f6ef0ae54367c8f656e3d578e9c6` 的显式 Local headless [run 33576693917](https://github.com/whyour/qinglong/actions/runs/33576693917) 与 Local Console [run 33578754251](https://github.com/whyour/qinglong/actions/runs/33578754251) 均成功。headless amd64/arm64/milestone artifact 为 `9827434236`/`9827259756`/`9827553099`,Console 为 `9828792297`/`9828714089`/`9828941802`;均绑定同一 source revision,保留至 2026-10-02。headless 继续不携带 Local API/旧面板;Console artifact 携带本 Adapter,但仍只证明后端兼容切片,不包含改造后的 2.x 静态面板。 -后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。ADR-0530 已完成 capability/bootstrap 与旧面板源码的内存凭据、菜单降级、只读 Crontab 适配;仍须完成同源静态资源装配和真实浏览器 journey,才能把该页面作为可下载阶段产物交付。完整 2.x 零改兼容不作为 3.0 目标。 +后续按 `health/system/user capability → Run/Log read → 显式写操作` 推进。ADR-0530 已完成 capability/bootstrap、同源静态装配、真实浏览器 journey 和双架构阶段实物;ADR-0531 继续以规范 v3 API、caller-driven 读取和 Profile 预算开放 Cron 日志。完整 2.x 零改兼容不作为 3.0 目标。 diff --git a/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md b/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md index 4802045b..a71869ca 100644 --- a/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md +++ b/docs/adr/ADR-0530-bounded-local-panel-capability-bootstrap.md @@ -1,6 +1,6 @@ # ADR-0530:有界 Local 面板能力发现与启动适配 -- 状态:Accepted(D-428 本地同源装配候选已闭合,等待远端 CI 与双架构可下载实物门) +- 状态:Accepted(D-428 exact Console 双架构阶段实物已交付) - 日期:2026-09-02 - 关联 RFC:QL-RFC-0001 D-428、D-427、D-423、D-424 @@ -40,13 +40,14 @@ ADR-0529 已交付认证后的只读 `/api/crons` Adapter,但现有 2.x 面板 本地装配候选已经通过 12-package Console closure、719 files / 16,162,123 bytes(上限 768 files / 20 MiB)镜像审计,以及面板 240-file 哈希/磁盘闭包复核。最终 arm64 Edge 镜像 `sha256:27472cf1bdd66d9fa4e937622ef69d4e36f74918a759de62818b4e59d265714c` 已完成 fresh setup/replay、Owner provision/challenge/claim/presentation/ack、首个 Task 执行与日志标记、graceful stop、SQLite integrity 和 HTTP 200/401 边界,结果为 `compatible=true`。 -真实浏览器已验证现有页面使用内存中的 `ql3c_` credential 完成 `/login` → `/crontab`,53 个同源静态请求全部为 200,`/api/health`、`/api/system`、`/api/v3/capabilities`、`/api/user`、`/api/system/config` 和 `/api/crons` 均为 200;最终镜像同时验证 `/console` 原生管理台与 `/login` 旧页面共存。旧页面仍引用的外部图标/装饰图片被严格 CSP 阻止,页面功能可用但这些装饰会缺失;本阶段不为消除装饰错误而放宽网络或 CSP。远端 CI 和可下载双架构实物仍待闭合。 +真实浏览器已验证现有页面使用内存中的 `ql3c_` credential 完成 `/login` → `/crontab`,53 个同源静态请求全部为 200,`/api/health`、`/api/system`、`/api/v3/capabilities`、`/api/user`、`/api/system/config` 和 `/api/crons` 均为 200;最终镜像同时验证 `/console` 原生管理台与 `/login` 旧页面共存。旧页面仍引用的外部图标/装饰图片被严格 CSP 阻止,页面功能可用但这些装饰会缺失;本阶段不为消除装饰错误而放宽网络或 CSP。 + +提交 `fa730da0912d0e3c503697e7816aa0b14fa02165` 的普通主 CI [run 33597913690](https://github.com/whyour/qinglong/actions/runs/33597913690) 为 42 success / 3 expected condition skip / 0 fail;显式 Console artifact [run 33597938992](https://github.com/whyour/qinglong/actions/runs/33597938992) 为 43 success / 2 expected skip / 0 fail。该 run 交付 amd64/arm64/milestone artifact `9834695619`/`9834657575`/`9834716653`,大小 `239011275`/`234410955`/`6797` bytes,保留至 2026-10-02;下载后的 milestone `SHA256SUMS` 与 v7 auditor 均通过并返回 `compatible=true`。D-428 因此闭合为可直接试运行的 exact 双架构阶段页面产物。 仍未完成: -1. 推送后通过远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone; -2. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求; -3. 为旧页面增加 Run/Log 只读 adapter 后再开放日志入口; -4. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。 +1. 把旧面板引用的外部图标和装饰图片转为受审本地资产,做到严格 CSP 下无外部请求; +2. Run/Log 入口由 ADR-0531 以规范 v3 API 的 caller-driven 客户端桥接继续推进; +3. 写操作必须逐项映射到 3.0 revision、Policy、presence/approval、audit 和 mutation fence,不能用通配兼容路由一次性开放。 -在远端实物门完成前,本 ADR 声明的是“当前提交可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。 +本 ADR 声明的是“已有 exact 双架构阶段产物可装配并实跑现有面板的受控子集”,不是 Public Release,也不声明完整 2.x 面板可以零修改直连。 diff --git a/docs/adr/ADR-0531-canonical-run-log-bridge-for-legacy-panel.md b/docs/adr/ADR-0531-canonical-run-log-bridge-for-legacy-panel.md new file mode 100644 index 00000000..30bee48b --- /dev/null +++ b/docs/adr/ADR-0531-canonical-run-log-bridge-for-legacy-panel.md @@ -0,0 +1,40 @@ +# ADR-0531:现有面板到规范 Run 日志的有界桥接 + +- 状态:Accepted(D-429 源码候选;远端 CI 与双架构 Console 实物待 gate) +- 日期:2026-09-04 +- 关联 RFC:QL-RFC-0001 D-429、D-428、D-415 + +## 背景 + +ADR-0530 已把现有 2.x 面板作为受限静态客户端装入 Local Console,并只开放 Cron 列表。用户仍无法从该页面观察一次定时执行的日志。恢复旧 `/api/crons/:id/log`、文件系统日志目录或 2 秒轮询,会复制已经存在的 3.0 Run/Attempt/Artifact 权威,并给 Edge 路由设备增加持续请求。 + +Run 聚合已经持久化可选 `triggerId`,规范 Local API 已提供有界 Run list、Run detail 与 Attempt log range read。缺口不是新的日志后端,而是安全地把这条规范链交给受限旧页面。 + +## 决策 + +- `BoundedRunListItem` 增加可选 `triggerId`。只有值存在且满足 128 字节无控制字符边界时才投影;其余 Run 私有字段仍不跨越 projection。 +- 旧面板不新增 `/api/crons/:id/log` 兼容路由。用户点击某个 Cron 的“日志”后,页面按 capability budget 分页读取 `/api/v3/projects/:project/runs`,以 `triggerId + taskId` 精确选择最新 Run,再读取 Run detail 的 latest Attempt 和该 Attempt 的首个日志片段。 +- 每次日志片段仍经过既有 Bearer authentication、`artifact.read` Policy、durable audit、credential reconfirm 与 Artifact retention 检查。页面不接触文件路径、Artifact id 或 Secret。 +- Edge 最多扫描 64 条 Run、读取 16 KiB;Standalone 最多扫描 256 条 Run、读取 32 KiB。每页最大 64 条。未找到、尚未就绪、已清理、运行中无 Artifact 和截断状态必须显式呈现。 +- QL3 模式下 Cron 列表不再运行旧页面的 10 秒轮询,日志也不运行旧页面的 2 秒轮询。初始列表只读一次,后续列表由分页/页面动作触发;日志只由点击和“刷新”按钮触发。 +- QL3 的 Action 列只保留“日志”。运行、停止、更多、创建、批量操作和写 Modal 继续隐藏;2.x 模式保持原行为。 + +## 部署与架构边界 + +- 不新增 workspace package、production dependency、migration、schema、数据库连接、listener、timer、watcher 或后台进程。 +- 默认 headless 产物不含旧面板,资源占用不变。Console 仍使用同一个 loopback Local API 和同一个受限静态闭包。 +- Cluster 不复用 Local 面板 shell;`triggerId` 是 profile-neutral 的 Run 低敏感关联字段,因此规范 Run projection 可被 Local/Cluster 消费,但本 ADR 不新增 Cluster UI。 +- 这是一条迁移期客户端桥接,不把 2.x 页面定义为 QingLong 3.0 的领域模型或长期管理面。新的 3.0 能力优先进入原生 `/console`。 + +## 验证与剩余门禁 + +本地已完成 runtime-core 594/594、Local API 93/93、18-package clean build/test、后端 1685 项(1683 pass / 2 environment skip / 0 fail)、兼容闭包回归 4/4、Node 20 production panel build,以及 package boundary、Edge import、Cluster dependency 和 240 files / 11,965,017 bytes 离线闭包审计。真实 Chromium mock journey 验证了 QL3 credential 登录、只读 Action 列、Run list → Run detail → Attempt log 三个规范请求和 Base64/UTF-8 日志展示;页面稳定 15 秒只有一次 Cron list,日志打开后稳定 5 秒没有自动重读。 + +仍须完成 GitNexus staged/default-branch change detection、阶段提交、远端完整 CI,并生成、下载和离线复核 exact amd64/arm64 Console Trial Kit 与 milestone,才可把 D-429 标记为双架构阶段实物已交付。 + +## 不做的方案 + +- 不恢复旧文件日志 API 或任意路径读取; +- 不把日志轮询速度做成配置项;低端设备默认仍会付出持续成本; +- 不在本切片开放 Run start/stop、Cron mutation 或完整 `/log` 页面; +- 不为复用旧页面而把 Local SQLite/POSIX authority 带入 Cluster。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 0be970bf..3345bf9f 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -533,7 +533,8 @@ | [ADR-0527](./ADR-0527-reviewed-automation-reconciliation-application-and-rollback.md) | 受审核 Automation Reconciliation 应用与显式回滚 | Accepted(exact headless 双架构阶段实物已交付) | | [ADR-0528](./ADR-0528-cross-domain-reconciliation-completion-rehearsal.md) | 跨域 Reconciliation Completion 演练 | Accepted(D-426c3 源码候选;双架构阶段实物待 gate) | | [ADR-0529](./ADR-0529-bounded-read-only-local-panel-cron-adapter.md) | 有界只读 Local 旧面板 Cron Adapter | Accepted(D-427 双架构阶段实物已交付) | -| [ADR-0530](./ADR-0530-bounded-local-panel-capability-bootstrap.md) | 有界 Local 面板能力发现与启动适配 | Proposed(D-428 源码候选;可下载同源装配与双架构实物待 gate) | +| [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 源码候选;远端 CI 与双架构 Console 实物待 gate) | ## 规则 diff --git a/packages/ql3-local-api/test/runListRoute.test.cjs b/packages/ql3-local-api/test/runListRoute.test.cjs index db60538b..7c046bd8 100644 --- a/packages/ql3-local-api/test/runListRoute.test.cjs +++ b/packages/ql3-local-api/test/runListRoute.test.cjs @@ -1,9 +1,7 @@ const assert = require('node:assert/strict'); const { test } = require('node:test'); -const { - createLocalApiRunListRoute, -} = require('../dist/run/runListRoute.js'); +const { createLocalApiRunListRoute } = require('../dist/run/runListRoute.js'); function run(id, createdAtMs, overrides = {}) { return { @@ -29,7 +27,7 @@ test('returns the shared bounded Project Run list projection', async () => { const route = createLocalApiRunListRoute({ async listRunsByProject(query) { calls.push(query); - return [run('run-b', 20), run('run-a', 10)]; + return [run('run-b', 20, { triggerId: 'cron:task-b' }), run('run-a', 10)]; }, }); const response = await route.handle({ @@ -39,6 +37,7 @@ test('returns the shared bounded Project Run list projection', async () => { assert.deepEqual(calls, [{ projectId: 'prj_default', limit: 2 }]); assert.equal(response.statusCode, 200); assert.equal(response.body.runs[0].id, 'run-b'); + assert.equal(response.body.runs[0].triggerId, 'cron:task-b'); assert.equal(response.body.hasMore, true); assert.deepEqual(response.body.next, { createdAtMs: 20, runId: 'run-b' }); assert.equal(JSON.stringify(response).includes('secret-adjacent'), false); @@ -50,7 +49,9 @@ test('fails closed on cross-Project, malformed and unavailable pages', async () [run('run-a', 10, { status: 'invented' })], ]) { const route = createLocalApiRunListRoute({ - async listRunsByProject() { return rows; }, + async listRunsByProject() { + return rows; + }, }); assert.deepEqual( await route.handle({ projectId: 'prj_default', input: {} }), @@ -58,7 +59,9 @@ test('fails closed on cross-Project, malformed and unavailable pages', async () ); } const unavailable = createLocalApiRunListRoute({ - async listRunsByProject() { throw new Error('offline'); }, + async listRunsByProject() { + throw new Error('offline'); + }, }); assert.deepEqual( await unavailable.handle({ projectId: 'prj_default', input: {} }), diff --git a/packages/ql3-runtime-core/src/run/projection/boundedRunListProjection.ts b/packages/ql3-runtime-core/src/run/projection/boundedRunListProjection.ts index 4d77f79a..07e7aa52 100644 --- a/packages/ql3-runtime-core/src/run/projection/boundedRunListProjection.ts +++ b/packages/ql3-runtime-core/src/run/projection/boundedRunListProjection.ts @@ -28,6 +28,7 @@ export interface BoundedRunListItem { readonly id: string; readonly taskId: string; readonly taskRevision: string; + readonly triggerId?: string; readonly status: RunStatus; readonly version: number; readonly eventSequence: number; @@ -73,7 +74,11 @@ function boundedText(value: unknown, maximum: number): value is string { ); } -function integer(value: unknown, minimum: number, maximum: number): value is number { +function integer( + value: unknown, + minimum: number, + maximum: number, +): value is number { return ( Number.isSafeInteger(value) && Number(value) >= minimum && @@ -89,7 +94,11 @@ function cursor(value: unknown): value is Readonly { Reflect.ownKeys(value).length === 2 && Object.hasOwn(value, 'createdAtMs') && Object.hasOwn(value, 'runId') && - integer((value as ProjectRunListCursor).createdAtMs, 0, Number.MAX_SAFE_INTEGER) && + integer( + (value as ProjectRunListCursor).createdAtMs, + 0, + Number.MAX_SAFE_INTEGER, + ) && boundedText((value as ProjectRunListCursor).runId, 128) ); } @@ -153,6 +162,7 @@ function projectRun( (boundary !== undefined && !isBefore(rowCursor, boundary)) || !boundedText(value.taskId, 255) || !boundedText(value.taskRevision, 255) || + (value.triggerId !== undefined && !boundedText(value.triggerId, 128)) || !RUN_STATUSES.includes(value.status) || !EXECUTION_ORIGINS.includes(value.executionOrigin) || (value.executionOwner !== 'legacy' && value.executionOwner !== 'runtime') || @@ -169,6 +179,7 @@ function projectRun( id: value.id, taskId: value.taskId, taskRevision: value.taskRevision, + ...(value.triggerId === undefined ? {} : { triggerId: value.triggerId }), status: value.status, version: value.version, eventSequence: value.eventSequence, @@ -177,8 +188,12 @@ function projectRun( executionOwner: value.executionOwner, createdAtMs: value.createdAtMs, ...(value.queuedAtMs === undefined ? {} : { queuedAtMs: value.queuedAtMs }), - ...(value.startedAtMs === undefined ? {} : { startedAtMs: value.startedAtMs }), - ...(value.finishedAtMs === undefined ? {} : { finishedAtMs: value.finishedAtMs }), + ...(value.startedAtMs === undefined + ? {} + : { startedAtMs: value.startedAtMs }), + ...(value.finishedAtMs === undefined + ? {} + : { finishedAtMs: value.finishedAtMs }), }); } diff --git a/packages/ql3-runtime-core/test/boundedRunListProjection.test.cjs b/packages/ql3-runtime-core/test/boundedRunListProjection.test.cjs index 8bd2e680..fa4a40cd 100644 --- a/packages/ql3-runtime-core/test/boundedRunListProjection.test.cjs +++ b/packages/ql3-runtime-core/test/boundedRunListProjection.test.cjs @@ -37,7 +37,11 @@ test('projects one descending bounded page and a stable keyset cursor', async () { async listRunsByProject(query) { calls.push(query); - return [run('run-c', 30), run('run-b', 20), run('run-a', 10)]; + return [ + run('run-c', 30, { triggerId: 'cron:task-run-c' }), + run('run-b', 20), + run('run-a', 10), + ]; }, }, 'prj_default', @@ -50,6 +54,7 @@ test('projects one descending bounded page and a stable keyset cursor', async () id: 'run-c', taskId: 'task-run-c', taskRevision: 'revision-1', + triggerId: 'cron:task-run-c', status: 'succeeded', version: 2, eventSequence: 3, @@ -109,7 +114,11 @@ test('uses default and maximum bounds and passes an exact cursor', async () => { }); test('fails closed on malformed inputs, rows, ordering and repository failures', async () => { - const reader = { async listRunsByProject() { return []; } }; + const reader = { + async listRunsByProject() { + return []; + }, + }; for (const input of [ null, { limit: 0 }, @@ -131,7 +140,11 @@ test('fails closed on malformed inputs, rows, ordering and repository failures', ]) { await assert.rejects( executeBoundedRunListProjection( - { async listRunsByProject() { return rows; } }, + { + async listRunsByProject() { + return rows; + }, + }, 'prj_default', {}, ), @@ -140,7 +153,11 @@ test('fails closed on malformed inputs, rows, ordering and repository failures', } await assert.rejects( executeBoundedRunListProjection( - { async listRunsByProject() { throw new Error('offline'); } }, + { + async listRunsByProject() { + throw new Error('offline'); + }, + }, 'prj_default', {}, ), diff --git a/src/pages/crontab/index.tsx b/src/pages/crontab/index.tsx index cba1c16b..a8cae45f 100644 --- a/src/pages/crontab/index.tsx +++ b/src/pages/crontab/index.tsx @@ -305,7 +305,7 @@ const Crontab = () => { const isPc = !isPhone; return ( - {record.status === CrontabStatus.idle && ( + {!qingLong3ReadOnly && record.status === CrontabStatus.idle && ( { e.stopPropagation(); @@ -315,7 +315,7 @@ const Crontab = () => { {intl.get('运行')} )} - {record.status !== CrontabStatus.idle && ( + {!qingLong3ReadOnly && record.status !== CrontabStatus.idle && ( { e.stopPropagation(); @@ -333,7 +333,9 @@ const Crontab = () => { > {intl.get('日志')} - + {!qingLong3ReadOnly && ( + + )} ); }, @@ -373,7 +375,7 @@ const Crontab = () => { const visibleColumns = qingLong3ReadOnly ? columns .filter((column) => - ['name', 'command', 'status', 'schedule'].includes( + ['name', 'command', 'status', 'schedule', 'action'].includes( String(column.key || ''), ), ) @@ -465,6 +467,7 @@ const Crontab = () => { }; useEffect(() => { + if (qingLong3ReadOnly) return; let timer: ReturnType; let cancelled = false; const poll = async () => { @@ -1132,13 +1135,14 @@ const Crontab = () => { components={isPhone || pageConf.size < 50 ? undefined : vt} /> - {!qingLong3ReadOnly && isLogModalVisible && ( + {isLogModalVisible && ( { - getCronDetail(logCron); + if (!qingLong3ReadOnly) getCronDetail(logCron); setIsLogModalVisible(false); }} cron={logCron} + qingLong3={qingLong3} /> )} {!qingLong3ReadOnly && isModalVisible && ( diff --git a/src/pages/crontab/logModal.tsx b/src/pages/crontab/logModal.tsx index aa83c23c..c9d01fb1 100644 --- a/src/pages/crontab/logModal.tsx +++ b/src/pages/crontab/logModal.tsx @@ -1,5 +1,5 @@ -import intl from "react-intl-universal"; -import React, { useEffect, useRef, useState } from "react"; +import intl from 'react-intl-universal'; +import React, { useEffect, useRef, useState } from 'react'; import { Modal, message, @@ -8,32 +8,166 @@ import { Statistic, Button, Typography, -} from "antd"; -import { request } from "@/utils/http"; -import config from "@/utils/config"; +} from 'antd'; +import { request } from '@/utils/http'; +import config from '@/utils/config'; +import { + qingLong3Credential, + type QingLong3Capabilities, +} from '@/utils/qinglong3'; import { Loading3QuartersOutlined, CheckCircleOutlined, -} from "@ant-design/icons"; -import { PageLoading } from "@ant-design/pro-layout"; -import { logEnded } from "@/utils"; -import { CrontabStatus } from "./type"; -import Ansi from "ansi-to-react"; +} from '@ant-design/icons'; +import { PageLoading } from '@ant-design/pro-layout'; +import { logEnded } from '@/utils'; +import { CrontabStatus } from './type'; +import Ansi from 'ansi-to-react'; const { Countdown } = Statistic; +const ACTIVE_RUN_STATUSES = new Set([ + 'created', + 'queued', + 'dispatching', + 'running', + 'waiting_approval', + 'retry_wait', + 'lost', +]); + +async function qingLong3Get(path: string, acceptedStatuses: number[] = []) { + const credential = qingLong3Credential(); + if (!credential) throw new Error('QL3_AUTHENTICATION_REQUIRED'); + const response = await fetch(path, { + method: 'GET', + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + headers: { + accept: 'application/json', + authorization: `Bearer ${credential}`, + }, + }); + const value = await response.json(); + if (!response.ok && !acceptedStatuses.includes(response.status)) { + throw new Error( + typeof value?.code === 'string' ? value.code : 'QL3_REQUEST_UNAVAILABLE', + ); + } + return { status: response.status, value }; +} + +function decodeQingLong3Log(content: unknown): string { + if (typeof content !== 'string') throw new Error('QL3_LOG_INVALID'); + const binary = atob(content); + const bytes = Uint8Array.from(binary, (character) => character.charCodeAt(0)); + return new TextDecoder().decode(bytes); +} + +async function readQingLong3CronLog( + cron: any, + capabilities: Readonly, +): Promise> { + const projectId = cron?.ql3?.projectId; + const taskId = cron?.ql3?.taskId; + const triggerId = cron?.ql3?.triggerId; + if ( + typeof projectId !== 'string' || + typeof taskId !== 'string' || + typeof triggerId !== 'string' + ) { + throw new Error('QL3_CRON_IDENTITY_INVALID'); + } + + let remaining = capabilities.limits.cronRows; + let cursor: { createdAtMs: number; runId: string } | undefined; + let matchedRun: any; + while (remaining > 0) { + const limit = Math.min(capabilities.limits.cronPageSize, remaining); + const query = new URLSearchParams({ limit: String(limit) }); + if (cursor) { + query.set('after_created_at_ms', String(cursor.createdAtMs)); + query.set('after_run_id', cursor.runId); + } + const { value: page } = await qingLong3Get( + `/api/v3/projects/${projectId}/runs?${query.toString()}`, + ); + if (!Array.isArray(page?.runs) || page.runs.length > limit) { + throw new Error('QL3_RUN_PAGE_INVALID'); + } + matchedRun = page.runs.find( + (run: any) => run?.triggerId === triggerId && run?.taskId === taskId, + ); + if (matchedRun) break; + remaining -= page.runs.length; + if (!page.hasMore || !page.next || page.runs.length === 0) break; + cursor = page.next; + } + if (!matchedRun) { + return { log: intl.get('暂无日志'), active: false }; + } + + const { value: detail } = await qingLong3Get( + `/api/v3/projects/${projectId}/runs/${matchedRun.id}`, + ); + const attempt = detail?.run?.latestAttempt; + const status = detail?.run?.status; + const active = ACTIVE_RUN_STATUSES.has(status); + if (!attempt?.id || attempt.logAvailable !== true) { + return { + log: `${intl.get('暂无日志')}\n\nRun: ${matchedRun.id}\nStatus: ${ + status || 'unknown' + }`, + active, + }; + } + + const length = capabilities.limits.logChunkBytes; + const { status: responseStatus, value: logView } = await qingLong3Get( + `/api/v3/projects/${projectId}/runs/${matchedRun.id}/attempts/${attempt.id}/log?offset=0&length=${length}`, + [202, 410], + ); + if (responseStatus === 202 || logView?.status === 'pending') { + return { log: `日志尚未就绪\n\nRun: ${matchedRun.id}`, active }; + } + if (responseStatus === 410 || logView?.status === 'retired') { + return { + log: `日志已按保留策略清理\n\nRun: ${matchedRun.id}`, + active: false, + }; + } + if (logView?.status !== 'available' || logView?.encoding !== 'base64') { + throw new Error('QL3_LOG_INVALID'); + } + const suffix = + logView.range?.nextOffset !== undefined + ? `\n\n[仅显示前 ${length} 字节,可点击刷新重新读取当前片段]` + : logView.truncation?.truncated === true + ? '\n\n[日志已按运行时保留上限截断]' + : ''; + return { + log: + `${decodeQingLong3Log(logView.content)}${suffix}` || intl.get('暂无日志'), + active, + }; +} + const CronLogModal = ({ cron, handleCancel, data, logUrl, + qingLong3, }: { cron?: any; handleCancel: () => void; data?: string; logUrl?: string; + qingLong3?: Readonly | null; }) => { - const [value, setValue] = useState(intl.get("启动中...")); + const [value, setValue] = useState(intl.get('启动中...')); const [loading, setLoading] = useState(true); const [executing, setExecuting] = useState(true); const [isPhone, setIsPhone] = useState(false); @@ -44,18 +178,33 @@ const CronLogModal = ({ if (isFirst) { setLoading(true); } + if (qingLong3) { + readQingLong3CronLog(cron, qingLong3) + .then(({ log, active }) => { + if (localStorage.getItem('logCron') !== uniqPath) return; + setValue(log); + setExecuting(active); + setTimeout(() => autoScroll()); + }) + .catch(() => { + setValue('日志读取失败,请检查凭据或稍后重试'); + setExecuting(false); + }) + .finally(() => setLoading(false)); + return; + } request .get(logUrl ? logUrl : `${config.apiPrefix}crons/${cron.id}/log`) .then(({ code, data, logStatus }) => { if ( code === 200 && - localStorage.getItem("logCron") === uniqPath && + localStorage.getItem('logCron') === uniqPath && data !== value ) { - const log = (data as string) || intl.get("暂无日志"); + const log = (data as string) || intl.get('暂无日志'); setValue(log); const hasNext = logStatus === 'running'; - if (!hasNext && !logEnded(value) && value !== intl.get("启动中...")) { + if (!hasNext && !logEnded(value) && value !== intl.get('启动中...')) { setTimeout(() => { autoScroll(); }); @@ -83,13 +232,13 @@ const CronLogModal = ({ setTimeout(() => { document - .querySelector("#log-flag") - ?.scrollIntoView({ behavior: "smooth" }); + .querySelector('#log-flag') + ?.scrollIntoView({ behavior: 'smooth' }); }, 600); }; const cancel = () => { - localStorage.removeItem("logCron"); + localStorage.removeItem('logCron'); handleCancel(); }; @@ -105,7 +254,7 @@ const CronLogModal = ({ const titleElement = () => { return ( -
+
{(executing || loading) && } {!executing && !loading && } @@ -141,8 +290,15 @@ const CronLogModal = ({ onOk={() => cancel()} onCancel={() => cancel()} footer={[ - , + ] + : []), + , ]} > @@ -154,9 +310,9 @@ const CronLogModal = ({ style={ isPhone ? { - fontFamily: "Source Code Pro", - zoom: 0.83, - } + fontFamily: 'Source Code Pro', + zoom: 0.83, + } : {} } > diff --git a/test/back/ql3LegacyPanelBundle.test.cjs b/test/back/ql3LegacyPanelBundle.test.cjs index 371ec106..fb268e88 100644 --- a/test/back/ql3LegacyPanelBundle.test.cjs +++ b/test/back/ql3LegacyPanelBundle.test.cjs @@ -138,3 +138,39 @@ test('rejects mutable assets and external entrypoint drift', (t) => { /retains an external origin/u, ); }); + +test('keeps the QL3 legacy panel log journey canonical, bounded and caller-driven', () => { + const crontab = fs.readFileSync( + path.resolve(__dirname, '../../src/pages/crontab/index.tsx'), + 'utf8', + ); + const logModal = fs.readFileSync( + path.resolve(__dirname, '../../src/pages/crontab/logModal.tsx'), + 'utf8', + ); + + assert.match( + crontab, + /\['name', 'command', 'status', 'schedule', 'action'\]/u, + ); + assert.match(crontab, /!qingLong3ReadOnly && record\.status/u); + assert.match(crontab, /qingLong3=\{qingLong3\}/u); + assert.match( + crontab, + /useEffect\(\(\) => \{\s+if \(qingLong3ReadOnly\) return;[\s\S]+setTimeout\(poll, 10000\)/u, + ); + + assert.match(logModal, /qingLong3Credential\(\)/u); + assert.match( + logModal, + /Math\.min\(capabilities\.limits\.cronPageSize, remaining\)/u, + ); + assert.match(logModal, /\/api\/v3\/projects\/\$\{projectId\}\/runs\?/u); + assert.match(logModal, /run\?\.triggerId === triggerId/u); + assert.match(logModal, /attempts\/\$\{attempt\.id\}\/log\?offset=0&length=/u); + assert.match( + logModal, + /if \(qingLong3\) \{[\s\S]+readQingLong3CronLog[\s\S]+return;\s+\}\s+request/u, + ); + assert.match(logModal, />\s*刷新\s*<\/Button>/u); +});