diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 4de73836..24f06a08 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -1,5 +1,7 @@ # QingLong 3.0 Architecture RFC +- D-423/ADR-0518(已实现源码候选,待远程阶段实物验收):Local Console 复用既有 immutable Trigger revision、固定 Task revision/content digest、semantic cron validation、durable schedule cursor 与原子 Run admission,新增有界 `GET /api/v3/projects/:projectId/triggers[/:triggerId]` 和强认证 `PUT /api/v3/projects/:projectId/triggers/:triggerId`。列表只返回低敏摘要,精确读取才返回完整 spec/Task digest;读取走 `task.read`,mutation 要求 User credential、`task.update`、两分钟一次性 owner-private exact-content proof、credential reconfirm,并由 request-scoped SQLite Trigger repository 在同一事务重验 Policy/RoleBinding/Task pin/credential fence、追加 immutable revision、初始化或更新 schedule 与 durable audit。Console 仅支持冻结的 `qinglong/cron@v1` expression/timezone/`skip|fire_once`,编辑、启停均追加 revision,不提供删除或通用 provider 编辑。Fresh/adopted Profile 共用现有 database close fence;不新增 package、migration、connection、daemon、watcher、每 Trigger timer 或第二 scheduler。默认 headless 不携带 API/Console 资产与 listener,Cluster 不复用 Local POSIX proof/SQLite authority。18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;package boundary、122-module Edge import 与精确 Cluster dependency audit compatible。默认 Edge 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 4,150,439/4,150,583 bytes、479 files/101 modules,三资产 84,401 bytes。该结论目前只证明可进入远程验收;最新可下载实物仍是 D-422,必须等 D-423 exact commit 的 CI、Kubernetes、双架构 Console milestone 与下载离线复核闭合后再升级。 + - RFC ID: QL-RFC-0001 - 标题:QingLong 3.0 运行时、工作流与 AI 自动化架构 - 状态:Draft diff --git a/docs/adr/ADR-0518-local-console-cron-trigger-administration.md b/docs/adr/ADR-0518-local-console-cron-trigger-administration.md new file mode 100644 index 00000000..6470ec8d --- /dev/null +++ b/docs/adr/ADR-0518-local-console-cron-trigger-administration.md @@ -0,0 +1,71 @@ +# ADR-0518:Local Console cron Trigger 管理 + +- 状态:Accepted +- 日期:2026-08-29 +- 对应 RFC 切片:D-423 +- 关联:ADR-0094、ADR-0103、ADR-0512、ADR-0516、ADR-0517 + +## 背景 + +Local Console 已能创建、无损编辑和手动运行内建 command Task,但部署者仍需转到受信 CLI 才能把 Task 变成周期自动化。QingLong 3.0 已有 immutable Trigger revision、固定 Task revision/content digest、语义化 cron 校验、durable schedule cursor 和原子 Run admission;缺口是一个不复制这些 authority、适合低配单机的安全产品入口。 + +本切片只开放既有本机 Trigger authority。它不能把 Local POSIX presence proof 搬到 Cluster,也不能为每个 Trigger 新建内存 timer,或让默认 headless 设备承担 Console 资产和 HTTP listener。 + +## 决策 + +### 1. Local API 暴露有界 Trigger 读取与强认证 mutation + +新增固定路由: + +```text +GET /api/v3/projects/:projectId/triggers +GET /api/v3/projects/:projectId/triggers/:triggerId +PUT /api/v3/projects/:projectId/triggers/:triggerId +``` + +列表按 `triggerId` 使用稳定 keyset,Edge 默认 16 条、Standalone 默认 32 条、硬上限 64 条。列表只返回 identity、revision、Task pin、schema、enabled、content digest 和时间戳;完整 cron spec 与 Task content digest 只在精确详情读取中返回。读取复用 `task.read` Policy、credential reconfirm 与 durable audit,存储损坏或不可用时失败关闭。 + +PUT 的 body 必须精确包含 `expectedRevision`、`mutationId`、Task ID/revision/content digest、spec、enabled 与发生时间。请求只接受 User credential,并要求 `task.update` Policy;服务为 canonical exact body 签发两分钟、一次性的 owner-private presence challenge,proof 消费后才形成 `local_console` principal。事务前再次确认 exact credential,随后由既有 Trigger administration service 和 request-scoped SQLite repository 重验 Project/RoleBinding/credential/Policy fence,原子写 immutable revision、head、schedule cursor 与 durable audit。 + +### 2. Trigger 始终固定当前 Task 内容 + +Console 在创建或更新 Trigger 前精确读取关联 Task,提交其当前 revision 与 content digest。既有 repository 在事务内重新验证 pin;Task 已变化时拒绝写入,不猜测重绑。 + +创建使用 `expectedRevision=null`,更新使用当前 revision。Trigger ID 与 Task ID 创建后不能改绑;启用、停用、cron/timezone/misfire 修改都追加新 revision。当前阶段不提供删除,停用是可审计、可恢复的替代操作。 + +### 3. 首个 UI 只支持冻结的内建 cron schema + +Console 的“定时”工作区支持创建、查看、编辑、启用和停用 `qinglong/cron@v1`,字段为 cron expression、显式 timezone 与 `skip|fire_once` misfire policy。它不实现通用 provider/schema 编辑器,不接受浏览器自行扩展未知 Trigger kind。 + +Credential、presence proof 与编辑快照仍只保存在页面内存,不进入 Cookie、Web Storage、URL、日志或遥测。成功写入后由现有 durable Scheduler 在既有单一有界 lifecycle 中发现;Console 不轮询、不注册每 Trigger callback,也不新增 daemon、watcher、timer 或数据库连接。 + +### 4. Fresh 与 adopted Profile 使用同一窄 authority + +Fresh SQLite runtime 和 adopted Profile 都在现有数据库 close fence 上暴露 `TriggerSource` 与 `triggerAdministrationForCredential`。每次 mutation 取得绑定当前 credential fence 的 request-scoped repository;常驻 Application 不持有一个可绕过认证的裸 append authority。 + +`@qinglong/local-api` 只有 `triggerPutRoute.ts` 被 package dependency audit 精确允许导入 `@qinglong/local-admin/trigger-administration`。同目录或其他文件不能借此扩大依赖;Cluster dependency audit 继续拒绝 Local SQLite/POSIX authority 越界。 + +### 5. 部署档位保持分层 + +- 默认 Edge/Standalone headless 不包含 Local API、Console 资产或 listener;SQLite runtime 只增加复用既有 Trigger mutation 的窄装配代码。 +- opt-in `edge-application-api` 与 `standalone-application-api` 承担同源 Console/API 增量,仍只有一个 Application 进程、一个 SQLite connection 和现有 Scheduler lifecycle。 +- Cluster 不复用 Local proof、SQLite repository 或 Local admin service;后续 Cluster Console mutation 必须走 TLS、共享 PostgreSQL authority、RBAC/Approval 与 HA fence。 + +## 不采用的方案 + +- 不在浏览器或 HTTP 层实现第二套 cron scheduler:调度事实必须留在 durable Trigger/schedule authority。 +- 不允许 Bearer 单因子直接写 Trigger:周期自动化会持续产生执行,必须保留本机 presence proof。 +- 不让列表返回完整 spec 或 Task digest:列表应是低敏、有界的浏览面。 +- 不通过删除实现“关闭定时”:不可变 revision 与停用记录更可审计。 +- 不新建 `ql3-local-trigger-api` 微包:现有 Local API capability 内的两个路由文件不足以形成独立部署/依赖边界。 +- 不为路由器注册每 Trigger timer:现有有界 durable scheduler 已覆盖该职责。 + +## 结果与验证边界 + +定向测试覆盖列表投影不泄漏 spec/digest、精确详情、坏存储失败关闭、presence challenge、exact body/credential fence、Policy/audit、Task pin、创建/更新冲突与 proof 漂移。真实 SQLite/loopback 旅程已完成 Task 创建/更新 → Trigger challenge/proof/create → bounded list → exact read → 新 revision disable,并验证 create/get/list/update audit。 + +本地 18-package clean build/test 为 `3,044 total / 3,022 pass / 22 conditional、platform 或 external-service skip / 0 fail`,其中 Local API `70/70`、Local SQLite `248/248`、Local Admin `96/96`,Local Application `55 total / 51 pass / 4 platform skip / 0 fail`;完整 backend 为 `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`。package boundary 保持 18 packages、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,122-module Edge import 与精确 Cluster dependency audit 均 compatible。 + +三项 Console 静态资产合计 84,401 bytes,仍低于 192 KiB 总闭包与 96 KiB 单文件门。默认 Edge 为 2,754,742 bytes/331 files/3 packages/58 modules,RSS delta 11,108,352 bytes;opt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/12 packages/101 modules,RSS delta 17,088,512/17,055,744 bytes,均低于既有门。 + +这些结果证明 D-423 是可进入远程验收的源码候选,不自动等于新的下载产物。当前最新可下载实物仍是 D-422 绑定的 Console v5 milestone;只有 D-423 exact commit 的普通 CI、Kubernetes gate 与显式双架构 Local Console milestone 全部成功,且下载索引通过 checksum/离线 auditor 后,才能把本 ADR 的阶段状态升级为“已交付实物”。 diff --git a/docs/adr/README.md b/docs/adr/README.md index f91fba58..0ae255ed 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -520,7 +520,8 @@ | [ADR-0514](./ADR-0514-stage-usable-first-automation-journey.md) | 阶段可用的首个自动化旅程 | Accepted(Console v5 双架构实物已由 D-420 闭合) | | [ADR-0515](./ADR-0515-bounded-local-console-run-log.md) | Local Console 的有界 Run 日志观察面 | Accepted(首份实际 Console v5 双架构 milestone 已交付) | | [ADR-0516](./ADR-0516-request-scoped-local-console-task-mutation.md) | request-scoped Local Console Task mutation | Accepted(新双架构 Trial Kit 待本阶段 milestone) | -| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(源码候选,待 CI 与新双架构 milestone) | +| [ADR-0517](./ADR-0517-strong-local-console-task-authoring-lease.md) | 强认证 Local Console Task authoring lease | Accepted(D-422 双架构 milestone 已交付) | +| [ADR-0518](./ADR-0518-local-console-cron-trigger-administration.md) | Local Console cron Trigger 管理 | Accepted(源码候选,待 CI 与新双架构 milestone) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 75442f2a..3132fb06 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -27,11 +27,14 @@ | D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 | | D-421 Console Task 创建切片 | request-scoped credential fence、两分钟一次性本机 proof、同事务 Policy/Audit/Task mutation 已完成;Console 可创建 command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Web update 等待 authoring read/lease;Cluster 不复用 Local proof;仍不是生产或公开发布 | | D-422 Console Task 安全编辑切片 | 强认证完整定义读取、10 分钟一次性 authoring lease、第二份 exact save proof 与 revision/content/credential fence 已完成;Console 可无损编辑内建 argv command Task;同源双架构 Console v5 Trial Kit 与 milestone 已生成并验真 | Cluster 不复用 Local proof;尚无通用 workflow 编辑器、2.x 升级或生产远程管理;仍不是正式发布 | +| D-423 Console cron Trigger 管理切片 | 源码候选已复用既有 immutable Trigger、Task pin、durable schedule 与原子 audit authority;Console/API 可 list/read/create/update/enable/disable `qinglong/cron@v1`,真实 SQLite/loopback 已通过 | 尚未绑定新的远程 CI 与双架构 milestone;当前下载物仍是 D-422,Cluster 不复用 Local proof;不提供删除或通用 Trigger schema 编辑 | D-421 已关闭 D-420 记录的“Web Task mutation 必须独立设计”缺口,而且没有改名复用 run `33173769047` 的旧 archive。修复提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的普通主 CI [run 33229592307](https://github.com/whyour/qinglong/actions/runs/33229592307) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33229592293](https://github.com/whyour/qinglong/actions/runs/33229592293) 成功;随后显式 Local Console milestone [run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 为 42 success/2 scope skip/0 fail。由此 Web 创建能力已进入新的阶段实物,而不再只是候选源码。 D-422 已从“源码候选”升级为阶段实物:本地真实 SQLite/loopback 已证明读取、租约、第二次 proof、更新与新围栏启动闭环;18-package clean build/test 为 `3,038 total / 3,016 pass / 22 conditional skip / 0 fail`,Local API `64/64`,默认 Edge 与 opt-in Edge/Standalone Console 资源门及真实 Chromium 双证明编辑均通过。修复提交 `f28bf74d1bd29e9b8a8727915de19509f4bda9cf` 的普通主 CI [run 33236204273](https://github.com/whyour/qinglong/actions/runs/33236204273) 为 41 success/3 expected artifact-finalizer skip/0 fail,同源 Kubernetes deployment [run 33236204254](https://github.com/whyour/qinglong/actions/runs/33236204254) 为 1/1;随后显式 Local Console milestone [run 33237026187](https://github.com/whyour/qinglong/actions/runs/33237026187) 为 42 success/2 scope skip/0 fail。阶段产物绑定新的 exact commit/run/artifact digest,没有沿用或改名复用 D-421 archive。 +D-423 当前是已通过本地门禁的源码候选,不冒充阶段实物:18-package clean build/test `3,044 total / 3,022 pass / 22 conditional skip / 0 fail`,完整 backend `1,653 total / 1,651 pass / 2 Linux conditional skip / 0 fail`,Local API `70/70`;18-package boundary、122-module Edge import 与 Cluster dependency audit 均 compatible。默认 headless Edge 为 2,754,742 bytes/331 files/58 modules,opt-in Edge/Standalone Console 为 4,150,439/4,150,583 bytes、479 files/101 modules,三资产合计 84,401 bytes。只有 D-423 exact commit 的完整 CI/Kubernetes 与显式 Console milestone 成功并完成下载复核后,下面的“当前最新可交付”才允许替换为 D-423;在此之前部署者应继续下载 D-422。 + D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。 当前最新可交付 Local Console v5 绑定提交 `f28bf74d1bd29e9b8a8727915de19509f4bda9cf` 与 [GitHub Actions run 33237026187](https://github.com/whyour/qinglong/actions/runs/33237026187),保留至 2026-09-28: diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md index 29190bbf..9e46a873 100644 --- a/docs/operations/ql3-local-web-console.md +++ b/docs/operations/ql3-local-web-console.md @@ -1,24 +1,24 @@ # QingLong 3.0 Local Web Console -Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建 command Task、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 +Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来创建和编辑 command Task、配置 cron Trigger、查看 Task/Run/执行事件,并显式启动或取消一次运行。它由 Console Local Alpha Trial Kit 交付,但不进入默认 headless 变体,也不是 2.x Web UI 的完整替代品。 ## 选择部署档位 | 场景 | 建议 | | --- | --- | -| 内存很小、只需后台自动化的路由/NAS | 继续使用默认 `edge` headless Application;Console 零增量 | +| 内存很小、只需后台自动化的路由/NAS | 继续使用默认 `edge` headless Application;不携带 Console/API 资产与 listener | | 路由/NAS 需要临时人工查看与操作 | 选择 `edge-application-api`,只通过 loopback 或 SSH tunnel 访问 | | 普通单节点服务器 | 选择 `standalone-application-api` | | Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 | -D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421 增加 request-scoped strong-auth Task PUT 与 Console command Task 创建器;它不复用 CLI 的进程级 active credential,也不让单因子 Bearer 直接写 Task。绑定提交 `dc1686bd6fb3505174dd9a14098ae5c2c92a1a7f` 的双架构实物已由 [milestone run 33230227006](https://github.com/whyour/qinglong/actions/runs/33230227006) 生成;普通 push 的源码和 CI 仍不是公开下载物。 +D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console。D-421/D-422 依次增加 request-scoped strong-auth Task 创建与双 proof 无损编辑;绑定提交 `f28bf74d1bd29e9b8a8727915de19509f4bda9cf` 的最新双架构实物已由 [milestone run 33237026187](https://github.com/whyour/qinglong/actions/runs/33237026187) 生成。D-423 源码候选继续开放既有 immutable Trigger/cron authority;在新的 exact-commit milestone 成功前,当前下载物仍不包含该能力。 ## 前置条件 - 已完成 Local fresh setup,并有受支持的 Application config; - Owner pepper keyring 与 SQLite active pepper 一致; - 已通过 [`ql3-identity`](./ql3-local-identity-credential.md) 为 active Identity 签发 API credential; -- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、启动和取消分别还需要 `task.create`、`run.start` 与 `run.stop`; +- credential 对目标 Project 至少有读取 Task/Run 的权限;创建 Task、修改 Task/Trigger、启动和取消分别还需要 `task.create`、`task.update`、`run.start` 与 `run.stop`; - config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。 ## 启动 @@ -57,22 +57,25 @@ ssh -L 5701:127.0.0.1:5701 router.example 2. 选择“创建任务”,填写 Task ID、名称、argv 可执行文件和逐行参数,再选择“保存并生成本机证明”。 3. 在部署设备上以 QingLong 数据目录 owner 读取 `/console-presence/<页面显示的 basename>`;把 JSON 的完整 `ql3p_…` proof 值粘贴回页面。文件为 `0600`、两分钟有效且只能用于这份 exact 操作一次。不要通过聊天、日志或 URL 转发 proof。 4. 编辑现有内建 command Task 时先选择“编辑任务”,完成第一次本机证明以读取完整定义并取得 10 分钟一次性编辑租约。保存新内容时页面会要求第二份 proof;第一份只授权读取,不能复用来保存。Task ID 只读,未展示的 command config 与 labels 会原样保留。 -5. 创建或更新成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`。 -6. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 -7. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 -8. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 -9. 完成后选择“断开并清除凭据”,再关闭页面。 +5. 需要周期运行时进入“定时”,选择“新建定时”,填写 Trigger ID、已存在的 Task ID、cron expression、显式 timezone、`skip|fire_once` misfire policy 与 enabled。页面会先读取当前 Task revision/content digest,再要求一份绑定这次 exact Trigger 内容的本机 proof。 +6. 编辑、启用或停用 Trigger 都会追加 immutable revision;当前阶段没有删除。Task 已被其他操作更新时,旧 pin 会失败关闭,应刷新后重新确认,不能猜测重绑。 +7. 创建或更新 Task 成功后核对 revision/content fence,再选择“运行一次”。fresh Console Trial Kit 也可直接使用 `alpha-first-automation`;enabled Trigger 则由已有 durable Scheduler 自动产生 Run,不依赖浏览器保持打开。 +8. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。 +9. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。 +10. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 +11. 完成后选择“断开并清除凭据”,再关闭页面。 Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 ## 当前阶段可用边界 -当前可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start 与 Run list/read/events/steps/log/cancel。编辑器只修改当前展示字段,并保留完整快照中的其他 config/labels;其他 kind/schema 继续使用受信管理入口。页面暂不负责: +D-423 源码候选的可操作闭环是内建 argv command Task create/list/read/update/enable/disable/start、`qinglong/cron@v1` Trigger list/read/create/update/enable/disable,以及 Run list/read/events/steps/log/cancel。Task 编辑器只修改当前展示字段并保留完整快照中的其他 config/labels;其他 Task kind 或 Trigger schema 继续使用受信管理入口。页面暂不负责: - Identity、Policy、Secret、Plugin Package 或 AI 配置管理; +- Trigger 删除、通用 Trigger provider/schema 编辑或 Cluster Trigger 管理; - 日志整文件下载、终端、文件管理或 2.x 数据迁移; - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 -三项静态资产总计 69,723 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,077,890 / 4,078,034 bytes、473 files、12 packages、95 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 16,269,312 / 16,318,464 bytes,低于 28 MiB。基础 headless Edge 保持 2,737,205 bytes、329 files、3 packages、58 modules,不携带这些资产。 +三项静态资产总计 84,401 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 4,150,439 / 4,150,583 bytes、479 files、12 packages、101 loaded modules,仍低于 6 MiB/640-file 门;本机 RSS delta 为 17,088,512 / 17,055,744 bytes,低于 28 MiB。默认 headless Edge 为 2,754,742 bytes、331 files、3 packages、58 modules,RSS delta 11,108,352 bytes;它不携带 Console/API 资产、listener 或第二个 scheduler,但包含复用既有 SQLite Trigger mutation authority 的小幅装配代码增量。 停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。 diff --git a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts index 38250562..ed3dac47 100644 --- a/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts +++ b/packages/ql3-local-admin/src/adopted-profile/localAdoptedProfile.ts @@ -19,6 +19,9 @@ type LocalSqliteTaskStartRepository = ReadyLocalStorage['taskStartRepository']; type TaskDefinitionRepository = ReadyLocalStorage['taskDefinitions']; type TaskDefinitionAdministrationForCredential = ReadyLocalStorage['taskDefinitionAdministrationForCredential']; +type TriggerRepository = ReadyLocalStorage['triggers']; +type TriggerAdministrationForCredential = + ReadyLocalStorage['triggerAdministrationForCredential']; type LocalScheduleStore = ReadyLocalStorage['schedules']; type LocalDispatchStore = ReadyLocalStorage['dispatch']; type LocalSecretEnvelopeRepository = ReadyLocalStorage['localSecrets']; @@ -90,6 +93,8 @@ export type LocalAdoptedProfileBootstrapResult = readonly taskStartRepository: LocalSqliteTaskStartRepository; readonly taskDefinitions: TaskDefinitionRepository; readonly taskDefinitionAdministrationForCredential: TaskDefinitionAdministrationForCredential; + readonly triggers: TriggerRepository; + readonly triggerAdministrationForCredential: TriggerAdministrationForCredential; readonly schedules: LocalScheduleStore; readonly dispatch: LocalDispatchStore; readonly executionControl: ReadyLocalStorage['executionControl']; @@ -240,6 +245,9 @@ export async function bootstrapLocalAdoptedProfileStorage( taskDefinitions: readyStorage.taskDefinitions, taskDefinitionAdministrationForCredential: readyStorage.taskDefinitionAdministrationForCredential, + triggers: readyStorage.triggers, + triggerAdministrationForCredential: + readyStorage.triggerAdministrationForCredential, schedules: readyStorage.schedules, dispatch: readyStorage.dispatch, executionControl: readyStorage.executionControl, diff --git a/packages/ql3-local-api/assets/console/console.css b/packages/ql3-local-api/assets/console/console.css index 6c49fc58..2a961102 100644 --- a/packages/ql3-local-api/assets/console/console.css +++ b/packages/ql3-local-api/assets/console/console.css @@ -784,6 +784,7 @@ textarea:focus-visible, .editor-grid input:not([type='checkbox']), .editor-grid textarea, +.editor-grid select, .presence-input input { width: 100%; padding: 11px 12px; diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js index 2234b3c3..75a278d0 100644 --- a/packages/ql3-local-api/assets/console/console.js +++ b/packages/ql3-local-api/assets/console/console.js @@ -3,6 +3,7 @@ const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TASK_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + const CRON_FIELD_PATTERN = /^[0-9A-Za-z*?,/#LW-]+$/; const TOKEN_PATTERN = /^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/; const PRESENCE_PATTERN = @@ -51,6 +52,11 @@ '暂时无法建立安全编辑会话。请稍后重新读取 Task。', invalid_task_definition: 'Task 定义无效。请检查 ID、命令与参数。', task_definition_unavailable: 'Task 暂时无法保存。请检查数据库状态。', + trigger_query_unavailable: '定时触发器暂时不可读取。请检查数据库状态。', + trigger_fence_rejected: + 'Trigger、Task 或授权在确认期间发生变化。请刷新后重新编辑。', + invalid_trigger: '定时配置无效。请检查表达式、时区与 Task 状态。', + trigger_unavailable: '定时配置暂时无法保存。请检查数据库状态。', run_cancellation_fence_rejected: '运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。', request_unavailable: '本次请求没有完成,请确认服务仍在运行。', @@ -71,6 +77,7 @@ description: document.getElementById('section-description'), refresh: document.getElementById('refresh-button'), createTask: document.getElementById('create-task-button'), + createTrigger: document.getElementById('create-trigger-button'), dialog: document.getElementById('confirmation-dialog'), dialogTitle: document.getElementById('confirmation-title'), dialogCopy: document.getElementById('confirmation-copy'), @@ -89,6 +96,18 @@ taskArgs: document.getElementById('task-args-input'), taskEnabled: document.getElementById('task-enabled-input'), taskEnabledLabel: document.getElementById('task-enabled-label'), + triggerEditor: document.getElementById('trigger-editor-dialog'), + triggerEditorTitle: document.getElementById('trigger-editor-title'), + triggerEditorIntro: document.getElementById('trigger-editor-intro'), + triggerEditorForm: document.getElementById('trigger-editor-form'), + triggerEditorClose: document.getElementById('trigger-editor-close'), + triggerEditorSave: document.getElementById('trigger-editor-save'), + triggerId: document.getElementById('trigger-id-input'), + triggerTaskId: document.getElementById('trigger-task-id-input'), + triggerExpression: document.getElementById('trigger-expression-input'), + triggerTimezone: document.getElementById('trigger-timezone-input'), + triggerMisfire: document.getElementById('trigger-misfire-input'), + triggerEnabled: document.getElementById('trigger-enabled-input'), presenceDialog: document.getElementById('presence-dialog'), presenceForm: document.getElementById('presence-form'), presenceCopy: document.getElementById('presence-copy'), @@ -109,6 +128,7 @@ pendingAction: null, pendingPresence: null, authoringSnapshot: null, + triggerSnapshot: null, toastTimer: null, }; @@ -487,6 +507,121 @@ }); } + function openTriggerEditor(snapshot = null, task = null) { + state.triggerSnapshot = snapshot; + nodes.triggerEditorForm.reset(); + const editing = snapshot !== null; + nodes.triggerEditorTitle.textContent = editing + ? '编辑定时触发器' + : '创建定时触发器'; + nodes.triggerEditorIntro.textContent = editing + ? `将基于 Trigger revision ${snapshot.revision} 写入新 revision,并重新绑定 Task 当前内容。` + : 'Trigger 会绑定 Task 当前 revision 与内容摘要;Task 改变后需重新保存定时配置。'; + nodes.triggerId.readOnly = editing; + nodes.triggerTaskId.readOnly = editing; + if (editing) { + nodes.triggerId.setAttribute('aria-readonly', 'true'); + nodes.triggerTaskId.setAttribute('aria-readonly', 'true'); + nodes.triggerId.value = snapshot.triggerId; + nodes.triggerTaskId.value = snapshot.taskId; + nodes.triggerExpression.value = snapshot.spec.config.expression; + nodes.triggerTimezone.value = snapshot.spec.config.timezone; + nodes.triggerMisfire.value = snapshot.spec.config.misfirePolicy; + nodes.triggerEnabled.checked = snapshot.enabled; + } else { + nodes.triggerId.removeAttribute('aria-readonly'); + nodes.triggerTaskId.removeAttribute('aria-readonly'); + nodes.triggerExpression.value = '0 * * * *'; + nodes.triggerTimezone.value = 'UTC'; + nodes.triggerMisfire.value = 'skip'; + nodes.triggerEnabled.checked = true; + if (task) { + nodes.triggerTaskId.value = task.taskId; + nodes.triggerId.value = `cron:${task.taskId}`; + } + } + nodes.triggerEditor.returnValue = ''; + nodes.triggerEditor.showModal(); + (editing || task ? nodes.triggerExpression : nodes.triggerId).focus(); + } + + async function triggerDraft() { + const triggerId = nodes.triggerId.value.trim(); + const taskId = nodes.triggerTaskId.value.trim(); + const expression = nodes.triggerExpression.value + .trim() + .replace(/\s+/gu, ' '); + const timezone = nodes.triggerTimezone.value.trim(); + const fields = expression.split(' '); + if (!TASK_PATTERN.test(triggerId) || !TASK_PATTERN.test(taskId)) { + throw new TypeError('Trigger ID 或 Task ID 格式无效。'); + } + if ( + (fields.length !== 5 && fields.length !== 6) || + fields.some((field) => !CRON_FIELD_PATTERN.test(field)) || + !timezone + ) { + throw new TypeError('Cron 表达式或时区无效。'); + } + const taskValue = await api( + `/api/v3/projects/${state.project}/tasks/${taskId}`, + ); + const task = taskValue.task; + if ( + !task || + task.taskId !== taskId || + !Number.isSafeInteger(task.revision) || + !/^[a-f0-9]{64}$/u.test(task.contentDigest) + ) { + throw new ConsoleRequestError('response_unavailable', 503, null); + } + const snapshot = state.triggerSnapshot; + return Object.freeze({ + triggerId, + body: Object.freeze({ + expectedRevision: snapshot ? snapshot.revision : null, + mutationId: newMutationId(), + taskId, + taskRevision: task.revision, + taskContentDigest: task.contentDigest, + spec: Object.freeze({ + schema: 'qinglong/cron@v1', + config: Object.freeze({ + expression, + timezone, + misfirePolicy: nodes.triggerMisfire.value, + }), + }), + enabled: nodes.triggerEnabled.checked, + occurredAtMs: Date.now(), + }), + }); + } + + async function saveTriggerDraft() { + nodes.triggerEditorSave.disabled = true; + try { + const mutation = await triggerDraft(); + const value = await api( + `/api/v3/projects/${state.project}/triggers/${mutation.triggerId}`, + { + method: 'PUT', + body: mutation.body, + acceptStatus: 428, + }, + ); + if (value.code === 'local_presence_required') { + showPresenceChallenge({ kind: 'trigger-mutation', mutation }, value); + return; + } + throw new ConsoleRequestError('response_unavailable', 503, null); + } catch (error) { + showToast(describeError(error), 'error'); + } finally { + nodes.triggerEditorSave.disabled = false; + } + } + function showPresenceChallenge(action, challenge) { if ( challenge?.code !== 'local_presence_required' || @@ -498,8 +633,11 @@ } state.pendingPresence = Object.freeze({ ...action, challenge }); const authoringRead = action.kind === 'authoring'; + const triggerMutation = action.kind === 'trigger-mutation'; nodes.presenceCopy.textContent = authoringRead ? '读取完整 Task 定义需要部署设备上的一次性证明。返回的编辑租约不替代保存时的新内容证明。' + : triggerMutation + ? '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Trigger 与 Task revision,且只能使用一次。' : '使用部署 QingLong 的系统用户读取下面的私有文件。证明只绑定这次 Task 内容,且只能使用一次。'; nodes.presenceSubmit.textContent = authoringRead ? '验证并加载定义' @@ -514,6 +652,7 @@ nodes.presenceError.textContent = ''; nodes.presenceError.hidden = true; nodes.taskEditor.close(); + nodes.triggerEditor.close(); nodes.presenceDialog.returnValue = ''; nodes.presenceDialog.showModal(); nodes.presenceProof.focus(); @@ -600,6 +739,34 @@ showToast('完整 Task 定义已加载;保存仍需要新的本机证明。'); return; } + if (pending.kind === 'trigger-mutation') { + const value = await api( + `/api/v3/projects/${state.project}/triggers/${pending.mutation.triggerId}`, + { + method: 'PUT', + body: pending.mutation.body, + presence: proof, + }, + ); + const updated = pending.mutation.body.expectedRevision !== null; + state.pendingPresence = null; + state.triggerSnapshot = null; + nodes.presenceProof.value = ''; + nodes.presenceDialog.close(); + showToast( + value.status === 'existing' + ? '已找到同一 Trigger 请求。' + : updated + ? '定时触发器已更新。' + : '定时触发器已创建。', + ); + state.view = 'triggers'; + state.selectedId = pending.mutation.triggerId; + updateNavigation(); + await refresh(); + await selectTrigger(pending.mutation.triggerId); + return; + } const value = await api( `/api/v3/projects/${state.project}/tasks/${pending.mutation.taskId}`, { @@ -726,6 +893,9 @@ if (task.kind === 'command' && task.specSchema === 'qinglong/command@v1') { actions.append(actionButton('编辑任务', () => beginTaskAuthoring(task))); } + actions.append( + actionButton('添加定时', () => openTriggerEditor(null, task)), + ); if (task.enabled) { actions.append( actionButton('运行一次', () => { @@ -771,6 +941,126 @@ } } + async function renderTriggers() { + const value = await api( + `/api/v3/projects/${state.project}/triggers?limit=64`, + ); + const triggers = Array.isArray(value.triggers) ? value.triggers : []; + if (triggers.length === 0) { + empty('还没有定时触发器。创建后,现有本地调度器会按 cron 自动生成 Run。'); + return; + } + const fragment = document.createDocumentFragment(); + fragment.append(listHeader('Cron trigger ledger', triggers.length)); + const list = element('div', 'record-list'); + for (const trigger of triggers) { + const button = element('button', 'record'); + button.type = 'button'; + button.dataset.identity = trigger.triggerId; + if (state.selectedId === trigger.triggerId) { + button.setAttribute('aria-current', 'true'); + } + const main = element('span'); + main.append(element('span', 'record-title', trigger.triggerId)); + main.append( + recordMeta([ + trigger.taskId, + `trigger rev ${trigger.revision}`, + `task rev ${trigger.taskRevision}`, + ]), + ); + const side = element('span', 'record-side'); + const enabled = element( + 'span', + 'status', + trigger.enabled ? '自动执行' : '已停用', + ); + enabled.dataset.tone = trigger.enabled ? 'active' : 'quiet'; + side.append(enabled); + side.append( + element('span', 'record-time', formatTime(trigger.updatedAtMs)), + ); + button.append(main, side); + button.addEventListener('click', () => selectTrigger(trigger.triggerId)); + list.append(button); + } + fragment.append(list); + if (value.truncated) { + fragment.append( + element( + 'p', + 'privacy-note', + '当前只展示前 64 条;使用 API 可继续读取下一页。', + ), + ); + } + replace(nodes.ledger, fragment); + } + + async function selectTrigger(triggerId) { + state.selectedId = triggerId; + for (const row of nodes.ledger.querySelectorAll('.record')) { + if (row.dataset.identity === triggerId) { + row.setAttribute('aria-current', 'true'); + } else { + row.removeAttribute('aria-current'); + } + } + const loadingBox = element('div', 'loading-state'); + loadingBox.append(element('span')); + replace(nodes.detail, loadingBox); + try { + const value = await api( + `/api/v3/projects/${state.project}/triggers/${triggerId}`, + ); + renderTriggerDetail(value.trigger); + } catch (error) { + detailEmpty(describeError(error)); + } + } + + function renderTriggerDetail(trigger) { + const config = trigger?.spec?.config; + const fragment = document.createDocumentFragment(); + fragment.append( + detailHeader('Cron trigger', trigger.triggerId, trigger.taskId), + ); + const facts = element('div', 'facts'); + facts.append( + fact('状态', trigger.enabled ? '自动执行' : '已停用'), + fact('Revision', trigger.revision), + fact('Cron', config?.expression || '—'), + fact('时区', config?.timezone || '—'), + fact('Misfire', config?.misfirePolicy || '—'), + fact( + 'Task fence', + `rev ${trigger.taskRevision} · ${shortDigest( + trigger.taskContentDigest, + )}`, + ), + fact('Content fence', shortDigest(trigger.contentDigest)), + fact('更新时间', formatTime(trigger.updatedAtMs)), + ); + fragment.append(facts); + const actions = element('div', 'detail-actions'); + if (trigger.spec?.schema === 'qinglong/cron@v1') { + actions.append( + actionButton('编辑或停用', () => openTriggerEditor(trigger)), + ); + } + actions.append( + actionButton('查看绑定任务', async () => { + state.view = 'tasks'; + state.selectedId = trigger.taskId; + updateNavigation(); + await refresh(); + await selectTask(trigger.taskId); + }), + ); + fragment.append(actions); + replace(nodes.detail, fragment); + } + async function renderRuns() { const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`); const runs = Array.isArray(value.runs) ? value.runs : []; @@ -1035,12 +1325,21 @@ } if (state.view === 'tasks') { nodes.createTask.hidden = false; + nodes.createTrigger.hidden = true; nodes.kicker.textContent = 'Project task authority'; nodes.title.textContent = '任务调度台'; nodes.description.textContent = '创建命令 Task,查看当前 revision 与内容围栏。管理写入需要部署设备上的一次性本机证明。'; + } else if (state.view === 'triggers') { + nodes.createTask.hidden = true; + nodes.createTrigger.hidden = false; + nodes.kicker.textContent = 'Durable cron authority'; + nodes.title.textContent = '定时触发器'; + nodes.description.textContent = + '配置内置 cron Trigger,绑定 Task 当前 revision;停用只追加历史,不删除证据。'; } else { nodes.createTask.hidden = true; + nodes.createTrigger.hidden = true; nodes.kicker.textContent = 'Durable run evidence'; nodes.title.textContent = '运行事实账本'; nodes.description.textContent = @@ -1053,6 +1352,7 @@ loading(); try { if (state.view === 'tasks') await renderTasks(); + else if (state.view === 'triggers') await renderTriggers(); else await renderRuns(); setConnection('connected', `${state.project} · 已连接`); } catch (error) { @@ -1086,7 +1386,9 @@ state.pendingAction = null; state.pendingPresence = null; state.authoringSnapshot = null; + state.triggerSnapshot = null; if (nodes.taskEditor.open) nodes.taskEditor.close(); + if (nodes.triggerEditor.open) nodes.triggerEditor.close(); if (nodes.presenceDialog.open) nodes.presenceDialog.close(); nodes.token.value = ''; nodes.token.disabled = false; @@ -1096,6 +1398,7 @@ nodes.nav.hidden = true; nodes.refresh.hidden = true; nodes.createTask.hidden = true; + nodes.createTrigger.hidden = true; setConnection('idle', '等待凭据'); nodes.kicker.textContent = 'Connection gate'; nodes.title.textContent = '先建立一条本机连接'; @@ -1133,6 +1436,7 @@ nodes.disconnect.addEventListener('click', disconnect); nodes.refresh.addEventListener('click', refresh); nodes.createTask.addEventListener('click', () => openTaskEditor()); + nodes.createTrigger.addEventListener('click', () => openTriggerEditor()); nodes.taskEditorClose.addEventListener('click', () => { state.authoringSnapshot = null; nodes.taskEditor.close(); @@ -1141,9 +1445,18 @@ event.preventDefault(); await saveTaskDraft(); }); + nodes.triggerEditorClose.addEventListener('click', () => { + state.triggerSnapshot = null; + nodes.triggerEditor.close(); + }); + nodes.triggerEditorForm.addEventListener('submit', async (event) => { + event.preventDefault(); + await saveTriggerDraft(); + }); nodes.presenceCancel.addEventListener('click', () => { state.pendingPresence = null; state.authoringSnapshot = null; + state.triggerSnapshot = null; nodes.presenceProof.value = ''; nodes.presenceDialog.close(); }); @@ -1181,6 +1494,7 @@ event.target instanceof HTMLTextAreaElement || nodes.dialog.open || nodes.taskEditor.open || + nodes.triggerEditor.open || nodes.presenceDialog.open ) { return; @@ -1188,6 +1502,8 @@ const view = event.key.toLowerCase() === 't' ? 'tasks' + : event.key.toLowerCase() === 's' + ? 'triggers' : event.key.toLowerCase() === 'r' ? 'runs' : null; diff --git a/packages/ql3-local-api/assets/console/index.html b/packages/ql3-local-api/assets/console/index.html index 62391ffd..0ef17b4a 100644 --- a/packages/ql3-local-api/assets/console/index.html +++ b/packages/ql3-local-api/assets/console/index.html @@ -67,6 +67,9 @@ + @@ -91,6 +94,9 @@ + @@ -185,6 +191,58 @@ + +
+
+
+

