diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 81cd74a8..ee7714b0 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-28): +- D-417/ADR-0512(已实现,Console Trial Kit 交付待后续切片):开发约二十天后的阶段产物从“可直接启动的 headless runtime”继续推进到首个可操作 Web 产品面。既有可选 `@qinglong/local-api` 现在内置 `/`、`/console.css`、`/console.js` 三文件离线 Console,总计 43,252 bytes,无前端框架、CDN、网络字体、遥测或 Web Storage;它复用同一 Node 进程、loopback listener、SQLite authority、Bearer/Project Policy/durable audit/re-confirm 链,不新增 package、sidecar、数据库连接、timer、watcher 或 cache。部署者可以按 Project 查看 Task revision/content fence、显式确认启动,查看 durable Run、Workflow Step 数与 Event sequence,并对未终止 Run 提交显式 cancellation request;取消请求不会伪装成停止终态。Credential 只在当前页面内存,服务端固定 CSP/COOP/CORP/Permissions Policy/no-referrer/nosniff/DENY/no-store,远程设备只能由操作者建立 SSH tunnel,不开放 LAN/public listener。默认 Edge/Standalone 与 D-416 Trial Kit 继续 headless、零增量;Console 只进入 opt-in `edge-application-api|standalone-application-api`,两档 artifact 为 `3,953,346 / 3,953,490 bytes`、467 files、12 packages、90 loaded modules,低于 6 MiB/640-file 门。Local API 48/48、pruner 11/11、完整 backend `1,637 total / 1,635 pass / 2 conditional skip / 0 fail`、18-package clean build/test 退出 0;package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency、122-module Edge import 与全部 14 档 Local artifact audit 均 compatible。桌面 1440×960 与手机 390×844 已检查 Task、Run/Event spine 和显式确认工作态。该切片形成可构建、可运行的 source/application-api 阶段制品,但当前 v3 Trial Kit 尚未携带 Console;把 Console image/quickstart 变成路由/NAS 可下载物、固定低性能物理 Edge 容量数据和 public release 仍是后续门。 + - D-416/ADR-0511(已实现,首份真实 v3 Trial Kit 待授权):阶段产物的判定从“可下载、可验真”推进到“部署者可直接试运行”。Local Trial Kit schema 升为 `qinglong/alpha-local-trial-kit@v3`,每个 amd64/arm64 bundle 新增由唯一模板生成并绑定 archive、两个 exact image ID、source 和 architecture 的 `quickstart.sh`;离线 auditor 会重新渲染 canonical journey,拒绝即使重算 manifest/checksum 的本地脚本改写。目标路由/NAS 或单节点只需 POSIX shell、`sha256sum` 和 Docker,即可在一个尚不存在的隔离目录完成全包 checksum、镜像 load/identity 复核、128 MiB 短生命周期 operator fresh setup、首 Owner 建立,并按 Edge `128 MiB/64 PID` 或 Standalone `256 MiB/256 PID` 启动 Application、等待 `active` 后输出 logs/stop/remove 指令。显式 artifact job 在 upload 前必须执行将要上传的 exact quickstart、检查 SQLite/Owner delivery 并 graceful stop;普通 push 不生成大 archive。聚焦 materializer/milestone/stage/workflow 回归为 `26/26`,完整 backend 为 `1636 total / 1634 pass / 2 conditional skip / 0 fail`,18-package clean build/test 退出 0;package boundary 保持 `18 packages / singleSourcePackages=[] / shallowSourcePackages=[]`,Cluster dependency、122-module Edge import、镜像/版本与 14 档 Local artifact 均 compatible,基础 Edge/Standalone 仍为 `2,669,390 / 2,669,468 bytes`、325 files、58 modules。历史同源 arm64 镜像对已本地物化为 v3 八文件开发 bundle,184,648,192-byte archive 完成 checksum、canonical audit、load 与 exact identity;Docker Desktop 在 Owner directory ownership 处按既有 POSIX 不等价失败关闭,未冒充原生 Linux 成功,正式 native exact-script 证据由手动 artifact job 产生。该切片不安装宿主 Node.js,不把 Operator 变成 daemon,不增加镜像 layer、workspace package、端口或稳态 RSS,也不触碰 2.x/migration。当前产物是无外部 listener、AI-excluded 的 headless Alpha runtime,不冒充 2.x Web UI、公开 release、生产 HA 或 LTS;首份真实双架构 v3 下载物仍需维护者授权 `produce_alpha_artifacts=true`。 - D-415/ADR-0510(已实现,首份真实 stage index 待授权):二十天开发形成的 Local 与 Cluster 候选现在有统一、面向部署者的最外层阶段交付入口。新增 `qinglong/alpha-stage-index@v1` finalizer/auditor,仅在显式 `produce_alpha_artifacts=true + alpha_artifact_scope=all` 且 Local/Cluster milestone 都成功后运行;它重新离线审计两个索引,要求 version/source/workflow SHA/ref/run/attempt 完全一致,记录两个 milestone manifest digest,并为 amd64/arm64 写出机器可读选择:低配 Edge/Standalone 只下载一个 Local Trial Kit、稳态只运行 Application 且 Operator 短时启动;Cluster 下载 control/admin/worker,按需增加 control-ai。跨索引审计必须同时持有 stage 与两个 milestone,拒绝跨运行混用、内容/选择漂移和额外文件。该索引不重复十个大 archive,不改变 Local-only/Cluster-only 授权路径,也不冒充受保护 tag、GHCR immutable digest、签名、正式 OCI catalog、生产 deployment lock 或 HA;普通 push/PR 不生成实际阶段 artifact。实现不新增 workspace package、runtime dependency、镜像 layer、端口、daemon、timer、连接池或 Edge/Standalone 稳态 RSS。 diff --git a/docs/adr/ADR-0512-bounded-offline-local-web-console.md b/docs/adr/ADR-0512-bounded-offline-local-web-console.md new file mode 100644 index 00000000..34ce0e1b --- /dev/null +++ b/docs/adr/ADR-0512-bounded-offline-local-web-console.md @@ -0,0 +1,77 @@ +# ADR-0512:有界、离线的 Local Web Console + +- 状态:Accepted +- 日期:2026-08-28 +- 决策:D-417 +- 关联:ADR-0367、ADR-0370、ADR-0371、ADR-0374、ADR-0375、ADR-0503、ADR-0511 + +## 背景 + +开发约二十天后,D-416 已把 Local Alpha 闭合为可在 fresh 目录直接启动的阶段产物,但常驻 Application 仍是 headless runtime。仓库已有可选 `@qinglong/local-api`:它在同一 Node.js 进程、同一 SQLite authority 和一个 loopback listener 上提供 Task/Run 读取、启动、取消、事件、步骤和日志 API。部署者仍需手写 HTTP 请求,无法把已有后端能力视为可操作产品。 + +直接把 2.x 前端带入 3.0 会重新引入大依赖树、旧 API 假设和低配设备负担;单独启动前端 sidecar 又会增加第二个常驻进程、端口和部署故障面。阶段性产品需要一个足够小、可离线、与现有 authority 同源的操作界面,同时必须诚实区分默认 headless Trial Kit 和显式 opt-in 的 `application-api` Profile。 + +## 决策 + +### 1. Console 属于既有 Local API 制品 + +Console 由 `@qinglong/local-api` 自带固定的 `/`、`/console.css`、`/console.js` 三文件闭包,通过既有 loopback HTTP surface 提供。它不新增 workspace package、前端框架、第三方运行时依赖、listener、sidecar、数据库连接、timer、watcher 或 cache;静态文件在进程启动时一次性加载并校验。 + +单文件上限为 96 KiB,总闭包上限为 192 KiB。当前三文件共 43,252 bytes。运行时 artifact pruner 只允许调用方显式保留 `local-api/assets/console/console.js`;路径必须是已安装、规范化的 package-relative regular file,不能用目录通配保留未审计脚本。 + +### 2. 第一阶段只关闭最常用操作回路 + +Console 提供: + +- 按 Project 查看最多 64 个 Task,读取 revision、schema、content digest 和 enabled 状态; +- 对当前 Task 进行带 revision/content fence 的显式确认启动; +- 查看最多 64 个 durable Run,读取状态、version、执行归属和创建时间; +- 查看 Run 的最多 64 条 Event sequence 与 Workflow Step 数; +- 对未终止 Run 进行显式确认的 durable cancellation request。 + +页面不创建或编辑 Task,不管理 Identity/Policy/Secret/Plugin,不内嵌终端,也不把“取消已请求”展示成“执行已经停止”。超过窗口的数据继续通过 API keyset/pagination 读取。 + +### 3. 凭据和浏览器边界失败关闭 + +静态壳层无需凭据即可从 loopback 读取,所有 `/api/v3` 请求继续经过原 Bearer authentication、Project Policy、durable audit 和 authority re-confirm。Credential 只保存在当前页面 JavaScript closure;输入框在连接后清空并禁用,断开时清除状态。禁止 URL、Cookie、`localStorage`、`sessionStorage`、Service Worker 和外部资源保存或传输 credential。 + +服务端发送 `default-src 'none'` CSP、same-origin COOP/CORP、Permissions Policy、no-referrer、nosniff、DENY 和 no-store。HTML/CSS/JS 不引用网络字体、CDN、遥测或远程图片,不使用 inline script/style、`innerHTML` 或 `eval`。listener 继续只允许 `127.0.0.1|::1`;远程管理必须由部署者显式建立 SSH tunnel 或等价受信通道,Console 不把 Local API 扩展为 LAN/public listener。 + +### 4. 默认 Edge/Standalone 保持零增量 + +D-416 的 Local Trial Kit 和基础 `edge|standalone` Application 仍保持 headless,不包含 `@qinglong/local-api`。只有选择 `edge-application-api|standalone-application-api` 的制品才携带 Console,因此低配路由器不为未使用的 UI 支付包体、listener 或稳态 RSS;Cluster 节点继续使用 Cluster Control 的独立产品与部署路径。 + +本决策形成可构建、可运行的 `application-api` 阶段制品,但不声称当前 v3 Trial Kit 已经携带 Web Console。把 opt-in Console image/quickstart 变成面向路由/NAS 的实际下载物是下一交付切片。 + +## 被拒绝的替代方案 + +### 新建前端 workspace package + +拒绝。三个离线资产没有独立领域 authority 或复用消费者;新包会重新制造用户此前指出的过细 package 和单文件包问题。 + +### 引入 React/Vue/Umi 构建链 + +拒绝。当前操作面规模不足以抵消依赖、漏洞、构建和低配包体成本;后续功能达到独立应用规模时再以新 ADR 评估。 + +### 独立静态服务器或 sidecar + +拒绝。它增加常驻进程、端口、健康检查和跨 origin credential 处理,同时没有新的 authority 价值。 + +### 直接监听局域网地址 + +拒绝。Alpha 尚未关闭 TLS、CSRF、可信代理、会话撤销和远程暴露门;loopback + 显式 tunnel 保持风险可见。 + +## 影响 + +- `application-api` Edge/Standalone 制品当前为 3,953,346 / 3,953,490 bytes、467 files、12 packages,远低于 6 MiB/640-file budget;加载闭包为 90 modules,实测 import RSS delta 约 14.0 MiB; +- 基础 headless Edge/Standalone、D-416 Trial Kit、Cluster Profile 和 AI/MCP Profile 不因本切片新增 Console 进程或端口; +- Console 视觉和交互已在 1440×960 与 390×844 视口验证,工作态覆盖 Task detail、运行确认、Run evidence/Event sequence 和取消入口; +- 当前仍缺少面向部署用户的 Console Docker image/Trial Kit 选择、真实低性能物理 Edge 容量数据,以及受保护 public release。 + +## 验证 + +- Local API 完整测试 48/48:包含三资产闭包、离线/credential custody、真实 loopback 静态响应、favicon、GET body/query alias 拒绝,以及既有认证 API/SQLite/过载/drain 回归; +- runtime artifact pruner 11/11:显式 JS asset 保留、缺失/逃逸/重复路径在任何删除前失败关闭; +- `edge-application-api` 与 `standalone-application-api` artifact audit 均 `compatible=true`,且证明 Console 被 pack、未被 runtime pruning 删除; +- 完整 backend 为 1,637 total / 1,635 pass / 2 conditional skip / 0 fail,18-package clean build/test 退出 0; +- package boundary 保持 18 packages 且无 single-source/shallow package,Cluster dependency、122-module Edge import 与全部 14 档 Local artifact audit 均 `compatible=true`;远程 CI 结果在本切片推送后记录。 diff --git a/docs/adr/README.md b/docs/adr/README.md index 35bd1d64..176a301b 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -515,6 +515,7 @@ | [ADR-0509](./ADR-0509-cluster-alpha-bundle-and-milestone-closure.md) | Cluster Alpha Bundle 与跨架构里程碑闭合 | Accepted(首份实际 Cluster milestone artifact 待维护者授权) | | [ADR-0510](./ADR-0510-cross-profile-alpha-stage-delivery-index.md) | 跨 Profile Alpha 阶段交付索引 | Accepted(首份实际 stage index 待维护者授权) | | [ADR-0511](./ADR-0511-runnable-local-alpha-quickstart.md) | 可直接试运行的 Local Alpha Quickstart | Accepted(首份实际 v3 Trial Kit 待维护者授权) | +| [ADR-0512](./ADR-0512-bounded-offline-local-web-console.md) | 有界、离线的 Local Web Console | Accepted(Console Trial Kit 交付待后续切片) | ## 规则 diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md index 43b2c559..50c8a0b9 100644 --- a/docs/operations/ql3-alpha-candidate.md +++ b/docs/operations/ql3-alpha-candidate.md @@ -17,6 +17,15 @@ ## 当前阶段实物(2026-08-27) +在下面保留的历史 exact-image 证据之外,2026-08-28 的源码阶段已经形成两条可独立验收的产品线: + +| 阶段产物 | 当前可用能力 | 仍缺少 | +| --- | --- | --- | +| D-416 Local Alpha Trial Kit v3 | POSIX shell + Docker 一条命令完成 checksum、load、fresh setup、首 Owner、headless Application active/stop | 首份远端双架构实际 artifact 仍需维护者授权 `produce_alpha_artifacts=true` | +| D-417 opt-in Local Web Console | `application-api` 制品可在同进程/同 SQLite/loopback listener 上查看 Task/Run/Event 并显式运行或取消;Edge/Standalone 制品审计通过 | 尚未进入 Trial Kit Docker image/quickstart,不能冒充当前路由/NAS 下载物 | + +这两个边界共同防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:D-416 已关闭 fresh headless 用户旅程,D-417 已关闭 source/application-api 操作界面,但两者尚未合并成实际可下载的 Console Trial Kit。操作说明见 [Local Web Console](./ql3-local-web-console.md)。 + 提交 `4239464af6937d56528a0a2c573d12329bc7ca55` 已形成最新 owner-private arm64 工程候选: - Application image ID `sha256:0d1d4b80ee46e9bb671d846f93d9a6d832c9856a91eed03f299055904da88a50`,operator image ID `sha256:b9122f481b1ba60d7eee9a3ed5ca57c9c141cbc389e7c7dbe19c6f6b1c98b49e`; diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md index 598393f0..8fb479eb 100644 --- a/docs/operations/ql3-local-alpha-trial-kit.md +++ b/docs/operations/ql3-local-alpha-trial-kit.md @@ -68,7 +68,9 @@ Owner delivery 保留在新数据目录的 `owner-delivery/`,operator command 当前 Application 是无外部 listener、AI-excluded 的 headless Alpha runtime。该试运行可 验证 3.0 SQLite、Owner authority、调度/插件基础与生命周期,但还不是 2.x Web UI 的 -可替代版本。 +可替代版本。D-417 已在 opt-in `application-api` Profile 提供首个 Local Web Console, +但本 v3 Trial Kit 尚未携带该 Profile;不要期待 quickstart 启动后出现 Web 端口。Console +当前使用方式与边界见 [Local Web Console](./ql3-local-web-console.md)。 ## 手工加载与最小 smoke diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md new file mode 100644 index 00000000..f62e4226 --- /dev/null +++ b/docs/operations/ql3-local-web-console.md @@ -0,0 +1,75 @@ +# QingLong 3.0 Local Web Console + +Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查看 Task、Run 和执行事件,并显式启动或取消一次运行。它不是当前 headless Local Alpha Trial Kit 的组成部分,也不是 2.x Web UI 的完整替代品。 + +## 选择部署档位 + +| 场景 | 建议 | +| --- | --- | +| 内存很小、只需后台自动化的路由/NAS | 继续使用默认 `edge` headless Application;Console 零增量 | +| 路由/NAS 需要临时人工查看与操作 | 选择 `edge-application-api`,只通过 loopback 或 SSH tunnel 访问 | +| 普通单节点服务器 | 选择 `standalone-application-api` | +| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 | + +当前仓库能生成 Node.js `application-api` 制品;D-416 的 v3 Docker Trial Kit 仍只包含 headless Application。需要下载即用的 Console image/quickstart 时,应等待后续 Console Trial Kit 切片,不能把本页命令误认为当前公开下载物。 + +## 前置条件 + +- 已完成 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 的权限;启动和取消分别还需要 `run.start` 与 `run.stop`; +- config、keyring、database 和 credential delivery 保持既有 `0700/0600`、no-symlink 和同 UID authority。 + +## 启动 + +创建私有 `local-api.json`: + +```json +{ + "schema": "qinglong/local-api-process@v1", + "deploymentRoot": "/srv/qinglong3", + "applicationConfigFilePath": "/srv/qinglong3/private/application.json", + "ownerPepperKeyringDirectory": "/srv/qinglong3/private/owner-pepper", + "listener": { "host": "127.0.0.1", "port": 5701 } +} +``` + +运行同一进程的 Application + API + Console: + +```sh +ql3-local-api --config /srv/qinglong3/private/local-api.json +``` + +在设备本机打开 `http://127.0.0.1:5701/`。服务只接受 `127.0.0.1` 或 `::1`,不会监听 LAN 地址。 + +从管理电脑访问路由/NAS 时,显式建立受信 SSH tunnel: + +```sh +ssh -L 5701:127.0.0.1:5701 router.example +``` + +随后在管理电脑打开 `http://127.0.0.1:5701/`。不要用反向代理临时绕过 loopback;TLS、可信代理、CSRF 和远程会话边界尚未作为本阶段产品门验收。 + +## 使用 + +1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。 +2. 在“任务”中选择 Task,核对 revision/content fence 后才能“运行一次”。 +3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度。 +4. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。 +5. 完成后选择“断开并清除凭据”,再关闭页面。 + +Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 + +## 当前阶段可用边界 + +当前可操作闭环是 Task list/read/start 与 Run list/read/events/steps/cancel。页面不负责: + +- 创建、编辑、启停 Task; +- Identity、Policy、Secret、Plugin Package 或 AI 配置管理; +- Run log 展示、终端、文件管理或 2.x 数据迁移; +- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 + +三项静态资产总计 43,252 bytes,不依赖 CDN、网络字体或前端框架。`edge-application-api` 制品为 3,953,346 bytes/467 files,`standalone-application-api` 为 3,953,490 bytes/467 files;基础 headless Profile 不携带这些资产。 + +停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。 diff --git a/packages/ql3-local-api/assets/console/console.css b/packages/ql3-local-api/assets/console/console.css new file mode 100644 index 00000000..a67d2303 --- /dev/null +++ b/packages/ql3-local-api/assets/console/console.css @@ -0,0 +1,795 @@ +:root { + --fog: #e8eeef; + --paper: #f8fbfa; + --ink: #14272c; + --deep: #19353d; + --deep-soft: #254750; + --signal: #009fb4; + --signal-soft: #c8edf0; + --amber: #e3972f; + --danger: #ba4a43; + --line: #b9c8ca; + --muted: #64777b; + --white: #ffffff; + --shadow: 0 18px 60px rgba(15, 45, 53, 0.12); + font-family: 'Avenir Next', Avenir, 'Segoe UI', system-ui, sans-serif; + color: var(--ink); + background: var(--fog); +} + +* { + box-sizing: border-box; +} + +html, +body { + min-height: 100%; + margin: 0; +} + +body { + background: linear-gradient(rgba(20, 39, 44, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(20, 39, 44, 0.035) 1px, transparent 1px), + var(--fog); + background-size: 24px 24px; +} + +button, +input { + font: inherit; +} + +button { + cursor: pointer; +} + +button:focus-visible, +input:focus-visible, +[tabindex]:focus-visible { + outline: 3px solid var(--amber); + outline-offset: 3px; +} + +.skip-link { + position: fixed; + z-index: 20; + top: 12px; + left: 12px; + padding: 10px 14px; + color: var(--white); + background: var(--deep); + transform: translateY(-150%); +} + +.skip-link:focus { + transform: translateY(0); +} + +.shell { + min-height: 100vh; + display: grid; + grid-template-columns: minmax(280px, 22vw) 1fr; +} + +.gate { + position: sticky; + top: 0; + min-height: 100vh; + padding: 28px clamp(24px, 3vw, 44px); + display: flex; + flex-direction: column; + color: var(--white); + background: var(--deep); + box-shadow: var(--shadow); +} + +.brand { + display: flex; + align-items: center; + gap: 10px; + letter-spacing: 0.02em; +} + +.brand-mark { + width: 28px; + height: 28px; + display: grid; + grid-template-columns: repeat(3, 1fr); + align-items: end; + gap: 3px; +} + +.brand-mark i { + display: block; + background: var(--signal); +} + +.brand-mark i:nth-child(1) { + height: 42%; +} +.brand-mark i:nth-child(2) { + height: 100%; +} +.brand-mark i:nth-child(3) { + height: 68%; +} + +.brand-name { + font-weight: 700; +} + +.brand-version { + margin-left: auto; + padding: 3px 7px; + border: 1px solid rgba(255, 255, 255, 0.28); + font: 600 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; + letter-spacing: 0.12em; +} + +.gate-copy { + margin: clamp(56px, 11vh, 104px) 0 32px; +} + +.eyebrow { + margin: 0 0 12px; + color: var(--signal); + font: 700 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; + letter-spacing: 0.14em; + text-transform: uppercase; +} + +.gate-copy h1 { + margin: 0; + font-family: 'Avenir Next Condensed', 'Arial Narrow', 'Segoe UI', sans-serif; + font-size: clamp(42px, 5.4vw, 70px); + font-weight: 600; + line-height: 0.92; + letter-spacing: -0.045em; +} + +.gate-copy > p:last-child { + max-width: 27ch; + margin: 24px 0 0; + color: rgba(255, 255, 255, 0.68); + font-size: 14px; + line-height: 1.7; +} + +.connection-state { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 0; + border-top: 1px solid rgba(255, 255, 255, 0.16); + border-bottom: 1px solid rgba(255, 255, 255, 0.16); + font: 650 12px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.signal { + width: 9px; + height: 9px; + border-radius: 50%; + background: var(--amber); + box-shadow: 0 0 0 4px rgba(227, 151, 47, 0.16); +} + +.connection-state[data-state='connected'] .signal { + background: var(--signal); + box-shadow: 0 0 0 4px rgba(0, 159, 180, 0.18); +} + +.connection-state[data-state='error'] .signal { + background: var(--danger); + box-shadow: 0 0 0 4px rgba(186, 74, 67, 0.18); +} + +.credential-form { + display: grid; + gap: 10px; + margin-top: 22px; +} + +.credential-form label { + color: rgba(255, 255, 255, 0.62); + font: 700 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.credential-form input { + width: 100%; + padding: 11px 12px; + color: var(--white); + border: 1px solid rgba(255, 255, 255, 0.24); + border-radius: 0; + background: rgba(255, 255, 255, 0.055); + font: 500 13px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.credential-form input::placeholder { + color: rgba(255, 255, 255, 0.32); +} + +.primary-button, +.quiet-button, +.danger-button, +.refresh-button, +.action-button { + min-height: 40px; + padding: 9px 14px; + border: 1px solid transparent; + border-radius: 0; + font-weight: 700; +} + +.primary-button { + margin-top: 4px; + color: var(--deep); + background: var(--signal); +} + +.quiet-button { + color: inherit; + border-color: currentColor; + background: transparent; +} + +.danger-button { + color: var(--white); + background: var(--danger); +} + +.privacy-note { + margin: 4px 0 0; + color: rgba(255, 255, 255, 0.48); + font-size: 11px; + line-height: 1.5; +} + +.section-nav { + margin-top: 28px; + border-top: 1px solid rgba(255, 255, 255, 0.16); +} + +.section-nav button { + width: 100%; + padding: 14px 0; + display: flex; + justify-content: space-between; + color: rgba(255, 255, 255, 0.6); + border: 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.16); + background: transparent; + text-align: left; +} + +.section-nav button[aria-current='page'] { + color: var(--white); +} + +.section-nav button[aria-current='page'] span::before { + content: ''; + display: inline-block; + width: 14px; + height: 3px; + margin: 0 10px 3px 0; + background: var(--signal); +} + +.section-nav kbd { + color: inherit; + font: 600 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.gate-footer { + margin-top: auto; + padding-top: 24px; + display: flex; + justify-content: space-between; + color: rgba(255, 255, 255, 0.38); + font: 600 9px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.workspace { + min-width: 0; + padding: clamp(28px, 5vw, 72px); +} + +.masthead { + display: flex; + align-items: end; + justify-content: space-between; + gap: 32px; + margin-bottom: 34px; +} + +.masthead h2 { + margin: 0; + font-family: 'Avenir Next Condensed', 'Arial Narrow', 'Segoe UI', sans-serif; + font-size: clamp(34px, 4.2vw, 58px); + font-weight: 600; + line-height: 1; + letter-spacing: -0.04em; +} + +.masthead > div > p:last-child { + max-width: 65ch; + margin: 14px 0 0; + color: var(--muted); + line-height: 1.6; +} + +.refresh-button { + flex: 0 0 auto; + color: var(--deep); + border-color: var(--line); + background: rgba(255, 255, 255, 0.5); +} + +.workspace-grid { + display: grid; + grid-template-columns: minmax(0, 1.55fr) minmax(280px, 0.85fr); + min-height: 620px; + border: 1px solid var(--line); + background: rgba(248, 251, 250, 0.82); + box-shadow: var(--shadow); +} + +.ledger, +.detail { + min-width: 0; + padding: clamp(20px, 3vw, 34px); +} + +.ledger { + border-right: 1px solid var(--line); +} + +.detail { + background: rgba(255, 255, 255, 0.46); +} + +.welcome-state, +.detail-empty, +.empty-state, +.loading-state, +.error-state { + min-height: 420px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; +} + +.welcome-index { + color: var(--signal); + font: 800 clamp(46px, 8vw, 100px) / 0.85 'Avenir Next Condensed', + 'Arial Narrow', sans-serif; + letter-spacing: -0.05em; +} + +.welcome-state p { + margin: 28px 0; + color: var(--muted); + font-size: 15px; + letter-spacing: 0.06em; +} + +.welcome-rule { + width: 180px; + display: grid; + grid-template-columns: 1fr 2fr 1fr; + gap: 5px; +} + +.welcome-rule i { + height: 4px; + background: var(--line); +} + +.welcome-rule i:nth-child(2) { + background: var(--signal); +} + +.detail-glyph { + color: var(--signal); + font-size: 54px; +} + +.detail-empty p, +.empty-state p, +.error-state p { + max-width: 30ch; + color: var(--muted); + line-height: 1.6; +} + +.list-header { + margin-bottom: 18px; + display: flex; + align-items: baseline; + justify-content: space-between; + border-bottom: 2px solid var(--ink); + padding-bottom: 10px; +} + +.list-header strong { + font-size: 13px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.list-header span { + color: var(--muted); + font: 600 11px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.record-list { + display: grid; + gap: 0; +} + +.record { + width: 100%; + padding: 18px 4px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 16px; + color: var(--ink); + border: 0; + border-bottom: 1px solid var(--line); + background: transparent; + text-align: left; +} + +.record:hover, +.record[aria-current='true'] { + background: linear-gradient(90deg, var(--signal-soft), transparent 74%); +} + +.record-title { + display: block; + overflow: hidden; + font-weight: 750; + text-overflow: ellipsis; + white-space: nowrap; +} + +.record-meta { + margin-top: 7px; + display: flex; + flex-wrap: wrap; + gap: 7px 14px; + color: var(--muted); + font: 550 11px/1.4 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.record-side { + display: grid; + justify-items: end; + align-content: center; + gap: 7px; +} + +.status { + display: inline-flex; + align-items: center; + gap: 7px; + font: 750 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.status::before { + content: ''; + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--signal); +} + +.status[data-tone='waiting']::before { + background: var(--amber); +} +.status[data-tone='failed']::before { + background: var(--danger); +} +.status[data-tone='quiet']::before { + background: var(--line); +} + +.record-time { + color: var(--muted); + font: 500 10px/1.2 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.detail-header { + padding-bottom: 20px; + border-bottom: 2px solid var(--ink); +} + +.detail-header h3 { + margin: 5px 0 8px; + font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif; + font-size: 30px; + font-weight: 650; + line-height: 1; + overflow-wrap: anywhere; +} + +.detail-header code, +.fact dd, +.event-sequence { + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.detail-header code { + color: var(--muted); + font-size: 11px; + overflow-wrap: anywhere; +} + +.facts { + margin: 22px 0; + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px; +} + +.fact { + min-width: 0; + margin: 0; + padding-left: 10px; + border-left: 3px solid var(--line); +} + +.fact dt { + color: var(--muted); + font-size: 10px; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.fact dd { + margin: 5px 0 0; + font-size: 12px; + overflow-wrap: anywhere; +} + +.detail-actions { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 22px 0; +} + +.action-button { + color: var(--deep); + border-color: var(--deep); + background: transparent; +} + +.action-button[data-kind='danger'] { + color: var(--danger); + border-color: var(--danger); +} + +.timeline-heading { + margin: 30px 0 14px; + font-size: 12px; + letter-spacing: 0.1em; + text-transform: uppercase; +} + +.event-spine { + position: relative; + margin: 0; + padding: 0; + list-style: none; +} + +.event-spine::before { + content: ''; + position: absolute; + top: 7px; + bottom: 7px; + left: 16px; + width: 2px; + background: var(--line); +} + +.event { + position: relative; + min-height: 50px; + padding: 0 0 18px 46px; +} + +.event-sequence { + position: absolute; + z-index: 1; + top: 0; + left: 0; + width: 34px; + height: 24px; + display: grid; + place-items: center; + color: var(--deep); + border: 2px solid var(--signal); + background: var(--paper); + font-size: 10px; + font-weight: 800; +} + +.event strong { + display: block; + font-size: 12px; + overflow-wrap: anywhere; +} + +.event span:last-child { + display: block; + margin-top: 4px; + color: var(--muted); + font: 500 10px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace; +} + +.confirmation-dialog { + width: min(460px, calc(100vw - 32px)); + padding: 0; + color: var(--ink); + border: 1px solid var(--line); + border-radius: 0; + background: var(--paper); + box-shadow: var(--shadow); +} + +.confirmation-dialog::backdrop { + background: rgba(11, 31, 36, 0.7); +} + +.confirmation-dialog form { + padding: 30px; +} + +.confirmation-dialog h2 { + margin: 0; + font-family: 'Avenir Next Condensed', 'Arial Narrow', sans-serif; + font-size: 32px; +} + +.confirmation-dialog form > p:not(.eyebrow) { + color: var(--muted); + line-height: 1.6; +} + +.dialog-actions { + margin-top: 26px; + display: flex; + justify-content: flex-end; + gap: 10px; +} + +.confirmation-dialog .quiet-button { + color: var(--deep); +} + +.toast { + position: fixed; + z-index: 30; + right: 24px; + bottom: 24px; + max-width: min(420px, calc(100vw - 48px)); + padding: 14px 18px; + color: var(--white); + background: var(--deep-soft); + box-shadow: var(--shadow); + font-size: 13px; +} + +.toast[data-tone='error'] { + background: var(--danger); +} + +.loading-state span { + width: 34px; + height: 34px; + border: 3px solid var(--line); + border-top-color: var(--signal); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 980px) { + .shell { + grid-template-columns: 240px 1fr; + } + + .workspace { + padding: 32px; + } + + .workspace-grid { + grid-template-columns: 1fr; + } + + .ledger { + border-right: 0; + border-bottom: 1px solid var(--line); + } +} + +@media (max-width: 720px) { + .shell { + display: block; + } + + .gate { + position: relative; + min-height: auto; + padding: 22px; + } + + .gate-copy { + margin: 42px 0 26px; + } + + .gate-copy h1 { + font-size: 48px; + } + + .gate-footer { + margin-top: 36px; + } + + .workspace { + padding: 26px 18px 48px; + } + + .masthead { + align-items: flex-start; + flex-direction: column; + } + + .masthead h2 { + font-size: 40px; + } + + .workspace-grid { + min-height: 0; + } + + .welcome-state, + .detail-empty, + .empty-state, + .loading-state, + .error-state { + min-height: 300px; + } + + .record { + grid-template-columns: 1fr; + } + + .record-side { + justify-items: start; + } +} + +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + scroll-behavior: auto !important; + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + } +} diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js new file mode 100644 index 00000000..e393a2dd --- /dev/null +++ b/packages/ql3-local-api/assets/console/console.js @@ -0,0 +1,730 @@ +(() => { + 'use strict'; + + const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + const TOKEN_PATTERN = + /^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/; + const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); + const STATUS_LABELS = Object.freeze({ + created: '已创建', + queued: '排队中', + dispatching: '派发中', + running: '运行中', + cancel_requested: '正在取消', + succeeded: '成功', + failed: '失败', + cancelled: '已取消', + timed_out: '已超时', + }); + const ERROR_LABELS = Object.freeze({ + authentication_required: '凭据无效或已经失效。请断开连接后重新输入。', + authorization_denied: '当前身份没有执行该操作的权限。', + server_overloaded: '设备正在处理其他请求,请稍后刷新。', + server_draining: '服务正在停止,暂不接受新请求。', + task_list_unavailable: '任务列表暂时不可用。请检查本机服务与数据库。', + task_query_unavailable: '任务详情暂时不可用。', + run_list_unavailable: '运行列表暂时不可用。请检查本机服务与数据库。', + run_query_unavailable: '运行详情暂时不可用。', + run_event_list_unavailable: '运行事件暂时不可用。', + run_step_list_unavailable: 'Workflow Step 暂时不可用。', + task_start_fence_rejected: + '任务在确认期间发生变化,本次启动已安全拒绝。请刷新后重试。', + run_cancellation_fence_rejected: + '运行在确认期间发生变化,本次取消已安全拒绝。请刷新后重试。', + request_unavailable: '本次请求没有完成,请确认服务仍在运行。', + }); + + const nodes = Object.freeze({ + form: document.getElementById('credential-form'), + project: document.getElementById('project-input'), + token: document.getElementById('token-input'), + disconnect: document.getElementById('disconnect-button'), + connection: document.getElementById('connection-state'), + connectionLabel: document.getElementById('connection-label'), + nav: document.querySelector('.section-nav'), + ledger: document.getElementById('ledger'), + detail: document.getElementById('detail'), + kicker: document.getElementById('section-kicker'), + title: document.getElementById('section-title'), + description: document.getElementById('section-description'), + refresh: document.getElementById('refresh-button'), + dialog: document.getElementById('confirmation-dialog'), + dialogTitle: document.getElementById('confirmation-title'), + dialogCopy: document.getElementById('confirmation-copy'), + dialogAccept: document.getElementById('confirmation-accept'), + toast: document.getElementById('toast'), + }); + + const state = { + token: null, + project: 'default', + view: 'tasks', + selectedId: null, + pendingAction: null, + toastTimer: null, + }; + + class ConsoleRequestError extends Error { + constructor(code, status, requestId) { + super(code); + this.name = 'ConsoleRequestError'; + this.code = code; + this.status = status; + this.requestId = requestId; + } + } + + function element(tag, className, text) { + const value = document.createElement(tag); + if (className) value.className = className; + if (text !== undefined) value.textContent = String(text); + return value; + } + + function replace(node, ...children) { + node.replaceChildren(...children); + } + + function formatTime(value) { + if (!Number.isSafeInteger(value) || value < 0) return '—'; + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).format(new Date(value)); + } + + function shortDigest(value) { + return typeof value === 'string' && value.length > 18 + ? `${value.slice(0, 10)}…${value.slice(-6)}` + : value || '—'; + } + + function statusTone(status) { + if (status === 'failed' || status === 'timed_out') return 'failed'; + if ( + status === 'created' || + status === 'queued' || + status === 'dispatching' || + status === 'cancel_requested' + ) { + return 'waiting'; + } + if (status === 'cancelled') return 'quiet'; + return 'active'; + } + + function statusBadge(status) { + const badge = element('span', 'status', STATUS_LABELS[status] || status); + badge.dataset.tone = statusTone(status); + return badge; + } + + function newMutationId() { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + const bytes = crypto.getRandomValues(new Uint8Array(16)); + bytes[6] = (bytes[6] & 0x0f) | 0x40; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = [...bytes].map((value) => value.toString(16).padStart(2, '0')); + return `${hex.slice(0, 4).join('')}-${hex.slice(4, 6).join('')}-${hex + .slice(6, 8) + .join('')}-${hex.slice(8, 10).join('')}-${hex.slice(10).join('')}`; + } + + async function api(path, options = {}) { + if (!state.token) { + throw new ConsoleRequestError('authentication_required', 401, null); + } + const headers = { + accept: 'application/json', + authorization: `Bearer ${state.token}`, + }; + let body; + if (options.body !== undefined) { + body = JSON.stringify(options.body); + headers['content-type'] = 'application/json'; + } + let response; + try { + response = await fetch(path, { + method: options.method || 'GET', + headers, + ...(body === undefined ? {} : { body }), + cache: 'no-store', + credentials: 'omit', + redirect: 'error', + referrerPolicy: 'no-referrer', + }); + } catch { + throw new ConsoleRequestError('request_unavailable', 503, null); + } + let value; + try { + value = await response.json(); + } catch { + throw new ConsoleRequestError( + 'response_unavailable', + response.status, + response.headers.get('x-request-id'), + ); + } + if (!response.ok) { + throw new ConsoleRequestError( + typeof value.code === 'string' ? value.code : 'request_unavailable', + response.status, + response.headers.get('x-request-id'), + ); + } + return value; + } + + function describeError(error) { + if (!(error instanceof ConsoleRequestError)) + return '操作没有完成,请刷新后重试。'; + const label = ERROR_LABELS[error.code] || `请求被拒绝:${error.code}`; + return error.requestId ? `${label} 请求编号 ${error.requestId}` : label; + } + + function showToast(message, tone = 'ok') { + if (state.toastTimer) window.clearTimeout(state.toastTimer); + nodes.toast.textContent = message; + nodes.toast.dataset.tone = tone; + nodes.toast.hidden = false; + state.toastTimer = window.setTimeout(() => { + nodes.toast.hidden = true; + state.toastTimer = null; + }, 5_000); + } + + function setConnection(mode, label) { + nodes.connection.dataset.state = mode; + nodes.connectionLabel.textContent = label; + } + + function setBusy(value) { + nodes.ledger.setAttribute('aria-busy', String(value)); + nodes.refresh.disabled = value; + } + + function loading() { + const box = element('div', 'loading-state'); + box.append(element('span')); + replace(nodes.ledger, box); + setBusy(true); + } + + function empty(message) { + const box = element('div', 'empty-state'); + box.append(element('p', null, message)); + replace(nodes.ledger, box); + } + + function errorState(error) { + const box = element('div', 'error-state'); + box.append(element('p', null, describeError(error))); + const retry = element('button', 'action-button', '重新读取'); + retry.type = 'button'; + retry.addEventListener('click', () => refresh()); + box.append(retry); + replace(nodes.ledger, box); + setConnection('error', '连接需要检查'); + } + + function detailEmpty(message = '选择一项,查看它的执行围栏与事件轨迹。') { + const box = element('div', 'detail-empty'); + box.append(element('span', 'detail-glyph', '⌁')); + box.append(element('p', null, message)); + replace(nodes.detail, box); + } + + function listHeader(label, count) { + const header = element('div', 'list-header'); + header.append(element('strong', null, label)); + header.append(element('span', null, `${count} 条 / 当前窗口`)); + return header; + } + + function recordMeta(values) { + const meta = element('span', 'record-meta'); + for (const value of values) meta.append(element('span', null, value)); + return meta; + } + + function fact(label, value) { + const item = element('dl', 'fact'); + item.append(element('dt', null, label)); + item.append(element('dd', null, value)); + return item; + } + + function detailHeader(kicker, title, identity) { + const header = element('header', 'detail-header'); + header.append(element('p', 'eyebrow', kicker)); + header.append(element('h3', null, title)); + header.append(element('code', null, identity)); + return header; + } + + function actionButton(label, handler, kind = 'normal') { + const button = element('button', 'action-button', label); + button.type = 'button'; + button.dataset.kind = kind; + button.addEventListener('click', handler); + return button; + } + + function confirmAction(title, copy, acceptLabel, action, dangerous = false) { + state.pendingAction = action; + nodes.dialogTitle.textContent = title; + nodes.dialogCopy.textContent = copy; + nodes.dialogAccept.textContent = acceptLabel; + nodes.dialogAccept.className = dangerous + ? 'danger-button' + : 'primary-button'; + nodes.dialog.returnValue = ''; + nodes.dialog.showModal(); + } + + async function renderTasks() { + const value = await api(`/api/v3/projects/${state.project}/tasks?limit=64`); + const tasks = Array.isArray(value.tasks) ? value.tasks : []; + if (tasks.length === 0) { + empty('还没有可见任务。请先通过受信任的管理入口发布 Task。'); + return; + } + const fragment = document.createDocumentFragment(); + fragment.append(listHeader('Task switchboard', tasks.length)); + const list = element('div', 'record-list'); + for (const task of tasks) { + const button = element('button', 'record'); + button.type = 'button'; + button.dataset.identity = task.taskId; + if (state.selectedId === task.taskId) + button.setAttribute('aria-current', 'true'); + const main = element('span'); + main.append(element('span', 'record-title', task.name)); + main.append( + recordMeta([ + task.taskId, + `${task.kind} · rev ${task.revision}`, + task.specSchema, + ]), + ); + const side = element('span', 'record-side'); + const enabled = element( + 'span', + 'status', + task.enabled ? '可运行' : '已停用', + ); + enabled.dataset.tone = task.enabled ? 'active' : 'quiet'; + side.append(enabled); + side.append(element('span', 'record-time', formatTime(task.updatedAtMs))); + button.append(main, side); + button.addEventListener('click', () => selectTask(task.taskId)); + list.append(button); + } + fragment.append(list); + if (value.hasMore) { + fragment.append( + element( + 'p', + 'privacy-note', + '当前只展示前 64 条;完整分页将在后续 Console 切片开放。', + ), + ); + } + replace(nodes.ledger, fragment); + } + + async function selectTask(taskId) { + state.selectedId = taskId; + const selected = nodes.ledger.querySelectorAll('.record'); + for (const row of selected) { + if (row.dataset.identity === taskId) { + 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}/tasks/${taskId}`, + ); + renderTaskDetail(value.task); + } catch (error) { + detailEmpty(describeError(error)); + } + } + + function renderTaskDetail(task) { + const fragment = document.createDocumentFragment(); + fragment.append(detailHeader('Task definition', task.name, task.taskId)); + const facts = element('div', 'facts'); + facts.append( + fact('状态', task.enabled ? '可运行' : '已停用'), + fact('类型', task.kind), + fact('Revision', task.revision), + fact('Schema', task.specSchema), + fact('Content fence', shortDigest(task.contentDigest)), + fact('更新时间', formatTime(task.updatedAtMs)), + ); + fragment.append(facts); + const actions = element('div', 'detail-actions'); + if (task.enabled) { + actions.append( + actionButton('运行一次', () => { + confirmAction( + '运行这个任务?', + `将按 revision ${task.revision} 与当前内容摘要启动“${task.name}”。定义如有变化,服务端会拒绝本次操作。`, + '确认运行', + () => startTask(task), + ); + }), + ); + } + fragment.append(actions); + replace(nodes.detail, fragment); + } + + async function startTask(task) { + try { + const value = await api( + `/api/v3/projects/${state.project}/tasks/${task.taskId}/runs`, + { + method: 'POST', + body: { + schema: 'qinglong/task-start@v1', + mutationId: newMutationId(), + expectedRevision: task.revision, + expectedContentDigest: task.contentDigest, + }, + }, + ); + showToast( + value.status === 'existing' + ? '已找到同一启动请求。' + : '任务已进入运行队列。', + ); + state.view = 'runs'; + state.selectedId = value.runId; + updateNavigation(); + await refresh(); + await selectRun(value.runId); + } catch (error) { + showToast(describeError(error), 'error'); + } + } + + async function renderRuns() { + const value = await api(`/api/v3/projects/${state.project}/runs?limit=64`); + const runs = Array.isArray(value.runs) ? value.runs : []; + if (runs.length === 0) { + empty('还没有运行记录。切换到任务,选择一个已启用 Task 开始运行。'); + return; + } + const fragment = document.createDocumentFragment(); + fragment.append(listHeader('Run ledger', runs.length)); + const list = element('div', 'record-list'); + for (const run of runs) { + const button = element('button', 'record'); + button.type = 'button'; + button.dataset.identity = run.id; + if (state.selectedId === run.id) + button.setAttribute('aria-current', 'true'); + const main = element('span'); + main.append(element('span', 'record-title', run.taskId)); + main.append( + recordMeta([ + run.id, + `rev ${run.taskRevision}`, + `${run.executionOrigin} / ${run.executionOwner}`, + ]), + ); + const side = element('span', 'record-side'); + side.append(statusBadge(run.status)); + side.append(element('span', 'record-time', formatTime(run.createdAtMs))); + button.append(main, side); + button.addEventListener('click', () => selectRun(run.id)); + list.append(button); + } + fragment.append(list); + if (value.hasMore) { + fragment.append( + element( + 'p', + 'privacy-note', + '当前只展示最近 64 条;使用 API 可继续读取下一页。', + ), + ); + } + replace(nodes.ledger, fragment); + } + + async function selectRun(runId) { + state.selectedId = runId; + const selected = nodes.ledger.querySelectorAll('.record'); + for (const row of selected) { + if (row.dataset.identity === runId) { + 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 [runValue, eventValue, stepValue] = await Promise.all([ + api(`/api/v3/projects/${state.project}/runs/${runId}`), + api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`), + api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`), + ]); + renderRunDetail(runValue.run, eventValue, stepValue); + } catch (error) { + detailEmpty(describeError(error)); + } + } + + function renderRunDetail(run, eventPage, stepPage) { + const events = Array.isArray(eventPage.events) ? eventPage.events : []; + const steps = Array.isArray(stepPage.steps) ? stepPage.steps : []; + const fragment = document.createDocumentFragment(); + fragment.append(detailHeader('Run evidence', run.taskId, run.id)); + const facts = element('div', 'facts'); + facts.append( + fact('状态', STATUS_LABELS[run.status] || run.status), + fact('Version', run.version), + fact('事件', run.eventSequence), + fact('Workflow steps', steps.length), + fact('执行归属', run.executionOwner), + fact('创建时间', formatTime(run.createdAtMs)), + ); + fragment.append(facts); + if (!TERMINAL.has(run.status) && run.status !== 'cancel_requested') { + const actions = element('div', 'detail-actions'); + actions.append( + actionButton( + '请求取消', + () => { + confirmAction( + '请求取消运行?', + '取消请求会先写入 durable intent。正在执行的进程只有在安全控制链确认后才会停止。', + '确认请求取消', + () => cancelRun(run), + true, + ); + }, + 'danger', + ), + ); + fragment.append(actions); + } + fragment.append(element('h4', 'timeline-heading', 'Event sequence')); + if (events.length === 0) { + fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。')); + } else { + const timeline = element('ol', 'event-spine'); + for (const event of events) { + const item = element('li', 'event'); + item.append(element('span', 'event-sequence', event.sequence)); + const copy = element('div'); + copy.append(element('strong', null, event.type)); + copy.append( + element( + 'span', + null, + `${event.actorType} · ${formatTime(event.createdAtMs)}`, + ), + ); + item.append(copy); + timeline.append(item); + } + fragment.append(timeline); + } + if (eventPage.hasMore || stepPage.hasMore) { + fragment.append( + element( + 'p', + 'privacy-note', + '详情超过当前 64 条窗口;完整证据可通过 API 分页读取。', + ), + ); + } + replace(nodes.detail, fragment); + } + + async function cancelRun(run) { + try { + const value = await api( + `/api/v3/projects/${state.project}/runs/${run.id}/cancellation`, + { + method: 'POST', + body: { + schema: 'qinglong/run-cancellation@v1', + mutationId: newMutationId(), + }, + }, + ); + showToast( + value.status === 'already_terminal' + ? '运行已经结束。' + : value.status === 'already_requested' + ? '取消请求已经存在。' + : '取消请求已写入。', + ); + await refresh(); + await selectRun(run.id); + } catch (error) { + showToast(describeError(error), 'error'); + } + } + + function updateNavigation() { + for (const button of nodes.nav.querySelectorAll('button[data-view]')) { + if (button.dataset.view === state.view) + button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + } + if (state.view === 'tasks') { + nodes.kicker.textContent = 'Project task authority'; + nodes.title.textContent = '任务调度台'; + nodes.description.textContent = + '查看当前 Task revision 与内容围栏。运行前会再次读取详情并要求显式确认。'; + } else { + nodes.kicker.textContent = 'Durable run evidence'; + nodes.title.textContent = '运行事实账本'; + nodes.description.textContent = + '状态来自持久化 Run;取消只是请求,只有终态事件才能证明执行已经停止。'; + } + } + + async function refresh() { + if (!state.token) return; + loading(); + try { + if (state.view === 'tasks') await renderTasks(); + else await renderRuns(); + setConnection('connected', `${state.project} · 已连接`); + } catch (error) { + errorState(error); + } finally { + setBusy(false); + } + } + + function connect(token, project) { + state.token = token; + state.project = project; + state.view = 'tasks'; + state.selectedId = null; + nodes.token.value = ''; + nodes.token.disabled = true; + nodes.project.disabled = true; + nodes.form.querySelector('.primary-button').hidden = true; + nodes.disconnect.hidden = false; + nodes.nav.hidden = false; + nodes.refresh.hidden = false; + detailEmpty(); + updateNavigation(); + setConnection('connected', `${project} · 正在验证`); + refresh(); + } + + function disconnect() { + state.token = null; + state.selectedId = null; + state.pendingAction = null; + nodes.token.value = ''; + nodes.token.disabled = false; + nodes.project.disabled = false; + nodes.form.querySelector('.primary-button').hidden = false; + nodes.disconnect.hidden = true; + nodes.nav.hidden = true; + nodes.refresh.hidden = true; + setConnection('idle', '等待凭据'); + nodes.kicker.textContent = 'Connection gate'; + nodes.title.textContent = '先建立一条本机连接'; + nodes.description.textContent = + '输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。'; + const welcome = element('div', 'welcome-state'); + welcome.append(element('span', 'welcome-index', 'QL / 03')); + welcome.append(element('p', null, '任务是计划,运行是事实,事件是证据。')); + const rule = element('div', 'welcome-rule'); + rule.setAttribute('aria-hidden', 'true'); + rule.append(element('i'), element('i'), element('i')); + welcome.append(rule); + replace(nodes.ledger, welcome); + detailEmpty(); + nodes.token.focus(); + } + + nodes.form.addEventListener('submit', (event) => { + event.preventDefault(); + const token = nodes.token.value.trim(); + const project = nodes.project.value.trim(); + if (!PROJECT_PATTERN.test(project)) { + showToast('项目 ID 格式无效。', 'error'); + nodes.project.focus(); + return; + } + if (!TOKEN_PATTERN.test(token)) { + showToast('API Credential 格式无效。', 'error'); + nodes.token.focus(); + return; + } + connect(token, project); + }); + + nodes.disconnect.addEventListener('click', disconnect); + nodes.refresh.addEventListener('click', refresh); + + for (const button of nodes.nav.querySelectorAll('button[data-view]')) { + button.addEventListener('click', () => { + if (button.dataset.view === state.view) return; + state.view = button.dataset.view; + state.selectedId = null; + detailEmpty(); + updateNavigation(); + refresh(); + }); + } + + nodes.dialog.addEventListener('close', async () => { + const action = state.pendingAction; + state.pendingAction = null; + if (nodes.dialog.returnValue === 'accept' && typeof action === 'function') { + await action(); + } + }); + + document.addEventListener('keydown', (event) => { + if ( + !state.token || + event.metaKey || + event.ctrlKey || + event.altKey || + event.target instanceof HTMLInputElement || + nodes.dialog.open + ) { + return; + } + const view = + event.key.toLowerCase() === 't' + ? 'tasks' + : event.key.toLowerCase() === 'r' + ? 'runs' + : null; + if (!view || view === state.view) return; + state.view = view; + state.selectedId = null; + detailEmpty(); + updateNavigation(); + refresh(); + }); +})(); diff --git a/packages/ql3-local-api/assets/console/index.html b/packages/ql3-local-api/assets/console/index.html new file mode 100644 index 00000000..af027a9e --- /dev/null +++ b/packages/ql3-local-api/assets/console/index.html @@ -0,0 +1,130 @@ + + + + + + + + + QingLong 3.0 · 自动化值班台 + + + + + +
+ + +
+
+
+

