diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index f54ef8c2..6d8e7ae6 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,20 @@ 最新增量证据(2026-08-20): +- D-378/ADR-0471(已接受):把孵化重点从连续 Cluster Console 增量转回 3.0 首发兼容闭环,新增直接运行生产 Cron/Subscription + Express Router 与 Celebrate validator 的 2.x 核心执行 API 基线。Cron 覆盖 list/create/update/disable/enable/run/stop、单日志、日志列表和 + 单实例 stop;Subscription 覆盖 list/create/update/disable/enable/run/stop/log。确定性 TypeDI spy 同时锁定服务参数与现行 JSON envelope,尤其 + 保持 run/stop/enable/disable 的 `{code:200}` 异步接受语义,不把 v3 Run 字段静默塞回 2.x;两个非法 body 均在 service dispatch 前返回 + HTTP 400。聚焦真实回环 Router contract `18/18`。本切片不修改生产 function/class/method,不新增 package、dependency、binary、schema、 + migration、服务、部署对象或常驻资源;它只是首个核心样本,不代表完整 2.x 兼容与升级/回滚 Gate 已关闭。System、Script、Open API、鉴权错误码、 + 真实 SQLite 升级、Primary 双态与目标实例 rollback rehearsal 仍须后续完成。完整 backend 为 + `1,523 total / 1,521 pass / 2 conditional skip / 0 fail`;18-package clean build/逐包测试在允许既有 Worker TLS 回环门后单次退出 0。 + package boundary、Cluster dependency、Edge import、Cluster/Worker deployment、Console 与 Console distribution 七项审计全部 + compatible/passed;workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Cluster Admin 保持 + `129 source / 128 nested`。14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 保持 + `2,598,669 / 2,598,747` bytes、57 loaded modules,Application+AI 为 `4,501,822 / 4,501,954`,MCP 为 + `7,324,601 / 7,324,709`,证明测试基线不进入低配路由设备闭包。本切片不改变 PostgreSQL schema、ACL、repository、role、Pool、连接或 + failover 语义,因此不重跑且不重新占有 HA 证明;D-373/D-374 PostgreSQL 18.6 arm64 HA `146/146`、timeline `1→2` 仅作为相邻既有基线。 - D-377/ADR-0470(已接受):修复可选 Console authority 已默认关闭、浏览器却仍展示全部二十个操作的产品与授权边界漂移。Console CLI 现在把 完成私有文件校验后的唯一 `allowedOperations` 集合注入 server;十三个 Project/Copilot 基础只读操作必须完整存在,Run、Worker 与 Package 三组可选操作只能整组启用或整组关闭,未知、重复、缺失基础操作或半组配置均在监听前失败。认证后的同源 @@ -9463,7 +9477,7 @@ flowchart LR | 切片 | 当前状态 | 已具备 | 尚未接入/门禁 | | --- | --- | --- | --- | -| PR-0 Migration/Cluster 基线 | In progress | SQLite/PostgreSQL reviewed migration、checksum/rollback/schema ownership/readiness 与 Node 20/24 × x64/arm64 CI 基线;`runtime-core` 提供共享领域 contract,`cluster-postgres` 提供 exact-pinned Pool、四角色、21 条 migration、capability v20/35 表、可写主库、受审 function/role activation check、完整 cluster repository 及显式 admin-only Plugin Package installation和 Approved Action authority,`cluster-admin` 提供短生命周期发布 authority、POSIX/Kubernetes Secret adapter、标准 OCI stage verifier、exact-registry credential provider、有界恢复与 caller-driven Package Approved Action dispatcher,`local-admin` 以相同 handler 组合 edge/standalone 单 SQLite authority;`cluster-control` 提供 readiness-first HTTP/mTLS admission、startup recovery、数据库时钟 row-lease scheduler 和 Remote Worker 控制面。Package admission 已在两个方言的同一事务中强绑定 immutable proposal、exact executing snapshot、Project Policy fence 与数据库观察时钟,并以 receipt 收敛 COMMIT response loss;dispatcher 固定 start 前 retry、start 后 durable inspection,edge/standalone/cluster 默认单批 1/4/16 且无 timer。PostgreSQL 18 physical-promotion 门禁已在本机 arm64 完成 streaming、`remote_apply`、受控复制链分区、fence-before-promote、timeline 1→2、新 generation 同步策略、新 slot、旧主 `pg_rewind`/只读 sync rejoin、端点延迟切换、双 control 摘流/激活,以及 scheduler、credential v1/v2/v3/v4、completion、cancellation 和通用事务不确定窗口的精确收敛。ADR-0126/0127 进一步完成独立 process/migration binary、三 workspace package/五 production root 的 builder/production 双 lock、实际非 root/只读 Docker smoke、双副本 QL3 Kustomize、migration/runtime authority 分离与有界私有 CA 文件绑定;本地 PostgreSQL 18 TLS 1.3 门禁已验证旧 CA、overlap、新 CA、SIGHUP 端点证书切换和逆序回滚,并接入 x64/arm64 CI;ADR-0128 已完成 control 43 外部+3 内部与 admin 85 外部+3 内部 component 的精确 CycloneDX、两种真实 arm64 镜像 inventory 对账、control 真实本地 amd64/arm64 OCI + 逐平台 SPDX/SLSA digest graph、双镜像原生 x64/arm64 image/OCI CI matrix、两个独立 GHCR repository 的 digest-bound Cosign/GitHub attestation 发布契约与发布后 manifest/signature/provenance/CycloneDX 四重自验证门;ADR-0129 已完成 CloudNativePG 1.30.0/PostgreSQL 18.4 digest lock、三实例同步 HA、四 DatabaseRole/Database owner、离散凭据、`-rw` endpoint、`ca.crt` 投影、Kustomize/audit 门及四节点 Kind worker-loss live contract/CI 定义;ADR-0137 至 ADR-0141 已完成 Plugin Package PostgreSQL parity、Approved Action 双方言 durable authority、immutable Package proposal、execution/start-barrier、Package 双方言原子 admission receipt、单用途 Project/Policy lock authority、跨 adapter 合同、标准 OCI resolver、exact-registry credential provider、ConfigMap CAS publisher、独立 admin image、最小权限 Job/RBAC 与真实 Kind 双 Pod resourceVersion/RBAC live gate,并由 21 项本机物理 HA 门复验 PostgreSQL authority。ADR-0230 把 Workflow frontier 与 Task Attempt admission 接入既有 production Scheduler cadence;ADR-0231 又在同一 production process 中以独立 mTLS listener/Worker Pool 和内部 runtime capability port 装入 Remote Worker 全套无 Secret transport,保持 20 包、单 Scheduler timer 与显式权限 subpath | PostgreSQL 18 × x64/arm64 远端 CI 成功记录;取得一次 CloudNativePG live job 成功证据并继续完成 CA overlap rotation/rollback、Barman/WAL archive restore、Kubernetes HA control-plane/failover、基础设施 STONITH、真实 Pod/raw-wire 分区、多 Pod 与 Session replacement 竞争;stale lock/temp 修复与 pepper retire/reference/GC;PostgreSQL Package admission 过期租约真库门、资源 generation consumer 和受认证管理入口;admin 真实双架构 OCI 记录、两个 image 的实际远端 GHCR manifest digest、Cosign/GitHub attestation 独立 verify 记录、漏洞/容量证明;Cluster Secret provider、Remote Worker expiry/retry lifecycle 与启动装配;管理 CLI/API/UI、审批与 rate limit;schedule 指标/告警;正式停写与双数据库 backup manifest;固定物理 edge 基线和完整 2.x API 契约样本 | +| PR-0 Migration/Cluster 基线 | In progress | SQLite/PostgreSQL reviewed migration、checksum/rollback/schema ownership/readiness 与 Node 20/24 × x64/arm64 CI 基线;`runtime-core` 提供共享领域 contract,`cluster-postgres` 提供 exact-pinned Pool、四角色、21 条 migration、capability v20/35 表、可写主库、受审 function/role activation check、完整 cluster repository 及显式 admin-only Plugin Package installation和 Approved Action authority,`cluster-admin` 提供短生命周期发布 authority、POSIX/Kubernetes Secret adapter、标准 OCI stage verifier、exact-registry credential provider、有界恢复与 caller-driven Package Approved Action dispatcher,`local-admin` 以相同 handler 组合 edge/standalone 单 SQLite authority;`cluster-control` 提供 readiness-first HTTP/mTLS admission、startup recovery、数据库时钟 row-lease scheduler 和 Remote Worker 控制面。Package admission 已在两个方言的同一事务中强绑定 immutable proposal、exact executing snapshot、Project Policy fence 与数据库观察时钟,并以 receipt 收敛 COMMIT response loss;dispatcher 固定 start 前 retry、start 后 durable inspection,edge/standalone/cluster 默认单批 1/4/16 且无 timer。PostgreSQL 18 physical-promotion 门禁已在本机 arm64 完成 streaming、`remote_apply`、受控复制链分区、fence-before-promote、timeline 1→2、新 generation 同步策略、新 slot、旧主 `pg_rewind`/只读 sync rejoin、端点延迟切换、双 control 摘流/激活,以及 scheduler、credential v1/v2/v3/v4、completion、cancellation 和通用事务不确定窗口的精确收敛。ADR-0126/0127 进一步完成独立 process/migration binary、三 workspace package/五 production root 的 builder/production 双 lock、实际非 root/只读 Docker smoke、双副本 QL3 Kustomize、migration/runtime authority 分离与有界私有 CA 文件绑定;本地 PostgreSQL 18 TLS 1.3 门禁已验证旧 CA、overlap、新 CA、SIGHUP 端点证书切换和逆序回滚,并接入 x64/arm64 CI;ADR-0128 已完成 control 43 外部+3 内部与 admin 85 外部+3 内部 component 的精确 CycloneDX、两种真实 arm64 镜像 inventory 对账、control 真实本地 amd64/arm64 OCI + 逐平台 SPDX/SLSA digest graph、双镜像原生 x64/arm64 image/OCI CI matrix、两个独立 GHCR repository 的 digest-bound Cosign/GitHub attestation 发布契约与发布后 manifest/signature/provenance/CycloneDX 四重自验证门;ADR-0129 已完成 CloudNativePG 1.30.0/PostgreSQL 18.4 digest lock、三实例同步 HA、四 DatabaseRole/Database owner、离散凭据、`-rw` endpoint、`ca.crt` 投影、Kustomize/audit 门及四节点 Kind worker-loss live contract/CI 定义;ADR-0137 至 ADR-0141 已完成 Plugin Package PostgreSQL parity、Approved Action 双方言 durable authority、immutable Package proposal、execution/start-barrier、Package 双方言原子 admission receipt、单用途 Project/Policy lock authority、跨 adapter 合同、标准 OCI resolver、exact-registry credential provider、ConfigMap CAS publisher、独立 admin image、最小权限 Job/RBAC 与真实 Kind 双 Pod resourceVersion/RBAC live gate,并由 21 项本机物理 HA 门复验 PostgreSQL authority。ADR-0230 把 Workflow frontier 与 Task Attempt admission 接入既有 production Scheduler cadence;ADR-0231 又在同一 production process 中以独立 mTLS listener/Worker Pool 和内部 runtime capability port 装入 Remote Worker 全套无 Secret transport,保持 20 包、单 Scheduler timer 与显式权限 subpath;ADR-0471 新增 Cron/Subscription 真实 Router 的首个 2.x 核心执行 API envelope/validation 基线 `18/18` | PostgreSQL 18 × x64/arm64 远端 CI 成功记录;取得一次 CloudNativePG live job 成功证据并继续完成 CA overlap rotation/rollback、Barman/WAL archive restore、Kubernetes HA control-plane/failover、基础设施 STONITH、真实 Pod/raw-wire 分区、多 Pod 与 Session replacement 竞争;stale lock/temp 修复与 pepper retire/reference/GC;PostgreSQL Package admission 过期租约真库门、资源 generation consumer 和受认证管理入口;admin 真实双架构 OCI 记录、两个 image 的实际远端 GHCR manifest digest、Cosign/GitHub attestation 独立 verify 记录、漏洞/容量证明;Cluster Secret provider、Remote Worker expiry/retry lifecycle 与启动装配;管理 CLI/API/UI、审批与 rate limit;schedule 指标/告警;正式停写与双数据库 backup manifest;固定物理 edge 基线和 System/Script/Open API/鉴权错误码/Primary 双态的剩余 2.x 契约、真实 SQLite 升级及目标实例回滚演练 | | PR-1 Run Schema | Incubating | Run/RunAttempt/RunEvent schema、nullable cancel request 与 Attempt deadline 字段及恢复索引、CancellationDispatch 状态/version/lease/backoff schema、Repository port、临时 Sequelize adapter、统一事件大小/分页上限、跨 adapter RunRepository contract suite(原子事务、回滚、Run/Attempt/RetryPolicy CAS、唯一错误、分页与取消恢复);ADR-0041 的 `pg-0003-run-retry-policy`、capability v2、driver-neutral PostgreSQL Run Repository 与真实 `pg.Pool` 上的共享 Repository/rollback/SQLSTATE contract;ADR-0063/0069/0071/0073/0074/0076 的独立 Node 24 local-sqlite typed schema、十二条 reviewed migration、capability v6、共享 operation authority、readiness/RunRepository/API credential repository/receipt journal/dispatch plan/encrypted Secret envelope/Project Policy/security audit/authorized mutation/stable Identity catalog、Drizzle↔真实 catalog table/column/index/CHECK/FK lockstep、base/adopted/application edge/standalone 产物门禁;ADR-0064 的 legacy baseline/plan digest、Online Backup recovery、side-by-side target migration、staged manifest、双库栅栏 activation、source 生命周期写栅栏、target stable identity 和重启语义;ADR-0065 的独立 cutover authority、外部副作用停机 evidence、append-only journal、start/restart/stop barrier 与 unknown→manual_required 收敛;ADR-0066 的 adopted storage→Run reconciliation→receipt maintenance→domain recovery→lifecycle→admission application gate、严格有界 recovery summary 与 admission-first reverse stop;ADR-0067 的 SQLite 事实驱动 Run 候选源、256 条硬上限、截断失败关闭和唯一 Repository authority;ADR-0068 的 receipt-first Reconciler、callback token/sequence fence、exact local-process identity、Attempt/Run/双 Event 原子终态推进和最终 verifier;ADR-0069 的 local-process 单向包边界、pre-spawn journal、受审 POSIX launcher、immutable receipt、exact identity 和 Profile-aware cleanup lifecycle;ADR-0070 的独立 local-execution、spawn 前后双 transaction CAS、callback digest、exact stop 补偿与 fail-closed starting 保留;ADR-0071 的独立 local-dispatch、不可变 revision/context、Secret-first materializer、Profile Artifact admission、4/64 MiB output hard quota 和窄 application facade;ADR-0073/0074 的 Project-bound SecretRef、AES-256-GCM、外置 keyring 生命周期、双 SQLite authority CAS、application preflight、强 Principal/Policy 和 envelope+audit 原子提交;ADR-0086 的本机 Owner provisioning/challenge/claim/delivery acknowledgement/credential recovery CLI;ADR-0377 的 Local/Cluster 同构、Profile-aware、Project-scoped Artifact range read | fresh database/pepper setup、credential rotation/GC 运维编排与 Secret/Project/Role/Approval 管理 CLI/API/UI、备份/rekey、2.x/target process controller、人工 recovery、target 写后 reconciliation 与完整 cutover/rollback 演练;retry 产品策略、Artifact retention/tombstone stack、具体本机 lifecycle 和 target executable;Linux x64/arm64、PID namespace、断电与固定路由设备门禁;PostgreSQL 16/18 双连接并发与 failover integration;Task revision/context 跨方言 contract/并发压力与引用感知 retention、Keyv 数据迁移 | | PR-2 Run 状态机 | Incubating | 纯转换表、终态/时间/错误/执行器元数据规则、Run version 与 event sequence CAS、事务性 RunCommandService、回滚测试 | 重复 Worker callback/fencing、并发数据库压力测试、Primary 执行链接入 | | PR-3 Executor 端口 | Incubating | ADR-0003、ExecutionSpec/Context/Handle/Result、Executor port、LocalProcessExecutor、进程组取消/超时升级、流式背压、Legacy Cron spec builder、真实进程 contract tests、可复现 edge 基准入口 | 固定 edge/多架构设备基线、Legacy builder 与 makeCommand 差异审计、Primary 生产流量接入 | diff --git a/docs/adr/ADR-0471-legacy-core-api-compatibility-baseline.md b/docs/adr/ADR-0471-legacy-core-api-compatibility-baseline.md new file mode 100644 index 00000000..88bb8b5f --- /dev/null +++ b/docs/adr/ADR-0471-legacy-core-api-compatibility-baseline.md @@ -0,0 +1,65 @@ +# ADR-0471:Legacy 核心执行 API 兼容基线 + +- 状态:Accepted +- 日期:2026-08-20 +- 关联 RFC:QL-RFC-0001 D-378、PR-0、Runtime Milestone Gate +- 关联 ADR:ADR-0002、ADR-0445、ADR-0454、ADR-0470 + +## 上下文 + +QingLong 3.0 已经具备 Shadow Run、受门禁的 Manual Primary、Run/Event、取消恢复和本地/集群执行协议,但这些新能力不能以破坏 2.x +Cron 与 Subscription API 为代价。现有路由仍是 Web UI、Shell、Open API 适配层和大量部署脚本的产品契约;仅验证 Runtime 内部状态机,不能证明 +3.0 开关两侧仍保留相同的 HTTP method、path、请求校验、服务参数和响应 envelope。 + +首个兼容基线应从最靠近现有用户执行闭环的路径开始:Cron 与 Subscription 的列表、创建、更新、启停、手动运行、停止和日志。测试必须执行真实 +Express Router 与 Celebrate 校验器,而不是复制一份路径清单或只搜索源码。它也不能为了测试方便启动完整 QingLong master/worker、数据库、gRPC、 +Scheduler 或任何 3.0 后台 lifecycle。 + +## 决策 + +1. 增加独立的真实回环 HTTP 契约测试,直接注册生产 `back/api/cron.ts` 与 `back/api/subscription.ts` Router,并挂载在现行 `/api` 前缀。 +2. 服务层使用 TypeDI 注入的确定性 spy,因此测试只裁决 Router 责任:HTTP method/path、Celebrate request contract、参数透传、2.x response envelope + 与错误请求在 service dispatch 前拒绝。它不伪造数据库、进程或 Scheduler 集成证据。 +3. Cron 基线覆盖 list、create、update、disable、enable、run、stop、单日志、日志列表和单实例 stop。`run|stop|enable|disable` 继续返回 + `{code: 200}`,不得因为 3.0 内部拥有 Run ID 而向 2.x 成功 envelope 强塞 v3 字段。 +4. Subscription 基线覆盖 list、create、update、disable、enable、run、stop 和日志;`searchValue`、`ids` 与 numeric ID 的现有转换语义保持不变。 +5. 非法 Cron run 与 Subscription stop body 必须由真实校验器返回 HTTP 400,且 service spy 调用数保持不变,证明无副作用 dispatch。 +6. 本切片只增加测试、ADR 和 RFC 状态,不修改任何生产 function/class/method,不新增依赖、package、binary、schema、migration、端口、服务、timer、 + queue、cache、数据库连接或部署对象。测试结束时关闭临时回环 listener 并清理 TypeDI token。 +7. 本基线不是“完整 2.x 兼容已完成”的声明。System、Script、Open API、鉴权/错误码、真实 SQLite 升级、Primary 开关双态、目标实例回滚和 UI/Shell + 仍须后续独立 contract 与 rehearsal,完成前 Runtime Milestone Gate 保持未关闭。 + +## 被拒绝的替代方案 + +### 只用正则扫描路由源码 + +拒绝。文本存在不能证明路由注册顺序、校验器转换、TypeDI 调用和实际 JSON 序列化行为。 + +### 启动完整 QingLong 服务作为每次后端单测前置条件 + +拒绝。它会把数据库、gRPC、cluster fork、文件系统和调度器故障混入 Router contract,增加低配开发机和 CI 的成本,也无法精确定位兼容漂移。 + +### 让 2.x run 响应直接返回 QingLong 3.0 Run 对象 + +拒绝。现有客户端依赖 code-only 的异步接受语义;Run/Event 应通过 `/api/v3` 或后续显式兼容扩展发现,不能静默改变 2.x envelope。 + +### 将全部 2.x API 一次性冻结在一个超大测试中 + +拒绝。首个阶段优先覆盖执行主路径,并明确登记剩余域;后续 contract 应按 Identity/Open API、System/Script、文件与日志等稳定产品边界扩展,避免难以维护的 +单体 fixture。 + +## 升级与回滚 + +- 本切片没有生产行为和持久化变更。升级只增加 CI 回归门,运行中实例不加载测试文件。 +- 若契约基线本身错误,可回滚测试、ADR 与 RFC 增量;不得通过删除测试掩盖真实 2.x 兼容回归,应先由 Maintainer 明确批准兼容变更。 + +## 验证与证据 + +- 聚焦真实 Router 契约为 `18/18`,覆盖 16 个成功路径/子路径和 2 个校验前拒绝路径;每个成功路径同时校验 HTTP 状态、JSON envelope 与 service 参数。 +- 完整 backend 为 `1,523 total / 1,521 pass / 2 conditional skip / 0 fail`;18-package clean build 与逐包测试在允许既有 Worker TLS + 回环门后单次退出 0。 +- package boundary、Cluster dependency、Edge import、Cluster/Worker deployment、Console 与 Console distribution 七项审计全部 + compatible/passed;workspace 仍为 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`。 +- 14 档 Local artifact audit 全部 compatible;基础 Edge/Standalone 为 `2,598,669 / 2,598,747` bytes、57 loaded modules, + Application+AI 为 `4,501,822 / 4,501,954`,MCP 为 `7,324,601 / 7,324,709`。新增测试不进入任何运行制品。 +- PostgreSQL HA 不重跑且不重新占有既有证明,因为本切片没有 schema、ACL、repository、role、Pool、连接或 failover 变化。 diff --git a/docs/adr/README.md b/docs/adr/README.md index fb922bb8..8929def1 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -474,6 +474,7 @@ | [ADR-0468](./ADR-0468-optional-console-worker-observation.md) | 可选 Console Worker 只读观察 | Accepted | | [ADR-0469](./ADR-0469-optional-console-package-installation-observation.md) | 可选 Console Package Installation 只读观察 | Accepted | | [ADR-0470](./ADR-0470-session-scoped-console-capability-discovery.md) | Console 会话级能力发现与服务端操作围栏 | Accepted | +| [ADR-0471](./ADR-0471-legacy-core-api-compatibility-baseline.md) | Legacy 核心执行 API 兼容基线 | Accepted | ## 规则 diff --git a/test/back/ql3LegacyCoreApiCompatibility.test.cjs b/test/back/ql3LegacyCoreApiCompatibility.test.cjs new file mode 100644 index 00000000..35e3ccdf --- /dev/null +++ b/test/back/ql3LegacyCoreApiCompatibility.test.cjs @@ -0,0 +1,397 @@ +require('ts-node/register/transpile-only'); +require('reflect-metadata'); + +const assert = require('node:assert/strict'); +const http = require('node:http'); +const { after, before, test } = require('node:test'); +const express = require('express'); +const { errors } = require('celebrate'); +const { Container } = require('typedi'); + +const CronService = require('../../back/services/cron').default; +const SubscriptionService = require('../../back/services/subscription').default; +const registerCronRoutes = require('../../back/api/cron').default; +const registerSubscriptionRoutes = + require('../../back/api/subscription').default; + +const calls = []; +let origin; +let server; +let serverListening = false; + +function record(domain, operation, args, result) { + calls.push({ domain, operation, args }); + return Promise.resolve(result); +} + +const cronService = { + crontabs: (...args) => + record('cron', 'list', args, { + data: [{ id: 11, name: 'legacy-task', status: 0 }], + total: 1, + }), + create: (...args) => + record('cron', 'create', args, { + id: 11, + ...args[0], + status: 0, + }), + update: (...args) => + record('cron', 'update', args, { + ...args[0], + status: 0, + }), + disabled: (...args) => record('cron', 'disable', args, undefined), + enabled: (...args) => record('cron', 'enable', args, undefined), + run: (...args) => record('cron', 'run', args, undefined), + stop: (...args) => record('cron', 'stop', args, undefined), + log: (...args) => + record('cron', 'log', args, { + content: 'legacy log\n', + status: 'running', + }), + logs: (...args) => record('cron', 'logs', args, ['one.log', 'two.log']), + stopInstance: (...args) => + record('cron', 'stop-instance', args, { + code: 200, + message: '实例已停止', + }), +}; + +const subscriptionService = { + list: (...args) => + record('subscription', 'list', args, [ + { id: 21, name: 'legacy-subscription', status: 0 }, + ]), + create: (...args) => + record('subscription', 'create', args, { + id: 21, + ...args[0], + status: 0, + }), + update: (...args) => + record('subscription', 'update', args, { + ...args[0], + status: 0, + }), + disabled: (...args) => record('subscription', 'disable', args, undefined), + enabled: (...args) => record('subscription', 'enable', args, undefined), + run: (...args) => record('subscription', 'run', args, undefined), + stop: (...args) => record('subscription', 'stop', args, undefined), + log: (...args) => record('subscription', 'log', args, 'subscription log\n'), +}; + +const logger = { + debug() {}, + error() {}, + info() {}, + warn() {}, +}; + +async function request(method, pathname, body) { + const response = await fetch(`${origin}${pathname}`, { + method, + headers: body === undefined ? {} : { 'content-type': 'application/json' }, + body: body === undefined ? undefined : JSON.stringify(body), + }); + const text = await response.text(); + return { + status: response.status, + body: text.length === 0 ? undefined : JSON.parse(text), + }; +} + +function lastCall() { + return calls.at(-1); +} + +before(async () => { + Container.set('logger', logger); + Container.set(CronService, cronService); + Container.set(SubscriptionService, subscriptionService); + + const app = express(); + app.set('case sensitive routing', true); + app.set('strict routing', true); + app.use(express.json()); + + const api = express.Router(); + registerCronRoutes(api); + registerSubscriptionRoutes(api); + app.use('/api', api); + app.use(errors()); + app.use((error, _request, response, _next) => { + response.status(error.status || 500).json({ + code: error.status || 500, + message: error.message, + }); + }); + + server = http.createServer(app); + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + serverListening = true; + const address = server.address(); + origin = `http://127.0.0.1:${address.port}`; +}); + +after(async () => { + Container.remove(CronService); + Container.remove(SubscriptionService); + Container.remove('logger'); + if (serverListening) { + await new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); + } +}); + +test('preserves the 2.x Cron API envelope across the QingLong 3.0 runtime boundary', async (t) => { + await t.test( + 'lists tasks with the existing query and response shape', + async () => { + const response = await request( + 'GET', + '/api/crons?searchValue=legacy&page=1&size=20', + ); + assert.deepEqual(response, { + status: 200, + body: { + code: 200, + data: { + data: [{ id: 11, name: 'legacy-task', status: 0 }], + total: 1, + }, + }, + }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation: 'list', + args: [ + { + searchValue: 'legacy', + page: '1', + size: '20', + }, + ], + }); + }, + ); + + const createBody = { + name: 'legacy-task', + command: 'echo compatible', + schedule: '0 0 * * *', + }; + + await t.test( + 'creates and updates tasks without adding a v3-only envelope', + async () => { + const created = await request('POST', '/api/crons', createBody); + assert.equal(created.status, 200); + assert.deepEqual(created.body, { + code: 200, + data: { id: 11, ...createBody, status: 0 }, + }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation: 'create', + args: [createBody], + }); + + const updateBody = { id: 11, ...createBody, command: 'echo updated' }; + const updated = await request('PUT', '/api/crons', updateBody); + assert.equal(updated.status, 200); + assert.deepEqual(updated.body, { + code: 200, + data: { ...updateBody, status: 0 }, + }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation: 'update', + args: [updateBody], + }); + }, + ); + + for (const operation of ['disable', 'enable', 'run', 'stop']) { + await t.test( + `${operation} keeps the code-only success response`, + async () => { + const response = await request('PUT', `/api/crons/${operation}`, [11]); + assert.deepEqual(response, { status: 200, body: { code: 200 } }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation, + args: [[11]], + }); + }, + ); + } + + await t.test( + 'keeps task log and instance-stop response contracts', + async () => { + const log = await request('GET', '/api/crons/11/log'); + assert.deepEqual(log, { + status: 200, + body: { + code: 200, + data: 'legacy log\n', + logStatus: 'running', + }, + }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation: 'log', + args: [11], + }); + + const logs = await request('GET', '/api/crons/11/logs'); + assert.deepEqual(logs, { + status: 200, + body: { code: 200, data: ['one.log', 'two.log'] }, + }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation: 'logs', + args: [11], + }); + + const stopped = await request('POST', '/api/crons/11/instances/101/stop'); + assert.deepEqual(stopped, { + status: 200, + body: { code: 200, message: '实例已停止' }, + }); + assert.deepEqual(lastCall(), { + domain: 'cron', + operation: 'stop-instance', + args: [101], + }); + }, + ); + + await t.test( + 'rejects an invalid execution request before service dispatch', + async () => { + const callCount = calls.length; + const response = await request('PUT', '/api/crons/run', ['x']); + assert.equal(response.status, 400); + assert.equal(calls.length, callCount); + }, + ); +}); + +test('preserves the 2.x Subscription API envelope across the QingLong 3.0 runtime boundary', async (t) => { + await t.test('lists subscriptions with both legacy filters', async () => { + const response = await request( + 'GET', + '/api/subscriptions?searchValue=legacy&ids=%5B21%5D', + ); + assert.deepEqual(response, { + status: 200, + body: { + code: 200, + data: [{ id: 21, name: 'legacy-subscription', status: 0 }], + }, + }); + assert.deepEqual(lastCall(), { + domain: 'subscription', + operation: 'list', + args: ['legacy', '[21]'], + }); + }); + + const createBody = { + type: 'public-repo', + url: 'https://example.invalid/repository.git', + schedule_type: 'cron', + alias: 'legacy-subscription', + }; + + await t.test( + 'creates and updates subscriptions with the legacy envelope', + async () => { + const created = await request('POST', '/api/subscriptions', createBody); + assert.deepEqual(created, { + status: 200, + body: { + code: 200, + data: { id: 21, ...createBody, status: 0 }, + }, + }); + assert.deepEqual(lastCall(), { + domain: 'subscription', + operation: 'create', + args: [createBody], + }); + + const updateBody = { + id: 21, + type: 'public-repo', + url: 'https://example.invalid/updated.git', + alias: 'legacy-subscription', + }; + const updated = await request('PUT', '/api/subscriptions', updateBody); + assert.deepEqual(updated, { + status: 200, + body: { + code: 200, + data: { ...updateBody, status: 0 }, + }, + }); + assert.deepEqual(lastCall(), { + domain: 'subscription', + operation: 'update', + args: [updateBody], + }); + }, + ); + + for (const operation of ['disable', 'enable', 'run', 'stop']) { + await t.test( + `${operation} keeps the code-only success response`, + async () => { + const response = await request( + 'PUT', + `/api/subscriptions/${operation}`, + [21], + ); + assert.deepEqual(response, { status: 200, body: { code: 200 } }); + assert.deepEqual(lastCall(), { + domain: 'subscription', + operation, + args: [[21]], + }); + }, + ); + } + + await t.test('keeps the subscription log response contract', async () => { + const response = await request('GET', '/api/subscriptions/21/log'); + assert.deepEqual(response, { + status: 200, + body: { code: 200, data: 'subscription log\n' }, + }); + assert.deepEqual(lastCall(), { + domain: 'subscription', + operation: 'log', + args: [21], + }); + }); + + await t.test( + 'rejects an invalid stop request before service dispatch', + async () => { + const callCount = calls.length; + const response = await request('PUT', '/api/subscriptions/stop', [ + 21, + 'x', + ]); + assert.equal(response.status, 400); + assert.equal(calls.length, callCount); + }, + ); +});