diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index f195a513..9f50053e 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -50,6 +50,8 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 唯一推荐入口是 `platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01`; - 一次调用直接披露 GitHub PR `mergedAt`、trigger wait、PipelineRun/TaskRun、端到端耗时、Argo、runtime health 和 `evidenceGaps`; - 证据不完整时保留已成立的耗时字段并返回 `partial` 与明确缺口,不得手工串联第二状态源; + - 端到端耗时严格超过 owning YAML 预算时输出 `pac-automatic-delivery-over-budget`,固定 `blocking=false`、`mutation=false`; + - 超预算提示只下钻 env identity、依赖缓存、BuildKit/cache 和 stage timing,不给出人工恢复命令; - 该入口固定只读且 `mutation=false`,不得用于同步、触发、补跑或运行面写入。 - 新增 PaC consumer 的首次引导: @@ -73,6 +75,9 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help ## P0 边界 +- PK01 的 CI/CD 默认只允许只读诊断: + - 普通 PR merge、自动滚动、环境恢复或跨服务交付不构成 PK01 变更授权; + - 只有用户在当前请求中明确要求修改 PK01,才允许提交或推广会改变 PK01 版本、配置、Secret 绑定、边缘路由、容器或运行面状态的 source 变更。 - PaC migrated consumer 的唯一正式 CI/CD 触发是目标 source branch 的 GitHub PR merge。合并后由 GitHub webhook -> Gitea controlled mirror + immutable snapshot ref -> Gitea webhook -> PaC -> Tekton -> GitOps/Argo -> runtime 自动滚动;不得要求主代理、子代理或操作者再执行 apply、closeout、trigger、sync、flush 或补跑。 - PaC consumer 首次引导必须满足以下约束: - 默认使用 `pipelines-as-code bootstrap --dry-run|--confirm`,不再人工串联 Gitea bootstrap 与 PaC apply; diff --git a/.agents/skills/unidesk-cicd/references/env-reuse.md b/.agents/skills/unidesk-cicd/references/env-reuse.md index b60a61f6..a9a7b32a 100644 --- a/.agents/skills/unidesk-cicd/references/env-reuse.md +++ b/.agents/skills/unidesk-cicd/references/env-reuse.md @@ -62,6 +62,22 @@ The history/status env reuse display is log-derived: - Sentinel's compact table headed `ENV_REUSE NODE_DEPS ...` is parsed as human-readable fallback. - Missing env reuse text is not proof that no reuse happened; first check the consumer type, the relevant TaskRun logs, and whether the pipeline emits the expected fields. +## 自动交付预算告警 + +- 自动交付端到端预算的唯一真相是 `config/platform-infra/pipelines-as-code.yaml#deliveryTiming.endToEndBudgetSeconds`。 +- `delivery-timing` 只有在 PR `mergedAt`、PipelineRun 时间、Argo、runtime、health 和 provenance 证据完整时才判定预算。 +- 证据不完整时状态固定为 `partial`,`overBudget=null`,不得提前输出超预算告警。 +- 严格超过预算时输出 `pac-automatic-delivery-over-budget` typed warning: + - `blocking=false`; + - `mutation=false`; + - 不改变 PipelineRun、artifact、GitOps、Argo、runtime 或 health 的成功终态。 +- 告警只指向现有只读 `status`、`history` 和 `delivery-timing` 入口,并提示检查: + - env identity; + - 依赖缓存; + - BuildKit/cache; + - stage timing。 +- `status/history` 复用同一预算摘要;只有流水线阶段耗时而缺少完整端到端证据时仍显示 `partial`,不得把 PipelineRun 完成等同于 runtime closeout 完成。 + ## Interpreting Results Use these interpretations: diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index b611f056..2cae539a 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -20,6 +20,7 @@ GitHub issue/PR 正式读写必须走 `bun scripts/cli.ts gh ...` 或 `trans gh: - 规划型、多阶段、架构/API/平台运维类 issue 第一阶段必须 `P0 SPEC 先行`;细则见 [references/issues.md](references/issues.md)。 - `P0 SPEC 先行` 段不得写入硬编码阈值、采样周期、重试次数、并发数等可调参数;必须写明这些参数由指定 YAML/source-of-truth 控制,issue 只列配置路径、字段族和验收读取方式。 - `gh` 默认输出是 k8s 风格 text/table/summary/Next/Disclosure;脚本消费或全量排障必须显式用 `--json`、`--full` 或 `--raw`。 +- `gh auth status --repo owner/name` 分别披露仓库 read、基于 `git push --dry-run` 的非变更 write 能力和 PR merge eligibility,保持 `writesRemote=false`、`mutation=false` 与 `valuesPrinted=false`;merge eligibility 只覆盖 token 与仓权限,分支保护、检查和 PR 状态仍由 guarded merge 判定。 - `gh issue view` / `gh pr view` 的 `--json body,...`、`--full` 和 `--raw` 只用于显式机器结构化披露;请求正文时只在 `.data.issue.body` / `.data.pullRequest.body` 出现一次。 - 多行正文使用 quoted heredoc: - 正文使用 `--body-stdin <<'EOF'`;issue close/reopen 生命周期评论只用 `--comment-stdin <<'EOF'`。 diff --git a/.agents/skills/unidesk-nginx/SKILL.md b/.agents/skills/unidesk-nginx/SKILL.md index 437fe0df..87ef18de 100644 --- a/.agents/skills/unidesk-nginx/SKILL.md +++ b/.agents/skills/unidesk-nginx/SKILL.md @@ -47,6 +47,9 @@ bun scripts/cli.ts platform-infra nginx status --target PK01 ## 不可越过的边界 +- PK01 Nginx 默认只允许只读诊断: + - 只有用户在当前请求中明确要求修改 PK01,才允许变更版本、owning YAML、Compose、容器、systemd、路由、端口、Secret 绑定或运行状态; + - 泛化的修复、恢复、部署和排障请求不构成 PK01 变更授权。 - 通用镜像只在 NC01 构建;Master server 和 PK01 不执行镜像构建。 - PK01 正式部署只走 `platform-infra nginx apply`,不使用 Demo Compose 代替。 - target、监听端口、上游、资源、日志上限和 API key 来源只写 owning YAML。 diff --git a/.agents/skills/unidesk-ops/SKILL.md b/.agents/skills/unidesk-ops/SKILL.md index 36170b46..fcd73d16 100644 --- a/.agents/skills/unidesk-ops/SKILL.md +++ b/.agents/skills/unidesk-ops/SKILL.md @@ -76,6 +76,9 @@ D601 若需要让 `sub2api-egress-proxy` 绕开 pod overlay,可在 YAML 中显 ## P0 边界 +- PK01 默认只允许只读诊断: + - 只有用户在当前请求中明确要求修改 PK01,才允许改变服务版本、YAML/Compose/systemd、数据库配置、容器、网络、Secret 绑定或其他运行面状态; + - “修复”“恢复”“部署”“清理”或跨系统故障处理不自动扩大为 PK01 mutation 授权。 - backend-core 运行面恢复 healthy 后,除非用户明确要求,不主动 rebuild/restart/替换 backend-core。 - Master server 不作为通用构建机;Docker/Rust/Go/前端高 CPU 构建必须走批准的 CI/运行面。 - GC 默认 plan 只读,真实删除必须显式 `run --confirm` 并遵循 allowlist/retention。 diff --git a/.agents/skills/unidesk-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 0ecfd9cd..3e0d09e4 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -26,6 +26,10 @@ bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm ## 边界 +- PK01 默认只允许只读诊断: + - 只有用户在当前请求中明确要求修改 PK01,才允许变更镜像版本、YAML、Compose、容器、环境变量、Caddy/FRP、账号池、Secret 绑定或其他配置/运行面状态; + - “修复”“恢复”“部署”或排查 Sub2API/Artificer 不自动构成 PK01 变更授权; + - 发现根因位于 PK01 时,先保留只读证据并请求明确授权。 - YAML 是 source of truth;target、public exposure、Secret sourceRef、Codex pool 和 sentinel 配置都从 YAML 进入 CLI。 - Secret 只输出对象名、key 名、presence、fingerprint 或 redacted prefix;禁止打印完整 token/key。 - 默认 active target 以 YAML `defaults.targetId` 和 target role 为准;当前 `api.pikapython.com` 对应 PK01 host-Docker target。 diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 2ac87be5..a42fb70d 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -9,6 +9,9 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 ## 高频规则 +- 涉及 PK01 的派单默认只允许只读调查: + - 只有用户在当前请求中明确要求修改 PK01,子 issue、MDTODO 或代理 prompt 才能授权修改其版本、配置、Secret 绑定、边缘路由、容器或运行面; + - 泛化的修复、恢复、部署和排障目标不构成 PK01 变更授权。 - 主代理与子代理必须按用户明确目标控制实现范围: - 未经用户明确要求,禁止新增或扩展通用合同、租约、安全机制、围栏及其配套门禁; - 既有权限、Secret 脱敏、不可逆操作和损害预防边界继续生效,本规则不授权绕过既有安全底线; diff --git a/.agents/skills/unidesk-ymalops/SKILL.md b/.agents/skills/unidesk-ymalops/SKILL.md index 3eb57210..689b5281 100644 --- a/.agents/skills/unidesk-ymalops/SKILL.md +++ b/.agents/skills/unidesk-ymalops/SKILL.md @@ -22,6 +22,9 @@ description: UniDesk YAML-first 运维正规化技能。用户提到 ymal-first/ ## Guardrails +- PK01 配置默认只允许只读盘点: + - 只有用户在当前请求中明确要求修改 PK01,才允许通过 YAML-first 正规化、故障恢复或跨服务配置收敛改变其版本、owning YAML、public exposure、Secret 绑定、Compose/容器/systemd 或运行面状态; + - 只读盘点不构成 PK01 变更授权。 - 先执行 `git status --short --branch` 和 `git pull --ff-only origin master`;保留并绕开无关并行修改。 - 源码、配置、部署类正规化默认在独立 `.worktree/` 中做;轻量 skill/docs/reference 收敛可按项目规则直接在主 worktree 做。 - YAML 是 source of truth。不得新增隐藏代码默认值、schema 数值硬限制、合同测试或测试硬编码策略。 diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index e5d04e4a..cea135da 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -19,6 +19,8 @@ defaults: consumerId: hwlab-nc01-v03 display: timeZone: Asia/Shanghai +deliveryTiming: + endToEndBudgetSeconds: 120 observability: configRef: config/platform-infra/observability.yaml tracesEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces diff --git a/docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md b/docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md new file mode 100644 index 00000000..1fdbf343 --- /dev/null +++ b/docs/MDTODO/details/github-pr-draft-ready-command/R2_Task_Report.md @@ -0,0 +1,32 @@ +# R2 任务报告 + +## 结论 + +- pikainc/pikaoa 继续使用 config/unidesk-cli.yaml#github.auth.repositoryOverrides 中既有的 pikainc-selfmedia-gh-token.txt;未修改 Secret sourceRef、运行面 Secret 或 PikaOA 业务。 +- 先前“该 token 只能读、不能 merge”的根因假设已被真实原入口证伪:主代理使用同一受控入口首次成功合并 pikainc/pikaoa#9,merge commit 为 0142d2ff47ccfa4eb7543dcee11dd05e0bae2c6c,远端 head branch 删除成功。 +- gh auth status --repo 现在分别披露 repo read、基于 HTTPS git push --dry-run 的 write capability 和 PR merge eligibility。write 探针使用临时 askpass、随机 ref 与 --dry-run,固定报告 writesRemote=false、mutation=false、valuesPrinted=false。 +- PR merge eligibility 只覆盖 token 与仓库写权限;分支保护、检查和 PR 状态仍由 guarded gh pr merge 判定。 + +## 非阻塞判定 + +- write capability 不可用或未知时,auth status 仍保持可读认证成功,返回 warning=true、blocking=false,不成为业务发布门禁。 +- 使用公开只读仓验证了 repo-read=ok、repo-write/pr-merge=unavailable 且命令退出码为 0。 +- 未新增权限阈值、版本门禁或 YAML 数值策略;探针复用既有 GitHub 请求超时预算。 + +## 验证 + +- bun --check scripts/src/gh/repository-capability.ts +- bun --check scripts/src/gh/auth-pr-read.ts +- bun --check scripts/src/gh/default-render.ts +- bun --check scripts/src/gh/help.ts +- bun test scripts/src/gh-token-repository-override.test.ts:6 pass,0 fail。 +- bun scripts/cli.ts gh auth status --repo pikainc/pikaoa:read/write/pr-merge 均 available,warnings=0,valuesPrinted=false。 +- 从 /tmp 调用同一 auth status:成功,证明探针不依赖调用方 cwd。 +- bun scripts/cli.ts gh auth status --repo octocat/Hello-World:read available,write/pr-merge unavailable,非阻塞返回。 +- bun scripts/cli.ts gh pr view 9 --repo pikainc/pikaoa --json title,state,stateDetail,merged,mergedAt,mergeCommit,head,base:stateDetail=merged,merge commit 与主代理证据一致。 +- git diff --check:通过。 + +## 风险 + +- git push --dry-run 证明 Git contents 写能力,不替代最终 PR merge 的分支保护与检查判断;CLI 已在帮助和输出中明确这一边界。 +- 本任务没有再次执行真实 merge,也没有修改或生成任何 Secret。 diff --git a/docs/MDTODO/github-pr-draft-ready-command.md b/docs/MDTODO/github-pr-draft-ready-command.md index b6cc2d5d..27cc8971 100644 --- a/docs/MDTODO/github-pr-draft-ready-command.md +++ b/docs/MDTODO/github-pr-draft-ready-command.md @@ -23,4 +23,7 @@ 实现 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的 ready、already-ready 与 dry-run 合同,并补 scoped help,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.2_Task_Report.md)。 ### R1.3 [completed] -覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。 \ No newline at end of file +覆盖 [#1749](https://github.com/pikasTech/unidesk/issues/1749) 的成功与失败测试,使用新命令验收 [PR #1748](https://github.com/pikasTech/unidesk/pull/1748),提交非 draft PR,完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R1.3_Task_Report.md)。 +## R2 [completed] + +修复 `pikainc/pikaoa` 受控 GitHub token repository override 的写权限路由,并让 `gh auth status` 区分 repo 可读与 PR merge capability,执行记录见 [pikasTech/unidesk#2063](https://github.com/pikasTech/unidesk/issues/2063),完成任务后将详细报告写入[任务报告](./details/github-pr-draft-ready-command/R2_Task_Report.md)。 diff --git a/scripts/src/gh/auth-pr-read.ts b/scripts/src/gh/auth-pr-read.ts index 99b0b1ac..d75342ec 100644 --- a/scripts/src/gh/auth-pr-read.ts +++ b/scripts/src/gh/auth-pr-read.ts @@ -6,8 +6,52 @@ import { ghBinaryPath, repoParts, resolveToken } from "./auth-and-safety"; import { commandError, errorPayload, githubRequest, isGitHubError, prBodyReadCommands, runnerDisposition } from "./client"; import { issueRead } from "./issue-read"; import { needsPrGraphqlMetadata, numberOrNull, prCloseoutMetadata, prCloseoutMetadataError, prCompactSummary, prFileSummary, prGraphqlMetadata, prMetadataSummary, prSummary, selectedPrJson, sumPrFileStats } from "./pr-summary"; +import { probeRepositoryWriteCapability } from "./repository-capability"; import { MAX_PR_FILES_LIMIT, PR_LIST_JSON_FIELDS } from "./types"; -import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, PrListJsonField, PrListState, PrReadJsonField } from "./types"; +import type { GitHubCommandResult, GitHubDegradedReason, GitHubIssue, GitHubPullRequest, GitHubPullRequestFile, GitHubRepository, PrListJsonField, PrListState, PrReadJsonField } from "./types"; + +type RepositoryCapabilityStatus = "available" | "unavailable" | "unknown"; + +function capabilityStatus(available: boolean | null): RepositoryCapabilityStatus { + if (available === true) return "available"; + if (available === false) return "unavailable"; + return "unknown"; +} + +function repositoryPermissionLevel(permissions: Record | undefined): "admin" | "maintain" | "write" | "read" | "unknown" { + if (permissions === undefined) return "unknown"; + if (permissions.admin === true) return "admin"; + if (permissions.maintain === true) return "maintain"; + if (permissions.push === true) return "write"; + if (permissions.pull === true || permissions.triage === true) return "read"; + return "unknown"; +} + +function repositoryCapabilities(repo: GitHubRepository, issueReadable: boolean, repositoryWrite: ReturnType): Record { + const permissionLevel = repositoryPermissionLevel(repo.permissions); + return { + repositoryRead: { + status: capabilityStatus(issueReadable), + available: issueReadable, + evidence: "repository-and-issue-read", + }, + repositoryWrite: { + ...repositoryWrite, + permissionLevel, + }, + pullRequestMerge: { + status: repositoryWrite.status, + available: repositoryWrite.available, + permissionLevel, + inferredFrom: "repository-write-git-push-dry-run", + requiredPermission: "contents:write", + writesRemote: false, + mutation: false, + caveat: "guarded merge still checks branch protection, checks, and PR state", + }, + valuesPrinted: false, + }; +} export async function authStatus(repo: string): Promise { const ghPath = ghBinaryPath(); @@ -48,7 +92,7 @@ export async function authStatus(repo: string): Promise { }); } - const repoProbe = await githubRequest<{ full_name?: string; private?: boolean }>(token, "GET", `/repos/${owner}/${name}`); + const repoProbe = await githubRequest(token, "GET", `/repos/${owner}/${name}`); if (isGitHubError(repoProbe)) { return commandError("auth status", repo, repoProbe, { degraded: [...degraded, repoProbe.degradedReason], @@ -68,6 +112,9 @@ export async function authStatus(repo: string): Promise { }); } + const repositoryWrite = probeRepositoryWriteCapability(repo, token); + const capabilities = repositoryCapabilities(repoProbe, true, repositoryWrite); + return { ok: true, command: "auth status", @@ -80,7 +127,16 @@ export async function authStatus(repo: string): Promise { repo: { ok: true, fullName: repoProbe.full_name ?? repo, private: repoProbe.private ?? null }, issueRead: { ok: true, readable: true, sampleCount: issueProbe.length }, }, + capabilities, + warnings: (capabilities.repositoryWrite as { available: boolean | null }).available === true ? [] : [{ + warning: true, + blocking: false, + code: "github-repository-write-capability-unavailable", + capability: "pull-request-merge", + status: (capabilities.pullRequestMerge as { status: RepositoryCapabilityStatus }).status, + }], restFallback: true, + valuesPrinted: false, }; } diff --git a/scripts/src/gh/default-render.ts b/scripts/src/gh/default-render.ts index 4560513b..57325de5 100644 --- a/scripts/src/gh/default-render.ts +++ b/scripts/src/gh/default-render.ts @@ -381,6 +381,11 @@ function renderAuthStatus(result: GitHubCommandResult): string { const token = record(result.token); const gh = record(result.gh); const probes = record(result.probes); + const capabilities = record(result.capabilities); + const repositoryRead = record(capabilities.repositoryRead); + const repositoryWrite = record(capabilities.repositoryWrite); + const pullRequestMerge = record(capabilities.pullRequestMerge); + const warnings = Array.isArray(result.warnings) ? result.warnings : []; const tokenDetail = [ `source=${ghText(token.source)}`, `scope=${ghText(token.scope)}`, @@ -399,6 +404,9 @@ function renderAuthStatus(result: GitHubCommandResult): string { ["rest-api", probeStatus(probes.restApi), ghText(probes.restApi)], ["repo", probeStatus(probes.repo), probeDetail(probes.repo)], ["issue-read", probeStatus(probes.issueRead), probeDetail(probes.issueRead)], + ["repo-read", capabilityProbeStatus(repositoryRead), capabilityProbeDetail(repositoryRead)], + ["repo-write", capabilityProbeStatus(repositoryWrite), capabilityProbeDetail(repositoryWrite)], + ["pr-merge", capabilityProbeStatus(pullRequestMerge), capabilityProbeDetail(pullRequestMerge)], ]; return [ `gh auth status (${result.ok ? "ok" : "failed"})`, @@ -407,12 +415,31 @@ function renderAuthStatus(result: GitHubCommandResult): string { "", "Summary:", ` repo=${ghText(result.repo)} restFallback=${ghText(result.restFallback)} valuesPrinted=false`, + ` warnings=${warnings.length} blocking=false`, "", "Disclosure:", " token values are never printed; use --raw only where supported for structured diagnostics.", ].join("\n"); } +function capabilityProbeStatus(value: Record): string { + if (value.status === "available") return "ok"; + if (value.status === "unavailable") return "unavailable"; + if (value.status === "unknown") return "unknown"; + return "skipped"; +} + +function capabilityProbeDetail(value: Record): string { + if (Object.keys(value).length === 0) return "not-probed"; + return [ + `status=${ghText(value.status)}`, + `permission=${ghText(value.permissionLevel)}`, + `evidence=${ghText(value.evidence ?? value.inferredFrom)}`, + `reason=${ghText(value.reason)}`, + `mutation=${ghText(value.mutation)}`, + ].filter((item) => !item.endsWith("=-") && !item.endsWith("=undefined")).join(" "); +} + function renderRepoResult(result: GitHubCommandResult): string { const repo = record(result.repository); const planned = record(result.planned); diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index daa53fe3..6049673e 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -69,7 +69,7 @@ export function ghHelp(): unknown { notes: [ "Issue and PR create/read/update/comment/close/reopen use GitHub REST and do not require the gh binary when GH_TOKEN or GITHUB_TOKEN is present.", "repo view/create use GitHub REST through the same token path. repo create defaults to private repositories, preflights existing repos, supports --dry-run, and refuses duplicate creation.", - "Token values are never printed; auth status reports only token source and presence.", + "Token values are never printed; auth status reports token source, presence, repository read capability, non-mutating git push dry-run write capability and PR merge eligibility.", "Default gh output is k8s-style text/table/summary with Next drill-down commands. Machine-readable or full-disclosure output must be requested explicitly with --json, --full, or --raw where supported.", "issue list and pr list accept a single positional owner/repo as a compatibility alias for --repo owner/name. The positional repo and --repo must match if both are supplied; non-repo positionals fail structurally instead of falling back to the default repo.", "issue list defaults to --state open and bounded --limit 30; it paginates GitHub REST/Search pages internally when --limit exceeds GitHub's per-page cap and discloses pagination/rawCount/hasMore so operators do not mistake a single page for the full repository. --search uses GitHub Search Issues API with repo/type/state qualifiers for low-friction dedupe lookup before creating a new issue. --title-prefix filters the bounded listed issues locally by exact title startsWith, useful for [FEEDBACK] dedupe, and reports titleFilter input/output counts. Supported --json fields are number,title,state,closed,closedAt,url,updatedAt,createdAt,author,labels and unknown fields fail structurally.", @@ -160,6 +160,9 @@ export function ghScopedHelpNotes(tokens: string[]): string[] { if (key === "issue view" || key === "issue read") { notes.push("Human full-body reading uses `trans gh:/owner/repo/issue/ cat`; use the same route with `rg ` for targeted lookup."); notes.push("`--json body`, `--full`, and `--raw` are explicit structured machine disclosure; recent comment progress uses bounded `gh issue comments `."); + } else if (key === "auth status") { + notes.push("Auth status reports repository read capability plus a non-mutating git push dry-run write probe and PR merge eligibility without printing token values."); + notes.push("PR merge eligibility covers token and repository permission only; guarded merge still checks branch protection, checks, and PR state."); } else if (key === "pr view" || key === "pr read") { notes.push("Human full-body reading uses `trans gh:/owner/repo/pr/ cat`; use the same route with `rg ` for targeted lookup."); notes.push("`--json body`, `--full`, and `--raw` are explicit structured machine disclosure; changed-file review uses bounded `gh pr files` or `gh pr review-plan`."); diff --git a/scripts/src/gh/repository-capability.ts b/scripts/src/gh/repository-capability.ts new file mode 100644 index 00000000..686877a9 --- /dev/null +++ b/scripts/src/gh/repository-capability.ts @@ -0,0 +1,80 @@ +import { spawnSync } from "node:child_process"; +import { randomBytes } from "node:crypto"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { rootPath } from "../config"; +import { REQUEST_TIMEOUT_MS } from "./types"; + +export interface RepositoryWriteCapability { + status: "available" | "unavailable" | "unknown"; + available: boolean | null; + evidence: "git-push-dry-run"; + reason: string; + exitCode: number | null; + writesRemote: false; + mutation: false; + valuesPrinted: false; +} + +export function probeRepositoryWriteCapability(repo: string, token: string): RepositoryWriteCapability { + const root = mkdtempSync(join(tmpdir(), "unidesk-gh-auth-")); + const askpass = join(root, "askpass.sh"); + const probeRef = `refs/heads/unidesk-auth-capability-${randomBytes(6).toString("hex")}`; + try { + writeFileSync(askpass, [ + "#!/bin/sh", + "case \"$1\" in", + " *Username*) printf '%s\\n' 'x-access-token' ;;", + " *Password*) printf '%s\\n' \"$UNIDESK_GH_AUTH_TOKEN\" ;;", + " *) printf '\\n' ;;", + "esac", + "", + ].join("\n"), { encoding: "utf8", mode: 0o700 }); + const result = spawnSync("git", [ + "-c", "credential.helper=", + "-c", "http.extraHeader=", + "push", "--dry-run", "--porcelain", + `https://github.com/${repo}.git`, + `HEAD:${probeRef}`, + ], { + encoding: "utf8", + cwd: rootPath(), + env: { + ...process.env, + GH_TOKEN: undefined, + GITHUB_TOKEN: undefined, + GIT_ASKPASS: askpass, + GIT_ASKPASS_REQUIRE: "force", + GIT_TERMINAL_PROMPT: "0", + UNIDESK_GH_AUTH_TOKEN: token, + }, + stdio: ["ignore", "pipe", "pipe"], + timeout: REQUEST_TIMEOUT_MS, + maxBuffer: 64 * 1024, + }); + if (result.status === 0) return capability(true, "git-push-dry-run-succeeded", result.status); + const output = `${result.stdout ?? ""}\n${result.stderr ?? ""}`; + if (/write access .* not granted|permission denied|authentication failed|requested url returned error: 403|repository not found|remote rejected/iu.test(output)) { + return capability(false, "git-push-dry-run-permission-denied", result.status); + } + if (result.error?.name === "ETIMEDOUT") return capability(null, "git-push-dry-run-timeout", result.status); + if ((result.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return capability(null, "git-binary-missing", result.status); + return capability(null, "git-push-dry-run-inconclusive", result.status); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +function capability(available: boolean | null, reason: string, exitCode: number | null): RepositoryWriteCapability { + return { + status: available === true ? "available" : available === false ? "unavailable" : "unknown", + available, + evidence: "git-push-dry-run", + reason, + exitCode, + writesRemote: false, + mutation: false, + valuesPrinted: false, + }; +} diff --git a/scripts/src/platform-infra-pac-delivery-timing.test.ts b/scripts/src/platform-infra-pac-delivery-timing.test.ts new file mode 100644 index 00000000..32689513 --- /dev/null +++ b/scripts/src/platform-infra-pac-delivery-timing.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, test } from "bun:test"; +import { observePacDeliveryBudget, observePacHistoryDeliveryBudget, observePacStatusDeliveryBudget } from "./platform-infra-pac-delivery-timing"; + +const policy = { + endToEndBudgetSeconds: 120, + configPath: "config/platform-infra/pipelines-as-code.yaml#deliveryTiming.endToEndBudgetSeconds", +}; + +function observe(endToEndSeconds: number | null, complete: boolean) { + return observePacDeliveryBudget({ + policy, + targetId: "NC01", + consumerId: "selfmedia-nc01", + pipelineRunId: "selfmedia-nc01-fixture", + endToEndSeconds, + complete, + }); +} + +describe("PaC automatic delivery budget warning", () => { + test("120 seconds stays within budget without a warning", () => { + const result = observe(120, true); + expect(result).toMatchObject({ state: "complete", overBudget: false, warning: null, blocking: false, mutation: false }); + }); + + test("more than 120 seconds emits a non-blocking env reuse warning", () => { + const result = observe(121, true); + expect(result.overBudget).toBe(true); + expect(result.warning).toMatchObject({ + code: "pac-automatic-delivery-over-budget", + severity: "warning", + blocking: false, + mutation: false, + observedSeconds: 121, + budgetSeconds: 120, + configPath: policy.configPath, + }); + const warningText = JSON.stringify(result.warning); + expect(warningText).toContain("env reuse"); + expect(warningText).toContain("BuildKit/cache"); + expect(warningText).not.toMatch(/trigger-current|refresh|sync|PipelineRun create/iu); + }); + + test("missing timing evidence remains partial and never warns", () => { + const result = observe(null, false); + expect(result).toMatchObject({ state: "partial", overBudget: null, warning: null, blocking: false, mutation: false }); + }); + + test("status and history summaries stay partial without exact end-to-end evidence", () => { + const status = observePacStatusDeliveryBudget({ + policy, + targetId: "NC01", + consumerId: "selfmedia-nc01", + summary: { ready: true, latestPipelineRun: { name: "fixture", durationSeconds: 193 } }, + }); + const history = observePacHistoryDeliveryBudget({ + policy, + targetId: "NC01", + consumerIds: ["selfmedia-nc01"], + defaultConsumerId: "selfmedia-nc01", + rows: [{ id: "fixture", consumer: "selfmedia-nc01", durationSeconds: 193 }], + }); + expect(status).toMatchObject({ state: "partial", overBudget: null, warning: null, pipelineDurationSeconds: 193, mutation: false }); + expect(history).toMatchObject({ state: "partial", blocking: false, mutation: false }); + expect((history.observations as Array>)[0]).toMatchObject({ state: "partial", warning: null, pipelineDurationSeconds: 193 }); + }); +}); diff --git a/scripts/src/platform-infra-pac-delivery-timing.ts b/scripts/src/platform-infra-pac-delivery-timing.ts index 9a68f400..b2de7803 100644 --- a/scripts/src/platform-infra-pac-delivery-timing.ts +++ b/scripts/src/platform-infra-pac-delivery-timing.ts @@ -11,6 +11,21 @@ export interface PacDeliveryTimingBinding { branch: string; pipeline: string; }; + policy: PacDeliveryTimingPolicy; +} + +export interface PacDeliveryTimingPolicy { + endToEndBudgetSeconds: number; + configPath: string; +} + +export interface PacDeliveryBudgetObservationInput { + policy: PacDeliveryTimingPolicy; + targetId: string; + consumerId: string; + pipelineRunId: string | null; + endToEndSeconds: number | null; + complete: boolean; } interface PacDeliveryTimingObservers { @@ -34,6 +49,106 @@ function durationBetween(start: unknown, end: unknown): number | null { return Math.max(0, Math.round((b - a) / 1000)); } +export function observePacDeliveryBudget(input: PacDeliveryBudgetObservationInput): Record { + const evidenceState = input.complete && input.endToEndSeconds !== null ? "complete" : "partial"; + const overBudget = evidenceState === "complete" && (input.endToEndSeconds as number) > input.policy.endToEndBudgetSeconds; + const warning = overBudget ? { + code: "pac-automatic-delivery-over-budget", + severity: "warning", + blocking: false, + mutation: false, + object: { kind: "consumer", id: input.consumerId }, + configPath: input.policy.configPath, + metric: "end-to-end-seconds", + observedSeconds: input.endToEndSeconds, + budgetSeconds: input.policy.endToEndBudgetSeconds, + hint: { + summary: "优先检查并优化 env reuse;对照 env identity、依赖缓存、BuildKit/cache 和 stage timing 定位慢阶段。", + status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${input.targetId} --consumer ${input.consumerId} --json`, + history: input.pipelineRunId === null + ? `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${input.targetId} --consumer ${input.consumerId} --limit 10 --json` + : `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${input.targetId} --consumer ${input.consumerId} --id ${input.pipelineRunId} --full`, + focus: ["env-identity", "dependency-cache", "buildkit-cache", "stage-timing"], + mutation: false, + valuesPrinted: false, + }, + valuesPrinted: false, + } : null; + return { + state: evidenceState, + metric: "end-to-end-seconds", + observedSeconds: input.endToEndSeconds, + budgetSeconds: input.policy.endToEndBudgetSeconds, + overBudget: evidenceState === "complete" ? overBudget : null, + configPath: input.policy.configPath, + warning, + blocking: false, + mutation: false, + valuesPrinted: false, + }; +} + +export function observePacStatusDeliveryBudget(input: { + policy: PacDeliveryTimingPolicy; + targetId: string; + consumerId: string; + summary: Record | null; +}): Record { + const latest = record(input.summary?.latestPipelineRun); + const observedEndToEnd = typeof latest.endToEndSeconds === "number" ? latest.endToEndSeconds : null; + return { + ...observePacDeliveryBudget({ + policy: input.policy, + targetId: input.targetId, + consumerId: input.consumerId, + pipelineRunId: typeof latest.name === "string" ? latest.name : null, + endToEndSeconds: observedEndToEnd, + complete: input.summary?.ready === true && observedEndToEnd !== null, + }), + evidenceSource: "status-summary", + pipelineDurationSeconds: typeof latest.durationSeconds === "number" ? latest.durationSeconds : null, + exactCommand: `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target ${input.targetId} --consumer ${input.consumerId} --json`, + }; +} + +export function observePacHistoryDeliveryBudget(input: { + policy: PacDeliveryTimingPolicy; + targetId: string; + consumerIds: readonly string[]; + defaultConsumerId: string; + rows: readonly Record[]; +}): Record { + const observations = input.rows.map((row) => { + const consumerId = typeof row.consumer === "string" ? row.consumer : input.consumerIds[0] ?? input.defaultConsumerId; + const observedEndToEnd = typeof row.endToEndSeconds === "number" ? row.endToEndSeconds : null; + return { + ...observePacDeliveryBudget({ + policy: input.policy, + targetId: input.targetId, + consumerId, + pipelineRunId: typeof row.id === "string" ? row.id : typeof row.pipelineRun === "string" ? row.pipelineRun : null, + endToEndSeconds: observedEndToEnd, + complete: row.deliveryComplete === true && observedEndToEnd !== null, + }), + consumer: consumerId, + pipelineRun: row.id ?? row.pipelineRun ?? null, + evidenceSource: "history-summary", + pipelineDurationSeconds: typeof row.durationSeconds === "number" ? row.durationSeconds : null, + }; + }); + return { + state: observations.length > 0 && observations.every((item) => item.state === "complete") ? "complete" : "partial", + policy: input.policy, + observations, + exactCommand: input.consumerIds.length === 1 + ? `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target ${input.targetId} --consumer ${input.consumerIds[0]} --json` + : null, + blocking: false, + mutation: false, + valuesPrinted: false, + }; +} + export async function observePacDeliveryTiming( binding: PacDeliveryTimingBinding, observers: PacDeliveryTimingObservers, @@ -85,11 +200,28 @@ export async function observePacDeliveryTiming( 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"); + if (statusSummary.ready !== true) evidenceGaps.push("runtime-closeout-not-ready"); + const state = evidenceGaps.length === 0 ? "complete" : "partial"; + const endToEndSeconds = durationBetween(mergedAt, completionTime); + const pipelineRunId = typeof row.id === "string" + ? row.id + : typeof latest.name === "string" + ? latest.name + : null; + const deliveryBudget = observePacDeliveryBudget({ + policy: binding.policy, + targetId: String(binding.target.id ?? binding.consumer.node), + consumerId: binding.consumer.id, + pipelineRunId, + endToEndSeconds, + complete: state === "complete", + }); + const warning = record(deliveryBudget.warning); return { ok: commit !== null && startTime !== null && completionTime !== null, action: "platform-infra-pipelines-as-code-delivery-timing", mutation: false, - state: evidenceGaps.length === 0 ? "complete" : "partial", + state, target: binding.target, consumer: binding.consumer, source: { commit, pullRequest, github: githubEvidence }, @@ -99,10 +231,13 @@ export async function observePacDeliveryTiming( pipelineCompletion: completionTime, triggerWaitSeconds: durationBetween(mergedAt, startTime), pipelineDurationSeconds: row.durationSeconds ?? durationBetween(startTime, completionTime), - endToEndSeconds: durationBetween(mergedAt, completionTime), + endToEndSeconds, }, - pipeline: { id: row.id ?? latest.name ?? null, status: row.status ?? latest.status ?? null, taskRuns: row.taskRuns ?? statusSummary.taskRuns ?? null }, + deliveryBudget, + warnings: Object.keys(warning).length === 0 ? [] : [warning], + pipeline: { id: pipelineRunId, status: row.status ?? latest.status ?? null, taskRuns: row.taskRuns ?? statusSummary.taskRuns ?? null }, runtime: { argo, health: runtime, provenance }, evidenceGaps: [...new Set(evidenceGaps)], + valuesPrinted: false, }; } diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 6667168e..df4e35a9 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -37,7 +37,12 @@ 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"; +import { + observePacDeliveryTiming, + observePacHistoryDeliveryBudget, + observePacStatusDeliveryBudget, + type PacDeliveryTimingPolicy, +} 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"; @@ -80,6 +85,7 @@ export interface PacConfig { display: { timeZone: string; }; + deliveryTiming: PacDeliveryTimingPolicy; observability: { configRef: string; tracesEndpoint: string; @@ -461,6 +467,7 @@ export function parsePacConfigDocument( const gitOpsMirrorFlush = y.objectField(closeout, "gitOpsMirrorFlush", "closeout"); const gitea = y.objectField(root, "gitea", ""); const display = y.objectField(root, "display", ""); + const deliveryTiming = y.objectField(root, "deliveryTiming", ""); const observability = y.objectField(root, "observability", ""); const observabilitySampling = y.objectField(observability, "sampling", "observability"); const exporterFailure = y.objectField(observability, "exporterFailure", "observability"); @@ -495,6 +502,10 @@ export function parsePacConfigDocument( display: { timeZone: y.stringField(display, "timeZone", "display"), }, + deliveryTiming: { + endToEndBudgetSeconds: positiveInteger(deliveryTiming, "endToEndBudgetSeconds", "deliveryTiming"), + configPath: `${configLabel}#deliveryTiming.endToEndBudgetSeconds`, + }, observability: { configRef: y.stringField(observability, "configRef", "observability"), tracesEndpoint: urlField(observability, "tracesEndpoint", "observability"), @@ -1297,6 +1308,8 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise consumer.id), + defaultConsumerId: pac.defaults.consumerId, + rows, + }); + const deliveryWarnings = arrayRecords(deliveryBudget.observations) + .map((observation) => record(observation.warning)) + .filter((warning) => Object.keys(warning).length > 0); return { ok: result.exitCode === 0 && parsed?.ok !== false && historyErrors.length === 0, action: "platform-infra-pipelines-as-code-history", @@ -1527,9 +1552,10 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< }, consumers: selectedConsumers.map((consumer) => consumer.id), detailId: options.detailId, - rows: arrayRecords(record(remote).rows), + rows, + deliveryBudget, historyErrors, - warnings: pac.validationWarnings, + warnings: [...pac.validationWarnings, ...deliveryWarnings], controlPlane: parsed === null ? undefined : { crdPresent: parsed.crdPresent === true, controllerReady: parsed.controllerReady, @@ -2051,6 +2077,7 @@ function configSummary(pac: PacConfig, consumer: PacConsumer, repository: PacRep metadata: pac.metadata, release: pac.release, capabilities: capabilitySummary(pac), + deliveryTiming: pac.deliveryTiming, gitea: { configRef: pac.gitea.configRef, internalBaseUrl: pac.gitea.internalBaseUrl, webhookBranch: pac.gitea.webhook.branch }, display: pac.display, repositories: pac.repositories.map(repositorySummary), @@ -2070,6 +2097,7 @@ function compactConfigSummary(pac: PacConfig, consumer: PacConsumer, repository: providerType: repository.providerType, sourceUrl: repository.cloneUrl, displayTimeZone: pac.display.timeZone, + deliveryTiming: pac.deliveryTiming, consumer: `${consumer.node}/${consumer.lane}`, consumerId: consumer.id, pipeline: consumer.pipeline, @@ -2159,6 +2187,7 @@ function compactPlanJson(result: Record): Record): Record, includeTaskD config: result.config, consumers: result.consumers, detailId: result.detailId, + deliveryBudget: result.deliveryBudget, rows: arrayRecords(result.rows).map((row) => { const taskRuns = record(row.taskRuns); const artifact = record(row.artifact); @@ -2393,6 +2424,7 @@ export function compactHistoryJson(result: Record, includeTaskD }; }), historyErrors: result.historyErrors, + warnings: result.warnings, next: result.next, valuesPrinted: false, }; @@ -2476,6 +2508,7 @@ export function renderStatus(result: Record): RenderedCliResult const coverage = arrayRecords(result.coverage); const warnings = arrayRecords(result.warnings); const latest = record(summary.latestPipelineRun); + const deliveryBudget = record(result.deliveryBudget); const taskRuns = arrayRecords(summary.taskRuns); const artifact = record(summary.artifact); const argo = record(summary.argo); @@ -2521,6 +2554,7 @@ export function renderStatus(result: Record): RenderedCliResult "", "LATEST PIPELINERUN", ...table(["NAME", "STATUS", "REASON", "DURATION_S", "SOURCE"], [[stringValue(latest.name), stringValue(latest.status), stringValue(latest.reason), stringValue(latest.durationSeconds), short(stringValue(latest.sourceCommit))]]), + ` delivery-budget: state=${stringValue(deliveryBudget.state)} end-to-end=${stringValue(deliveryBudget.observedSeconds)}s/${stringValue(deliveryBudget.budgetSeconds)}s exact=${stringValue(deliveryBudget.exactCommand)}`, "", "DELIVERY OBSERVATION", ...table(["MODE", "VALID", "SOURCE_MATCHED", "AFFECTED", "ROLLOUT", "BUILD", "REUSED", "REASON"], [[ @@ -2585,12 +2619,21 @@ function renderPacConfigWarnings(warnings: readonly Record[]): return [ "", "NON-BLOCKING WARNINGS", - ...table(["OBJECT", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [ + ...table(["OBJECT", "BLOCKING", "CODE", "CONFIG", "OBSERVED/BUDGET"], warnings.map((item) => [ `${stringValue(record(item.object).kind)}:${stringValue(record(item.object).id)}`, boolText(item.blocking), stringValue(item.code), stringValue(item.configPath), + item.observedSeconds === undefined ? "-" : `${stringValue(item.observedSeconds)}s/${stringValue(item.budgetSeconds)}s`, ])), + ...warnings.flatMap((item) => { + const hint = record(item.hint); + return Object.keys(hint).length === 0 ? [] : [ + ` hint: ${compactLine(stringValue(hint.summary))}`, + ` status: ${stringValue(hint.status)}`, + ` history: ${stringValue(hint.history)}`, + ]; + }), ]; } @@ -2681,6 +2724,7 @@ async function deliveryTiming(config: UniDeskConfig, options: CommonOptions): Pr branch: authority.consumer.sourceBranch, pipeline: consumer.pipeline, }, + policy: pac.deliveryTiming, }, { history: async () => await history(config, historyOptions), status: async () => await status(config, options), @@ -2694,14 +2738,18 @@ function renderDeliveryTiming(result: Record): RenderedCliResul const runtime = record(result.runtime); const argo = record(runtime.argo); const health = record(runtime.health); + const deliveryBudget = record(result.deliveryBudget); + const warnings = arrayRecords(result.warnings); 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)}`, + `BUDGET_S: ${stringValue(deliveryBudget.budgetSeconds)} OVER_BUDGET: ${stringValue(deliveryBudget.overBudget)} BUDGET_STATE: ${stringValue(deliveryBudget.state)}`, `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(", ") || "-"}`, + ...renderPacConfigWarnings(warnings), ]; return rendered(result, "platform-infra pipelines-as-code delivery-timing", lines); } @@ -2710,12 +2758,16 @@ export function renderHistory(result: Record): RenderedCliResul const rows = arrayRecords(result.rows); const config = record(result.config); const historyErrors = arrayRecords(result.historyErrors); + const deliveryBudget = record(result.deliveryBudget); + const warnings = arrayRecords(result.warnings); const detailId = stringValue(result.detailId); const timeHeader = `TIME(${stringValue(config.displayTimeZone)})`; const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE HISTORY", `DATA: ${stringValue(config.source)}`, `ROWS: ${stringValue(rows.length)} LIMIT_PER_CONSUMER: ${stringValue(config.limitPerConsumer)} READ_ERRORS: ${stringValue(historyErrors.length)}`, + `DELIVERY_BUDGET: state=${stringValue(deliveryBudget.state)} budget=${stringValue(record(deliveryBudget.policy).endToEndBudgetSeconds)}s exact=${stringValue(deliveryBudget.exactCommand)}`, + ...renderPacConfigWarnings(warnings), ...(historyErrors.length === 0 ? [] : [ "", "READ ERRORS",