Cron scheduling

+

创建定时触发器

+
+ +
+

+ Trigger 会绑定 Task 当前 revision 与内容摘要;Task 改变后需重新保存定时配置。 +

+
+ + + + + + +
+

+ 当前 Console 只开放内置 qinglong/cron@v1;停用会写入新 revision,不删除历史。 +

+
+ +
+
+
+

Local presence · 02:00

diff --git a/packages/ql3-local-api/src/admission/localApiAdmission.ts b/packages/ql3-local-api/src/admission/localApiAdmission.ts index 0a51b3c7..25022074 100644 --- a/packages/ql3-local-api/src/admission/localApiAdmission.ts +++ b/packages/ql3-local-api/src/admission/localApiAdmission.ts @@ -28,6 +28,11 @@ import type { LocalApiTaskReadRoute } from '../task/taskReadRoute'; import type { LocalApiTaskStartRoute } from '../task/taskStartRoute'; import type { LocalApiTaskPutRoute } from '../task/taskPutRoute'; import type { LocalApiTaskAuthoringRoute } from '../task/taskAuthoringRoute'; +import type { + LocalApiTriggerListRoute, + LocalApiTriggerReadRoute, +} from '../trigger/triggerReadRoutes'; +import type { LocalApiTriggerPutRoute } from '../trigger/triggerPutRoute'; import type { LocalApiResponse } from '../transport/contract'; export type LocalApiAdmissionOperation = @@ -90,6 +95,22 @@ export type LocalApiAdmissionOperation = operationId: 'task.authoring'; projectId: string; taskId: string; + }> + | Readonly<{ + operationId: 'trigger.list'; + projectId: string; + limit: number; + after?: Readonly<{ readonly triggerId: string }>; + }> + | Readonly<{ + operationId: 'trigger.get'; + projectId: string; + triggerId: string; + }> + | Readonly<{ + operationId: 'trigger.put'; + projectId: string; + triggerId: string; }>; export interface LocalApiAdmissionRequest { @@ -128,6 +149,9 @@ export interface LocalApiAdmissionOptions { readonly taskStartRoute: LocalApiTaskStartRoute; readonly taskPutRoute: LocalApiTaskPutRoute; readonly taskAuthoringRoute: LocalApiTaskAuthoringRoute; + readonly triggerListRoute: LocalApiTriggerListRoute; + readonly triggerReadRoute: LocalApiTriggerReadRoute; + readonly triggerPutRoute: LocalApiTriggerPutRoute; readonly now?: () => number; readonly randomUuid?: () => string; } @@ -208,6 +232,9 @@ export function createLocalApiAdmission( typeof options.taskStartRoute?.handle !== 'function' || typeof options.taskPutRoute?.handle !== 'function' || typeof options.taskAuthoringRoute?.handle !== 'function' || + typeof options.triggerListRoute?.handle !== 'function' || + typeof options.triggerReadRoute?.handle !== 'function' || + typeof options.triggerPutRoute?.handle !== 'function' || (options.now !== undefined && typeof options.now !== 'function') || (options.randomUuid !== undefined && typeof options.randomUuid !== 'function') @@ -296,6 +323,25 @@ export function createLocalApiAdmission( }); } + if (request.operation.operationId === 'trigger.put') { + const triggerPutOperation = request.operation; + return Object.freeze({ + bodyMode: 'json' as const, + maximumBodyBytes: 20 * 1_024, + async handle(body: unknown | null) { + return options.triggerPutRoute.handle({ + requestId: request.requestId, + projectId: triggerPutOperation.projectId, + triggerId: triggerPutOperation.triggerId, + body, + presence: request.localPresence, + authenticated, + signal: request.signal, + }); + }, + }); + } + let decision: Readonly; try { decision = normalizeSecurityPolicyDecision( @@ -309,7 +355,9 @@ export function createLocalApiAdmission( : request.operation.operationId === 'task.start' ? 'run.start' : request.operation.operationId === 'task.list' || - request.operation.operationId === 'task.get' + request.operation.operationId === 'task.get' || + request.operation.operationId === 'trigger.list' || + request.operation.operationId === 'trigger.get' ? 'task.read' : 'run.read', ), @@ -443,8 +491,24 @@ export function createLocalApiAdmission( principal: authenticated.principal, policyFence: decision.fence, }); + case 'trigger.list': + if (body !== null) return response(400, 'invalid_request_body'); + return options.triggerListRoute.handle({ + projectId: request.operation.projectId, + limit: request.operation.limit, + ...(request.operation.after + ? { after: request.operation.after } + : {}), + }); + case 'trigger.get': + if (body !== null) return response(400, 'invalid_request_body'); + return options.triggerReadRoute.handle({ + projectId: request.operation.projectId, + triggerId: request.operation.triggerId, + }); case 'task.put': case 'task.authoring': + case 'trigger.put': return response(503, 'request_unavailable'); } }, diff --git a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts index 9ea4ea6d..9883771a 100644 --- a/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts +++ b/packages/ql3-local-api/src/application-runtime/localApiProductSurface.ts @@ -22,6 +22,11 @@ import { createLocalApiTaskReadRoute } from '../task/taskReadRoute'; import { createLocalApiTaskStartRoute } from '../task/taskStartRoute'; import { createLocalApiTaskPutRoute } from '../task/taskPutRoute'; import { createLocalApiTaskAuthoringRoute } from '../task/taskAuthoringRoute'; +import { + createLocalApiTriggerListRoute, + createLocalApiTriggerReadRoute, +} from '../trigger/triggerReadRoutes'; +import { createLocalApiTriggerPutRoute } from '../trigger/triggerPutRoute'; import { startLocalApiHttpSurface } from '../transport/httpSurface'; export interface LocalApiProductSurfaceEvent { @@ -165,6 +170,31 @@ export function createLocalApiProductSurface( ? {} : { randomUuid: options.randomUuid }), }); + const triggerListRoute = createLocalApiTriggerListRoute( + authority.triggers, + ); + const triggerReadRoute = createLocalApiTriggerReadRoute( + authority.triggers, + ); + const triggerPutRoute = createLocalApiTriggerPutRoute({ + projectPolicy: authority.projectPolicy, + triggers: authority.triggers, + triggerAdministrationForCredential: (fence) => { + if (fence.subjectType !== 'user') { + throw new TypeError('Trigger mutation requires a User credential'); + } + return authority.triggerAdministrationForCredential({ + ...fence, + subjectType: 'user', + }); + }, + securityAudit: authority.securityAudit, + presenceProof, + ...(options.now === undefined ? {} : { now: options.now }), + ...(options.randomUuid === undefined + ? {} + : { randomUuid: options.randomUuid }), + }); const admission = createLocalApiAdmission({ authenticator, policy, @@ -180,6 +210,9 @@ export function createLocalApiProductSurface( taskStartRoute, taskPutRoute, taskAuthoringRoute, + triggerListRoute, + triggerReadRoute, + triggerPutRoute, ...(options.now === undefined ? {} : { now: options.now }), ...(options.randomUuid === undefined ? {} diff --git a/packages/ql3-local-api/src/authentication/strongLocalPrincipal.ts b/packages/ql3-local-api/src/authentication/strongLocalPrincipal.ts new file mode 100644 index 00000000..618d1b13 --- /dev/null +++ b/packages/ql3-local-api/src/authentication/strongLocalPrincipal.ts @@ -0,0 +1,26 @@ +import { + normalizeSecurityPrincipal, + type SecurityPrincipal, +} from '@qinglong/runtime-core/security'; + +import type { AuthenticatedLocalApiRequest } from './credentialAuthenticator'; +import type { ConsumedLocalPresenceProof } from './localPresenceProof'; + +export function strongLocalConsolePrincipal( + authenticated: Readonly, + proof: Readonly, +): Readonly { + return normalizeSecurityPrincipal( + { + subject: authenticated.principal.subject, + authenticationId: `local_presence:${proof.authorizationId}`, + authenticatedAtMs: proof.authenticatedAtMs, + expiresAtMs: Math.min( + proof.expiresAtMs, + authenticated.principal.expiresAtMs, + ), + assurance: 'local_console', + }, + proof.authenticatedAtMs, + ); +} diff --git a/packages/ql3-local-api/src/task/taskPutRoute.ts b/packages/ql3-local-api/src/task/taskPutRoute.ts index 045cce65..83539009 100644 --- a/packages/ql3-local-api/src/task/taskPutRoute.ts +++ b/packages/ql3-local-api/src/task/taskPutRoute.ts @@ -14,7 +14,6 @@ import { } from '@qinglong/runtime-core/project-policy'; import { normalizeSecurityPolicyDecision, - normalizeSecurityPrincipal, type SecurityPolicyDecision, } from '@qinglong/runtime-core/security'; import { @@ -38,6 +37,7 @@ import { } from '@qinglong/runtime-core/task-definition-administration'; import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator'; +import { strongLocalConsolePrincipal } from '../authentication/strongLocalPrincipal'; import { LocalPresenceProofUnavailableError, type LocalPresenceBinding, @@ -491,18 +491,9 @@ export function createLocalApiTaskPutRoute( } let strongPrincipal; try { - strongPrincipal = normalizeSecurityPrincipal( - { - subject: request.authenticated.principal.subject, - authenticationId: `local_presence:${proof.authorizationId}`, - authenticatedAtMs: proof.authenticatedAtMs, - expiresAtMs: Math.min( - proof.expiresAtMs, - request.authenticated.principal.expiresAtMs, - ), - assurance: 'local_console', - }, - proof.authenticatedAtMs, + strongPrincipal = strongLocalConsolePrincipal( + request.authenticated, + proof, ); } catch { return response(503, { code: 'authentication_unavailable' }); diff --git a/packages/ql3-local-api/src/transport/httpSurface.ts b/packages/ql3-local-api/src/transport/httpSurface.ts index 7af6852e..83e55752 100644 --- a/packages/ql3-local-api/src/transport/httpSurface.ts +++ b/packages/ql3-local-api/src/transport/httpSurface.ts @@ -42,6 +42,10 @@ const TASK_START_ROUTE_PATTERN = /^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/runs$/; const TASK_AUTHORING_ROUTE_PATTERN = /^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/tasks\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/authoring$/; +const TRIGGER_LIST_ROUTE_PATTERN = + /^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers$/; +const TRIGGER_READ_ROUTE_PATTERN = + /^\/api\/v3\/projects\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})\/triggers\/([A-Za-z0-9][A-Za-z0-9._:-]{0,127})$/; const RUN_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const TASK_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const LOCAL_CONSOLE_CONTENT_SECURITY_POLICY = @@ -55,7 +59,8 @@ type LocalApiRouteResolution = | 'invalid_run_event_list_query' | 'invalid_run_step_list_query' | 'invalid_run_log_read_query' - | 'invalid_task_list_query'; + | 'invalid_task_list_query' + | 'invalid_trigger_list_query'; }>; export interface LocalApiHttpSurfaceOptions { @@ -393,6 +398,53 @@ function parseTaskListQuery( }); } +function parseTriggerListQuery( + rawQuery: string | undefined, + profile: LocalApplicationProfile, +): Readonly<{ + limit: number; + after?: Readonly<{ readonly triggerId: string }>; +}> { + if (rawQuery === undefined) { + return Object.freeze({ limit: profile === 'edge' ? 16 : 32 }); + } + if (rawQuery.length === 0) throw new TypeError(); + const values = new Map(); + for (const field of rawQuery.split('&')) { + const separator = field.indexOf('='); + if ( + separator < 1 || + separator !== field.lastIndexOf('=') || + separator === field.length - 1 + ) { + throw new TypeError(); + } + const name = field.slice(0, separator); + const value = field.slice(separator + 1); + if (values.has(name) || (name !== 'limit' && name !== 'after_trigger_id')) { + throw new TypeError(); + } + values.set(name, value); + } + const rawLimit = values.get('limit'); + const limit = + rawLimit === undefined ? (profile === 'edge' ? 16 : 32) : Number(rawLimit); + const triggerId = values.get('after_trigger_id'); + if ( + !Number.isSafeInteger(limit) || + limit < 1 || + limit > 64 || + (rawLimit !== undefined && String(limit) !== rawLimit) || + (triggerId !== undefined && !TASK_ID_PATTERN.test(triggerId)) + ) { + throw new TypeError(); + } + return Object.freeze({ + limit, + ...(triggerId === undefined ? {} : { after: Object.freeze({ triggerId }) }), + }); +} + function parseRunAttemptLogReadQuery( rawQuery: string | undefined, profile: LocalApplicationProfile, @@ -482,6 +534,14 @@ function route( : null; } if (request.method === 'PUT') { + const triggerPutMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path); + if (triggerPutMatch && rawQuery === undefined) { + return Object.freeze({ + operationId: 'trigger.put', + projectId: triggerPutMatch[1]!, + triggerId: triggerPutMatch[2]!, + }); + } const taskPutMatch = TASK_READ_ROUTE_PATTERN.exec(path); return taskPutMatch && rawQuery === undefined ? Object.freeze({ @@ -492,6 +552,29 @@ function route( : null; } if (request.method !== 'GET') return null; + const triggerReadMatch = TRIGGER_READ_ROUTE_PATTERN.exec(path); + if (triggerReadMatch) { + return rawQuery === undefined + ? Object.freeze({ + operationId: 'trigger.get', + projectId: triggerReadMatch[1]!, + triggerId: triggerReadMatch[2]!, + }) + : null; + } + const triggerListMatch = TRIGGER_LIST_ROUTE_PATTERN.exec(path); + if (triggerListMatch) { + try { + const input = parseTriggerListQuery(rawQuery, profile); + return Object.freeze({ + operationId: 'trigger.list', + projectId: triggerListMatch[1]!, + ...input, + }); + } catch { + return Object.freeze({ errorCode: 'invalid_trigger_list_query' }); + } + } const runAttemptLogReadMatch = RUN_ATTEMPT_LOG_READ_ROUTE_PATTERN.exec(path); if (runAttemptLogReadMatch) { try { diff --git a/packages/ql3-local-api/src/trigger/triggerPutRoute.ts b/packages/ql3-local-api/src/trigger/triggerPutRoute.ts new file mode 100644 index 00000000..ddaab13c --- /dev/null +++ b/packages/ql3-local-api/src/trigger/triggerPutRoute.ts @@ -0,0 +1,469 @@ +import { createHash, randomUUID } from 'node:crypto'; + +import { + LocalTriggerAdministrationAuthenticationError, + LocalTriggerAdministrationAuthorizationError, + LocalTriggerAdministrationConfigurationError, + LocalTriggerAdministrationUnavailableError, + createLocalTriggerAdministrationService, +} from '@qinglong/local-admin/trigger-administration'; +import { + ProjectPolicyEngine, + ProjectPolicyUnavailableError, + type ProjectPolicyRepository, +} from '@qinglong/runtime-core/project-policy'; +import { + normalizeSecurityPolicyDecision, + type SecurityPolicyDecision, +} from '@qinglong/runtime-core/security'; +import { + normalizeSecurityAuditRecord, + type SecurityAuditOutcome, + type SecurityAuditSink, +} from '@qinglong/runtime-core/security-audit'; +import { + InvalidTriggerError, + InvalidTriggerSpecSemanticError, + TriggerConflictError, + TriggerUnavailableError, + UnsupportedTriggerSpecError, + normalizeAppendTriggerRevisionCommand, + type AppendTriggerRevisionCommand, + type TriggerRecord, + type TriggerSource, +} from '@qinglong/runtime-core/trigger'; +import { + TriggerAdministrationAuthorizationFenceConflictError, + TriggerAdministrationMutationConflictError, + type TriggerAdministrationRepository, +} from '@qinglong/runtime-core/trigger-administration'; + +import type { AuthenticatedLocalApiRequest } from '../authentication/credentialAuthenticator'; +import { + LocalPresenceProofUnavailableError, + type LocalPresenceBinding, + type LocalPresenceProofManager, +} from '../authentication/localPresenceProof'; +import { strongLocalConsolePrincipal } from '../authentication/strongLocalPrincipal'; +import type { LocalApiResponse } from '../transport/contract'; + +const BODY_KEYS = Object.freeze([ + 'enabled', + 'expectedRevision', + 'mutationId', + 'occurredAtMs', + 'spec', + 'taskContentDigest', + 'taskId', + 'taskRevision', +]); + +export interface LocalApiTriggerPutRequest { + readonly requestId: string; + readonly projectId: string; + readonly triggerId: string; + readonly body: unknown | null; + readonly presence: string | null; + readonly authenticated: Readonly; + readonly signal: AbortSignal; +} + +export interface LocalApiTriggerPutRoute { + handle( + request: Readonly, + ): Promise; +} + +export interface LocalApiTriggerPutRouteOptions { + readonly projectPolicy: ProjectPolicyRepository; + readonly triggers: TriggerSource; + readonly triggerAdministrationForCredential: ( + fence: Readonly, + ) => Promise; + readonly securityAudit: SecurityAuditSink; + readonly presenceProof: LocalPresenceProofManager; + readonly now?: () => number; + readonly randomUuid?: () => string; +} + +function response( + statusCode: number, + body: Readonly>, +): LocalApiResponse { + return Object.freeze({ statusCode, body: Object.freeze(body) }); +} + +function canonicalJson(value: unknown): string { + if ( + value === null || + typeof value === 'boolean' || + typeof value === 'number' || + typeof value === 'string' + ) { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((entry) => canonicalJson(entry)).join(',')}]`; + } + const record = value as Readonly>; + return `{${Object.keys(record) + .sort() + .map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`) + .join(',')}}`; +} + +function normalizeBody( + body: unknown | null, + projectId: string, + triggerId: string, +): Readonly { + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw new InvalidTriggerError('HTTP body must be an object'); + } + const keys = Object.keys(body).sort(); + if ( + BODY_KEYS.some((key) => !keys.includes(key)) || + keys.some((key) => !BODY_KEYS.includes(key)) + ) { + throw new InvalidTriggerError('HTTP body has an invalid shape'); + } + return normalizeAppendTriggerRevisionCommand({ + projectId, + triggerId, + ...(body as Omit), + }); +} + +function operationId( + command: Readonly, +): 'trigger.create' | 'trigger.update' { + return command.expectedRevision === null + ? 'trigger.create' + : 'trigger.update'; +} + +function requestDigest( + command: Readonly, +): string { + return createHash('sha256') + .update('qinglong3.local-api-trigger-put.v1\0', 'utf8') + .update(canonicalJson(command), 'utf8') + .digest('hex'); +} + +function presenceBinding( + command: Readonly, + authenticated: Readonly, +): Readonly { + if ( + authenticated.principal.subject.type !== 'user' || + authenticated.credentialFence.subjectType !== 'user' + ) { + throw new LocalPresenceProofUnavailableError( + 'strong User credential is required', + ); + } + return Object.freeze({ + requestDigest: requestDigest(command), + credentialId: authenticated.credentialFence.credentialId, + credentialVersion: authenticated.credentialFence.credentialVersion, + subjectType: 'user', + subjectId: authenticated.credentialFence.subjectId, + }); +} + +function timestamp(now: () => number): number { + const value = now(); + if (!Number.isSafeInteger(value) || value < 0) { + throw new LocalPresenceProofUnavailableError('clock is invalid'); + } + return value; +} + +async function recordAudit( + audit: SecurityAuditSink, + values: { + readonly eventId: string; + readonly requestId: string; + readonly operationId: 'trigger.create' | 'trigger.update'; + readonly projectId: string; + readonly authenticated: Readonly | null; + readonly outcome: SecurityAuditOutcome; + readonly reasons: readonly string[]; + readonly fence: SecurityPolicyDecision['fence']; + readonly occurredAtMs: number; + }, +): Promise { + try { + await audit.record( + normalizeSecurityAuditRecord({ + eventId: values.eventId, + requestId: values.requestId, + operationId: values.operationId, + projectId: values.projectId, + subject: values.authenticated?.principal.subject ?? null, + authenticationId: + values.authenticated?.principal.authenticationId ?? null, + outcome: values.outcome, + reasons: values.reasons, + fence: values.fence, + occurredAtMs: values.occurredAtMs, + }), + ); + return true; + } catch { + return false; + } +} + +function summary(trigger: Readonly) { + return Object.freeze({ + triggerId: trigger.triggerId, + revision: trigger.revision, + taskId: trigger.taskId, + taskRevision: trigger.taskRevision, + taskContentDigest: trigger.taskContentDigest, + spec: trigger.spec, + enabled: trigger.enabled, + contentDigest: trigger.contentDigest, + createdAtMs: trigger.createdAtMs, + updatedAtMs: trigger.updatedAtMs, + }); +} + +function isCredentialFenceConflict(error: unknown): boolean { + return ( + !!error && + typeof error === 'object' && + 'code' in error && + typeof error.code === 'string' && + error.code.startsWith('LOCAL_SQLITE_AUTHENTICATED_') + ); +} + +export function createLocalApiTriggerPutRoute( + options: Readonly, +): Readonly { + if ( + !options || + typeof options !== 'object' || + Array.isArray(options) || + typeof options.projectPolicy?.resolve !== 'function' || + typeof options.triggers?.findCurrentTrigger !== 'function' || + typeof options.triggers?.listTriggers !== 'function' || + typeof options.triggerAdministrationForCredential !== 'function' || + typeof options.securityAudit?.record !== 'function' || + typeof options.presenceProof?.issue !== 'function' || + typeof options.presenceProof?.consume !== 'function' || + (options.now !== undefined && typeof options.now !== 'function') || + (options.randomUuid !== undefined && + typeof options.randomUuid !== 'function') + ) { + throw new TypeError('Local API Trigger put route options are invalid'); + } + const now = options.now ?? Date.now; + const uuid = options.randomUuid ?? randomUUID; + const policy = new ProjectPolicyEngine(options.projectPolicy); + + return Object.freeze({ + async handle(request: Readonly) { + if (request.signal.aborted) { + return response(503, { code: 'request_unavailable' }); + } + let command: Readonly; + try { + command = normalizeBody( + request.body, + request.projectId, + request.triggerId, + ); + } catch (error) { + return error instanceof InvalidTriggerError + ? response(400, { code: 'invalid_trigger' }) + : response(503, { code: 'trigger_unavailable' }); + } + const operation = operationId(command); + let occurredAtMs: number; + try { + occurredAtMs = timestamp(now); + } catch { + return response(503, { code: 'local_presence_unavailable' }); + } + let decision: Readonly; + try { + decision = normalizeSecurityPolicyDecision( + await policy.authorize( + request.authenticated.principal, + request.projectId, + 'task.update', + ), + ); + } catch (error) { + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: request.authenticated, + outcome: 'authorization_unavailable', + reasons: ['policy_unavailable'], + fence: null, + occurredAtMs, + }); + return response(503, { + code: + audited && error instanceof ProjectPolicyUnavailableError + ? 'authorization_unavailable' + : 'security_audit_unavailable', + }); + } + if (decision.effect !== 'allow') { + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: request.authenticated, + outcome: + decision.effect === 'require_approval' + ? 'approval_required' + : 'denied', + reasons: decision.reasons, + fence: decision.fence, + occurredAtMs, + }); + if (!audited) { + return response(503, { code: 'security_audit_unavailable' }); + } + return response(403, { + code: + decision.effect === 'require_approval' + ? 'approval_required' + : 'forbidden', + }); + } + let binding: Readonly; + try { + binding = presenceBinding(command, request.authenticated); + } catch { + return response(401, { code: 'strong_authentication_required' }); + } + if (!request.presence) { + let challenge; + try { + challenge = options.presenceProof.issue(binding); + } catch { + return response(503, { code: 'local_presence_unavailable' }); + } + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: request.authenticated, + outcome: 'approval_required', + reasons: ['local_presence_required'], + fence: decision.fence, + occurredAtMs, + }); + if (!audited) { + return response(503, { code: 'security_audit_unavailable' }); + } + return response(428, { + code: 'local_presence_required', + authorizationId: challenge.authorizationId, + requestDigest: challenge.requestDigest, + expiresAtMs: challenge.expiresAtMs, + proofFileName: challenge.proofFileName, + }); + } + let proof; + try { + await request.authenticated.confirm(); + proof = options.presenceProof.consume(request.presence, binding); + } catch { + return response(503, { code: 'authentication_unavailable' }); + } + if (!proof) { + const audited = await recordAudit(options.securityAudit, { + eventId: uuid(), + requestId: request.requestId, + operationId: operation, + projectId: request.projectId, + authenticated: null, + outcome: 'authentication_rejected', + reasons: ['local_presence_rejected'], + fence: null, + occurredAtMs, + }); + return audited + ? response(401, { code: 'local_presence_rejected' }) + : response(503, { code: 'security_audit_unavailable' }); + } + if (request.signal.aborted) { + return response(503, { code: 'request_unavailable' }); + } + let strongPrincipal; + try { + strongPrincipal = strongLocalConsolePrincipal( + request.authenticated, + proof, + ); + } catch { + return response(503, { code: 'authentication_unavailable' }); + } + try { + const mutations = await options.triggerAdministrationForCredential( + request.authenticated.credentialFence, + ); + const service = createLocalTriggerAdministrationService( + options.projectPolicy, + mutations, + options.triggers, + options.securityAudit, + { now }, + ); + const result = await service.put({ + ...command, + requestId: request.requestId, + principal: strongPrincipal, + }); + return response(result.status === 'created' ? 201 : 200, { + status: result.status, + trigger: summary(result.trigger), + }); + } catch (error) { + if ( + error instanceof TriggerConflictError || + error instanceof TriggerAdministrationMutationConflictError || + error instanceof + TriggerAdministrationAuthorizationFenceConflictError || + isCredentialFenceConflict(error) + ) { + return response(409, { code: 'trigger_fence_rejected' }); + } + if (error instanceof LocalTriggerAdministrationAuthenticationError) { + return response(401, { code: 'strong_authentication_required' }); + } + if (error instanceof LocalTriggerAdministrationAuthorizationError) { + return response(403, { code: 'forbidden' }); + } + if ( + error instanceof InvalidTriggerError || + error instanceof InvalidTriggerSpecSemanticError || + error instanceof UnsupportedTriggerSpecError || + error instanceof LocalTriggerAdministrationConfigurationError + ) { + return response(400, { code: 'invalid_trigger' }); + } + if ( + error instanceof TriggerUnavailableError || + error instanceof LocalTriggerAdministrationUnavailableError + ) { + return response(503, { code: 'trigger_unavailable' }); + } + return response(503, { code: 'trigger_unavailable' }); + } + }, + }); +} diff --git a/packages/ql3-local-api/src/trigger/triggerReadRoutes.ts b/packages/ql3-local-api/src/trigger/triggerReadRoutes.ts new file mode 100644 index 00000000..22b70301 --- /dev/null +++ b/packages/ql3-local-api/src/trigger/triggerReadRoutes.ts @@ -0,0 +1,121 @@ +import { + InvalidTriggerError, + TriggerUnavailableError, + type TriggerRecord, + type TriggerSource, +} from '@qinglong/runtime-core/trigger'; + +import type { LocalApiResponse } from '../transport/contract'; + +export interface LocalApiTriggerListRequest { + readonly projectId: string; + readonly limit: number; + readonly after?: Readonly<{ readonly triggerId: string }>; +} + +export interface LocalApiTriggerReadRequest { + readonly projectId: string; + readonly triggerId: string; +} + +export interface LocalApiTriggerListRoute { + handle( + request: Readonly, + ): Promise; +} + +export interface LocalApiTriggerReadRoute { + handle( + request: Readonly, + ): Promise; +} + +function response( + statusCode: number, + body: Readonly>, +): LocalApiResponse { + return Object.freeze({ statusCode, body: Object.freeze(body) }); +} + +function summary(trigger: Readonly) { + return Object.freeze({ + triggerId: trigger.triggerId, + revision: trigger.revision, + taskId: trigger.taskId, + taskRevision: trigger.taskRevision, + specSchema: trigger.spec.schema, + enabled: trigger.enabled, + contentDigest: trigger.contentDigest, + createdAtMs: trigger.createdAtMs, + updatedAtMs: trigger.updatedAtMs, + }); +} + +function detail(trigger: Readonly) { + return Object.freeze({ + ...summary(trigger), + projectId: trigger.projectId, + taskContentDigest: trigger.taskContentDigest, + spec: trigger.spec, + }); +} + +function unavailable(error: unknown): LocalApiResponse | null { + return error instanceof InvalidTriggerError || + error instanceof TriggerUnavailableError + ? response(503, { code: 'trigger_query_unavailable' }) + : null; +} + +export function createLocalApiTriggerListRoute( + triggers: Pick, +): Readonly { + if (!triggers || typeof triggers.listTriggers !== 'function') { + throw new TypeError('Local API Trigger list repository is invalid'); + } + return Object.freeze({ + async handle(request: Readonly) { + try { + const page = await triggers.listTriggers({ + projectId: request.projectId, + limit: request.limit, + ...(request.after ? { after: request.after } : {}), + }); + return response(200, { + triggers: Object.freeze(page.triggers.map(summary)), + truncated: page.truncated, + next: page.next ?? null, + }); + } catch (error) { + const mapped = unavailable(error); + if (mapped) return mapped; + throw error; + } + }, + }); +} + +export function createLocalApiTriggerReadRoute( + triggers: Pick, +): Readonly { + if (!triggers || typeof triggers.findCurrentTrigger !== 'function') { + throw new TypeError('Local API Trigger read repository is invalid'); + } + return Object.freeze({ + async handle(request: Readonly) { + try { + const trigger = await triggers.findCurrentTrigger( + request.projectId, + request.triggerId, + ); + return trigger + ? response(200, { trigger: detail(trigger) }) + : response(404, { code: 'trigger_not_found' }); + } catch (error) { + const mapped = unavailable(error); + if (mapped) return mapped; + throw error; + } + }, + }); +} diff --git a/packages/ql3-local-api/test/admission.test.cjs b/packages/ql3-local-api/test/admission.test.cjs index 2c764067..24e7057f 100644 --- a/packages/ql3-local-api/test/admission.test.cjs +++ b/packages/ql3-local-api/test/admission.test.cjs @@ -13,6 +13,79 @@ const PRINCIPAL = Object.freeze({ assurance: 'single_factor', }); +test('uses task.read for bounded Trigger list and read projections', async () => { + const { admission, events } = fixture(); + assert.deepEqual( + await execute( + admission, + request({ + operation: { + operationId: 'trigger.list', + projectId: 'prj_default', + limit: 16, + }, + }), + ), + { statusCode: 200, body: { triggers: [], truncated: false } }, + ); + assert.deepEqual(events, [ + 'authenticate', + 'authorize:task.read:prj_default', + 'audit:allowed:trigger.list', + 'confirm', + 'triggers:prj_default:16', + ]); + + events.length = 0; + assert.deepEqual( + await execute( + admission, + request({ + operation: { + operationId: 'trigger.get', + projectId: 'prj_default', + triggerId: 'cron:task-a', + }, + }), + ), + { + statusCode: 200, + body: { trigger: { triggerId: 'cron:task-a' } }, + }, + ); + assert.deepEqual(events, [ + 'authenticate', + 'authorize:task.read:prj_default', + 'audit:allowed:trigger.get', + 'confirm', + 'trigger:prj_default:cron:task-a', + ]); +}); + +test('defers Trigger put Policy, presence and mutation to the route', async () => { + const { admission, events } = fixture(); + const prepared = await admission.prepare( + request({ + localPresence: 'ql3p_bound', + operation: { + operationId: 'trigger.put', + projectId: 'prj_default', + triggerId: 'cron:task-a', + }, + }), + ); + assert.equal(prepared.bodyMode, 'json'); + assert.equal(prepared.maximumBodyBytes, 20 * 1024); + assert.deepEqual(await prepared.handle({ enabled: true }), { + statusCode: 201, + body: { status: 'created' }, + }); + assert.deepEqual(events, [ + 'authenticate', + 'trigger-put:prj_default:cron:task-a', + ]); +}); + function request(overrides = {}) { return Object.freeze({ requestId: 'local:019f70c0-0000-7000-8000-000000000001', @@ -152,6 +225,27 @@ function fixture(overrides = {}) { return { statusCode: 200, body: { task: { taskId: value.taskId } } }; }, }, + triggerListRoute: { + async handle(value) { + events.push(`triggers:${value.projectId}:${value.limit}`); + return { statusCode: 200, body: { triggers: [], truncated: false } }; + }, + }, + triggerReadRoute: { + async handle(value) { + events.push(`trigger:${value.projectId}:${value.triggerId}`); + return { + statusCode: 200, + body: { trigger: { triggerId: value.triggerId } }, + }; + }, + }, + triggerPutRoute: { + async handle(value) { + events.push(`trigger-put:${value.projectId}:${value.triggerId}`); + return { statusCode: 201, body: { status: 'created' } }; + }, + }, now: () => 10_000, randomUuid: () => '019f70c0-0000-4000-8000-000000000002', ...overrides, diff --git a/packages/ql3-local-api/test/consoleAssets.test.cjs b/packages/ql3-local-api/test/consoleAssets.test.cjs index cbc87ebe..af7a8df3 100644 --- a/packages/ql3-local-api/test/consoleAssets.test.cjs +++ b/packages/ql3-local-api/test/consoleAssets.test.cjs @@ -86,6 +86,10 @@ test('loads one bounded offline Console asset closure', () => { assert.match(text, /\.\.\.snapshot\.task\.spec\.config/u); assert.match(text, /snapshot\.task\.labels/u); assert.match(text, /setAttribute\('aria-readonly', 'true'\)/u); + assert.match(text, /qinglong\/cron@v1/u); + assert.match(text, /triggers\/\$\{mutation\.triggerId\}/u); + assert.match(text, /state\.view === 'triggers'/u); + assert.match(text, /trigger_fence_rejected/u); } if (requestPath === '/') { assert.match(text, /id="task-editor-dialog"/u); @@ -93,6 +97,8 @@ test('loads one bounded offline Console asset closure', () => { assert.match(text, /保存并生成本机证明/u); assert.match(text, /id="task-editor-title"/u); assert.match(text, /id="presence-copy"/u); + assert.match(text, /id="trigger-editor-dialog"/u); + assert.match(text, /data-view="triggers"/u); } } assert.ok(totalBytes <= 192 * 1024); diff --git a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs index addb8955..18c2b84e 100644 --- a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs +++ b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs @@ -439,6 +439,9 @@ test('serves an authenticated Run through one real SQLite authority and durable taskDefinitions: runtime.taskDefinitions, taskDefinitionAdministrationForCredential: runtime.taskDefinitionAdministrationForCredential, + triggers: runtime.triggers, + triggerAdministrationForCredential: + runtime.triggerAdministrationForCredential, apiCredentials: runtime.apiCredentials, ownerPepper: runtime.ownerPepper, projectPolicy: runtime.projectPolicy, @@ -694,6 +697,126 @@ test('serves an authenticated Run through one real SQLite authority and durable assert.equal(updatedTask.body.task.enabled, true); assert.equal(JSON.stringify(updatedTask).includes('/bin/echo'), false); + const triggerPath = '/api/v3/projects/default/triggers/cron:task-1'; + const triggerBody = JSON.stringify({ + expectedRevision: null, + mutationId: '019f7300-0000-4000-8000-000000000703', + taskId: 'task-1', + taskRevision: updatedTask.body.task.revision, + taskContentDigest: updatedTask.body.task.contentDigest, + spec: { + schema: 'qinglong/cron@v1', + config: { + expression: '0 * * * *', + timezone: 'UTC', + misfirePolicy: 'skip', + }, + }, + enabled: true, + occurredAtMs: NOW, + }); + const triggerOptions = { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(triggerBody)), + }, + body: triggerBody, + }; + const triggerChallenge = await request( + port, + `Bearer ${TOKEN}`, + triggerPath, + triggerOptions, + ); + assert.equal(triggerChallenge.statusCode, 428); + assert.equal(triggerChallenge.body.code, 'local_presence_required'); + const triggerProof = JSON.parse( + fs.readFileSync( + path.join(root, 'console-presence', triggerChallenge.body.proofFileName), + 'utf8', + ), + ); + const triggerCreated = await request(port, `Bearer ${TOKEN}`, triggerPath, { + ...triggerOptions, + headers: { + ...triggerOptions.headers, + 'x-qinglong-local-presence': triggerProof.proof, + }, + }); + assert.equal(triggerCreated.statusCode, 201, JSON.stringify(triggerCreated)); + assert.equal(triggerCreated.body.status, 'created'); + assert.equal(triggerCreated.body.trigger.revision, 1); + assert.equal(triggerCreated.body.trigger.enabled, true); + + const triggerList = await request( + port, + `Bearer ${TOKEN}`, + '/api/v3/projects/default/triggers?limit=16', + ); + assert.equal(triggerList.statusCode, 200); + assert.equal(triggerList.body.triggers.length, 1); + assert.equal(triggerList.body.triggers[0].triggerId, 'cron:task-1'); + assert.equal(triggerList.body.triggers[0].spec, undefined); + + const triggerRead = await request(port, `Bearer ${TOKEN}`, triggerPath); + assert.equal(triggerRead.statusCode, 200); + assert.deepEqual(triggerRead.body.trigger.spec, { + schema: 'qinglong/cron@v1', + config: { + expression: '0 * * * *', + timezone: 'UTC', + misfirePolicy: 'skip', + }, + }); + assert.equal( + triggerRead.body.trigger.taskContentDigest, + updatedTask.body.task.contentDigest, + ); + + const triggerDisableBody = JSON.stringify({ + ...JSON.parse(triggerBody), + expectedRevision: 1, + mutationId: '019f7300-0000-4000-8000-000000000704', + enabled: false, + }); + const triggerDisableOptions = { + method: 'PUT', + headers: { + 'content-type': 'application/json', + 'content-length': String(Buffer.byteLength(triggerDisableBody)), + }, + body: triggerDisableBody, + }; + const triggerDisableChallenge = await request( + port, + `Bearer ${TOKEN}`, + triggerPath, + triggerDisableOptions, + ); + assert.equal(triggerDisableChallenge.statusCode, 428); + const triggerDisableProof = JSON.parse( + fs.readFileSync( + path.join( + root, + 'console-presence', + triggerDisableChallenge.body.proofFileName, + ), + 'utf8', + ), + ); + const triggerDisabled = await request(port, `Bearer ${TOKEN}`, triggerPath, { + ...triggerDisableOptions, + headers: { + ...triggerDisableOptions.headers, + 'x-qinglong-local-presence': triggerDisableProof.proof, + }, + }); + assert.equal(triggerDisabled.statusCode, 200); + assert.equal(triggerDisabled.body.status, 'updated'); + assert.equal(triggerDisabled.body.trigger.revision, 2); + assert.equal(triggerDisabled.body.trigger.enabled, false); + const taskStartBody = JSON.stringify({ schema: 'qinglong/task-start@v1', mutationId: '019f7300-0000-7000-8000-000000000800', @@ -857,7 +980,8 @@ test('serves an authenticated Run through one real SQLite authority and durable WHERE operation_id IN ( 'run.get', 'run.list', 'run.events.list', 'run.steps.list', 'run.cancel', 'task.authoring.read', 'task.create', 'task.get', - 'task.list', 'task.start', 'task.update', 'run.log.read' + 'task.list', 'task.start', 'task.update', 'run.log.read', + 'trigger.create', 'trigger.get', 'trigger.list', 'trigger.update' ) ORDER BY operation_id, outcome`, ) @@ -884,6 +1008,12 @@ test('serves an authenticated Run through one real SQLite authority and durable 'task.start:allowed', 'task.update:allowed', 'task.update:approval_required', + 'trigger.create:allowed', + 'trigger.create:approval_required', + 'trigger.get:allowed', + 'trigger.list:allowed', + 'trigger.update:allowed', + 'trigger.update:approval_required', ], ); assert.deepEqual( diff --git a/packages/ql3-local-api/test/triggerPutRoute.test.cjs b/packages/ql3-local-api/test/triggerPutRoute.test.cjs new file mode 100644 index 00000000..cdb76bde --- /dev/null +++ b/packages/ql3-local-api/test/triggerPutRoute.test.cjs @@ -0,0 +1,227 @@ +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { test } = require('node:test'); + +const { createTriggerRecord } = require('@qinglong/runtime-core/trigger'); +const { + createLocalPresenceProofManager, +} = require('../dist/authentication/localPresenceProof.js'); +const { + createLocalApiTriggerPutRoute, +} = require('../dist/trigger/triggerPutRoute.js'); + +const PRINCIPAL = Object.freeze({ + subject: Object.freeze({ type: 'user', id: 'owner' }), + authenticationId: 'local_credential:owner-console:1', + authenticatedAtMs: 9_000, + expiresAtMs: 60_000, + assurance: 'single_factor', +}); + +const FENCE = Object.freeze({ + credentialId: 'owner-console', + credentialVersion: 1, + pepperKeyId: 'owner-v1', + materialDigest: 'a'.repeat(64), + subjectType: 'user', + subjectId: 'owner', + secretDigest: 'b'.repeat(64), + notBeforeAtMs: 1, + expiresAtMs: 60_000, +}); + +function triggerBody(overrides = {}) { + return Object.freeze({ + expectedRevision: null, + mutationId: '019f9100-0000-4000-8000-000000000101', + taskId: 'task-a', + taskRevision: 2, + taskContentDigest: 'c'.repeat(64), + spec: Object.freeze({ + schema: 'qinglong/cron@v1', + config: Object.freeze({ + expression: '0 * * * *', + timezone: 'UTC', + misfirePolicy: 'skip', + }), + }), + enabled: true, + occurredAtMs: 10_000, + ...overrides, + }); +} + +function uuidFactory() { + let sequence = 200; + return () => { + sequence += 1; + return `019f9100-0000-4000-8000-${String(sequence).padStart(12, '0')}`; + }; +} + +function fixture(t) { + const deploymentRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'ql3-trigger-put-'), + ); + fs.chmodSync(deploymentRoot, 0o700); + t.after(() => fs.rmSync(deploymentRoot, { recursive: true, force: true })); + const calls = []; + const presenceProof = createLocalPresenceProofManager({ + deploymentRoot, + profile: 'edge', + now: () => 10_000, + randomUuid: uuidFactory(), + randomSecret: () => Buffer.alloc(32, 14), + }); + t.after(() => presenceProof.close()); + const projectPolicy = { + async resolve(projectId, subject) { + calls.push(['policy', projectId, subject]); + return { + project: { + id: projectId, + name: 'Default', + slug: 'default', + status: 'active', + version: 3, + createdAtMs: 1, + updatedAtMs: 2, + }, + binding: { + projectId, + subject, + version: 5, + state: 'active', + role: 'owner', + mutationId: 'owner-binding', + changedBy: { type: 'user', id: 'bootstrap-owner' }, + createdAtMs: 2, + }, + }; + }, + async append() { + throw new Error('not used'); + }, + }; + const triggers = { + async findCurrentTrigger() { + return null; + }, + async findTriggerRevision() { + return null; + }, + async listTriggers() { + return { triggers: [], truncated: false }; + }, + }; + const route = createLocalApiTriggerPutRoute({ + projectPolicy, + triggers, + async triggerAdministrationForCredential(fence) { + calls.push(['credential-fence', fence]); + return { + async appendAuthorizedTriggerRevision(mutation) { + calls.push(['mutation', mutation]); + return { + status: 'created', + trigger: createTriggerRecord(mutation.command, 10_000), + }; + }, + }; + }, + securityAudit: { + async record(record) { + calls.push(['audit', record]); + }, + }, + presenceProof, + now: () => 10_000, + randomUuid: uuidFactory(), + }); + const authenticated = Object.freeze({ + principal: PRINCIPAL, + credentialFence: FENCE, + async confirm() { + calls.push(['confirm']); + }, + }); + return { route, calls, deploymentRoot, authenticated }; +} + +function request(state, body, overrides = {}) { + return Object.freeze({ + requestId: 'local:019f9100-0000-4000-8000-000000000301', + projectId: 'default', + triggerId: 'cron:task-a', + body, + presence: null, + authenticated: state.authenticated, + signal: new AbortController().signal, + ...overrides, + }); +} + +function readProof(state, challenge) { + return JSON.parse( + fs.readFileSync( + path.join( + state.deploymentRoot, + 'console-presence', + challenge.body.proofFileName, + ), + 'utf8', + ), + ).proof; +} + +test('requires exact local presence and commits Trigger audit plus mutation through one credential fence', async (t) => { + const state = fixture(t); + const body = triggerBody(); + const challenge = await state.route.handle(request(state, body)); + assert.equal(challenge.statusCode, 428); + assert.equal(challenge.body.code, 'local_presence_required'); + const created = await state.route.handle( + request(state, body, { presence: readProof(state, challenge) }), + ); + assert.equal(created.statusCode, 201); + assert.equal(created.body.status, 'created'); + assert.equal(created.body.trigger.triggerId, 'cron:task-a'); + const mutation = state.calls.find(([kind]) => kind === 'mutation')[1]; + assert.deepEqual(mutation.actor, { type: 'user', id: 'owner' }); + assert.deepEqual(mutation.fence, { + projectVersion: 3, + bindingVersion: 5, + }); + assert.equal(mutation.audit.operationId, 'trigger.create'); + assert.equal(mutation.audit.outcome, 'allowed'); + assert.equal( + mutation.audit.authenticationId.startsWith('local_presence:'), + true, + ); + assert.deepEqual( + state.calls + .filter(([kind]) => kind === 'audit') + .map(([, audit]) => [audit.operationId, audit.outcome, audit.reasons[0]]), + [['trigger.create', 'approval_required', 'local_presence_required']], + ); +}); + +test('binds the proof to exact Trigger content and rejects malformed requests', async (t) => { + const state = fixture(t); + assert.deepEqual( + await state.route.handle(request(state, { enabled: true })), + { statusCode: 400, body: { code: 'invalid_trigger' } }, + ); + const body = triggerBody(); + const challenge = await state.route.handle(request(state, body)); + const proof = readProof(state, challenge); + assert.deepEqual( + await state.route.handle( + request(state, triggerBody({ enabled: false }), { presence: proof }), + ), + { statusCode: 401, body: { code: 'local_presence_rejected' } }, + ); + assert.equal(state.calls.filter(([kind]) => kind === 'mutation').length, 0); +}); diff --git a/packages/ql3-local-api/test/triggerReadRoutes.test.cjs b/packages/ql3-local-api/test/triggerReadRoutes.test.cjs new file mode 100644 index 00000000..76b9b40a --- /dev/null +++ b/packages/ql3-local-api/test/triggerReadRoutes.test.cjs @@ -0,0 +1,112 @@ +const assert = require('node:assert/strict'); +const { test } = require('node:test'); + +const { + createLocalApiTriggerListRoute, + createLocalApiTriggerReadRoute, +} = require('../dist/trigger/triggerReadRoutes.js'); +const { + TriggerUnavailableError, + triggerContentDigest, +} = require('@qinglong/runtime-core/trigger'); + +function trigger(triggerId = 'cron:task-a') { + const fields = { + projectId: 'default', + triggerId, + revision: 2, + taskId: 'task-a', + taskRevision: 3, + taskContentDigest: 'a'.repeat(64), + spec: { + schema: 'qinglong/cron@v1', + config: { + expression: '0 * * * *', + timezone: 'UTC', + misfirePolicy: 'skip', + }, + }, + enabled: true, + }; + return Object.freeze({ + ...fields, + mutationId: '019f7300-0000-4000-8000-000000000001', + contentDigest: triggerContentDigest(fields), + createdAtMs: 100, + updatedAtMs: 200, + }); +} + +test('projects bounded Trigger summaries and one complete cron detail', async () => { + const record = trigger(); + const source = { + async listTriggers(input) { + assert.deepEqual(input, { projectId: 'default', limit: 16 }); + return { triggers: [record], truncated: false }; + }, + async findCurrentTrigger(projectId, triggerId) { + assert.equal(projectId, 'default'); + return triggerId === record.triggerId ? record : null; + }, + }; + const list = createLocalApiTriggerListRoute(source); + const read = createLocalApiTriggerReadRoute(source); + const listed = await list.handle({ projectId: 'default', limit: 16 }); + assert.equal(listed.statusCode, 200); + assert.equal(listed.body.triggers[0].triggerId, record.triggerId); + assert.equal(listed.body.triggers[0].spec, undefined); + assert.equal(listed.body.triggers[0].taskContentDigest, undefined); + + const found = await read.handle({ + projectId: 'default', + triggerId: record.triggerId, + }); + assert.deepEqual(found, { + statusCode: 200, + body: { + trigger: { + triggerId: record.triggerId, + revision: 2, + taskId: 'task-a', + taskRevision: 3, + specSchema: 'qinglong/cron@v1', + enabled: true, + contentDigest: record.contentDigest, + createdAtMs: 100, + updatedAtMs: 200, + projectId: 'default', + taskContentDigest: 'a'.repeat(64), + spec: record.spec, + }, + }, + }); + assert.deepEqual( + await read.handle({ projectId: 'default', triggerId: 'missing' }), + { statusCode: 404, body: { code: 'trigger_not_found' } }, + ); +}); + +test('fails closed when Trigger storage is unavailable', async () => { + const source = { + async listTriggers() { + throw new TriggerUnavailableError(); + }, + async findCurrentTrigger() { + throw new TriggerUnavailableError(); + }, + }; + assert.deepEqual( + await createLocalApiTriggerListRoute(source).handle({ + projectId: 'default', + limit: 16, + }), + { statusCode: 503, body: { code: 'trigger_query_unavailable' } }, + ); + assert.deepEqual( + await createLocalApiTriggerReadRoute(source).handle({ + projectId: 'default', + triggerId: 'cron:task-a', + }), + { statusCode: 503, body: { code: 'trigger_query_unavailable' } }, + ); +}); diff --git a/packages/ql3-local-application/src/application-runtime/activation.ts b/packages/ql3-local-application/src/application-runtime/activation.ts index bf45d93f..42978cc7 100644 --- a/packages/ql3-local-application/src/application-runtime/activation.ts +++ b/packages/ql3-local-application/src/application-runtime/activation.ts @@ -522,6 +522,9 @@ export async function bootstrapLocalApplication( taskDefinitions: storage.taskDefinitions, taskDefinitionAdministrationForCredential: storage.taskDefinitionAdministrationForCredential, + triggers: storage.triggers, + triggerAdministrationForCredential: + storage.triggerAdministrationForCredential, apiCredentials: storage.apiCredentials, ownerPepper: storage.ownerPepper, projectPolicy: storage.projectPolicy, diff --git a/packages/ql3-local-application/src/application-runtime/contract.ts b/packages/ql3-local-application/src/application-runtime/contract.ts index 4e335a6c..ec56d2ed 100644 --- a/packages/ql3-local-application/src/application-runtime/contract.ts +++ b/packages/ql3-local-application/src/application-runtime/contract.ts @@ -69,6 +69,8 @@ export interface LocalApplicationProductSurfaceAuthority { | 'listTaskDefinitions' >; readonly taskDefinitionAdministrationForCredential: ReadyFreshStorage['taskDefinitionAdministrationForCredential']; + readonly triggers: ReadyFreshStorage['triggers']; + readonly triggerAdministrationForCredential: ReadyFreshStorage['triggerAdministrationForCredential']; readonly runAttemptLogRead: Readonly<{ read( request: Readonly, diff --git a/packages/ql3-local-application/test/activation.test.cjs b/packages/ql3-local-application/test/activation.test.cjs index fca208c6..d11e6a83 100644 --- a/packages/ql3-local-application/test/activation.test.cjs +++ b/packages/ql3-local-application/test/activation.test.cjs @@ -1759,6 +1759,11 @@ test('starts an optional product surface after recovery and drains it before own typeof authority.taskDefinitions.listTaskDefinitions, 'function', ); + assert.equal(typeof authority.triggers.listTriggers, 'function'); + assert.equal( + typeof authority.triggerAdministrationForCredential, + 'function', + ); assert.equal(typeof authority.runAttemptLogRead.read, 'function'); assert.equal(typeof authority.apiCredentials.resolve, 'function'); assert.equal(typeof authority.ownerPepper.resolveKey, 'function'); diff --git a/packages/ql3-local-sqlite/src/profile/localProfile.ts b/packages/ql3-local-sqlite/src/profile/localProfile.ts index cb2835fe..4d6bad48 100644 --- a/packages/ql3-local-sqlite/src/profile/localProfile.ts +++ b/packages/ql3-local-sqlite/src/profile/localProfile.ts @@ -43,6 +43,8 @@ export type LocalProfileStorageBootstrapResult = readonly taskStartRepository: LocalSqliteRuntimeDatabase['taskStartRepository']; readonly taskDefinitions: LocalSqliteRuntimeDatabase['taskDefinitions']; readonly taskDefinitionAdministrationForCredential: LocalSqliteRuntimeDatabase['taskDefinitionAdministrationForCredential']; + readonly triggers: LocalSqliteRuntimeDatabase['triggers']; + readonly triggerAdministrationForCredential: LocalSqliteRuntimeDatabase['triggerAdministrationForCredential']; readonly schedules: LocalSqliteRuntimeDatabase['schedules']; readonly dispatch: LocalSqliteRuntimeDatabase['localDispatch']; readonly executionControl: LocalSqliteRuntimeDatabase['executionControl']; @@ -141,6 +143,9 @@ export async function bootstrapLocalProfileStorage( taskDefinitions: database.taskDefinitions, taskDefinitionAdministrationForCredential: database.taskDefinitionAdministrationForCredential, + triggers: database.triggers, + triggerAdministrationForCredential: + database.triggerAdministrationForCredential, schedules: database.schedules, dispatch: database.localDispatch, executionControl: database.executionControl, diff --git a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts index 64c7bd98..6f9c998e 100644 --- a/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts +++ b/packages/ql3-local-sqlite/src/runtime/runtimeDatabase.ts @@ -37,6 +37,7 @@ import type { StepRunRepository } from '@qinglong/runtime-core/step-run'; import type { RunCancellationRepository } from '@qinglong/runtime-core/run-cancellation'; import type { TaskStartRepository } from '@qinglong/runtime-core/task-start'; import type { TaskDefinitionAdministrationRepository } from '@qinglong/runtime-core/task-definition-administration'; +import type { TriggerAdministrationRepository } from '@qinglong/runtime-core/trigger-administration'; import type { ToolExecutionCompletionRepository } from '@qinglong/runtime-core/tool-execution-completion'; import type { ToolExecutionFailureCompletionRepository } from '@qinglong/runtime-core/tool-execution-failure-completion'; import type { ToolExecutionStartBarrierRepository } from '@qinglong/runtime-core/tool-execution-start-barrier'; @@ -102,6 +103,9 @@ export interface LocalSqliteRuntimeDatabase { fence: Readonly, ): Promise; readonly triggers: LocalSqliteTriggerRepository; + triggerAdministrationForCredential( + fence: Readonly, + ): Promise; readonly schedules: LocalSqliteScheduleRepository; readonly localDispatch: LocalDispatchStore; readonly executionControl: LocalExecutionControlSource; @@ -284,6 +288,37 @@ export async function openLocalSqliteRuntimeDatabase( ); }, triggers, + async triggerAdministrationForCredential( + fence: Readonly, + ) { + const [administration, triggerAdministration] = await Promise.all([ + import('../administration/packageManagement.js'), + import('../scheduling/triggerAdministration.js'), + ]); + const { + confirmLocalSqliteAuthenticatedUserCredentialFence, + LocalSqliteAuthenticatedManagementFenceError, + } = administration; + const { LocalSqliteTriggerAdministrationRepository } = + triggerAdministration; + confirmLocalSqliteAuthenticatedUserCredentialFence(authority, fence); + return new LocalSqliteTriggerAdministrationRepository( + authority, + triggers, + (actor) => { + if ( + actor.type !== fence.subjectType || + actor.id !== fence.subjectId + ) { + throw new LocalSqliteAuthenticatedManagementFenceError(); + } + confirmLocalSqliteAuthenticatedUserCredentialFence( + authority, + fence, + ); + }, + ); + }, schedules, localDispatch: runRuntimeCapabilities.dispatch, executionControl: runRuntimeCapabilities.executionControl, diff --git a/packages/ql3-local-sqlite/src/scheduling/triggerAdministration.ts b/packages/ql3-local-sqlite/src/scheduling/triggerAdministration.ts index 299a96e1..a7df6b27 100644 --- a/packages/ql3-local-sqlite/src/scheduling/triggerAdministration.ts +++ b/packages/ql3-local-sqlite/src/scheduling/triggerAdministration.ts @@ -97,7 +97,7 @@ function sameCredentialFence( ); } -class LocalSqliteTriggerAdministrationRepository +export class LocalSqliteTriggerAdministrationRepository implements TriggerAdministrationRepository { constructor( diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index cf0e73d0..ab4e65a1 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -3168,6 +3168,14 @@ function auditSourceImports(root, packagePath, findings) { ) { continue; } + if ( + packagePath === 'packages/ql3-local-api' && + path.relative(packageDirectory, filePath) === + 'src/trigger/triggerPutRoute.ts' && + specifier === '@qinglong/local-admin/trigger-administration' + ) { + continue; + } if ( path.isAbsolute(specifier) || forbidden.some( diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index 66b46dde..c24daac2 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -2541,6 +2541,10 @@ test('confines Local API identity authentication to its exact read-only adapter' "import { destroy } from '@qinglong/local-owner-console/pepper-custody/destructive';", 'transport/widened.ts': "import { authenticate } from '@qinglong/local-owner-console/identity-authentication';", + 'trigger/triggerPutRoute.ts': + "import { put } from '@qinglong/local-admin/trigger-administration';", + 'trigger/widened.ts': + "import { put } from '@qinglong/local-admin/trigger-administration';", }; for (const [relativePath, source] of Object.entries(sources)) { const filePath = path.join(packageDirectory, relativePath); @@ -2562,6 +2566,10 @@ test('confines Local API identity authentication to its exact read-only adapter' code: 'FORBIDDEN_LOCAL_IDENTITY_AUTHORITY_IMPORT', file: 'packages/ql3-local-api/src/transport/widened.ts', }, + { + code: 'FORBIDDEN_PACKAGE_SOURCE_IMPORT', + file: 'packages/ql3-local-api/src/trigger/widened.ts', + }, ], ); }); diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 8e01258e..99822015 100644 --- a/test/back/ql3PackageBoundaryAudit.test.cjs +++ b/test/back/ql3PackageBoundaryAudit.test.cjs @@ -130,10 +130,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', ( rootSourceFileRoles: localApi.rootSourceFileRoles, }, { - sourceFiles: 21, + sourceFiles: 24, rootSourceFiles: 1, rootSourceLines: 84, - nestedSourceFiles: 20, + nestedSourceFiles: 23, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, );