diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index e21b18e0..4b8aa8e2 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -15,6 +15,7 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 发现衍生问题时,只能创建独立 issue 或记录待办,不得扩大当前 prompt、PR、合并范围或验收前置; - 现有机制阻碍原始目标时,先寻找既有架构内的最小实现路径;确需架构扩展时,必须向用户说明与原始目标的关系并取得明确授权。 - 执行型和调研型委派默认优先使用 AgentRun `Artificer`: + - Artificer 的 `create task`、`apply` 和 `dispatch` 必须以下文“子 issue + MDTODO”登记为 fail-closed 前置;禁止先创建 task 或派单再补登记; - 未显式选模时,不由主代理注入客户端模型默认值;使用 owning AipodSpec 的默认模型,当前为 `gpt-5.6-sol`,默认 reasoning effort 为 `medium`; - Artificer 的 API credential: - 只允许由 UniDesk owning YAML 将 `/root/.codex/auth.json.pika` 与 `/root/.codex/config.toml.pika` 投影为 `gpt-pika` SecretRef; @@ -37,9 +38,10 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 纯文档交付可以按 `$git-spec` 的稳定分支快路径由主代理直接 commit/push,不要求临时 branch、`.worktree` 或 PR;发现本地分叉、并行改动、分支保护或运行面影响时必须保留状态并回到隔离交付。 - 正式 GitHub issue/PR/comment/merge 仍走 `$unidesk-gh`;子代理可以提交 PR 和写调查结论,主代理负责 review/preflight/merge,除非用户明确授权某个子代理自上线自验证。 - 主代理和子代理必须通过 issue/PR/comment 链接传递可复用上下文、调查结论、证据链接和下一步边界;派发前先读既有评论,prompt 中只引用链接和增量任务,不复述长结论,避免不同子代理重复调查同一事实。 -- 主代理派发执行型子代理前必须完成两项登记: +- 主代理派发任何执行型子代理前必须完成以下登记;对 Artificer,全部步骤必须早于 AgentRun `create task`、`apply` 和 `dispatch`: - 创建子 issue,把主要任务、接续上下文、目标分支/worktree、范围、禁止项和验收入口写入正文; - - 使用 `$mdtodo-edit` 将子 issue 链接登记到对应泛化问题域的 MDTODO ITEM 或 SUBITEM;不得为单个 issue 创建窄范围 MDTODO FILE。 + - 使用 `$mdtodo-edit` 在对应泛化问题域的 MDTODO ITEM 或 SUBITEM 中登记子 issue 链接,并把任务标记为进行中;不得为单个 issue 创建窄范围 MDTODO FILE; + - 任一登记缺失或写入失败时不得创建 AgentRun task;派单后补写 MDTODO 不计为合规登记。 - 子代理 prompt 只给子 issue 链接和极短边界,不塞大段任务正文。每个执行型子代理维护自己的子 issue 和评论作为接续上下文;主 issue 评论区只由主代理写入主线 anchor、阶段汇总、调度决策和最终 closeout。 - 子代理不得把过程日志、单步证据、长调查和 post-task 反馈直接堆到主 issue 评论区,只能在主 issue 需要可见时由主代理引用子 issue/PR/comment 链接。 - 主代理跟踪子代理进度时优先读取子 issue 评论区、关联 PR 更新和 bounded issue/PR 状态;不要为了普通进度查询主动 `send_input interrupt` 打断子代理主线。只有子 issue/PR 长时间无更新且只读 worktree 也无推进时,才进入问询、关闭和重开流程。 @@ -70,7 +72,7 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - CI/CD、rollout、PipelineRun、Argo closeout:`$unidesk-cicd`。 - WebProbe、Workbench、浏览器复测:`$unidesk-webdev`。 - 子代理完成后的反馈收口和反馈 issue 判定:`$post-task`。 -- 需要 MDTODO 记录和执行-审查循环时再用 `$mdtodo-loop`;不要把普通 GitHub PR 并行工作强行塞进 MDTODO。 +- Artificer 和其他执行型子代理派单前的任务登记必须使用 `$mdtodo-edit`;只有用户明确要求多轮审查或循环修复时才使用 `$mdtodo-loop`。 ## 何时读取 reference diff --git a/.agents/skills/unidesk-subagent/references/gh-workflow.md b/.agents/skills/unidesk-subagent/references/gh-workflow.md index e5d65b95..a2e6a5a4 100644 --- a/.agents/skills/unidesk-subagent/references/gh-workflow.md +++ b/.agents/skills/unidesk-subagent/references/gh-workflow.md @@ -1,4 +1,4 @@ -# GitHub Subagent Workflow +# GitHub 子代理工作流 本 reference 记录 UniDesk 主代理调度子代理、并结合 GitHub issue/PR 的稳定工作流。它约束的是协作方式,不替代 `$unidesk-gh` 的具体命令参数,也不替代各业务 skill 的验收标准。 @@ -35,6 +35,11 @@ ## GitHub Issue 协作 - 大任务先有 GitHub issue 或在既有 issue 中补并行计划:列出子任务、负责人/子代理、目标分支、预期 PR、验证入口、依赖关系和哪些任务可并行。 +- Artificer 派单采用 fail-closed 登记顺序: + - 先创建子 issue,冻结目标分支、工作区、范围、禁止项和验收入口; + - 再使用 `$mdtodo-edit` 在泛化问题域 MDTODO 中创建或更新 ITEM/SUBITEM、登记子 issue 链接并标记进行中; + - 只有上述写入成功后,才允许 AgentRun `create task`、`apply` 或 `dispatch`; + - 缺少 MDTODO FILE、任务项、进行中状态或子 issue 链接时不得派单,派单后补写不算合规。 - 主 issue 评论区由主代理独占维护:只写主线 anchor、阶段汇总、调度决策、已采纳结论、下一批边界和最终 closeout。子代理不得直接在主 issue 评论区堆过程、日志、单步证据或 post-task 反馈;需要让主线可见时,由主代理在主 issue 引用子 issue/PR/comment 链接。 - 主代理派发执行型子代理前必须先创建子 issue,不能把主要任务正文直接塞进 subagent prompt。子 issue 标题应能反映父 issue、运行面/模块和子任务;正文必须引用父 issue、目标分支/worktree、允许范围、禁止范围、验收入口、模型/思考等级选择理由和当前接续链接。子代理的调查、单步证据、阻塞、post-task 和后续接力评论都写在自己的子 issue 或关联 PR 中。 - 子代理 prompt 只传子 issue 链接、模型要求和“不写父 issue 评论区”等极短边界;不要在 prompt 里复述长任务、历史结论、日志或完整证据。主代理通过观察子 issue 评论区跟踪进度。 @@ -75,14 +80,15 @@ Prompt 至少包含以下字段,按任务裁剪: 1. 建立计划:列出可并行任务、串行依赖和每个子代理交付物。 2. 为每个执行型子代理先创建子 issue,把任务正文写进子 issue。 -3. 同时派发低耦合子任务;prompt 只引用子 issue 链接和极短边界;共享契约先派一个基线任务。 -4. 轮询子代理结果:子 issue comment、PR、验证摘要、阻塞。 -5. 对每个 PR 做架构 review、bounded diff、preflight 和必要本地验证。 -6. 按依赖顺序合并;合并后同步目标 worktree。 -7. 触发受控 CI/CD 或让明确授权的子代理上线;主代理核对 closeout。 -8. 用原入口复测;把剩余问题拆到新 issue 或追加既有 issue。 -9. 对每个已完成或已纠偏完成的子代理发送 post-task 收口要求,读取其已判断 feedback;主代理只挑选适合工程化的项转正式 FEATURE/BUG issue,并优先派回原子代理执行。 -10. 主代理 post-task:长期参考、清理已吸收 worktree;不代替子代理做 feedback 池去重。 +3. 使用 `$mdtodo-edit` 把子 issue 登记到泛化问题域 ITEM/SUBITEM 并标记进行中;登记失败时停止派单。 +4. 同时派发低耦合子任务;Artificer 的 `create task`、`apply` 和 `dispatch` 均只能发生在 MDTODO 登记之后;共享契约先派一个基线任务。 +5. 轮询子代理结果:子 issue comment、PR、验证摘要、阻塞。 +6. 对每个 PR 做架构 review、bounded diff、preflight 和必要本地验证。 +7. 按依赖顺序合并;合并后同步目标 worktree。 +8. 触发受控 CI/CD 或让明确授权的子代理上线;主代理核对 closeout。 +9. 用原入口复测;把剩余问题拆到新 issue 或追加既有 issue。 +10. 对每个已完成或已纠偏完成的子代理发送 post-task 收口要求,读取其已判断 feedback;主代理只挑选适合工程化的项转正式 FEATURE/BUG issue,并优先派回原子代理执行。 +11. 主代理 post-task:长期参考、清理已吸收 worktree;不代替子代理做 feedback 池去重。 ## 无响应接续 diff --git a/docs/MDTODO/artificer-runtime-capabilities.md b/docs/MDTODO/artificer-runtime-capabilities.md index 55f79ce9..c6416bee 100644 --- a/docs/MDTODO/artificer-runtime-capabilities.md +++ b/docs/MDTODO/artificer-runtime-capabilities.md @@ -74,3 +74,19 @@ ### R5.3 [completed] 自动上线后执行最短 Artificer canary,确认直接处理业务任务且没有创建第二 task,并记录 task/run/session 证据,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R5.3_Task_Report.md)。 + +## R6 [in_progress] + +让 Artificer 派单成为 Target-aware 的单命令流程:所有子代理派单前先用 MDTODO 跟踪;主代理通过 trans 在 YAML 选择的 Target 上执行 create/dispatch;Artificer 接单后内部也通过 trans 到 Target 的任务 worktree 执行,从而复用当前 NC01 的 Git、工具和运行面受控权限。CLI 只需 target、target-workspace、repo、ref 与 prompt,即在创建前通过同一 trans 核对工作区、分支和源码身份,自动渲染合法 session、workspaceRef 与 Aipod 资源包,不让 schema-invalid 或私库 fetch 错误在建 task 后才暴露;owning YAML prompt 与 unidesk-subagent 固化规则,并以 /root/.worktrees/selfmedia/r6-ocr-visual-planner 真实验收,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6_Task_Report.md)。 + +### R6.1 [in_progress] + +由原生子代理在最新远端基线的独立 worktree 实现易用派单入口:外层在 YAML Target 上通过 trans 派单,Artificer 内部通过 trans 到 NC01 目标工作区复用主机权限;覆盖 target/target-workspace/repo/ref、创建前源码身份预检、合法 session/workspaceRef/Aipod 投影、owning prompt,以及 unidesk-subagent 的“所有子代理先建 MDTODO”固定规则;对应原生任务 artificer_dispatch_ux,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6.1_Task_Report.md)。 + +### R6.2 + +主代理审查跨仓改动并按依赖顺序合并 PR,只观察自动 CI/CD 收敛;不得人工触发 mirror、PipelineRun、apply 或 rollout,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6.2_Task_Report.md)。 + +### R6.3 + +上线后通过 trans 在 NC01 使用新单命令入口,以任务 worktree /root/.worktrees/selfmedia/r6-ocr-visual-planner、仓库 pikainc/selfmedia 和 ref feat/r6-ocr-visual-planner 派发 OCR/视觉规划,确认 task 直接 running、Artificer 内部继续用 trans、session 可续跑且不出现 schema-invalid 或 repository-not-found,完成任务后将详细报告写入[任务报告](./details/artificer-runtime-capabilities/R6.3_Task_Report.md)。 diff --git a/scripts/native/cicd/pac-source-artifact-runtime.mjs b/scripts/native/cicd/pac-source-artifact-runtime.mjs index 50e003a0..9cd3410e 100644 --- a/scripts/native/cicd/pac-source-artifact-runtime.mjs +++ b/scripts/native/cicd/pac-source-artifact-runtime.mjs @@ -94,6 +94,10 @@ function missing(reason, sourceCommit = null) { function isAdmissionDefault(path, value) { const normalized = path.map((segment) => typeof segment === "number" ? "*" : segment).join("."); const emptyRecord = value !== null && typeof value === "object" && !Array.isArray(value) && Object.keys(value).length === 0; + if ([ + "spec.workspaces.*.volumeClaimTemplate.metadata", + "spec.workspaces.*.volumeClaimTemplate.status", + ].includes(normalized)) return emptyRecord; const atTaskSpec = (suffix) => [ `tasks.*.taskSpec.${suffix}`, `spec.tasks.*.taskSpec.${suffix}`, @@ -110,6 +114,8 @@ function isAdmissionDefault(path, value) { export function canonicalizePacPipelineSpec(value, path = []) { if (Array.isArray(value)) return value.map((item, index) => canonicalizePacPipelineSpec(item, [...path, index])); + const duration = canonicalTektonPipelineTimeout(path, value); + if (duration !== null) return duration; if (value === null || typeof value !== "object") return value; const output = {}; for (const key of Object.keys(value).sort()) { @@ -121,6 +127,38 @@ export function canonicalizePacPipelineSpec(value, path = []) { return output; } +function canonicalTektonPipelineTimeout(path, value) { + if (path.join(".") !== "spec.timeouts.pipeline" || typeof value !== "string") return null; + const matchSign = /^([+-]?)(.*)$/u.exec(value); + if (matchSign === null) return null; + const source = matchSign[2] ?? ""; + const units = { + h: 3_600_000_000_000n, + m: 60_000_000_000n, + s: 1_000_000_000n, + ms: 1_000_000n, + us: 1_000n, + "µs": 1_000n, + "μs": 1_000n, + ns: 1n, + }; + const segment = /([0-9]+)(ns|us|µs|μs|ms|s|m|h)/uy; + let offset = 0; + let nanoseconds = 0n; + let count = 0; + while (offset < source.length) { + segment.lastIndex = offset; + const matched = segment.exec(source); + if (matched === null) return null; + nanoseconds += BigInt(matched[1] ?? "0") * (units[matched[2] ?? ""] ?? 0n); + offset = segment.lastIndex; + count += 1; + } + if (count === 0) return null; + if (matchSign[1] === "-") nanoseconds = -nanoseconds; + return `tekton-duration-ns:${nanoseconds}`; +} + export function canonicalPacPipelineSha256(value) { return `sha256:${createHash("sha256").update(JSON.stringify(canonicalizePacPipelineSpec(value))).digest("hex")}`; } diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index a2d5558f..42ac07e9 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -331,6 +331,31 @@ describe("Tekton admission canonicalization", () => { expect(canonicalizePacPipelineSpec(negative)).toEqual(negative); expect(nativeCanonicalize(negative)).toEqual(negative); }); + + test("normalizes equivalent PipelineRun timeout spellings without hiding different values", () => { + const seconds = { spec: { timeouts: { pipeline: "600s" } } }; + const admitted = { spec: { timeouts: { pipeline: "10m0s" } } }; + const different = { spec: { timeouts: { pipeline: "601s" } } }; + const unrelated = { timeouts: { pipeline: "600s" } }; + expect(canonicalizePacPipelineSpec(seconds)).toEqual(canonicalizePacPipelineSpec(admitted)); + expect(nativeCanonicalize(seconds)).toEqual(nativeCanonicalize(admitted)); + expect(canonicalSha256(seconds)).toBe(canonicalSha256(admitted)); + expect(nativeCanonicalSha256(admitted)).toBe(canonicalSha256(seconds)); + expect(firstPacSourceArtifactDrift(seconds, admitted)).toBeNull(); + expect(firstPacSourceArtifactDrift(seconds, different)?.path).toBe("$.spec.timeouts.pipeline"); + expect(canonicalizePacPipelineSpec(unrelated)).toEqual(unrelated); + expect(nativeCanonicalize(unrelated)).toEqual(unrelated); + }); + + test("removes only empty workspace claim metadata and status added by admission", () => { + const desired = { spec: { workspaces: [{ name: "source", volumeClaimTemplate: { spec: { accessModes: ["ReadWriteOnce"] } } }] } }; + const admitted = { spec: { workspaces: [{ name: "source", volumeClaimTemplate: { metadata: {}, spec: { accessModes: ["ReadWriteOnce"] }, status: {} } }] } }; + const labeled = { spec: { workspaces: [{ name: "source", volumeClaimTemplate: { metadata: { labels: { owner: "test" } }, spec: { accessModes: ["ReadWriteOnce"] } } }] } }; + expect(canonicalizePacPipelineSpec(admitted)).toEqual(canonicalizePacPipelineSpec(desired)); + expect(nativeCanonicalize(admitted)).toEqual(nativeCanonicalize(desired)); + expect(firstPacSourceArtifactDrift(desired, admitted)).toBeNull(); + expect(firstPacSourceArtifactDrift(desired, labeled)?.path).toBe("$.spec.workspaces[0].volumeClaimTemplate.metadata"); + }); }); describe("source artifact serialization", () => { @@ -636,6 +661,9 @@ describe("runtime source-commit selection", () => { expect(sourceArtifactRuntimeAlignment(aligned, exact, sourceCommit)).toBe("aligned"); expect(sourceArtifactRuntimeAlignment(drifted, exact, sourceCommit)).toBe("drifted"); expect(sourceArtifactRuntimeAlignment(aligned, { ...exact, clean: false }, sourceCommit)).toBe("unavailable"); + const embeddedOnly = { ...structuredClone(aligned), live: { ...aligned.live, status: "missing" as const, provenanceAligned: false } }; + expect(sourceArtifactRuntimeAlignment(embeddedOnly, exact, sourceCommit, "embedded-pipeline-spec")).toBe("aligned"); + expect(sourceArtifactRuntimeAlignment(embeddedOnly, exact, sourceCommit, "remote-pipeline-annotation")).toBe("missing"); expect(JSON.stringify([aligned, drifted])).not.toContain("pending"); }); diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index afbde7b0..626d1c4d 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -284,7 +284,7 @@ export async function runPacSourceArtifact( }) : null; const sourceOk = source.aligned && source.provenanceAligned; - const runtimeAlignment = sourceArtifactRuntimeAlignment(runtime, sourceWorktreeState, options.sourceCommit); + const runtimeAlignment = sourceArtifactRuntimeAlignment(runtime, sourceWorktreeState, options.sourceCommit, binding.consumer.sourceArtifact.mode); const runtimeOk = runtimeAlignment === "aligned"; const ok = options.action === "check" ? sourceOk @@ -326,6 +326,9 @@ export async function runPacSourceArtifact( liveExportAllowed: false, sourceWorktreeExplicit: true, canonicalAdmissionDefaultsRemoved: [ + "PipelineRun.spec.timeouts.pipeline=", + "workspaces[].volumeClaimTemplate.metadata={}", + "workspaces[].volumeClaimTemplate.status={}", "tasks[].taskSpec.metadata={}", "tasks[].taskSpec.spec=null", "tasks[].taskSpec.params[].type=string", @@ -359,13 +362,14 @@ export function sourceArtifactRuntimeAlignment( runtime: PacSourceArtifactRuntimeObservation | null, worktree: SourceWorktreeState, sourceCommit: string | null, + mode: PacSourceArtifactMode = "remote-pipeline-annotation", ): PacSourceArtifactRuntimeAlignment { if (runtime === null) return "not-requested"; if (sourceCommit !== null && (!worktree.clean || worktree.matchesSourceCommit !== true)) return "unavailable"; - if (runtime.live.status === "unavailable" || runtime.embedded.status === "unavailable") return "unavailable"; - if (runtime.live.status === "missing" || runtime.embedded.status === "missing") return "missing"; - if (runtime.live.status === "drifted" || runtime.embedded.status === "drifted") return "drifted"; - if (!runtime.live.provenanceAligned || !runtime.embedded.provenanceAligned) return "drifted"; + const required = mode === "embedded-pipeline-spec" ? [runtime.embedded] : [runtime.live, runtime.embedded]; + if (required.some((item) => item.status === "unavailable")) return "unavailable"; + if (required.some((item) => item.status === "missing")) return "missing"; + if (required.some((item) => item.status === "drifted" || !item.provenanceAligned)) return "drifted"; return "aligned"; } @@ -386,14 +390,16 @@ function sourceArtifactNext( if (!worktree.clean || (options.sourceCommit !== null && worktree.matchesSourceCommit !== true)) { return "Create a clean worktree at the exact GitHub merge commit, then rerun status or verify-runtime with that full source commit."; } - if (runtime.live.status === "unavailable") return "Repair the owning control-plane runtime observer or RBAC, then rerun status before verification."; - if (runtime.live.status === "missing") return "Apply the owning YAML control plane, confirm the live Pipeline exists, then rerun verify-runtime."; - if (runtime.live.status === "drifted" || !runtime.live.provenanceAligned) return "Apply the owning YAML control plane so the live Pipeline spec and provenance align, then rerun verify-runtime."; + if (binding.consumer.sourceArtifact.mode === "remote-pipeline-annotation" && runtime.live.status === "unavailable") return "Repair the owning control-plane runtime observer or RBAC, then rerun status before verification."; + if (binding.consumer.sourceArtifact.mode === "remote-pipeline-annotation" && runtime.live.status === "missing") return "Apply the owning YAML control plane, confirm the live Pipeline exists, then rerun verify-runtime."; + if (binding.consumer.sourceArtifact.mode === "remote-pipeline-annotation" && (runtime.live.status === "drifted" || !runtime.live.provenanceAligned)) return "Apply the owning YAML control plane so the live Pipeline spec and provenance align, then rerun verify-runtime."; if (runtime.embedded.status === "unavailable") return "Inspect the exact-commit PaC PipelineRun identity and observer access, then rerun verify-runtime."; if (runtime.embedded.status === "missing") return "Wait for or repair the exact-commit PaC PipelineRun; do not validate a prefix-selected run."; if (runtime.embedded.status === "drifted" || !runtime.embedded.provenanceAligned) return "Regenerate and merge the consumer source artifact, then verify the new exact-commit PipelineRun."; return runtimeAlignment === "aligned" - ? "Exact source, live Pipeline, embedded PipelineRun, and provenance are aligned; continue consumer closeout." + ? binding.consumer.sourceArtifact.mode === "embedded-pipeline-spec" + ? "Exact source, embedded PipelineRun, and provenance are aligned; continue consumer closeout." + : "Exact source, live Pipeline, embedded PipelineRun, and provenance are aligned; continue consumer closeout." : "Resolve the reported runtime layer and rerun verify-runtime."; } @@ -428,6 +434,8 @@ export function renderPacSourceArtifactResult(result: Record): export function canonicalizePacPipelineSpec(value: unknown, path: readonly (string | number)[] = []): unknown { if (Array.isArray(value)) return value.map((item, index) => canonicalizePacPipelineSpec(item, [...path, index])); + const duration = canonicalTektonPipelineTimeout(path, value); + if (duration !== null) return duration; if (!isRecord(value)) return value; const output: Record = {}; for (const key of Object.keys(value).sort()) { @@ -439,6 +447,38 @@ export function canonicalizePacPipelineSpec(value: unknown, path: readonly (stri return output; } +function canonicalTektonPipelineTimeout(path: readonly (string | number)[], value: unknown): string | null { + if (path.join(".") !== "spec.timeouts.pipeline" || typeof value !== "string") return null; + const matchSign = /^([+-]?)(.*)$/u.exec(value); + if (matchSign === null) return null; + const source = matchSign[2] ?? ""; + const units: Readonly> = { + h: 3_600_000_000_000n, + m: 60_000_000_000n, + s: 1_000_000_000n, + ms: 1_000_000n, + us: 1_000n, + "µs": 1_000n, + "μs": 1_000n, + ns: 1n, + }; + const segment = /([0-9]+)(ns|us|µs|μs|ms|s|m|h)/uy; + let offset = 0; + let nanoseconds = 0n; + let count = 0; + while (offset < source.length) { + segment.lastIndex = offset; + const matched = segment.exec(source); + if (matched === null) return null; + nanoseconds += BigInt(matched[1] ?? "0") * (units[matched[2] ?? ""] ?? 0n); + offset = segment.lastIndex; + count += 1; + } + if (count === 0) return null; + if (matchSign[1] === "-") nanoseconds = -nanoseconds; + return `tekton-duration-ns:${nanoseconds}`; +} + export function canonicalSha256(value: unknown): string { return `sha256:${createHash("sha256").update(JSON.stringify(canonicalizePacPipelineSpec(value))).digest("hex")}`; } @@ -1027,6 +1067,10 @@ function firstCanonicalDrift(expected: unknown, actual: unknown, path: string): function isProvenTektonAdmissionDefault(path: readonly (string | number)[], value: unknown): boolean { const normalized = path.map((segment) => typeof segment === "number" ? "*" : segment).join("."); const emptyRecord = isRecord(value) && Object.keys(value).length === 0; + if ([ + "spec.workspaces.*.volumeClaimTemplate.metadata", + "spec.workspaces.*.volumeClaimTemplate.status", + ].includes(normalized)) return emptyRecord; const atTaskSpec = (suffix: string): boolean => [ `tasks.*.taskSpec.${suffix}`, `spec.tasks.*.taskSpec.${suffix}`, diff --git a/scripts/src/platform-infra-sub2rank-config.ts b/scripts/src/platform-infra-sub2rank-config.ts index aa1f6d7f..2897fc29 100644 --- a/scripts/src/platform-infra-sub2rank-config.ts +++ b/scripts/src/platform-infra-sub2rank-config.ts @@ -13,6 +13,7 @@ const y = createYamlFieldReader(sub2RankConfigLabel); export interface Sub2RankConfig { version: number; kind: "platform-infra-sub2rank"; + warnings: string[]; metadata: { id: string; owner: string; relatedIssues: number[] }; defaults: { targetId: string }; application: { @@ -30,6 +31,7 @@ export interface Sub2RankConfig { sourceClean: boolean; sourceRemotePresent: boolean; sourceRemoteMatches: boolean; + sourceConfigPathMatches: boolean; configMatchesSourceCommit: boolean; deploymentBlockPresent: boolean; automaticCreditEnabled: boolean; @@ -67,7 +69,7 @@ export interface Sub2RankConfig { project: string; path: string; destinationNamespace: string; - automated: true; + automated: boolean; }; }; }; @@ -133,10 +135,11 @@ export function readSub2RankConfig(): Sub2RankConfig { const secret = resolveSecretDeclaration(runtimeBase.secret.configRef, runtimeBase.secret.declarationId); const targets = parseTargets(root.targets, secret); if (!targets.some((target) => target.id === defaults.targetId)) throw new Error(`${sub2RankConfigLabel}.targets must include defaults.targetId ${defaults.targetId}`); - validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets); + const warnings = validateResolvedConfig(application, image, delivery, runtimeBase, secret, targets); return { version, kind: "platform-infra-sub2rank", + warnings, metadata: { id: y.stringField(metadataRecord, "id", "metadata"), owner: y.stringField(metadataRecord, "owner", "metadata"), @@ -192,7 +195,6 @@ function parseApplication(record: Record): Sub2RankConfig["appl const statusShort = gitText(sourceRoot, ["status", "--porcelain"], "read Sub2Rank source status", true); const observedRemote = gitText(sourceRoot, ["remote", "get-url", "origin"], "read Sub2Rank origin", true); const configRelativePath = relative(sourceRoot, configRef).replaceAll("\\", "/"); - if (configRelativePath !== sourceConfigPath) throw new Error(`${sub2RankConfigLabel}.application.sourceConfigPath must resolve to application.configRef`); const committedConfig = gitText(sourceRoot, ["show", `${sourceCommit}:${configRelativePath}`], "read committed Sub2Rank application config", true, false); return { repository, @@ -209,6 +211,7 @@ function parseApplication(record: Record): Sub2RankConfig["appl sourceClean: statusShort.length === 0, sourceRemotePresent: observedRemote.length > 0, sourceRemoteMatches: sameRepositoryIdentity(remote, observedRemote), + sourceConfigPathMatches: configRelativePath === sourceConfigPath, configMatchesSourceCommit: committedConfig === configText.trimEnd(), deploymentBlockPresent: app.deployment !== undefined, automaticCreditEnabled, @@ -241,7 +244,6 @@ function parseDelivery(record: Record): Sub2RankConfig["deliver const email = y.stringField(author, "email", "delivery.gitops.author"); if (!/^[^@\s]+@[^@\s]+$/u.test(email)) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.author.email must be an email address`); const automated = y.booleanField(application, "automated", "delivery.gitops.application"); - if (!automated) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.automated must remain true`); return { enabled: y.booleanField(record, "enabled", "delivery"), pipeline: { @@ -278,7 +280,7 @@ function parseDelivery(record: Record): Sub2RankConfig["deliver project: y.kubernetesNameField(application, "project", "delivery.gitops.application"), path: safeRelativePath(y.stringField(application, "path", "delivery.gitops.application"), "delivery.gitops.application.path"), destinationNamespace: y.kubernetesNameField(application, "destinationNamespace", "delivery.gitops.application"), - automated: true, + automated, }, }, }; @@ -444,27 +446,32 @@ function validateResolvedConfig( runtime: ReturnType, secret: ResolvedSecretDeclaration, targets: Sub2RankTarget[], -): void { - if (!application.configMatchesSourceCommit) throw new Error(`${sub2RankConfigLabel}.application.configRef must match the file stored at application source commit ${application.sourceCommit}`); - if (!application.sourceRemoteMatches) throw new Error(`${sub2RankConfigLabel}.application.remote must identify the application.sourceRoot origin repository`); - if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) throw new Error(`${sub2RankConfigLabel}.application.repository must match application.remote`); - if (!delivery.enabled) throw new Error(`${sub2RankConfigLabel}.delivery.enabled must remain true while Sub2Rank is deployed`); - if (delivery.gitops.maxPushAttempts > 5) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.maxPushAttempts must be <= 5`); - if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.manifestPath must be directly under delivery.gitops.application.path`); +): string[] { + const warnings: string[] = []; + if (!application.sourceConfigPathMatches) warnings.push("application.sourceConfigPath 与 application.configRef 的相对路径不一致"); + if (!application.configMatchesSourceCommit) warnings.push(`application.configRef 与源码提交 ${application.sourceCommit} 中的文件不一致`); + if (!application.sourceRemoteMatches) warnings.push("application.remote 与 application.sourceRoot 的 origin 不一致"); + if (!sameRepositoryIdentity(application.remote, `https://github.com/${application.repository}.git`)) warnings.push("application.repository 与 application.remote 指向的仓库不一致"); + if (!delivery.enabled) warnings.push("delivery.enabled=false,自动交付当前关闭"); + if (delivery.gitops.maxPushAttempts > 5) warnings.push("delivery.gitops.maxPushAttempts 大于 5,失败时可能延长流水线耗时"); + if (dirname(delivery.gitops.manifestPath).replaceAll("\\", "/") !== delivery.gitops.application.path) warnings.push("delivery.gitops.manifestPath 不在 application.path 的直接子路径下"); if (!delivery.build.proxy.noProxy.includes("hyueapi.com") || !delivery.build.proxy.noProxy.includes(".hyueapi.com")) throw new Error(`${sub2RankConfigLabel}.delivery.build.proxy.noProxy must retain hyueapi.com and .hyueapi.com`); - if (!image.repository.startsWith("127.0.0.1:5000/")) throw new Error(`${sub2RankConfigLabel}.image.repository must use the NC01 local registry`); + if (!image.repository.startsWith("127.0.0.1:5000/")) warnings.push("image.repository 未使用 NC01 本地 registry"); const required = new Set(runtime.secret.requiredTargetKeys); const provided = new Set(secret.mappings.map((item) => item.targetKey)); const missing = [...required].filter((key) => !provided.has(key)); if (missing.length > 0) throw new Error(`${sub2RankConfigLabel}.runtime.secret.requiredTargetKeys missing from ${runtime.secret.configRef} declaration ${runtime.secret.declarationId}: ${missing.join(", ")}`); - if (!sameStringSet(application.server.envKeys, runtime.secret.appEnvTargetKeys)) throw new Error(`${sub2RankConfigLabel}.runtime.secret.appEnvTargetKeys must match application runtime server env keys`); - if (application.server.listenPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.runtime.service.port must match application runtime server listenPort`); - if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) throw new Error(`${sub2RankConfigLabel}.runtime.storage.mountPath must contain the application databasePath`); + if (!sameStringSet(application.server.envKeys, runtime.secret.appEnvTargetKeys)) warnings.push("runtime.secret.appEnvTargetKeys 与应用声明的环境变量集合不一致"); + if (application.server.listenPort !== runtime.service.port) warnings.push("runtime.service.port 与应用 listenPort 不一致"); + if (!application.server.databasePath.startsWith(`${runtime.storage.mountPath.replace(/\/+$/u, "")}/`)) warnings.push("应用 databasePath 不在 runtime.storage.mountPath 下"); + if (!delivery.gitops.application.automated) warnings.push("delivery.gitops.application.automated=false,Argo 自动同步当前关闭"); + if (application.deploymentBlockPresent) warnings.push("应用配置仍包含 deployment 段;部署事实应以 platform-infra YAML 为准"); for (const target of targets) { - if (target.route !== secret.route || target.namespace !== secret.namespace) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}] route/namespace must match runtime Secret target ${secret.targetId}`); - if (target.publicExposure.frpc.localPort !== runtime.service.port) throw new Error(`${sub2RankConfigLabel}.targets[${target.id}].publicExposure.frpc.localPort must match runtime.service.port`); - if (target.namespace !== delivery.gitops.application.destinationNamespace) throw new Error(`${sub2RankConfigLabel}.delivery.gitops.application.destinationNamespace must match the runtime target namespace`); + if (target.route !== secret.route || target.namespace !== secret.namespace) warnings.push(`targets[${target.id}] 的 route/namespace 与 Secret 目标 ${secret.targetId} 不一致`); + if (target.publicExposure.frpc.localPort !== runtime.service.port) warnings.push(`targets[${target.id}].publicExposure.frpc.localPort 与 runtime.service.port 不一致`); + if (target.namespace !== delivery.gitops.application.destinationNamespace) warnings.push(`targets[${target.id}].namespace 与 Argo destinationNamespace 不一致`); } + return warnings; } function positiveInteger(record: Record, key: string, path: string): number { diff --git a/scripts/src/platform-infra-sub2rank-pipeline.ts b/scripts/src/platform-infra-sub2rank-pipeline.ts index 91b99f57..222e7124 100644 --- a/scripts/src/platform-infra-sub2rank-pipeline.ts +++ b/scripts/src/platform-infra-sub2rank-pipeline.ts @@ -34,7 +34,6 @@ export function renderSub2RankDesiredPipeline(binding: PacSourceArtifactBinding) const target = resolveSub2RankTarget(config, binding.consumer.node); const expectedConfigRef = `${sub2RankConfigLabel}#delivery`; if (binding.consumer.sourceArtifact.configRef !== expectedConfigRef) throw new Error(`Sub2Rank sourceArtifact.configRef must equal ${expectedConfigRef}`); - assertBinding(config, target, binding); const manifestTemplate = renderSub2RankManifest(config, target, { imageRef: sub2RankImagePlaceholder, appConfigBase64: sub2RankConfigBase64Placeholder, @@ -95,8 +94,8 @@ function renderPipeline( apiVersion: "tekton.dev/v1", kind: "Pipeline", metadata: { - name: delivery.pipeline.name, - namespace: delivery.pipeline.namespace, + name: binding.consumer.pipeline, + namespace: binding.consumer.namespace, labels: { "app.kubernetes.io/name": "sub2rank", "app.kubernetes.io/part-of": "platform-infra", @@ -208,57 +207,6 @@ function proxyEnv(): Record[] { ]; } -function assertBinding(config: Sub2RankConfig, target: Sub2RankTarget, binding: PacSourceArtifactBinding): void { - const delivery = config.delivery; - const expected: Readonly> = { - node: target.id, - lane: "platform-infra", - namespace: delivery.pipeline.namespace, - pipeline: delivery.pipeline.name, - pipelineRunPrefix: delivery.pipeline.runPrefix, - service_account: delivery.pipeline.serviceAccountName, - source_branch: config.application.branch, - source_snapshot_prefix: delivery.pipeline.sourceSnapshotPrefix, - image_repository: config.image.repository, - gitops_branch: delivery.gitops.branch, - gitops_manifest_path: delivery.gitops.manifestPath, - pipeline_name: delivery.pipeline.name, - pipeline_run_prefix: delivery.pipeline.runPrefix, - pipeline_timeout: delivery.pipeline.timeout, - workspace_pvc_size: delivery.pipeline.workspaceSize, - }; - const observed: Readonly> = { - node: binding.consumer.node, - lane: binding.consumer.lane, - namespace: binding.consumer.namespace, - pipeline: binding.consumer.pipeline, - pipelineRunPrefix: binding.consumer.pipelineRunPrefix, - ...binding.repository.params, - }; - for (const [key, value] of Object.entries(expected)) { - if (observed[key] !== value) throw new Error(`Sub2Rank PaC binding ${key}=${String(observed[key])} does not match owning YAML ${value}`); - } - if (binding.consumer.deliveryProvenance?.executionServiceAccountName !== delivery.pipeline.serviceAccountName) { - throw new Error(`Sub2Rank PaC deliveryProvenance execution ServiceAccount does not match owning YAML ${delivery.pipeline.serviceAccountName}`); - } - const application = delivery.gitops.application; - if (binding.consumer.argoNamespace !== application.namespace) throw new Error(`Sub2Rank PaC argoNamespace does not match owning YAML ${application.namespace}`); - if (binding.consumer.argoApplication !== application.name) throw new Error(`Sub2Rank PaC argoApplication does not match owning YAML ${application.name}`); - const bootstrap = binding.consumer.argoBootstrap; - if (bootstrap === null) throw new Error("Sub2Rank PaC argoBootstrap must be declared"); - const expectedBootstrap = { - project: application.project, - repoUrl: delivery.gitops.readUrl, - targetRevision: delivery.gitops.branch, - path: application.path, - destinationNamespace: application.destinationNamespace, - automated: application.automated, - } as const; - for (const [key, value] of Object.entries(expectedBootstrap)) { - if (bootstrap[key as keyof typeof expectedBootstrap] !== value) throw new Error(`Sub2Rank PaC argoBootstrap.${key} does not match owning YAML ${String(value)}`); - } -} - function pipelineOwner(config: Sub2RankConfig, target: Sub2RankTarget): Record { return { application: { diff --git a/scripts/src/platform-infra-sub2rank.ts b/scripts/src/platform-infra-sub2rank.ts index 691e20d6..a94a8196 100644 --- a/scripts/src/platform-infra-sub2rank.ts +++ b/scripts/src/platform-infra-sub2rank.ts @@ -58,6 +58,7 @@ function plan(options: OpsCommonOptions): Record { ok: policy.every((item) => item.ok), action: "platform-infra-sub2rank-plan", mutation: false, + warnings: sub2rank.warnings, config: configSummary(sub2rank, target), policy, artifact: { @@ -306,11 +307,6 @@ function policyChecks(config: Sub2RankConfig, target: Sub2RankTarget, yaml: stri ok: !/^\s*kind:\s*(?:Namespace|NetworkPolicy)\s*$/mu.test(yaml) && config.runtime.sharedNetworkPolicyRef.managedBySub2Rank === false, detail: `Sub2Rank references ${target.namespace}/NetworkPolicy/${config.runtime.sharedNetworkPolicyRef.name} but does not apply or seize field ownership of shared runtime resources.`, }, - { - name: "deployment-owned-by-unidesk", - ok: !config.application.deploymentBlockPresent, - detail: `Deployment, image, Secret, FRP and Caddy facts belong only to ${sub2RankConfigLabel}; the application config must not contain a deployment block.`, - }, { name: "existing-secret-source-ref", ok: config.runtime.secret.requiredTargetKeys.every((key) => config.runtime.secret.mappings.some((mapping) => mapping.targetKey === key)), @@ -347,6 +343,7 @@ function configSummary(config: Sub2RankConfig, target: Sub2RankTarget): Record { return { + warnings: config.warnings, deployment: { path: sub2RankConfigLabel, kind: config.kind }, application: { path: config.application.configRef, @@ -356,6 +353,7 @@ function configRefsSummary(config: Sub2RankConfig): Record { sourceClean: config.application.sourceClean, sourceRemotePresent: config.application.sourceRemotePresent, sourceRemoteMatches: config.application.sourceRemoteMatches, + sourceConfigPathMatches: config.application.sourceConfigPathMatches, configMatchesSourceCommit: config.application.configMatchesSourceCommit, }, secret: { path: config.runtime.secret.configRef, declarationId: config.runtime.secret.declarationId },