mirror of
https://github.com/whyour/qinglong.git
synced 2026-09-23 12:05:27 +08:00
feat(ql3): add optional console run drilldown
This commit is contained in:
@@ -7,6 +7,11 @@ 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
|
URL or method. Do not deploy it as a Kubernetes workload, Ingress, shared LAN
|
||||||
listener, Edge component or legacy 2.x Web route.
|
listener, Edge component or legacy 2.x Web route.
|
||||||
|
|
||||||
|
Run cancellation availability is a second, explicit authority. It is disabled
|
||||||
|
by default. Supplying a separate Run management mTLS config and short-lived
|
||||||
|
User assertion adds only status, blocked-list and inspect reads to the same
|
||||||
|
loopback process; rearm, stop and retry remain absent.
|
||||||
|
|
||||||
Use `ql3-cluster-admin` from the same independently verified Admin release as
|
Use `ql3-cluster-admin` from the same independently verified Admin release as
|
||||||
the Cluster deployment. D-328 also supports the image-carried
|
the Cluster deployment. D-328 also supports the image-carried
|
||||||
`docker-loopback.sh`: it uses an explicit container-only listener but publishes
|
`docker-loopback.sh`: it uses an explicit container-only listener but publishes
|
||||||
@@ -97,6 +102,15 @@ and `artifact.read`; `run.read` covers Run and Workflow observations, while
|
|||||||
requested Copilot output. The Console has no route for Run/Workflow start,
|
requested Copilot output. The Console has no route for Run/Workflow start,
|
||||||
diagnosis creation or cancellation even if a wider credential is supplied.
|
diagnosis creation or cancellation even if a wider credential is supplied.
|
||||||
|
|
||||||
|
To enable the optional cancellation drill-down, copy
|
||||||
|
`run-management-client-config.example.json` to `run-management-client.json`,
|
||||||
|
install its CA, client certificate and private key, and issue a short-lived
|
||||||
|
strong User assertion with only `run.read` into
|
||||||
|
`run-management-assertion.jwt`. These files are independent of the Project API
|
||||||
|
credential. Supplying only one of config/assertion is rejected before a
|
||||||
|
listener or Cluster request is created. The assertion is reread for every
|
||||||
|
explicit click so it can be rotated or removed while the Console is running.
|
||||||
|
|
||||||
Create an independent 256-bit browser session key without placing its value in
|
Create an independent 256-bit browser session key without placing its value in
|
||||||
argv or an environment variable:
|
argv or an environment variable:
|
||||||
|
|
||||||
@@ -107,6 +121,11 @@ node -e 'process.stdout.write(require("node:crypto").randomBytes(32).toString("b
|
|||||||
chmod 0600 /absolute/private/ql3-copilot-console/client.json /absolute/private/ql3-copilot-console/ca.pem /absolute/private/ql3-copilot-console/credential /absolute/private/ql3-copilot-console/session
|
chmod 0600 /absolute/private/ql3-copilot-console/client.json /absolute/private/ql3-copilot-console/ca.pem /absolute/private/ql3-copilot-console/credential /absolute/private/ql3-copilot-console/session
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If optional Run management reads are enabled, apply the same owner-private,
|
||||||
|
canonical, non-symlink `0600` rule to `run-management-client.json`,
|
||||||
|
`run-management-ca.pem`, `run-management-client.crt`,
|
||||||
|
`run-management-client.key` and `run-management-assertion.jwt`.
|
||||||
|
|
||||||
Every file must be a current-owner, non-symlink, canonical regular file. The
|
Every file must be a current-owner, non-symlink, canonical regular file. The
|
||||||
session file contains exactly 43 base64url characters and no newline. It is a
|
session file contains exactly 43 base64url characters and no newline. It is a
|
||||||
browser-to-loopback secret only; it cannot authenticate to the Cluster API.
|
browser-to-loopback secret only; it cannot authenticate to the Cluster API.
|
||||||
@@ -124,6 +143,14 @@ ql3-cluster-admin copilot-console --check \
|
|||||||
--session /absolute/private/ql3-copilot-console/session
|
--session /absolute/private/ql3-copilot-console/session
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Append both flags to preflight and serve when the optional authority is
|
||||||
|
intended:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
--run-management-config /absolute/private/ql3-copilot-console/run-management-client.json \
|
||||||
|
--run-management-assertion /absolute/private/ql3-copilot-console/run-management-assertion.jwt
|
||||||
|
```
|
||||||
|
|
||||||
It validates all three private authorities and performs one unauthenticated
|
It validates all three private authorities and performs one unauthenticated
|
||||||
TLS 1.3 `GET /readyz`. It does not open the Console listener or reveal paths,
|
TLS 1.3 `GET /readyz`. It does not open the Console listener or reveal paths,
|
||||||
endpoint, credential, Project or Cluster identity.
|
endpoint, credential, Project or Cluster identity.
|
||||||
@@ -150,13 +177,20 @@ 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
|
keep the workstation surface bounded, but this Cluster-only product is still
|
||||||
excluded from small router Edge/Standalone artifacts.
|
excluded from small router Edge/Standalone artifacts.
|
||||||
|
|
||||||
The page exposes thirteen exact operations: Copilot `inspect|output`; Run
|
The default 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; Task list/detail; and Workflow list plus Workflow Run
|
||||||
list/detail/events/steps. List responses use 32-row pages and offer an explicit
|
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
|
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
|
automatic cascade from a list to details, steps or events, so each authority
|
||||||
read remains visible and intentional.
|
read remains visible and intentional.
|
||||||
|
|
||||||
|
Explicit Run management authority raises the available vocabulary to sixteen:
|
||||||
|
one Project cancellation status, one fixed 16-item blocked snapshot page and
|
||||||
|
one low-sensitive Run cancellation inspection. An `attention_required` status
|
||||||
|
offers a user-clicked blocked-list step; each returned Run offers a user-clicked
|
||||||
|
inspect step. Pagination is also click-only. There is no polling, automatic
|
||||||
|
page traversal, bulk inspection or mutation route.
|
||||||
|
|
||||||
## Export a redacted evidence bundle
|
## Export a redacted evidence bundle
|
||||||
|
|
||||||
After at least one successful read, **Export redacted bundle** creates one
|
After at least one successful read, **Export redacted bundle** creates one
|
||||||
@@ -204,6 +238,12 @@ the image with the verified digest and selecting one unused host port. The
|
|||||||
launcher rejects `bridge|default|host|none`, mutable tags, noncanonical private
|
launcher rejects `bridge|default|host|none`, mutable tags, noncanonical private
|
||||||
roots, ports outside `1024..65535` and unknown resource classes.
|
roots, ports outside `1024..65535` and unknown resource classes.
|
||||||
|
|
||||||
|
The image launcher keeps Run management disabled unless
|
||||||
|
`QL3_COPILOT_CONSOLE_RUN_MANAGEMENT=enabled` is set. Enabled mode reads
|
||||||
|
`run-management-client.json` and `run-management-assertion.jwt` from the same
|
||||||
|
read-only private mount; all certificate paths in the config must point into
|
||||||
|
that mount. `disabled` is the only default and unknown values fail closed.
|
||||||
|
|
||||||
| Resource class | Memory | CPU | PIDs | Console reads |
|
| Resource class | Memory | CPU | PIDs | Console reads |
|
||||||
| --- | ---: | ---: | ---: | ---: |
|
| --- | ---: | ---: | ---: | ---: |
|
||||||
| `compact` | 192 MiB | 0.25 | 32 | 2, no queue |
|
| `compact` | 192 MiB | 0.25 | 32 | 2, no queue |
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ private_root=${QL3_COPILOT_CONSOLE_PRIVATE_ROOT-}
|
|||||||
network=${QL3_COPILOT_CONSOLE_NETWORK-}
|
network=${QL3_COPILOT_CONSOLE_NETWORK-}
|
||||||
port=${QL3_COPILOT_CONSOLE_PORT-}
|
port=${QL3_COPILOT_CONSOLE_PORT-}
|
||||||
resource_class=${QL3_COPILOT_CONSOLE_RESOURCE_CLASS-compact}
|
resource_class=${QL3_COPILOT_CONSOLE_RESOURCE_CLASS-compact}
|
||||||
|
run_management=${QL3_COPILOT_CONSOLE_RUN_MANAGEMENT-disabled}
|
||||||
|
|
||||||
printf '%s' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}@sha256:[0-9a-f]{64}$' || fail
|
printf '%s' "$image" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9._/-]{0,191}@sha256:[0-9a-f]{64}$' || fail
|
||||||
printf '%s' "$network" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$' || fail
|
printf '%s' "$network" | grep -Eq '^[A-Za-z0-9][A-Za-z0-9_.-]{0,62}$' || fail
|
||||||
@@ -57,6 +58,10 @@ case "$resource_class" in
|
|||||||
;;
|
;;
|
||||||
*) fail ;;
|
*) fail ;;
|
||||||
esac
|
esac
|
||||||
|
case "$run_management" in
|
||||||
|
disabled|enabled) ;;
|
||||||
|
*) fail ;;
|
||||||
|
esac
|
||||||
|
|
||||||
set -- docker run --rm --pull never --init --read-only \
|
set -- docker run --rm --pull never --init --read-only \
|
||||||
--network "$network" \
|
--network "$network" \
|
||||||
@@ -81,6 +86,12 @@ set -- "$@" "$image" copilot-console \
|
|||||||
--credential /var/run/secrets/qinglong3/copilot-console/credential \
|
--credential /var/run/secrets/qinglong3/copilot-console/credential \
|
||||||
--session /var/run/secrets/qinglong3/copilot-console/session
|
--session /var/run/secrets/qinglong3/copilot-console/session
|
||||||
|
|
||||||
|
if [ "$run_management" = enabled ]; then
|
||||||
|
set -- "$@" \
|
||||||
|
--run-management-config /var/run/secrets/qinglong3/copilot-console/run-management-client.json \
|
||||||
|
--run-management-assertion /var/run/secrets/qinglong3/copilot-console/run-management-assertion.jwt
|
||||||
|
fi
|
||||||
|
|
||||||
if [ "$mode" = check ]; then
|
if [ "$mode" = check ]; then
|
||||||
set -- "$@" --check
|
set -- "$@" --check
|
||||||
fi
|
fi
|
||||||
|
|||||||
@@ -3,5 +3,6 @@
|
|||||||
"QL3_COPILOT_CONSOLE_PRIVATE_ROOT": "/absolute/private/ql3-copilot-console",
|
"QL3_COPILOT_CONSOLE_PRIVATE_ROOT": "/absolute/private/ql3-copilot-console",
|
||||||
"QL3_COPILOT_CONSOLE_NETWORK": "qinglong3-copilot-console-egress",
|
"QL3_COPILOT_CONSOLE_NETWORK": "qinglong3-copilot-console-egress",
|
||||||
"QL3_COPILOT_CONSOLE_PORT": "5701",
|
"QL3_COPILOT_CONSOLE_PORT": "5701",
|
||||||
"QL3_COPILOT_CONSOLE_RESOURCE_CLASS": "compact"
|
"QL3_COPILOT_CONSOLE_RESOURCE_CLASS": "compact",
|
||||||
|
"QL3_COPILOT_CONSOLE_RUN_MANAGEMENT": "disabled"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"endpoint": "https://replace-cluster-api.example.com:8448/api/v3/runs/management",
|
||||||
|
"servername": "replace-cluster-api.example.com",
|
||||||
|
"caFile": "/absolute/private/ql3-copilot-console/run-management-ca.pem",
|
||||||
|
"clientCertificateFile": "/absolute/private/ql3-copilot-console/run-management-client.crt",
|
||||||
|
"clientPrivateKeyFile": "/absolute/private/ql3-copilot-console/run-management-client.key",
|
||||||
|
"requestTimeoutMs": 5000
|
||||||
|
}
|
||||||
@@ -85,6 +85,8 @@ COPY --chmod=0444 deploy/console/ql3-cluster-copilot/README.md \
|
|||||||
share/ql3-copilot-console/README.md
|
share/ql3-copilot-console/README.md
|
||||||
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/client-config.example.json \
|
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/client-config.example.json \
|
||||||
share/ql3-copilot-console/client-config.example.json
|
share/ql3-copilot-console/client-config.example.json
|
||||||
|
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/run-management-client-config.example.json \
|
||||||
|
share/ql3-copilot-console/run-management-client-config.example.json
|
||||||
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json \
|
COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json \
|
||||||
share/ql3-copilot-console/host-environment.example.json
|
share/ql3-copilot-console/host-environment.example.json
|
||||||
|
|
||||||
|
|||||||
@@ -6,12 +6,28 @@
|
|||||||
- 目标版本:QingLong 3.x
|
- 目标版本:QingLong 3.x
|
||||||
- 作者:QingLong Maintainers
|
- 作者:QingLong Maintainers
|
||||||
- 创建日期:2026-07-17
|
- 创建日期:2026-07-17
|
||||||
- 最后更新:2026-08-19
|
- 最后更新:2026-08-20
|
||||||
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
|
- 讨论范围:架构与演进路线,不包含最终 UI 视觉方案
|
||||||
|
|
||||||
最新增量证据(2026-08-19):
|
最新增量证据(2026-08-20):
|
||||||
|
|
||||||
- D-368/ADR-0461(已接受;Console 可选接入待完成):在既有 Run management plane 与 `ql3 run` 增加
|
- D-369/ADR-0462(已接受):在既有 operator-workstation Copilot Console 内完成显式可选、默认关闭的 Run management 只读纵切,新增固定
|
||||||
|
`run_cancellation_status`、`run_cancellation_blocked_list`、`run_cancellation_inspect` 三种 browser/BFF operation。只有同时提供独立
|
||||||
|
`--run-management-config` 与 owner-private `--run-management-assertion` 才启用,Project API credential、浏览器 session 与 Run 专用 TLS 1.3/mTLS/OIDC
|
||||||
|
authority 继续隔离;preflight 不消耗 assertion,serve 每次用户点击重新读取短期 assertion。导航严格为用户点击的 `status → blocked → inspect`,blocked
|
||||||
|
page 固定 16 项且下一页必须再次点击;没有启动读取、轮询、自动翻页、N+1 inspect、timer、queue、cache 或隐藏重试。Browser vocabulary、固定 route、
|
||||||
|
request normalizer 与 executor 均不能表达 `rearm`、`stop`、`retry` 或任意 mutation。blocked cursor 只在网络中保留 canonical token,证据导出改写为
|
||||||
|
per-bundle alias。容器 launcher 只有显式 `QL3_COPILOT_CONSOLE_RUN_MANAGEMENT=enabled` 才传入两个只读 mount 路径,默认仍为 disabled;没有新增
|
||||||
|
package、依赖、binary、服务、端口、Kubernetes workload、schema、连接池或常驻资源。聚焦 build/test 为 `46/46`;Cluster Admin 全量为
|
||||||
|
`420 total / 417 pass / 3 conditional skip / 0 fail`;backend 在允许 loopback listener 的宿主环境为
|
||||||
|
`1,491 total / 1,489 pass / 2 conditional skip / 0 fail`;18-package clean build 与逐包顺序测试单次退出 0。package/dependency/Edge import/Cluster
|
||||||
|
deployment/Console/Console distribution 六项审计全部 compatible、零 finding;workspace 保持 18 packages、无 single/shallow package,Cluster Admin 为
|
||||||
|
`125 source / 124 nested`,根目录仍只有 1 个受审 binary entry。`14/14` Local artifact audit 全部 compatible;基础 Edge/Standalone 精确为
|
||||||
|
`2,589,998 / 2,590,076` bytes、315 files、56 modules,Application+AI 为 `4,493,151 / 4,493,283` bytes,MCP 为
|
||||||
|
`7,315,930 / 7,316,038` bytes,证明 Cluster Console authority 没有进入低配路由设备闭包。本 Gate 不改 PostgreSQL schema、repository、service、role、
|
||||||
|
Pool、连接或 HA 拓扑,因此不重跑和不重新占有物理 HA 证明;D-368 PostgreSQL 18.6 arm64 `146/146`、timeline `1→2` 仅作为相邻既有基线。
|
||||||
|
|
||||||
|
- D-368/ADR-0461(已接受;Console 可选接入由 D-369 完成):在既有 Run management plane 与 `ql3 run` 增加
|
||||||
`run.cancellation.blocked.list`/`blocked` 一次性 drill-down。服务端固定 16 项、只查询 17 行,客户端不能提供 limit 或自动翻页;页面只含
|
`run.cancellation.blocked.list`/`blocked` 一次性 drill-down。服务端固定 16 项、只查询 17 行,客户端不能提供 limit 或自动翻页;页面只含
|
||||||
`{runId,blockedAtMs}`,以 PostgreSQL 首屏时间和 `(blockedAtMs,runId)` 组成稳定 cursor,严格 oldest-first、快照后新增项不进入后续页。强认证 User 继续使用
|
`{runId,blockedAtMs}`,以 PostgreSQL 首屏时间和 `(blockedAtMs,runId)` 组成稳定 cursor,严格 oldest-first、快照后新增项不进入后续页。强认证 User 继续使用
|
||||||
`run.read`,Policy fence、数据库时间、键集读取与 allowed audit 在同一 5 秒 SERIALIZABLE 短事务;CLI cursor 是有版本、精确字段、大小受限的 canonical
|
`run.read`,Policy fence、数据库时间、键集读取与 allowed audit 在同一 5 秒 SERIALIZABLE 短事务;CLI cursor 是有版本、精确字段、大小受限的 canonical
|
||||||
@@ -27,7 +43,7 @@
|
|||||||
rearm、生产交付、WAL 与 promotion;报告 SHA-256 为 `1fbd58c5bb32bbf83b6c1970a594f7879c33d63057a3b9c34f13ec9917ff5c44`,独立 evidence audit
|
rearm、生产交付、WAL 与 promotion;报告 SHA-256 为 `1fbd58c5bb32bbf83b6c1970a594f7879c33d63057a3b9c34f13ec9917ff5c44`,独立 evidence audit
|
||||||
compatible 且零 finding。
|
compatible 且零 finding。
|
||||||
|
|
||||||
- D-367/ADR-0460(已接受;有界 blocked drill-down 由 D-368 完成,Console 可选接入待完成):在现有 `ql3 run`/`ql3-run-client` 增加一次性
|
- D-367/ADR-0460(已接受;有界 blocked drill-down 由 D-368 完成,Console 可选接入由 D-369 完成):在现有 `ql3 run`/`ql3-run-client` 增加一次性
|
||||||
`status --config=... --assertion=... --project=... [--format=text|json]` 产品入口。它在内存生成固定 `run.cancellation.summary` 命令,仍经同一
|
`status --config=... --assertion=... --project=... [--format=text|json]` 产品入口。它在内存生成固定 `run.cancellation.summary` 命令,仍经同一
|
||||||
exact codec、TLS 1.3、Run 专用 mTLS/OIDC、固定 management route 和响应交叉不变量校验;原 `--command` 私有文件模式保持兼容。默认 text 是无 ANSI
|
exact codec、TLS 1.3、Run 专用 mTLS/OIDC、固定 management route 和响应交叉不变量校验;原 `--command` 私有文件模式保持兼容。默认 text 是无 ANSI
|
||||||
的确定性 Project 状态卡,JSON 使用 `qinglong/run-cancellation-status@v1`;两者只含 D-366 低敏计数与结论。告警映射固定为
|
的确定性 Project 状态卡,JSON 使用 `qinglong/run-cancellation-status@v1`;两者只含 D-366 低敏计数与结论。告警映射固定为
|
||||||
@@ -42,7 +58,7 @@
|
|||||||
`7,315,930 / 7,316,038` bytes。PostgreSQL 18.6 arm64 HA `145/145`、timeline `1→2`,报告 SHA-256 为
|
`7,315,930 / 7,316,038` bytes。PostgreSQL 18.6 arm64 HA `145/145`、timeline `1→2`,报告 SHA-256 为
|
||||||
`59a568d0511cde671946ebf6df09f88868a3d591c5021c90bc27d4715411091e`,独立 evidence audit compatible 且零 finding。
|
`59a568d0511cde671946ebf6df09f88868a3d591c5021c90bc27d4715411091e`,独立 evidence audit compatible 且零 finding。
|
||||||
|
|
||||||
- D-366/ADR-0459(已接受;一次性产品 CLI 由 D-367 完成,Console 可选接入待完成):在既有 Run management plane 增加 `run.cancellation.summary`,由强认证 User 以
|
- D-366/ADR-0459(已接受;一次性产品 CLI 由 D-367 完成,Console 可选接入由 D-369 完成):在既有 Run management plane 增加 `run.cancellation.summary`,由强认证 User 以
|
||||||
`run.read` 按需读取 Project 级 PostgreSQL 快照。响应只有五态 dispatch 计数、due/expired-lease 信号、四种 blocking-result 计数、最早 blocked
|
`run.read` 按需读取 Project 级 PostgreSQL 快照。响应只有五态 dispatch 计数、due/expired-lease 信号、四种 blocking-result 计数、最早 blocked
|
||||||
时间和 `clear|converging|attention_required`/`none|wait|inspect` 固定结论,不返回 Run/Attempt/Worker identity 或 lease capability。blocked 触发
|
时间和 `clear|converging|attention_required`/`none|wait|inspect` 固定结论,不返回 Run/Attempt/Worker identity 或 lease capability。blocked 触发
|
||||||
`attention_required`,但不错误撤回整个 Cluster readiness;due/expired 只作为 caller-driven 收敛信号。查询与 allowed audit 位于同一 5 秒
|
`attention_required`,但不错误撤回整个 Cluster readiness;due/expired 只作为 caller-driven 收敛信号。查询与 allowed audit 位于同一 5 秒
|
||||||
@@ -9292,7 +9308,7 @@ flowchart LR
|
|||||||
| PR-2 Run 状态机 | Incubating | 纯转换表、终态/时间/错误/执行器元数据规则、Run version 与 event sequence CAS、事务性 RunCommandService、回滚测试 | 重复 Worker callback/fencing、并发数据库压力测试、Primary 执行链接入 |
|
| PR-2 Run 状态机 | Incubating | 纯转换表、终态/时间/错误/执行器元数据规则、Run version 与 event sequence CAS、事务性 RunCommandService、回滚测试 | 重复 Worker callback/fencing、并发数据库压力测试、Primary 执行链接入 |
|
||||||
| PR-3 Executor 端口 | Incubating | ADR-0003、ExecutionSpec/Context/Handle/Result、Executor port、LocalProcessExecutor、进程组取消/超时升级、流式背压、Legacy Cron spec builder、真实进程 contract tests、可复现 edge 基准入口 | 固定 edge/多架构设备基线、Legacy builder 与 makeCommand 差异审计、Primary 生产流量接入 |
|
| PR-3 Executor 端口 | Incubating | ADR-0003、ExecutionSpec/Context/Handle/Result、Executor port、LocalProcessExecutor、进程组取消/超时升级、流式背压、Legacy Cron spec builder、真实进程 contract tests、可复现 edge 基准入口 | 固定 edge/多架构设备基线、Legacy builder 与 makeCommand 差异审计、Primary 生产流量接入 |
|
||||||
| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;监听前一次性、Profile-aware 的 keyset Startup Reconciler,终态证据补齐、lost/abandoned/pending 分流与 terminal Attempt response-loss 修复;origin-bounded 且逐级守恒的版本化 startup difference report、固定字段 metric batch 与一次性 collector;显式、只读、闭合窗口且 Profile-bounded 的 Shadow→Legacy 终态差异审计;128/256 MiB Linux arm64 资源门、SQLite 零增长与 Shadow enabled→off 进程重启回滚;process-epoch Legacy admission/capture/failure/pending 守恒;clean-shutdown `0600` no-replace capture+startup exporter;manual Edge 8/Standalone 32–128 canary;capture/terminal/resource 自包含 Primary bundle;rollout v2 loader 重算 source digest 与 eligibility;不可变 prepare/observe/resource/qualify 目标实例仪式、独立只读 audit;失败开放和契约测试 | 首次真实目标实例完整 canary 与 bootstrap activated 记录、其他 origin 独立 capture/Primary gate、固定物理 edge/flash/断电证据 |
|
| PR-4 Shadow Run | Incubating | origin 三态策略;默认关闭的 `QL3_SHADOW_ORIGINS`;manual、scheduled_node、boot、subscription、system 与 script 现有 ChildProcess 旁路观察;system crond 显式 origin marker、Shell execution ID、finish-only 准入、确定性 Run/Attempt 与 exact replay;`@once` 保持 manual、gRPC transport 不冒充 origin 的准入裁决;每个 worker 懒加载;Run/Attempt/Event 影子生命周期;稳定且不复制 caller 原文的 task identity/revision 与有界日志引用;同 worker 有界注册表和跨 worker 持久化候选关联;stop all/stop instance、Shell callback、乱序/迟到/歧义处理;监听前一次性、Profile-aware 的 keyset Startup Reconciler,终态证据补齐、lost/abandoned/pending 分流与 terminal Attempt response-loss 修复;origin-bounded 且逐级守恒的版本化 startup difference report、固定字段 metric batch 与一次性 collector;显式、只读、闭合窗口且 Profile-bounded 的 Shadow→Legacy 终态差异审计;128/256 MiB Linux arm64 资源门、SQLite 零增长与 Shadow enabled→off 进程重启回滚;process-epoch Legacy admission/capture/failure/pending 守恒;clean-shutdown `0600` no-replace capture+startup exporter;manual Edge 8/Standalone 32–128 canary;capture/terminal/resource 自包含 Primary bundle;rollout v2 loader 重算 source digest 与 eligibility;不可变 prepare/observe/resource/qualify 目标实例仪式、独立只读 audit;失败开放和契约测试 | 首次真实目标实例完整 canary 与 bootstrap activated 记录、其他 origin 独立 capture/Primary gate、固定物理 edge/flash/断电证据 |
|
||||||
| PR-5 Primary LocalExecutor | Incubating(默认不激活,仅 manifest-gated manual) | runtime-owned Run 创建器;持久化先于 spawn;Run/Attempt 完整成功、失败、取消、超时与 lost 闭环;Executor handle 身份校验;spawn 后激活写失败的 stop+lost 补偿;completion rejection 安全收敛;独立 Primary 幂等查询与唯一索引竞态裁决;durable `run.cancel_requested`、stop-before-signal、首次请求幂等、晚到完成裁决与待取消有界恢复查询;最多 64 条一页的 cross-worker cancellation source;独立 CancellationDispatch Repository 原子 claim/result、lease expiry 接管、owner/token/version fencing、指数退避与结果 RunEvent;PostgreSQL `pg-0066`/capability v65 adapter 以数据库时间、Run→Attempt→dispatch 锁序、digest-only durable token、最小 runtime 权限和原子 RunEvent/Run version CAS 提供多副本同构实现,真实双连接与 HA promotion 门已通过;caller-driven Worker lease-control 已以 settle-before-stop 接入 Cluster 生产组合,复用 ingress drain 且不新增 cadence;最多 64 页的单周期 cancel supervisor;显式 start/stop、无重叠、错误隔离、停止等待有上限且 timer unref 的 lifecycle runner;Linux durable handle 的 PID/boot/start ticks/process-group 复验与 TERM/KILL controller;完整有界分页且 fail-closed 的 startup Reconcile supervisor;RunningInstance nullable `run_id/attempt_id` 关联;Primary 专用组合 Repository 在同一 SQLite 事务提交前投影 Crontab/RunningInstance,失败整体回滚;有界且防穿越的 legacy log output ref;manual owner seam、真实本机装配、单 spawn/fail-closed;严格 manual-only rollout manifest loader、短期审批/gate、配置哈希审计;HTTP worker 已接轻量 lazy bootstrap,accepted 后按 receipt-first reconcile→completion receipt lifecycle→timeout intent lifecycle→cancel dispatch lifecycle→router→durable active receipt 顺序激活,失败撤销,监听失败和 shutdown 以 stopping→有界清理→stopped/failed 失效;receipt 固定为单文件 observed-state projection,Linux 以 boot/PID/process-group/start ticks 复验,独立 auditor 支持 active 且 off/rolled-back 拒绝 live runtime;Primary timeout 在 spawn 前持久化绝对 deadline,有界 source/requester/supervisor 只提交 timeout 意图并复用 CancellationDispatch;代码级 edge/standalone Profile 为各 lifecycle 提供不同 cadence 与页上限,cluster-control/worker 拒绝误装本机 SQLite Primary;统一 CompletionService 原子提交 Attempt/Run/双 Event,spawn 前保存 callback token hash、终态推进 sequence,实时回调与 receipt consumer 共享入口并覆盖两个清理 crash window;manual Primary 已接入受限 POSIX launcher、`0600` direct-file stdout/stderr、父进程退出后续写、不可覆盖 receipt 生产、回执环境清除、TERM 转发等待及 live transaction 后清理;Startup Reconciler receipt-first 双检查并在确定 exited 后执行 profile 化的单次 50/100 ms publish grace;`0007` 独立 CompletionReceiptJournal 在 spawn 前登记、为升级前 active Attempt 补登记并驱动周期扫描,使终态残留继续可发现;确定无效的已知 Attempt receipt 先持久化隔离状态,再进入确定性私有分片 quarantine;终态 missing 与 quarantine 按 edge/standalone retention 有界清理;非 Journal 文件具备只读优先、固定分片/条目上限、overflow fail-closed、显式同盘隔离的 Node 24 运维 CLI;扫描具备页上限、resume cursor、timer unref、无重叠、有界 stop 和低敏计数;ENOSPC 与 launcher receipt 存储失败有代码门禁;显式最长 24 小时 approve 写入、`primary_selected` 只读状态、selection receipt、approval-expiry off 与 intent/completion crash-replay rollback | 首次真实目标实例完整激活/回滚仪式;用户可见的 Cluster cancellation availability/blocked 处置面;固定 edge/Linux 多架构与真实磁盘压力基线、完整 2.x API 契约和回滚演练;共享 config 多写者 authority |
|
| PR-5 Primary LocalExecutor | Incubating(默认不激活,仅 manifest-gated manual) | runtime-owned Run 创建器;持久化先于 spawn;Run/Attempt 完整成功、失败、取消、超时与 lost 闭环;Executor handle 身份校验;spawn 后激活写失败的 stop+lost 补偿;completion rejection 安全收敛;独立 Primary 幂等查询与唯一索引竞态裁决;durable `run.cancel_requested`、stop-before-signal、首次请求幂等、晚到完成裁决与待取消有界恢复查询;最多 64 条一页的 cross-worker cancellation source;独立 CancellationDispatch Repository 原子 claim/result、lease expiry 接管、owner/token/version fencing、指数退避与结果 RunEvent;PostgreSQL `pg-0066`/capability v65 adapter 以数据库时间、Run→Attempt→dispatch 锁序、digest-only durable token、最小 runtime 权限和原子 RunEvent/Run version CAS 提供多副本同构实现,真实双连接与 HA promotion 门已通过;caller-driven Worker lease-control 已以 settle-before-stop 接入 Cluster 生产组合,复用 ingress drain 且不新增 cadence;最多 64 页的单周期 cancel supervisor;显式 start/stop、无重叠、错误隔离、停止等待有上限且 timer unref 的 lifecycle runner;Linux durable handle 的 PID/boot/start ticks/process-group 复验与 TERM/KILL controller;完整有界分页且 fail-closed 的 startup Reconcile supervisor;RunningInstance nullable `run_id/attempt_id` 关联;Primary 专用组合 Repository 在同一 SQLite 事务提交前投影 Crontab/RunningInstance,失败整体回滚;有界且防穿越的 legacy log output ref;manual owner seam、真实本机装配、单 spawn/fail-closed;严格 manual-only rollout manifest loader、短期审批/gate、配置哈希审计;HTTP worker 已接轻量 lazy bootstrap,accepted 后按 receipt-first reconcile→completion receipt lifecycle→timeout intent lifecycle→cancel dispatch lifecycle→router→durable active receipt 顺序激活,失败撤销,监听失败和 shutdown 以 stopping→有界清理→stopped/failed 失效;receipt 固定为单文件 observed-state projection,Linux 以 boot/PID/process-group/start ticks 复验,独立 auditor 支持 active 且 off/rolled-back 拒绝 live runtime;Primary timeout 在 spawn 前持久化绝对 deadline,有界 source/requester/supervisor 只提交 timeout 意图并复用 CancellationDispatch;代码级 edge/standalone Profile 为各 lifecycle 提供不同 cadence 与页上限,cluster-control/worker 拒绝误装本机 SQLite Primary;统一 CompletionService 原子提交 Attempt/Run/双 Event,spawn 前保存 callback token hash、终态推进 sequence,实时回调与 receipt consumer 共享入口并覆盖两个清理 crash window;manual Primary 已接入受限 POSIX launcher、`0600` direct-file stdout/stderr、父进程退出后续写、不可覆盖 receipt 生产、回执环境清除、TERM 转发等待及 live transaction 后清理;Startup Reconciler receipt-first 双检查并在确定 exited 后执行 profile 化的单次 50/100 ms publish grace;`0007` 独立 CompletionReceiptJournal 在 spawn 前登记、为升级前 active Attempt 补登记并驱动周期扫描,使终态残留继续可发现;确定无效的已知 Attempt receipt 先持久化隔离状态,再进入确定性私有分片 quarantine;终态 missing 与 quarantine 按 edge/standalone retention 有界清理;非 Journal 文件具备只读优先、固定分片/条目上限、overflow fail-closed、显式同盘隔离的 Node 24 运维 CLI;扫描具备页上限、resume cursor、timer unref、无重叠、有界 stop 和低敏计数;ENOSPC 与 launcher receipt 存储失败有代码门禁;显式最长 24 小时 approve 写入、`primary_selected` 只读状态、selection receipt、approval-expiry off 与 intent/completion crash-replay rollback;用户可见的 Cluster cancellation availability/blocked 只读面已由 D-366~D-369 完成 | 首次真实目标实例完整激活/回滚仪式;mutation 处置继续使用独立 exact-CAS CLI;固定 edge/Linux 多架构与真实磁盘压力基线、完整 2.x API 契约和回滚演练;共享 config 多写者 authority |
|
||||||
| PR-7 Worker Session、Run Lease 与启动协议基础 | Incubating(默认关闭,独立入口显式 opt-in) | ADR-0012/0013/0014/0021/0057–0061/0108–0121/0231–0239/0377;有界 capability/Placement/Dispatcher;SQLite 协议孵化与 PostgreSQL v9 Session/Run Lease/credential/attestation authority;immutable revision Placement、数据库时钟 keyset candidate、认证 Worker Pull、digest-only offer recovery;versioned capability-free ExecutionSpec response、stable claim 跨重启退避、单 owner 原子 inbox 准入与 TLS 1.3 mTLS/`ql3w` HTTPS client;同一 package journal 上 revision-fenced starting/spawn/started/running/completion 状态、callback digest、tagged no-spawn 与 ambiguous recovery;PostgreSQL starting/running/start-failure/completion 数据库权威事务、精确重放与 cancellation/timeout 优先终态;batch Secret delivery 在 Attempt advisory lock 下复验 Session/Lease/revision 完整围栏并复用单 Agent,Secret-before-Artifact materializer 将同一 log ID 交给 Executor/journal/running ACK;offer-scoped `wlog-*` 私有文件 spool、Edge/Node 容量策略、append/quota/path 防护、barrier 后 output ownership、受审 POSIX Executor、truncation fact、固定内存流式 source、认证 Artifact stream、共享 immutable store port、S3-compatible SSE/checksum/条件 promotion adapter、upload-before-completion 协调,以及 Local/Cluster 同构、Profile-aware、ETag-fenced range read;用户取消 run.stop mutation 以数据库时间写 intent/Event 并在事务内复验 Project/RoleBinding fence;非执行取消 convergence lifecycle、运行期 expiry 与安全 lost retry 已接入 cluster-control 单一全局 cadence;完整 generation/version/token/Attempt fencing;独立最小权限 Worker ingress、CA/CRL 与连接 generation 热重载;offer journal、spawn barrier、receipt-first recovery;独立 `@qinglong/worker-runtime` 的本地 P-256 CSR、key/chain/trust 验证、generation + active pointer 安装和持久退避;默认关闭的 production process 已装配具体 execution graph、完整 Session heartbeat/drain/offline、direct-file bootstrap、单 Agent/单 cadence、startup reconciliation、证书 maintenance、transport fail-close/recovery 与 Edge/Node 有界预算;真实 PostgreSQL 18 + Linux Node 合约已覆盖 Run completion、credential 和 CA 双轮换且保持同一 Session;真实 K3s 合约已覆盖 TLS/credential Secret 分权、双对象 CAS、Recreate 顺序、identity generation 与单节点 PVC recovery;所有能力默认不可达且受 edge/cluster import audit 约束 | 具体 cert-manager/Vault/SPIFFE/离线 CA adapter 与模板、ingress reload controller、生产 RBAC、证书到期告警和 `ql3w` credential recovery 产品面;具体 KMS/Vault Secret provider、对象存储 credential/temporary lifecycle 与 retention/tombstone;Worker 管理 API;真实 Kubernetes 多节点 CSI/node-loss/production 360 秒 drain 与固定 edge 文件系统 suspend/时钟/断电、x64/arm64 资源门禁 |
|
| PR-7 Worker Session、Run Lease 与启动协议基础 | Incubating(默认关闭,独立入口显式 opt-in) | ADR-0012/0013/0014/0021/0057–0061/0108–0121/0231–0239/0377;有界 capability/Placement/Dispatcher;SQLite 协议孵化与 PostgreSQL v9 Session/Run Lease/credential/attestation authority;immutable revision Placement、数据库时钟 keyset candidate、认证 Worker Pull、digest-only offer recovery;versioned capability-free ExecutionSpec response、stable claim 跨重启退避、单 owner 原子 inbox 准入与 TLS 1.3 mTLS/`ql3w` HTTPS client;同一 package journal 上 revision-fenced starting/spawn/started/running/completion 状态、callback digest、tagged no-spawn 与 ambiguous recovery;PostgreSQL starting/running/start-failure/completion 数据库权威事务、精确重放与 cancellation/timeout 优先终态;batch Secret delivery 在 Attempt advisory lock 下复验 Session/Lease/revision 完整围栏并复用单 Agent,Secret-before-Artifact materializer 将同一 log ID 交给 Executor/journal/running ACK;offer-scoped `wlog-*` 私有文件 spool、Edge/Node 容量策略、append/quota/path 防护、barrier 后 output ownership、受审 POSIX Executor、truncation fact、固定内存流式 source、认证 Artifact stream、共享 immutable store port、S3-compatible SSE/checksum/条件 promotion adapter、upload-before-completion 协调,以及 Local/Cluster 同构、Profile-aware、ETag-fenced range read;用户取消 run.stop mutation 以数据库时间写 intent/Event 并在事务内复验 Project/RoleBinding fence;非执行取消 convergence lifecycle、运行期 expiry 与安全 lost retry 已接入 cluster-control 单一全局 cadence;完整 generation/version/token/Attempt fencing;独立最小权限 Worker ingress、CA/CRL 与连接 generation 热重载;offer journal、spawn barrier、receipt-first recovery;独立 `@qinglong/worker-runtime` 的本地 P-256 CSR、key/chain/trust 验证、generation + active pointer 安装和持久退避;默认关闭的 production process 已装配具体 execution graph、完整 Session heartbeat/drain/offline、direct-file bootstrap、单 Agent/单 cadence、startup reconciliation、证书 maintenance、transport fail-close/recovery 与 Edge/Node 有界预算;真实 PostgreSQL 18 + Linux Node 合约已覆盖 Run completion、credential 和 CA 双轮换且保持同一 Session;真实 K3s 合约已覆盖 TLS/credential Secret 分权、双对象 CAS、Recreate 顺序、identity generation 与单节点 PVC recovery;所有能力默认不可达且受 edge/cluster import audit 约束 | 具体 cert-manager/Vault/SPIFFE/离线 CA adapter 与模板、ingress reload controller、生产 RBAC、证书到期告警和 `ql3w` credential recovery 产品面;具体 KMS/Vault Secret provider、对象存储 credential/temporary lifecycle 与 retention/tombstone;Worker 管理 API;真实 Kubernetes 多节点 CSI/node-loss/production 360 秒 drain 与固定 edge 文件系统 suspend/时钟/断电、x64/arm64 资源门禁 |
|
||||||
| PR-8 Project/Policy/Approval Core | Incubating(默认拒绝、无生产业务执行入口) | ADR-0028;统一六类 ActorRef 与 exact-shape 校验;`0017` ownerless default Project 和 append-only versioned RoleBinding;owner/admin/operator/viewer 固定矩阵;Project 内 mutation 幂等、expected-version CAS、双 SQLite 连接竞争门禁;archived read-only、revocation、存储损坏 fail-closed;Agent 写/Secret/Tool `require_approval`;ADR-0047 把六类 subject、role/permission matrix 与 fence 抽到 runtime-core,`pg-0004-project-policy`/capability v3 建立 ownerless PostgreSQL baseline、严格 role/state CHECK、append-only runtime 权限、SERIALIZABLE Project lock、mutation replay、双连接单 winner 和 cluster admission authorizer;ADR-0049/`pg-0005` capability v4 建立 stable IdentitySubject、append-only digest-only API credential、真实 cluster bearer authenticator、write-only durable security audit 与最小权限 runtime role,且已验证 HTTP→credential→Policy→audit→handler 纵向链路;ADR-0051 建立 `/api/v3` 认证前 peer/global 双预算、transport-peer-only、无 timer 且有界内存的 overload shield;ADR-0027 Artifact authorizer adapter;ADR-0029 `AuthenticatedPrincipal` contract、`0018` digest-only versioned challenge、CSPRNG/TTL、同事务消费 challenge + 写首 owner、精确重放与双连接竞争/崩溃回滚门禁;ADR-0030 `0019` stable identity/binding、legacy HS384 + current-session membership、logout/platform/revoke/disable、single-factor 与损坏 fail-closed 门禁;ADR-0031 `0020` digest-bound ApprovalRequest、User-only decision、Project/Role version fence、精确 expiry/重放/并发裁决及同事务 immutable dispatch;ADR-0032 `0021` execution backfill、三表原子 consume、稳定 due keyset、claim/renew/start/result fencing、pre-start takeover/post-start recovery-required、attempt budget、handler inspect/digest barrier 和 bounded dispatcher;ADR-0033/`0022` control/resolution backfill、start/renew/completion 原子联动、稳定 recovery keyset、双 resolver claim/takeover、finding/result 精确重放、自动/人工终结、迟到 completion 单 winner 和 evidence-only bounded reconciler;ADR-0034/`0023` 首个 `run.create` canonical plan、Run/Attempt/Event/receipt 同事务、幂等 collision fail-closed、renew/终态 fence、真实 SQLite handler 与 automatic evidence provider;ADR-0035/`0024` 独立 `approval.recover` 矩阵、稳定 User + 五分钟强认证、Project/RoleBinding fence、human resolution + authorization fact 原子提交、撤权竞态与回滚门禁;ADR-0036 recovery-first 单 timer lifecycle、edge/standalone 独立 cadence/页预算、跨周期 cursor、非重叠与有界 stop;ADR-0074 以新的 Node 24 SQLite v5 ownerless Project/RoleBinding/audit authority 和独立 local-secret-admin 提供强 Principal、`secret.manage`、撤权 fence、envelope+allowed audit 原子提交及不回显语义;ADR-0086 以可信 POSIX console 和 staged delivery 完成本机首 Owner 产品 ceremony | fresh database/pepper setup 与安全迁移向导;`shareStore`/Express 到 authentication core 的 production migration;credential rotation/revocation API、mTLS/Worker enrollment、恢复码;Project/Role/Approval/Secret 管理 CLI/API/UI、audit retention/query/export/alert、preview Artifact/digest/immutable plan builder、真实 MFA/hardware adapter、人工 recovery API/UI/独立 rate limit 与审计事件、handler/provider registry、lifecycle startup/shutdown/指标/admission gate;PostgreSQL action/receipt/provider/recovery-authorization 与 OPA adapter、缓存 version 失效;Tool/Package/Secret/Shell 各自的 handler/evidence contract;Secret/Run/Tool/Workflow waiting_approval 全入口装配;完整回滚演练 |
|
| PR-8 Project/Policy/Approval Core | Incubating(默认拒绝、无生产业务执行入口) | ADR-0028;统一六类 ActorRef 与 exact-shape 校验;`0017` ownerless default Project 和 append-only versioned RoleBinding;owner/admin/operator/viewer 固定矩阵;Project 内 mutation 幂等、expected-version CAS、双 SQLite 连接竞争门禁;archived read-only、revocation、存储损坏 fail-closed;Agent 写/Secret/Tool `require_approval`;ADR-0047 把六类 subject、role/permission matrix 与 fence 抽到 runtime-core,`pg-0004-project-policy`/capability v3 建立 ownerless PostgreSQL baseline、严格 role/state CHECK、append-only runtime 权限、SERIALIZABLE Project lock、mutation replay、双连接单 winner 和 cluster admission authorizer;ADR-0049/`pg-0005` capability v4 建立 stable IdentitySubject、append-only digest-only API credential、真实 cluster bearer authenticator、write-only durable security audit 与最小权限 runtime role,且已验证 HTTP→credential→Policy→audit→handler 纵向链路;ADR-0051 建立 `/api/v3` 认证前 peer/global 双预算、transport-peer-only、无 timer 且有界内存的 overload shield;ADR-0027 Artifact authorizer adapter;ADR-0029 `AuthenticatedPrincipal` contract、`0018` digest-only versioned challenge、CSPRNG/TTL、同事务消费 challenge + 写首 owner、精确重放与双连接竞争/崩溃回滚门禁;ADR-0030 `0019` stable identity/binding、legacy HS384 + current-session membership、logout/platform/revoke/disable、single-factor 与损坏 fail-closed 门禁;ADR-0031 `0020` digest-bound ApprovalRequest、User-only decision、Project/Role version fence、精确 expiry/重放/并发裁决及同事务 immutable dispatch;ADR-0032 `0021` execution backfill、三表原子 consume、稳定 due keyset、claim/renew/start/result fencing、pre-start takeover/post-start recovery-required、attempt budget、handler inspect/digest barrier 和 bounded dispatcher;ADR-0033/`0022` control/resolution backfill、start/renew/completion 原子联动、稳定 recovery keyset、双 resolver claim/takeover、finding/result 精确重放、自动/人工终结、迟到 completion 单 winner 和 evidence-only bounded reconciler;ADR-0034/`0023` 首个 `run.create` canonical plan、Run/Attempt/Event/receipt 同事务、幂等 collision fail-closed、renew/终态 fence、真实 SQLite handler 与 automatic evidence provider;ADR-0035/`0024` 独立 `approval.recover` 矩阵、稳定 User + 五分钟强认证、Project/RoleBinding fence、human resolution + authorization fact 原子提交、撤权竞态与回滚门禁;ADR-0036 recovery-first 单 timer lifecycle、edge/standalone 独立 cadence/页预算、跨周期 cursor、非重叠与有界 stop;ADR-0074 以新的 Node 24 SQLite v5 ownerless Project/RoleBinding/audit authority 和独立 local-secret-admin 提供强 Principal、`secret.manage`、撤权 fence、envelope+allowed audit 原子提交及不回显语义;ADR-0086 以可信 POSIX console 和 staged delivery 完成本机首 Owner 产品 ceremony | fresh database/pepper setup 与安全迁移向导;`shareStore`/Express 到 authentication core 的 production migration;credential rotation/revocation API、mTLS/Worker enrollment、恢复码;Project/Role/Approval/Secret 管理 CLI/API/UI、audit retention/query/export/alert、preview Artifact/digest/immutable plan builder、真实 MFA/hardware adapter、人工 recovery API/UI/独立 rate limit 与审计事件、handler/provider registry、lifecycle startup/shutdown/指标/admission gate;PostgreSQL action/receipt/provider/recovery-authorization 与 OPA adapter、缓存 version 失效;Tool/Package/Secret/Shell 各自的 handler/evidence contract;Secret/Run/Tool/Workflow waiting_approval 全入口装配;完整回滚演练 |
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
# ADR-0462:Copilot Console 显式可选 Run Management Drill-down
|
||||||
|
|
||||||
|
- 状态:Accepted
|
||||||
|
- 日期:2026-08-20
|
||||||
|
- 关联 RFC:QL-RFC-0001 D-369、PR-5、PR-7
|
||||||
|
- 关联 ADR:ADR-0322、ADR-0323、ADR-0329、ADR-0330、ADR-0458、ADR-0459、ADR-0460、ADR-0461
|
||||||
|
- Amends:ADR-0461 的 Console 可选接入边界
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
QingLong 3.0 已有一次性 `ql3 run status`、固定 16 项 blocked page 和单 Run cancellation inspect,但 operator 在图形 Console 中仍要切换 CLI 才能完成 `status → blocked → inspect`。既有 Console 只持有 Project API credential;直接把 Run management mTLS/OIDC authority 设为默认,会让每次普通 Run/Task/Workflow/Copilot 观察都无条件携带更高价值的 User assertion,也会扩大 operator workstation、容器 launcher 和低资源部署的默认攻击面。
|
||||||
|
|
||||||
|
Console 仍必须是按需启动的 Cluster operator 工具,而不是 QingLong 常驻服务。Edge/Standalone 和小型路由设备不能为该能力增加 package、依赖、进程、端口、timer、连接、schema 或安装字节;Cluster 节点也不能把一次人工诊断变成轮询、自动翻页或批量 N+1。
|
||||||
|
|
||||||
|
## 决策
|
||||||
|
|
||||||
|
1. 在既有 `@qinglong/cluster-admin` Copilot Console 增加 `run_cancellation_status`、`run_cancellation_blocked_list`、`run_cancellation_inspect` 三个固定只读操作。不新增 workspace package、binary、服务、Ingress、Kubernetes workload、数据库 migration、连接池、timer、queue 或 cache。
|
||||||
|
2. Run management authority 默认 `disabled`。只有启动参数同时提供 `--run-management-config` 与 `--run-management-assertion` 时才启用;缺一项在监听或网络 I/O 前失败。配置必须是独立的 Run 专用 TLS 1.3/mTLS 文件,assertion 必须是 canonical、owner-private `0600` JWT 文件。两者不复用或替代 Project API credential 与浏览器 session key。
|
||||||
|
3. preflight 只在本机验证 Run config、client certificate/private key 和 assertion 格式,不消耗 assertion,也不冒充 authorization 或 management endpoint readiness。serve 时每次用户点击重新读取 assertion 文件,因此短期凭据可以原位轮换或删除;路径、JWT、证书和 endpoint 不进入 stdout、浏览器或失败事实。
|
||||||
|
4. 浏览器只提交精确 schema、Project、请求 ID、可选 opaque cursor 或单一 Run ID;BFF 只接受三个固定同源 POST route,并在服务端构造既有规范 Run management command。浏览器不能提交 URL、HTTP method、limit、排序、filter、audit ID、mutation ID、expected version、retry delay 或任意 management operation。
|
||||||
|
5. 导航完全由用户触发:status 返回 `attention_required/inspect` 时才显示“读取 Blocked Runs”;blocked page 中每个 Run 提供独立 inspect 点击;下一页也必须显式点击。没有启动时读取、后台轮询、自动翻页、自动逐 Run inspect、隐藏重试或浏览器缓存。
|
||||||
|
6. Console 永远不路由 `run.cancellation.rearm`、`run.stop`、`run.retry` 或其他 mutation。即使 operator 给了更宽的 User assertion,浏览器 vocabulary、request normalizer、route table 与 executor 分支仍无法表达 mutation;处置继续使用独立 CLI 的 exact-CAS rearm。
|
||||||
|
7. status、blocked 和 inspect 复用既有 Run management result validator,再投影为固定产品事实。blocked cursor 在网络请求中保持版本化 canonical token;写入浏览器脱敏 evidence bundle 时只变成 per-bundle `cursor-NNN` alias,不泄露原 continuation。离线 verifier 同步验证 16 种固定 read operation 和相同 alias/allowlist。
|
||||||
|
8. native 模式继续只监听 ephemeral `127.0.0.1`。镜像 launcher 继续以 host-loopback publication、只读 root、非 root UID、无 capability、no-new-privileges、固定 memory/CPU/PID 和两并发/无队列运行;只有显式 `QL3_COPILOT_CONSOLE_RUN_MANAGEMENT=enabled` 才把容器私有只读 mount 中的 Run config/assertion 路径传给进程,未知值失败关闭。
|
||||||
|
9. Console 资产仍由包内 SHA-256 精确绑定,CSP、Host/Origin/session、4 KiB request、约 2 MiB response、2 in-flight、16 connection 与 no-store 边界不变。新增 UI 不引入 framework、第三方依赖、browser storage、worker、WebSocket、EventSource、clipboard/share API 或 timer。
|
||||||
|
10. 实现保留在已有 `cluster-admin/copilot-console` 与 `cluster-admin/run-management` 子域。新增 inspection projection 是现有 package 内的实质职责文件,不是新 package 或根目录平铺;workspace 仍维持 18 个职责包,Edge/Standalone artifact closure 不得导入 `cluster-admin`。
|
||||||
|
|
||||||
|
## 被拒绝的替代方案
|
||||||
|
|
||||||
|
### 默认把 Run management assertion 塞进 Console
|
||||||
|
|
||||||
|
拒绝。普通观察不需要 strong User authority;默认持有会扩大凭据暴露时长和部署准备成本,也让“只读 Project Console”与“高价值管理身份”无法独立撤销。
|
||||||
|
|
||||||
|
### 让浏览器直接访问 management endpoint
|
||||||
|
|
||||||
|
拒绝。浏览器将获得 assertion、mTLS capability 或可变 endpoint,并需要扩大 CSP/CORS;这破坏 server-only credential、固定 route 和同源 session fence。
|
||||||
|
|
||||||
|
### status 后自动获取 blocked page 并逐项 inspect
|
||||||
|
|
||||||
|
拒绝。一次点击会变成隐藏的多请求 fan-out,成本随 blocked 数增长;并发状态变化也会让自动链难以审计。每一步显式点击让请求次数、快照 cursor 和 operator 意图可见。
|
||||||
|
|
||||||
|
### 在只读 Console 中增加 rearm 按钮
|
||||||
|
|
||||||
|
拒绝。rearm 需要 `run.stop`、expected dispatch version/result 和明确 mutation ceremony。把它放进当前 evidence ledger 会混淆观察与处置,并让浏览器页面获得 mutation authority。
|
||||||
|
|
||||||
|
### 新建 Console Run-management package 或常驻 sidecar
|
||||||
|
|
||||||
|
拒绝。能力只组合现有 client、command codec 和 projection,不存在独立依赖、发布责任或生命周期;拆包会重新制造单职责过细的 workspace。常驻 sidecar则会增加 Cluster 资源和凭据驻留时间。
|
||||||
|
|
||||||
|
## 资源、安全与部署影响
|
||||||
|
|
||||||
|
- 默认关闭与 Edge/Standalone 路径均为零新增运行时 I/O、连接、timer、listener、内存状态、安装依赖和数据库成本。
|
||||||
|
- 显式启用只增加本机已存在 Console 进程中的常数级配置状态;每次点击仍是一条短 TLS 请求,最多两个并发且不排队。blocked page 固定 16 项,不自动读取下一页或详情。
|
||||||
|
- compact/standard 容器预算保持 `192 MiB / 0.25 CPU / 32 PIDs` 与 `512 MiB / 1 CPU / 64 PIDs`;本切片不据此宣称生产容量,Linux x64/arm64 实测仍是独立发布证据。
|
||||||
|
- assertion 是短期强 User 凭据,只在 BFF 进程按调用读取并由通用 management client 清零 buffer;浏览器 session 不能直接认证 Cluster,Project credential 也不能替代 Run authority。
|
||||||
|
- 本切片不改 PostgreSQL schema、repository、Run service、management server 或 HA 拓扑,因此不重新占有 PostgreSQL HA 证明;D-368 的 v67 `146/146` 只作为相邻既有证据,不冒充 D-369 新执行结果。
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
- 包级类型构建和聚焦门 `46/46`,覆盖默认关闭、参数成对约束、私有配置预检、三条固定 route、非法 cursor、无任意 path、浏览器 session/Host/Origin、并发上限、脱敏 cursor alias、16-operation 离线复核、launcher opt-in 和 mutation vocabulary 拒绝。
|
||||||
|
- 真实 loopback/TLS 纵切从浏览器风格 status POST 经 Console BFF、独立 Run mTLS client 到 management endpoint,验证只发送一条 `run.cancellation.summary`、请求 ID 绑定、Bearer assertion、低敏 status projection 和无 mutation。
|
||||||
|
- Cluster Admin 全量为 `420 total / 417 pass / 3 conditional skip / 0 fail`;backend 宿主门为 `1,491 total / 1,489 pass / 2 conditional skip / 0 fail`;18-package clean build/顺序测试退出 0。
|
||||||
|
- package/dependency/Edge import/Cluster deployment/Console/Console distribution 六项审计全部 compatible、零 finding;workspace 保持 18 package,Cluster Admin 为 `125 source / 124 nested` 且只有一个受审根入口。
|
||||||
|
- 14 档 Local artifact audit 全部 compatible;Edge/Standalone 为 `2,589,998 / 2,590,076` bytes,Application+AI 为 `4,493,151 / 4,493,283` bytes,MCP 为 `7,315,930 / 7,316,038` bytes。
|
||||||
|
- 本切片未改数据库或 HA ownership,未重跑 PostgreSQL HA;D-368 的 `146/146` 仅作为相邻基线。
|
||||||
|
|
||||||
|
## 后续
|
||||||
|
|
||||||
|
D-370 应优先取得固定 Linux x64/arm64 Cluster Admin Console 容量与 assertion rotation/expiry 现场证据,或继续推进 CloudNativePG live failover 发布门;不得把 workstation compact/standard limit 冒充生产容量,也不得为了监控便利引入 Console polling、常驻 authority 或 Edge 依赖。
|
||||||
@@ -234,7 +234,7 @@ h3 {
|
|||||||
}
|
}
|
||||||
.mode-tabs {
|
.mode-tabs {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(4, 1fr);
|
||||||
margin: 1.8rem 0 1.2rem;
|
margin: 1.8rem 0 1.2rem;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,11 @@
|
|||||||
const routes = Object.freeze({
|
const routes = Object.freeze({
|
||||||
inspect: '/api/v1/copilot/inspect',
|
inspect: '/api/v1/copilot/inspect',
|
||||||
output: '/api/v1/copilot/output',
|
output: '/api/v1/copilot/output',
|
||||||
|
run_cancellation_status: '/api/v1/run-management/cancellation-status',
|
||||||
|
run_cancellation_blocked_list:
|
||||||
|
'/api/v1/run-management/blocked-cancellations',
|
||||||
|
run_cancellation_inspect:
|
||||||
|
'/api/v1/run-management/cancellation-inspect',
|
||||||
run_list: '/api/v1/observe/run-list',
|
run_list: '/api/v1/observe/run-list',
|
||||||
run_read: '/api/v1/observe/run',
|
run_read: '/api/v1/observe/run',
|
||||||
run_event_list: '/api/v1/observe/run-events',
|
run_event_list: '/api/v1/observe/run-events',
|
||||||
@@ -20,6 +25,9 @@
|
|||||||
const labels = Object.freeze({
|
const labels = Object.freeze({
|
||||||
inspect: 'Copilot 诊断状态',
|
inspect: 'Copilot 诊断状态',
|
||||||
output: 'Copilot 诊断内容',
|
output: 'Copilot 诊断内容',
|
||||||
|
run_cancellation_status: '取消可用性',
|
||||||
|
run_cancellation_blocked_list: 'Blocked Cancellations',
|
||||||
|
run_cancellation_inspect: '取消诊断',
|
||||||
run_list: 'Run 目录',
|
run_list: 'Run 目录',
|
||||||
run_read: 'Run 详情',
|
run_read: 'Run 详情',
|
||||||
run_event_list: 'Run Events',
|
run_event_list: 'Run Events',
|
||||||
@@ -110,6 +118,12 @@
|
|||||||
if (operation === 'inspect' || operation === 'output') {
|
if (operation === 'inspect' || operation === 'output') {
|
||||||
result.sourceRunId = value('source-run-id');
|
result.sourceRunId = value('source-run-id');
|
||||||
result.requestId = value('diagnosis-request-id');
|
result.requestId = value('diagnosis-request-id');
|
||||||
|
} else if (operation === 'run_cancellation_status') {
|
||||||
|
return result;
|
||||||
|
} else if (operation === 'run_cancellation_blocked_list') {
|
||||||
|
result.cursor = null;
|
||||||
|
} else if (operation === 'run_cancellation_inspect') {
|
||||||
|
result.runId = value('cancellation-run-id');
|
||||||
} else if (operation === 'run_list') {
|
} else if (operation === 'run_list') {
|
||||||
result.afterCreatedAtMs = null;
|
result.afterCreatedAtMs = null;
|
||||||
result.afterRunId = null;
|
result.afterRunId = null;
|
||||||
@@ -156,7 +170,13 @@
|
|||||||
|
|
||||||
const nextPage = function (operation, prior, fact) {
|
const nextPage = function (operation, prior, fact) {
|
||||||
const next = Object.assign({}, prior, { requestId: requestId() });
|
const next = Object.assign({}, prior, { requestId: requestId() });
|
||||||
if (operation === 'run_list' && fact.hasMore === true && fact.next) {
|
if (
|
||||||
|
operation === 'run_cancellation_blocked_list' &&
|
||||||
|
fact.truncated === true &&
|
||||||
|
typeof fact.nextCursor === 'string'
|
||||||
|
) {
|
||||||
|
next.cursor = fact.nextCursor;
|
||||||
|
} else if (operation === 'run_list' && fact.hasMore === true && fact.next) {
|
||||||
next.afterCreatedAtMs = fact.next.createdAtMs;
|
next.afterCreatedAtMs = fact.next.createdAtMs;
|
||||||
next.afterRunId = fact.next.runId;
|
next.afterRunId = fact.next.runId;
|
||||||
} else if (
|
} else if (
|
||||||
@@ -200,6 +220,39 @@
|
|||||||
return next;
|
return next;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const appendDrilldownControls = function (entry, operation, fact) {
|
||||||
|
if (
|
||||||
|
operation === 'run_cancellation_status' &&
|
||||||
|
fact.operatorAction === 'inspect'
|
||||||
|
) {
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = '显式读取 Blocked Runs';
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
void execute('run_cancellation_blocked_list');
|
||||||
|
});
|
||||||
|
entry.append(button);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
operation !== 'run_cancellation_blocked_list' ||
|
||||||
|
!Array.isArray(fact.items)
|
||||||
|
) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
fact.items.forEach(function (item) {
|
||||||
|
if (!item || typeof item.runId !== 'string') return;
|
||||||
|
const button = document.createElement('button');
|
||||||
|
button.type = 'button';
|
||||||
|
button.textContent = '显式检查 ' + item.runId;
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
document.getElementById('cancellation-run-id').value = item.runId;
|
||||||
|
void execute('run_cancellation_inspect');
|
||||||
|
});
|
||||||
|
entry.append(button);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const appendEvidence = function (operation, request, response) {
|
const appendEvidence = function (operation, request, response) {
|
||||||
const fact = response.result.result;
|
const fact = response.result.result;
|
||||||
const observedAtMs = Date.now();
|
const observedAtMs = Date.now();
|
||||||
@@ -235,6 +288,7 @@
|
|||||||
});
|
});
|
||||||
entry.append(button);
|
entry.append(button);
|
||||||
}
|
}
|
||||||
|
appendDrilldownControls(entry, operation, fact);
|
||||||
ledger.prepend(entry);
|
ledger.prepend(entry);
|
||||||
evidenceRecords.push({ record: record, bytes: recordBytes, entry: entry });
|
evidenceRecords.push({ record: record, bytes: recordBytes, entry: entry });
|
||||||
evidenceBytes += recordBytes;
|
evidenceBytes += recordBytes;
|
||||||
|
|||||||
@@ -28,6 +28,9 @@
|
|||||||
const operations = Object.freeze([
|
const operations = Object.freeze([
|
||||||
'inspect',
|
'inspect',
|
||||||
'output',
|
'output',
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
'run_list',
|
'run_list',
|
||||||
'run_read',
|
'run_read',
|
||||||
'run_event_list',
|
'run_event_list',
|
||||||
@@ -44,6 +47,9 @@
|
|||||||
const requestFields = Object.freeze({
|
const requestFields = Object.freeze({
|
||||||
inspect: ['projectId', 'requestId', 'sourceRunId'],
|
inspect: ['projectId', 'requestId', 'sourceRunId'],
|
||||||
output: ['projectId', 'requestId', 'sourceRunId'],
|
output: ['projectId', 'requestId', 'sourceRunId'],
|
||||||
|
run_cancellation_status: ['projectId', 'requestId'],
|
||||||
|
run_cancellation_blocked_list: ['cursor', 'projectId', 'requestId'],
|
||||||
|
run_cancellation_inspect: ['projectId', 'requestId', 'runId'],
|
||||||
run_list: [
|
run_list: [
|
||||||
'afterCreatedAtMs',
|
'afterCreatedAtMs',
|
||||||
'afterRunId',
|
'afterRunId',
|
||||||
@@ -112,7 +118,9 @@
|
|||||||
afterStepRunId: 'step',
|
afterStepRunId: 'step',
|
||||||
afterTaskId: 'task',
|
afterTaskId: 'task',
|
||||||
artifactId: 'artifact',
|
artifactId: 'artifact',
|
||||||
|
attemptId: 'attempt',
|
||||||
contentDigest: 'digest',
|
contentDigest: 'digest',
|
||||||
|
cursor: 'cursor',
|
||||||
diagnosisRunId: 'run',
|
diagnosisRunId: 'run',
|
||||||
executionId: 'execution',
|
executionId: 'execution',
|
||||||
id: 'identifier',
|
id: 'identifier',
|
||||||
@@ -133,7 +141,10 @@
|
|||||||
});
|
});
|
||||||
const safeContainers = new Set([
|
const safeContainers = new Set([
|
||||||
'attempts',
|
'attempts',
|
||||||
|
'blockingResults',
|
||||||
'counts',
|
'counts',
|
||||||
|
'dispatch',
|
||||||
|
'dispatches',
|
||||||
'events',
|
'events',
|
||||||
'items',
|
'items',
|
||||||
'metadata',
|
'metadata',
|
||||||
@@ -142,6 +153,7 @@
|
|||||||
'run',
|
'run',
|
||||||
'runs',
|
'runs',
|
||||||
'source',
|
'source',
|
||||||
|
'signals',
|
||||||
'step',
|
'step',
|
||||||
'steps',
|
'steps',
|
||||||
'summary',
|
'summary',
|
||||||
@@ -168,9 +180,15 @@
|
|||||||
]);
|
]);
|
||||||
const safeEnumKeys = new Set([
|
const safeEnumKeys = new Set([
|
||||||
'finishReason',
|
'finishReason',
|
||||||
|
'assessment',
|
||||||
|
'cancelReason',
|
||||||
'kind',
|
'kind',
|
||||||
|
'lastResult',
|
||||||
'operation',
|
'operation',
|
||||||
|
'operatorAction',
|
||||||
'outcome',
|
'outcome',
|
||||||
|
'runStatus',
|
||||||
|
'severity',
|
||||||
'stage',
|
'stage',
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
@@ -178,11 +196,15 @@
|
|||||||
'accepted',
|
'accepted',
|
||||||
'active',
|
'active',
|
||||||
'admission',
|
'admission',
|
||||||
|
'attention_required',
|
||||||
'available',
|
'available',
|
||||||
'blocked',
|
'blocked',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
'completed',
|
'completed',
|
||||||
'completion',
|
'completion',
|
||||||
|
'converging',
|
||||||
|
'critical',
|
||||||
|
'clear',
|
||||||
'dispatch',
|
'dispatch',
|
||||||
'dispatching',
|
'dispatching',
|
||||||
'disabled',
|
'disabled',
|
||||||
@@ -191,12 +213,18 @@
|
|||||||
'failed',
|
'failed',
|
||||||
'finalization',
|
'finalization',
|
||||||
'installed',
|
'installed',
|
||||||
|
'inspect',
|
||||||
|
'invalid',
|
||||||
|
'identity_mismatch',
|
||||||
'local',
|
'local',
|
||||||
'lost',
|
'lost',
|
||||||
'missing',
|
'missing',
|
||||||
'model',
|
'model',
|
||||||
|
'none',
|
||||||
'not_found',
|
'not_found',
|
||||||
'pending',
|
'pending',
|
||||||
|
'pid_mismatch',
|
||||||
|
'policy',
|
||||||
'post_model',
|
'post_model',
|
||||||
'pre_model',
|
'pre_model',
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -204,6 +232,8 @@
|
|||||||
'queued',
|
'queued',
|
||||||
'ready',
|
'ready',
|
||||||
'recovery',
|
'recovery',
|
||||||
|
'rearm',
|
||||||
|
'reconcile',
|
||||||
'rejected',
|
'rejected',
|
||||||
'remote',
|
'remote',
|
||||||
'retained',
|
'retained',
|
||||||
@@ -217,13 +247,20 @@
|
|||||||
'step',
|
'step',
|
||||||
'stop',
|
'stop',
|
||||||
'succeeded',
|
'succeeded',
|
||||||
|
'shutdown',
|
||||||
'system',
|
'system',
|
||||||
'task',
|
'task',
|
||||||
'terminal',
|
'terminal',
|
||||||
'timed_out',
|
'timed_out',
|
||||||
|
'timeout',
|
||||||
'tool',
|
'tool',
|
||||||
'trigger',
|
'trigger',
|
||||||
'unknown',
|
'unknown',
|
||||||
|
'unsupported',
|
||||||
|
'user',
|
||||||
|
'wait',
|
||||||
|
'warning',
|
||||||
|
'ok',
|
||||||
'unavailable',
|
'unavailable',
|
||||||
'workflow',
|
'workflow',
|
||||||
]);
|
]);
|
||||||
@@ -232,7 +269,7 @@
|
|||||||
const freeTextKey =
|
const freeTextKey =
|
||||||
/text|content|stdout|stderr|command|input|output|environment|reason|error|message|description|name|path|url|uri|host|endpoint/iu;
|
/text|content|stdout|stderr|command|input|output|environment|reason|error|message|description|name|path|url|uri|host|endpoint/iu;
|
||||||
const numericKey =
|
const numericKey =
|
||||||
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
|
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|exitCode|pending|leased|retryWait|dispatched|blocked|due|expiredLease|identityMismatch|pidMismatch|unsupported|invalid|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
|
||||||
const schemaValue = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
const schemaValue = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
||||||
|
|
||||||
class ClusterConsoleEvidenceBundleError extends TypeError {
|
class ClusterConsoleEvidenceBundleError extends TypeError {
|
||||||
|
|||||||
@@ -24,7 +24,7 @@
|
|||||||
<div class="boundary" aria-label="当前权限边界">
|
<div class="boundary" aria-label="当前权限边界">
|
||||||
<span class="boundary-dot" aria-hidden="true"></span>
|
<span class="boundary-dot" aria-hidden="true"></span>
|
||||||
<span>本机只读 BFF</span>
|
<span>本机只读 BFF</span>
|
||||||
<strong>Run · Task · Workflow · Copilot</strong>
|
<strong>Run · Task · Workflow · Copilot · Optional management</strong>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -51,6 +51,7 @@
|
|||||||
|
|
||||||
<nav class="mode-tabs" aria-label="观察面">
|
<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 active" data-panel="runtime-panel" aria-pressed="true">运行态</button>
|
||||||
|
<button type="button" class="mode-tab" data-panel="management-panel" aria-pressed="false">取消可用性</button>
|
||||||
<button type="button" class="mode-tab" data-panel="workflow-panel" aria-pressed="false">工作流</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>
|
<button type="button" class="mode-tab" data-panel="copilot-panel" aria-pressed="false">Copilot</button>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -79,6 +80,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section id="management-panel" class="mode-panel" hidden>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-title"><span>01</span><strong>Project 状态</strong></div>
|
||||||
|
<button type="button" class="primary" data-read="run_cancellation_status">读取取消可用性</button>
|
||||||
|
<p class="field-note">只有启动进程显式提供独立 Run management 配置与短期 assertion 时可用;页面不会自动刷新。</p>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-title"><span>02</span><strong>Blocked Runs</strong></div>
|
||||||
|
<button type="button" data-read="run_cancellation_blocked_list">读取首屏 Blocked Runs</button>
|
||||||
|
<p class="field-note">固定 16 项快照页。下一页必须在证据条目中再次显式点击。</p>
|
||||||
|
</div>
|
||||||
|
<div class="control-group">
|
||||||
|
<div class="control-title"><span>03</span><strong>单 Run 诊断</strong></div>
|
||||||
|
<input id="cancellation-run-id" type="text" maxlength="128" autocomplete="off" spellcheck="false" placeholder="run-id" />
|
||||||
|
<button type="button" data-read="run_cancellation_inspect">读取取消诊断</button>
|
||||||
|
<p class="field-note">该只读面没有 rearm、stop、retry 或其他 mutation 入口。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section id="workflow-panel" class="mode-panel" hidden>
|
<section id="workflow-panel" class="mode-panel" hidden>
|
||||||
<label class="field-label" for="package-name">Package</label>
|
<label class="field-label" for="package-name">Package</label>
|
||||||
<input id="package-name" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="ops-package" />
|
<input id="package-name" type="text" maxlength="63" autocomplete="off" spellcheck="false" placeholder="ops-package" />
|
||||||
@@ -110,7 +130,7 @@
|
|||||||
|
|
||||||
<aside class="trust-note">
|
<aside class="trust-note">
|
||||||
<span>Authority boundary</span>
|
<span>Authority boundary</span>
|
||||||
<p>Cluster credential 只由本机进程从私有文件读取。浏览器无法提交任意路径,也没有 start、cancel 或 diagnose 权限入口。脱敏导出只处理本页已读事实,不补读或上传。</p>
|
<p>Project credential 与可选 Run management authority 只由本机进程从彼此独立的私有文件读取。浏览器无法提交任意路径,也没有 start、stop、retry、rearm 或 diagnose 权限入口。脱敏导出只处理本页已读事实,不补读或上传。</p>
|
||||||
</aside>
|
</aside>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
@@ -141,7 +161,7 @@
|
|||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<span>Loopback only · explicit reads · zero polling</span>
|
<span>Loopback only · explicit reads · zero polling</span>
|
||||||
<span>QingLong 3.0 incubation / D-330</span>
|
<span>QingLong 3.0 incubation / D-369</span>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -24,25 +24,25 @@ const ASSETS = Object.freeze([
|
|||||||
name: 'index.html',
|
name: 'index.html',
|
||||||
field: 'html',
|
field: 'html',
|
||||||
maximumBytes: 32 * 1024,
|
maximumBytes: 32 * 1024,
|
||||||
digest: '5d452c947a9f1266e4920cf48e7d5116b3f5ef8f9120f681124ed61f0217f5ff',
|
digest: 'a5a3d46a8493a27b53bd4a253ef38ebaf00d204a1454f4b47a1f1ceff668855f',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
name: 'app.css',
|
name: 'app.css',
|
||||||
field: 'css',
|
field: 'css',
|
||||||
maximumBytes: 64 * 1024,
|
maximumBytes: 64 * 1024,
|
||||||
digest: '5cf82b0a88920d106530603a7d407f852312138e5b7af5c422b3bccee785f144',
|
digest: 'ddfe85971df0b8acfaed8b4bb5f5bcdf679347106294987d928bbb82dc6610ec',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
name: 'evidence-bundle.js',
|
name: 'evidence-bundle.js',
|
||||||
field: 'evidenceBundle',
|
field: 'evidenceBundle',
|
||||||
maximumBytes: 32 * 1024,
|
maximumBytes: 32 * 1024,
|
||||||
digest: '6ecb14d2f59d872b889bb42c22bf0c0d2c150c90ea708fb1662d47f17f2e2095',
|
digest: '739ff786b651de23876fc5f4df5073e211085dfdfa1d2ecb79f53d5c871c6c1d',
|
||||||
}),
|
}),
|
||||||
Object.freeze({
|
Object.freeze({
|
||||||
name: 'app.js',
|
name: 'app.js',
|
||||||
field: 'javascript',
|
field: 'javascript',
|
||||||
maximumBytes: 32 * 1024,
|
maximumBytes: 32 * 1024,
|
||||||
digest: 'f109c5b0491ba9a473e3129e35773edf38ac745b403e1f547f8252aa2932cdff',
|
digest: '7ed994d8f2f5b151a247c5dec1d2841d45d30ff05b14dd1f41c12c5582acf9e6',
|
||||||
}),
|
}),
|
||||||
] as const);
|
] as const);
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
executeClusterCopilotCommand,
|
executeClusterCopilotCommand,
|
||||||
executeClusterProjectApiRead,
|
executeClusterProjectApiRead,
|
||||||
@@ -8,6 +10,20 @@ import {
|
|||||||
validateClusterCopilotClientCredentialFile,
|
validateClusterCopilotClientCredentialFile,
|
||||||
} from '../copilot-client/client';
|
} from '../copilot-client/client';
|
||||||
import { readCanonicalFile } from '../management-support/managementClientConfiguration';
|
import { readCanonicalFile } from '../management-support/managementClientConfiguration';
|
||||||
|
import { validateClusterAuthenticatedManagementClientConfiguration } from '../management-support/pluginPackageManagementClient';
|
||||||
|
import {
|
||||||
|
createRunCancellationBlockedListCommand,
|
||||||
|
projectRunCancellationBlockedList,
|
||||||
|
} from '../run-management/runCancellationBlockedList';
|
||||||
|
import {
|
||||||
|
createRunCancellationInspectionCommand,
|
||||||
|
projectRunCancellationInspection,
|
||||||
|
} from '../run-management/runCancellationInspection';
|
||||||
|
import {
|
||||||
|
createRunCancellationStatusCommand,
|
||||||
|
projectRunCancellationStatus,
|
||||||
|
} from '../run-management/runCancellationStatus';
|
||||||
|
import { executeClusterRunManagementCommand } from '../run-management/runManagementClient';
|
||||||
import { loadClusterCopilotConsoleAssets } from './assets';
|
import { loadClusterCopilotConsoleAssets } from './assets';
|
||||||
import {
|
import {
|
||||||
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
||||||
@@ -25,6 +41,7 @@ const USAGE = [
|
|||||||
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
||||||
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
||||||
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
|
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
|
||||||
|
' Optional Run reads: --run-management-config /absolute/run-client.json --run-management-assertion /absolute/assertion.jwt',
|
||||||
'',
|
'',
|
||||||
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
|
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
|
||||||
'The browser session key remains in a separate owner-private 0600 file.',
|
'The browser session key remains in a separate owner-private 0600 file.',
|
||||||
@@ -35,12 +52,21 @@ interface ClusterCopilotConsoleCliArguments {
|
|||||||
readonly configFile: string;
|
readonly configFile: string;
|
||||||
readonly credentialFile: string;
|
readonly credentialFile: string;
|
||||||
readonly networkBoundary: 'host-loopback' | 'container-published-loopback';
|
readonly networkBoundary: 'host-loopback' | 'container-published-loopback';
|
||||||
|
readonly runManagementAssertionFile?: string;
|
||||||
|
readonly runManagementConfigFile?: string;
|
||||||
readonly sessionFile: string;
|
readonly sessionFile: string;
|
||||||
readonly port: number;
|
readonly port: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
const SESSION_TOKEN = /^[A-Za-z0-9_-]{43}$/;
|
||||||
|
const MANAGEMENT_ASSERTION = /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+$/;
|
||||||
const MAXIMUM_SESSION_BYTES = 128;
|
const MAXIMUM_SESSION_BYTES = 128;
|
||||||
|
const MAXIMUM_MANAGEMENT_ASSERTION_BYTES = 16 * 1024;
|
||||||
|
const RUN_MANAGEMENT_OPERATIONS = new Set([
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
|
]);
|
||||||
|
|
||||||
function usageFailure(): never {
|
function usageFailure(): never {
|
||||||
process.stderr.write(USAGE + '\n');
|
process.stderr.write(USAGE + '\n');
|
||||||
@@ -77,6 +103,8 @@ export function parseClusterCopilotConsoleCliArguments(
|
|||||||
let configFile: string | undefined;
|
let configFile: string | undefined;
|
||||||
let credentialFile: string | undefined;
|
let credentialFile: string | undefined;
|
||||||
let sessionFile: string | undefined;
|
let sessionFile: string | undefined;
|
||||||
|
let runManagementConfigFile: string | undefined;
|
||||||
|
let runManagementAssertionFile: string | undefined;
|
||||||
let port = 0;
|
let port = 0;
|
||||||
let portSeen = false;
|
let portSeen = false;
|
||||||
let containerPublishedLoopback = false;
|
let containerPublishedLoopback = false;
|
||||||
@@ -116,6 +144,28 @@ export function parseClusterCopilotConsoleCliArguments(
|
|||||||
index += session.consumed;
|
index += session.consumed;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const runManagementConfig = argumentValue(
|
||||||
|
argv,
|
||||||
|
index,
|
||||||
|
'--run-management-config',
|
||||||
|
);
|
||||||
|
if (runManagementConfig) {
|
||||||
|
if (runManagementConfigFile !== undefined) return usageFailure();
|
||||||
|
runManagementConfigFile = runManagementConfig.value;
|
||||||
|
index += runManagementConfig.consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const runManagementAssertion = argumentValue(
|
||||||
|
argv,
|
||||||
|
index,
|
||||||
|
'--run-management-assertion',
|
||||||
|
);
|
||||||
|
if (runManagementAssertion) {
|
||||||
|
if (runManagementAssertionFile !== undefined) return usageFailure();
|
||||||
|
runManagementAssertionFile = runManagementAssertion.value;
|
||||||
|
index += runManagementAssertion.consumed;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
const portArgument = argumentValue(argv, index, '--port');
|
const portArgument = argumentValue(argv, index, '--port');
|
||||||
if (portArgument) {
|
if (portArgument) {
|
||||||
if (portSeen || !/^(?:0|[1-9][0-9]{0,4})$/.test(portArgument.value)) {
|
if (portSeen || !/^(?:0|[1-9][0-9]{0,4})$/.test(portArgument.value)) {
|
||||||
@@ -138,6 +188,8 @@ export function parseClusterCopilotConsoleCliArguments(
|
|||||||
configFile === undefined ||
|
configFile === undefined ||
|
||||||
credentialFile === undefined ||
|
credentialFile === undefined ||
|
||||||
sessionFile === undefined ||
|
sessionFile === undefined ||
|
||||||
|
(runManagementConfigFile === undefined) !==
|
||||||
|
(runManagementAssertionFile === undefined) ||
|
||||||
(containerPublishedLoopback && port === 0) ||
|
(containerPublishedLoopback && port === 0) ||
|
||||||
(!containerPublishedLoopback && check && port !== 0)
|
(!containerPublishedLoopback && check && port !== 0)
|
||||||
) {
|
) {
|
||||||
@@ -150,11 +202,128 @@ export function parseClusterCopilotConsoleCliArguments(
|
|||||||
networkBoundary: containerPublishedLoopback
|
networkBoundary: containerPublishedLoopback
|
||||||
? 'container-published-loopback'
|
? 'container-published-loopback'
|
||||||
: 'host-loopback',
|
: 'host-loopback',
|
||||||
|
...(runManagementConfigFile !== undefined &&
|
||||||
|
runManagementAssertionFile !== undefined
|
||||||
|
? { runManagementConfigFile, runManagementAssertionFile }
|
||||||
|
: {}),
|
||||||
sessionFile,
|
sessionFile,
|
||||||
port,
|
port,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function validateRunManagementAuthority(
|
||||||
|
parsed: Readonly<ClusterCopilotConsoleCliArguments>,
|
||||||
|
): boolean {
|
||||||
|
if (
|
||||||
|
parsed.runManagementConfigFile === undefined ||
|
||||||
|
parsed.runManagementAssertionFile === undefined
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
validateClusterAuthenticatedManagementClientConfiguration(
|
||||||
|
parsed.runManagementConfigFile,
|
||||||
|
'run',
|
||||||
|
);
|
||||||
|
let bytes: Buffer | undefined;
|
||||||
|
try {
|
||||||
|
bytes = readCanonicalFile(
|
||||||
|
parsed.runManagementAssertionFile,
|
||||||
|
MAXIMUM_MANAGEMENT_ASSERTION_BYTES,
|
||||||
|
'private',
|
||||||
|
);
|
||||||
|
if (
|
||||||
|
bytes.some((byte) => byte > 0x7f) ||
|
||||||
|
!MANAGEMENT_ASSERTION.test(bytes.toString('ascii'))
|
||||||
|
) {
|
||||||
|
throw new Error('invalid Run management assertion');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} finally {
|
||||||
|
bytes?.fill(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function availableOperations(runManagementAuthority: boolean) {
|
||||||
|
return runManagementAuthority
|
||||||
|
? CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS
|
||||||
|
: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS.filter(
|
||||||
|
(operation) => !RUN_MANAGEMENT_OPERATIONS.has(operation),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandIdSource(requestId: string): () => string {
|
||||||
|
let first = true;
|
||||||
|
return () => {
|
||||||
|
if (first) {
|
||||||
|
first = false;
|
||||||
|
return requestId;
|
||||||
|
}
|
||||||
|
return randomUUID();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function executeConsoleRead(
|
||||||
|
request: Readonly<ClusterCopilotConsoleReadRequest>,
|
||||||
|
parsed: Readonly<ClusterCopilotConsoleCliArguments>,
|
||||||
|
) {
|
||||||
|
if (request.operation === 'inspect' || request.operation === 'output') {
|
||||||
|
return executeClusterCopilotCommand({
|
||||||
|
configFile: parsed.configFile,
|
||||||
|
credentialFile: parsed.credentialFile,
|
||||||
|
command: clusterCopilotConsoleClientCommand(request),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
request.operation === 'run_cancellation_status' ||
|
||||||
|
request.operation === 'run_cancellation_blocked_list' ||
|
||||||
|
request.operation === 'run_cancellation_inspect'
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
parsed.runManagementConfigFile === undefined ||
|
||||||
|
parsed.runManagementAssertionFile === undefined
|
||||||
|
) {
|
||||||
|
throw new Error('Run management authority is disabled');
|
||||||
|
}
|
||||||
|
const createUuid = commandIdSource(request.requestId);
|
||||||
|
const command =
|
||||||
|
request.operation === 'run_cancellation_status'
|
||||||
|
? createRunCancellationStatusCommand(request.projectId, createUuid)
|
||||||
|
: request.operation === 'run_cancellation_blocked_list'
|
||||||
|
? createRunCancellationBlockedListCommand(
|
||||||
|
request.projectId,
|
||||||
|
request.cursor ?? undefined,
|
||||||
|
createUuid,
|
||||||
|
)
|
||||||
|
: createRunCancellationInspectionCommand(
|
||||||
|
request.projectId,
|
||||||
|
request.runId,
|
||||||
|
createUuid,
|
||||||
|
);
|
||||||
|
const result = await executeClusterRunManagementCommand({
|
||||||
|
configFile: parsed.runManagementConfigFile,
|
||||||
|
assertionFile: parsed.runManagementAssertionFile,
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
const projected =
|
||||||
|
request.operation === 'run_cancellation_status'
|
||||||
|
? projectRunCancellationStatus(result)
|
||||||
|
: request.operation === 'run_cancellation_blocked_list'
|
||||||
|
? projectRunCancellationBlockedList(result)
|
||||||
|
: projectRunCancellationInspection(result);
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1 as const,
|
||||||
|
requestId: result.requestId,
|
||||||
|
result: projected as unknown as Readonly<Record<string, unknown>>,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return executeClusterProjectApiRead({
|
||||||
|
configFile: parsed.configFile,
|
||||||
|
credentialFile: parsed.credentialFile,
|
||||||
|
path: clusterCopilotConsoleProjectReadPath(request),
|
||||||
|
requestId: request.requestId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function readSessionDigest(sessionFile: string): Buffer {
|
function readSessionDigest(sessionFile: string): Buffer {
|
||||||
let bytes: Buffer | undefined;
|
let bytes: Buffer | undefined;
|
||||||
try {
|
try {
|
||||||
@@ -183,6 +352,8 @@ async function main(): Promise<void> {
|
|||||||
const assets = loadClusterCopilotConsoleAssets(__dirname);
|
const assets = loadClusterCopilotConsoleAssets(__dirname);
|
||||||
validateClusterCopilotClientConfiguration(parsed.configFile);
|
validateClusterCopilotClientConfiguration(parsed.configFile);
|
||||||
validateClusterCopilotClientCredentialFile(parsed.credentialFile);
|
validateClusterCopilotClientCredentialFile(parsed.credentialFile);
|
||||||
|
const runManagementAuthority = validateRunManagementAuthority(parsed);
|
||||||
|
const operations = availableOperations(runManagementAuthority);
|
||||||
const sessionDigest = readSessionDigest(parsed.sessionFile);
|
const sessionDigest = readSessionDigest(parsed.sessionFile);
|
||||||
if (parsed.check) {
|
if (parsed.check) {
|
||||||
try {
|
try {
|
||||||
@@ -199,7 +370,10 @@ async function main(): Promise<void> {
|
|||||||
publishedHostAddress: '127.0.0.1',
|
publishedHostAddress: '127.0.0.1',
|
||||||
browserCredential: 'forbidden',
|
browserCredential: 'forbidden',
|
||||||
clusterCredential: 'server_only',
|
clusterCredential: 'server_only',
|
||||||
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
runManagementAuthority: runManagementAuthority
|
||||||
|
? 'server_only'
|
||||||
|
: 'disabled',
|
||||||
|
operations,
|
||||||
mutation: false,
|
mutation: false,
|
||||||
}) + '\n',
|
}) + '\n',
|
||||||
);
|
);
|
||||||
@@ -214,19 +388,7 @@ async function main(): Promise<void> {
|
|||||||
assets,
|
assets,
|
||||||
executor: Object.freeze({
|
executor: Object.freeze({
|
||||||
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
|
execute(request: Readonly<ClusterCopilotConsoleReadRequest>) {
|
||||||
if (request.operation === 'inspect' || request.operation === 'output') {
|
return executeConsoleRead(request, parsed);
|
||||||
return executeClusterCopilotCommand({
|
|
||||||
configFile: parsed.configFile,
|
|
||||||
credentialFile: parsed.credentialFile,
|
|
||||||
command: clusterCopilotConsoleClientCommand(request),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return executeClusterProjectApiRead({
|
|
||||||
configFile: parsed.configFile,
|
|
||||||
credentialFile: parsed.credentialFile,
|
|
||||||
path: clusterCopilotConsoleProjectReadPath(request),
|
|
||||||
requestId: request.requestId,
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
networkBoundary: parsed.networkBoundary,
|
networkBoundary: parsed.networkBoundary,
|
||||||
@@ -244,7 +406,10 @@ async function main(): Promise<void> {
|
|||||||
publishedHostAddress: '127.0.0.1',
|
publishedHostAddress: '127.0.0.1',
|
||||||
browserCredential: 'forbidden',
|
browserCredential: 'forbidden',
|
||||||
clusterCredential: 'server_only',
|
clusterCredential: 'server_only',
|
||||||
operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS,
|
runManagementAuthority: runManagementAuthority
|
||||||
|
? 'server_only'
|
||||||
|
: 'disabled',
|
||||||
|
operations,
|
||||||
mutation: false,
|
mutation: false,
|
||||||
}) + '\n',
|
}) + '\n',
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -11,6 +11,9 @@ export const CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA =
|
|||||||
export const CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS = Object.freeze([
|
export const CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS = Object.freeze([
|
||||||
'inspect',
|
'inspect',
|
||||||
'output',
|
'output',
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
'run_list',
|
'run_list',
|
||||||
'run_read',
|
'run_read',
|
||||||
'run_event_list',
|
'run_event_list',
|
||||||
@@ -39,6 +42,10 @@ interface BaseReadRequest<
|
|||||||
export type ClusterCopilotConsoleReadRequest =
|
export type ClusterCopilotConsoleReadRequest =
|
||||||
| (BaseReadRequest<'inspect'> & Readonly<{ sourceRunId: string }>)
|
| (BaseReadRequest<'inspect'> & Readonly<{ sourceRunId: string }>)
|
||||||
| (BaseReadRequest<'output'> & Readonly<{ sourceRunId: string }>)
|
| (BaseReadRequest<'output'> & Readonly<{ sourceRunId: string }>)
|
||||||
|
| BaseReadRequest<'run_cancellation_status'>
|
||||||
|
| (BaseReadRequest<'run_cancellation_blocked_list'> &
|
||||||
|
Readonly<{ cursor: string | null }>)
|
||||||
|
| (BaseReadRequest<'run_cancellation_inspect'> & Readonly<{ runId: string }>)
|
||||||
| (BaseReadRequest<'run_list'> &
|
| (BaseReadRequest<'run_list'> &
|
||||||
Readonly<{
|
Readonly<{
|
||||||
afterCreatedAtMs: number | null;
|
afterCreatedAtMs: number | null;
|
||||||
@@ -98,6 +105,7 @@ export class InvalidClusterCopilotConsoleReadRequestError extends TypeError {
|
|||||||
|
|
||||||
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
const IDENTITY = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
||||||
const COPILOT_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 RUN_CANCELLATION_CURSOR = /^v1\.[A-Za-z0-9_-]{1,512}$/;
|
||||||
const PACKAGE_NAME = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
|
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 WORKFLOW_ID = /^[a-z][a-z0-9-]{0,62}$/;
|
||||||
const UUID_V4 =
|
const UUID_V4 =
|
||||||
@@ -205,6 +213,36 @@ export function normalizeClusterCopilotConsoleReadRequest(
|
|||||||
sourceRunId: record.sourceRunId,
|
sourceRunId: record.sourceRunId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
if (op === 'run_cancellation_status') {
|
||||||
|
exact(record, op, []);
|
||||||
|
return Object.freeze({
|
||||||
|
...common(record),
|
||||||
|
operation: op,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (op === 'run_cancellation_blocked_list') {
|
||||||
|
exact(record, op, ['cursor']);
|
||||||
|
if (
|
||||||
|
record.cursor !== null &&
|
||||||
|
(typeof record.cursor !== 'string' ||
|
||||||
|
!RUN_CANCELLATION_CURSOR.test(record.cursor))
|
||||||
|
)
|
||||||
|
invalid();
|
||||||
|
return Object.freeze({
|
||||||
|
...common(record),
|
||||||
|
operation: op,
|
||||||
|
cursor: record.cursor as string | null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (op === 'run_cancellation_inspect') {
|
||||||
|
exact(record, op, ['runId']);
|
||||||
|
if (!identifier(record.runId)) invalid();
|
||||||
|
return Object.freeze({
|
||||||
|
...common(record),
|
||||||
|
operation: op,
|
||||||
|
runId: record.runId,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (op === 'run_list') {
|
if (op === 'run_list') {
|
||||||
exact(record, op, ['afterCreatedAtMs', 'afterRunId', 'limit']);
|
exact(record, op, ['afterCreatedAtMs', 'afterRunId', 'limit']);
|
||||||
if (
|
if (
|
||||||
@@ -433,7 +471,13 @@ export function clusterCopilotConsoleProjectReadPath(
|
|||||||
request: Readonly<ClusterCopilotConsoleReadRequest>,
|
request: Readonly<ClusterCopilotConsoleReadRequest>,
|
||||||
): string {
|
): string {
|
||||||
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
|
const normalized = normalizeClusterCopilotConsoleReadRequest(request);
|
||||||
if (normalized.operation === 'inspect' || normalized.operation === 'output')
|
if (
|
||||||
|
normalized.operation === 'inspect' ||
|
||||||
|
normalized.operation === 'output' ||
|
||||||
|
normalized.operation === 'run_cancellation_status' ||
|
||||||
|
normalized.operation === 'run_cancellation_blocked_list' ||
|
||||||
|
normalized.operation === 'run_cancellation_inspect'
|
||||||
|
)
|
||||||
invalid();
|
invalid();
|
||||||
const project = '/api/v3/projects/' + encoded(normalized.projectId);
|
const project = '/api/v3/projects/' + encoded(normalized.projectId);
|
||||||
if (normalized.operation === 'run_list') {
|
if (normalized.operation === 'run_list') {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ const LIMITS = Object.freeze({
|
|||||||
const OPERATIONS = Object.freeze([
|
const OPERATIONS = Object.freeze([
|
||||||
'inspect',
|
'inspect',
|
||||||
'output',
|
'output',
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
'run_list',
|
'run_list',
|
||||||
'run_read',
|
'run_read',
|
||||||
'run_event_list',
|
'run_event_list',
|
||||||
@@ -48,6 +51,17 @@ const REQUEST_FIELDS: Readonly<Record<EvidenceOperation, readonly string[]>> =
|
|||||||
Object.freeze({
|
Object.freeze({
|
||||||
inspect: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
inspect: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
||||||
output: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
output: Object.freeze(['projectId', 'requestId', 'sourceRunId']),
|
||||||
|
run_cancellation_status: Object.freeze(['projectId', 'requestId']),
|
||||||
|
run_cancellation_blocked_list: Object.freeze([
|
||||||
|
'cursor',
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
]),
|
||||||
|
run_cancellation_inspect: Object.freeze([
|
||||||
|
'projectId',
|
||||||
|
'requestId',
|
||||||
|
'runId',
|
||||||
|
]),
|
||||||
run_list: Object.freeze([
|
run_list: Object.freeze([
|
||||||
'afterCreatedAtMs',
|
'afterCreatedAtMs',
|
||||||
'afterRunId',
|
'afterRunId',
|
||||||
@@ -121,7 +135,9 @@ const IDENTIFIER_DOMAINS: Readonly<Record<string, string>> = Object.freeze({
|
|||||||
afterStepRunId: 'step',
|
afterStepRunId: 'step',
|
||||||
afterTaskId: 'task',
|
afterTaskId: 'task',
|
||||||
artifactId: 'artifact',
|
artifactId: 'artifact',
|
||||||
|
attemptId: 'attempt',
|
||||||
contentDigest: 'digest',
|
contentDigest: 'digest',
|
||||||
|
cursor: 'cursor',
|
||||||
diagnosisRunId: 'run',
|
diagnosisRunId: 'run',
|
||||||
executionId: 'execution',
|
executionId: 'execution',
|
||||||
id: 'identifier',
|
id: 'identifier',
|
||||||
@@ -142,7 +158,10 @@ const IDENTIFIER_DOMAINS: Readonly<Record<string, string>> = Object.freeze({
|
|||||||
});
|
});
|
||||||
const SAFE_CONTAINERS = new Set([
|
const SAFE_CONTAINERS = new Set([
|
||||||
'attempts',
|
'attempts',
|
||||||
|
'blockingResults',
|
||||||
'counts',
|
'counts',
|
||||||
|
'dispatch',
|
||||||
|
'dispatches',
|
||||||
'events',
|
'events',
|
||||||
'items',
|
'items',
|
||||||
'metadata',
|
'metadata',
|
||||||
@@ -151,6 +170,7 @@ const SAFE_CONTAINERS = new Set([
|
|||||||
'run',
|
'run',
|
||||||
'runs',
|
'runs',
|
||||||
'source',
|
'source',
|
||||||
|
'signals',
|
||||||
'step',
|
'step',
|
||||||
'steps',
|
'steps',
|
||||||
'summary',
|
'summary',
|
||||||
@@ -176,10 +196,16 @@ const SAFE_BOOLEANS = new Set([
|
|||||||
'truncated',
|
'truncated',
|
||||||
]);
|
]);
|
||||||
const SAFE_ENUM_KEYS = new Set([
|
const SAFE_ENUM_KEYS = new Set([
|
||||||
|
'assessment',
|
||||||
|
'cancelReason',
|
||||||
'finishReason',
|
'finishReason',
|
||||||
'kind',
|
'kind',
|
||||||
|
'lastResult',
|
||||||
'operation',
|
'operation',
|
||||||
|
'operatorAction',
|
||||||
'outcome',
|
'outcome',
|
||||||
|
'runStatus',
|
||||||
|
'severity',
|
||||||
'stage',
|
'stage',
|
||||||
'status',
|
'status',
|
||||||
]);
|
]);
|
||||||
@@ -187,11 +213,15 @@ const SAFE_ENUM_VALUES = new Set([
|
|||||||
'accepted',
|
'accepted',
|
||||||
'active',
|
'active',
|
||||||
'admission',
|
'admission',
|
||||||
|
'attention_required',
|
||||||
'available',
|
'available',
|
||||||
'blocked',
|
'blocked',
|
||||||
'cancelled',
|
'cancelled',
|
||||||
'completed',
|
'completed',
|
||||||
'completion',
|
'completion',
|
||||||
|
'converging',
|
||||||
|
'critical',
|
||||||
|
'clear',
|
||||||
'dispatch',
|
'dispatch',
|
||||||
'dispatching',
|
'dispatching',
|
||||||
'disabled',
|
'disabled',
|
||||||
@@ -200,12 +230,18 @@ const SAFE_ENUM_VALUES = new Set([
|
|||||||
'failed',
|
'failed',
|
||||||
'finalization',
|
'finalization',
|
||||||
'installed',
|
'installed',
|
||||||
|
'inspect',
|
||||||
|
'invalid',
|
||||||
|
'identity_mismatch',
|
||||||
'local',
|
'local',
|
||||||
'lost',
|
'lost',
|
||||||
'missing',
|
'missing',
|
||||||
'model',
|
'model',
|
||||||
|
'none',
|
||||||
'not_found',
|
'not_found',
|
||||||
'pending',
|
'pending',
|
||||||
|
'pid_mismatch',
|
||||||
|
'policy',
|
||||||
'post_model',
|
'post_model',
|
||||||
'pre_model',
|
'pre_model',
|
||||||
'prompt',
|
'prompt',
|
||||||
@@ -213,6 +249,8 @@ const SAFE_ENUM_VALUES = new Set([
|
|||||||
'queued',
|
'queued',
|
||||||
'ready',
|
'ready',
|
||||||
'recovery',
|
'recovery',
|
||||||
|
'rearm',
|
||||||
|
'reconcile',
|
||||||
'rejected',
|
'rejected',
|
||||||
'remote',
|
'remote',
|
||||||
'retained',
|
'retained',
|
||||||
@@ -226,18 +264,25 @@ const SAFE_ENUM_VALUES = new Set([
|
|||||||
'step',
|
'step',
|
||||||
'stop',
|
'stop',
|
||||||
'succeeded',
|
'succeeded',
|
||||||
|
'shutdown',
|
||||||
'system',
|
'system',
|
||||||
'task',
|
'task',
|
||||||
'terminal',
|
'terminal',
|
||||||
'timed_out',
|
'timed_out',
|
||||||
|
'timeout',
|
||||||
'tool',
|
'tool',
|
||||||
'trigger',
|
'trigger',
|
||||||
'unknown',
|
'unknown',
|
||||||
|
'unsupported',
|
||||||
|
'user',
|
||||||
|
'wait',
|
||||||
|
'warning',
|
||||||
|
'ok',
|
||||||
'unavailable',
|
'unavailable',
|
||||||
'workflow',
|
'workflow',
|
||||||
]);
|
]);
|
||||||
const NUMERIC_KEY =
|
const NUMERIC_KEY =
|
||||||
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
|
/^(?:schemaVersion|version|revision|sequence|attempt|priority|limit|offset|size|total|count|exitCode|pending|leased|retryWait|dispatched|blocked|due|expiredLease|identityMismatch|pidMismatch|unsupported|invalid|[A-Za-z0-9_]*(?:AtMs|TimeMs|DurationMs|Bytes|Tokens|Micros|Sequence|Version|Count|Limit|Offset|Size|Total))$/u;
|
||||||
const SCHEMA_VALUE = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
const SCHEMA_VALUE = /^[a-z0-9][a-z0-9./_-]{0,126}@[a-z0-9._-]{1,16}$/u;
|
||||||
const SHA256 = /^[0-9a-f]{64}$/u;
|
const SHA256 = /^[0-9a-f]{64}$/u;
|
||||||
const CONTROL = /[\0-\x1f\x7f]/u;
|
const CONTROL = /[\0-\x1f\x7f]/u;
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ import {
|
|||||||
ClusterCopilotClientRemoteError,
|
ClusterCopilotClientRemoteError,
|
||||||
ClusterCopilotClientRequestError,
|
ClusterCopilotClientRequestError,
|
||||||
} from '../copilot-client/client';
|
} from '../copilot-client/client';
|
||||||
|
import {
|
||||||
|
ClusterPluginPackageManagementClientConfigurationError,
|
||||||
|
ClusterPluginPackageManagementClientRemoteError,
|
||||||
|
ClusterPluginPackageManagementClientRequestError,
|
||||||
|
} from '../management-support/pluginPackageManagementClient';
|
||||||
import { type ClusterCopilotConsoleAssets } from './assets';
|
import { type ClusterCopilotConsoleAssets } from './assets';
|
||||||
import {
|
import {
|
||||||
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
CLUSTER_COPILOT_CONSOLE_READ_RESPONSE_SCHEMA,
|
||||||
@@ -186,6 +191,10 @@ const READ_ROUTES: Readonly<
|
|||||||
> = Object.freeze({
|
> = Object.freeze({
|
||||||
'/api/v1/copilot/inspect': 'inspect',
|
'/api/v1/copilot/inspect': 'inspect',
|
||||||
'/api/v1/copilot/output': 'output',
|
'/api/v1/copilot/output': 'output',
|
||||||
|
'/api/v1/run-management/cancellation-status': 'run_cancellation_status',
|
||||||
|
'/api/v1/run-management/blocked-cancellations':
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'/api/v1/run-management/cancellation-inspect': 'run_cancellation_inspect',
|
||||||
'/api/v1/observe/run-list': 'run_list',
|
'/api/v1/observe/run-list': 'run_list',
|
||||||
'/api/v1/observe/run': 'run_read',
|
'/api/v1/observe/run': 'run_read',
|
||||||
'/api/v1/observe/run-events': 'run_event_list',
|
'/api/v1/observe/run-events': 'run_event_list',
|
||||||
@@ -294,7 +303,9 @@ async function readJsonBody(request: IncomingMessage): Promise<unknown> {
|
|||||||
|
|
||||||
function remoteFailure(
|
function remoteFailure(
|
||||||
response: ServerResponse,
|
response: ServerResponse,
|
||||||
error: ClusterCopilotClientRemoteError,
|
error:
|
||||||
|
| ClusterCopilotClientRemoteError
|
||||||
|
| ClusterPluginPackageManagementClientRemoteError,
|
||||||
): void {
|
): void {
|
||||||
const statusCode =
|
const statusCode =
|
||||||
error.statusCode === 404 ? 404 : error.statusCode === 429 ? 429 : 502;
|
error.statusCode === 404 ? 404 : error.statusCode === 429 ? 429 : 502;
|
||||||
@@ -457,11 +468,17 @@ export async function startClusterCopilotConsoleServer(
|
|||||||
code: 'invalid_cluster_copilot_console_read_request',
|
code: 'invalid_cluster_copilot_console_read_request',
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} else if (error instanceof ClusterCopilotClientRemoteError) {
|
} else if (
|
||||||
|
error instanceof ClusterCopilotClientRemoteError ||
|
||||||
|
error instanceof ClusterPluginPackageManagementClientRemoteError
|
||||||
|
) {
|
||||||
remoteFailure(response, error);
|
remoteFailure(response, error);
|
||||||
} else if (
|
} else if (
|
||||||
error instanceof ClusterCopilotClientConfigurationError ||
|
error instanceof ClusterCopilotClientConfigurationError ||
|
||||||
error instanceof ClusterCopilotClientRequestError
|
error instanceof ClusterCopilotClientRequestError ||
|
||||||
|
error instanceof
|
||||||
|
ClusterPluginPackageManagementClientConfigurationError ||
|
||||||
|
error instanceof ClusterPluginPackageManagementClientRequestError
|
||||||
) {
|
) {
|
||||||
sendJson(
|
sendJson(
|
||||||
response,
|
response,
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
|
import type { ClusterRunManagementClientResult } from './runManagementClient';
|
||||||
|
import {
|
||||||
|
RUN_CANCELLATION_DISPATCH_INSPECT_REQUEST_SCHEMA,
|
||||||
|
normalizeClusterRunManagementCommand,
|
||||||
|
type ClusterRunManagementCancellationInspectCommand,
|
||||||
|
type ClusterRunManagementCancellationInspectTransportResult,
|
||||||
|
} from './runManagementTransport';
|
||||||
|
|
||||||
|
export const RUN_CANCELLATION_INSPECTION_SCHEMA =
|
||||||
|
'qinglong/run-cancellation-inspection@v1' as const;
|
||||||
|
|
||||||
|
type CancellationDiagnostic =
|
||||||
|
ClusterRunManagementCancellationInspectTransportResult['diagnostic'];
|
||||||
|
|
||||||
|
export type RunCancellationInspectionObservation = Readonly<
|
||||||
|
{
|
||||||
|
schemaVersion: 1;
|
||||||
|
schema: typeof RUN_CANCELLATION_INSPECTION_SCHEMA;
|
||||||
|
component: 'qinglong3-run-management-client';
|
||||||
|
event: 'cancellation_inspected';
|
||||||
|
requestId: string;
|
||||||
|
} & Omit<CancellationDiagnostic, 'schema'>
|
||||||
|
>;
|
||||||
|
|
||||||
|
export function createRunCancellationInspectionCommand(
|
||||||
|
projectId: string,
|
||||||
|
runId: string,
|
||||||
|
createUuid: () => string = randomUUID,
|
||||||
|
): Readonly<ClusterRunManagementCancellationInspectCommand> {
|
||||||
|
const requestId = createUuid();
|
||||||
|
const auditEventId = createUuid();
|
||||||
|
let failureAuditEventId = createUuid();
|
||||||
|
for (
|
||||||
|
let attempts = 0;
|
||||||
|
failureAuditEventId === auditEventId && attempts < 3;
|
||||||
|
attempts += 1
|
||||||
|
) {
|
||||||
|
failureAuditEventId = createUuid();
|
||||||
|
}
|
||||||
|
return normalizeClusterRunManagementCommand({
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'run.cancellation.inspect',
|
||||||
|
request: {
|
||||||
|
projectId,
|
||||||
|
runId,
|
||||||
|
requestId,
|
||||||
|
auditEventId,
|
||||||
|
failureAuditEventId,
|
||||||
|
body: { schema: RUN_CANCELLATION_DISPATCH_INSPECT_REQUEST_SCHEMA },
|
||||||
|
},
|
||||||
|
}) as Readonly<ClusterRunManagementCancellationInspectCommand>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function projectRunCancellationInspection(
|
||||||
|
result: Readonly<ClusterRunManagementClientResult>,
|
||||||
|
): Readonly<RunCancellationInspectionObservation> {
|
||||||
|
if (result.result.operation !== 'run.cancellation.inspect') {
|
||||||
|
throw new TypeError(
|
||||||
|
'Run cancellation inspection requires an inspect result',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const { schema: _schema, ...diagnostic } = result.result.diagnostic;
|
||||||
|
return Object.freeze({
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: RUN_CANCELLATION_INSPECTION_SCHEMA,
|
||||||
|
component: 'qinglong3-run-management-client',
|
||||||
|
event: 'cancellation_inspected',
|
||||||
|
requestId: result.requestId,
|
||||||
|
...diagnostic,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -224,6 +224,30 @@ test('normalizes Copilot and fixed Project observation operations without arbitr
|
|||||||
}),
|
}),
|
||||||
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
|
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
|
||||||
);
|
);
|
||||||
|
assert.deepEqual(
|
||||||
|
normalizeClusterCopilotConsoleReadRequest({
|
||||||
|
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-read-status',
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-read-status',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
clusterCopilotConsoleProjectReadPath({
|
||||||
|
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-read-status',
|
||||||
|
}),
|
||||||
|
{ code: 'QL3_CLUSTER_COPILOT_CONSOLE_READ_REQUEST_INVALID' },
|
||||||
|
);
|
||||||
assert.throws(
|
assert.throws(
|
||||||
() =>
|
() =>
|
||||||
normalizeClusterCopilotConsoleReadRequest({
|
normalizeClusterCopilotConsoleReadRequest({
|
||||||
@@ -527,6 +551,72 @@ test('forwards one exact bounded Run list read and exposes no path field', async
|
|||||||
assert.equal(Object.hasOwn(requests[0], 'url'), false);
|
assert.equal(Object.hasOwn(requests[0], 'url'), false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('routes only the three fixed Run management reads and validates their cursors', async (t) => {
|
||||||
|
const reads = [];
|
||||||
|
const { server, headers } = await fixture(async (read) => {
|
||||||
|
reads.push(read);
|
||||||
|
return {
|
||||||
|
schemaVersion: 1,
|
||||||
|
requestId: read.requestId,
|
||||||
|
result: { operation: read.operation },
|
||||||
|
};
|
||||||
|
});
|
||||||
|
t.after(() => server.close());
|
||||||
|
const cases = [
|
||||||
|
[
|
||||||
|
'/api/v1/run-management/cancellation-status',
|
||||||
|
{
|
||||||
|
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-status-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/api/v1/run-management/blocked-cancellations',
|
||||||
|
{
|
||||||
|
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
|
operation: 'run_cancellation_blocked_list',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-blocked-1',
|
||||||
|
cursor: null,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'/api/v1/run-management/cancellation-inspect',
|
||||||
|
{
|
||||||
|
schema: CLUSTER_COPILOT_CONSOLE_READ_REQUEST_SCHEMA,
|
||||||
|
operation: 'run_cancellation_inspect',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-inspect-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
];
|
||||||
|
for (const [path, body] of cases) {
|
||||||
|
const response = await request(server.origin, {
|
||||||
|
method: 'POST',
|
||||||
|
path,
|
||||||
|
headers,
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
}
|
||||||
|
assert.deepEqual(
|
||||||
|
reads,
|
||||||
|
cases.map(([, body]) => body),
|
||||||
|
);
|
||||||
|
|
||||||
|
const invalidCursor = await request(server.origin, {
|
||||||
|
method: 'POST',
|
||||||
|
path: '/api/v1/run-management/blocked-cancellations',
|
||||||
|
headers,
|
||||||
|
body: { ...cases[1][1], cursor: 'opaque-unversioned' },
|
||||||
|
});
|
||||||
|
assert.equal(invalidCursor.statusCode, 400);
|
||||||
|
assert.equal(reads.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('returns model text as JSON data only after an explicit output read', async (t) => {
|
test('returns model text as JSON data only after an explicit output read', async (t) => {
|
||||||
const { server, headers } = await fixture(async (command) => {
|
const { server, headers } = await fixture(async (command) => {
|
||||||
assert.equal(command.operation, 'output');
|
assert.equal(command.operation, 'output');
|
||||||
|
|||||||
@@ -34,6 +34,11 @@ const consoleOperations = [
|
|||||||
'workflow_event_list',
|
'workflow_event_list',
|
||||||
'workflow_step_list',
|
'workflow_step_list',
|
||||||
];
|
];
|
||||||
|
const runManagementOperations = [
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
|
];
|
||||||
|
|
||||||
function privateFile(directory, name, contents) {
|
function privateFile(directory, name, contents) {
|
||||||
const filePath = path.join(directory, name);
|
const filePath = path.join(directory, name);
|
||||||
@@ -106,6 +111,41 @@ function get(origin) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function post(origin, token, path, body) {
|
||||||
|
const url = new URL(origin);
|
||||||
|
const bytes = Buffer.from(JSON.stringify(body), 'utf8');
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const request = httpRequest(
|
||||||
|
{
|
||||||
|
hostname: '127.0.0.1',
|
||||||
|
port: Number(url.port),
|
||||||
|
method: 'POST',
|
||||||
|
path,
|
||||||
|
agent: false,
|
||||||
|
headers: {
|
||||||
|
authorization: `QL3-Console ${token}`,
|
||||||
|
origin,
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-length': String(bytes.byteLength),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
(response) => {
|
||||||
|
const chunks = [];
|
||||||
|
response.on('data', (chunk) => chunks.push(chunk));
|
||||||
|
response.on('end', () => {
|
||||||
|
const text = Buffer.concat(chunks).toString('utf8');
|
||||||
|
resolve({
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
body: JSON.parse(text),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
request.once('error', reject);
|
||||||
|
request.end(bytes);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function fixture(t) {
|
async function fixture(t) {
|
||||||
const directory = fs.realpathSync(
|
const directory = fs.realpathSync(
|
||||||
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-console-cli-')),
|
fs.mkdtempSync(path.join(os.tmpdir(), 'ql3-copilot-console-cli-')),
|
||||||
@@ -120,18 +160,61 @@ async function fixture(t) {
|
|||||||
maxVersion: 'TLSv1.3',
|
maxVersion: 'TLSv1.3',
|
||||||
},
|
},
|
||||||
(request, response) => {
|
(request, response) => {
|
||||||
requests.push({
|
const chunks = [];
|
||||||
method: request.method,
|
request.on('data', (chunk) => chunks.push(chunk));
|
||||||
path: request.url,
|
request.on('end', () => {
|
||||||
authorization: request.headers.authorization,
|
const command =
|
||||||
tls: request.socket.getProtocol(),
|
chunks.length === 0
|
||||||
|
? null
|
||||||
|
: JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
||||||
|
requests.push({
|
||||||
|
method: request.method,
|
||||||
|
path: request.url,
|
||||||
|
authorization: request.headers.authorization,
|
||||||
|
tls: request.socket.getProtocol(),
|
||||||
|
command,
|
||||||
|
});
|
||||||
|
const body =
|
||||||
|
command?.operation === 'run.cancellation.summary'
|
||||||
|
? {
|
||||||
|
schemaVersion: 1,
|
||||||
|
requestId: command.request.requestId,
|
||||||
|
result: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'run.cancellation.summary',
|
||||||
|
summary: {
|
||||||
|
schema: 'qinglong/run-cancellation-dispatch-summary@v1',
|
||||||
|
projectId: command.request.projectId,
|
||||||
|
observedAtMs: 1_700_000_000_000,
|
||||||
|
assessment: 'attention_required',
|
||||||
|
operatorAction: 'inspect',
|
||||||
|
dispatches: {
|
||||||
|
total: 1,
|
||||||
|
pending: 0,
|
||||||
|
leased: 0,
|
||||||
|
retryWait: 0,
|
||||||
|
dispatched: 0,
|
||||||
|
blocked: 1,
|
||||||
|
},
|
||||||
|
signals: { due: 0, expiredLease: 0 },
|
||||||
|
blockingResults: {
|
||||||
|
identityMismatch: 1,
|
||||||
|
pidMismatch: 0,
|
||||||
|
unsupported: 0,
|
||||||
|
invalid: 0,
|
||||||
|
},
|
||||||
|
oldestBlockedAtMs: 1_699_999_999_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: { status: 'ready' };
|
||||||
|
const bytes = Buffer.from(JSON.stringify(body), 'utf8');
|
||||||
|
response.writeHead(200, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-length': String(bytes.byteLength),
|
||||||
|
});
|
||||||
|
response.end(bytes);
|
||||||
});
|
});
|
||||||
const bytes = Buffer.from('{"status":"ready"}', 'utf8');
|
|
||||||
response.writeHead(200, {
|
|
||||||
'content-type': 'application/json; charset=utf-8',
|
|
||||||
'content-length': String(bytes.byteLength),
|
|
||||||
});
|
|
||||||
response.end(bytes);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
await new Promise((resolve, reject) => {
|
await new Promise((resolve, reject) => {
|
||||||
@@ -160,14 +243,43 @@ async function fixture(t) {
|
|||||||
requestTimeoutMs: 2_000,
|
requestTimeoutMs: 2_000,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
const clientCertificateFile = privateFile(
|
||||||
|
directory,
|
||||||
|
'run-client.crt',
|
||||||
|
fs.readFileSync(path.join(tlsFixture, 'client-cert.pem')),
|
||||||
|
);
|
||||||
|
const clientPrivateKeyFile = privateFile(
|
||||||
|
directory,
|
||||||
|
'run-client.key',
|
||||||
|
fs.readFileSync(path.join(tlsFixture, 'client-key.pem')),
|
||||||
|
);
|
||||||
|
const runManagementConfigFile = privateFile(
|
||||||
|
directory,
|
||||||
|
'run-client.json',
|
||||||
|
JSON.stringify({
|
||||||
|
schemaVersion: 1,
|
||||||
|
endpoint: `https://localhost:${
|
||||||
|
server.address().port
|
||||||
|
}/api/v3/runs/management`,
|
||||||
|
servername: 'localhost',
|
||||||
|
caFile,
|
||||||
|
clientCertificateFile,
|
||||||
|
clientPrivateKeyFile,
|
||||||
|
requestTimeoutMs: 2_000,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const sessionToken = randomBytes(32).toString('base64url');
|
||||||
return {
|
return {
|
||||||
requests,
|
requests,
|
||||||
configFile,
|
configFile,
|
||||||
credentialFile: privateFile(directory, 'credential', credential),
|
credentialFile: privateFile(directory, 'credential', credential),
|
||||||
sessionFile: privateFile(
|
sessionFile: privateFile(directory, 'session', sessionToken),
|
||||||
|
sessionToken,
|
||||||
|
runManagementConfigFile,
|
||||||
|
runManagementAssertionFile: privateFile(
|
||||||
directory,
|
directory,
|
||||||
'session',
|
'run-assertion.jwt',
|
||||||
randomBytes(32).toString('base64url'),
|
'eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJvcGVyYXRvci0xIn0.c2lnbmF0dXJl',
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -178,6 +290,7 @@ test('CLI exposes deterministic help and a low-sensitive failure surface', async
|
|||||||
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
' ql3-copilot-console --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--port=0..65535]',
|
||||||
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
' ql3-copilot-console --check --config /absolute/client.json --credential /absolute/credential --session /absolute/session',
|
||||||
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
|
' ql3-copilot-console --container-published-loopback --port=1024..65535 --config /absolute/client.json --credential /absolute/credential --session /absolute/session [--check]',
|
||||||
|
' Optional Run reads: --run-management-config /absolute/run-client.json --run-management-assertion /absolute/assertion.jwt',
|
||||||
'',
|
'',
|
||||||
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
|
'Native mode binds 127.0.0.1. Container mode requires host-loopback port publication.',
|
||||||
'The browser session key remains in a separate owner-private 0600 file.',
|
'The browser session key remains in a separate owner-private 0600 file.',
|
||||||
@@ -231,6 +344,7 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
|
|||||||
publishedHostAddress: '127.0.0.1',
|
publishedHostAddress: '127.0.0.1',
|
||||||
browserCredential: 'forbidden',
|
browserCredential: 'forbidden',
|
||||||
clusterCredential: 'server_only',
|
clusterCredential: 'server_only',
|
||||||
|
runManagementAuthority: 'disabled',
|
||||||
operations: consoleOperations,
|
operations: consoleOperations,
|
||||||
mutation: false,
|
mutation: false,
|
||||||
});
|
});
|
||||||
@@ -240,10 +354,52 @@ test('preflight proves private authority and unauthenticated TLS 1.3 readiness',
|
|||||||
path: '/readyz',
|
path: '/readyz',
|
||||||
authorization: undefined,
|
authorization: undefined,
|
||||||
tls: 'TLSv1.3',
|
tls: 'TLSv1.3',
|
||||||
|
command: null,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preflight enables exactly three optional Run management reads only when both private files are explicit', async (t) => {
|
||||||
|
const value = await fixture(t);
|
||||||
|
const result = await runCli([
|
||||||
|
'--check',
|
||||||
|
'--config',
|
||||||
|
value.configFile,
|
||||||
|
'--credential',
|
||||||
|
value.credentialFile,
|
||||||
|
'--session',
|
||||||
|
value.sessionFile,
|
||||||
|
'--run-management-config',
|
||||||
|
value.runManagementConfigFile,
|
||||||
|
'--run-management-assertion',
|
||||||
|
value.runManagementAssertionFile,
|
||||||
|
]);
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const fact = JSON.parse(result.stdout);
|
||||||
|
assert.equal(fact.runManagementAuthority, 'server_only');
|
||||||
|
assert.deepEqual(fact.operations, [
|
||||||
|
'inspect',
|
||||||
|
'output',
|
||||||
|
...runManagementOperations,
|
||||||
|
...consoleOperations.slice(2),
|
||||||
|
]);
|
||||||
|
assert.equal(fact.mutation, false);
|
||||||
|
assert.equal(value.requests.length, 1);
|
||||||
|
|
||||||
|
const incomplete = await runCli([
|
||||||
|
'--config',
|
||||||
|
value.configFile,
|
||||||
|
'--credential',
|
||||||
|
value.credentialFile,
|
||||||
|
'--session',
|
||||||
|
value.sessionFile,
|
||||||
|
'--run-management-config',
|
||||||
|
value.runManagementConfigFile,
|
||||||
|
]);
|
||||||
|
assert.equal(incomplete.status, 64);
|
||||||
|
assert.doesNotMatch(incomplete.stderr, /ql3-copilot-console-cli-/);
|
||||||
|
});
|
||||||
|
|
||||||
test('serve mode starts an ephemeral loopback origin and shuts down cleanly', async (t) => {
|
test('serve mode starts an ephemeral loopback origin and shuts down cleanly', async (t) => {
|
||||||
const value = await fixture(t);
|
const value = await fixture(t);
|
||||||
const child = spawn(
|
const child = spawn(
|
||||||
@@ -269,6 +425,7 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
|
|||||||
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
|
assert.match(started.origin, /^http:\/\/127\.0\.0\.1:[0-9]+$/);
|
||||||
assert.deepEqual(started.operations, consoleOperations);
|
assert.deepEqual(started.operations, consoleOperations);
|
||||||
assert.equal(started.mutation, false);
|
assert.equal(started.mutation, false);
|
||||||
|
assert.equal(started.runManagementAuthority, 'disabled');
|
||||||
assert.equal(started.networkBoundary, 'host-loopback');
|
assert.equal(started.networkBoundary, 'host-loopback');
|
||||||
assert.equal(started.publishedHostAddress, '127.0.0.1');
|
assert.equal(started.publishedHostAddress, '127.0.0.1');
|
||||||
const shell = await get(started.origin);
|
const shell = await get(started.origin);
|
||||||
@@ -282,6 +439,66 @@ test('serve mode starts an ephemeral loopback origin and shuts down cleanly', as
|
|||||||
assert.deepEqual(result, { status: 0, signal: null });
|
assert.deepEqual(result, { status: 0, signal: null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('serve mode forwards one explicit status click through the optional mTLS Run authority', async (t) => {
|
||||||
|
const value = await fixture(t);
|
||||||
|
const child = spawn(
|
||||||
|
process.execPath,
|
||||||
|
[
|
||||||
|
cliPath,
|
||||||
|
'--config',
|
||||||
|
value.configFile,
|
||||||
|
'--credential',
|
||||||
|
value.credentialFile,
|
||||||
|
'--session',
|
||||||
|
value.sessionFile,
|
||||||
|
'--run-management-config',
|
||||||
|
value.runManagementConfigFile,
|
||||||
|
'--run-management-assertion',
|
||||||
|
value.runManagementAssertionFile,
|
||||||
|
'--port=0',
|
||||||
|
],
|
||||||
|
{ cwd: packageRoot, stdio: ['ignore', 'pipe', 'pipe'] },
|
||||||
|
);
|
||||||
|
t.after(() => {
|
||||||
|
if (child.exitCode === null && child.signalCode === null)
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
});
|
||||||
|
const started = JSON.parse(await firstLine(child.stdout));
|
||||||
|
assert.equal(started.runManagementAuthority, 'server_only');
|
||||||
|
const response = await post(
|
||||||
|
started.origin,
|
||||||
|
value.sessionToken,
|
||||||
|
'/api/v1/run-management/cancellation-status',
|
||||||
|
{
|
||||||
|
schema: 'qinglong/cluster-copilot-console-read-request@v1',
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
projectId: 'project-main',
|
||||||
|
requestId: 'console-status-1',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
assert.equal(response.statusCode, 200);
|
||||||
|
assert.equal(
|
||||||
|
response.body.result.result.schema,
|
||||||
|
'qinglong/run-cancellation-status@v1',
|
||||||
|
);
|
||||||
|
assert.equal(response.body.result.result.assessment, 'attention_required');
|
||||||
|
assert.equal(response.body.result.result.operatorAction, 'inspect');
|
||||||
|
assert.equal(response.body.result.result.dispatches.blocked, 1);
|
||||||
|
const management = value.requests.find(
|
||||||
|
(request) => request.path === '/api/v3/runs/management',
|
||||||
|
);
|
||||||
|
assert.equal(management.method, 'POST');
|
||||||
|
assert.equal(management.command.operation, 'run.cancellation.summary');
|
||||||
|
assert.equal(management.command.request.requestId, 'console-status-1');
|
||||||
|
assert.match(management.authorization, /^Bearer [A-Za-z0-9_-]+\./);
|
||||||
|
child.kill('SIGTERM');
|
||||||
|
const exit = await new Promise((resolve, reject) => {
|
||||||
|
child.once('error', reject);
|
||||||
|
child.once('close', (status, signal) => resolve({ status, signal }));
|
||||||
|
});
|
||||||
|
assert.deepEqual(exit, { status: 0, signal: null });
|
||||||
|
});
|
||||||
|
|
||||||
test('container mode requires an explicit publish port before any authority read', async () => {
|
test('container mode requires an explicit publish port before any authority read', async () => {
|
||||||
const result = await runCli([
|
const result = await runCli([
|
||||||
'--container-published-loopback',
|
'--container-published-loopback',
|
||||||
|
|||||||
@@ -175,6 +175,64 @@ test('resets the undisclosed alias table for every bundle', async () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('redacts optional Run management observations while preserving fixed availability facts', async () => {
|
||||||
|
const bundle = await createClusterConsoleEvidenceBundle(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
observedAtMs: 1_700_000_003_000,
|
||||||
|
request: {
|
||||||
|
schema: requestSchema,
|
||||||
|
operation: 'run_cancellation_status',
|
||||||
|
projectId: 'project-sensitive',
|
||||||
|
requestId: 'console-request-sensitive',
|
||||||
|
},
|
||||||
|
fact: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
schema: 'qinglong/run-cancellation-status@v1',
|
||||||
|
component: 'qinglong3-run-management-client',
|
||||||
|
event: 'cancellation_status_observed',
|
||||||
|
requestId: 'console-request-sensitive',
|
||||||
|
projectId: 'project-sensitive',
|
||||||
|
observedAtMs: 1_700_000_003_000,
|
||||||
|
assessment: 'attention_required',
|
||||||
|
operatorAction: 'inspect',
|
||||||
|
severity: 'critical',
|
||||||
|
exitCode: 20,
|
||||||
|
dispatches: {
|
||||||
|
total: 1,
|
||||||
|
pending: 0,
|
||||||
|
leased: 0,
|
||||||
|
retryWait: 0,
|
||||||
|
dispatched: 0,
|
||||||
|
blocked: 1,
|
||||||
|
},
|
||||||
|
signals: { due: 0, expiredLease: 0 },
|
||||||
|
blockingResults: {
|
||||||
|
identityMismatch: 1,
|
||||||
|
pidMismatch: 0,
|
||||||
|
unsupported: 0,
|
||||||
|
invalid: 0,
|
||||||
|
},
|
||||||
|
oldestBlockedAtMs: 1_700_000_002_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
1_700_000_004_000,
|
||||||
|
webcrypto,
|
||||||
|
);
|
||||||
|
const entry = bundle.entries[0];
|
||||||
|
assert.equal(entry.operation, 'run_cancellation_status');
|
||||||
|
assert.equal(entry.target.projectId, 'project-001');
|
||||||
|
assert.equal(entry.fact.projectId, 'project-001');
|
||||||
|
assert.equal(entry.fact.assessment, 'attention_required');
|
||||||
|
assert.equal(entry.fact.operatorAction, 'inspect');
|
||||||
|
assert.equal(entry.fact.dispatches.blocked, 1);
|
||||||
|
assert.equal(entry.fact.blockingResults.identityMismatch, 1);
|
||||||
|
assert.equal(entry.fact.component, undefined);
|
||||||
|
assert.doesNotMatch(JSON.stringify(bundle), /project-sensitive/);
|
||||||
|
});
|
||||||
|
|
||||||
test('fails closed on widened records, unsafe JSON and every capacity ceiling', async () => {
|
test('fails closed on widened records, unsafe JSON and every capacity ceiling', async () => {
|
||||||
const error = { code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID' };
|
const error = { code: 'QL3_CLUSTER_CONSOLE_EVIDENCE_BUNDLE_INVALID' };
|
||||||
assert.throws(() => measureClusterConsoleEvidenceRecord(null), error);
|
assert.throws(() => measureClusterConsoleEvidenceRecord(null), error);
|
||||||
|
|||||||
@@ -123,6 +123,20 @@ test('cross-verifies every fixed Console read operation', async (t) => {
|
|||||||
const requests = {
|
const requests = {
|
||||||
inspect: { projectId: 'p-1', requestId: 'q-1', sourceRunId: 'r-1' },
|
inspect: { projectId: 'p-1', requestId: 'q-1', sourceRunId: 'r-1' },
|
||||||
output: { projectId: 'p-2', requestId: 'q-2', sourceRunId: 'r-2' },
|
output: { projectId: 'p-2', requestId: 'q-2', sourceRunId: 'r-2' },
|
||||||
|
run_cancellation_status: {
|
||||||
|
projectId: 'p-management',
|
||||||
|
requestId: 'q-management-status',
|
||||||
|
},
|
||||||
|
run_cancellation_blocked_list: {
|
||||||
|
cursor: null,
|
||||||
|
projectId: 'p-management',
|
||||||
|
requestId: 'q-management-blocked',
|
||||||
|
},
|
||||||
|
run_cancellation_inspect: {
|
||||||
|
projectId: 'p-management',
|
||||||
|
requestId: 'q-management-inspect',
|
||||||
|
runId: 'r-management',
|
||||||
|
},
|
||||||
run_list: {
|
run_list: {
|
||||||
afterCreatedAtMs: null,
|
afterCreatedAtMs: null,
|
||||||
afterRunId: null,
|
afterRunId: null,
|
||||||
@@ -218,7 +232,7 @@ test('cross-verifies every fixed Console read operation', async (t) => {
|
|||||||
);
|
);
|
||||||
const result = verifyClusterConsoleEvidenceBundleFile(filePath);
|
const result = verifyClusterConsoleEvidenceBundleFile(filePath);
|
||||||
assert.equal(result.status, 'verified');
|
assert.equal(result.status, 'verified');
|
||||||
assert.equal(result.bundle.entryCount, 13);
|
assert.equal(result.bundle.entryCount, 16);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('CLI is secret-free on success, invalid input and usage errors', async (t) => {
|
test('CLI is secret-free on success, invalid input and usage errors', async (t) => {
|
||||||
|
|||||||
@@ -0,0 +1,93 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const test = require('node:test');
|
||||||
|
|
||||||
|
const {
|
||||||
|
createRunCancellationInspectionCommand,
|
||||||
|
projectRunCancellationInspection,
|
||||||
|
} = require('../dist/run-management/runCancellationInspection.js');
|
||||||
|
|
||||||
|
const uuids = [
|
||||||
|
'console-request-1',
|
||||||
|
'019f9400-0000-4000-8000-000000000001',
|
||||||
|
'019f9400-0000-4000-8000-000000000002',
|
||||||
|
];
|
||||||
|
|
||||||
|
test('creates one read-only cancellation inspection command with caller-bound request identity', () => {
|
||||||
|
let index = 0;
|
||||||
|
const command = createRunCancellationInspectionCommand(
|
||||||
|
'project-1',
|
||||||
|
'run-1',
|
||||||
|
() => uuids[index++],
|
||||||
|
);
|
||||||
|
assert.deepEqual(command, {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'run.cancellation.inspect',
|
||||||
|
request: {
|
||||||
|
projectId: 'project-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
requestId: 'console-request-1',
|
||||||
|
auditEventId: '019f9400-0000-4000-8000-000000000001',
|
||||||
|
failureAuditEventId: '019f9400-0000-4000-8000-000000000002',
|
||||||
|
body: {
|
||||||
|
schema: 'qinglong/run-cancellation-dispatch-inspect@v1',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('projects a validated diagnostic without transport-only nesting', () => {
|
||||||
|
const observation = projectRunCancellationInspection({
|
||||||
|
schemaVersion: 1,
|
||||||
|
requestId: 'console-request-1',
|
||||||
|
result: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'run.cancellation.inspect',
|
||||||
|
diagnostic: {
|
||||||
|
schema: 'qinglong/run-cancellation-dispatch-diagnostic@v1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
runId: 'run-1',
|
||||||
|
runStatus: 'running',
|
||||||
|
runVersion: 7,
|
||||||
|
eventSequence: 9,
|
||||||
|
cancelRequestedAtMs: 1_700_000_000_000,
|
||||||
|
cancelReason: 'user',
|
||||||
|
operatorAction: 'rearm',
|
||||||
|
dispatch: {
|
||||||
|
attemptId: 'attempt-1',
|
||||||
|
status: 'blocked',
|
||||||
|
version: 3,
|
||||||
|
dispatchCount: 2,
|
||||||
|
lastResult: 'identity_mismatch',
|
||||||
|
createdAtMs: 1_699_999_990_000,
|
||||||
|
updatedAtMs: 1_700_000_000_000,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(observation.schema, 'qinglong/run-cancellation-inspection@v1');
|
||||||
|
assert.equal(observation.requestId, 'console-request-1');
|
||||||
|
assert.equal(observation.projectId, 'project-1');
|
||||||
|
assert.equal(observation.runId, 'run-1');
|
||||||
|
assert.equal(observation.operatorAction, 'rearm');
|
||||||
|
assert.equal(observation.dispatch.lastResult, 'identity_mismatch');
|
||||||
|
assert.equal(Object.isFrozen(observation), true);
|
||||||
|
assert.equal(Object.hasOwn(observation, 'diagnostic'), false);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('rejects a non-inspection result before projection', () => {
|
||||||
|
assert.throws(
|
||||||
|
() =>
|
||||||
|
projectRunCancellationInspection({
|
||||||
|
schemaVersion: 1,
|
||||||
|
requestId: 'console-request-1',
|
||||||
|
result: {
|
||||||
|
schemaVersion: 1,
|
||||||
|
operation: 'run.cancellation.summary',
|
||||||
|
summary: {},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
/requires an inspect result/,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -425,7 +425,7 @@ const { statSync, writeFileSync } = require('node:fs');
|
|||||||
const { rootCertificates } = require('node:tls');
|
const { rootCertificates } = require('node:tls');
|
||||||
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
|
const facade = '/opt/qinglong/node_modules/@qinglong/cluster-admin/dist/product-cli/cli.js';
|
||||||
const share = '/opt/qinglong/share/ql3-copilot-console';
|
const share = '/opt/qinglong/share/ql3-copilot-console';
|
||||||
for (const [file, mode] of [['docker-loopback.sh', 0o555], ['verify-release.sh', 0o555], ['README.md', 0o444], ['client-config.example.json', 0o444], ['host-environment.example.json', 0o444]]) {
|
for (const [file, mode] of [['docker-loopback.sh', 0o555], ['verify-release.sh', 0o555], ['README.md', 0o444], ['client-config.example.json', 0o444], ['run-management-client-config.example.json', 0o444], ['host-environment.example.json', 0o444]]) {
|
||||||
if ((statSync(share + '/' + file).mode & 0o777) !== mode) process.exit(51);
|
if ((statSync(share + '/' + file).mode & 0o777) !== mode) process.exit(51);
|
||||||
}
|
}
|
||||||
writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 });
|
writeFileSync('/tmp/ca.pem', rootCertificates[0], { mode: 0o600 });
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const REQUIRED_FILES = Object.freeze([
|
|||||||
CONSOLE_ROOT + '/evidenceVerifier.ts',
|
CONSOLE_ROOT + '/evidenceVerifier.ts',
|
||||||
CONSOLE_ROOT + '/evidenceVerifierCli.ts',
|
CONSOLE_ROOT + '/evidenceVerifierCli.ts',
|
||||||
CONSOLE_ROOT + '/server.ts',
|
CONSOLE_ROOT + '/server.ts',
|
||||||
|
'packages/ql3-cluster-admin/src/run-management/runCancellationInspection.ts',
|
||||||
CLIENT_FILE,
|
CLIENT_FILE,
|
||||||
ASSET_ROOT + '/index.html',
|
ASSET_ROOT + '/index.html',
|
||||||
ASSET_ROOT + '/app.css',
|
ASSET_ROOT + '/app.css',
|
||||||
@@ -21,6 +22,7 @@ const REQUIRED_FILES = Object.freeze([
|
|||||||
ASSET_ROOT + '/app.js',
|
ASSET_ROOT + '/app.js',
|
||||||
DEPLOYMENT_ROOT + '/README.md',
|
DEPLOYMENT_ROOT + '/README.md',
|
||||||
DEPLOYMENT_ROOT + '/client-config.example.json',
|
DEPLOYMENT_ROOT + '/client-config.example.json',
|
||||||
|
DEPLOYMENT_ROOT + '/run-management-client-config.example.json',
|
||||||
'deploy/containers/ql3-cluster-admin/Dockerfile',
|
'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||||
'scripts/ql3-cluster-admin-product-live-contract.cjs',
|
'scripts/ql3-cluster-admin-product-live-contract.cjs',
|
||||||
]);
|
]);
|
||||||
@@ -103,6 +105,9 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'clusterCopilotConsoleClientCommand',
|
'clusterCopilotConsoleClientCommand',
|
||||||
'clusterCopilotConsoleProjectReadPath',
|
'clusterCopilotConsoleProjectReadPath',
|
||||||
"'run_event_list'",
|
"'run_event_list'",
|
||||||
|
"'run_cancellation_status'",
|
||||||
|
"'run_cancellation_blocked_list'",
|
||||||
|
"'run_cancellation_inspect'",
|
||||||
"'task_read'",
|
"'task_read'",
|
||||||
"'workflow_step_list'",
|
"'workflow_step_list'",
|
||||||
]);
|
]);
|
||||||
@@ -130,6 +135,7 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
"request.headers.host !== expectedOrigin.slice('http://'.length)",
|
"request.headers.host !== expectedOrigin.slice('http://'.length)",
|
||||||
'maximumConcurrentRequests: 2',
|
'maximumConcurrentRequests: 2',
|
||||||
"'/api/v1/copilot/inspect': 'inspect'",
|
"'/api/v1/copilot/inspect': 'inspect'",
|
||||||
|
"'/api/v1/run-management/cancellation-status': 'run_cancellation_status'",
|
||||||
"'/api/v1/observe/run-list': 'run_list'",
|
"'/api/v1/observe/run-list': 'run_list'",
|
||||||
"'/api/v1/observe/task-list': 'task_list'",
|
"'/api/v1/observe/task-list': 'task_list'",
|
||||||
"'/api/v1/observe/workflow-list': 'workflow_list'",
|
"'/api/v1/observe/workflow-list': 'workflow_list'",
|
||||||
@@ -144,27 +150,29 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'WebSocket',
|
'WebSocket',
|
||||||
'set-cookie',
|
'set-cookie',
|
||||||
'diagnose',
|
'diagnose',
|
||||||
'cancel',
|
'run.cancellation.rearm',
|
||||||
'child_process',
|
'child_process',
|
||||||
'node:fs',
|
'node:fs',
|
||||||
'node:net',
|
'node:net',
|
||||||
]);
|
]);
|
||||||
expectFragments(CONSOLE_ROOT + '/cli.ts', [
|
expectFragments(CONSOLE_ROOT + '/cli.ts', [
|
||||||
'--session /absolute/session',
|
'--session /absolute/session',
|
||||||
|
'--run-management-config /absolute/run-client.json',
|
||||||
|
'--run-management-assertion /absolute/assertion.jwt',
|
||||||
'readCanonicalFile(',
|
'readCanonicalFile(',
|
||||||
"'private'",
|
"'private'",
|
||||||
'validateClusterCopilotClientCredentialFile',
|
'validateClusterCopilotClientCredentialFile',
|
||||||
"clusterCredential: 'server_only'",
|
"clusterCredential: 'server_only'",
|
||||||
'networkBoundary: parsed.networkBoundary',
|
'networkBoundary: parsed.networkBoundary',
|
||||||
"publishedHostAddress: '127.0.0.1'",
|
"publishedHostAddress: '127.0.0.1'",
|
||||||
'operations: CLUSTER_COPILOT_CONSOLE_READ_OPERATIONS',
|
'runManagementAuthority: runManagementAuthority',
|
||||||
'mutation: false',
|
'mutation: false',
|
||||||
]);
|
]);
|
||||||
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
|
rejectFragments(CONSOLE_ROOT + '/cli.ts', [
|
||||||
'process.env',
|
'process.env',
|
||||||
'0.0.0.0',
|
'0.0.0.0',
|
||||||
'diagnose',
|
'diagnose',
|
||||||
'cancel',
|
"operation: 'run.cancellation.rearm'",
|
||||||
]);
|
]);
|
||||||
expectFragments(CONSOLE_ROOT + '/evidenceVerifier.ts', [
|
expectFragments(CONSOLE_ROOT + '/evidenceVerifier.ts', [
|
||||||
'qinglong/cluster-console-evidence-verification@v1',
|
'qinglong/cluster-console-evidence-verification@v1',
|
||||||
@@ -214,6 +222,9 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'读取 Run 列表',
|
'读取 Run 列表',
|
||||||
'读取 Workflow Runs',
|
'读取 Workflow Runs',
|
||||||
'显式读取诊断内容',
|
'显式读取诊断内容',
|
||||||
|
'读取取消可用性',
|
||||||
|
'读取首屏 Blocked Runs',
|
||||||
|
'该只读面没有 rearm',
|
||||||
'模型文本是不可信内容',
|
'模型文本是不可信内容',
|
||||||
'导出脱敏包',
|
'导出脱敏包',
|
||||||
'/evidence-bundle.js',
|
'/evidence-bundle.js',
|
||||||
@@ -237,7 +248,7 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'WebSocket',
|
'WebSocket',
|
||||||
'EventSource',
|
'EventSource',
|
||||||
'diagnose',
|
'diagnose',
|
||||||
'cancel',
|
'run.cancellation.rearm',
|
||||||
'http://',
|
'http://',
|
||||||
'https://',
|
'https://',
|
||||||
'navigator.',
|
'navigator.',
|
||||||
@@ -280,6 +291,8 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
'Do not deploy it as a Kubernetes workload',
|
'Do not deploy it as a Kubernetes workload',
|
||||||
'Run, Task, Workflow',
|
'Run, Task, Workflow',
|
||||||
'thirteen exact operations',
|
'thirteen exact operations',
|
||||||
|
'available vocabulary to sixteen',
|
||||||
|
'QL3_COPILOT_CONSOLE_RUN_MANAGEMENT=enabled',
|
||||||
'--port=0',
|
'--port=0',
|
||||||
'TLS 1.3 `GET /readyz`',
|
'TLS 1.3 `GET /readyz`',
|
||||||
'excluded from small router Edge/Standalone artifacts',
|
'excluded from small router Edge/Standalone artifacts',
|
||||||
@@ -423,6 +436,9 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
operations: Object.freeze([
|
operations: Object.freeze([
|
||||||
'inspect',
|
'inspect',
|
||||||
'output',
|
'output',
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
'run_list',
|
'run_list',
|
||||||
'run_read',
|
'run_read',
|
||||||
'run_event_list',
|
'run_event_list',
|
||||||
@@ -456,7 +472,7 @@ function auditClusterCopilotConsole(options = {}) {
|
|||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
fileWrites: false,
|
fileWrites: false,
|
||||||
}),
|
}),
|
||||||
sourceFileCount: 6,
|
sourceFileCount: 7,
|
||||||
findings: Object.freeze(findings),
|
findings: Object.freeze(findings),
|
||||||
compatible: findings.length === 0,
|
compatible: findings.length === 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ const FILES = Object.freeze({
|
|||||||
'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs',
|
'scripts/ql3-cluster-admin-release-workstation-ceremony-audit.cjs',
|
||||||
environment:
|
environment:
|
||||||
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
'deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||||
|
runManagementExample:
|
||||||
|
'deploy/console/ql3-cluster-copilot/run-management-client-config.example.json',
|
||||||
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
image: 'deploy/containers/ql3-cluster-admin/Dockerfile',
|
||||||
workflow: '.github/workflows/ql3-image-release.yml',
|
workflow: '.github/workflows/ql3-image-release.yml',
|
||||||
candidate: 'scripts/ql3-release-candidate-contract.cjs',
|
candidate: 'scripts/ql3-release-candidate-contract.cjs',
|
||||||
@@ -82,6 +84,9 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
|||||||
'memory=192m',
|
'memory=192m',
|
||||||
'standard)',
|
'standard)',
|
||||||
'memory=512m',
|
'memory=512m',
|
||||||
|
'QL3_COPILOT_CONSOLE_RUN_MANAGEMENT-disabled',
|
||||||
|
'--run-management-config /var/run/secrets/qinglong3/copilot-console/run-management-client.json',
|
||||||
|
'--run-management-assertion /var/run/secrets/qinglong3/copilot-console/run-management-assertion.jwt',
|
||||||
],
|
],
|
||||||
'QL3_COPILOT_CONSOLE_LAUNCHER_CONTRACT_DRIFT',
|
'QL3_COPILOT_CONSOLE_LAUNCHER_CONTRACT_DRIFT',
|
||||||
);
|
);
|
||||||
@@ -200,6 +205,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
|||||||
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-copilot-console-egress',
|
QL3_COPILOT_CONSOLE_NETWORK: 'qinglong3-copilot-console-egress',
|
||||||
QL3_COPILOT_CONSOLE_PORT: '5701',
|
QL3_COPILOT_CONSOLE_PORT: '5701',
|
||||||
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
|
QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'compact',
|
||||||
|
QL3_COPILOT_CONSOLE_RUN_MANAGEMENT: 'disabled',
|
||||||
};
|
};
|
||||||
if (
|
if (
|
||||||
environment &&
|
environment &&
|
||||||
@@ -223,6 +229,8 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
|||||||
'share/ql3-copilot-console/verify-release.sh',
|
'share/ql3-copilot-console/verify-release.sh',
|
||||||
'COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
'COPY --chmod=0444 deploy/console/ql3-cluster-copilot/host-environment.example.json',
|
||||||
'share/ql3-copilot-console/host-environment.example.json',
|
'share/ql3-copilot-console/host-environment.example.json',
|
||||||
|
'COPY --chmod=0444 deploy/console/ql3-cluster-copilot/run-management-client-config.example.json',
|
||||||
|
'share/ql3-copilot-console/run-management-client-config.example.json',
|
||||||
],
|
],
|
||||||
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
|
'QL3_COPILOT_CONSOLE_IMAGE_DISTRIBUTION_DRIFT',
|
||||||
);
|
);
|
||||||
@@ -315,6 +323,7 @@ function auditClusterCopilotConsoleDistribution(options = {}) {
|
|||||||
hostPublication: '127.0.0.1',
|
hostPublication: '127.0.0.1',
|
||||||
kubernetesResident: false,
|
kubernetesResident: false,
|
||||||
additionalWorkspacePackages: 0,
|
additionalWorkspacePackages: 0,
|
||||||
|
runManagementAuthorityDefault: 'disabled',
|
||||||
externalWorkstationCeremony: 'source-tag-private-report',
|
externalWorkstationCeremony: 'source-tag-private-report',
|
||||||
ceremonyStatus: 'implementation-ready-public-release-pending',
|
ceremonyStatus: 'implementation-ready-public-release-pending',
|
||||||
findings: Object.freeze(findings),
|
findings: Object.freeze(findings),
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
|
|||||||
operations: [
|
operations: [
|
||||||
'inspect',
|
'inspect',
|
||||||
'output',
|
'output',
|
||||||
|
'run_cancellation_status',
|
||||||
|
'run_cancellation_blocked_list',
|
||||||
|
'run_cancellation_inspect',
|
||||||
'run_list',
|
'run_list',
|
||||||
'run_read',
|
'run_read',
|
||||||
'run_event_list',
|
'run_event_list',
|
||||||
@@ -61,7 +64,7 @@ test('keeps the QingLong 3.0 Copilot Console independent and read-only', () => {
|
|||||||
networkAccess: false,
|
networkAccess: false,
|
||||||
fileWrites: false,
|
fileWrites: false,
|
||||||
},
|
},
|
||||||
sourceFileCount: 6,
|
sourceFileCount: 7,
|
||||||
findings: [],
|
findings: [],
|
||||||
compatible: true,
|
compatible: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ test('accepts the signed multi-architecture Admin OCI workstation distribution',
|
|||||||
hostPublication: '127.0.0.1',
|
hostPublication: '127.0.0.1',
|
||||||
kubernetesResident: false,
|
kubernetesResident: false,
|
||||||
additionalWorkspacePackages: 0,
|
additionalWorkspacePackages: 0,
|
||||||
|
runManagementAuthorityDefault: 'disabled',
|
||||||
externalWorkstationCeremony: 'source-tag-private-report',
|
externalWorkstationCeremony: 'source-tag-private-report',
|
||||||
ceremonyStatus: 'implementation-ready-public-release-pending',
|
ceremonyStatus: 'implementation-ready-public-release-pending',
|
||||||
findings: [],
|
findings: [],
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ const launcher = path.join(
|
|||||||
ROOT,
|
ROOT,
|
||||||
'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
'deploy/console/ql3-cluster-copilot/docker-loopback.sh',
|
||||||
);
|
);
|
||||||
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'a'.repeat(64)}`;
|
const image = `ghcr.io/example/qinglong3-cluster-admin@sha256:${'a'.repeat(
|
||||||
|
64,
|
||||||
|
)}`;
|
||||||
|
|
||||||
function fixture(t) {
|
function fixture(t) {
|
||||||
const directory = fs.realpathSync(
|
const directory = fs.realpathSync(
|
||||||
@@ -114,9 +116,24 @@ test('publishes standard serve only on host loopback', (t) => {
|
|||||||
assert.equal(args[args.indexOf('--memory') + 1], '512m');
|
assert.equal(args[args.indexOf('--memory') + 1], '512m');
|
||||||
assert.equal(args[args.indexOf('--cpus') + 1], '1');
|
assert.equal(args[args.indexOf('--cpus') + 1], '1');
|
||||||
assert.equal(args[args.indexOf('--pids-limit') + 1], '64');
|
assert.equal(args[args.indexOf('--pids-limit') + 1], '64');
|
||||||
|
assert.equal(args[args.indexOf('--publish') + 1], '127.0.0.1:5701:5701/tcp');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('adds optional Run management files only after an explicit enabled switch', (t) => {
|
||||||
|
const value = fixture(t);
|
||||||
|
const result = invoke('check', {
|
||||||
|
...value.env,
|
||||||
|
QL3_COPILOT_CONSOLE_RUN_MANAGEMENT: 'enabled',
|
||||||
|
});
|
||||||
|
assert.equal(result.status, 0, result.stderr);
|
||||||
|
const args = fs.readFileSync(value.capture, 'utf8').trimEnd().split('\n');
|
||||||
assert.equal(
|
assert.equal(
|
||||||
args[args.indexOf('--publish') + 1],
|
args[args.indexOf('--run-management-config') + 1],
|
||||||
'127.0.0.1:5701:5701/tcp',
|
'/var/run/secrets/qinglong3/copilot-console/run-management-client.json',
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
args[args.indexOf('--run-management-assertion') + 1],
|
||||||
|
'/var/run/secrets/qinglong3/copilot-console/run-management-assertion.jwt',
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,8 +143,12 @@ test('rejects mutable, ambient and malformed host inputs before Docker', (t) =>
|
|||||||
{ ...value.env, QL3_COPILOT_CONSOLE_IMAGE: 'ghcr.io/example/admin:latest' },
|
{ ...value.env, QL3_COPILOT_CONSOLE_IMAGE: 'ghcr.io/example/admin:latest' },
|
||||||
{ ...value.env, QL3_COPILOT_CONSOLE_NETWORK: 'host' },
|
{ ...value.env, QL3_COPILOT_CONSOLE_NETWORK: 'host' },
|
||||||
{ ...value.env, QL3_COPILOT_CONSOLE_PORT: '80' },
|
{ ...value.env, QL3_COPILOT_CONSOLE_PORT: '80' },
|
||||||
{ ...value.env, QL3_COPILOT_CONSOLE_PRIVATE_ROOT: `${value.privateRoot}:rw` },
|
{
|
||||||
|
...value.env,
|
||||||
|
QL3_COPILOT_CONSOLE_PRIVATE_ROOT: `${value.privateRoot}:rw`,
|
||||||
|
},
|
||||||
{ ...value.env, QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'unbounded' },
|
{ ...value.env, QL3_COPILOT_CONSOLE_RESOURCE_CLASS: 'unbounded' },
|
||||||
|
{ ...value.env, QL3_COPILOT_CONSOLE_RUN_MANAGEMENT: 'ambient' },
|
||||||
]) {
|
]) {
|
||||||
const rejected = invoke('serve', environment);
|
const rejected = invoke('serve', environment);
|
||||||
assert.equal(rejected.status, 78);
|
assert.equal(rejected.status, 78);
|
||||||
|
|||||||
@@ -340,10 +340,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
rootSourceFileRoles: clusterAdmin.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 124,
|
sourceFiles: 125,
|
||||||
rootSourceFiles: 1,
|
rootSourceFiles: 1,
|
||||||
rootSourceLines: 61,
|
rootSourceLines: 61,
|
||||||
nestedSourceFiles: 123,
|
nestedSourceFiles: 124,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'modelInvocationMigrationCli.ts': 'binary_entry',
|
'modelInvocationMigrationCli.ts': 'binary_entry',
|
||||||
},
|
},
|
||||||
@@ -385,10 +385,10 @@ test('current QL3 workspace has exactly eighteen reviewed package boundaries', (
|
|||||||
rootSourceFileRoles: clusterControl.rootSourceFileRoles,
|
rootSourceFileRoles: clusterControl.rootSourceFileRoles,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
sourceFiles: 65,
|
sourceFiles: 65,
|
||||||
rootSourceFiles: 2,
|
rootSourceFiles: 2,
|
||||||
rootSourceLines: 195,
|
rootSourceLines: 195,
|
||||||
nestedSourceFiles: 63,
|
nestedSourceFiles: 63,
|
||||||
rootSourceFileRoles: {
|
rootSourceFileRoles: {
|
||||||
'aiCli.ts': 'binary_entry',
|
'aiCli.ts': 'binary_entry',
|
||||||
'cli.ts': 'binary_entry',
|
'cli.ts': 'binary_entry',
|
||||||
|
|||||||
Reference in New Issue
Block a user