mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-20 16:07:11 +08:00
feat(ql3): add explicit cluster observation console
This commit is contained in:
@@ -1,10 +1,10 @@
|
||||
# Cluster Copilot read-only Console
|
||||
# Cluster read-only field Console
|
||||
|
||||
This Console is an operator-workstation process, not a resident QingLong
|
||||
service. Native execution serves digest-bound assets on an ephemeral
|
||||
`127.0.0.1` port and
|
||||
forwards only `inspect` and explicit `output` reads to the existing Cluster
|
||||
Copilot API. Do not deploy it as a Kubernetes workload, Ingress, shared LAN
|
||||
`127.0.0.1` port and forwards only a fixed vocabulary of Run, Task, Workflow
|
||||
and Copilot reads to existing Cluster APIs. It never accepts a browser-provided
|
||||
URL or method. Do not deploy it as a Kubernetes workload, Ingress, shared LAN
|
||||
listener, Edge component or legacy 2.x Web route.
|
||||
|
||||
Use `ql3-cluster-admin` from the same independently verified Admin release as
|
||||
@@ -46,9 +46,11 @@ it is owned by the current operator; for the image-carried launcher it and all
|
||||
files are owned by UID/GID `10001:10001`. Copy `client-config.example.json` to
|
||||
`client.json`, install the reviewed
|
||||
Cluster API CA as `ca.pem`, and install a separately issued `ql3c_` Project API
|
||||
credential as `credential`. Give the credential only `run.read` and
|
||||
`artifact.read`; the Console has no route for diagnosis creation or
|
||||
cancellation even if a wider credential is supplied.
|
||||
credential as `credential`. Give the credential only `run.read`, `task.read`
|
||||
and `artifact.read`; `run.read` covers Run and Workflow observations, while
|
||||
`task.read` covers Task list/detail and `artifact.read` covers an explicitly
|
||||
requested Copilot output. The Console has no route for Run/Workflow start,
|
||||
diagnosis creation or cancellation even if a wider credential is supplied.
|
||||
|
||||
Create an independent 256-bit browser session key without placing its value in
|
||||
argv or an environment variable:
|
||||
@@ -103,6 +105,13 @@ Model text is rendered as plain text and remains untrusted advice. These limits
|
||||
keep the workstation surface bounded, but this Cluster-only product is still
|
||||
excluded from small router Edge/Standalone artifacts.
|
||||
|
||||
The page exposes thirteen exact operations: Copilot `inspect|output`; Run
|
||||
list/detail/events/steps; Task list/detail; and Workflow list plus Workflow Run
|
||||
list/detail/events/steps. List responses use 32-row pages and offer an explicit
|
||||
next-page read only when the upstream cursor says more data exists. There is no
|
||||
automatic cascade from a list to details, steps or events, so each authority
|
||||
read remains visible and intentional.
|
||||
|
||||
## Run the verified image
|
||||
|
||||
Create a dedicated Docker network whose egress is restricted by the host
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
最新增量证据(2026-08-16):
|
||||
|
||||
- D-329/ADR-0421(已接受):同一 loopback-only Console/BFF 已扩展为 Cluster field ledger,固定提供 Copilot `inspect|output`、Run list/detail/events/steps、Task list/detail、Workflow list 与 Workflow Run list/detail/events/steps 共 13 个显式只读 operation;browser 仍不能提交 upstream URL/method/header/credential。服务端 exact contract 负责 ID/cursor/limit 校验和 path/query 生成,并复用既有 owner-private `ql3c_`、TLS 1.3、request-ID、2 MiB response 与低敏错误 transport;通用 Project read grammar 只接受审核过的 Run/Task/Workflow GET,拒绝 mutation、absolute URL 与 path traversal。UI 采用仅存内存的 evidence ledger,每次按钮只执行一次读取,分页只在 `hasMore|truncated` 携带 cursor 时由用户显式触发,没有自动 detail cascade、poller、WebSocket/SSE、retry、queue、cache 或后台 timer。实现继续留在 `@qinglong/cluster-admin`,workspace 维持 18 package,部署 credential 推荐只授予 `run.read|task.read|artifact.read`;不回接 2.x Web/session、不新增 Cluster route/schema/SQL/Pool/Kubernetes resident service,也不进入 Edge/Standalone closure。13-operation contract、Console/CLI/TLS 定向门 23/23,Cluster Admin 378 pass/3 条件 skip,完整 18-package test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。真实浏览器完成 Run/Task/Workflow 读取、显式下一页、恶意 HTML 纯文本、390×844 与零 console error/warning,并发现、修正 `[hidden]` 被 panel layout 覆盖的问题;真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes,在 non-root/read-only/network-none/no-capability/no-new-privileges/0.25 CPU/128 MiB/32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpacked;package/dependency/Cluster deployment/image release/Console/distribution 审计零 finding,workspace 为 18 package 且无 single-source/shallow package。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应把现场 evidence 升级为可下载的显式脱敏诊断包,或补公开 release digest 的外部工作站 ceremony;不得增加浏览器代理权、自动全量抓取或把 Console 变为 Kubernetes 常驻服务。
|
||||
- D-328/ADR-0420(已接受):Cluster Copilot Console 的 operator-workstation 分发已冻结为既有 `qinglong3-cluster-admin@sha256:…` 多架构 OCI,而不是再发布 Node archive、安装器镜像或第 19 个 workspace package。Admin release workflow 已有 amd64/arm64 原生构建、production dependency audit、BuildKit provenance/SBOM、OS vulnerability scan、扫描后 OCI merge、keyless Cosign signature,以及绑定 repository/workflow/source commit/source tag 的 GitHub provenance、CycloneDX 和 OS-vulnerability attestations;新增 `verify-release.sh` 要求 image digest、40-hex revision 与 `refs/tags/v3.*`,独立复验 exact workflow certificate identity、GitHub OIDC issuer、三类 predicate、OCI bundle 并拒绝 mutable tag、branch ref 与 self-hosted builder。已签名 image filesystem 现在携带 `0555` launcher/verifier 和 `0444` 文档/配置模板,因此 signature 同时覆盖宿主启动路径,不存在第二套依赖树。原生 Console 默认继续只监听 `127.0.0.1` ephemeral port;只有显式 `--container-published-loopback` 加固定高端口才允许容器 network namespace 内监听 `0.0.0.0`,并仍对外报告 `publishedHostAddress=127.0.0.1`。reviewed launcher 只接受 immutable digest、canonical private root、显式命名网络和 `compact|standard` 两档资源,拒绝 `bridge|default|host|none`;`check` 不 publish,`serve` 唯一映射为 `127.0.0.1:<port>:<port>/tcp`,同时固定 non-root `10001:10001`、read-only root、drop ALL、no-new-privileges、8 MiB noexec tmpfs、一个只读 authority mount、`--pull never`、3 秒 stop,compact 为 192 MiB/0.25 CPU/32 PIDs,standard 为 512 MiB/1 CPU/64 PIDs。启动器/验签器/分发审计与 Console 定向门 30/30;release/OCI/SBOM 定向门 75/75,package/dependency/Edge/Cluster deployment/Console distribution 审计全部零 finding。Cluster Admin 376 pass/3 条件 skip,完整 18-package clean build/test 退出 0,backend 1,223 pass/2 条件 skip/0 fail。npm pack dry-run 保持 245 files、258,591-byte tarball、1,616,582-byte unpacked。真实 arm64 image `qinglong3-cluster-admin:d328-local` 为 344,492,529 bytes,验证 10 个产品命令、镜像内五项分发文件及 mode、原生 loopback Console;新增 named-network 现场门选择一个空闲高端口并以 exact `127.0.0.1:P:P` 发布,宿主真实读取页面,同时复验 read-only/non-root/no-capability/no-new-privileges 边界与容器/网络清理。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 仍为 4,493,043/4,493,175 bytes,MCP 仍为 7,315,930/7,316,038 bytes,证明工作站分发没有进入低配路由设备闭包。workspace 保持 18 package、`singleSourcePackages=[]`、`shallowSourcePackages=[]`,Cluster Admin 120 个源码中 119 个位于嵌套职责目录。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,继续引用 D-323 PostgreSQL 18.6 arm64 physical HA 142/142、timeline `1→2` 基线。下一独立 Gate 应在同一 Console/BFF ownership 下增加只读 Run/Task/Workflow 观察面,或补公开 release tag 的外部工作站 ceremony;不得把容器内部 listener 误当宿主 LAN authority、引入第二分发依赖树或把 Console 变成 Kubernetes 常驻服务。
|
||||
- D-327/ADR-0419(已接受):QingLong 3.0 首个 Cluster 浏览器产品面已冻结为独立的 operator-workstation、loopback-only、只读 Copilot Console,而不是继续扩展 2.x Umi `src/pages`、legacy session 与 `/api` proxy。实现内聚在既有 `@qinglong/cluster-admin/copilot-console`,workspace 仍为 18 个 package;统一产品 façade 增加第十个静态命令 `copilot-console`。BFF 只监听 `127.0.0.1` ephemeral port,启动前复验包内 HTML/CSS/JS 的路径、realpath、类型、UTF-8、大小与固定 SHA-256;三项资源合计 24,150 bytes,无外部 asset/font/CDN。Cluster `ql3c_` credential 始终留在服务端 owner-private `0600` 文件且每次上游调用重新读取;浏览器只使用另一份 exact 256-bit session key,服务端只保存 domain-separated digest,页面只保存在内存,不进入 cookie、URL、argv、environment、local/session storage。Browser BFF 仅接受 exact `inspect|output`,复用 D-324 共享 TypeScript client,不执行 CLI 子进程、不直连数据库/application capability,并明确没有 diagnose/cancel、poller、WebSocket/SSE、ServiceWorker、queue/retry/cache 或后台 timer。Host、Origin、单 Authorization、route/operation 和 JSON framing 必须 exact;第三个并发 read 立即 `429`,固定 4 KiB request、约 2 MiB response、2 in-flight、16 connections 和 2 秒 shutdown ceiling。响应全为 `no-store` 且使用 default-deny CSP;模型文本只通过 `textContent` 显示并持续标记为 untrusted/no-action-authority。部署手册固定受信运维工作站生命周期,禁止 Kubernetes workload、Ingress、sidecar、共享 LAN 和容器 `0.0.0.0`;Edge/Standalone、Local MCP、Cluster Control/AI closure 均不导入 Console。npm pack dry-run 确认 245 files、258,012-byte tarball、1,614,503-byte unpacked,包含三项静态资源与全部 BFF/CLI 编译产物;独立审计还发现并修正真实 Admin Dockerfile 原先遗漏 assets 的发布缺陷,并把生产 files 白名单精确收窄到 `assets/copilot-console/*`。真实 Playwright 现场门覆盖 session 解锁、status read、显式 output reveal、390px 响应式布局和键盘路径;含 `<script>` 的模型输出保持纯文本,最终 0 error/0 warning,并修正了现代 HTML `/v` pattern 对未转义 `-` 的兼容问题。Console contract/CLI 12/12、定向产品入口 25/25、Cluster Admin 374 pass/3 条件 skip、完整 18-package clean build/test 退出 0、backend 1,215 pass/2 条件 skip/0 fail;package/dependency/Edge import/Cluster deployment/Console 审计零 finding,OCI/release 64/64、SBOM 11/11。真实 arm64 Admin image `qinglong3-cluster-admin:d327-local` 为 344,479,739 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 10 个产品命令,并在同一受限容器内真实启动 Console、读取 digest-bound 页面与干净关闭。14 档 Local artifact 全部 compatible;默认 Edge/Standalone 仍精确为 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes,证明 Cluster UI 没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应交付可独立验签的 operator-workstation Admin/Console 分发物,或在同一 3.0 Console ownership 下增加受同一 BFF 约束的只读 Run/Task/Workflow 观察面;不得回接 2.x session、把浏览器变成 Cluster credential holder,或把 Console 变为常驻 Kubernetes 服务。
|
||||
- D-326/ADR-0418(已接受):Cluster Copilot MCP 已补齐明确的外部 host 部署与资源边界。MCP 仍是 stdio 子进程,必须由支持 MCP 的外部 host 按 session 启动并持有 stdin/stdout;它不部署为 Kubernetes Deployment/Service,否则会形成没有消费者却长期持有 Project credential 的孤儿进程。新增 `deploy/mcp/ql3-cluster-copilot/` 提供 digest-pinned host 配置、owner-private client/MCP 配置示例和固定 Docker launcher;launcher 只允许显式命名网络与 `compact|standard|dense` 三档资源,分别限制为 192 MiB/0.25 CPU/32 PIDs/并发 1、512 MiB/1 CPU/64 PIDs/并发 4、1 GiB/2 CPU/96 PIDs/并发 16,并强制 `--pull never --init --read-only --cap-drop ALL --security-opt no-new-privileges --user 10001:10001`,只读挂载一个私有 authority root,禁止 Docker socket、Kubernetes token、数据库 credential、host/default/bridge/none 网络和可写工作目录。统一产品入口新增第九个静态命令 `ql3-cluster-admin copilot-mcp`;`ql3-copilot-mcp --check` 会先复验私有 config/credential/CA,再用无认证、固定 `GET /readyz` 做低敏预检,并在启动前拒绝配置并发超过 host resource class ceiling,serve 路径仍保持无 listener、无 queue/retry/poller/cache。部署审计同时禁止任何 Kubernetes YAML 常驻该 MCP,并修正了一个真实发布缺陷:OCI layout 旧 fixture 仍声称 Admin 镜像入口是 recovery CLI,现已与真实 `product-cli/cli.js` entrypoint 对齐。workspace 仍为 18 package、无 single-source/shallow package;Cluster Admin 保持 116 个源码、115 个位于嵌套职责目录,Admin SBOM 保持 91 components/87 external/4 internal,Control 和全部 Local 闭包不变。专项发布审计 145/145、Cluster Admin 362 pass/3 条件 skip、18-package clean build/test 退出 0、backend 1,210 pass/2 条件 skip/0 fail,package/dependency/Edge import/Cluster deployment 审计零 finding。真实 arm64 Admin image `qinglong3-cluster-admin:d326-local` 为 344,423,357 bytes,在 `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB/32 PIDs 下验证 9 个产品命令与新 entrypoint。14 档 Local artifact 全部逐档复验且与 D-325 完全一致:默认 Edge/Standalone 为 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 为 4,493,043/4,493,175 bytes,MCP 为 7,315,930/7,316,038 bytes,证明 Cluster MCP host 部署没有进入低配路由设备。本 Gate 无 schema、migration、SQL、role、Pool、连接或 HA 拓扑变化,因此不重跑物理 HA,继续引用 D-323 PostgreSQL 18.6 arm64 142/142、timeline `1→2` 基线。下一独立 Gate 应冻结 Cluster UI ownership/read-only Copilot surface,或使用公开 release digest 补真实外部 host session 证据;均不得把 stdio MCP 改成常驻服务或扩大其 credential/网络 authority。
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
# ADR-0421:显式 Cluster Run、Task 与 Workflow 观察台
|
||||
|
||||
- 状态:Accepted
|
||||
- 日期:2026-08-16
|
||||
- 关联 RFC:QL-RFC-0001 D-329、Phase 2
|
||||
- 扩展:ADR-0419
|
||||
|
||||
## 背景
|
||||
|
||||
ADR-0419/0420 已冻结 loopback-only Console 与签名 Admin OCI 分发,但人的观察面仍只覆盖 Copilot diagnosis。值班人员要判断“有哪些 Task、某个 Run 走到哪一步、Workflow Run 是否截断或失败”,仍需手工拼接 Cluster API URL。让浏览器获得通用 proxy、任意 path 或 `ql3c_` credential 会直接破坏既有 BFF 边界;为这组 UI/transport 文件再建 workspace package,也会恢复已经清理的薄包和额外制品闭包。
|
||||
|
||||
现有 Cluster Control 已拥有 Project-scoped、Policy-checked、有界的 Run list/detail/events/steps、Task list/detail,以及 Plugin Package Workflow list 和 Workflow Run list/detail/events/steps。D-329 不需要新增 route、repository、schema 或数据库读取模型,只需要把既有低敏投影组合成可用、显式且失败关闭的运维产品面。
|
||||
|
||||
## 决策
|
||||
|
||||
1. 观察面继续归属 `@qinglong/cluster-admin/copilot-console` 和同一个签名 Admin OCI;workspace 保持 18 个 package,不新增浏览器依赖、生产依赖、服务或第二分发物。名称保留以兼容已发布命令,但页面产品语义升级为 Cluster field ledger。
|
||||
2. BFF 固定开放 13 个只读 operation:Copilot `inspect|output`;Run list/detail/events/steps;Task list/detail;Workflow list 与 Workflow Run list/detail/events/steps。服务端为每种 operation 生成 exact Cluster API `GET` path/query,浏览器不能提交 URL、method、header、credential、permission 或 response shape。
|
||||
3. `ql3c_` credential 每次请求从 canonical、current-owner、0600 文件重新读取,TLS 继续固定 1.3、显式 CA/DNS、identity encoding、无 redirect/proxy/compression/connection reuse。新 Project read transport 只接受审核过的 Run/Task/Workflow path grammar,并明确拒绝 start/cancellation、绝对 URL、路径穿越与未知 query。
|
||||
4. Run 与 Workflow 共用 `run.read`,Task 使用 `task.read`,Copilot output 继续使用既有 `artifact.read`;部署推荐 credential 只授予这三项 read permission。UI 即使收到权限更宽的 credential,也没有 mutation route 或通用转发器。
|
||||
5. list 默认每页 32、上限 64;只有响应明确 `hasMore|truncated` 且携带下一 cursor 时,页面才出现“显式读取下一页”。读取列表不会自动读取 detail/steps/events,读取 Run 也不会自动级联。没有 poller、watcher、SSE、WebSocket、retry、queue、cache 或后台 timer。
|
||||
6. 页面采用“现场证据账本”而非通用 Dashboard:每次人工读取形成一条仅存于页面内存的按时序 evidence,所有远端值和模型文本都用 `textContent` 写入。reload/pagehide 清空 session 与账本;不使用 cookie、local/session storage、IndexedDB、ServiceWorker、外部 asset/font/CDN。
|
||||
7. BFF 保留 ADR-0419 的 exact Host/Origin/session、default-deny CSP、no-store、4 KiB request、约 2 MiB response、2 in-flight、16 connections 与第三请求即时 429。未知 route、route/operation mismatch、额外字段、cursor 半对、非法 ID/UUID/limit 一律在上游调用前失败关闭。
|
||||
8. Console 仍是短生命周期 operator-workstation process,不成为 Kubernetes Deployment/Ingress/sidecar 或共享 LAN listener。它不进入 Edge/Standalone、Local API/MCP 或 Cluster Control/AI 默认 closure;路由设备的默认制品必须保持不变。
|
||||
|
||||
## 不选择
|
||||
|
||||
- **浏览器通用反向代理**:即使只允许 GET,也会把未来新增 path 静默暴露给页面,无法证明 operation vocabulary 与权限边界。
|
||||
- **扩展 Copilot command schema**:Run/Task/Workflow 是普通 Cluster observation,不应伪装成 diagnosis command;BFF executor 在内部按领域分派。
|
||||
- **复制第二套 TLS/credential client**:会产生配置、TOCTOU、header、body ceiling 与错误投影漂移;D-329 复用同一 Project API authority transport。
|
||||
- **自动加载详情或实时刷新**:扩大读取次数与资源占用,也让值班人员无法区分 durable fact 与页面自行触发的背景读。
|
||||
- **回接 2.x Web 或新建 package**:前者恢复 legacy session/controller/proxy,后者为单一消费者制造薄包和额外发布矩阵。
|
||||
|
||||
## 验收
|
||||
|
||||
1. contract 单测覆盖全部 13 个 operation、exact field set、cursor pair、ID/package/workflow/UUID/limit、固定 path/query 与 mutation/absolute URL 拒绝。
|
||||
2. TLS 端到端覆盖 owner-private credential、TLS 1.3、Bearer、GET-only、request-ID exact matching、远端低敏错误与 credential buffer 清理;既有 Copilot client/MCP/product CLI 回归必须保持兼容。
|
||||
3. Console server/CLI 覆盖 Host/Origin/session、route confusion、无任意 path、并发即时 429、digest-bound assets、container-published-loopback 和 13-operation manifest。
|
||||
4. 真实浏览器覆盖 session unlock、Run/Task/Workflow/Copilot 手动读取、显式下一页、恶意内容纯文本、窄屏/键盘与零 console error;真实 Admin image 复验相同页面、非 root、read-only/no-capability/no-new-privileges 与 host-loopback publication。
|
||||
5. Console、distribution、package、dependency、Edge import、Cluster deployment 审计零 finding;18-package clean build/test、Cluster Admin、backend、14 档 Local artifact 全部通过后,本 ADR 才转为 Accepted 并进行 D-329 阶段提交。
|
||||
|
||||
## 接受证据
|
||||
|
||||
- 13-operation contract、Console server/CLI 与 TLS client 定向门 23/23;Cluster Admin 378 pass、3 条件 skip;完整 18-package test 退出 0;backend 1,223 pass、2 条件 skip、0 fail。
|
||||
- 真实浏览器完成 Run、Task、Workflow 三类 BFF 读取、Task 显式下一页、恶意 HTML 纯文本、390×844 无横向溢出和 0 error/0 warning;验收同时发现并修正了 `[hidden]` 被 panel layout 覆盖的问题。
|
||||
- 真实 arm64 Admin image `qinglong3-cluster-admin:d329-local` 为 344,518,724 bytes;在 non-root `10001:10001`、read-only root、network none、drop ALL、no-new-privileges、0.25 CPU、128 MiB、32 PIDs 下验证 10 个产品命令、原生/host-published Console 与内置分发文件。
|
||||
- Console/distribution/package/dependency/Cluster deployment/image release 审计零 finding。npm pack dry-run 为 245 files、262,246-byte tarball、1,642,267-byte unpacked;workspace 保持 18 package,`singleSourcePackages=[]`、`shallowSourcePackages=[]`。
|
||||
- 14 档 Local artifact 全部 compatible;默认 Edge/Standalone 精确保持 2,589,890/2,589,968 bytes、315 files、56 modules,application+AI 保持 4,493,043/4,493,175 bytes,MCP 保持 7,315,930/7,316,038 bytes。
|
||||
- 本 Gate 不修改 schema、migration、SQL、role、Pool、连接或 HA 拓扑,因此不重复消耗 PostgreSQL HA 现场门,继续引用 D-323 的 PostgreSQL 18.6 arm64 142/142 与 timeline `1→2` 基线。
|
||||
@@ -424,6 +424,7 @@
|
||||
| [ADR-0418](./ADR-0418-explicit-cluster-copilot-mcp-host-deployment.md) | 显式 Cluster Copilot MCP Host 部署与资源边界 | Accepted |
|
||||
| [ADR-0419](./ADR-0419-loopback-read-only-cluster-copilot-console.md) | Loopback-only Cluster Copilot 只读 Console | Accepted |
|
||||
| [ADR-0420](./ADR-0420-signed-admin-oci-console-workstation-distribution.md) | 以签名 Admin OCI 分发 Cluster Copilot 工作站 Console | Accepted |
|
||||
| [ADR-0421](./ADR-0421-explicit-cluster-run-task-workflow-observation-console.md) | 显式 Cluster Run、Task 与 Workflow 观察台 | Accepted |
|
||||
|
||||
## 规则
|
||||
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
# `@qinglong/cluster-admin`
|
||||
|
||||
This private QingLong 3.0 package owns explicit cluster operations and the
|
||||
bounded Cluster Copilot MCP and Console product surfaces.
|
||||
bounded Cluster Copilot MCP plus Cluster field Console product surfaces.
|
||||
Database/Kubernetes administration remains short-lived and requires distinct
|
||||
purpose-bound authority. The MCP subpath has only the remote API client; the
|
||||
Console is a loopback-only read BFF serving digest-bound static assets. Neither
|
||||
opens database or Kubernetes authority, enters the legacy 2.x Web application,
|
||||
or resides in `cluster-control`.
|
||||
|
||||
The Console accepts only `inspect` and explicit `output` reads. Its Cluster
|
||||
API credential stays in a canonical owner-private file and is reread for each
|
||||
upstream request; browser JavaScript receives only a separate session token
|
||||
which cannot call Cluster APIs. It binds `127.0.0.1`, enforces exact
|
||||
Host/Origin, no-store responses and a closed CSP, renders model text only via
|
||||
`textContent`, and keeps diagnose/cancel, polling, cache, WebSocket,
|
||||
ServiceWorker and legacy session authority absent.
|
||||
The Console accepts thirteen exact Run, Task, Workflow and Copilot reads. The
|
||||
browser cannot provide an upstream path or HTTP method, and every list page and
|
||||
detail/evidence read requires an explicit click. Its Cluster API credential
|
||||
stays in a canonical owner-private file and is reread for each upstream
|
||||
request; browser JavaScript receives only a separate session token which cannot
|
||||
call Cluster APIs. It binds `127.0.0.1`, enforces exact Host/Origin, no-store
|
||||
responses and a closed CSP, renders all returned data only via `textContent`,
|
||||
and keeps start/diagnose/cancel, polling, cache, WebSocket, ServiceWorker and
|
||||
legacy session authority absent.
|
||||
|
||||
The reviewed operator-workstation setup, private-file ceremony, release
|
||||
verification, preflight and session lifecycle are documented in
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
:root {
|
||||
--paper: #f3f6f1;
|
||||
--paper-deep: #e7ede6;
|
||||
--ink: #17231d;
|
||||
--muted: #607067;
|
||||
--line: #cbd5cc;
|
||||
--moss: #2f654d;
|
||||
--moss-soft: #dce9df;
|
||||
--amber: #c98724;
|
||||
--plum: #69556d;
|
||||
--rust: #a84f38;
|
||||
--white: #fbfdf9;
|
||||
--shadow: 0 24px 70px rgba(31, 52, 40, 0.12);
|
||||
font-family:
|
||||
"Avenir Next", "Segoe UI Variable", "Segoe UI", "PingFang SC",
|
||||
"Hiragino Sans GB", sans-serif;
|
||||
--porcelain: #eef3f5;
|
||||
--paper: #f8faf9;
|
||||
--ink: #152329;
|
||||
--muted: #607079;
|
||||
--line: #bdcbd0;
|
||||
--cyan: #087f8c;
|
||||
--cyan-soft: #d7ecee;
|
||||
--signal: #df6538;
|
||||
--violet: #655f87;
|
||||
--error: #a13f32;
|
||||
--white: #ffffff;
|
||||
font-family: 'Avenir Next', 'Segoe UI Variable', 'PingFang SC', sans-serif;
|
||||
color: var(--ink);
|
||||
background: var(--paper);
|
||||
background: var(--porcelain);
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
@@ -23,19 +20,25 @@
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
html {
|
||||
min-width: 320px;
|
||||
background:
|
||||
linear-gradient(90deg, rgba(47, 101, 77, 0.035) 1px, transparent 1px)
|
||||
0 0 / 24px 24px,
|
||||
var(--paper);
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
rgba(8, 127, 140, 0.035) 1px,
|
||||
transparent 1px
|
||||
)
|
||||
0 0 / 28px 28px,
|
||||
var(--porcelain);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
button,
|
||||
input {
|
||||
font: inherit;
|
||||
@@ -43,15 +46,13 @@ input {
|
||||
|
||||
button {
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 3px;
|
||||
padding: 0.78rem 1rem;
|
||||
border-radius: 2px;
|
||||
padding: 0.72rem 0.85rem;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 140ms ease,
|
||||
background-color 140ms ease,
|
||||
color 140ms ease;
|
||||
transition: transform 130ms ease, background-color 130ms ease,
|
||||
color 130ms ease;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
@@ -59,22 +60,19 @@ button:hover:not(:disabled) {
|
||||
background: var(--ink);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.42;
|
||||
}
|
||||
|
||||
button.primary {
|
||||
background: var(--moss);
|
||||
border-color: var(--moss);
|
||||
border-color: var(--cyan);
|
||||
background: var(--cyan);
|
||||
color: var(--white);
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
pre:focus-visible {
|
||||
outline: 3px solid color-mix(in srgb, var(--amber) 72%, white);
|
||||
outline: 3px solid color-mix(in srgb, var(--signal) 72%, white);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
@@ -83,15 +81,14 @@ input {
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
border-radius: 0;
|
||||
padding: 0.72rem 0;
|
||||
padding: 0.64rem 0;
|
||||
color: var(--ink);
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #91a097;
|
||||
color: #8a9ba2;
|
||||
}
|
||||
|
||||
.skip-link {
|
||||
position: fixed;
|
||||
left: 1rem;
|
||||
@@ -102,477 +99,395 @@ input::placeholder {
|
||||
background: var(--ink);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.skip-link:focus {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.shell {
|
||||
width: min(1440px, 100%);
|
||||
width: min(1500px, 100%);
|
||||
margin: 0 auto;
|
||||
min-height: 100vh;
|
||||
padding: 2rem clamp(1rem, 3vw, 3.5rem) 1.25rem;
|
||||
padding: 1.6rem clamp(0.8rem, 3vw, 3rem) 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.masthead {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 2rem;
|
||||
padding-bottom: 1.6rem;
|
||||
padding-bottom: 1.4rem;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1.2rem;
|
||||
gap: 1.1rem;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 3.15rem;
|
||||
height: 3.15rem;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border: 1px solid var(--ink);
|
||||
border-radius: 50%;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
clip-path: polygon(0 0, 82% 0, 100% 18%, 100% 100%, 18% 100%, 0 82%);
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-weight: 800;
|
||||
letter-spacing: -0.09em;
|
||||
letter-spacing: -0.08em;
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
margin: 0 0 0.42rem;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.69rem;
|
||||
margin: 0 0 0.38rem;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.67rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
h1,
|
||||
h2,
|
||||
h3,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-bottom: 0;
|
||||
max-width: 18ch;
|
||||
font-size: clamp(1.55rem, 3vw, 3rem);
|
||||
line-height: 0.98;
|
||||
letter-spacing: -0.055em;
|
||||
max-width: 20ch;
|
||||
font-size: clamp(1.5rem, 3vw, 2.85rem);
|
||||
line-height: 1;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-bottom: 0.7rem;
|
||||
font-size: clamp(1.45rem, 2vw, 2.15rem);
|
||||
margin-bottom: 0.65rem;
|
||||
font-size: clamp(1.35rem, 2vw, 2rem);
|
||||
letter-spacing: -0.035em;
|
||||
}
|
||||
|
||||
h3 {
|
||||
letter-spacing: -0.025em;
|
||||
}
|
||||
|
||||
.boundary {
|
||||
min-width: 11rem;
|
||||
min-width: 13rem;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.22rem 0.52rem;
|
||||
gap: 0.2rem 0.5rem;
|
||||
align-items: center;
|
||||
font-size: 0.76rem;
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.boundary strong {
|
||||
grid-column: 2;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.boundary-dot {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
width: 0.62rem;
|
||||
height: 0.62rem;
|
||||
border-radius: 50%;
|
||||
background: var(--moss);
|
||||
box-shadow: 0 0 0 5px var(--moss-soft);
|
||||
background: var(--cyan);
|
||||
box-shadow: 0 0 0 5px var(--cyan-soft);
|
||||
}
|
||||
|
||||
.workspace {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(18rem, 0.82fr) minmax(28rem, 1.4fr);
|
||||
min-height: 680px;
|
||||
grid-template-columns: minmax(20rem, 0.8fr) minmax(30rem, 1.45fr);
|
||||
min-height: 720px;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.control-deck,
|
||||
.evidence-panel {
|
||||
padding: clamp(1.5rem, 3vw, 3.25rem);
|
||||
padding: clamp(1.35rem, 3vw, 2.8rem);
|
||||
}
|
||||
|
||||
.control-deck {
|
||||
border-right: 1px solid var(--ink);
|
||||
background: rgba(251, 253, 249, 0.72);
|
||||
background: rgba(248, 250, 249, 0.84);
|
||||
}
|
||||
|
||||
.section-heading > p:last-child {
|
||||
max-width: 44ch;
|
||||
line-height: 1.7;
|
||||
max-width: 46ch;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.session-gate {
|
||||
margin: 2.5rem 0 2rem;
|
||||
padding: 1.1rem;
|
||||
border-left: 3px solid var(--plum);
|
||||
background: color-mix(in srgb, var(--plum) 8%, var(--white));
|
||||
margin: 2rem 0;
|
||||
padding: 1rem;
|
||||
border-left: 3px solid var(--violet);
|
||||
background: color-mix(in srgb, var(--violet) 7%, var(--white));
|
||||
}
|
||||
|
||||
.inline-control {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.8rem;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.field-note {
|
||||
margin: 0.75rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.target-form {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
}
|
||||
|
||||
.target-form label,
|
||||
.field-label,
|
||||
.session-gate label {
|
||||
margin-top: 0.9rem;
|
||||
font-size: 0.78rem;
|
||||
display: block;
|
||||
margin-top: 0.8rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.025em;
|
||||
}
|
||||
|
||||
.actions {
|
||||
.field-note {
|
||||
margin: 0.65rem 0 0;
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.5;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mode-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
margin: 1.8rem 0 1.2rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.mode-tab {
|
||||
border: 0;
|
||||
border-bottom: 3px solid transparent;
|
||||
border-radius: 0;
|
||||
padding-inline: 0.3rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
.mode-tab.active {
|
||||
border-bottom-color: var(--cyan);
|
||||
color: var(--ink);
|
||||
font-weight: 760;
|
||||
}
|
||||
.mode-tab:hover:not(:disabled) {
|
||||
transform: none;
|
||||
background: var(--cyan-soft);
|
||||
color: var(--ink);
|
||||
}
|
||||
.mode-panel {
|
||||
display: grid;
|
||||
gap: 0.7rem;
|
||||
}
|
||||
.control-group {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
margin-top: 1.7rem;
|
||||
padding: 0.9rem 0 1.05rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.control-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.control-title span {
|
||||
color: var(--cyan);
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
.button-pair,
|
||||
.button-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.55rem;
|
||||
}
|
||||
.button-grid {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.trust-note {
|
||||
margin-top: 2.4rem;
|
||||
padding-top: 1rem;
|
||||
margin-top: 2rem;
|
||||
padding-top: 0.9rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.trust-note span {
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.68rem;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.66rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--plum);
|
||||
color: var(--violet);
|
||||
}
|
||||
|
||||
.trust-note p {
|
||||
margin: 0.45rem 0 0;
|
||||
font-size: 0.8rem;
|
||||
margin: 0.4rem 0 0;
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.55;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.evidence-panel {
|
||||
position: relative;
|
||||
background: var(--paper-deep);
|
||||
background: var(--paper);
|
||||
}
|
||||
|
||||
.evidence-header,
|
||||
.output-heading {
|
||||
.evidence-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.status-chip {
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 999px;
|
||||
padding: 0.35rem 0.64rem;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.33rem 0.62rem;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.66rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.status-chip[data-tone="running"] {
|
||||
color: var(--amber);
|
||||
.status-chip[data-tone='busy'] {
|
||||
color: var(--signal);
|
||||
}
|
||||
|
||||
.status-chip[data-tone="success"] {
|
||||
color: var(--moss);
|
||||
.status-chip[data-tone='success'] {
|
||||
color: var(--cyan);
|
||||
}
|
||||
|
||||
.status-chip[data-tone="failed"] {
|
||||
color: var(--rust);
|
||||
.status-chip[data-tone='failed'] {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
min-height: 450px;
|
||||
min-height: 480px;
|
||||
display: grid;
|
||||
place-content: center;
|
||||
justify-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
max-width: 38ch;
|
||||
line-height: 1.65;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.empty-glyph {
|
||||
width: 12rem;
|
||||
height: 7rem;
|
||||
margin-bottom: 2rem;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
gap: 1.3rem;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.empty-glyph span {
|
||||
width: 1px;
|
||||
background: var(--ink);
|
||||
transform-origin: bottom;
|
||||
animation: signal 2.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.empty-glyph span:nth-child(1) {
|
||||
height: 35%;
|
||||
}
|
||||
|
||||
.empty-glyph span:nth-child(2) {
|
||||
height: 82%;
|
||||
animation-delay: 180ms;
|
||||
}
|
||||
|
||||
.empty-glyph span:nth-child(3) {
|
||||
height: 54%;
|
||||
animation-delay: 360ms;
|
||||
}
|
||||
|
||||
@keyframes signal {
|
||||
0%,
|
||||
100% {
|
||||
transform: scaleY(0.55);
|
||||
opacity: 0.42;
|
||||
}
|
||||
45% {
|
||||
transform: scaleY(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.trace-rail {
|
||||
list-style: none;
|
||||
margin: 2.2rem 0;
|
||||
padding: 0;
|
||||
.evidence-rail {
|
||||
width: min(25rem, 80%);
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
align-items: center;
|
||||
margin-bottom: 2.2rem;
|
||||
}
|
||||
|
||||
.trace-rail li {
|
||||
.evidence-rail i {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
gap: 0.75rem;
|
||||
min-height: 5rem;
|
||||
}
|
||||
|
||||
.trace-rail li:not(:last-child)::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0.42rem;
|
||||
left: 0.45rem;
|
||||
right: -0.45rem;
|
||||
height: 1px;
|
||||
background: var(--moss);
|
||||
background: var(--line);
|
||||
}
|
||||
|
||||
.trace-node {
|
||||
.evidence-rail i::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -0.35rem;
|
||||
width: 0.7rem;
|
||||
height: 0.7rem;
|
||||
border: 1px solid var(--cyan);
|
||||
background: var(--paper);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.ledger {
|
||||
margin: 0;
|
||||
padding: 1rem 0 0;
|
||||
list-style: none;
|
||||
counter-reset: evidence;
|
||||
}
|
||||
.ledger-entry {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 0.9rem;
|
||||
height: 0.9rem;
|
||||
border: 3px solid var(--paper-deep);
|
||||
border-radius: 50%;
|
||||
background: var(--moss);
|
||||
box-shadow: 0 0 0 1px var(--moss);
|
||||
padding: 1.1rem 0 1.2rem 3.5rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
counter-increment: evidence;
|
||||
}
|
||||
|
||||
.trace-rail small,
|
||||
.fact-grid dt,
|
||||
.output-meta dt {
|
||||
display: block;
|
||||
margin-bottom: 0.42rem;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
.ledger-entry::before {
|
||||
content: counter(evidence, decimal-leading-zero);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 1.15rem;
|
||||
width: 2.3rem;
|
||||
color: var(--cyan);
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
.ledger-entry::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 2.35rem;
|
||||
top: 1.05rem;
|
||||
bottom: 1rem;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.ledger-entry header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
align-items: baseline;
|
||||
}
|
||||
.ledger-entry h3 {
|
||||
margin-bottom: 0.35rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
.ledger-entry time {
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.11em;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.trace-rail strong {
|
||||
font-size: 0.9rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.fact-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
margin: 0;
|
||||
border-top: 1px solid var(--ink);
|
||||
border-left: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.fact-grid div {
|
||||
min-height: 6.4rem;
|
||||
padding: 1rem;
|
||||
border-right: 1px solid var(--ink);
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.fact-grid dd,
|
||||
.output-meta dd {
|
||||
margin: 0;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
font-size: 0.82rem;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.output-panel {
|
||||
margin-top: 2rem;
|
||||
padding: 1.2rem;
|
||||
border: 1px solid var(--plum);
|
||||
background: var(--white);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
|
||||
.output-heading span {
|
||||
border: 1px solid var(--plum);
|
||||
padding: 0.3rem 0.55rem;
|
||||
font-size: 0.66rem;
|
||||
color: var(--plum);
|
||||
}
|
||||
|
||||
.output-panel pre {
|
||||
.ledger-entry pre {
|
||||
max-height: 28rem;
|
||||
overflow: auto;
|
||||
margin: 1rem 0;
|
||||
padding: 1rem;
|
||||
border-left: 3px solid var(--plum);
|
||||
margin: 0.65rem 0 0;
|
||||
padding: 0.9rem;
|
||||
border-left: 2px solid var(--cyan);
|
||||
background: #e9f0f1;
|
||||
color: #203138;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
font: 0.82rem/1.7 ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
background: #f1eef2;
|
||||
overflow-wrap: anywhere;
|
||||
font: 0.72rem/1.55 ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
}
|
||||
|
||||
.output-meta {
|
||||
display: grid;
|
||||
grid-template-columns: 0.7fr 0.7fr 1.6fr;
|
||||
gap: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.message {
|
||||
min-height: 1.4rem;
|
||||
margin-top: 1.2rem;
|
||||
font-size: 0.82rem;
|
||||
margin-top: 1rem;
|
||||
font-size: 0.8rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.message[data-tone="error"] {
|
||||
color: var(--rust);
|
||||
.message[data-tone='error'] {
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
footer {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
padding-top: 1rem;
|
||||
font-family: ui-monospace, "SFMono-Regular", Consolas, monospace;
|
||||
padding-top: 0.9rem;
|
||||
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
||||
font-size: 0.65rem;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
[hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@media (max-width: 900px) {
|
||||
.masthead,
|
||||
footer {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.boundary {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.workspace {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.control-deck {
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid var(--ink);
|
||||
}
|
||||
|
||||
.trace-rail {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.trace-rail li {
|
||||
min-height: 3rem;
|
||||
}
|
||||
|
||||
.trace-rail li:not(:last-child)::after {
|
||||
left: 0.42rem;
|
||||
right: auto;
|
||||
top: 0.8rem;
|
||||
bottom: -1.45rem;
|
||||
width: 1px;
|
||||
height: auto;
|
||||
.empty-state {
|
||||
min-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
.shell {
|
||||
padding-inline: 0.75rem;
|
||||
padding-inline: 0.65rem;
|
||||
}
|
||||
|
||||
.brand-lockup {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.brand-mark {
|
||||
flex: 0 0 auto;
|
||||
width: 2.7rem;
|
||||
height: 2.7rem;
|
||||
display: none;
|
||||
}
|
||||
.inline-control,
|
||||
.button-pair,
|
||||
.button-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.mode-tabs {
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.control-deck,
|
||||
.evidence-panel {
|
||||
padding: 1.25rem;
|
||||
padding: 1.1rem;
|
||||
}
|
||||
|
||||
.inline-control,
|
||||
.actions,
|
||||
.fact-grid,
|
||||
.output-meta {
|
||||
grid-template-columns: 1fr;
|
||||
.ledger-entry {
|
||||
padding-left: 2.8rem;
|
||||
}
|
||||
.ledger-entry::after {
|
||||
left: 1.9rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -582,7 +497,5 @@ footer {
|
||||
*::after {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,190 +1,297 @@
|
||||
"use strict";
|
||||
'use strict';
|
||||
|
||||
(function () {
|
||||
const commandSchema = "qinglong/cluster-copilot-console-read-request@v1";
|
||||
const sessionForm = document.getElementById("session-form");
|
||||
const sessionInput = document.getElementById("session-token");
|
||||
const targetForm = document.getElementById("target-form");
|
||||
const inspectButton = document.getElementById("inspect-button");
|
||||
const outputButton = document.getElementById("output-button");
|
||||
const emptyState = document.getElementById("empty-state");
|
||||
const resultView = document.getElementById("result-view");
|
||||
const outputPanel = document.getElementById("output-panel");
|
||||
const outputText = document.getElementById("output-text");
|
||||
const message = document.getElementById("message");
|
||||
const statusChip = document.getElementById("status-chip");
|
||||
let sessionToken = "";
|
||||
let currentTarget = null;
|
||||
const schema = 'qinglong/cluster-copilot-console-read-request@v1';
|
||||
const routes = Object.freeze({
|
||||
inspect: '/api/v1/copilot/inspect',
|
||||
output: '/api/v1/copilot/output',
|
||||
run_list: '/api/v1/observe/run-list',
|
||||
run_read: '/api/v1/observe/run',
|
||||
run_event_list: '/api/v1/observe/run-events',
|
||||
run_step_list: '/api/v1/observe/run-steps',
|
||||
task_list: '/api/v1/observe/task-list',
|
||||
task_read: '/api/v1/observe/task',
|
||||
workflow_list: '/api/v1/observe/workflow-list',
|
||||
workflow_run_list: '/api/v1/observe/workflow-run-list',
|
||||
workflow_run_read: '/api/v1/observe/workflow-run',
|
||||
workflow_event_list: '/api/v1/observe/workflow-events',
|
||||
workflow_step_list: '/api/v1/observe/workflow-steps',
|
||||
});
|
||||
const labels = Object.freeze({
|
||||
inspect: 'Copilot 诊断状态',
|
||||
output: 'Copilot 诊断内容',
|
||||
run_list: 'Run 目录',
|
||||
run_read: 'Run 详情',
|
||||
run_event_list: 'Run Events',
|
||||
run_step_list: 'Run Steps',
|
||||
task_list: 'Task 目录',
|
||||
task_read: 'Task 当前修订',
|
||||
workflow_list: 'Workflow 目录',
|
||||
workflow_run_list: 'Workflow Run 目录',
|
||||
workflow_run_read: 'Workflow Run 详情',
|
||||
workflow_event_list: 'Workflow Run Events',
|
||||
workflow_step_list: 'Workflow Run Steps',
|
||||
});
|
||||
const sessionForm = document.getElementById('session-form');
|
||||
const sessionInput = document.getElementById('session-token');
|
||||
const controls = document.getElementById('console-controls');
|
||||
const ledger = document.getElementById('ledger');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const message = document.getElementById('message');
|
||||
const statusChip = document.getElementById('status-chip');
|
||||
let sessionToken = '';
|
||||
let busy = false;
|
||||
|
||||
const setText = function (id, value) {
|
||||
document.getElementById(id).textContent =
|
||||
value === null || value === undefined || value === "" ? "—" : String(value);
|
||||
const value = function (id) {
|
||||
return document.getElementById(id).value.trim();
|
||||
};
|
||||
|
||||
const setMessage = function (value, tone) {
|
||||
message.textContent = value;
|
||||
message.dataset.tone = tone || "neutral";
|
||||
const requestId = function () {
|
||||
return 'console-' + crypto.randomUUID();
|
||||
};
|
||||
|
||||
const dateTime = function (value) {
|
||||
if (!Number.isSafeInteger(value)) return "—";
|
||||
return new Intl.DateTimeFormat("zh-CN", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "medium",
|
||||
}).format(new Date(value));
|
||||
const setMessage = function (text, tone) {
|
||||
message.textContent = text;
|
||||
message.dataset.tone = tone || 'neutral';
|
||||
};
|
||||
|
||||
const setBusy = function (busy) {
|
||||
inspectButton.disabled = busy;
|
||||
outputButton.disabled =
|
||||
busy || !currentTarget || currentTarget.outputAvailable !== true;
|
||||
};
|
||||
|
||||
const request = async function (operation, target) {
|
||||
const response = await fetch("/api/v1/copilot/" + operation, {
|
||||
method: "POST",
|
||||
cache: "no-store",
|
||||
credentials: "omit",
|
||||
redirect: "error",
|
||||
referrerPolicy: "no-referrer",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
Authorization: "QL3-Console " + sessionToken,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
schema: commandSchema,
|
||||
operation: operation,
|
||||
projectId: target.projectId,
|
||||
sourceRunId: target.sourceRunId,
|
||||
requestId: target.requestId,
|
||||
}),
|
||||
const setBusy = function (next) {
|
||||
busy = next;
|
||||
document.querySelectorAll('[data-read]').forEach(function (button) {
|
||||
button.disabled = next;
|
||||
});
|
||||
const body = await response.json();
|
||||
if (!response.ok) {
|
||||
const error = new Error(
|
||||
typeof body.code === "string" ? body.code : "console_request_failed",
|
||||
);
|
||||
error.code = typeof body.code === "string" ? body.code : "console_request_failed";
|
||||
throw error;
|
||||
if (next) {
|
||||
statusChip.textContent = '读取中';
|
||||
statusChip.dataset.tone = 'busy';
|
||||
} else if (statusChip.dataset.tone === 'busy') {
|
||||
statusChip.textContent = '只读就绪';
|
||||
statusChip.dataset.tone = 'success';
|
||||
}
|
||||
return body;
|
||||
};
|
||||
|
||||
const targetFromForm = function () {
|
||||
const form = new FormData(targetForm);
|
||||
const base = function (operation) {
|
||||
const projectId = value('project-id');
|
||||
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(projectId)) {
|
||||
throw new Error('project_id_invalid');
|
||||
}
|
||||
return {
|
||||
projectId: String(form.get("projectId") || "").trim(),
|
||||
sourceRunId: String(form.get("sourceRunId") || "").trim(),
|
||||
requestId: String(form.get("requestId") || "").trim(),
|
||||
schema: schema,
|
||||
operation: operation,
|
||||
projectId: projectId,
|
||||
requestId: requestId(),
|
||||
};
|
||||
};
|
||||
|
||||
const renderInspection = function (response) {
|
||||
const result = response.result;
|
||||
const fact = result.result;
|
||||
currentTarget = {
|
||||
projectId: fact.projectId,
|
||||
sourceRunId: fact.sourceRunId,
|
||||
requestId: fact.requestId,
|
||||
outputAvailable: fact.outputAvailable,
|
||||
};
|
||||
emptyState.hidden = true;
|
||||
resultView.hidden = false;
|
||||
outputPanel.hidden = true;
|
||||
outputText.textContent = "";
|
||||
setText("admitted-at", dateTime(fact.admittedAtMs));
|
||||
setText("stage", fact.stage || (fact.status === "running" ? "processing" : null));
|
||||
setText("outcome", fact.outcome || fact.status);
|
||||
setText("diagnosis-run", fact.diagnosisRunId);
|
||||
setText("reason", fact.reason);
|
||||
setText(
|
||||
"tokens",
|
||||
fact.usage === null ? null : fact.usage.totalTokens,
|
||||
);
|
||||
setText(
|
||||
"cost",
|
||||
fact.usage === null || fact.usage.costMicros === null
|
||||
? null
|
||||
: "$" + (fact.usage.costMicros / 1000000).toFixed(6),
|
||||
);
|
||||
statusChip.textContent = fact.status === "running" ? "诊断进行中" : fact.outcome;
|
||||
statusChip.dataset.tone =
|
||||
fact.status === "running"
|
||||
? "running"
|
||||
: fact.outcome === "succeeded"
|
||||
? "success"
|
||||
: "failed";
|
||||
outputButton.disabled = fact.outputAvailable !== true;
|
||||
setMessage(
|
||||
fact.outputAvailable
|
||||
? "状态已验证。诊断内容仍未读取。"
|
||||
: "状态已验证;当前没有可读取的诊断内容。",
|
||||
);
|
||||
const payload = function (operation) {
|
||||
const result = base(operation);
|
||||
if (operation === 'inspect' || operation === 'output') {
|
||||
result.sourceRunId = value('source-run-id');
|
||||
result.requestId = value('diagnosis-request-id');
|
||||
} else if (operation === 'run_list') {
|
||||
result.afterCreatedAtMs = null;
|
||||
result.afterRunId = null;
|
||||
result.limit = 32;
|
||||
} else if (operation === 'run_read') {
|
||||
result.runId = value('run-id');
|
||||
} else if (operation === 'run_event_list') {
|
||||
result.runId = value('run-id');
|
||||
result.afterSequence = 0;
|
||||
result.limit = 32;
|
||||
} else if (operation === 'run_step_list') {
|
||||
result.runId = value('run-id');
|
||||
result.afterStepKey = null;
|
||||
result.afterStepRunId = null;
|
||||
result.limit = 32;
|
||||
} else if (operation === 'task_list') {
|
||||
result.afterTaskId = null;
|
||||
result.limit = 32;
|
||||
} else if (operation === 'task_read') {
|
||||
result.taskId = value('task-id');
|
||||
} else if (operation === 'workflow_list') {
|
||||
result.packageName = value('package-name');
|
||||
} else {
|
||||
result.packageName = value('package-name');
|
||||
result.workflowId = value('workflow-id');
|
||||
if (operation === 'workflow_run_list') {
|
||||
result.afterAdmittedAtMs = null;
|
||||
result.afterRunId = null;
|
||||
result.limit = 32;
|
||||
} else {
|
||||
result.runId = value('workflow-run-id');
|
||||
if (operation === 'workflow_event_list') {
|
||||
result.afterSequence = 0;
|
||||
result.limit = 32;
|
||||
} else if (operation === 'workflow_step_list') {
|
||||
result.afterStepKey = null;
|
||||
result.afterStepRunId = null;
|
||||
result.limit = 32;
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const renderOutput = function (response) {
|
||||
const nextPage = function (operation, prior, fact) {
|
||||
const next = Object.assign({}, prior, { requestId: requestId() });
|
||||
if (operation === 'run_list' && fact.hasMore === true && fact.next) {
|
||||
next.afterCreatedAtMs = fact.next.createdAtMs;
|
||||
next.afterRunId = fact.next.runId;
|
||||
} else if (
|
||||
operation === 'task_list' &&
|
||||
fact.hasMore === true &&
|
||||
fact.next
|
||||
) {
|
||||
next.afterTaskId = fact.next.taskId;
|
||||
} else if (operation === 'run_event_list' && fact.hasMore === true) {
|
||||
next.afterSequence = fact.nextAfterSequence;
|
||||
} else if (
|
||||
operation === 'run_step_list' &&
|
||||
fact.hasMore === true &&
|
||||
fact.next
|
||||
) {
|
||||
next.afterStepKey = fact.next.stepKey;
|
||||
next.afterStepRunId = fact.next.stepRunId;
|
||||
} else if (
|
||||
operation === 'workflow_run_list' &&
|
||||
fact.truncated === true &&
|
||||
fact.next
|
||||
) {
|
||||
next.afterAdmittedAtMs = fact.next.admittedAtMs;
|
||||
next.afterRunId = fact.next.runId;
|
||||
} else if (
|
||||
operation === 'workflow_event_list' &&
|
||||
fact.truncated === true &&
|
||||
fact.nextAfterSequence !== null
|
||||
) {
|
||||
next.afterSequence = fact.nextAfterSequence;
|
||||
} else if (
|
||||
operation === 'workflow_step_list' &&
|
||||
fact.truncated === true &&
|
||||
fact.next
|
||||
) {
|
||||
next.afterStepKey = fact.next.stepKey;
|
||||
next.afterStepRunId = fact.next.id;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
return next;
|
||||
};
|
||||
|
||||
const appendEvidence = function (operation, request, response) {
|
||||
const fact = response.result.result;
|
||||
outputPanel.hidden = false;
|
||||
outputText.textContent = fact.result.text;
|
||||
setText("finish-reason", fact.result.finishReason);
|
||||
setText("output-bytes", fact.reference.outputBytes);
|
||||
setText("content-digest", fact.reference.contentDigest);
|
||||
setMessage("诊断内容已显式读取;请把它当作不可信建议进行复核。");
|
||||
outputPanel.scrollIntoView({ behavior: "smooth", block: "nearest" });
|
||||
const entry = document.createElement('li');
|
||||
entry.className = 'ledger-entry';
|
||||
const header = document.createElement('header');
|
||||
const title = document.createElement('h3');
|
||||
const time = document.createElement('time');
|
||||
const output = document.createElement('pre');
|
||||
title.textContent = labels[operation];
|
||||
time.textContent = new Intl.DateTimeFormat('zh-CN', {
|
||||
dateStyle: 'short',
|
||||
timeStyle: 'medium',
|
||||
}).format(new Date());
|
||||
output.tabIndex = 0;
|
||||
output.textContent = JSON.stringify(fact, null, 2);
|
||||
header.append(title, time);
|
||||
entry.append(header, output);
|
||||
const next = nextPage(operation, request, fact);
|
||||
if (next) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.textContent = '显式读取下一页';
|
||||
button.addEventListener('click', function () {
|
||||
void execute(operation, next);
|
||||
});
|
||||
entry.append(button);
|
||||
}
|
||||
ledger.prepend(entry);
|
||||
emptyState.hidden = true;
|
||||
ledger.hidden = false;
|
||||
};
|
||||
|
||||
sessionForm.addEventListener("submit", function (event) {
|
||||
const execute = async function (operation, prepared) {
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
setMessage('正在读取 ' + labels[operation] + '…');
|
||||
try {
|
||||
const body = prepared || payload(operation);
|
||||
const response = await fetch(routes[operation], {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
credentials: 'omit',
|
||||
redirect: 'error',
|
||||
referrerPolicy: 'no-referrer',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
Authorization: 'QL3-Console ' + sessionToken,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const responseBody = await response.json();
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
typeof responseBody.code === 'string'
|
||||
? responseBody.code
|
||||
: 'console_request_failed',
|
||||
);
|
||||
}
|
||||
appendEvidence(operation, body, responseBody);
|
||||
setMessage(
|
||||
labels[operation] + ' 已加入本页证据账本。刷新页面会清空。',
|
||||
'success',
|
||||
);
|
||||
} catch (error) {
|
||||
statusChip.textContent = '读取失败';
|
||||
statusChip.dataset.tone = 'failed';
|
||||
setMessage(
|
||||
'无法读取:' +
|
||||
(error instanceof Error ? error.message : 'console_request_failed'),
|
||||
'error',
|
||||
);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
sessionForm.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
const candidate = sessionInput.value.trim();
|
||||
if (!/^[A-Za-z0-9_-]{43}$/.test(candidate)) {
|
||||
setMessage("浏览器访问密钥格式无效。", "error");
|
||||
setMessage('浏览器访问密钥格式无效。', 'error');
|
||||
return;
|
||||
}
|
||||
sessionToken = candidate;
|
||||
sessionInput.value = "";
|
||||
sessionInput.value = '';
|
||||
sessionForm.hidden = true;
|
||||
targetForm.hidden = false;
|
||||
setMessage("本次页面已解锁。访问密钥只保留在内存中。");
|
||||
document.getElementById("project-id").focus();
|
||||
controls.hidden = false;
|
||||
statusChip.textContent = '只读就绪';
|
||||
statusChip.dataset.tone = 'success';
|
||||
setMessage('本页已解锁;Cluster credential 仍只存在于服务端。', 'success');
|
||||
document.getElementById('project-id').focus();
|
||||
});
|
||||
|
||||
targetForm.addEventListener("submit", async function (event) {
|
||||
event.preventDefault();
|
||||
const target = targetFromForm();
|
||||
setBusy(true);
|
||||
setMessage("正在读取 durable status…");
|
||||
try {
|
||||
const response = await request("inspect", target);
|
||||
renderInspection(response);
|
||||
} catch (error) {
|
||||
currentTarget = null;
|
||||
outputPanel.hidden = true;
|
||||
statusChip.textContent = "读取失败";
|
||||
statusChip.dataset.tone = "failed";
|
||||
setMessage("无法读取诊断状态:" + error.code, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
document.querySelectorAll('.mode-tab').forEach(function (tab) {
|
||||
tab.addEventListener('click', function () {
|
||||
document.querySelectorAll('.mode-tab').forEach(function (candidate) {
|
||||
const selected = candidate === tab;
|
||||
candidate.classList.toggle('active', selected);
|
||||
candidate.setAttribute('aria-pressed', String(selected));
|
||||
});
|
||||
document.querySelectorAll('.mode-panel').forEach(function (panel) {
|
||||
panel.hidden = panel.id !== tab.dataset.panel;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
outputButton.addEventListener("click", async function () {
|
||||
if (!currentTarget || currentTarget.outputAvailable !== true) return;
|
||||
setBusy(true);
|
||||
setMessage("正在显式读取诊断内容…");
|
||||
try {
|
||||
const response = await request("output", currentTarget);
|
||||
renderOutput(response);
|
||||
} catch (error) {
|
||||
setMessage("无法读取诊断内容:" + error.code, "error");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
document.querySelectorAll('[data-read]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
void execute(button.dataset.read);
|
||||
});
|
||||
});
|
||||
|
||||
window.addEventListener("pagehide", function () {
|
||||
sessionToken = "";
|
||||
currentTarget = null;
|
||||
outputText.textContent = "";
|
||||
window.addEventListener('pagehide', function () {
|
||||
sessionToken = '';
|
||||
ledger.textContent = '';
|
||||
});
|
||||
})();
|
||||
|
||||
@@ -4,214 +4,136 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="light" />
|
||||
<meta
|
||||
name="description"
|
||||
content="QingLong 3.0 Cluster Copilot 只读诊断台"
|
||||
/>
|
||||
<title>QingLong 3.0 · Copilot 诊断台</title>
|
||||
<meta name="description" content="QingLong 3.0 Cluster 只读现场记录台" />
|
||||
<title>QingLong 3.0 · Cluster 现场记录台</title>
|
||||
<link rel="stylesheet" href="/app.css" />
|
||||
<script src="/app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<a class="skip-link" href="#workspace">跳到诊断工作区</a>
|
||||
<a class="skip-link" href="#workspace">跳到观察工作区</a>
|
||||
<div class="shell">
|
||||
<header class="masthead">
|
||||
<div class="brand-lockup" aria-label="QingLong 3.0 Cluster Console">
|
||||
<div class="brand-lockup">
|
||||
<span class="brand-mark" aria-hidden="true">QL</span>
|
||||
<div>
|
||||
<p class="eyebrow">QingLong 3.0 / Cluster field console</p>
|
||||
<h1>故障诊断,不替你执行。</h1>
|
||||
<p class="eyebrow">QingLong 3.0 / Cluster field ledger</p>
|
||||
<h1>沿着证据读,不替集群做决定。</h1>
|
||||
</div>
|
||||
</div>
|
||||
<div class="boundary" aria-label="当前权限边界">
|
||||
<span class="boundary-dot" aria-hidden="true"></span>
|
||||
<span>只读边界</span>
|
||||
<strong>inspect · output</strong>
|
||||
<span>本机只读 BFF</span>
|
||||
<strong>Run · Task · Workflow · Copilot</strong>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="workspace" class="workspace">
|
||||
<section class="control-deck" aria-labelledby="target-title">
|
||||
<aside class="control-deck" aria-labelledby="control-title">
|
||||
<div class="section-heading">
|
||||
<p class="eyebrow">Target coordinates</p>
|
||||
<h2 id="target-title">定位一次诊断</h2>
|
||||
<p>
|
||||
输入已存在的 Project、源 Run 和诊断请求。页面不会创建、取消或重试任何任务。
|
||||
</p>
|
||||
<p class="eyebrow">Observation coordinates</p>
|
||||
<h2 id="control-title">选择要读取的事实</h2>
|
||||
<p>每次按钮点击只发起一次有界 GET。页面不创建、取消、重试、轮询或缓存任何任务。</p>
|
||||
</div>
|
||||
|
||||
<form id="session-form" class="session-gate" autocomplete="off">
|
||||
<label for="session-token">浏览器访问密钥</label>
|
||||
<div class="inline-control">
|
||||
<input
|
||||
id="session-token"
|
||||
name="sessionToken"
|
||||
type="password"
|
||||
inputmode="text"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
required
|
||||
pattern="[A-Za-z0-9_\-]{43}"
|
||||
aria-describedby="session-note"
|
||||
/>
|
||||
<button type="submit">解锁本次页面</button>
|
||||
<input id="session-token" name="sessionToken" type="password" autocomplete="off" spellcheck="false" required pattern="[A-Za-z0-9_\-]{43}" />
|
||||
<button type="submit">解锁本页</button>
|
||||
</div>
|
||||
<p id="session-note" class="field-note">
|
||||
从独立的 0600 session 文件读取并粘贴;仅保留在当前页面内存,不会发送到 Cluster API。
|
||||
</p>
|
||||
<p class="field-note">从独立的 0600 session 文件粘贴;密钥只存在于当前页面内存。</p>
|
||||
</form>
|
||||
|
||||
<form id="target-form" class="target-form" autocomplete="off" hidden>
|
||||
<label for="project-id">Project</label>
|
||||
<input
|
||||
id="project-id"
|
||||
name="projectId"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="project-main"
|
||||
required
|
||||
/>
|
||||
<div id="console-controls" hidden>
|
||||
<label class="field-label" for="project-id">Project</label>
|
||||
<input id="project-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="project-main" required />
|
||||
|
||||
<label for="run-id">源 Run</label>
|
||||
<input
|
||||
id="run-id"
|
||||
name="sourceRunId"
|
||||
type="text"
|
||||
maxlength="36"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="run_..."
|
||||
required
|
||||
/>
|
||||
<nav class="mode-tabs" aria-label="观察面">
|
||||
<button type="button" class="mode-tab active" data-panel="runtime-panel" aria-pressed="true">运行态</button>
|
||||
<button type="button" class="mode-tab" data-panel="workflow-panel" aria-pressed="false">工作流</button>
|
||||
<button type="button" class="mode-tab" data-panel="copilot-panel" aria-pressed="false">Copilot</button>
|
||||
</nav>
|
||||
|
||||
<label for="request-id">诊断请求</label>
|
||||
<input
|
||||
id="request-id"
|
||||
name="requestId"
|
||||
type="text"
|
||||
maxlength="128"
|
||||
spellcheck="false"
|
||||
autocomplete="off"
|
||||
placeholder="diag-request-..."
|
||||
required
|
||||
/>
|
||||
<section id="runtime-panel" class="mode-panel">
|
||||
<div class="control-group">
|
||||
<div class="control-title"><span>01</span><strong>目录</strong></div>
|
||||
<div class="button-pair">
|
||||
<button type="button" data-read="task_list">读取 Task 列表</button>
|
||||
<button type="button" data-read="run_list">读取 Run 列表</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-title"><span>02</span><strong>Task 详情</strong></div>
|
||||
<input id="task-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="task-id" />
|
||||
<button type="button" data-read="task_read">读取当前修订</button>
|
||||
</div>
|
||||
<div class="control-group">
|
||||
<div class="control-title"><span>03</span><strong>Run 证据链</strong></div>
|
||||
<input id="run-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="run-id" />
|
||||
<div class="button-grid">
|
||||
<button type="button" data-read="run_read">读取 Run</button>
|
||||
<button type="button" data-read="run_step_list">读取 Steps</button>
|
||||
<button type="button" data-read="run_event_list">读取 Events</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="actions">
|
||||
<button id="inspect-button" class="primary" type="submit">
|
||||
读取诊断状态
|
||||
</button>
|
||||
<button id="output-button" type="button" disabled>
|
||||
显式读取诊断内容
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
<section id="workflow-panel" class="mode-panel" hidden>
|
||||
<label class="field-label" for="package-name">Package</label>
|
||||
<input id="package-name" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="ops-package" />
|
||||
<button type="button" data-read="workflow_list">读取 Workflow 列表</button>
|
||||
<label class="field-label" for="workflow-id">Workflow</label>
|
||||
<input id="workflow-id" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="daily-maintenance" />
|
||||
<button type="button" data-read="workflow_run_list">读取 Workflow Runs</button>
|
||||
<label class="field-label" for="workflow-run-id">Workflow Run UUID</label>
|
||||
<input id="workflow-run-id" type="text" maxlength="36" autocomplete="off" spellcheck="false" placeholder="00000000-0000-4000-8000-000000000000" />
|
||||
<div class="button-grid">
|
||||
<button type="button" data-read="workflow_run_read">读取 Run</button>
|
||||
<button type="button" data-read="workflow_step_list">读取 Steps</button>
|
||||
<button type="button" data-read="workflow_event_list">读取 Events</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="copilot-panel" class="mode-panel" hidden>
|
||||
<label class="field-label" for="source-run-id">源 Run</label>
|
||||
<input id="source-run-id" type="text" maxlength="36" autocomplete="off" spellcheck="false" placeholder="run-source-1" />
|
||||
<label class="field-label" for="diagnosis-request-id">诊断请求</label>
|
||||
<input id="diagnosis-request-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="diagnosis-request-1" />
|
||||
<div class="button-pair">
|
||||
<button type="button" class="primary" data-read="inspect">读取诊断状态</button>
|
||||
<button type="button" data-read="output">显式读取诊断内容</button>
|
||||
</div>
|
||||
<p class="field-note">模型文本是不可信内容,只会以纯文本进入证据账本。</p>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<aside class="trust-note">
|
||||
<span>Authority note</span>
|
||||
<p>
|
||||
Cluster credential 只存在于本机 BFF 的私有文件中。浏览器不能读取它,也不能调用 diagnose 或 cancel。
|
||||
</p>
|
||||
<span>Authority boundary</span>
|
||||
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。</p>
|
||||
</aside>
|
||||
</section>
|
||||
</aside>
|
||||
|
||||
<section class="evidence-panel" aria-labelledby="evidence-title">
|
||||
<div class="evidence-header">
|
||||
<div>
|
||||
<p class="eyebrow">Durable evidence</p>
|
||||
<h2 id="evidence-title">诊断轨迹</h2>
|
||||
<p class="eyebrow">Explicit read ledger</p>
|
||||
<h2 id="evidence-title">本页证据账本</h2>
|
||||
</div>
|
||||
<span id="status-chip" class="status-chip" data-tone="idle">等待目标</span>
|
||||
<span id="status-chip" class="status-chip" data-tone="idle">等待读取</span>
|
||||
</div>
|
||||
|
||||
<div id="empty-state" class="empty-state">
|
||||
<div class="empty-glyph" aria-hidden="true">
|
||||
<span></span><span></span><span></span>
|
||||
</div>
|
||||
<h3>先读取状态,再决定是否查看内容</h3>
|
||||
<p>
|
||||
状态响应只包含有界、低敏的 durable facts。模型文本必须由你再次明确选择。
|
||||
</p>
|
||||
<div class="evidence-rail" aria-hidden="true"><i></i><i></i><i></i><i></i></div>
|
||||
<h3>一次读取,一条可复核事实</h3>
|
||||
<p>先解锁页面并选择坐标。返回值按读取顺序保留在当前内存,reload 后清空。</p>
|
||||
</div>
|
||||
|
||||
<div id="result-view" hidden>
|
||||
<ol class="trace-rail" aria-label="诊断状态时间线">
|
||||
<li>
|
||||
<span class="trace-node"></span>
|
||||
<div>
|
||||
<small>ADMITTED</small>
|
||||
<strong id="admitted-at">—</strong>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="trace-node"></span>
|
||||
<div>
|
||||
<small>STAGE</small>
|
||||
<strong id="stage">—</strong>
|
||||
</div>
|
||||
</li>
|
||||
<li>
|
||||
<span class="trace-node"></span>
|
||||
<div>
|
||||
<small>OUTCOME</small>
|
||||
<strong id="outcome">—</strong>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<dl class="fact-grid">
|
||||
<div>
|
||||
<dt>Diagnosis Run</dt>
|
||||
<dd id="diagnosis-run">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Reason</dt>
|
||||
<dd id="reason">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tokens</dt>
|
||||
<dd id="tokens">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Settled cost</dt>
|
||||
<dd id="cost">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section id="output-panel" class="output-panel" hidden>
|
||||
<div class="output-heading">
|
||||
<div>
|
||||
<p class="eyebrow">Explicit content read</p>
|
||||
<h3>诊断内容</h3>
|
||||
</div>
|
||||
<span>不可信模型输出</span>
|
||||
</div>
|
||||
<pre id="output-text" tabindex="0"></pre>
|
||||
<dl class="output-meta">
|
||||
<div>
|
||||
<dt>Finish reason</dt>
|
||||
<dd id="finish-reason">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Output bytes</dt>
|
||||
<dd id="output-bytes">—</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Content digest</dt>
|
||||
<dd id="content-digest">—</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<ol id="ledger" class="ledger" aria-live="polite" hidden></ol>
|
||||
<div id="message" class="message" role="status" aria-live="polite"></div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer>
|
||||
<span>Loopback only · no legacy session · no browser credential</span>
|
||||
<span>QingLong 3.0 incubation / D-327</span>
|
||||
<span>Loopback only · explicit reads · zero polling</span>
|
||||
<span>QingLong 3.0 incubation / D-329</span>
|
||||
</footer>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -75,6 +75,24 @@ export interface ClusterCopilotClientReadiness {
|
||||
readonly ready: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Package-internal read transport shared by the Copilot client and the
|
||||
* loopback operator console. The caller owns the reviewed path vocabulary;
|
||||
* this boundary still rejects non-Project, mutation and cross-origin targets.
|
||||
*/
|
||||
export interface ClusterProjectApiReadExecution {
|
||||
readonly configFile: string;
|
||||
readonly credentialFile: string;
|
||||
readonly path: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export interface ClusterProjectApiReadResult {
|
||||
readonly schemaVersion: 1;
|
||||
readonly requestId: string;
|
||||
readonly result: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
interface PreparedClusterCopilotClientConfiguration {
|
||||
readonly endpoint: URL;
|
||||
readonly servername: string;
|
||||
@@ -134,6 +152,163 @@ const DNS_NAME =
|
||||
const API_CREDENTIAL =
|
||||
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
|
||||
const RESPONSE_CODE = /^[a-z][a-z0-9_]{0,127}$/;
|
||||
const TRANSPORT_REQUEST_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const PROJECT_SEGMENT = '[A-Za-z0-9][A-Za-z0-9._:-]{0,127}';
|
||||
const PACKAGE_SEGMENT = '[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?';
|
||||
const WORKFLOW_SEGMENT = '[a-z][a-z0-9-]{0,62}';
|
||||
const UUID_SEGMENT =
|
||||
'[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}';
|
||||
const POSITIVE_LIMIT = '(?:[1-9]|[1-5][0-9]|6[0-4])';
|
||||
const NON_NEGATIVE_INTEGER = '(?:0|[1-9][0-9]{0,15})';
|
||||
const projectRoot = '/api/v3/projects/' + PROJECT_SEGMENT;
|
||||
const PROJECT_READ_PATHS = Object.freeze([
|
||||
new RegExp('^' + projectRoot + '/runs\\?limit=' + POSITIVE_LIMIT + '$'),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/runs\\?after_created_at_ms=' +
|
||||
NON_NEGATIVE_INTEGER +
|
||||
'&after_run_id=' +
|
||||
PROJECT_SEGMENT +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp('^' + projectRoot + '/runs/' + PROJECT_SEGMENT + '$'),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/runs/' +
|
||||
PROJECT_SEGMENT +
|
||||
'/events\\?after_sequence=' +
|
||||
NON_NEGATIVE_INTEGER +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/runs/' +
|
||||
PROJECT_SEGMENT +
|
||||
'/steps\\?limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/runs/' +
|
||||
PROJECT_SEGMENT +
|
||||
'/steps\\?after_step_key=' +
|
||||
PROJECT_SEGMENT +
|
||||
'&after_step_run_id=' +
|
||||
PROJECT_SEGMENT +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp('^' + projectRoot + '/tasks\\?limit=' + POSITIVE_LIMIT + '$'),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/tasks\\?after_task_id=' +
|
||||
PROJECT_SEGMENT +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp('^' + projectRoot + '/tasks/' + PROJECT_SEGMENT + '$'),
|
||||
new RegExp(
|
||||
'^' + projectRoot + '/packages/' + PACKAGE_SEGMENT + '/workflows$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/packages/' +
|
||||
PACKAGE_SEGMENT +
|
||||
'/workflows/' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'/runs\\?limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/packages/' +
|
||||
PACKAGE_SEGMENT +
|
||||
'/workflows/' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'/runs\\?after_admitted_at_ms=' +
|
||||
NON_NEGATIVE_INTEGER +
|
||||
'&after_run_id=' +
|
||||
UUID_SEGMENT +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/packages/' +
|
||||
PACKAGE_SEGMENT +
|
||||
'/workflows/' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'/runs/' +
|
||||
UUID_SEGMENT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/packages/' +
|
||||
PACKAGE_SEGMENT +
|
||||
'/workflows/' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'/runs/' +
|
||||
UUID_SEGMENT +
|
||||
'/events\\?after_sequence=' +
|
||||
NON_NEGATIVE_INTEGER +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/packages/' +
|
||||
PACKAGE_SEGMENT +
|
||||
'/workflows/' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'/runs/' +
|
||||
UUID_SEGMENT +
|
||||
'/steps\\?limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
new RegExp(
|
||||
'^' +
|
||||
projectRoot +
|
||||
'/packages/' +
|
||||
PACKAGE_SEGMENT +
|
||||
'/workflows/' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'/runs/' +
|
||||
UUID_SEGMENT +
|
||||
'/steps\\?after_step_key=' +
|
||||
WORKFLOW_SEGMENT +
|
||||
'&after_step_run_id=' +
|
||||
UUID_SEGMENT +
|
||||
'&limit=' +
|
||||
POSITIVE_LIMIT +
|
||||
'$',
|
||||
),
|
||||
]);
|
||||
|
||||
function projectReadPathValid(value: string): boolean {
|
||||
return PROJECT_READ_PATHS.some((pattern) => pattern.test(value));
|
||||
}
|
||||
|
||||
function configurationFailure(): never {
|
||||
throw new ClusterCopilotClientConfigurationError();
|
||||
@@ -413,10 +588,7 @@ function requestJson(
|
||||
} catch (cause) {
|
||||
throw new ClusterCopilotClientRequestError({ cause });
|
||||
}
|
||||
finish(
|
||||
undefined,
|
||||
Object.freeze({ ...provisional, body }),
|
||||
);
|
||||
finish(undefined, Object.freeze({ ...provisional, body }));
|
||||
} catch (error) {
|
||||
finish(
|
||||
error instanceof ClusterCopilotClientRequestError
|
||||
@@ -447,10 +619,7 @@ function requestJson(
|
||||
});
|
||||
}
|
||||
|
||||
function responseRequestId(
|
||||
response: JsonResponse,
|
||||
expected: string,
|
||||
): string {
|
||||
function responseRequestId(response: JsonResponse, expected: string): string {
|
||||
const value = response.headers['x-request-id'];
|
||||
if (
|
||||
rawHeaderCount(response.rawHeaders, 'x-request-id') !== 1 ||
|
||||
@@ -462,7 +631,9 @@ function responseRequestId(
|
||||
return value;
|
||||
}
|
||||
|
||||
function retryAfterSeconds(value: string | string[] | undefined): number | null {
|
||||
function retryAfterSeconds(
|
||||
value: string | string[] | undefined,
|
||||
): number | null {
|
||||
if (typeof value !== 'string' || !/^[1-9][0-9]{0,3}$/.test(value)) {
|
||||
return null;
|
||||
}
|
||||
@@ -480,7 +651,9 @@ function remoteCode(value: unknown): string {
|
||||
keys.length < 1 ||
|
||||
keys.length > 3 ||
|
||||
keys[0] !== 'code' ||
|
||||
keys.some((key) => key !== 'code' && key !== 'reason' && key !== 'schema') ||
|
||||
keys.some(
|
||||
(key) => key !== 'code' && key !== 'reason' && key !== 'schema',
|
||||
) ||
|
||||
typeof record.code !== 'string' ||
|
||||
!RESPONSE_CODE.test(record.code)
|
||||
) {
|
||||
@@ -521,9 +694,7 @@ function readCredentialBytes(credentialFile: string): Buffer {
|
||||
return bytes;
|
||||
} catch (error) {
|
||||
bytes?.fill(0);
|
||||
if (
|
||||
error instanceof ClusterCopilotClientConfigurationError
|
||||
) {
|
||||
if (error instanceof ClusterCopilotClientConfigurationError) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterCopilotClientConfigurationError();
|
||||
@@ -552,8 +723,7 @@ export async function probeClusterCopilotClientReadiness(
|
||||
);
|
||||
const status = readinessStatus(response.body);
|
||||
const ready = response.statusCode === 200 && status === 'ready';
|
||||
const notReady =
|
||||
response.statusCode === 503 && status === 'not_ready';
|
||||
const notReady = response.statusCode === 503 && status === 'not_ready';
|
||||
if (!ready && !notReady) throw new ClusterCopilotClientRequestError();
|
||||
return Object.freeze({ schemaVersion: 1, transport: 'https', ready });
|
||||
} catch (error) {
|
||||
@@ -594,10 +764,7 @@ async function executeNormalizedClusterCopilotCommand(
|
||||
);
|
||||
if (request.body !== null) {
|
||||
bodyBytes = Buffer.from(JSON.stringify(request.body), 'utf8');
|
||||
if (
|
||||
bodyBytes.length < 2 ||
|
||||
bodyBytes.length > MAXIMUM_COMMAND_BYTES
|
||||
) {
|
||||
if (bodyBytes.length < 2 || bodyBytes.length > MAXIMUM_COMMAND_BYTES) {
|
||||
return configurationFailure();
|
||||
}
|
||||
}
|
||||
@@ -661,11 +828,7 @@ export async function executeClusterCopilotCommand(
|
||||
options?: ClusterCopilotClientOptions,
|
||||
): Promise<Readonly<ClusterCopilotClientResult>> {
|
||||
const normalizedOptions = validateOptions(options);
|
||||
const record = exact(execution, [
|
||||
'command',
|
||||
'configFile',
|
||||
'credentialFile',
|
||||
]);
|
||||
const record = exact(execution, ['command', 'configFile', 'credentialFile']);
|
||||
const command = normalizeClusterCopilotClientCommand(record.command);
|
||||
return executeNormalizedClusterCopilotCommand(
|
||||
record.configFile as string,
|
||||
@@ -680,11 +843,7 @@ export async function executeClusterCopilotClient(
|
||||
options?: ClusterCopilotClientOptions,
|
||||
): Promise<Readonly<ClusterCopilotClientResult>> {
|
||||
const normalizedOptions = validateOptions(options);
|
||||
const record = exact(paths, [
|
||||
'commandFile',
|
||||
'configFile',
|
||||
'credentialFile',
|
||||
]);
|
||||
const record = exact(paths, ['commandFile', 'configFile', 'credentialFile']);
|
||||
let commandBytes: Buffer | undefined;
|
||||
try {
|
||||
commandBytes = readCanonicalFile(
|
||||
@@ -712,3 +871,89 @@ export async function executeClusterCopilotClient(
|
||||
commandBytes?.fill(0);
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeClusterProjectApiRead(
|
||||
execution: ClusterProjectApiReadExecution,
|
||||
options?: ClusterCopilotClientOptions,
|
||||
): Promise<Readonly<ClusterProjectApiReadResult>> {
|
||||
const normalizedOptions = validateOptions(options);
|
||||
const record = exact(execution, [
|
||||
'configFile',
|
||||
'credentialFile',
|
||||
'path',
|
||||
'requestId',
|
||||
]);
|
||||
if (
|
||||
typeof record.path !== 'string' ||
|
||||
record.path.length > 2_048 ||
|
||||
!projectReadPathValid(record.path) ||
|
||||
record.path.includes('..') ||
|
||||
record.path.includes('//') ||
|
||||
typeof record.requestId !== 'string' ||
|
||||
!TRANSPORT_REQUEST_ID.test(record.requestId)
|
||||
) {
|
||||
throw new ClusterCopilotClientRequestError();
|
||||
}
|
||||
let credentialBytes: Buffer | undefined;
|
||||
let prepared: PreparedClusterCopilotClientConfiguration | undefined;
|
||||
try {
|
||||
prepared = prepareConfiguration(record.configFile as string);
|
||||
credentialBytes = readCredentialBytes(record.credentialFile as string);
|
||||
const response = await requestJson(
|
||||
prepared,
|
||||
Object.freeze({
|
||||
method: 'GET',
|
||||
path: record.path,
|
||||
requestId: record.requestId,
|
||||
authorization: `Bearer ${credentialBytes.toString('ascii')}`,
|
||||
}),
|
||||
MAXIMUM_RESPONSE_BYTES,
|
||||
normalizedOptions,
|
||||
);
|
||||
const requestId = responseRequestId(response, record.requestId);
|
||||
if (response.statusCode === 200) {
|
||||
if (
|
||||
!response.body ||
|
||||
typeof response.body !== 'object' ||
|
||||
Array.isArray(response.body)
|
||||
) {
|
||||
throw new ClusterCopilotClientRequestError();
|
||||
}
|
||||
return Object.freeze({
|
||||
schemaVersion: 1,
|
||||
requestId,
|
||||
result: Object.freeze({
|
||||
...(response.body as Record<string, unknown>),
|
||||
}),
|
||||
});
|
||||
}
|
||||
if (response.statusCode >= 400 && response.statusCode <= 599) {
|
||||
throw new ClusterCopilotClientRemoteError(
|
||||
response.statusCode,
|
||||
remoteCode(response.body),
|
||||
requestId,
|
||||
retryAfterSeconds(response.headers['retry-after']),
|
||||
);
|
||||
}
|
||||
throw new ClusterCopilotClientRequestError();
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof ClusterPluginPackageManagementClientConfigurationError
|
||||
) {
|
||||
throw new ClusterCopilotClientConfigurationError();
|
||||
}
|
||||
if (
|
||||
error instanceof ClusterCopilotClientConfigurationError ||
|
||||
error instanceof ClusterCopilotClientRequestError ||
|
||||
error instanceof ClusterCopilotClientRemoteError
|
||||
) {
|
||||
throw error;
|
||||
}
|
||||
throw new ClusterCopilotClientRequestError({
|
||||
cause: error instanceof Error ? error : undefined,
|
||||
});
|
||||
} finally {
|
||||
credentialBytes?.fill(0);
|
||||
prepared?.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import { createHash } from 'node:crypto';
|
||||
import {
|
||||
lstatSync,
|
||||
readFileSync,
|
||||
realpathSync,
|
||||
type PathLike,
|
||||
} from 'node:fs';
|
||||
import { lstatSync, readFileSync, realpathSync, type PathLike } from 'node:fs';
|
||||
import { isAbsolute, relative, resolve, sep } from 'node:path';
|
||||
import { TextDecoder } from 'node:util';
|
||||
|
||||
@@ -28,19 +23,19 @@ const ASSETS = Object.freeze([
|
||||
name: 'index.html',
|
||||
field: 'html',
|
||||
maximumBytes: 32 * 1024,
|
||||
digest: 'f9fa959f30b92c6b000eecb744ce1d0a7fce822c62b3e17dcf10d4d579a072ac',
|
||||
digest: 'ed8db5c26dec23e7a5237ef1cd4f5f9c3fc9f5a04a4751b7a3e0ed22dac54c42',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'app.css',
|
||||
field: 'css',
|
||||
maximumBytes: 64 * 1024,
|
||||
digest: '200c3405e1e12329fcfb50509b31b19f1567a91552865f039ce0c2de1530032c',
|
||||
digest: '54234cbba7e110de2f68fad2abd657c334b7e3e80c5d9b4f59bda7e122b4b62f',
|
||||
}),
|
||||
Object.freeze({
|
||||
name: 'app.js',
|
||||
field: 'javascript',
|
||||
maximumBytes: 32 * 1024,
|
||||
digest: 'd60913e725e767d9fa2cb65d60c0eae6d75d219f4bec8aad166bed8b6507fe02',
|
||||
digest: '61811eac6a89b097b67823ccf49b0736af6494be7b187dbdcbecfc59adb3fce0',
|
||||
}),
|
||||
] as const);
|
||||
|
||||
|
||||
@@ -2,13 +2,19 @@
|
||||
|
||||
import {
|
||||
executeClusterCopilotCommand,
|
||||
executeClusterProjectApiRead,
|
||||
probeClusterCopilotClientReadiness,
|
||||
validateClusterCopilotClientConfiguration,
|
||||
validateClusterCopilotClientCredentialFile,
|
||||
type ClusterCopilotClientCommand,
|
||||
} from '../copilot-client/client';
|
||||
import { readCanonicalFile } from '../management-support/managementClientConfiguration';
|
||||
import { loadClusterCopilotConsoleAssets } from './assets';
|
||||
import {
|
||||
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||
clusterCopilotConsoleClientCommand,
|
||||
clusterCopilotConsoleProjectReadPath,
|
||||
type ClusterCopilotConsoleReadRequest,
|
||||
} from './contracts';
|
||||
import {
|
||||
clusterCopilotConsoleSessionDigest,
|
||||
startClusterCopilotConsoleServer,
|
||||
@@ -28,9 +34,7 @@ interface ClusterCopilotConsoleCliArguments {
|
||||
readonly check: boolean;
|
||||
readonly configFile: string;
|
||||
readonly credentialFile: string;
|
||||
readonly networkBoundary:
|
||||
| 'host-loopback'
|
||||
| 'container-published-loopback';
|
||||
readonly networkBoundary: 'host-loopback' | 'container-published-loopback';
|
||||
readonly sessionFile: string;
|
||||
readonly port: number;
|
||||
}
|
||||
@@ -154,11 +158,7 @@ export function parseClusterCopilotConsoleCliArguments(
|
||||
function readSessionDigest(sessionFile: string): Buffer {
|
||||
let bytes: Buffer | undefined;
|
||||
try {
|
||||
bytes = readCanonicalFile(
|
||||
sessionFile,
|
||||
MAXIMUM_SESSION_BYTES,
|
||||
'private',
|
||||
);
|
||||
bytes = readCanonicalFile(sessionFile, MAXIMUM_SESSION_BYTES, 'private');
|
||||
if (
|
||||
bytes.some((byte) => byte > 0x7f) ||
|
||||
!SESSION_TOKEN.test(bytes.toString('ascii'))
|
||||
@@ -199,7 +199,7 @@ async function main(): Promise<void> {
|
||||
publishedHostAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||
mutation: false,
|
||||
}) + '\n',
|
||||
);
|
||||
@@ -213,11 +213,19 @@ async function main(): Promise<void> {
|
||||
const server = await startClusterCopilotConsoleServer({
|
||||
assets,
|
||||
executor: Object.freeze({
|
||||
execute(command: Readonly<ClusterCopilotClientCommand>) {
|
||||
return executeClusterCopilotCommand({
|
||||
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
|
||||
if (request.operation === 'inspect' || request.operation === 'output') {
|
||||
return executeClusterCopilotCommand({
|
||||
configFile: parsed.configFile,
|
||||
credentialFile: parsed.credentialFile,
|
||||
command: clusterCopilotConsoleClientCommand(request),
|
||||
});
|
||||
}
|
||||
return executeClusterProjectApiRead({
|
||||
configFile: parsed.configFile,
|
||||
credentialFile: parsed.credentialFile,
|
||||
command,
|
||||
path: clusterCopilotConsoleProjectReadPath(request),
|
||||
requestId: request.requestId,
|
||||
});
|
||||
},
|
||||
}),
|
||||
@@ -236,7 +244,7 @@ async function main(): Promise<void> {
|
||||
publishedHostAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||
mutation: false,
|
||||
}) + '\n',
|
||||
);
|
||||
|
||||
@@ -8,16 +8,85 @@ export const CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA =
|
||||
export const CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA =
|
||||
'qinglong/cluster-copilot-console-read-response@v1' as const;
|
||||
|
||||
export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output';
|
||||
export const CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS = Object.freeze([
|
||||
'inspect',
|
||||
'output',
|
||||
'run_list',
|
||||
'run_read',
|
||||
'run_event_list',
|
||||
'run_step_list',
|
||||
'task_list',
|
||||
'task_read',
|
||||
'workflow_list',
|
||||
'workflow_run_list',
|
||||
'workflow_run_read',
|
||||
'workflow_event_list',
|
||||
'workflow_step_list',
|
||||
] as const);
|
||||
|
||||
export interface ClusterCopilotConsoleReadRequest {
|
||||
export type ClusterCopilotConsoleReadOperation =
|
||||
(typeof CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS)[number];
|
||||
|
||||
interface BaseReadRequest<
|
||||
Operation extends ClusterCopilotConsoleReadOperation,
|
||||
> {
|
||||
readonly schema: typeof CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA;
|
||||
readonly operation: ClusterCopilotConsoleReadOperation;
|
||||
readonly operation: Operation;
|
||||
readonly projectId: string;
|
||||
readonly sourceRunId: string;
|
||||
readonly requestId: string;
|
||||
}
|
||||
|
||||
export type ClusterCopilotConsoleReadRequest =
|
||||
| (BaseReadRequest<'inspect'> & Readonly<{ sourceRunId: string }>)
|
||||
| (BaseReadRequest<'output'> & Readonly<{ sourceRunId: string }>)
|
||||
| (BaseReadRequest<'run_list'> &
|
||||
Readonly<{
|
||||
afterCreatedAtMs: number | null;
|
||||
afterRunId: string | null;
|
||||
limit: number;
|
||||
}>)
|
||||
| (BaseReadRequest<'run_read'> & Readonly<{ runId: string }>)
|
||||
| (BaseReadRequest<'run_event_list'> &
|
||||
Readonly<{ runId: string; afterSequence: number; limit: number }>)
|
||||
| (BaseReadRequest<'run_step_list'> &
|
||||
Readonly<{
|
||||
runId: string;
|
||||
afterStepKey: string | null;
|
||||
afterStepRunId: string | null;
|
||||
limit: number;
|
||||
}>)
|
||||
| (BaseReadRequest<'task_list'> &
|
||||
Readonly<{ afterTaskId: string | null; limit: number }>)
|
||||
| (BaseReadRequest<'task_read'> & Readonly<{ taskId: string }>)
|
||||
| (BaseReadRequest<'workflow_list'> & Readonly<{ packageName: string }>)
|
||||
| (BaseReadRequest<'workflow_run_list'> &
|
||||
Readonly<{
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
afterAdmittedAtMs: number | null;
|
||||
afterRunId: string | null;
|
||||
limit: number;
|
||||
}>)
|
||||
| (BaseReadRequest<'workflow_run_read'> &
|
||||
Readonly<{ packageName: string; workflowId: string; runId: string }>)
|
||||
| (BaseReadRequest<'workflow_event_list'> &
|
||||
Readonly<{
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
afterSequence: number;
|
||||
limit: number;
|
||||
}>)
|
||||
| (BaseReadRequest<'workflow_step_list'> &
|
||||
Readonly<{
|
||||
packageName: string;
|
||||
workflowId: string;
|
||||
runId: string;
|
||||
afterStepKey: string | null;
|
||||
afterStepRunId: string | null;
|
||||
limit: number;
|
||||
}>);
|
||||
|
||||
export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
|
||||
readonly code = 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID';
|
||||
|
||||
@@ -28,47 +97,305 @@ export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
|
||||
}
|
||||
|
||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||
const RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
||||
const COPILOT_RUN_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,35}$/;
|
||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
||||
const WORKFLOW_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
||||
const UUID_V4 =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
|
||||
function invalid(): never {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
|
||||
export function normalizeClusterCopilotConsoleReadRequest(
|
||||
value: unknown,
|
||||
): Readonly<ClusterCopilotConsoleReadRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
return invalid();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
const keys = Object.keys(record).sort();
|
||||
function exact(
|
||||
record: Record<string, unknown>,
|
||||
operation: ClusterCopilotConsoleReadOperation,
|
||||
fields: readonly string[],
|
||||
): void {
|
||||
const actual = Object.keys(record).sort();
|
||||
const expected = [
|
||||
'operation',
|
||||
'projectId',
|
||||
'requestId',
|
||||
'schema',
|
||||
'sourceRunId',
|
||||
];
|
||||
...fields,
|
||||
].sort();
|
||||
if (
|
||||
keys.length !== expected.length ||
|
||||
keys.some((key, index) => key !== expected[index]) ||
|
||||
actual.length !== expected.length ||
|
||||
actual.some((key, index) => key !== expected[index]) ||
|
||||
record.schema !== CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA ||
|
||||
(record.operation !== 'inspect' && record.operation !== 'output') ||
|
||||
record.operation !== operation ||
|
||||
typeof record.projectId !== 'string' ||
|
||||
!IDENTITY.test(record.projectId) ||
|
||||
typeof record.sourceRunId !== 'string' ||
|
||||
!RUN_ID.test(record.sourceRunId) ||
|
||||
typeof record.requestId !== 'string' ||
|
||||
!IDENTITY.test(record.requestId)
|
||||
) {
|
||||
return invalid();
|
||||
}
|
||||
return Object.freeze({
|
||||
)
|
||||
invalid();
|
||||
}
|
||||
|
||||
function identifier(value: unknown): value is string {
|
||||
return typeof value === 'string' && IDENTITY.test(value);
|
||||
}
|
||||
|
||||
function limit(value: unknown): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) && Number(value) >= 1 && Number(value) <= 64
|
||||
);
|
||||
}
|
||||
|
||||
function sequence(value: unknown): value is number {
|
||||
return (
|
||||
Number.isSafeInteger(value) &&
|
||||
Number(value) >= 0 &&
|
||||
Number(value) <= 2_147_483_647
|
||||
);
|
||||
}
|
||||
|
||||
function timestamp(value: unknown): value is number {
|
||||
return Number.isSafeInteger(value) && Number(value) >= 0;
|
||||
}
|
||||
|
||||
function common(record: Record<string, unknown>) {
|
||||
return {
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
operation: record.operation,
|
||||
projectId: record.projectId,
|
||||
sourceRunId: record.sourceRunId,
|
||||
requestId: record.requestId,
|
||||
projectId: record.projectId as string,
|
||||
requestId: record.requestId as string,
|
||||
} as const;
|
||||
}
|
||||
|
||||
function workflowTarget(
|
||||
record: Record<string, unknown>,
|
||||
requireRun: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
typeof record.packageName === 'string' &&
|
||||
PACKAGE_NAME.test(record.packageName) &&
|
||||
typeof record.workflowId === 'string' &&
|
||||
WORKFLOW_ID.test(record.workflowId) &&
|
||||
(!requireRun ||
|
||||
(typeof record.runId === 'string' && UUID_V4.test(record.runId)))
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeClusterCopilotConsoleReadRequest(
|
||||
value: unknown,
|
||||
): Readonly<ClusterCopilotConsoleReadRequest> {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) invalid();
|
||||
const record = value as Record<string, unknown>;
|
||||
const operation = record.operation;
|
||||
if (
|
||||
typeof operation !== 'string' ||
|
||||
!CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.includes(
|
||||
operation as ClusterCopilotConsoleReadOperation,
|
||||
)
|
||||
)
|
||||
invalid();
|
||||
const op = operation as ClusterCopilotConsoleReadOperation;
|
||||
|
||||
if (op === 'inspect' || op === 'output') {
|
||||
exact(record, op, ['sourceRunId']);
|
||||
if (
|
||||
typeof record.sourceRunId !== 'string' ||
|
||||
!COPILOT_RUN_ID.test(record.sourceRunId)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
sourceRunId: record.sourceRunId,
|
||||
});
|
||||
}
|
||||
if (op === 'run_list') {
|
||||
exact(record, op, ['afterCreatedAtMs', 'afterRunId', 'limit']);
|
||||
if (
|
||||
(record.afterCreatedAtMs === null) !== (record.afterRunId === null) ||
|
||||
(record.afterCreatedAtMs !== null &&
|
||||
!timestamp(record.afterCreatedAtMs)) ||
|
||||
(record.afterRunId !== null && !identifier(record.afterRunId)) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
afterCreatedAtMs: record.afterCreatedAtMs as number | null,
|
||||
afterRunId: record.afterRunId as string | null,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
if (op === 'run_read') {
|
||||
exact(record, op, ['runId']);
|
||||
if (!identifier(record.runId)) invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
runId: record.runId,
|
||||
});
|
||||
}
|
||||
if (op === 'run_event_list') {
|
||||
exact(record, op, ['afterSequence', 'limit', 'runId']);
|
||||
if (
|
||||
!identifier(record.runId) ||
|
||||
!sequence(record.afterSequence) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
runId: record.runId,
|
||||
afterSequence: record.afterSequence as number,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
if (op === 'run_step_list') {
|
||||
exact(record, op, ['afterStepKey', 'afterStepRunId', 'limit', 'runId']);
|
||||
if (
|
||||
!identifier(record.runId) ||
|
||||
(record.afterStepKey === null) !== (record.afterStepRunId === null) ||
|
||||
(record.afterStepKey !== null && !identifier(record.afterStepKey)) ||
|
||||
(record.afterStepRunId !== null && !identifier(record.afterStepRunId)) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
runId: record.runId,
|
||||
afterStepKey: record.afterStepKey as string | null,
|
||||
afterStepRunId: record.afterStepRunId as string | null,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
if (op === 'task_list') {
|
||||
exact(record, op, ['afterTaskId', 'limit']);
|
||||
if (
|
||||
(record.afterTaskId !== null && !identifier(record.afterTaskId)) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
afterTaskId: record.afterTaskId as string | null,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
if (op === 'task_read') {
|
||||
exact(record, op, ['taskId']);
|
||||
if (!identifier(record.taskId)) invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
taskId: record.taskId,
|
||||
});
|
||||
}
|
||||
if (op === 'workflow_list') {
|
||||
exact(record, op, ['packageName']);
|
||||
if (
|
||||
typeof record.packageName !== 'string' ||
|
||||
!PACKAGE_NAME.test(record.packageName)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
packageName: record.packageName,
|
||||
});
|
||||
}
|
||||
if (op === 'workflow_run_list') {
|
||||
exact(record, op, [
|
||||
'afterAdmittedAtMs',
|
||||
'afterRunId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'workflowId',
|
||||
]);
|
||||
if (
|
||||
!workflowTarget(record, false) ||
|
||||
(record.afterAdmittedAtMs === null) !== (record.afterRunId === null) ||
|
||||
(record.afterAdmittedAtMs !== null &&
|
||||
!timestamp(record.afterAdmittedAtMs)) ||
|
||||
(record.afterRunId !== null &&
|
||||
(typeof record.afterRunId !== 'string' ||
|
||||
!UUID_V4.test(record.afterRunId))) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
packageName: record.packageName as string,
|
||||
workflowId: record.workflowId as string,
|
||||
afterAdmittedAtMs: record.afterAdmittedAtMs as number | null,
|
||||
afterRunId: record.afterRunId as string | null,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
if (op === 'workflow_run_read') {
|
||||
exact(record, op, ['packageName', 'runId', 'workflowId']);
|
||||
if (!workflowTarget(record, true)) invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
packageName: record.packageName as string,
|
||||
workflowId: record.workflowId as string,
|
||||
runId: record.runId as string,
|
||||
});
|
||||
}
|
||||
if (op === 'workflow_event_list') {
|
||||
exact(record, op, [
|
||||
'afterSequence',
|
||||
'limit',
|
||||
'packageName',
|
||||
'runId',
|
||||
'workflowId',
|
||||
]);
|
||||
if (
|
||||
!workflowTarget(record, true) ||
|
||||
!sequence(record.afterSequence) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: op,
|
||||
packageName: record.packageName as string,
|
||||
workflowId: record.workflowId as string,
|
||||
runId: record.runId as string,
|
||||
afterSequence: record.afterSequence as number,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
exact(record, 'workflow_step_list', [
|
||||
'afterStepKey',
|
||||
'afterStepRunId',
|
||||
'limit',
|
||||
'packageName',
|
||||
'runId',
|
||||
'workflowId',
|
||||
]);
|
||||
if (
|
||||
!workflowTarget(record, true) ||
|
||||
(record.afterStepKey === null) !== (record.afterStepRunId === null) ||
|
||||
(record.afterStepKey !== null &&
|
||||
(typeof record.afterStepKey !== 'string' ||
|
||||
!WORKFLOW_ID.test(record.afterStepKey))) ||
|
||||
(record.afterStepRunId !== null &&
|
||||
(typeof record.afterStepRunId !== 'string' ||
|
||||
!UUID_V4.test(record.afterStepRunId))) ||
|
||||
!limit(record.limit)
|
||||
)
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
...common(record),
|
||||
operation: 'workflow_step_list',
|
||||
packageName: record.packageName as string,
|
||||
workflowId: record.workflowId as string,
|
||||
runId: record.runId as string,
|
||||
afterStepKey: record.afterStepKey as string | null,
|
||||
afterStepRunId: record.afterStepRunId as string | null,
|
||||
limit: record.limit as number,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -76,6 +403,8 @@ export function clusterCopilotConsoleClientCommand(
|
||||
request: Readonly<ClusterCopilotConsoleReadRequest>,
|
||||
): Readonly<ClusterCopilotClientCommand> {
|
||||
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
|
||||
if (normalized.operation !== 'inspect' && normalized.operation !== 'output')
|
||||
invalid();
|
||||
return Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
|
||||
operation: normalized.operation,
|
||||
@@ -84,3 +413,111 @@ export function clusterCopilotConsoleClientCommand(
|
||||
requestId: normalized.requestId,
|
||||
});
|
||||
}
|
||||
|
||||
function encoded(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
function query(
|
||||
entries: readonly (readonly [string, string | number])[],
|
||||
): string {
|
||||
return entries.length === 0
|
||||
? ''
|
||||
: '?' +
|
||||
entries
|
||||
.map(([key, value]) => encoded(key) + '=' + encoded(String(value)))
|
||||
.join('&');
|
||||
}
|
||||
|
||||
export function clusterCopilotConsoleProjectReadPath(
|
||||
request: Readonly<ClusterCopilotConsoleReadRequest>,
|
||||
): string {
|
||||
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
|
||||
if (normalized.operation === 'inspect' || normalized.operation === 'output')
|
||||
invalid();
|
||||
const project = '/api/v3/projects/' + encoded(normalized.projectId);
|
||||
if (normalized.operation === 'run_list') {
|
||||
const cursor: (readonly [string, string | number])[] =
|
||||
normalized.afterCreatedAtMs === null
|
||||
? []
|
||||
: [
|
||||
['after_created_at_ms', normalized.afterCreatedAtMs],
|
||||
['after_run_id', normalized.afterRunId!],
|
||||
];
|
||||
return project + '/runs' + query([...cursor, ['limit', normalized.limit]]);
|
||||
}
|
||||
if (normalized.operation === 'run_read')
|
||||
return project + '/runs/' + encoded(normalized.runId);
|
||||
if (normalized.operation === 'run_event_list')
|
||||
return (
|
||||
project +
|
||||
'/runs/' +
|
||||
encoded(normalized.runId) +
|
||||
'/events' +
|
||||
query([
|
||||
['after_sequence', normalized.afterSequence],
|
||||
['limit', normalized.limit],
|
||||
])
|
||||
);
|
||||
if (normalized.operation === 'run_step_list') {
|
||||
const cursor: (readonly [string, string | number])[] =
|
||||
normalized.afterStepKey === null
|
||||
? []
|
||||
: [
|
||||
['after_step_key', normalized.afterStepKey],
|
||||
['after_step_run_id', normalized.afterStepRunId!],
|
||||
];
|
||||
return (
|
||||
project +
|
||||
'/runs/' +
|
||||
encoded(normalized.runId) +
|
||||
'/steps' +
|
||||
query([...cursor, ['limit', normalized.limit]])
|
||||
);
|
||||
}
|
||||
if (normalized.operation === 'task_list') {
|
||||
const cursor: (readonly [string, string | number])[] =
|
||||
normalized.afterTaskId === null
|
||||
? []
|
||||
: [['after_task_id', normalized.afterTaskId]];
|
||||
return project + '/tasks' + query([...cursor, ['limit', normalized.limit]]);
|
||||
}
|
||||
if (normalized.operation === 'task_read')
|
||||
return project + '/tasks/' + encoded(normalized.taskId);
|
||||
const packageRoot = project + '/packages/' + encoded(normalized.packageName);
|
||||
if (normalized.operation === 'workflow_list')
|
||||
return packageRoot + '/workflows';
|
||||
const workflowRoot =
|
||||
packageRoot + '/workflows/' + encoded(normalized.workflowId);
|
||||
if (normalized.operation === 'workflow_run_list') {
|
||||
const cursor: (readonly [string, string | number])[] =
|
||||
normalized.afterAdmittedAtMs === null
|
||||
? []
|
||||
: [
|
||||
['after_admitted_at_ms', normalized.afterAdmittedAtMs],
|
||||
['after_run_id', normalized.afterRunId!],
|
||||
];
|
||||
return (
|
||||
workflowRoot + '/runs' + query([...cursor, ['limit', normalized.limit]])
|
||||
);
|
||||
}
|
||||
const runRoot = workflowRoot + '/runs/' + encoded(normalized.runId);
|
||||
if (normalized.operation === 'workflow_run_read') return runRoot;
|
||||
if (normalized.operation === 'workflow_event_list')
|
||||
return (
|
||||
runRoot +
|
||||
'/events' +
|
||||
query([
|
||||
['after_sequence', normalized.afterSequence],
|
||||
['limit', normalized.limit],
|
||||
])
|
||||
);
|
||||
const cursor: (readonly [string, string | number])[] =
|
||||
normalized.afterStepKey === null
|
||||
? []
|
||||
: [
|
||||
['after_step_key', normalized.afterStepKey],
|
||||
['after_step_run_id', normalized.afterStepRunId!],
|
||||
];
|
||||
return runRoot + '/steps' + query([...cursor, ['limit', normalized.limit]]);
|
||||
}
|
||||
|
||||
@@ -9,17 +9,14 @@ import {
|
||||
ClusterCopilotClientConfigurationError,
|
||||
ClusterCopilotClientRemoteError,
|
||||
ClusterCopilotClientRequestError,
|
||||
type ClusterCopilotClientCommand,
|
||||
type ClusterCopilotClientResult,
|
||||
} from '../copilot-client/client';
|
||||
import {
|
||||
type ClusterCopilotConsoleAssets,
|
||||
} from './assets';
|
||||
import { type ClusterCopilotConsoleAssets } from './assets';
|
||||
import {
|
||||
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
InvalidClusterCopilotConsoleReadRequestError,
|
||||
clusterCopilotConsoleClientCommand,
|
||||
normalizeClusterCopilotConsoleReadRequest,
|
||||
type ClusterCopilotConsoleReadOperation,
|
||||
type ClusterCopilotConsoleReadRequest,
|
||||
} from './contracts';
|
||||
|
||||
export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
|
||||
@@ -31,9 +28,13 @@ export const CLUSTER_COPILOT_CONSOLE_LIMITS = Object.freeze({
|
||||
});
|
||||
|
||||
export interface ClusterCopilotConsoleExecutor {
|
||||
execute(
|
||||
command: Readonly<ClusterCopilotClientCommand>,
|
||||
): Promise<Readonly<ClusterCopilotClientResult>>;
|
||||
execute(request: Readonly<ClusterCopilotConsoleReadRequest>): Promise<
|
||||
Readonly<{
|
||||
schemaVersion: 1;
|
||||
requestId: string;
|
||||
result: Readonly<Record<string, unknown>>;
|
||||
}>
|
||||
>;
|
||||
}
|
||||
|
||||
export interface ClusterCopilotConsoleServerOptions {
|
||||
@@ -111,10 +112,7 @@ export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
|
||||
return invalid();
|
||||
}
|
||||
const decoded = Buffer.from(value, 'base64url');
|
||||
if (
|
||||
decoded.byteLength !== 32 ||
|
||||
decoded.toString('base64url') !== value
|
||||
) {
|
||||
if (decoded.byteLength !== 32 || decoded.toString('base64url') !== value) {
|
||||
decoded.fill(0);
|
||||
return invalid();
|
||||
}
|
||||
@@ -125,7 +123,9 @@ export function clusterCopilotConsoleSessionDigest(value: string): Buffer {
|
||||
.digest();
|
||||
}
|
||||
|
||||
function securityHeaders(contentType: string): Readonly<Record<string, string>> {
|
||||
function securityHeaders(
|
||||
contentType: string,
|
||||
): Readonly<Record<string, string>> {
|
||||
return Object.freeze({
|
||||
'cache-control': 'no-store',
|
||||
'content-security-policy': CONTENT_SECURITY_POLICY,
|
||||
@@ -181,11 +181,29 @@ function headerCount(request: IncomingMessage, name: string): number {
|
||||
return count;
|
||||
}
|
||||
|
||||
function targetPath(request: IncomingMessage): 'inspect' | 'output' | null {
|
||||
const READ_ROUTES: Readonly<
|
||||
Record<string, ClusterCopilotConsoleReadOperation>
|
||||
> = Object.freeze({
|
||||
'/api/v1/copilot/inspect': 'inspect',
|
||||
'/api/v1/copilot/output': 'output',
|
||||
'/api/v1/observe/run-list': 'run_list',
|
||||
'/api/v1/observe/run': 'run_read',
|
||||
'/api/v1/observe/run-events': 'run_event_list',
|
||||
'/api/v1/observe/run-steps': 'run_step_list',
|
||||
'/api/v1/observe/task-list': 'task_list',
|
||||
'/api/v1/observe/task': 'task_read',
|
||||
'/api/v1/observe/workflow-list': 'workflow_list',
|
||||
'/api/v1/observe/workflow-run-list': 'workflow_run_list',
|
||||
'/api/v1/observe/workflow-run': 'workflow_run_read',
|
||||
'/api/v1/observe/workflow-events': 'workflow_event_list',
|
||||
'/api/v1/observe/workflow-steps': 'workflow_step_list',
|
||||
});
|
||||
|
||||
function targetPath(
|
||||
request: IncomingMessage,
|
||||
): ClusterCopilotConsoleReadOperation | null {
|
||||
if (request.method !== 'POST') return null;
|
||||
if (request.url === '/api/v1/copilot/inspect') return 'inspect';
|
||||
if (request.url === '/api/v1/copilot/output') return 'output';
|
||||
return null;
|
||||
return request.url === undefined ? null : READ_ROUTES[request.url] ?? null;
|
||||
}
|
||||
|
||||
function authorize(
|
||||
@@ -279,11 +297,7 @@ function remoteFailure(
|
||||
error: ClusterCopilotClientRemoteError,
|
||||
): void {
|
||||
const statusCode =
|
||||
error.statusCode === 404
|
||||
? 404
|
||||
: error.statusCode === 429
|
||||
? 429
|
||||
: 502;
|
||||
error.statusCode === 404 ? 404 : error.statusCode === 429 ? 429 : 502;
|
||||
sendJson(
|
||||
response,
|
||||
statusCode,
|
||||
@@ -377,9 +391,7 @@ export async function startClusterCopilotConsoleServer(
|
||||
request.resume();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests
|
||||
) {
|
||||
if (inFlight >= CLUSTER_COPILOT_CONSOLE_LIMITS.maximumConcurrentRequests) {
|
||||
sendJson(
|
||||
response,
|
||||
429,
|
||||
@@ -400,9 +412,7 @@ export async function startClusterCopilotConsoleServer(
|
||||
if (normalized.operation !== operation) {
|
||||
throw new InvalidClusterCopilotConsoleReadRequestError();
|
||||
}
|
||||
const result = await executor.execute(
|
||||
clusterCopilotConsoleClientCommand(normalized),
|
||||
);
|
||||
const result = await executor.execute(normalized);
|
||||
const envelope = Object.freeze({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||
operation,
|
||||
@@ -416,12 +426,7 @@ export async function startClusterCopilotConsoleServer(
|
||||
) {
|
||||
throw new ClusterCopilotClientRequestError();
|
||||
}
|
||||
send(
|
||||
response,
|
||||
200,
|
||||
'application/json; charset=utf-8',
|
||||
encoded,
|
||||
);
|
||||
send(response, 200, 'application/json; charset=utf-8', encoded);
|
||||
} catch (error) {
|
||||
if (response.headersSent) {
|
||||
response.destroy();
|
||||
|
||||
@@ -20,6 +20,7 @@ const {
|
||||
CLUSTER_COPILOT_CLIENT_CONFIG_SCHEMA,
|
||||
ClusterCopilotClientRemoteError,
|
||||
executeClusterCopilotClient,
|
||||
executeClusterProjectApiRead,
|
||||
probeClusterCopilotClientReadiness,
|
||||
validateClusterCopilotClientConfiguration,
|
||||
} = require('../dist/copilot-client/client.js');
|
||||
@@ -35,7 +36,9 @@ const {
|
||||
validateClusterCopilotClientResponse,
|
||||
} = require('../dist/copilot-client/contracts.js');
|
||||
|
||||
const credential = `ql3c_credential-1_${Buffer.alloc(32, 7).toString('base64url')}`;
|
||||
const credential = `ql3c_credential-1_${Buffer.alloc(32, 7).toString(
|
||||
'base64url',
|
||||
)}`;
|
||||
const baseCommand = {
|
||||
schema: CLUSTER_COPILOT_CLIENT_COMMAND_SCHEMA,
|
||||
projectId: 'project-1',
|
||||
@@ -58,11 +61,7 @@ function temporaryDirectory(t) {
|
||||
}
|
||||
|
||||
function configuration(directory, port) {
|
||||
const caFile = privateFile(
|
||||
directory,
|
||||
'ca.pem',
|
||||
fs.readFileSync(caFixture),
|
||||
);
|
||||
const caFile = privateFile(directory, 'ca.pem', fs.readFileSync(caFixture));
|
||||
return privateFile(
|
||||
directory,
|
||||
'client.json',
|
||||
@@ -113,9 +112,7 @@ async function startServer(handler) {
|
||||
port: server.address().port,
|
||||
close: () =>
|
||||
new Promise((resolvePromise, reject) => {
|
||||
server.close((error) =>
|
||||
error ? reject(error) : resolvePromise(),
|
||||
);
|
||||
server.close((error) => (error ? reject(error) : resolvePromise()));
|
||||
}),
|
||||
};
|
||||
}
|
||||
@@ -266,7 +263,11 @@ test('normalizes only the four bounded commands and derives exact requests', ()
|
||||
});
|
||||
|
||||
test('validates exact target-bound response state for every operation', () => {
|
||||
const diagnose = { ...baseCommand, operation: 'diagnose', traceId: 'trace-1' };
|
||||
const diagnose = {
|
||||
...baseCommand,
|
||||
operation: 'diagnose',
|
||||
traceId: 'trace-1',
|
||||
};
|
||||
const inspect = { ...baseCommand, operation: 'inspect' };
|
||||
const output = { ...baseCommand, operation: 'output' };
|
||||
const cancel = {
|
||||
@@ -307,8 +308,7 @@ test('validates exact target-bound response state for every operation', () => {
|
||||
'diagnosis',
|
||||
);
|
||||
assert.equal(
|
||||
validateClusterCopilotClientResponse(cancellationResponse(), cancel)
|
||||
.status,
|
||||
validateClusterCopilotClientResponse(cancellationResponse(), cancel).status,
|
||||
'accepted',
|
||||
);
|
||||
assert.equal(
|
||||
@@ -357,10 +357,10 @@ test('validates exact target-bound response state for every operation', () => {
|
||||
validateClusterCopilotClientResponse(invalidOutput, output),
|
||||
);
|
||||
assert.throws(() =>
|
||||
validateClusterCopilotClientResponse(
|
||||
cancellationResponse(),
|
||||
{ ...cancel, requestId: 'other-request' },
|
||||
),
|
||||
validateClusterCopilotClientResponse(cancellationResponse(), {
|
||||
...cancel,
|
||||
requestId: 'other-request',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -383,7 +383,10 @@ test('uses TLS 1.3, Bearer credential and exact request identities end to end',
|
||||
if (request.url === '/readyz') {
|
||||
assert.equal(request.headers.authorization, undefined);
|
||||
jsonResponse(response, 200, null, { status: 'ready' });
|
||||
} else if (request.method === 'POST' && request.url.endsWith('/cancellation')) {
|
||||
} else if (
|
||||
request.method === 'POST' &&
|
||||
request.url.endsWith('/cancellation')
|
||||
) {
|
||||
jsonResponse(response, 202, requestId, cancellationResponse());
|
||||
} else if (request.method === 'POST') {
|
||||
jsonResponse(response, 201, requestId, diagnoseResponse());
|
||||
@@ -448,6 +451,65 @@ test('uses TLS 1.3, Bearer credential and exact request identities end to end',
|
||||
});
|
||||
});
|
||||
|
||||
test('reuses the credential-safe TLS boundary for fixed Project API reads only', async (t) => {
|
||||
const seen = [];
|
||||
const server = await startServer((request, response) => {
|
||||
const chunks = [];
|
||||
request.on('data', (chunk) => chunks.push(chunk));
|
||||
request.on('end', () => {
|
||||
seen.push({
|
||||
method: request.method,
|
||||
url: request.url,
|
||||
authorization: request.headers.authorization,
|
||||
requestId: request.headers['x-request-id'],
|
||||
bodyBytes: Buffer.concat(chunks).byteLength,
|
||||
tls: request.socket.getProtocol(),
|
||||
});
|
||||
jsonResponse(response, 200, request.headers['x-request-id'], {
|
||||
runs: [],
|
||||
hasMore: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
t.after(() => server.close());
|
||||
const directory = temporaryDirectory(t);
|
||||
const execution = {
|
||||
configFile: configuration(directory, server.port),
|
||||
credentialFile: privateFile(directory, 'credential', credential),
|
||||
path: '/api/v3/projects/project-1/runs?limit=32',
|
||||
requestId: 'console-read-1',
|
||||
};
|
||||
assert.deepEqual(await executeClusterProjectApiRead(execution), {
|
||||
schemaVersion: 1,
|
||||
requestId: 'console-read-1',
|
||||
result: { runs: [], hasMore: false },
|
||||
});
|
||||
assert.deepEqual(seen, [
|
||||
{
|
||||
method: 'GET',
|
||||
url: '/api/v3/projects/project-1/runs?limit=32',
|
||||
authorization: `Bearer ${credential}`,
|
||||
requestId: 'console-read-1',
|
||||
bodyBytes: 0,
|
||||
tls: 'TLSv1.3',
|
||||
},
|
||||
]);
|
||||
await assert.rejects(
|
||||
executeClusterProjectApiRead({
|
||||
...execution,
|
||||
path: '/api/v3/projects/project-1/runs/run-1/cancellation',
|
||||
}),
|
||||
{ code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED' },
|
||||
);
|
||||
await assert.rejects(
|
||||
executeClusterProjectApiRead({
|
||||
...execution,
|
||||
path: 'https://attacker.example/api/v3/projects/project-1/runs',
|
||||
}),
|
||||
{ code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED' },
|
||||
);
|
||||
});
|
||||
|
||||
test('fails closed on weak files, request-id drift and low-sensitive remote errors', async (t) => {
|
||||
let mode = 'readiness-drift';
|
||||
const server = await startServer((request, response) => {
|
||||
@@ -488,18 +550,15 @@ test('fails closed on weak files, request-id drift and low-sensitive remote erro
|
||||
});
|
||||
|
||||
mode = 'remote';
|
||||
await assert.rejects(
|
||||
executeClusterCopilotClient(paths),
|
||||
(error) => {
|
||||
assert.equal(error instanceof ClusterCopilotClientRemoteError, true);
|
||||
assert.equal(error.statusCode, 429);
|
||||
assert.equal(error.responseCode, 'copilot_rate_limited');
|
||||
assert.equal(error.requestId, baseCommand.requestId);
|
||||
assert.equal(error.retryAfterSeconds, 30);
|
||||
assert.equal(JSON.stringify(error).includes('private detail'), false);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
await assert.rejects(executeClusterCopilotClient(paths), (error) => {
|
||||
assert.equal(error instanceof ClusterCopilotClientRemoteError, true);
|
||||
assert.equal(error.statusCode, 429);
|
||||
assert.equal(error.responseCode, 'copilot_rate_limited');
|
||||
assert.equal(error.requestId, baseCommand.requestId);
|
||||
assert.equal(error.retryAfterSeconds, 30);
|
||||
assert.equal(JSON.stringify(error).includes('private detail'), false);
|
||||
return true;
|
||||
});
|
||||
|
||||
const cli = await runCli([
|
||||
`--config=${configFile}`,
|
||||
@@ -602,11 +661,7 @@ test('rejects response framing drift, oversized bodies, aborts and timeouts', as
|
||||
});
|
||||
t.after(() => server.close());
|
||||
const directory = temporaryDirectory(t);
|
||||
const caFile = privateFile(
|
||||
directory,
|
||||
'ca.pem',
|
||||
fs.readFileSync(caFixture),
|
||||
);
|
||||
const caFile = privateFile(directory, 'ca.pem', fs.readFileSync(caFixture));
|
||||
const configFile = privateFile(
|
||||
directory,
|
||||
'client.json',
|
||||
@@ -625,12 +680,7 @@ test('rejects response framing drift, oversized bodies, aborts and timeouts', as
|
||||
}),
|
||||
credentialFile: privateFile(directory, 'credential', credential),
|
||||
};
|
||||
for (const failureMode of [
|
||||
'content-type',
|
||||
'oversized',
|
||||
'abort',
|
||||
'timeout',
|
||||
]) {
|
||||
for (const failureMode of ['content-type', 'oversized', 'abort', 'timeout']) {
|
||||
mode = failureMode;
|
||||
await assert.rejects(executeClusterCopilotClient(paths), {
|
||||
code: 'QL3_CLUSTER_COPILOT_CLIENT_REQUEST_FAILED',
|
||||
|
||||
@@ -16,6 +16,7 @@ const {
|
||||
const {
|
||||
CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
clusterCopilotConsoleClientCommand,
|
||||
clusterCopilotConsoleProjectReadPath,
|
||||
normalizeClusterCopilotConsoleReadRequest,
|
||||
} = require('../dist/copilot-console/contracts.js');
|
||||
const {
|
||||
@@ -42,7 +43,8 @@ function inspection() {
|
||||
operation: 'inspect',
|
||||
requestId: 'transport-read-1',
|
||||
result: {
|
||||
schema: 'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1',
|
||||
schema:
|
||||
'qinglong/cluster-copilot-failure-diagnosis-inspection-response@v1',
|
||||
status: 'terminal',
|
||||
projectId: 'project-main',
|
||||
sourceRunId: 'run-source-1',
|
||||
@@ -72,7 +74,8 @@ function output() {
|
||||
operation: 'output',
|
||||
requestId: 'transport-read-2',
|
||||
result: {
|
||||
schema: 'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
|
||||
schema:
|
||||
'qinglong/cluster-copilot-failure-diagnosis-output-read-response@v1',
|
||||
status: 'available',
|
||||
projectId: 'project-main',
|
||||
sourceRunId: 'run-source-1',
|
||||
@@ -180,7 +183,7 @@ async function unusedPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
test('normalizes only the two read operations into the shared client contract', () => {
|
||||
test('normalizes Copilot and fixed Project observation operations without arbitrary paths', () => {
|
||||
assert.deepEqual(
|
||||
clusterCopilotConsoleClientCommand(
|
||||
normalizeClusterCopilotConsoleReadRequest(target('inspect')),
|
||||
@@ -197,6 +200,22 @@ test('normalizes only the two read operations into the shared client contract',
|
||||
clusterCopilotConsoleClientCommand(target('output')).operation,
|
||||
'output',
|
||||
);
|
||||
const runList = normalizeClusterCopilotConsoleReadRequest({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
operation: 'run_list',
|
||||
projectId: 'project-main',
|
||||
requestId: 'console-read-1',
|
||||
afterCreatedAtMs: 1_700_000_000_000,
|
||||
afterRunId: 'run-9',
|
||||
limit: 32,
|
||||
});
|
||||
assert.equal(
|
||||
clusterCopilotConsoleProjectReadPath(runList),
|
||||
'/api/v3/projects/project-main/runs?after_created_at_ms=1700000000000&after_run_id=run-9&limit=32',
|
||||
);
|
||||
assert.throws(() => clusterCopilotConsoleClientCommand(runList), {
|
||||
code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID',
|
||||
});
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterCopilotConsoleReadRequest({
|
||||
@@ -215,22 +234,162 @@ test('normalizes only the two read operations into the shared client contract',
|
||||
);
|
||||
});
|
||||
|
||||
test('maps every reviewed Project observation operation to one fixed GET path', () => {
|
||||
const workflowRunId = '123e4567-e89b-42d3-a456-426614174000';
|
||||
const workflowStepRunId = '123e4567-e89b-42d3-a456-426614174001';
|
||||
const cases = [
|
||||
[
|
||||
{
|
||||
operation: 'run_list',
|
||||
afterCreatedAtMs: 1_700_000_000_000,
|
||||
afterRunId: 'run-9',
|
||||
limit: 32,
|
||||
},
|
||||
'/api/v3/projects/project-main/runs?after_created_at_ms=1700000000000&after_run_id=run-9&limit=32',
|
||||
],
|
||||
[
|
||||
{ operation: 'run_read', runId: 'run-9' },
|
||||
'/api/v3/projects/project-main/runs/run-9',
|
||||
],
|
||||
[
|
||||
{
|
||||
operation: 'run_event_list',
|
||||
runId: 'run-9',
|
||||
afterSequence: 7,
|
||||
limit: 16,
|
||||
},
|
||||
'/api/v3/projects/project-main/runs/run-9/events?after_sequence=7&limit=16',
|
||||
],
|
||||
[
|
||||
{
|
||||
operation: 'run_step_list',
|
||||
runId: 'run-9',
|
||||
afterStepKey: 'model',
|
||||
afterStepRunId: 'step-run-3',
|
||||
limit: 8,
|
||||
},
|
||||
'/api/v3/projects/project-main/runs/run-9/steps?after_step_key=model&after_step_run_id=step-run-3&limit=8',
|
||||
],
|
||||
[
|
||||
{ operation: 'task_list', afterTaskId: 'task-9', limit: 4 },
|
||||
'/api/v3/projects/project-main/tasks?after_task_id=task-9&limit=4',
|
||||
],
|
||||
[
|
||||
{ operation: 'task_read', taskId: 'task-9' },
|
||||
'/api/v3/projects/project-main/tasks/task-9',
|
||||
],
|
||||
[
|
||||
{ operation: 'workflow_list', packageName: 'ops-pack' },
|
||||
'/api/v3/projects/project-main/packages/ops-pack/workflows',
|
||||
],
|
||||
[
|
||||
{
|
||||
operation: 'workflow_run_list',
|
||||
packageName: 'ops-pack',
|
||||
workflowId: 'nightly-repair',
|
||||
afterAdmittedAtMs: 1_700_000_000_000,
|
||||
afterRunId: workflowRunId,
|
||||
limit: 32,
|
||||
},
|
||||
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs?after_admitted_at_ms=1700000000000&after_run_id=123e4567-e89b-42d3-a456-426614174000&limit=32',
|
||||
],
|
||||
[
|
||||
{
|
||||
operation: 'workflow_run_read',
|
||||
packageName: 'ops-pack',
|
||||
workflowId: 'nightly-repair',
|
||||
runId: workflowRunId,
|
||||
},
|
||||
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000',
|
||||
],
|
||||
[
|
||||
{
|
||||
operation: 'workflow_event_list',
|
||||
packageName: 'ops-pack',
|
||||
workflowId: 'nightly-repair',
|
||||
runId: workflowRunId,
|
||||
afterSequence: 9,
|
||||
limit: 16,
|
||||
},
|
||||
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000/events?after_sequence=9&limit=16',
|
||||
],
|
||||
[
|
||||
{
|
||||
operation: 'workflow_step_list',
|
||||
packageName: 'ops-pack',
|
||||
workflowId: 'nightly-repair',
|
||||
runId: workflowRunId,
|
||||
afterStepKey: 'publish',
|
||||
afterStepRunId: workflowStepRunId,
|
||||
limit: 8,
|
||||
},
|
||||
'/api/v3/projects/project-main/packages/ops-pack/workflows/nightly-repair/runs/123e4567-e89b-42d3-a456-426614174000/steps?after_step_key=publish&after_step_run_id=123e4567-e89b-42d3-a456-426614174001&limit=8',
|
||||
],
|
||||
];
|
||||
|
||||
for (const [requestFields, expectedPath] of cases) {
|
||||
const normalized = normalizeClusterCopilotConsoleReadRequest({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
projectId: 'project-main',
|
||||
requestId: 'console-read-1',
|
||||
...requestFields,
|
||||
});
|
||||
assert.equal(
|
||||
clusterCopilotConsoleProjectReadPath(normalized),
|
||||
expectedPath,
|
||||
);
|
||||
assert.equal(Object.isFrozen(normalized), true);
|
||||
}
|
||||
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterCopilotConsoleReadRequest({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
operation: 'run_step_list',
|
||||
projectId: 'project-main',
|
||||
requestId: 'console-read-1',
|
||||
runId: 'run-9',
|
||||
afterStepKey: 'model',
|
||||
afterStepRunId: null,
|
||||
limit: 8,
|
||||
}),
|
||||
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
|
||||
);
|
||||
assert.throws(
|
||||
() =>
|
||||
normalizeClusterCopilotConsoleReadRequest({
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
operation: 'workflow_run_read',
|
||||
projectId: 'project-main',
|
||||
requestId: 'console-read-1',
|
||||
packageName: 'ops-pack',
|
||||
workflowId: 'nightly-repair',
|
||||
runId: 'not-a-workflow-run-uuid',
|
||||
}),
|
||||
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
|
||||
);
|
||||
});
|
||||
|
||||
test('loads only digest-bound packaged assets and rejects drift', async (t) => {
|
||||
const assets = loadClusterCopilotConsoleAssets(moduleDirectory);
|
||||
assert.match(assets.html, /故障诊断,不替你执行/);
|
||||
assert.match(assets.html, /沿着证据读,不替集群做决定/);
|
||||
assert.match(assets.css, /prefers-reduced-motion/);
|
||||
assert.match(assets.javascript, /textContent = fact\.result\.text/);
|
||||
assert.doesNotMatch(assets.javascript, /localStorage|sessionStorage|innerHTML/);
|
||||
assert.match(assets.javascript, /output\.textContent = JSON\.stringify/);
|
||||
assert.match(assets.javascript, /run_event_list/);
|
||||
assert.doesNotMatch(
|
||||
assets.javascript,
|
||||
/localStorage|sessionStorage|innerHTML/,
|
||||
);
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'ql3-console-assets-'));
|
||||
t.after(() => require('node:fs').rmSync(root, { recursive: true, force: true }));
|
||||
t.after(() =>
|
||||
require('node:fs').rmSync(root, { recursive: true, force: true }),
|
||||
);
|
||||
const fakeModuleDirectory = join(root, 'dist', 'copilot-console');
|
||||
await mkdir(fakeModuleDirectory, { recursive: true });
|
||||
await cp(
|
||||
resolve(moduleDirectory, '../../assets'),
|
||||
join(root, 'assets'),
|
||||
{ recursive: true },
|
||||
);
|
||||
await cp(resolve(moduleDirectory, '../../assets'), join(root, 'assets'), {
|
||||
recursive: true,
|
||||
});
|
||||
await writeFile(
|
||||
join(root, 'assets', 'copilot-console', 'app.js'),
|
||||
'"drift";\n',
|
||||
@@ -250,13 +409,16 @@ test('serves an immutable same-origin shell with a closed browser policy', async
|
||||
assert.equal(html.headers['x-frame-options'], 'DENY');
|
||||
assert.match(html.headers['content-security-policy'], /default-src 'none'/);
|
||||
assert.match(html.headers['content-security-policy'], /connect-src 'self'/);
|
||||
assert.match(html.text, /Cluster field console/);
|
||||
assert.match(html.text, /Cluster field ledger/);
|
||||
|
||||
const css = await request(server.origin, { path: '/app.css' });
|
||||
const javascript = await request(server.origin, { path: '/app.js' });
|
||||
assert.equal(css.statusCode, 200);
|
||||
assert.equal(javascript.statusCode, 200);
|
||||
assert.equal(javascript.headers['content-type'], 'text/javascript; charset=utf-8');
|
||||
assert.equal(
|
||||
javascript.headers['content-type'],
|
||||
'text/javascript; charset=utf-8',
|
||||
);
|
||||
});
|
||||
|
||||
test('allows only an explicit fixed-port container listener behind host loopback publication', async (t) => {
|
||||
@@ -299,7 +461,7 @@ test('keeps the Cluster credential server-side and forwards one exact inspect',
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(commands, [
|
||||
{
|
||||
schema: 'qinglong/cluster-copilot-client-command@v1',
|
||||
schema: 'qinglong/cluster-copilot-console-read-request@v1',
|
||||
operation: 'inspect',
|
||||
projectId: 'project-main',
|
||||
sourceRunId: 'run-source-1',
|
||||
@@ -314,6 +476,39 @@ test('keeps the Cluster credential server-side and forwards one exact inspect',
|
||||
assert.doesNotMatch(response.text, /ql3c_|authorization|credential/i);
|
||||
});
|
||||
|
||||
test('forwards one exact bounded Run list read and exposes no path field', async (t) => {
|
||||
const requests = [];
|
||||
const { server, headers } = await fixture(async (read) => {
|
||||
requests.push(read);
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
requestId: read.requestId,
|
||||
result: { runs: [], hasMore: false },
|
||||
};
|
||||
});
|
||||
t.after(() => server.close());
|
||||
const body = {
|
||||
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||
operation: 'run_list',
|
||||
projectId: 'project-main',
|
||||
requestId: 'console-read-2',
|
||||
afterCreatedAtMs: null,
|
||||
afterRunId: null,
|
||||
limit: 32,
|
||||
};
|
||||
const response = await request(server.origin, {
|
||||
method: 'POST',
|
||||
path: '/api/v1/observe/run-list',
|
||||
headers,
|
||||
body,
|
||||
});
|
||||
assert.equal(response.statusCode, 200);
|
||||
assert.deepEqual(requests, [body]);
|
||||
assert.deepEqual(response.body.result.result, { runs: [], hasMore: false });
|
||||
assert.equal(Object.hasOwn(requests[0], 'path'), false);
|
||||
assert.equal(Object.hasOwn(requests[0], 'url'), false);
|
||||
});
|
||||
|
||||
test('returns model text as JSON data only after an explicit output read', async (t) => {
|
||||
const { server, headers } = await fixture(async (command) => {
|
||||
assert.equal(command.operation, 'output');
|
||||
@@ -331,7 +526,10 @@ test('returns model text as JSON data only after an explicit output read', async
|
||||
response.body.result.result.result.text,
|
||||
'<script>never execute</script>',
|
||||
);
|
||||
assert.equal(response.headers['content-type'], 'application/json; charset=utf-8');
|
||||
assert.equal(
|
||||
response.headers['content-type'],
|
||||
'application/json; charset=utf-8',
|
||||
);
|
||||
assert.equal(response.headers['x-content-type-options'], 'nosniff');
|
||||
});
|
||||
|
||||
@@ -344,7 +542,10 @@ test('masks wrong Host, Origin, session and every non-read route', async (t) =>
|
||||
t.after(() => server.close());
|
||||
const cases = [
|
||||
{ ...headers, origin: 'https://attacker.example' },
|
||||
{ ...headers, authorization: 'QL3-Console ' + randomBytes(32).toString('base64url') },
|
||||
{
|
||||
...headers,
|
||||
authorization: 'QL3-Console ' + randomBytes(32).toString('base64url'),
|
||||
},
|
||||
{ ...headers, host: 'attacker.example' },
|
||||
];
|
||||
for (const candidate of cases) {
|
||||
|
||||
@@ -18,8 +18,22 @@ const tlsFixture = path.resolve(
|
||||
packageRoot,
|
||||
'../ql3-cluster-control/test/fixtures/mtls',
|
||||
);
|
||||
const credential =
|
||||
'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url');
|
||||
const credential = 'ql3c_console_' + Buffer.alloc(32, 9).toString('base64url');
|
||||
const consoleOperations = [
|
||||
'inspect',
|
||||
'output',
|
||||
'run_list',
|
||||
'run_read',
|
||||
'run_event_list',
|
||||
'run_step_list',
|
||||
'task_list',
|
||||
'task_read',
|
||||
'workflow_list',
|
||||
'workflow_run_list',
|
||||
'workflow_run_read',
|
||||
'workflow_event_list',
|
||||
'workflow_step_list',
|
||||
];
|
||||
|
||||
function privateFile(directory, name, contents) {
|
||||
const filePath = path.join(directory, name);
|
||||
@@ -189,7 +203,10 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
|
||||
component: 'qinglong3-cluster-copilot-console',
|
||||
event: 'process_failed',
|
||||
});
|
||||
assert.doesNotMatch(failed.stderr, /client-secret|cluster-secret|browser-secret/);
|
||||
assert.doesNotMatch(
|
||||
failed.stderr,
|
||||
/client-secret|cluster-secret|browser-secret/,
|
||||
);
|
||||
});
|
||||
|
||||
test('preflight proves private authority and unauthenticated TLS 1.3 readiness', async (t) => {
|
||||
@@ -214,7 +231,7 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
|
||||
publishedHostAddress: '127.0.0.1',
|
||||
browserCredential: 'forbidden',
|
||||
clusterCredential: 'server_only',
|
||||
operations: ['inspect', 'output'],
|
||||
operations: consoleOperations,
|
||||
mutation: false,
|
||||
});
|
||||
assert.deepEqual(value.requests, [
|
||||
@@ -244,18 +261,19 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
|
||||
{ cwd: packageRoot, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||
);
|
||||
t.after(() => {
|
||||
if (child.exitCode === null && child.signalCode === null) child.kill('SIGKILL');
|
||||
if (child.exitCode === null && child.signalCode === null)
|
||||
child.kill('SIGKILL');
|
||||
});
|
||||
const started = JSON.parse(await firstLine(child.stdout));
|
||||
assert.equal(started.event, 'started');
|
||||
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
|
||||
assert.deepEqual(started.operations, ['inspect', 'output']);
|
||||
assert.deepEqual(started.operations, consoleOperations);
|
||||
assert.equal(started.mutation, false);
|
||||
assert.equal(started.networkBoundary, 'host-loopback');
|
||||
assert.equal(started.publishedHostAddress, '127.0.0.1');
|
||||
const shell = await get(started.origin);
|
||||
assert.equal(shell.statusCode, 200);
|
||||
assert.match(shell.body, /Cluster field console/);
|
||||
assert.match(shell.body, /Cluster field ledger/);
|
||||
child.kill('SIGTERM');
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
child.once('error', reject);
|
||||
|
||||
@@ -254,13 +254,13 @@ child.stdout.on('data', (chunk) => {
|
||||
if (newline === -1 || settled) return;
|
||||
let started;
|
||||
try { started = JSON.parse(stdout.slice(0, newline)); } catch { finish(43); return; }
|
||||
if (started.event !== 'started' || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(started.origin) || JSON.stringify(started.operations) !== JSON.stringify(['inspect', 'output']) || started.mutation !== false) { finish(44); return; }
|
||||
if (started.event !== 'started' || !/^http:\/\/127\.0\.0\.1:[0-9]+$/.test(started.origin) || JSON.stringify(started.operations) !== JSON.stringify(['inspect', 'output', 'run_list', 'run_read', 'run_event_list', 'run_step_list', 'task_list', 'task_read', 'workflow_list', 'workflow_run_list', 'workflow_run_read', 'workflow_event_list', 'workflow_step_list']) || started.mutation !== false) { finish(44); return; }
|
||||
get(started.origin, (response) => {
|
||||
const chunks = [];
|
||||
response.on('data', (chunk) => chunks.push(chunk));
|
||||
response.once('end', () => {
|
||||
const body = Buffer.concat(chunks).toString('utf8');
|
||||
if (response.statusCode !== 200 || !body.includes('Cluster field console') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; }
|
||||
if (response.statusCode !== 200 || !body.includes('Cluster field ledger') || !body.includes('/app.css') || !body.includes('/app.js')) { finish(45); return; }
|
||||
child.once('close', (status, signal) => {
|
||||
if (status !== 0 || signal !== null) { finish(46); return; }
|
||||
settled = true;
|
||||
@@ -411,7 +411,9 @@ process.once('SIGINT', () => child.kill('SIGINT'));
|
||||
} catch {}
|
||||
}
|
||||
fail(
|
||||
`published Console did not start (running=${String(state?.Running)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
|
||||
`published Console did not start (running=${String(
|
||||
state?.Running,
|
||||
)}, exit=${String(state?.ExitCode)}, code=${terminalCode})`,
|
||||
);
|
||||
}
|
||||
let started;
|
||||
@@ -433,16 +435,14 @@ process.once('SIGINT', () => child.kill('SIGINT'));
|
||||
container,
|
||||
`${containerPort}/tcp`,
|
||||
]).trim();
|
||||
const publishedMatch = /^127\.0\.0\.1:([1-9][0-9]{0,4})$/u.exec(
|
||||
published,
|
||||
);
|
||||
const publishedMatch = /^127\.0\.0\.1:([1-9][0-9]{0,4})$/u.exec(published);
|
||||
if (!publishedMatch) fail('published Console escaped host loopback');
|
||||
const origin = `http://127.0.0.1:${publishedMatch[1]}`;
|
||||
const probe = execFileSync(
|
||||
process.execPath,
|
||||
[
|
||||
'-e',
|
||||
"require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field console'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));",
|
||||
"require('node:http').get(process.argv[1],(r)=>{const c=[];r.on('data',(x)=>c.push(x));r.on('end',()=>{const b=Buffer.concat(c).toString('utf8');if(r.statusCode!==200||!b.includes('Cluster field ledger'))process.exit(2);process.stdout.write(JSON.stringify({status:r.statusCode,assets:b.includes('/app.css')&&b.includes('/app.js')}));});}).on('error',()=>process.exit(3));",
|
||||
origin,
|
||||
],
|
||||
{ encoding: 'utf8', timeout: 5_000 },
|
||||
|
||||
@@ -4,6 +4,7 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const CONSOLE_ROOT = 'packages/ql3-cluster-admin/src/copilot-console';
|
||||
const CLIENT_FILE = 'packages/ql3-cluster-admin/src/copilot-client/client.ts';
|
||||
const ASSET_ROOT = 'packages/ql3-cluster-admin/assets/copilot-console';
|
||||
const DEPLOYMENT_ROOT = 'deploy/console/ql3-cluster-copilot';
|
||||
const REQUIRED_FILES = Object.freeze([
|
||||
@@ -11,6 +12,7 @@ const REQUIRED_FILES = Object.freeze([
|
||||
CONSOLE_ROOT + '/cli.ts',
|
||||
CONSOLE_ROOT + '/contracts.ts',
|
||||
CONSOLE_ROOT + '/server.ts',
|
||||
CLIENT_FILE,
|
||||
ASSET_ROOT + '/index.html',
|
||||
ASSET_ROOT + '/app.css',
|
||||
ASSET_ROOT + '/app.js',
|
||||
@@ -93,18 +95,30 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
};
|
||||
|
||||
expectFragments(CONSOLE_ROOT + '/contracts.ts', [
|
||||
"export type ClusterCopilotConsoleReadOperation = 'inspect' | 'output'",
|
||||
'CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS',
|
||||
'CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA',
|
||||
'clusterCopilotConsoleClientCommand',
|
||||
'clusterCopilotConsoleProjectReadPath',
|
||||
"'run_event_list'",
|
||||
"'task_read'",
|
||||
"'workflow_step_list'",
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/contracts.ts', [
|
||||
"| 'diagnose'",
|
||||
"| 'cancel'",
|
||||
"'cancel'",
|
||||
'mutationId',
|
||||
'traceId',
|
||||
'endpoint',
|
||||
'credential',
|
||||
]);
|
||||
expectFragments(CLIENT_FILE, [
|
||||
'executeClusterProjectApiRead',
|
||||
'PROJECT_READ_PATHS',
|
||||
"method: 'GET'",
|
||||
'readCredentialBytes(record.credentialFile as string)',
|
||||
'credentialBytes?.fill(0)',
|
||||
]);
|
||||
rejectFragments(CLIENT_FILE, ["method: 'DELETE'", "method: 'PATCH'"]);
|
||||
expectFragments(CONSOLE_ROOT + '/server.ts', [
|
||||
"networkBoundary === 'host-loopback' ? '127.0.0.1' : '0.0.0.0'",
|
||||
"networkBoundary === 'container-published-loopback'",
|
||||
@@ -112,8 +126,10 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
'request.headers.origin !== expectedOrigin',
|
||||
"request.headers.host !== expectedOrigin.slice('http://'.length)",
|
||||
'maximumConcurrentRequests: 2',
|
||||
"request.url === '/api/v1/copilot/inspect'",
|
||||
"request.url === '/api/v1/copilot/output'",
|
||||
"'/api/v1/copilot/inspect': 'inspect'",
|
||||
"'/api/v1/observe/run-list': 'run_list'",
|
||||
"'/api/v1/observe/task-list': 'task_list'",
|
||||
"'/api/v1/observe/workflow-list': 'workflow_list'",
|
||||
"default-src 'none'",
|
||||
"frame-ancestors 'none'",
|
||||
"'cache-control': 'no-store'",
|
||||
@@ -134,9 +150,9 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
"'private'",
|
||||
'validateClusterCopilotClientCredentialFile',
|
||||
"clusterCredential: 'server_only'",
|
||||
"networkBoundary: parsed.networkBoundary",
|
||||
'networkBoundary: parsed.networkBoundary',
|
||||
"publishedHostAddress: '127.0.0.1'",
|
||||
"operations: ['inspect', 'output']",
|
||||
'operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS',
|
||||
'mutation: false',
|
||||
]);
|
||||
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
|
||||
@@ -146,16 +162,19 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
'cancel',
|
||||
]);
|
||||
expectFragments(ASSET_ROOT + '/index.html', [
|
||||
'故障诊断,不替你执行。',
|
||||
'只读边界',
|
||||
'沿着证据读,不替集群做决定。',
|
||||
'本机只读 BFF',
|
||||
'读取 Run 列表',
|
||||
'读取 Workflow Runs',
|
||||
'显式读取诊断内容',
|
||||
'不可信模型输出',
|
||||
'模型文本是不可信内容',
|
||||
]);
|
||||
expectFragments(ASSET_ROOT + '/app.js', [
|
||||
'credentials: "omit"',
|
||||
'cache: "no-store"',
|
||||
'outputText.textContent = fact.result.text',
|
||||
'sessionToken = ""',
|
||||
"credentials: 'omit'",
|
||||
"cache: 'no-store'",
|
||||
'output.textContent = JSON.stringify(fact, null, 2)',
|
||||
'nextPage(operation, request, fact)',
|
||||
"sessionToken = ''",
|
||||
]);
|
||||
rejectFragments(ASSET_ROOT + '/app.js', [
|
||||
'localStorage',
|
||||
@@ -178,7 +197,8 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
expectFragments(DEPLOYMENT_ROOT + '/README.md', [
|
||||
'operator-workstation process',
|
||||
'Do not deploy it as a Kubernetes workload',
|
||||
'only `inspect` and explicit `output` reads',
|
||||
'Run, Task, Workflow',
|
||||
'thirteen exact operations',
|
||||
'--port=0',
|
||||
'TLS 1.3 `GET /readyz`',
|
||||
'excluded from small router Edge/Standalone artifacts',
|
||||
@@ -196,7 +216,7 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
'function runConsoleContract(image)',
|
||||
"[facade, 'copilot-console'",
|
||||
"started.event !== 'started'",
|
||||
"body.includes('Cluster field console')",
|
||||
"body.includes('Cluster field ledger')",
|
||||
'runConsoleContract(image);',
|
||||
'function runPublishedConsoleContract(image)',
|
||||
'runPublishedConsoleContract(image);',
|
||||
@@ -219,8 +239,7 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
);
|
||||
}
|
||||
if (
|
||||
manifest?.bin?.['ql3-copilot-console'] !==
|
||||
'dist/copilot-console/cli.js' ||
|
||||
manifest?.bin?.['ql3-copilot-console'] !== 'dist/copilot-console/cli.js' ||
|
||||
manifest?.exports?.['./copilot-console']?.require !==
|
||||
'./dist/copilot-console/server.js' ||
|
||||
!Array.isArray(manifest?.files) ||
|
||||
@@ -305,7 +324,21 @@ function auditClusterCopilotConsole(options = {}) {
|
||||
component: 'cluster-copilot-console',
|
||||
owner: '@qinglong/cluster-admin',
|
||||
lifecycle: 'operator-workstation-loopback',
|
||||
operations: Object.freeze(['inspect', 'output']),
|
||||
operations: Object.freeze([
|
||||
'inspect',
|
||||
'output',
|
||||
'run_list',
|
||||
'run_read',
|
||||
'run_event_list',
|
||||
'run_step_list',
|
||||
'task_list',
|
||||
'task_read',
|
||||
'workflow_list',
|
||||
'workflow_run_list',
|
||||
'workflow_run_read',
|
||||
'workflow_event_list',
|
||||
'workflow_step_list',
|
||||
]),
|
||||
legacyUiCoupled: false,
|
||||
kubernetesResident: false,
|
||||
assetCount: 3,
|
||||
|
||||
@@ -53,7 +53,7 @@ test('binds the live image gate to native and container-published loopback', ()
|
||||
const source = fs.readFileSync(script, 'utf8');
|
||||
assert.match(source, /function runConsoleContract\(image\)/);
|
||||
assert.match(source, /\[facade, 'copilot-console'/);
|
||||
assert.match(source, /body\.includes\('Cluster field console'\)/);
|
||||
assert.match(source, /body\.includes\('Cluster field ledger'\)/);
|
||||
assert.match(source, /runConsoleContract\(image\);/);
|
||||
assert.match(source, /consoleLoopback: true/);
|
||||
assert.match(source, /consoleAssets: true/);
|
||||
|
||||
@@ -25,7 +25,21 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
|
||||
component: 'cluster-copilot-console',
|
||||
owner: '@qinglong/cluster-admin',
|
||||
lifecycle: 'operator-workstation-loopback',
|
||||
operations: ['inspect', 'output'],
|
||||
operations: [
|
||||
'inspect',
|
||||
'output',
|
||||
'run_list',
|
||||
'run_read',
|
||||
'run_event_list',
|
||||
'run_step_list',
|
||||
'task_list',
|
||||
'task_read',
|
||||
'workflow_list',
|
||||
'workflow_run_list',
|
||||
'workflow_run_read',
|
||||
'workflow_event_list',
|
||||
'workflow_step_list',
|
||||
],
|
||||
legacyUiCoupled: false,
|
||||
kubernetesResident: false,
|
||||
assetCount: 3,
|
||||
@@ -47,11 +61,7 @@ test('rejects a remote listener or mutation vocabulary', () => {
|
||||
root,
|
||||
readFile: intercept(
|
||||
'packages/ql3-cluster-admin/src/copilot-console/contracts.ts',
|
||||
(source) =>
|
||||
source.replace(
|
||||
"'inspect' | 'output'",
|
||||
"'inspect' | 'output' | 'cancel'",
|
||||
),
|
||||
(source) => source.replace("'output',", "'output', 'cancel',"),
|
||||
),
|
||||
});
|
||||
assert.equal(listener.compatible, false);
|
||||
@@ -127,8 +137,7 @@ test('rejects coupling into the legacy UI or Kubernetes workloads', () => {
|
||||
(source) => source + '\n// ql3-copilot-console\n',
|
||||
),
|
||||
});
|
||||
const kubernetesTarget =
|
||||
'deploy/kubernetes/ql3-cluster/base/deployment.yaml';
|
||||
const kubernetesTarget = 'deploy/kubernetes/ql3-cluster/base/deployment.yaml';
|
||||
const kubernetes = auditClusterCopilotConsole({
|
||||
root,
|
||||
readFile: intercept(
|
||||
|
||||
Reference in New Issue
Block a user