diff --git a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md index 0cb2d5ec..84f7a224 100644 --- a/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md +++ b/docs/QINGLONG_3_0_ARCHITECTURE_RFC.md @@ -11,6 +11,8 @@ 最新增量证据(2026-08-28): +- D-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 modules,Cluster 零变化。Local API 49/49、完整 backend `1,650 total / 1,648 pass / 2 Linux conditional skip / 0 fail`、18-package clean build/test 退出 0;package/source、Local image、122-module Edge import 和 Cluster dependency audit 均 compatible。现有 `task.put` 的进程级 active credential fence 不适合常驻并发 HTTP,Web 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、依赖或稳态 RSS,Cluster 不复用本机 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。 diff --git a/docs/adr/ADR-0515-bounded-local-console-run-log.md b/docs/adr/ADR-0515-bounded-local-console-run-log.md new file mode 100644 index 00000000..f6418ae2 --- /dev/null +++ b/docs/adr/ADR-0515-bounded-local-console-run-log.md @@ -0,0 +1,57 @@ +# ADR-0515:Local 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` 写入 `
`;不使用 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 Tool,Local 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 bytes;import 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 均 compatible;workspace 仍为 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 仍需维护者显式授权。
diff --git a/docs/operations/ql3-alpha-candidate.md b/docs/operations/ql3-alpha-candidate.md
index 85435947..a24ff5e5 100644
--- a/docs/operations/ql3-alpha-candidate.md
+++ b/docs/operations/ql3-alpha-candidate.md
@@ -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-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-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)。
diff --git a/docs/operations/ql3-local-alpha-trial-kit.md b/docs/operations/ql3-local-alpha-trial-kit.md
index 20ae43af..07188199 100644
--- a/docs/operations/ql3-local-alpha-trial-kit.md
+++ b/docs/operations/ql3-local-alpha-trial-kit.md
@@ -80,7 +80,9 @@ AI-excluded,但 quickstart 会在 Linux 上使用 host network,让容器内
Console quickstart 还会通过 strong local operator 创建一个默认不自动运行的
`alpha-first-automation`。在页面输入 `owner-credential.json` 中的 token,选择该 Task,
核对 revision/content digest 后显式运行;它只执行 `/bin/echo` 固定标记,不使用网络、
-SecretRef 或 Trigger。headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。
+SecretRef 或 Trigger。D-420 后选择该 Run 还会经既有 `artifact.read` 权限链展示 latest
+Attempt 首个 32 KiB 日志;后续内容继续使用 API 分页,页面不会自动轮询或下载整份日志。
+headless 不创建示例 Task,因此低配默认档没有示例数据或稳态开销。
## 手工加载与最小 smoke
diff --git a/docs/operations/ql3-local-web-console.md b/docs/operations/ql3-local-web-console.md
index e5638376..583024f2 100644
--- a/docs/operations/ql3-local-web-console.md
+++ b/docs/operations/ql3-local-web-console.md
@@ -11,7 +11,7 @@ Local Web Console 是 `@qinglong/local-api` 的 opt-in 操作界面,用来查
| 普通单节点服务器 | 选择 `standalone-application-api` |
| Kubernetes/Cluster 节点 | 不使用本 Local Console;继续使用 Cluster Control/Console 路径 |
-D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。实际大 archive 仍只由维护者显式 artifact run 生成;普通 push 的源码和 CI 不是公开下载物。
+D-418 已闭合独立 Console image/Trial Kit;D-419 的 v5 quickstart 进一步安装可直接使用的 Owner credential presentation,并创建默认不自动运行的 `alpha-first-automation`。D-420 又把该 Run 的 latest Attempt 首个 32 KiB 日志带到 Console,并明确展示 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,选择“连接本机”。
2. fresh Console Trial Kit 可先选择 `alpha-first-automation`;核对 revision/content fence 后才能“运行一次”。
-3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度。
-4. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
-5. 完成后选择“断开并清除凭据”,再关闭页面。
+3. 在“运行”中选择 durable Run,按 Event sequence 判断实际进度;Bounded log 只显示 latest Attempt 的首个 32 KiB,后续内容仍需通过 API 分页读取。
+4. 日志 pending 时使用“刷新”显式重读;retired 表示内容已按保留策略清理,不代表 Run/Event 事实丢失。
+5. “请求取消”只提交 durable cancellation intent;界面出现 `cancelled|failed|succeeded|timed_out` 终态前,不要认为进程已经停止。
+6. 完成后选择“断开并清除凭据”,再关闭页面。
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;
- Identity、Policy、Secret、Plugin Package 或 AI 配置管理;
-- Run log 展示、终端、文件管理或 2.x 数据迁移;
+- 日志整文件下载、终端、文件管理或 2.x 数据迁移;
- LAN/public 暴露、TLS termination、多用户 Web session 或 Cluster 管理。
-三项静态资产总计 43,252 bytes,不依赖 CDN、网络字体或前端框架。`edge-application-api` 制品为 3,953,346 bytes/467 files,`standalone-application-api` 为 3,953,490 bytes/467 files;基础 headless Profile 不携带这些资产。
+三项静态资产总计 48,318 bytes,不依赖 CDN、网络字体或前端框架,仍低于 192 KiB 总闭包和单文件 96 KiB 门。`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、3 packages、58 modules,不携带这些资产。
停止 Local API 进程走与 Application 相同的 drain/shutdown 路径。Console 没有独立数据库、后台任务或需要额外清理的持久状态。
diff --git a/packages/ql3-local-api/assets/console/console.css b/packages/ql3-local-api/assets/console/console.css
index a67d2303..c87f6ebd 100644
--- a/packages/ql3-local-api/assets/console/console.css
+++ b/packages/ql3-local-api/assets/console/console.css
@@ -577,6 +577,60 @@ input:focus-visible,
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 {
margin: 30px 0 14px;
font-size: 12px;
diff --git a/packages/ql3-local-api/assets/console/console.js b/packages/ql3-local-api/assets/console/console.js
index e393a2dd..34a8f76d 100644
--- a/packages/ql3-local-api/assets/console/console.js
+++ b/packages/ql3-local-api/assets/console/console.js
@@ -4,6 +4,7 @@
const PROJECT_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const TOKEN_PATTERN =
/^ql3c_[A-Za-z0-9][A-Za-z0-9._:-]{0,63}_[A-Za-z0-9_-]{43}$/;
+ const LOG_READ_BYTES = 32 * 1024;
const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'timed_out']);
const STATUS_LABELS = Object.freeze({
created: '已创建',
@@ -103,6 +104,27 @@
: 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) {
if (status === 'failed' || status === 'timed_out') return 'failed';
if (
@@ -485,13 +507,100 @@
api(`/api/v3/projects/${state.project}/runs/${runId}/events?limit=64`),
api(`/api/v3/projects/${state.project}/runs/${runId}/steps?limit=64`),
]);
- renderRunDetail(runValue.run, eventValue, stepValue);
+ const logView = await readRunLog(runValue.run);
+ renderRunDetail(runValue.run, eventValue, stepValue, logView);
} catch (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 steps = Array.isArray(stepPage.steps) ? stepPage.steps : [];
const fragment = document.createDocumentFragment();
@@ -525,6 +634,7 @@
);
fragment.append(actions);
}
+ fragment.append(renderRunLog(logView));
fragment.append(element('h4', 'timeline-heading', 'Event sequence'));
if (events.length === 0) {
fragment.append(element('p', 'privacy-note', '当前窗口没有可见事件。'));
diff --git a/packages/ql3-local-api/src/run/runReadRoute.ts b/packages/ql3-local-api/src/run/runReadRoute.ts
index 6daad9a1..3c0885c7 100644
--- a/packages/ql3-local-api/src/run/runReadRoute.ts
+++ b/packages/ql3-local-api/src/run/runReadRoute.ts
@@ -2,6 +2,10 @@ import {
BoundedRunReadProjectionUnavailableError,
executeBoundedRunReadProjection,
} 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 { LocalApiResponse } from '../transport/contract';
@@ -22,10 +26,61 @@ function response(
return Object.freeze({ statusCode, body: Object.freeze(body) });
}
+function latestAttemptProjection(
+ value: Readonly,
+ runId: string,
+): Readonly> | 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(
- runs: Pick,
+ runs: Pick<
+ RunRepositoryReader,
+ 'findRunById' | 'findLatestAttemptByRunId'
+ >,
): Readonly {
- 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');
}
return Object.freeze({
@@ -39,9 +94,22 @@ export function createLocalApiRunReadRoute(
if (projection.found !== true) {
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;
return response(200, {
- run: Object.freeze({ projectId: request.projectId, ...view }),
+ run: Object.freeze({
+ projectId: request.projectId,
+ ...view,
+ latestAttempt: attemptView,
+ }),
});
} catch (error) {
if (
diff --git a/packages/ql3-local-api/test/consoleAssets.test.cjs b/packages/ql3-local-api/test/consoleAssets.test.cjs
index 2bfd9d46..d7dd7926 100644
--- a/packages/ql3-local-api/test/consoleAssets.test.cjs
+++ b/packages/ql3-local-api/test/consoleAssets.test.cjs
@@ -72,6 +72,10 @@ test('loads one bounded offline Console asset closure', () => {
);
assert.match(text, /authorization: `Bearer \$\{state\.token\}`/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);
diff --git a/packages/ql3-local-api/test/runReadRoute.test.cjs b/packages/ql3-local-api/test/runReadRoute.test.cjs
index 7ef86ac7..53de0eea 100644
--- a/packages/ql3-local-api/test/runReadRoute.test.cjs
+++ b/packages/ql3-local-api/test/runReadRoute.test.cjs
@@ -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 () => {
const route = createLocalApiRunReadRoute({
async findRunById(runId) {
assert.equal(runId, 'run_123');
return run({ version: 0 });
},
+ async findLatestAttemptByRunId(runId) {
+ assert.equal(runId, 'run_123');
+ return attempt();
+ },
});
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.body.run.projectId, 'prj_default');
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);
});
@@ -57,6 +85,9 @@ test('collapses absent and cross-project Runs and fails closed on repository err
async findRunById() {
return value;
},
+ async findLatestAttemptByRunId() {
+ throw new Error('must not inspect Attempt for an absent Run');
+ },
});
assert.deepEqual(
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() {
throw new Error('database unavailable');
},
+ async findLatestAttemptByRunId() {
+ throw new Error('database unavailable');
+ },
});
assert.deepEqual(
await unavailable.handle({ projectId: 'prj_default', runId: 'run_123' }),
{ 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' } },
+ );
+});
diff --git a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs
index 52a44dee..316c21df 100644
--- a/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs
+++ b/packages/ql3-local-api/test/sqliteHttp.integration.test.cjs
@@ -448,6 +448,14 @@ test('serves an authenticated Run through one real SQLite authority and durable
assert.equal(accepted.statusCode, 200);
assert.equal(accepted.body.run.id, RUN_ID);
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);
const listed = await request(
diff --git a/packages/ql3-local-application/src/application-runtime/contract.ts b/packages/ql3-local-application/src/application-runtime/contract.ts
index 2f43ac1b..694aa3d0 100644
--- a/packages/ql3-local-application/src/application-runtime/contract.ts
+++ b/packages/ql3-local-application/src/application-runtime/contract.ts
@@ -50,7 +50,10 @@ export interface LocalApplicationProductSurfaceAuthority {
readonly profile: LocalApplicationProfile;
readonly runs: Pick<
ReadyFreshStorage['runs'],
- 'findRunById' | 'listEvents' | 'listRunsByProject'
+ | 'findRunById'
+ | 'findLatestAttemptByRunId'
+ | 'listEvents'
+ | 'listRunsByProject'
>;
readonly stepRuns: Awaited>;
readonly runCancellation: Awaited<