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):
- 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-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-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)。
+3 -1
View File
@@ -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
+8 -7
View File
@@ -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 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,选择“连接本机”。
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 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 没有独立数据库、后台任务或需要额外清理的持久状态。
@@ -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;
@@ -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', '当前窗口没有可见事件。'));
+71 -3
View File
@@ -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<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(
runs: Pick<RunRepositoryReader, 'findRunById'>,
runs: Pick<
RunRepositoryReader,
'findRunById' | 'findLatestAttemptByRunId'
>,
): 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');
}
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 (
@@ -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);
@@ -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' } },
);
});
@@ -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(
@@ -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<ReturnType<ReadyFreshStorage['stepRunReader']>>;
readonly runCancellation: Awaited<