fix: 将只读状态预算收敛到 YAML

This commit is contained in:
Codex
2026-07-15 11:25:24 +02:00
parent fe23d35c6e
commit 18d0bd89b2
7 changed files with 131 additions and 14 deletions
+3
View File
@@ -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
@@ -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:
@@ -0,0 +1,79 @@
# 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。
## 官方最佳实践
- 遵循仓库 `AGENTS.md``docs/reference/yaml-first-ops.md`owning YAML 是可调运维事实的唯一真相,parser 只做结构与类型校验,代码只消费已验证值。
- 遵循 `docs/reference/cli.md`:远端 timeout、空响应和解析失败必须形成有界 typed 输出,并保留稳定退出语义与只读 drill-down。
- 遵循 Issue #2165 的 P0 SPECKubernetes、Tekton、Argo 和既有 runtime 聚合器继续作为状态 authority;预算配置不新增状态源、缓存 authority 或交付 mutation。
## Current Control Flow
1. CLI 读取 PaC 或 AgentRun YAML。
2. parser 解析目标、consumer 或 lane。
3. capture 调用使用 TypeScript 文件内固定的 timeout 常量。
4. typed observation 披露实际 timeout 数值,但无法证明该数值的 owning config path。
5. YAML 缺失预算时仍可继续执行,不能按当前选中对象 fail-closed。
## Desired Control Flow
1. PaC parser 必填读取 `config/platform-infra/pipelines-as-code.yaml#observability.readOnlyCapture.timeoutMs`
2. AgentRun lane parser 必填读取 `config/agentrun.yaml#controlPlane.lanes.<lane>.status.readOnlyCapture.timeoutMs`
3. parser 将预算值与精确 config path 放入 typed spec,不提供源码默认值。
4. capture 只使用已验证的 YAML 预算。
5. PaC text/JSON observation 与 AgentRun status text/JSON 摘要同时披露 config path 和实际 timeout。
6. 缺失字段时 parser 在发起远端读取前失败,并指出缺失 YAML 路径。
## 修改
- `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` 声明 `20000`
- `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`
## 验证证据
- 定向 Bun 测试验证 PaC typed observation 保留 timeout、config path、首断点与只读 Next。
- TypeScript 语法检查覆盖修改的 parser、PaC 与 AgentRun control-plane 文件。
- YAML 读取和原 CLI 入口验证证明预算由选中配置解析,并在 text/JSON 中可见。
- `git diff --check` 验证补丁格式。
- 所有目标源码、Git、验证和原入口观察均通过 `trans NC01:/root/unidesk/.worktree/2165-pac-status-visible ...` 执行。
## 权限与变更边界
- 保留现有 typed observation、即时 progress、只读 Next 和非 mutation 行为。
- 未增加 env、ConfigMap、数据库、缓存或第二状态源 authority。
- 未增加合同、租约、安全围栏或锁。
- 未执行 Vitest、PK01 操作、人工 PipelineRun、Argo、mirror 或其他交付 mutation。
- 不自行合并 PR;管理性结论由主代理审核。
## 剩余边界
- Issue #2167 不属于 R8 当前 blocker 修复范围,本轮不扩展其 authority、实现或验收。
- PR 合并与后续交付由主代理审核后决定。
+14
View File
@@ -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<string, unknown>, nodes: Record<string, AgentR
function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, unknown>, 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<string, u
nodeKubeRoute: node.kubeRoute,
nodeUnideskWorkspace: node.unideskWorkspace,
version,
status: {
readOnlyCapture: {
timeoutMs: integerField(readOnlyCapture, "timeoutMs", `${path}.status.readOnlyCapture`),
configPath: `${path}.status.readOnlyCapture.timeoutMs`,
},
},
source: {
statusMode,
repository: stringField(source, "repository", `${path}.source`),
+7 -7
View File
@@ -47,8 +47,6 @@ import { compactAgentRunLaneStatusTarget, compactLaneSecretsStatus } from "./tri
import { capture, captureJsonPayload, compactCapture, record, stringOrNull, timedStatusStage } from "./utils";
import { yamlLaneK3sSourceStatusScript, yamlLaneRuntimeStatusScript, yamlLaneSourceStatusScript } from "./yaml-lane";
const agentRunStatusCaptureTimeoutMs = 20_000;
export function parseSecretSyncOptions(args: string[]): SecretSyncOptions {
const base = parseConfirmOptions(args);
let node: string | null = null;
@@ -419,7 +417,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
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)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs }));
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);
@@ -530,6 +528,7 @@ export async function statusPipelinesAsCodeLane(config: UniDeskConfig, options:
command: "agentrun control-plane status",
mode: "pipelines-as-code-migrated-lane",
configPath: target.configPath,
readCaptureBudget: spec.status.readOnlyCapture,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary,
alignment: {
@@ -642,8 +641,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)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })
: capture(config, `${spec.nodeRoute}:${spec.source.workspace}`, ["sh", "--", yamlLaneSourceStatusScript(spec)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs }));
? 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
@@ -651,8 +650,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)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })),
timedStatusStage("git-mirror", () => capture(config, spec.nodeKubeRoute, ["sh", "--", yamlLaneGitMirrorStatusScript(spec)], { runtimeTimeoutMs: agentRunStatusCaptureTimeoutMs })),
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);
@@ -904,6 +903,7 @@ export async function statusYamlLane(config: UniDeskConfig, options: StatusOptio
command: "agentrun control-plane status",
mode: "yaml-declared-node-lane",
configPath: target.configPath,
readCaptureBudget: spec.status.readOnlyCapture,
target: options.full || options.raw ? agentRunLaneSummary(spec) : compactAgentRunLaneStatusTarget(spec),
summary: options.full || options.raw ? detailedSummary : commanderSummary,
alignment: {
@@ -64,6 +64,7 @@ describe("PaC 失败证据合同", () => {
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({
@@ -74,6 +75,7 @@ describe("PaC 失败证据合同", () => {
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" },
});
@@ -81,6 +83,7 @@ describe("PaC 失败证据合同", () => {
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:");
});
@@ -52,7 +52,6 @@ const remoteScriptFile = rootPath("scripts", "src", "platform-infra-pipelines-as
const evaluatorFile = rootPath("scripts", "native", "cicd", "pac-status-evaluator.cjs");
const sourceArtifactRuntimeObserverFile = rootPath("scripts", "native", "cicd", "pac-source-artifact-runtime.mjs");
const fieldManager = "unidesk-platform-infra-pipelines-as-code";
const pacReadOnlyCaptureTimeoutMs = 25_000;
const y = createYamlFieldReader(configLabel);
function kubernetesLabelValue(value: string): string {
@@ -111,6 +110,10 @@ export interface PacConfig {
deliveryTiming: PacDeliveryTimingPolicy;
observability: {
configRef: string;
readOnlyCapture: {
timeoutMs: number;
configPath: string;
};
tracesEndpoint: string;
serviceName: string;
propagation: string[];
@@ -530,6 +533,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");
@@ -569,6 +573,10 @@ export function parsePacConfigDocument(
},
observability: {
configRef: y.stringField(observability, "configRef", "observability"),
readOnlyCapture: {
timeoutMs: integerInRange(readOnlyCapture.timeoutMs, 1_000, 120_000, "observability.readOnlyCapture.timeoutMs"),
configPath: `${configLabel}#observability.readOnlyCapture.timeoutMs`,
},
tracesEndpoint: urlField(observability, "tracesEndpoint", "observability"),
serviceName: y.stringField(observability, "serviceName", "observability"),
propagation: y.stringArrayField(observability, "propagation", "observability"),
@@ -1371,7 +1379,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
const repository = resolveRepository(pac, consumer.repositoryRef);
const secrets = ensureSecrets(pac, false);
const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId);
const observed = await capturePacReadOnly(config, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""), `${stringValue(next.status)} --full`);
const observed = await capturePacReadOnly(config, pac, "status", target, consumer, remoteScript("status", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, ""), `${stringValue(next.status)} --full`);
const { result, parsed, observation } = observed;
const summary = parsed === null ? null : statusSummary(parsed);
const deliveryAuthority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
@@ -1627,7 +1635,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
const next = selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId);
const observed = await capturePacReadOnly(config, "history", target, firstConsumer, remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers), stringValue(record(next).history));
const observed = await capturePacReadOnly(config, pac, "history", target, firstConsumer, remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers), stringValue(record(next).history));
const { result, parsed, observation } = observed;
const remote = parsed ?? compactCapture(result, { full: true });
const historyErrors = arrayRecords(record(remote).historyErrors);
@@ -1723,7 +1731,7 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
const consumer = resolveConsumer(pac, selection.selectedConsumerIds[0] ?? null);
const repository = resolveRepository(pac, consumer.repositoryRef);
const next = nextCommands(target.id, consumer.id, pac.defaults.consumerId);
const observed = await capturePacReadOnly(config, "debug-step", target, consumer, remoteScript("debug-step", pac, target, repository, consumer, { ...options, confirm: false, dryRun: true, wait: false }, emptySecretMaterial(), ""), stringValue(next.status));
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);
@@ -1747,6 +1755,7 @@ async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promis
async function capturePacReadOnly(
config: UniDeskConfig,
pac: PacConfig,
action: PacReadOnlyAction,
target: PacTarget,
consumer: PacConsumer,
@@ -1755,8 +1764,9 @@ async function capturePacReadOnly(
): Promise<{ readonly result: Awaited<ReturnType<typeof capture>>; readonly parsed: Record<string, unknown> | null; readonly observation: PacReadOnlyCaptureObservation }> {
const stage = `${action}-remote-observe`;
const startedAt = Date.now();
printPacReadOnlyProgress(target, consumer, stage, "started", startedAt, { timeoutMs: pacReadOnlyCaptureTimeoutMs });
const result = await capture(config, target.route, ["sh"], script, { runtimeTimeoutMs: pacReadOnlyCaptureTimeoutMs });
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,
@@ -1765,7 +1775,8 @@ async function capturePacReadOnly(
result,
parsed,
elapsedMs: Date.now() - startedAt,
timeoutMs: pacReadOnlyCaptureTimeoutMs,
timeoutMs: captureBudget.timeoutMs,
configPath: captureBudget.configPath,
next,
});
printPacReadOnlyProgress(target, consumer, stage, observation.status, startedAt, {
@@ -1783,6 +1794,7 @@ export function pacReadOnlyCaptureObservation(options: {
readonly parsed: Record<string, unknown> | null;
readonly elapsedMs: number;
readonly timeoutMs: number;
readonly configPath: string;
readonly next: string;
}): PacReadOnlyCaptureObservation {
const stdoutBytes = Buffer.byteLength(options.result.stdout, "utf8");
@@ -1806,6 +1818,7 @@ export function pacReadOnlyCaptureObservation(options: {
stage: `${options.action}-remote-observe`,
elapsedMs: options.elapsedMs,
timeoutMs: options.timeoutMs,
configPath: options.configPath,
exitCode: options.result.exitCode,
stdoutBytes,
stderrBytes,
@@ -2452,6 +2465,7 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
capabilities: record(result.config).capabilities,
warnings: result.warnings,
deliveryBudget: result.deliveryBudget,
observation: result.observation,
summary: compactStatusSummary(record(result.summary)),
next: result.next,
valuesPrinted: false,
@@ -2575,6 +2589,7 @@ export function compactHistoryJson(result: Record<string, unknown>, 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);
@@ -3125,6 +3140,7 @@ function renderPacReadObservation(result: Record<string, unknown>): string[] {
boolText(observation.partial),
stringValue(firstBreak.code),
]]),
` budget-config: ${stringValue(observation.configPath)}`,
` next-read-only: ${stringValue(observation.next)}`,
];
}