feat: add PaC delivery timing query
This commit is contained in:
@@ -138,6 +138,14 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
|
||||
|
||||
使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target <NODE> --limit 10` 审计全部 consumer 的触发、耗时与 reuse。命令必须读取 target node 上的实时 Gitea Repository CR 与 Tekton PipelineRun/TaskRun,在 target 侧聚合并显式输出 `READ_ERRORS`。
|
||||
|
||||
- 单次交付耗时查询:
|
||||
- 使用 `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target <NODE> --consumer <id>`;
|
||||
- 命令从 owning YAML 解析上游 GitHub repository、branch 与 consumer;
|
||||
- 默认关联最新 PipelineRun source commit 对应的 GitHub PR `mergedAt`;
|
||||
- 同时披露 trigger wait、PipelineRun、端到端耗时及 Argo/runtime closeout;
|
||||
- 缺少 PR、时间戳或 provenance 时返回 `partial` 与 `evidenceGaps`,不得用 commit 时间替代 `mergedAt`;
|
||||
- 整个入口保持 `mutation=false`,不写入历史库,不触发、同步或补跑交付。
|
||||
|
||||
`history --id <pipelinerun>` 必须按 target 内唯一 PipelineRun prefix 或 Tekton pipeline 归属解析实际 consumer,并用该 consumer 生成 status/history `Next`。零匹配、多匹配或显式 `--consumer` 与实际归属冲突时必须在远端读取前 fail-closed;禁止回退默认 consumer。
|
||||
|
||||
## 按需只读调查
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
# R7 任务报告
|
||||
|
||||
## 结果
|
||||
|
||||
- 新增只读命令 `platform-infra pipelines-as-code delivery-timing --target <NODE> --consumer <id>`。
|
||||
- 命令从 `config/platform-infra/pipelines-as-code.yaml` 与 `config/platform-infra/gitea.yaml` 精确解析 consumer、上游 GitHub repository 和 branch。
|
||||
- 单次调用并行读取最新 PaC PipelineRun/TaskRun 与 status closeout,再按 source commit 查询 GitHub 关联 PR;不新增历史库或第二状态源。
|
||||
- 默认及 JSON 输出包含 source commit、PR、mergedAt、PipelineRun 起止、trigger wait、pipeline duration、end-to-end duration、Argo/runtime health 和 `evidenceGaps`。
|
||||
- PR、时间戳或 provenance 缺失时保持 `partial/unknown`,不使用 commit 时间冒充 mergedAt;入口固定 `mutation=false`。
|
||||
|
||||
## 真实只读验收
|
||||
|
||||
命令:
|
||||
|
||||
`bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 --json`
|
||||
|
||||
结果:
|
||||
|
||||
- source commit:`11bfcf2657cd4f62e5e7221803872e53143e5f0c`
|
||||
- GitHub PR:`pikainc/selfmedia#26`
|
||||
- mergedAt:`2026-07-14T05:54:19Z`
|
||||
- PipelineRun:`2026-07-14T05:54:26Z` 至 `2026-07-14T05:57:28Z`
|
||||
- trigger wait:7 秒
|
||||
- PipelineRun:182 秒
|
||||
- 端到端:189 秒
|
||||
- TaskRun:181 秒
|
||||
- Argo:`Synced/Healthy`
|
||||
- runtime:`1/1` ready
|
||||
- 状态:`partial`,保留既有 `runtime-provenance-unknown` 缺口
|
||||
- mutation:`false`
|
||||
|
||||
## 验证
|
||||
|
||||
- `bun --check scripts/src/platform-infra-pipelines-as-code.ts`:通过。
|
||||
- `bun scripts/cli.ts check --syntax-only`:11/11 通过。
|
||||
- `bun test scripts/src/cicd-pr-merge-auto-delivery-doc-contract.test.ts`:8/8 通过。
|
||||
- `quick_validate.py .agents/skills/unidesk-cicd`:通过。
|
||||
- `git diff --check`:通过。
|
||||
- 受控 scripts typecheck 已安装锁文件声明的 TypeScript 后执行;仓库现有 364 条诊断、94 个路径,本次新模块路径匹配 0 条。
|
||||
|
||||
## Skill 入口建议
|
||||
|
||||
按调度边界未修改 `unidesk-cicd/SKILL.md` 核心规则。建议主代理审核后,在高频入口加入:
|
||||
|
||||
`bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01`
|
||||
@@ -86,3 +86,7 @@
|
||||
## R6 [in_progress]
|
||||
|
||||
执行 [UniDesk #1981](https://github.com/pikasTech/unidesk/issues/1981):固定仓库产品功能 schema 路径为 config/feature-config.schema.json,CI/CD 只读校验 schema 语法、值匹配和功能到唯一配置变量的一对一关系;缺失、非法、不匹配或重复仅输出 typed warning 并投影到 OTel/status/history/debug-step,必须继续 artifact、GitOps/Argo 和滚动上线,禁止写回、默认值、变量删除、功能关闭、架构切换及其他补救操作,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R6_Task_Report.md)。
|
||||
|
||||
## R7 [completed]
|
||||
|
||||
依据 [UniDesk #2005](https://github.com/pikasTech/unidesk/issues/2005) 实现 SELFMEDIA CI/CD 单次只读查询:从 owning YAML 解析 selfmedia-nc01,在一次受控 CLI 调用内关联 pikainc/selfmedia GitHub mergedAt、PaC PipelineRun/TaskRun、Argo/runtime health,直接披露 trigger wait、pipeline duration、end-to-end duration 与证据缺口;同步 unidesk-cicd skill 的唯一推荐入口,禁止新增第二状态源或交付 mutation,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R7_Task_Report.md)。
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
- 同 deliveryId 与同 payload 必须幂等,异 payload 返回 `409`。PVC 不可用或容量满返回 `503` 且 readiness=false。inbox worker 按 YAML 有界 backoff 自动重试并在重启后恢复 `accepted` / `processing`;Caddy 只对尚未持久化的请求做小于 10 秒的 body-safe 有界重试。
|
||||
- Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot;禁止新增 polling/read-model fallback 或第二 source authority。
|
||||
- 默认 PaC 入口是 `bun scripts/cli.ts platform-infra pipelines-as-code status|history --target <node>`。`status` 应显示 webhook、PipelineRun/TaskRun duration、image status、env identity、digest、GitOps commit、Argo revision 和 runtime provenance;`history` 必须读取 target node 上的实时对象、显式报告 read error,并支持 consumer 与 PipelineRun id 下钻。`history --id` 必须通过运行面 provenance 与 PipelineRun prefix 唯一解析实际 consumer;零匹配、多匹配或显式 consumer 不一致都 fail-closed,不能回退到默认 consumer 或默认 node。
|
||||
- 最新一次端到端耗时使用 `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target <node> --consumer <id>` 一次查询。命令从两份 owning YAML 解析 source repository、branch 与 consumer,按 PipelineRun source commit 关联 GitHub PR `mergedAt`,并投影 trigger wait、PipelineRun、端到端耗时、Argo/runtime closeout 与证据缺口;缺失关联时保持 `partial/unknown`,不得使用 commit 时间冒充 `mergedAt`,且始终 `mutation=false`。
|
||||
- Sentinel 内部 publish capability 由 `config/platform-infra/pipelines-as-code.yaml#capabilities.sentinelInternalPublish` 单一拥有,默认 `enabled=false`。Pod env、ownerReferences、ServiceAccount、label 与 annotation 不能证明 creator;#1769 的 admission-owned provenance 与最小权限边界落地前不得启用。`.tekton` 继续走既有自动 `publish-current`,不能退化成人工 publish。
|
||||
- PaC 观察必须把 PipelineRun 分类为外层 `outer-pac-event`、历史内层 `inner-deterministic-publish` 或 `unknown`。只有唯一外层 PaC push event 能驱动 latest、status、debug-step 与兼容 closeout;Web sentinel 的 source、image、artifact、GitOps 步骤必须直接位于该 outer PipelineRun,禁止再创建 nested PipelineRun。历史内层运行只作为未绑定执行观察,缺少 admission-owned 父子证据时标记 `unproven`,禁止按名称前缀推断关系。
|
||||
- CI/CD OTel endpoint、service/resource 属性、采样与 exporter timeout/failure policy 由 `config/platform-infra/pipelines-as-code.yaml` 引用现有 `config/platform-infra/observability.yaml` 并负责渲染。outer PaC 在最早可控 step 生成 W3C trace context,后续 source、image、artifact、GitOps 与 helper 复用同一 traceId;export 失败或超时只输出 `warning=true`、`blocking=false` 的有界事件,不改变 PipelineRun、TaskRun 或业务 step 终态。
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { resolveToken } from "./gh/auth-and-safety";
|
||||
import { githubRequest, isGitHubError } from "./gh/client";
|
||||
|
||||
export interface PacDeliveryTimingBinding {
|
||||
target: Record<string, unknown>;
|
||||
consumer: {
|
||||
id: string;
|
||||
node: string;
|
||||
lane: string;
|
||||
repository: string;
|
||||
branch: string;
|
||||
pipeline: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface PacDeliveryTimingObservers {
|
||||
history: () => Promise<Record<string, unknown>>;
|
||||
status: () => Promise<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function records(value: unknown): Record<string, unknown>[] {
|
||||
return Array.isArray(value) ? value.map(record) : [];
|
||||
}
|
||||
|
||||
function durationBetween(start: unknown, end: unknown): number | null {
|
||||
if (typeof start !== "string" || typeof end !== "string") return null;
|
||||
const a = Date.parse(start);
|
||||
const b = Date.parse(end);
|
||||
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
|
||||
return Math.max(0, Math.round((b - a) / 1000));
|
||||
}
|
||||
|
||||
export async function observePacDeliveryTiming(
|
||||
binding: PacDeliveryTimingBinding,
|
||||
observers: PacDeliveryTimingObservers,
|
||||
): Promise<Record<string, unknown>> {
|
||||
const [sourceOwner, sourceRepo] = binding.consumer.repository.split("/");
|
||||
if (!sourceOwner || !sourceRepo) throw new Error(`invalid owning YAML source repository ${binding.consumer.repository}`);
|
||||
const [historyResult, statusResult] = await Promise.all([observers.history(), observers.status()]);
|
||||
const row = records(historyResult.rows)[0] ?? {};
|
||||
const statusSummary = record(statusResult.summary);
|
||||
const latest = record(statusSummary.latestPipelineRun);
|
||||
const commit = typeof row.commit === "string" ? row.commit : typeof latest.sourceCommit === "string" ? latest.sourceCommit : null;
|
||||
const evidenceGaps: string[] = [];
|
||||
let pullRequest: Record<string, unknown> | null = null;
|
||||
let githubEvidence: Record<string, unknown> = { repository: binding.consumer.repository, source: "github-commit-pulls", mutation: false };
|
||||
if (commit === null) {
|
||||
evidenceGaps.push("source-commit-missing");
|
||||
} else {
|
||||
const tokenInfo = resolveToken(binding.consumer.repository, false);
|
||||
if (tokenInfo.token === null) {
|
||||
evidenceGaps.push("github-token-unavailable");
|
||||
githubEvidence = { ...githubEvidence, ok: false, token: tokenInfo.probe };
|
||||
} else {
|
||||
const response = await githubRequest<unknown>(tokenInfo.token, "GET", `/repos/${sourceOwner}/${sourceRepo}/commits/${commit}/pulls`);
|
||||
if (isGitHubError(response)) {
|
||||
evidenceGaps.push("github-pr-lookup-failed");
|
||||
githubEvidence = { ...githubEvidence, ok: false, error: response };
|
||||
} else {
|
||||
const prs = Array.isArray(response) ? response : [];
|
||||
const merged = prs.find((item) => record(item).merged_at !== null && record(item).merged_at !== undefined) ?? prs[0];
|
||||
if (merged === undefined) {
|
||||
evidenceGaps.push("github-pr-not-found");
|
||||
} else {
|
||||
const item = record(merged);
|
||||
pullRequest = { number: item.number ?? null, title: item.title ?? null, url: item.html_url ?? null, mergedAt: item.merged_at ?? null, mergeCommit: commit };
|
||||
if (typeof item.merged_at !== "string") evidenceGaps.push("github-mergedAt-missing");
|
||||
}
|
||||
githubEvidence = { ...githubEvidence, ok: true, count: prs.length, token: tokenInfo.probe };
|
||||
}
|
||||
}
|
||||
}
|
||||
const mergedAt = pullRequest?.mergedAt ?? null;
|
||||
const startTime = row.startTime ?? null;
|
||||
const completionTime = row.completionTime ?? null;
|
||||
const argo = record(statusSummary.argo);
|
||||
const runtime = record(statusSummary.runtime);
|
||||
const provenance = record(statusSummary.provenance);
|
||||
if (mergedAt === null) evidenceGaps.push("mergedAt-missing");
|
||||
if (startTime === null || completionTime === null) evidenceGaps.push("pipeline-timestamps-missing");
|
||||
if (argo.sync === undefined || argo.health === undefined) evidenceGaps.push("argo-closeout-missing");
|
||||
if (runtime.readyReplicas === undefined) evidenceGaps.push("runtime-health-missing");
|
||||
if (provenance.relation === "unknown") evidenceGaps.push("runtime-provenance-unknown");
|
||||
return {
|
||||
ok: commit !== null && startTime !== null && completionTime !== null,
|
||||
action: "platform-infra-pipelines-as-code-delivery-timing",
|
||||
mutation: false,
|
||||
state: evidenceGaps.length === 0 ? "complete" : "partial",
|
||||
target: binding.target,
|
||||
consumer: binding.consumer,
|
||||
source: { commit, pullRequest, github: githubEvidence },
|
||||
timing: {
|
||||
mergedAt,
|
||||
pipelineStart: startTime,
|
||||
pipelineCompletion: completionTime,
|
||||
triggerWaitSeconds: durationBetween(mergedAt, startTime),
|
||||
pipelineDurationSeconds: row.durationSeconds ?? durationBetween(startTime, completionTime),
|
||||
endToEndSeconds: durationBetween(mergedAt, completionTime),
|
||||
},
|
||||
pipeline: { id: row.id ?? latest.name ?? null, status: row.status ?? latest.status ?? null, taskRuns: row.taskRuns ?? statusSummary.taskRuns ?? null },
|
||||
runtime: { argo, health: runtime, provenance },
|
||||
evidenceGaps: [...new Set(evidenceGaps)],
|
||||
};
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { runPlatformInfraGiteaCommand } from "./platform-infra-gitea";
|
||||
import { readGiteaConfig } from "./platform-infra-gitea-config";
|
||||
import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap";
|
||||
import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection";
|
||||
import { observePacDeliveryTiming } from "./platform-infra-pac-delivery-timing";
|
||||
|
||||
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
|
||||
const configLabel = "config/platform-infra/pipelines-as-code.yaml";
|
||||
@@ -273,6 +274,11 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
|
||||
const result = await history(config, options);
|
||||
return options.raw ? result : options.full ? compactHistoryJson(result, true) : options.json ? compactHistoryJson(result) : renderHistory(result);
|
||||
}
|
||||
if (action === "delivery-timing" || action === "timing") {
|
||||
const options = parseCommonOptions(args.slice(1));
|
||||
const result = await deliveryTiming(config, options);
|
||||
return options.full || options.raw || options.json ? result : renderDeliveryTiming(result);
|
||||
}
|
||||
if (action === "debug-step") {
|
||||
const options = parseHistoryOptions(args.slice(1));
|
||||
const result = await debugStep(config, options);
|
||||
@@ -395,12 +401,13 @@ function help(scope: string | null): Record<string, unknown> {
|
||||
};
|
||||
}
|
||||
return {
|
||||
command: "platform-infra pipelines-as-code plan|status|history|debug-step|source-artifact",
|
||||
command: "platform-infra pipelines-as-code plan|status|history|delivery-timing|debug-step|source-artifact",
|
||||
configTruth: configLabel,
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
|
||||
"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 delivery-timing --target NC01 --consumer selfmedia-nc01 [--json]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun>",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer <consumer>] [--id <pipelinerun>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree",
|
||||
@@ -2423,6 +2430,49 @@ function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
|
||||
return rendered(result, "platform-infra pipelines-as-code closeout", lines);
|
||||
}
|
||||
|
||||
async function deliveryTiming(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
|
||||
const pac = readPacConfig();
|
||||
const target = resolveTarget(pac, options.targetId);
|
||||
const consumer = resolveConsumer(pac, options.consumerId);
|
||||
if (consumer.node.toLowerCase() !== target.id.toLowerCase()) throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`);
|
||||
const authority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
|
||||
if (authority.kind !== "pac-pr-merge") throw new Error(`delivery authority is ${authority.kind}: ${authority.reason}`);
|
||||
const historyOptions: HistoryOptions = { ...options, limit: 1, detailId: null };
|
||||
return await observePacDeliveryTiming({
|
||||
target: targetSummary(target),
|
||||
consumer: {
|
||||
id: consumer.id,
|
||||
node: consumer.node,
|
||||
lane: consumer.lane,
|
||||
repository: authority.consumer.sourceRepository,
|
||||
branch: authority.consumer.sourceBranch,
|
||||
pipeline: consumer.pipeline,
|
||||
},
|
||||
}, {
|
||||
history: async () => await history(config, historyOptions),
|
||||
status: async () => await status(config, options),
|
||||
});
|
||||
}
|
||||
|
||||
function renderDeliveryTiming(result: Record<string, unknown>): RenderedCliResult {
|
||||
const timing = record(result.timing);
|
||||
const source = record(result.source);
|
||||
const pr = record(source.pullRequest);
|
||||
const runtime = record(result.runtime);
|
||||
const argo = record(runtime.argo);
|
||||
const health = record(runtime.health);
|
||||
const lines = [
|
||||
"PLATFORM-INFRA DELIVERY TIMING",
|
||||
`STATE: ${stringValue(result.state)} MUTATION: ${boolText(result.mutation)}`,
|
||||
`SOURCE: ${stringValue(record(result.consumer).repository)}@${stringValue(source.commit)} PR#${stringValue(pr.number)}`,
|
||||
`MERGED_AT: ${stringValue(timing.mergedAt)} PIPELINE_START: ${stringValue(timing.pipelineStart)} PIPELINE_DONE: ${stringValue(timing.pipelineCompletion)}`,
|
||||
`TRIGGER_WAIT_S: ${stringValue(timing.triggerWaitSeconds)} PIPELINE_S: ${stringValue(timing.pipelineDurationSeconds)} END_TO_END_S: ${stringValue(timing.endToEndSeconds)}`,
|
||||
`ARGO: ${stringValue(argo.sync)}/${stringValue(argo.health)} RUNTIME_READY: ${stringValue(health.readyReplicas)}/${stringValue(health.replicas)}`,
|
||||
`EVIDENCE_GAPS: ${(Array.isArray(result.evidenceGaps) ? result.evidenceGaps.map(String) : []).join(", ") || "-"}`,
|
||||
];
|
||||
return rendered(result, "platform-infra pipelines-as-code delivery-timing", lines);
|
||||
}
|
||||
|
||||
export function renderHistory(result: Record<string, unknown>): RenderedCliResult {
|
||||
const rows = arrayRecords(result.rows);
|
||||
const config = record(result.config);
|
||||
|
||||
Reference in New Issue
Block a user