diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index 2b639b4e..85ab56e7 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -53,6 +53,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limi bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id bun scripts/cli.ts platform-infra pipelines-as-code diagnose-regression --target NC01 --consumer bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 +bun scripts/cli.ts platform-infra pipelines-as-code delivery-observe --target NC01 --consumer --source-commit bun scripts/cli.ts platform-infra pipelines-as-code closeout --target --consumer --wait --timeout 120s bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer --id @@ -102,6 +103,13 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 该入口固定只读且 `mutation=false`,不得用于同步、触发、补跑或运行面写入。 - 验收 registry cache 性能时,按 [Gitea + PaC](references/gitea-pac.md) 的冷热两次正常 PR 流程执行,不用人工 PipelineRun 或重复 status/timing 试探。 +- PaC consumer 按 source commit 做发布后取证: + - 唯一推荐入口是 `platform-infra pipelines-as-code delivery-observe --target --consumer --source-commit `; + - 一次 target-side capture 直接返回匹配的 PipelineRun、Argo sync/health 与首个退化资源或 condition、同 namespace exact-source workloads、ready/digest、首断点和 `complete|partial|blocked`; + - 固定 `remoteCaptureCount=1`、`mutation=false`,不得再手工串联 `status`、`history`、`delivery-timing` 或裸 Kubernetes 对象查询; + - `complete` 表示 PipelineRun、exact-source runtime 与 Argo 全部就绪;`partial` 保留已成立的 exact-source 证据并明确 Argo 或 runtime 首断点;`blocked` 表示目标读取失败、source 不匹配、PipelineRun 未成功或当前 runtime 未观察到该 source; + - TaskRun artifact 日志默认延后时只披露 `artifactEvidence.deferred=true`,不放宽既有 status evaluator,也不伪造 provenance。 + - 新增 PaC consumer 的首次引导: - 先执行一次 `pipelines-as-code bootstrap --dry-run`; - 确认后把同一命令改为 `--confirm`; diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8.4_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8.4_Task_Report.md new file mode 100644 index 00000000..cec01d19 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R8.4_Task_Report.md @@ -0,0 +1,24 @@ +# R8.4 任务报告 + +## 结果 + +完成 UniDesk #2510 的 PaC 发布后一次取证入口: + +- 新增 `platform-infra pipelines-as-code delivery-observe --target --consumer --source-commit `。 +- 命令复用一次既有 status target-side capture,固定 `remoteCaptureCount=1`、`mutation=false`,不查询 GitHub、不触发交付、不串联 history/timing/裸 Kubernetes 查询。 +- 一次输出有界 PipelineRun source match、Argo sync/health/首个退化资源或 condition、runtime namespace 全部 workload 的 source commit/ready/digest、首断点和 `complete|partial|blocked`。 +- status evaluator 未放宽;TaskRun 日志延后显式标记 `artifactEvidence.deferred=true`。 +- 期望副本数为 0 的声明式缩容 workload 按 ready 处理。 + +## 验证 + +- `bun test scripts/src/platform-infra-pac-delivery-observe.test.ts scripts/src/platform-infra-pac-failure-evidence.test.ts` +- `bash -n scripts/src/platform-infra-pipelines-as-code-remote.sh` +- `bun scripts/cli.ts check --syntax-only` +- `git diff --check` +- NC01/HWLAB L1 只读 smoke:单次采集约 12.1 秒,当前 source `8b57d87ff721ab7cc815ae09e5f3bd6c5de302aa` PipelineRun exact match,观察到 18 个 exact-source workloads;Argo 为 `Synced/Degraded`,因此结果正确保留为 `partial`,未误报 source commit 缺失。 +- 对已被后续发布替代的旧 source,入口返回 `blocked/pipeline-source-commit-mismatch`,保持 fail-closed。 + +## 工具调用减少 + +同类发布后取证从手工串联 status、history、delivery-timing 和多个对象查询,收敛为一次受控命令;最短路径已固化到 `unidesk-cicd` skill。 diff --git a/docs/MDTODO/pr-merge-driven-automatic-delivery.md b/docs/MDTODO/pr-merge-driven-automatic-delivery.md index 68a2c552..0858849c 100644 --- a/docs/MDTODO/pr-merge-driven-automatic-delivery.md +++ b/docs/MDTODO/pr-merge-driven-automatic-delivery.md @@ -126,7 +126,7 @@ ### R8.3 [in_progress] 继续解决 [UniDesk #2165](https://github.com/pikasTech/unidesk/issues/2165) 的 single-consumer `status` 可见性回归:远端观察只输出 started 后结束且缺少 success/partial/timeout/failure 终态摘要,而同 consumer `history` 已能返回 typed `pac-read-timeout`;保持目标 Kubernetes/Tekton/Argo/runtime 唯一 authority,不新增状态源或交付 mutation,修复所有退出分支的有界终态输出,并以一次目标侧最小 status smoke 验收,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R8.3_Task_Report.md)。 -### R8.4 +### R8.4 [completed] 解决 [UniDesk #2510](https://github.com/pikasTech/unidesk/issues/2510):修复 HWLAB PaC PipelineRun 已成功且 Gitea source authority 已 committed 后,single-consumer status 仍误报 source-commit-invalid-or-missing、id-specific history 在 25 秒预算内超时且 Argo Synced/Degraded 缺少首个资源下钻的问题;保持目标 Tekton/Argo/runtime 唯一 authority,禁止人工 PipelineRun、mirror、sync、refresh、第二 history store和放宽 evaluator,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R8.4_Task_Report.md)。 ## R9 [in_progress] diff --git a/scripts/src/platform-infra-pac-delivery-observe.test.ts b/scripts/src/platform-infra-pac-delivery-observe.test.ts new file mode 100644 index 00000000..b8e99bda --- /dev/null +++ b/scripts/src/platform-infra-pac-delivery-observe.test.ts @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import { test } from "bun:test"; + +import { projectPacDeliveryObserve, renderPacDeliveryObserve } from "./platform-infra-pac-delivery-observe"; + +const commit = "b".repeat(40); + +function fixture(overrides: Record = {}): Record { + return { + target: { id: "NC01" }, + consumer: { id: "fixture" }, + observation: { ok: true }, + summary: { + latestPipelineRun: { name: "fixture-run", status: "True", reason: "Succeeded", sourceCommit: commit }, + pipelineRuns: [{ name: "fixture-run", status: "True", reason: "Succeeded", sourceCommit: commit }], + artifact: { collector: { mode: "taskrun-status-only" } }, + argo: { sync: "Synced", health: "Healthy", revision: "a".repeat(40) }, + runtime: { + workloads: [ + { namespace: "fixture", deployment: "api", sourceCommit: commit, readyReplicas: 1, replicas: 1, digest: "sha256:abc" }, + { namespace: "fixture", deployment: "old", sourceCommit: "c".repeat(40), readyReplicas: 1, replicas: 1 }, + ], + }, + ...overrides, + }, + }; +} + +test("delivery observe completes from one exact source capture", () => { + const result = projectPacDeliveryObserve({ status: fixture(), sourceCommit: commit }); + assert.equal(result.status, "complete"); + assert.equal(result.remoteCaptureCount, 1); + assert.equal((result.pipeline as Record).sourceMatched, true); + assert.equal((result.runtime as Record).matchingWorkloadCount, 1); + assert.equal(result.firstBreak, null); + assert.match(renderPacDeliveryObserve(result).join("\n"), /REMOTE_CAPTURE_COUNT 1/u); +}); + +test("delivery observe preserves exact runtime evidence while surfacing Argo degradation", () => { + const result = projectPacDeliveryObserve({ + status: fixture({ + argo: { + sync: "OutOfSync", + health: "Degraded", + firstDegradedResource: { kind: "Deployment", namespace: "fixture", name: "worker" }, + firstCondition: { type: "ComparisonError", message: "bounded" }, + }, + }), + sourceCommit: commit, + }); + assert.equal(result.status, "partial"); + assert.equal(result.ok, true); + assert.equal((result.firstBreak as Record).code, "argo-not-ready"); +}); + +test("delivery observe blocks a mismatched source commit without accepting another workload", () => { + const result = projectPacDeliveryObserve({ status: fixture(), sourceCommit: "d".repeat(40) }); + assert.equal(result.status, "blocked"); + assert.equal(result.ok, false); + assert.equal((result.firstBreak as Record).code, "pipeline-source-commit-mismatch"); +}); + +test("delivery observe selects an exact source from the bounded status capture", () => { + const older = "d".repeat(40); + const status = fixture(); + const summary = status.summary as Record; + summary.latestPipelineRun = { name: "newer-run", status: "True", sourceCommit: commit }; + summary.pipelineRuns = [ + summary.latestPipelineRun, + { name: "older-run", status: "True", reason: "Succeeded", sourceCommit: older }, + ]; + summary.runtime = { + workloads: [{ namespace: "fixture", deployment: "api", sourceCommit: older, readyReplicas: 1, replicas: 1 }], + }; + const result = projectPacDeliveryObserve({ status, sourceCommit: older }); + assert.equal(result.status, "complete"); + assert.equal((result.pipeline as Record).id, "older-run"); + assert.equal((result.pipeline as Record).selectedFrom, "bounded-status-history"); +}); + +test("delivery observe treats an intentionally scaled-to-zero workload as ready", () => { + const status = fixture(); + (status.summary as Record).runtime = { + workloads: [{ namespace: "fixture", deployment: "idle", sourceCommit: commit, readyReplicas: 0, replicas: 0 }], + }; + const result = projectPacDeliveryObserve({ status, sourceCommit: commit }); + assert.equal(result.status, "complete"); + assert.equal((result.runtime as Record).ready, true); +}); diff --git a/scripts/src/platform-infra-pac-delivery-observe.ts b/scripts/src/platform-infra-pac-delivery-observe.ts new file mode 100644 index 00000000..58f11a46 --- /dev/null +++ b/scripts/src/platform-infra-pac-delivery-observe.ts @@ -0,0 +1,158 @@ +// Responsibility: project one PaC status capture into explicit source-commit release evidence. + +export interface PacDeliveryObserveInput { + readonly status: Record; + readonly sourceCommit: string; +} + +export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Record { + const summary = record(input.status.summary); + const pipelineRuns = arrayRecords(summary.pipelineRuns); + const latestPipeline = record(summary.latestPipelineRun); + const pipeline = pipelineRuns.find((item) => item.sourceCommit === input.sourceCommit) ?? latestPipeline; + const argo = record(summary.argo); + const runtime = record(summary.runtime); + const workloads = arrayRecords(runtime.workloads); + const matchingWorkloads = workloads.filter((item) => item.sourceCommit === input.sourceCommit); + const pipelineSourceCommit = stringOrNull(pipeline.sourceCommit); + const sourceMatched = pipelineSourceCommit === input.sourceCommit; + const pipelineSucceeded = pipeline.status === "True"; + const runtimeReady = matchingWorkloads.length > 0 && matchingWorkloads.every(workloadReady); + const argoReady = argo.sync === "Synced" && argo.health === "Healthy"; + const observationReady = record(input.status.observation).ok === true; + const state = !observationReady || !sourceMatched || !pipelineSucceeded || matchingWorkloads.length === 0 + ? "blocked" + : runtimeReady && argoReady + ? "complete" + : "partial"; + const firstBreak = firstBreakFor({ + observationReady, + sourceMatched, + pipelineSucceeded, + matchingWorkloads, + runtimeReady, + argoReady, + argo, + }); + + return { + ok: state !== "blocked", + status: state, + action: "platform-infra-pipelines-as-code-delivery-observe", + mutation: false, + sourceCommit: input.sourceCommit, + remoteCaptureCount: 1, + target: input.status.target, + consumer: input.status.consumer, + observation: input.status.observation, + pipeline: { + id: pipeline.name ?? null, + status: pipeline.status ?? null, + reason: pipeline.reason ?? null, + sourceCommit: pipelineSourceCommit, + sourceMatched, + durationSeconds: pipeline.durationSeconds ?? null, + selectedFrom: pipeline === latestPipeline ? "latest" : "bounded-status-history", + }, + argo: { + sync: argo.sync ?? null, + health: argo.health ?? null, + revision: argo.revision ?? null, + targetRevision: argo.targetRevision ?? null, + firstDegradedResource: argo.firstDegradedResource ?? null, + firstCondition: argo.firstCondition ?? null, + }, + runtime: { + workloadCount: workloads.length, + matchingWorkloadCount: matchingWorkloads.length, + ready: runtimeReady, + workloads: matchingWorkloads.map((item) => ({ + deployment: item.deployment ?? null, + namespace: item.namespace ?? null, + readyReplicas: item.readyReplicas ?? null, + replicas: item.replicas ?? null, + sourceCommit: item.sourceCommit ?? null, + digest: item.digest ?? null, + })), + }, + artifactEvidence: { + mode: record(summary.artifact).collector === undefined ? null : record(record(summary.artifact).collector).mode ?? null, + deferred: record(record(summary.artifact).collector).mode === "taskrun-status-only", + }, + firstBreak, + valuesPrinted: false, + }; +} + +export function renderPacDeliveryObserve(result: Record): string[] { + const target = record(result.target); + const consumer = record(result.consumer); + const pipeline = record(result.pipeline); + const argo = record(result.argo); + const runtime = record(result.runtime); + const firstBreak = record(result.firstBreak); + const workloads = arrayRecords(runtime.workloads); + return [ + "PLATFORM-INFRA PIPELINES-AS-CODE DELIVERY OBSERVE", + `TARGET ${text(target.id)} CONSUMER ${text(consumer.id)} STATUS ${text(result.status)} MUTATION false`, + `SOURCE expected=${short(text(result.sourceCommit))} observed=${short(text(pipeline.sourceCommit))} matched=${String(pipeline.sourceMatched === true)}`, + `PIPELINE id=${text(pipeline.id)} status=${text(pipeline.status)} reason=${text(pipeline.reason)}`, + `ARGO sync=${text(argo.sync)} health=${text(argo.health)} revision=${short(text(argo.revision))}`, + `RUNTIME matching=${text(runtime.matchingWorkloadCount)}/${text(runtime.workloadCount)} ready=${String(runtime.ready === true)}`, + ...workloads.map((item) => ` ${text(item.namespace)}/${text(item.deployment)} ${text(item.readyReplicas)}/${text(item.replicas)} digest=${short(text(item.digest), 24)}`), + `FIRST_BREAK ${Object.keys(firstBreak).length === 0 ? "-" : `${text(firstBreak.code)} phase=${text(firstBreak.phase)}`}`, + "REMOTE_CAPTURE_COUNT 1", + ]; +} + +function firstBreakFor(input: { + observationReady: boolean; + sourceMatched: boolean; + pipelineSucceeded: boolean; + matchingWorkloads: readonly Record[]; + runtimeReady: boolean; + argoReady: boolean; + argo: Record; +}): Record | null { + if (!input.observationReady) return { code: "target-capture-failed", phase: "remote-read" }; + if (!input.sourceMatched) return { code: "pipeline-source-commit-mismatch", phase: "pipeline" }; + if (!input.pipelineSucceeded) return { code: "pipeline-not-succeeded", phase: "pipeline" }; + if (input.matchingWorkloads.length === 0) return { code: "runtime-source-commit-not-observed", phase: "runtime" }; + if (!input.runtimeReady) return { code: "runtime-workload-not-ready", phase: "runtime" }; + if (!input.argoReady) { + return { + code: "argo-not-ready", + phase: "argo", + resource: input.argo.firstDegradedResource ?? null, + condition: input.argo.firstCondition ?? null, + }; + } + return null; +} + +function workloadReady(value: Record): boolean { + return Number.isInteger(value.replicas) + && Number(value.replicas) >= 0 + && Number.isInteger(value.readyReplicas) + && Number(value.readyReplicas) >= Number(value.replicas); +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function stringOrNull(value: unknown): string | null { + return typeof value === "string" && value.length > 0 ? value : null; +} + +function text(value: unknown): string { + return value === null || value === undefined || value === "" ? "-" : String(value); +} + +function short(value: string, max = 16): string { + return value.length <= max ? value : value.slice(0, max); +} diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 40b5859d..51f1498e 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -1280,24 +1280,48 @@ NODE namespace=$(printf '%s' "$target" | cut -f1) name=$(printf '%s' "$target" | cut -f2) deploy_file=$(mktemp) - pac_read kubectl -n "$namespace" get deploy "$name" -o json >"$deploy_file" 2>/dev/null || printf '{}' >"$deploy_file" - node - "$deploy_file" <<'NODE' || printf '{}' + pac_read kubectl -n "$namespace" get deploy -o json >"$deploy_file" 2>/dev/null || printf '{"items":[]}' >"$deploy_file" + UNIDESK_PAC_RUNTIME_PRIMARY_DEPLOYMENT="$name" node - "$deploy_file" <<'NODE' || printf '{}' const fs = require('node:fs'); const input = fs.readFileSync(process.argv[2], 'utf8').trim(); if (!input) { process.stdout.write('{}'); process.exit(0); } -const deploy = JSON.parse(input); -const image = deploy.spec?.template?.spec?.containers?.[0]?.image || null; -const digest = image && String(image).includes('@') ? String(image).split('@').slice(1).join('@') : null; +const list = JSON.parse(input); +const items = Array.isArray(list.items) ? list.items : []; +const sourceCommit = (deploy) => { + const labels = deploy.spec?.template?.metadata?.labels || {}; + const annotations = deploy.spec?.template?.metadata?.annotations || {}; + return labels['unidesk.ai/source-commit'] + || labels['hwlab.pikastech.local/source-commit'] + || labels['agentrun.pikastech.local/source-commit'] + || annotations['unidesk.ai/source-commit'] + || annotations['hwlab.pikastech.local/source-commit'] + || annotations['agentrun.pikastech.local/source-commit'] + || null; +}; +const workload = (deploy) => { + const image = deploy.spec?.template?.spec?.containers?.[0]?.image || null; + const digest = image && String(image).includes('@') ? String(image).split('@').slice(1).join('@') : null; + return { + namespace: deploy.metadata?.namespace || null, + deployment: deploy.metadata?.name || null, + readyReplicas: deploy.status?.readyReplicas ?? 0, + replicas: deploy.spec?.replicas ?? null, + sourceCommit: sourceCommit(deploy), + image, + digest, + valuesPrinted: false, + }; +}; +const workloads = items.map(workload).sort((left, right) => String(left.deployment).localeCompare(String(right.deployment))).slice(0, 100); +const primary = workloads.find((item) => item.deployment === process.env.UNIDESK_PAC_RUNTIME_PRIMARY_DEPLOYMENT) || {}; process.stdout.write(JSON.stringify({ - namespace: deploy.metadata?.namespace || null, - deployment: deploy.metadata?.name || null, - readyReplicas: deploy.status?.readyReplicas ?? null, - replicas: deploy.spec?.replicas ?? null, - image, - digest, + ...primary, + namespace: primary.namespace || process.env.UNIDESK_PAC_RUNTIME_NAMESPACE || null, + workloads, + workloadCount: workloads.length, valuesPrinted: false, })); NODE @@ -1776,7 +1800,7 @@ status_action() { argo_file=$(mktemp) pac_read kubectl -n "$UNIDESK_PAC_ARGO_NAMESPACE" get application "$UNIDESK_PAC_ARGO_APPLICATION" -o json >"$argo_file" 2>/dev/null || printf '{}' >"$argo_file" export UNIDESK_PAC_ARGO_FILE="$argo_file" - argo=$(node -e 'const fs=require("fs"); const s=fs.readFileSync(0,"utf8").trim(); if(!s){process.stdout.write("{}"); process.exit(0)} const a=JSON.parse(s); const source=a.spec?.source || a.spec?.sources?.[0] || {}; let repoURL=source.repoURL||null; try { const url=new URL(repoURL); url.username=""; url.password=""; repoURL=url.toString(); } catch {} process.stdout.write(JSON.stringify({sync:a.status?.sync?.status||null, health:a.status?.health?.status||null, revision:a.status?.sync?.revision||null, repoURL, targetRevision:source.targetRevision||null}))' <"$argo_file" || echo '{}') + argo=$(node -e 'const fs=require("fs"); const s=fs.readFileSync(0,"utf8").trim(); if(!s){process.stdout.write("{}"); process.exit(0)} const a=JSON.parse(s); const source=a.spec?.source || a.spec?.sources?.[0] || {}; let repoURL=source.repoURL||null; try { const url=new URL(repoURL); url.username=""; url.password=""; repoURL=url.toString(); } catch {} const bounded=(value)=>typeof value==="string"?value.replace(/(authorization\\s*[:=]\\s*)(?:bearer\\s+)?\\S+/giu,"$1[REDACTED]").replace(/(token|password|secret)(\\s*[:=]\\s*)\\S+/giu,"$1$2[REDACTED]").replace(/\\s+/gu," ").trim().slice(0,480):null; const resources=a.status?.resources||[]; const degraded=resources.find((item)=>item.status==="OutOfSync"||item.health?.status==="Degraded"||item.health?.status==="Missing")||resources.find((item)=>item.status!=="Synced"||(item.health?.status&&item.health.status!=="Healthy"))||null; const condition=(a.status?.conditions||[])[0]||((a.status?.operationState?.phase&&a.status.operationState.phase!=="Succeeded")?{type:a.status.operationState.phase,message:a.status.operationState.message}:null); process.stdout.write(JSON.stringify({sync:a.status?.sync?.status||null, health:a.status?.health?.status||null, revision:a.status?.sync?.revision||null, repoURL, targetRevision:source.targetRevision||null, firstDegradedResource:degraded?{group:degraded.group||null,kind:degraded.kind||null,namespace:degraded.namespace||null,name:degraded.name||null,status:degraded.status||null,health:degraded.health?.status||null}:null,firstCondition:condition?{type:condition.type||null,message:bounded(condition.message)}:null}))' <"$argo_file" || echo '{}') argo=$(json_normalize "$argo") status_progress gitops-revision revision_relation=$(gitops_revision_relation "$artifact" "$argo") diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index a52c1687..7c027b1b 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -48,6 +48,7 @@ import { } from "./platform-infra-pac-delivery-timing"; import { diagnosePacRegression, renderPacRegressionDiagnosis } from "./platform-infra-pac-regression-diagnosis"; import { capturePacNodeStatusSnapshot, type PacNodeStatusConsumer } from "./platform-infra-pac-node-status"; +import { projectPacDeliveryObserve, renderPacDeliveryObserve } from "./platform-infra-pac-delivery-observe"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; @@ -287,6 +288,10 @@ interface CloseoutOptions extends CommonOptions { timeoutMs: number; } +interface DeliveryObserveOptions extends CommonOptions { + sourceCommit: string; +} + interface ApplyOptions extends CommonOptions { confirm: boolean; dryRun: boolean; @@ -380,6 +385,14 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf const result = await deliveryTiming(config, options); return options.full || options.raw || options.json ? result : renderDeliveryTiming(result); } + if (action === "delivery-observe" || action === "observe-delivery") { + const options = parseDeliveryObserveOptions(args.slice(1)); + const observed = await status(config, options); + const result = projectPacDeliveryObserve({ status: observed, sourceCommit: options.sourceCommit }); + return options.full || options.raw || options.json + ? result + : rendered(result, "platform-infra pipelines-as-code delivery-observe", renderPacDeliveryObserve(result)); + } if (action === "debug-step") { const options = parseHistoryOptions(args.slice(1)); const result = await debugStep(config, options); @@ -509,7 +522,7 @@ function help(scope: string | null): Record { }; } return { - command: "platform-infra pipelines-as-code plan|status|history|diagnose-regression|delivery-timing|debug-step|source-artifact", + command: "platform-infra pipelines-as-code plan|status|history|diagnose-regression|delivery-timing|delivery-observe|debug-step|source-artifact", configTruth: configLabel, usage: [ "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", @@ -517,6 +530,7 @@ function help(scope: string | null): Record { "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]", "bun scripts/cli.ts platform-infra pipelines-as-code diagnose-regression --target NC01 --consumer agentrun-nc01-v02 [--limit 20] [--json|--full]", "bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 [--json]", + "bun scripts/cli.ts platform-infra pipelines-as-code delivery-observe --target NC01 --consumer --source-commit [--json]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id ", "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer ] [--id ] [--json]", "bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree", @@ -2385,6 +2399,7 @@ function statusSummary(payload: Record): Record"); + return { ...parseCommonOptions(commonArgs), sourceCommit }; +} + function parseHistoryOptions(args: string[], defaultLimit = 5): HistoryOptions { const commonArgs: string[] = []; let limit = defaultLimit;