feat(ql3): surface bounded console run logs

This commit is contained in:
whyour
2026-08-28 12:27:19 +08:00
parent 920a31fb66
commit 57953ec80b
12 changed files with 391 additions and 14 deletions
+2
View File
@@ -11,6 +11,8 @@
最新增量证据(2026-08-28): 最新增量证据(2026-08-28):
- D-420/ADR-0515(已实现,远端双架构门待验证):阶段可用的首个自动化从“原生 CI 能通过 API 看到 bounded log marker”推进为“部署者能在 Console 直接观察实际输出”。Local Run HTTP 详情只追加严格验证的 latest Attempt 低敏摘要(ID、序号、状态、时间和 `logAvailable`),不返回 executor handle、Artifact ID、路径、Worker、PID 或错误明细;共享 HIGH 风险 `executeBoundedRunReadProjection` 保持不变,避免 Local UI 字段漂移到内建 Run read/compare Tool。Console 使用既有 `artifact.read` Policy/Audit/credential re-confirm 链,每次固定读取首个 32 KiB base64 窗口,分别显示 available/pending/retired/not-found/unavailable、range 与 truncation,不新增轮询、WebSocket、timer、缓存或整文件下载。三资产增至 48,318 bytes`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes、467 files、12 packages、90 loaded modules,仍低于 6 MiB/640-file 门,默认 headless Edge 保持 2,669,390 bytes/325 files/58 modulesCluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0package/source、Local image、122-module Edge import 和 Cluster dependency audit 均 compatible。现有 `task.put` 的进程级 active credential fence 不适合常驻并发 HTTPWeb Task 创建/修订必须以后续“每请求 credential fence + 同事务 Policy/Audit/mutation”切片完成,D-420 不用单因子 Bearer 绕过强认证。
- D-419/ADR-0514(实现中):二十天研发的阶段产物验收从“能下载、能启动、能打开 Console”推进到“能完成第一份真实自动化”。首 Owner bootstrap ready record 不再要求部署者手工拼接 token;短生命周期 operator 新增独立、no-replace、可幂等重放且不返回 Secret 的 `owner.credential-presentation.install-from-delivery`,在同 UID、`0700/0600`、deployment-root containment、no-symlink 和原子发布边界内生成标准 credential presentation,同时不修改 HIGH 风险的 delivery record/recovery/acknowledgement 协议。headless 与 Console 都获得后续管理所需的 presentation,但只有 opt-in Console quickstart 创建默认不自动运行、无网络/SecretRef/Trigger 的 `alpha-first-automation`;用户仍需核对 revision/content digest fence 后显式启动。原生 amd64/arm64 Console 门必须使用该真实 Owner credential 读取 Task、接受一次 fenced Run、等待 `succeeded` 并从 bounded log 看到固定工作标记。Trial Kit/verification 升为 `@v5/@v3`;默认低配 headless 不新增示例数据、listener、daemon、timer、依赖或稳态 RSSCluster 不复用本机 SQLite credential 路径。首份真实双架构 v5 大产物仍待维护者显式授权。 - D-419/ADR-0514(实现中):二十天研发的阶段产物验收从“能下载、能启动、能打开 Console”推进到“能完成第一份真实自动化”。首 Owner bootstrap ready record 不再要求部署者手工拼接 token;短生命周期 operator 新增独立、no-replace、可幂等重放且不返回 Secret 的 `owner.credential-presentation.install-from-delivery`,在同 UID、`0700/0600`、deployment-root containment、no-symlink 和原子发布边界内生成标准 credential presentation,同时不修改 HIGH 风险的 delivery record/recovery/acknowledgement 协议。headless 与 Console 都获得后续管理所需的 presentation,但只有 opt-in Console quickstart 创建默认不自动运行、无网络/SecretRef/Trigger 的 `alpha-first-automation`;用户仍需核对 revision/content digest fence 后显式启动。原生 amd64/arm64 Console 门必须使用该真实 Owner credential 读取 Task、接受一次 fenced Run、等待 `succeeded` 并从 bounded log 看到固定工作标记。Trial Kit/verification 升为 `@v5/@v3`;默认低配 headless 不新增示例数据、listener、daemon、timer、依赖或稳态 RSSCluster 不复用本机 SQLite credential 路径。首份真实双架构 v5 大产物仍待维护者显式授权。
- D-418/ADR-0513(已实现,首份真实双架构产物待授权):二十天研发形成的 headless 用户旅程与 opt-in Console 已闭合为部署者可选择的 Local Alpha 交付链。手动门新增默认 `headless``local_alpha_variant=headless|console`;一次授权 run 只生成所选变体,不把两套 Application 塞入同一 archive。默认 Docker/runtime 与路由/NAS 继续使用原 8 个内部 package、无 listener 的 headless 镜像;独立 `runtime-console` target 增加 Local API/Console,固定 12-package、6 MiB/640-file、AI-excluded 闭包。Console quickstart 仅支持 Linux Docker host,使用 host network 让容器内仍严格绑定 `127.0.0.1:5700` 的服务可由宿主访问,远程只能显式 SSH tunnel,不放宽为 `0.0.0.0` 或 public ingress。Trial Kit、verification、Local milestone 与 stage index 分别升级为 `qinglong/alpha-local-trial-kit@v4``qinglong/alpha-local-trial-kit-verification@v2``qinglong/alpha-local-milestone@v2``qinglong/alpha-stage-index@v2`variant 贯穿 archive/SBOM/evidence/artifact name/deployment selection,拒绝跨变体与跨架构混用。普通双架构 CI 同时构建、Trivy 扫描、库存/SBOM 对账和实跑两种镜像;Console 必须完成 fresh Owner、loopback 首页 200、未认证 API 401 与 graceful stop。实际大 archive 仍只由维护者显式 `produce_alpha_artifacts=true` 生成;没有同 run/attempt 的双架构 milestone 时,单个 archive 仍只是工程中间物,不冒充 public release、生产 HA、签名或 LTS。 - D-418/ADR-0513(已实现,首份真实双架构产物待授权):二十天研发形成的 headless 用户旅程与 opt-in Console 已闭合为部署者可选择的 Local Alpha 交付链。手动门新增默认 `headless``local_alpha_variant=headless|console`;一次授权 run 只生成所选变体,不把两套 Application 塞入同一 archive。默认 Docker/runtime 与路由/NAS 继续使用原 8 个内部 package、无 listener 的 headless 镜像;独立 `runtime-console` target 增加 Local API/Console,固定 12-package、6 MiB/640-file、AI-excluded 闭包。Console quickstart 仅支持 Linux Docker host,使用 host network 让容器内仍严格绑定 `127.0.0.1:5700` 的服务可由宿主访问,远程只能显式 SSH tunnel,不放宽为 `0.0.0.0` 或 public ingress。Trial Kit、verification、Local milestone 与 stage index 分别升级为 `qinglong/alpha-local-trial-kit@v4``qinglong/alpha-local-trial-kit-verification@v2``qinglong/alpha-local-milestone@v2``qinglong/alpha-stage-index@v2`variant 贯穿 archive/SBOM/evidence/artifact name/deployment selection,拒绝跨变体与跨架构混用。普通双架构 CI 同时构建、Trivy 扫描、库存/SBOM 对账和实跑两种镜像;Console 必须完成 fresh Owner、loopback 首页 200、未认证 API 401 与 graceful stop。实际大 archive 仍只由维护者显式 `produce_alpha_artifacts=true` 生成;没有同 run/attempt 的双架构 milestone 时,单个 archive 仍只是工程中间物,不冒充 public release、生产 HA、签名或 LTS。
@@ -0,0 +1,57 @@
# ADR-0515Local Console 的有界 Run 日志观察面
- 状态:Accepted
- 日期:2026-08-28
- 对应 RFC 切片:D-420
- 关联:ADR-0377、ADR-0378、ADR-0379、ADR-0512、ADR-0513、ADR-0514
## 背景
D-419 已让 Console Trial Kit 使用真实 Owner credential 完成示例 Task read、带 revision/content digest 围栏的显式启动、`succeeded` 终态和 bounded log marker 验证。但 marker 只由原生 CI 直接读取 HTTP API;部署者在 Web Console 里仍只能看到 Run、Step 数和 Event sequence,无法确认脚本实际输出。对连续研发约二十天后的阶段产物而言,“测试知道工作完成”不能代替“试用者能在产品面观察工作结果”。
既有 `run.log.read` 已具备 Project 掩蔽、`artifact.read` Policy、durable audit、credential re-confirm、32 KiB Application 上限和 retention/truncation 状态。缺口是一个不放大低配设备常驻成本、也不把内部 Artifact authority 暴露给浏览器的显示层。
## 决策
### 1. Run HTTP 详情只追加 latest Attempt 的低敏摘要
Local API 在既有 Run 详情中追加 `latestAttempt`:仅包含 Attempt ID、序号、状态、创建/开始/完成时间和 `logAvailable` 布尔值。它不返回 `executorHandle`、PID、Worker、Artifact ID、文件路径、错误摘要或内部 lease。
该摘要由 Local API 自己调用 `findLatestAttemptByRunId` 并严格验证 Run ownership、ID、状态和时间边界。共享 `executeBoundedRunReadProjection` 保持不变:GitNexus 将其判定为 HIGH 风险,直接修改会同时影响内建 Run read/compare Tool。Local 产品增强不得悄然改变 AI/Tool 投影语义。
### 2. Console 每次只读首个 32 KiB 窗口
用户选择 Run 时,Console 使用 `latestAttempt.id` 调用既有 authenticated log API,固定请求 `offset=0&length=32768`。返回内容必须是有界 base64,浏览器解码后只通过 `textContent` 写入 `<pre>`;不使用 HTML 插值、下载链接或 Artifact 路径。
界面明确区分:
- `available`:显示 byte range、total bytes、truncation fact,并在存在 `nextOffset` 时说明后续内容应经 API 分页读取;
- `pending`:说明日志尚未发布,可由用户显式刷新;
- `retired`:说明日志已按 retention 清理,但 Run/Event 事实仍保留;
- `not_found|unavailable`:不扩大存在性信息,也不遮蔽已经取得的 Run/Event。
Console 不新增轮询、WebSocket、timer、后台缓存或整文件下载。用户刷新仍是当前 Alpha 的资源控制边界。
### 3. 低配与集群部署边界不变
新增资产仍只进入 opt-in `edge-application-api|standalone-application-api` 和 Console Trial Kit。默认 headless 路由/NAS 不携带 Console,也不增加 listener、请求、RSS、进程或稳态 I/O。Cluster 节点继续使用独立 Cluster Control/Console authority,不复用 SQLite Attempt 或本机 Owner credential 路径。
### 4. Task mutation 不搭便车进入常驻 HTTP
现有 `task.put` 是强认证、短生命周期 CLI:一次进程只激活一个 credential fence,并在 SQLite 事务内复验 credential、Project/RoleBinding、Audit 和 mutation。常驻 HTTP 若直接复用其进程级 active fence,会引入并发凭据串线和撤销竞态。
D-420 不以单因子 Bearer 或非原子的 HTTP adapter 绕过该边界。Web Task 创建/修订必须另行设计“每请求 credential fence + 同事务 Policy/Audit/mutation”,并继续保持 headless/Cluster 分层。
## 不采用的方案
- 不修改共享 Run read projection:其 HIGH 风险上游包含内建 Run read/compare ToolLocal UI 字段不应进入通用 Tool contract。
- 不把 Artifact ID 或路径返回浏览器:Attempt ID 已足够调用受 Policy 保护的读取能力。
- 不自动轮询或推送日志:这会给低配设备增加持续请求、timer 和连接状态;当前 Alpha 由用户刷新。
- 不一次读取或渲染整份日志:日志大小不受 UI 控制,首窗口足以完成阶段自动化观察,后续仍由 API 分页。
- 不顺便开放 Web `task.put`:现有强认证事务不能安全地转换为长期进程的共享 active fence。
## 结果与验证边界
D-420 把 D-419 的“CI 能从 API 看见 marker”推进为“部署者能在 Console 看见实际输出”,同时保持 Run/Event 为独立事实源。三项离线 Console 资产现为 48,318 bytes,仍低于 192 KiB 总闭包和单文件 96 KiB 门;没有新增 workspace package、第三方依赖或默认 Profile 资源。`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes、467 files、12 packages、90 loaded modules,距 6 MiB 分别保留 2,330,921 / 2,330,777 bytesimport RSS delta 为 14,745,600 / 14,794,752 bytes,低于 28 MiB 门。默认 headless Edge 仍为 2,669,390 bytes、325 files、3 packages、58 modules,证明没有携带 Console 增量。
定向验证包括 Local API 49/49、真实 SQLite/loopback/Bearer/Policy/Audit/log range 集成,以及 1440×960 和 390×844 的真实浏览器工作态。Local artifact 三档、Local image、package/source boundary、122-module Edge import 和 Cluster dependency audit 均 compatibleworkspace 仍为 18 packages`singleSourcePackages=[]``shallowSourcePackages=[]`。完整 backend 为 `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`18-package clean build/test 在允许 loopback TLS 的宿主门中退出 0。双架构远端门仍按本切片提交继续验证;这些门通过也不等于首份真实双架构 v5 Trial Kit 已生成,实际大 archive 仍需维护者显式授权。
+1
View File
@@ -25,6 +25,7 @@
| D-418 Console Trial Kit v4 | 显式 Linux-only 变体;同一 quickstart 关闭 Owner ceremony 后启动 loopback Console,支持 Task/Run/Event、显式运行与取消;CI 验证首页 200、未认证 API 401 | 仍是 Alpha、无 public ingress/TLS/签名;首份实际 archive 同样需维护者授权 | | D-418 Console Trial Kit v4 | 显式 Linux-only 变体;同一 quickstart 关闭 Owner ceremony 后启动 loopback Console,支持 Task/Run/Event、显式运行与取消;CI 验证首页 200、未认证 API 401 | 仍是 Alpha、无 public ingress/TLS/签名;首份实际 archive 同样需维护者授权 |
| D-419 Local Trial Kit v5 | headless/Console 都原子安装标准 Owner credential presentation;不再要求部署者手工拼接 bootstrap Secret | 首份实际双架构 v5 archive 仍需维护者授权 | | D-419 Local Trial Kit v5 | headless/Console 都原子安装标准 Owner credential presentation;不再要求部署者手工拼接 bootstrap Secret | 首份实际双架构 v5 archive 仍需维护者授权 |
| D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 | | D-419 Console 首自动化闭环 | quickstart 创建无网络/SecretRef/Trigger 的示例 Task;原生 CI 使用真实 Owner credential 完成 read、fenced start、`succeeded` 与 bounded log marker | 仍不提供 Web Task 编辑、2.x 升级或生产远程管理 |
| D-420 Console Run 日志观察面 | 选择 Run 后经既有认证/Policy/Audit 链读取 latest Attempt 首个 32 KiB,展示 range、truncation、pending/retired 等明确状态 | 不自动轮询、不提供整文件下载;Web Task 创建/修订仍待独立强认证事务切片 |
D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。 D-418 防止把“20 天代码和测试”冒充“用户已经能下载并完整操作”:源码与普通 CI 已具备生成、审计和实跑两种 Trial Kit 的能力,但只有显式 artifact run 生成且被同 run 的双架构 milestone 收录后,才是可下载阶段产物。操作说明见 [Local Alpha Trial Kit](./ql3-local-alpha-trial-kit.md) 与 [Local Web Console](./ql3-local-web-console.md)。
+3 -1
View File
@@ -80,7 +80,9 @@ AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内
Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的 Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的
`alpha-first-automation`。在页面输入 `owner-credential.json` 中的 token,选择该 Task `alpha-first-automation`。在页面输入 `owner-credential.json` 中的 token,选择该 Task
核对 revision/content digest 后显式运行;它只执行 `/bin/echo` 固定标记,不使用网络、 核对 revision/content digest 后显式运行;它只执行 `/bin/echo` 固定标记,不使用网络、
SecretRef 或 Trigger。headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。 SecretRef 或 Trigger。D-420 后选择该 Run 还会经既有 `artifact.read` 权限链展示 latest
Attempt 首个 32 KiB 日志;后续内容继续使用 API 分页,页面不会自动轮询或下载整份日志。
headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。
## 手工加载与最小 smoke ## 手工加载与最小 smoke
+8 -7
View File
@@ -11,7 +11,7 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查
| 普通单节点服务器 | 选择 `standalone-application-api` | | 普通单节点服务器 | 选择 `standalone-application-api` |
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 | | Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
D-418 已闭合独立 Console image/Trial KitD-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。 D-418 已闭合独立 Console image/Trial KitD-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console,并明确展示 pending、retired、missing 与 truncation 状态。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
## 前置条件 ## 前置条件
@@ -55,21 +55,22 @@ ssh -L 5701:127.0.0.1:5701 router.example
1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。 1. 输入 Project ID 和 `ql3c_…` API credential,选择“连接本机”。
2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。 2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。
3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度。 3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取
4. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止 4. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失
5. 完成后选择“断开并清除凭据”,再关闭页面 5. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止
6. 完成后选择“断开并清除凭据”,再关闭页面。
Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。 Credential 只存在当前页面内存,不进入 URL、Cookie 或 Web Storage。页面刷新会丢失 credential,需要重新输入;这是当前安全边界,不是缺陷。
## 当前阶段可用边界 ## 当前阶段可用边界
当前可操作闭环是 Task list/read/start 与 Run list/read/events/steps/cancel。页面不负责: 当前可操作闭环是 Task list/read/start 与 Run list/read/events/steps/log/cancel。页面不负责:
- 创建、编辑、启停 Task - 创建、编辑、启停 Task
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理; - Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
- Run log 展示、终端、文件管理或 2.x 数据迁移; - 日志整文件下载、终端、文件管理或 2.x 数据迁移;
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。 - LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
三项静态资产总计 43,252 bytes,不依赖 CDN、网络字体或前端框架`edge-application-api` 制品为 3,953,346 bytes/467 files`standalone-application-api` 为 3,953,490 bytes/467 files;基础 headless Profile 不携带这些资产。 三项静态资产总计 48,318 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`edge-application-api|standalone-application-api` 为 3,960,535 / 3,960,679 bytes467 files、12 packages、90 loaded modules,仍低于 6 MiB/640-file 门;基础 headless Edge 保持 2,669,390 bytes、325 files、3 packages、58 modules不携带这些资产。
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。 停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
@@ -577,6 +577,60 @@ input:focus-visible,
border-color: var(--danger); border-color: var(--danger);
} }
.run-log {
margin: 28px 0;
border: 1px solid var(--line);
background: var(--deep);
color: var(--white);
}
.run-log-header {
padding: 10px 12px;
display: flex;
justify-content: space-between;
gap: 12px;
border-bottom: 1px solid rgba(255, 255, 255, 0.18);
font: 650 10px/1.3 ui-monospace, 'SFMono-Regular', Consolas, monospace;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.run-log-header span,
.run-log-meta,
.run-log-placeholder {
color: rgba(255, 255, 255, 0.62);
}
.run-log-meta,
.run-log-placeholder {
margin: 0;
padding: 11px 12px;
font-size: 10px;
line-height: 1.5;
}
.run-log-content {
max-height: 320px;
margin: 0;
padding: 14px 12px 18px;
overflow: auto;
white-space: pre-wrap;
overflow-wrap: anywhere;
font: 500 11px/1.55 ui-monospace, 'SFMono-Regular', Consolas, monospace;
tab-size: 2;
}
.run-log[data-state='pending'],
.run-log[data-state='not_started'] {
border-left: 4px solid var(--amber);
}
.run-log[data-state='retired'],
.run-log[data-state='not_found'],
.run-log[data-state='unavailable'] {
border-left: 4px solid var(--line);
}
.timeline-heading { .timeline-heading {
margin: 30px 0 14px; margin: 30px 0 14px;
font-size: 12px; font-size: 12px;
@@ -4,6 +4,7 @@
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOKEN_PATTERN = const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/; /^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
const LOG_READ_BYTES = 32 * 1024;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']); const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUS_LABELS = Object.freeze({ const STATUS_LABELS = Object.freeze({
created: '已创建', created: '已创建',
@@ -103,6 +104,27 @@
: value || '—'; : value || '—';
} }
function decodeBase64Utf8(value) {
if (
typeof value !== 'string' ||
value.length > 48 * 1024 ||
!/^[A-Za-z0-9+/]*={0,2}$/u.test(value)
) {
return null;
}
try {
const binary = window.atob(value);
if (binary.length > LOG_READ_BYTES) return null;
const bytes = new Uint8Array(binary.length);
for (let index = 0; index < binary.length; index += 1) {
bytes[index] = binary.charCodeAt(index);
}
return new TextDecoder('utf-8').decode(bytes);
} catch {
return null;
}
}
function statusTone(status) { function statusTone(status) {
if (status === 'failed' || status === 'timed_out') return 'failed'; if (status === 'failed' || status === 'timed_out') return 'failed';
if ( if (
@@ -485,13 +507,100 @@
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`), api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`), api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
]); ]);
renderRunDetail(runValue.run, eventValue, stepValue); const logView = await readRunLog(runValue.run);
renderRunDetail(runValue.run, eventValue, stepValue, logView);
} catch (error) { } catch (error) {
detailEmpty(describeError(error)); detailEmpty(describeError(error));
} }
} }
function renderRunDetail(run, eventPage, stepPage) { async function readRunLog(run) {
const attempt = run?.latestAttempt;
if (!attempt || typeof attempt.id !== 'string') {
return Object.freeze({ status: 'not_started' });
}
try {
const value = await api(
`/api/v3/projects/${state.project}/runs/${run.id}/attempts/${attempt.id}/log?offset=0&length=${LOG_READ_BYTES}`,
);
if (value.status === 'pending') {
return Object.freeze({ status: 'pending', attempt });
}
const content =
value.status === 'available' && value.encoding === 'base64'
? decodeBase64Utf8(value.content)
: null;
if (
content === null ||
!value.range ||
!Number.isSafeInteger(value.range.start) ||
!Number.isSafeInteger(value.range.endExclusive) ||
!Number.isSafeInteger(value.range.totalBytes)
) {
return Object.freeze({ status: 'unavailable', attempt });
}
return Object.freeze({
status: 'available',
attempt,
content,
range: value.range,
truncation: value.truncation,
});
} catch (error) {
if (error instanceof ConsoleRequestError && error.status === 410) {
return Object.freeze({ status: 'retired', attempt });
}
if (error instanceof ConsoleRequestError && error.status === 404) {
return Object.freeze({ status: 'not_found', attempt });
}
return Object.freeze({ status: 'unavailable', attempt });
}
}
function renderRunLog(logView) {
const section = element('section', 'run-log');
section.dataset.state = logView.status;
const header = element('div', 'run-log-header');
header.append(element('strong', null, 'Bounded log'));
if (logView.attempt) {
header.append(
element(
'span',
null,
`Attempt ${logView.attempt.attempt} · ${logView.attempt.status}`,
),
);
}
section.append(header);
if (logView.status === 'available') {
const metadata = [
`${logView.range.start}${logView.range.endExclusive} / ${logView.range.totalBytes} bytes`,
];
if (logView.truncation?.truncated === true) metadata.push('执行端已截断');
if (logView.truncation?.truncated === 'unknown')
metadata.push('截断状态未知');
if (logView.range.nextOffset !== undefined)
metadata.push('后续内容可经 API 分页读取');
section.append(element('p', 'run-log-meta', metadata.join(' · ')));
section.append(
element('pre', 'run-log-content', logView.content || '(空日志)'),
);
return section;
}
const labels = {
not_started: '当前 Run 还没有可读取的执行 Attempt。',
pending: '日志尚未发布;运行中可使用“刷新”重新读取。',
retired: '日志已按保留策略清理,Run 与 Event 事实仍然保留。',
not_found: '当前 Project 下没有找到这份 Attempt 日志。',
unavailable: '日志暂时不可用;Run 状态与 Event 仍可独立核验。',
};
section.append(
element('p', 'run-log-placeholder', labels[logView.status] || labels.unavailable),
);
return section;
}
function renderRunDetail(run, eventPage, stepPage, logView) {
const events = Array.isArray(eventPage.events) ? eventPage.events : []; const events = Array.isArray(eventPage.events) ? eventPage.events : [];
const steps = Array.isArray(stepPage.steps) ? stepPage.steps : []; const steps = Array.isArray(stepPage.steps) ? stepPage.steps : [];
const fragment = document.createDocumentFragment(); const fragment = document.createDocumentFragment();
@@ -525,6 +634,7 @@
); );
fragment.append(actions); fragment.append(actions);
} }
fragment.append(renderRunLog(logView));
fragment.append(element('h4', 'timeline-heading', 'Event sequence')); fragment.append(element('h4', 'timeline-heading', 'Event sequence'));
if (events.length === 0) { if (events.length === 0) {
fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。')); fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。'));
+71 -3
View File
@@ -2,6 +2,10 @@ import {
BoundedRunReadProjectionUnavailableError, BoundedRunReadProjectionUnavailableError,
executeBoundedRunReadProjection, executeBoundedRunReadProjection,
} from '@qinglong/runtime-core/bounded-run-read-projection'; } from '@qinglong/runtime-core/bounded-run-read-projection';
import {
RUN_ATTEMPT_STATUSES,
type RunAttemptRecord,
} from '@qinglong/runtime-core/run';
import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository'; import type { RunRepositoryReader } from '@qinglong/runtime-core/run-repository';
import type { LocalApiResponse } from '../transport/contract'; import type { LocalApiResponse } from '../transport/contract';
@@ -22,10 +26,61 @@ function response(
return Object.freeze({ statusCode, body: Object.freeze(body) }); return Object.freeze({ statusCode, body: Object.freeze(body) });
} }
function latestAttemptProjection(
value: Readonly<RunAttemptRecord>,
runId: string,
): Readonly<Record<string, boolean | string | number>> | null {
const boundedText = (candidate: unknown, maximum: number) =>
typeof candidate === 'string' &&
candidate.length > 0 &&
candidate.length <= maximum &&
!/[\u0000-\u001f\u007f]/u.test(candidate);
const timestamp = (candidate: unknown) =>
Number.isSafeInteger(candidate) && Number(candidate) >= 0;
if (
!value ||
typeof value !== 'object' ||
Array.isArray(value) ||
value.runId !== runId ||
!boundedText(value.id, 128) ||
!Number.isSafeInteger(value.attempt) ||
value.attempt < 1 ||
value.attempt > 2_147_483_647 ||
!RUN_ATTEMPT_STATUSES.includes(value.status) ||
!timestamp(value.createdAtMs) ||
(value.startedAtMs !== undefined && !timestamp(value.startedAtMs)) ||
(value.finishedAtMs !== undefined && !timestamp(value.finishedAtMs)) ||
(value.logArtifactId !== undefined &&
!boundedText(value.logArtifactId, 128))
) {
return null;
}
return Object.freeze({
id: value.id,
attempt: value.attempt,
status: value.status,
logAvailable: value.logArtifactId !== undefined,
createdAtMs: value.createdAtMs,
...(value.startedAtMs === undefined
? {}
: { startedAtMs: value.startedAtMs }),
...(value.finishedAtMs === undefined
? {}
: { finishedAtMs: value.finishedAtMs }),
});
}
export function createLocalApiRunReadRoute( export function createLocalApiRunReadRoute(
runs: Pick<RunRepositoryReader, 'findRunById'>, runs: Pick<
RunRepositoryReader,
'findRunById' | 'findLatestAttemptByRunId'
>,
): Readonly<LocalApiRunReadRoute> { ): Readonly<LocalApiRunReadRoute> {
if (!runs || typeof runs.findRunById !== 'function') { if (
!runs ||
typeof runs.findRunById !== 'function' ||
typeof runs.findLatestAttemptByRunId !== 'function'
) {
throw new TypeError('Local API Run read repository is invalid'); throw new TypeError('Local API Run read repository is invalid');
} }
return Object.freeze({ return Object.freeze({
@@ -39,9 +94,22 @@ export function createLocalApiRunReadRoute(
if (projection.found !== true) { if (projection.found !== true) {
return response(404, { code: 'run_not_found' }); return response(404, { code: 'run_not_found' });
} }
const latestAttempt = await runs.findLatestAttemptByRunId(
request.runId,
);
const attemptView = latestAttempt
? latestAttemptProjection(latestAttempt, request.runId)
: null;
if (latestAttempt && !attemptView) {
throw new BoundedRunReadProjectionUnavailableError();
}
const { found: _found, ...view } = projection; const { found: _found, ...view } = projection;
return response(200, { return response(200, {
run: Object.freeze({ projectId: request.projectId, ...view }), run: Object.freeze({
projectId: request.projectId,
...view,
latestAttempt: attemptView,
}),
}); });
} catch (error) { } catch (error) {
if ( if (
@@ -72,6 +72,10 @@ test('loads one bounded offline Console asset closure', () => {
); );
assert.match(text, /authorization: `Bearer \$\{state\.token\}`/u); assert.match(text, /authorization: `Bearer \$\{state\.token\}`/u);
assert.match(text, /credentials: 'omit'/u); assert.match(text, /credentials: 'omit'/u);
assert.match(text, /attempts\/\$\{attempt\.id\}\/log/u);
assert.match(text, /const LOG_READ_BYTES = 32 \* 1024/u);
assert.match(text, /new TextDecoder\('utf-8'\)/u);
assert.match(text, /日志已按保留策略清理/u);
} }
} }
assert.ok(totalBytes <= 192 * 1024); assert.ok(totalBytes <= 192 * 1024);
@@ -33,12 +33,32 @@ function run(overrides = {}) {
}; };
} }
function attempt(overrides = {}) {
return {
id: 'attempt_123',
runId: 'run_123',
attempt: 2,
status: 'running',
executorType: 'local_process',
executorHandle: 'private-executor-handle',
logArtifactId: 'private-log-artifact-id',
callbackSequence: 0,
createdAtMs: 2_100,
startedAtMs: 2_200,
...overrides,
};
}
test('returns the shared bounded Run projection without secret-adjacent fields', async () => { test('returns the shared bounded Run projection without secret-adjacent fields', async () => {
const route = createLocalApiRunReadRoute({ const route = createLocalApiRunReadRoute({
async findRunById(runId) { async findRunById(runId) {
assert.equal(runId, 'run_123'); assert.equal(runId, 'run_123');
return run({ version: 0 }); return run({ version: 0 });
}, },
async findLatestAttemptByRunId(runId) {
assert.equal(runId, 'run_123');
return attempt();
},
}); });
const response = await route.handle({ const response = await route.handle({
@@ -48,6 +68,14 @@ test('returns the shared bounded Run projection without secret-adjacent fields',
assert.equal(response.statusCode, 200); assert.equal(response.statusCode, 200);
assert.equal(response.body.run.projectId, 'prj_default'); assert.equal(response.body.run.projectId, 'prj_default');
assert.equal(response.body.run.version, 0); assert.equal(response.body.run.version, 0);
assert.deepEqual(response.body.run.latestAttempt, {
id: 'attempt_123',
attempt: 2,
status: 'running',
logAvailable: true,
createdAtMs: 2_100,
startedAtMs: 2_200,
});
assert.equal(JSON.stringify(response).includes('private'), false); assert.equal(JSON.stringify(response).includes('private'), false);
}); });
@@ -57,6 +85,9 @@ test('collapses absent and cross-project Runs and fails closed on repository err
async findRunById() { async findRunById() {
return value; return value;
}, },
async findLatestAttemptByRunId() {
throw new Error('must not inspect Attempt for an absent Run');
},
}); });
assert.deepEqual( assert.deepEqual(
await route.handle({ projectId: 'prj_default', runId: 'run_123' }), await route.handle({ projectId: 'prj_default', runId: 'run_123' }),
@@ -67,9 +98,45 @@ test('collapses absent and cross-project Runs and fails closed on repository err
async findRunById() { async findRunById() {
throw new Error('database unavailable'); throw new Error('database unavailable');
}, },
async findLatestAttemptByRunId() {
throw new Error('database unavailable');
},
}); });
assert.deepEqual( assert.deepEqual(
await unavailable.handle({ projectId: 'prj_default', runId: 'run_123' }), await unavailable.handle({ projectId: 'prj_default', runId: 'run_123' }),
{ statusCode: 503, body: { code: 'run_query_unavailable' } }, { statusCode: 503, body: { code: 'run_query_unavailable' } },
); );
}); });
test('returns null without an Attempt and fails closed on invalid Attempt projections', async () => {
const withoutAttempt = createLocalApiRunReadRoute({
async findRunById() {
return run();
},
async findLatestAttemptByRunId() {
return null;
},
});
const response = await withoutAttempt.handle({
projectId: 'prj_default',
runId: 'run_123',
});
assert.equal(response.statusCode, 200);
assert.equal(response.body.run.latestAttempt, null);
const invalidAttempt = createLocalApiRunReadRoute({
async findRunById() {
return run();
},
async findLatestAttemptByRunId() {
return attempt({ runId: 'another_run' });
},
});
assert.deepEqual(
await invalidAttempt.handle({
projectId: 'prj_default',
runId: 'run_123',
}),
{ statusCode: 503, body: { code: 'run_query_unavailable' } },
);
});
@@ -448,6 +448,14 @@ test('serves an authenticated Run through one real SQLite authority and durable
assert.equal(accepted.statusCode, 200); assert.equal(accepted.statusCode, 200);
assert.equal(accepted.body.run.id, RUN_ID); assert.equal(accepted.body.run.id, RUN_ID);
assert.equal(accepted.body.run.projectId, 'default'); assert.equal(accepted.body.run.projectId, 'default');
assert.deepEqual(accepted.body.run.latestAttempt, {
id: ATTEMPT_ID,
attempt: 1,
status: 'running',
logAvailable: true,
createdAtMs: NOW - 90,
startedAtMs: NOW - 80,
});
assert.equal(JSON.stringify(accepted).includes('secret'), false); assert.equal(JSON.stringify(accepted).includes('secret'), false);
const listed = await request( const listed = await request(
@@ -50,7 +50,10 @@ export interface LocalApplicationProductSurfaceAuthority {
readonly profile: LocalApplicationProfile; readonly profile: LocalApplicationProfile;
readonly runs: Pick< readonly runs: Pick<
ReadyFreshStorage['runs'], ReadyFreshStorage['runs'],
'findRunById' | 'listEvents' | 'listRunsByProject' | 'findRunById'
| 'findLatestAttemptByRunId'
| 'listEvents'
| 'listRunsByProject'
>; >;
readonly stepRuns: Awaited<ReturnType<ReadyFreshStorage['stepRunReader']>>; readonly stepRuns: Awaited<ReturnType<ReadyFreshStorage['stepRunReader']>>;
readonly runCancellation: Awaited< readonly runCancellation: Awaited<