Connection gate

+

先建立一条本机连接

+

+ 输入 quickstart 交付的 Owner API Credential。连接成功后才能读取或执行操作。 +

+
+ +
+ +
+
+
+ QL / 03 +

任务是计划,运行是事实,事件是证据。

+ +
+
+ + +
+
+
+ + +
+

Explicit action

+

确认操作

+

+
+ + +
+
+
+ + + + diff --git a/packages/ql3-local-api/package.json b/packages/ql3-local-api/package.json index 57831157..2b8d4e4b 100644 --- a/packages/ql3-local-api/package.json +++ b/packages/ql3-local-api/package.json @@ -27,6 +27,7 @@ } }, "files": [ + "assets/console/*", "dist/**/*.js", "dist/**/*.d.ts" ], diff --git a/packages/ql3-local-api/src/console/localConsoleAssets.ts b/packages/ql3-local-api/src/console/localConsoleAssets.ts new file mode 100644 index 00000000..707f2d0d --- /dev/null +++ b/packages/ql3-local-api/src/console/localConsoleAssets.ts @@ -0,0 +1,98 @@ +import { createHash } from 'node:crypto'; +import { lstatSync, readFileSync, realpathSync } from 'node:fs'; +import path from 'node:path'; + +const MAX_ASSET_BYTES = 96 * 1_024; +const MAX_TOTAL_BYTES = 192 * 1_024; + +const DEFINITIONS = Object.freeze([ + Object.freeze({ + requestPath: '/', + file: 'index.html', + contentType: 'text/html; charset=utf-8', + }), + Object.freeze({ + requestPath: '/console.css', + file: 'console.css', + contentType: 'text/css; charset=utf-8', + }), + Object.freeze({ + requestPath: '/console.js', + file: 'console.js', + contentType: 'text/javascript; charset=utf-8', + }), +]); + +export interface LocalConsoleAsset { + readonly contentType: string; + readonly etag: string; + readonly body: Buffer; +} + +export type LocalConsoleAssets = ReadonlyMap; + +export class LocalConsoleAssetError extends Error { + readonly code = 'QL3_LOCAL_CONSOLE_ASSET_INVALID'; + + constructor(message: string, options?: ErrorOptions) { + super(`Local Console asset is invalid: ${message}`, options); + this.name = 'LocalConsoleAssetError'; + } +} + +function loadAsset( + root: string, + definition: (typeof DEFINITIONS)[number], +): Readonly { + const filePath = path.join(root, definition.file); + let stat; + let body: Buffer; + try { + stat = lstatSync(filePath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 2 || + stat.size > MAX_ASSET_BYTES || + realpathSync(filePath) !== filePath + ) { + throw new TypeError('asset identity is incompatible'); + } + body = readFileSync(filePath); + } catch (error) { + throw new LocalConsoleAssetError(definition.file, { cause: error }); + } + if (body.byteLength !== stat.size) { + throw new LocalConsoleAssetError( + `${definition.file} changed while loading`, + ); + } + return Object.freeze({ + contentType: definition.contentType, + etag: `"${createHash('sha256').update(body).digest('hex')}"`, + body, + }); +} + +export function loadLocalConsoleAssets(): LocalConsoleAssets { + const root = path.resolve(__dirname, '../../assets/console'); + let canonicalRoot: string; + try { + const stat = lstatSync(root); + if (!stat.isDirectory() || stat.isSymbolicLink()) throw new TypeError(); + canonicalRoot = realpathSync(root); + } catch (error) { + throw new LocalConsoleAssetError('asset root', { cause: error }); + } + const assets = new Map>(); + let totalBytes = 0; + for (const definition of DEFINITIONS) { + const asset = loadAsset(canonicalRoot, definition); + totalBytes += asset.body.byteLength; + if (totalBytes > MAX_TOTAL_BYTES) { + throw new LocalConsoleAssetError('asset set exceeds its byte budget'); + } + assets.set(definition.requestPath, asset); + } + return assets; +} diff --git a/packages/ql3-local-api/src/transport/httpSurface.ts b/packages/ql3-local-api/src/transport/httpSurface.ts index 3ab9ac7f..73edb480 100644 --- a/packages/ql3-local-api/src/transport/httpSurface.ts +++ b/packages/ql3-local-api/src/transport/httpSurface.ts @@ -13,6 +13,10 @@ import type { BoundedRunListInput } from '@qinglong/runtime-core/bounded-run-lis import type { BoundedRunEventListInput } from '@qinglong/runtime-core/bounded-run-event-list-projection'; import type { BoundedRunStepListInput } from '@qinglong/runtime-core/bounded-run-step-list-projection'; import type { BoundedTaskListInput } from '@qinglong/runtime-core/bounded-task-list-projection'; +import { + loadLocalConsoleAssets, + type LocalConsoleAsset, +} from '../console/localConsoleAssets'; import type { LocalApiResponse } from './contract'; const MAX_HEADER_BYTES = 8 * 1_024; @@ -38,6 +42,8 @@ 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 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 = + "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'"; type LocalApiRouteResolution = | LocalApiAdmissionOperation @@ -559,6 +565,45 @@ function send( response.end(body); } +function sendConsoleAsset( + response: ServerResponse, + requestId: string, + asset: Readonly, +): void { + if (response.destroyed || response.headersSent) return; + response.statusCode = 200; + response.setHeader('content-type', asset.contentType); + response.setHeader('cache-control', 'no-store'); + response.setHeader( + 'content-security-policy', + LOCAL_CONSOLE_CONTENT_SECURITY_POLICY, + ); + response.setHeader('cross-origin-opener-policy', 'same-origin'); + response.setHeader('cross-origin-resource-policy', 'same-origin'); + response.setHeader( + 'permissions-policy', + 'camera=(), geolocation=(), microphone=()', + ); + response.setHeader('referrer-policy', 'no-referrer'); + response.setHeader('x-content-type-options', 'nosniff'); + response.setHeader('x-frame-options', 'DENY'); + response.setHeader('x-request-id', requestId); + response.setHeader('etag', asset.etag); + response.setHeader('content-length', asset.body.byteLength); + response.end(asset.body); +} + +function sendConsoleFavicon(response: ServerResponse, requestId: string): void { + if (response.destroyed || response.headersSent) return; + response.statusCode = 204; + response.setHeader('cache-control', 'no-store'); + response.setHeader('cross-origin-resource-policy', 'same-origin'); + response.setHeader('referrer-policy', 'no-referrer'); + response.setHeader('x-content-type-options', 'nosniff'); + response.setHeader('x-request-id', requestId); + response.end(); +} + function errorResponse(statusCode: number, code: string): LocalApiResponse { return Object.freeze({ statusCode, @@ -588,6 +633,7 @@ export async function startLocalApiHttpSurface( options: LocalApiHttpSurfaceOptions, ): Promise> { validateOptions(options); + const consoleAssets = loadLocalConsoleAssets(); const uuid = options.randomUuid ?? randomUUID; const maxConcurrentRequests = options.profile === 'edge' ? 4 : 32; const drainTimeoutMs = options.profile === 'edge' ? 5_000 : 10_000; @@ -611,6 +657,28 @@ export async function startLocalApiHttpSurface( send(response, requestId, errorResponse(503, 'server_overloaded')); return; } + const consoleAsset = + request.method === 'GET' && typeof request.url === 'string' + ? consoleAssets.get(request.url) + : undefined; + if (consoleAsset) { + if (hasRequestBody(request)) { + send(response, requestId, errorResponse(400, 'invalid_request_body')); + request.resume(); + return; + } + sendConsoleAsset(response, requestId, consoleAsset); + return; + } + if (request.method === 'GET' && request.url === '/favicon.ico') { + if (hasRequestBody(request)) { + send(response, requestId, errorResponse(400, 'invalid_request_body')); + request.resume(); + return; + } + sendConsoleFavicon(response, requestId); + return; + } const resolvedRoute = route(request, options.profile); if (!resolvedRoute) { send(response, requestId, errorResponse(404, 'route_not_found')); diff --git a/packages/ql3-local-api/test/consoleAssets.test.cjs b/packages/ql3-local-api/test/consoleAssets.test.cjs new file mode 100644 index 00000000..2bfd9d46 --- /dev/null +++ b/packages/ql3-local-api/test/consoleAssets.test.cjs @@ -0,0 +1,168 @@ +const assert = require('node:assert/strict'); +const http = require('node:http'); +const net = require('node:net'); +const { test } = require('node:test'); + +const { + loadLocalConsoleAssets, +} = require('../dist/console/localConsoleAssets.js'); +const { + startLocalApiHttpSurface, +} = require('../dist/transport/httpSurface.js'); + +function reservePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.once('error', reject); + server.listen(0, '127.0.0.1', () => { + const address = server.address(); + server.close((error) => { + if (error) reject(error); + else resolve(address.port); + }); + }); + }); +} + +function request(port, requestPath, options = {}) { + return new Promise((resolve, reject) => { + const outgoing = http.request( + { + host: '127.0.0.1', + port, + path: requestPath, + method: options.method ?? 'GET', + headers: { + connection: 'close', + ...(options.headers ?? {}), + }, + }, + (response) => { + const chunks = []; + response.on('data', (chunk) => chunks.push(chunk)); + response.on('end', () => + resolve({ + statusCode: response.statusCode, + headers: response.headers, + body: Buffer.concat(chunks), + }), + ); + }, + ); + outgoing.once('error', reject); + outgoing.end(options.body); + }); +} + +test('loads one bounded offline Console asset closure', () => { + const assets = loadLocalConsoleAssets(); + assert.deepEqual([...assets.keys()], ['/', '/console.css', '/console.js']); + let totalBytes = 0; + for (const [requestPath, asset] of assets) { + assert.match(asset.etag, /^"[0-9a-f]{64}"$/); + assert.ok(asset.body.byteLength >= 100); + assert.ok(asset.body.byteLength <= 96 * 1024); + totalBytes += asset.body.byteLength; + const text = asset.body.toString('utf8'); + assert.equal(/https?:\/\//u.test(text), false); + if (requestPath === '/console.js') { + assert.equal( + /\b(?:localStorage|sessionStorage|innerHTML|eval)\b/u.test(text), + false, + ); + assert.match(text, /authorization: `Bearer \$\{state\.token\}`/u); + assert.match(text, /credentials: 'omit'/u); + } + } + assert.ok(totalBytes <= 192 * 1024); +}); + +test('serves the Console without authentication and preserves API admission', async (t) => { + const calls = []; + const port = await reservePort(); + const active = await startLocalApiHttpSurface({ + profile: 'edge', + host: '127.0.0.1', + port, + admission: { + async prepare(value) { + calls.push(value.operation.operationId); + return { + statusCode: 401, + body: { code: 'authentication_required' }, + }; + }, + }, + randomUuid: () => '00000000-0000-4000-8000-000000000001', + }); + t.after(() => active.stopAndDrain()); + + for (const [requestPath, contentType] of [ + ['/', 'text/html; charset=utf-8'], + ['/console.css', 'text/css; charset=utf-8'], + ['/console.js', 'text/javascript; charset=utf-8'], + ]) { + const response = await request(port, requestPath); + assert.equal(response.statusCode, 200); + assert.equal(response.headers['content-type'], contentType); + assert.equal(response.headers['cache-control'], 'no-store'); + assert.equal(response.headers['x-content-type-options'], 'nosniff'); + assert.equal(response.headers['x-frame-options'], 'DENY'); + assert.equal(response.headers['referrer-policy'], 'no-referrer'); + assert.equal(response.headers['cross-origin-opener-policy'], 'same-origin'); + assert.equal( + response.headers['cross-origin-resource-policy'], + 'same-origin', + ); + assert.match( + response.headers['content-security-policy'], + /default-src 'none'/u, + ); + assert.match(response.headers.etag, /^"[0-9a-f]{64}"$/); + assert.ok(response.body.byteLength >= 100); + } + assert.deepEqual(calls, []); + + const favicon = await request(port, '/favicon.ico'); + assert.equal(favicon.statusCode, 204); + assert.equal(favicon.headers['cache-control'], 'no-store'); + assert.equal(favicon.body.byteLength, 0); + assert.deepEqual(calls, []); + + const api = await request(port, '/api/v3/projects/default/tasks?limit=1'); + assert.equal(api.statusCode, 401); + assert.deepEqual(JSON.parse(api.body.toString('utf8')), { + code: 'authentication_required', + }); + assert.deepEqual(calls, ['task.list']); +}); + +test('rejects request bodies and query aliases on Console assets', async (t) => { + const port = await reservePort(); + const active = await startLocalApiHttpSurface({ + profile: 'standalone', + host: '127.0.0.1', + port, + admission: { + async prepare() { + throw new Error('static assets must not reach admission'); + }, + }, + }); + t.after(() => active.stopAndDrain()); + + const body = await request(port, '/', { + headers: { 'content-length': '1' }, + body: 'x', + }); + assert.equal(body.statusCode, 400); + assert.deepEqual(JSON.parse(body.body.toString('utf8')), { + code: 'invalid_request_body', + }); + + const alias = await request(port, '/console.js?cache=1'); + assert.equal(alias.statusCode, 404); + assert.deepEqual(JSON.parse(alias.body.toString('utf8')), { + code: 'route_not_found', + }); +}); diff --git a/scripts/ql3-cluster-dependency-audit.cjs b/scripts/ql3-cluster-dependency-audit.cjs index 66838e86..55f863c4 100644 --- a/scripts/ql3-cluster-dependency-audit.cjs +++ b/scripts/ql3-cluster-dependency-audit.cjs @@ -3323,6 +3323,9 @@ function auditPackageScripts(packagePath, manifest, findings) { function auditPackageFiles(packagePath, manifest, findings) { const expected = ['dist/**/*.js', 'dist/**/*.d.ts']; + if (packagePath === 'packages/ql3-local-api') { + expected.unshift('assets/console/*'); + } if (packagePath === 'packages/ql3-cluster-admin') { expected.push('assets/copilot-console/*'); } diff --git a/scripts/ql3-local-profile-artifact-audit.cjs b/scripts/ql3-local-profile-artifact-audit.cjs index 51d95ff4..7c5c7e37 100644 --- a/scripts/ql3-local-profile-artifact-audit.cjs +++ b/scripts/ql3-local-profile-artifact-audit.cjs @@ -352,6 +352,44 @@ function auditLocalApiExecutable(artifactDirectory) { ) { fail('local API executable help output is invalid'); } + const consoleDirectory = path.join(packageDirectory, 'assets', 'console'); + const expectedAssets = ['console.css', 'console.js', 'index.html']; + const actualAssets = fs.readdirSync(consoleDirectory).sort(); + if (JSON.stringify(actualAssets) !== JSON.stringify(expectedAssets)) { + fail( + `local Console asset closure is invalid: ${JSON.stringify(actualAssets)}`, + ); + } + let totalBytes = 0; + const contents = {}; + for (const name of expectedAssets) { + const assetPath = path.join(consoleDirectory, name); + const stat = fs.lstatSync(assetPath); + if ( + !stat.isFile() || + stat.isSymbolicLink() || + stat.size < 100 || + stat.size > 96 * 1024 + ) { + fail(`local Console asset is invalid: ${name}`); + } + totalBytes += stat.size; + contents[name] = fs.readFileSync(assetPath, 'utf8'); + } + if ( + totalBytes > 192 * 1024 || + !contents['index.html'].includes('href="/console.css"') || + !contents['index.html'].includes('src="/console.js"') || + /<(?:script|style)(?:\s|>)[^>]*>\s*[^<\s]/u.test(contents['index.html']) || + /\b(?:localStorage|sessionStorage|innerHTML|eval)\b/u.test( + contents['console.js'], + ) || + /https?:\/\//u.test(contents['index.html']) || + /https?:\/\//u.test(contents['console.css']) || + /https?:\/\//u.test(contents['console.js']) + ) { + fail('local Console offline or credential-custody contract is invalid'); + } } function packageRootFromEntry(entryPath, packageName) { @@ -672,6 +710,9 @@ function main() { entrySpecifiers, excludedInternalPackages: options.application && !options.ai ? ['@qinglong/ai'] : [], + retainedJavaScriptFiles: options.api + ? ['local-api/assets/console/console.js'] + : [], }, ); const prunedMcpExternalDevelopment = options.mcp diff --git a/scripts/ql3-prune-runtime-artifact.cjs b/scripts/ql3-prune-runtime-artifact.cjs index bbe30f3f..69e6d91b 100644 --- a/scripts/ql3-prune-runtime-artifact.cjs +++ b/scripts/ql3-prune-runtime-artifact.cjs @@ -89,7 +89,10 @@ function projectRuntimeExports(value, requiredKeys = null) { return Object.fromEntries( Object.entries(value) .filter(([key]) => { - if (key !== '.' && !/^\.\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(key)) { + if ( + key !== '.' && + !/^\.\/[A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*$/.test(key) + ) { fail(`runtime package export key ${key} is invalid`); } return requiredKeys === null || requiredKeys.has(key); @@ -127,7 +130,9 @@ function normalizeEntrySpecifiers(options) { Array.isArray(options) || Reflect.ownKeys(options).some( (key) => - key !== 'entrySpecifiers' && key !== 'excludedInternalPackages', + key !== 'entrySpecifiers' && + key !== 'excludedInternalPackages' && + key !== 'retainedJavaScriptFiles', ) || !Array.isArray(options.entrySpecifiers) || options.entrySpecifiers.length > 64 @@ -147,6 +152,41 @@ function normalizeEntrySpecifiers(options) { return Object.freeze(entries); } +function normalizeRetainedJavaScriptFiles(options, inventory) { + if (options === undefined || options.retainedJavaScriptFiles === undefined) { + return Object.freeze([]); + } + if ( + !Array.isArray(options.retainedJavaScriptFiles) || + options.retainedJavaScriptFiles.length > 64 + ) { + fail('retained JavaScript file options are invalid'); + } + const inventoryPaths = new Set(inventory.files.map(({ path: file }) => file)); + const retained = []; + const seen = new Set(); + for (const value of options.retainedJavaScriptFiles) { + if ( + typeof value !== 'string' || + Buffer.byteLength(value, 'utf8') > 256 || + !/^[a-z0-9]+(?:-[a-z0-9]+)*(?:\/[A-Za-z0-9._-]+)+\.js$/u.test(value) || + seen.has(value) + ) { + fail('retained JavaScript file is invalid'); + } + seen.add(value); + const resolved = path.resolve(inventory.resolved, ...value.split('/')); + if ( + !resolved.startsWith(`${inventory.resolved}${path.sep}`) || + !inventoryPaths.has(resolved) + ) { + fail('retained JavaScript file is not installed'); + } + retained.push(resolved); + } + return Object.freeze(retained); +} + function normalizeExcludedInternalPackages(options) { if (options === undefined || options.excludedInternalPackages === undefined) { return Object.freeze([]); @@ -195,14 +235,18 @@ function collectStaticModuleSpecifiers(files) { for (const match of contents.matchAll(STATIC_MODULE_SPECIFIER_PATTERN)) { const specifier = match[2]; const start = match.index + match[0].indexOf(specifier); - literalRanges.push(Object.freeze({ start, end: start + specifier.length })); + literalRanges.push( + Object.freeze({ start, end: start + specifier.length }), + ); literalCalls.add(match.index); specifiers.push(specifier); } MODULE_LOAD_START_PATTERN.lastIndex = 0; for (const match of contents.matchAll(MODULE_LOAD_START_PATTERN)) { if (!literalCalls.has(match.index)) { - fail(`runtime JavaScript contains a non-literal module load at ${file.path}`); + fail( + `runtime JavaScript contains a non-literal module load at ${file.path}`, + ); } } let occurrence = contents.indexOf('@qinglong/'); @@ -212,7 +256,9 @@ function collectStaticModuleSpecifiers(files) { ({ start, end }) => occurrence >= start && occurrence < end, ) ) { - fail(`runtime JavaScript contains an unproved internal specifier at ${file.path}`); + fail( + `runtime JavaScript contains an unproved internal specifier at ${file.path}`, + ); } occurrence = contents.indexOf('@qinglong/', occurrence + 1); } @@ -269,8 +315,8 @@ function manifestBinTargets(record) { typeof bin === 'string' ? [bin] : bin && typeof bin === 'object' && !Array.isArray(bin) - ? Object.values(bin) - : null; + ? Object.values(bin) + : null; if (!values || values.some((value) => typeof value !== 'string')) { fail(`runtime package bin is invalid for ${record.manifest.name}`); } @@ -297,10 +343,11 @@ function canonicalRelativeSpecifier(specifier) { else while (remainder.startsWith('../')) remainder = remainder.slice(3); return ( remainder.length > 0 && - remainder.split('/').every( - (segment) => - segment.length > 0 && segment !== '.' && segment !== '..', - ) + remainder + .split('/') + .every( + (segment) => segment.length > 0 && segment !== '.' && segment !== '..', + ) ); } @@ -373,6 +420,10 @@ function pruneRuntimeArtifact(directory, options) { normalizeExcludedInternalPackages(options), ); const inventory = inventoryRuntimeArtifact(directory); + const retainedJavaScriptFiles = normalizeRetainedJavaScriptFiles( + options, + inventory, + ); const manifests = inventory.files.filter( (file) => path.basename(file.path) === 'package.json' && @@ -523,6 +574,9 @@ function pruneRuntimeArtifact(directory, options) { } } } + for (const file of retainedJavaScriptFiles) { + enqueueJavascript(file, 'explicit runtime asset'); + } for (const specifier of entrySpecifiers) requireExport(specifier); while (pendingJavascript.length > 0) { @@ -536,11 +590,7 @@ function pruneRuntimeArtifact(directory, options) { requireExport(specifier, filePath); } else if (specifier.startsWith('.')) { enqueueJavascript( - resolveRelativeRuntimeTarget( - filePath, - specifier, - owner.packageRoot, - ), + resolveRelativeRuntimeTarget(filePath, specifier, owner.packageRoot), `relative import from ${filePath}`, ); } @@ -567,13 +617,8 @@ function pruneRuntimeArtifact(directory, options) { for (const record of packageRecords) { const { file, before, manifest, packageRoot } = record; const required = requiredExports.get(manifest.name); - const retainMain = - !manifest.exports || required.has('.'); - const projected = projectRuntimeManifest( - manifest, - required, - retainMain, - ); + const retainMain = !manifest.exports || required.has('.'); + const projected = projectRuntimeManifest(manifest, required, retainMain); if (projected.exports) { for (const target of Object.values(projected.exports)) { validateRuntimeExportTarget(packageRoot, target); diff --git a/test/back/ql3ClusterDependencyAudit.test.cjs b/test/back/ql3ClusterDependencyAudit.test.cjs index 3dafb8cd..66b46dde 100644 --- a/test/back/ql3ClusterDependencyAudit.test.cjs +++ b/test/back/ql3ClusterDependencyAudit.test.cjs @@ -14,6 +14,10 @@ const { test('ships runtime JavaScript and declarations without development maps', () => { for (const [packagePath, files] of [ ['packages/ql3-runtime-core', ['dist/**/*.js', 'dist/**/*.d.ts']], + [ + 'packages/ql3-local-api', + ['assets/console/*', 'dist/**/*.js', 'dist/**/*.d.ts'], + ], [ 'packages/ql3-cluster-admin', ['dist/**/*.js', 'dist/**/*.d.ts', 'assets/copilot-console/*'], diff --git a/test/back/ql3PackageBoundaryAudit.test.cjs b/test/back/ql3PackageBoundaryAudit.test.cjs index 0d63abf4..af2fe936 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: 17, + sourceFiles: 18, rootSourceFiles: 1, rootSourceLines: 84, - nestedSourceFiles: 16, + nestedSourceFiles: 17, rootSourceFileRoles: { 'cli.ts': 'binary_entry' }, }, ); diff --git a/test/back/ql3RuntimeArtifactPruner.test.cjs b/test/back/ql3RuntimeArtifactPruner.test.cjs index 6390fd37..b7f89a0d 100644 --- a/test/back/ql3RuntimeArtifactPruner.test.cjs +++ b/test/back/ql3RuntimeArtifactPruner.test.cjs @@ -79,7 +79,10 @@ test('prunes only deployment waste while preserving runtime package semantics', `${JSON.stringify(application.manifest, null, 2)}\n`, ); const executable = path.join(application.dist, 'cli.js'); - fs.writeFileSync(path.join(application.dist, 'index.js'), "'use strict';\n"); + fs.writeFileSync( + path.join(application.dist, 'index.js'), + "'use strict';\n", + ); fs.writeFileSync( executable, "#!/usr/bin/env node\n'use strict';\n//# sourceMappingURL=cli.js.map\n", @@ -163,7 +166,10 @@ test('projects runtime exports from static imports and explicit Profile entries' `${JSON.stringify(runtime.manifest, null, 2)}\n`, ); for (const name of ['index', 'used', 'lazy', 'unused']) { - fs.writeFileSync(path.join(runtime.dist, `${name}.js`), "'use strict';\n"); + fs.writeFileSync( + path.join(runtime.dist, `${name}.js`), + "'use strict';\n", + ); } const application = writePackage( @@ -277,10 +283,7 @@ test('allows only explicitly excluded and declared optional feature imports', () entrySpecifiers: ['@qinglong/local-application'], excludedInternalPackages: ['@qinglong/ai'], }); - assert.equal( - report.packageManifests.runtimeExports.excludedSpecifiers, - 1, - ); + assert.equal(report.packageManifests.runtimeExports.excludedSpecifiers, 1); } finally { current.close(); } @@ -370,7 +373,10 @@ test('retains relative, dynamic, cyclic, bin, migration and asset closure', () = `${JSON.stringify(runtime.manifest, null, 2)}\n`, ); for (const name of ['index', 'used', 'unused']) { - fs.writeFileSync(path.join(runtime.dist, `${name}.js`), "'use strict';\n"); + fs.writeFileSync( + path.join(runtime.dist, `${name}.js`), + "'use strict';\n", + ); } const application = writePackage( @@ -422,9 +428,16 @@ test('retains relative, dynamic, cyclic, bin, migration and asset closure', () = const assetDirectory = path.join(application.dist, 'assets'); fs.mkdirSync(assetDirectory); fs.writeFileSync(path.join(assetDirectory, 'schema.json'), '{}\n'); + const consoleDirectory = path.join(application.packageRoot, 'assets'); + fs.mkdirSync(consoleDirectory); + fs.writeFileSync( + path.join(consoleDirectory, 'console.js'), + "'use strict';\ndocument.title = 'QingLong';\n", + ); const report = pruneRuntimeArtifact(current.root, { entrySpecifiers: ['@qinglong/local-application'], + retainedJavaScriptFiles: ['local-application/assets/console.js'], }); for (const relative of [ @@ -435,10 +448,14 @@ test('retains relative, dynamic, cyclic, bin, migration and asset closure', () = 'bin-support.js', 'migration/ledger.js', 'assets/schema.json', + '../assets/console.js', ]) { assert.equal(fs.existsSync(path.join(application.dist, relative)), true); } - assert.equal(fs.existsSync(path.join(application.dist, 'unused.js')), false); + assert.equal( + fs.existsSync(path.join(application.dist, 'unused.js')), + false, + ); assert.equal(fs.existsSync(path.join(runtime.dist, 'used.js')), true); assert.equal(fs.existsSync(path.join(runtime.dist, 'index.js')), false); assert.equal(fs.existsSync(path.join(runtime.dist, 'unused.js')), false); @@ -525,6 +542,39 @@ test('rejects escaping and missing relative targets before mutation', () => { } }); +test('rejects invalid or missing explicit JavaScript assets before mutation', () => { + const current = fixture(); + try { + const application = writePackage( + current.root, + 'local-application', + '@qinglong/local-application', + ); + const entry = path.join(application.dist, 'index.js'); + const map = path.join(application.dist, 'index.js.map'); + fs.writeFileSync(entry, "'use strict';\n"); + fs.writeFileSync(map, '{}\n'); + + for (const retainedJavaScriptFiles of [ + ['../outside.js'], + ['local-application/assets/missing.js'], + ['local-application/dist/index.js', 'local-application/dist/index.js'], + ]) { + assert.throws( + () => + pruneRuntimeArtifact(current.root, { + entrySpecifiers: ['@qinglong/local-application'], + retainedJavaScriptFiles, + }), + /retained JavaScript file/u, + ); + assert.equal(fs.existsSync(map), true); + } + } finally { + current.close(); + } +}); + test('rejects symbolic links before making partial changes', () => { const current = fixture(); try {