diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index a38bd660..fcd61de4 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -119,6 +119,18 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 未迁移的 legacy CI/CD 若仍保留人工入口,必须由单一受控命令完成 source sync、构建、发布、GitOps/Argo 收敛、runtime provenance 校验和 `/health` 端点验证。PaC migrated consumer 不得使用该人工入口,其唯一交付触发是 GitHub PR merge。 - CI/CD 端到端 wall-clock 目标及预算只由 owning YAML 声明;PaC migrated consumer 从 GitHub PR merge 事件计时,legacy lane 才从操作者触发受控命令计时,均到 runtime ready 且 `/health` 端点验证完成为止。 - CI/CD validation 阶段只能验证部署对象的 `/health` 端点和必要 provenance;禁止在 CI/CD gate 中运行 web-probe、Playwright、远程浏览器截图、用户路径 E2E 或等价重型业务探针。业务/用户入口验证只能作为发布后的独立 post-deploy validation 证据,不得阻塞 CI/CD 一键交付。 +- 产品功能配置 schema 的 CI/CD 合同: + - repo 内唯一约定路径固定为 `config/feature-config.schema.json`; + - schema 固定使用 JSON Schema Draft 2020-12,并由 lockfile 锁定的完整结构化 validator 校验;禁止用正则、浅层字段遍历或关闭整体 strict 代替; + - schema 只描述产品功能配置,不得承载数据 authority、事件路径、投影实现或交付路径选择; + - 每个顶层 `properties` 成员必须声明唯一非空 `x-unidesk-feature`;一个产品功能只允许一个规范配置 property,禁止别名、反向变量或重复开关; + - candidate snapshot 必须由正常 source-artifact 阶段从 owning YAML 或已渲染 workload 显式生成;禁止读取 runner `process.env` 作为产品配置 authority; + - HWLAB、AgentRun 与 Sentinel consumer 必须复用同一 shared helper,并在正常 source-artifact/GitOps publish 路径执行,不得只挂到某个 consumer 的专属后处理; + - CI/CD 必须检查 schema 语法、嵌套配置值匹配和功能到配置 property 的一对一关系,并输出有界 typed warning;输入快照缺失使用独立 typed warning,不得与 schema 非法混淆; + - schema 缺失、非法、candidate 缺失、配置不匹配、重复 property、validator/依赖异常或 OTel 导出失败时,只允许记录 warning;embedded 命令必须以 `0` 退出,PipelineRun、artifact、GitOps promote、Argo reconcile、runtime rollout 和 `/health` closeout 必须继续; + - schema 校验器只读,禁止写回配置、注入默认值、删除变量、关闭功能、切换架构路径或执行任何补救操作;运行时必须原样接收本次待发布配置; + - schema warning 必须实际导出 CI/CD OTel span/event;endpoint、serviceName、sampling 和 exporter timeout 只读 `config/platform-infra/pipelines-as-code.yaml#observability` 与精确 repository params,零/多 repository 匹配在 renderer 生成期 fail-closed; + - OTel exporter 自身失败继续输出 `blocking=false` warning;warning 同时进入 `status|history|debug-step` 的同构有界投影,但不得改变成功/失败终态。 - 任一 CI/CD 阶段或总耗时超过 owning YAML 预算时,不要继续死等或把超长等待视为正常;先输出阶段耗时分解,并优先从 env reuse、git mirror、BuildKit/cache、GitOps/Argo watch 和 runtime readiness 探测方向优化后再继续交付。 - 仅 legacy lane 的 node-scoped `trigger-current --wait` 可以把 source sync、pre/post flush、PipelineRun、GitOps/Argo、runtime readiness 和 `/health` closeout 放进同一端到端 YAML 预算;超预算时由 CLI 输出阶段分解、Argo target revision、runtime/public 状态和 TaskRun/Pod drill-down。PaC migrated consumer 禁止执行 `trigger-current`,也禁止手动串联状态、sync 或 flush 命令补齐交付。 - node-scoped `control-plane status` 可见性规则: diff --git a/.agents/skills/unidesk-code-queue/SKILL.md b/.agents/skills/unidesk-code-queue/SKILL.md index a344f422..fcf551fb 100644 --- a/.agents/skills/unidesk-code-queue/SKILL.md +++ b/.agents/skills/unidesk-code-queue/SKILL.md @@ -28,8 +28,7 @@ bun scripts/cli.ts agentrun create task \ --repo \ --ref \ --mdtodo-id \ - --prompt-stdin \ - --dry-run + --prompt-stdin ``` 资源命令的 Target 规则: diff --git a/.agents/skills/unidesk-code-queue/references/resources.md b/.agents/skills/unidesk-code-queue/references/resources.md index b55604f2..514937ea 100644 --- a/.agents/skills/unidesk-code-queue/references/resources.md +++ b/.agents/skills/unidesk-code-queue/references/resources.md @@ -23,12 +23,11 @@ bun scripts/cli.ts agentrun create task \ --repo \ --ref \ --mdtodo-id \ - --prompt-stdin \ - --dry-run + --prompt-stdin ``` -- 先用同一命令保留 `--dry-run` 查看完整计划; -- dry-run 与 live 都必须先经 owning YAML 选中的 Target `trans` 重入; +- 默认直接执行 live 单命令;不要把 `--dry-run` 作为派单前置步骤; +- live 必须先经 owning YAML 选中的 Target `trans` 重入并完成只读 preflight; - preflight 必须验证: - task worktree 是 Git 根目录且 clean; - origin 与 `--repo` 一致; @@ -41,7 +40,7 @@ bun scripts/cli.ts agentrun create task \ - session identity; - provider/tool SecretRef 的对象、key 与 presence; - preflight 失败固定 `mutation=false`,不得创建 task; -- live 在 submit 成功后自动 dispatch,不要求用户再运行第二条命令; +- preflight 通过后在同一命令内 submit 并自动 dispatch,不要求用户再运行第二条命令; - 未显式传 `--idempotency-key` 时,由 Target/repo/ref/MDTODO/verified commit 派生稳定键; - 幂等重试遇到已有 running 或 terminal task 时,返回既有 attempt/run/session,不重复 dispatch; - 最终输出必须包含 task、attempt、run、command、runner 和 session identity。 diff --git a/.agents/skills/unidesk-gh/SKILL.md b/.agents/skills/unidesk-gh/SKILL.md index f5ecfbf1..b611f056 100644 --- a/.agents/skills/unidesk-gh/SKILL.md +++ b/.agents/skills/unidesk-gh/SKILL.md @@ -27,7 +27,10 @@ GitHub issue/PR 正式读写必须走 `bun scripts/cli.ts gh ...` 或 `trans gh: - 只有需要复用或正式保留的文件才使用 `--body-file`。 - 不要把 Markdown 塞进 shell 参数。 - `gh pr create` 默认 Next 只给有界 `pr view`、`pr review-plan`、`pr preflight` 和 closeout status 下钻;不得默认提示 `preflight --full|--raw`、手工 CI/CD、mirror sync、PipelineRun 或 Argo refresh。 -- PR merge 只走 guarded `gh pr merge`;主代理按 `$unidesk-subagent` 完成 review/preflight/merge 授权判断后再执行,不把“禁止默认 Next”理解成“PR 永远不能由主代理受控合并”;只有确认 ancestry 可丢弃时才显式 `--squash`。 +- PR review 先用 `review-plan` 建立文件索引,只对需要审查的文件执行 `pr diff --file`,不得机械下钻每个文件。 +- PR merge 只走 guarded `gh pr merge`;它内建 readiness/preflight,普通已审 PR 不先机械执行独立 `pr preflight`。主代理按 `$unidesk-subagent` 完成 review 和 merge 授权判断后执行;只有确认 ancestry 可丢弃时才显式 `--squash`。 +- 功能、skill、MDTODO 报告和完成状态在 merge 前已齐备时,必须进入同一个 PR。禁止仅为补写 merge SHA、`mergedAt`、branch deletion 或重复 closeout 再开文档 PR;这些事实由 GitHub PR 和 `gh pr merge` 默认摘要保存。 +- 成功 merge 后默认摘要已披露 `mergeCommit` 和 `mergedAt`,不再机械执行 `pr view`。只有输出缺字段、merge 后出现新运行证据,或需要定点排障时才继续查询;只有用户明确要求把 merge 后新证据写回 Git 时,才创建第二个 closeout PR。 ## 常用入口 diff --git a/.agents/skills/unidesk-gh/references/pull-requests.md b/.agents/skills/unidesk-gh/references/pull-requests.md index 790f7c3b..6318d6c9 100644 --- a/.agents/skills/unidesk-gh/references/pull-requests.md +++ b/.agents/skills/unidesk-gh/references/pull-requests.md @@ -2,7 +2,7 @@ PR 工作必须使用受控 UniDesk GitHub 命令: -- review 前先用 `pr review-plan`、`pr diff --file` 和有界文件下钻。 +- review 前先用 `pr review-plan` 建立 changed-file 索引,只对需要审查的文件使用 `pr diff --file` 和有界 hunk 下钻;不得逐文件机械展开。 - 人工完整 PR 正文使用 `trans gh:/owner/repo/pr/ cat`;定点查找使用同一路由的 `rg `。 - `pr view --json body,...`、`--full` 和 `--raw` 只用于显式机器结构化披露;正文只在 `.data.pullRequest.body` 输出一次。 - `pr create` 默认 Next 只给有界 observe/review/preflight/status: @@ -13,11 +13,14 @@ PR 工作必须使用受控 UniDesk GitHub 命令: 默认不得提示 `preflight --full|--raw`、手工 CI/CD、mirror sync、创建 PipelineRun 或 Argo refresh。 - `pr preflight` 是可选只读诊断;`pr merge` 内部会自行执行 preflight。 - 主代理受控合并仍然允许: - - 主代理按 `$unidesk-subagent` 完成 review/preflight/merge 判断后使用 guarded `pr merge`; + - 主代理按 `$unidesk-subagent` 完成 review 和 merge 授权判断后使用 guarded `pr merge`; - 不得把 `pr create` Next 限制误解成“PR 永远不能人工受控合并”。 - `pr merge --merge` 默认删除已合并的同 repo head branch,清理匹配且干净的本地 `.worktree`,并快进位于 PR base branch 的本地主工作区。 - `pr merge --merge --sync-node JD01` 在支持时额外执行映射节点 source-workspace sync;当前用于 HWLAB `v0.3`。 - 只有明确需要保留合并后状态时才使用 `--keep-branch` 或 `--skip-local-closeout`。 - 只有 ancestry 和语义吸收都明确安全时才使用 squash。 +- 功能实现、skill、MDTODO 报告和完成状态在 merge 前已齐备时,必须用一个 PR 收口。不得仅为补 merge SHA、`mergedAt`、branch deletion、已合并状态或重复 closeout 再开 PR。 +- merge commit 与合并时间由 GitHub PR 事实和 `gh pr merge` 默认摘要保存,不要求回写 MDTODO 报告。成功合并后不机械执行 `pr view`;只有默认输出缺少必要字段或需要定点排障时才查询。 +- 只有 merge 后才产生新的运行面证据,并且用户明确要求将该证据写回 Git 时,第二个 closeout PR 才合理。 -Closeout 应写明 source branch、验证证据和残余风险。 +同一 PR 的 closeout 应在 merge 前写明 source branch、验证证据和残余风险。 diff --git a/.agents/skills/unidesk-selfmedia/SKILL.md b/.agents/skills/unidesk-selfmedia/SKILL.md new file mode 100644 index 00000000..3a1d3eaa --- /dev/null +++ b/.agents/skills/unidesk-selfmedia/SKILL.md @@ -0,0 +1,42 @@ +--- +name: unidesk-selfmedia +description: UniDesk SelfMedia 工厂的 YAML-first 运维技能,覆盖 NC01 生产 Secret 分发与消费者生效、GitHub 到 Gitea/PaC/Tekton/GitOps/Argo 自动交付、WebTerm/Codex 权限边界、公网 IP 健康与登录鉴权验收。用户提到 SelfMedia、自媒体工厂、selfmedia-nc01、管理员账号密码更新、WebTerm、微信公众号文章、4317 公网入口、SelfMedia 部署或流水线运维时使用。 +--- + +# UniDesk SelfMedia 运维 + +遵循 `Skill(cli-spec)`,只通过 UniDesk owning YAML 和受控 CLI 操作 NC01 运行面。 + +## 固定边界 + +- 把 `config/selfmedia.yaml` 作为部署、存储、运行时、权限和公网暴露真相。 +- 把 `config/secrets-distribution.yaml` 作为管理员凭据、API key、Codex 凭据和消费者 rollout 真相。 +- 把 `config/platform-infra/pipelines-as-code.yaml` 与 `config/platform-infra/gitea.yaml` 作为自动交付真相。 +- 通过 `trans NC01:` 在 NC01 选中的最新干净 UniDesk worktree 执行命令。 +- 只披露 sourceRef、键名、presence、fingerprint、字节数和步骤摘要;不得读取、打印或从运行面反解 Secret 值。 +- SelfMedia 源码交付只由 `pikainc/selfmedia` PR merge 触发;不得人工创建 PipelineRun、推送 Gitea、刷新 Argo 或 patch 运行时。 + +## 高频流程 + +1. 管理员凭据更新时,按[运维入口](references/operations.md#管理员凭据分发)依次执行 `plan`、`sync`、job 查询和 `status`。 +2. 确认 `selfmedia-nc01` 的 `consumerRollout` 已声明 `selfmedia` Deployment;应用启动时读取 Secret,缺少 rollout 不能判定新凭据已生效。 +3. 代码发布只观察 PR merge 后的自动 PaC/GitOps 收敛;只有自动链失败时才下钻 status/history/debug-step。 +4. 最后从 YAML 解析公网地址,验证 `/` 与 `/healthz` 可达、匿名业务 API 返回 `401`,再用用户提供的新账号完成登录。 + +## 验收判定 + +- Secret 同步结果必须为 `succeeded`、`valuesPrinted=false`,并且消费者 restart/status 均为 `exitCode=0`。 +- `secrets status` 必须确认 `selfmedia-access`、`selfmedia-runtime`、`selfmedia-codex` 的声明键完整。 +- WebTerm 必须保留 PVC session、restart/resume、统一 SelfMedia CLI 与非 root、无 ServiceAccount token/Kubernetes/Docker/宿主权限边界。 +- 公网验收只记录 HTTP 状态、session/job/trace、fingerprint 和 artifact SHA,不记录 cookie、账号、密码或 API key。 + +## 相关技能 + +- Secret/YAML 归属或 renderer 修改同时使用 `$unidesk-ymalops`。 +- PaC、Tekton、GitOps、Argo 或自动交付排障同时使用 `$unidesk-cicd`。 +- 跨节点现场闭环同时使用 `$dad-dev` 与 `$unidesk-trans`。 +- 公网页面、布局或浏览器原入口验收同时使用 `$unidesk-webdev`。 + +## 详细入口 + +- 凭据分发、自动交付和公网验收命令见[运维入口](references/operations.md)。 diff --git a/.agents/skills/unidesk-selfmedia/agents/openai.yaml b/.agents/skills/unidesk-selfmedia/agents/openai.yaml new file mode 100644 index 00000000..094bfcbf --- /dev/null +++ b/.agents/skills/unidesk-selfmedia/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "UniDesk SelfMedia 运维" + short_description: "SelfMedia YAML-first 部署、凭据与公网验收" + default_prompt: "使用 $unidesk-selfmedia 完成 SelfMedia 的 YAML-first 运维和原入口验收。" diff --git a/.agents/skills/unidesk-selfmedia/references/operations.md b/.agents/skills/unidesk-selfmedia/references/operations.md new file mode 100644 index 00000000..eb36362a --- /dev/null +++ b/.agents/skills/unidesk-selfmedia/references/operations.md @@ -0,0 +1,70 @@ +# SelfMedia 运维入口 + +## 管理员凭据分发 + +- 在 owner-only `/root/.unidesk/.env/selfmedia-access.env` 中维护以下键: + - `SELFMEDIA_ADMIN_USERNAME`; + - `SELFMEDIA_ADMIN_PASSWORD`; + - `SELFMEDIA_API_KEY`; + - `SELFMEDIA_SESSION_SECRET`。 +- 不用 `cat`、`source`、`env`、Pod env 或 Secret decode 检查值。 +- 先预检 sourceRef、键和 fingerprint: + +```bash +bun scripts/cli.ts secrets plan --config config/secrets-distribution.yaml --scope selfmedia --target selfmedia-nc01 +``` + +- 提交异步同步作业: + +```bash +bun scripts/cli.ts secrets sync --config config/secrets-distribution.yaml --scope selfmedia --target selfmedia-nc01 --confirm +``` + +- 使用返回的 job ID 查询,直到终态: + +```bash +bun scripts/cli.ts job status --tail-bytes 12000 +``` + +- confirmed sync 默认只返回 source/Secret fingerprint、键和 rollout 摘要;只有一次性下钻时才使用 `--full`,不得让默认输出因超限隐藏 ready 证据。 + +- 再核对目标 Secret 的键存在性: + +```bash +bun scripts/cli.ts secrets status --config config/secrets-distribution.yaml --scope selfmedia --target selfmedia-nc01 +``` + +- `consumerRollout` 是凭据生效合同: + - `selfmedia` Deployment 通过 Secret `subPath` 挂载凭据; + - 后端在启动时加载管理员凭据和 session secret; + - `sync` 必须按 YAML 重启消费者,并通过多次短连接轮询等待 ready; + - 总等待时限和轮询间隔分别由 `timeoutSeconds`、`pollIntervalSeconds` 声明; + - 只更新 Secret 而没有成功 rollout 时,不得声称新账号已生效。 + +## 自动交付观察 + +- SelfMedia PaC consumer 为 `selfmedia-nc01`: + +```bash +bun scripts/cli.ts platform-infra pipelines-as-code status --target NC01 --consumer selfmedia-nc01 +bun scripts/cli.ts platform-infra pipelines-as-code history --target NC01 --consumer selfmedia-nc01 --limit 10 +``` + +- 只有失败归因时才按具体 PipelineRun 下钻: + +```bash +bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target NC01 --consumer selfmedia-nc01 --id +``` + +- 正常发布只由 `pikainc/selfmedia` 的 GitHub PR merge 触发,不执行 bootstrap、apply、trigger-current、mirror sync、人工 PipelineRun 或 Argo refresh/sync。 + +## 公网原入口验收 + +- 从 `config/selfmedia.yaml#delivery.targets.NC01.deployment` 读取公网 host、端口和健康路径,不在脚本中复制配置值。 +- 无凭据检查: + - `/` 返回 `200`; + - `/healthz` 返回 `200`; + - `/api/v1/system` 返回 `401`; + - `/api/v1/terminal/status` 返回 `401`。 +- 使用 owner 提供的新账号完成浏览器登录;不得把账号密码写入命令历史、截图、报告、issue 或日志。 +- 登录后核对 WebTerm 打开、Codex session resume、统一 CLI 请求和流水线 job 状态;不使用终端访问 Kubernetes、Docker socket、ServiceAccount token 或宿主路径。 diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 9ba352d2..e8cd0b95 100644 --- a/.agents/skills/unidesk-subagent/SKILL.md +++ b/.agents/skills/unidesk-subagent/SKILL.md @@ -14,6 +14,10 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 既有权限、Secret 脱敏、不可逆操作和损害预防边界继续生效,本规则不授权绕过既有安全底线; - 发现衍生问题时,只能创建独立 issue 或记录待办,不得扩大当前 prompt、PR、合并范围或验收前置; - 现有机制阻碍原始目标时,先寻找既有架构内的最小实现路径;确需架构扩展时,必须向用户说明与原始目标的关系并取得明确授权。 + - bug fix 不得擅自改变 event authority、transport、persistence、replay 或 source of truth;发现现有架构可能是根因时,也必须先完成证据和规格裁决。 + - 涉及事件、投影、实时、回放或持久化的派单,主代理必须在子 issue 中先写出 current data flow 和 desired data flow;两者存在架构变化时,先更新长期 SPEC 并取得用户明确授权,再允许实施。 + - 新增测试只能验证已经授权的合同,不能用测试通过把未经授权的架构变化、迁移依赖或新 authority 合法化。 + - 主代理审核顺序固定为:先比较用户最新目标、适用 SPEC 和 current/desired data flow,再审核代码内部一致性、测试和实现质量;后者通过不能覆盖前者偏离。 - 执行型和调研型委派默认优先使用 AgentRun `Artificer`: - Artificer 的 `create task`、`apply` 和 `dispatch` 必须以下文“子 issue + MDTODO”登记为 fail-closed 前置;禁止先创建 task 或派单再补登记; - 原生子代理和 Artificer 每次派单前都必须已有对应 MDTODO ITEM 或 SUBITEM;派单 prompt 与最终报告必须写明同一个 MDTODO ID; @@ -38,18 +42,38 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - Artificer 禁止递归修复自身: - 影响派单、AipodSpec、provider/model、credential 投影、session/workspace、Runner、AgentRun runtime 或其 CI/CD 可用性的故障,调查与实现必须交给原生子代理; - Artificer 只能在修复自动上线后作为被测对象,执行最短 canary、原 session 续跑或业务验收; - - Artificer 可用时,新任务和可安全接续的剩余任务必须优先交给 Artificer,原生子代理只用于管理性主代理职责以外、且 Artificer 本身修复或恢复所必需的工作; - - Artificer 已被证明确实不可用时,先创建独立 issue 和泛化问题域 MDTODO,再派一个原生子代理修复 Artificer;紧急且边界独立的业务任务可以暂时派原生子代理,但不得把临时 fallback 固化为长期调度入口; + - Artificer 是所有执行型子代理任务的强制默认执行面:用户只说“子代理”“并行代理”或“派代理”时,必须使用 Artificer;只有用户明确指定“原生子代理”,或 Artificer 经有界 readiness、最短真实派单或 typed failure 证明确实不可用时,才允许使用原生子代理; + - 主代理不得因为方便、速度、空闲并发槽、已有原生 worktree、历史习惯或预计任务较短而自行降级到原生子代理;管理性、决策性工作由主代理直接完成,不构成原生子代理例外; + - Artificer 已被证明确实不可用时,先创建独立 issue 和泛化问题域 MDTODO,再派一个原生子代理修复 Artificer;只有与故障修复解耦且用户明确要求继续的紧急业务任务,才允许临时使用原生子代理,并必须在 Artificer 恢复后交接;不得把临时 fallback 固化为长期调度入口; - Artificer 恢复后,原生子代理必须在 clean commit、PR 或只读报告 checkpoint 停止继续扩展;主代理通过原 issue、MDTODO 和 session 传递接续上下文,再由 Artificer 接替尚未完成的工作;禁止两个执行面共享可写 worktree、重复提交或并发修改同一任务; - 管理性、决策性文档仍由主代理负责,不为了满足 Artificer 优先规则而外包治理决策。 +- 子代理并发必须从用户原始任务的依赖图出发主动扩展: + - 主代理先识别串行定锚项、已就绪任务和后续依赖,再计算当前可安全派发集合; + - 串行定锚完成后,应立即并发派出所有低耦合、可独立验收且具备独立 worktree/branch/issue/PR 的已就绪任务,直到达到可用执行槽、运行面资源预算或真实依赖边界; + - 存在两个及以上已就绪任务时,长期只运行一个子代理属于调度失败: + - 主代理必须补派; + - 无法补派时,在当前 anchor 中写明具名依赖、共享文件冲突、运行面容量或权限阻塞; + - 每个任务终态和依赖变化后重新计算可安全派发集合; + - 主代理自己的审核、规划和等待不计入子代理并发量;不得用一个长期任务加主代理活动宣称已经并行; + - 不得为了凑并发拆出无独立价值的日志采集、重复调查、重复实现或审核代理;并发量服从原始任务边界和成功率,不服从固定数字。 +- 运行面故障、用户原入口不可用,或凭据/配置变更导致服务退化时,必须先恢复运行面,再完成工程化: + - 主代理先冻结恢复判定标准,并保留恢复关键路径的控制权; + - 优先用既有受控入口实施最小、可逆、可验证的恢复,只有受控入口本身缺失并直接阻塞恢复时,才在关键路径修改工具; + - 通用抽象、输出优化、skill、报告和长期治理不得成为恢复门禁; + - 恢复达到用户原入口可用的标准后,主代理不得把临时恢复当作任务完成,必须继续完成用户明确要求的根因修复、工程化、PR、验证和治理; + - 与恢复根因解耦且具备独立 issue、MDTODO、worktree 和验收入口的工程化任务,必须在恢复期间立即并行派发,不得全部排到恢复之后串行执行。 - 子代理并行只用于成功率高、耦合度低、能放进独立 worktree/branch/issue/PR 的任务;共享架构方向、公共契约和同文件高冲突修改先串行定锚。 +- 子代理产出的审核默认由主代理直接完成: + - 用户允许子代理实施、要求并行或启用多轮审查,不等于允许调度审核子代理; + - 只有用户明确要求子代理审核、独立审核代理或多代理交叉审核时,才允许派发审核型子代理; + - 主代理发现问题后,直接形成纠偏要求并由原执行 session 续跑或在原任务边界内修复,不新增审核代理。 - 管理性、决策性文档由主代理直接完成,不把决策权或治理写入外包给子代理: - 主代理负责 `AGENTS.md`、skill 核心规则、长期 reference 规范、架构决策、主 issue anchor/closeout,以及 MDTODO FILE 的领域划分、ITEM 结构和状态治理; - 子代理可以在已明确的 issue、MDTODO ITEM 和验收边界内独立调研,并编写对应任务报告、验证记录、实现说明和证据附件; - 子代理报告只承载其任务事实与结论,不能自行改写上层目标、架构取舍、优先级、主线状态或跨任务治理规则; - 主代理审阅子代理报告后,亲自把被采纳的结论写入管理性、决策性文档。 - 纯文档交付可以按 `$git-spec` 的稳定分支快路径由主代理直接 commit/push,不要求临时 branch、`.worktree` 或 PR;发现本地分叉、并行改动、分支保护或运行面影响时必须保留状态并回到隔离交付。 -- 正式 GitHub issue/PR/comment/merge 仍走 `$unidesk-gh`;子代理可以提交 PR 和写调查结论,主代理负责 review/preflight/merge,除非用户明确授权某个子代理自上线自验证。 +- 正式 GitHub issue/PR/comment/merge 仍走 `$unidesk-gh`;子代理可以提交 PR 和写调查结论,主代理负责 bounded review 和 guarded merge,独立 preflight 只用于定点排障,除非用户明确授权某个子代理自上线自验证。 - 主代理和子代理必须通过 issue/PR/comment 链接传递可复用上下文、调查结论、证据链接和下一步边界;派发前先读既有评论,prompt 中只引用链接和增量任务,不复述长结论,避免不同子代理重复调查同一事实。 - 主代理派发任何执行型子代理前必须完成以下登记;对 Artificer,全部步骤必须早于 AgentRun `create task`、`apply` 和 `dispatch`: - 创建子 issue,把主要任务、接续上下文、目标分支/worktree、范围、禁止项和验收入口写入正文; @@ -76,7 +100,11 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 当前用户任务只要派出过子代理: - 主代理就必须保持任务进行中; - 等待本轮全部子代理进入真实终态; - - 不得把 task 创建成功、dispatch 成功、`running`、已提交 commit 或已创建 PR 当作主代理可以结束的交付点。 + - 不得把 task 创建成功、dispatch 成功、`running`、已提交 commit 或已创建 PR 当作主代理可以结束的交付点; + - 等待期间仍要维护并发窗口: + - 任一子代理终态、依赖解除或新任务就绪后,立即重新计算可安全派发集合; + - 还有独立已就绪任务时及时补派,避免并发窗口退化为长期单任务等待; + - 只有具名依赖、共享写入边界、运行面容量或权限阻塞时才允许保持较低并发,并把理由写入主线 anchor。 - 子代理返回后,主代理必须继续完成职责范围内的收尾: - 完成必要的纠偏和 post-task; - 审核 PR 并执行获授权的合并; @@ -110,7 +138,7 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - CI/CD、rollout、PipelineRun、Argo closeout:`$unidesk-cicd`。 - WebProbe、Workbench、浏览器复测:`$unidesk-webdev`。 - 子代理完成后的反馈收口和反馈 issue 判定:`$post-task`。 -- Artificer 和其他执行型子代理派单前的任务登记必须使用 `$mdtodo-edit`;只有用户明确要求多轮审查或循环修复时才使用 `$mdtodo-loop`。 +- 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 acf81765..99cd4242 100644 --- a/.agents/skills/unidesk-subagent/references/gh-workflow.md +++ b/.agents/skills/unidesk-subagent/references/gh-workflow.md @@ -4,13 +4,33 @@ ## 调度原则 +- 执行型任务默认且必须交给 Artificer。用户泛称“子代理”“并行代理”或“派代理”不等于授权原生子代理;只有用户明确指定原生子代理,或 Artificer 经有界 readiness、最短真实派单或 typed failure 证明不可用时,才允许原生执行面。 +- 主代理不能以方便、速度、空闲槽、已有 worktree、任务较短或历史习惯作为原生降级理由。Artificer 故障修复可由原生子代理承担;管理性与决策性工作仍由主代理直接完成。 - 主代理先明确总目标、目标 repo/branch/lane、架构方向和不可倒退边界,再拆任务。不能把“决定架构是否倒退”“是否关闭用户问题”“是否合并 PR”整体外包给子代理。 +- 主代理直接审核子代理产出的 diff、验证证据和 PR。允许子代理实施、要求并行或多轮审查都不授权审核子代理;只有用户明确要求子代理审核时,才建立独立审核任务。 - 只有低耦合任务才并行:不同 worktree、不同模块或不同 issue,产物可以独立 review,失败不会阻塞其他任务继续产出证据。 - 高耦合任务先串行定锚:公共类型/契约、source-of-truth、请求治理 authority、共享 runtime policy、同一大文件或同一状态机的修改,应先由主代理或一个子代理形成基线,其他子代理基于基线继续。 - 任务要按成功率分层:调查、工具增强、业务修复、验证、PR review、上线 closeout 可以并行,但“修同一个根因的两套实现”通常不应并行。 - 主代理需要持续调度,而不是把多个子任务排队串行执行。用户明确要求并行且任务在不同 worktree 时,应同时派发能并行的子任务,并通过 issue/PR 状态汇总。 +- 主代理必须维护可安全并发窗口: + - 从原始任务建立依赖图,区分串行定锚、已就绪任务和等待依赖任务; + - 每次定锚完成、子代理终态或依赖变化后重新计算已就绪集合,并立即补派低耦合任务; + - 并发目标是 `min(已就绪独立任务数, 可用执行槽, 运行面安全容量)`,不硬编码固定数量; + - 当两个及以上任务已就绪却长期只有一个活跃子代理时,必须视为调度缺口并补派,不能把主代理活动计入子代理并发; + - 只有具名依赖、共享文件冲突、权限或运行面容量阻塞时才降低并发,并把原因和下一次重算触发点写入主线 anchor。 +- 并发不能靠重复调查、同根因的多套实现、无独立验收价值的日志采集或默认审核代理虚增;每条并发线都必须有独立 issue、worktree、交付物和验收入口。 - 派发 CI/CD 或运行面调优子任务时,主代理不要在 prompt 或评论里反复解释历史;用 issue/comment 链接引用既有结论,把下一步限定为可由子代理自主真实触发的单步 gate,并要求子代理在该 gate 内小闭环调优到通过后再提交 PR。 +## 恢复优先与工程化并发 + +- 依赖图必须先标出运行面恢复关键路径和用户原入口恢复标准。主代理控制恢复关键路径,避免多个代理同时修改同一生产对象、恢复状态机或共享配置。 +- 恢复优先指先让用户原入口通过既有受控路径恢复到可验证状态,不表示工程化任务整体串行: + - 恢复关键路径上的最小诊断、配置修正和复测按真实依赖顺序执行; + - 与根因解耦的 CLI、reference、报告、独立仓库修复和长期治理,在具备独立 issue、MDTODO、worktree、PR 与验收入口时立即并行; + - 只有缺少受控恢复入口直接阻塞恢复时,才允许把工具修改放到恢复关键路径。 +- 运行面恢复后,主代理继续完成根因修复、持久化配置、自动交付、原入口复测和治理收口;不得以临时恢复或单次手工成功代替用户要求的终态。 +- 恢复期间降低并发必须有具名原因,例如共享生产对象、同一状态机、权限边界或运行面容量;原因解除后立即重新计算可安全并发窗口。 + ## 模型与思考等级 - Artificer 默认不显式覆盖模型: @@ -60,8 +80,11 @@ ## PR 工作流 - 子代理 PR 应小而可审:一个根因、一个模块边界、一个目标 issue;跨模块架构 PR 必须在 body 中写明为什么不是局部修补。 -- 主代理 review 时先看架构约束和倒退风险,再看实现细节。重点检查是否重新引入旧 authority、并行请求源、裸 API 绕过、隐藏默认、阈值硬编码或降低探针能力。 -- 合并前使用 `$unidesk-gh` 的 review/preflight/merge 入口。独立 PR 可以并行 review;有依赖的 PR 按契约基线、实现、验证工具、closeout 的顺序合并。 +- 主代理本人 review: + - 先看架构约束和倒退风险,再看实现细节; + - 重点检查是否重新引入旧 authority、并行请求源、裸 API 绕过、隐藏默认、阈值硬编码或降低探针能力; + - 默认不再派审核子代理。 +- 合并前使用 `$unidesk-gh` 的 bounded review 和 guarded merge 入口;`pr merge` 已内建 preflight,普通已审 PR 不机械重复执行独立 `pr preflight`。独立 PR 可以并行 review;有依赖的 PR 按契约基线、实现、验证工具、closeout 的顺序合并。 - 子代理 PR 合并后,主代理负责同步目标 worktree、触发受控 CI/CD、执行原入口复测,并把 PipelineRun、GitOps revision、observer、trace、report SHA 等证据写回 issue。 - 若用户明确让子代理“自己上线自己验证”,子代理可以执行受控 CI/CD 和原入口验证,但必须在 issue/PR 留下完整证据;主代理仍要抽查并最终汇总。 @@ -83,9 +106,10 @@ Prompt 至少包含以下字段,按任务裁剪: 1. 建立计划:列出可并行任务、串行依赖和每个子代理交付物。 2. 为每个执行型子代理先创建子 issue,把任务正文写进子 issue。 3. 使用 `$mdtodo-edit` 把子 issue 登记到泛化问题域 ITEM/SUBITEM 并标记进行中;登记失败时停止派单。 -4. 同时派发低耦合子任务;Artificer 的 `create task`、`apply` 和 `dispatch` 均只能发生在 MDTODO 登记之后;共享契约先派一个基线任务。 -5. 轮询子代理结果:子 issue comment、PR、验证摘要、阻塞。 -6. 对每个 PR 做架构 review、bounded diff、preflight 和必要本地验证。 +4. 计算当前可安全并发窗口并同时派发全部已就绪的低耦合子任务;共享契约先派一个基线任务。 + - Artificer 的 `create task`、`apply` 和 `dispatch` 均只能发生在 MDTODO 登记之后。 +5. 轮询子代理结果:子 issue comment、PR、验证摘要、阻塞;任一任务终态或依赖变化后立即重新计算窗口并补派,不能退化为长期单任务等待。 +6. 对每个 PR 做架构 review、bounded diff 和必要本地验证;只有定点排障时单独执行 preflight,正常收口直接使用内建 readiness 的 guarded merge。 7. 按依赖顺序合并;合并后同步目标 worktree。 8. 触发受控 CI/CD 或让明确授权的子代理上线;主代理核对 closeout。 9. 用原入口复测;把剩余问题拆到新 issue 或追加既有 issue。 diff --git a/.tekton/web-probe-sentinel-jd01-pac.yaml b/.tekton/web-probe-sentinel-jd01-pac.yaml index 83fafa54..6222e326 100644 --- a/.tekton/web-probe-sentinel-jd01-pac.yaml +++ b/.tekton/web-probe-sentinel-jd01-pac.yaml @@ -68,6 +68,7 @@ spec: bun scripts/native/cicd/render-sentinel-publish-task.ts \ --node JD01 \ --lane v03 \ + --repository-id sentinel-jd01-v03 \ --sentinel jd01-web-probe-sentinel \ --pipeline-run "$(context.pipelineRun.name)" \ --source-commit "{{revision}}" \ diff --git a/.tekton/web-probe-sentinel-nc01-pac.yaml b/.tekton/web-probe-sentinel-nc01-pac.yaml index 6ec53bce..5a8126c7 100644 --- a/.tekton/web-probe-sentinel-nc01-pac.yaml +++ b/.tekton/web-probe-sentinel-nc01-pac.yaml @@ -68,6 +68,7 @@ spec: bun scripts/native/cicd/render-sentinel-publish-task.ts \ --node NC01 \ --lane v03 \ + --repository-id sentinel-nc01-v03 \ --sentinel nc01-web-probe-sentinel \ --pipeline-run "$(context.pipelineRun.name)" \ --source-commit "{{revision}}" \ diff --git a/bun.lock b/bun.lock index 29d6e93c..3b8c817b 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,10 @@ "workspaces": { "": { "name": "unidesk-root", + "dependencies": { + "ajv-dist": "8.17.1", + "yaml": "2.3.4", + }, "devDependencies": { "@types/bun": "latest", "@types/node": "latest", @@ -98,6 +102,8 @@ "adler-32": ["adler-32@1.3.1", "", {}, "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A=="], + "ajv-dist": ["ajv-dist@8.17.1", "", {}, "sha512-KzJwANMzTTR/RERGnkx+bHzmxIfMTPMMv7+cH1d6Lx9UQ7BZyhiieq4hnO5lRuBWOtYTUL8hyWs7RJYI/45Rtg=="], + "bun-types": ["bun-types@1.3.13", "", { "dependencies": { "@types/node": "*" } }, "sha512-QXKeHLlOLqQX9LgYaHJfzdBaV21T63HhFJnvuRCcjZiaUDpbs5ED1MgxbMra71CsryN/1dAoXuJJJwIv/2drVA=="], "cfb": ["cfb@1.2.2", "", { "dependencies": { "adler-32": "~1.3.0", "crc-32": "~1.2.0" } }, "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA=="], @@ -184,6 +190,8 @@ "xlsx": ["xlsx@0.18.5", "", { "dependencies": { "adler-32": "~1.3.0", "cfb": "~1.2.1", "codepage": "~1.15.0", "crc-32": "~1.2.1", "ssf": "~0.11.2", "wmf": "~1.0.1", "word": "~0.3.0" }, "bin": { "xlsx": "bin/xlsx.njs" } }, "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ=="], + "yaml": ["yaml@2.3.4", "", {}, "sha512-8aAvwVUSHpfEqTQ4w/KMlf3HcRdt50E5ODIQJBw1fQ5RL34xabzxtUlzTXVqc4rkZsPbvrXKWnABCD7kWSmocA=="], + "vite/fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], } } diff --git a/config/feature-config.schema.json b/config/feature-config.schema.json new file mode 100644 index 00000000..7b604882 --- /dev/null +++ b/config/feature-config.schema.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://unidesk.pikapython.com/schema/feature-config.schema.json", + "title": "UniDesk Product Feature Configuration", + "type": "object", + "properties": { + "webProbeSentinel": { + "x-unidesk-feature": "web-probe-sentinel", + "type": "object", + "required": [ + "enabled", + "scheduler" + ], + "properties": { + "enabled": { + "type": "boolean" + }, + "scheduler": { + "type": "object", + "required": [ + "intervalSeconds" + ], + "properties": { + "intervalSeconds": { + "type": "integer", + "minimum": 30 + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": false +} diff --git a/config/hwlab-node-lanes.yaml b/config/hwlab-node-lanes.yaml index 69f10dee..0e718449 100644 --- a/config/hwlab-node-lanes.yaml +++ b/config/hwlab-node-lanes.yaml @@ -123,6 +123,7 @@ lanes: configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01 renderer: hwlab-runtime-lane mode: remote-pipeline-annotation + maxInlineScriptBytes: 32768 git: url: git@github.com:pikasTech/HWLAB.git readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikasTech-HWLAB.git @@ -209,26 +210,30 @@ lanes: codexStdioSupervisor: repo-owned kafkaEventBridge: enabled: true - features: - directPublish: true - liveKafkaSse: true - kafkaRefreshReplay: true - transactionalProjector: false - projectionOutboxRelay: false - projectionRealtime: false - refreshReplay: - groupIdPrefix: hwlab-v03-cloud-api-sse - timeoutMs: 30000 - scanLimit: 1000000 - matchedEventLimit: 2000 - liveBufferLimit: 2000 + transactionalProjector: + heartbeatIntervalMs: 2000 + projectionOutboxRelay: + intervalMs: 250 + batchSize: 100 + leaseMs: 30000 + sendTimeoutMs: 10000 + retryBackoffMs: 1000 + projectionRealtime: + outboxTailBatchSize: 100 + sseHeartbeatMs: 15000 + sseDrainTimeoutMs: 2500 + healthThresholds: + consumerLagWarning: 0 + outboxBacklogWarning: 0 + retryCountWarning: 0 + failedInboxWarning: 0 + dlqWarning: 0 configRef: config/platform-infra/kafka.yaml#clients.hwlab-v03-cloud-api bootstrapServers: platform-infra-kafka-kafka-bootstrap.platform-infra.svc.cluster.local:9092 stdioTopic: codex-stdio.raw.v1 agentRunEventTopic: agentrun.event.v1 hwlabEventTopic: hwlab.event.v1 clientId: hwlab-v03-cloud-api - directPublishConsumerGroupId: hwlab-v03-agentrun-event-direct-publish transactionalProjectorConsumerGroupId: hwlab-v03-agentrun-event-projector hwlabEventConsumerGroupId: hwlab-v03-workbench-live-sse publicExposure: diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 6a0595c4..c1a1b8d7 100644 --- a/config/platform-infra/sub2api.yaml +++ b/config/platform-infra/sub2api.yaml @@ -82,7 +82,7 @@ targets: redisReplicas: 1 image: repository: docker.1panel.live/weishaw/sub2api - tag: 0.1.153 + tag: 0.1.155 pullPolicy: IfNotPresent dependencyImages: redis: docker.m.daocloud.io/library/redis:8-alpine diff --git a/config/secrets-distribution.yaml b/config/secrets-distribution.yaml index ae2a973a..0accf54d 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -9,6 +9,7 @@ metadata: - 300 - 313 - 2256 + - 1964 sources: root: /root/.unidesk/.state/secrets @@ -147,6 +148,27 @@ sources: randomBase64Url: bytes: 24 prefix: poa_ + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + type: env + requiredKeys: + - SELFMEDIA_ADMIN_USERNAME + - SELFMEDIA_ADMIN_PASSWORD + - SELFMEDIA_API_KEY + - SELFMEDIA_SESSION_SECRET + createIfMissing: + enabled: true + values: + SELFMEDIA_ADMIN_USERNAME: admin + randomBase64Url: + SELFMEDIA_ADMIN_PASSWORD: + bytes: 32 + prefix: smp_ + SELFMEDIA_API_KEY: + bytes: 32 + prefix: smk_ + SELFMEDIA_SESSION_SECRET: + bytes: 32 + prefix: sms_ - sourceRef: ~/.env/TOKEN type: raw-file required: true @@ -192,6 +214,11 @@ targets: namespace: selfmedia scope: selfmedia enabled: true + consumerRollout: + deployments: + - selfmedia + timeoutSeconds: 180 + pollIntervalSeconds: 5 - id: pikaoa-nc01 route: NC01:k3s namespace: pikaoa @@ -219,6 +246,23 @@ kubernetesSecrets: - sourceRef: platform-infra/pk01-frp.env sourceKey: FRP_TOKEN targetKey: FRP_TOKEN + - name: selfmedia-access + targetId: selfmedia-nc01 + secretName: selfmedia-access + type: Opaque + data: + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_ADMIN_USERNAME + targetKey: admin-username + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_ADMIN_PASSWORD + targetKey: admin-password + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_API_KEY + targetKey: api-key + - sourceRef: /root/.unidesk/.env/selfmedia-access.env + sourceKey: SELFMEDIA_SESSION_SECRET + targetKey: session-secret - name: selfmedia-runtime targetId: selfmedia-nc01 secretName: selfmedia-runtime diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml index 82dacec9..ec4d9586 100644 --- a/config/selfmedia.yaml +++ b/config/selfmedia.yaml @@ -172,6 +172,20 @@ delivery: mountPath: /home/codex/.codex/state subPath: state secrets: + access: + sourceRef: /root/.unidesk/.env/selfmedia-access.env + target: + secretName: selfmedia-access + readOnly: true + files: + - targetKey: admin-username + mountPath: /run/secrets/selfmedia-access/admin-username + - targetKey: admin-password + mountPath: /run/secrets/selfmedia-access/admin-password + - targetKey: api-key + mountPath: /run/secrets/selfmedia-access/api-key + - targetKey: session-secret + mountPath: /run/secrets/selfmedia-access/session-secret runtime: sourceRef: ~/.env/TOKEN target: diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index cd1914de..1a5ec1ea 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -27,6 +27,12 @@ github: sourceRef: pikainc-selfmedia-gh-token.txt sourceKey: GH_TOKEN format: raw-token + - repository: pikainc/pikaoa + priority: before-env + root: /root/.unidesk/.env + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token prMerge: unknownRetry: maxAttempts: 5 diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index f7d28024..2941ed46 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -23,12 +23,38 @@ delivery: changeDetection: runtimePaths: - .tekton/unidesk-host-pac.yaml + - config/feature-config.schema.json + - config/hwlab-node-lanes.yaml - config/platform-infra/pipelines-as-code.yaml - config/unidesk-host-k8s.yaml + - bun.lock + - package.json - scripts/native/cicd/build-unidesk-host-image.sh + - scripts/native/cicd/feature-config-schema-warning.mjs + - scripts/native/cicd/otel-runtime-config.ts + - scripts/native/cicd/pac-status-evaluator.cjs - scripts/native/cicd/prepare-unidesk-host-release.mjs - scripts/native/cicd/publish-unidesk-host-gitops.mjs + - scripts/native/cicd/render-sentinel-publish-task.ts - scripts/native/deploy/render-unidesk-host-service.mjs + - scripts/native/hwlab/runtime-gitops-observability.mjs + - scripts/native/hwlab/runtime-gitops-postprocess.mjs + - scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs + - scripts/native/hwlab/runtime-gitops-verify.mjs + - scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js + - scripts/vendor/ajv-dist/8.17.1/manifest.json + - scripts/src/agentrun-manifests.ts + - scripts/src/hwlab-node/render.ts + - scripts/src/hwlab-node/web-probe.ts + - scripts/src/hwlab-node-web-observe-runner-realtime-source.ts + - scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts + - scripts/src/pac-feature-config-schema-stage.ts + - scripts/src/platform-infra-pac-feature-config-projection.ts + - scripts/src/platform-infra-pipelines-as-code-source-artifact.ts + - scripts/src/platform-infra-pipelines-as-code.ts + - scripts/src/config.ts + - scripts/src/hwlab-node-lanes.ts + - scripts/src/yaml-composition.ts - src/components/microservices/todo-note/Dockerfile - src/components/microservices/todo-note/bun.lock - src/components/microservices/todo-note/package.json @@ -58,6 +84,12 @@ delivery: branch: unidesk-host-gitops manifestPath: deploy/gitops/unidesk-host/todo-note.yaml releaseStatePath: deploy/gitops-state/unidesk-host/todo-note.json + resources: + - id: hwlab-nc01-v03-runtime-gitops-scripts + renderer: hwlab-runtime-gitops-scripts + configRef: config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01 + manifestPath: deploy/gitops/unidesk-host/hwlab-nc01-v03-runtime-gitops-scripts.yaml + namespace: hwlab-ci author: name: UniDesk Host CI email: unidesk-host-ci@unidesk.local diff --git a/docs/MDTODO/agentrun-runtime-reliability.md b/docs/MDTODO/agentrun-runtime-reliability.md index ee4a146a..2d031ec3 100644 --- a/docs/MDTODO/agentrun-runtime-reliability.md +++ b/docs/MDTODO/agentrun-runtime-reliability.md @@ -114,6 +114,17 @@ 解决 [UniDesk #1883](https://github.com/pikasTech/unidesk/issues/1883) 与 [AgentRun #342](https://github.com/pikasTech/agentrun/issues/342):让 Artificer runner 的受控 trans 复用 UniDesk Windows route 解析与 frontend transport,支持 D518:win 及 win// 原入口;区分 primary source workspace 与用户授权的远端 tool target,删除 unsupported-operation 和 host path 误判,不新增节点特例、旁路 SSH 或隐藏 fallback。两个 PR 自动上线后用最短 Artificer canary 验收 pwd、ls、cat 和 apply-patch,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5.6_Task_Report.md)。 -### R5.7 [in_progress] +### R5.7 -解决 [UniDesk #2014](https://github.com/pikasTech/unidesk/issues/2014):把 `config/unidesk-cli.yaml#github.auth.repositoryOverrides` 的既有私有仓凭据通过 YAML-first tool credential/resource bundle 投影给 Artificer,使受控 `unidesk-gh` 可读取、创建和评论任务私有仓 PR;Secret 只披露 SecretRef presence/fingerprint/valuesPrinted=false,不新增第二 GitHub 客户端、权限契约、租约或围栏,并与 PikaOA MVP 主线并行且不作为业务门禁,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5.7_Task_Report.md)。 +解决 [UniDesk #1957](https://github.com/pikasTech/unidesk/issues/1957):修复 Artificer resource bundle 缺失 `$post-task` skill 与 `$unidesk-subagent` 权威收口引用不一致;只沿 owning skill source、AipodSpec、resource bundle 和 runner 投影定位并物化,禁止 prompt 复制规则、手工 Pod/宿主补文件、第二 bundle authority 或人工 CI/CD,完成任务后以真实 Artificer follow-up 加载 `$post-task` 验收,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5.7_Task_Report.md)。 + +### R5.8 [in_progress] + +解决 [UniDesk #2014](https://github.com/pikasTech/unidesk/issues/2014):把 `config/unidesk-cli.yaml#github.auth.repositoryOverrides` 的既有私有仓凭据通过 YAML-first tool credential/resource bundle 投影给 Artificer,使受控 `unidesk-gh` 可读取、创建和评论任务私有仓 PR;Secret 只披露 SecretRef presence/fingerprint/valuesPrinted=false,不新增第二 GitHub 客户端、权限契约、租约或围栏,并与 PikaOA MVP 主线并行且不作为业务门禁,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R5.8_Task_Report.md)。 +## R6 [completed] + +将“先恢复运行面再工程化”固化为 unidesk-subagent 的泛化主线规则:运行面恢复关键路径由主代理控制,低耦合工程化任务及时并行,恢复后继续完成工程化交付;将单 PR 收口、复用 guarded merge 内建 preflight、避免仅补 merge SHA 的重复 PR 和减少碎片化 GitHub 查询固化到 unidesk-gh 及相关 reference,并让 merge 默认摘要直接披露 merge commit 与 mergedAt,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R6_Task_Report.md)。 + +## R7 [completed] + +将 Artificer 固化为所有执行型子代理任务的强制默认执行面:用户只说“子代理”时必须使用 Artificer;只有用户明确指定原生子代理,或 Artificer 经有界 readiness/真实派单证据确认故障不可用时,才允许原生子代理执行;禁止主代理因方便、速度、空闲槽或历史习惯自行降级,并要求 Artificer 恢复后立即停止原生扩展并交接,完成任务后将详细报告写入[任务报告](./details/agentrun-runtime-reliability/R7_Task_Report.md)。 diff --git a/docs/MDTODO/details/agentrun-runtime-reliability/R6_Task_Report.md b/docs/MDTODO/details/agentrun-runtime-reliability/R6_Task_Report.md new file mode 100644 index 00000000..40d6625b --- /dev/null +++ b/docs/MDTODO/details/agentrun-runtime-reliability/R6_Task_Report.md @@ -0,0 +1,22 @@ +# R6 任务报告 + +## 结论 + +已将“先恢复运行面,再工程化”固化为子代理调度的泛化原则:主代理冻结恢复标准并控制恢复关键路径,优先通过既有受控入口做最小、可逆、可验证的恢复;恢复不再阻塞低耦合工程化任务并行,运行面恢复后仍继续完成根因修复、PR、验证与治理收口。 + +已将减少重复交付固化到 GitHub skill 与 PR reference:review-plan 只建立索引并按需下钻,guarded merge 内建 preflight,功能、skill、MDTODO 报告和完成状态应在同一 PR 收口,禁止仅为补 merge SHA、mergedAt 或重复 closeout 创建第二个 PR。只有 merge 后产生新运行证据且用户明确要求写回 Git 时,才允许第二个 closeout PR。 + +已更新 GitHub merge 默认文本摘要,成功合并直接披露 mergeCommit 与 mergedAt,并取消机械 pr view 的 Next 提示。 + +## 验证 + +- unidesk-subagent skill quick_validate:通过。 +- unidesk-gh skill quick_validate:通过。 +- bun --check scripts/src/gh/pr-merge.ts:通过。 +- bun scripts/cli.ts check --syntax-only:11 项通过,0 项失败。 +- renderer 定向验证:成功输出包含 mergeCommit=abc123 与 mergedAt=2026-07-14T10:00:00Z,且不包含 gh pr view。 +- git diff --check:通过。 + +## 交付边界 + +本任务不修改运行面、不新增状态库或门禁。规则、CLI、MDTODO 报告与完成状态在同一功能 PR 中交付;merge 事实由 GitHub 和受控 merge 摘要保存,不回写本报告。 diff --git a/docs/MDTODO/details/agentrun-runtime-reliability/R7_Task_Report.md b/docs/MDTODO/details/agentrun-runtime-reliability/R7_Task_Report.md new file mode 100644 index 00000000..59214b82 --- /dev/null +++ b/docs/MDTODO/details/agentrun-runtime-reliability/R7_Task_Report.md @@ -0,0 +1,7 @@ +# R7 任务报告 + +已将 Artificer 固化为执行型子代理任务的强制默认执行面。用户泛称子代理、并行代理或派代理时必须使用 Artificer;只有用户明确指定原生子代理,或 Artificer 经有界 readiness、最短真实派单或 typed failure 证明确实不可用时,才允许原生执行面。 + +规则明确禁止主代理因方便、速度、空闲并发槽、已有 worktree、任务较短或历史习惯自行降级。Artificer 故障修复仍可交给原生子代理;管理性和决策性工作由主代理直接完成。临时原生 fallback 必须在 Artificer 恢复后停止扩展并交接。 + +验证:unidesk-subagent skill quick_validate 通过;git diff --check 通过。 diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R1.3_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R1.3_Task_Report.md new file mode 100644 index 00000000..a4fc785c --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R1.3_Task_Report.md @@ -0,0 +1,68 @@ +# R1.3 任务报告 + +## 结论 + +- 已从 owner-only sourceRef `/root/.unidesk/.env/selfmedia-access.env` 重新分发用户更新后的 SelfMedia 管理员凭据。 +- sourceRef 权限保持 `0600`,新旧 fingerprint 已变化;全过程未读取、解码或输出账号、密码、cookie 与 Secret 值。 +- 已将 SelfMedia 的 owning YAML、Secret sync/status、消费者生效、自动交付观察、WebTerm 权限边界和公网鉴权验收沉淀为 `unidesk-selfmedia` skill。 +- 已通过 UniDesk PR [#1995](https://github.com/pikasTech/unidesk/pull/1995) 合并到 `master`,merge commit 为 `393711d9773cce8faa738a01e1297a5bf513e663`。 + +## SelfMedia 凭据兼容修复与交付 + +- 新管理员密码为 13 字节,旧配置错误地与机器 API key、session secret 共用 32 字节门槛。 +- SelfMedia PR [#25](https://github.com/pikainc/selfmedia/pull/25) 拆分强度配置: + - 管理员密码最小长度由 YAML 配置为 12 字节; + - API key 与 session secret 分别保持 32 字节; + - merge commit 为 `a50869afcbccde3c0a0e06d595f1938f5f671f78`。 +- 自动 PipelineRun `selfmedia-nc01-a50869afcbccde3c0a0e06d595f1938f5f671f78-8fgcs`: + - 状态为 Succeeded; + - 耗时 194 秒; + - 镜像摘要前缀为 `sha256:acf4191432f`; + - GitOps revision 为 `78c0bfa0745c`; + - Argo 为 `Synced/Healthy`; + - runtime 为 `1/1`。 +- SelfMedia PR [#26](https://github.com/pikainc/selfmedia/pull/26) 收口对应 MDTODO 与验收记录,merge commit 为 `11bfcf2657cd4f62e5e7221803872e53143e5f0c`。 + +## YAML-first 运维能力 + +- `config/selfmedia.yaml` 继续负责部署、存储、运行时、权限与公网暴露。 +- `config/secrets-distribution.yaml` 为 `selfmedia-nc01` 声明 `selfmedia` Deployment 的 `consumerRollout`、等待时限与轮询间隔。 +- `scripts/src/secrets.ts` 在 Secret apply 成功后: + - 按 YAML 声明重启消费者; + - 用多次短 `trans` 连接轮询 Deployment ready; + - 默认 confirmed sync 只输出有界摘要; + - `--full` 保留显式完整下钻; + - 所有输出保持 `valuesPrinted=false`。 +- 新 skill 文件: + - `.agents/skills/unidesk-selfmedia/SKILL.md`; + - `.agents/skills/unidesk-selfmedia/references/operations.md`; + - `.agents/skills/unidesk-selfmedia/agents/openai.yaml`。 + +## 真实同步与公网验收 + +- Secret 同步 job `secrets_sync_20260714052911863_5fd8da`: + - 状态为 Succeeded; + - 总耗时 9 秒; + - Secret apply exitCode 为 0; + - consumer rollout restart exitCode 为 0; + - ready 轮询 2 次,耗时 8 秒; + - Deployment generation 为 18; + - ready/available 为 `1/1`; + - 默认 stdout 为 8032 bytes,未截断; + - `valuesPrinted=false`。 +- 公网原入口: + - `/` 返回 200; + - `/healthz` 返回 200; + - 匿名 `/api/v1/system` 返回 401; + - 匿名 `/api/v1/terminal/status` 返回 401; + - 用户更新后的管理员账号登录返回 200; + - 登录 session 访问 `/api/v1/system` 返回 200。 +- 登录验证只记录 HTTP 状态和 `valuesPrinted=false`,未记录账号、密码或 cookie。 + +## 验证 + +- `quick_validate.py .agents/skills/unidesk-selfmedia`:skill valid。 +- `bun scripts/cli.ts check --syntax-only`:11/11。 +- `bun --check scripts/src/secrets.ts`:通过。 +- `git diff --check`:通过。 +- UniDesk PR #1995 preflight:`MERGEABLE/CLEAN`,已用 merge commit 合并。 diff --git a/docs/MDTODO/hwlab-web-product-experience.md b/docs/MDTODO/hwlab-web-product-experience.md index ce9e8b81..51b2f7b0 100644 --- a/docs/MDTODO/hwlab-web-product-experience.md +++ b/docs/MDTODO/hwlab-web-product-experience.md @@ -73,3 +73,7 @@ ### R1.14 [completed] 依据用户最新反馈全量重写 HWLAB v0.3 管理域导航、授权、密钥、用户、账务与 MDTODO 页面代码(子 issue [pikasTech/HWLAB#2522](https://github.com/pikasTech/HWLAB/issues/2522)):禁止以换色、圆角、字母方块、薄包装组件或局部 CSS 冒充重写;先抽取工业运营台通用工作区、状态轨、实体索引、权限矩阵、密钥流向、审计/账本时间线、真实线性图标与 Markdown/报告 Viewer,再重建五页组件树、状态组合和样式并删除旧私有实现。MDTODO 只保留成熟的有界三栏、任务树→正文→报告、Source/File、inline edit、报告调宽/关闭/全屏和深链思想;保留 typed API、稳定 ID、revision/fingerprint、ACL、Secret 脱敏与公共 Workbench Launch 合同,完成 1920/960/390 原入口验收,完成任务后将详细报告写入[任务报告](./details/hwlab-web-product-experience/R1.14_Task_Report.md)。 + +## R2 [in_progress] + +执行 [HWLAB #2537](https://github.com/pikasTech/HWLAB/issues/2537):纠正此前名义完成但真实效果不达标的问题,依据 OA SPEC 和 R1_Context 全量重写 HWLAB v0.3 非 Workbench 页面代码;先抽取工业风高信息密度通用表格、卡片、弹窗、日志/Markdown/JSON/Trace Viewer、Inspector 和图标/列表/密度切换,再重写 AC/SK/UR/AB、MDTODO、HWPOD 与 HWPOD Node,补 Python 单文件节点下载及分阶段接入引导;保留 MDTODO 三栏布局思想、typed API、权限/Secret/硬件/账务 authority 和 Workbench 主路径,完成 1920/960/390、ARIA、reduced-motion 与完整异步状态验收,完成任务后将详细报告写入[任务报告](./details/hwlab-web-product-experience/R2_Task_Report.md)。 diff --git a/docs/MDTODO/observability-trace-reliability.md b/docs/MDTODO/observability-trace-reliability.md index b1cb50be..113aca61 100644 --- a/docs/MDTODO/observability-trace-reliability.md +++ b/docs/MDTODO/observability-trace-reliability.md @@ -57,8 +57,56 @@ ### R4.3 [in_progress] -执行 [UniDesk #1949](https://github.com/pikasTech/unidesk/issues/1949):由 config/hwlab-node-lanes.yaml 选择 transactionalProjector、projectionOutboxRelay、projectionRealtime 单一产品 authority,关闭 live-only direct authority,保持 renderer/status/preflight YAML-first,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.3_Task_Report.md)。 +执行 [UniDesk #1949](https://github.com/pikasTech/unidesk/issues/1949) 与 [UniDesk #1981](https://github.com/pikasTech/unidesk/issues/1981):删除 config/hwlab-node-lanes.yaml、renderer、runtime GitOps 和 CI/CD 中对 Workbench 数据 authority、direct/live/refresh、projector/outbox/realtime 的架构选择与六个布尔 env 硬门禁;HWLAB v0.3 固定纯 Kafka transactional projector→outbox→实时/回放 SSE,YAML 只保留 broker/topic/group/tuning 与产品功能配置,schema 校验仅 warning 且不阻塞滚动上线,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.3_Task_Report.md)。 -### R4.4 +### R4.4 [in_progress] 依赖 R4.1-R4.3,主代理完成 PR 架构审核、顺序合并、自动 CI/CD 和 NC01/v03 原 Workbench 新 hi turn 验收;核对 command/run 实际耗时、Kafka backlog/lag、运行中增量、terminal/final、刷新 replay、公开 result 与 OTel 一致后收口,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4_Task_Report.md)。 + +#### R4.4.1 [in_progress] + +执行 [HWLAB #2527](https://github.com/pikasTech/HWLAB/issues/2527):修复 HWLAB PaC source artifact 未吸收 UniDesk owning YAML transactional projection authority 的漂移,以新的正常 PR merge 自动生成 direct/live/replay=false、projector/outbox/realtime=true 的 GitOps manifest;禁止手工 PipelineRun、mirror sync、Argo refresh 和运行面 patch,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.1_Task_Report.md)。 + +#### R4.4.2 [in_progress] + +执行 [UniDesk #1959](https://github.com/pikasTech/unidesk/issues/1959) 及其后续解阻任务:修复 HWLAB PaC source artifact 的 runtime GitOps helper、ARG_MAX、marker 与 overlay 投影问题,并按最新 R4.4.2.10/R6 合同删除六个架构 env 及其 CI 硬门禁;大段逻辑继续使用 repo-native 文件或等价受管制品,产品 schema 失败只 warning 并继续滚动上线,禁止手改生成物、HTTP 补链、页面轮询、第二 authority 和人工 PipelineRun/同步/刷新/patch,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2_Task_Report.md)。 + +##### R4.4.2.1 [in_progress] + +执行 [UniDesk #1963](https://github.com/pikasTech/unidesk/issues/1963):修复 HWLAB runtime GitOps scripts ConfigMap 的 Git-backed 自动 ownership/交付漂移,只由正常 PR merge 自动事件验收;保持 `UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE`、纯 Kafka transactional projector 与实时/回放 SSE,禁止 B64 回退、HTTP 补链、轮询、第二 authority、人工 PipelineRun/ConfigMap apply/运行面 patch及额外锁租约状态库,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.1_Task_Report.md)。 + +##### R4.4.2.2 [in_progress] + +执行 [HWLAB #2530](https://github.com/pikasTech/HWLAB/issues/2530):增加 repo-owned PaC 回归验证 `UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE`、禁止 B64 内联并校对 ConfigMap mount/helper 合同,以正常 v0.3 PR merge 触发真实自动交付;保持纯 Kafka transactional projector 与实时/回放 SSE,禁止空提交、人工 PipelineRun/同步/刷新/运行面 patch及额外锁租约状态库,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.2_Task_Report.md)。 + +##### R4.4.2.3 [in_progress] + +执行 [UniDesk #1967](https://github.com/pikasTech/unidesk/issues/1967):修复 runtime GitOps postprocess 仅 JSON.parse 而跳过真实 YAML workload,使用结构化 YAML API 覆盖普通、多文档与 List 并显式写入六项 `false,false,false,true,true,true` 能力 env;只由正常 PR merge 自动交付和新 HWLAB merge 验收,禁止放宽 required boolean、手工 patch/补跑/同步/刷新及额外锁租约状态库,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.3_Task_Report.md)。 + +##### R4.4.2.4 [in_progress] + +执行 [UniDesk #1968](https://github.com/pikasTech/unidesk/issues/1968):对齐 runtime GitOps guard 的 typed `continuing/no-build-no-rollout-plan-gitops-verify` 与 PaC terminal evaluator,保持普通 skip 与 verify 模式显式分离且缺证据 fail closed,使 status/history/debug-step 同构;禁止按 TaskRun 成功启发式放宽、人工补跑/同步/刷新/patch及额外状态库锁租约,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.4_Task_Report.md)。 + +##### R4.4.2.5 [in_progress] + +执行 [HWLAB #2532](https://github.com/pikasTech/HWLAB/issues/2532):修复 prepare-source 仍期待 `require.resolve("yaml")` 的过期测试,结构化验证 CI node dependency bootstrap、renderer dependency-install-disabled 与 runtime GitOps helper 的 YAML 模块可解析合同,跑通全文件测试并以正常 v0.3 PR merge触发最终自动验收;禁止运行时 npm install、B64、手改生成物、空提交及人工补跑/patch,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.5_Task_Report.md)。 + +##### R4.4.2.6 [in_progress] + +执行 [UniDesk #1971](https://github.com/pikasTech/unidesk/issues/1971):修复 runtime GitOps verify guard 保留旧 `!willRunGitopsPromote` 条件导致 typed continuing marker 永不发出的合同缺口,使无 build/rollout plan 与继续 verify 正交并让 status/history/debug-step 精确返回 runtime-gitops-verify/valid=true;禁止放宽 evaluator、人工补跑/同步/刷新/patch、第二 authority及额外锁租约状态库,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.6_Task_Report.md)。 + +##### R4.4.2.7 [in_progress] + +执行 [HWLAB #2534](https://github.com/pikasTech/HWLAB/issues/2534):在 UniDesk #1972 自动交付后用受控 source-artifact renderer 重新生成 HWLAB NC01/v03 PaC artifact,使无 build/rollout plan 的 typed continuing marker 与 `willRunGitopsPromote` 解耦,并以正常 v0.3 PR merge 验证 runtime-gitops-verify/valid=true;禁止手改生成物、空提交、人工补跑/同步/刷新/patch、放宽 evaluator及第二 authority,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.7_Task_Report.md)。 + +##### R4.4.2.8 [in_progress] + +执行 [UniDesk #1973](https://github.com/pikasTech/unidesk/issues/1973):修复 PaC evaluator 对 runtime GitOps verify 真实 marker 组合的误判,允许零运行时变更 plan 下 collect-artifacts skip 与 gitops-promote continuing 共存,同时继续拒绝 promote skipped/continuing 冲突、非零 plan 与终态证据缺失;由正常 PR merge 自动交付后只读复核 `runtime-gitops-verify/valid=true`,禁止人工补跑、同步、刷新、patch及第二 authority,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.8_Task_Report.md)。 + +##### R4.4.2.9 [in_progress] + +执行 [UniDesk #1975](https://github.com/pikasTech/unidesk/issues/1975):修复 runtime GitOps scripts ConfigMap 丢失 owning YAML `codeAgentRuntime` overlay 导致 postprocess 静默未写纯 Kafka capability env、cloud-api CrashLoop 的问题,并让 verify 在工作负载未命中或 transactional projection capability/env 不完整时 fail closed;以正常 PR merge 自动链和新的 HWLAB source merge 验证 Pod ready、Argo `Synced/Healthy`、projector/outbox/realtime health 归零,禁止人工补跑、同步、刷新、patch及第二 authority,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.9_Task_Report.md)。 + +##### R4.4.2.10 [in_progress] + +执行 [HWLAB #2538](https://github.com/pikasTech/HWLAB/issues/2538) P0 架构纠偏:确认 PR #2540 把已工作的 agentrun.event.v1→hwlab.event.v1→实时 SSE/Kafka retention 回放错误替换为强制 PostgreSQL transactional projector,构成重大架构偏移;从最新 v0.3 分析偏移提交并创建新的精确纠偏提交,恢复 direct publish、live Kafka SSE、Kafka refresh replay 及其单一 Kafka authority,删除强制 v8 schema 启动门禁和数据库迁移依赖,保留 canonical duration 修复、feature-config schema、HTTP fallback 禁令及其他无关改动;通过新 PR 和自动 PaC/GitOps/Argo 上线后校对实时、回放、terminal/final 与 canonical duration,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.10_Task_Report.md)。 diff --git a/docs/MDTODO/pr-merge-driven-automatic-delivery.md b/docs/MDTODO/pr-merge-driven-automatic-delivery.md index 7508ba6b..26edb2b8 100644 --- a/docs/MDTODO/pr-merge-driven-automatic-delivery.md +++ b/docs/MDTODO/pr-merge-driven-automatic-delivery.md @@ -82,3 +82,19 @@ ### R5.3 合并修复 PR 后只观察新的 GitHub webhook→Gitea mirror→PaC→Tekton→GitOps/Argo 自动事件,验证 watcher Ready、TaskRun 创建及 AgentRun/Monitor runtime 收敛,不人工补齐既有 queued run,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R5.3_Task_Report.md)。 + +## 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 [in_progress] + +执行 [UniDesk #2008](https://github.com/pikasTech/unidesk/issues/2008):按 PJ2026-01060106 建立 HWLAB 开发/生产双环境发布;v0.3 固定为 NC01 development,release 固定为 production,两个环境在 source、Pipeline、GitOps、Argo、namespace、public origin、PostgreSQL database/role/Secret/migration ledger 和 Kafka consumer group 上隔离,所有值由 owning YAML 控制,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R7_Task_Report.md)。 + +### R7.1 [in_progress] + +迁移 [UniDesk #2009](https://github.com/pikasTech/unidesk/issues/2009):保持 v0.3 开发 branch/namespace/DB consumer,删除其对 hwlab.pikapython.com 的所有权,选择并 YAML 固化 NC01 公网 IP 的空闲 HTTP 端口和同源 API/health/probe,通过新 PR 和自动链完成原入口验证,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R7.1_Task_Report.md)。 + +### R7.2 [in_progress] + +建立 [UniDesk #2010](https://github.com/pikasTech/unidesk/issues/2010):跟随 release branch 的独立 production PaC/Tekton/GitOps/Argo、namespace、host PostgreSQL database/role/Secret/migration ledger、Kafka consumer group 与 https://hwlab.pikapython.com YAML publicExposure;release 只从完成 P0 Kafka 纠偏并验证的 v0.3 commit 初始化,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R7.2_Task_Report.md)。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index 871c7dc4..dd84fe23 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -11,3 +11,15 @@ ### R1.2 [completed] 有界恢复 NC01 AgentRun manager 受控代理可见性:定位连续 agentrun-proxy-exec-failed,只允许检查并恢复既有 manager.baseUrl、lane service proxy 与授权 presence,不打印凭据,不修改 Artificer 主线源码,不新增安全围栏;恢复后用原始 agentrun events/logs 入口验证并写任务报告,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.2_Task_Report.md)。 + +### R1.3 [completed] + +重新分发用户更新后的 SelfMedia 管理员凭据,并将 owning YAML、Secret sync/status、自动交付与公网鉴权验收最短路径沉淀到 `unidesk-selfmedia` skill;不得读取或输出凭据值,不得影响其他仓库 token 选择,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R1.3_Task_Report.md)。 + +## R2 [in_progress] + +依据 [UniDesk #2006](https://github.com/pikasTech/unidesk/issues/2006) 优化 selfmedia-nc01 PaC 流水线的 env reuse 与依赖缓存:由 owning YAML、lockfile 和 source artifact 生成环境身份,依赖不变时复用并披露 hit/miss 与阶段耗时,依赖变化时正确失效;通过正常 SELFMEDIA PR merge 自动交付验收,保持 commit-pinned artifact、GitOps/Argo、runtime health 和单一 source authority,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R2_Task_Report.md)。 + +## R3 [in_progress] + +依据 [UniDesk #2007](https://github.com/pikasTech/unidesk/issues/2007) 为 pikainc/selfmedia release 分支建立独立生产 PaC/GitOps 流水线:master 保持开发 consumer,release 精确驱动独立生产 namespace、Pipeline、GitOps branch/Application、runtime/PVC/Secret 与 YAML-first 公网端口;禁止 branch follower、手工同步和共享可写状态,以正常 release PR merge 验收生产公网入口、登录、digest 与 health,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R3_Task_Report.md)。 diff --git a/docs/reference/agentrun.md b/docs/reference/agentrun.md index d32467b5..c3b2e50d 100644 --- a/docs/reference/agentrun.md +++ b/docs/reference/agentrun.md @@ -244,12 +244,12 @@ bun scripts/cli.ts agentrun create task \ --repo \ --ref \ --mdtodo-id \ - --prompt-stdin \ - --dry-run + --prompt-stdin ``` -- dry-run 与 live 都先经 Target `trans` 重入并执行 source/worktree/Aipod/SecretRef 预检; -- live 的 submit 成功后自动 dispatch; +- 默认直接执行 live 单命令,不把 `--dry-run` 作为派单前置步骤; +- live 先经 Target `trans` 重入并执行 source/worktree/Aipod/SecretRef 只读预检; +- 预检通过后在同一命令内 submit 并自动 dispatch; - 幂等重试不得重复创建 attempt 或重复 dispatch; - UniDesk 不实现 AgentRun queue 协议,也不把 task double-write 回旧 Code Queue。 diff --git a/docs/reference/code-queue-supervision.md b/docs/reference/code-queue-supervision.md index 51c02708..a6cdfb42 100644 --- a/docs/reference/code-queue-supervision.md +++ b/docs/reference/code-queue-supervision.md @@ -200,6 +200,12 @@ PR 是审查型交付入口,不是所有 Code Queue 任务的默认出口。Un PR handoff 的职责默认分开:runner 实现、测试、提交、push head branch 并创建 PR;指挥官监督并发、steer、审阅、确认 checks 和合并裁决。短期内 GPT-5.5 runner 如果收到明确 PR 收口授权,并且 PR 是普通 UniDesk source 变更、checks 满足任务要求、无冲突且不涉及 prod/runtime/release/security/database/破坏性回滚,可以自行用 repo-owned GitHub merge/close 路径完成收口并报告 SHA。高风险、边界不清、checks 失败或用户/指挥官保留 final action 的 PR 仍必须交回 commander 审查。host commander 也不把直接编辑业务代码当成常规 PR 替代路径。 +- PR 审核默认由指挥官主代理直接完成: + - runner 或 Artificer 的实施授权不包含审核授权; + - 并行实施、多轮审查或 reviewer feedback 不自动创建审核 runner; + - 只有用户明确要求子代理审核、独立审核代理或多代理交叉审核时,才允许派发审核型 AgentRun; + - 主代理发现问题后,优先通过原 session follow-up 纠偏,再由主代理复核。 + PR 支持本身是 Code Queue 能力的一部分。当前 UniDesk CLI 支持 `gh pr list|view|create|update|comment create|comment update|comment edit|comment delete|close|reopen`,其中 create 需要显式 `--title`、`--base`、`--head` 和正文来源,update 需要显式 PR number、正文来源和 `--mode replace|append`,comment create/update/edit 需要显式 commentId 或目标 shorthand 和正文来源,且推荐使用 `--body-stdin` 或 `--body-file`。PR 收口观察应使用 `gh pr view --json state,stateDetail,closed,closedAt,merged,mergedAt,mergeCommit,headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup` 获取普通关闭/已合并区分、merge commit、目标分支、源分支、mergeability 和检查汇总;该路径仍是只读元数据,不执行 merge,且 `closeoutMetadata.ok=false`、`missingOrUnknownFields` 或 GraphQL failure 都只能作为“需要人工复核/重试”的信号。`pr create --dry-run`、`pr update --dry-run` 与 `pr comment create/update/edit --dry-run` 只返回 planned operation,不创建 PR、不更新正文、不写评论;非 dry-run 创建前会校验 repo、base、head 和 compare ahead 状态,append 更新会先读取当前 PR body。`gh pr list` 不开放 mergeability/statusCheckRollup 列表字段,避免默认拉取 noisy/raw 状态汇总。`gh pr delete` 在 UniDesk REST CLI 中返回 `unsupported-command`,不能伪造硬删除;需要移除活跃 PR 时使用 `gh pr close`。`gh pr merge` 是 guarded REST write,没有 `--confirm` 可以绕过 preflight;获得收口授权的 GPT-5.5 runner 可使用 UniDesk `gh pr merge`、GitHub UI 或等价 repo-owned GitHub merge path 处理普通 PR,并报告结果 SHA。需要 PR 交付时,prompt 必须明确允许的人工、runner 或后续工具路径,并报告未覆盖范围。 ### PR 驱动派单模板 diff --git a/package.json b/package.json index f4ffa627..f30f95cb 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,9 @@ "vite": "^8.1.0", "vue": "3", "xlsx": "^0.18.5" + }, + "dependencies": { + "ajv-dist": "8.17.1", + "yaml": "2.3.4" } } diff --git a/project-management/PJ2026-01/deviations/PJ2026-010401080313-20260714-pure-kafka-architecture-deviation.md b/project-management/PJ2026-01/deviations/PJ2026-010401080313-20260714-pure-kafka-architecture-deviation.md new file mode 100644 index 00000000..b0a123a8 --- /dev/null +++ b/project-management/PJ2026-01/deviations/PJ2026-010401080313-20260714-pure-kafka-architecture-deviation.md @@ -0,0 +1,84 @@ +# PJ2026-010401080313 纯Kafka权威架构偏离复盘 + +## 1. 事件摘要 + +| 字段 | 内容 | +| --- | --- | +| 严重度 | P0 开发事故 | +| 日期 | 2026-07-14 | +| 影响规格 | [PJ2026-010401080313 Workbench实时权威](../specs/PJ2026-010401080313-workbench-realtime-authority.md) | +| 关联执行 | pikasTech/HWLAB#2538、PR #2540 | +| 事故类型 | 未经授权改变 event authority、persistence 和 replay 架构,并引入数据库 schema 启动硬前置 | +| 当前状态 | 已停止继续扩展,正在通过新 PR 精确纠偏;禁止粗暴 reset 或覆盖无关修改 | + +事故边界如下: + +- 原始目标是修复 Workbench timing/duration 与一次 17 分钟投影异常。 +- 原始架构要求坚持 `agentrun.event.v1 -> hwlab.event.v1 -> 实时 SSE / Kafka retention 回放 SSE`。 +- 实际实现把链路替换为 PostgreSQL transactional projector、facts/outbox 和数据库 replay。 +- 实际实现删除 direct publish、live Kafka SSE 与 Kafka refresh replay,构成对用户目标和实时权威的根本偏离。 + +## 2. 时间线 + +| 阶段 | 事实 | +| --- | --- | +| 事故前 | 运行配置启用 direct publish、live Kafka SSE、Kafka refresh replay;transactional projector 与 projection outbox relay 关闭。 | +| 任务定义 | #2538 和 MDTODO 被错误写成“删除 direct/live/refresh,固定 transactional projector”,没有先证明 duration 故障需要改变 authority。 | +| 实现 | `3e03e94e` 固定 transactional 投影并修改 timing;`fc87a7e5` 删除 direct/live/refresh;`bef23280` 删除 HTTP 自动补链;`2603b00c` 继续清理旧投影残余。 | +| 合并 | PR #2540 通过 merge commit `6ae0d1b5a48118b6a65a521919de0bc5801e969d` 进入 `v0.3`。 | +| 部署 | PipelineRun/GitOps 成功,但新 Cloud API Pod 因 `workbench_transactional_realtime_schema_blocked` CrashLoop;Argo 为 `Synced/Degraded`,旧 Pod 继续服务。 | +| 判定 | 数据库迁移被误认为新架构的必要条件,随后确认它不是原纯 Kafka实时/回放能力的依赖。 | +| 止损 | 主代理纠正 #2538 与 MDTODO,要求从最新 `v0.3` 建新分支和新 PR,逐文件恢复纯 Kafka路径并保留无关修复。 | + +## 3. 影响 + +- 新 Cloud API Pod 无法启动,滚动发布退化;旧 Pod 暂时维持服务,因此未发生完整业务中断。 +- CI/CD 显示成功而运行面 `Degraded`,扩大了“流水线成功等于发布成功”的可见性误差。 +- 原本无需数据库迁移的实时/回放链被增加 v8 schema 前置,导致修复路径被错误引向数据库迁移。 +- direct publish、live/replay Kafka 代码被删除,回退成本从配置切换扩大为代码纠偏。 +- 子代理和主代理的多轮 review 围绕新架构内部一致性优化,消耗时间并偏离原始 Web 增强主线。 + +## 4. 直接原因 + +1. 任务合同直接把未经用户授权的 PostgreSQL transactional projector 写成目标架构。 +2. PR 删除原 authority 路径,并用启动断言强制新 schema 就绪。 +3. review 把“Kafka 仍在链路中”误判为“纯 Kafka”,没有检查 Kafka 是否仍是实时和回放 authority。 +4. canonical duration 修复与 authority 替换被耦合在同一 PR,未保持问题与架构变更的因果边界。 + +## 5. 系统性原因 + +- 派单前没有写出 current data flow、desired data flow 和两者差异,也没有要求用户授权 authority/persistence/replay 变化。 +- 主代理以测试和内部一致性替代对用户目标、最新 SPEC、线上运行配置和数据流的优先审查。 +- 较旧“Workbench唯一投影”规格包含数据库 outbox 设计,较新的纯 Kafka要求尚未形成明确优先级裁决;冲突未先解决就进入实现。 +- 新测试验证了 transactional 架构,却没有证明该架构是被授权目标;测试把偏移固化成了代码门禁。 +- rollout 验证只看到 PipelineRun/GitOps 成功,没有把新 Pod readiness、Argo health 和启动 blocker 作为同一发布判定。 + +## 6. 主代理责任 + +主代理对事故负最终责任:主代理创建和接受了错误任务合同,未在派单前完成架构裁决;review 时继续要求删除旧路径;在看到 schema blocker 后仍一度沿数据库迁移方向调查。子代理按错误合同实现不能替代主代理的架构审查责任。 + +## 7. 检测与恢复 + +本次事故由新 Pod CrashLoop、Argo `Synced/Degraded` 和 `workbench_transactional_realtime_schema_blocked` 暴露。恢复不执行数据库迁移来承认新架构,而是从最新 `v0.3` 创建精确纠偏提交: + +- 恢复 direct publish、live Kafka SSE 和 Kafka retention replay。 +- 删除 v8 schema 作为 Cloud API 启动前置。 +- 保留 canonical duration、feature config schema、HTTP fallback 禁令和无关修复。 +- 通过新的 PR、自动 PaC/GitOps/Argo 上线,并在原入口校对 live/replay SSE、terminal/final 和 canonical duration。 + +禁止 reset、force push、整提交粗暴反转或人工 runtime patch,以免覆盖 PR #2540 中与架构偏移无关的正确修改。 + +## 8. 纠正与防复发措施 + +| 措施 | Owner | 完成判定 | +| --- | --- | --- | +| 更新实时权威 SPEC | 主代理 | 明确纯 Kafka data flow、数据库非阻塞边界和新旧规格优先级。 | +| 精确纠偏 PR #2540 | HWLAB 实施代理 | 新 PR 恢复 live/replay 路径,Cloud API 不再依赖 v8 schema 启动。 | +| 原入口验证 | 主代理审核 | 自动上线后以同一 session/trace 校对实时、刷新回放、terminal/final 与 duration。 | +| 固化派单规则 | 主代理 | `unidesk-subagent` 要求 current/desired data flow 和架构变化授权。 | +| 固化 review 顺序 | 主代理 | 先比较用户目标、SPEC、运行 data flow,再看代码内部一致性和测试。 | +| CI/CD 可见性 | 独立任务 | PipelineRun 成功但 Argo/Pod degraded 时明确报告发布未完成,不以 schema warning 阻塞滚动。 | + +## 9. 长期判定 + +修复 timing、duration、丢事件或 UI 收敛问题时,不得默认改变 event authority、transport、persistence、replay 或 source of truth。若证据表明确需架构变化,必须先更新长期 SPEC,写清 current/desired data flow、迁移与回退方案,并取得用户明确授权;新增测试只能验证已授权架构,不能反向把未经授权的实现合法化。 diff --git a/project-management/PJ2026-01/specs/PJ2026-0104010803-workbench-unique-projection.md b/project-management/PJ2026-01/specs/PJ2026-0104010803-workbench-unique-projection.md index 0f4f853a..b1797897 100644 --- a/project-management/PJ2026-01/specs/PJ2026-0104010803-workbench-unique-projection.md +++ b/project-management/PJ2026-01/specs/PJ2026-0104010803-workbench-unique-projection.md @@ -7,6 +7,8 @@ 当前正文仍在规格治理草稿中;未定稿前不新增版本号,不为单次编辑追加 `待提交` 版本。 +> 裁决优先级:本规格中以 PostgreSQL aggregate event stream、transactional projector、durable outbox 或 `/v1/workbench/sync` 作为实时/回放 authority 的条款,已被 2026-07-14 的 [PJ2026-010401080313 Workbench实时权威](PJ2026-010401080313-workbench-realtime-authority.md) `draft-2026-07-14-p0-pure-kafka-authority` 取代。当前权威链固定为 `agentrun.event.v1 -> mapper -> hwlab.event.v1 -> 实时 SSE / Kafka retention 回放 SSE`;PostgreSQL 只可作为非阻塞派生读模型,不能成为 Cloud API 启动、Kafka 实时或回放的前置。未完成正文重整前,冲突条款一律以最新专项裁决为准。 + ## 正文 ## PJ2026-0104010803 Workbench唯一投影需求规格 diff --git a/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md b/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md index 97ee400e..d8b63013 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md +++ b/project-management/PJ2026-01/specs/PJ2026-010401080313-workbench-realtime-authority.md @@ -19,7 +19,7 @@ | 短名 | Workbench实时权威 | | 层级 | L4 专项规格切片 | | 状态 | 草稿 | -| 实现引用版本 | draft-2026-07-08-p0-workbench-realtime-authority-v2; draft-2026-07-09-p1-single-step-debug | +| 实现引用版本 | draft-2026-07-08-p0-workbench-realtime-authority-v2; draft-2026-07-09-p1-single-step-debug; draft-2026-07-14-p0-pure-kafka-authority | | 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | | 上级规格 | [PJ2026-010401 Web工作台](PJ2026-010401-web-workbench.md) | | 关联规格 | [PJ2026-0104010803 Workbench唯一投影](PJ2026-0104010803-workbench-unique-projection.md)、[PJ2026-0106050514 Workbench实时运行面](PJ2026-0106050514-workbench-realtime-runtime.md)、[PJ2026-010403 API契约](PJ2026-010403-api-contract.md)、[PJ2026-01060508 Web哨兵](PJ2026-01060508-web-probe-sentinel.md) | @@ -31,14 +31,21 @@ ### 2.1 目的 -Workbench实时权威负责把 Workbench 主 timeline、session rail、turn card、Final Response、Trace detail 和 transport diagnostic 的实时输入收敛到同一 durable projection 语义。当前目标不是“永远不用 REST”,而是禁止前端多端点补洞成为 correctness path:SSE typed event 是 live 主路径,`/v1/workbench/sync` 只能作为与 SSE 同语义的统一 replay/delta,initial snapshot 只负责页面初始水位,detail/history 只服务用户显式查看。 +Workbench实时权威负责把 Workbench 主 timeline、session rail、turn card、Final Response、Trace detail 和 transport diagnostic 收敛到纯 Kafka 事件权威: + +- 固定数据流为 `agentrun.event.v1 -> mapper -> hwlab.event.v1 -> 实时 SSE / Kafka retention 回放 SSE`。 +- 浏览器只消费同一 SSE 合同。 +- 断线、刷新和 cursor 缺口由服务端按 Kafka retention 回放后继续 live tail。 +- `/v1/workbench/sync` 或业务 REST 补链不得成为 correctness path。 + +PostgreSQL 可以保存查询优化所需的派生读模型,但不是 `agentrun.event.v1` 与 `hwlab.event.v1` 之间的 inline authority,也不是实时或回放 SSE 的启动前置。读模型 schema 缺失、迁移未执行或投影器退化必须形成 warning/diagnostic,不能关闭 direct publish、live SSE、Kafka replay,不能阻塞 Cloud API 启动或滚动上线。 本规格同时定义单步调试工作台。调试工作台必须能在不接触真实运行面、不触发 automatic recovery 和不污染生产 Workbench store 的前提下,用 fake SSE 数据逐条驱动同一 reducer,暴露每一步的 authority decision、entity version、state diff、DOM triad 和禁止请求。 ### 2.2 范围内 - `/v1/workbench/events` typed event 的 authority metadata、cursor、entity family/id/version、projectionRevision 和 terminal seal 语义。 -- `/v1/workbench/sync` replay/delta 的语义边界:它是 SSE replay 等价物,不是第二套 REST repair 事实源。 +- Kafka retention 回放 SSE 的 cursor、去重、顺序和回放到 live tail 的切换语义。 - initial snapshot、explicit detail/history、trace-detail-only payload、session list/detail/messages read model 和主状态投影之间的合并边界。 - 前端 reducer/adapter 的 authority gate、sealed guard、detail-only rejection、late stale rejection 和 diagnostic 输出。 - 自动恢复、SSE error、cursor gap、cross-tab projection signal 和 refresh/reconnect 的允许动作与禁止动作。 @@ -56,9 +63,9 @@ Workbench实时权威负责把 Workbench 主 timeline、session rail、turn card | 术语 | 定义 | | --- | --- | -| Realtime Authority v2 | Workbench 主状态只能由 initial snapshot、SSE typed event、统一 `/v1/workbench/sync` replay/delta 和显式 detail/history 中各自被授权的 projection 输入更新的规则集合。 | +| Realtime Authority v2 | Workbench 主状态只能由 initial snapshot、`hwlab.event.v1` 的 live/replay SSE typed event 和显式 detail/history 中各自被授权的输入更新的规则集合。 | | 高纯度 SSE | Workbench live correctness 优先由 SSE typed event 驱动;断线、重复、乱序和缺口通过同一 durable cursor/replay 语义处理,不回退到多个业务 REST endpoint 自行补事实。 | -| sync replay | `/v1/workbench/sync` 或等价入口按 session/trace scope 与 cursor 返回的 typed delta。它必须能被转换为与 SSE 相同的 reducer event,并携带同一 authority metadata。 | +| Kafka replay SSE | SSE 服务按客户端 cursor 从 `hwlab.event.v1` retention 回放 typed event,追上水位后在同一连接继续 live tail;replay 与 live 使用同一事件 schema 和 reducer。 | | 多源补洞 | 前端在 automatic recovery 中同时或顺序调用 `/turns`、`/sessions/:id/messages`、`/traces/:id/events`、旧 `/v1/agent/*` 等端点来推断 terminal/final/message/session 主状态。该模式禁止。 | | detail-only | 只服务 Trace detail、历史页、诊断和审计的 payload。它可以展示过程,不能覆盖主 message/finalResponse/turn/session authority。 | | terminal seal | 同一 durable projection revision 内写入的 message/part terminal status、turn terminal、Final Response、timing、session running=false 和 cursor checkpoint。 | @@ -71,7 +78,7 @@ Workbench 主状态只接受以下输入: - `initial snapshot`:页面初始加载或 explicit user refresh 的水位输入,只能写入其声明的 session/message/turn/trace bucket。 - `SSE typed event`:live delivery 主路径,必须携带 `contractVersion`、`realtimeAuthority`、`entity.family/id/version`、cursor 和 `projectionRevision`。 -- `sync replay/delta`:与 SSE 同语义的 replay 输入,必须先转换为 typed event 或同等 reducer action,再进入主 reducer。 +- `Kafka replay SSE`:服务端按 cursor 从 `hwlab.event.v1` retention 回放,与 live event 使用同一 schema、authority metadata 和 reducer action。 - `explicit detail/history`:用户显式打开的 trace detail、历史分页或详情读取,只能写 detail bucket 或 diagnostic bucket;声明 `detailProjection=true` 或 `authority=trace-detail-only` 时必须拒绝写主状态。 - `optimistic local echo`:submit admission 前后的本地临时投影,必须有 stable id,对账后由 durable event/sync 覆盖;不得成为跨页面或刷新后的事实源。 @@ -81,6 +88,7 @@ Workbench 主状态只接受以下输入: - 用 trace tail、trace detail 末行、message text、session list preview、elapsed timeout、localStorage、DOM active card 或 analyzer fallback 推断 `running`、terminal、Final Response 或 session active。 - 让 late session list/detail/messages、detail-only trace payload、transport diagnostic、requestfailed 或 stale running snapshot 覆盖 sealed entity version。 - 用 reload、切换 session、自动 repair helper、测试后门或 probe analyzer suppress 把已经分裂的页面补成通过。 +- 用 `/v1/workbench/sync`、数据库 outbox replay、HTTP polling 或读模型查询替代 Kafka retention replay,或在 Kafka replay 可用前要求 PostgreSQL schema 就绪。 ## 5. 单步调试工作台 @@ -123,7 +131,7 @@ fake 数据优先来自真实受控样本脱敏后的 fixture。合成 fixture 静态验收: -- 自动恢复路径只允许 `SSE typed event`、`/v1/workbench/sync` replay 或 diagnostic;不得出现旧 `/turns`、`/sessions/:id/messages`、`/traces/:id/events` automatic fan-out 写主状态。 +- 自动恢复路径只允许 live/replay `SSE typed event` 或 diagnostic;不得出现 `/v1/workbench/sync`、旧 `/turns`、`/sessions/:id/messages`、`/traces/:id/events` automatic fan-out 写主状态。 - `traceHydration*` 命名和运行时口径必须收敛为 `traceDetail*` 或显式 detail read;旧配置字段如保留,只能作为 deprecated alias。 - `detailProjection=true`、`authority=trace-detail-only` 和缺 authority metadata 的输入必须被 reducer gate 拒绝写主状态,并产生可见 diagnostic。 @@ -133,6 +141,7 @@ fake 数据优先来自真实受控样本脱敏后的 fixture。合成 fixture - refresh/reconnect/cross-tab signal 后,control/observer/fresh page 不出现 persistent `cross-page-projection-divergence`。 - `workbench-automatic-recovery-fanout-authority`、旧 agent read-through 和旧 Workbench detail fan-out 不回归。 - fake SSE 单步调试可以在无真实 API、无 `/sync`、无补洞的模式下完成 terminal seal、late stale rejection、detail-only rejection 和两页收敛测试。 +- Cloud API 在 Workbench 数据库 schema 缺失或读模型迁移未完成时仍能启动,并继续 direct publish、live SSE 与 Kafka retention replay;退化项只形成可见 warning/diagnostic。 ## 7. 过程控制 diff --git a/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md b/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md index 7e7a8c8f..6a0d9e6b 100644 --- a/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md +++ b/project-management/PJ2026-01/specs/PJ2026-010601-controlled-release.md @@ -84,6 +84,7 @@ | PJ2026-01060103 | 发布判定 | 本规格 6.3 | source/GitOps/runtime 一致性、health/readiness 和 migration/SecretRef presence | CI/CD、GitOps、YAML运维 | 平台管理员、业务模块 | | PJ2026-01060104 | 验证分层 | 本规格 6.4 | 自测试、综合联调、CLI/API 交互和真实运行面通过口径 | 目标 runtime、业务模块测试需求 | 发布决策 | | PJ2026-01060105 | AgentRun发布 | [PJ2026-01060105 AgentRun发布Lane](PJ2026-01060105-agentrun-v01-release-lane.md) | AgentRun `v0.1` 的发布 lane、Pipeline、runtime namespace 和真实联调细则 | 源码同步、YAML运维、Agent编排 | AgentRun runtime | +| PJ2026-01060106 | HWLAB双环境 | [PJ2026-01060106 HWLAB双环境](PJ2026-01060106-hwlab-dev-production-lanes.md) | HWLAB `v0.3` development 与 `release` production 的分支、流水线、公开入口和数据隔离 | 源码同步、YAML运维、Workbench实时权威 | HWLAB runtime | ## 6. 原子需求 diff --git a/project-management/PJ2026-01/specs/PJ2026-01060106-hwlab-dev-production-lanes.md b/project-management/PJ2026-01/specs/PJ2026-01060106-hwlab-dev-production-lanes.md new file mode 100644 index 00000000..78095709 --- /dev/null +++ b/project-management/PJ2026-01/specs/PJ2026-01060106-hwlab-dev-production-lanes.md @@ -0,0 +1,182 @@ +# PJ2026-01060106 HWLAB双环境 + +## 修改历史 + +| 版本 | 对应 commit id | 更新日期 | 变更说明 | +| --- | --- | --- | --- | + +当前正文仍在规格治理草稿中;未定稿前不新增版本号,不为单次编辑追加 `待提交` 版本。 + +## 正文 + +## PJ2026-01060106 HWLAB双环境需求规格 + +## 1. 文档控制 + +| 字段 | 内容 | +| --- | --- | +| 编号 | PJ2026-01060106 | +| 短名 | HWLAB双环境 | +| 层级 | L3 子课题 | +| 状态 | 草稿 | +| 实现引用版本 | draft-2026-07-14-p0-hwlab-dev-production-lanes | +| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | +| 上级规格 | [PJ2026-010601 发布流水](PJ2026-010601-controlled-release.md) | +| 关联规格 | [PJ2026-010602 源码同步](PJ2026-010602-source-sync.md)、[PJ2026-010603 YAML运维](PJ2026-010603-yaml-first-ops.md)、[PJ2026-010401080313 Workbench实时权威](PJ2026-010401080313-workbench-realtime-authority.md) | +| 规格治理索引 | [规格治理](spec-governance.md) | + +本文定义 HWLAB 在 NC01 上的 development/production 双环境。所有可调事实由 owning YAML 控制,自动发布只由目标 source branch 的 PR merge/branch update 驱动,禁止人工补跑 PipelineRun、手工 Argo sync、运行时 patch 或从集群反解配置。 + +## 2. 目的和范围 + +### 2.1 目的 + +把当前 `v0.3` 固定为开发环境,并建立跟随 `release` 分支的生产环境。两套环境在发布身份、运行资源、公开入口、数据库数据域和 Kafka consumer identity 上隔离,使开发变更可以持续滚动,同时只有经过开发原入口验证的 commit 才能晋升为生产基线。 + +### 2.2 范围内 + +- `v0.3` development 与 `release` production 的 source、PaC consumer、Tekton Pipeline、GitOps branch/path、Argo Application、namespace 和 artifact provenance。 +- development 的 NC01 公网 IP HTTP 入口与 production 的 `https://hwlab.pikapython.com` 入口。 +- 共用 NC01 host PostgreSQL 服务时,两套独立 database、role、Secret consumer 和 migration ledger。 +- 两套独立 Kafka consumer group identity,以及纯 Kafka实时/回放权威不被数据库迁移阻塞的边界。 +- `v0.3` 到 `release` 的受控晋升、自动部署和原入口验证。 + +### 2.3 范围外 + +- Web 页面能力和组件重写归客户端规格。 +- AgentRun 自身的发布 lane 和数据库归 AgentRun 专项规格。 +- DNS、Caddy、FRP 和证书服务的通用实现归 YAML运维与公开入口平台;本规格只定义 HWLAB 两个 consumer 的所有权。 +- 从 development 向 production 复制业务数据、Secret value 或用户会话不在本任务范围内。 + +## 3. 术语表 + +| 术语 | 定义 | +| --- | --- | +| development lane | source 为 `v0.3`、保留当前开发 namespace、通过 NC01 公网 IP 空闲 HTTP 端口访问的 HWLAB 环境。 | +| production lane | source 为 `release`、使用独立 production namespace、通过 `https://hwlab.pikapython.com` 访问的 HWLAB 环境。 | +| 晋升 | 把已在 development 完成原入口验证的精确 commit 纳入 `release`,随后由 production 自动链发布。 | +| 数据域隔离 | 两个环境不共享 PostgreSQL database、role、Secret consumer 或 migration ledger,任何 schema/data mutation 只作用于选中 lane。 | +| 入口所有权 | owning YAML 中唯一声明某 public origin 的 consumer;同一 host/origin 不得同时归 development 和 production。 | + +## 4. 架构与数据流 + +### 4.1 系统架构 + +```mermaid +flowchart LR + V03[HWLAB v0.3] --> DEVPA[development PaC/Pipeline] + DEVPA --> DEVGIT[development GitOps] + DEVGIT --> DEVNS[development namespace] + DEVNS --> DEVHTTP[152.53.229.148:YAML端口] + DEVNS --> DEVDB[(host PostgreSQL: development database)] + + REL[HWLAB release] --> PRODPA[production PaC/Pipeline] + PRODPA --> PRODGIT[production GitOps] + PRODGIT --> PRODNS[production namespace] + PRODNS --> PRODHTTPS[https://hwlab.pikapython.com] + PRODNS --> PRODDB[(host PostgreSQL: production database)] + + KAFKA[(Kafka)] -->|development consumer group| DEVNS + KAFKA -->|production consumer group| PRODNS +``` + +### 4.2 运行数据流 + +```mermaid +flowchart LR + AR[agentrun.event.v1] --> MAP[HWLAB mapper] + MAP --> HE[hwlab.event.v1] + HE -->|development group| DEVSSE[development live/replay SSE] + HE -->|production group| PRODSSE[production live/replay SSE] + DEVSSE --> DEVWEB[development Web] + PRODSSE --> PRODWEB[production Web] + DEVWEB -.显式查询.-> DEVDB[(development read model)] + PRODWEB -.显式查询.-> PRODDB[(production read model)] +``` + +Kafka event 是实时与回放 authority。数据库只保存各 lane 的业务数据和派生读模型;任一数据库 schema 缺失不得改变 Kafka direct publish/live/replay 路径,也不得成为 Cloud API 启动或自动滚动前置。 + +### 4.3 晋升与自动发布时序 + +```mermaid +sequenceDiagram + participant D as v0.3 + participant DC as development CI/CD + participant DV as development 原入口验证 + participant R as release + participant PC as production CI/CD + participant PV as production 原入口验证 + D->>DC: merge/update commit C + DC->>DV: 自动构建、GitOps、rollout + DV-->>D: C 验证通过 + D->>R: 受控晋升精确 commit C + R->>PC: branch update 自动触发 + PC->>PV: 自动构建、GitOps、rollout + PV-->>R: digest/source/runtime/入口证据 +``` + +初始 `release` 分支只能从完成 P0 纯 Kafka纠偏并在 development 原入口验证通过的 `v0.3` commit 创建。不得从已知强制 PostgreSQL transactional projector 的事故 commit 创建,也不得把 production 自动链的缺失用人工 PipelineRun 或 runtime patch 填补。 + +## 5. 配置与隔离 + +### 5.1 YAML-first 配置 + +owning YAML 至少声明: + +- lane id、source repository/branch、PaC consumer、Pipeline、GitOps branch/path、Argo Application 和 namespace。 +- public exposure 的 scheme、host、port、target service、health path、Caddy/FRP managed block 引用。 +- PostgreSQL host service reference、database、role、Secret sourceRef/targetKey、migration ledger identity。 +- Kafka bootstrap SecretRef、topic contract、consumer group identity 和 replay retention/cursor 配置。 +- artifact repository、image name、digest provenance 和 source commit label。 + +代码只校验和渲染 YAML,不内置 development/production 特例,不从现有 Deployment、Secret、Ingress、Caddy 或数据库反解事实。Secret 输出只允许 presence、object/key、fingerprint 和脱敏摘要。 + +### 5.2 公开入口 + +- development 正式入口是 `http://152.53.229.148:`;实施任务应探测空闲端口并把最终值固化到 owning YAML。 +- production 正式入口固定为 `https://hwlab.pikapython.com`,由 production publicExposure 独占。 +- development 必须删除 `hwlab.pikapython.com` 的 route、probe、CORS/origin 和 managed block 所有权;Web/API/health 使用同源 development 入口。 +- production 证书、Caddy/FRP route 和 health probe 由 YAML 渲染,禁止手改共享配置文件。 + +### 5.3 PostgreSQL 与 Kafka 隔离 + +- 两个 lane 可以复用同一 NC01 host PostgreSQL 服务进程,但必须使用不同 database、role、Secret consumer 和 migration ledger。 +- migration 命令必须显式选中 lane,只能迁移其 database;禁止用共享 DSN、共享 schema 或默认 database 回退。 +- production 初始化不复制 development 会话、用户业务数据或 migration history;需要数据导入时必须另立受控任务。 +- development 与 production 使用不同 Kafka consumer group identity。运行状态和 lag 必须带 lane 标签,禁止同名 group 导致 offset 互相推进。 +- 数据库、Kafka 或 schema warning 不得触发功能关闭、authority 切换、HTTP fallback 或阻塞其他 lane 滚动上线。 + +## 6. 原子需求 + +### 6.1 HWLAB-LANE-REQ-001 Development迁移 + +`v0.3` 自动链必须继续部署到 development namespace,并迁移到 YAML 声明的 NC01 公网 IP HTTP 入口。迁移完成后 `https://hwlab.pikapython.com` 只指向 production,不再路由到 development。 + +### 6.2 HWLAB-LANE-REQ-002 Production自动链 + +`release` 必须拥有独立 PaC consumer、Tekton Pipeline/ServiceAccount、GitOps branch/path、Argo Application、namespace、image provenance 和 release status。`release` update 自动触发完整链,不依赖主代理或值班人员人工补跑。 + +### 6.3 HWLAB-LANE-REQ-003 Source与制品对齐 + +每个 lane 的 PipelineRun、GitOps desired state、runtime image digest 和 workload label 必须能关联同一 source commit。缺失或不一致必须可见,但微服务契约、版本或 schema 检查只输出 warning,不得阻塞业务和滚动上线。 + +### 6.4 HWLAB-LANE-REQ-004 数据边界 + +任一 lane 的应用、migration、Secret 和管理命令不得连接另一 lane 的 database/role。验收必须证明两个环境写入的 canary 数据、migration ledger 和 consumer group offset 互不出现于对方数据域。 + +### 6.5 HWLAB-LANE-REQ-005 原入口验收 + +原入口验收要求如下: + +- development 从新的公网 IP HTTP 入口完成 Web、API、health 和至少一次实时/回放 SSE 验收。 +- production 从 `https://hwlab.pikapython.com` 完成同样验收。 +- production 同时核对 source commit、image digest、namespace、database identity 和 Kafka consumer group。 +- 源码测试、PipelineRun 成功或 Argo Synced 不能单独替代原入口通过。 + +## 7. 过程控制 + +- 父任务由 pikasTech/unidesk#2008 跟踪。 +- development 入口迁移由 pikasTech/unidesk#2009 和 MDTODO `R7.1` 跟踪。 +- production release lane 由 pikasTech/unidesk#2010 和 MDTODO `R7.2` 跟踪。 +- 两个实施任务可以在独立 worktree 并行;共享 YAML/publicExposure 基线的合并和上线顺序由主代理审核。 +- production 实现可以先准备,但 `release` 分支创建、生产部署和公网切换必须等待 P0 纯 Kafka纠偏在 development 验证完成。 diff --git a/scripts/native/cicd/ajv2020-vendor.test.ts b/scripts/native/cicd/ajv2020-vendor.test.ts new file mode 100644 index 00000000..a28c36a6 --- /dev/null +++ b/scripts/native/cicd/ajv2020-vendor.test.ts @@ -0,0 +1,19 @@ +import { expect, test } from "bun:test"; +import { createHash } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const repositoryRoot = resolve(import.meta.dir, "../../.."); +const manifestPath = resolve(repositoryRoot, "scripts/vendor/ajv-dist/8.17.1/manifest.json"); + +test("repo-owned Ajv 2020 bundle matches the locked dependency and source artifact", () => { + const manifest = JSON.parse(readFileSync(manifestPath, "utf8")); + const packageJson = JSON.parse(readFileSync(resolve(repositoryRoot, "package.json"), "utf8")); + const vendor = readFileSync(resolve(repositoryRoot, manifest.vendorPath)); + const source = readFileSync(resolve(repositoryRoot, manifest.sourcePath)); + + expect(packageJson.dependencies[manifest.package]).toBe(manifest.version); + expect(vendor.byteLength).toBe(manifest.bytes); + expect(createHash("sha256").update(vendor).digest("hex")).toBe(manifest.sha256); + expect(vendor.equals(source)).toBe(true); +}); diff --git a/scripts/native/cicd/feature-config-schema-warning.mjs b/scripts/native/cicd/feature-config-schema-warning.mjs new file mode 100644 index 00000000..f2ea5968 --- /dev/null +++ b/scripts/native/cicd/feature-config-schema-warning.mjs @@ -0,0 +1,409 @@ +#!/usr/bin/env node +import { randomBytes } from "node:crypto"; +import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs"; +import path from "node:path"; +import { createRequire } from "node:module"; +import { pathToFileURL } from "node:url"; + +const require = createRequire(import.meta.url); +const cwdRequire = createRequire(path.join(process.cwd(), "package.json")); +const YAML = globalThis.unideskYaml ?? optionalRequire("yaml") ?? optionalRequire("yaml", cwdRequire); +const VALIDATOR_UNAVAILABLE = "FEATURE_CONFIG_VALIDATOR_UNAVAILABLE"; + +export const FEATURE_CONFIG_SCHEMA_PATH = "config/feature-config.schema.json"; + +export function validateFeatureConfigSchema(options = {}) { + const repoDir = path.resolve(options.repoDir ?? process.cwd()); + const schemaPath = FEATURE_CONFIG_SCHEMA_PATH; + const absoluteSchemaPath = path.join(repoDir, schemaPath); + if (!existsSync(absoluteSchemaPath)) return result("feature-config-schema-missing", "schema file is missing", 1, null); + + let schema; + try { + schema = JSON.parse(readFileSync(absoluteSchemaPath, "utf8")); + } catch (error) { + return result("feature-config-schema-invalid", `schema JSON parse failed: ${errorMessage(error)}`, 1, null); + } + + const featureErrors = featureDeclarationErrors(schema); + if (featureErrors.invalid.length > 0) return result("feature-config-schema-invalid", featureErrors.invalid[0], featureErrors.invalid.length, null); + if (featureErrors.duplicates.length > 0) return result("feature-config-variable-duplicate", featureErrors.duplicates[0], featureErrors.duplicates.length, null); + + let validate; + try { + const Ajv2020 = ajv2020(); + const ajv = new Ajv2020({ allErrors: true, strict: true, validateFormats: false }); + ajv.addKeyword({ keyword: "x-unidesk-feature", schemaType: "string", valid: true }); + validate = ajv.compile(schema); + } catch (error) { + return result( + error?.code === VALIDATOR_UNAVAILABLE ? "feature-config-validator-unavailable" : "feature-config-schema-invalid", + errorMessage(error), + 1, + null, + ); + } + + let candidate; + try { + candidate = options.candidate ?? renderedWorkloadCandidate({ + renderedRoot: options.renderedRoot, + propertyNames: Object.keys(schema.properties), + }); + } catch (error) { + return result("feature-config-validator-failure", errorMessage(error), 1, null); + } + if (candidate === null || !isRecord(candidate.values) || Object.keys(candidate.values).length === 0) { + return result("feature-config-input-missing", "rendered workload candidate snapshot is missing", 1, candidate?.summary ?? null); + } + + if (!validate(candidate.values)) { + const errors = Array.isArray(validate.errors) ? validate.errors : []; + return result("feature-config-value-mismatch", boundedAjvError(errors[0]), errors.length || 1, candidate.summary); + } + return result("feature-config-schema-valid", null, 0, candidate.summary); +} + +export function emitFeatureConfigSchemaValidation(options = {}) { + const validation = validateFeatureConfigSchema(options); + process.stderr.write(`${JSON.stringify(validation)}\n`); + return validation; +} + +export async function runFeatureConfigSchemaValidation(options = {}) { + let validation; + try { + validation = emitFeatureConfigSchemaValidation(options); + } catch (error) { + validation = result("feature-config-validator-failure", errorMessage(error), 1, null); + process.stderr.write(`${JSON.stringify(validation)}\n`); + } + try { + const endpoint = options.otelEndpoint ?? process.env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT ?? ""; + const timeoutMs = positiveInteger(options.otelTimeoutMs ?? process.env.OTEL_EXPORTER_TIMEOUT_MS); + const samplingRatio = samplingRatioValue(options.otelSamplingRatio ?? process.env.OTEL_TRACES_SAMPLER_ARG); + if (endpoint.length === 0) { + const exportWarning = otelWarning("ENDPOINT_MISSING", null, false, null, timeoutMs); + process.stderr.write(`${JSON.stringify(exportWarning)}\n`); + return { validation, otelPayload: null, otelWarning: exportWarning }; + } + if (timeoutMs === null) { + const exportWarning = otelWarning("TIMEOUT_MISSING", null, false, null, null); + process.stderr.write(`${JSON.stringify(exportWarning)}\n`); + return { validation, otelPayload: null, otelWarning: exportWarning }; + } + const otelPayload = featureConfigOtelPayload(validation, { + serviceName: options.otelServiceName ?? process.env.OTEL_SERVICE_NAME ?? "unidesk-cicd", + consumer: options.consumer ?? process.env.UNIDESK_PAC_CONSUMER ?? null, + samplingRatio, + traceparent: options.traceparent ?? process.env.TRACEPARENT ?? process.env.traceparent ?? "", + nowMs: options.nowMs, + traceId: options.traceId, + spanId: options.spanId, + }); + const exportWarning = await exportFeatureConfigOtel(endpoint, otelPayload, timeoutMs, options.fetchImpl ?? fetch); + if (exportWarning !== null) process.stderr.write(`${JSON.stringify(exportWarning)}\n`); + return { validation, otelPayload, otelWarning: exportWarning }; + } catch (error) { + const exportWarning = otelWarning("EXPORT_INTERNAL_FAILURE", null, false, error, positiveInteger(options.otelTimeoutMs ?? process.env.OTEL_EXPORTER_TIMEOUT_MS)); + process.stderr.write(`${JSON.stringify(exportWarning)}\n`); + return { validation, otelPayload: null, otelWarning: exportWarning }; + } +} + +export function featureConfigOtelPayload(validation, options = {}) { + const parent = parseTraceparent(options.traceparent); + const traceId = options.traceId ?? parent.traceId ?? randomBytes(16).toString("hex"); + const spanId = options.spanId ?? randomBytes(8).toString("hex"); + const nowMs = options.nowMs ?? Date.now(); + const startTimeUnixNano = String(BigInt(nowMs) * 1_000_000n); + return { + resourceSpans: [{ + resource: { attributes: otelAttributes({ + "service.name": options.serviceName ?? "unidesk-cicd", + "unidesk.pac.consumer": options.consumer ?? null, + "unidesk.otel.sampling_ratio": options.samplingRatio ?? null, + "unidesk.values_redacted": true, + }) }, + scopeSpans: [{ + scope: { name: "unidesk.cicd.feature-config", version: "v1" }, + spans: [{ + traceId, + spanId, + ...(parent.spanId === null ? {} : { parentSpanId: parent.spanId }), + name: "cicd.feature_config.schema", + kind: 1, + startTimeUnixNano, + endTimeUnixNano: startTimeUnixNano, + attributes: otelAttributes({ + "feature_config.warning": validation.warning, + "feature_config.blocking": false, + "feature_config.code": validation.code, + "feature_config.schema_path": validation.schemaPath, + "feature_config.error_count": validation.errorCount, + }), + events: [{ + timeUnixNano: startTimeUnixNano, + name: "feature-config-schema-validation", + attributes: otelAttributes({ + "feature_config.warning": validation.warning, + "feature_config.code": validation.code, + "feature_config.first_error": validation.firstError, + }), + }], + status: { code: 1 }, + }], + }], + }], + }; +} + +async function exportFeatureConfigOtel(endpoint, payload, timeoutMs, fetchImpl) { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetchImpl(endpoint, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(payload), + signal: controller.signal, + }); + if (response.ok) return null; + return otelWarning(`HTTP_${response.status}`, response.status, false, null, timeoutMs); + } catch (error) { + return otelWarning(error instanceof Error && error.name === "AbortError" ? "TIMEOUT" : "EXPORT_FAILED", null, error instanceof Error && error.name === "AbortError", error, timeoutMs); + } finally { + clearTimeout(timeout); + } +} + +function featureDeclarationErrors(schema) { + if (!isRecord(schema) || schema.type !== "object" || !isRecord(schema.properties)) { + return { invalid: ["schema root must be an object schema with properties"], duplicates: [] }; + } + const invalid = []; + const variablesByFeature = new Map(); + for (const [propertyName, propertySchema] of Object.entries(schema.properties)) { + if (!isRecord(propertySchema)) { + invalid.push(`property ${propertyName} schema must be an object`); + continue; + } + const feature = propertySchema["x-unidesk-feature"]; + if (typeof feature !== "string" || feature.trim().length === 0) { + invalid.push(`property ${propertyName} must declare x-unidesk-feature`); + continue; + } + const variables = variablesByFeature.get(feature.trim()) ?? []; + variables.push(propertyName); + variablesByFeature.set(feature.trim(), variables); + } + const duplicates = [...variablesByFeature.entries()] + .filter(([, variables]) => variables.length > 1) + .map(([feature, variables]) => `feature ${feature} maps to multiple properties: ${variables.join(",")}`); + return { invalid, duplicates }; +} + +function renderedWorkloadCandidate({ renderedRoot, propertyNames }) { + if (typeof renderedRoot !== "string" || renderedRoot.length === 0 || !existsSync(renderedRoot) || YAML === null) return null; + const wanted = new Set(propertyNames); + const values = {}; + let fileCount = 0; + let workloadCount = 0; + let matchedValueCount = 0; + for (const file of listStructuredFiles(renderedRoot)) { + fileCount += 1; + for (const document of parseDocuments(file)) { + for (const item of kubernetesItems(document)) { + const podSpec = item?.spec?.template?.spec; + if (!isRecord(podSpec)) continue; + workloadCount += 1; + for (const group of ["containers", "initContainers"]) { + for (const container of Array.isArray(podSpec[group]) ? podSpec[group] : []) { + for (const env of Array.isArray(container?.env) ? container.env : []) { + if (!wanted.has(env?.name) || typeof env?.value !== "string") continue; + values[env.name] = parseRenderedValue(env.value); + matchedValueCount += 1; + } + } + } + } + } + } + return { + values, + summary: { + source: "rendered-workload-env", + renderedRoot: path.relative(process.cwd(), path.resolve(renderedRoot)) || ".", + fileCount, + workloadCount, + matchedValueCount, + propertyCount: propertyNames.length, + valuesPrinted: false, + }, + }; +} + +function listStructuredFiles(root) { + const maxFiles = 2_000; + const maxDirectories = 2_000; + const files = []; + const pending = [path.resolve(root)]; + let directoryCount = 0; + while (pending.length > 0) { + const current = pending.pop(); + directoryCount += 1; + if (directoryCount > maxDirectories) throw new Error(`rendered workload directory limit exceeded: ${maxDirectories}`); + for (const name of readdirSync(current)) { + const file = path.join(current, name); + const stat = lstatSync(file); + if (stat.isSymbolicLink()) continue; + if (stat.isDirectory()) pending.push(file); + else if (/\.(?:ya?ml|json)$/iu.test(name)) { + files.push(file); + if (files.length > maxFiles) throw new Error(`rendered workload file limit exceeded: ${maxFiles}`); + } + } + } + return files.sort(); +} + +function parseDocuments(file) { + const text = readFileSync(file, "utf8"); + if (/\.json$/iu.test(file)) { + try { return [JSON.parse(text)]; } catch { return []; } + } + try { return YAML.parseAllDocuments(text).map((document) => document.toJS()).filter((document) => document !== null); } catch { return []; } +} + +function kubernetesItems(document) { + return document?.kind === "List" && Array.isArray(document.items) ? document.items : [document]; +} + +function parseRenderedValue(value) { + try { return JSON.parse(value); } catch { return value; } +} + +function boundedAjvError(error) { + if (!isRecord(error)) return "candidate does not match schema"; + const instancePath = typeof error.instancePath === "string" ? error.instancePath : ""; + const keyword = typeof error.keyword === "string" ? error.keyword : "validation"; + const message = typeof error.message === "string" ? error.message : "failed"; + return `${instancePath || "/"} ${keyword} ${message}`; +} + +function result(code, firstError, errorCount, candidate) { + const warning = errorCount > 0; + const boundedError = typeof firstError === "string" ? firstError.replace(/\s+/gu, " ").trim().slice(0, 240) : null; + return { + event: "feature-config-schema-validation", + warning, + blocking: false, + code, + schemaPath: FEATURE_CONFIG_SCHEMA_PATH, + errorCount, + firstError: boundedError, + candidate, + mutation: false, + valuesPrinted: false, + otel: { + spanEvent: "cicd.feature_config.schema", + attributes: { + "feature_config.warning": warning, + "feature_config.code": code, + "feature_config.schema_path": FEATURE_CONFIG_SCHEMA_PATH, + "feature_config.error_count": errorCount, + "feature_config.blocking": false, + }, + }, + }; +} + +function optionalRequire(name, loader = require) { + try { return loader(name); } catch { return null; } +} + +function ajv2020() { + if (globalThis.unideskAjv2020) return globalThis.unideskAjv2020; + if (typeof process.env.UNIDESK_AJV2020_BUNDLE === "string" && process.env.UNIDESK_AJV2020_BUNDLE.length > 0) { + return bundledAjv(process.env.UNIDESK_AJV2020_BUNDLE); + } + try { + return require("ajv-dist/dist/ajv2020.bundle.js"); + } catch { + throw validatorUnavailable("Ajv 2020 validator dependency is unavailable"); + } +} + +function bundledAjv(file) { + if (!existsSync(file)) throw validatorUnavailable("Ajv 2020 validator bundle is missing"); + try { + const module = { exports: {} }; + Function("module", "exports", readFileSync(file, "utf8"))(module, module.exports); + const exported = module.exports.default ?? module.exports; + if (typeof exported !== "function") throw new TypeError("bundle export is not a constructor"); + return exported; + } catch (error) { + if (error?.code === VALIDATOR_UNAVAILABLE) throw error; + throw validatorUnavailable(`Ajv 2020 validator bundle is corrupt (${error instanceof Error ? error.name : "Error"})`); + } +} + +function validatorUnavailable(message) { + const error = new Error(message); + error.code = VALIDATOR_UNAVAILABLE; + return error; +} + +function isRecord(value) { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function errorMessage(error) { + return error instanceof Error ? error.message : String(error); +} + +function positiveInteger(value) { + const parsed = Number(value); + return Number.isInteger(parsed) && parsed > 0 ? parsed : null; +} + +function samplingRatioValue(value) { + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 0 && parsed <= 1 ? parsed : null; +} + +function parseTraceparent(value) { + const match = /^00-([0-9a-f]{32})-([0-9a-f]{16})-[0-9a-f]{2}$/iu.exec(String(value)); + return { traceId: match?.[1]?.toLowerCase() ?? null, spanId: match?.[2]?.toLowerCase() ?? null }; +} + +function otelAttributes(values) { + return Object.entries(values).filter(([, value]) => value !== null && value !== undefined).map(([key, value]) => ({ + key, + value: typeof value === "boolean" + ? { boolValue: value } + : typeof value === "number" + ? { intValue: String(value) } + : { stringValue: String(value) }, + })); +} + +function otelWarning(causeCode, httpStatus, timedOut, error, timeoutMs) { + return { + event: "cicd.otel.export", + warning: true, + blocking: false, + causeCode, + ...(httpStatus === null ? {} : { httpStatus }), + timedOut, + errorType: error instanceof Error ? error.name : null, + timeoutMs, + valuesRedacted: true, + }; +} + +if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) { + const renderedRootIndex = process.argv.indexOf("--rendered-root"); + await runFeatureConfigSchemaValidation({ renderedRoot: renderedRootIndex === -1 ? null : process.argv[renderedRootIndex + 1] }); + process.exitCode = 0; +} diff --git a/scripts/native/cicd/feature-config-schema-warning.test.ts b/scripts/native/cicd/feature-config-schema-warning.test.ts new file mode 100644 index 00000000..fc28cdf9 --- /dev/null +++ b/scripts/native/cicd/feature-config-schema-warning.test.ts @@ -0,0 +1,248 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; +import { + featureConfigOtelPayload, + runFeatureConfigSchemaValidation, + validateFeatureConfigSchema, +} from "./feature-config-schema-warning.mjs"; + +const roots: string[] = []; +const validatorScript = resolve(import.meta.dir, "feature-config-schema-warning.mjs"); +const vendorBundle = resolve(import.meta.dir, "../../vendor/ajv-dist/8.17.1/ajv2020.min.js"); + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +test("valid nested Draft 2020-12 candidate remains warning-free", () => { + const root = fixture(schema({ + tracePanel: feature("trace-panel", { + type: "object", + required: ["enabled", "views"], + properties: { + enabled: { type: "boolean" }, + views: { type: "array", minItems: 1, items: { type: "string", pattern: "^[a-z-]+$" } }, + }, + additionalProperties: false, + }), + }), { tracePanel: '{"enabled":true,"views":["timeline"]}' }); + expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({ + warning: false, + blocking: false, + code: "feature-config-schema-valid", + errorCount: 0, + mutation: false, + candidate: { source: "rendered-workload-env", matchedValueCount: 1 }, + }); +}); + +test("repo-owned Ajv 2020 bundle performs real validation without workspace dependencies", () => { + const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }), { tracePanel: "true" }); + const run = runValidator(root, vendorBundle); + expect(run.status).toBe(0); + expect(run.stderr).toContain('"code":"feature-config-schema-valid"'); + expect(run.stderr).not.toContain("feature-config-validator-unavailable"); +}); + +test("missing or corrupt Ajv bundle is a typed warning and exits zero", () => { + const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) }), { tracePanel: "true" }); + const missing = runValidator(root, join(root, "missing-ajv2020.min.js")); + expect(missing.status).toBe(0); + expect(missing.stderr).toContain('"code":"feature-config-validator-unavailable"'); + + const corruptBundle = join(root, "corrupt-ajv2020.min.js"); + writeFileSync(corruptBundle, "this is not JavaScript"); + const corrupt = runValidator(root, corruptBundle); + expect(corrupt.status).toBe(0); + expect(corrupt.stderr).toContain('"code":"feature-config-validator-unavailable"'); +}); + +test("missing schema is a typed non-blocking warning", () => { + const root = fixture(); + expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({ + warning: true, + blocking: false, + code: "feature-config-schema-missing", + }); +}); + +test("invalid JSON and unsupported Draft 2020-12 schema are typed warnings", () => { + const invalidJsonRoot = fixture("{"); + expect(validateFeatureConfigSchema({ repoDir: invalidJsonRoot, renderedRoot: join(invalidJsonRoot, "rendered") }).code).toBe("feature-config-schema-invalid"); + + const invalidSchemaRoot = fixture(JSON.stringify({ $schema: "https://json-schema.org/draft/2020-12/schema", type: "object", properties: { tracePanel: { type: "not-a-json-schema-type", "x-unidesk-feature": "trace-panel" } } })); + expect(validateFeatureConfigSchema({ repoDir: invalidSchemaRoot, renderedRoot: join(invalidSchemaRoot, "rendered") }).code).toBe("feature-config-schema-invalid"); +}); + +test("missing rendered candidate is distinct from schema failure", () => { + const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "boolean" }) })); + expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "missing") })).toMatchObject({ + warning: true, + blocking: false, + code: "feature-config-input-missing", + }); +}); + +test("nested value mismatch is bounded and does not disclose the value", () => { + const root = fixture(schema({ tracePanel: feature("trace-panel", { type: "object", required: ["enabled"], properties: { enabled: { const: true } } }) }), { + tracePanel: '{"enabled":false,"secret":"do-not-print"}', + }); + const result = validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") }); + expect(result).toMatchObject({ warning: true, blocking: false, code: "feature-config-value-mismatch", valuesPrinted: false }); + expect(result.firstError).not.toContain("do-not-print"); +}); + +test("duplicate feature properties are reported without blocking", () => { + const root = fixture(schema({ + tracePanel: feature("trace-panel", { type: "boolean" }), + tracePanelAlias: feature("trace-panel", { type: "boolean" }), + })); + expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({ + warning: true, + blocking: false, + code: "feature-config-variable-duplicate", + errorCount: 1, + }); +}); + +test("actual OTLP span and event carry redacted validation attributes", () => { + const root = fixture(); + const validation = validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") }); + const payload = featureConfigOtelPayload(validation, { + serviceName: "unidesk-cicd", + consumer: "hwlab-nc01-v03", + samplingRatio: 1, + nowMs: 1, + traceId: "1".repeat(32), + spanId: "2".repeat(16), + }); + const span = payload.resourceSpans[0].scopeSpans[0].spans[0]; + expect(span.name).toBe("cicd.feature_config.schema"); + expect(span.events[0].name).toBe("feature-config-schema-validation"); + expect(JSON.stringify(payload)).not.toContain("do-not-print"); + expect(JSON.stringify(payload)).toContain("unidesk.otel.sampling_ratio"); +}); + +test("missing endpoint remains a non-blocking OTel warning", async () => { + const root = fixture(); + const result = await runFeatureConfigSchemaValidation({ + repoDir: root, + renderedRoot: join(root, "rendered"), + otelEndpoint: "", + otelTimeoutMs: 300, + }); + expect(result.otelWarning).toMatchObject({ + event: "cicd.otel.export", + warning: true, + blocking: false, + causeCode: "ENDPOINT_MISSING", + timeoutMs: 300, + }); +}); + +test("export failure uses configured timeout and remains non-blocking", async () => { + const root = fixture(); + let requestSignal: AbortSignal | null = null; + const result = await runFeatureConfigSchemaValidation({ + repoDir: root, + renderedRoot: join(root, "rendered"), + otelEndpoint: "http://otel.example.test/v1/traces", + otelServiceName: "unidesk-cicd", + otelSamplingRatio: 1, + otelTimeoutMs: 300, + fetchImpl: async (_input: URL | RequestInfo, init?: RequestInit) => { + requestSignal = init?.signal ?? null; + throw new Error("collector unavailable"); + }, + }); + expect(requestSignal).not.toBeNull(); + expect(result.otelWarning).toMatchObject({ + event: "cicd.otel.export", + warning: true, + blocking: false, + causeCode: "EXPORT_FAILED", + timeoutMs: 300, + }); +}); + +test("unexpected validator failure is typed and never rejects", async () => { + const options: Record = { + repoDir: fixture(schema({ webProbeSentinel: feature("web-probe-sentinel", { type: "object" }) })), + renderedRoot: "unused", + otelEndpoint: "", + otelTimeoutMs: 300, + }; + Object.defineProperty(options, "candidate", { get: () => { throw new Error("unexpected candidate failure"); } }); + const result = await runFeatureConfigSchemaValidation(options); + expect(result.validation).toMatchObject({ + warning: true, + blocking: false, + code: "feature-config-validator-failure", + mutation: false, + }); + expect(result.otelWarning).toMatchObject({ warning: true, blocking: false, causeCode: "ENDPOINT_MISSING" }); +}); + +test("candidate scan ignores directory symlinks and bounds directory traversal", () => { + const root = fixture(schema({ webProbeSentinel: feature("web-probe-sentinel", { type: "boolean" }) }), { + webProbeSentinel: "true", + }); + symlinkSync(join(root, "rendered"), join(root, "rendered", "loop"), "dir"); + expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({ + warning: false, + code: "feature-config-schema-valid", + }); + for (let index = 0; index <= 2_000; index += 1) mkdirSync(join(root, "rendered", `directory-${index}`)); + expect(validateFeatureConfigSchema({ repoDir: root, renderedRoot: join(root, "rendered") })).toMatchObject({ + warning: true, + blocking: false, + code: "feature-config-validator-failure", + firstError: "rendered workload directory limit exceeded: 2000", + }); +}); + +function fixture(content?: string, env: Record = {}) { + const root = mkdtempSync(join(tmpdir(), "feature-config-schema-")); + roots.push(root); + mkdirSync(join(root, "rendered")); + if (content !== undefined) { + mkdirSync(join(root, "config")); + writeFileSync(join(root, "config", "feature-config.schema.json"), content); + } + writeFileSync(join(root, "rendered", "deployment.yaml"), deployment(env)); + return root; +} + +function runValidator(root: string, bundlePath: string) { + return spawnSync(process.execPath, [validatorScript, "--rendered-root", join(root, "rendered")], { + cwd: root, + encoding: "utf8", + env: { + ...process.env, + UNIDESK_AJV2020_BUNDLE: bundlePath, + OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "", + OTEL_EXPORTER_TIMEOUT_MS: "300", + }, + }); +} + +function schema(properties: Record) { + return JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties, + additionalProperties: false, + }); +} + +function feature(name: string, value: Record) { + return { ...value, "x-unidesk-feature": name }; +} + +function deployment(env: Record) { + const rows = Object.entries(env).map(([name, value]) => ` - name: ${name}\n value: ${JSON.stringify(value)}`).join("\n"); + return `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: fixture\nspec:\n template:\n spec:\n containers:\n - name: fixture\n env:\n${rows || " []"}\n`; +} diff --git a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs index a068c0c7..5618110f 100644 --- a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs +++ b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs @@ -336,10 +336,16 @@ function injectRuntimeGitopsGuard(pipeline) { const step = steps.find((item) => typeof recordOrNull(item)?.script === "string" && item.script.includes("scripts/gitops-render.mjs")); if (!step) throw new Error("runtime GitOps guard injection failed: gitops-promote render step missing"); const originalScript = String(step.script); - let script = patchNoBuildNoRolloutSkip(originalScript); - const noBuildSkipPatched = script !== originalScript; + let script = patchNoBuildNoRolloutCondition(originalScript); + const noBuildConditionPatched = script !== originalScript; + const beforeSkipPatch = script; + script = patchNoBuildNoRolloutSkip(script); + const noBuildSkipPatched = script !== beforeSkipPatch; const beforeInjection = script; script = injectRuntimeGitopsCommands(script); + if (hasWillRunGitopsPromoteCoupling(script)) { + throw new Error("runtime GitOps guard injection failed: no-build-no-rollout marker remains coupled to willRunGitopsPromote"); + } if (hasNoBuildNoRolloutEarlyExit(script)) { throw new Error("runtime GitOps guard injection failed: no-build-no-rollout-plan early exit remains after patch"); } @@ -354,12 +360,20 @@ function injectRuntimeGitopsGuard(pipeline) { present: true, configMapName: runtimeGitopsConfigMapName, stepName: stringOrNull(step.name), + noBuildConditionPatched, noBuildSkipPatched, postprocessInjected, verifyInjected, }; } +function patchNoBuildNoRolloutCondition(script) { + return String(script).replace( + /const willRunGitopsPromote = plan\.ciCdPlan && plan\.ciCdPlan\.willRunGitopsPromote === true;\nprocess\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u, + "process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);", + ); +} + function patchNoBuildNoRolloutSkip(script) { return String(script).replace( /(^|\n)([ \t]*)echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u, @@ -367,6 +381,10 @@ function patchNoBuildNoRolloutSkip(script) { ); } +function hasWillRunGitopsPromoteCoupling(script) { + return /process\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u.test(String(script)); +} + function hasNoBuildNoRolloutEarlyExit(script) { return /echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u.test(String(script)); } diff --git a/scripts/native/cicd/otel-runtime-config.ts b/scripts/native/cicd/otel-runtime-config.ts index 1b18c542..12a0791f 100644 --- a/scripts/native/cicd/otel-runtime-config.ts +++ b/scripts/native/cicd/otel-runtime-config.ts @@ -28,8 +28,7 @@ export interface CicdOtelExportWarning { } export function loadCicdOtelRuntimeConfig( - node: string, - lane: string, + repositoryId: string, env: Record = process.env, ): CicdOtelRuntimeConfig { const root = materializeYamlComposition( @@ -37,7 +36,7 @@ export function loadCicdOtelRuntimeConfig( { label: "platform-infra-pipelines-as-code", stripTemplateKeys: true }, ).value; const observability = record(root.observability, `${pacConfigPath}#observability`); - const repository = repositoryFor(root.repositories, node, lane); + const repository = selectCicdOtelRepository(root.repositories, repositoryId); const params = record(repository.params, `${pacConfigPath}#repositories.${repository.id}.params`); const fallbackCodes: string[] = []; const endpoint = authoritativeValue( @@ -117,12 +116,12 @@ export async function exportCicdOtelPayload( } } -function repositoryFor(value: unknown, node: string, lane: string): Record { +export function selectCicdOtelRepository(value: unknown, repositoryId: string): Record { if (!Array.isArray(value)) throw new Error(`${pacConfigPath}#repositories must be an array`); - const expectedId = `sentinel-${node.toLowerCase()}-${lane.toLowerCase()}`; - const repository = value.find((item) => recordOrNull(item)?.id === expectedId); - if (repository === undefined) throw new Error(`${pacConfigPath} has no repository ${expectedId}`); - return record(repository, `${pacConfigPath}#repositories.${expectedId}`); + if (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/u.test(repositoryId)) throw new Error(`invalid PaC repository id ${repositoryId}`); + const repositories = value.filter((item) => recordOrNull(item)?.id === repositoryId); + if (repositories.length !== 1) throw new Error(`${pacConfigPath} requires exactly one repository ${repositoryId}; matched ${repositories.length}`); + return record(repositories[0], `${pacConfigPath}#repositories.${repositoryId}`); } function authoritativeValue( diff --git a/scripts/native/cicd/pac-status-evaluator.cjs b/scripts/native/cicd/pac-status-evaluator.cjs index f60a62c3..f15cf7a7 100644 --- a/scripts/native/cicd/pac-status-evaluator.cjs +++ b/scripts/native/cicd/pac-status-evaluator.cjs @@ -25,6 +25,41 @@ function exactSkipMarker(records, event) { && item.reason === "no-build-no-rollout-plan") || null; } +function exactRuntimeGitopsVerifyMarker(records) { + return [...records].reverse().find((item) => item.event === "gitops-promote" + && item.status === "continuing" + && item.reason === "no-build-no-rollout-plan-gitops-verify") || null; +} + +function featureConfigSchemaObservation(records) { + const item = [...records].reverse().find((recordValue) => recordValue.event === "feature-config-schema-validation") || null; + if (item === null) return null; + const candidate = record(item.candidate); + const code = stringOrNull(item.code); + const schemaPath = stringOrNull(item.schemaPath); + const firstError = stringOrNull(item.firstError); + return { + warning: item.warning === true, + blocking: false, + code: code === null ? "feature-config-schema-invalid" : code.slice(0, 100), + schemaPath: schemaPath === null ? "config/feature-config.schema.json" : schemaPath.slice(0, 200), + errorCount: Number.isInteger(item.errorCount) ? Math.max(0, item.errorCount) : null, + firstError: firstError === null ? null : firstError.replace(/\s+/gu, " ").trim().slice(0, 240), + candidate: Object.keys(candidate).length === 0 ? null : { + source: stringOrNull(candidate.source), + renderedRoot: stringOrNull(candidate.renderedRoot), + fileCount: Number.isInteger(candidate.fileCount) ? candidate.fileCount : null, + workloadCount: Number.isInteger(candidate.workloadCount) ? candidate.workloadCount : null, + matchedValueCount: Number.isInteger(candidate.matchedValueCount) ? candidate.matchedValueCount : null, + propertyCount: Number.isInteger(candidate.propertyCount) ? candidate.propertyCount : null, + valuesPrinted: false, + }, + spanEvent: "cicd.feature_config.schema", + mutation: false, + valuesPrinted: false, + }; +} + const pacContractTasks = new Set(["plan-artifacts", "collect-artifacts", "gitops-promote"]); function firstString(...values) { @@ -362,13 +397,15 @@ function terminalSource(task, marker, markerStatus = "skipped", markerSource = " function extractPacSourceObservation(recordsInput) { const records = Array.isArray(recordsInput) ? recordsInput.map(record) : []; + const featureConfigSchema = featureConfigSchemaObservation(records); const plan = [...records].reverse().find((item) => item.event === "pac-delivery-plan" || item.event === "g14-ci-plan") || null; const collectMarker = exactSkipMarker(records, "collect-artifacts"); const promoteMarker = exactSkipMarker(records, "gitops-promote"); + const runtimeGitopsVerifyMarker = exactRuntimeGitopsVerifyMarker(records); const planTask = exactTaskTerminal(records, "plan-artifacts"); const collectTask = exactTaskTerminal(records, "collect-artifacts"); const promoteTask = exactTaskTerminal(records, "gitops-promote"); - if (plan === null && collectMarker === null && promoteMarker === null && planTask === null && collectTask === null && promoteTask === null) return null; + if (plan === null && collectMarker === null && promoteMarker === null && runtimeGitopsVerifyMarker === null && planTask === null && collectTask === null && promoteTask === null) return null; const affected = stringArray(plan?.affectedServices); const rollout = stringArray(plan?.rolloutServices); @@ -384,6 +421,8 @@ function extractPacSourceObservation(recordsInput) { const noRuntimeChangeMarkers = collectMarker !== null && promoteMarker !== null; const skipMarkerSeen = collectMarker !== null || promoteMarker !== null; const skipMarkerConflict = skipMarkerSeen && !noRuntimeChangePlan; + const runtimeGitopsVerifyMarkerConflict = runtimeGitopsVerifyMarker !== null + && (!noRuntimeChangePlan || promoteMarker !== null); const deliveryTerminalReady = planTask?.status === "succeeded" && collectTask?.status === "succeeded" && promoteTask?.status === "succeeded"; @@ -394,11 +433,15 @@ function extractPacSourceObservation(recordsInput) { let mode = "inconsistent"; let valid = false; let reason = "structured-plan-or-terminal-marker-is-incomplete"; - if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && noRuntimeChangeMarkers) { + if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && noRuntimeChangeMarkers && runtimeGitopsVerifyMarker === null) { mode = "no-runtime-change"; valid = true; reason = "no-build-no-rollout-plan"; - } else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && !skipMarkerConflict && deliveryTerminalReady) { + } else if (planShapeValid && sourceCommitValid && noRuntimeChangePlan && runtimeGitopsVerifyMarker !== null && !runtimeGitopsVerifyMarkerConflict && deliveryTerminalReady) { + mode = "runtime-gitops-verify"; + valid = true; + reason = "no-build-no-rollout-plan-gitops-verify"; + } else if (planShapeValid && sourceCommitValid && !noRuntimeChangePlan && runtimeGitopsVerifyMarker === null && !skipMarkerConflict && deliveryTerminalReady) { mode = "delivery"; valid = true; reason = "artifact-or-runtime-delivery-required"; @@ -406,10 +449,14 @@ function extractPacSourceObservation(recordsInput) { reason = "source-commit-invalid-or-missing"; } else if (!planShapeValid) { reason = "delivery-plan-shape-invalid-or-missing"; + } else if (runtimeGitopsVerifyMarkerConflict) { + reason = "runtime-gitops-verify-marker-conflicts-with-plan-or-skip-marker"; } else if (skipMarkerConflict) { reason = "no-runtime-change-marker-conflicts-with-delivery-plan"; } else if (deliveryTerminalFailed) { reason = "delivery-terminal-failed"; + } else if (noRuntimeChangePlan && runtimeGitopsVerifyMarker !== null) { + reason = "runtime-gitops-verify-terminal-evidence-incomplete"; } else if (!noRuntimeChangePlan) { reason = "delivery-terminal-evidence-incomplete"; } @@ -440,8 +487,11 @@ function extractPacSourceObservation(recordsInput) { terminalSources: { planArtifacts: terminalSource(planTask, plan, "observed", planMarkerSource), collectArtifacts: terminalSource(collectTask, collectMarker), - gitopsPromote: terminalSource(promoteTask, promoteMarker), + gitopsPromote: runtimeGitopsVerifyMarker === null + ? terminalSource(promoteTask, promoteMarker) + : terminalSource(promoteTask, runtimeGitopsVerifyMarker, "continuing", "runtime-gitops-verify-structured-log"), }, + featureConfigSchema, valuesPrinted: false, }; } @@ -602,14 +652,14 @@ function evaluatePacStatus(inputValue) { const deliveryDisabled = artifact.imageStatus === "disabled"; const deliverySkipped = artifact.imageStatus === "skipped"; const sourceObservationMode = stringOrNull(sourceObservation.mode); - const hwlabCatalogDelivery = sourceObservationMode === "delivery" + const hwlabCatalogDelivery = (sourceObservationMode === "delivery" || sourceObservationMode === "runtime-gitops-verify") && sourceObservation.contract === "hwlab-g14-ci-plan"; const noRuntimeChange = sourceObservationMode === "no-runtime-change"; const catalog = record(artifact.catalog); const catalogPresent = catalog.present === true; const catalogSourceCommit = stringOrNull(catalog.sourceCommit); const catalogSourceMatches = catalogSourceCommit !== null && catalogSourceCommit === sourceCommit; - const catalogDeliveryEvidence = sourceObservationMode === "delivery" + const catalogDeliveryEvidence = (sourceObservationMode === "delivery" || sourceObservationMode === "runtime-gitops-verify") && sourceObservation.valid === true && catalogPresent && catalog.status === "published" @@ -892,6 +942,25 @@ function deliveryObservation(sourceCommit = "c".repeat(40)) { ]); } +function runtimeGitopsVerifyObservation(sourceCommit = "c".repeat(40)) { + return extractPacSourceObservation([ + { + event: "g14-ci-plan", + sourceCommitId: sourceCommit, + affectedServices: [], + rolloutServices: [], + buildServices: [], + reusedServices: Array.from({ length: 8 }, (_item, index) => `service-${index}`), + noImageBuildReason: "test-only-change", + }, + { event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" }, + { event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" }, + { event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] }, + ]); +} + function publishedCatalog(sourceCommit = "c".repeat(40)) { return { present: true, status: "published", sourceCommit, registryVerified: true }; } @@ -902,6 +971,7 @@ function runPacStatusFixtureChecks() { const revision = "b".repeat(40); const retained = noRuntimeChangeObservation(); const delivery = deliveryObservation(); + const runtimeGitopsVerify = runtimeGitopsVerifyObservation(); const exactArgo = { sync: "Synced", health: "Healthy", revision, revisionRelation: { relation: "exact", expectedRevision: revision, observedRevision: revision } }; const cases = [ { @@ -910,6 +980,24 @@ function runPacStatusFixtureChecks() { expectedCode: "pac-ready-no-runtime-change", input: fixtureInput({ artifact: { imageStatus: "retained", sourceObservation: retained } }), }, + { + id: "runtime-gitops-verify-ready", + expectedOk: true, + expectedCode: "pac-ready-gitops-exact", + input: fixtureInput({ artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: runtimeGitopsVerify, catalog: publishedCatalog() }, argo: exactArgo }), + }, + { + id: "runtime-gitops-verify-catalog-missing", + expectedOk: false, + expectedCode: "pac-artifact-catalog-missing", + input: fixtureInput({ artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], gitopsCommit: revision, sourceObservation: runtimeGitopsVerify }, argo: exactArgo }), + }, + { + id: "runtime-gitops-verify-gitops-missing", + expectedOk: false, + expectedCode: "pac-gitops-missing", + input: fixtureInput({ artifact: { imageStatus: "reused", digest: digestA, digests: [digestA], sourceObservation: runtimeGitopsVerify, catalog: publishedCatalog() }, argo: exactArgo }), + }, { id: "retained-gitops-missing", expectedOk: false, @@ -1000,6 +1088,55 @@ function runPacStatusFixtureChecks() { expectedCode: "pac-source-observation-inconsistent", input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([{ event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: [] }]) } }), }, + { + id: "runtime-gitops-verify-marker-missing", + expectedOk: false, + expectedCode: "pac-source-observation-inconsistent", + input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([ + { event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: ["service"] }, + { event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] }, + ]) } }), + }, + { + id: "runtime-gitops-verify-promote-marker-conflict", + expectedOk: false, + expectedCode: "pac-source-observation-inconsistent", + input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([ + { event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: ["service"] }, + { event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" }, + { event: "gitops-promote", status: "skipped", reason: "no-build-no-rollout-plan" }, + { event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" }, + { event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] }, + ]) } }), + }, + { + id: "runtime-gitops-verify-nonzero-plan-conflict", + expectedOk: false, + expectedCode: "pac-source-observation-inconsistent", + input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([ + { event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: ["service"], rolloutServices: ["service"], buildServices: [], reusedServices: ["service"] }, + { event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" }, + { event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "gitops-promote", taskRun: "promote", status: "succeeded", reason: "Succeeded", results: [] }, + ]) } }), + }, + { + id: "runtime-gitops-verify-terminal-evidence-missing", + expectedOk: false, + expectedCode: "pac-source-observation-inconsistent", + input: fixtureInput({ artifact: { sourceObservation: extractPacSourceObservation([ + { event: "g14-ci-plan", sourceCommitId: "c".repeat(40), affectedServices: [], rolloutServices: [], buildServices: [], reusedServices: ["service"] }, + { event: "collect-artifacts", status: "skipped", reason: "no-build-no-rollout-plan" }, + { event: "gitops-promote", status: "continuing", reason: "no-build-no-rollout-plan-gitops-verify" }, + { event: "pac-task-terminal", pipelineTask: "plan-artifacts", taskRun: "plan", status: "succeeded", reason: "Succeeded", results: [] }, + { event: "pac-task-terminal", pipelineTask: "collect-artifacts", taskRun: "collect", status: "succeeded", reason: "Succeeded", results: [] }, + ]) } }), + }, { id: "retained-source-mismatch", expectedOk: false, diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.mjs b/scripts/native/cicd/publish-unidesk-host-gitops.mjs index 018a83d3..62efe49a 100755 --- a/scripts/native/cicd/publish-unidesk-host-gitops.mjs +++ b/scripts/native/cicd/publish-unidesk-host-gitops.mjs @@ -2,6 +2,8 @@ import { spawnSync } from "node:child_process"; import { mkdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } from "node:fs"; import { dirname, resolve } from "node:path"; +import { runtimeGitopsScriptsConfigMap } from "../hwlab/runtime-gitops-scripts-configmap.mjs"; +import { hwlabRuntimeLaneSpecForNode } from "../../src/hwlab-node-lanes.ts"; function option(name) { const index = process.argv.indexOf(name); @@ -43,6 +45,49 @@ function safeReleaseStatePath(value) { return path; } +function safeResourceManifestPath(value, pathLabel) { + const path = required(value, pathLabel); + if (path.startsWith("/") || path.split("/").includes("..") || !path.endsWith(".yaml")) throw new Error(`${pathLabel} must be a safe relative .yaml path`); + return path; +} + +export function renderGitOpsResources(gitops, sourceRoot) { + if (gitops.resources === undefined) return []; + if (!Array.isArray(gitops.resources)) throw new Error("delivery.gitops.resources must be an array"); + return gitops.resources.map((value, index) => { + const pathLabel = `delivery.gitops.resources[${index}]`; + const resource = record(value, pathLabel); + const renderer = required(resource.renderer, `${pathLabel}.renderer`); + if (renderer !== "hwlab-runtime-gitops-scripts") throw new Error(`${pathLabel}.renderer is unsupported: ${renderer}`); + const configRef = required(resource.configRef, `${pathLabel}.configRef`); + const match = /^config\/hwlab-node-lanes\.yaml#lanes\.([^.]+)\.targets\.([^.]+)$/u.exec(configRef); + if (match === null) throw new Error(`${pathLabel}.configRef must select config/hwlab-node-lanes.yaml#lanes..targets.`); + const lane = match[1]; + const node = match[2]; + const spec = hwlabRuntimeLaneSpecForNode(lane, node); + const overlay = { + nodeId: spec.nodeId, + lane: spec.lane, + runtimePath: spec.runtimePath, + observability: spec.observability, + }; + const configMapName = `${spec.pipeline}-runtime-gitops-scripts`; + const manifest = Bun.YAML.stringify(runtimeGitopsScriptsConfigMap({ + overlay, + scriptsDir: resolve(sourceRoot, "scripts/native/hwlab"), + featureConfigValidatorPath: resolve(sourceRoot, "scripts/native/cicd/feature-config-schema-warning.mjs"), + ajvBundlePath: resolve(sourceRoot, "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), + namespace: required(resource.namespace, `${pathLabel}.namespace`), + configMapName, + })); + return { + id: required(resource.id, `${pathLabel}.id`), + path: safeResourceManifestPath(resource.manifestPath, `${pathLabel}.manifestPath`), + content: `${manifest.trimEnd()}\n`, + }; + }); +} + function releaseValue(releaseDir, name) { return required(readFileSync(resolve(releaseDir, name), "utf8").trim(), `${releaseDir}/${name}`); } @@ -100,6 +145,7 @@ const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl"); const branch = required(gitops.branch, "delivery.gitops.branch"); const manifestPath = safeManifestPath(gitops.manifestPath); const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath); +const gitopsResources = action === "skip" ? [] : renderGitOpsResources(gitops, sourceRoot); let digest = null; let digestRef = null; let manifest = null; @@ -146,6 +192,26 @@ if (action === "skip") { digest = existingState.digest; digestRef = existingState.digestRef; runtimeSourceCommit = existingState.sourceCommit; + const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim(); + process.stdout.write(`${JSON.stringify({ + ok: true, + phase: "gitops-publish", + action, + reason, + status: "skipped", + imageStatus: "skipped", + sourceCommit, + baselineSourceCommit, + runtimeSourceCommit, + digest, + digestRef, + gitopsCommit, + resources: [], + changed: false, + pushAttempts: 0, + valuesPrinted: false, + })}\n`); + return; } else if (action === "build" && manifest !== null) { mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, manifest, "utf8"); @@ -163,12 +229,23 @@ if (action === "skip") { removeFile(statePath); } +for (const resource of gitopsResources) { + const resourcePath = resolve(worktree, resource.path); + if (!resourcePath.startsWith(`${worktree}/`)) throw new Error(`resolved resource path escaped the GitOps worktree: ${resource.id}`); + if (enabled) { + mkdirSync(dirname(resourcePath), { recursive: true }); + writeFileSync(resourcePath, resource.content, "utf8"); + } else { + removeFile(resourcePath); + } +} + run("git", ["config", "user.name", required(author.name, "delivery.gitops.author.name")], worktree); run("git", ["config", "user.email", required(author.email, "delivery.gitops.author.email")], worktree); run("git", ["add", "-A"], worktree); const changed = run("git", ["diff", "--cached", "--quiet"], worktree, true).status !== 0; if (changed) { - const commitAction = action === "build" ? "deploy" : "remove"; + const commitAction = action === "disabled" ? "remove" : "deploy"; run("git", ["commit", "-m", `unidesk-host: ${commitAction} ${serviceRef} ${sourceCommit.slice(0, 12)}`], worktree); } @@ -208,10 +285,11 @@ process.stdout.write(`${JSON.stringify({ digest, digestRef, gitopsCommit, + resources: gitopsResources.map((resource) => ({ id: resource.id, path: resource.path })), changed, pushAttempts, valuesPrinted: false, })}\n`); } -if (!process.execArgv.includes("--check")) main(); +if (import.meta.main && !process.execArgv.includes("--check")) main(); diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.test.ts b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts new file mode 100644 index 00000000..98c66a9d --- /dev/null +++ b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { rootPath } from "../../src/config"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("UniDesk Host GitOps publisher", () => { + test("skip preserves the GitOps branch and every managed resource", () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-host-gitops-skip-")); + temporaryRoots.push(root); + const seed = join(root, "seed"); + const bare = join(root, "gitops.git"); + const releaseDir = join(root, "release"); + const publisherWorktree = join(root, "publisher"); + const configPath = join(root, "config.yaml"); + const statePath = "deploy/gitops-state/unidesk-host/todo-note.json"; + const resourcePath = "deploy/gitops/unidesk-host/hwlab-runtime-gitops-scripts.yaml"; + const runtimeSourceCommit = "a".repeat(40); + const sourceCommit = "b".repeat(40); + const digest = `sha256:${"c".repeat(64)}`; + + mkdirSync(seed); + git(["init", "-b", "unidesk-host-gitops"], seed); + identity(seed); + write(seed, "deploy/gitops/unidesk-host/todo-note.yaml", "existing service manifest\n"); + write(seed, resourcePath, "stale configmap must remain unchanged\n"); + write(seed, statePath, `${JSON.stringify({ + version: 1, + kind: "UniDeskHostReleaseState", + serviceRef: "services.todoNote", + sourceCommit: runtimeSourceCommit, + digest, + digestRef: `registry.example/todo-note@${digest}`, + }, null, 2)}\n`); + git(["add", "."], seed); + git(["commit", "-m", "seed gitops"], seed); + const originalHead = git(["rev-parse", "HEAD"], seed).stdout.trim(); + git(["init", "--bare", bare], root); + git(["remote", "add", "origin", `file://${bare}`], seed); + git(["push", "origin", "unidesk-host-gitops"], seed); + + mkdirSync(releaseDir); + writeFileSync(join(releaseDir, "action"), "skip\n"); + writeFileSync(join(releaseDir, "reason"), "runtime-inputs-unchanged\n"); + writeFileSync(join(releaseDir, "baseline-source-commit"), `${runtimeSourceCommit}\n`); + writeFileSync(configPath, Bun.YAML.stringify({ + delivery: { + enabled: true, + serviceRef: "services.todoNote", + image: { repository: "registry.example/todo-note" }, + gitops: { + readUrl: `file://${bare}`, + writeUrl: `file://${bare}`, + branch: "unidesk-host-gitops", + manifestPath: "deploy/gitops/unidesk-host/todo-note.yaml", + releaseStatePath: statePath, + resources: [{ + id: "hwlab-runtime-gitops-scripts", + renderer: "hwlab-runtime-gitops-scripts", + configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01", + manifestPath: resourcePath, + namespace: "hwlab-ci", + }], + author: { name: "UniDesk Test", email: "unidesk-test@example.invalid" }, + }, + }, + })); + + const result = spawnSync("bun", [ + "scripts/native/cicd/publish-unidesk-host-gitops.mjs", + "--config", configPath, + "--source-root", rootPath(), + "--metadata", join(root, "unused-metadata.json"), + "--release-dir", releaseDir, + "--source-commit", sourceCommit, + "--worktree", publisherWorktree, + ], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 }); + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout) as Record; + expect(output).toMatchObject({ action: "skip", status: "skipped", changed: false, pushAttempts: 0, resources: [] }); + expect(output.gitopsCommit).toBe(originalHead); + expect(git(["rev-parse", "refs/heads/unidesk-host-gitops"], bare).stdout.trim()).toBe(originalHead); + expect(show(bare, resourcePath)).toBe("stale configmap must remain unchanged\n"); + expect(JSON.parse(show(bare, statePath)).sourceCommit).toBe(runtimeSourceCommit); + }); +}); + +function write(root: string, relativePath: string, content: string) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function identity(root: string) { + git(["config", "user.name", "UniDesk Test"], root); + git(["config", "user.email", "unidesk-test@example.invalid"], root); +} + +function show(bare: string, relativePath: string): string { + return git(["show", `refs/heads/unidesk-host-gitops:${relativePath}`], bare).stdout; +} + +function git(args: string[], cwd: string) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + return result; +} diff --git a/scripts/native/cicd/render-sentinel-publish-task.test.ts b/scripts/native/cicd/render-sentinel-publish-task.test.ts index cd562c4c..aa55534d 100644 --- a/scripts/native/cicd/render-sentinel-publish-task.test.ts +++ b/scripts/native/cicd/render-sentinel-publish-task.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { rootPath } from "../../src/config"; -import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config"; +import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig, selectCicdOtelRepository } from "./otel-runtime-config"; const roots: string[] = []; @@ -12,7 +12,7 @@ afterEach(() => { }); test("unexpanded Repository inputs fall back to materialized owning YAML", () => { - const config = loadCicdOtelRuntimeConfig("NC01", "v03", { + const config = loadCicdOtelRuntimeConfig("sentinel-nc01-v03", { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}", OTEL_SERVICE_NAME: "{{otel_service_name}}", OTEL_TRACES_SAMPLER_ARG: "{{otel_sampling_ratio}}", @@ -34,7 +34,7 @@ test("unexpanded Repository inputs fall back to materialized owning YAML", () => }); test("valid but mismatched runtime inputs cannot override owning YAML", () => { - const config = loadCicdOtelRuntimeConfig("NC01", "v03", { + const config = loadCicdOtelRuntimeConfig("sentinel-nc01-v03", { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://collector.example.invalid:4318/v1/traces", OTEL_SERVICE_NAME: "different-service", OTEL_TRACES_SAMPLER_ARG: "0.5", @@ -53,7 +53,7 @@ test("valid but mismatched runtime inputs cannot override owning YAML", () => { }); test("legal owning endpoint and non-blocking exporter failure share the bounded helper", async () => { - const config = loadCicdOtelRuntimeConfig("NC01", "v03", { + const config = loadCicdOtelRuntimeConfig("sentinel-nc01-v03", { OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces", OTEL_SERVICE_NAME: "unidesk-cicd-sentinel-nc01", OTEL_TRACES_SAMPLER_ARG: "1", @@ -81,6 +81,14 @@ test("legal owning endpoint and non-blocking exporter failure share the bounded }); }); +test("repository identity selection fails closed on zero or multiple exact matches", () => { + expect(() => selectCicdOtelRepository([], "sentinel-nc01-v03")).toThrow("matched 0"); + expect(() => selectCicdOtelRepository([ + { id: "sentinel-nc01-v03", params: {} }, + { id: "sentinel-nc01-v03", params: {} }, + ], "sentinel-nc01-v03")).toThrow("matched 2"); +}); + test("actual renderer replaces unexpanded inputs without changing business exit", async () => { const result = await render({ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: "{{otel_traces_endpoint}}" }, false); expect(result.exitCode).toBe(0); @@ -89,6 +97,12 @@ test("actual renderer replaces unexpanded inputs without changing business exit" expect(result.stderr).toContain('"endpointHostname":"otel-collector.platform-infra.svc.cluster.local"'); expect(result.stderr).not.toContain("{{otel_traces_endpoint}}"); expect(readFileSync(join(result.outputDir, "source.sh"), "utf8")).toContain("http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces"); + const publish = readFileSync(join(result.outputDir, "publish.sh"), "utf8"); + expect(publish).toContain("UNIDESK_PAC_CONSUMER='sentinel-nc01-v03'"); + expect(publish).toContain("feature-config-schema-validation"); + expect(publish).toContain("cicd.feature_config.schema"); + expect(publish).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'"); + expect(publish).toContain("feature-config-validator-process-failure"); }); async function render(env: Record, traced = true) { @@ -100,6 +114,7 @@ async function render(env: Record, traced = true) { "scripts/native/cicd/render-sentinel-publish-task.ts", "--node", "NC01", "--lane", "v03", + "--repository-id", "sentinel-nc01-v03", "--sentinel", "nc01-web-probe-sentinel", "--pipeline-run", "hwlab-web-probe-sentinel-nc01-test", "--source-commit", "a".repeat(40), diff --git a/scripts/native/cicd/render-sentinel-publish-task.ts b/scripts/native/cicd/render-sentinel-publish-task.ts index 284f6bb0..87f4c8c8 100644 --- a/scripts/native/cicd/render-sentinel-publish-task.ts +++ b/scripts/native/cicd/render-sentinel-publish-task.ts @@ -9,6 +9,7 @@ import { sentinelPublishSourceShell, } from "../../src/hwlab-node-web-sentinel-cicd-jobs"; import { exportCicdOtelPayload, loadCicdOtelRuntimeConfig } from "./otel-runtime-config"; +import { renderPacFeatureConfigSchemaStage } from "../../src/pac-feature-config-schema-stage"; const options = parseOptions(process.argv.slice(2)); const sourceAuthority = options.sourceAuthority === "gitea-snapshot" ? "gitea-snapshot" : fail("--source-authority must be gitea-snapshot"); @@ -26,7 +27,7 @@ const state = loadSentinelCicdState( ); const outputDir = resolve(options.outputDir); mkdirSync(outputDir, { recursive: true }); -const otelConfig = loadCicdOtelRuntimeConfig(options.node, options.lane); +const otelConfig = loadCicdOtelRuntimeConfig(options.repositoryId); const otelSetup = [ `export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${shellQuote(otelConfig.endpoint)}`, `export OTEL_EXPORTER_TIMEOUT_MS=${shellQuote(String(otelConfig.timeoutMs))}`, @@ -38,6 +39,12 @@ const traceSetup = [ "if [ -f /workspace/meta/tracestate ]; then TRACESTATE=$(cat /workspace/meta/tracestate); export TRACESTATE; fi", ].join("\n"); const proxySetup = sentinelImageBuildProxyEnv(state).map(({ name, value }) => `export ${name}=${shellQuote(value)}`).join("\n"); +const featureConfigSchemaStage = renderPacFeatureConfigSchemaStage({ + repositoryId: options.repositoryId, + consumer: options.repositoryId, + repoDirExpression: "'/workspace/source'", + renderedRootExpression: '"$gitops_worktree"', +}); writeExecutable("source.sh", [ "#!/bin/sh", "set -eu", @@ -49,7 +56,7 @@ writeExecutable("source.sh", [ "if [ -n \"$incoming_tracestate\" ]; then printf '%s' \"$incoming_tracestate\" > /workspace/meta/tracestate; fi", ].join("\n")); writeExecutable("image-build.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishImageBuildShell(state, options.pipelineRun)].join("\n")); -writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true)].join("\n")); +writeExecutable("publish.sh", ["#!/bin/sh", "set -eu", otelSetup, traceSetup, proxySetup, sentinelPublishShell(state, options.pipelineRun, true, featureConfigSchemaStage)].join("\n")); emitConfigFallbackWarning(); await emitOuterSpan(); process.stdout.write(`${JSON.stringify({ ok: true, action: "render-sentinel-publish-task", node: options.node, lane: options.lane, sentinel: options.sentinel, pipelineRun: options.pipelineRun, sourceCommit: options.sourceCommit, outputDir, traceId: traceId(process.env.TRACEPARENT), valuesRedacted: true })}\n`); @@ -60,7 +67,7 @@ function writeExecutable(name: string, content: string): void { chmodSync(path, 0o755); } -function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pipelineRun" | "sourceCommit" | "sourceStageRef" | "sourceAuthority" | "outputDir", string> { +function parseOptions(args: string[]): Record<"node" | "lane" | "repositoryId" | "sentinel" | "pipelineRun" | "sourceCommit" | "sourceStageRef" | "sourceAuthority" | "outputDir", string> { const values = new Map(); for (let index = 0; index < args.length; index += 2) { const key = args[index]; @@ -72,6 +79,7 @@ function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pi const result = { node: required("node"), lane: required("lane"), + repositoryId: required("repository-id"), sentinel: required("sentinel"), pipelineRun: required("pipeline-run"), sourceCommit: required("source-commit"), @@ -80,7 +88,7 @@ function parseOptions(args: string[]): Record<"node" | "lane" | "sentinel" | "pi outputDir: required("output-dir"), }; if (!/^[0-9a-f]{40}$/u.test(result.sourceCommit)) fail("--source-commit must be a 40-character lowercase Git commit"); - for (const key of ["node", "lane", "sentinel", "pipelineRun"] as const) if (!/^[A-Za-z0-9._-]+$/u.test(result[key])) fail(`--${key} must be a simple id`); + for (const key of ["node", "lane", "repositoryId", "sentinel", "pipelineRun"] as const) if (!/^[A-Za-z0-9._-]+$/u.test(result[key])) fail(`--${key.replace(/[A-Z]/gu, (letter) => `-${letter.toLowerCase()}`)} must be a simple id`); if (!result.sourceStageRef.startsWith("refs/unidesk/snapshots/")) fail("--source-stage-ref must be an immutable UniDesk snapshot ref"); return result; } diff --git a/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs b/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs index 77e0372e..2bad4c9a 100644 --- a/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs +++ b/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs @@ -4,6 +4,7 @@ import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; +import { runtimeGitopsScriptsConfigMap } from "./runtime-gitops-scripts-configmap.mjs"; const requireFromScript = createRequire(import.meta.url); const requireFromCwd = createRequire(path.join(process.cwd(), "package.json")); @@ -53,10 +54,16 @@ function injectRuntimeGitopsGuard(pipeline) { if (!step) throw new Error("runtime GitOps guard injection failed: gitops-promote render step missing"); const originalScript = String(step.script); - let script = patchNoBuildNoRolloutSkip(originalScript); - const noBuildSkipPatched = script !== originalScript; + let script = patchNoBuildNoRolloutCondition(originalScript); + const noBuildConditionPatched = script !== originalScript; + const beforeSkipPatch = script; + script = patchNoBuildNoRolloutSkip(script); + const noBuildSkipPatched = script !== beforeSkipPatch; const beforeInjection = script; script = injectRuntimeGitopsCommands(script); + if (hasWillRunGitopsPromoteCoupling(script)) { + throw new Error("runtime GitOps guard injection failed: no-build-no-rollout marker remains coupled to willRunGitopsPromote"); + } if (hasNoBuildNoRolloutEarlyExit(script)) { throw new Error("runtime GitOps guard injection failed: no-build-no-rollout-plan early exit remains after patch"); } @@ -69,12 +76,20 @@ function injectRuntimeGitopsGuard(pipeline) { present: true, configMapName, stepName: typeof step.name === "string" ? step.name : null, + noBuildConditionPatched, noBuildSkipPatched, postprocessInjected: !hasPostprocess(beforeInjection) && postprocessPresent, verifyInjected: !hasVerify(beforeInjection) && verifyPresent, }; } +function patchNoBuildNoRolloutCondition(script) { + return String(script).replace( + /const willRunGitopsPromote = plan\.ciCdPlan && plan\.ciCdPlan\.willRunGitopsPromote === true;\nprocess\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u, + "process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);", + ); +} + function patchNoBuildNoRolloutSkip(script) { return String(script).replace( /(^|\n)([ \t]*)echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u, @@ -82,19 +97,21 @@ function patchNoBuildNoRolloutSkip(script) { ); } +function hasWillRunGitopsPromoteCoupling(script) { + return /process\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u.test(String(script)); +} + function hasNoBuildNoRolloutEarlyExit(script) { return /echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u.test(String(script)); } function injectRuntimeGitopsCommands(script) { - if (hasPostprocess(script) && hasVerify(script)) return script; - const overlayEnv = `UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=${shellSingle(Buffer.from(JSON.stringify({ - runtimePath: overlay.runtimePath, - observability: overlay.observability, - }), "utf8").toString("base64"))}`; + const overlayEnv = "UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json"; + const migratedScript = String(script).replace(/UNIDESK_RUNTIME_GITOPS_OVERLAY_B64='[^'\n]*'/gu, overlayEnv); + if (hasPostprocess(migratedScript) && hasVerify(migratedScript)) return migratedScript; const postprocess = `${overlayEnv} node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-postprocess.mjs`; const verify = `${overlayEnv} node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-verify.mjs`; - return String(script).replace( + return migratedScript.replace( /(node scripts\/run-bun\.mjs scripts\/gitops-render\.mjs[^\n]*--use-deploy-images[^\n]*)/g, (match) => { if (match.includes("--check")) return hasVerify(script) ? match : verify; @@ -127,26 +144,9 @@ function ensureRuntimeGitopsScriptsMount(taskSpec, step) { function writeScriptsConfigMap(targetPath, namespace) { if (scriptsDir === null) throw new Error("--scripts-dir is required with --scripts-configmap"); - const data = {}; - for (const name of ["runtime-gitops-observability.mjs", "runtime-gitops-postprocess.mjs", "runtime-gitops-verify.mjs"]) { - data[name] = readFileSync(path.join(scriptsDir, name), "utf8"); - } - writeFileSync(targetPath, `${YAML.stringify({ - apiVersion: "v1", - kind: "ConfigMap", - metadata: { - name: configMapName, - namespace, - labels: { - "app.kubernetes.io/name": "hwlab-runtime-gitops-scripts", - "app.kubernetes.io/part-of": "unidesk-hwlab-control-plane", - "hwlab.pikastech.local/node": overlay.nodeId ?? null, - "hwlab.pikastech.local/lane": overlay.lane ?? null, - }, - }, - data, - }).trimEnd()}\n`, "utf8"); - return { name: configMapName, namespace, keyCount: Object.keys(data).length, path: targetPath }; + const configMap = runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, namespace, configMapName }); + writeFileSync(targetPath, `${YAML.stringify(configMap).trimEnd()}\n`, "utf8"); + return { name: configMapName, namespace, keyCount: Object.keys(configMap.data).length, path: targetPath }; } function pipelineNamespace(docs) { @@ -193,10 +193,6 @@ function objectOr(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } -function shellSingle(value) { - return `'${String(value).replaceAll("'", "'\\''")}'`; -} - function requireYaml() { try { return requireFromScript("yaml"); diff --git a/scripts/native/hwlab/runtime-gitops-postprocess.mjs b/scripts/native/hwlab/runtime-gitops-postprocess.mjs index f38ba2ee..604035d7 100644 --- a/scripts/native/hwlab/runtime-gitops-postprocess.mjs +++ b/scripts/native/hwlab/runtime-gitops-postprocess.mjs @@ -2,8 +2,12 @@ // SPEC: PJ2026-01060703 CI/CD branch follower runtime GitOps postprocess. // Responsibility: mutate rendered runtime GitOps files after HWLAB source render, before publish. import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; +const requireFromScript = createRequire(import.meta.url); +const requireFromCwd = createRequire(path.join(process.cwd(), "package.json")); +const YAML = requireYaml(); const repoDir = process.cwd(); const overlay = readOverlay(); const runtimePath = requiredOverlayString("runtimePath"); @@ -20,26 +24,106 @@ emit({ ok: true, runtimePath, ...result }); function postprocessRuntimeGitops() { const prometheusOperatorDisabled = overlay?.observability?.prometheusOperator === false; - if (!prometheusOperatorDisabled) { - return { observabilityPrometheusOperator: overlay?.observability?.prometheusOperator ?? null, observabilityWorkloadsChanged: false, kustomizationChanged: false }; - } const changedFiles = []; const deletedFiles = []; for (const file of listYamlFiles(runtimeDir)) { - const changed = stripPrometheusOperatorResourcesFromFile(file); + const changed = prometheusOperatorDisabled ? stripPrometheusOperatorResourcesFromFile(file) : "unchanged"; if (changed === "deleted") deletedFiles.push(path.relative(repoDir, file)); if (changed === "changed") changedFiles.push(path.relative(repoDir, file)); } - const kustomizationChanged = pruneMissingKustomizationResources(); + const kustomizationChanged = prometheusOperatorDisabled ? pruneMissingKustomizationResources() : false; + const codeAgentRuntimeChanged = patchCodeAgentRuntimeWorkloads(); return { - observabilityPrometheusOperator: false, + observabilityPrometheusOperator: overlay?.observability?.prometheusOperator ?? null, observabilityWorkloadsChanged: changedFiles.length > 0 || deletedFiles.length > 0, kustomizationChanged, + codeAgentRuntimeChanged, changedFiles, deletedFiles, }; } +function patchCodeAgentRuntimeWorkloads() { + const runtime = overlay?.codeAgentRuntime; + if (!runtime?.enabled || !runtime.kafkaEventBridge) return false; + const kafka = runtime.kafkaEventBridge; + let changed = false; + for (const file of listYamlFiles(runtimeDir)) { + const documents = parseStructuredDocuments(readFileSync(file, "utf8"), file); + let fileChanged = false; + for (const document of documents.values) { + const items = isKubernetesList(document) ? document.items : [document]; + for (const item of items) { + const workloadName = String(item?.metadata?.labels?.["app.kubernetes.io/name"] ?? item?.metadata?.name ?? ""); + const containers = item?.spec?.template?.spec?.containers; + if (!Array.isArray(containers)) continue; + for (const container of containers) { + if (!Array.isArray(container?.env)) continue; + if (workloadName === "hwlab-cloud-api" && container.name === "hwlab-cloud-api") { + fileChanged = patchCloudApiKafkaEnv(container, kafka) || fileChanged; + } + } + } + } + if (fileChanged) { + writeFileSync(file, serializeStructuredDocuments(documents), "utf8"); + changed = true; + } + } + return changed; +} + +function patchCloudApiKafkaEnv(container, kafka) { + let changed = false; + changed = setEnvValue(container, "HWLAB_KAFKA_ENABLED", String(kafka.enabled)) || changed; + changed = setEnvValue(container, "HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafka.enabled)) || changed; + changed = setEnvValue(container, "HWLAB_KAFKA_PROJECTOR_GROUP_ID", String(kafka.transactionalProjectorConsumerGroupId)) || changed; + changed = setEnvValue(container, "HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", String(kafka.hwlabEventConsumerGroupId)) || changed; + changed = patchOptionalEnvGroup(container, kafka.enabled, { + HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS: kafka.transactionalProjector?.heartbeatIntervalMs, + }) || changed; + changed = patchOptionalEnvGroup(container, kafka.enabled, { + HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS: kafka.projectionOutboxRelay?.intervalMs, + HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE: kafka.projectionOutboxRelay?.batchSize, + HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS: kafka.projectionOutboxRelay?.leaseMs, + HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS: kafka.projectionOutboxRelay?.sendTimeoutMs, + HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS: kafka.projectionOutboxRelay?.retryBackoffMs, + }) || changed; + changed = patchOptionalEnvGroup(container, kafka.enabled, { + HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE: kafka.projectionRealtime?.outboxTailBatchSize, + HWLAB_WORKBENCH_SSE_HEARTBEAT_MS: kafka.projectionRealtime?.sseHeartbeatMs, + HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS: kafka.projectionRealtime?.sseDrainTimeoutMs, + }) || changed; + return changed; +} + +function patchOptionalEnvGroup(container, enabled, values) { + let changed = false; + for (const [name, value] of Object.entries(values)) { + changed = enabled ? setEnvValue(container, name, String(value)) || changed : removeEnvValue(container, name) || changed; + } + return changed; +} + +function setEnvValue(container, name, value) { + const existing = container.env.find((item) => item?.name === name); + if (existing?.value === value && existing.valueFrom === undefined) return false; + if (existing) { + existing.value = value; + delete existing.valueFrom; + } else { + container.env.push({ name, value }); + } + return true; +} + +function removeEnvValue(container, name) { + const next = container.env.filter((item) => item?.name !== name); + if (next.length === container.env.length) return false; + container.env = next; + return true; +} + function stripPrometheusOperatorResourcesFromFile(file) { const original = readFileSync(file, "utf8"); const jsonResult = stripPrometheusOperatorResourcesFromJsonFile(file, original); @@ -146,6 +230,20 @@ function parseJsonDocument(text) { } } +function parseStructuredDocuments(text, file) { + const json = parseJsonDocument(text); + if (json !== null) return { format: "json", values: [json] }; + const documents = YAML.parseAllDocuments(text); + const errors = documents.flatMap((document) => document.errors); + if (errors.length > 0) throw new Error(`invalid YAML in ${file}: ${errors.map((error) => error.message).join("; ")}`); + return { format: "yaml", values: documents.map((document) => document.toJS()).filter((document) => document !== null) }; +} + +function serializeStructuredDocuments(documents) { + if (documents.format === "json") return `${JSON.stringify(documents.values[0], null, 2)}\n`; + return `${documents.values.map((document) => YAML.stringify(document).trimEnd()).join("\n---\n")}\n`; +} + function leadingSpaces(value) { const match = value.match(/^ */u); return match ? match[0].length : 0; @@ -166,11 +264,21 @@ function listYamlFiles(root) { } function readOverlay() { + const file = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE; + if (file) return JSON.parse(readFileSync(file, "utf8")); const encoded = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_B64; - if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required"); + if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE or UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required"); return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); } +function requireYaml() { + try { + return requireFromScript("yaml"); + } catch { + return requireFromCwd("yaml"); + } +} + function requiredOverlayString(name) { const value = overlay[name]; if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`); diff --git a/scripts/native/hwlab/runtime-gitops-postprocess.test.ts b/scripts/native/hwlab/runtime-gitops-postprocess.test.ts new file mode 100644 index 00000000..d1c36199 --- /dev/null +++ b/scripts/native/hwlab/runtime-gitops-postprocess.test.ts @@ -0,0 +1,110 @@ +import { afterEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const roots: string[] = []; +const script = resolve(import.meta.dir, "runtime-gitops-postprocess.mjs"); + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +test("patches fixed Kafka transport settings without architecture capability env", () => { + const root = mkdtempSync(join(tmpdir(), "runtime-gitops-postprocess-")); + roots.push(root); + const runtimeDir = join(root, "runtime"); + mkdirSync(runtimeDir); + writeFileSync(join(root, "overlay.json"), JSON.stringify(overlay("runtime"))); + writeFileSync(join(runtimeDir, "cloud-api.yaml"), deploymentYaml("hwlab-cloud-api")); + writeFileSync(join(runtimeDir, "cloud-web.yaml"), `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: unchanged\n---\n${deploymentYaml("hwlab-cloud-web")}`); + writeFileSync(join(runtimeDir, "list.yaml"), `apiVersion: v1\nkind: List\nitems:\n${listItem(deploymentYaml("hwlab-cloud-api"))}\n`); + writeFileSync(join(runtimeDir, "json-workload.yaml"), `${JSON.stringify(Bun.YAML.parse(deploymentYaml("hwlab-cloud-api")), null, 2)}\n`); + + const result = spawnSync(process.execPath, [script], { + cwd: root, + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") }, + encoding: "utf8", + }); + + if (result.status !== 0) throw new Error(result.stderr); + expect(result.stderr).toContain('"codeAgentRuntimeChanged":true'); + + const api = Bun.YAML.parse(readFileSync(join(runtimeDir, "cloud-api.yaml"), "utf8")) as any; + expect(envValues(api)).toMatchObject({ + HWLAB_KAFKA_ENABLED: "true", + HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED: "true", + HWLAB_KAFKA_PROJECTOR_GROUP_ID: "hwlab-projector", + HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID: "hwlab-events", + }); + expect(architectureEnvNames(api)).toEqual([]); + + const multiDocs = readFileSync(join(runtimeDir, "cloud-web.yaml"), "utf8").split(/^---$/mu).map((document) => Bun.YAML.parse(document) as any); + expect(multiDocs[0].kind).toBe("ConfigMap"); + expect(envValues(multiDocs[1])).toEqual({ EXISTING: "preserved" }); + + const list = Bun.YAML.parse(readFileSync(join(runtimeDir, "list.yaml"), "utf8")) as any; + expect(list.kind).toBe("List"); + expect(architectureEnvNames(list.items[0])).toEqual([]); + + const jsonText = readFileSync(join(runtimeDir, "json-workload.yaml"), "utf8"); + expect(jsonText.startsWith("{\n")).toBe(true); + expect(architectureEnvNames(JSON.parse(jsonText))).toEqual([]); + + const afterFirstRun = snapshot(runtimeDir); + const second = spawnSync(process.execPath, [script], { + cwd: root, + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") }, + encoding: "utf8", + }); + if (second.status !== 0) throw new Error(second.stderr); + expect(second.stderr).toContain('"codeAgentRuntimeChanged":false'); + expect(snapshot(runtimeDir)).toEqual(afterFirstRun); +}); + +function overlay(runtimePath: string) { + return { + runtimePath, + codeAgentRuntime: { + enabled: true, + kafkaEventBridge: { + enabled: true, + transactionalProjectorConsumerGroupId: "hwlab-projector", + hwlabEventConsumerGroupId: "hwlab-events", + transactionalProjector: { heartbeatIntervalMs: 2000 }, + projectionOutboxRelay: { intervalMs: 250, batchSize: 100, leaseMs: 30000, sendTimeoutMs: 10000, retryBackoffMs: 1000 }, + projectionRealtime: { outboxTailBatchSize: 100, sseHeartbeatMs: 1000, sseDrainTimeoutMs: 5000 }, + }, + }, + }; +} + +function deploymentYaml(name: string) { + return `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${name}\n labels:\n app.kubernetes.io/name: ${name}\nspec:\n template:\n spec:\n containers:\n - name: ${name}\n env:\n - name: EXISTING\n value: preserved\n`; +} + +function listItem(value: string) { + const [first, ...rest] = value.trimEnd().split("\n"); + return [` - ${first}`, ...rest.map((line) => ` ${line}`)].join("\n"); +} + +function envValues(workload: any) { + return Object.fromEntries(workload.spec.template.spec.containers[0].env.map((item: any) => [item.name, item.value])); +} + +function architectureEnvNames(workload: any) { + const forbidden = new Set([ + "HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", + "HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", + "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", + "HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", + "HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", + "HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", + ]); + return workload.spec.template.spec.containers[0].env.map((item: any) => item.name).filter((name: string) => forbidden.has(name)); +} + +function snapshot(runtimeDir: string) { + return ["cloud-api.yaml", "cloud-web.yaml", "list.yaml", "json-workload.yaml"].map((name) => readFileSync(join(runtimeDir, name), "utf8")); +} diff --git a/scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs b/scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs new file mode 100644 index 00000000..2c6693e6 --- /dev/null +++ b/scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs"; +import path from "node:path"; + +export function runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, featureConfigValidatorPath = null, ajvBundlePath = null, namespace, configMapName }) { + const data = { + "runtime-gitops-overlay.json": `${JSON.stringify({ + runtimePath: requiredString(overlay.runtimePath, "overlay.runtimePath"), + observability: objectOr(overlay.observability), + codeAgentRuntime: objectOr(overlay.codeAgentRuntime), + })}\n`, + }; + for (const name of [ + "runtime-gitops-observability.mjs", + "runtime-gitops-postprocess.mjs", + "runtime-gitops-verify.mjs", + ]) { + data[name] = readFileSync(path.join(scriptsDir, name), "utf8"); + } + const featureConfigValidator = optionalFile(featureConfigValidatorPath ?? path.join(scriptsDir, "feature-config-schema-warning.mjs")); + if (featureConfigValidator !== null) data["feature-config-schema-warning.mjs"] = featureConfigValidator; + const ajvBundle = optionalFile(ajvBundlePath ?? path.join(scriptsDir, "ajv2020.min.js")); + if (ajvBundle !== null) data["ajv2020.min.js"] = ajvBundle; + return { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { + name: configMapName, + namespace, + labels: { + "app.kubernetes.io/name": "hwlab-runtime-gitops-scripts", + "app.kubernetes.io/part-of": "unidesk-hwlab-control-plane", + "app.kubernetes.io/managed-by": "unidesk-host-gitops", + "hwlab.pikastech.local/node": overlay.nodeId ?? null, + "hwlab.pikastech.local/lane": overlay.lane ?? null, + }, + }, + data, + }; +} + +function requiredString(value, pathLabel) { + if (typeof value !== "string" || value.length === 0) throw new Error(`${pathLabel} must be a non-empty string`); + return value; +} + +function objectOr(value) { + return value && typeof value === "object" && !Array.isArray(value) ? value : {}; +} + +function optionalFile(file) { + try { + return readFileSync(file, "utf8"); + } catch { + return null; + } +} diff --git a/scripts/native/hwlab/runtime-gitops-scripts-configmap.test.ts b/scripts/native/hwlab/runtime-gitops-scripts-configmap.test.ts new file mode 100644 index 00000000..21f510f0 --- /dev/null +++ b/scripts/native/hwlab/runtime-gitops-scripts-configmap.test.ts @@ -0,0 +1,72 @@ +import { afterEach, expect, test } from "bun:test"; +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { runtimeGitopsScriptsConfigMap } from "./runtime-gitops-scripts-configmap.mjs"; + +const roots: string[] = []; + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +test("preserves codeAgentRuntime in the owning runtime GitOps overlay", () => { + const scriptsDir = mkdtempSync(join(tmpdir(), "runtime-gitops-configmap-")); + roots.push(scriptsDir); + for (const name of [ + "runtime-gitops-observability.mjs", + "runtime-gitops-postprocess.mjs", + "runtime-gitops-verify.mjs", + "feature-config-schema-warning.mjs", + "ajv2020.min.js", + ]) { + writeFileSync(join(scriptsDir, name), `${name}\n`); + } + const codeAgentRuntime = { + enabled: true, + kafkaEventBridge: { + enabled: true, + transactionalProjector: { heartbeatIntervalMs: 2000 }, + projectionOutboxRelay: { intervalMs: 250 }, + projectionRealtime: { sseHeartbeatMs: 1000 }, + }, + }; + + const configMap = runtimeGitopsScriptsConfigMap({ + overlay: { runtimePath: "runtime", observability: { prometheusOperator: false }, codeAgentRuntime }, + scriptsDir, + namespace: "hwlab", + configMapName: "runtime-gitops-scripts", + }); + + expect(JSON.parse(configMap.data["runtime-gitops-overlay.json"])).toEqual({ + runtimePath: "runtime", + observability: { prometheusOperator: false }, + codeAgentRuntime, + }); + expect(configMap.data["feature-config-schema-warning.mjs"]).toBe("feature-config-schema-warning.mjs\n"); + expect(configMap.data["ajv2020.min.js"]).toBe("ajv2020.min.js\n"); +}); + +test("missing optional validator artifacts do not block ConfigMap rendering", () => { + const scriptsDir = mkdtempSync(join(tmpdir(), "runtime-gitops-configmap-")); + roots.push(scriptsDir); + for (const name of [ + "runtime-gitops-observability.mjs", + "runtime-gitops-postprocess.mjs", + "runtime-gitops-verify.mjs", + ]) { + writeFileSync(join(scriptsDir, name), `${name}\n`); + } + + const configMap = runtimeGitopsScriptsConfigMap({ + overlay: { runtimePath: "runtime" }, + scriptsDir, + namespace: "hwlab", + configMapName: "runtime-gitops-scripts", + }); + + expect(configMap.data["runtime-gitops-verify.mjs"]).toBe("runtime-gitops-verify.mjs\n"); + expect(configMap.data["feature-config-schema-warning.mjs"]).toBeUndefined(); + expect(configMap.data["ajv2020.min.js"]).toBeUndefined(); +}); diff --git a/scripts/native/hwlab/runtime-gitops-verify.mjs b/scripts/native/hwlab/runtime-gitops-verify.mjs index c79b4d91..88076b18 100644 --- a/scripts/native/hwlab/runtime-gitops-verify.mjs +++ b/scripts/native/hwlab/runtime-gitops-verify.mjs @@ -2,8 +2,12 @@ // SPEC: PJ2026-01060703 CI/CD branch follower runtime GitOps verify. // Responsibility: fail publish before Argo when rendered runtime GitOps violates UniDesk overlay gates. import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import { createRequire } from "node:module"; import path from "node:path"; +const requireFromScript = createRequire(import.meta.url); +const requireFromCwd = createRequire(path.join(process.cwd(), "package.json")); +const YAML = requireYaml(); const repoDir = process.cwd(); const overlay = readOverlay(); const runtimePath = requiredOverlayString("runtimePath"); @@ -20,6 +24,9 @@ if (overlay?.observability?.prometheusOperator === false) { const refs = findPrometheusOperatorResources(); if (refs.length > 0) fail("prometheus-operator-resource-present", { runtimePath, refs: refs.slice(0, 12), refCount: refs.length }); } +if (overlay?.codeAgentRuntime?.enabled && overlay.codeAgentRuntime.kafkaEventBridge?.enabled) { + checks.push("code-agent-runtime-kafka-event-bridge-enabled"); +} console.error(JSON.stringify({ event: "unidesk-runtime-gitops-verify", ok: true, runtimePath, checks })); @@ -87,6 +94,15 @@ function parseJsonDocument(text) { } } +function parseStructuredDocuments(text, file) { + const json = parseJsonDocument(text); + if (json !== null) return [json]; + const documents = YAML.parseAllDocuments(text); + const errors = documents.flatMap((document) => document.errors); + if (errors.length > 0) fail("runtime-yaml-invalid", { file: path.relative(repoDir, file), errors: errors.map((error) => error.message) }); + return documents.map((document) => document.toJS()).filter((document) => document !== null); +} + function listYamlFiles(root) { const out = []; for (const name of readdirSync(root)) { @@ -102,11 +118,21 @@ function listYamlFiles(root) { } function readOverlay() { + const file = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE; + if (file) return JSON.parse(readFileSync(file, "utf8")); const encoded = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_B64; - if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required"); + if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE or UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required"); return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); } +function requireYaml() { + try { + return requireFromScript("yaml"); + } catch { + return requireFromCwd("yaml"); + } +} + function requiredOverlayString(name) { const value = overlay[name]; if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`); diff --git a/scripts/native/hwlab/runtime-gitops-verify.test.ts b/scripts/native/hwlab/runtime-gitops-verify.test.ts new file mode 100644 index 00000000..3f2da472 --- /dev/null +++ b/scripts/native/hwlab/runtime-gitops-verify.test.ts @@ -0,0 +1,38 @@ +import { afterEach, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +const roots: string[] = []; +const script = resolve(import.meta.dir, "runtime-gitops-verify.mjs"); + +afterEach(() => { + while (roots.length > 0) rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +test("architecture capability env and workload matches are not runtime GitOps gates", () => { + const root = mkdtempSync(join(tmpdir(), "runtime-gitops-verify-")); + roots.push(root); + mkdirSync(join(root, "runtime")); + writeFileSync(join(root, "runtime", "unrelated.yaml"), "apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: unrelated\n"); + writeFileSync(join(root, "overlay.json"), JSON.stringify({ + runtimePath: "runtime", + codeAgentRuntime: { + enabled: true, + kafkaEventBridge: { enabled: true }, + }, + })); + + const result = spawnSync(process.execPath, [script], { + cwd: root, + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE: join(root, "overlay.json") }, + encoding: "utf8", + }); + + expect(result.status).toBe(0); + expect(result.stderr).toContain('"code-agent-runtime-kafka-event-bridge-enabled"'); + expect(result.stderr).not.toContain("capability-env-invalid"); + expect(result.stderr).not.toContain("workload-missing"); + expect(result.stderr).not.toContain("authority-invalid"); +}); diff --git a/scripts/src/agentrun-manifests.ts b/scripts/src/agentrun-manifests.ts index 05b67b80..4424a1a5 100644 --- a/scripts/src/agentrun-manifests.ts +++ b/scripts/src/agentrun-manifests.ts @@ -2,6 +2,8 @@ // Renders AgentRun YAML lane policy into runtime manager environment. import { createHash } from "node:crypto"; import type { AgentRunLaneSpec } from "./agentrun-lanes"; +import { gitRepositoryIdentity, resolveCicdDeliveryAuthority } from "./cicd-delivery-authority"; +import { renderPacFeatureConfigSchemaStage } from "./pac-feature-config-schema-stage"; import { stableJsonSha256 } from "./stable-json"; export interface AgentRunArtifactService { @@ -408,6 +410,19 @@ function agentRunTektonGitopsPublishScript(spec: AgentRunLaneSpec): string { const templateImage = agentRunImageArtifact(spec, { sourceCommit: sourcePlaceholder, envIdentity: envPlaceholder, digest: digestPlaceholder, status: statusPlaceholder }); const templateFiles = renderAgentRunGitopsFiles(spec, { sourceCommit: sourcePlaceholder, image: templateImage }); const templateB64 = Buffer.from(JSON.stringify(templateFiles), "utf8").toString("base64"); + const authority = resolveCicdDeliveryAuthority({ + node: spec.nodeId, + lane: spec.lane, + sourceRepository: gitRepositoryIdentity(spec.source.repository), + sourceBranch: spec.source.branch, + }); + if (authority.kind !== "pac-pr-merge") throw new Error(`AgentRun ${spec.nodeId}/${spec.lane} requires exact PaC consumer authority for feature config validation`); + const featureConfigSchemaStage = renderPacFeatureConfigSchemaStage({ + repositoryId: authority.consumer.repositoryRef, + consumer: authority.consumer.consumerId, + repoDirExpression: '"$root"', + renderedRootExpression: '"$PWD/$(params.gitops-root)"', + }); return [ "#!/bin/sh", "set -eu", @@ -446,6 +461,7 @@ function agentRunTektonGitopsPublishScript(spec: AgentRunLaneSpec): string { " writeFileSync(file.path, content);", "}", "NODE", + featureConfigSchemaStage, "git add source.json \"$(params.artifact-catalog)\" \"$(params.gitops-root)\"", "if git diff --quiet --cached; then changed=false; else changed=true; git -c user.email=agentrun@unidesk.local -c user.name='UniDesk AgentRun PaC' commit -m \"deploy: render AgentRun $(params.gitops-branch) from PaC\"; fi", "git remote set-url origin \"$git_auth_url\"", diff --git a/scripts/src/agentrun/rest-bridge.ts b/scripts/src/agentrun/rest-bridge.ts index 596202ab..cc260486 100644 --- a/scripts/src/agentrun/rest-bridge.ts +++ b/scripts/src/agentrun/rest-bridge.ts @@ -667,7 +667,6 @@ function targetTaskPreflightFromArgs(args: string[], aipod: string): AgentRunTar mutation: false, recoveryActions: [ "修复错误后用同一条 create task 命令重新执行;失败预检不会创建 task。", - "先保留 --dry-run 查看 Target、source、workspaceRef、resourceBundleRef、session 与 SecretRef 摘要。", ], valuesPrinted: false, }, diff --git a/scripts/src/gh-token-repository-override.test.ts b/scripts/src/gh-token-repository-override.test.ts index 541b7b04..e1e3fff9 100644 --- a/scripts/src/gh-token-repository-override.test.ts +++ b/scripts/src/gh-token-repository-override.test.ts @@ -84,6 +84,22 @@ describe("GitHub repository token overrides", () => { expect(selected.probe).toMatchObject({ source: "yaml-token-source", scope: "repository-override", yamlSourcePriority: "before-env" }); }); + test("matches repository overrides without case sensitivity", () => { + delete process.env.GH_TOKEN; + delete process.env.GITHUB_TOKEN; + const authConfig = testAuthConfig(); + + for (const repo of ["PikaInc/selfmedia", "pikainc/SELFMEDIA", "PIKAINC/SELFMEDIA"]) { + const selected = resolveToken(repo, false, authConfig); + expect(selected.token).toBe("selfmedia-token"); + expect(selected.probe).toMatchObject({ scope: "repository-override", sourceRef: "selfmedia-token.txt", valuesPrinted: false }); + } + + const global = resolveToken("PikasTech/UniDesk", false, authConfig); + expect(global.token).toBe("global-token"); + expect(global.probe).toMatchObject({ scope: "global", sourceRef: "global-token.txt", valuesPrinted: false }); + }); + test("uses the final positional repository for issue and PR token selection", () => { delete process.env.GH_TOKEN; delete process.env.GITHUB_TOKEN; diff --git a/scripts/src/gh/auth-and-safety.ts b/scripts/src/gh/auth-and-safety.ts index 2082c988..b9542157 100644 --- a/scripts/src/gh/auth-and-safety.ts +++ b/scripts/src/gh/auth-and-safety.ts @@ -31,6 +31,10 @@ export interface GitHubYamlAuthConfig { repositoryOverrides: GitHubYamlTokenSourceConfig[]; } +function repositoryLookupKey(repository: string): string { + return repository.toLowerCase(); +} + export function readIssueLifecycleCommentBody(options: GitHubOptions, command: string): { body: string; bodySource: Record } | null { if (options.comment === undefined && options.commentFile === undefined) return null; if (options.comment !== undefined) { @@ -59,7 +63,10 @@ export function tokenFromEnvironment(): GitHubTokenProbe { } export function tokenFromYamlSource(repo: string, authConfig: GitHubYamlAuthConfig = readGitHubYamlAuthConfig()): { token: string | null; probe: GitHubTokenProbe } { - const override = authConfig.repositoryOverrides.find((candidate) => candidate.repository === repo) ?? null; + const repoLookupKey = repositoryLookupKey(repo); + const override = authConfig.repositoryOverrides.find((candidate) => ( + candidate.repository !== undefined && repositoryLookupKey(candidate.repository) === repoLookupKey + )) ?? null; const config = override ?? authConfig.tokenSource; if (config === null || config.enabled === false) { return { @@ -162,8 +169,9 @@ function readGitHubYamlAuthConfig(): GitHubYamlAuthConfig { const configRef = `${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF}[${index}]`; const override = asRecord(value, configRef); const repository = repositoryField(override, "repository", configRef); - if (seenRepositories.has(repository)) throw new Error(`${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF} contains duplicate repository ${repository}`); - seenRepositories.add(repository); + const repositoryKey = repositoryLookupKey(repository); + if (seenRepositories.has(repositoryKey)) throw new Error(`${GITHUB_REPOSITORY_OVERRIDES_CONFIG_REF} contains duplicate repository ${repository}`); + seenRepositories.add(repositoryKey); return { repository, priority: tokenSourcePriorityField(override, "priority", configRef), diff --git a/scripts/src/gh/help.ts b/scripts/src/gh/help.ts index 88fba10f..daa53fe3 100644 --- a/scripts/src/gh/help.ts +++ b/scripts/src/gh/help.ts @@ -100,14 +100,14 @@ export function ghHelp(): unknown { "Commander brief ClaudeQQ defaults to private target 645275593 through backend-core /api/microservices/claudeqq/proxy; UNIDESK_COMMANDER_BRIEF_CLAUDEQQ_* env vars can override target, base URL, timeout, and enabled state.", "comment update/edit PATCHes /repos/{owner}/{repo}/issues/comments/{comment_id} and preserves the comment id/timeline; comment delete is supported because GitHub supports deleting issue comments, but routine wording fixes should use update/edit. issue/pr hard delete is unsupported and close is the lifecycle alternative.", "PR files is the canonical compact changed-file/stat summary. It uses GitHub REST, returns bounded file rows, additions/deletions/changes when available, truncation metadata, and a next command for full details. Raw diff patches are not emitted by default; gh pr diff --stat is a compatibility alias for the same JSON summary.", - "PR review-plan is the review-first bounded patch index. It uses GitHub REST PR files, prints changed files with additions/deletions/hunk counts/patch-line counts/default truncation flags, and emits one per-file gh pr diff --file drill-down command without creating a local worktree.", + "PR review-plan is the review-first bounded patch index. It uses GitHub REST PR files, prints changed files with additions/deletions/hunk counts/patch-line counts/default truncation flags, and emits per-file gh pr diff --file drill-down commands without creating a local worktree. Use only the drill-downs needed for review; do not mechanically expand every file.", "PR diff --file reads one changed file patch from GitHub REST and prints only a bounded excerpt by default. Add --hunk N to inspect one hunk, --limit N to change displayed patch lines, or --full/--raw for explicit structured full-patch disclosure. --stat remains the no-patch file/stat compatibility path.", "PR edit/update PATCHes /repos/{owner}/{repo}/pulls/{number} through REST only, never GitHub Projects Classic GraphQL/projectCards, and returns low-noise JSON with repo, PR number, changedFields, url, and body size/SHA metadata instead of echoing the full body.", "PR view is the canonical bounded metadata summary; read remains a UniDesk compatibility alias. Human full-body reading uses `trans gh:/owner/repo/pr/ cat`, and targeted lookup uses the same route with `rg `. `--json body`, `--full`, and `--raw` are explicit structured machine disclosure only. View/read retain positional numbers, GitHub URLs, owner/repo#number shorthand, compatible --number, REST closeout fields, and on-demand GraphQL closeout metadata.", "PR preflight/closeout accept the same owner/repo#number shorthand as PR view/read so merge readiness checks do not require repeating --repo after a PR URL has already been normalized.", "PR list does not fetch mergeability or statusCheckRollup; request those closeout fields with gh pr view --json headRefName,baseRefName,mergeable,mergeStateStatus,statusCheckRollup.", "PR preflight is a low-noise read-only closeout helper for explicit diagnosis only. It combines redacted auth capability, PR branch/state metadata, mergeability, mergeStateStatus, compact status check counts, and an explicit read-only policy. It is not a required step before gh pr merge. Use --full or --raw to include all fetched status contexts.", - "PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. It defaults to deleting the merged same-repo head branch, cleaning the matching local .worktree when clean, and fast-forwarding the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.", + "PR merge is the one-command guarded write path: it reads closeout metadata itself, retries GitHub UNKNOWN/null mergeability with YAML-configured exponential backoff, refuses non-open/draft/conflicting/non-clean/failed/pending PRs, then uses GitHub REST merge. Its success summary includes mergeCommit and mergedAt, so a routine follow-up pr view is unnecessary. It defaults to deleting the merged same-repo head branch, cleaning the matching local .worktree when clean, and fast-forwarding the local main worktree on the PR base branch; --sync-node NODE additionally runs mapped node source-workspace sync. Use --dry-run to see the exact merge and closeout plan without writing.", ], }; } diff --git a/scripts/src/gh/pr-merge.ts b/scripts/src/gh/pr-merge.ts index 2ac346a1..16a2e664 100644 --- a/scripts/src/gh/pr-merge.ts +++ b/scripts/src/gh/pr-merge.ts @@ -169,6 +169,12 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails? : {}; const mergeMethod = result.method ?? result.mergeMethod ?? details.method; const deleteBranch = result.deleteBranch ?? details.deleteBranch; + const mergeResult = isRecord(result.mergeResult) ? result.mergeResult : {}; + const mergeCommitValue = pullRequest.mergeCommit; + const mergeCommit = isRecord(mergeCommitValue) + ? mergeCommitValue.oid + : mergeCommitValue ?? mergeResult.sha; + const mergedAt = pullRequest.mergedAt; const status = result.ok === true ? result.alreadyMerged === true ? "already-merged" @@ -198,13 +204,14 @@ export function renderPrMergeTable(result: GitHubCommandResult, fallbackDetails? ` repo=${ghText(result.repo)} title=${ghShort(ghText(pullRequest.title), 96)}`, ` blockers=${blockers.length === 0 ? "-" : blockers.join(",")} pending=${pending.length === 0 ? "-" : pending.join(",")}`, ` retry=${retry.attempts !== undefined && retry.maxAttempts !== undefined ? `${ghText(retry.attempts)}/${ghText(retry.maxAttempts)} exhausted=${ghText(retry.exhausted)}` : "-"}`, + ` mergeCommit=${ghText(mergeCommit)} mergedAt=${ghText(mergedAt)}`, ` branchDeletion=${ghText(branchDeletion.ok ?? branchDeletion.skippedReason ?? branchDeletion.attempted)}`, ` closeout localWorktree=${closeoutCell(localWorktree)} mainWorktree=${closeoutCell(mainWorktree)} nodeSyncs=${nodeSyncs.length === 0 ? "-" : nodeSyncs.map(closeoutCell).join(",")}`, "", "Next:", ]; - if (result.ok === true && result.alreadyMerged !== true && result.dryRun !== true) { - lines.push(` bun scripts/cli.ts gh pr view ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)}`); + if (result.ok === true && result.dryRun !== true) { + lines.push(" no follow-up required; merge facts are included in Summary."); } else { lines.push(` ${prMergeRetryCommand(ghText(result.repo), result.number ?? details.number, mergeMethod, deleteBranch)}`); lines.push(` bun scripts/cli.ts gh pr preflight ${ghText(result.number ?? details.number)} --repo ${ghText(result.repo)} --full`); diff --git a/scripts/src/hwlab-node-lanes.ts b/scripts/src/hwlab-node-lanes.ts index 4331e231..4271d3e5 100644 --- a/scripts/src/hwlab-node-lanes.ts +++ b/scripts/src/hwlab-node-lanes.ts @@ -545,20 +545,27 @@ export interface HwlabRuntimeKafkaShadowProducerSpec { export interface HwlabRuntimeKafkaEventBridgeSpec { readonly enabled: boolean; - readonly features: { - readonly directPublish: boolean; - readonly liveKafkaSse: boolean; - readonly kafkaRefreshReplay: boolean; - readonly transactionalProjector: boolean; - readonly projectionOutboxRelay: boolean; - readonly projectionRealtime: boolean; + readonly transactionalProjector?: { + readonly heartbeatIntervalMs: number; }; - readonly refreshReplay?: { - readonly groupIdPrefix: string; - readonly timeoutMs: number; - readonly scanLimit: number; - readonly matchedEventLimit: number; - readonly liveBufferLimit: number; + readonly projectionOutboxRelay?: { + readonly intervalMs: number; + readonly batchSize: number; + readonly leaseMs: number; + readonly sendTimeoutMs: number; + readonly retryBackoffMs: number; + }; + readonly projectionRealtime?: { + readonly outboxTailBatchSize: number; + readonly sseHeartbeatMs: number; + readonly sseDrainTimeoutMs: number; + }; + readonly healthThresholds: { + readonly consumerLagWarning: number; + readonly outboxBacklogWarning: number; + readonly retryCountWarning: number; + readonly failedInboxWarning: number; + readonly dlqWarning: number; }; readonly configRef: string; readonly bootstrapServers: string; @@ -566,7 +573,6 @@ export interface HwlabRuntimeKafkaEventBridgeSpec { readonly agentRunEventTopic: string; readonly hwlabEventTopic: string; readonly clientId: string; - readonly directPublishConsumerGroupId: string; readonly transactionalProjectorConsumerGroupId: string; readonly hwlabEventConsumerGroupId: string; } @@ -628,6 +634,7 @@ export interface HwlabRuntimePipelineProvenanceSpec { readonly configRef: string; readonly renderer: "hwlab-runtime-lane"; readonly mode: "remote-pipeline-annotation"; + readonly maxInlineScriptBytes: number; } export interface HwlabRuntimeLaneSpec { @@ -1060,6 +1067,7 @@ export function parseHwlabRuntimePipelineProvenance( configRef, renderer: enumStringField(raw, "renderer", path, ["hwlab-runtime-lane"]), mode: enumStringField(raw, "mode", path, ["remote-pipeline-annotation"]), + maxInlineScriptBytes: boundedIntegerField(raw, "maxInlineScriptBytes", path, 1024, 131072), }; } @@ -1220,55 +1228,75 @@ function codeAgentKafkaShadowProducerConfig(value: unknown, path: string): Hwlab function codeAgentKafkaEventBridgeConfig(value: unknown, path: string): HwlabRuntimeKafkaEventBridgeSpec { const raw = asRecord(value, path); - const features = asRecord(raw.features, `${path}.features`); - const liveKafkaSse = booleanField(features, "liveKafkaSse", `${path}.features`); - const kafkaRefreshReplay = booleanField(features, "kafkaRefreshReplay", `${path}.features`); - const refreshReplay = raw.refreshReplay === undefined + if (raw.refreshReplay !== undefined) throw new Error(`${path}.refreshReplay was removed with the fixed projection realtime architecture`); + const enabled = booleanField(raw, "enabled", path); + const transactionalProjectorConfig = raw.transactionalProjector === undefined ? undefined - : codeAgentKafkaRefreshReplayConfig(raw.refreshReplay, `${path}.refreshReplay`); - if (kafkaRefreshReplay && refreshReplay === undefined) { - throw new Error(`${path}.refreshReplay is required when ${path}.features.kafkaRefreshReplay is true`); - } - if (kafkaRefreshReplay && !liveKafkaSse) { - throw new Error(`${path}.features.liveKafkaSse must be true when ${path}.features.kafkaRefreshReplay is true`); - } - const directPublishConsumerGroupId = stringField(raw, "directPublishConsumerGroupId", path); + : codeAgentKafkaTransactionalProjectorConfig(raw.transactionalProjector, `${path}.transactionalProjector`); + const projectionOutboxRelayConfig = raw.projectionOutboxRelay === undefined + ? undefined + : codeAgentKafkaProjectionOutboxRelayConfig(raw.projectionOutboxRelay, `${path}.projectionOutboxRelay`); + const projectionRealtimeConfig = raw.projectionRealtime === undefined + ? undefined + : codeAgentKafkaProjectionRealtimeConfig(raw.projectionRealtime, `${path}.projectionRealtime`); + if (transactionalProjectorConfig === undefined) throw new Error(`${path}.transactionalProjector is required by the fixed transactional projection architecture`); + if (projectionOutboxRelayConfig === undefined) throw new Error(`${path}.projectionOutboxRelay is required by the fixed transactional projection architecture`); + if (projectionRealtimeConfig === undefined) throw new Error(`${path}.projectionRealtime is required by the fixed transactional projection architecture`); const transactionalProjectorConsumerGroupId = stringField(raw, "transactionalProjectorConsumerGroupId", path); const hwlabEventConsumerGroupId = stringField(raw, "hwlabEventConsumerGroupId", path); - if (new Set([directPublishConsumerGroupId, transactionalProjectorConsumerGroupId, hwlabEventConsumerGroupId]).size !== 3) { - throw new Error(`${path} consumer group ids must be distinct so enabled capabilities receive independent event streams`); + if (transactionalProjectorConsumerGroupId === hwlabEventConsumerGroupId) { + throw new Error(`${path} projector and realtime consumer group ids must be distinct`); } return { - enabled: booleanField(raw, "enabled", path), - features: { - directPublish: booleanField(features, "directPublish", `${path}.features`), - liveKafkaSse, - kafkaRefreshReplay, - transactionalProjector: booleanField(features, "transactionalProjector", `${path}.features`), - projectionOutboxRelay: booleanField(features, "projectionOutboxRelay", `${path}.features`), - projectionRealtime: booleanField(features, "projectionRealtime", `${path}.features`), - }, - ...(refreshReplay === undefined ? {} : { refreshReplay }), + enabled, + transactionalProjector: transactionalProjectorConfig, + projectionOutboxRelay: projectionOutboxRelayConfig, + projectionRealtime: projectionRealtimeConfig, + healthThresholds: codeAgentKafkaHealthThresholdsConfig(raw.healthThresholds, `${path}.healthThresholds`), configRef: stringField(raw, "configRef", path), bootstrapServers: stringField(raw, "bootstrapServers", path), stdioTopic: stringField(raw, "stdioTopic", path), agentRunEventTopic: stringField(raw, "agentRunEventTopic", path), hwlabEventTopic: stringField(raw, "hwlabEventTopic", path), clientId: stringField(raw, "clientId", path), - directPublishConsumerGroupId, transactionalProjectorConsumerGroupId, hwlabEventConsumerGroupId, }; } -function codeAgentKafkaRefreshReplayConfig(value: unknown, path: string): NonNullable { +function codeAgentKafkaTransactionalProjectorConfig(value: unknown, path: string): NonNullable { + const raw = asRecord(value, path); + return { heartbeatIntervalMs: numberField(raw, "heartbeatIntervalMs", path) }; +} + +function codeAgentKafkaProjectionOutboxRelayConfig(value: unknown, path: string): NonNullable { const raw = asRecord(value, path); return { - groupIdPrefix: stringField(raw, "groupIdPrefix", path), - timeoutMs: numberField(raw, "timeoutMs", path), - scanLimit: numberField(raw, "scanLimit", path), - matchedEventLimit: numberField(raw, "matchedEventLimit", path), - liveBufferLimit: numberField(raw, "liveBufferLimit", path), + intervalMs: numberField(raw, "intervalMs", path), + batchSize: numberField(raw, "batchSize", path), + leaseMs: numberField(raw, "leaseMs", path), + sendTimeoutMs: numberField(raw, "sendTimeoutMs", path), + retryBackoffMs: numberField(raw, "retryBackoffMs", path), + }; +} + +function codeAgentKafkaProjectionRealtimeConfig(value: unknown, path: string): NonNullable { + const raw = asRecord(value, path); + return { + outboxTailBatchSize: numberField(raw, "outboxTailBatchSize", path), + sseHeartbeatMs: numberField(raw, "sseHeartbeatMs", path), + sseDrainTimeoutMs: numberField(raw, "sseDrainTimeoutMs", path), + }; +} + +function codeAgentKafkaHealthThresholdsConfig(value: unknown, path: string): HwlabRuntimeKafkaEventBridgeSpec["healthThresholds"] { + const raw = asRecord(value, path); + return { + consumerLagWarning: nonNegativeIntegerField(raw, "consumerLagWarning", path), + outboxBacklogWarning: nonNegativeIntegerField(raw, "outboxBacklogWarning", path), + retryCountWarning: nonNegativeIntegerField(raw, "retryCountWarning", path), + failedInboxWarning: nonNegativeIntegerField(raw, "failedInboxWarning", path), + dlqWarning: nonNegativeIntegerField(raw, "dlqWarning", path), }; } diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts index f95fcbd7..4ef215fe 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.test.ts @@ -51,8 +51,8 @@ test("realtime fanout keeps the YAML terminal timeout when durationMs is absent" conditionalEventTypePairs: { "tool-call": { agentrun: "tool_call", hwlab: "tool" } }, expectedKafka: { topics: { stdio: "codex-stdio.raw.v1", agentrun: "agentrun.event.v1", hwlab: "hwlab.event.v1" }, - groups: { directPublish: "direct", transactionalProjector: "projector", liveSse: "live" }, - capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true }, + groups: { transactionalProjector: "projector", liveSse: "live" }, + capabilities: { transactionalProjector: true, projectionOutboxRelay: true, projectionRealtime: true }, }, }; @@ -194,7 +194,7 @@ test("realtime fanout validates stdio against the scoped AgentRun session lineag assert.equal(expectedStreamSessionId("hwlab", hwlabSessionId), hwlabSessionId); }); -test("realtime fanout validates YAML-owned refresh and legacy live-only connected contracts", () => { +test("realtime fanout validates the fixed transactional replay and live connected contract", () => { const source = nodeWebObserveRunnerRealtimeSource(); const build = new Function(`${source}\nreturn realtimeAssertConnectedContract;`) as () => ( connected: Record, @@ -207,7 +207,7 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte const sessionId = "ses_refresh_contract"; const expectedKafka = { topics: { hwlab: "hwlab.event.v1" }, - capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true }, + capabilities: { transactionalProjector: true, projectionOutboxRelay: true, projectionRealtime: true }, }; const refreshProfile = { expectedKafka, @@ -249,28 +249,6 @@ test("realtime fanout validates YAML-owned refresh and legacy live-only connecte /replayed count exceeds matched count/u, ); - const liveProfile = { - expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: false } }, - expectedProductSse: { - deliverySemantics: "live-only", - liveOnly: true, - replay: false, - replaySupported: false, - lossPossible: true, - refreshHandoff: false, - replayPriorTraceOnReconnect: false, - }, - }; - assert.doesNotThrow(() => assertConnected({ - realtimeSource: "hwlab.event.v1", - deliverySemantics: "live-only", - liveOnly: true, - replay: false, - replaySupported: false, - lossPossible: true, - filters: { sessionId, traceId: null }, - capabilities: liveProfile.expectedKafka.capabilities, - }, sessionId, "live", liveProfile)); }); test("existing session refresh rejects duplicate or reordered Workbench identities", () => { @@ -326,7 +304,7 @@ test("existing session refresh preserves rejected connected evidence and rejects counts: { matched: 8, replayed: 8, buffered: 1, bufferedDelivered: 1, liveDelivered: 0, deduplicated: 0 }, }; const profile = { - expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { directPublish: true, liveKafkaSse: true, kafkaRefreshReplay: true } }, + expectedKafka: { topics: { hwlab: "hwlab.event.v1" }, capabilities: { transactionalProjector: true, projectionOutboxRelay: true, projectionRealtime: true } }, expectedProductSse: { deliverySemantics: "kafka-retention-then-live", liveOnly: false, replay: true, replaySupported: true, lossPossible: false, refreshHandoff: true }, }; const connected = { diff --git a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts index de3a3529..0586236d 100644 --- a/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts +++ b/scripts/src/hwlab-node-web-observe-runner-realtime-source.ts @@ -803,11 +803,18 @@ function realtimeFanoutEffectiveProfile(profile, durationMs) { if (!expectedProductSse || typeof expectedProductSse.deliverySemantics !== "string") throw new Error("realtime fanout profile lacks YAML-owned product SSE expectations"); const expectedKafka = profile.expectedKafka && typeof profile.expectedKafka === "object" ? profile.expectedKafka : null; if (!expectedKafka?.topics || !expectedKafka?.groups || !expectedKafka?.capabilities) throw new Error("realtime fanout profile lacks YAML-derived Kafka expectations"); - if (expectedKafka.capabilities.directPublish !== true || expectedKafka.capabilities.liveKafkaSse !== true) { - throw new Error("realtime fanout profile requires YAML-enabled directPublish and liveKafkaSse capabilities"); + if (expectedKafka.capabilities.transactionalProjector !== true + || expectedKafka.capabilities.projectionOutboxRelay !== true + || expectedKafka.capabilities.projectionRealtime !== true) { + throw new Error("realtime fanout profile requires the fixed transactional projection capabilities"); } - if (expectedProductSse.refreshHandoff !== (expectedKafka.capabilities.kafkaRefreshReplay === true)) { - throw new Error("realtime fanout product SSE refreshHandoff differs from the YAML Kafka capability"); + if (expectedProductSse.deliverySemantics !== "kafka-retention-then-live" + || expectedProductSse.liveOnly !== false + || expectedProductSse.replay !== true + || expectedProductSse.replaySupported !== true + || expectedProductSse.lossPossible !== false + || expectedProductSse.refreshHandoff !== true) { + throw new Error("realtime fanout product SSE must use fixed retention replay and live handoff semantics"); } if (expectedProductSse.replayPriorTraceOnReconnect === true && expectedProductSse.replay !== true) { throw new Error("realtime fanout replayPriorTraceOnReconnect requires replay=true"); diff --git a/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts b/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts index e27f8a91..9af4ce6a 100644 --- a/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts +++ b/scripts/src/hwlab-node-web-sentinel-cicd-jobs.ts @@ -791,7 +791,7 @@ export function sentinelPublishImageBuildShell(state: SentinelCicdState, jobName ].join("\n"); } -export function sentinelPublishShell(state: SentinelCicdState, jobName: string, publishGitops: boolean): string { +export function sentinelPublishShell(state: SentinelCicdState, jobName: string, publishGitops: boolean, featureConfigSchemaStage = ""): string { const gitopsFiles = publishGitops ? sentinelGitopsFiles(state) : []; const filesB64 = Buffer.from(JSON.stringify(gitopsFiles.map((file) => ({ path: file.path, @@ -871,6 +871,7 @@ export function sentinelPublishShell(state: SentinelCicdState, jobName: string, "}", "console.error(JSON.stringify({event:'web-probe-sentinel-gitops-files', fileCount: files.length, valuesRedacted:true}));", "NODE", + ...(featureConfigSchemaStage.length === 0 ? [] : [featureConfigSchemaStage]), " git add .", " file_count=$(git diff --cached --name-only | wc -l | tr -d ' ')", " if git diff --quiet --cached; then changed=false; else changed=true; git -c user.email=web-probe-sentinel@unidesk.local -c user.name='UniDesk Web Probe Sentinel' commit -m \"deploy: render web-probe sentinel ${source_commit}\"; fi", diff --git a/scripts/src/hwlab-node/render.ts b/scripts/src/hwlab-node/render.ts index 17c84056..ffd53590 100644 --- a/scripts/src/hwlab-node/render.ts +++ b/scripts/src/hwlab-node/render.ts @@ -44,11 +44,15 @@ import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay"; import { pipelineProvenanceAnnotationKeys } from "../pipeline-provenance"; import { hwlabNodeDeliveryAuthority, hwlabNodeDeliveryNext } from "./delivery-authority"; import type { CicdDeliveryAuthority } from "../cicd-delivery-authority"; +import { renderPacFeatureConfigSchemaStage } from "../pac-feature-config-schema-stage"; const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd(); const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd(); +const runtimeGitopsScriptsConfigMapNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs"), "utf8").trimEnd(); const runtimeGitopsPostprocessNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-postprocess.mjs"), "utf8").trimEnd(); const runtimeGitopsVerifyNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-verify.mjs"), "utf8").trimEnd(); +const featureConfigSchemaWarningNativeScript = readFileSync(rootPath("scripts/native/cicd/feature-config-schema-warning.mjs"), "utf8").trimEnd(); +const ajv2020BundleScript = readFileSync(rootPath("scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), "utf8").trimEnd(); const runtimePipelineProvenanceNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-pipeline-provenance.mjs"), "utf8").trimEnd(); export function nodeRuntimeGitMirrorJobName(mirror: NodeRuntimeGitMirrorTargetSpec, action: "sync" | "flush"): string { @@ -753,6 +757,8 @@ export function summarizeNodeRuntimeControlPlaneStatus( const pipelineRunDiagnostics = record(status.pipelineRunDiagnostics); const argo = record(status.argo); const runtime = record(status.runtime); + const codeAgentRuntime = record(runtime.codeAgentRuntime); + const kafkaAuthority = record(codeAgentRuntime.kafkaAuthority); const publicProbes = record(status.publicProbes); const gitMirror = record(status.gitMirror); const gitMirrorCompact = record(gitMirror.compact); @@ -822,8 +828,25 @@ export function summarizeNodeRuntimeControlPlaneStatus( externalPostgresReady: runtime.externalPostgresBridge === undefined && runtime.externalPostgresSecrets === undefined ? null : record(runtime.externalPostgresBridge).ready === true && record(runtime.externalPostgresSecrets).ready === true, - codeAgentRuntimeReady: record(runtime.codeAgentRuntime).required === true ? record(runtime.codeAgentRuntime).ready === true : null, - codeAgentRuntimeReason: typeof record(runtime.codeAgentRuntime).degradedReason === "string" ? record(runtime.codeAgentRuntime).degradedReason : null, + codeAgentRuntimeReady: codeAgentRuntime.required === true ? codeAgentRuntime.ready === true : null, + codeAgentRuntimeReason: typeof codeAgentRuntime.degradedReason === "string" ? codeAgentRuntime.degradedReason : null, + kafkaAuthority: Object.keys(kafkaAuthority).length === 0 ? null : { + authority: kafkaAuthority.authority ?? null, + fixedGroups: kafkaAuthority.fixedGroups ?? null, + capabilities: kafkaAuthority.capabilities ?? null, + liveReplay: kafkaAuthority.liveReplay ?? null, + healthObserved: kafkaAuthority.healthObserved === true, + healthStatus: kafkaAuthority.healthStatus ?? null, + consumerLag: kafkaAuthority.consumerLag ?? null, + backlogCount: kafkaAuthority.backlogCount ?? null, + retryCount: kafkaAuthority.retryCount ?? null, + failedInboxCount: kafkaAuthority.failedInboxCount ?? null, + dlqCount: kafkaAuthority.dlqCount ?? null, + thresholds: kafkaAuthority.thresholds ?? null, + warning: kafkaAuthority.warning === true, + warningReasons: kafkaAuthority.warningReasons ?? [], + blocking: false, + }, }, publicProbe: { ready: publicProbes.ready === true, @@ -1520,12 +1543,24 @@ export function renderNodeRuntimeControlPlaneOnNode(spec: HwlabRuntimeLaneSpec, `--web-endpoint ${shellQuote(spec.publicWebUrl)}`, `--out ${shellQuote(renderDir)}`, ].join(" "), - ...nodeRuntimePipelinePostprocessScript(), + ...nodeRuntimePipelinePostprocessScript(nodeRuntimeFeatureConfigSchemaStage(spec)), ].join("\n"); return { result: runNodeHostScriptAsync(spec, script, timeoutSeconds, `${spec.nodeId.toLowerCase()}-${spec.lane}-render`), renderDir, worktreeDir, location: "node-host" }; } -export function nodeRuntimePipelinePostprocessScript(): string[] { +export function nodeRuntimeFeatureConfigSchemaStage(spec: HwlabRuntimeLaneSpec): string { + const authority = hwlabNodeDeliveryAuthority({ node: spec.nodeId, lane: spec.lane, spec }); + if (authority.kind !== "pac-pr-merge") throw new Error(`HWLAB ${spec.nodeId}/${spec.lane} requires exact PaC consumer authority for feature config validation`); + return renderPacFeatureConfigSchemaStage({ + repositoryId: authority.consumer.repositoryRef, + consumer: authority.consumer.consumerId, + repoDirExpression: '"$PWD"', + renderedRootExpression: JSON.stringify(spec.runtimePath), + ajvBundleExpression: "'/etc/unidesk-cicd-runtime-gitops/ajv2020.min.js'", + }); +} + +export function nodeRuntimePipelinePostprocessScript(featureConfigSchemaStage: string): string[] { return [ "node - \"$render_dir\" \"$overlay_b64\" <<'NODE'", "const fs = require('fs');", @@ -1534,6 +1569,7 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "const renderDir = process.argv[2];", "const overlay = JSON.parse(Buffer.from(process.argv[3], 'base64').toString('utf8'));", `const runtimeGitopsObservabilityNativeScript = ${JSON.stringify(runtimeGitopsObservabilityNativeScript)};`, + `const featureConfigSchemaStage = ${JSON.stringify(featureConfigSchemaStage)};`, "const pipelinePath = path.join(renderDir, overlay.tektonDir, 'pipeline.yaml');", "let text = fs.readFileSync(pipelinePath, 'utf8');", "let YAML = null;", @@ -1562,6 +1598,9 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "echo '{\"event\":\"prepare-source-dependencies\",\"status\":\"not-required\",\"dependency\":\"yaml\",\"manager\":\"inline-service-id-parser\"}' >&2", "ci_timing_emit prepare-source-dependencies succeeded \"$prepare_source_dependencies_started_ms\"`;", "}", + "function featureConfigSchemaValidationScript() {", + " return featureConfigSchemaStage;", + "}", "function validatePrepareSourceDependencyScript(script) {", " const marker = 'NODE_UNIDESK_YAML_DEPENDENCY';", " let offset = 0;", @@ -1818,12 +1857,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "function cloudWebRuntimeEnvEntries() {", " const exposure = overlay.publicExposure;", " const entries = !exposure || !Array.isArray(exposure.extraProxies) ? [] : exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));", - " const kafka = overlay.codeAgentRuntime && overlay.codeAgentRuntime.kafkaEventBridge;", - " if (kafka) {", - " entries.push({ name: 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', value: String(kafka.enabled && kafka.features.liveKafkaSse) });", - " entries.push({ name: 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', value: String(kafka.enabled && kafka.features.kafkaRefreshReplay) });", - " entries.push({ name: 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', value: String(kafka.enabled && kafka.features.projectionRealtime) });", - " }", " return entries;", "}", "function startupProbeFrom(probe) {", @@ -1921,31 +1954,35 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " if (codeAgentRuntime.kafkaEventBridge) {", " const kafka = codeAgentRuntime.kafkaEventBridge;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector))) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID', String(kafka.directPublishConsumerGroupId)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTOR_GROUP_ID', String(kafka.transactionalProjectorConsumerGroupId)) || codeAgentRuntimeChanged;", " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID', String(kafka.hwlabEventConsumerGroupId)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED', String(kafka.enabled && kafka.features.directPublish)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', String(kafka.enabled && kafka.features.liveKafkaSse)) || codeAgentRuntimeChanged;", - " const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', String(refreshReplayEnabled)) || codeAgentRuntimeChanged;", - " if (refreshReplayEnabled) {", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', String(kafka.refreshReplay.groupIdPrefix)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', String(kafka.refreshReplay.timeoutMs)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', String(kafka.refreshReplay.scanLimit)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', String(kafka.refreshReplay.matchedEventLimit)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT', String(kafka.refreshReplay.liveBufferLimit)) || codeAgentRuntimeChanged;", + " if (kafka.enabled) {", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS', String(kafka.transactionalProjector.heartbeatIntervalMs)) || codeAgentRuntimeChanged;", " } else {", - " codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT']) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS']) || codeAgentRuntimeChanged;", + " }", + " if (kafka.enabled) {", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS', String(kafka.projectionOutboxRelay.intervalMs)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE', String(kafka.projectionOutboxRelay.batchSize)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS', String(kafka.projectionOutboxRelay.leaseMs)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS', String(kafka.projectionOutboxRelay.sendTimeoutMs)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS', String(kafka.projectionOutboxRelay.retryBackoffMs)) || codeAgentRuntimeChanged;", + " } else {", + " codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE', 'HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS', 'HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS']) || codeAgentRuntimeChanged;", + " }", + " if (kafka.enabled) {", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE', String(kafka.projectionRealtime.outboxTailBatchSize)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_SSE_HEARTBEAT_MS', String(kafka.projectionRealtime.sseHeartbeatMs)) || codeAgentRuntimeChanged;", + " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS', String(kafka.projectionRealtime.sseDrainTimeoutMs)) || codeAgentRuntimeChanged;", + " } else {", + " codeAgentRuntimeChanged = removeEnvValues(container, ['HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE', 'HWLAB_WORKBENCH_SSE_HEARTBEAT_MS', 'HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS']) || codeAgentRuntimeChanged;", " }", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED', String(kafka.enabled && kafka.features.transactionalProjector)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED', String(kafka.enabled && kafka.features.projectionOutboxRelay)) || codeAgentRuntimeChanged;", - " codeAgentRuntimeChanged = setEnvValue(container, 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', String(kafka.enabled && kafka.features.projectionRealtime)) || codeAgentRuntimeChanged;", " }", " }", " }", @@ -2270,12 +2307,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "function cloudWebRuntimeEnvEntries() {", " const exposure = overlay.publicExposure;", " const entries = !exposure || !Array.isArray(exposure.extraProxies) ? [] : exposure.extraProxies.filter((proxy) => proxy && proxy.cloudWebEnvName && proxy.publicBaseUrl).map((proxy) => ({ name: String(proxy.cloudWebEnvName), value: String(proxy.publicBaseUrl) }));", - " const kafka = overlay.codeAgentRuntime && overlay.codeAgentRuntime.kafkaEventBridge;", - " if (kafka) {", - " entries.push({ name: 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', value: String(kafka.enabled && kafka.features.liveKafkaSse) });", - " entries.push({ name: 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', value: String(kafka.enabled && kafka.features.kafkaRefreshReplay) });", - " entries.push({ name: 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', value: String(kafka.enabled && kafka.features.projectionRealtime) });", - " }", " return entries;", "}", "function workloadRef(item, file, container) { return { file, kind: item && item.kind, name: item && item.metadata && item.metadata.name, container: container && container.name }; }", @@ -2349,31 +2380,14 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " if (codeAgentRuntime.kafkaEventBridge) {", " const kafka = codeAgentRuntime.kafkaEventBridge;", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_ENABLED', String(kafka.enabled));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled && (kafka.features.directPublish || kafka.features.transactionalProjector)));", + " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED', String(kafka.enabled));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_BOOTSTRAP_SERVERS', String(kafka.bootstrapServers));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_STDIO_TOPIC', String(kafka.stdioTopic));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC', String(kafka.agentRunEventTopic));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_EVENT_TOPIC', String(kafka.hwlabEventTopic));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_CLIENT_ID', String(kafka.clientId));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID', String(kafka.directPublishConsumerGroupId));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_PROJECTOR_GROUP_ID', String(kafka.transactionalProjectorConsumerGroupId));", " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID', String(kafka.hwlabEventConsumerGroupId));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED', String(kafka.enabled && kafka.features.directPublish));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED', String(kafka.enabled && kafka.features.liveKafkaSse));", - " const refreshReplayEnabled = kafka.enabled && kafka.features.kafkaRefreshReplay;", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED', String(refreshReplayEnabled));", - " if (refreshReplayEnabled) {", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', String(kafka.refreshReplay.groupIdPrefix));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', String(kafka.refreshReplay.timeoutMs));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', String(kafka.refreshReplay.scanLimit));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', String(kafka.refreshReplay.matchedEventLimit));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT', String(kafka.refreshReplay.liveBufferLimit));", - " } else {", - " for (const envName of ['HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT', 'HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT']) checkCodeAgentRuntimeAbsent(item, file, container, envName);", - " }", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED', String(kafka.enabled && kafka.features.transactionalProjector));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED', String(kafka.enabled && kafka.features.projectionOutboxRelay));", - " checkCodeAgentRuntimeValue(item, file, container, 'HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED', String(kafka.enabled && kafka.features.projectionRealtime));", " }", " }", " }", @@ -2407,7 +2421,6 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { "if (workloadCheck.wrongCodeAgentRuntimeEnvs.length > 0) fail('code-agent-runtime-env-mismatch', { refs: workloadCheck.wrongCodeAgentRuntimeEnvs.slice(0, 12), count: workloadCheck.wrongCodeAgentRuntimeEnvs.length });", "if (overlay.codeAgentRuntime && overlay.codeAgentRuntime.enabled) checks.push('code-agent-runtime-env');", "if (workloadCheck.wrongCloudWebRuntimeEnvs.length > 0) fail('cloud-web-runtime-env-mismatch', { refs: workloadCheck.wrongCloudWebRuntimeEnvs.slice(0, 12), count: workloadCheck.wrongCloudWebRuntimeEnvs.length });", - "if (cloudWebRuntimeEnvEntries().length > 0) checks.push('cloud-web-runtime-env');", "const pg = overlay.externalPostgres;", "if (pg && pg.serviceName) {", " const access = pg.runtimeAccess || { endpointAddress: pg.endpointAddress, port: pg.port };", @@ -2528,8 +2541,8 @@ export function nodeRuntimePipelinePostprocessScript(): string[] { " return next;", " });", " result = result.replace(/(node scripts\\/run-bun\\.mjs scripts\\/gitops-render\\.mjs[^\\n]*--use-deploy-images[^\\n]*)/g, (match) => {", - " if (match.includes('--check')) return runtimeGitopsVerifyScript();", - " return `${match}\\n${[runtimePathOverlayScript(), runtimeGitopsPostprocessScript()].filter(Boolean).join('\\n')}`;", + " if (match.includes('--check')) return match;", + " return `${match}\\n${featureConfigSchemaValidationScript()}\\n${runtimePathOverlayScript()}`;", " });", " result = result.replace(/node scripts\\/run-bun\\.mjs scripts\\/gitops-render\\.mjs([^\\n]*?)--out \"\\$render_check_dir\"/g, (match) => match.includes('--gitops-root ') ? match : `${match} --gitops-root ${JSON.stringify(overlay.gitopsRoot)} --node ${shellSingle(overlay.nodeId)} --git-read-url ${shellSingle(overlay.gitReadUrl)} --git-write-url ${shellSingle(overlay.gitWriteUrl)} --runtime-endpoint ${shellSingle(overlay.publicApiUrl)} --web-endpoint ${shellSingle(overlay.publicWebUrl)}`);", " validatePrepareSourceDependencyScript(result);", @@ -2887,9 +2900,12 @@ function runtimeGitopsPipelineGuardScript(): string[] { "runtime_gitops_guard_dir=\"$render_dir/.unidesk-runtime-gitops\"", "mkdir -p \"$runtime_gitops_guard_dir\"", ...writeRuntimeGitopsNativeScript("runtime-gitops-pipeline-guard.mjs", runtimeGitopsPipelineGuardNativeScript, "UNIDESK_RUNTIME_GITOPS_PIPELINE_GUARD_MJS"), + ...writeRuntimeGitopsNativeScript("runtime-gitops-scripts-configmap.mjs", runtimeGitopsScriptsConfigMapNativeScript, "UNIDESK_RUNTIME_GITOPS_SCRIPTS_CONFIGMAP_MJS"), ...writeRuntimeGitopsNativeScript("runtime-gitops-observability.mjs", runtimeGitopsObservabilityNativeScript, "UNIDESK_RUNTIME_GITOPS_OBSERVABILITY_MJS"), ...writeRuntimeGitopsNativeScript("runtime-gitops-postprocess.mjs", runtimeGitopsPostprocessNativeScript, "UNIDESK_RUNTIME_GITOPS_POSTPROCESS_MJS"), ...writeRuntimeGitopsNativeScript("runtime-gitops-verify.mjs", runtimeGitopsVerifyNativeScript, "UNIDESK_RUNTIME_GITOPS_VERIFY_MJS"), + ...writeRuntimeGitopsNativeScript("feature-config-schema-warning.mjs", featureConfigSchemaWarningNativeScript, "UNIDESK_FEATURE_CONFIG_SCHEMA_WARNING_MJS"), + ...writeRuntimeGitopsNativeScript("ajv2020.min.js", ajv2020BundleScript, "UNIDESK_AJV2020_MIN_JS"), [ "UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=\"$overlay_b64\"", "node \"$runtime_gitops_guard_dir/runtime-gitops-pipeline-guard.mjs\"", diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts index 3a699328..11c1906e 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.test.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.test.ts @@ -3,11 +3,13 @@ import { mkdtemp, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "bun:test"; +import { readFileSync } from "node:fs"; import { hwlabRuntimeLaneSpecForNode } from "../hwlab-node-lanes"; +import { rootPath } from "../config"; import { buildWebObserveCommandVisibility, nodeWebProbeRealtimeFanoutProfiles, nodeWebProbeWorkbenchKafkaDebugReplay, nodeWebProbeWorkbenchTraceReadability, resolveWebObserveActionJson } from "./web-probe-observe-actions"; -test("realtime fanout runner contract derives topics, groups, and independent capabilities from owning YAML", () => { +test("realtime authority runner contract derives topics, groups, and transactional capabilities from owning YAML", () => { const profiles = nodeWebProbeRealtimeFanoutProfiles(hwlabRuntimeLaneSpecForNode("v03", "NC01")); const expectedKafka = profiles["pure-kafka-live"]?.expectedKafka as Record; assert.deepEqual(expectedKafka.topics, { @@ -16,17 +18,34 @@ test("realtime fanout runner contract derives topics, groups, and independent ca hwlab: "hwlab.event.v1", }); assert.deepEqual(expectedKafka.groups, { - directPublish: "hwlab-v03-agentrun-event-direct-publish", transactionalProjector: "hwlab-v03-agentrun-event-projector", liveSse: "hwlab-v03-workbench-live-sse", }); assert.deepEqual(expectedKafka.capabilities, { - directPublish: true, - liveKafkaSse: true, - kafkaRefreshReplay: true, - transactionalProjector: false, - projectionOutboxRelay: false, - projectionRealtime: false, + transactionalProjector: true, + projectionOutboxRelay: true, + projectionRealtime: true, + }); + const kafka = hwlabRuntimeLaneSpecForNode("v03", "NC01").codeAgentRuntime?.kafkaEventBridge; + assert.deepEqual(kafka?.transactionalProjector, { heartbeatIntervalMs: 2_000 }); + assert.deepEqual(kafka?.projectionOutboxRelay, { + intervalMs: 250, + batchSize: 100, + leaseMs: 30_000, + sendTimeoutMs: 10_000, + retryBackoffMs: 1_000, + }); + assert.deepEqual(kafka?.projectionRealtime, { + outboxTailBatchSize: 100, + sseHeartbeatMs: 15_000, + sseDrainTimeoutMs: 2_500, + }); + assert.deepEqual(kafka?.healthThresholds, { + consumerLagWarning: 0, + outboxBacklogWarning: 0, + retryCountWarning: 0, + failedInboxWarning: 0, + dlqWarning: 0, }); assert.deepEqual(profiles["pure-kafka-live"]?.requiredEventTypes, { agentrun: ["user_message", "assistant_message", "terminal_status"], @@ -38,6 +57,16 @@ test("realtime fanout runner contract derives topics, groups, and independent ca }); }); +test("realtime authority parser and inline render expose no direct authority or duplicate native postprocess execution", () => { + const laneSource = readFileSync(rootPath("scripts", "src", "hwlab-node-lanes.ts"), "utf8"); + const renderSource = readFileSync(rootPath("scripts", "src", "hwlab-node", "render.ts"), "utf8"); + assert.doesNotMatch(laneSource, /direct-live/u); + assert.equal(renderSource.includes("UNIDESK_RUNTIME_GITOPS_RUNTIME_PATH"), false); + assert.equal(renderSource.includes('UNIDESK_RUNTIME_GITOPS_OVERLAY_B64="$overlay_b64" UNIDESK_RUNTIME_GITOPS_RUNTIME_PATH'), false); + assert.match(renderSource, /runtimeGitopsPostprocessScript\(\)/u); + assert.match(renderSource, /writeRuntimeGitopsNativeScript\("runtime-gitops-postprocess\.mjs"/u); +}); + test("Workbench Kafka debug replay runner contract is derived from owning YAML", () => { assert.deepEqual(nodeWebProbeWorkbenchKafkaDebugReplay(hwlabRuntimeLaneSpecForNode("v03", "NC01")), { enabled: true, diff --git a/scripts/src/hwlab-node/web-probe-observe-actions.ts b/scripts/src/hwlab-node/web-probe-observe-actions.ts index 1bed10cf..2afa7b76 100644 --- a/scripts/src/hwlab-node/web-probe-observe-actions.ts +++ b/scripts/src/hwlab-node/web-probe-observe-actions.ts @@ -23,7 +23,11 @@ export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec): if (Object.keys(profiles).length === 0) return {}; const kafka = spec.codeAgentRuntime?.kafkaEventBridge; if (kafka === undefined) throw new Error("realtimeFanoutProfiles requires codeAgentRuntime.kafkaEventBridge in the owning YAML"); - const capabilities = Object.fromEntries(Object.entries(kafka.features).map(([name, enabled]) => [name, kafka.enabled && enabled])); + const capabilities = { + transactionalProjector: kafka.enabled, + projectionOutboxRelay: kafka.enabled, + projectionRealtime: kafka.enabled, + }; return Object.fromEntries(Object.entries(profiles).map(([id, profile]) => { if (profile.businessEvent !== kafka.hwlabEventTopic) { throw new Error(`realtimeFanoutProfiles.${id}.businessEvent must match codeAgentRuntime.kafkaEventBridge.hwlabEventTopic`); @@ -38,7 +42,6 @@ export function nodeWebProbeRealtimeFanoutProfiles(spec: HwlabRuntimeLaneSpec): hwlab: kafka.hwlabEventTopic, }, groups: { - directPublish: kafka.directPublishConsumerGroupId, transactionalProjector: kafka.transactionalProjectorConsumerGroupId, liveSse: kafka.hwlabEventConsumerGroupId, }, diff --git a/scripts/src/hwlab-node/web-probe.ts b/scripts/src/hwlab-node/web-probe.ts index 605bb694..7ea2491e 100644 --- a/scripts/src/hwlab-node/web-probe.ts +++ b/scripts/src/hwlab-node/web-probe.ts @@ -1482,6 +1482,7 @@ export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, na const apiKey = secretKeyStatus(spec, runtime.apiKeySecretName, runtime.apiKeySecretKey); const cloudApiEnv = nodeRuntimeCodeAgentCloudApiEnvStatus(spec, runtime); const cloudWebEnv = nodeRuntimeCodeAgentCloudWebEnvStatus(spec, runtime); + const kafkaAuthority = nodeRuntimeKafkaAuthorityStatus(spec, runtime); const ready = runnerNamespace.exitCode === 0 && secretNamespace.exitCode === 0 && managerNamespace.exitCode === 0 @@ -1538,12 +1539,73 @@ export function nodeRuntimeCodeAgentRuntimeStatus(spec: HwlabRuntimeLaneSpec, na apiKey, cloudApiEnv, cloudWebEnv, + kafkaAuthority, repoUrlFrom: runtime.repoUrlFrom, providerIdFrom: runtime.providerIdFrom, valuesPrinted: false, }; } +function nodeRuntimeKafkaAuthorityStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable): Record { + const kafka = runtime.kafkaEventBridge; + if (kafka === undefined || kafka.enabled === false) return { required: false, authority: kafka === undefined ? null : "transactional-projection", valuesPrinted: false }; + const healthUrl = `${spec.publicApiUrl.replace(/\/+$/u, "")}/health/ready`; + const result = runCommand(["curl", "-k", "-sS", "--connect-timeout", "5", "--max-time", "12", healthUrl], repoRoot, { timeoutMs: 15_000 }); + let health: Record = {}; + try { + health = result.exitCode === 0 ? record(JSON.parse(result.stdout)) : {}; + } catch { + health = {}; + } + const projector = record(health.kafkaProjector); + const components = Array.isArray(projector.components) ? projector.components.map(record) : []; + const observations = [projector, ...components]; + const consumerLag = observations.map((item) => record(item.consumerLag).total).find((value) => typeof value === "number") ?? null; + const backlogCount = observations.map((item) => item.backlogCount).find((value) => typeof value === "number") ?? null; + const retryCount = observations.map((item) => item.retryCount).find((value) => typeof value === "number") ?? null; + const failedInboxCount = observations.map((item) => item.failedInboxCount).find((value) => typeof value === "number") ?? null; + const dlqCount = observations.map((item) => item.dlqCount).find((value) => typeof value === "number") ?? null; + const thresholds = kafka.healthThresholds; + const warningReasons = [ + typeof consumerLag === "number" && consumerLag > thresholds.consumerLagWarning ? "consumer-lag" : null, + typeof backlogCount === "number" && backlogCount > thresholds.outboxBacklogWarning ? "outbox-backlog" : null, + typeof retryCount === "number" && retryCount > thresholds.retryCountWarning ? "outbox-retry" : null, + typeof failedInboxCount === "number" && failedInboxCount > thresholds.failedInboxWarning ? "failed-inbox" : null, + typeof dlqCount === "number" && dlqCount > thresholds.dlqWarning ? "dlq" : null, + ].filter((value): value is string => value !== null); + return { + required: true, + authority: "transactional-projection", + fixedGroups: { + transactionalProjector: kafka.transactionalProjectorConsumerGroupId, + liveSse: kafka.hwlabEventConsumerGroupId, + }, + capabilities: { + transactionalProjector: true, + projectionOutboxRelay: true, + projectionRealtime: true, + }, + liveReplay: { + live: true, + replay: true, + cursor: "projection-outbox-seq", + }, + healthObserved: Object.keys(projector).length > 0, + healthStatus: projector.status ?? null, + consumerLag, + backlogCount, + retryCount, + failedInboxCount, + dlqCount, + thresholds, + warning: warningReasons.length > 0, + warningReasons, + blocking: false, + result: compactRuntimeCommand(result), + valuesPrinted: false, + }; +} + function nodeRuntimeCodeAgentManagerTarget(managerUrl: string, fallbackNamespace: string): { namespace: string; serviceName: string } { let parsed: URL | null = null; try { @@ -1632,47 +1694,55 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti const kafkaEventBridge = runtime.kafkaEventBridge; if (kafkaEventBridge !== undefined) { expectValue("HWLAB_KAFKA_ENABLED", String(kafkaEventBridge.enabled)); - expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled && (kafkaEventBridge.features.directPublish || kafkaEventBridge.features.transactionalProjector))); + expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_CONSUME_ENABLED", String(kafkaEventBridge.enabled)); expectValue("HWLAB_KAFKA_BOOTSTRAP_SERVERS", kafkaEventBridge.bootstrapServers); expectValue("HWLAB_KAFKA_STDIO_TOPIC", kafkaEventBridge.stdioTopic); expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_TOPIC", kafkaEventBridge.agentRunEventTopic); expectValue("HWLAB_KAFKA_EVENT_TOPIC", kafkaEventBridge.hwlabEventTopic); expectValue("HWLAB_KAFKA_CLIENT_ID", kafkaEventBridge.clientId); - expectValue("HWLAB_KAFKA_AGENTRUN_EVENT_GROUP_ID", kafkaEventBridge.directPublishConsumerGroupId); expectValue("HWLAB_KAFKA_PROJECTOR_GROUP_ID", kafkaEventBridge.transactionalProjectorConsumerGroupId); expectValue("HWLAB_KAFKA_HWLAB_EVENT_GROUP_ID", kafkaEventBridge.hwlabEventConsumerGroupId); - expectValue("HWLAB_KAFKA_DIRECT_PUBLISH_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.directPublish)); - expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse)); - const refreshReplayEnabled = kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay; - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(refreshReplayEnabled)); - const refreshReplayEnvNames = [ - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", - "HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", - ]; - if (refreshReplayEnabled) { - const refreshReplay = kafkaEventBridge.refreshReplay; - if (refreshReplay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.refreshReplay is required when Kafka refresh replay is enabled"); - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX", refreshReplay.groupIdPrefix); - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS", String(refreshReplay.timeoutMs)); - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT", String(refreshReplay.scanLimit)); - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT", String(refreshReplay.matchedEventLimit)); - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT", String(refreshReplay.liveBufferLimit)); + if (kafkaEventBridge.enabled) { + if (kafkaEventBridge.transactionalProjector === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.transactionalProjector is required when transactional projector is enabled"); + expectValue("HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS", String(kafkaEventBridge.transactionalProjector.heartbeatIntervalMs)); } else { - for (const name of refreshReplayEnvNames) expectAbsent(name); + expectAbsent("HWLAB_KAFKA_PROJECTOR_HEARTBEAT_INTERVAL_MS"); + } + const relayEnvNames = [ + "HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS", + "HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE", + "HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS", + "HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS", + "HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS", + ]; + if (kafkaEventBridge.enabled) { + const relay = kafkaEventBridge.projectionOutboxRelay; + if (relay === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.projectionOutboxRelay is required when projection outbox relay is enabled"); + expectValue("HWLAB_KAFKA_OUTBOX_RELAY_INTERVAL_MS", String(relay.intervalMs)); + expectValue("HWLAB_KAFKA_OUTBOX_RELAY_BATCH_SIZE", String(relay.batchSize)); + expectValue("HWLAB_KAFKA_OUTBOX_RELAY_LEASE_MS", String(relay.leaseMs)); + expectValue("HWLAB_KAFKA_OUTBOX_RELAY_SEND_TIMEOUT_MS", String(relay.sendTimeoutMs)); + expectValue("HWLAB_KAFKA_OUTBOX_RELAY_RETRY_BACKOFF_MS", String(relay.retryBackoffMs)); + } else { + for (const name of relayEnvNames) expectAbsent(name); + } + const projectionRealtimeEnvNames = ["HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE", "HWLAB_WORKBENCH_SSE_HEARTBEAT_MS", "HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS"]; + if (kafkaEventBridge.enabled) { + const realtime = kafkaEventBridge.projectionRealtime; + if (realtime === undefined) throw new Error("codeAgentRuntime.kafkaEventBridge.projectionRealtime is required when projection realtime is enabled"); + expectValue("HWLAB_WORKBENCH_OUTBOX_TAIL_BATCH_SIZE", String(realtime.outboxTailBatchSize)); + expectValue("HWLAB_WORKBENCH_SSE_HEARTBEAT_MS", String(realtime.sseHeartbeatMs)); + expectValue("HWLAB_WORKBENCH_SSE_DRAIN_TIMEOUT_MS", String(realtime.sseDrainTimeoutMs)); + } else { + for (const name of projectionRealtimeEnvNames) expectAbsent(name); } - expectValue("HWLAB_KAFKA_TRANSACTIONAL_PROJECTOR_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.transactionalProjector)); - expectValue("HWLAB_KAFKA_PROJECTION_OUTBOX_RELAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionOutboxRelay)); - expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime)); } return { ready: mismatches.length === 0, deploymentExists: true, deploymentName: "hwlab-cloud-api", containerName: "hwlab-cloud-api", - checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 21), + checkedEnvCount: 9 + (kafkaShadowProducer === undefined ? 0 : 5) + (kafkaEventBridge === undefined ? 0 : 22), envCount: envByName.size, mismatches, result: compactCodeAgentDeploymentProbeResult(envProbe), @@ -1681,53 +1751,9 @@ function nodeRuntimeCodeAgentCloudApiEnvStatus(spec: HwlabRuntimeLaneSpec, runti } function nodeRuntimeCodeAgentCloudWebEnvStatus(spec: HwlabRuntimeLaneSpec, runtime: NonNullable): Record { - const kafkaEventBridge = runtime.kafkaEventBridge; - if (kafkaEventBridge === undefined) return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false }; - const envProbe = runNodeK3sArgs(spec, [ - "kubectl", - "-n", - spec.runtimeNamespace, - "get", - "deployment", - "hwlab-cloud-web", - "-o", - 'jsonpath={range .spec.template.spec.containers[?(@.name=="hwlab-cloud-web")].env[*]}{.name}{"\\t"}{.value}{"\\t"}{.valueFrom.secretKeyRef.name}{"\\t"}{.valueFrom.secretKeyRef.key}{"\\n"}{end}', - ], 60); - if (envProbe.exitCode !== 0) { - return { - required: true, - ready: false, - deploymentExists: false, - mismatches: [{ name: "hwlab-cloud-web", reason: "deployment-missing" }], - result: compactCodeAgentDeploymentProbeResult(envProbe), - valuesPrinted: false, - }; - } - const envByName = parseCodeAgentEnvProbe(envProbe.stdout); - const mismatches: Array> = []; - const expectValue = (name: string, expected: string): void => { - const item = envByName.get(name); - if (item === undefined) { - mismatches.push({ name, expected, present: false, kind: "value" }); - return; - } - if (item.value !== expected) mismatches.push({ name, expected, value: item.value, present: true, kind: "value" }); - }; - expectValue("HWLAB_WORKBENCH_LIVE_KAFKA_SSE_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.liveKafkaSse)); - expectValue("HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.kafkaRefreshReplay)); - expectValue("HWLAB_WORKBENCH_PROJECTION_REALTIME_ENABLED", String(kafkaEventBridge.enabled && kafkaEventBridge.features.projectionRealtime)); - return { - required: true, - ready: mismatches.length === 0, - deploymentExists: true, - deploymentName: "hwlab-cloud-web", - containerName: "hwlab-cloud-web", - checkedEnvCount: 3, - envCount: envByName.size, - mismatches, - result: compactCodeAgentDeploymentProbeResult(envProbe), - valuesPrinted: false, - }; + void spec; + void runtime; + return { required: false, ready: true, checkedEnvCount: 0, valuesPrinted: false }; } type CodeAgentEnvProbeRow = { diff --git a/scripts/src/pac-feature-config-schema-stage.test.ts b/scripts/src/pac-feature-config-schema-stage.test.ts new file mode 100644 index 00000000..5da4e6ba --- /dev/null +++ b/scripts/src/pac-feature-config-schema-stage.test.ts @@ -0,0 +1,123 @@ +import { expect, test } from "bun:test"; +import { mkdtempSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { spawnSync } from "node:child_process"; + +import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { renderAgentRunPipelineManifest } from "./agentrun-manifests"; +import { renderPacFeatureConfigSchemaStage } from "./pac-feature-config-schema-stage"; + +test("shared stage injects owning YAML OTel values for all three PaC repositories", () => { + for (const [repositoryId, consumer] of [ + ["hwlab-nc01-v03", "hwlab-nc01-v03"], + ["agentrun-nc01-v02", "agentrun-nc01-v02"], + ["sentinel-nc01-v03", "sentinel-nc01-v03"], + ] as const) { + const stage = renderPacFeatureConfigSchemaStage({ + repositoryId, + consumer, + repoDirExpression: "'/workspace/source'", + renderedRootExpression: "'/workspace/rendered'", + }); + expect(stage).toContain("feature-config-schema-validation"); + expect(stage).toContain("cicd.feature_config.schema"); + expect(stage).toContain("OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces'"); + expect(stage).toContain("OTEL_TRACES_SAMPLER_ARG='1'"); + expect(stage).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'"); + expect(stage).toContain("NODE_UNIDESK_AJV2020_BUNDLE"); + expect(stage).not.toContain("process.env.webProbeSentinel"); + } +}); + +test("HWLAB, AgentRun, and Sentinel normal stages validate without source workspace node_modules", () => { + const root = mkdtempSync(join(tmpdir(), "feature-config-consumers-")); + try { + const source = join(root, "source"); + const rendered = join(root, "rendered"); + const temp = join(root, "tmp"); + mkdirSync(join(source, "config"), { recursive: true }); + mkdirSync(rendered, { recursive: true }); + mkdirSync(temp); + writeFileSync(join(source, "config", "feature-config.schema.json"), JSON.stringify({ + $schema: "https://json-schema.org/draft/2020-12/schema", + type: "object", + properties: { webProbeSentinel: { type: "boolean", "x-unidesk-feature": "web-probe-sentinel" } }, + additionalProperties: false, + })); + writeFileSync(join(rendered, "deployment.json"), JSON.stringify({ + apiVersion: "apps/v1", + kind: "Deployment", + spec: { template: { spec: { containers: [{ name: "fixture", env: [{ name: "webProbeSentinel", value: "true" }] }] } } }, + })); + const vendorBundle = resolve(import.meta.dir, "../vendor/ajv-dist/8.17.1/ajv2020.min.js"); + for (const consumer of [ + { repositoryId: "hwlab-nc01-v03", consumer: "hwlab-nc01-v03", ajvBundleExpression: JSON.stringify(vendorBundle) }, + { repositoryId: "agentrun-nc01-v02", consumer: "agentrun-nc01-v02" }, + { repositoryId: "sentinel-nc01-v03", consumer: "sentinel-nc01-v03" }, + ]) { + const stage = renderPacFeatureConfigSchemaStage({ + ...consumer, + repoDirExpression: JSON.stringify(source), + renderedRootExpression: JSON.stringify(rendered), + }); + const result = spawnSync("sh", [], { + input: `set -eu\n${stage}\n`, + encoding: "utf8", + env: { ...process.env, TMPDIR: temp }, + }); + expect(result.status, consumer.consumer).toBe(0); + expect(result.stderr, consumer.consumer).toContain('"code":"feature-config-schema-valid"'); + expect(result.stderr, consumer.consumer).not.toContain("feature-config-validator-unavailable"); + expect(result.stderr, consumer.consumer).not.toContain("feature-config-validator-process-failure"); + expect(readdirSync(temp), consumer.consumer).toEqual([]); + } + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("embedded node process failure remains a typed warning with zero shell exit", () => { + const root = mkdtempSync(join(tmpdir(), "feature-config-stage-")); + try { + const bin = join(root, "bin"); + const temp = join(root, "tmp"); + mkdirSync(bin); + mkdirSync(temp); + writeFileSync(join(bin, "node"), "#!/bin/sh\nexit 71\n", { mode: 0o755 }); + const stage = renderPacFeatureConfigSchemaStage({ + repositoryId: "sentinel-nc01-v03", + consumer: "sentinel-nc01-v03", + repoDirExpression: "'/workspace/source'", + renderedRootExpression: "'/workspace/rendered'", + }); + const result = spawnSync("sh", [], { + input: `set -eu\n${stage}\n`, + env: { ...process.env, PATH: `${bin}:${process.env.PATH ?? ""}`, TMPDIR: temp }, + encoding: "utf8", + }); + expect(result.status).toBe(0); + expect(result.stderr).toContain("feature-config-validator-process-failure"); + expect(readdirSync(temp)).toEqual([]); + } finally { + rmSync(root, { recursive: true, force: true }); + } +}); + +test("AgentRun normal GitOps publish task contains the shared warning-only stage", () => { + const { spec } = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }); + const pipeline = renderAgentRunPipelineManifest(spec) as Record; + const scripts = pipeline.spec.tasks.flatMap((task: Record) => task.taskSpec.steps.map((step: Record) => String(step.script ?? ""))).join("\n"); + expect(scripts).toContain("UNIDESK_PAC_CONSUMER='agentrun-nc01-v02'"); + expect(scripts).toContain("feature-config-schema-validation"); + expect(scripts).toContain("feature-config-validator-process-failure"); + expect(scripts).toContain("OTEL_EXPORTER_TIMEOUT_MS='300'"); + expect(scripts).toContain("NODE_UNIDESK_AJV2020_BUNDLE"); +}); + +test("repo-owned schema is Draft 2020-12 with one canonical feature property", () => { + const schema = JSON.parse(readFileSync("config/feature-config.schema.json", "utf8")); + expect(schema.$schema).toBe("https://json-schema.org/draft/2020-12/schema"); + expect(schema.properties.webProbeSentinel["x-unidesk-feature"]).toBe("web-probe-sentinel"); + expect(Object.keys(schema.properties)).toEqual(["webProbeSentinel"]); +}); diff --git a/scripts/src/pac-feature-config-schema-stage.ts b/scripts/src/pac-feature-config-schema-stage.ts new file mode 100644 index 00000000..4eb70a3d --- /dev/null +++ b/scripts/src/pac-feature-config-schema-stage.ts @@ -0,0 +1,66 @@ +import { readFileSync } from "node:fs"; + +import { rootPath } from "./config"; +import { loadCicdOtelRuntimeConfig } from "../native/cicd/otel-runtime-config"; + +const validatorSource = readFileSync(rootPath("scripts/native/cicd/feature-config-schema-warning.mjs"), "utf8").trimEnd(); +const ajv2020BundleSource = readFileSync(rootPath("scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js"), "utf8").trimEnd(); + +export interface PacFeatureConfigSchemaStageOptions { + readonly repositoryId: string; + readonly consumer: string; + readonly repoDirExpression: string; + readonly renderedRootExpression: string; + readonly ajvBundleExpression?: string | null; +} + +export function renderPacFeatureConfigSchemaStage(options: PacFeatureConfigSchemaStageOptions): string { + const otel = loadCicdOtelRuntimeConfig(options.repositoryId); + const materializeBundle = options.ajvBundleExpression === undefined || options.ajvBundleExpression === null; + return [ + "feature_config_schema_started_ms=\"$(date +%s%3N 2>/dev/null || date +%s000)\"", + `export UNIDESK_FEATURE_CONFIG_REPO_DIR=${options.repoDirExpression}`, + `export UNIDESK_FEATURE_CONFIG_RENDERED_ROOT=${options.renderedRootExpression}`, + `export UNIDESK_PAC_CONSUMER=${shellSingle(options.consumer)}`, + `export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=${shellSingle(otel.endpoint)}`, + `export OTEL_SERVICE_NAME=${shellSingle(otel.serviceName)}`, + `export OTEL_TRACES_SAMPLER_ARG=${shellSingle(String(otel.samplingRatio))}`, + `export OTEL_EXPORTER_TIMEOUT_MS=${shellSingle(String(otel.timeoutMs))}`, + ...(materializeBundle + ? [ + "feature_config_ajv_bundle=''", + "if feature_config_ajv_bundle=\"$(mktemp \"${TMPDIR:-/tmp}/unidesk-ajv2020.XXXXXX\" 2>/dev/null)\"; then", + " if ! cat >\"$feature_config_ajv_bundle\" <<'NODE_UNIDESK_AJV2020_BUNDLE'", + ajv2020BundleSource, + "NODE_UNIDESK_AJV2020_BUNDLE", + " then", + " rm -f \"$feature_config_ajv_bundle\" || true", + " feature_config_ajv_bundle=''", + " fi", + "fi", + "export UNIDESK_AJV2020_BUNDLE=\"${feature_config_ajv_bundle:-${TMPDIR:-/tmp}/unidesk-ajv2020-unavailable}\"", + ] + : [`export UNIDESK_AJV2020_BUNDLE=${options.ajvBundleExpression}`]), + "if ! node --input-type=module <<'NODE_UNIDESK_FEATURE_CONFIG_SCHEMA'", + validatorSource, + "await runFeatureConfigSchemaValidation({", + " repoDir: process.env.UNIDESK_FEATURE_CONFIG_REPO_DIR,", + " renderedRoot: process.env.UNIDESK_FEATURE_CONFIG_RENDERED_ROOT,", + " consumer: process.env.UNIDESK_PAC_CONSUMER,", + "});", + "NODE_UNIDESK_FEATURE_CONFIG_SCHEMA", + "then", + " printf '{\"event\":\"feature-config-schema-validation\",\"warning\":true,\"blocking\":false,\"code\":\"feature-config-validator-process-failure\",\"mutation\":false,\"valuesPrinted\":false}\\n' >&2", + "fi", + ...(materializeBundle ? ["if [ -n \"$feature_config_ajv_bundle\" ]; then rm -f \"$feature_config_ajv_bundle\" || true; fi"] : []), + "if command -v ci_timing_emit >/dev/null 2>&1; then", + " ci_timing_emit feature-config-schema-validation succeeded \"$feature_config_schema_started_ms\"", + "else", + " printf '{\"event\":\"ci.stage.timing\",\"stage\":\"feature-config-schema-validation\",\"status\":\"succeeded\",\"blocking\":false}\\n' >&2", + "fi", + ].join("\n"); +} + +function shellSingle(value: string): string { + return `'${value.replaceAll("'", `'"'"'`)}'`; +} diff --git a/scripts/src/platform-infra-pac-failure-evidence.test.ts b/scripts/src/platform-infra-pac-failure-evidence.test.ts index a1ececa9..63c28769 100644 --- a/scripts/src/platform-infra-pac-failure-evidence.test.ts +++ b/scripts/src/platform-infra-pac-failure-evidence.test.ts @@ -11,6 +11,9 @@ const require = createRequire(import.meta.url); const { parsePacLogRecords } = require("../native/cicd/pac-status-evaluator.cjs") as { parsePacLogRecords(value: string): Array>; }; +const { extractPacSourceObservation } = require("../native/cicd/pac-status-evaluator.cjs") as { + extractPacSourceObservation(records: Array>, sourceCommit: string): Record; +}; describe("PaC 失败证据合同", () => { test("真实 sentinel TaskRun 日志保留 typed 首断点", () => { @@ -52,6 +55,39 @@ describe("PaC 失败证据合同", () => { expect(compactStatus).toMatchObject({ artifact: { traceId: row.traceId, firstBreak: row.firstBreak, error: row.error } }); }); + test("feature-config warning 在 status、history、debug-step 同构有界投影", () => { + const observation = extractPacSourceObservation([ + { + event: "pac-delivery-plan", + sourceCommitId: "a".repeat(40), + affectedServices: [], + rolloutServices: [], + buildServices: [], + reusedServices: [], + }, + { + event: "feature-config-schema-validation", + warning: true, + blocking: false, + code: "feature-config-input-missing", + schemaPath: "config/feature-config.schema.json", + errorCount: 1, + firstError: "rendered workload candidate snapshot is missing", + mutation: false, + valuesPrinted: false, + }, + ]); + const expected = "feature-config-schema: warning=true blocking=false code=feature-config-input-missing path=config/feature-config.schema.json errors=1 first=rendered workload candidate snapshot is missing"; + const history = renderHistory({ rows: [{ id: "run", sourceObservation: observation }], detailId: "run", config: {}, historyErrors: [], next: {} }).renderedText; + const debug = renderDebugStep({ realRun: { found: true, pipelineRun: {}, artifact: { sourceObservation: observation } }, checks: [], target: {}, next: {} }).renderedText; + const status = renderStatus({ summary: { latestPipelineRun: {}, artifact: {}, diagnostics: {}, sourceObservation: observation }, consumer: {}, deliveryAuthority: {}, config: {}, coverage: [], target: {}, next: {} }).renderedText; + expect(status).toContain(expected); + expect(debug).toContain(expected); + expect(history).toContain("featureConfigCode"); + expect(history).toContain("feature-config-input-missing"); + expect(JSON.stringify(observation)).not.toContain("process.env"); + }); + test("collector 遍历全部 matching TaskRun,terminal record 仍限定合同任务", () => { const remote = readFileSync(resolve(import.meta.dir, "platform-infra-pipelines-as-code-remote.sh"), "utf8"); expect(remote).toContain("for (const item of matching)"); diff --git a/scripts/src/platform-infra-pac-feature-config-projection.test.ts b/scripts/src/platform-infra-pac-feature-config-projection.test.ts new file mode 100644 index 00000000..735b1e60 --- /dev/null +++ b/scripts/src/platform-infra-pac-feature-config-projection.test.ts @@ -0,0 +1,35 @@ +import { expect, test } from "bun:test"; + +import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection"; + +test("status and debug share one bounded feature config line", () => { + const observation = { + warning: true, + blocking: false, + code: "feature-config-value-mismatch", + schemaPath: "config/feature-config.schema.json", + errorCount: 2, + firstError: " /webProbeSentinel required must have required property ", + }; + const line = renderFeatureConfigSchemaLine(observation); + expect(line).toBe(" feature-config-schema: warning=true blocking=false code=feature-config-value-mismatch path=config/feature-config.schema.json errors=2 first=/webProbeSentinel required must have required property"); + expect(line.length).toBeLessThan(300); +}); + +test("history uses the same bounded observation fields", () => { + expect(featureConfigSchemaHistoryFields({ + warning: true, + blocking: false, + code: "feature-config-schema-missing", + schemaPath: "config/feature-config.schema.json", + errorCount: 1, + firstError: "schema file is missing", + })).toEqual([ + ["featureConfigWarning", "true"], + ["featureConfigBlocking", "false"], + ["featureConfigCode", "feature-config-schema-missing"], + ["featureConfigSchemaPath", "config/feature-config.schema.json"], + ["featureConfigErrorCount", "1"], + ["featureConfigFirstError", "schema file is missing"], + ]); +}); diff --git a/scripts/src/platform-infra-pac-feature-config-projection.ts b/scripts/src/platform-infra-pac-feature-config-projection.ts new file mode 100644 index 00000000..5532aa34 --- /dev/null +++ b/scripts/src/platform-infra-pac-feature-config-projection.ts @@ -0,0 +1,35 @@ +export function renderFeatureConfigSchemaLine(value: unknown): string { + const featureConfigSchema = record(value); + return ` feature-config-schema: warning=${boolText(featureConfigSchema.warning)} blocking=${boolText(featureConfigSchema.blocking)} code=${stringValue(featureConfigSchema.code)} path=${stringValue(featureConfigSchema.schemaPath)} errors=${stringValue(featureConfigSchema.errorCount)} first=${compactLine(stringValue(featureConfigSchema.firstError))}`; +} + +export function featureConfigSchemaHistoryFields(value: unknown): string[][] { + const featureConfigSchema = record(value); + return [ + ["featureConfigWarning", stringValue(featureConfigSchema.warning)], + ["featureConfigBlocking", stringValue(featureConfigSchema.blocking)], + ["featureConfigCode", stringValue(featureConfigSchema.code)], + ["featureConfigSchemaPath", stringValue(featureConfigSchema.schemaPath)], + ["featureConfigErrorCount", stringValue(featureConfigSchema.errorCount)], + ["featureConfigFirstError", compactLine(stringValue(featureConfigSchema.firstError))], + ]; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function stringValue(value: unknown, fallback = "-"): string { + if (typeof value === "string" && value.length > 0) return value; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return fallback; +} + +function boolText(value: unknown): string { + return value === true ? "true" : "false"; +} + +function compactLine(value: string): string { + const trimmed = value.replace(/\s+/gu, " ").trim(); + return trimmed.length > 0 ? trimmed.slice(0, 120) : "-"; +} 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 42ac07e9..f3a92889 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 @@ -7,7 +7,7 @@ import { resolve } from "node:path"; import { rootPath } from "./config"; import { applyNodeRuntimeDeployYamlOverlay, nodeRuntimeDeployYamlOverlayShellScript } from "./hwlab-node/deploy-overlay"; import { hwlabRuntimePipelineProvenance } from "./hwlab-node/pipeline-provenance"; -import { nodeRuntimePipelineProvenancePostprocessScript } from "./hwlab-node/render"; +import { nodeRuntimeFeatureConfigSchemaStage, nodeRuntimePipelinePostprocessScript, nodeRuntimePipelineProvenancePostprocessScript } from "./hwlab-node/render"; import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe"; import { hwlabRuntimeLaneSpecForNode, parseHwlabRuntimePipelineProvenance } from "./hwlab-node-lanes"; import { pipelineProvenanceAnnotationKeys, pipelineProvenanceAnnotations } from "./pipeline-provenance"; @@ -35,6 +35,7 @@ import { observePacSourceArtifactRuntime, selectPipelineRunBySourceCommit, } from "../native/cicd/pac-source-artifact-runtime.mjs"; +import { renderGitOpsResources } from "../native/cicd/publish-unidesk-host-gitops.mjs"; const sourceCommit = "a".repeat(40); const provenance = { @@ -238,6 +239,189 @@ describe("HWLAB YAML-owned Pipeline provenance", () => { }, ownerPath, configRef)).toThrow("mode must be one of remote-pipeline-annotation"); }); + test("runtime GitOps guard keeps Tekton scripts below the owning YAML budget", () => { + const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-hwlab-runtime-gitops-guard-")); + try { + const spec = hwlabRuntimeLaneSpecForNode("v03", "NC01"); + const budget = spec.pipelineProvenance?.maxInlineScriptBytes; + expect(budget).toBeNumber(); + const overlay = nodeRuntimeRenderOverlay(spec); + const pipelineDir = resolve(temporary, spec.tektonDir); + const pipelinePath = resolve(pipelineDir, "pipeline.yaml"); + const configMapPath = resolve(pipelineDir, "runtime-gitops-scripts.yaml"); + const gitMirrorDir = resolve(temporary, "devops-infra"); + mkdirSync(pipelineDir, { recursive: true }); + mkdirSync(gitMirrorDir, { recursive: true }); + writeFileSync(resolve(gitMirrorDir, "git-mirror.yaml"), Bun.YAML.stringify({ + apiVersion: "v1", + kind: "List", + items: [ + { + apiVersion: "v1", + kind: "ConfigMap", + metadata: { name: overlay.gitMirror.syncConfigMapName }, + data: { + "sync.sh": "#!/bin/sh\nset -eu\nrepo_url='ssh://git@github.com/pikasTech/HWLAB.git'\n", + "flush.sh": "#!/bin/sh\nset -eu\nremote='ssh://git@github.com/pikasTech/HWLAB.git'\n", + }, + }, + { + apiVersion: "apps/v1", + kind: "Deployment", + metadata: { name: overlay.gitMirror.serviceReadName }, + spec: { template: { spec: { containers: [{ name: "git-mirror", env: [] }] } } }, + }, + ], + })); + writeFileSync(pipelinePath, Bun.YAML.stringify({ + apiVersion: "tekton.dev/v1", + kind: "Pipeline", + metadata: { name: spec.pipeline, namespace: "hwlab-ci" }, + spec: { tasks: [{ + name: "gitops-promote", + taskSpec: { steps: [{ + name: "render", + script: [ + "if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE'", + "const fs = require(\"node:fs\");", + "const plan = JSON.parse(fs.readFileSync(process.argv[2], \"utf8\"));", + "const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : [];", + "const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : [];", + "const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true;", + "process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);", + "NODE", + "then", + " printf 'false' > /tekton/results/runtime-ready-required || true", + " echo '{\"event\":\"gitops-promote\",\"status\":\"skipped\",\"reason\":\"no-build-no-rollout-plan\"}'", + " exit 0", + "fi", + "node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images", + "node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images --check", + "", + ].join("\n"), + }] }, + }] }, + })); + const overlayBase64 = Buffer.from(JSON.stringify(overlay), "utf8").toString("base64"); + const postprocessInput = [ + "set -eu", + `render_dir=${JSON.stringify(temporary)}`, + `overlay_b64=${JSON.stringify(overlayBase64)}`, + ...nodeRuntimePipelinePostprocessScript(nodeRuntimeFeatureConfigSchemaStage(spec)), + ].join("\n"); + const result = spawnSync("sh", [], { + cwd: rootPath(), + input: postprocessInput, + encoding: "utf8", + timeout: 30_000, + }); + expect(result.status, result.stderr).toBe(0); + const guardedPipelineText = readFileSync(pipelinePath, "utf8"); + const repeated = spawnSync("node", [ + resolve(temporary, ".unidesk-runtime-gitops", "runtime-gitops-pipeline-guard.mjs"), + "--pipeline", pipelinePath, + ], { + cwd: rootPath(), + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_B64: overlayBase64 }, + encoding: "utf8", + timeout: 30_000, + }); + expect(repeated.status, repeated.stderr).toBe(0); + expect(readFileSync(pipelinePath, "utf8")).toBe(guardedPipelineText); + const pipeline = Bun.YAML.parse(guardedPipelineText) as Record; + const scripts = pipeline.spec.tasks.flatMap((task: Record) => task.taskSpec.steps.map((step: Record) => String(step.script ?? ""))); + expect(Math.max(...scripts.map((script: string) => Buffer.byteLength(script)))).toBeLessThanOrEqual(budget as number); + expect(scripts.join("\n")).toContain("process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);"); + expect(scripts.join("\n")).not.toContain("!willRunGitopsPromote"); + expect(scripts.join("\n")).toContain("no-build-no-rollout-plan-gitops-verify"); + expect(scripts.join("\n")).not.toContain('"status":"skipped","reason":"no-build-no-rollout-plan"'); + expect(scripts.join("\n")).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json"); + expect(scripts.join("\n")).toContain("export UNIDESK_AJV2020_BUNDLE='/etc/unidesk-cicd-runtime-gitops/ajv2020.min.js'"); + expect(scripts.join("\n")).toContain("feature-config-schema-validation"); + expect(scripts.join("\n")).toContain("cicd.feature_config.schema"); + expect(scripts.join("\n")).toContain("export OTEL_EXPORTER_OTLP_TRACES_ENDPOINT='http://otel-collector.platform-infra.svc.cluster.local:4318/v1/traces'"); + expect(scripts.join("\n")).toContain("export OTEL_SERVICE_NAME='unidesk-cicd'"); + expect(scripts.join("\n")).toContain("export OTEL_TRACES_SAMPLER_ARG='1'"); + expect(scripts.join("\n")).toContain("export OTEL_EXPORTER_TIMEOUT_MS='300'"); + expect(scripts.join("\n")).toContain("feature-config-validator-process-failure"); + expect(scripts.join("\n").indexOf("scripts/gitops-render.mjs --use-deploy-images")) + .toBeLessThan(scripts.join("\n").indexOf("feature-config-schema-validation")); + expect(scripts.join("\n")).not.toContain("NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS"); + expect(scripts.join("\n")).not.toContain("NODE_UNIDESK_RUNTIME_GITOPS_VERIFY"); + expect(scripts.join("\n")).not.toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64="); + expect(scripts.join("\n")).not.toContain(Buffer.from(JSON.stringify(overlay.observability), "utf8").toString("base64")); + const configMap = Bun.YAML.parse(readFileSync(configMapPath, "utf8")) as Record; + expect(configMap.metadata.labels["app.kubernetes.io/managed-by"]).toBe("unidesk-host-gitops"); + expect(JSON.parse(configMap.data["runtime-gitops-overlay.json"])).toEqual({ runtimePath: overlay.runtimePath, observability: overlay.observability, codeAgentRuntime: overlay.codeAgentRuntime }); + expect(Object.keys(configMap.data).sort()).toEqual([ + "ajv2020.min.js", + "feature-config-schema-warning.mjs", + "runtime-gitops-observability.mjs", + "runtime-gitops-overlay.json", + "runtime-gitops-postprocess.mjs", + "runtime-gitops-verify.mjs", + ]); + const hostConfig = Bun.YAML.parse(readFileSync(rootPath("config", "unidesk-host-k8s.yaml"), "utf8")) as Record; + const runtimePaths = new Set(hostConfig.delivery.changeDetection.runtimePaths as string[]); + for (const inputPath of [ + "bun.lock", + "config/hwlab-node-lanes.yaml", + "package.json", + "scripts/native/cicd/feature-config-schema-warning.mjs", + "scripts/native/hwlab/runtime-gitops-observability.mjs", + "scripts/native/hwlab/runtime-gitops-postprocess.mjs", + "scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs", + "scripts/native/hwlab/runtime-gitops-verify.mjs", + "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js", + "scripts/vendor/ajv-dist/8.17.1/manifest.json", + "scripts/src/config.ts", + "scripts/src/hwlab-node/render.ts", + "scripts/src/hwlab-node-lanes.ts", + "scripts/src/platform-infra-pipelines-as-code.ts", + "scripts/src/yaml-composition.ts", + ]) expect(runtimePaths.has(inputPath), inputPath).toBe(true); + expect(hostConfig.delivery.gitops.resources).toContainEqual({ + id: "hwlab-nc01-v03-runtime-gitops-scripts", + renderer: "hwlab-runtime-gitops-scripts", + configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01", + manifestPath: "deploy/gitops/unidesk-host/hwlab-nc01-v03-runtime-gitops-scripts.yaml", + namespace: "hwlab-ci", + }); + const gitopsResources = renderGitOpsResources(hostConfig.delivery.gitops, rootPath()); + expect(gitopsResources).toHaveLength(1); + expect(gitopsResources[0]?.path).toBe("deploy/gitops/unidesk-host/hwlab-nc01-v03-runtime-gitops-scripts.yaml"); + const gitopsConfigMap = Bun.YAML.parse(gitopsResources[0]?.content ?? "") as Record; + expect(gitopsConfigMap.metadata.name).toBe("hwlab-nc01-v03-ci-image-publish-runtime-gitops-scripts"); + expect(gitopsConfigMap.metadata.namespace).toBe("hwlab-ci"); + expect(gitopsConfigMap.data["runtime-gitops-postprocess.mjs"]).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE"); + expect(gitopsConfigMap.data["runtime-gitops-postprocess.mjs"].indexOf("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE")) + .toBeLessThan(gitopsConfigMap.data["runtime-gitops-postprocess.mjs"].indexOf("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64")); + const legacyPayload = Buffer.from(JSON.stringify({ runtimePath: overlay.runtimePath, observability: overlay.observability }), "utf8").toString("base64"); + const legacyPipeline = Bun.YAML.parse(readFileSync(pipelinePath, "utf8")) as Record; + const legacyStep = legacyPipeline.spec.tasks[0].taskSpec.steps[0]; + legacyStep.script = String(legacyStep.script).replaceAll( + "UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json", + `UNIDESK_RUNTIME_GITOPS_OVERLAY_B64='${legacyPayload}'`, + ); + writeFileSync(pipelinePath, Bun.YAML.stringify(legacyPipeline)); + const migrated = spawnSync("node", [ + rootPath("scripts", "native", "hwlab", "runtime-gitops-pipeline-guard.mjs"), + "--pipeline", pipelinePath, + ], { + cwd: rootPath(), + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_B64: Buffer.from(JSON.stringify(overlay), "utf8").toString("base64") }, + encoding: "utf8", + timeout: 30_000, + }); + expect(migrated.status).toBe(0); + const migratedText = readFileSync(pipelinePath, "utf8"); + expect(migratedText).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json"); + expect(migratedText).not.toContain(legacyPayload); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + test("normal control-plane postprocess writes exactly the shared four provenance fields", () => { const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-hwlab-pipeline-provenance-")); try { @@ -491,7 +675,15 @@ describe("runtime source-commit selection", () => { configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01", renderer: "hwlab-runtime-lane", mode: "remote-pipeline-annotation", + maxInlineScriptBytes: 32768, }); + expect(spec.codeAgentRuntime?.kafkaEventBridge).toMatchObject({ + enabled: true, + transactionalProjectorConsumerGroupId: "hwlab-v03-agentrun-event-projector", + hwlabEventConsumerGroupId: "hwlab-v03-workbench-live-sse", + }); + expect(spec.codeAgentRuntime?.kafkaEventBridge).not.toHaveProperty("features"); + expect(spec.codeAgentRuntime?.kafkaEventBridge).not.toHaveProperty("refreshReplay"); expect(remoteAnnotations).toEqual(pipelineProvenanceAnnotations(remoteProvenance)); expect(Object.keys(remoteAnnotations).sort()).toEqual(Object.values(pipelineProvenanceAnnotationKeys).sort()); expect(readFileSync(rootPath("scripts", "src", "hwlab-node", "render.ts"), "utf8")).not.toContain("overlay.sourceArtifactProvenance"); 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 626d1c4d..95899065 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -9,7 +9,7 @@ import { AGENTRUN_CONFIG_PATH, resolveAgentRunLaneTarget } from "./agentrun-lane import { renderAgentRunPipelineManifest } from "./agentrun-manifests"; import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes"; import { nodeRuntimeGitopsRoot } from "./hwlab-node/cleanup"; -import { nodeRuntimePipelinePostprocessScript } from "./hwlab-node/render"; +import { nodeRuntimeFeatureConfigSchemaStage, nodeRuntimePipelinePostprocessScript } from "./hwlab-node/render"; import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe"; import { hwlabRuntimePipelineProvenance } from "./hwlab-node/pipeline-provenance"; import { applyNodeRuntimeDeployYamlOverlay } from "./hwlab-node/deploy-overlay"; @@ -617,7 +617,7 @@ function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWor "set -eu", `render_dir=${shellQuote(output)}`, `overlay_b64=${shellQuote(overlay)}`, - ...nodeRuntimePipelinePostprocessScript(), + ...nodeRuntimePipelinePostprocessScript(nodeRuntimeFeatureConfigSchemaStage(spec)), ].join("\n"); runChecked("sh", [], temporarySource, 120_000, "HWLAB UniDesk domain postprocess/verify renderer", postprocess); const path = resolve(output, spec.tektonDir, "pipeline.yaml"); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 672b985a..2a576470 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -36,6 +36,7 @@ import { renderPacBootstrap, resolvePacBootstrapMirrorRepository, } from "./platform-infra-pipelines-as-code-bootstrap"; +import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; @@ -2286,6 +2287,7 @@ export function renderStatus(result: Record): RenderedCliResult stringValue(deliveryPlan.reusedServiceCount), stringValue(sourceObservation.reason), ]]), + renderFeatureConfigSchemaLine(sourceObservation.featureConfigSchema), "", "TASKRUN DURATIONS", ...(taskRuns.length === 0 ? ["-"] : table(["TASKRUN", "STATUS", "REASON", "FAILED_STEP", "EXIT", "DURATION_S"], taskRuns.map((item) => { @@ -2503,6 +2505,7 @@ export function renderDebugStep(result: Record): RenderedCliRes stringValue(catalog.digestCount), short(stringValue(artifact.gitopsCommit), 16), ]]), + renderFeatureConfigSchemaLine(record(artifact.sourceObservation).featureConfigSchema), ]), "", "STATUS EVALUATOR FIXTURES", @@ -2925,6 +2928,7 @@ function renderHistoryDetail(row: Record): string[] { ["rolloutServices", stringValue(deliveryPlan.rolloutServiceCount)], ["buildServices", stringValue(deliveryPlan.buildServiceCount)], ["reusedServices", stringValue(deliveryPlan.reusedServiceCount)], + ...featureConfigSchemaHistoryFields(sourceObservation.featureConfigSchema), ]), "", "TERMINAL EVIDENCE", diff --git a/scripts/src/secrets.ts b/scripts/src/secrets.ts index 3ae1a934..d7c391f2 100644 --- a/scripts/src/secrets.ts +++ b/scripts/src/secrets.ts @@ -40,7 +40,7 @@ interface SecretDistributionConfig { version: number; kind: "unidesk-secret-distribution"; metadata: { id: string; owner: string; relatedIssues: number[] }; - sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: RawSourceFileConfig[] }; + sources: { root: string; files: EnvSourceFileConfig[]; externalFiles: SourceFileConfig[] }; targets: DistributionTarget[]; kubernetesSecrets: KubernetesSecretConfig[]; } @@ -76,6 +76,11 @@ interface DistributionTarget { namespace: string; scope: string; enabled: boolean; + consumerRollout: { + deployments: string[]; + timeoutSeconds: number; + pollIntervalSeconds: number; + } | null; } interface KubernetesSecretConfig { @@ -263,8 +268,9 @@ async function sync(config: UniDeskConfig, options: SecretsOptions): Promise parseSourceFile(item, `${label}.sources.files[${index}]`)), externalFiles: sources.externalFiles === undefined ? [] - : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseRawSourceFile(item, `${label}.sources.externalFiles[${index}]`)), + : arrayOfRecords(sources.externalFiles, `${label}.sources.externalFiles`).map((item, index) => parseExternalSourceFile(item, `${label}.sources.externalFiles[${index}]`)), }, targets: arrayOfRecords(root.targets, `${label}.targets`).map((item, index) => parseTarget(item, `${label}.targets[${index}]`)), kubernetesSecrets: arrayOfRecords(root.kubernetesSecrets, `${label}.kubernetesSecrets`).map((item, index) => parseKubernetesSecret(item, `${label}.kubernetesSecrets[${index}]`)), @@ -320,13 +326,13 @@ function readSecretDistributionConfig(pathArg: string): SecretDistributionConfig return config; } -function parseSourceFile(record: Record, path: string): EnvSourceFileConfig { +function parseSourceFile(record: Record, path: string, external = false): EnvSourceFileConfig { const type = stringField(record, "type", path); if (type !== "env") throw new Error(`${path}.type must be env`); const createRaw = record.createIfMissing === undefined ? {} : objectField(record, "createIfMissing", path); const randomBase64UrlRaw = createRaw.randomBase64Url === undefined ? {} : objectField(createRaw, "randomBase64Url", `${path}.createIfMissing`); return { - sourceRef: sourceRefField(record, "sourceRef", path), + sourceRef: external ? externalSourceRefField(record, "sourceRef", path) : sourceRefField(record, "sourceRef", path), type, requiredKeys: stringArrayField(record, "requiredKeys", path).map((key, index) => envKeyValue(key, `${path}.requiredKeys[${index}]`)), required: true, @@ -339,15 +345,16 @@ function parseSourceFile(record: Record, path: string): EnvSour }; } -function parseRawSourceFile(record: Record, path: string): RawSourceFileConfig { +function parseExternalSourceFile(record: Record, path: string): SourceFileConfig { const type = stringField(record, "type", path); + if (type === "env") return parseSourceFile(record, path, true); if (type !== "raw-file") throw new Error(`${path}.type must be raw-file`); const createRaw = objectField(record, "createIfMissing", path); const enabled = booleanField(createRaw, "enabled", `${path}.createIfMissing`); - const randomHex = createRaw.randomHex === undefined + const randomHex: Record = createRaw.randomHex === undefined ? {} : { contents: randomByteCount(createRaw.randomHex, `${path}.createIfMissing.randomHex`) }; - const randomBase64Url = createRaw.randomBase64Url === undefined + const randomBase64Url: Record = createRaw.randomBase64Url === undefined ? {} : { contents: randomBase64UrlSpec(createRaw.randomBase64Url, `${path}.createIfMissing.randomBase64Url`) }; if (createRaw.values !== undefined) throw new Error(`${path}.createIfMissing.values is not allowed for raw-file sources`); @@ -369,12 +376,22 @@ function parseRawSourceFile(record: Record, path: string): RawS } function parseTarget(record: Record, path: string): DistributionTarget { + const rollout = record.consumerRollout === undefined ? null : objectField(record, "consumerRollout", path); + const deployments = rollout === null + ? [] + : stringArrayField(rollout, "deployments", `${path}.consumerRollout`).map((value, index) => kubernetesNameValue(value, `${path}.consumerRollout.deployments[${index}]`)); + const timeoutSeconds = rollout === null ? 0 : integerField(rollout, "timeoutSeconds", `${path}.consumerRollout`); + const pollIntervalSeconds = rollout === null ? 0 : integerField(rollout, "pollIntervalSeconds", `${path}.consumerRollout`); + if (rollout !== null && (deployments.length === 0 || timeoutSeconds <= 0 || pollIntervalSeconds <= 0)) { + throw new Error(`${path}.consumerRollout requires deployments, a positive timeoutSeconds, and a positive pollIntervalSeconds`); + } return { id: simpleId(stringField(record, "id", path), `${path}.id`), route: stringField(record, "route", path), namespace: kubernetesNameField(record, "namespace", path), scope: simpleId(stringField(record, "scope", path), `${path}.scope`), enabled: booleanField(record, "enabled", path), + consumerRollout: rollout === null ? null : { deployments, timeoutSeconds, pollIntervalSeconds }, }; } @@ -414,7 +431,8 @@ function validateDistributionConfig(config: SecretDistributionConfig): void { for (const item of secret.data) { const source = sources.get(item.sourceRef); if (source === undefined) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} references undeclared sourceRef ${item.sourceRef}`); - if (!source.requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); + const requiredKeys: readonly string[] = source.requiredKeys; + if (!requiredKeys.includes(item.sourceKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps undeclared source key ${item.sourceRef}.${item.sourceKey}`); if (targetKeys.has(item.targetKey)) throw new Error(`${config.configPath}.kubernetesSecrets.${secret.name} maps duplicate target key ${item.targetKey}`); targetKeys.add(item.targetKey); } @@ -429,7 +447,7 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean, const root = secretRoot(config); const materials = new Map(); const entries = allSourceFiles(config).filter((source) => selectedRefs.has(source.sourceRef)).map((source) => { - const sourcePath = source.type === "env" ? join(root, source.sourceRef) : externalSourcePath(source.sourceRef); + const sourcePath = sourcePathFor(root, source); const exists = existsSync(sourcePath); const existingText = exists ? readFileSync(sourcePath, "utf8") : ""; const existing = exists ? source.type === "env" ? parseEnvFile(existingText) : { contents: existingText } : {}; @@ -502,6 +520,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean, presence, bytes, fingerprint, + ownerOnly: true, + fileMode: "0600", valuesPrinted: false, }; } @@ -517,6 +537,8 @@ function inspectSources(config: SecretDistributionConfig, materialize: boolean, generatedKeys, unmaterializedGeneratedKeys, fingerprint: material.fingerprint, + ownerOnly: true, + fileMode: "0600", valuesPrinted: false, }; }); @@ -590,10 +612,17 @@ async function applyTargetSecrets(config: UniDeskConfig, target: DistributionTar const yaml = renderSecretManifest(target, secrets); const result = await capture(config, target.route, ["sh"], applySecretScript(target, secrets, yaml)); const parsed = parseJsonOutput(result.stdout); + const applied = result.exitCode === 0 && boolField(parsed, "ok", false); + const consumerRolloutStatus = applied && target.consumerRollout !== null + ? await waitForConsumerRollout(config, target) + : null; return { - ok: result.exitCode === 0 && boolField(parsed, "ok", false), + ok: applied && (consumerRolloutStatus === null || consumerRolloutStatus.ok === true), target: targetSummary(target), - summary: parsed, + summary: { + ...parsed, + ...(consumerRolloutStatus === null ? {} : { consumerRolloutStatus }), + }, remote: secretCaptureSummary(result), ...(options.raw ? { rawCaptureOmitted: true, rawPolicy: "Secret distribution never returns raw SSH capture because remote output can contain credential-bearing diagnostics." } : {}), }; @@ -640,6 +669,9 @@ ${Object.entries(secret.data).sort(([a], [b]) => a.localeCompare(b)).map(([key, function applySecretScript(target: DistributionTarget, secrets: DesiredSecret[], yaml: string): string { const manifestB64 = Buffer.from(yaml, "utf8").toString("base64"); const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64"); + const rollout = target.consumerRollout; + const rolloutDeployments = rollout?.deployments.join(" ") ?? ""; + const rolloutSummaryB64 = Buffer.from(JSON.stringify(rollout), "utf8").toString("base64"); return ` set -u tmp="$(mktemp -d)" @@ -656,22 +688,38 @@ else printf '%s\\n' 'skipped because namespace sync failed' >"$tmp/apply.err" apply_rc=1 fi -python3 - "$ns_rc" "$apply_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" <<'PY' +rollout_restart_rc=0 +: >"$tmp/rollout-restart.out" +: >"$tmp/rollout-restart.err" +if [ "$apply_rc" -eq 0 ] && [ -n '${rolloutDeployments}' ]; then + for deployment in ${rolloutDeployments}; do + kubectl -n ${target.namespace} rollout restart "deployment/$deployment" >>"$tmp/rollout-restart.out" 2>>"$tmp/rollout-restart.err" || { + rollout_restart_rc=$? + break + } + done +elif [ "$apply_rc" -ne 0 ] && [ -n '${rolloutDeployments}' ]; then + rollout_restart_rc=1 + printf '%s\\n' 'skipped because Secret apply failed' >"$tmp/rollout-restart.err" +fi +python3 - "$ns_rc" "$apply_rc" "$rollout_restart_rc" "$tmp/ns.out" "$tmp/ns.err" "$tmp/apply.out" "$tmp/apply.err" "$tmp/rollout-restart.out" "$tmp/rollout-restart.err" <<'PY' import base64, json, os, sys -ns_rc, apply_rc = int(sys.argv[1]), int(sys.argv[2]) +ns_rc, apply_rc, rollout_restart_rc = [int(value) for value in sys.argv[1:4]] def byte_count(path): try: return os.path.getsize(path) except FileNotFoundError: return 0 payload = { - "ok": ns_rc == 0 and apply_rc == 0, + "ok": ns_rc == 0 and apply_rc == 0 and rollout_restart_rc == 0, "namespace": "${target.namespace}", "secrets": json.loads(base64.b64decode("${summaryB64}").decode("utf-8")), + "consumerRollout": json.loads(base64.b64decode("${rolloutSummaryB64}").decode("utf-8")), "valuesPrinted": False, "steps": { - "namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[3]), "stderrBytes": byte_count(sys.argv[4]), "outputOmitted": True}, - "apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[5]), "stderrBytes": byte_count(sys.argv[6]), "outputOmitted": True}, + "namespace": {"exitCode": ns_rc, "stdoutBytes": byte_count(sys.argv[4]), "stderrBytes": byte_count(sys.argv[5]), "outputOmitted": True}, + "apply": {"exitCode": apply_rc, "stdoutBytes": byte_count(sys.argv[6]), "stderrBytes": byte_count(sys.argv[7]), "outputOmitted": True}, + "consumerRolloutRestart": {"exitCode": rollout_restart_rc, "stdoutBytes": byte_count(sys.argv[8]), "stderrBytes": byte_count(sys.argv[9]), "outputOmitted": True}, }, } print(json.dumps(payload, ensure_ascii=False, indent=2)) @@ -680,6 +728,105 @@ PY `; } +async function waitForConsumerRollout(config: UniDeskConfig, target: DistributionTarget): Promise> { + const rollout = target.consumerRollout; + if (rollout === null) return { ok: true, mode: "not-declared", mutation: false }; + const startedAt = Date.now(); + const deadline = startedAt + rollout.timeoutSeconds * 1000; + let attempts = 0; + let lastSummary: Record | null = null; + let lastRemote: Record | null = null; + while (true) { + attempts += 1; + const result = await capture(config, target.route, ["sh"], consumerRolloutStatusScript(target)); + const parsed = parseJsonOutput(result.stdout); + lastSummary = parsed; + lastRemote = secretCaptureSummary(result); + if (result.exitCode === 0 && boolField(parsed, "ok", false)) { + return { + ok: true, + mutation: false, + attempts, + elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000), + summary: parsed, + remote: lastRemote, + valuesPrinted: false, + }; + } + const remainingMs = deadline - Date.now(); + if (remainingMs <= 0) { + return { + ok: false, + mutation: false, + attempts, + elapsedSeconds: Math.ceil((Date.now() - startedAt) / 1000), + reason: "consumer-rollout-timeout", + summary: lastSummary, + remote: lastRemote, + valuesPrinted: false, + }; + } + await Bun.sleep(Math.min(rollout.pollIntervalSeconds * 1000, remainingMs)); + } +} + +function consumerRolloutStatusScript(target: DistributionTarget): string { + const rollout = target.consumerRollout; + if (rollout === null) throw new Error(`target ${target.id} does not declare consumerRollout`); + const commands = rollout.deployments.map((deployment, index) => [ + `kubectl -n ${target.namespace} get deployment ${deployment} -o json >"$tmp/deployment.${index}.json" 2>"$tmp/deployment.${index}.err"`, + `printf '%s' "$?" >"$tmp/deployment.${index}.rc"`, + ].join("\n")).join("\n"); + const expectedB64 = Buffer.from(JSON.stringify(rollout.deployments), "utf8").toString("base64"); + return ` +set -u +tmp="$(mktemp -d)" +trap 'rm -rf "$tmp"' EXIT +${commands} +python3 - "$tmp" <<'PY' +import base64, json, os, sys +tmp = sys.argv[1] +expected = json.loads(base64.b64decode("${expectedB64}").decode("utf-8")) +items = [] +ok = True +for index, name in enumerate(expected): + try: + rc = int(open(os.path.join(tmp, f"deployment.{index}.rc"), encoding="utf-8").read() or "1") + except FileNotFoundError: + rc = 1 + try: + observed = json.load(open(os.path.join(tmp, f"deployment.{index}.json"), encoding="utf-8")) + except Exception: + observed = None + metadata = (observed or {}).get("metadata") or {} + spec = (observed or {}).get("spec") or {} + status = (observed or {}).get("status") or {} + generation = int(metadata.get("generation") or 0) + observed_generation = int(status.get("observedGeneration") or 0) + desired = int(spec.get("replicas") or 0) + updated = int(status.get("updatedReplicas") or 0) + ready = int(status.get("readyReplicas") or 0) + available = int(status.get("availableReplicas") or 0) + item_ok = rc == 0 and desired > 0 and observed_generation >= generation and updated >= desired and ready >= desired and available >= desired + ok = ok and item_ok + items.append({ + "name": name, + "exists": rc == 0, + "generation": generation, + "observedGeneration": observed_generation, + "desiredReplicas": desired, + "updatedReplicas": updated, + "readyReplicas": ready, + "availableReplicas": available, + "ready": item_ok, + }) +payload = {"ok": ok, "namespace": "${target.namespace}", "deployments": items, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False, indent=2)) +sys.exit(0 if ok else 1) +PY +`; +} + function statusSecretScript(target: DistributionTarget, secrets: DesiredSecret[]): string { const summaryB64 = Buffer.from(JSON.stringify(secrets.map(remoteSecretSummary)), "utf8").toString("base64"); const commands = secrets.map((secret, index) => [ @@ -761,6 +908,22 @@ function sourceSummary(sources: SourceInspection): Record { return { ok: sources.ok, root: sources.root, entries: sources.entries, valuesPrinted: false }; } +function compactSourceSummary(sources: SourceInspection): Record { + return { + ok: sources.ok, + entries: sources.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + type: entry.type, + presence: entry.presence ?? entry.exists, + missingKeys: entry.missingKeys ?? [], + action: entry.action ?? "none", + fingerprint: entry.fingerprint ?? null, + valuesPrinted: false, + })), + valuesPrinted: false, + }; +} + function desiredSecretSummary(secret: DesiredSecret): Record { return { name: secret.name, @@ -775,6 +938,17 @@ function desiredSecretSummary(secret: DesiredSecret): Record { }; } +function compactDesiredSecretSummary(secret: DesiredSecret): Record { + return { + name: secret.name, + secretName: secret.secretName, + keys: Object.keys(secret.data).sort(), + missingKeys: secret.missingKeys, + fingerprint: secret.fingerprint, + valuesPrinted: false, + }; +} + function remoteSecretSummary(secret: DesiredSecret): Record { return { name: secret.name, @@ -786,7 +960,7 @@ function remoteSecretSummary(secret: DesiredSecret): Record { } function targetSummary(target: DistributionTarget): Record { - return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope }; + return { id: target.id, route: target.route, namespace: target.namespace, scope: target.scope, consumerRollout: target.consumerRollout }; } function readOptionValue(args: string[], index: number, option: string): string { @@ -967,6 +1141,12 @@ function externalSourcePath(sourceRef: string): string { throw new Error(`external sourceRef ${sourceRef} must be an absolute path or a ~/ home path`); } +function sourcePathFor(root: string, source: SourceFileConfig): string { + return source.sourceRef.startsWith("~/") || isAbsolute(source.sourceRef) + ? externalSourcePath(source.sourceRef) + : join(root, source.sourceRef); +} + function sourceDataKeyField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (!/^[A-Za-z_][A-Za-z0-9._-]*$/u.test(value)) throw new Error(`${path}.${key} must be a source data key`); @@ -982,6 +1162,10 @@ function kubernetesNameField(obj: Record, key: string, path: st return yamlKubernetesNameField(obj, key, path, ""); } +function kubernetesNameValue(value: string, path: string): string { + return yamlKubernetesNameField({ value }, "value", path, ""); +} + function kubernetesSecretKeyField(obj: Record, key: string, path: string): string { const value = stringField(obj, key, path); if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${key} must be a Kubernetes Secret key`); diff --git a/scripts/src/selfmedia-config.ts b/scripts/src/selfmedia-config.ts index 4f4dde63..7cbb60e5 100644 --- a/scripts/src/selfmedia-config.ts +++ b/scripts/src/selfmedia-config.ts @@ -158,6 +158,7 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe if (boolean(ci.serviceAccountAutomount, `${path}.ci.serviceAccountAutomount`) !== false) throw new Error(`${path}.ci.serviceAccountAutomount must be false`); if (boolean(publicExposure.enabled, `${path}.deployment.publicExposure.enabled`) !== true) throw new Error(`${path}.deployment.publicExposure.enabled must be true`); if (integer(publicExposure.hostPort, `${path}.deployment.publicExposure.hostPort`) !== 4317) throw new Error(`${path}.deployment.publicExposure.hostPort must be 4317`); + validateAccessSecret(deployment, `${path}.deployment`); const gitopsWriteUrl = text(gitops.writeUrl, `${path}.gitops.writeUrl`); if (!gitopsWriteUrl.startsWith("http://gitea-http.")) throw new Error(`${path}.gitops.writeUrl must use internal Gitea authority`); return { @@ -206,6 +207,29 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe }; } +function validateAccessSecret(deployment: Record, path: string): void { + const secrets = record(deployment.secrets, `${path}.secrets`); + const access = record(secrets.access, `${path}.secrets.access`); + const target = record(access.target, `${path}.secrets.access.target`); + absolutePath(access.sourceRef, `${path}.secrets.access.sourceRef`); + kubernetesName(target.secretName, `${path}.secrets.access.target.secretName`); + if (boolean(target.readOnly, `${path}.secrets.access.target.readOnly`) !== true) { + throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.readOnly must be true`); + } + if (!Array.isArray(target.files) || target.files.length === 0) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files must be a non-empty array`); + const targetKeys = new Set(); + const mountPaths = new Set(); + target.files.forEach((value, index) => { + const file = record(value, `${path}.secrets.access.target.files[${index}]`); + const targetKey = text(file.targetKey, `${path}.secrets.access.target.files[${index}].targetKey`); + const mountPath = absolutePath(file.mountPath, `${path}.secrets.access.target.files[${index}].mountPath`); + if (targetKeys.has(targetKey)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate targetKey ${targetKey}`); + if (mountPaths.has(mountPath)) throw new Error(`${selfMediaConfigRef}.${path}.secrets.access.target.files contains duplicate mountPath ${mountPath}`); + targetKeys.add(targetKey); + mountPaths.add(mountPath); + }); +} + function parseCutoverTarget(id: string, value: Record): SelfMediaCutoverTarget { const path = `cutover.targets.${id}`; const source = record(value.source, `${path}.source`); diff --git a/scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js b/scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js new file mode 100644 index 00000000..9c1d30a6 --- /dev/null +++ b/scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js @@ -0,0 +1,9 @@ +/* ajv 8.17.1 (ajv2020): Another JSON Schema Validator */ +!function(e){if("object"==typeof exports&&"undefined"!=typeof module)module.exports=e();else if("function"==typeof define&&define.amd)define([],e);else{("undefined"!=typeof window?window:"undefined"!=typeof global?global:"undefined"!=typeof self?self:this).ajv2020=e()}}((function(){return function e(t,r,o){function s(n,i){if(!r[n]){if(!t[n]){var c="function"==typeof require&&require;if(!i&&c)return c(n,!0);if(a)return a(n,!0);var d=new Error("Cannot find module '"+n+"'");throw d.code="MODULE_NOT_FOUND",d}var l=r[n]={exports:{}};t[n][0].call(l.exports,(function(e){return s(t[n][1][e]||e)}),l,l.exports,e,t,r,o)}return r[n].exports}for(var a="function"==typeof require&&require,n=0;n1)return!1;const e=this._items[0];return""===e||'""'===e}get str(){var e;return null!==(e=this._str)&&void 0!==e?e:this._str=this._items.reduce(((e,t)=>`${e}${t}`),"")}get names(){var e;return null!==(e=this._names)&&void 0!==e?e:this._names=this._items.reduce(((e,t)=>(t instanceof s&&(e[t.str]=(e[t.str]||0)+1),e)),{})}}function n(e,...t){const r=[e[0]];let o=0;for(;o"),GTE:new o._Code(">="),LT:new o._Code("<"),LTE:new o._Code("<="),EQ:new o._Code("==="),NEQ:new o._Code("!=="),NOT:new o._Code("!"),OR:new o._Code("||"),AND:new o._Code("&&"),ADD:new o._Code("+")};class i{optimizeNodes(){return this}optimizeNames(e,t){return this}}class c extends i{constructor(e,t,r){super(),this.varKind=e,this.name=t,this.rhs=r}render({es5:e,_n:t}){return`${e?s.varKinds.var:this.varKind} ${this.name}${void 0===this.rhs?"":` = ${this.rhs}`};`+t}optimizeNames(e,t){if(e[this.name.str])return this.rhs&&(this.rhs=I(this.rhs,e,t)),this}get names(){return this.rhs instanceof o._CodeOrName?this.rhs.names:{}}}class d extends i{constructor(e,t,r){super(),this.lhs=e,this.rhs=t,this.sideEffects=r}render({_n:e}){return`${this.lhs} = ${this.rhs};`+e}optimizeNames(e,t){if(!(this.lhs instanceof o.Name)||e[this.lhs.str]||this.sideEffects)return this.rhs=I(this.rhs,e,t),this}get names(){return R(this.lhs instanceof o.Name?{}:{...this.lhs.names},this.rhs)}}class l extends d{constructor(e,t,r,o){super(e,r,o),this.op=t}render({_n:e}){return`${this.lhs} ${this.op}= ${this.rhs};`+e}}class u extends i{constructor(e){super(),this.label=e,this.names={}}render({_n:e}){return`${this.label}:`+e}}class m extends i{constructor(e){super(),this.label=e,this.names={}}render({_n:e}){return`break${this.label?` ${this.label}`:""};`+e}}class f extends i{constructor(e){super(),this.error=e}render({_n:e}){return`throw ${this.error};`+e}get names(){return this.error.names}}class p extends i{constructor(e){super(),this.code=e}render({_n:e}){return`${this.code};`+e}optimizeNodes(){return`${this.code}`?this:void 0}optimizeNames(e,t){return this.code=I(this.code,e,t),this}get names(){return this.code instanceof o._CodeOrName?this.code.names:{}}}class h extends i{constructor(e=[]){super(),this.nodes=e}render(e){return this.nodes.reduce(((t,r)=>t+r.render(e)),"")}optimizeNodes(){const{nodes:e}=this;let t=e.length;for(;t--;){const r=e[t].optimizeNodes();Array.isArray(r)?e.splice(t,1,...r):r?e[t]=r:e.splice(t,1)}return e.length>0?this:void 0}optimizeNames(e,t){const{nodes:r}=this;let o=r.length;for(;o--;){const s=r[o];s.optimizeNames(e,t)||(x(e,s.names),r.splice(o,1))}return r.length>0?this:void 0}get names(){return this.nodes.reduce(((e,t)=>O(e,t.names)),{})}}class y extends h{render(e){return"{"+e._n+super.render(e)+"}"+e._n}}class v extends h{}class g extends y{}g.kind="else";class $ extends y{constructor(e,t){super(t),this.condition=e}render(e){let t=`if(${this.condition})`+super.render(e);return this.else&&(t+="else "+this.else.render(e)),t}optimizeNodes(){super.optimizeNodes();const e=this.condition;if(!0===e)return this.nodes;let t=this.else;if(t){const e=t.optimizeNodes();t=this.else=Array.isArray(e)?new g(e):e}return t?!1===e?t instanceof $?t:t.nodes:this.nodes.length?this:new $(C(e),t instanceof $?[t]:t.nodes):!1!==e&&this.nodes.length?this:void 0}optimizeNames(e,t){var r;if(this.else=null===(r=this.else)||void 0===r?void 0:r.optimizeNames(e,t),super.optimizeNames(e,t)||this.else)return this.condition=I(this.condition,e,t),this}get names(){const e=super.names;return R(e,this.condition),this.else&&O(e,this.else.names),e}}$.kind="if";class _ extends y{}_.kind="for";class b extends _{constructor(e){super(),this.iteration=e}render(e){return`for(${this.iteration})`+super.render(e)}optimizeNames(e,t){if(super.optimizeNames(e,t))return this.iteration=I(this.iteration,e,t),this}get names(){return O(super.names,this.iteration.names)}}class w extends _{constructor(e,t,r,o){super(),this.varKind=e,this.name=t,this.from=r,this.to=o}render(e){const t=e.es5?s.varKinds.var:this.varKind,{name:r,from:o,to:a}=this;return`for(${t} ${r}=${o}; ${r}<${a}; ${r}++)`+super.render(e)}get names(){const e=R(super.names,this.from);return R(e,this.to)}}class P extends _{constructor(e,t,r,o){super(),this.loop=e,this.varKind=t,this.name=r,this.iterable=o}render(e){return`for(${this.varKind} ${this.name} ${this.loop} ${this.iterable})`+super.render(e)}optimizeNames(e,t){if(super.optimizeNames(e,t))return this.iterable=I(this.iterable,e,t),this}get names(){return O(super.names,this.iterable.names)}}class E extends y{constructor(e,t,r){super(),this.name=e,this.args=t,this.async=r}render(e){return`${this.async?"async ":""}function ${this.name}(${this.args})`+super.render(e)}}E.kind="func";class S extends h{render(e){return"return "+super.render(e)}}S.kind="return";class j extends y{render(e){let t="try"+super.render(e);return this.catch&&(t+=this.catch.render(e)),this.finally&&(t+=this.finally.render(e)),t}optimizeNodes(){var e,t;return super.optimizeNodes(),null===(e=this.catch)||void 0===e||e.optimizeNodes(),null===(t=this.finally)||void 0===t||t.optimizeNodes(),this}optimizeNames(e,t){var r,o;return super.optimizeNames(e,t),null===(r=this.catch)||void 0===r||r.optimizeNames(e,t),null===(o=this.finally)||void 0===o||o.optimizeNames(e,t),this}get names(){const e=super.names;return this.catch&&O(e,this.catch.names),this.finally&&O(e,this.finally.names),e}}class k extends y{constructor(e){super(),this.error=e}render(e){return`catch(${this.error})`+super.render(e)}}k.kind="catch";class N extends y{render(e){return"finally"+super.render(e)}}N.kind="finally";function O(e,t){for(const r in t)e[r]=(e[r]||0)+(t[r]||0);return e}function R(e,t){return t instanceof o._CodeOrName?O(e,t.names):e}function I(e,t,r){return e instanceof o.Name?a(e):(s=e)instanceof o._Code&&s._items.some((e=>e instanceof o.Name&&1===t[e.str]&&void 0!==r[e.str]))?new o._Code(e._items.reduce(((e,t)=>(t instanceof o.Name&&(t=a(t)),t instanceof o._Code?e.push(...t._items):e.push(t),e)),[])):e;var s;function a(e){const o=r[e.str];return void 0===o||1!==t[e.str]?e:(delete t[e.str],o)}}function x(e,t){for(const r in t)e[r]=(e[r]||0)-(t[r]||0)}function C(e){return"boolean"==typeof e||"number"==typeof e||null===e?!e:o._`!${D(e)}`}r.CodeGen=class{constructor(e,t={}){this._values={},this._blockStarts=[],this._constants={},this.opts={...t,_n:t.lines?"\n":""},this._extScope=e,this._scope=new s.Scope({parent:e}),this._nodes=[new v]}toString(){return this._root.render(this.opts)}name(e){return this._scope.name(e)}scopeName(e){return this._extScope.name(e)}scopeValue(e,t){const r=this._extScope.value(e,t);return(this._values[r.prefix]||(this._values[r.prefix]=new Set)).add(r),r}getScopeValue(e,t){return this._extScope.getValue(e,t)}scopeRefs(e){return this._extScope.scopeRefs(e,this._values)}scopeCode(){return this._extScope.scopeCode(this._values)}_def(e,t,r,o){const s=this._scope.toName(t);return void 0!==r&&o&&(this._constants[s.str]=r),this._leafNode(new c(e,s,r)),s}const(e,t,r){return this._def(s.varKinds.const,e,t,r)}let(e,t,r){return this._def(s.varKinds.let,e,t,r)}var(e,t,r){return this._def(s.varKinds.var,e,t,r)}assign(e,t,r){return this._leafNode(new d(e,t,r))}add(e,t){return this._leafNode(new l(e,r.operators.ADD,t))}code(e){return"function"==typeof e?e():e!==o.nil&&this._leafNode(new p(e)),this}object(...e){const t=["{"];for(const[r,s]of e)t.length>1&&t.push(","),t.push(r),(r!==s||this.opts.es5)&&(t.push(":"),(0,o.addCodeArg)(t,s));return t.push("}"),new o._Code(t)}if(e,t,r){if(this._blockNode(new $(e)),t&&r)this.code(t).else().code(r).endIf();else if(t)this.code(t).endIf();else if(r)throw new Error('CodeGen: "else" body without "then" body');return this}elseIf(e){return this._elseNode(new $(e))}else(){return this._elseNode(new g)}endIf(){return this._endBlockNode($,g)}_for(e,t){return this._blockNode(e),t&&this.code(t).endFor(),this}for(e,t){return this._for(new b(e),t)}forRange(e,t,r,o,a=(this.opts.es5?s.varKinds.var:s.varKinds.let)){const n=this._scope.toName(e);return this._for(new w(a,n,t,r),(()=>o(n)))}forOf(e,t,r,a=s.varKinds.const){const n=this._scope.toName(e);if(this.opts.es5){const e=t instanceof o.Name?t:this.var("_arr",t);return this.forRange("_i",0,o._`${e}.length`,(t=>{this.var(n,o._`${e}[${t}]`),r(n)}))}return this._for(new P("of",a,n,t),(()=>r(n)))}forIn(e,t,r,a=(this.opts.es5?s.varKinds.var:s.varKinds.const)){if(this.opts.ownProperties)return this.forOf(e,o._`Object.keys(${t})`,r);const n=this._scope.toName(e);return this._for(new P("in",a,n,t),(()=>r(n)))}endFor(){return this._endBlockNode(_)}label(e){return this._leafNode(new u(e))}break(e){return this._leafNode(new m(e))}return(e){const t=new S;if(this._blockNode(t),this.code(e),1!==t.nodes.length)throw new Error('CodeGen: "return" should have one node');return this._endBlockNode(S)}try(e,t,r){if(!t&&!r)throw new Error('CodeGen: "try" without "catch" and "finally"');const o=new j;if(this._blockNode(o),this.code(e),t){const e=this.name("e");this._currNode=o.catch=new k(e),t(e)}return r&&(this._currNode=o.finally=new N,this.code(r)),this._endBlockNode(k,N)}throw(e){return this._leafNode(new f(e))}block(e,t){return this._blockStarts.push(this._nodes.length),e&&this.code(e).endBlock(t),this}endBlock(e){const t=this._blockStarts.pop();if(void 0===t)throw new Error("CodeGen: not in self-balancing block");const r=this._nodes.length-t;if(r<0||void 0!==e&&r!==e)throw new Error(`CodeGen: wrong number of nodes: ${r} vs ${e} expected`);return this._nodes.length=t,this}func(e,t=o.nil,r,s){return this._blockNode(new E(e,t,r)),s&&this.code(s).endFunc(),this}endFunc(){return this._endBlockNode(E)}optimize(e=1){for(;e-- >0;)this._root.optimizeNodes(),this._root.optimizeNames(this._root.names,this._constants)}_leafNode(e){return this._currNode.nodes.push(e),this}_blockNode(e){this._currNode.nodes.push(e),this._nodes.push(e)}_endBlockNode(e,t){const r=this._currNode;if(r instanceof e||t&&r instanceof t)return this._nodes.pop(),this;throw new Error(`CodeGen: not in block "${t?`${e.kind}/${t.kind}`:e.kind}"`)}_elseNode(e){const t=this._currNode;if(!(t instanceof $))throw new Error('CodeGen: "else" without "if"');return this._currNode=t.else=e,this}get _root(){return this._nodes[0]}get _currNode(){const e=this._nodes;return e[e.length-1]}set _currNode(e){const t=this._nodes;t[t.length-1]=e}},r.not=C;const T=M(r.operators.AND);r.and=function(...e){return e.reduce(T)};const A=M(r.operators.OR);function M(e){return(t,r)=>t===o.nil?r:r===o.nil?t:o._`${D(t)} ${e} ${D(r)}`}function D(e){return e instanceof o.Name?e:o._`(${e})`}r.or=function(...e){return e.reduce(A)}},{"./code":1,"./scope":3}],3:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.ValueScope=r.ValueScopeName=r.Scope=r.varKinds=r.UsedValueState=void 0;const o=e("./code");class s extends Error{constructor(e){super(`CodeGen: "code" for ${e} not defined`),this.value=e.value}}var a;!function(e){e[e.Started=0]="Started",e[e.Completed=1]="Completed"}(a||(r.UsedValueState=a={})),r.varKinds={const:new o.Name("const"),let:new o.Name("let"),var:new o.Name("var")};class n{constructor({prefixes:e,parent:t}={}){this._names={},this._prefixes=e,this._parent=t}toName(e){return e instanceof o.Name?e:this.name(e)}name(e){return new o.Name(this._newName(e))}_newName(e){return`${e}${(this._names[e]||this._nameGroup(e)).index++}`}_nameGroup(e){var t,r;if((null===(r=null===(t=this._parent)||void 0===t?void 0:t._prefixes)||void 0===r?void 0:r.has(e))||this._prefixes&&!this._prefixes.has(e))throw new Error(`CodeGen: prefix "${e}" is not allowed in this scope`);return this._names[e]={prefix:e,index:0}}}r.Scope=n;class i extends o.Name{constructor(e,t){super(t),this.prefix=e}setValue(e,{property:t,itemIndex:r}){this.value=e,this.scopePath=o._`.${new o.Name(t)}[${r}]`}}r.ValueScopeName=i;const c=o._`\n`;r.ValueScope=class extends n{constructor(e){super(e),this._values={},this._scope=e.scope,this.opts={...e,_n:e.lines?c:o.nil}}get(){return this._scope}name(e){return new i(e,this._newName(e))}value(e,t){var r;if(void 0===t.ref)throw new Error("CodeGen: ref must be passed in value");const o=this.toName(e),{prefix:s}=o,a=null!==(r=t.key)&&void 0!==r?r:t.ref;let n=this._values[s];if(n){const e=n.get(a);if(e)return e}else n=this._values[s]=new Map;n.set(a,o);const i=this._scope[s]||(this._scope[s]=[]),c=i.length;return i[c]=t.ref,o.setValue(t,{property:s,itemIndex:c}),o}getValue(e,t){const r=this._values[e];if(r)return r.get(t)}scopeRefs(e,t=this._values){return this._reduceValues(t,(t=>{if(void 0===t.scopePath)throw new Error(`CodeGen: name "${t}" has no value`);return o._`${e}${t.scopePath}`}))}scopeCode(e=this._values,t,r){return this._reduceValues(e,(e=>{if(void 0===e.value)throw new Error(`CodeGen: name "${e}" has no value`);return e.value.code}),t,r)}_reduceValues(e,t,n={},i){let c=o.nil;for(const d in e){const l=e[d];if(!l)continue;const u=n[d]=n[d]||new Map;l.forEach((e=>{if(u.has(e))return;u.set(e,a.Started);let n=t(e);if(n){c=o._`${c}${this.opts.es5?r.varKinds.var:r.varKinds.const} ${e} = ${n};${this.opts._n}`}else{if(!(n=null==i?void 0:i(e)))throw new s(e);c=o._`${c}${n}${this.opts._n}`}u.set(e,a.Completed)}))}return c}}},{"./code":1}],4:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.extendErrors=r.resetErrorsCount=r.reportExtraError=r.reportError=r.keyword$DataError=r.keywordError=void 0;const o=e("./codegen"),s=e("./util"),a=e("./names");function n(e,t){const r=e.const("err",t);e.if(o._`${a.default.vErrors} === null`,(()=>e.assign(a.default.vErrors,o._`[${r}]`)),o._`${a.default.vErrors}.push(${r})`),e.code(o._`${a.default.errors}++`)}function i(e,t){const{gen:r,validateName:s,schemaEnv:a}=e;a.$async?r.throw(o._`new ${e.ValidationError}(${t})`):(r.assign(o._`${s}.errors`,t),r.return(!1))}r.keywordError={message({keyword:e}){return o.str`must pass "${e}" keyword validation`}},r.keyword$DataError={message({keyword:e,schemaType:t}){return t?o.str`"${e}" keyword must be ${t} ($data)`:o.str`"${e}" keyword is invalid ($data)`}},r.reportError=function(e,t=r.keywordError,s,a){const{it:c}=e,{gen:l,compositeRule:u,allErrors:m}=c,f=d(e,t,s);(null!=a?a:u||m)?n(l,f):i(c,o._`[${f}]`)},r.reportExtraError=function(e,t=r.keywordError,o){const{it:s}=e,{gen:c,compositeRule:l,allErrors:u}=s;n(c,d(e,t,o)),l||u||i(s,a.default.vErrors)},r.resetErrorsCount=function(e,t){e.assign(a.default.errors,t),e.if(o._`${a.default.vErrors} !== null`,(()=>e.if(t,(()=>e.assign(o._`${a.default.vErrors}.length`,t)),(()=>e.assign(a.default.vErrors,null)))))},r.extendErrors=function({gen:e,keyword:t,schemaValue:r,data:s,errsCount:n,it:i}){if(void 0===n)throw new Error("ajv implementation error");const c=e.name("err");e.forRange("i",n,a.default.errors,(n=>{e.const(c,o._`${a.default.vErrors}[${n}]`),e.if(o._`${c}.instancePath === undefined`,(()=>e.assign(o._`${c}.instancePath`,(0,o.strConcat)(a.default.instancePath,i.errorPath)))),e.assign(o._`${c}.schemaPath`,o.str`${i.errSchemaPath}/${t}`),i.opts.verbose&&(e.assign(o._`${c}.schema`,r),e.assign(o._`${c}.data`,s))}))};const c={keyword:new o.Name("keyword"),schemaPath:new o.Name("schemaPath"),params:new o.Name("params"),propertyName:new o.Name("propertyName"),message:new o.Name("message"),schema:new o.Name("schema"),parentSchema:new o.Name("parentSchema")};function d(e,t,r){const{createErrors:s}=e.it;return!1===s?o._`{}`:function(e,t,r={}){const{gen:s,it:n}=e,i=[l(n,r),u(e,r)];return function(e,{params:t,message:r},s){const{keyword:n,data:i,schemaValue:d,it:l}=e,{opts:u,propertyName:m,topSchemaRef:f,schemaPath:p}=l;s.push([c.keyword,n],[c.params,"function"==typeof t?t(e):t||o._`{}`]),u.messages&&s.push([c.message,"function"==typeof r?r(e):r]);u.verbose&&s.push([c.schema,d],[c.parentSchema,o._`${f}${p}`],[a.default.data,i]);m&&s.push([c.propertyName,m])}(e,t,i),s.object(...i)}(e,t,r)}function l({errorPath:e},{instancePath:t}){const r=t?o.str`${e}${(0,s.getErrorPath)(t,s.Type.Str)}`:e;return[a.default.instancePath,(0,o.strConcat)(a.default.instancePath,r)]}function u({keyword:e,it:{errSchemaPath:t}},{schemaPath:r,parentSchema:a}){let n=a?t:o.str`${t}/${e}`;return r&&(n=o.str`${n}${(0,s.getErrorPath)(r,s.Type.Str)}`),[c.schemaPath,n]}},{"./codegen":2,"./names":6,"./util":10}],5:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.resolveSchema=r.getCompilingSchema=r.resolveRef=r.compileSchema=r.SchemaEnv=void 0;const o=e("./codegen"),s=e("../runtime/validation_error"),a=e("./names"),n=e("./resolve"),i=e("./util"),c=e("./validate");class d{constructor(e){var t;let r;this.refs={},this.dynamicAnchors={},"object"==typeof e.schema&&(r=e.schema),this.schema=e.schema,this.schemaId=e.schemaId,this.root=e.root||this,this.baseId=null!==(t=e.baseId)&&void 0!==t?t:(0,n.normalizeId)(null==r?void 0:r[e.schemaId||"$id"]),this.schemaPath=e.schemaPath,this.localRefs=e.localRefs,this.meta=e.meta,this.$async=null==r?void 0:r.$async,this.refs={}}}function l(e){const t=m.call(this,e);if(t)return t;const r=(0,n.getFullPath)(this.opts.uriResolver,e.root.baseId),{es5:i,lines:d}=this.opts.code,{ownProperties:l}=this.opts,u=new o.CodeGen(this.scope,{es5:i,lines:d,ownProperties:l});let f;e.$async&&(f=u.scopeValue("Error",{ref:s.default,code:o._`require("ajv/dist/runtime/validation_error").default`}));const p=u.scopeName("validate");e.validateName=p;const h={gen:u,allErrors:this.opts.allErrors,data:a.default.data,parentData:a.default.parentData,parentDataProperty:a.default.parentDataProperty,dataNames:[a.default.data],dataPathArr:[o.nil],dataLevel:0,dataTypes:[],definedProperties:new Set,topSchemaRef:u.scopeValue("schema",!0===this.opts.code.source?{ref:e.schema,code:(0,o.stringify)(e.schema)}:{ref:e.schema}),validateName:p,ValidationError:f,schema:e.schema,schemaEnv:e,rootId:r,baseId:e.baseId||r,schemaPath:o.nil,errSchemaPath:e.schemaPath||(this.opts.jtd?"":"#"),errorPath:o._`""`,opts:this.opts,self:this};let y;try{this._compilations.add(e),(0,c.validateFunctionCode)(h),u.optimize(this.opts.code.optimize);const t=u.toString();y=`${u.scopeRefs(a.default.scope)}return ${t}`,this.opts.code.process&&(y=this.opts.code.process(y,e));const r=new Function(`${a.default.self}`,`${a.default.scope}`,y)(this,this.scope.get());if(this.scope.value(p,{ref:r}),r.errors=null,r.schema=e.schema,r.schemaEnv=e,e.$async&&(r.$async=!0),!0===this.opts.code.source&&(r.source={validateName:p,validateCode:t,scopeValues:u._values}),this.opts.unevaluated){const{props:e,items:t}=h;r.evaluated={props:e instanceof o.Name?void 0:e,items:t instanceof o.Name?void 0:t,dynamicProps:e instanceof o.Name,dynamicItems:t instanceof o.Name},r.source&&(r.source.evaluated=(0,o.stringify)(r.evaluated))}return e.validate=r,e}catch(t){throw delete e.validate,delete e.validateName,y&&this.logger.error("Error compiling schema, function code:",y),t}finally{this._compilations.delete(e)}}function u(e){return(0,n.inlineRef)(e.schema,this.opts.inlineRefs)?e.schema:e.validate?e:l.call(this,e)}function m(e){for(const o of this._compilations)if((t=o).schema===(r=e).schema&&t.root===r.root&&t.baseId===r.baseId)return o;var t,r}function f(e,t){let r;for(;"string"==typeof(r=this.refs[t]);)t=r;return r||this.schemas[t]||p.call(this,e,t)}function p(e,t){const r=this.opts.uriResolver.parse(t),o=(0,n._getFullPath)(this.opts.uriResolver,r);let s=(0,n.getFullPath)(this.opts.uriResolver,e.baseId,void 0);if(Object.keys(e.schema).length>0&&o===s)return y.call(this,r,e);const a=(0,n.normalizeId)(o),i=this.refs[a]||this.schemas[a];if("string"==typeof i){const t=p.call(this,e,i);if("object"!=typeof(null==t?void 0:t.schema))return;return y.call(this,r,t)}if("object"==typeof(null==i?void 0:i.schema)){if(i.validate||l.call(this,i),a===(0,n.normalizeId)(t)){const{schema:t}=i,{schemaId:r}=this.opts,o=t[r];return o&&(s=(0,n.resolveUrl)(this.opts.uriResolver,s,o)),new d({schema:t,schemaId:r,root:e,baseId:s})}return y.call(this,r,i)}}r.SchemaEnv=d,r.compileSchema=l,r.resolveRef=function(e,t,r){var o;r=(0,n.resolveUrl)(this.opts.uriResolver,t,r);const s=e.refs[r];if(s)return s;let a=f.call(this,e,r);if(void 0===a){const s=null===(o=e.localRefs)||void 0===o?void 0:o[r],{schemaId:n}=this.opts;s&&(a=new d({schema:s,schemaId:n,root:e,baseId:t}))}return void 0!==a?e.refs[r]=u.call(this,a):void 0},r.getCompilingSchema=m,r.resolveSchema=p;const h=new Set(["properties","patternProperties","enum","dependencies","definitions"]);function y(e,{baseId:t,schema:r,root:o}){var s;if("/"!==(null===(s=e.fragment)||void 0===s?void 0:s[0]))return;for(const o of e.fragment.slice(1).split("/")){if("boolean"==typeof r)return;const e=r[(0,i.unescapeFragment)(o)];if(void 0===e)return;const s="object"==typeof(r=e)&&r[this.opts.schemaId];!h.has(o)&&s&&(t=(0,n.resolveUrl)(this.opts.uriResolver,t,s))}let a;if("boolean"!=typeof r&&r.$ref&&!(0,i.schemaHasRulesButRef)(r,this.RULES)){const e=(0,n.resolveUrl)(this.opts.uriResolver,t,r.$ref);a=p.call(this,o,e)}const{schemaId:c}=this.opts;return a=a||new d({schema:r,schemaId:c,root:o,baseId:t}),a.schema!==a.root.schema?a:void 0}},{"../runtime/validation_error":32,"./codegen":2,"./names":6,"./resolve":8,"./util":10,"./validate":15}],6:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./codegen"),s={data:new o.Name("data"),valCxt:new o.Name("valCxt"),instancePath:new o.Name("instancePath"),parentData:new o.Name("parentData"),parentDataProperty:new o.Name("parentDataProperty"),rootData:new o.Name("rootData"),dynamicAnchors:new o.Name("dynamicAnchors"),vErrors:new o.Name("vErrors"),errors:new o.Name("errors"),this:new o.Name("this"),self:new o.Name("self"),scope:new o.Name("scope"),json:new o.Name("json"),jsonPos:new o.Name("jsonPos"),jsonLen:new o.Name("jsonLen"),jsonPart:new o.Name("jsonPart")};r.default=s},{"./codegen":2}],7:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./resolve");class s extends Error{constructor(e,t,r,s){super(s||`can't resolve reference ${r} from id ${t}`),this.missingRef=(0,o.resolveUrl)(e,t,r),this.missingSchema=(0,o.normalizeId)((0,o.getFullPath)(e,this.missingRef))}}r.default=s},{"./resolve":8}],8:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.getSchemaRefs=r.resolveUrl=r.normalizeId=r._getFullPath=r.getFullPath=r.inlineRef=void 0;const o=e("./util"),s=e("fast-deep-equal"),a=e("json-schema-traverse"),n=new Set(["type","format","pattern","maxLength","minLength","maxProperties","minProperties","maxItems","minItems","maximum","minimum","uniqueItems","multipleOf","required","enum","const"]);r.inlineRef=function(e,t=!0){return"boolean"==typeof e||(!0===t?!c(e):!!t&&d(e)<=t)};const i=new Set(["$ref","$recursiveRef","$recursiveAnchor","$dynamicRef","$dynamicAnchor"]);function c(e){for(const t in e){if(i.has(t))return!0;const r=e[t];if(Array.isArray(r)&&r.some(c))return!0;if("object"==typeof r&&c(r))return!0}return!1}function d(e){let t=0;for(const r in e){if("$ref"===r)return Infinity;if(t++,!n.has(r)&&("object"==typeof e[r]&&(0,o.eachItem)(e[r],(e=>t+=d(e))),Infinity===t))return Infinity}return t}function l(e,t="",r){!1!==r&&(t=f(t));const o=e.parse(t);return u(e,o)}function u(e,t){return e.serialize(t).split("#")[0]+"#"}r.getFullPath=l,r._getFullPath=u;const m=/#\/?$/;function f(e){return e?e.replace(m,""):""}r.normalizeId=f,r.resolveUrl=function(e,t,r){return r=f(r),e.resolve(t,r)};const p=/^[a-z_][-a-z0-9._]*$/i;r.getSchemaRefs=function(e,t){if("boolean"==typeof e)return{};const{schemaId:r,uriResolver:o}=this.opts,n=f(e[r]||t),i={"":n},c=l(o,n,!1),d={},u=new Set;return a(e,{allKeys:!0},((e,t,o,s)=>{if(void 0===s)return;const a=c+t;let n=i[s];function l(t){if(t=f(n?(0,this.opts.uriResolver.resolve)(n,t):t),u.has(t))throw h(t);u.add(t);let r=this.refs[t];return"string"==typeof r&&(r=this.refs[r]),"object"==typeof r?m(e,r.schema,t):t!==f(a)&&("#"===t[0]?(m(e,d[t],t),d[t]=e):this.refs[t]=a),t}function y(e){if("string"==typeof e){if(!p.test(e))throw new Error(`invalid anchor "${e}"`);l.call(this,`#${e}`)}}"string"==typeof e[r]&&(n=l.call(this,e[r])),y.call(this,e.$anchor),y.call(this,e.$dynamicAnchor),i[t]=n})),d;function m(e,t,r){if(void 0!==t&&!s(e,t))throw h(r)}function h(e){return new Error(`reference "${e}" resolves to more than one schema`)}}},{"./util":10,"fast-deep-equal":83,"json-schema-traverse":88}],9:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.getRules=r.isJSONType=void 0;const o=new Set(["string","number","integer","boolean","null","object","array"]);r.isJSONType=function(e){return"string"==typeof e&&o.has(e)},r.getRules=function(){const e={number:{type:"number",rules:[]},string:{type:"string",rules:[]},array:{type:"array",rules:[]},object:{type:"object",rules:[]}};return{types:{...e,integer:!0,boolean:!0,null:!0},rules:[{rules:[]},e.number,e.string,e.array,e.object],post:{rules:[]},all:{},keywords:{}}}},{}],10:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.checkStrictMode=r.getErrorPath=r.Type=r.useFunc=r.setEvaluated=r.evaluatedPropsToName=r.mergeEvaluated=r.eachItem=r.unescapeJsonPointer=r.escapeJsonPointer=r.escapeFragment=r.unescapeFragment=r.schemaRefOrVal=r.schemaHasRulesButRef=r.schemaHasRules=r.checkUnknownRules=r.alwaysValidSchema=r.toHash=void 0;const o=e("./codegen"),s=e("./codegen/code");function a(e,t=e.schema){const{opts:r,self:o}=e;if(!r.strictSchema)return;if("boolean"==typeof t)return;const s=o.RULES.keywords;for(const r in t)s[r]||p(e,`unknown keyword: "${r}"`)}function n(e,t){if("boolean"==typeof e)return!e;for(const r in e)if(t[r])return!0;return!1}function i(e){return"number"==typeof e?`${e}`:e.replace(/~/g,"~0").replace(/\//g,"~1")}function c(e){return e.replace(/~1/g,"/").replace(/~0/g,"~")}function d({mergeNames:e,mergeToName:t,mergeValues:r,resultToName:s}){return(a,n,i,c)=>{const d=void 0===i?n:i instanceof o.Name?(n instanceof o.Name?e(a,n,i):t(a,n,i),i):n instanceof o.Name?(t(a,i,n),n):r(n,i);return c!==o.Name||d instanceof o.Name?d:s(a,d)}}function l(e,t){if(!0===t)return e.var("props",!0);const r=e.var("props",o._`{}`);return void 0!==t&&u(e,r,t),r}function u(e,t,r){Object.keys(r).forEach((r=>e.assign(o._`${t}${(0,o.getProperty)(r)}`,!0)))}r.toHash=function(e){const t={};for(const r of e)t[r]=!0;return t},r.alwaysValidSchema=function(e,t){return"boolean"==typeof t?t:0===Object.keys(t).length||(a(e,t),!n(t,e.self.RULES.all))},r.checkUnknownRules=a,r.schemaHasRules=n,r.schemaHasRulesButRef=function(e,t){if("boolean"==typeof e)return!e;for(const r in e)if("$ref"!==r&&t.all[r])return!0;return!1},r.schemaRefOrVal=function({topSchemaRef:e,schemaPath:t},r,s,a){if(!a){if("number"==typeof r||"boolean"==typeof r)return r;if("string"==typeof r)return o._`${r}`}return o._`${e}${t}${(0,o.getProperty)(s)}`},r.unescapeFragment=function(e){return c(decodeURIComponent(e))},r.escapeFragment=function(e){return encodeURIComponent(i(e))},r.escapeJsonPointer=i,r.unescapeJsonPointer=c,r.eachItem=function(e,t){if(Array.isArray(e))for(const r of e)t(r);else t(e)},r.mergeEvaluated={props:d({mergeNames(e,t,r){return e.if(o._`${r} !== true && ${t} !== undefined`,(()=>{e.if(o._`${t} === true`,(()=>e.assign(r,!0)),(()=>e.assign(r,o._`${r} || {}`).code(o._`Object.assign(${r}, ${t})`)))}))},mergeToName(e,t,r){return e.if(o._`${r} !== true`,(()=>{!0===t?e.assign(r,!0):(e.assign(r,o._`${r} || {}`),u(e,r,t))}))},mergeValues(e,t){return!0===e||{...e,...t}},resultToName:l}),items:d({mergeNames(e,t,r){return e.if(o._`${r} !== true && ${t} !== undefined`,(()=>e.assign(r,o._`${t} === true ? true : ${r} > ${t} ? ${r} : ${t}`)))},mergeToName(e,t,r){return e.if(o._`${r} !== true`,(()=>e.assign(r,!0===t||o._`${r} > ${t} ? ${r} : ${t}`)))},mergeValues(e,t){return!0===e||Math.max(e,t)},resultToName(e,t){return e.var("items",t)}})},r.evaluatedPropsToName=l,r.setEvaluated=u;const m={};var f;function p(e,t,r=e.opts.strictSchema){if(r){if(t=`strict mode: ${t}`,!0===r)throw new Error(t);e.self.logger.warn(t)}}r.useFunc=function(e,t){return e.scopeValue("func",{ref:t,code:m[t.code]||(m[t.code]=new s._Code(t.code))})},function(e){e[e.Num=0]="Num",e[e.Str=1]="Str"}(f||(r.Type=f={})),r.getErrorPath=function(e,t,r){if(e instanceof o.Name){const s=t===f.Num;return r?s?o._`"[" + ${e} + "]"`:o._`"['" + ${e} + "']"`:s?o._`"/" + ${e}`:o._`"/" + ${e}.replace(/~/g, "~0").replace(/\\//g, "~1")`}return r?(0,o.getProperty)(e).toString():"/"+i(e)},r.checkStrictMode=p},{"./codegen":2,"./codegen/code":1}],11:[function(e,t,r){"use strict";function o(e,t){return t.rules.some((t=>s(e,t)))}function s(e,t){var r;return void 0!==e[t.keyword]||(null===(r=t.definition.implements)||void 0===r?void 0:r.some((t=>void 0!==e[t])))}Object.defineProperty(r,"__esModule",{value:!0}),r.shouldUseRule=r.shouldUseGroup=r.schemaHasRulesForType=void 0,r.schemaHasRulesForType=function({schema:e,self:t},r){const s=t.RULES.types[r];return s&&!0!==s&&o(e,s)},r.shouldUseGroup=o,r.shouldUseRule=s},{}],12:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.boolOrEmptySchema=r.topBoolOrEmptySchema=void 0;const o=e("../errors"),s=e("../codegen"),a=e("../names"),n={message:"boolean schema is false"};function i(e,t){const{gen:r,data:s}=e;(0,o.reportError)({gen:r,keyword:"false schema",data:s,schema:!1,schemaCode:!1,schemaValue:!1,params:{},it:e},n,void 0,t)}r.topBoolOrEmptySchema=function(e){const{gen:t,schema:r,validateName:o}=e;!1===r?i(e,!1):"object"==typeof r&&!0===r.$async?t.return(a.default.data):(t.assign(s._`${o}.errors`,null),t.return(!0))},r.boolOrEmptySchema=function(e,t){const{gen:r,schema:o}=e;!1===o?(r.var(t,!1),i(e)):r.var(t,!0)}},{"../codegen":2,"../errors":4,"../names":6}],13:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.reportTypeError=r.checkDataTypes=r.checkDataType=r.coerceAndCheckDataType=r.getJSONTypes=r.getSchemaTypes=r.DataType=void 0;const o=e("../rules"),s=e("./applicability"),a=e("../errors"),n=e("../codegen"),i=e("../util");var c;function d(e){const t=Array.isArray(e)?e:e?[e]:[];if(t.every(o.isJSONType))return t;throw new Error("type must be JSONType or JSONType[]: "+t.join(","))}!function(e){e[e.Correct=0]="Correct",e[e.Wrong=1]="Wrong"}(c||(r.DataType=c={})),r.getSchemaTypes=function(e){const t=d(e.type);if(t.includes("null")){if(!1===e.nullable)throw new Error("type: null contradicts nullable: false")}else{if(!t.length&&void 0!==e.nullable)throw new Error('"nullable" cannot be used without "type"');!0===e.nullable&&t.push("null")}return t},r.getJSONTypes=d,r.coerceAndCheckDataType=function(e,t){const{gen:r,data:o,opts:a}=e,i=function(e,t){return t?e.filter((e=>l.has(e)||"array"===t&&"array"===e)):[]}(t,a.coerceTypes),d=t.length>0&&!(0===i.length&&1===t.length&&(0,s.schemaHasRulesForType)(e,t[0]));if(d){const s=m(t,o,a.strictNumbers,c.Wrong);r.if(s,(()=>{i.length?function(e,t,r){const{gen:o,data:s,opts:a}=e,i=o.let("dataType",n._`typeof ${s}`),c=o.let("coerced",n._`undefined`);"array"===a.coerceTypes&&o.if(n._`${i} == 'object' && Array.isArray(${s}) && ${s}.length == 1`,(()=>o.assign(s,n._`${s}[0]`).assign(i,n._`typeof ${s}`).if(m(t,s,a.strictNumbers),(()=>o.assign(c,s)))));o.if(n._`${c} !== undefined`);for(const e of r)(l.has(e)||"array"===e&&"array"===a.coerceTypes)&&d(e);function d(e){switch(e){case"string":return void o.elseIf(n._`${i} == "number" || ${i} == "boolean"`).assign(c,n._`"" + ${s}`).elseIf(n._`${s} === null`).assign(c,n._`""`);case"number":return void o.elseIf(n._`${i} == "boolean" || ${s} === null + || (${i} == "string" && ${s} && ${s} == +${s})`).assign(c,n._`+${s}`);case"integer":return void o.elseIf(n._`${i} === "boolean" || ${s} === null + || (${i} === "string" && ${s} && ${s} == +${s} && !(${s} % 1))`).assign(c,n._`+${s}`);case"boolean":return void o.elseIf(n._`${s} === "false" || ${s} === 0 || ${s} === null`).assign(c,!1).elseIf(n._`${s} === "true" || ${s} === 1`).assign(c,!0);case"null":return o.elseIf(n._`${s} === "" || ${s} === 0 || ${s} === false`),void o.assign(c,null);case"array":o.elseIf(n._`${i} === "string" || ${i} === "number" + || ${i} === "boolean" || ${s} === null`).assign(c,n._`[${s}]`)}}o.else(),p(e),o.endIf(),o.if(n._`${c} !== undefined`,(()=>{o.assign(s,c),function({gen:e,parentData:t,parentDataProperty:r},o){e.if(n._`${t} !== undefined`,(()=>e.assign(n._`${t}[${r}]`,o)))}(e,c)}))}(e,t,i):p(e)}))}return d};const l=new Set(["string","number","integer","boolean","null"]);function u(e,t,r,o=c.Correct){const s=o===c.Correct?n.operators.EQ:n.operators.NEQ;let a;switch(e){case"null":return n._`${t} ${s} null`;case"array":a=n._`Array.isArray(${t})`;break;case"object":a=n._`${t} && typeof ${t} == "object" && !Array.isArray(${t})`;break;case"integer":a=i(n._`!(${t} % 1) && !isNaN(${t})`);break;case"number":a=i();break;default:return n._`typeof ${t} ${s} ${e}`}return o===c.Correct?a:(0,n.not)(a);function i(e=n.nil){return(0,n.and)(n._`typeof ${t} == "number"`,e,r?n._`isFinite(${t})`:n.nil)}}function m(e,t,r,o){if(1===e.length)return u(e[0],t,r,o);let s;const a=(0,i.toHash)(e);if(a.array&&a.object){const e=n._`typeof ${t} != "object"`;s=a.null?e:n._`!${t} || ${e}`,delete a.null,delete a.array,delete a.object}else s=n.nil;a.number&&delete a.integer;for(const e in a)s=(0,n.and)(s,u(e,t,r,o));return s}r.checkDataType=u,r.checkDataTypes=m;const f={message({schema:e}){return`must be ${e}`},params({schema:e,schemaValue:t}){return"string"==typeof e?n._`{type: ${e}}`:n._`{type: ${t}}`}};function p(e){const t=function(e){const{gen:t,data:r,schema:o}=e,s=(0,i.schemaRefOrVal)(e,o,"type");return{gen:t,keyword:"type",data:r,schema:o.type,schemaCode:s,schemaValue:s,parentSchema:o,params:{},it:e}}(e);(0,a.reportError)(t,f)}r.reportTypeError=p},{"../codegen":2,"../errors":4,"../rules":9,"../util":10,"./applicability":11}],14:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.assignDefaults=void 0;const o=e("../codegen"),s=e("../util");function a(e,t,r){const{gen:a,compositeRule:n,data:i,opts:c}=e;if(void 0===r)return;const d=o._`${i}${(0,o.getProperty)(t)}`;if(n)return void(0,s.checkStrictMode)(e,`default is ignored for: ${d}`);let l=o._`${d} === undefined`;"empty"===c.useDefaults&&(l=o._`${l} || ${d} === null || ${d} === ""`),a.if(l,o._`${d} = ${(0,o.stringify)(r)}`)}r.assignDefaults=function(e,t){const{properties:r,items:o}=e.schema;if("object"===t&&r)for(const t in r)a(e,t,r[t].default);else"array"===t&&Array.isArray(o)&&o.forEach(((t,r)=>a(e,r,t.default)))}},{"../codegen":2,"../util":10}],15:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.getData=r.KeywordCxt=r.validateFunctionCode=void 0;const o=e("./boolSchema"),s=e("./dataType"),a=e("./applicability"),n=e("./dataType"),i=e("./defaults"),c=e("./keyword"),d=e("./subschema"),l=e("../codegen"),u=e("../names"),m=e("../resolve"),f=e("../util"),p=e("../errors");function h({gen:e,validateName:t,schema:r,schemaEnv:o,opts:s},a){s.code.es5?e.func(t,l._`${u.default.data}, ${u.default.valCxt}`,o.$async,(()=>{e.code(l._`"use strict"; ${y(r,s)}`),function(e,t){e.if(u.default.valCxt,(()=>{e.var(u.default.instancePath,l._`${u.default.valCxt}.${u.default.instancePath}`),e.var(u.default.parentData,l._`${u.default.valCxt}.${u.default.parentData}`),e.var(u.default.parentDataProperty,l._`${u.default.valCxt}.${u.default.parentDataProperty}`),e.var(u.default.rootData,l._`${u.default.valCxt}.${u.default.rootData}`),t.dynamicRef&&e.var(u.default.dynamicAnchors,l._`${u.default.valCxt}.${u.default.dynamicAnchors}`)}),(()=>{e.var(u.default.instancePath,l._`""`),e.var(u.default.parentData,l._`undefined`),e.var(u.default.parentDataProperty,l._`undefined`),e.var(u.default.rootData,u.default.data),t.dynamicRef&&e.var(u.default.dynamicAnchors,l._`{}`)}))}(e,s),e.code(a)})):e.func(t,l._`${u.default.data}, ${function(e){return l._`{${u.default.instancePath}="", ${u.default.parentData}, ${u.default.parentDataProperty}, ${u.default.rootData}=${u.default.data}${e.dynamicRef?l._`, ${u.default.dynamicAnchors}={}`:l.nil}}={}`}(s)}`,o.$async,(()=>e.code(y(r,s)).code(a)))}function y(e,t){const r="object"==typeof e&&e[t.schemaId];return r&&(t.code.source||t.code.process)?l._`/*# sourceURL=${r} */`:l.nil}function v(e,t){$(e)&&(_(e),g(e))?function(e,t){const{schema:r,gen:o,opts:s}=e;s.$comment&&r.$comment&&w(e);(function(e){const t=e.schema[e.opts.schemaId];t&&(e.baseId=(0,m.resolveUrl)(e.opts.uriResolver,e.baseId,t))})(e),function(e){if(e.schema.$async&&!e.schemaEnv.$async)throw new Error("async schema in sync schema")}(e);const a=o.const("_errs",u.default.errors);b(e,a),o.var(t,l._`${a} === ${u.default.errors}`)}(e,t):(0,o.boolOrEmptySchema)(e,t)}function g({schema:e,self:t}){if("boolean"==typeof e)return!e;for(const r in e)if(t.RULES.all[r])return!0;return!1}function $(e){return"boolean"!=typeof e.schema}function _(e){(0,f.checkUnknownRules)(e),function(e){const{schema:t,errSchemaPath:r,opts:o,self:s}=e;t.$ref&&o.ignoreKeywordsWithRef&&(0,f.schemaHasRulesButRef)(t,s.RULES)&&s.logger.warn(`$ref: keywords ignored in schema at path "${r}"`)}(e)}function b(e,t){if(e.opts.jtd)return P(e,[],!1,t);const r=(0,s.getSchemaTypes)(e.schema);P(e,r,!(0,s.coerceAndCheckDataType)(e,r),t)}function w({gen:e,schemaEnv:t,schema:r,errSchemaPath:o,opts:s}){const a=r.$comment;if(!0===s.$comment)e.code(l._`${u.default.self}.logger.log(${a})`);else if("function"==typeof s.$comment){const r=l.str`${o}/$comment`,s=e.scopeValue("root",{ref:t.root});e.code(l._`${u.default.self}.opts.$comment(${a}, ${r}, ${s}.schema)`)}}function P(e,t,r,o){const{gen:s,schema:i,data:c,allErrors:d,opts:m,self:p}=e,{RULES:h}=p;function y(f){(0,a.shouldUseGroup)(i,f)&&(f.type?(s.if((0,n.checkDataType)(f.type,c,m.strictNumbers)),E(e,f),1===t.length&&t[0]===f.type&&r&&(s.else(),(0,n.reportTypeError)(e)),s.endIf()):E(e,f),d||s.if(l._`${u.default.errors} === ${o||0}`))}!i.$ref||!m.ignoreKeywordsWithRef&&(0,f.schemaHasRulesButRef)(i,h)?(m.jtd||function(e,t){if(e.schemaEnv.meta||!e.opts.strictTypes)return;(function(e,t){if(!t.length)return;if(!e.dataTypes.length)return void(e.dataTypes=t);t.forEach((t=>{j(e.dataTypes,t)||k(e,`type "${t}" not allowed by context "${e.dataTypes.join(",")}"`)})),function(e,t){const r=[];for(const o of e.dataTypes)j(t,o)?r.push(o):t.includes("integer")&&"number"===o&&r.push("integer");e.dataTypes=r}(e,t)})(e,t),e.opts.allowUnionTypes||function(e,t){t.length>1&&(2!==t.length||!t.includes("null"))&&k(e,"use allowUnionTypes to allow union type keyword")}(e,t);!function(e,t){const r=e.self.RULES.all;for(const o in r){const s=r[o];if("object"==typeof s&&(0,a.shouldUseRule)(e.schema,s)){const{type:r}=s.definition;r.length&&!r.some((e=>S(t,e)))&&k(e,`missing type "${r.join(",")}" for keyword "${o}"`)}}}(e,e.dataTypes)}(e,t),s.block((()=>{for(const e of h.rules)y(e);y(h.post)}))):s.block((()=>O(e,"$ref",h.all.$ref.definition)))}function E(e,t){const{gen:r,schema:o,opts:{useDefaults:s}}=e;s&&(0,i.assignDefaults)(e,t.type),r.block((()=>{for(const r of t.rules)(0,a.shouldUseRule)(o,r)&&O(e,r.keyword,r.definition,t.type)}))}function S(e,t){return e.includes(t)||"number"===t&&e.includes("integer")}function j(e,t){return e.includes(t)||"integer"===t&&e.includes("number")}function k(e,t){(0,f.checkStrictMode)(e,t+=` at "${e.schemaEnv.baseId+e.errSchemaPath}" (strictTypes)`,e.opts.strictTypes)}r.validateFunctionCode=function(e){$(e)&&(_(e),g(e))?function(e){const{schema:t,opts:r,gen:o}=e;h(e,(()=>{r.$comment&&t.$comment&&w(e),function(e){const{schema:t,opts:r}=e;void 0!==t.default&&r.useDefaults&&r.strictSchema&&(0,f.checkStrictMode)(e,"default is ignored in the schema root")}(e),o.let(u.default.vErrors,null),o.let(u.default.errors,0),r.unevaluated&&function(e){const{gen:t,validateName:r}=e;e.evaluated=t.const("evaluated",l._`${r}.evaluated`),t.if(l._`${e.evaluated}.dynamicProps`,(()=>t.assign(l._`${e.evaluated}.props`,l._`undefined`))),t.if(l._`${e.evaluated}.dynamicItems`,(()=>t.assign(l._`${e.evaluated}.items`,l._`undefined`)))}(e),b(e),function(e){const{gen:t,schemaEnv:r,validateName:o,ValidationError:s,opts:a}=e;r.$async?t.if(l._`${u.default.errors} === 0`,(()=>t.return(u.default.data)),(()=>t.throw(l._`new ${s}(${u.default.vErrors})`))):(t.assign(l._`${o}.errors`,u.default.vErrors),a.unevaluated&&function({gen:e,evaluated:t,props:r,items:o}){r instanceof l.Name&&e.assign(l._`${t}.props`,r);o instanceof l.Name&&e.assign(l._`${t}.items`,o)}(e),t.return(l._`${u.default.errors} === 0`))}(e)}))}(e):h(e,(()=>(0,o.topBoolOrEmptySchema)(e)))};class N{constructor(e,t,r){if((0,c.validateKeywordUsage)(e,t,r),this.gen=e.gen,this.allErrors=e.allErrors,this.keyword=r,this.data=e.data,this.schema=e.schema[r],this.$data=t.$data&&e.opts.$data&&this.schema&&this.schema.$data,this.schemaValue=(0,f.schemaRefOrVal)(e,this.schema,r,this.$data),this.schemaType=t.schemaType,this.parentSchema=e.schema,this.params={},this.it=e,this.def=t,this.$data)this.schemaCode=e.gen.const("vSchema",x(this.$data,e));else if(this.schemaCode=this.schemaValue,!(0,c.validSchemaType)(this.schema,t.schemaType,t.allowUndefined))throw new Error(`${r} value must be ${JSON.stringify(t.schemaType)}`);("code"in t?t.trackErrors:!1!==t.errors)&&(this.errsCount=e.gen.const("_errs",u.default.errors))}result(e,t,r){this.failResult((0,l.not)(e),t,r)}failResult(e,t,r){this.gen.if(e),r?r():this.error(),t?(this.gen.else(),t(),this.allErrors&&this.gen.endIf()):this.allErrors?this.gen.endIf():this.gen.else()}pass(e,t){this.failResult((0,l.not)(e),void 0,t)}fail(e){if(void 0===e)return this.error(),void(this.allErrors||this.gen.if(!1));this.gen.if(e),this.error(),this.allErrors?this.gen.endIf():this.gen.else()}fail$data(e){if(!this.$data)return this.fail(e);const{schemaCode:t}=this;this.fail(l._`${t} !== undefined && (${(0,l.or)(this.invalid$data(),e)})`)}error(e,t,r){if(t)return this.setParams(t),this._error(e,r),void this.setParams({});this._error(e,r)}_error(e,t){(e?p.reportExtraError:p.reportError)(this,this.def.error,t)}$dataError(){(0,p.reportError)(this,this.def.$dataError||p.keyword$DataError)}reset(){if(void 0===this.errsCount)throw new Error('add "trackErrors" to keyword definition');(0,p.resetErrorsCount)(this.gen,this.errsCount)}ok(e){this.allErrors||this.gen.if(e)}setParams(e,t){t?Object.assign(this.params,e):this.params=e}block$data(e,t,r=l.nil){this.gen.block((()=>{this.check$data(e,r),t()}))}check$data(e=l.nil,t=l.nil){if(!this.$data)return;const{gen:r,schemaCode:o,schemaType:s,def:a}=this;r.if((0,l.or)(l._`${o} === undefined`,t)),e!==l.nil&&r.assign(e,!0),(s.length||a.validateSchema)&&(r.elseIf(this.invalid$data()),this.$dataError(),e!==l.nil&&r.assign(e,!1)),r.else()}invalid$data(){const{gen:e,schemaCode:t,schemaType:r,def:o,it:s}=this;return(0,l.or)(function(){if(r.length){if(!(t instanceof l.Name))throw new Error("ajv implementation error");const e=Array.isArray(r)?r:[r];return l._`${(0,n.checkDataTypes)(e,t,s.opts.strictNumbers,n.DataType.Wrong)}`}return l.nil}(),function(){if(o.validateSchema){const r=e.scopeValue("validate$data",{ref:o.validateSchema});return l._`!${r}(${t})`}return l.nil}())}subschema(e,t){const r=(0,d.getSubschema)(this.it,e);(0,d.extendSubschemaData)(r,this.it,e),(0,d.extendSubschemaMode)(r,e);const o={...this.it,...r,items:void 0,props:void 0};return v(o,t),o}mergeEvaluated(e,t){const{it:r,gen:o}=this;r.opts.unevaluated&&(!0!==r.props&&void 0!==e.props&&(r.props=f.mergeEvaluated.props(o,e.props,r.props,t)),!0!==r.items&&void 0!==e.items&&(r.items=f.mergeEvaluated.items(o,e.items,r.items,t)))}mergeValidEvaluated(e,t){const{it:r,gen:o}=this;if(r.opts.unevaluated&&(!0!==r.props||!0!==r.items))return o.if(t,(()=>this.mergeEvaluated(e,l.Name))),!0}}function O(e,t,r,o){const s=new N(e,r,t);"code"in r?r.code(s,o):s.$data&&r.validate?(0,c.funcKeywordCode)(s,r):"macro"in r?(0,c.macroKeywordCode)(s,r):(r.compile||r.validate)&&(0,c.funcKeywordCode)(s,r)}r.KeywordCxt=N;const R=/^\/(?:[^~]|~0|~1)*$/,I=/^([0-9]+)(#|\/(?:[^~]|~0|~1)*)?$/;function x(e,{dataLevel:t,dataNames:r,dataPathArr:o}){let s,a;if(""===e)return u.default.rootData;if("/"===e[0]){if(!R.test(e))throw new Error(`Invalid JSON-pointer: ${e}`);s=e,a=u.default.rootData}else{const n=I.exec(e);if(!n)throw new Error(`Invalid JSON-pointer: ${e}`);const i=+n[1];if(s=n[2],"#"===s){if(i>=t)throw new Error(c("property/index",i));return o[t-i]}if(i>t)throw new Error(c("data",i));if(a=r[t-i],!s)return a}let n=a;const i=s.split("/");for(const e of i)e&&(a=l._`${a}${(0,l.getProperty)((0,f.unescapeJsonPointer)(e))}`,n=l._`${n} && ${a}`);return n;function c(e,r){return`Cannot access ${e} ${r} levels up, current level is ${t}`}}r.getData=x},{"../codegen":2,"../errors":4,"../names":6,"../resolve":8,"../util":10,"./applicability":11,"./boolSchema":12,"./dataType":13,"./defaults":14,"./keyword":16,"./subschema":17}],16:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.validateKeywordUsage=r.validSchemaType=r.funcKeywordCode=r.macroKeywordCode=void 0;const o=e("../codegen"),s=e("../names"),a=e("../../vocabularies/code"),n=e("../errors");function i(e){const{gen:t,data:r,it:s}=e;t.if(s.parentData,(()=>t.assign(r,o._`${s.parentData}[${s.parentDataProperty}]`)))}function c(e,t,r){if(void 0===r)throw new Error(`keyword "${t}" failed to compile`);return e.scopeValue("keyword","function"==typeof r?{ref:r}:{ref:r,code:(0,o.stringify)(r)})}r.macroKeywordCode=function(e,t){const{gen:r,keyword:s,schema:a,parentSchema:n,it:i}=e,d=t.macro.call(i.self,a,n,i),l=c(r,s,d);!1!==i.opts.validateSchema&&i.self.validateSchema(d,!0);const u=r.name("valid");e.subschema({schema:d,schemaPath:o.nil,errSchemaPath:`${i.errSchemaPath}/${s}`,topSchemaRef:l,compositeRule:!0},u),e.pass(u,(()=>e.error(!0)))},r.funcKeywordCode=function(e,t){var r;const{gen:d,keyword:l,schema:u,parentSchema:m,$data:f,it:p}=e;!function({schemaEnv:e},t){if(t.async&&!e.$async)throw new Error("async keyword in sync schema")}(p,t);const h=!f&&t.compile?t.compile.call(p.self,u,m,p):t.validate,y=c(d,l,h),v=d.let("valid");function g(r=(t.async?o._`await `:o.nil)){d.assign(v,o._`${r}${(0,a.callValidateCode)(e,y,p.opts.passContext?s.default.this:s.default.self,!("compile"in t&&!f||!1===t.schema))}`,t.modifying)}function $(e){var r;d.if((0,o.not)(null!==(r=t.valid)&&void 0!==r?r:v),e)}e.block$data(v,(function(){if(!1===t.errors)g(),t.modifying&&i(e),$((()=>e.error()));else{const r=t.async?function(){const e=d.let("ruleErrs",null);return d.try((()=>g(o._`await `)),(t=>d.assign(v,!1).if(o._`${t} instanceof ${p.ValidationError}`,(()=>d.assign(e,o._`${t}.errors`)),(()=>d.throw(t))))),e}():function(){const e=o._`${y}.errors`;return d.assign(e,null),g(o.nil),e}();t.modifying&&i(e),$((()=>function(e,t){const{gen:r}=e;r.if(o._`Array.isArray(${t})`,(()=>{r.assign(s.default.vErrors,o._`${s.default.vErrors} === null ? ${t} : ${s.default.vErrors}.concat(${t})`).assign(s.default.errors,o._`${s.default.vErrors}.length`),(0,n.extendErrors)(e)}),(()=>e.error()))}(e,r)))}})),e.ok(null!==(r=t.valid)&&void 0!==r?r:v)},r.validSchemaType=function(e,t,r=!1){return!t.length||t.some((t=>"array"===t?Array.isArray(e):"object"===t?e&&"object"==typeof e&&!Array.isArray(e):typeof e==t||r&&void 0===e))},r.validateKeywordUsage=function({schema:e,opts:t,self:r,errSchemaPath:o},s,a){if(Array.isArray(s.keyword)?!s.keyword.includes(a):s.keyword!==a)throw new Error("ajv implementation error");const n=s.dependencies;if(null==n?void 0:n.some((t=>!Object.prototype.hasOwnProperty.call(e,t))))throw new Error(`parent schema must have dependencies of ${a}: ${n.join(",")}`);if(s.validateSchema){if(!s.validateSchema(e[a])){const e=`keyword "${a}" value is invalid at path "${o}": `+r.errorsText(s.validateSchema.errors);if("log"!==t.validateSchema)throw new Error(e);r.logger.error(e)}}}},{"../../vocabularies/code":51,"../codegen":2,"../errors":4,"../names":6}],17:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.extendSubschemaMode=r.extendSubschemaData=r.getSubschema=void 0;const o=e("../codegen"),s=e("../util");r.getSubschema=function(e,{keyword:t,schemaProp:r,schema:a,schemaPath:n,errSchemaPath:i,topSchemaRef:c}){if(void 0!==t&&void 0!==a)throw new Error('both "keyword" and "schema" passed, only one allowed');if(void 0!==t){const a=e.schema[t];return void 0===r?{schema:a,schemaPath:o._`${e.schemaPath}${(0,o.getProperty)(t)}`,errSchemaPath:`${e.errSchemaPath}/${t}`}:{schema:a[r],schemaPath:o._`${e.schemaPath}${(0,o.getProperty)(t)}${(0,o.getProperty)(r)}`,errSchemaPath:`${e.errSchemaPath}/${t}/${(0,s.escapeFragment)(r)}`}}if(void 0!==a){if(void 0===n||void 0===i||void 0===c)throw new Error('"schemaPath", "errSchemaPath" and "topSchemaRef" are required with "schema"');return{schema:a,schemaPath:n,topSchemaRef:c,errSchemaPath:i}}throw new Error('either "keyword" or "schema" must be passed')},r.extendSubschemaData=function(e,t,{dataProp:r,dataPropType:a,data:n,dataTypes:i,propertyName:c}){if(void 0!==n&&void 0!==r)throw new Error('both "data" and "dataProp" passed, only one allowed');const{gen:d}=t;if(void 0!==r){const{errorPath:n,dataPathArr:i,opts:c}=t;l(d.let("data",o._`${t.data}${(0,o.getProperty)(r)}`,!0)),e.errorPath=o.str`${n}${(0,s.getErrorPath)(r,a,c.jsPropertySyntax)}`,e.parentDataProperty=o._`${r}`,e.dataPathArr=[...i,e.parentDataProperty]}if(void 0!==n){l(n instanceof o.Name?n:d.let("data",n,!0)),void 0!==c&&(e.propertyName=c)}function l(r){e.data=r,e.dataLevel=t.dataLevel+1,e.dataTypes=[],t.definedProperties=new Set,e.parentData=t.data,e.dataNames=[...t.dataNames,r]}i&&(e.dataTypes=i)},r.extendSubschemaMode=function(e,{jtdDiscriminator:t,jtdMetadata:r,compositeRule:o,createErrors:s,allErrors:a}){void 0!==o&&(e.compositeRule=o),void 0!==s&&(e.createErrors=s),void 0!==a&&(e.allErrors=a),e.jtdDiscriminator=t,e.jtdMetadata=r}},{"../codegen":2,"../util":10}],18:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.CodeGen=r.Name=r.nil=r.stringify=r.str=r._=r.KeywordCxt=void 0;var o=e("./compile/validate");Object.defineProperty(r,"KeywordCxt",{enumerable:!0,get(){return o.KeywordCxt}});var s=e("./compile/codegen");Object.defineProperty(r,"_",{enumerable:!0,get(){return s._}}),Object.defineProperty(r,"str",{enumerable:!0,get(){return s.str}}),Object.defineProperty(r,"stringify",{enumerable:!0,get(){return s.stringify}}),Object.defineProperty(r,"nil",{enumerable:!0,get(){return s.nil}}),Object.defineProperty(r,"Name",{enumerable:!0,get(){return s.Name}}),Object.defineProperty(r,"CodeGen",{enumerable:!0,get(){return s.CodeGen}});const a=e("./runtime/validation_error"),n=e("./compile/ref_error"),i=e("./compile/rules"),c=e("./compile"),d=e("./compile/codegen"),l=e("./compile/resolve"),u=e("./compile/validate/dataType"),m=e("./compile/util"),f=e("./refs/data.json"),p=e("./runtime/uri"),h=(e,t)=>new RegExp(e,t);h.code="new RegExp";const y=["removeAdditional","useDefaults","coerceTypes"],v=new Set(["validate","serialize","parse","wrapper","root","schema","keyword","pattern","formats","validate$data","func","obj","Error"]),g={errorDataPath:"",format:"`validateFormats: false` can be used instead.",nullable:'"nullable" keyword is supported by default.',jsonPointers:"Deprecated jsPropertySyntax can be used instead.",extendRefs:"Deprecated ignoreKeywordsWithRef can be used instead.",missingRefs:"Pass empty schema with $id that should be ignored to ajv.addSchema.",processCode:"Use option `code: {process: (code, schemaEnv: object) => string}`",sourceCode:"Use option `code: {source: true}`",strictDefaults:"It is default now, see option `strict`.",strictKeywords:"It is default now, see option `strict`.",uniqueItems:'"uniqueItems" keyword is always validated.',unknownFormats:"Disable strict mode or pass `true` to `ajv.addFormat` (or `formats` option).",cache:"Map is used as cache, schema object as key.",serialize:"Map is used as cache, schema object as key.",ajvErrors:"It is default now."},$={ignoreKeywordsWithRef:"",jsPropertySyntax:"",unicode:'"minLength"/"maxLength" account for unicode characters by default.'};function _(e){var t,r,o,s,a,n,i,c,d,l,u,m,f,y,v,g,$,_,b,w,P,E,S,j,k;const N=e.strict,O=null===(t=e.code)||void 0===t?void 0:t.optimize,R=!0===O||void 0===O?1:O||0,I=null!==(o=null===(r=e.code)||void 0===r?void 0:r.regExp)&&void 0!==o?o:h,x=null!==(s=e.uriResolver)&&void 0!==s?s:p.default;return{strictSchema:null===(n=null!==(a=e.strictSchema)&&void 0!==a?a:N)||void 0===n||n,strictNumbers:null===(c=null!==(i=e.strictNumbers)&&void 0!==i?i:N)||void 0===c||c,strictTypes:null!==(l=null!==(d=e.strictTypes)&&void 0!==d?d:N)&&void 0!==l?l:"log",strictTuples:null!==(m=null!==(u=e.strictTuples)&&void 0!==u?u:N)&&void 0!==m?m:"log",strictRequired:null!==(y=null!==(f=e.strictRequired)&&void 0!==f?f:N)&&void 0!==y&&y,code:e.code?{...e.code,optimize:R,regExp:I}:{optimize:R,regExp:I},loopRequired:null!==(v=e.loopRequired)&&void 0!==v?v:200,loopEnum:null!==(g=e.loopEnum)&&void 0!==g?g:200,meta:null===($=e.meta)||void 0===$||$,messages:null===(_=e.messages)||void 0===_||_,inlineRefs:null===(b=e.inlineRefs)||void 0===b||b,schemaId:null!==(w=e.schemaId)&&void 0!==w?w:"$id",addUsedSchema:null===(P=e.addUsedSchema)||void 0===P||P,validateSchema:null===(E=e.validateSchema)||void 0===E||E,validateFormats:null===(S=e.validateFormats)||void 0===S||S,unicodeRegExp:null===(j=e.unicodeRegExp)||void 0===j||j,int32range:null===(k=e.int32range)||void 0===k||k,uriResolver:x}}class b{constructor(e={}){this.schemas={},this.refs={},this.formats={},this._compilations=new Set,this._loading={},this._cache=new Map,e=this.opts={...e,..._(e)};const{es5:t,lines:r}=this.opts.code;this.scope=new d.ValueScope({scope:{},prefixes:v,es5:t,lines:r}),this.logger=function(e){if(!1===e)return N;if(void 0===e)return console;if(e.log&&e.warn&&e.error)return e;throw new Error("logger must implement log, warn and error methods")}(e.logger);const o=e.validateFormats;e.validateFormats=!1,this.RULES=(0,i.getRules)(),w.call(this,g,e,"NOT SUPPORTED"),w.call(this,$,e,"DEPRECATED","warn"),this._metaOpts=k.call(this),e.formats&&S.call(this),this._addVocabularies(),this._addDefaultMetaSchema(),e.keywords&&j.call(this,e.keywords),"object"==typeof e.meta&&this.addMetaSchema(e.meta),E.call(this),e.validateFormats=o}_addVocabularies(){this.addKeyword("$async")}_addDefaultMetaSchema(){const{$data:e,meta:t,schemaId:r}=this.opts;let o=f;"id"===r&&(o={...f},o.id=o.$id,delete o.$id),t&&e&&this.addMetaSchema(o,o[r],!1)}defaultMeta(){const{meta:e,schemaId:t}=this.opts;return this.opts.defaultMeta="object"==typeof e?e[t]||e:void 0}validate(e,t){let r;if("string"==typeof e){if(r=this.getSchema(e),!r)throw new Error(`no schema with key or ref "${e}"`)}else r=this.compile(e);const o=r(t);return"$async"in r||(this.errors=r.errors),o}compile(e,t){const r=this._addSchema(e,t);return r.validate||this._compileSchemaEnv(r)}compileAsync(e,t){if("function"!=typeof this.opts.loadSchema)throw new Error("options.loadSchema should be a function");const{loadSchema:r}=this.opts;return o.call(this,e,t);async function o(e,t){await s.call(this,e.$schema);const r=this._addSchema(e,t);return r.validate||a.call(this,r)}async function s(e){e&&!this.getSchema(e)&&await o.call(this,{$ref:e},!0)}async function a(e){try{return this._compileSchemaEnv(e)}catch(t){if(!(t instanceof n.default))throw t;return i.call(this,t),await c.call(this,t.missingSchema),a.call(this,e)}}function i({missingSchema:e,missingRef:t}){if(this.refs[e])throw new Error(`AnySchema ${e} is loaded but ${t} cannot be resolved`)}async function c(e){const r=await d.call(this,e);this.refs[e]||await s.call(this,r.$schema),this.refs[e]||this.addSchema(r,e,t)}async function d(e){const t=this._loading[e];if(t)return t;try{return await(this._loading[e]=r(e))}finally{delete this._loading[e]}}}addSchema(e,t,r,o=this.opts.validateSchema){if(Array.isArray(e)){for(const t of e)this.addSchema(t,void 0,r,o);return this}let s;if("object"==typeof e){const{schemaId:t}=this.opts;if(s=e[t],void 0!==s&&"string"!=typeof s)throw new Error(`schema ${t} must be string`)}return t=(0,l.normalizeId)(t||s),this._checkUnique(t),this.schemas[t]=this._addSchema(e,r,t,o,!0),this}addMetaSchema(e,t,r=this.opts.validateSchema){return this.addSchema(e,t,!0,r),this}validateSchema(e,t){if("boolean"==typeof e)return!0;let r;if(r=e.$schema,void 0!==r&&"string"!=typeof r)throw new Error("$schema must be a string");if(r=r||this.opts.defaultMeta||this.defaultMeta(),!r)return this.logger.warn("meta-schema not available"),this.errors=null,!0;const o=this.validate(r,e);if(!o&&t){const e="schema is invalid: "+this.errorsText();if("log"!==this.opts.validateSchema)throw new Error(e);this.logger.error(e)}return o}getSchema(e){let t;for(;"string"==typeof(t=P.call(this,e));)e=t;if(void 0===t){const{schemaId:r}=this.opts,o=new c.SchemaEnv({schema:{},schemaId:r});if(t=c.resolveSchema.call(this,o,e),!t)return;this.refs[e]=t}return t.validate||this._compileSchemaEnv(t)}removeSchema(e){if(e instanceof RegExp)return this._removeAllSchemas(this.schemas,e),this._removeAllSchemas(this.refs,e),this;switch(typeof e){case"undefined":return this._removeAllSchemas(this.schemas),this._removeAllSchemas(this.refs),this._cache.clear(),this;case"string":{const t=P.call(this,e);return"object"==typeof t&&this._cache.delete(t.schema),delete this.schemas[e],delete this.refs[e],this}case"object":{this._cache.delete(e);let t=e[this.opts.schemaId];return t&&(t=(0,l.normalizeId)(t),delete this.schemas[t],delete this.refs[t]),this}default:throw new Error("ajv.removeSchema: invalid parameter")}}addVocabulary(e){for(const t of e)this.addKeyword(t);return this}addKeyword(e,t){let r;if("string"==typeof e)r=e,"object"==typeof t&&(this.logger.warn("these parameters are deprecated, see docs for addKeyword"),t.keyword=r);else{if("object"!=typeof e||void 0!==t)throw new Error("invalid addKeywords parameters");if(r=(t=e).keyword,Array.isArray(r)&&!r.length)throw new Error("addKeywords: keyword must be string or non-empty array")}if(R.call(this,r,t),!t)return(0,m.eachItem)(r,(e=>I.call(this,e))),this;C.call(this,t);const o={...t,type:(0,u.getJSONTypes)(t.type),schemaType:(0,u.getJSONTypes)(t.schemaType)};return(0,m.eachItem)(r,0===o.type.length?e=>I.call(this,e,o):e=>o.type.forEach((t=>I.call(this,e,o,t)))),this}getKeyword(e){const t=this.RULES.all[e];return"object"==typeof t?t.definition:!!t}removeKeyword(e){const{RULES:t}=this;delete t.keywords[e],delete t.all[e];for(const r of t.rules){const t=r.rules.findIndex((t=>t.keyword===e));t>=0&&r.rules.splice(t,1)}return this}addFormat(e,t){return"string"==typeof t&&(t=new RegExp(t)),this.formats[e]=t,this}errorsText(e=this.errors,{separator:t=", ",dataVar:r="data"}={}){return e&&0!==e.length?e.map((e=>`${r}${e.instancePath} ${e.message}`)).reduce(((e,r)=>e+t+r)):"No errors"}$dataMetaSchema(e,t){const r=this.RULES.all;e=JSON.parse(JSON.stringify(e));for(const o of t){const t=o.split("/").slice(1);let s=e;for(const e of t)s=s[e];for(const e in r){const t=r[e];if("object"!=typeof t)continue;const{$data:o}=t.definition,a=s[e];o&&a&&(s[e]=A(a))}}return e}_removeAllSchemas(e,t){for(const r in e){const o=e[r];t&&!t.test(r)||("string"==typeof o?delete e[r]:o&&!o.meta&&(this._cache.delete(o.schema),delete e[r]))}}_addSchema(e,t,r,o=this.opts.validateSchema,s=this.opts.addUsedSchema){let a;const{schemaId:n}=this.opts;if("object"==typeof e)a=e[n];else{if(this.opts.jtd)throw new Error("schema must be object");if("boolean"!=typeof e)throw new Error("schema must be object or boolean")}let i=this._cache.get(e);if(void 0!==i)return i;r=(0,l.normalizeId)(a||r);const d=l.getSchemaRefs.call(this,e,r);return i=new c.SchemaEnv({schema:e,schemaId:n,meta:t,baseId:r,localRefs:d}),this._cache.set(i.schema,i),s&&!r.startsWith("#")&&(r&&this._checkUnique(r),this.refs[r]=i),o&&this.validateSchema(e,!0),i}_checkUnique(e){if(this.schemas[e]||this.refs[e])throw new Error(`schema with key or id "${e}" already exists`)}_compileSchemaEnv(e){if(e.meta?this._compileMetaSchema(e):c.compileSchema.call(this,e),!e.validate)throw new Error("ajv implementation error");return e.validate}_compileMetaSchema(e){const t=this.opts;this.opts=this._metaOpts;try{c.compileSchema.call(this,e)}finally{this.opts=t}}}function w(e,t,r,o="error"){for(const s in e){s in t&&this.logger[o](`${r}: option ${s}. ${e[s]}`)}}function P(e){return e=(0,l.normalizeId)(e),this.schemas[e]||this.refs[e]}function E(){const e=this.opts.schemas;if(e)if(Array.isArray(e))this.addSchema(e);else for(const t in e)this.addSchema(e[t],t)}function S(){for(const e in this.opts.formats){const t=this.opts.formats[e];t&&this.addFormat(e,t)}}function j(e){if(Array.isArray(e))this.addVocabulary(e);else{this.logger.warn("keywords option as map is deprecated, pass array");for(const t in e){const r=e[t];r.keyword||(r.keyword=t),this.addKeyword(r)}}}function k(){const e={...this.opts};for(const t of y)delete e[t];return e}b.ValidationError=a.default,b.MissingRefError=n.default,r.default=b;const N={log(){},warn(){},error(){}};const O=/^[a-z_$][a-z0-9_$:-]*$/i;function R(e,t){const{RULES:r}=this;if((0,m.eachItem)(e,(e=>{if(r.keywords[e])throw new Error(`Keyword ${e} is already defined`);if(!O.test(e))throw new Error(`Keyword ${e} has invalid name`)})),t&&t.$data&&!("code"in t)&&!("validate"in t))throw new Error('$data keyword must have "code" or "validate" function')}function I(e,t,r){var o;const s=null==t?void 0:t.post;if(r&&s)throw new Error('keyword with "post" flag cannot have "type"');const{RULES:a}=this;let n=s?a.post:a.rules.find((({type:e})=>e===r));if(n||(n={type:r,rules:[]},a.rules.push(n)),a.keywords[e]=!0,!t)return;const i={keyword:e,definition:{...t,type:(0,u.getJSONTypes)(t.type),schemaType:(0,u.getJSONTypes)(t.schemaType)}};t.before?x.call(this,n,i,t.before):n.rules.push(i),a.all[e]=i,null===(o=t.implements)||void 0===o||o.forEach((e=>this.addKeyword(e)))}function x(e,t,r){const o=e.rules.findIndex((e=>e.keyword===r));o>=0?e.rules.splice(o,0,t):(e.rules.push(t),this.logger.warn(`rule ${r} is not defined`))}function C(e){let{metaSchema:t}=e;void 0!==t&&(e.$data&&this.opts.$data&&(t=A(t)),e.validateSchema=this.compile(t,!0))}const T={$ref:"https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#"};function A(e){return{anyOf:[e,T]}}},{"./compile":5,"./compile/codegen":2,"./compile/ref_error":7,"./compile/resolve":8,"./compile/rules":9,"./compile/util":10,"./compile/validate":15,"./compile/validate/dataType":13,"./refs/data.json":19,"./runtime/uri":31,"./runtime/validation_error":32}],19:[function(e,t,r){t.exports={$id:"https://raw.githubusercontent.com/ajv-validator/ajv/master/lib/refs/data.json#",description:"Meta-schema for $data reference (JSON AnySchema extension proposal)",type:"object",required:["$data"],properties:{$data:{type:"string",anyOf:[{format:"relative-json-pointer"},{format:"json-pointer"}]}},additionalProperties:!1}},{}],20:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./schema.json"),s=e("./meta/applicator.json"),a=e("./meta/unevaluated.json"),n=e("./meta/content.json"),i=e("./meta/core.json"),c=e("./meta/format-annotation.json"),d=e("./meta/meta-data.json"),l=e("./meta/validation.json"),u=["/properties"];r.default=function(e){return[o,s,a,n,i,t(this,c),d,t(this,l)].forEach((e=>this.addMetaSchema(e,void 0,!1))),this;function t(t,r){return e?t.$dataMetaSchema(r,u):r}}},{"./meta/applicator.json":21,"./meta/content.json":22,"./meta/core.json":23,"./meta/format-annotation.json":24,"./meta/meta-data.json":25,"./meta/unevaluated.json":26,"./meta/validation.json":27,"./schema.json":28}],21:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/applicator",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/applicator":!0},$dynamicAnchor:"meta",title:"Applicator vocabulary meta-schema",type:["object","boolean"],properties:{prefixItems:{$ref:"#/$defs/schemaArray"},items:{$dynamicRef:"#meta"},contains:{$dynamicRef:"#meta"},additionalProperties:{$dynamicRef:"#meta"},properties:{type:"object",additionalProperties:{$dynamicRef:"#meta"},default:{}},patternProperties:{type:"object",additionalProperties:{$dynamicRef:"#meta"},propertyNames:{format:"regex"},default:{}},dependentSchemas:{type:"object",additionalProperties:{$dynamicRef:"#meta"},default:{}},propertyNames:{$dynamicRef:"#meta"},if:{$dynamicRef:"#meta"},then:{$dynamicRef:"#meta"},else:{$dynamicRef:"#meta"},allOf:{$ref:"#/$defs/schemaArray"},anyOf:{$ref:"#/$defs/schemaArray"},oneOf:{$ref:"#/$defs/schemaArray"},not:{$dynamicRef:"#meta"}},$defs:{schemaArray:{type:"array",minItems:1,items:{$dynamicRef:"#meta"}}}}},{}],22:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/content",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/content":!0},$dynamicAnchor:"meta",title:"Content vocabulary meta-schema",type:["object","boolean"],properties:{contentEncoding:{type:"string"},contentMediaType:{type:"string"},contentSchema:{$dynamicRef:"#meta"}}}},{}],23:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/core",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/core":!0},$dynamicAnchor:"meta",title:"Core vocabulary meta-schema",type:["object","boolean"],properties:{$id:{$ref:"#/$defs/uriReferenceString",$comment:"Non-empty fragments not allowed.",pattern:"^[^#]*#?$"},$schema:{$ref:"#/$defs/uriString"},$ref:{$ref:"#/$defs/uriReferenceString"},$anchor:{$ref:"#/$defs/anchorString"},$dynamicRef:{$ref:"#/$defs/uriReferenceString"},$dynamicAnchor:{$ref:"#/$defs/anchorString"},$vocabulary:{type:"object",propertyNames:{$ref:"#/$defs/uriString"},additionalProperties:{type:"boolean"}},$comment:{type:"string"},$defs:{type:"object",additionalProperties:{$dynamicRef:"#meta"}}},$defs:{anchorString:{type:"string",pattern:"^[A-Za-z_][-A-Za-z0-9._]*$"},uriString:{type:"string",format:"uri"},uriReferenceString:{type:"string",format:"uri-reference"}}}},{}],24:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/format-annotation",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/format-annotation":!0},$dynamicAnchor:"meta",title:"Format vocabulary meta-schema for annotation results",type:["object","boolean"],properties:{format:{type:"string"}}}},{}],25:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/meta-data",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/meta-data":!0},$dynamicAnchor:"meta",title:"Meta-data vocabulary meta-schema",type:["object","boolean"],properties:{title:{type:"string"},description:{type:"string"},default:!0,deprecated:{type:"boolean",default:!1},readOnly:{type:"boolean",default:!1},writeOnly:{type:"boolean",default:!1},examples:{type:"array",items:!0}}}},{}],26:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/unevaluated",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/unevaluated":!0},$dynamicAnchor:"meta",title:"Unevaluated applicator vocabulary meta-schema",type:["object","boolean"],properties:{unevaluatedItems:{$dynamicRef:"#meta"},unevaluatedProperties:{$dynamicRef:"#meta"}}}},{}],27:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/meta/validation",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/validation":!0},$dynamicAnchor:"meta",title:"Validation vocabulary meta-schema",type:["object","boolean"],properties:{type:{anyOf:[{$ref:"#/$defs/simpleTypes"},{type:"array",items:{$ref:"#/$defs/simpleTypes"},minItems:1,uniqueItems:!0}]},const:!0,enum:{type:"array",items:!0},multipleOf:{type:"number",exclusiveMinimum:0},maximum:{type:"number"},exclusiveMaximum:{type:"number"},minimum:{type:"number"},exclusiveMinimum:{type:"number"},maxLength:{$ref:"#/$defs/nonNegativeInteger"},minLength:{$ref:"#/$defs/nonNegativeIntegerDefault0"},pattern:{type:"string",format:"regex"},maxItems:{$ref:"#/$defs/nonNegativeInteger"},minItems:{$ref:"#/$defs/nonNegativeIntegerDefault0"},uniqueItems:{type:"boolean",default:!1},maxContains:{$ref:"#/$defs/nonNegativeInteger"},minContains:{$ref:"#/$defs/nonNegativeInteger",default:1},maxProperties:{$ref:"#/$defs/nonNegativeInteger"},minProperties:{$ref:"#/$defs/nonNegativeIntegerDefault0"},required:{$ref:"#/$defs/stringArray"},dependentRequired:{type:"object",additionalProperties:{$ref:"#/$defs/stringArray"}}},$defs:{nonNegativeInteger:{type:"integer",minimum:0},nonNegativeIntegerDefault0:{$ref:"#/$defs/nonNegativeInteger",default:0},simpleTypes:{enum:["array","boolean","integer","null","number","object","string"]},stringArray:{type:"array",items:{type:"string"},uniqueItems:!0,default:[]}}}},{}],28:[function(e,t,r){t.exports={$schema:"https://json-schema.org/draft/2020-12/schema",$id:"https://json-schema.org/draft/2020-12/schema",$vocabulary:{"https://json-schema.org/draft/2020-12/vocab/core":!0,"https://json-schema.org/draft/2020-12/vocab/applicator":!0,"https://json-schema.org/draft/2020-12/vocab/unevaluated":!0,"https://json-schema.org/draft/2020-12/vocab/validation":!0,"https://json-schema.org/draft/2020-12/vocab/meta-data":!0,"https://json-schema.org/draft/2020-12/vocab/format-annotation":!0,"https://json-schema.org/draft/2020-12/vocab/content":!0},$dynamicAnchor:"meta",title:"Core and Validation specifications meta-schema",allOf:[{$ref:"meta/core"},{$ref:"meta/applicator"},{$ref:"meta/unevaluated"},{$ref:"meta/validation"},{$ref:"meta/meta-data"},{$ref:"meta/format-annotation"},{$ref:"meta/content"}],type:["object","boolean"],$comment:"This meta-schema also defines keywords that have appeared in previous drafts in order to prevent incompatible extensions as they remain in common use.",properties:{definitions:{$comment:'"definitions" has been replaced by "$defs".',type:"object",additionalProperties:{$dynamicRef:"#meta"},deprecated:!0,default:{}},dependencies:{$comment:'"dependencies" has been split and replaced by "dependentSchemas" and "dependentRequired" in order to serve their differing semantics.',type:"object",additionalProperties:{anyOf:[{$dynamicRef:"#meta"},{$ref:"meta/validation#/$defs/stringArray"}]},deprecated:!0,default:{}},$recursiveAnchor:{$comment:'"$recursiveAnchor" has been replaced by "$dynamicAnchor".',$ref:"meta/core#/$defs/anchorString",deprecated:!0},$recursiveRef:{$comment:'"$recursiveRef" has been replaced by "$dynamicRef".',$ref:"meta/core#/$defs/uriReferenceString",deprecated:!0}}}},{}],29:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("fast-deep-equal");o.code='require("ajv/dist/runtime/equal").default',r.default=o},{"fast-deep-equal":83}],30:[function(e,t,r){"use strict";function o(e){const t=e.length;let r,o=0,s=0;for(;s=55296&&r<=56319&&sfunction(a){r.forRange("i",t.length,d,(t=>{e.subschema({keyword:i,dataProp:t,dataPropType:s.Type.Num},a),c.allErrors||r.if((0,o.not)(a),(()=>r.break()))}))}(a))),e.ok(a)}}r.validateAdditionalItems=n,r.default=a},{"../../compile/codegen":2,"../../compile/util":10}],34:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../code"),s=e("../../compile/codegen"),a=e("../../compile/names"),n=e("../../compile/util");r.default={keyword:"additionalProperties",type:["object"],schemaType:["boolean","object"],allowUndefined:!0,trackErrors:!0,error:{message:"must NOT have additional properties",params({params:e}){return s._`{additionalProperty: ${e.additionalProperty}}`}},code(e){const{gen:t,schema:r,parentSchema:i,data:c,errsCount:d,it:l}=e;if(!d)throw new Error("ajv implementation error");const{allErrors:u,opts:m}=l;if(l.props=!0,"all"!==m.removeAdditional&&(0,n.alwaysValidSchema)(l,r))return;const f=(0,o.allSchemaProperties)(i.properties),p=(0,o.allSchemaProperties)(i.patternProperties);function h(e){t.code(s._`delete ${c}[${e}]`)}function y(o){if("all"===m.removeAdditional||m.removeAdditional&&!1===r)h(o);else{if(!1===r)return e.setParams({additionalProperty:o}),e.error(),void(u||t.break());if("object"==typeof r&&!(0,n.alwaysValidSchema)(l,r)){const r=t.name("valid");"failing"===m.removeAdditional?(v(o,r,!1),t.if((0,s.not)(r),(()=>{e.reset(),h(o)}))):(v(o,r),u||t.if((0,s.not)(r),(()=>t.break())))}}}function v(t,r,o){const s={keyword:"additionalProperties",dataProp:t,dataPropType:n.Type.Str};!1===o&&Object.assign(s,{compositeRule:!0,createErrors:!1,allErrors:!1}),e.subschema(s,r)}t.forIn("key",c,(r=>{f.length||p.length?t.if(function(r){let a;if(f.length>8){const e=(0,n.schemaRefOrVal)(l,i.properties,"properties");a=(0,o.isOwnProperty)(t,e,r)}else a=f.length?(0,s.or)(...f.map((e=>s._`${r} === ${e}`))):s.nil;return p.length&&(a=(0,s.or)(a,...p.map((t=>s._`${(0,o.usePattern)(e,t)}.test(${r})`)))),(0,s.not)(a)}(r),(()=>y(r))):y(r)})),e.ok(s._`${d} === ${a.default.errors}`)}}},{"../../compile/codegen":2,"../../compile/names":6,"../../compile/util":10,"../code":51}],35:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/util");r.default={keyword:"allOf",schemaType:"array",code(e){const{gen:t,schema:r,it:s}=e;if(!Array.isArray(r))throw new Error("ajv implementation error");const a=t.name("valid");r.forEach(((t,r)=>{if((0,o.alwaysValidSchema)(s,t))return;const n=e.subschema({keyword:"allOf",schemaProp:r},a);e.ok(a),e.mergeEvaluated(n)}))}}},{"../../compile/util":10}],36:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../code");r.default={keyword:"anyOf",schemaType:"array",trackErrors:!0,code:o.validateUnion,error:{message:"must match a schema in anyOf"}}},{"../code":51}],37:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util");r.default={keyword:"contains",type:"array",schemaType:["object","boolean"],before:"uniqueItems",trackErrors:!0,error:{message({params:{min:e,max:t}}){return void 0===t?o.str`must contain at least ${e} valid item(s)`:o.str`must contain at least ${e} and no more than ${t} valid item(s)`},params({params:{min:e,max:t}}){return void 0===t?o._`{minContains: ${e}}`:o._`{minContains: ${e}, maxContains: ${t}}`}},code(e){const{gen:t,schema:r,parentSchema:a,data:n,it:i}=e;let c,d;const{minContains:l,maxContains:u}=a;i.opts.next?(c=void 0===l?1:l,d=u):c=1;const m=t.const("len",o._`${n}.length`);if(e.setParams({min:c,max:d}),void 0===d&&0===c)return void(0,s.checkStrictMode)(i,'"minContains" == 0 without "maxContains": "contains" keyword ignored');if(void 0!==d&&c>d)return(0,s.checkStrictMode)(i,'"minContains" > "maxContains" is always invalid'),void e.fail();if((0,s.alwaysValidSchema)(i,r)){let t=o._`${m} >= ${c}`;return void 0!==d&&(t=o._`${t} && ${m} <= ${d}`),void e.pass(t)}i.items=!0;const f=t.name("valid");function p(){const e=t.name("_valid"),r=t.let("count",0);h(e,(()=>t.if(e,(()=>function(e){t.code(o._`${e}++`),void 0===d?t.if(o._`${e} >= ${c}`,(()=>t.assign(f,!0).break())):(t.if(o._`${e} > ${d}`,(()=>t.assign(f,!1).break())),1===c?t.assign(f,!0):t.if(o._`${e} >= ${c}`,(()=>t.assign(f,!0))))}(r)))))}function h(r,o){t.forRange("i",0,m,(t=>{e.subschema({keyword:"contains",dataProp:t,dataPropType:s.Type.Num,compositeRule:!0},r),o()}))}void 0===d&&1===c?h(f,(()=>t.if(f,(()=>t.break())))):0===c?(t.let(f,!0),void 0!==d&&t.if(o._`${n}.length > 0`,p)):(t.let(f,!1),p()),e.result(f,(()=>e.reset()))}}},{"../../compile/codegen":2,"../../compile/util":10}],38:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.validateSchemaDeps=r.validatePropertyDeps=r.error=void 0;const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../code");r.error={message({params:{property:e,depsCount:t,deps:r}}){return o.str`must have ${1===t?"property":"properties"} ${r} when property ${e} is present`},params({params:{property:e,depsCount:t,deps:r,missingProperty:s}}){return o._`{property: ${e}, + missingProperty: ${s}, + depsCount: ${t}, + deps: ${r}}`}};const n={keyword:"dependencies",type:"object",schemaType:"object",error:r.error,code(e){const[t,r]=function({schema:e}){const t={},r={};for(const o in e){if("__proto__"===o)continue;(Array.isArray(e[o])?t:r)[o]=e[o]}return[t,r]}(e);i(e,t),c(e,r)}};function i(e,t=e.schema){const{gen:r,data:s,it:n}=e;if(0===Object.keys(t).length)return;const i=r.let("missing");for(const c in t){const d=t[c];if(0===d.length)continue;const l=(0,a.propertyInData)(r,s,c,n.opts.ownProperties);e.setParams({property:c,depsCount:d.length,deps:d.join(", ")}),n.allErrors?r.if(l,(()=>{for(const t of d)(0,a.checkReportMissingProp)(e,t)})):(r.if(o._`${l} && (${(0,a.checkMissingProp)(e,d,i)})`),(0,a.reportMissingProp)(e,i),r.else())}}function c(e,t=e.schema){const{gen:r,data:o,keyword:n,it:i}=e,c=r.name("valid");for(const d in t)(0,s.alwaysValidSchema)(i,t[d])||(r.if((0,a.propertyInData)(r,o,d,i.opts.ownProperties),(()=>{const t=e.subschema({keyword:n,schemaProp:d},c);e.mergeValidEvaluated(t,c)}),(()=>r.var(c,!0))),e.ok(c))}r.validatePropertyDeps=i,r.validateSchemaDeps=c,r.default=n},{"../../compile/codegen":2,"../../compile/util":10,"../code":51}],39:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./dependencies");r.default={keyword:"dependentSchemas",type:"object",schemaType:"object",code(e){return(0,o.validateSchemaDeps)(e)}}},{"./dependencies":38}],40:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util");function a(e,t){const r=e.schema[t];return void 0!==r&&!(0,s.alwaysValidSchema)(e,r)}r.default={keyword:"if",schemaType:["object","boolean"],trackErrors:!0,error:{message({params:e}){return o.str`must match "${e.ifClause}" schema`},params({params:e}){return o._`{failingKeyword: ${e.ifClause}}`}},code(e){const{gen:t,parentSchema:r,it:n}=e;void 0===r.then&&void 0===r.else&&(0,s.checkStrictMode)(n,'"if" without "then" and "else" is ignored');const i=a(n,"then"),c=a(n,"else");if(!i&&!c)return;const d=t.let("valid",!0),l=t.name("_valid");if(function(){const t=e.subschema({keyword:"if",compositeRule:!0,createErrors:!1,allErrors:!1},l);e.mergeEvaluated(t)}(),e.reset(),i&&c){const r=t.let("ifClause");e.setParams({ifClause:r}),t.if(l,u("then",r),u("else",r))}else i?t.if(l,u("then")):t.if((0,o.not)(l),u("else"));function u(r,s){return()=>{const a=e.subschema({keyword:r},l);t.assign(d,l),e.mergeValidEvaluated(a,d),s?t.assign(s,o._`${r}`):e.setParams({ifClause:r})}}e.pass(d,(()=>e.error(!0)))}}},{"../../compile/codegen":2,"../../compile/util":10}],41:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./additionalItems"),s=e("./prefixItems"),a=e("./items"),n=e("./items2020"),i=e("./contains"),c=e("./dependencies"),d=e("./propertyNames"),l=e("./additionalProperties"),u=e("./properties"),m=e("./patternProperties"),f=e("./not"),p=e("./anyOf"),h=e("./oneOf"),y=e("./allOf"),v=e("./if"),g=e("./thenElse");r.default=function(e=!1){const t=[f.default,p.default,h.default,y.default,v.default,g.default,d.default,l.default,c.default,u.default,m.default];return e?t.push(s.default,n.default):t.push(o.default,a.default),t.push(i.default),t}},{"./additionalItems":33,"./additionalProperties":34,"./allOf":35,"./anyOf":36,"./contains":37,"./dependencies":38,"./if":40,"./items":42,"./items2020":43,"./not":44,"./oneOf":45,"./patternProperties":46,"./prefixItems":47,"./properties":48,"./propertyNames":49,"./thenElse":50}],42:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.validateTuple=void 0;const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../code"),n={keyword:"items",type:"array",schemaType:["object","array","boolean"],before:"uniqueItems",code(e){const{schema:t,it:r}=e;if(Array.isArray(t))return i(e,"additionalItems",t);r.items=!0,(0,s.alwaysValidSchema)(r,t)||e.ok((0,a.validateArray)(e))}};function i(e,t,r=e.schema){const{gen:a,parentSchema:n,data:i,keyword:c,it:d}=e;!function(e){const{opts:o,errSchemaPath:a}=d,n=r.length,i=n===e.minItems&&(n===e.maxItems||!1===e[t]);if(o.strictTuples&&!i){(0,s.checkStrictMode)(d,`"${c}" is ${n}-tuple, but minItems or maxItems/${t} are not specified or different at path "${a}"`,o.strictTuples)}}(n),d.opts.unevaluated&&r.length&&!0!==d.items&&(d.items=s.mergeEvaluated.items(a,r.length,d.items));const l=a.name("valid"),u=a.const("len",o._`${i}.length`);r.forEach(((t,r)=>{(0,s.alwaysValidSchema)(d,t)||(a.if(o._`${u} > ${r}`,(()=>e.subschema({keyword:c,schemaProp:r,dataProp:r},l))),e.ok(l))}))}r.validateTuple=i,r.default=n},{"../../compile/codegen":2,"../../compile/util":10,"../code":51}],43:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../code"),n=e("./additionalItems");r.default={keyword:"items",type:"array",schemaType:["object","boolean"],before:"uniqueItems",error:{message({params:{len:e}}){return o.str`must NOT have more than ${e} items`},params({params:{len:e}}){return o._`{limit: ${e}}`}},code(e){const{schema:t,parentSchema:r,it:o}=e,{prefixItems:i}=r;o.items=!0,(0,s.alwaysValidSchema)(o,t)||(i?(0,n.validateAdditionalItems)(e,i):e.ok((0,a.validateArray)(e)))}}},{"../../compile/codegen":2,"../../compile/util":10,"../code":51,"./additionalItems":33}],44:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/util");r.default={keyword:"not",schemaType:["object","boolean"],trackErrors:!0,code(e){const{gen:t,schema:r,it:s}=e;if((0,o.alwaysValidSchema)(s,r))return void e.fail();const a=t.name("valid");e.subschema({keyword:"not",compositeRule:!0,createErrors:!1,allErrors:!1},a),e.failResult(a,(()=>e.reset()),(()=>e.error()))},error:{message:"must NOT be valid"}}},{"../../compile/util":10}],45:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util");r.default={keyword:"oneOf",schemaType:"array",trackErrors:!0,error:{message:"must match exactly one schema in oneOf",params({params:e}){return o._`{passingSchemas: ${e.passing}}`}},code(e){const{gen:t,schema:r,parentSchema:a,it:n}=e;if(!Array.isArray(r))throw new Error("ajv implementation error");if(n.opts.discriminator&&a.discriminator)return;const i=r,c=t.let("valid",!1),d=t.let("passing",null),l=t.name("_valid");e.setParams({passing:d}),t.block((function(){i.forEach(((r,a)=>{let i;(0,s.alwaysValidSchema)(n,r)?t.var(l,!0):i=e.subschema({keyword:"oneOf",schemaProp:a,compositeRule:!0},l),a>0&&t.if(o._`${l} && ${c}`).assign(c,!1).assign(d,o._`[${d}, ${a}]`).else(),t.if(l,(()=>{t.assign(c,!0),t.assign(d,a),i&&e.mergeEvaluated(i,o.Name)}))}))})),e.result(c,(()=>e.reset()),(()=>e.error(!0)))}}},{"../../compile/codegen":2,"../../compile/util":10}],46:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../code"),s=e("../../compile/codegen"),a=e("../../compile/util"),n=e("../../compile/util");r.default={keyword:"patternProperties",type:"object",schemaType:"object",code(e){const{gen:t,schema:r,data:i,parentSchema:c,it:d}=e,{opts:l}=d,u=(0,o.allSchemaProperties)(r),m=u.filter((e=>(0,a.alwaysValidSchema)(d,r[e])));if(0===u.length||m.length===u.length&&(!d.opts.unevaluated||!0===d.props))return;const f=l.strictSchema&&!l.allowMatchingProperties&&c.properties,p=t.name("valid");!0===d.props||d.props instanceof s.Name||(d.props=(0,n.evaluatedPropsToName)(t,d.props));const{props:h}=d;function y(e){for(const t in f)new RegExp(e).test(t)&&(0,a.checkStrictMode)(d,`property ${t} matches pattern ${e} (use allowMatchingProperties)`)}function v(r){t.forIn("key",i,(a=>{t.if(s._`${(0,o.usePattern)(e,r)}.test(${a})`,(()=>{const o=m.includes(r);o||e.subschema({keyword:"patternProperties",schemaProp:r,dataProp:a,dataPropType:n.Type.Str},p),d.opts.unevaluated&&!0!==h?t.assign(s._`${h}[${a}]`,!0):o||d.allErrors||t.if((0,s.not)(p),(()=>t.break()))}))}))}!function(){for(const e of u)f&&y(e),d.allErrors?v(e):(t.var(p,!0),v(e),t.if(p))}()}}},{"../../compile/codegen":2,"../../compile/util":10,"../code":51}],47:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./items");r.default={keyword:"prefixItems",type:"array",schemaType:["array"],before:"uniqueItems",code(e){return(0,o.validateTuple)(e,"items")}}},{"./items":42}],48:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/validate"),s=e("../code"),a=e("../../compile/util"),n=e("./additionalProperties");r.default={keyword:"properties",type:"object",schemaType:"object",code(e){const{gen:t,schema:r,parentSchema:i,data:c,it:d}=e;"all"===d.opts.removeAdditional&&void 0===i.additionalProperties&&n.default.code(new o.KeywordCxt(d,n.default,"additionalProperties"));const l=(0,s.allSchemaProperties)(r);for(const e of l)d.definedProperties.add(e);d.opts.unevaluated&&l.length&&!0!==d.props&&(d.props=a.mergeEvaluated.props(t,(0,a.toHash)(l),d.props));const u=l.filter((e=>!(0,a.alwaysValidSchema)(d,r[e])));if(0===u.length)return;const m=t.name("valid");for(const r of u)f(r)?p(r):(t.if((0,s.propertyInData)(t,c,r,d.opts.ownProperties)),p(r),d.allErrors||t.else().var(m,!0),t.endIf()),e.it.definedProperties.add(r),e.ok(m);function f(e){return d.opts.useDefaults&&!d.compositeRule&&void 0!==r[e].default}function p(t){e.subschema({keyword:"properties",schemaProp:t,dataProp:t},m)}}}},{"../../compile/util":10,"../../compile/validate":15,"../code":51,"./additionalProperties":34}],49:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util");r.default={keyword:"propertyNames",type:"object",schemaType:["object","boolean"],error:{message:"property name must be valid",params({params:e}){return o._`{propertyName: ${e.propertyName}}`}},code(e){const{gen:t,schema:r,data:a,it:n}=e;if((0,s.alwaysValidSchema)(n,r))return;const i=t.name("valid");t.forIn("key",a,(r=>{e.setParams({propertyName:r}),e.subschema({keyword:"propertyNames",data:r,dataTypes:["string"],propertyName:r,compositeRule:!0},i),t.if((0,o.not)(i),(()=>{e.error(!0),n.allErrors||t.break()}))})),e.ok(i)}}},{"../../compile/codegen":2,"../../compile/util":10}],50:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/util");r.default={keyword:["then","else"],schemaType:["object","boolean"],code({keyword:e,parentSchema:t,it:r}){void 0===t.if&&(0,o.checkStrictMode)(r,`"${e}" without "if" is ignored`)}}},{"../../compile/util":10}],51:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.validateUnion=r.validateArray=r.usePattern=r.callValidateCode=r.schemaProperties=r.allSchemaProperties=r.noPropertyInData=r.propertyInData=r.isOwnProperty=r.hasPropFunc=r.reportMissingProp=r.checkMissingProp=r.checkReportMissingProp=void 0;const o=e("../compile/codegen"),s=e("../compile/util"),a=e("../compile/names"),n=e("../compile/util");function i(e){return e.scopeValue("func",{ref:Object.prototype.hasOwnProperty,code:o._`Object.prototype.hasOwnProperty`})}function c(e,t,r){return o._`${i(e)}.call(${t}, ${r})`}function d(e,t,r,s){const a=o._`${t}${(0,o.getProperty)(r)} === undefined`;return s?(0,o.or)(a,(0,o.not)(c(e,t,r))):a}function l(e){return e?Object.keys(e).filter((e=>"__proto__"!==e)):[]}r.checkReportMissingProp=function(e,t){const{gen:r,data:s,it:a}=e;r.if(d(r,s,t,a.opts.ownProperties),(()=>{e.setParams({missingProperty:o._`${t}`},!0),e.error()}))},r.checkMissingProp=function({gen:e,data:t,it:{opts:r}},s,a){return(0,o.or)(...s.map((s=>(0,o.and)(d(e,t,s,r.ownProperties),o._`${a} = ${s}`))))},r.reportMissingProp=function(e,t){e.setParams({missingProperty:t},!0),e.error()},r.hasPropFunc=i,r.isOwnProperty=c,r.propertyInData=function(e,t,r,s){const a=o._`${t}${(0,o.getProperty)(r)} !== undefined`;return s?o._`${a} && ${c(e,t,r)}`:a},r.noPropertyInData=d,r.allSchemaProperties=l,r.schemaProperties=function(e,t){return l(t).filter((r=>!(0,s.alwaysValidSchema)(e,t[r])))},r.callValidateCode=function({schemaCode:e,data:t,it:{gen:r,topSchemaRef:s,schemaPath:n,errorPath:i},it:c},d,l,u){const m=u?o._`${e}, ${t}, ${s}${n}`:t,f=[[a.default.instancePath,(0,o.strConcat)(a.default.instancePath,i)],[a.default.parentData,c.parentData],[a.default.parentDataProperty,c.parentDataProperty],[a.default.rootData,a.default.rootData]];c.opts.dynamicRef&&f.push([a.default.dynamicAnchors,a.default.dynamicAnchors]);const p=o._`${m}, ${r.object(...f)}`;return l!==o.nil?o._`${d}.call(${l}, ${p})`:o._`${d}(${p})`};const u=o._`new RegExp`;r.usePattern=function({gen:e,it:{opts:t}},r){const s=t.unicodeRegExp?"u":"",{regExp:a}=t.code,i=a(r,s);return e.scopeValue("pattern",{key:i.toString(),ref:i,code:o._`${"new RegExp"===a.code?u:(0,n.useFunc)(e,a)}(${r}, ${s})`})},r.validateArray=function(e){const{gen:t,data:r,keyword:a,it:n}=e,i=t.name("valid");if(n.allErrors){const e=t.let("valid",!0);return c((()=>t.assign(e,!1))),e}return t.var(i,!0),c((()=>t.break())),i;function c(n){const c=t.const("len",o._`${r}.length`);t.forRange("i",0,c,(r=>{e.subschema({keyword:a,dataProp:r,dataPropType:s.Type.Num},i),t.if((0,o.not)(i),n)}))}},r.validateUnion=function(e){const{gen:t,schema:r,keyword:a,it:n}=e;if(!Array.isArray(r))throw new Error("ajv implementation error");if(r.some((e=>(0,s.alwaysValidSchema)(n,e)))&&!n.opts.unevaluated)return;const i=t.let("valid",!1),c=t.name("_valid");t.block((()=>r.forEach(((r,s)=>{const n=e.subschema({keyword:a,schemaProp:s,compositeRule:!0},c);t.assign(i,o._`${i} || ${c}`);e.mergeValidEvaluated(n,c)||t.if((0,o.not)(i))})))),e.result(i,(()=>e.reset()),(()=>e.error(!0)))}},{"../compile/codegen":2,"../compile/names":6,"../compile/util":10}],52:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});r.default={keyword:"id",code(){throw new Error('NOT SUPPORTED: keyword "id", use "$id" for schema ID')}}},{}],53:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./id"),s=e("./ref");r.default=["$schema","$id","$defs","$vocabulary",{keyword:"$comment"},"definitions",o.default,s.default]},{"./id":52,"./ref":54}],54:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.callRef=r.getValidate=void 0;const o=e("../../compile/ref_error"),s=e("../code"),a=e("../../compile/codegen"),n=e("../../compile/names"),i=e("../../compile"),c=e("../../compile/util"),d={keyword:"$ref",schemaType:"string",code(e){const{gen:t,schema:r,it:s}=e,{baseId:n,schemaEnv:c,validateName:d,opts:m,self:f}=s,{root:p}=c;if(("#"===r||"#/"===r)&&n===p.baseId)return function(){if(c===p)return u(e,d,c,c.$async);const r=t.scopeValue("root",{ref:p});return u(e,a._`${r}.validate`,p,p.$async)}();const h=i.resolveRef.call(f,p,n,r);if(void 0===h)throw new o.default(s.opts.uriResolver,n,r);return h instanceof i.SchemaEnv?function(t){const r=l(e,t);u(e,r,t,t.$async)}(h):function(o){const s=t.scopeValue("schema",!0===m.code.source?{ref:o,code:(0,a.stringify)(o)}:{ref:o}),n=t.name("valid"),i=e.subschema({schema:o,dataTypes:[],schemaPath:a.nil,topSchemaRef:s,errSchemaPath:r},n);e.mergeEvaluated(i),e.ok(n)}(h)}};function l(e,t){const{gen:r}=e;return t.validate?r.scopeValue("validate",{ref:t.validate}):a._`${r.scopeValue("wrapper",{ref:t})}.validate`}function u(e,t,r,o){const{gen:i,it:d}=e,{allErrors:l,schemaEnv:u,opts:m}=d,f=m.passContext?n.default.this:a.nil;function p(e){const t=a._`${e}.errors`;i.assign(n.default.vErrors,a._`${n.default.vErrors} === null ? ${t} : ${n.default.vErrors}.concat(${t})`),i.assign(n.default.errors,a._`${n.default.vErrors}.length`)}function h(e){var t;if(!d.opts.unevaluated)return;const o=null===(t=null==r?void 0:r.validate)||void 0===t?void 0:t.evaluated;if(!0!==d.props)if(o&&!o.dynamicProps)void 0!==o.props&&(d.props=c.mergeEvaluated.props(i,o.props,d.props));else{const t=i.var("props",a._`${e}.evaluated.props`);d.props=c.mergeEvaluated.props(i,t,d.props,a.Name)}if(!0!==d.items)if(o&&!o.dynamicItems)void 0!==o.items&&(d.items=c.mergeEvaluated.items(i,o.items,d.items));else{const t=i.var("items",a._`${e}.evaluated.items`);d.items=c.mergeEvaluated.items(i,t,d.items,a.Name)}}o?function(){if(!u.$async)throw new Error("async schema referenced by sync schema");const r=i.let("valid");i.try((()=>{i.code(a._`await ${(0,s.callValidateCode)(e,t,f)}`),h(t),l||i.assign(r,!0)}),(e=>{i.if(a._`!(${e} instanceof ${d.ValidationError})`,(()=>i.throw(e))),p(e),l||i.assign(r,!1)})),e.ok(r)}():e.result((0,s.callValidateCode)(e,t,f),(()=>h(t)),(()=>p(t)))}r.getValidate=l,r.callRef=u,r.default=d},{"../../compile":5,"../../compile/codegen":2,"../../compile/names":6,"../../compile/ref_error":7,"../../compile/util":10,"../code":51}],55:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../discriminator/types"),a=e("../../compile"),n=e("../../compile/ref_error"),i=e("../../compile/util");r.default={keyword:"discriminator",type:"object",schemaType:"object",error:{message({params:{discrError:e,tagName:t}}){return e===s.DiscrError.Tag?`tag "${t}" must be string`:`value of tag "${t}" must be in oneOf`},params({params:{discrError:e,tag:t,tagName:r}}){return o._`{error: ${e}, tag: ${r}, tagValue: ${t}}`}},code(e){const{gen:t,data:r,schema:c,parentSchema:d,it:l}=e,{oneOf:u}=d;if(!l.opts.discriminator)throw new Error("discriminator: requires discriminator option");const m=c.propertyName;if("string"!=typeof m)throw new Error("discriminator: requires propertyName");if(c.mapping)throw new Error("discriminator: mapping is not supported");if(!u)throw new Error("discriminator: requires oneOf keyword");const f=t.let("valid",!1),p=t.const("tag",o._`${r}${(0,o.getProperty)(m)}`);function h(r){const s=t.name("valid"),a=e.subschema({keyword:"oneOf",schemaProp:r},s);return e.mergeEvaluated(a,o.Name),s}t.if(o._`typeof ${p} == "string"`,(()=>function(){const r=function(){var e;const t={},r=s(d);let o=!0;for(let t=0;te.error(!1,{discrError:s.DiscrError.Tag,tag:p,tagName:m}))),e.ok(f)}}},{"../../compile":5,"../../compile/codegen":2,"../../compile/ref_error":7,"../../compile/util":10,"../discriminator/types":56}],56:[function(e,t,r){"use strict";var o;Object.defineProperty(r,"__esModule",{value:!0}),r.DiscrError=void 0,function(e){e.Tag="tag",e.Mapping="mapping"}(o||(r.DiscrError=o={}))},{}],57:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./core"),s=e("./validation"),a=e("./applicator"),n=e("./dynamic"),i=e("./next"),c=e("./unevaluated"),d=e("./format"),l=e("./metadata"),u=[n.default,o.default,s.default,(0,a.default)(!0),d.default,l.metadataVocabulary,l.contentVocabulary,i.default,c.default];r.default=u},{"./applicator":41,"./core":53,"./dynamic":60,"./format":64,"./metadata":65,"./next":66,"./unevaluated":67,"./validation":73}],58:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.dynamicAnchor=void 0;const o=e("../../compile/codegen"),s=e("../../compile/names"),a=e("../../compile"),n=e("../core/ref"),i={keyword:"$dynamicAnchor",schemaType:"string",code(e){return c(e,e.schema)}};function c(e,t){const{gen:r,it:i}=e;i.schemaEnv.root.dynamicAnchors[t]=!0;const c=o._`${s.default.dynamicAnchors}${(0,o.getProperty)(t)}`,d="#"===i.errSchemaPath?i.validateName:function(e){const{schemaEnv:t,schema:r,self:o}=e.it,{root:s,baseId:i,localRefs:c,meta:d}=t.root,{schemaId:l}=o.opts,u=new a.SchemaEnv({schema:r,schemaId:l,root:s,baseId:i,localRefs:c,meta:d});return a.compileSchema.call(o,u),(0,n.getValidate)(e,u)}(e);r.if(o._`!${c}`,(()=>r.assign(c,d)))}r.dynamicAnchor=c,r.default=i},{"../../compile":5,"../../compile/codegen":2,"../../compile/names":6,"../core/ref":54}],59:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.dynamicRef=void 0;const o=e("../../compile/codegen"),s=e("../../compile/names"),a=e("../core/ref"),n={keyword:"$dynamicRef",schemaType:"string",code(e){return i(e,e.schema)}};function i(e,t){const{gen:r,keyword:n,it:i}=e;if("#"!==t[0])throw new Error(`"${n}" only supports hash fragment reference`);const c=t.slice(1);if(i.allErrors)d();else{const t=r.let("valid",!1);d(t),e.ok(t)}function d(e){if(i.schemaEnv.root.dynamicAnchors[c]){const t=r.let("_v",o._`${s.default.dynamicAnchors}${(0,o.getProperty)(c)}`);r.if(t,l(t,e),l(i.validateName,e))}else l(i.validateName,e)()}function l(t,o){return o?()=>r.block((()=>{(0,a.callRef)(e,t),r.let(o,!0)})):()=>(0,a.callRef)(e,t)}}r.dynamicRef=i,r.default=n},{"../../compile/codegen":2,"../../compile/names":6,"../core/ref":54}],60:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./dynamicAnchor"),s=e("./dynamicRef"),a=e("./recursiveAnchor"),n=e("./recursiveRef");r.default=[o.default,s.default,a.default,n.default]},{"./dynamicAnchor":58,"./dynamicRef":59,"./recursiveAnchor":61,"./recursiveRef":62}],61:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./dynamicAnchor"),s=e("../../compile/util");r.default={keyword:"$recursiveAnchor",schemaType:"boolean",code(e){e.schema?(0,o.dynamicAnchor)(e,""):(0,s.checkStrictMode)(e.it,"$recursiveAnchor: false is ignored")}}},{"../../compile/util":10,"./dynamicAnchor":58}],62:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./dynamicRef");r.default={keyword:"$recursiveRef",schemaType:"string",code(e){return(0,o.dynamicRef)(e,e.schema)}}},{"./dynamicRef":59}],63:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen");r.default={keyword:"format",type:["number","string"],schemaType:"string",$data:!0,error:{message({schemaCode:e}){return o.str`must match format "${e}"`},params({schemaCode:e}){return o._`{format: ${e}}`}},code(e,t){const{gen:r,data:s,$data:a,schema:n,schemaCode:i,it:c}=e,{opts:d,errSchemaPath:l,schemaEnv:u,self:m}=c;d.validateFormats&&(a?function(){const a=r.scopeValue("formats",{ref:m.formats,code:d.code.formats}),n=r.const("fDef",o._`${a}[${i}]`),c=r.let("fType"),l=r.let("format");r.if(o._`typeof ${n} == "object" && !(${n} instanceof RegExp)`,(()=>r.assign(c,o._`${n}.type || "string"`).assign(l,o._`${n}.validate`)),(()=>r.assign(c,o._`"string"`).assign(l,n))),e.fail$data((0,o.or)(!1===d.strictSchema?o.nil:o._`${i} && !${l}`,function(){const e=u.$async?o._`(${n}.async ? await ${l}(${s}) : ${l}(${s}))`:o._`${l}(${s})`,r=o._`(typeof ${l} == "function" ? ${e} : ${l}.test(${s}))`;return o._`${l} && ${l} !== true && ${c} === ${t} && !${r}`}()))}():function(){const a=m.formats[n];if(!a)return void function(){if(!1===d.strictSchema)return void m.logger.warn(e());throw new Error(e());function e(){return`unknown format "${n}" ignored in schema at path "${l}"`}}();if(!0===a)return;const[i,c,f]=function(e){const t=e instanceof RegExp?(0,o.regexpCode)(e):d.code.formats?o._`${d.code.formats}${(0,o.getProperty)(n)}`:void 0,s=r.scopeValue("formats",{key:n,ref:e,code:t});if("object"==typeof e&&!(e instanceof RegExp))return[e.type||"string",e.validate,o._`${s}.validate`];return["string",e,s]}(a);i===t&&e.pass(function(){if("object"==typeof a&&!(a instanceof RegExp)&&a.async){if(!u.$async)throw new Error("async format in sync schema");return o._`await ${f}(${s})`}return"function"==typeof c?o._`${f}(${s})`:o._`${f}.test(${s})`}())}())}}},{"../../compile/codegen":2}],64:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./format");r.default=[o.default]},{"./format":63}],65:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0}),r.contentVocabulary=r.metadataVocabulary=void 0,r.metadataVocabulary=["title","description","default","deprecated","readOnly","writeOnly","examples"],r.contentVocabulary=["contentMediaType","contentEncoding","contentSchema"]},{}],66:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./validation/dependentRequired"),s=e("./applicator/dependentSchemas"),a=e("./validation/limitContains");r.default=[o.default,s.default,a.default]},{"./applicator/dependentSchemas":39,"./validation/dependentRequired":71,"./validation/limitContains":74}],67:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./unevaluatedProperties"),s=e("./unevaluatedItems");r.default=[o.default,s.default]},{"./unevaluatedItems":68,"./unevaluatedProperties":69}],68:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util");r.default={keyword:"unevaluatedItems",type:"array",schemaType:["boolean","object"],error:{message({params:{len:e}}){return o.str`must NOT have more than ${e} items`},params({params:{len:e}}){return o._`{limit: ${e}}`}},code(e){const{gen:t,schema:r,data:a,it:n}=e,i=n.items||0;if(!0===i)return;const c=t.const("len",o._`${a}.length`);if(!1===r)e.setParams({len:i}),e.fail(o._`${c} > ${i}`);else if("object"==typeof r&&!(0,s.alwaysValidSchema)(n,r)){const r=t.var("valid",o._`${c} <= ${i}`);t.if((0,o.not)(r),(()=>function(r,a){t.forRange("i",a,c,(a=>{e.subschema({keyword:"unevaluatedItems",dataProp:a,dataPropType:s.Type.Num},r),n.allErrors||t.if((0,o.not)(r),(()=>t.break()))}))}(r,i))),e.ok(r)}n.items=!0}}},{"../../compile/codegen":2,"../../compile/util":10}],69:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../../compile/names");r.default={keyword:"unevaluatedProperties",type:"object",schemaType:["boolean","object"],trackErrors:!0,error:{message:"must NOT have unevaluated properties",params({params:e}){return o._`{unevaluatedProperty: ${e.unevaluatedProperty}}`}},code(e){const{gen:t,schema:r,data:n,errsCount:i,it:c}=e;if(!i)throw new Error("ajv implementation error");const{allErrors:d,props:l}=c;function u(a){if(!1===r)return e.setParams({unevaluatedProperty:a}),e.error(),void(d||t.break());if(!(0,s.alwaysValidSchema)(c,r)){const r=t.name("valid");e.subschema({keyword:"unevaluatedProperties",dataProp:a,dataPropType:s.Type.Str},r),d||t.if((0,o.not)(r),(()=>t.break()))}}l instanceof o.Name?t.if(o._`${l} !== true`,(()=>t.forIn("key",n,(e=>t.if(function(e,t){return o._`!${e} || !${e}[${t}]`}(l,e),(()=>u(e))))))):!0!==l&&t.forIn("key",n,(e=>void 0===l?u(e):t.if(function(e,t){const r=[];for(const s in e)!0===e[s]&&r.push(o._`${t} !== ${s}`);return(0,o.and)(...r)}(l,e),(()=>u(e))))),c.props=!0,e.ok(o._`${i} === ${a.default.errors}`)}}},{"../../compile/codegen":2,"../../compile/names":6,"../../compile/util":10}],70:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../../runtime/equal");r.default={keyword:"const",$data:!0,error:{message:"must be equal to constant",params({schemaCode:e}){return o._`{allowedValue: ${e}}`}},code(e){const{gen:t,data:r,$data:n,schemaCode:i,schema:c}=e;n||c&&"object"==typeof c?e.fail$data(o._`!${(0,s.useFunc)(t,a.default)}(${r}, ${i})`):e.fail(o._`${c} !== ${r}`)}}},{"../../compile/codegen":2,"../../compile/util":10,"../../runtime/equal":29}],71:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../applicator/dependencies");r.default={keyword:"dependentRequired",type:"object",schemaType:"object",error:o.error,code(e){return(0,o.validatePropertyDeps)(e)}}},{"../applicator/dependencies":38}],72:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../../runtime/equal");r.default={keyword:"enum",schemaType:"array",$data:!0,error:{message:"must be equal to one of the allowed values",params({schemaCode:e}){return o._`{allowedValues: ${e}}`}},code(e){const{gen:t,data:r,$data:n,schema:i,schemaCode:c,it:d}=e;if(!n&&0===i.length)throw new Error("enum must have non-empty array");let l;const u=()=>null!=l?l:l=(0,s.useFunc)(t,a.default);let m;if(i.length>=d.opts.loopEnum||n)m=t.let("valid"),e.block$data(m,(function(){t.assign(m,!1),t.forOf("v",c,(e=>t.if(o._`${u()}(${r}, ${e})`,(()=>t.assign(m,!0).break()))))}));else{if(!Array.isArray(i))throw new Error("ajv implementation error");const e=t.const("vSchema",c);m=(0,o.or)(...i.map(((t,s)=>function(e,t){const s=i[t];return"object"==typeof s&&null!==s?o._`${u()}(${r}, ${e}[${t}])`:o._`${r} === ${s}`}(e,s))))}e.pass(m)}}},{"../../compile/codegen":2,"../../compile/util":10,"../../runtime/equal":29}],73:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("./limitNumber"),s=e("./multipleOf"),a=e("./limitLength"),n=e("./pattern"),i=e("./limitProperties"),c=e("./required"),d=e("./limitItems"),l=e("./uniqueItems"),u=e("./const"),m=e("./enum");r.default=[o.default,s.default,a.default,n.default,i.default,c.default,d.default,l.default,{keyword:"type",schemaType:["string","array"]},{keyword:"nullable",schemaType:"boolean"},u.default,m.default]},{"./const":70,"./enum":72,"./limitItems":75,"./limitLength":76,"./limitNumber":77,"./limitProperties":78,"./multipleOf":79,"./pattern":80,"./required":81,"./uniqueItems":82}],74:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/util");r.default={keyword:["maxContains","minContains"],type:"array",schemaType:"number",code({keyword:e,parentSchema:t,it:r}){void 0===t.contains&&(0,o.checkStrictMode)(r,`"${e}" without "contains" is ignored`)}}},{"../../compile/util":10}],75:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen");r.default={keyword:["maxItems","minItems"],type:"array",schemaType:"number",$data:!0,error:{message:({keyword:e,schemaCode:t})=>o.str`must NOT have ${"maxItems"===e?"more":"fewer"} than ${t} items`,params({schemaCode:e}){return o._`{limit: ${e}}`}},code(e){const{keyword:t,data:r,schemaCode:s}=e;e.fail$data(o._`${r}.length ${"maxItems"===t?o.operators.GT:o.operators.LT} ${s}`)}}},{"../../compile/codegen":2}],76:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=e("../../compile/util"),a=e("../../runtime/ucs2length");r.default={keyword:["maxLength","minLength"],type:"string",schemaType:"number",$data:!0,error:{message:({keyword:e,schemaCode:t})=>o.str`must NOT have ${"maxLength"===e?"more":"fewer"} than ${t} characters`,params({schemaCode:e}){return o._`{limit: ${e}}`}},code(e){const{keyword:t,data:r,schemaCode:n,it:i}=e,c="maxLength"===t?o.operators.GT:o.operators.LT,d=!1===i.opts.unicode?o._`${r}.length`:o._`${(0,s.useFunc)(e.gen,a.default)}(${r})`;e.fail$data(o._`${d} ${c} ${n}`)}}},{"../../compile/codegen":2,"../../compile/util":10,"../../runtime/ucs2length":30}],77:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen"),s=o.operators,a={maximum:{okStr:"<=",ok:s.LTE,fail:s.GT},minimum:{okStr:">=",ok:s.GTE,fail:s.LT},exclusiveMaximum:{okStr:"<",ok:s.LT,fail:s.GTE},exclusiveMinimum:{okStr:">",ok:s.GT,fail:s.LTE}},n={message({keyword:e,schemaCode:t}){return o.str`must be ${a[e].okStr} ${t}`},params({keyword:e,schemaCode:t}){return o._`{comparison: ${a[e].okStr}, limit: ${t}}`}},i={keyword:Object.keys(a),type:"number",schemaType:"number",$data:!0,error:n,code(e){const{keyword:t,data:r,schemaCode:s}=e;e.fail$data(o._`${r} ${a[t].fail} ${s} || isNaN(${r})`)}};r.default=i},{"../../compile/codegen":2}],78:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen");r.default={keyword:["maxProperties","minProperties"],type:"object",schemaType:"number",$data:!0,error:{message:({keyword:e,schemaCode:t})=>o.str`must NOT have ${"maxProperties"===e?"more":"fewer"} than ${t} properties`,params({schemaCode:e}){return o._`{limit: ${e}}`}},code(e){const{keyword:t,data:r,schemaCode:s}=e;e.fail$data(o._`Object.keys(${r}).length ${"maxProperties"===t?o.operators.GT:o.operators.LT} ${s}`)}}},{"../../compile/codegen":2}],79:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/codegen");r.default={keyword:"multipleOf",type:"number",schemaType:"number",$data:!0,error:{message({schemaCode:e}){return o.str`must be multiple of ${e}`},params({schemaCode:e}){return o._`{multipleOf: ${e}}`}},code(e){const{gen:t,data:r,schemaCode:s,it:a}=e,n=a.opts.multipleOfPrecision,i=t.let("res"),c=n?o._`Math.abs(Math.round(${i}) - ${i}) > 1e-${n}`:o._`${i} !== parseInt(${i})`;e.fail$data(o._`(${s} === 0 || (${i} = ${r}/${s}, ${c}))`)}}},{"../../compile/codegen":2}],80:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../code"),s=e("../../compile/codegen");r.default={keyword:"pattern",type:"string",schemaType:"string",$data:!0,error:{message({schemaCode:e}){return s.str`must match pattern "${e}"`},params({schemaCode:e}){return s._`{pattern: ${e}}`}},code(e){const{data:t,$data:r,schema:a,schemaCode:n,it:i}=e,c=r?s._`(new RegExp(${n}, ${i.opts.unicodeRegExp?"u":""}))`:(0,o.usePattern)(e,a);e.fail$data(s._`!${c}.test(${t})`)}}},{"../../compile/codegen":2,"../code":51}],81:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../code"),s=e("../../compile/codegen"),a=e("../../compile/util");r.default={keyword:"required",type:"object",schemaType:"array",$data:!0,error:{message({params:{missingProperty:e}}){return s.str`must have required property '${e}'`},params({params:{missingProperty:e}}){return s._`{missingProperty: ${e}}`}},code(e){const{gen:t,schema:r,schemaCode:n,data:i,$data:c,it:d}=e,{opts:l}=d;if(!c&&0===r.length)return;const u=r.length>=l.loopRequired;if(d.allErrors?function(){if(u||c)e.block$data(s.nil,m);else for(const t of r)(0,o.checkReportMissingProp)(e,t)}():function(){const a=t.let("missing");if(u||c){const r=t.let("valid",!0);e.block$data(r,(()=>function(r,a){e.setParams({missingProperty:r}),t.forOf(r,n,(()=>{t.assign(a,(0,o.propertyInData)(t,i,r,l.ownProperties)),t.if((0,s.not)(a),(()=>{e.error(),t.break()}))}),s.nil)}(a,r))),e.ok(r)}else t.if((0,o.checkMissingProp)(e,r,a)),(0,o.reportMissingProp)(e,a),t.else()}(),l.strictRequired){const t=e.parentSchema.properties,{definedProperties:o}=e.it;for(const e of r)if(void 0===(null==t?void 0:t[e])&&!o.has(e)){(0,a.checkStrictMode)(d,`required property "${e}" is not defined at "${d.schemaEnv.baseId+d.errSchemaPath}" (strictRequired)`,d.opts.strictRequired)}}function m(){t.forOf("prop",n,(r=>{e.setParams({missingProperty:r}),t.if((0,o.noPropertyInData)(t,i,r,l.ownProperties),(()=>e.error()))}))}}}},{"../../compile/codegen":2,"../../compile/util":10,"../code":51}],82:[function(e,t,r){"use strict";Object.defineProperty(r,"__esModule",{value:!0});const o=e("../../compile/validate/dataType"),s=e("../../compile/codegen"),a=e("../../compile/util"),n=e("../../runtime/equal");r.default={keyword:"uniqueItems",type:"array",schemaType:"boolean",$data:!0,error:{message({params:{i:e,j:t}}){return s.str`must NOT have duplicate items (items ## ${t} and ${e} are identical)`},params({params:{i:e,j:t}}){return s._`{i: ${e}, j: ${t}}`}},code(e){const{gen:t,data:r,$data:i,schema:c,parentSchema:d,schemaCode:l,it:u}=e;if(!i&&!c)return;const m=t.let("valid"),f=d.items?(0,o.getSchemaTypes)(d.items):[];function p(a,n){const i=t.name("item"),c=(0,o.checkDataTypes)(f,i,u.opts.strictNumbers,o.DataType.Wrong),d=t.const("indices",s._`{}`);t.for(s._`;${a}--;`,(()=>{t.let(i,s._`${r}[${a}]`),t.if(c,s._`continue`),f.length>1&&t.if(s._`typeof ${i} == "string"`,s._`${i} += "_"`),t.if(s._`typeof ${d}[${i}] == "number"`,(()=>{t.assign(n,s._`${d}[${i}]`),e.error(),t.assign(m,!1).break()})).code(s._`${d}[${i}] = ${a}`)}))}function h(o,i){const c=(0,a.useFunc)(t,n.default),d=t.name("outer");t.label(d).for(s._`;${o}--;`,(()=>t.for(s._`${i} = ${o}; ${i}--;`,(()=>t.if(s._`${c}(${r}[${o}], ${r}[${i}])`,(()=>{e.error(),t.assign(m,!1).break(d)}))))))}e.block$data(m,(function(){const o=t.let("i",s._`${r}.length`),a=t.let("j");e.setParams({i:o,j:a}),t.assign(m,!0),t.if(s._`${o} > 1`,(()=>(f.length>0&&!f.some((e=>"object"===e||"array"===e))?p:h)(o,a)))}),s._`${l} === false`),e.ok(m)}}},{"../../compile/codegen":2,"../../compile/util":10,"../../compile/validate/dataType":13,"../../runtime/equal":29}],83:[function(e,t,r){"use strict";t.exports=function e(t,r){if(t===r)return!0;if(t&&r&&"object"==typeof t&&"object"==typeof r){if(t.constructor!==r.constructor)return!1;var o,s,a;if(Array.isArray(t)){if((o=t.length)!=r.length)return!1;for(s=o;0!=s--;)if(!e(t[s],r[s]))return!1;return!0}if(t.constructor===RegExp)return t.source===r.source&&t.flags===r.flags;if(t.valueOf!==Object.prototype.valueOf)return t.valueOf()===r.valueOf();if(t.toString!==Object.prototype.toString)return t.toString()===r.toString();if((o=(a=Object.keys(t)).length)!==Object.keys(r).length)return!1;for(s=o;0!=s--;)if(!Object.prototype.hasOwnProperty.call(r,a[s]))return!1;for(s=o;0!=s--;){var n=a[s];if(!e(t[n],r[n]))return!1}return!0}return t!=t&&r!=r}},{}],84:[function(e,t,r){"use strict";const{normalizeIPv6:o,normalizeIPv4:s,removeDotSegments:a,recomposeAuthority:n,normalizeComponentEncoding:i}=e("./lib/utils"),c=e("./lib/schemes");function d(e,t,r,o){const s={};return o||(e=f(l(e,r),r),t=f(l(t,r),r)),!(r=r||{}).tolerant&&t.scheme?(s.scheme=t.scheme,s.userinfo=t.userinfo,s.host=t.host,s.port=t.port,s.path=a(t.path||""),s.query=t.query):(void 0!==t.userinfo||void 0!==t.host||void 0!==t.port?(s.userinfo=t.userinfo,s.host=t.host,s.port=t.port,s.path=a(t.path||""),s.query=t.query):(t.path?("/"===t.path.charAt(0)?s.path=a(t.path):(s.path=void 0===e.userinfo&&void 0===e.host&&void 0===e.port||e.path?e.path?e.path.slice(0,e.path.lastIndexOf("/")+1)+t.path:t.path:"/"+t.path,s.path=a(s.path)),s.query=t.query):(s.path=e.path,s.query=void 0!==t.query?t.query:e.query),s.userinfo=e.userinfo,s.host=e.host,s.port=e.port),s.scheme=e.scheme),s.fragment=t.fragment,s}function l(e,t){const r={host:e.host,scheme:e.scheme,userinfo:e.userinfo,port:e.port,path:e.path,query:e.query,nid:e.nid,nss:e.nss,uuid:e.uuid,fragment:e.fragment,reference:e.reference,resourceName:e.resourceName,secure:e.secure,error:""},o=Object.assign({},t),s=[],i=c[(o.scheme||r.scheme||"").toLowerCase()];i&&i.serialize&&i.serialize(r,o),void 0!==r.path&&(o.skipEscape?r.path=unescape(r.path):(r.path=escape(r.path),void 0!==r.scheme&&(r.path=r.path.split("%3A").join(":")))),"suffix"!==o.reference&&r.scheme&&(s.push(r.scheme),s.push(":"));const d=n(r,o);if(void 0!==d&&("suffix"!==o.reference&&s.push("//"),s.push(d),r.path&&"/"!==r.path.charAt(0)&&s.push("/")),void 0!==r.path){let e=r.path;o.absolutePath||i&&i.absolutePath||(e=a(e)),void 0===d&&(e=e.replace(/^\/\//u,"/%2F")),s.push(e)}return void 0!==r.query&&(s.push("?"),s.push(r.query)),void 0!==r.fragment&&(s.push("#"),s.push(r.fragment)),s.join("")}const u=Array.from({length:127},((e,t)=>/[^!"$&'()*+,\-.;=_`a-z{}~]/u.test(String.fromCharCode(t))));const m=/^(?:([^#/:?]+):)?(?:\/\/((?:([^#/?@]*)@)?(\[[^#/?\]]+\]|[^#/:?]*)(?::(\d*))?))?([^#?]*)(?:\?([^#]*))?(?:#((?:.|[\n\r])*))?/u;function f(e,t){const r=Object.assign({},t),a={scheme:void 0,userinfo:void 0,host:"",port:void 0,path:"",query:void 0,fragment:void 0},n=-1!==e.indexOf("%");let i=!1;"suffix"===r.reference&&(e=(r.scheme?r.scheme+":":"")+"//"+e);const d=e.match(m);if(d){if(a.scheme=d[1],a.userinfo=d[3],a.host=d[4],a.port=parseInt(d[5],10),a.path=d[6]||"",a.query=d[7],a.fragment=d[8],isNaN(a.port)&&(a.port=d[5]),a.host){const e=s(a.host);if(!1===e.isIPV4){const t=o(e.host,{isIPV4:!1});a.host=t.host.toLowerCase(),i=t.isIPV6}else a.host=e.host,i=!0}a.reference=void 0!==a.scheme||void 0!==a.userinfo||void 0!==a.host||void 0!==a.port||a.path||void 0!==a.query?void 0===a.scheme?"relative":void 0===a.fragment?"absolute":"uri":"same-document",r.reference&&"suffix"!==r.reference&&r.reference!==a.reference&&(a.error=a.error||"URI is not a "+r.reference+" reference.");const e=c[(r.scheme||a.scheme||"").toLowerCase()];if(!(r.unicodeSupport||e&&e.unicodeSupport)&&a.host&&(r.domainHost||e&&e.domainHost)&&!1===i&&function(e){let t=0;for(let r=0,o=e.length;r126||u[t])return!0;return!1}(a.host))try{a.host=URL.domainToASCII(a.host.toLowerCase())}catch(e){a.error=a.error||"Host's domain name can not be converted to ASCII: "+e}(!e||e&&!e.skipNormalize)&&(n&&void 0!==a.scheme&&(a.scheme=unescape(a.scheme)),n&&void 0!==a.userinfo&&(a.userinfo=unescape(a.userinfo)),n&&void 0!==a.host&&(a.host=unescape(a.host)),void 0!==a.path&&a.path.length&&(a.path=escape(unescape(a.path))),void 0!==a.fragment&&a.fragment.length&&(a.fragment=encodeURI(decodeURIComponent(a.fragment)))),e&&e.parse&&e.parse(a,r)}else a.error=a.error||"URI can not be parsed.";return a}const p={SCHEMES:c,normalize(e,t){return"string"==typeof e?e=l(f(e,t),t):"object"==typeof e&&(e=f(l(e,t),t)),e},resolve(e,t,r){const o=Object.assign({scheme:"null"},r);return l(d(f(e,o),f(t,o),o,!0),{...o,skipEscape:!0})},resolveComponents:d,equal(e,t,r){return"string"==typeof e?(e=unescape(e),e=l(i(f(e,r),!0),{...r,skipEscape:!0})):"object"==typeof e&&(e=l(i(e,!0),{...r,skipEscape:!0})),"string"==typeof t?(t=unescape(t),t=l(i(f(t,r),!0),{...r,skipEscape:!0})):"object"==typeof t&&(t=l(i(t,!0),{...r,skipEscape:!0})),e.toLowerCase()===t.toLowerCase()},serialize:l,parse:f};t.exports=p,t.exports.default=p,t.exports.fastUri=p},{"./lib/schemes":85,"./lib/utils":87}],85:[function(e,t,r){"use strict";const o=/^[\da-f]{8}\b-[\da-f]{4}\b-[\da-f]{4}\b-[\da-f]{4}\b-[\da-f]{12}$/iu,s=/([\da-z][\d\-a-z]{0,31}):((?:[\w!$'()*+,\-.:;=@]|%[\da-f]{2})+)/iu;function a(e){return"boolean"==typeof e.secure?e.secure:"wss"===String(e.scheme).toLowerCase()}function n(e){return e.host||(e.error=e.error||"HTTP URIs must have a host."),e}function i(e){const t="https"===String(e.scheme).toLowerCase();return e.port!==(t?443:80)&&""!==e.port||(e.port=void 0),e.path||(e.path="/"),e}const c={scheme:"http",domainHost:!0,parse:n,serialize:i},d={scheme:"https",domainHost:c.domainHost,parse:n,serialize:i},l={scheme:"ws",domainHost:!0,parse(e){return e.secure=a(e),e.resourceName=(e.path||"/")+(e.query?"?"+e.query:""),e.path=void 0,e.query=void 0,e},serialize(e){if(e.port!==(a(e)?443:80)&&""!==e.port||(e.port=void 0),"boolean"==typeof e.secure&&(e.scheme=e.secure?"wss":"ws",e.secure=void 0),e.resourceName){const[t,r]=e.resourceName.split("?");e.path=t&&"/"!==t?t:void 0,e.query=r,e.resourceName=void 0}return e.fragment=void 0,e}},u={http:c,https:d,ws:l,wss:{scheme:"wss",domainHost:l.domainHost,parse:l.parse,serialize:l.serialize},urn:{scheme:"urn",parse(e,t){if(!e.path)return e.error="URN can not be parsed",e;const r=e.path.match(s);if(r){const o=t.scheme||e.scheme||"urn";e.nid=r[1].toLowerCase(),e.nss=r[2];const s=u[`${o}:${t.nid||e.nid}`];e.path=void 0,s&&(e=s.parse(e,t))}else e.error=e.error||"URN can not be parsed.";return e},serialize(e,t){const r=t.scheme||e.scheme||"urn",o=e.nid.toLowerCase(),s=u[`${r}:${t.nid||o}`];s&&(e=s.serialize(e,t));const a=e;return a.path=`${o||t.nid}:${e.nss}`,t.skipEscape=!0,a},skipNormalize:!0},"urn:uuid":{scheme:"urn:uuid",parse(e,t){const r=e;return r.uuid=r.nss,r.nss=void 0,t.tolerant||r.uuid&&o.test(r.uuid)||(r.error=r.error||"UUID is not valid."),r},serialize(e){const t=e;return t.nss=(e.uuid||"").toLowerCase(),t},skipNormalize:!0}};t.exports=u},{}],86:[function(e,t,r){"use strict";t.exports={HEX:{0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,a:10,A:10,b:11,B:11,c:12,C:12,d:13,D:13,e:14,E:14,f:15,F:15}}},{}],87:[function(e,t,r){"use strict";const{HEX:o}=e("./scopedChars");function s(e){if(c(e,".")<3)return{host:e,isIPV4:!1};const t=e.match(/^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])\.){3}(?:25[0-5]|2[0-4][0-9]|1[0-9][0-9]|[1-9][0-9]|[0-9])$/u)||[],[r]=t;return r?{host:i(r,"."),isIPV4:!0}:{host:e,isIPV4:!1}}function a(e,t=!1){let r="",s=!0;for(const t of e){if(void 0===o[t])return;"0"!==t&&!0===s&&(s=!1),s||(r+=t)}return t&&0===r.length&&(r="0"),r}function n(e,t={}){if(c(e,":")<2)return{host:e,isIPV6:!1};const r=function(e){let t=0;const r={error:!1,address:"",zone:""},o=[],s=[];let n=!1,i=!1,c=!1;function d(){if(s.length){if(!1===n){const e=a(s);if(void 0===e)return r.error=!0,!1;o.push(e)}s.length=0}return!0}for(let a=0;a7){r.error=!0;break}a-1>=0&&":"===e[a-1]&&(i=!0)}}return s.length&&(n?r.zone=s.join(""):o.push(c?s.join(""):a(s))),r.address=o.join(""),r}(e);if(r.error)return{host:e,isIPV6:!1};{let e=r.address,t=r.address;return r.zone&&(e+="%"+r.zone,t+="%25"+r.zone),{host:e,escapedHost:t,isIPV6:!0}}}function i(e,t){let r="",o=!0;const s=e.length;for(let a=0;athis.addVocabulary(e))),this.opts.discriminator&&this.addKeyword(a.default)}_addDefaultMetaSchema(){super._addDefaultMetaSchema();const{$data:e,meta:t}=this.opts;t&&(n.default.call(this,e),this.refs["http://json-schema.org/schema"]=i)}defaultMeta(){return this.opts.defaultMeta=super.defaultMeta()||(this.getSchema(i)?i:void 0)}}r.Ajv2020=c,t.exports=r=c,t.exports.Ajv2020=c,Object.defineProperty(r,"__esModule",{value:!0}),r.default=c;var d=e("./compile/validate");Object.defineProperty(r,"KeywordCxt",{enumerable:!0,get(){return d.KeywordCxt}});var l=e("./compile/codegen");Object.defineProperty(r,"_",{enumerable:!0,get(){return l._}}),Object.defineProperty(r,"str",{enumerable:!0,get(){return l.str}}),Object.defineProperty(r,"stringify",{enumerable:!0,get(){return l.stringify}}),Object.defineProperty(r,"nil",{enumerable:!0,get(){return l.nil}}),Object.defineProperty(r,"Name",{enumerable:!0,get(){return l.Name}}),Object.defineProperty(r,"CodeGen",{enumerable:!0,get(){return l.CodeGen}});var u=e("./runtime/validation_error");Object.defineProperty(r,"ValidationError",{enumerable:!0,get(){return u.default}});var m=e("./compile/ref_error");Object.defineProperty(r,"MissingRefError",{enumerable:!0,get(){return m.default}})},{"./compile/codegen":2,"./compile/ref_error":7,"./compile/validate":15,"./core":18,"./refs/json-schema-2020-12":20,"./runtime/validation_error":32,"./vocabularies/discriminator":55,"./vocabularies/draft2020":57}]},{},[])("2020")})); +//# sourceMappingURL=ajv2020.min.js.map \ No newline at end of file diff --git a/scripts/vendor/ajv-dist/8.17.1/manifest.json b/scripts/vendor/ajv-dist/8.17.1/manifest.json new file mode 100644 index 00000000..e51f00be --- /dev/null +++ b/scripts/vendor/ajv-dist/8.17.1/manifest.json @@ -0,0 +1,8 @@ +{ + "package": "ajv-dist", + "version": "8.17.1", + "sourcePath": "node_modules/ajv-dist/dist/ajv2020.min.js", + "vendorPath": "scripts/vendor/ajv-dist/8.17.1/ajv2020.min.js", + "sha256": "d2f97a24636c44135e1ffffeea29656c06a1c1f71b315e958c88d0e8126c2100", + "bytes": 132117 +}