diff --git a/.github/workflows/ql3-ci.yml b/.github/workflows/ql3-ci.yml index f896571f..fb43c728 100644 --- a/.github/workflows/ql3-ci.yml +++ b/.github/workflows/ql3-ci.yml @@ -143,7 +143,7 @@ jobs: pnpm build:back pnpm run build:packages:ql3 - name: Test fail-closed Linux resource gate - run: node --test test/back/ql3LinuxResourceGate.test.cjs test/back/ql3LinuxResourceWorkflow.test.cjs test/back/ql3PhysicalEdgeEvidence.test.cjs test/back/ql3PhysicalEdgeIdleSampler.test.cjs test/back/ql3PhysicalEdgeFaultProbe.test.cjs test/back/ql3PhysicalEdgeTaskScale.test.cjs + run: node --test test/back/ql3LinuxResourceGate.test.cjs test/back/ql3LinuxResourceWorkflow.test.cjs test/back/ql3LegacyShadowResourceRollback.test.cjs test/back/ql3PhysicalEdgeEvidence.test.cjs test/back/ql3PhysicalEdgeIdleSampler.test.cjs test/back/ql3PhysicalEdgeFaultProbe.test.cjs test/back/ql3PhysicalEdgeTaskScale.test.cjs - name: Prepare architecture evidence directory run: mkdir -p "${RUNNER_TEMP}/ql3-linux-resource-evidence" - name: Stress router headroom without claiming a supported minimum diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index b667f51e..0b5e65eb 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,20 @@ 最新增量证据(2026-08-19): +- D-359/ADR-0451、ADR-0452(已接受):为 D-358 的闭合窗口审计增加真实 compiled-backend 资源与关闭回滚门。证据 schema 固定为 + `qinglong/legacy-shadow-resource-rollback-evidence@v1`;edge 为 8 candidates/1 page,standalone 为 128 candidates/4 pages,8 个样本分别精确执行 + 16/64 条查询,SQLite database/WAL/SHM/journal 的 logical/allocated bytes 和 file count 前后必须一致。full 模式经过独立进程重启:enabled 与 off + 都执行真实 `ScheduleService.runTask` Legacy child 且 exit 0,Shadow Run 增量必须从 1 变为 0;off 还要求零 fact factory、默认 observer/Repository + 未加载。Linux arm64 router stress 保持 128 MiB/0.5 CPU/0 swap/64 PID,cgroup peak `80,416,768` bytes,edge audit p95 `3.914 ms`;Edge release + 保持 256 MiB/1 CPU/0 swap/128 PID,cgroup peak `139,079,680` bytes,edge full/standalone audit p95 分别为 `32.056/19.265 ms`,两档 + `memory.events max/oom/oom_kill` 增量均为 0。双 Node crash harness 留在 256 MiB 发布门,完整 Edge+Standalone Workflow/Prompt crash matrix 仍通过;路由门只跑 + 单进程产品/恢复 workload,不提高内存或 timeout。只读 mount 验证改为解析路径实际生效的最具体 mount:`/workspace` 可继承只读 `/`,但任一可写 descendant + 仍失败。干净镜像同时暴露历史 `build:back` 被 stale artifact 掩盖的 TypeScript common-root 错位;新构建器在隔离 staging 编译,只原子发布 + `output/back` 到既有扁平 `static/build/*`,重写 source map,拒绝携带 `output/packages`,失败保留上一版。D-359 不新增 package、生产依赖、schema、 + migration、数据库 authority、timer/watcher、端口或部署对象。完整 backend 与 18-package clean build/test 退出 0;静态契约 `126/126`,四项可执行 + 架构审计与 14/14 artifact budget 全部通过,制品字节保持 D-358 基线。D-359 不把 Docker arm64 冒充物理路由/断电/flash 证据。D-360 继续负责可信的 + Legacy→Shadow capture authority、具体 exporter 与正式 Primary gate。 + - D-358/ADR-0450(已接受):新增显式、只读、一次性的 Legacy Shadow 闭合窗口终态审计。调用方必须提供 origin 与 `[windowStartMs, windowEndMs)`,cohort 固定为窗口内创建的 legacy-owned Shadow Run;只有窗口经过默认五分钟 settling、候选 keyset 与 Legacy evidence 都完整且分母非零时,才输出 terminal agreement/full comparability permille。关联只接受 direct Run/Attempt reference、同 Cron @@ -9129,7 +9143,7 @@ flowchart LR | 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 生产流量接入 | -| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;监听前一次性、Profile-aware 的 keyset Startup Reconciler,终态证据补齐、lost/abandoned/pending 分流与 terminal Attempt response-loss 修复;origin-bounded 且逐级守恒的版本化 startup difference report、固定字段 metric batch 与一次性 collector;显式、只读、闭合窗口且 Profile-bounded 的 Shadow→Legacy 终态差异审计;失败开放和契约测试 | Legacy→Shadow capture authority、具体 exporter、资源压力、回滚演练和 Primary 门禁 | +| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;监听前一次性、Profile-aware 的 keyset Startup Reconciler,终态证据补齐、lost/abandoned/pending 分流与 terminal Attempt response-loss 修复;origin-bounded 且逐级守恒的版本化 startup difference report、固定字段 metric batch 与一次性 collector;显式、只读、闭合窗口且 Profile-bounded 的 Shadow→Legacy 终态差异审计;128/256 MiB Linux arm64 资源门、SQLite 零增长与 Shadow enabled→off 进程重启回滚;失败开放和契约测试 | Legacy→Shadow capture authority、具体 exporter 和 Primary 门禁 | | PR-5 Primary LocalExecutor | Incubating(默认不激活,仅 manifest-gated manual) | runtime-owned Run 创建器;持久化先于 spawn;Run/Attempt 完整成功、失败、取消、超时与 lost 闭环;Executor handle 身份校验;spawn 后激活写失败的 stop+lost 补偿;completion rejection 安全收敛;独立 Primary 幂等查询与唯一索引竞态裁决;durable `run.cancel_requested`、stop-before-signal、首次请求幂等、晚到完成裁决与待取消有界恢复查询;最多 64 条一页的 cross-worker cancellation source;独立 CancellationDispatch Repository 原子 claim/result、lease expiry 接管、owner/token/version fencing、指数退避与结果 RunEvent;最多 64 页的单周期 cancel supervisor;显式 start/stop、无重叠、错误隔离、停止等待有上限且 timer unref 的 lifecycle runner;Linux durable handle 的 PID/boot/start ticks/process-group 复验与 TERM/KILL controller;完整有界分页且 fail-closed 的 startup Reconcile supervisor;RunningInstance nullable `run_id/attempt_id` 关联;Primary 专用组合 Repository 在同一 SQLite 事务提交前投影 Crontab/RunningInstance,失败整体回滚;有界且防穿越的 legacy log output ref;manual owner seam、真实本机装配、单 spawn/fail-closed;严格 manual-only rollout manifest loader、短期审批/gate、配置哈希审计;HTTP worker 已接轻量 lazy bootstrap,accepted 后按 receipt-first reconcile→completion receipt lifecycle→timeout intent lifecycle→cancel dispatch lifecycle→router 顺序激活,失败撤销,监听失败和 shutdown 有界停止;Primary timeout 在 spawn 前持久化绝对 deadline,有界 source/requester/supervisor 只提交 timeout 意图并复用 CancellationDispatch;代码级 edge/standalone Profile 为各 lifecycle 提供不同 cadence 与页上限,cluster-control/worker 拒绝误装本机 SQLite Primary;统一 CompletionService 原子提交 Attempt/Run/双 Event,spawn 前保存 callback token hash、终态推进 sequence,实时回调与 receipt consumer 共享入口并覆盖两个清理 crash window;manual Primary 已接入受限 POSIX launcher、`0600` direct-file stdout/stderr、父进程退出后续写、不可覆盖 receipt 生产、回执环境清除、TERM 转发等待及 live transaction 后清理;Startup Reconciler receipt-first 双检查并在确定 exited 后执行 profile 化的单次 50/100 ms publish grace;`0007` 独立 CompletionReceiptJournal 在 spawn 前登记、为升级前 active Attempt 补登记并驱动周期扫描,使终态残留继续可发现;确定无效的已知 Attempt receipt 先持久化隔离状态,再进入确定性私有分片 quarantine;终态 missing 与 quarantine 按 edge/standalone retention 有界清理;非 Journal 文件具备只读优先、固定分片/条目上限、overflow fail-closed、显式同盘隔离的 Node 24 运维 CLI;扫描具备页上限、resume cursor、timer unref、无重叠、有界 stop 和低敏计数;ENOSPC 与 launcher receipt 存储失败有代码门禁 | 部署配置写入/审批入口与用户可见状态;PostgreSQL CancellationDispatch adapter;cluster-control 生产启动拓扑;固定 edge/Linux 多架构与真实磁盘压力基线、完整 2.x API 契约和回滚演练 | | PR-7 Worker Session、Run Lease 与启动协议基础 | Incubating(默认关闭,独立入口显式 opt-in) | ADR-0012/0013/0014/0021/0057–0061/0108–0121/0231–0239/0377;有界 capability/Placement/Dispatcher;SQLite 协议孵化与 PostgreSQL v9 Session/Run Lease/credential/attestation authority;immutable revision Placement、数据库时钟 keyset candidate、认证 Worker Pull、digest-only offer recovery;versioned capability-free ExecutionSpec response、stable claim 跨重启退避、单 owner 原子 inbox 准入与 TLS 1.3 mTLS/`ql3w` HTTPS client;同一 package journal 上 revision-fenced starting/spawn/started/running/completion 状态、callback digest、tagged no-spawn 与 ambiguous recovery;PostgreSQL starting/running/start-failure/completion 数据库权威事务、精确重放与 cancellation/timeout 优先终态;batch Secret delivery 在 Attempt advisory lock 下复验 Session/Lease/revision 完整围栏并复用单 Agent,Secret-before-Artifact materializer 将同一 log ID 交给 Executor/journal/running ACK;offer-scoped `wlog-*` 私有文件 spool、Edge/Node 容量策略、append/quota/path 防护、barrier 后 output ownership、受审 POSIX Executor、truncation fact、固定内存流式 source、认证 Artifact stream、共享 immutable store port、S3-compatible SSE/checksum/条件 promotion adapter、upload-before-completion 协调,以及 Local/Cluster 同构、Profile-aware、ETag-fenced range read;用户取消 run.stop mutation 以数据库时间写 intent/Event 并在事务内复验 Project/RoleBinding fence;非执行取消 convergence lifecycle、运行期 expiry 与安全 lost retry 已接入 cluster-control 单一全局 cadence;完整 generation/version/token/Attempt fencing;独立最小权限 Worker ingress、CA/CRL 与连接 generation 热重载;offer journal、spawn barrier、receipt-first recovery;独立 `@qinglong/worker-runtime` 的本地 P-256 CSR、key/chain/trust 验证、generation + active pointer 安装和持久退避;默认关闭的 production process 已装配具体 execution graph、完整 Session heartbeat/drain/offline、direct-file bootstrap、单 Agent/单 cadence、startup reconciliation、证书 maintenance、transport fail-close/recovery 与 Edge/Node 有界预算;真实 PostgreSQL 18 + Linux Node 合约已覆盖 Run completion、credential 和 CA 双轮换且保持同一 Session;真实 K3s 合约已覆盖 TLS/credential Secret 分权、双对象 CAS、Recreate 顺序、identity generation 与单节点 PVC recovery;所有能力默认不可达且受 edge/cluster import audit 约束 | 具体 cert-manager/Vault/SPIFFE/离线 CA adapter 与模板、ingress reload controller、生产 RBAC、证书到期告警和 `ql3w` credential recovery 产品面;具体 KMS/Vault Secret provider、对象存储 credential/temporary lifecycle 与 retention/tombstone;Worker 管理 API;真实 Kubernetes 多节点 CSI/node-loss/production 360 秒 drain 与固定 edge 文件系统 suspend/时钟/断电、x64/arm64 资源门禁 | | PR-8 Project/Policy/Approval Core | Incubating(默认拒绝、无生产业务执行入口) | ADR-0028;统一六类 ActorRef 与 exact-shape 校验;`0017` ownerless default Project 和 append-only versioned RoleBinding;owner/admin/operator/viewer 固定矩阵;Project 内 mutation 幂等、expected-version CAS、双 SQLite 连接竞争门禁;archived read-only、revocation、存储损坏 fail-closed;Agent 写/Secret/Tool `require_approval`;ADR-0047 把六类 subject、role/permission matrix 与 fence 抽到 runtime-core,`pg-0004-project-policy`/capability v3 建立 ownerless PostgreSQL baseline、严格 role/state CHECK、append-only runtime 权限、SERIALIZABLE Project lock、mutation replay、双连接单 winner 和 cluster admission authorizer;ADR-0049/`pg-0005` capability v4 建立 stable IdentitySubject、append-only digest-only API credential、真实 cluster bearer authenticator、write-only durable security audit 与最小权限 runtime role,且已验证 HTTP→credential→Policy→audit→handler 纵向链路;ADR-0051 建立 `/api/v3` 认证前 peer/global 双预算、transport-peer-only、无 timer 且有界内存的 overload shield;ADR-0027 Artifact authorizer adapter;ADR-0029 `AuthenticatedPrincipal` contract、`0018` digest-only versioned challenge、CSPRNG/TTL、同事务消费 challenge + 写首 owner、精确重放与双连接竞争/崩溃回滚门禁;ADR-0030 `0019` stable identity/binding、legacy HS384 + current-session membership、logout/platform/revoke/disable、single-factor 与损坏 fail-closed 门禁;ADR-0031 `0020` digest-bound ApprovalRequest、User-only decision、Project/Role version fence、精确 expiry/重放/并发裁决及同事务 immutable dispatch;ADR-0032 `0021` execution backfill、三表原子 consume、稳定 due keyset、claim/renew/start/result fencing、pre-start takeover/post-start recovery-required、attempt budget、handler inspect/digest barrier 和 bounded dispatcher;ADR-0033/`0022` control/resolution backfill、start/renew/completion 原子联动、稳定 recovery keyset、双 resolver claim/takeover、finding/result 精确重放、自动/人工终结、迟到 completion 单 winner 和 evidence-only bounded reconciler;ADR-0034/`0023` 首个 `run.create` canonical plan、Run/Attempt/Event/receipt 同事务、幂等 collision fail-closed、renew/终态 fence、真实 SQLite handler 与 automatic evidence provider;ADR-0035/`0024` 独立 `approval.recover` 矩阵、稳定 User + 五分钟强认证、Project/RoleBinding fence、human resolution + authorization fact 原子提交、撤权竞态与回滚门禁;ADR-0036 recovery-first 单 timer lifecycle、edge/standalone 独立 cadence/页预算、跨周期 cursor、非重叠与有界 stop;ADR-0074 以新的 Node 24 SQLite v5 ownerless Project/RoleBinding/audit authority 和独立 local-secret-admin 提供强 Principal、`secret.manage`、撤权 fence、envelope+allowed audit 原子提交及不回显语义;ADR-0086 以可信 POSIX console 和 staged delivery 完成本机首 Owner 产品 ceremony | fresh database/pepper setup 与安全迁移向导;`shareStore`/Express 到 authentication core 的 production migration;credential rotation/revocation API、mTLS/Worker enrollment、恢复码;Project/Role/Approval/Secret 管理 CLI/API/UI、audit retention/query/export/alert、preview Artifact/digest/immutable plan builder、真实 MFA/hardware adapter、人工 recovery API/UI/独立 rate limit 与审计事件、handler/provider registry、lifecycle startup/shutdown/指标/admission gate;PostgreSQL action/receipt/provider/recovery-authorization 与 OPA adapter、缓存 version 失效;Tool/Package/Secret/Shell 各自的 handler/evidence contract;Secret/Run/Tool/Workflow waiting_approval 全入口装配;完整回滚演练 | diff --git a/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md b/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md index 3dc033c6..81a34e40 100644 --- a/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md +++ b/docs/adr/ADR-0002-legacy-crontab-compatibility-and-shadow-write.md @@ -239,7 +239,7 @@ Shadow Adapter 不得: Shadow 转换仍必须遵守 ADR-0001。无法合法映射时追加 compat.transition_mismatch,不能强行覆盖终态。 -当前 Alpha 切片对已审 Node worker origin 直接观察同一 ChildProcess 的 spawn、error 和 exit 事件,因此不依赖 Shell callback 才能形成基本终态。下述两级关联已补充 Shell callback、stop/cancel 和乱序/迟到回调;ADR-0448 又补充了监听前一次性启动恢复,ADR-0449 将其投影为 origin-bounded、版本化的差异报告与固定字段 metric batch。ADR-0450 再提供显式、只读、Profile-bounded 的闭合窗口终态审计;但 2.x RunningInstance 缺少可信 origin,因此它只证明已写 Shadow Run 到 Legacy evidence 的一致性,不能单独证明 Legacy→Shadow 捕获率或替代正式 Primary gate。 +当前 Alpha 切片对已审 Node worker origin 直接观察同一 ChildProcess 的 spawn、error 和 exit 事件,因此不依赖 Shell callback 才能形成基本终态。下述两级关联已补充 Shell callback、stop/cancel 和乱序/迟到回调;ADR-0448 又补充了监听前一次性启动恢复,ADR-0449 将其投影为 origin-bounded、版本化的差异报告与固定字段 metric batch。ADR-0450 再提供显式、只读、Profile-bounded 的闭合窗口终态审计;ADR-0451 已在 128 MiB router stress 与 256 MiB Edge release cgroup 中证明有界查询、SQLite 零增长和进程重启后的 Shadow-off 回滚。但 2.x RunningInstance 缺少可信 origin,因此这些证据只覆盖已写 Shadow Run 到 Legacy evidence 的一致性,不能单独证明 Legacy→Shadow 捕获率或替代正式 Primary gate。 ### 9.4 `next` Alpha callback 与 stop 关联 @@ -253,7 +253,7 @@ Shadow 转换仍必须遵守 ADR-0001。无法合法映射时追加 compat.trans 6. 取消事实在 Legacy kill 前投递;同 worker 的后续 exit 排在取消之后。跨 worker 使用持久化定位器尽力关联,任何查询或写入失败都不能阻断 kill 或改变 2.x API 响应。 7. 乱序 finished 可以从 queued/claimed 补齐 dispatching、starting、running 和终态;重复终态 callback、取消后的迟到成功 callback 不覆盖终态,也不追加重复完成事件。 -这仍不是完整的 Shadow→Primary 门禁:ADR-0448 已提供启动后有界批量扫描、终态证据补齐、lost/abandoned 收敛和两事务 response-loss 修复;ADR-0449 已增加 startup 差异报表、固定低基数 metric batch 与可注入单次 collector;ADR-0450 已完成不伪造反向捕获率的闭合窗口 Shadow→Legacy 终态差异查询。具体 exporter、Legacy→Shadow capture authority、资源/回滚演练和正式 Primary gate 尚未完成。后续能力不得让 edge 增加常驻 watcher 或无界内存队列。 +这仍不是完整的 Shadow→Primary 门禁:ADR-0448 已提供启动后有界批量扫描、终态证据补齐、lost/abandoned 收敛和两事务 response-loss 修复;ADR-0449 已增加 startup 差异报表、固定低基数 metric batch 与可注入单次 collector;ADR-0450 已完成不伪造反向捕获率的闭合窗口 Shadow→Legacy 终态差异查询;ADR-0451 已完成 Profile-aware 资源压力和 Shadow-off 进程重启回滚。具体 exporter、Legacy→Shadow capture authority 和正式 Primary gate 尚未完成。后续能力不得让 edge 增加常驻 watcher 或无界内存队列。 ### 9.3 Shadow 写失败 diff --git a/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md b/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md new file mode 100644 index 00000000..3e40b82a --- /dev/null +++ b/docs/adr/ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md @@ -0,0 +1,77 @@ +# ADR-0451:按 Profile 有界的 Legacy Shadow 资源与关闭回滚证据 + +- 状态:Accepted +- 日期:2026-08-19 +- 关联 RFC:QL-RFC-0001 D-02、D-359、PR-4 +- 关联 ADR:ADR-0002、ADR-0088、ADR-0450 +- Amends:ADR-0450 的资源压力与 Shadow-off 回滚待办 + +## 上下文 + +ADR-0450 已冻结闭合窗口的 Shadow→Legacy 终态审计语义,但只证明查询结果,不证明它能在 128 MiB 路由档运行,也没有证明进程重启后把 +`QL3_SHADOW_ORIGINS` 从 enabled 改为 off 会立即停止新 Shadow 写入而继续执行 Legacy。单元测试或主机 RSS 不能替代受限 Linux cgroup;反过来, +在 128 MiB 门中同时驻留 Node 测试父进程和预期被 SIGKILL 的子进程,也不是路由设备的单进程产品模型。 + +资源门此前还要求 `/workspace` 必须是独立只读 mount。只读容器根已经让该路径继承只读语义;额外复制约 1.5 GiB 工作区到匿名 volume 会把私有页缓存 +计入 128 MiB cgroup,制造与产品 RSS 无关的 `memory.events max`。门禁必须验证路径实际生效的最具体 mount,并拒绝 `/workspace/**` 的任何可写覆盖, +而不是要求一种特定 mount 拓扑。 + +## 决策 + +1. 新增一次性证据入口 `benchmark:legacy-shadow`,报告 schema 固定为 + `qinglong/legacy-shadow-resource-rollback-evidence@v1`。报告只包含 Profile、预算、固定资源计数、回滚布尔结论和限制,不输出数据库路径、命令、Run、Attempt、 + Cron、PID、log 或 task identity。 +2. 审计 fixture 使用真实 Sequelize + SQLite、真实 migration、真实 Legacy Shadow writer 和 ADR-0450 auditor。edge 固定 8 个 candidate、`8 × 1 page`; + standalone 固定 128 个 candidate、`32 × 4 pages`。每页必须精确执行一条 candidate 查询和一条 evidence 查询;8 个样本分别固定为 16 和 64 条查询。 +3. 审计连接为单连接、SQLite read-only。运行前后同时比较 database、WAL、SHM 与 journal 的 logical bytes、allocated bytes 和 file count;任一变化均失败。 +4. `full` 模式必须启动三个独立进程:audit、Shadow-enabled Legacy execution、重启后的 Shadow-off Legacy execution。enabled 进程必须执行真实 + `ScheduleService.runTask` 子进程、Legacy exit 0 且恰好新增一个 terminal Shadow Run;off 进程仍须 Legacy exit 0,但 configured origins 为空、fact factory + 调用为零、默认 Shadow observer/Repository 未加载且 Run 增量为零。 +5. Legacy `TaskLimit` 构造器的异步数据库初始化必须在调度和 teardown 前通过同一单例显式等待;证据进程不得用 sleep、吞掉 unhandled rejection 或提前关闭 + Sequelize 来伪造成功。 +6. `router-stress-ci` 保持 128 MiB、0 swap、0.5 CPU、64 PID、非 root、只读根、`NoNewPrivs` 和 seccomp,只运行 edge audit-only。该档位保留真实 Edge + executor、产品 Workflow、SQLite lock 和失败升级恢复,但不运行需要同时驻留测试父/子 Node 的 crash matrix。 +7. `edge-release-ci` 保持 256 MiB、0 swap、1 CPU、128 PID,并运行 edge full rollback、standalone audit-only,以及 Edge+Standalone 的完整 Workflow/Prompt + crash matrix。把双进程 crash harness 放到发布门不减少覆盖,也不把测试框架开销冒充路由产品最低内存。 +8. mount 验证按目标路径选择最具体 mount。`/workspace` 可继承只读 `/`,但其任一可写 descendant mount 都使门禁失败;`/tmp` 仍必须是独立、可写且有容量上限的 + tmpfs。门禁不得为满足断言而复制整个工作区。 +9. 资源报告继续对 `memory.events max/oom/oom_kill/oom_group_kill` 的任一增量失败,不以“没有 OOM”接受内存触顶;也不放宽既有 latency、RSS、写放大或 + SQLite integrity contract。 +10. 本证据只证明 CI 容器内的有界行为,不证明物理路由、flash wear、断电存活、生产任务内容、Legacy→Shadow capture rate、Primary eligibility、 + PostgreSQL 或 Cluster runtime。 + +## 被拒绝的替代方案 + +### 把路由内存上限提高到 192/256 MiB + +拒绝。失败来自测试拓扑和 workspace volume 页缓存,不是产品 Edge workload 超出预算;扩大内存会掩盖错误建模。 + +### 增大 crash child 的 30 秒 timeout + +拒绝。128 MiB 下父/子双 Node 的回收抖动不是业务时延。完整 crash matrix 已在 256 MiB 发布门保留,路由门不应靠等待更久制造假稳定。 + +### Shadow-off 只调用配置解析函数 + +拒绝。回滚证据必须经过进程重启、真实 Legacy child 和数据库计数,证明 off 路径在执行发生时不构造 fact、不加载默认写入组件。 + +## 验证 + +- 主机聚焦门 `23/23`;edge full 与 standalone audit-only 都使用 compiled backend 并通过数据库完整性检查。 +- Linux arm64 router gate:128 MiB/0.5 CPU/0 swap/64 PID,cgroup peak `80,416,768` bytes;所有 `memory.events` 增量为 0。edge audit 8 个样本、16/16 查询、 + p95 `3.914 ms`、RSS 增量 `131,072` bytes,SQLite storage 前后相同。 +- Linux arm64 edge release gate:256 MiB/1 CPU/0 swap/128 PID,cgroup peak `139,079,680` bytes;所有 `memory.events` 增量为 0。edge full audit p95 + `32.056 ms`、16/16 查询、RSS 增量 `3,407,872` bytes;enabled/off Legacy 均 exit 0,Run 增量分别为 1/0,进程峰值 RSS 分别为 + `94,224,384 / 92,577,792` bytes。standalone audit 为 32 pages、64/64 查询、p95 `19.265 ms`、RSS 增量 `1,572,864` bytes,SQLite storage + 前后相同。 +- 同一发布门完整通过 Workflow admission/control、Prompt model/outer transaction 等 Edge+Standalone crash matrix;Prompt live execution 与 exact replay + 固定发生 2 次 key resolution、1 次 key load,门禁拒绝计数漂移。 +- 完整 `test:back` 与 18-package clean build/test 均退出 0;五个 package/deployment/image/vulnerability 静态契约文件为 `126/126`,Edge import、 + Cluster dependency、package boundary 与 Cluster deployment 四个可执行审计均为 compatible 且零 finding。14 个 Local Profile artifact 全部通过预算; + Edge/Standalone 字节依次为:base `2,589,998 / 2,590,076`、adopted `2,809,293 / 2,809,416`、application + `3,632,877 / 3,632,997`、application-api `3,800,430 / 3,800,574`、AI `3,069,251 / 3,069,341`、application+AI + `4,493,151 / 4,493,283`、MCP `7,315,930 / 7,316,038`。 + +## 后续 + +D-360 仍须建立可信的 origin-scoped Legacy→Shadow capture authority,组合 observer failure/capture evidence 与本 ADR/ADR-0450 的结果,再定义正式 Primary gate。 +固定物理 edge、真实 flash 写放大和断电演练必须作为独立现场证据,不能由本 ADR 的 Docker arm64 结果替代。 diff --git a/docs/adr/ADR-0452-atomic-flattened-backend-build-publication.md b/docs/adr/ADR-0452-atomic-flattened-backend-build-publication.md new file mode 100644 index 00000000..dc1ce2e3 --- /dev/null +++ b/docs/adr/ADR-0452-atomic-flattened-backend-build-publication.md @@ -0,0 +1,51 @@ +# ADR-0452:原子且扁平兼容的 Backend 构建发布 + +- 状态:Accepted +- 日期:2026-08-19 +- 关联 RFC:QL-RFC-0001 D-359 +- 关联 ADR:ADR-0042、ADR-0106、ADR-0451 + +## 上下文 + +Backend TypeScript 开始引用 QL3 workspace source 后,TypeScript 的 common source root 扩展到仓库根。一次真正清空输出目录的构建会生成 +`static/build/back/app.js` 与 `static/build/packages/**`,但 2.x 生产入口和现有镜像仍要求 `static/build/app.js`、`token.js`、`runtime/**` 等稳定路径。 +开发机长期残留的旧 `static/build/*` 会掩盖该错位,使增量构建和测试通过,而干净镜像不可启动。 + +直接把所有生产入口改到 `static/build/back/**` 会扩大 2.x 部署、CLI、Docker 与 Shell 兼容面;把 workspace package 编译副本发布进 backend 目录又会形成第二套 +package authority。构建必须只发布 backend subtree,同时保持既有路径和可追踪 source map。 + +## 决策 + +1. `build:back` 唯一进入 `scripts/ql3-build-back.cjs`,不再直接把 `tsc` 输出到公开的 `static/build`。 +2. 每次构建在 `static/.back-build--/output` 隔离目录执行 `tsc -p back/tsconfig.json --outDir ...`。失败时保留当前公开构建,不允许半成品覆盖。 +3. 发布前必须验证 staged `back/app.js`、`back/token.js`、`back/runtime/adapters/local-process/localProcessExecutor.js` 等必要输出存在; + `output/packages/**` 只作为编译闭包,不进入公开 backend。 +4. 只把 staged `output/back` 发布为扁平的 `static/build`,因此生产路径继续是 `static/build/app.js`、`runtime/**`,不要求修改 2.x 入口。 +5. 发布前重写 `.js.map` 的 relative sources,使其从最终扁平位置仍能解析到真实 `back/**` source;不得删除 map 或留下指向临时 staging 的路径。 +6. 发布采用同一父目录内 rename:现有 build 先改名为唯一 backup,staged backend 再 rename 到目标;只有新目录成功可见后才删除 backup。发布失败时恢复旧目录并清理精确 + staging/backup,不使用 shell、glob 或仓库级删除。 +7. 构建结束后 `static/build/back` 与 `static/build/packages` 必须不存在。测试和镜像门必须从干净输出执行,禁止依赖历史 artifact。 + +## 被拒绝的替代方案 + +### 修改所有生产入口指向 `static/build/back` + +拒绝。它把 TypeScript 内部 common-root 变化泄露成部署契约变化,并扩大 2.x 回滚风险。 + +### 构建前清空 `static/build` 后直接运行 tsc + +拒绝。它仍发布错误目录,并在编译失败时破坏最后一个可运行构建。 + +### 同时发布 `output/back` 与 `output/packages` + +拒绝。QL3 package 已有独立 dist/artifact gate,backend 不应携带另一份 workspace package 实现。 + +## 验证 + +- 主机和干净 Linux arm64 镜像中的 `pnpm build:back` 均通过;最终目录只保留原有 root layout,不存在 `static/build/back` 或 + `static/build/packages`。 +- source map 从最终 `static/build/runtime/**` 位置可解析回仓库 `back/runtime/**`,不引用临时 staging。 +- Edge benchmark、D359 compiled-backend 资源/回滚门和完整 backend 回归均只读取新的干净发布结果。 +- 完整 backend 回归与 18-package clean build/test 均退出 0;五个静态契约文件 `126/126`,四个可执行架构审计和 14 个 Local Profile artifact + 预算全部通过,证明扁平 backend 发布没有改变 package closure 或 Profile 制品基线。 +- 本 ADR 不改变 package graph、生产依赖、schema、migration、数据库或部署 authority。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 79bb2e20..e814d386 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -454,6 +454,8 @@ | [ADR-0448](./ADR-0448-bounded-legacy-shadow-startup-reconciliation.md) | 有界 Legacy Shadow 启动恢复 | Accepted | | [ADR-0449](./ADR-0449-versioned-legacy-shadow-startup-difference-report-and-metrics.md) | 版本化 Legacy Shadow 启动差异报告与指标批次 | Accepted | | [ADR-0450](./ADR-0450-closed-window-legacy-shadow-terminal-difference-audit.md) | 闭合窗口的 Legacy Shadow 终态差异审计 | Accepted | +| [ADR-0451](./ADR-0451-profile-bounded-legacy-shadow-resource-and-off-rollback-evidence.md) | 按 Profile 有界的 Legacy Shadow 资源与关闭回滚证据 | Accepted | +| [ADR-0452](./ADR-0452-atomic-flattened-backend-build-publication.md) | 原子且扁平兼容的 Backend 构建发布 | Accepted | ## 规则 diff --git a/package.json b/package.json index e6d4e238..257a345a 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,7 @@ "start:back": "nodemon ./back/app.ts", "start:front": "max dev", "build:front": "max build", - "build:back": "tsc -p back/tsconfig.json", + "build:back": "node scripts/ql3-build-back.cjs", "build:packages:ql3": "node scripts/ql3-clean-package-artifacts.cjs && pnpm -r --workspace-concurrency=1 --filter './packages/ql3-*' run build", "test:back": "node --test test/back/*.test.cjs", "test:packages:ql3": "pnpm run build:packages:ql3 && pnpm -r --workspace-concurrency=1 --filter './packages/ql3-*' exec sh -c 'node --test test/*.test.cjs'", @@ -54,6 +54,7 @@ "test:plugin-package-recovery-e2e:ql3": "pnpm --filter @qinglong/cluster-admin check && pnpm --filter @qinglong/cluster-control check && node scripts/ql3-plugin-package-recovery-e2e-live-contract.cjs", "audit:plugin-package-recovery-e2e:ql3": "node scripts/ql3-plugin-package-recovery-e2e-live-audit.cjs", "benchmark:edge": "node scripts/ql3-edge-benchmark.cjs", + "benchmark:legacy-shadow": "pnpm build:back && node scripts/ql3-legacy-shadow-resource-rollback.cjs --require-compiled", "benchmark:cluster-control": "node scripts/ql3-cluster-control-benchmark.cjs", "benchmark:worker": "node scripts/ql3-worker-resource-benchmark.cjs", "benchmark:ai-profile": "node scripts/ql3-ai-profile-benchmark.cjs", diff --git a/scripts/ql3-build-back.cjs b/scripts/ql3-build-back.cjs new file mode 100644 index 00000000..3ca28f4b --- /dev/null +++ b/scripts/ql3-build-back.cjs @@ -0,0 +1,176 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +const ROOT = path.resolve(__dirname, '..'); +const STATIC_ROOT = path.join(ROOT, 'static'); +const TARGET = path.join(STATIC_ROOT, 'build'); + +class QingLong3BackendBuildError extends Error { + constructor(message) { + super(`QingLong backend build failed: ${message}`); + this.name = 'QingLong3BackendBuildError'; + } +} + +function assertWithinStatic(target, label) { + const relative = path.relative(STATIC_ROOT, target); + if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { + throw new QingLong3BackendBuildError(`${label} escaped static root`); + } +} + +function listFiles(directory) { + const files = []; + const pending = [directory]; + while (pending.length > 0) { + const current = pending.pop(); + for (const entry of fs.readdirSync(current, { withFileTypes: true })) { + const entryPath = path.join(current, entry.name); + if (entry.isDirectory()) pending.push(entryPath); + else if (entry.isFile()) files.push(entryPath); + else { + throw new QingLong3BackendBuildError( + 'staged output contains a non-regular entry', + ); + } + } + } + return files; +} + +function sourceMapPlan(compiledBackend) { + return listFiles(compiledBackend) + .filter((filePath) => filePath.endsWith('.js.map')) + .map((sourcePath) => { + const relative = path.relative(compiledBackend, sourcePath); + const destinationPath = path.join(TARGET, relative); + const sourceMap = JSON.parse(fs.readFileSync(sourcePath, 'utf8')); + if (!Array.isArray(sourceMap.sources)) { + throw new QingLong3BackendBuildError( + `source map ${relative} has no sources array`, + ); + } + return Object.freeze({ + sourcePath, + destinationPath, + sourceMap, + absoluteSources: sourceMap.sources.map((source) => { + if (typeof source !== 'string' || path.isAbsolute(source)) { + throw new QingLong3BackendBuildError( + `source map ${relative} contains an invalid source`, + ); + } + return path.resolve(path.dirname(sourcePath), source); + }), + }); + }); +} + +function rewriteSourceMaps(plans) { + for (const plan of plans) { + plan.sourceMap.sources = plan.absoluteSources.map((absoluteSource) => + path + .relative(path.dirname(plan.destinationPath), absoluteSource) + .split(path.sep) + .join('/'), + ); + fs.writeFileSync(plan.sourcePath, JSON.stringify(plan.sourceMap)); + } +} + +function publishCompiledBackend(compiledBackend, stageRoot) { + for (const required of [ + 'app.js', + 'token.js', + 'runtime/adapters/local-process/localProcessExecutor.js', + ]) { + if (!fs.statSync(path.join(compiledBackend, required)).isFile()) { + throw new QingLong3BackendBuildError( + `compiled backend is missing ${required}`, + ); + } + } + const maps = sourceMapPlan(compiledBackend); + rewriteSourceMaps(maps); + const backup = path.join( + STATIC_ROOT, + `.build-backup-${process.pid}-${Date.now()}`, + ); + assertWithinStatic(TARGET, 'target'); + assertWithinStatic(backup, 'backup'); + let previousMoved = false; + try { + if (fs.existsSync(TARGET)) { + fs.renameSync(TARGET, backup); + previousMoved = true; + } + fs.renameSync(compiledBackend, TARGET); + } catch (error) { + if (!fs.existsSync(TARGET) && previousMoved && fs.existsSync(backup)) { + fs.renameSync(backup, TARGET); + } + throw error; + } + if (previousMoved) fs.rmSync(backup, { recursive: true, force: true }); + fs.rmSync(stageRoot, { recursive: true, force: true }); +} + +function main() { + fs.mkdirSync(STATIC_ROOT, { recursive: true }); + const stageRoot = fs.mkdtempSync(path.join(STATIC_ROOT, '.back-build-')); + assertWithinStatic(stageRoot, 'stage'); + const output = path.join(stageRoot, 'output'); + try { + const tsc = require.resolve('typescript/bin/tsc'); + const result = spawnSync( + process.execPath, + [tsc, '-p', 'back/tsconfig.json', '--outDir', output], + { + cwd: ROOT, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }, + ); + if (result.stdout) process.stdout.write(result.stdout); + if (result.stderr) process.stderr.write(result.stderr); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new QingLong3BackendBuildError( + `TypeScript compiler exited with ${result.status}`, + ); + } + const compiledBackend = path.join(output, 'back'); + if (!fs.existsSync(compiledBackend)) { + throw new QingLong3BackendBuildError( + 'TypeScript output did not contain the backend subtree', + ); + } + publishCompiledBackend(compiledBackend, stageRoot); + } catch (error) { + fs.rmSync(stageRoot, { recursive: true, force: true }); + throw error; + } +} + +module.exports = { + QingLong3BackendBuildError, + assertWithinStatic, + listFiles, + publishCompiledBackend, + rewriteSourceMaps, + sourceMapPlan, +}; + +if (require.main === module) { + try { + main(); + } catch (error) { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + } +} diff --git a/scripts/ql3-legacy-shadow-resource-rollback.cjs b/scripts/ql3-legacy-shadow-resource-rollback.cjs new file mode 100644 index 00000000..2ec6994f --- /dev/null +++ b/scripts/ql3-legacy-shadow-resource-rollback.cjs @@ -0,0 +1,776 @@ +#!/usr/bin/env node + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { performance } = require('node:perf_hooks'); +const sqlite3 = require('sqlite3'); +const { DataTypes, QueryTypes, Sequelize } = require('sequelize'); +const REPOSITORY_ROOT = path.resolve(__dirname, '..'); +process.env.QL_DIR ??= REPOSITORY_ROOT; +const BUILD_ROOT = path.join(REPOSITORY_ROOT, 'static/build'); +const BUILD_AVAILABLE = fs.existsSync( + path.join( + BUILD_ROOT, + 'runtime/application/legacyShadowTerminalDifferenceAuditor.js', + ), +); +if (!BUILD_AVAILABLE) require('ts-node/register/transpile-only'); +const fromRuntime = (relativePath) => + require(path.join( + BUILD_AVAILABLE ? BUILD_ROOT : path.join(REPOSITORY_ROOT, 'back'), + relativePath, + )); + +const MIB = 1024 * 1024; +const BASE_TIME_MS = 1_750_200_000_000; +const MAX_CHILD_OUTPUT_BYTES = 64 * 1024; +const PROFILES = Object.freeze({ + edge: Object.freeze({ candidates: 8, pageSize: 8, maxPages: 1 }), + standalone: Object.freeze({ candidates: 128, pageSize: 32, maxPages: 4 }), +}); +const MEASURES = Object.freeze([ + 'real_sequelize_sqlite_shadow_terminal_audit', + 'profile_maximum_closed_window_candidate_count', + 'bounded_candidate_and_evidence_query_count', + 'read_only_database_storage_stability', + 'process_restart_shadow_enabled_to_off', + 'real_legacy_child_execution_before_and_after_restart', + 'off_path_zero_fact_factory_and_shadow_write', +]); +const EXCLUSIONS = Object.freeze([ + 'legacy_to_shadow_capture_rate', + 'physical_router_or_flash_wear', + 'power_loss_survival', + 'production_task_content_or_identity', + 'primary_execution_eligibility', + 'cluster_or_postgresql_runtime', +]); + +class QingLong3LegacyShadowResourceRollbackError extends Error { + constructor(message) { + super( + `QingLong 3.0 Legacy Shadow resource/rollback gate failed: ${message}`, + ); + this.name = 'QingLong3LegacyShadowResourceRollbackError'; + } +} + +function integerArgument(name, value, minimum, maximum) { + if (!/^\d+$/.test(value)) { + throw new QingLong3LegacyShadowResourceRollbackError( + `${name} must be an integer`, + ); + } + const parsed = Number(value); + if (!Number.isSafeInteger(parsed) || parsed < minimum || parsed > maximum) { + throw new QingLong3LegacyShadowResourceRollbackError( + `${name} must be between ${minimum} and ${maximum}`, + ); + } + return parsed; +} + +function parseArguments(argv) { + const options = { + profile: 'edge', + mode: 'full', + samples: 8, + maxAuditP95Ms: 2_000, + maxRssDeltaBytes: 64 * MIB, + requireCompiled: false, + json: false, + }; + for (const argument of argv) { + if (argument === '--' || argument === '--json') { + if (argument === '--json') options.json = true; + } else if (argument === '--require-compiled') { + options.requireCompiled = true; + } else if (argument.startsWith('--profile=')) { + const value = argument.slice('--profile='.length); + if (!(value in PROFILES)) { + throw new QingLong3LegacyShadowResourceRollbackError( + '--profile must be edge or standalone', + ); + } + options.profile = value; + } else if (argument.startsWith('--mode=')) { + const value = argument.slice('--mode='.length); + if (value !== 'audit-only' && value !== 'full') { + throw new QingLong3LegacyShadowResourceRollbackError( + '--mode must be audit-only or full', + ); + } + options.mode = value; + } else if (argument.startsWith('--samples=')) { + options.samples = integerArgument( + '--samples', + argument.slice('--samples='.length), + 1, + 32, + ); + } else if (argument.startsWith('--max-audit-p95-ms=')) { + options.maxAuditP95Ms = integerArgument( + '--max-audit-p95-ms', + argument.slice('--max-audit-p95-ms='.length), + 1, + 60_000, + ); + } else if (argument.startsWith('--max-rss-delta-mb=')) { + options.maxRssDeltaBytes = + integerArgument( + '--max-rss-delta-mb', + argument.slice('--max-rss-delta-mb='.length), + 1, + 512, + ) * MIB; + } else { + throw new QingLong3LegacyShadowResourceRollbackError( + `unsupported argument ${argument}`, + ); + } + } + return Object.freeze(options); +} + +function nextIdentifier(sequence) { + return `019f7500-0000-7000-8000-${String(sequence).padStart(12, '0')}`; +} + +async function createFixture(databasePath, profile) { + const { defineSchemaMigrationModel } = fromRuntime('data/schemaMigration'); + const { runSchemaMigration } = fromRuntime('migrations/0002-run-schema'); + const { runningInstanceRunReferenceMigration } = fromRuntime( + 'migrations/0003-running-instance-run-reference', + ); + const { runCancellationRequestMigration } = fromRuntime( + 'migrations/0004-run-cancellation-request', + ); + const { runAttemptDeadlineMigration } = fromRuntime( + 'migrations/0006-run-attempt-deadline', + ); + const { runMigrations } = fromRuntime('migrations/runner'); + const { createLegacyLogArtifactId } = fromRuntime( + 'runtime/compatibility/legacyTaskRevision', + ); + const { LegacySequelizeRunRepository } = fromRuntime( + 'runtime/adapters/legacy-sequelize/runRepository', + ); + const { LegacyShadowRunWriter } = fromRuntime( + 'runtime/application/legacyShadowRunWriter', + ); + const database = new Sequelize({ + dialect: 'sqlite', + storage: databasePath, + logging: false, + }); + try { + await database.getQueryInterface().createTable('RunningInstances', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true, + }, + cron_id: { type: DataTypes.INTEGER, allowNull: false }, + pid: { type: DataTypes.INTEGER, allowNull: true }, + log_path: { type: DataTypes.STRING, allowNull: true }, + started_at: { type: DataTypes.INTEGER, allowNull: false }, + finished_at: { type: DataTypes.INTEGER, allowNull: true }, + status: { type: DataTypes.INTEGER, allowNull: false }, + exit_code: { type: DataTypes.INTEGER, allowNull: true }, + }); + await runMigrations({ + database, + migrationModel: defineSchemaMigrationModel(database), + migrations: [ + runSchemaMigration, + runningInstanceRunReferenceMigration, + runCancellationRequestMigration, + runAttemptDeadlineMigration, + ], + logger: { info() {} }, + }); + let identifierSequence = 10_000; + const repository = new LegacySequelizeRunRepository(database); + const writer = new LegacyShadowRunWriter(repository, () => { + identifierSequence += 1; + return nextIdentifier(identifierSequence); + }); + const rows = []; + for (let index = 0; index < PROFILES[profile].candidates; index += 1) { + const acceptedAtMs = BASE_TIME_MS + index * 2_000; + const legacyCronId = index + 1; + const pid = 20_000 + index; + const logPath = `resource/${index}.log`; + const reference = await writer.accept({ + origin: 'manual', + projectId: 'default', + taskId: `legacy-resource:${index}`, + taskRevision: `sha256:${String(index).padStart(64, '0')}`, + legacyCronId, + triggerType: 'manual', + acceptedAtMs, + }); + await writer.spawned(reference, { + atMs: acceptedAtMs + 100, + pid, + logArtifactId: createLegacyLogArtifactId(logPath), + }); + await writer.running(reference, acceptedAtMs + 200); + await writer.exited(reference, { + atMs: acceptedAtMs + 500, + exitCode: 0, + }); + rows.push({ + cron_id: legacyCronId, + run_id: reference.runId, + attempt_id: reference.attemptId, + pid, + log_path: logPath, + started_at: Math.floor((acceptedAtMs + 100) / 1_000), + finished_at: Math.floor((acceptedAtMs + 500) / 1_000), + status: 1, + exit_code: 0, + }); + } + await database.getQueryInterface().bulkInsert('RunningInstances', rows); + } finally { + await database.close(); + } +} + +function fileStorage(databasePath) { + const files = [ + databasePath, + `${databasePath}-wal`, + `${databasePath}-shm`, + `${databasePath}-journal`, + ]; + let logicalBytes = 0; + let allocatedBytes = 0; + let fileCount = 0; + for (const filePath of files) { + try { + const stat = fs.statSync(filePath); + logicalBytes += stat.size; + allocatedBytes += stat.blocks * 512; + fileCount += 1; + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + return Object.freeze({ logicalBytes, allocatedBytes, fileCount }); +} + +function percentile(values, fraction) { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.max(0, Math.ceil(sorted.length * fraction) - 1)]; +} + +async function auditChild(options) { + const { createLegacyLogArtifactId } = fromRuntime( + 'runtime/compatibility/legacyTaskRevision', + ); + const { LegacySequelizeShadowTerminalDifferenceSource } = fromRuntime( + 'runtime/adapters/legacy-sequelize/legacyShadowTerminalDifferenceSource', + ); + const { LegacyShadowTerminalDifferenceAuditor } = fromRuntime( + 'runtime/application/legacyShadowTerminalDifferenceAuditor', + ); + const databasePath = process.env.QL3_SHADOW_DRILL_DATABASE; + if (!path.isAbsolute(databasePath ?? '')) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'internal audit database is invalid', + ); + } + let queryCount = 0; + const database = new Sequelize({ + dialect: 'sqlite', + storage: databasePath, + logging() { + queryCount += 1; + }, + dialectOptions: { mode: sqlite3.OPEN_READONLY }, + pool: { max: 1, min: 0, idle: 1_000, acquire: 5_000 }, + }); + const rssBeforeBytes = process.memoryUsage().rss; + const durationsMs = []; + let pages = 0; + let scanned = 0; + try { + const source = new LegacySequelizeShadowTerminalDifferenceSource( + database, + createLegacyLogArtifactId, + ); + const auditor = new LegacyShadowTerminalDifferenceAuditor(source); + const profile = PROFILES[options.profile]; + const windowEndMs = BASE_TIME_MS + (profile.candidates - 1) * 2_000 + 1_000; + for (let sample = 0; sample < options.samples; sample += 1) { + const startedAt = performance.now(); + const report = await auditor.run({ + profile: options.profile, + origins: ['manual'], + windowStartMs: BASE_TIME_MS - 1, + windowEndMs, + observedAtMs: windowEndMs + 5 * 60_000, + }); + durationsMs.push(performance.now() - startedAt); + if ( + report.assessment !== 'matched' || + report.scanned !== profile.candidates || + report.remaining || + !report.evidenceComplete || + report.terminalAgreementPermille !== 1_000 + ) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'audit child did not reproduce the closed matched cohort', + ); + } + pages += report.pages; + scanned += report.scanned; + } + } finally { + await database.close(); + } + const rssAfterBytes = process.memoryUsage().rss; + return Object.freeze({ + samples: options.samples, + pages, + scanned, + queryCount, + expectedQueryCount: pages * 2, + durationP50Ms: Number(percentile(durationsMs, 0.5).toFixed(3)), + durationP95Ms: Number(percentile(durationsMs, 0.95).toFixed(3)), + durationMaxMs: Number(Math.max(...durationsMs).toFixed(3)), + rssBeforeBytes, + rssAfterBytes, + rssDeltaBytes: Math.max(0, rssAfterBytes - rssBeforeBytes), + peakProcessRssBytes: process.resourceUsage().maxRSS * 1024, + }); +} + +function logger() { + return { info() {}, warn() {}, error() {} }; +} + +async function runSummary(database) { + const [row] = await database.query( + `SELECT + COUNT(*) AS count, + SUM(CASE WHEN status = 'succeeded' THEN 1 ELSE 0 END) AS succeeded + FROM Runs + WHERE execution_owner = 'legacy'`, + { type: QueryTypes.SELECT }, + ); + return Object.freeze({ + count: Number(row.count), + succeeded: Number(row.succeeded ?? 0), + }); +} + +async function rollbackChild(mode, expectedBefore) { + const databasePath = process.env.QL3_SHADOW_DRILL_DATABASE; + if (!path.isAbsolute(databasePath ?? '')) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'internal rollback database is invalid', + ); + } + const taskLimit = fromRuntime('shared/pLimit').default; + await taskLimit.setCustomLimit(); + const ScheduleService = fromRuntime('services/schedule').default; + const bridge = fromRuntime('runtime/compatibility/legacyExecutionBridge'); + const { sequelize } = fromRuntime('data'); + let shortCircuitFactCalls = 0; + try { + const configuredOrigins = bridge.configuredLegacyShadowOrigins(); + if (mode === 'off') { + const observation = bridge.observeLegacyExecution('system', () => { + shortCircuitFactCalls += 1; + throw new Error('off path must not construct a Shadow fact'); + }); + if (observation !== undefined || shortCircuitFactCalls !== 0) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'off path did not short-circuit before Shadow fact construction', + ); + } + } + const service = new ScheduleService(logger()); + const command = `${JSON.stringify(process.execPath)} -e "process.exit(0)"`; + const result = await service.runTask( + command, + {}, + { + id: 'opaque-resource-rollback', + name: 'resource rollback fixture', + schedule: '0 * * * *', + runOrigin: 'system', + }, + ); + const expectedAfter = expectedBefore + (mode === 'enabled' ? 1 : 0); + const deadline = Date.now() + 10_000; + let observed = await runSummary(sequelize); + while ( + (observed.count !== expectedAfter || + observed.succeeded !== expectedAfter) && + Date.now() < deadline + ) { + await new Promise((resolve) => setTimeout(resolve, 10)); + observed = await runSummary(sequelize); + } + const loadedModules = Object.keys(require.cache); + const defaultObserverLoaded = loadedModules.some((filePath) => + /legacyShadowRunObserver\.(?:ts|js)$/.test(filePath), + ); + const repositoryLoaded = loadedModules.some((filePath) => + /legacy-sequelize\/runRepository\.(?:ts|js)$/.test(filePath), + ); + if ( + result?.code !== 0 || + observed.count !== expectedAfter || + observed.succeeded !== expectedAfter + ) { + throw new QingLong3LegacyShadowResourceRollbackError( + `${mode} restart did not preserve the expected Legacy result`, + ); + } + return Object.freeze({ + mode, + configuredOrigins, + legacyExitCode: result.code, + runCountBefore: expectedBefore, + runCountAfter: observed.count, + succeededRunCountAfter: observed.succeeded, + runDelta: observed.count - expectedBefore, + shortCircuitFactCalls, + defaultObserverLoaded, + repositoryLoaded, + peakProcessRssBytes: process.resourceUsage().maxRSS * 1024, + }); + } finally { + await sequelize.close(); + } +} + +function runInternalChild(args, env) { + const result = spawnSync(process.execPath, [__filename, ...args], { + cwd: path.resolve(__dirname, '..'), + env: { ...process.env, ...env }, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: MAX_CHILD_OUTPUT_BYTES, + }); + if (result.error) throw result.error; + if (result.status !== 0) { + throw new QingLong3LegacyShadowResourceRollbackError( + `internal child failed: ${(result.stderr || result.stdout).trim()}`, + ); + } + try { + const lines = result.stdout + .trim() + .split('\n') + .map((line) => line.trim()) + .filter(Boolean); + for (const line of lines.reverse()) { + try { + return JSON.parse(line); + } catch { + // Some legacy dependencies emit bounded diagnostics on stdout. + } + } + throw new Error('no JSON record was emitted'); + } catch (error) { + throw new QingLong3LegacyShadowResourceRollbackError( + `internal child returned invalid JSON: ${error.message}`, + ); + } +} + +async function inspectDatabase(databasePath) { + const database = new Sequelize({ + dialect: 'sqlite', + storage: databasePath, + logging: false, + dialectOptions: { mode: sqlite3.OPEN_READONLY }, + pool: { max: 1, min: 0 }, + }); + try { + const [integrity] = await database.query('PRAGMA integrity_check', { + type: QueryTypes.SELECT, + }); + return Object.freeze({ + integrity: integrity.integrity_check, + runCount: (await runSummary(database)).count, + }); + } finally { + await database.close(); + } +} + +async function runEvidence(options) { + const directory = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-legacy-shadow-resource-'), + ); + const dataDirectory = path.join(directory, 'data'); + const databaseDirectory = path.join(dataDirectory, 'db'); + const databasePath = path.join(databaseDirectory, 'database.sqlite'); + const previousDataDirectory = process.env.QL_DATA_DIR; + fs.mkdirSync(databaseDirectory, { recursive: true }); + process.env.QL_DATA_DIR = dataDirectory; + try { + await createFixture(databasePath, options.profile); + const beforeAudit = fileStorage(databasePath); + const childEnvironment = { + QL_DATA_DIR: dataDirectory, + QL3_SHADOW_DRILL_DATABASE: databasePath, + }; + let audit; + if (options.mode === 'audit-only') { + const previousDatabase = process.env.QL3_SHADOW_DRILL_DATABASE; + process.env.QL3_SHADOW_DRILL_DATABASE = databasePath; + try { + audit = await auditChild(options); + } finally { + if (previousDatabase === undefined) { + delete process.env.QL3_SHADOW_DRILL_DATABASE; + } else { + process.env.QL3_SHADOW_DRILL_DATABASE = previousDatabase; + } + } + } else { + audit = runInternalChild( + [ + '--internal-child=audit', + `--profile=${options.profile}`, + `--samples=${options.samples}`, + `--max-audit-p95-ms=${options.maxAuditP95Ms}`, + `--max-rss-delta-mb=${options.maxRssDeltaBytes / MIB}`, + ], + { ...childEnvironment, QL3_SHADOW_ORIGINS: '' }, + ); + } + const afterAudit = fileStorage(databasePath); + const fixtureRunCount = PROFILES[options.profile].candidates; + const enabled = + options.mode === 'full' + ? runInternalChild( + [ + '--internal-child=rollback-enabled', + `--expected-before=${fixtureRunCount}`, + ], + { ...childEnvironment, QL3_SHADOW_ORIGINS: 'system' }, + ) + : undefined; + const off = + options.mode === 'full' + ? runInternalChild( + [ + '--internal-child=rollback-off', + `--expected-before=${fixtureRunCount + 1}`, + ], + { ...childEnvironment, QL3_SHADOW_ORIGINS: '' }, + ) + : undefined; + const finalDatabase = await inspectDatabase(databasePath); + const violations = []; + if (audit.queryCount !== audit.expectedQueryCount) { + violations.push('audit query count exceeded the two-query page contract'); + } + if (audit.durationP95Ms > options.maxAuditP95Ms) { + violations.push('audit p95 exceeded its configured resource budget'); + } + if (audit.rssDeltaBytes > options.maxRssDeltaBytes) { + violations.push( + 'audit RSS delta exceeded its configured resource budget', + ); + } + if ( + beforeAudit.logicalBytes !== afterAudit.logicalBytes || + beforeAudit.allocatedBytes !== afterAudit.allocatedBytes || + beforeAudit.fileCount !== afterAudit.fileCount + ) { + violations.push('read-only audit changed SQLite storage'); + } + if ( + options.mode === 'full' && + (enabled.runDelta !== 1 || + enabled.legacyExitCode !== 0 || + !enabled.defaultObserverLoaded || + !enabled.repositoryLoaded) + ) { + violations.push( + 'enabled restart did not produce one Shadow terminal Run', + ); + } + if ( + options.mode === 'full' && + (off.runDelta !== 0 || + off.legacyExitCode !== 0 || + off.configuredOrigins.length !== 0 || + off.shortCircuitFactCalls !== 0 || + off.defaultObserverLoaded || + off.repositoryLoaded) + ) { + violations.push('off restart retained Shadow initialization or writes'); + } + if ( + finalDatabase.integrity !== 'ok' || + finalDatabase.runCount !== + fixtureRunCount + (options.mode === 'full' ? 1 : 0) + ) { + violations.push('rollback drill database did not converge safely'); + } + return Object.freeze({ + schemaVersion: 1, + fixture: 'qinglong/legacy-shadow-resource-rollback-evidence@v1', + profile: options.profile, + workload: Object.freeze({ + mode: options.mode, + runtime: BUILD_AVAILABLE ? 'compiled_backend' : 'typescript_fallback', + candidates: PROFILES[options.profile].candidates, + pageSize: PROFILES[options.profile].pageSize, + maxPages: PROFILES[options.profile].maxPages, + samples: options.samples, + }), + audit: Object.freeze({ + ...audit, + databaseStorageBefore: beforeAudit, + databaseStorageAfter: afterAudit, + readOnlyStorageStable: + beforeAudit.logicalBytes === afterAudit.logicalBytes && + beforeAudit.allocatedBytes === afterAudit.allocatedBytes && + beforeAudit.fileCount === afterAudit.fileCount, + }), + rollback: + options.mode === 'full' + ? Object.freeze({ + performed: true, + mechanism: 'process_restart_environment_disable', + enabled: Object.freeze({ + configuredOrigins: enabled.configuredOrigins, + legacyExitCode: enabled.legacyExitCode, + runDelta: enabled.runDelta, + defaultObserverLoaded: enabled.defaultObserverLoaded, + repositoryLoaded: enabled.repositoryLoaded, + peakProcessRssBytes: enabled.peakProcessRssBytes, + }), + off: Object.freeze({ + configuredOrigins: off.configuredOrigins, + legacyExitCode: off.legacyExitCode, + runDelta: off.runDelta, + shortCircuitFactCalls: off.shortCircuitFactCalls, + defaultObserverLoaded: off.defaultObserverLoaded, + repositoryLoaded: off.repositoryLoaded, + peakProcessRssBytes: off.peakProcessRssBytes, + }), + finalRunCount: finalDatabase.runCount, + databaseIntegrity: finalDatabase.integrity, + legacyContinued: + enabled.legacyExitCode === 0 && off.legacyExitCode === 0, + shadowWritesStopped: enabled.runDelta === 1 && off.runDelta === 0, + physicalPowerLossProven: false, + }) + : Object.freeze({ + performed: false, + reason: 'separate_release_gate', + finalRunCount: finalDatabase.runCount, + databaseIntegrity: finalDatabase.integrity, + }), + qualification: Object.freeze({ + passed: violations.length === 0, + measures: options.mode === 'full' ? MEASURES : MEASURES.slice(0, 4), + doesNotProve: + options.mode === 'full' + ? EXCLUSIONS + : [...EXCLUSIONS, 'shadow_off_process_restart_rollback'], + violations, + }), + }); + } finally { + if (previousDataDirectory === undefined) { + delete process.env.QL_DATA_DIR; + } else { + process.env.QL_DATA_DIR = previousDataDirectory; + } + fs.rmSync(directory, { recursive: true, force: true }); + } +} + +async function main() { + const internalChild = process.argv.find((argument) => + argument.startsWith('--internal-child='), + ); + if (internalChild) { + const mode = internalChild.slice('--internal-child='.length); + const profile = + process.argv + .find((argument) => argument.startsWith('--profile=')) + ?.slice('--profile='.length) ?? 'edge'; + const samples = Number( + process.argv + .find((argument) => argument.startsWith('--samples=')) + ?.slice('--samples='.length) ?? 1, + ); + let report; + if (mode === 'audit') { + report = await auditChild({ profile, samples }); + } else if (mode === 'rollback-enabled' || mode === 'rollback-off') { + const expectedBefore = Number( + process.argv + .find((argument) => argument.startsWith('--expected-before=')) + ?.slice('--expected-before='.length), + ); + if (!Number.isSafeInteger(expectedBefore) || expectedBefore < 0) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'internal expected Run count is invalid', + ); + } + report = await rollbackChild( + mode === 'rollback-enabled' ? 'enabled' : 'off', + expectedBefore, + ); + } else { + throw new QingLong3LegacyShadowResourceRollbackError( + 'internal child mode is invalid', + ); + } + process.stdout.write(`${JSON.stringify(report)}\n`); + return; + } + const options = parseArguments(process.argv.slice(2)); + if (Number(process.versions.node.split('.')[0]) < 24) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'Node.js 24 or newer is required', + ); + } + if (options.requireCompiled && !BUILD_AVAILABLE) { + throw new QingLong3LegacyShadowResourceRollbackError( + 'compiled backend is required for resource evidence', + ); + } + const report = await runEvidence(options); + process.stdout.write( + `${JSON.stringify(report, null, options.json ? 0 : 2)}\n`, + ); + if (!report.qualification.passed) process.exitCode = 1; +} + +module.exports = { + EXCLUSIONS, + MEASURES, + PROFILES, + QingLong3LegacyShadowResourceRollbackError, + parseArguments, + percentile, + runEvidence, +}; + +if (require.main === module) { + main().catch((error) => { + process.stderr.write( + `${error instanceof Error ? error.message : String(error)}\n`, + ); + process.exitCode = 1; + }); +} diff --git a/scripts/ql3-linux-resource-gate.cjs b/scripts/ql3-linux-resource-gate.cjs index 864082f3..dfe897e6 100644 --- a/scripts/ql3-linux-resource-gate.cjs +++ b/scripts/ql3-linux-resource-gate.cjs @@ -22,6 +22,9 @@ const RESOURCE_TIERS = Object.freeze({ sqliteMaxTransactionP95Ms: 500, sqliteMaxBatchStallMs: 5_000, sqliteMaxRssDeltaMb: 32, + shadowAuditSamples: 8, + shadowAuditMaxP95Ms: 1_500, + shadowAuditMaxRssDeltaMb: 32, workflowLockSamples: 16, workflowMaxLockP95Ms: 500, workflowMaxProcessRssMb: 96, @@ -41,6 +44,9 @@ const RESOURCE_TIERS = Object.freeze({ sqliteMaxTransactionP95Ms: 250, sqliteMaxBatchStallMs: 2_500, sqliteMaxRssDeltaMb: 64, + shadowAuditSamples: 8, + shadowAuditMaxP95Ms: 1_000, + shadowAuditMaxRssDeltaMb: 64, workflowLockSamples: 32, workflowMaxLockP95Ms: 250, workflowMaxProcessRssMb: 160, @@ -164,6 +170,33 @@ function parseMountOptions(raw) { return mounts; } +function mountOptionsForPath(mounts, targetPath) { + let selectedPath; + let selectedOptions; + for (const [mountPath, options] of mounts) { + const containsTarget = + mountPath === targetPath || + (mountPath === '/' && targetPath.startsWith('/')) || + (mountPath !== '/' && targetPath.startsWith(`${mountPath}/`)); + if ( + containsTarget && + (selectedPath === undefined || mountPath.length > selectedPath.length) + ) { + selectedPath = mountPath; + selectedOptions = options; + } + } + return selectedOptions; +} + +function isPathTreeReadOnly(mounts, targetPath) { + if (!mountOptionsForPath(mounts, targetPath)?.includes('ro')) return false; + return [...mounts].every( + ([mountPath, options]) => + !mountPath.startsWith(`${targetPath}/`) || !options.includes('rw'), + ); +} + function readLinuxEnvelope(root = '/') { const read = (relativePath) => fs.readFileSync(path.join(root, relativePath), 'utf8'); @@ -219,12 +252,13 @@ function validateEnvelope(tierName, envelope, identity) { violations.push(`architecture ${identity.architecture} is unsupported`); } if (identity.uid === 0) violations.push('resource workload must be non-root'); - for (const mountPath of ['/', '/workspace']) { - if (!envelope.mounts.get(mountPath)?.includes('ro')) { - violations.push(`${mountPath} must be mounted read-only`); - } + if (!mountOptionsForPath(envelope.mounts, '/')?.includes('ro')) { + violations.push('/ must be mounted read-only'); } - if (!envelope.mounts.get('/tmp')?.includes('rw')) { + if (!isPathTreeReadOnly(envelope.mounts, '/workspace')) { + violations.push('/workspace must be mounted read-only'); + } + if (!mountOptionsForPath(envelope.mounts, '/tmp')?.includes('rw')) { violations.push('/tmp must be a writable bounded tmpfs'); } return Object.freeze(violations); @@ -319,7 +353,7 @@ function createWorkloadPlans(root, tierName) { durableOutputBytes: PROMPT_RESOURCE_OUTPUT_BYTES, providerCalls: 2, keyLoads: 1, - keyResolutions: 1, + keyResolutions: 2, exactReplay: true, contentFree: true, maxLogicalWriteAmplificationPermille: 3_000, @@ -346,40 +380,44 @@ function createWorkloadPlans(root, tierName) { `--max-lock-p95-ms=${tier.workflowMaxLockP95Ms}`, ]), }), - Object.freeze({ - name: 'local-workflow-admission-crash-recovery', - format: 'node_test', - nodeArgs: Object.freeze([ - path.join( - root, - 'packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionCrashMatrix.test.cjs', - ), - ]), - contract: Object.freeze({ - profiles: Object.freeze(['edge', 'standalone']), - crashPointsPerProfile: 8, - scenarios: 16, - mechanism: 'process_sigkill_then_database_reopen', - physicalPowerLossProven: false, - }), - }), - Object.freeze({ - name: 'local-workflow-control-crash-recovery', - format: 'node_test', - nodeArgs: Object.freeze([ - path.join( - root, - 'packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskControlCrashMatrix.test.cjs', - ), - ]), - contract: Object.freeze({ - profiles: Object.freeze(['edge', 'standalone']), - crashPointsPerProfile: 8, - scenarios: 16, - conclusiveStopObserved: true, - physicalPowerLossProven: false, - }), - }), + ...(tierName === 'edge-release-ci' + ? [ + Object.freeze({ + name: 'local-workflow-admission-crash-recovery', + format: 'node_test', + nodeArgs: Object.freeze([ + path.join( + root, + 'packages/ql3-local-sqlite/test/pluginPackageWorkflowAdmissionCrashMatrix.test.cjs', + ), + ]), + contract: Object.freeze({ + profiles: Object.freeze(['edge', 'standalone']), + crashPointsPerProfile: 8, + scenarios: 16, + mechanism: 'process_sigkill_then_database_reopen', + physicalPowerLossProven: false, + }), + }), + Object.freeze({ + name: 'local-workflow-control-crash-recovery', + format: 'node_test', + nodeArgs: Object.freeze([ + path.join( + root, + 'packages/ql3-local-sqlite/test/pluginPackageWorkflowTaskControlCrashMatrix.test.cjs', + ), + ]), + contract: Object.freeze({ + profiles: Object.freeze(['edge', 'standalone']), + crashPointsPerProfile: 8, + scenarios: 16, + conclusiveStopObserved: true, + physicalPowerLossProven: false, + }), + }), + ] + : []), ...(tierName === 'edge-release-ci' ? [ Object.freeze({ @@ -437,6 +475,42 @@ function createWorkloadPlans(root, tierName) { `--max-database-growth-bytes=${4 * MIB}`, ]), }), + Object.freeze({ + name: 'legacy-shadow-terminal-edge', + script: path.join( + root, + 'scripts/ql3-legacy-shadow-resource-rollback.cjs', + ), + args: Object.freeze([ + '--json', + '--require-compiled', + '--profile=edge', + `--mode=${tierName === 'edge-release-ci' ? 'full' : 'audit-only'}`, + `--samples=${tier.shadowAuditSamples}`, + `--max-audit-p95-ms=${tier.shadowAuditMaxP95Ms}`, + `--max-rss-delta-mb=${tier.shadowAuditMaxRssDeltaMb}`, + ]), + }), + ...(tierName === 'edge-release-ci' + ? [ + Object.freeze({ + name: 'legacy-shadow-terminal-standalone', + script: path.join( + root, + 'scripts/ql3-legacy-shadow-resource-rollback.cjs', + ), + args: Object.freeze([ + '--json', + '--require-compiled', + '--profile=standalone', + '--mode=audit-only', + `--samples=${tier.shadowAuditSamples}`, + `--max-audit-p95-ms=${tier.shadowAuditMaxP95Ms}`, + `--max-rss-delta-mb=${tier.shadowAuditMaxRssDeltaMb}`, + ]), + }), + ] + : []), ]); } return Object.freeze([ @@ -656,10 +730,11 @@ function main() { pidsMax: after.pidsMax, noNewPrivileges: after.noNewPrivileges, seccompMode: after.seccompMode, - rootReadOnly: after.mounts.get('/')?.includes('ro') ?? false, - workspaceReadOnly: - after.mounts.get('/workspace')?.includes('ro') ?? false, - tmpWritable: after.mounts.get('/tmp')?.includes('rw') ?? false, + rootReadOnly: + mountOptionsForPath(after.mounts, '/')?.includes('ro') ?? false, + workspaceReadOnly: isPathTreeReadOnly(after.mounts, '/workspace'), + tmpWritable: + mountOptionsForPath(after.mounts, '/tmp')?.includes('rw') ?? false, memoryEventsBefore: before.memoryEvents, memoryEventsAfter: after.memoryEvents, }, @@ -679,6 +754,8 @@ module.exports = { RESOURCE_TIERS, QingLong3LinuxResourceGateError, createWorkloadPlans, + isPathTreeReadOnly, + mountOptionsForPath, parseArguments, parseCpuMax, parseKeyValueFile, diff --git a/test/back/ql3LegacyShadowResourceRollback.test.cjs b/test/back/ql3LegacyShadowResourceRollback.test.cjs new file mode 100644 index 00000000..a9db7545 --- /dev/null +++ b/test/back/ql3LegacyShadowResourceRollback.test.cjs @@ -0,0 +1,207 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); +const { test } = require('node:test'); +const { + EXCLUSIONS, + MEASURES, + PROFILES, + parseArguments, + percentile, +} = require('../../scripts/ql3-legacy-shadow-resource-rollback.cjs'); + +const REPOSITORY_ROOT = path.resolve(__dirname, '../..'); +const SCRIPT = path.join( + REPOSITORY_ROOT, + 'scripts/ql3-legacy-shadow-resource-rollback.cjs', +); +const NODE_MAJOR = Number(process.versions.node.split('.')[0]); + +function execute(profile, mode, samples) { + const result = spawnSync( + process.execPath, + [ + SCRIPT, + `--profile=${profile}`, + `--mode=${mode}`, + `--samples=${samples}`, + '--max-audit-p95-ms=10000', + '--max-rss-delta-mb=512', + '--json', + ], + { + cwd: REPOSITORY_ROOT, + encoding: 'utf8', + timeout: 30_000, + maxBuffer: 128 * 1024, + }, + ); + assert.equal(result.status, 0, result.stderr || result.stdout); + return JSON.parse(result.stdout.trim().split('\n').at(-1)); +} + +test('normalizes only bounded resource and rollback gate options', () => { + const packageManifest = JSON.parse( + fs.readFileSync(path.join(REPOSITORY_ROOT, 'package.json'), 'utf8'), + ); + const buildSource = fs.readFileSync( + path.join(REPOSITORY_ROOT, 'scripts/ql3-build-back.cjs'), + 'utf8', + ); + assert.equal( + packageManifest.scripts['build:back'], + 'node scripts/ql3-build-back.cjs', + ); + assert.match(buildSource, /path\.join\(output, 'back'\)/); + assert.match(buildSource, /fs\.renameSync\(compiledBackend, TARGET\)/); + assert.match(buildSource, /rewriteSourceMaps\(maps\)/); + assert.doesNotMatch(buildSource, /shell:\s*true/); + assert.deepEqual( + parseArguments([ + '--profile=standalone', + '--mode=audit-only', + '--samples=3', + '--max-audit-p95-ms=2500', + '--max-rss-delta-mb=48', + '--require-compiled', + '--json', + ]), + { + profile: 'standalone', + mode: 'audit-only', + samples: 3, + maxAuditP95Ms: 2_500, + maxRssDeltaBytes: 48 * 1024 * 1024, + requireCompiled: true, + json: true, + }, + ); + assert.throws( + () => parseArguments(['--profile=cluster']), + /profile must be edge or standalone/, + ); + assert.throws( + () => parseArguments(['--mode=rollback-only']), + /mode must be audit-only or full/, + ); + assert.throws( + () => parseArguments(['--samples=33']), + /samples must be between 1 and 32/, + ); + assert.throws( + () => parseArguments(['--database=/private/data.sqlite']), + /unsupported argument/, + ); + assert.equal(PROFILES.edge.candidates, 8); + assert.equal(PROFILES.standalone.candidates, 128); + assert.deepEqual(MEASURES.slice(0, 4), [ + 'real_sequelize_sqlite_shadow_terminal_audit', + 'profile_maximum_closed_window_candidate_count', + 'bounded_candidate_and_evidence_query_count', + 'read_only_database_storage_stability', + ]); + assert.ok(EXCLUSIONS.includes('primary_execution_eligibility')); +}); + +test('calculates deterministic nearest-rank latency percentiles', () => { + assert.equal(percentile([9, 1, 7, 3], 0.5), 3); + assert.equal(percentile([9, 1, 7, 3], 0.95), 9); +}); + +test( + 'proves a real enabled-to-off process restart without stopping Legacy execution', + { skip: NODE_MAJOR < 24 ? 'resource evidence requires Node 24' : false }, + () => { + const report = execute('edge', 'full', 2); + + assert.equal(report.schemaVersion, 1); + assert.equal( + report.fixture, + 'qinglong/legacy-shadow-resource-rollback-evidence@v1', + ); + assert.equal(report.profile, 'edge'); + assert.deepEqual( + { + mode: report.workload.mode, + candidates: report.workload.candidates, + pageSize: report.workload.pageSize, + maxPages: report.workload.maxPages, + samples: report.workload.samples, + }, + { mode: 'full', candidates: 8, pageSize: 8, maxPages: 1, samples: 2 }, + ); + assert.ok( + ['compiled_backend', 'typescript_fallback'].includes( + report.workload.runtime, + ), + ); + assert.equal(report.audit.scanned, 16); + assert.equal(report.audit.queryCount, 4); + assert.equal(report.audit.expectedQueryCount, 4); + assert.equal(report.audit.readOnlyStorageStable, true); + assert.equal(report.rollback.performed, true); + assert.deepEqual(report.rollback.enabled.configuredOrigins, ['system']); + assert.equal(report.rollback.enabled.legacyExitCode, 0); + assert.equal(report.rollback.enabled.runDelta, 1); + assert.equal(report.rollback.enabled.defaultObserverLoaded, true); + assert.equal(report.rollback.enabled.repositoryLoaded, true); + assert.deepEqual(report.rollback.off.configuredOrigins, []); + assert.equal(report.rollback.off.legacyExitCode, 0); + assert.equal(report.rollback.off.runDelta, 0); + assert.equal(report.rollback.off.shortCircuitFactCalls, 0); + assert.equal(report.rollback.off.defaultObserverLoaded, false); + assert.equal(report.rollback.off.repositoryLoaded, false); + assert.equal(report.rollback.legacyContinued, true); + assert.equal(report.rollback.shadowWritesStopped, true); + assert.equal(report.rollback.databaseIntegrity, 'ok'); + assert.equal(report.rollback.physicalPowerLossProven, false); + assert.equal(report.qualification.passed, true); + assert.deepEqual(report.qualification.violations, []); + assert.deepEqual(report.qualification.measures, MEASURES); + const serialized = JSON.stringify(report); + for (const forbidden of [ + 'database.sqlite', + 'opaque-resource-rollback', + 'process.exit', + 'legacy-resource:', + 'resource/0.log', + ]) { + assert.equal(serialized.includes(forbidden), false); + } + }, +); + +test( + 'exercises the full Standalone page budget without claiming rollback twice', + { skip: NODE_MAJOR < 24 ? 'resource evidence requires Node 24' : false }, + () => { + const report = execute('standalone', 'audit-only', 1); + + assert.deepEqual(report.workload, { + mode: 'audit-only', + runtime: report.workload.runtime, + candidates: 128, + pageSize: 32, + maxPages: 4, + samples: 1, + }); + assert.equal(report.audit.pages, 4); + assert.equal(report.audit.scanned, 128); + assert.equal(report.audit.queryCount, 8); + assert.equal(report.audit.expectedQueryCount, 8); + assert.equal(report.audit.readOnlyStorageStable, true); + assert.deepEqual(report.rollback, { + performed: false, + reason: 'separate_release_gate', + finalRunCount: 128, + databaseIntegrity: 'ok', + }); + assert.ok( + report.qualification.doesNotProve.includes( + 'shadow_off_process_restart_rollback', + ), + ); + assert.equal(report.qualification.passed, true); + }, +); diff --git a/test/back/ql3LinuxResourceGate.test.cjs b/test/back/ql3LinuxResourceGate.test.cjs index b1592087..3ac1f461 100644 --- a/test/back/ql3LinuxResourceGate.test.cjs +++ b/test/back/ql3LinuxResourceGate.test.cjs @@ -3,6 +3,8 @@ const { test } = require('node:test'); const { RESOURCE_TIERS, createWorkloadPlans, + isPathTreeReadOnly, + mountOptionsForPath, parseArguments, parseCpuMax, parseKeyValueFile, @@ -69,6 +71,11 @@ test('parses bounded cgroup v2 and mount evidence', () => { assert.deepEqual(mounts.get('/'), ['ro', 'relatime']); assert.deepEqual(mounts.get('/tmp'), ['rw', 'nosuid', 'nodev']); assert.deepEqual(mounts.get('/workspace'), ['ro', 'nodev']); + assert.deepEqual(mountOptionsForPath(mounts, '/workspace/cache'), [ + 'ro', + 'nodev', + ]); + assert.equal(isPathTreeReadOnly(mounts, '/workspace'), true); }); test('fails open hosts, root execution and a widened resource envelope', () => { @@ -81,6 +88,31 @@ test('fails open hosts, root execution and a widened resource envelope', () => { }), [], ); + const inheritedWorkspace = { + ...valid, + mounts: new Map([ + ['/', ['ro']], + ['/tmp', ['rw']], + ]), + }; + assert.deepEqual( + validateEnvelope('router-stress-ci', inheritedWorkspace, { + platform: 'linux', + architecture: 'arm64', + uid: 65532, + }), + [], + ); + assert.equal( + isPathTreeReadOnly( + new Map([ + ['/', ['ro']], + ['/workspace/cache', ['rw']], + ]), + '/workspace', + ), + false, + ); const widened = { ...valid, memoryMaxBytes: 256 * 1024 * 1024, @@ -115,23 +147,32 @@ test('builds tier-specific workload plans without shell commands', () => { 'node-sqlite', 'local-workflow-product', 'local-workflow-sqlite-lock', - 'local-workflow-admission-crash-recovery', - 'local-workflow-control-crash-recovery', 'plugin-package-failed-upgrade', + 'legacy-shadow-terminal-edge', ], ); assert.match(edge[0].script, /ql3-edge-benchmark\.cjs$/); assert.ok(edge[0].args.includes('--max-rss-delta-mb=64')); - assert.equal(edge[2].format, 'node_test'); + const edgeShadow = edge.find( + ({ name }) => name === 'legacy-shadow-terminal-edge', + ); + assert.match(edgeShadow.script, /ql3-legacy-shadow-resource-rollback\.cjs$/); + assert.ok(edgeShadow.args.includes('--profile=edge')); + assert.ok(edgeShadow.args.includes('--mode=audit-only')); + assert.ok(edgeShadow.args.includes('--samples=8')); + const edgeWorkflow = edge.find( + ({ name }) => name === 'local-workflow-product', + ); + assert.equal(edgeWorkflow.format, 'node_test'); assert.ok( - edge[2].nodeArgs.some((argument) => + edgeWorkflow.nodeArgs.some((argument) => /ql3-local-application\/test\/activation\.test\.cjs$/.test(argument), ), ); - assert.equal(edge[2].maxProcessRssBytes, 96 * 1024 * 1024); - assert.equal(edge[2].contract, undefined); + assert.equal(edgeWorkflow.maxProcessRssBytes, 96 * 1024 * 1024); + assert.equal(edgeWorkflow.contract, undefined); assert.equal( - edge[2].nodeArgs.includes( + edgeWorkflow.nodeArgs.includes( '--test-name-pattern=executes one admitted Workflow', ), true, @@ -151,15 +192,20 @@ test('builds tier-specific workload plans without shell commands', () => { 'local-ai-prompt-model-invocation-crash-recovery', 'local-ai-prompt-outer-transaction-crash-recovery', 'plugin-package-failed-upgrade', + 'legacy-shadow-terminal-edge', + 'legacy-shadow-terminal-standalone', ], ); + const releaseWorkflow = edgeRelease.find( + ({ name }) => name === 'local-workflow-product', + ); assert.equal( - edgeRelease[2].nodeArgs.includes( + releaseWorkflow.nodeArgs.includes( '--test-name-pattern=executes one admitted Workflow|stops one running Workflow Task', ), true, ); - assert.deepEqual(edgeRelease[2].contract, { + assert.deepEqual(releaseWorkflow.contract, { kind: 'local_workflow_product_lifecycle', profile: 'edge', completedWorkflowSteps: 2, @@ -175,51 +221,99 @@ test('builds tier-specific workload plans without shell commands', () => { cancelAudits: 1, physicalPowerLossProven: false, }); - assert.equal(edgeRelease[3].maxProcessRssBytes, 192 * 1024 * 1024); - assert.deepEqual(edgeRelease[3].env, { + const releaseEdgeShadow = edgeRelease.find( + ({ name }) => name === 'legacy-shadow-terminal-edge', + ); + const releaseStandaloneShadow = edgeRelease.find( + ({ name }) => name === 'legacy-shadow-terminal-standalone', + ); + assert.ok(releaseEdgeShadow.args.includes('--mode=full')); + assert.ok(releaseStandaloneShadow.args.includes('--profile=standalone')); + assert.ok(releaseStandaloneShadow.args.includes('--mode=audit-only')); + const edgePrompt = edgeRelease.find( + ({ name }) => name === 'local-ai-prompt-durable-output-edge', + ); + const standalonePrompt = edgeRelease.find( + ({ name }) => name === 'local-ai-prompt-durable-output-standalone', + ); + assert.equal(edgePrompt.maxProcessRssBytes, 192 * 1024 * 1024); + assert.deepEqual(edgePrompt.env, { QL3_PROMPT_RESOURCE_PROFILE: 'edge', QL3_PROMPT_RESOURCE_OUTPUT_BYTES: String(512 * 1024), }); - assert.equal(edgeRelease[3].contract.providerCalls, 2); - assert.equal(edgeRelease[3].contract.exactReplay, true); - assert.equal(edgeRelease[3].contract.contentFree, true); - assert.equal(edgeRelease[3].contract.durableOutputBytes, 512 * 1024); - assert.equal(edgeRelease[3].contract.maxWalWriteAmplificationPermille, 0); - assert.equal(edgeRelease[4].contract.profile, 'standalone'); - assert.equal(edgeRelease[4].contract.journalMode, 'wal'); - assert.equal(edgeRelease[4].contract.requireWalGrowth, true); - assert.match(edge[3].script, /ql3-local-workflow-resource-benchmark\.cjs$/); - assert.ok(edge[3].args.includes('--lock-samples=16')); - assert.ok(edge[3].args.includes('--max-lock-p95-ms=500')); - assert.equal(edge[4].contract.scenarios, 16); - assert.equal(edge[4].contract.physicalPowerLossProven, false); - assert.equal(edge[5].contract.scenarios, 16); - assert.equal(edge[5].contract.conclusiveStopObserved, true); - assert.equal(edge[5].contract.physicalPowerLossProven, false); - assert.equal(edgeRelease[8].contract.scenarios, 14); - assert.deepEqual(edgeRelease[8].contract.boundaries, [ + assert.equal(edgePrompt.contract.providerCalls, 2); + assert.equal(edgePrompt.contract.keyLoads, 1); + assert.equal(edgePrompt.contract.keyResolutions, 2); + assert.equal(edgePrompt.contract.exactReplay, true); + assert.equal(edgePrompt.contract.contentFree, true); + assert.equal(edgePrompt.contract.durableOutputBytes, 512 * 1024); + assert.equal(edgePrompt.contract.maxWalWriteAmplificationPermille, 0); + assert.equal(standalonePrompt.contract.profile, 'standalone'); + assert.equal(standalonePrompt.contract.journalMode, 'wal'); + assert.equal(standalonePrompt.contract.requireWalGrowth, true); + const workflowLock = edge.find( + ({ name }) => name === 'local-workflow-sqlite-lock', + ); + assert.match( + workflowLock.script, + /ql3-local-workflow-resource-benchmark\.cjs$/, + ); + assert.ok(workflowLock.args.includes('--lock-samples=16')); + assert.ok(workflowLock.args.includes('--max-lock-p95-ms=500')); + const releaseAdmissionCrash = edgeRelease.find( + ({ name }) => name === 'local-workflow-admission-crash-recovery', + ); + const releaseControlCrash = edgeRelease.find( + ({ name }) => name === 'local-workflow-control-crash-recovery', + ); + assert.equal(releaseAdmissionCrash.env, undefined); + assert.deepEqual(releaseAdmissionCrash.contract.profiles, [ + 'edge', + 'standalone', + ]); + assert.equal(releaseAdmissionCrash.contract.scenarios, 16); + assert.equal(releaseControlCrash.env, undefined); + assert.deepEqual(releaseControlCrash.contract.profiles, [ + 'edge', + 'standalone', + ]); + assert.equal(releaseControlCrash.contract.scenarios, 16); + const modelCrash = edgeRelease.find( + ({ name }) => name === 'local-ai-prompt-model-invocation-crash-recovery', + ); + const transactionCrash = edgeRelease.find( + ({ name }) => name === 'local-ai-prompt-outer-transaction-crash-recovery', + ); + assert.equal(modelCrash.contract.scenarios, 14); + assert.deepEqual(modelCrash.contract.boundaries, [ 'model_start', 'model_completion', ]); - assert.equal(edgeRelease[8].contract.physicalPowerLossProven, false); - assert.equal(edgeRelease[9].contract.scenarios, 20); - assert.deepEqual(edgeRelease[9].contract.operations, [ + assert.equal(modelCrash.contract.physicalPowerLossProven, false); + assert.equal(transactionCrash.contract.scenarios, 20); + assert.deepEqual(transactionCrash.contract.operations, [ 'admission', 'finalization', ]); - assert.equal(edgeRelease[9].contract.exactReplay, true); - assert.equal(edgeRelease[9].contract.contentFree, true); + assert.equal(transactionCrash.contract.exactReplay, true); + assert.equal(transactionCrash.contract.contentFree, true); assert.equal( - edgeRelease[9].contract.promptAdmissionFinalizationCrashProven, + transactionCrash.contract.promptAdmissionFinalizationCrashProven, true, ); - assert.equal(edgeRelease[9].contract.physicalPowerLossProven, false); + assert.equal(transactionCrash.contract.physicalPowerLossProven, false); + const edgeUpgrade = edge.find( + ({ name }) => name === 'plugin-package-failed-upgrade', + ); + const releaseUpgrade = edgeRelease.find( + ({ name }) => name === 'plugin-package-failed-upgrade', + ); assert.match( - edge.at(-1).script, + edgeUpgrade.script, /ql3-plugin-package-recovery-edge-benchmark\.cjs$/, ); - assert.ok(edge.at(-1).args.includes('--max-rss-delta-mb=64')); - assert.ok(edgeRelease.at(-1).args.includes('--max-rss-delta-mb=96')); + assert.ok(edgeUpgrade.args.includes('--max-rss-delta-mb=64')); + assert.ok(releaseUpgrade.args.includes('--max-rss-delta-mb=96')); const cluster = createWorkloadPlans('/workspace', 'cluster-control-ci'); assert.deepEqual( cluster.map(({ name }) => name), @@ -256,7 +350,7 @@ test('fails closed when durable Prompt resource evidence drifts', () => { durableOutputBytes: 512 * 1024, providerCalls: 2, keyLoads: 1, - keyResolutions: 1, + keyResolutions: 2, liveOnlyKeyLoads: 0, exactReplay: true, contentFree: true,