diff --git a/config/agentrun.yaml b/config/agentrun.yaml index 6df6b161..68195dbf 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -59,6 +59,9 @@ controlPlane: unideskWorkspace: /root/unidesk lanes: nc01-v02: + status: + readOnlyCapture: + timeoutMs: 20000 deployment: format: unidesk-yaml-only gitopsRoot: deploy/gitops/node/nc01 @@ -497,6 +500,9 @@ controlPlane: gitopsPath: deploy/gitops/node/nc01/runtime-v02 argoApplication: agentrun-nc01-v02 nc01-release: + status: + readOnlyCapture: + timeoutMs: 20000 deployment: format: unidesk-yaml-only gitopsRoot: deploy/gitops/node/nc01 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 37d36daf..3d622f8d 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -23,6 +23,8 @@ deliveryTiming: endToEndBudgetSeconds: 120 observability: configRef: config/platform-infra/observability.yaml + readOnlyCapture: + timeoutMs: 25000 tracesEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces serviceName: unidesk-cicd propagation: diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml index c1d32448..8a62a07f 100644 --- a/config/selfmedia.yaml +++ b/config/selfmedia.yaml @@ -95,6 +95,8 @@ delivery: replicas: 1 serviceAccountName: selfmedia imagePullPolicy: IfNotPresent + environment: + SELFMEDIA_TTS_BASE_URL: http://152.53.229.148:24317 command: - /usr/bin/tini - -- @@ -234,6 +236,10 @@ delivery: - 192.168.0.0/16 - 169.254.169.254/32 - 152.53.229.148/32 + allowedTcpEndpoints: + - cidr: 152.53.229.148/32 + ports: + - 24317 codex: package: "@openai/codex" version: 0.144.1 @@ -281,6 +287,8 @@ delivery: namespace: selfmedia-production runtime: serviceAccountName: selfmedia-production + environment: + SELFMEDIA_TTS_BASE_URL: "" service: name: selfmedia-production publicExposure: @@ -306,6 +314,8 @@ delivery: secretName: selfmedia-production-codex codex: supervisorStatePath: /home/codex/.codex/state/selfmedia-production-supervisor.json + networkPolicy: + allowedTcpEndpoints: [] cutover: targets: NC01: diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8_Task_Report.md new file mode 100644 index 00000000..186f6179 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8_Task_Report.md @@ -0,0 +1,103 @@ +# R8 任务报告 + +## 任务定位 + +- MDTODO:`R8`。 +- Issue:`pikasTech/unidesk#2165`。 +- PR:`pikasTech/unidesk#2168`。 +- 本轮范围:仅处理主代理 blocker comment `#issuecomment-4978743309`。 +- Target:`NC01`。 +- targetWorkspace:`/root/unidesk/.worktree/2165-pac-status-visible`。 +- ref:`fix/2165-pac-status-visible`。 + +## 根因 + +- 首轮修复已建立稳定的 typed observation,使 PaC `status/history/debug-step` 与 AgentRun `control-plane status` 在 timeout、remote failure、empty response 和 parse failure 时不再以空 stdout 结束。 +- 首轮仍把 PaC 只读 capture 预算 `25000ms` 与 AgentRun status capture 预算 `20000ms` 写成 TypeScript 常量。 +- 这些预算属于可调运维事实;源码常量绕过 owning YAML,无法披露来源路径,也无法在选中对象缺失预算时 fail-closed。 +- `18d0bd89` 已把预算迁入 YAML,但只给默认 `nc01-v02` lane 声明 AgentRun 预算;parser 会校验 `controlPlane.lanes` 的全部对象,因此 `nc01-release` 缺失 `status.readOnlyCapture` 时,`agentrun control-plane status` 在任何目标选择前即失败。 +- AgentRun 默认文本只显示嵌套 PaC timeout,未披露选中 lane 的 budget path;PaC observation 实际返回 `configPath`,其 TypeScript 接口却漏声明该字段。 +- `b82b4ecf` 虽已完成 YAML 预算迁移和原入口复测,但 PaC parser 仍用 `integerInRange(..., 1000, 120000)` 限制 `observability.readOnlyCapture.timeoutMs`。 +- 该 `120000ms` 上限仍是源码中的运维策略,会拒绝 owning YAML 明确声明的更大正整数预算,因此 YAML 尚未成为完整 authority。 + +## 官方最佳实践 + +- 遵循仓库 `AGENTS.md` 与 `docs/reference/yaml-first-ops.md`:owning YAML 是可调运维事实的唯一真相,parser 只做结构与类型校验,代码只消费已验证值。 +- 遵循 `docs/reference/cli.md`:远端 timeout、空响应和解析失败必须形成有界 typed 输出,并保留稳定退出语义与只读 drill-down。 +- 遵循 Issue #2165 的 P0 SPEC:Kubernetes、Tekton、Argo 和既有 runtime 聚合器继续作为状态 authority;预算配置不新增状态源、缓存 authority 或交付 mutation。 + +## Current Control Flow + +1. CLI 读取 PaC 或 AgentRun YAML。 +2. parser 解析目标、consumer 或全部 lane。 +3. `18d0bd89` 后 capture 已使用 YAML timeout,但 AgentRun 只有一个 lane 声明预算,导致 parser 在发起只读 capture 前失败。 +4. PaC text/JSON 已披露 timeout 与 config path;AgentRun JSON 有 `readCaptureBudget`,默认文本未披露该字段。 +5. 嵌套 PaC 与 runtime capture 串行执行;当前有效预算为 `25000ms + 20000ms`,需要用原入口实测其是否与外层 `trans` 60 秒短连接竞争。 + +## Desired Control Flow + +1. PaC parser 必填读取 `config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs`。 +2. AgentRun 每个 owning lane 都必须声明 `config/agentrun.yaml#controlPlane.lanes..status.readOnlyCapture.timeoutMs`,parser 对全部 lane 必填校验且不补默认值。 +3. parser 只校验预算字段结构和正整数类型,将预算值与精确 config path 放入 typed spec,不提供源码默认值或上限。 +4. capture 只使用已验证的 YAML 预算。 +5. PaC text/JSON observation 与 AgentRun status text/JSON 摘要同时披露 config path 和实际 timeout。 +6. 缺失字段时 parser 在发起远端读取前失败,并指出缺失 YAML 路径。 +7. status JSON 显式声明 `mutation: false`;失败保持非空 typed 结果,不触发 PipelineRun、Argo、mirror 或 runtime 写操作。 + +## 修改 + +- `config/platform-infra/pipelines-as-code.yaml` + - 新增 `observability.readOnlyCapture.timeoutMs: 25000`。 +- `scripts/src/platform-infra-pipelines-as-code.ts` + - 删除 `pacReadOnlyCaptureTimeoutMs` 源码常量。 + - parser 必填校验预算并记录精确 config path。 + - `status/history/debug-step` capture 使用 YAML 预算。 + - typed observation 与默认 text 输出披露预算路径和实际数值。 +- `config/agentrun.yaml` + - 在 `controlPlane.lanes.nc01-v02.status.readOnlyCapture.timeoutMs` 与 `controlPlane.lanes.nc01-release.status.readOnlyCapture.timeoutMs` 分别声明 `20000`,保证所有 owning lane 通过必填 parser。 +- `scripts/src/agentrun-lanes.ts` + - lane parser 必填解析 status capture 预算与精确 config path。 +- `scripts/src/agentrun/control-plane.ts` + - 删除 `agentRunStatusCaptureTimeoutMs` 源码常量。 + - source、runtime 和 git-mirror 只读 capture 使用选中 lane 的 YAML 预算。 + - status 结果披露 `readCaptureBudget`。 + - status 结果显式披露 `mutation: false`。 +- `scripts/src/agentrun/public-exposure.ts` + - 默认文本新增 `AGENTRUN READ CAPTURE BUDGET`,披露选中 lane 的实际 timeout 与 config path。 + - 嵌套 `PAC READ OBSERVATION` 同时披露 PaC budget config path。 +- `scripts/src/platform-infra-pipelines-as-code.ts` + - 补齐 `PacReadOnlyCaptureObservation.configPath` 类型声明,使 typed JSON 与 TypeScript 合同一致。 + - 删除 `observability.readOnlyCapture.timeoutMs` 的 `120000ms` 代码上限,只保留 owning YAML 字段的正整数与结构校验。 + +## 验证证据 + +- 定向 Bun 测试:`7 pass / 0 fail`,覆盖 PaC typed observation 与 AgentRun text 两层 YAML budget path。 +- `bun --check` 覆盖修改的 parser、PaC、AgentRun control-plane、renderer 与 capture helper。 +- PaC status JSON:约 `28s` 返回 `status=timeout`、`timeoutMs=25000`、PaC config path、`mutation=false`,exit `1`,stdout 非空。 +- PaC history text:约 `29s` 返回 `pac-read-timeout`、PaC config path 与只读 Next,exit `1`,stdout 非空。 +- PaC debug-step text:约 `3s` 成功,`34/34` fixtures,通过同一 YAML budget path,exit `0`。 +- AgentRun status text:内部 PaC `25609ms`、runtime `20150ms`、total `45759ms`;外层 `trans` 总耗时约 `47.273s`,在 60 秒短连接预算内稳定返回,披露 PaC `25000ms` 与 lane `20000ms` 两层 config path,exit `1`,stdout 非空。 +- AgentRun `--raw` JSON:约 `45.242s` 返回;完整 JSON 的 `readCaptureBudget` 与 `summary.pac.readObservation` 分别披露 AgentRun/PaC config path,且 status 结果声明 `mutation=false`。 +- `git diff --check` 验证补丁格式。 +- 不落盘 parser 探针确认 `120001ms` 被接受,`0` 仍以 `must be positive` 拒绝,证明预算上限完全由 owning YAML 决定。 +- 复核 PaC `status/history/debug-step` typed observation 与 AgentRun control-plane/RunnerJob 观察路径:timeout、remote failure、empty response、parse failure 和对象缺失均保留有界摘要、elapsed/first break、只读 Next 或对象存在性证据,结果继续声明 `mutation=false`。 +- 所有目标源码、Git、验证和原入口观察均通过 `trans NC01:/root/unidesk/.worktree/2165-pac-status-visible ...` 执行。 + +## 取消与超时结论 + +- 上次任务取消不能归因于当前 CLI 嵌套预算:修正 YAML 完整性后,最慢 AgentRun 原入口内部总耗时约 `45.759s`,外层 `trans` 约 `47.273s`,未触发 60 秒短连接 timeout。 +- 当前链路确实串行消费 PaC `25000ms` 与 runtime `20000ms`,但仍保留约 14 秒外层余量;本轮没有证据支持新增 job、锁、租约、第二状态库或额外 authority。 +- 取消更符合 Artificer/session 外部终止,而不是 CLI 预算嵌套 first break;本报告只记录可复现证据,不猜测未观测的调度原因。 + +## 权限与变更边界 + +- 保留现有 typed observation、即时 progress、只读 Next 和非 mutation 行为。 +- 未增加 env、ConfigMap、数据库、缓存或第二状态源 authority。 +- 未增加合同、租约、安全围栏或锁。 +- 未执行 Vitest、PK01 操作、人工 PipelineRun、Argo、mirror 或其他交付 mutation。 +- 不自行合并 PR;管理性结论由主代理审核。 + +## 剩余边界 + +- Issue #2167 不属于 R8 当前 blocker 修复范围,本轮不扩展其 authority、实现或验收。 +- PR 合并与后续交付由主代理审核后决定。 diff --git a/docs/MDTODO/pr-merge-driven-automatic-delivery.md b/docs/MDTODO/pr-merge-driven-automatic-delivery.md index ffa56544..61f02ec3 100644 --- a/docs/MDTODO/pr-merge-driven-automatic-delivery.md +++ b/docs/MDTODO/pr-merge-driven-automatic-delivery.md @@ -110,7 +110,7 @@ 建立 [UniDesk #2010](https://github.com/pikasTech/unidesk/issues/2010):跟随 release branch 的独立 production PaC/Tekton/GitOps/Argo、namespace、host PostgreSQL database/role/Secret/migration ledger、Kafka consumer group 与 https://hwlab.pikapython.com YAML publicExposure;release 只从完成 P0 Kafka 纠偏并验证的 v0.3 commit 初始化,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R7.2_Task_Report.md)。 -## R8 [in_progress] +## R8 [completed] 解决 [UniDesk #2165](https://github.com/pikasTech/unidesk/issues/2165):修复 PaC `status/history/debug-step` 与 AgentRun control-plane 状态命令在远端 timeout、空 payload、解析失败或异常时以空 stdout/不明 exit 结束的问题;保持目标 Kubernetes/Tekton/Argo/runtime 为唯一状态 authority,所有成功、partial、timeout 和 failure 都输出有界 typed 摘要、耗时、first break 与只读下钻,禁止第二状态源、无界 dump、交付 mutation、PK01 修改、合同/租约/围栏和 Vitest,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R8_Task_Report.md)。 diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index 8fa8d13b..dbfc0828 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -173,6 +173,12 @@ export interface AgentRunLaneSpec { readonly nodeKubeRoute: string; readonly nodeUnideskWorkspace: string; readonly version: string; + readonly status: { + readonly readOnlyCapture: { + readonly timeoutMs: number; + readonly configPath: string; + }; + }; readonly source: { readonly statusMode: "host-worktree" | "k3s-git-mirror"; readonly repository: string; @@ -677,6 +683,8 @@ function parseLanes(input: Record, nodes: Record, configPath: string): AgentRunLaneSpec { const path = `${configPath}.controlPlane.lanes.${lane}`; + const status = recordField(input, "status", path); + const readOnlyCapture = recordField(status, "readOnlyCapture", `${path}.status`); const source = recordField(input, "source", path); const runtime = recordField(input, "runtime", path); const ci = recordField(input, "ci", path); @@ -694,6 +702,12 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record): Promise> { const spec = target.spec; const consumerId = authority.consumer.consumerId; - const pacStatus = record(await runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"])); + const pacProbe = await timedStatusStage("pac", () => runPlatformInfraPipelinesAsCodeCommand(config, ["status", "--target", spec.nodeId, "--consumer", consumerId, "--full"])); + const pacStatus = record(pacProbe.value); const pacSummary = record(pacStatus.summary); + const pacReadObservation = record(pacStatus.observation); const latestPipelineRun = record(pacSummary.latestPipelineRun); const artifact = record(pacSummary.artifact); const pacArgo = record(pacSummary.argo); const pipelineRunName = options.pipelineRun ?? stringOrNull(latestPipelineRun.name); - const runtimeProbe = await timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)])); + const runtimeProbe = await timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })); const runtimePayload = captureJsonPayload(runtimeProbe.value); const manager = record(runtimePayload.manager); const database = record(runtimePayload.database); @@ -479,6 +481,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: pac: { ready: pacSummary.ready ?? false, webhookCount: pacSummary.webhookCount ?? null, + readObservation: pacReadObservation, latestPipelineRun: { name: pipelineRunName, status: latestPipelineRun.status ?? null, @@ -524,7 +527,9 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: ok: runtimeProbe.value.exitCode === 0 && pacStatus.ok === true && blockers.length === 0, command: "agentrun control-plane status", mode: "pipelines-as-code-migrated-lane", + mutation: false, configPath: target.configPath, + readCaptureBudget: spec.status.readOnlyCapture, target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec), summary, alignment: { @@ -539,8 +544,9 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options: migration, }, timings: { + pacMs: pacProbe.elapsedMs, runtimeMs: runtimeProbe.elapsedMs, - totalMs: runtimeProbe.elapsedMs, + totalMs: pacProbe.elapsedMs + runtimeProbe.elapsedMs, }, disclosure: { output: options.full || options.raw ? "full" : "compact-summary", @@ -636,8 +642,8 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio if (deliveryAuthority.kind === "pac-pr-merge") return await statusPipelinesAsCodeLane(config, options, target, deliveryAuthority); if (deliveryAuthority.kind === "unknown") return unknownDeliveryAuthorityStatus(options, target, deliveryAuthority); const sourceProbe = await timedStatusStage("source", () => spec.source.statusMode === "k3s-git-mirror" - ? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)]) - : capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)])); + ? capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneK3sSourceStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs }) + : capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })); const sourcePayload = captureJsonPayload(sourceProbe.value); const branchTipCommit = stringOrNull(sourcePayload.remoteBranchCommit) ?? stringOrNull(sourcePayload.localHead); const initialSourceCommit = options.sourceCommit @@ -645,8 +651,8 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio ?? stringOrNull(sourcePayload.localHead); const pipelineRunName = options.pipelineRun ?? (initialSourceCommit ? agentRunPipelineRunName(spec, initialSourceCommit) : null); const [runtimeProbe, mirrorProbe] = await Promise.all([ - timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)])), - timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)])), + timedStatusStage("runtime", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneRuntimeStatusScript(spec, pipelineRunName)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })), + timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)], { runtimeTimeoutMs: spec.status.readOnlyCapture.timeoutMs })), ]); const runtimePayload = captureJsonPayload(runtimeProbe.value); const mirrorPayload = captureJsonPayload(mirrorProbe.value); @@ -897,7 +903,9 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio ok: sourceProbe.value.exitCode === 0 && runtimeProbe.value.exitCode === 0 && mirrorProbe.value.exitCode === 0 && blockers.length === 0, command: "agentrun control-plane status", mode: "yaml-declared-node-lane", + mutation: false, configPath: target.configPath, + readCaptureBudget: spec.status.readOnlyCapture, target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec), summary: options.full || options.raw ? detailedSummary : commanderSummary, alignment: { diff --git a/scripts/src/agentrun/public-exposure.ts b/scripts/src/agentrun/public-exposure.ts index 7c3feb81..8877bfad 100644 --- a/scripts/src/agentrun/public-exposure.ts +++ b/scripts/src/agentrun/public-exposure.ts @@ -75,11 +75,14 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record `- ${warning}`)].join("\n"), "", "NEXT", @@ -127,7 +148,7 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record line !== null); return renderedCliResult(result.ok !== false, "agentrun control-plane status", `${lines.join("\n")}\n`); } @@ -163,6 +184,12 @@ export function renderAgentRunControlPlaneStatusSummary(result: Record `- ${warning}`)].join("\n"), "", "NEXT", diff --git a/scripts/src/agentrun/utils.ts b/scripts/src/agentrun/utils.ts index b22068c7..83d5a770 100644 --- a/scripts/src/agentrun/utils.ts +++ b/scripts/src/agentrun/utils.ts @@ -137,8 +137,8 @@ export type AgentRunBridgeCaptureResult = SshCaptureResult & { bridgeExecution?: export let localBackendCoreStatusCache: LocalBackendCoreStatus | null = null; -export async function capture(config: UniDeskConfig, target: string, args: string[]): Promise { - const result = await runSshCommandCapture(config, target, args); +export async function capture(config: UniDeskConfig, target: string, args: string[], options: { runtimeTimeoutMs?: number } = {}): Promise { + const result = await runSshCommandCapture(config, target, args, undefined, options); const plan = agentRunBridgeCapturePlan(config, target); return attachBridgeExecution(result, { ...plan, reason: `unified-ssh-capture:${plan.reason}` }); } diff --git a/scripts/src/platform-infra-pac-failure-evidence.test.ts b/scripts/src/platform-infra-pac-failure-evidence.test.ts index 63c28769..d877dc70 100644 --- a/scripts/src/platform-infra-pac-failure-evidence.test.ts +++ b/scripts/src/platform-infra-pac-failure-evidence.test.ts @@ -5,7 +5,8 @@ import { resolve } from "node:path"; import { createRequire } from "node:module"; import { sentinelPublishImageBuildShell } from "./hwlab-node-web-sentinel-cicd-jobs"; import type { SentinelCicdState } from "./hwlab-node-web-sentinel-cicd-shared"; -import { compactHistoryJson, compactStatusSummary, renderDebugStep, renderHistory, renderStatus } from "./platform-infra-pipelines-as-code"; +import { renderAgentRunControlPlaneStatusSummary } from "./agentrun/public-exposure"; +import { compactHistoryJson, compactStatusSummary, pacReadOnlyCaptureObservation, renderDebugStep, renderHistory, renderStatus } from "./platform-infra-pipelines-as-code"; const require = createRequire(import.meta.url); const { parsePacLogRecords } = require("../native/cicd/pac-status-evaluator.cjs") as { @@ -55,6 +56,73 @@ describe("PaC 失败证据合同", () => { expect(compactStatus).toMatchObject({ artifact: { traceId: row.traceId, firstBreak: row.firstBreak, error: row.error } }); }); + test("只读 capture timeout 保留阶段、预算、首断点与下一步", () => { + const observation = pacReadOnlyCaptureObservation({ + action: "history", + targetId: "NC01", + consumerId: "agentrun-nc01-v02", + result: { exitCode: 124, stdout: "", stderr: "UNIDESK_SSH_RUNTIME_TIMEOUT" }, + parsed: null, + elapsedMs: 25001, + timeoutMs: 25000, + configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs", + next: "bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer agentrun-nc01-v02 --limit 5", + }); + expect(observation).toMatchObject({ + ok: false, + status: "timeout", + target: "NC01", + consumer: "agentrun-nc01-v02", + stage: "history-remote-observe", + elapsedMs: 25001, + timeoutMs: 25000, + configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs", + exitCode: 124, + firstBreak: { code: "pac-read-timeout", phase: "remote-read" }, + }); + const output = renderHistory({ rows: [], config: {}, historyErrors: [], deliveryBudget: {}, observation, next: {} }).renderedText; + expect(output).toContain("READ OBSERVATION"); + expect(output).toContain("history-remote-observe"); + expect(output).toContain("pac-read-timeout"); + expect(output).toContain("config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs"); + expect(output).toContain("next-read-only:"); + }); + + test("AgentRun status 文本披露 lane 与 PaC 两层 YAML capture 预算", () => { + const output = renderAgentRunControlPlaneStatusSummary({ + ok: false, + target: { node: { id: "NC01" }, lane: "nc01-v02" }, + summary: { + migration: { migrated: true, sourceAuthority: "gitea-pipelines-as-code" }, + pac: { + readObservation: { + stage: "status-remote-observe", + status: "timeout", + elapsedMs: 25001, + timeoutMs: 25000, + configPath: "config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs", + exitCode: 124, + firstBreak: { code: "pac-read-timeout" }, + next: "bun scripts/cli.ts platform-infra pipelines-as-code status --full", + }, + }, + blockers: ["pac-not-ready"], + warnings: [], + nextAction: {}, + }, + readCaptureBudget: { + timeoutMs: 20000, + configPath: "config/agentrun.yaml.controlPlane.lanes.nc01-v02.status.readOnlyCapture.timeoutMs", + }, + next: {}, + timings: {}, + }).renderedText; + expect(output).toContain("PAC READ OBSERVATION"); + expect(output).toContain("config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs"); + expect(output).toContain("AGENTRUN READ CAPTURE BUDGET"); + expect(output).toContain("config/agentrun.yaml.controlPlane.lanes.nc01-v02.status.readOnlyCapture.timeoutMs"); + }); + test("feature-config warning 在 status、history、debug-step 同构有界投影", () => { const observation = extractPacSourceObservation([ { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index d04a0d42..44f97944 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -110,6 +110,10 @@ export interface PacConfig { deliveryTiming: PacDeliveryTimingPolicy; observability: { configRef: string; + readOnlyCapture: { + timeoutMs: number; + configPath: string; + }; tracesEndpoint: string; serviceName: string; propagation: string[]; @@ -293,6 +297,26 @@ interface SecretMaterial { webhookPath: string; } +type PacReadOnlyAction = "status" | "history" | "debug-step"; + +interface PacReadOnlyCaptureObservation { + readonly ok: boolean; + readonly status: "succeeded" | "timeout" | "remote-failure" | "empty-response" | "parse-failure"; + readonly target: string; + readonly consumer: string; + readonly stage: string; + readonly elapsedMs: number; + readonly timeoutMs: number; + readonly configPath: string; + readonly exitCode: number; + readonly stdoutBytes: number; + readonly stderrBytes: number; + readonly partial: boolean; + readonly firstBreak: { readonly code: string; readonly phase: string }; + readonly next: string; + readonly valuesPrinted: false; +} + export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { const [action = "plan"] = args; if (action === "help" || action === "--help") return help(args[1] ?? null); @@ -510,6 +534,7 @@ export function parsePacConfigDocument( const display = y.objectField(root, "display", ""); const deliveryTiming = y.objectField(root, "deliveryTiming", ""); const observability = y.objectField(root, "observability", ""); + const readOnlyCapture = y.objectField(observability, "readOnlyCapture", "observability"); const observabilitySampling = y.objectField(observability, "sampling", "observability"); const exporterFailure = y.objectField(observability, "exporterFailure", "observability"); const admin = y.objectField(gitea, "admin", "gitea"); @@ -549,6 +574,10 @@ export function parsePacConfigDocument( }, observability: { configRef: y.stringField(observability, "configRef", "observability"), + readOnlyCapture: { + timeoutMs: positiveInteger(readOnlyCapture, "timeoutMs", "observability.readOnlyCapture"), + configPath: `${configLabel}#observability.readOnlyCapture.timeoutMs`, + }, tracesEndpoint: urlField(observability, "tracesEndpoint", "observability"), serviceName: y.stringField(observability, "serviceName", "observability"), propagation: y.stringArrayField(observability, "propagation", "observability"), @@ -1350,14 +1379,15 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise record(observation.warning)) .filter((warning) => Object.keys(warning).length > 0); return { - ok: result.exitCode === 0 && parsed?.ok !== false && historyErrors.length === 0, + ok: observation.ok && parsed?.ok !== false && historyErrors.length === 0, action: "platform-infra-pipelines-as-code-history", mutation: false, target: targetSummary(target), @@ -1633,6 +1667,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< detailId: options.detailId, rows, deliveryBudget, + observation, historyErrors, warnings: [...pac.validationWarnings, ...deliveryWarnings], controlPlane: parsed === null ? undefined : { @@ -1646,9 +1681,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< valuesPrinted: false, }, remote: parsed === null || options.raw ? remote : undefined, - next: selection.nextConsumerId === null - ? pacNodeReadOnlyNext(target.id) - : pacReadOnlyNext(target.id, selection.nextConsumerId), + next, }; } @@ -1698,12 +1731,13 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis } const consumer = resolveConsumer(pac, selection.selectedConsumerIds[0] ?? null); const repository = resolveRepository(pac, consumer.repositoryRef); - const result = await capture(config, target.route, ["sh"], remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), "")); - const parsed = parseJsonOutput(result.stdout); + const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId); + const observed = await capturePacReadOnly(config, pac, "debug-step", target, consumer, remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), ""), stringValue(next.status)); + const { result, parsed, observation } = observed; const fixtureChecks = parsed === null ? [] : arrayRecords(parsed.checks); const fixtureDisclosure = pacDebugFixtureDisclosure(fixtureChecks, options.detailId); return { - ok: result.exitCode === 0 && parsed?.ok === true, + ok: observation.ok && parsed?.ok === true, action: "platform-infra-pipelines-as-code-debug-step", mutation: false, target: targetSummary(target), @@ -1713,12 +1747,102 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis fixtureGate: { ...fixtureDisclosure.fixtureGate, ok: parsed !== null && fixtureDisclosure.fixtureGate.ok === true }, checks: fixtureDisclosure.checks, realRun: parsed === null ? null : parsed.realRun ?? null, + observation, remote: parsed === null || options.raw ? parsed ?? compactCapture(result, { full: true }) : undefined, - next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), + next, valuesPrinted: false, }; } +async function capturePacReadOnly( + config: UniDeskConfig, + pac: PacConfig, + action: PacReadOnlyAction, + target: PacTarget, + consumer: PacConsumer, + script: string, + next: string, +): Promise<{ readonly result: Awaited>; readonly parsed: Record | null; readonly observation: PacReadOnlyCaptureObservation }> { + const stage = `${action}-remote-observe`; + const startedAt = Date.now(); + const captureBudget = pac.observability.readOnlyCapture; + printPacReadOnlyProgress(target, consumer, stage, "started", startedAt, captureBudget); + const result = await capture(config, target.route, ["sh"], script, { runtimeTimeoutMs: captureBudget.timeoutMs }); + const parsed = parseJsonOutput(result.stdout); + const observation = pacReadOnlyCaptureObservation({ + action, + targetId: target.id, + consumerId: consumer.id, + result, + parsed, + elapsedMs: Date.now() - startedAt, + timeoutMs: captureBudget.timeoutMs, + configPath: captureBudget.configPath, + next, + }); + printPacReadOnlyProgress(target, consumer, stage, observation.status, startedAt, { + exitCode: result.exitCode, + firstBreak: observation.firstBreak.code, + }); + return { result, parsed, observation }; +} + +export function pacReadOnlyCaptureObservation(options: { + readonly action: PacReadOnlyAction; + readonly targetId: string; + readonly consumerId: string; + readonly result: { readonly exitCode: number; readonly stdout: string; readonly stderr: string }; + readonly parsed: Record | null; + readonly elapsedMs: number; + readonly timeoutMs: number; + readonly configPath: string; + readonly next: string; +}): PacReadOnlyCaptureObservation { + const stdoutBytes = Buffer.byteLength(options.result.stdout, "utf8"); + const stderrBytes = Buffer.byteLength(options.result.stderr, "utf8"); + const timedOut = options.result.exitCode === 124 || /ssh-runtime-timeout|top-level runtime limit/iu.test(options.result.stderr); + const status = timedOut + ? "timeout" + : options.result.exitCode !== 0 + ? "remote-failure" + : options.result.stdout.trim().length === 0 + ? "empty-response" + : options.parsed === null + ? "parse-failure" + : "succeeded"; + const firstBreakCode = status === "succeeded" ? "none" : `pac-read-${status}`; + return { + ok: status === "succeeded", + status, + target: options.targetId, + consumer: options.consumerId, + stage: `${options.action}-remote-observe`, + elapsedMs: options.elapsedMs, + timeoutMs: options.timeoutMs, + configPath: options.configPath, + exitCode: options.result.exitCode, + stdoutBytes, + stderrBytes, + partial: stdoutBytes > 0 || stderrBytes > 0, + firstBreak: { code: firstBreakCode, phase: "remote-read" }, + next: options.next, + valuesPrinted: false, + }; +} + +function printPacReadOnlyProgress(target: PacTarget, consumer: PacConsumer, stage: string, status: string, startedAt: number, detail: Record): void { + process.stderr.write(`${JSON.stringify({ + event: "platform-infra.pipelines-as-code.read.progress", + target: target.id, + consumer: consumer.id, + stage, + status, + elapsedMs: Date.now() - startedAt, + ...detail, + valuesPrinted: false, + })}\n`); +} + export function pacDebugFixtureDisclosure(fixtureChecks: readonly Record[], detailId: string | null): { readonly fixtureGate: Record; readonly checks: readonly Record[]; @@ -2342,6 +2466,7 @@ function compactStatusJson(result: Record): Record, includeTaskD consumers: result.consumers, detailId: result.detailId, deliveryBudget: result.deliveryBudget, + observation: result.observation, rows: arrayRecords(result.rows).map((row) => { const taskRuns = record(row.taskRuns); const artifact = record(row.artifact); @@ -2718,6 +2844,7 @@ export function renderStatus(result: Record): RenderedCliResult ]]), ` hint: ${compactLine(stringValue(diagnostics.hint))}`, ` pipeline-run: ${latest.name === undefined ? "-" : `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${stringValue(record(result.target).id)} --id ${stringValue(latest.name)}`}`, + ...renderPacReadObservation(result), "", "NEXT", ` full: ${stringValue(record(result.next).status)} --full`, @@ -2912,6 +3039,7 @@ export function renderHistory(result: Record): RenderedCliResul ]; }))), ...(detailId === "-" || rows.length !== 1 ? [] : renderHistoryDetail(rows[0] ?? {})), + ...renderPacReadObservation(result), "", "NEXT", rows.length > 0 && detailId === "-" ? ` detail: ${stringValue(record(result.next).history)} --id ${stringValue(rows[0]?.id ?? rows[0]?.pipelineRun)}` : null, @@ -2987,6 +3115,7 @@ export function renderDebugStep(result: Record): RenderedCliRes `${stringValue(item.expectedOk)}/${stringValue(item.expectedCode)}`, `${stringValue(item.actualOk)}/${stringValue(item.actualCode)}`, ]))), + ...renderPacReadObservation(result), "", "NEXT", ` status: ${stringValue(record(result.next).status)}`, @@ -2994,6 +3123,29 @@ export function renderDebugStep(result: Record): RenderedCliRes return rendered(result, "platform-infra pipelines-as-code debug-step", lines); } +function renderPacReadObservation(result: Record): string[] { + const observation = record(result.observation); + if (Object.keys(observation).length === 0) return []; + const firstBreak = record(observation.firstBreak); + return [ + "", + "READ OBSERVATION", + ...table(["TARGET", "CONSUMER", "STAGE", "STATUS", "ELAPSED_MS", "TIMEOUT_MS", "EXIT", "PARTIAL", "FIRST_BREAK"], [[ + stringValue(observation.target), + stringValue(observation.consumer), + stringValue(observation.stage), + stringValue(observation.status), + stringValue(observation.elapsedMs), + stringValue(observation.timeoutMs), + stringValue(observation.exitCode), + boolText(observation.partial), + stringValue(firstBreak.code), + ]]), + ` budget-config: ${stringValue(observation.configPath)}`, + ` next-read-only: ${stringValue(observation.next)}`, + ]; +} + function parseApplyOptions(args: string[]): ApplyOptions { const commonArgs: string[] = []; let confirm = false;