diff --git a/.agents/skills/unidesk-cicd/SKILL.md b/.agents/skills/unidesk-cicd/SKILL.md index fcd61de4..299abf2a 100644 --- a/.agents/skills/unidesk-cicd/SKILL.md +++ b/.agents/skills/unidesk-cicd/SKILL.md @@ -32,6 +32,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 --consumer hwlab-jd01-v03 bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --limit 10 bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id +bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 --consumer --id bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer --source-worktree @@ -45,6 +46,12 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help - 单 consumer 再用 `platform-infra pipelines-as-code status --target --consumer ` 下钻; - 状态输出与 registry applicability 的长期判定见 [references/gitea-pac.md](references/gitea-pac.md)。 +- SELFMEDIA 最新一次端到端耗时: + - 唯一推荐入口是 `platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01`; + - 一次调用直接披露 GitHub PR `mergedAt`、trigger wait、PipelineRun/TaskRun、端到端耗时、Argo、runtime health 和 `evidenceGaps`; + - 证据不完整时保留已成立的耗时字段并返回 `partial` 与明确缺口,不得手工串联第二状态源; + - 该入口固定只读且 `mutation=false`,不得用于同步、触发、补跑或运行面写入。 + - 新增 PaC consumer 的首次引导: - 先执行一次 `pipelines-as-code bootstrap --dry-run`; - 确认后把同一命令改为 `--confirm`; diff --git a/.agents/skills/unidesk-cicd/references/gitea-pac.md b/.agents/skills/unidesk-cicd/references/gitea-pac.md index 1339e4a6..aa048640 100644 --- a/.agents/skills/unidesk-cicd/references/gitea-pac.md +++ b/.agents/skills/unidesk-cicd/references/gitea-pac.md @@ -64,6 +64,10 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target -- - 安装或更新 PaC controller、consumer RBAC、Repository CR、Argo bootstrap 与 Gitea webhook; - 不同步 source branch,不创建 snapshot,不触发业务 PipelineRun。 - 预检与失败边界: + - 默认 human 与显式 `--json` 使用同一份紧凑 typed projection,按 `yaml-exact-match`、`github-upstream`、`gitea-repository` 前置条件,以及 `gitea-init`、`pac-controller`、`tekton-consumer`、`gitops-argo` 阶段展示结果;只有显式 `--full` 或 `--raw` 才披露底层组合结果; + - YAML 精确匹配必须显示匹配数量,并区分 `yaml-repository-no-match` 与 `yaml-repository-multiple-matches`; + - GitHub 上游或凭据不可用、Gitea 初始化失败与 PaC apply 失败必须使用不同 blocker code;不得折叠为同一条未知错误; + - 失败输出只给 owning YAML 修正提示或 consumer 只读 `status` 下钻;不得给 mirror sync、人工 PipelineRun、Argo refresh 或其他 mutation 恢复命令; - PaC `apply` 在任何 Kubernetes 写入前读取 Gitea repository; - repository 缺失时返回 `gitea-repository-missing`,不留下半套 Tekton/RBAC/Argo 状态; - YAML 零匹配或多匹配时在本地拒绝,不猜测 repository; @@ -138,6 +142,14 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti 使用 `bun scripts/cli.ts platform-infra pipelines-as-code history --target --limit 10` 审计全部 consumer 的触发、耗时与 reuse。命令必须读取 target node 上的实时 Gitea Repository CR 与 Tekton PipelineRun/TaskRun,在 target 侧聚合并显式输出 `READ_ERRORS`。 +- 单次交付耗时查询: + - 使用 `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target --consumer `; + - 命令从 owning YAML 解析上游 GitHub repository、branch 与 consumer; + - 默认关联最新 PipelineRun source commit 对应的 GitHub PR `mergedAt`; + - 同时披露 trigger wait、PipelineRun、端到端耗时及 Argo/runtime closeout; + - 缺少 PR、时间戳或 provenance 时返回 `partial` 与 `evidenceGaps`,不得用 commit 时间替代 `mergedAt`; + - 整个入口保持 `mutation=false`,不写入历史库,不触发、同步或补跑交付。 + `history --id ` 必须按 target 内唯一 PipelineRun prefix 或 Tekton pipeline 归属解析实际 consumer,并用该 consumer 生成 status/history `Next`。零匹配、多匹配或显式 `--consumer` 与实际归属冲突时必须在远端读取前 fail-closed;禁止回退默认 consumer。 ## 按需只读调查 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-sub2api/SKILL.md b/.agents/skills/unidesk-sub2api/SKILL.md index 3b596eb1..6c2413ac 100644 --- a/.agents/skills/unidesk-sub2api/SKILL.md +++ b/.agents/skills/unidesk-sub2api/SKILL.md @@ -13,6 +13,8 @@ UniDesk 通过 `platform-infra sub2api` 运维 YAML 选中的 Sub2API target; bun scripts/cli.ts platform-infra sub2api report bun scripts/cli.ts platform-infra sub2api status --target PK01 bun scripts/cli.ts platform-infra sub2api validate --target PK01 +bun scripts/cli.ts platform-infra sub2api ops diagnosis --target PK01 +bun scripts/cli.ts platform-infra sub2api ops channels --target PK01 --window 7d bun scripts/cli.ts platform-infra sub2api plan --target PK01 bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm ``` @@ -24,6 +26,9 @@ bun scripts/cli.ts platform-infra sub2api image-prepull --target PK01 --confirm - YAML 是 source of truth;target、public exposure、Secret sourceRef、Codex pool 和 sentinel 配置都从 YAML 进入 CLI。 - Secret 只输出对象名、key 名、presence、fingerprint 或 redacted prefix;禁止打印完整 token/key。 - 默认 active target 以 YAML `defaults.targetId` 和 target role 为准;当前 `api.pikapython.com` 对应 PK01 host-Docker target。 +- 原生运维快照统一走 `ops diagnosis|channels`:默认输出紧凑文本,显式 `--json` 输出结构化结果;命令只读且不在前台模拟自动刷新。 +- `ops diagnosis` 的指标来自原生 admin/ops API;v0.1.155 没有独立 diagnosis/advice endpoint,CLI 必须把官方前端投影建议与 API 已验证事实分开呈现。 +- `ops channels` 使用原生渠道监控的 `7d`、`15d`、`30d` 窗口,支持平台、渠道和模型下钻;渠道历史固定分页并用 `--page-token ` 向更早记录翻页,`--record ` 精确下钻,不提供手工 `--limit`;原生响应未提供刷新倒计时时必须显示 unsupported。 - Codex pool、统一 API key、master `~/.codex` 配置、FRP/Caddy 暴露、账号增删都必须走本技能的受控 CLI。 - `api.pikapython.com` 异常先按 YAML target 区分 PK01 local edge/app、k3s FRP target 和账号池调度;用 `status`、`validate`、受控 apply/sync 以及最小 `/v1/responses` smoke 做分层恢复。完整步骤见 [references/troubleshooting.md](references/troubleshooting.md) 和 [references/public-exposure.md](references/public-exposure.md)。 diff --git a/.agents/skills/unidesk-subagent/SKILL.md b/.agents/skills/unidesk-subagent/SKILL.md index 69a798c9..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,8 +42,9 @@ 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 优先规则而外包治理决策。 - 子代理并发必须从用户原始任务的依赖图出发主动扩展: @@ -51,6 +56,12 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 每个任务终态和依赖变化后重新计算可安全派发集合; - 主代理自己的审核、规划和等待不计入子代理并发量;不得用一个长期任务加主代理活动宣称已经并行; - 不得为了凑并发拆出无独立价值的日志采集、重复调查、重复实现或审核代理;并发量服从原始任务边界和成功率,不服从固定数字。 +- 运行面故障、用户原入口不可用,或凭据/配置变更导致服务退化时,必须先恢复运行面,再完成工程化: + - 主代理先冻结恢复判定标准,并保留恢复关键路径的控制权; + - 优先用既有受控入口实施最小、可逆、可验证的恢复,只有受控入口本身缺失并直接阻塞恢复时,才在关键路径修改工具; + - 通用抽象、输出优化、skill、报告和长期治理不得成为恢复门禁; + - 恢复达到用户原入口可用的标准后,主代理不得把临时恢复当作任务完成,必须继续完成用户明确要求的根因修复、工程化、PR、验证和治理; + - 与恢复根因解耦且具备独立 issue、MDTODO、worktree 和验收入口的工程化任务,必须在恢复期间立即并行派发,不得全部排到恢复之后串行执行。 - 子代理并行只用于成功率高、耦合度低、能放进独立 worktree/branch/issue/PR 的任务;共享架构方向、公共契约和同文件高冲突修改先串行定锚。 - 子代理产出的审核默认由主代理直接完成: - 用户允许子代理实施、要求并行或启用多轮审查,不等于允许调度审核子代理; @@ -62,7 +73,7 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 子代理报告只承载其任务事实与结论,不能自行改写上层目标、架构取舍、优先级、主线状态或跨任务治理规则; - 主代理审阅子代理报告后,亲自把被采纳的结论写入管理性、决策性文档。 - 纯文档交付可以按 `$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、范围、禁止项和验收入口写入正文; @@ -81,6 +92,10 @@ description: UniDesk 主代理调度子代理的必读技能。用户提到子 - 子代理完成后必须留下可审查工件:PR、issue comment、commit、验证输出摘要、部署/observer/trace 证据或阻塞说明;主代理不能只凭口头结论合并。 - 子代理长时间无响应时,主代理按“问询 -> 只读检查 worktree/PR/issue -> 再窄问询”的顺序处理;多次问询仍无回复时,可以关闭旧子代理,并在原 worktree/原分支/原 issue 边界上重开新子代理接续。重开前不得删除或重置旧 worktree;新子代理必须先读取原 worktree 状态和既有 issue/PR/comment 链接,再继续最小下一步。 - 子代理完成任务后,主代理必须再给该子代理发送 post-task 收口要求;若主代理要求子代理纠偏或补验证,则等纠偏完成后再发 post-task。post-task 反馈由子代理按 `$post-task` 自行给出判断,主代理不负责反馈池去重;主代理只从子代理提好的反馈中挑选适合工程化的项转成正式 FEATURE/BUG issue,并优先派回提出该反馈的子代理执行。 +- post-task 反馈明确有助于达到“下次同类任务可以减少不必要工具调用”时,主代理可以直接创建对应 issue、登记 MDTODO,并派后续子代理改进工具、文档或 skill,无需再次等待用户确认: + - 仍须遵守子 issue、MDTODO、独立 worktree、目标分支、受控 GitHub 入口和主代理审核要求; + - 改进任务必须与原业务主线隔离并优先并发推进,不得成为当前业务交付、合并或上线门禁; + - 不得借反馈扩展业务功能、安全机制、权限契约、审计门禁或其他未经授权的范围。 - 主代理结束条件: - 当前用户任务只要派出过子代理: - 主代理就必须保持任务进行中; diff --git a/.agents/skills/unidesk-subagent/references/gh-workflow.md b/.agents/skills/unidesk-subagent/references/gh-workflow.md index 67b09643..99cd4242 100644 --- a/.agents/skills/unidesk-subagent/references/gh-workflow.md +++ b/.agents/skills/unidesk-subagent/references/gh-workflow.md @@ -4,6 +4,8 @@ ## 调度原则 +- 执行型任务默认且必须交给 Artificer。用户泛称“子代理”“并行代理”或“派代理”不等于授权原生子代理;只有用户明确指定原生子代理,或 Artificer 经有界 readiness、最短真实派单或 typed failure 证明不可用时,才允许原生执行面。 +- 主代理不能以方便、速度、空闲槽、已有 worktree、任务较短或历史习惯作为原生降级理由。Artificer 故障修复可由原生子代理承担;管理性与决策性工作仍由主代理直接完成。 - 主代理先明确总目标、目标 repo/branch/lane、架构方向和不可倒退边界,再拆任务。不能把“决定架构是否倒退”“是否关闭用户问题”“是否合并 PR”整体外包给子代理。 - 主代理直接审核子代理产出的 diff、验证证据和 PR。允许子代理实施、要求并行或多轮审查都不授权审核子代理;只有用户明确要求子代理审核时,才建立独立审核任务。 - 只有低耦合任务才并行:不同 worktree、不同模块或不同 issue,产物可以独立 review,失败不会阻塞其他任务继续产出证据。 @@ -19,6 +21,16 @@ - 并发不能靠重复调查、同根因的多套实现、无独立验收价值的日志采集或默认审核代理虚增;每条并发线都必须有独立 issue、worktree、交付物和验收入口。 - 派发 CI/CD 或运行面调优子任务时,主代理不要在 prompt 或评论里反复解释历史;用 issue/comment 链接引用既有结论,把下一步限定为可由子代理自主真实触发的单步 gate,并要求子代理在该 gate 内小闭环调优到通过后再提交 PR。 +## 恢复优先与工程化并发 + +- 依赖图必须先标出运行面恢复关键路径和用户原入口恢复标准。主代理控制恢复关键路径,避免多个代理同时修改同一生产对象、恢复状态机或共享配置。 +- 恢复优先指先让用户原入口通过既有受控路径恢复到可验证状态,不表示工程化任务整体串行: + - 恢复关键路径上的最小诊断、配置修正和复测按真实依赖顺序执行; + - 与根因解耦的 CLI、reference、报告、独立仓库修复和长期治理,在具备独立 issue、MDTODO、worktree、PR 与验收入口时立即并行; + - 只有缺少受控恢复入口直接阻塞恢复时,才允许把工具修改放到恢复关键路径。 +- 运行面恢复后,主代理继续完成根因修复、持久化配置、自动交付、原入口复测和治理收口;不得以临时恢复或单次手工成功代替用户要求的终态。 +- 恢复期间降低并发必须有具名原因,例如共享生产对象、同一状态机、权限边界或运行面容量;原因解除后立即重新计算可安全并发窗口。 + ## 模型与思考等级 - Artificer 默认不显式覆盖模型: @@ -63,6 +75,7 @@ - 每个子代理完成主任务后,主代理必须发送 post-task 收口要求;如果主代理发现需要纠偏、补证据或返工,应先让子代理完成纠偏,再发送 post-task。post-task 只收集反馈和后续问题线索,不应让子代理自行扩大代码修改范围。 - 子代理按 `$post-task` 自行输出已判断的 feedback 候选、疑似归属和是否建议转正式 issue;主代理不负责反馈池去重,不代替子代理维护 `[FEEDBACK]` issue。主代理只从子代理提好的 feedback 中挑选适合直接工程化的项,另起正式 FEATURE/BUG issue 后继续调度。 - feedback 转正式 issue 后,优先派回提出该 feedback 的子代理执行,使调查上下文和修复上下文保持连续;只有该子代理不可用或任务边界已变化时才派给其他子代理。 +- feedback 明确能减少下次同类任务不必要工具调用时,主代理可以直接建立正式 issue、登记 MDTODO 并派后续子代理改进工具、文档或 skill,无需再次等待用户确认。该任务使用独立 worktree/PR 并与业务主线并发,不能成为当前业务交付门禁,也不能借机扩展未经授权的业务或安全范围。 ## PR 工作流 @@ -71,7 +84,7 @@ - 先看架构约束和倒退风险,再看实现细节; - 重点检查是否重新引入旧 authority、并行请求源、裸 API 绕过、隐藏默认、阈值硬编码或降低探针能力; - 默认不再派审核子代理。 -- 合并前使用 `$unidesk-gh` 的 review/preflight/merge 入口。独立 PR 可以并行 review;有依赖的 PR 按契约基线、实现、验证工具、closeout 的顺序合并。 +- 合并前使用 `$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 留下完整证据;主代理仍要抽查并最终汇总。 @@ -96,7 +109,7 @@ Prompt 至少包含以下字段,按任务裁剪: 4. 计算当前可安全并发窗口并同时派发全部已就绪的低耦合子任务;共享契约先派一个基线任务。 - Artificer 的 `create task`、`apply` 和 `dispatch` 均只能发生在 MDTODO 登记之后。 5. 轮询子代理结果:子 issue comment、PR、验证摘要、阻塞;任一任务终态或依赖变化后立即重新计算窗口并补派,不能退化为长期单任务等待。 -6. 对每个 PR 做架构 review、bounded diff、preflight 和必要本地验证。 +6. 对每个 PR 做架构 review、bounded diff 和必要本地验证;只有定点排障时单独执行 preflight,正常收口直接使用内建 readiness 的 guarded merge。 7. 按依赖顺序合并;合并后同步目标 worktree。 8. 触发受控 CI/CD 或让明确授权的子代理上线;主代理核对 closeout。 9. 用原入口复测;把剩余问题拆到新 issue 或追加既有 issue。 diff --git a/config/pikaoa.yaml b/config/pikaoa.yaml new file mode 100644 index 00000000..c7dd7e55 --- /dev/null +++ b/config/pikaoa.yaml @@ -0,0 +1,164 @@ +version: 1 +kind: pikaoa-platform-delivery + +metadata: + id: pikaoa-enterprise + owner: unidesk + repository: pikainc/pikaoa + spec: PJ2026-03 + relatedIssues: + - 1952 + +defaults: + targetId: NC01 + +modules: + builtIn: + - identity + - partners + - contracts + - invoices + - audit + extensionContract: + registration: module-descriptor + persistence: schema-owned-migrations + api: versioned-http-routes + events: transactional-outbox + workers: named-consumer + +delivery: + targets: + NC01: + node: NC01 + lane: pikaoa + route: NC01:k3s + namespace: pikaoa + ci: + namespace: pikaoa-ci + pipeline: pikaoa-nc01-pac + pipelineRunPrefix: pikaoa-nc01 + serviceAccountName: pikaoa-nc01-tekton-runner + serviceAccountAutomount: false + roleBindingName: pikaoa-nc01-tekton-runner + workspaceSize: 8Gi + pipelineTimeout: 20m0s + toolsImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 + buildkitImage: 127.0.0.1:5000/hwlab/buildkit:rootless + source: + repository: pikainc/pikaoa + worktreeRemote: https://github.com/pikainc/pikaoa.git + branch: master + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + snapshotPrefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + build: + dockerfiles: + api: deploy/api.Dockerfile + worker: deploy/worker.Dockerfile + web: deploy/web.Dockerfile + images: + api: 127.0.0.1:5000/pikaoa/api + worker: 127.0.0.1:5000/pikaoa/worker + web: 127.0.0.1:5000/pikaoa/web + networkMode: host + proxy: + http: http://127.0.0.1:10808 + https: http://127.0.0.1:10808 + all: http://127.0.0.1:10808 + noProxy: + - localhost + - 127.0.0.1 + - "::1" + - 127.0.0.1:5000 + - localhost:5000 + - .svc + - .svc.cluster.local + - .cluster.local + - hyueapi.com + - .hyueapi.com + gitops: + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + writeUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + branch: nc01-pikaoa-gitops + manifestPath: deploy/gitops/nc01/resources.yaml + releaseStatePath: deploy/gitops-state/nc01/release.json + credentialSecretName: pac-gitea-pikaoa-nc01 + credentialTokenKey: token + credentialUsername: unidesk-admin + author: + name: PikaOA NC01 CI + email: pikaoa-nc01-ci@unidesk.local + runtime: + workloads: + api: + kind: Deployment + name: pikaoa-api + replicas: 2 + serviceName: pikaoa-api + port: 8080 + healthPath: /healthz + metricsPath: /metrics + worker: + kind: Deployment + name: pikaoa-worker + replicas: 1 + healthPort: 8081 + healthPath: /healthz + metricsPath: /metrics + web: + kind: Deployment + name: pikaoa-web + replicas: 2 + serviceName: pikaoa-web + port: 8080 + healthPath: /healthz + secret: + configRef: config/secrets-distribution.yaml + declarationId: pikaoa-runtime + database: + configRef: config/platform-db/postgres-pk01.yaml#exports.connectionStrings.pikaoa-database-url + observability: + configRef: config/platform-infra/observability.yaml + otlpHttpEndpoint: http://otel-collector.platform-infra.svc.cluster.local:4318 + prometheusScrape: true + propagation: + - tracecontext + - baggage + exporterFailure: + warning: true + blocking: false + bootstrapUsers: + administrator: + username: admin + passwordSourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + employee: + username: employee + passwordSourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + publicExposure: + enabled: true + publicBaseUrl: https://oa.pikapython.com + dns: + hostname: oa.pikapython.com + expectedA: 82.156.23.220 + resolvers: + - 1.1.1.1 + - 8.8.8.8 + - 223.5.5.5 + - 114.114.114.114 + frpc: + deploymentName: pikaoa-frpc + configMapName: pikaoa-frpc-config + configKey: frpc.toml + image: 127.0.0.1:5000/hwlab/frpc:v0.68.1 + serverAddr: 82.156.23.220 + serverPort: 22000 + proxyName: pikaoa-nc01-web + remotePort: 22096 + localIP: pikaoa-web.pikaoa.svc.cluster.local + localPort: 8080 + tokenEnvName: FRP_TOKEN + tokenTargetKey: FRP_TOKEN + pk01: + route: PK01 + caddyConfigPath: /etc/caddy/Caddyfile + caddyServiceName: caddy + responseHeaderTimeoutSeconds: 60 diff --git a/config/platform-db/postgres-pk01.yaml b/config/platform-db/postgres-pk01.yaml index 949b05c9..8795f1c6 100644 --- a/config/platform-db/postgres-pk01.yaml +++ b/config/platform-db/postgres-pk01.yaml @@ -359,6 +359,21 @@ postgres: user: hwlab_jd01_v03_app address: 74.48.78.17/32 method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 10.0.8.0/22 + method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 74.48.78.17/32 + method: scram-sha-256 + - type: hostssl + database: pikaoa + user: pikaoa + address: 152.53.229.148/32 + method: scram-sha-256 secrets: source: master-local @@ -462,6 +477,20 @@ secrets: HWLAB_JD01_V03_DB_NAME: hwlab_jd01_v03 randomHex: HWLAB_JD01_V03_DB_PASSWORD: 32 + - name: pikaoa-db-credentials + sourceRef: platform-db/pikaoa-db.env + type: env + requiredKeys: + - PIKAOA_DB_USER + - PIKAOA_DB_PASSWORD + - PIKAOA_DB_NAME + createIfMissing: + enabled: true + values: + PIKAOA_DB_USER: pikaoa + PIKAOA_DB_NAME: pikaoa + randomHex: + PIKAOA_DB_PASSWORD: 32 objects: roles: - name: sub2api @@ -527,6 +556,15 @@ objects: createdb: false createrole: false superuser: false + - name: pikaoa + passwordRef: + sourceRef: platform-db/pikaoa-db.env + key: PIKAOA_DB_PASSWORD + login: true + attributes: + createdb: false + createrole: false + superuser: false databases: - name: sub2api owner: sub2api @@ -563,6 +601,11 @@ objects: encoding: UTF8 locale: C.UTF-8 extensions: [] + - name: pikaoa + owner: pikaoa + encoding: UTF8 + locale: C.UTF-8 + extensions: [] exports: connectionStrings: @@ -705,6 +748,22 @@ exports: - scope: hwlab-jd01-v03 secret: hwlab-v03-openfga key: DATASTORE_URI + - name: pikaoa-database-url + sourceSecretRef: platform-db/pikaoa-db.env + render: + envKey: DATABASE_URL + format: postgresql://$(PIKAOA_DB_USER):$(PIKAOA_DB_PASSWORD)@$(PGHOST):$(PGPORT)/$(PIKAOA_DB_NAME)?sslmode=require + variables: + PGHOST: 82.156.23.220 + PGPORT: "5432" + writeToSecretSource: + sourceRef: platform-infra/pikaoa.env + key: DATABASE_URL + mode: update-or-insert + consumers: + - scope: pikaoa + secret: pikaoa-runtime + key: DATABASE_URL backup: phase: minimum-restoreable @@ -750,6 +809,9 @@ observability: - kind: psql-app-role database: hwlab_d601_v03 user: hwlab_d601_v03_app + - kind: psql-app-role + database: pikaoa + user: pikaoa - kind: disk-free path: /var/lib/postgresql/16/main minFreeGiB: 10 diff --git a/config/platform-infra/gitea.yaml b/config/platform-infra/gitea.yaml index 684cf036..eb551567 100644 --- a/config/platform-infra/gitea.yaml +++ b/config/platform-infra/gitea.yaml @@ -400,6 +400,52 @@ sourceAuthority: naming: gitea-actions-immutable-source prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-master-nc01 legacyGitMirror: null + - key: pikaoa-nc01 + targetId: NC01 + credentialOverride: + github: + transport: https-token + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token + requiredFor: + - upstream-mirror + - mirror-sync + - managed-repository-fetch + - github-head-observe + - github-hooks-list + - github-hooks-reconcile + permissions: + contents: read + metadata: read + webhooks: read-write + gitFetchCredential: + apiVersion: unidesk.ai/v1 + kind: GitFetchCredential + authMode: github-https-token + host: github.com + secretRef: + namespace: devops-infra + name: gitea-github-sync-secrets + key: github-token-pikainc-pikaoa + upstream: + repository: pikainc/pikaoa + cloneUrl: https://github.com/pikainc/pikaoa.git + branch: master + visibility: private + gitea: + owner: mirrors + name: pikainc-pikaoa + mirrorMode: controlled-push + publicRead: false + readUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops: + branch: nc01-pikaoa-gitops + flushDisposition: gitea-writeback + snapshot: + naming: gitea-actions-immutable-source + prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + legacyGitMirror: null targets: - id: JD01 diff --git a/config/platform-infra/observability.yaml b/config/platform-infra/observability.yaml index 3bb2e19a..1c4e3dd8 100644 --- a/config/platform-infra/observability.yaml +++ b/config/platform-infra/observability.yaml @@ -21,6 +21,10 @@ images: repository: docker.m.daocloud.io/grafana/tempo tag: 2.8.1 pullPolicy: IfNotPresent + prometheus: + repository: docker.m.daocloud.io/prom/prometheus + tag: v3.5.0 + pullPolicy: IfNotPresent targets: - id: NC01 @@ -66,6 +70,42 @@ traceBackend: mode: emptyDir retention: 24h +metricsBackend: + type: prometheus + enabled: true + targetIds: + - NC01 + deploymentName: prometheus + serviceName: prometheus + configMapName: prometheus-config + serviceAccountName: prometheus + clusterRoleName: platform-infra-prometheus + clusterRoleBindingName: platform-infra-prometheus + persistentVolumeClaimName: prometheus-data + replicas: 1 + httpPort: 9090 + storage: + mode: persistentVolumeClaim + size: 20Gi + retention: 15d + discovery: + namespaces: [] + labelSelector: + key: unidesk.ai/metrics + value: "true" + annotationKeys: + scrape: prometheus.io/scrape + path: prometheus.io/path + port: prometheus.io/port + defaultPath: /metrics + scrapeInterval: 30s + scrapeTimeout: 10s + query: + path: /api/v1/query + timeoutSeconds: 15 + maxSeries: 200 + maxResponseBytes: 262144 + sampling: mode: parentbased_traceidratio ratio: 1 @@ -185,3 +225,7 @@ probes: service: otel-collector portName: health path: / + - name: prometheus-ready + service: prometheus + portName: http + path: /-/ready diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index b798ac44..a7cc847e 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -211,6 +211,45 @@ repositories: runtime_service_port: "4317" health_path: /healthz health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz + - id: pikaoa-nc01 + name: pikaoa-nc01 + namespace: pikaoa-ci + providerType: gitea + url: https://gitea.pikapython.com/mirrors/pikainc-pikaoa + cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + owner: mirrors + repo: pikainc-pikaoa + secretName: pac-gitea-pikaoa-nc01 + tokenKey: token + webhookSecretKey: webhook.secret + concurrencyLimit: 1 + params: + git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + source_branch: master + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/pikaoa-master-nc01 + node: NC01 + pipeline_name: pikaoa-nc01-pac + pipeline_run_prefix: pikaoa-nc01 + service_account: pikaoa-nc01-tekton-runner + pipeline_timeout: 20m0s + workspace_pvc_size: 8Gi + image_repository: 127.0.0.1:5000/pikaoa/api + api_image_repository: 127.0.0.1:5000/pikaoa/api + worker_image_repository: 127.0.0.1:5000/pikaoa/worker + web_image_repository: 127.0.0.1:5000/pikaoa/web + registry_probe_base: http://127.0.0.1:5000 + gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + gitops_branch: nc01-pikaoa-gitops + gitops_username: unidesk-admin + gitops_secret_name: pac-gitea-pikaoa-nc01 + gitops_manifest_path: deploy/gitops/nc01/resources.yaml + runtime_namespace: pikaoa + runtime_deployment: pikaoa-api + runtime_service: pikaoa-web + runtime_service_port: "8080" + health_path: /healthz + health_url: http://pikaoa-web.pikaoa.svc.cluster.local:8080/healthz consumers: - extends: templates.consumers.agentrunV02 variables: @@ -372,6 +411,30 @@ consumers: hostNetwork: true dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 + - id: pikaoa-nc01 + repositoryRef: pikaoa-nc01 + node: NC01 + lane: pikaoa + namespace: pikaoa-ci + pipeline: pikaoa-nc01-pac + pipelineRunPrefix: pikaoa-nc01 + argoNamespace: argocd + argoApplication: pikaoa-nc01 + closeoutGitOpsMirrorFlush: false + argoBootstrap: + project: default + repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-pikaoa.git + targetRevision: nc01-pikaoa-gitops + path: deploy/gitops/nc01 + destinationNamespace: pikaoa + automated: true + repositoryCredential: + secretName: argocd-repo-pikaoa-nc01 + username: unidesk-admin + runnerServiceAccount: + name: pikaoa-nc01-tekton-runner + automountServiceAccountToken: false + roleBindingName: pikaoa-nc01-tekton-runner templates: repositories: agentrunV02: diff --git a/config/platform-infra/sub2api.yaml b/config/platform-infra/sub2api.yaml index 6a0595c4..503a45ba 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.147 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 76c65db2..0accf54d 100644 --- a/config/secrets-distribution.yaml +++ b/config/secrets-distribution.yaml @@ -88,6 +88,17 @@ sources: SUB2RANK_ADMIN_TOKEN: bytes: 32 prefix: srk_ + - sourceRef: platform-infra/pikaoa.env + type: env + requiredKeys: + - DATABASE_URL + - PIKAOA_SESSION_SECRET + createIfMissing: + enabled: true + randomBase64Url: + PIKAOA_SESSION_SECRET: + bytes: 32 + prefix: poa_ - sourceRef: hwlab/jd01-v03-opencode.env type: env requiredKeys: @@ -121,6 +132,22 @@ sources: createIfMissing: enabled: false externalFiles: + - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + type: raw-file + required: true + createIfMissing: + enabled: true + randomBase64Url: + bytes: 24 + prefix: poa_ + - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + type: raw-file + required: true + createIfMissing: + enabled: true + randomBase64Url: + bytes: 24 + prefix: poa_ - sourceRef: /root/.unidesk/.env/selfmedia-access.env type: env requiredKeys: @@ -192,8 +219,33 @@ targets: - selfmedia timeoutSeconds: 180 pollIntervalSeconds: 5 + - id: pikaoa-nc01 + route: NC01:k3s + namespace: pikaoa + scope: pikaoa + enabled: true kubernetesSecrets: + - name: pikaoa-runtime + targetId: pikaoa-nc01 + secretName: pikaoa-runtime + type: Opaque + data: + - sourceRef: platform-infra/pikaoa.env + sourceKey: DATABASE_URL + targetKey: DATABASE_URL + - sourceRef: platform-infra/pikaoa.env + sourceKey: PIKAOA_SESSION_SECRET + targetKey: PIKAOA_SESSION_SECRET + - sourceRef: ~/.unidesk/.env/pikaoa-admin-password.txt + sourceKey: contents + targetKey: PIKAOA_BOOTSTRAP_ADMIN_PASSWORD + - sourceRef: ~/.unidesk/.env/pikaoa-employee-password.txt + sourceKey: contents + targetKey: PIKAOA_BOOTSTRAP_EMPLOYEE_PASSWORD + - sourceRef: platform-infra/pk01-frp.env + sourceKey: FRP_TOKEN + targetKey: FRP_TOKEN - name: selfmedia-access targetId: selfmedia-nc01 secretName: selfmedia-access diff --git a/config/unidesk-cli.yaml b/config/unidesk-cli.yaml index 447ac3f1..1a5ec1ea 100644 --- a/config/unidesk-cli.yaml +++ b/config/unidesk-cli.yaml @@ -15,6 +15,12 @@ github: sourceKey: GH_TOKEN format: raw-token repositoryOverrides: + - repository: pikainc/pikaoa + priority: before-env + root: /root/.unidesk/.env + sourceRef: pikainc-selfmedia-gh-token.txt + sourceKey: GH_TOKEN + format: raw-token - repository: pikainc/selfmedia priority: before-env root: /root/.unidesk/.env diff --git a/docs/MDTODO/agentrun-runtime-reliability.md b/docs/MDTODO/agentrun-runtime-reliability.md index 9e188421..2d031ec3 100644 --- a/docs/MDTODO/agentrun-runtime-reliability.md +++ b/docs/MDTODO/agentrun-runtime-reliability.md @@ -117,3 +117,14 @@ ### R5.7 解决 [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/pikaoa-enterprise-platform/R1.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.1_Task_Report.md new file mode 100644 index 00000000..d4cc6e99 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.1_Task_Report.md @@ -0,0 +1,508 @@ +# PikaOA R1.1 开源办公系统与组件调研报告 + +## 1. 任务信息 + +| 字段 | 内容 | +| --- | --- | +| MDTODO | [R1.1](../../pikaoa-enterprise-platform.md) | +| 执行 issue | [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953) | +| 上层规格 | [PJ2026-03](../../../../project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md) | +| 调研日期 | 2026-07-13 | +| 交付性质 | 隔离选型文档,不包含业务代码、运行面、Secret 或 CI/CD 变更 | + +本报告只为 PikaOA 后续实现提供证据和建议,不改变 PJ2026-03 已冻结的模块化单体、CLI-first、 +HWLAB v0.3 同族前端和 YAML-first 方向。 + +## 2. 决策摘要 + +### 2.1 总体结论 + +- 不直接采用 ERPNext、Odoo、Twenty、EspoCRM、Dolibarr 或 OpenProject 作为 PikaOA 底座: + - 这些系统的领域边界、扩展模型、技术栈和升级路径会反向约束 PJ2026-03; + - 多数完整系统使用 GPL 或 AGPL,闭源可能性未知时不应复制源码或形成紧耦合派生实现; + - PikaOA 首期需要的是稳定平台内核与合同、发票事实,而不是迁移完整 ERP/CRM 产品。 +- 直接采用宽松许可证的 Go、Vue 和可观测性基础组件: + - HTTP:`chi`; + - PostgreSQL:`pgx` 与 `sqlc`; + - 数据库迁移:`goose`; + - CLI:`Cobra`; + - 授权:首期 `Casbin`,保留以后提取到 `OpenFGA` 的边界; + - 事务消息:PostgreSQL outbox 与 `Watermill`; + - 前端:Vue 3、Vite、TypeScript、Vue Router、Pinia、Tailwind; + - 可观测性:OpenTelemetry Go、OTLP Collector、Prometheus Go client 和结构化日志关联。 +- 仅参考成熟系统的业务建模,不复制实现: + - ERPNext/Frappe 的单据提交、取消、修订和审计思路; + - Odoo 的发票冲销、贷项通知单与会计事实保留思路; + - CRM 的统一伙伴、联系人、标签与上下文角色模型; + - Paperless-ngx 的文档归档、检索、标签和权限思路; + - Documenso 的签署流程与签署证据思路。 +- 首期不采用 `OpenFGA`、`Asynq`、`Atlas`: + - `OpenFGA` 增加独立服务和授权模型运维成本,当前 RBAC 可由 `Casbin` 满足; + - `Asynq` 依赖 Redis,首期已有 PostgreSQL outbox 即可提供可审计异步处理; + - `Atlas` 与 `goose` 职责重叠,首期应保持单一迁移真相。 + +### 2.2 推荐分类 + +- 直接采用: + - `chi`、`pgx`、`sqlc`、`goose`、`Cobra`、`Casbin` 和 `Watermill`; + - Vue 3、Vue Router、Pinia 和 Tailwind; + - OpenTelemetry Go、Prometheus Go client 和 OTLP Collector。 +- 组件复用: + - HWLAB v0.3 的前端布局语言、路由守卫和 API 错误呈现; + - HWLAB v0.3 的响应式工作台模式; + - 现有 UniDesk OTel、Tempo 和 Prometheus 运行能力。 +- 仅参考: + - ERPNext/Frappe、Odoo、Twenty、EspoCRM 和 Dolibarr; + - OpenProject、Documenso 和 Paperless-ngx; + - OpenFGA 和 Asynq。 +- 不采用: + - 完整 OA、ERP 或 CRM 产品作为 PikaOA 底座; + - GPL 或 AGPL 源码复制; + - 首期微服务拆分; + - 同时使用多套 HTTP、迁移或异步任务框架。 + +## 3. 调研方法与活跃度口径 + +- 来源优先级: + - 项目官方 GitHub 仓库及其许可证文件; + - 项目官方文档; + - GitHub Releases 和仓库最近推送时间。 +- 活跃度判定: + - `高`:调研日附近仍有提交,并在近数月内发布版本; + - `中`:持续维护,但发布节奏较慢或主要以稳定维护为主; + - 活跃度只说明维护状态,不等于适合 PikaOA。 +- 表中版本和活跃度是 2026-07-13 的采样证据: + - 最终依赖版本必须由实现任务根据锁文件、安全公告和兼容性验证选择; + - 不把本报告中的版本固化为部署策略。 + +## 4. 完整办公、ERP、CRM 与文档系统 + +### 4.1 ERPNext 与 Frappe + +- 来源: + - [ERPNext](https://github.com/frappe/erpnext); + - [Frappe](https://github.com/frappe/frappe)。 +- 活跃度与技术栈: + - 高;Python、JavaScript 和元数据驱动框架; + - ERPNext 于调研日仍有发布。 +- 许可证: + - ERPNext 为 GPL-3.0; + - Frappe 框架和附属组件需按锁定版本逐项复核。 +- 可复用层次: + - 单据状态、提交后修改限制、取消与修订; + - 审计时间线和模块元数据思路。 +- 成本与风险: + - 领域和框架绑定深; + - 采用整套系统会替换 Go 与既定模块边界; + - GPL 代码复制风险高。 +- 结论:仅参考业务语义,不采用产品或源码。 + +### 4.2 Odoo + +- 来源:[Odoo](https://github.com/odoo/odoo)。 +- 活跃度与技术栈:高;Python、JavaScript 和模块化 ERP。 +- 许可证: + - Community 核心通常为 LGPL-3.0; + - Enterprise 和单独模块另有许可,必须逐项核验。 +- 可复用层次: + - 发票、贷项通知单和冲销; + - 伙伴、模块注册和工作流。 +- 成本与风险: + - 社区版与企业版边界复杂; + - 直接采用会偏离 Go 和 CLI-first。 +- 结论:仅参考会计与伙伴模型。 + +### 4.3 Twenty + +- 来源:[Twenty](https://github.com/twentyhq/twenty)。 +- 活跃度与技术栈:高;TypeScript、React、NestJS 和 PostgreSQL。 +- 许可证:仓库包含开源与商业边界,采用前必须逐文件核对。 +- 可复用层次: + - 现代 CRM 交互; + - 统一对象、筛选、视图和活动时间线。 +- 成本与风险: + - 技术栈与 HWLAB Vue 不同; + - 许可证与企业功能边界增加合规审查。 +- 结论:仅参考产品交互和对象模型。 + +### 4.4 EspoCRM + +- 来源:[EspoCRM](https://github.com/espocrm/espocrm)。 +- 活跃度与技术栈:高;PHP 和 JavaScript。 +- 许可证:AGPL-3.0。 +- 可复用层次:CRM 实体、角色、团队、关系和审计思路。 +- 成本与风险:AGPL 网络服务义务,且 PHP 技术栈不匹配。 +- 结论:仅参考,不复制源码。 + +### 4.5 Dolibarr + +- 来源:[Dolibarr](https://github.com/Dolibarr/dolibarr)。 +- 活跃度与技术栈:高;PHP。 +- 许可证:GPL-3.0。 +- 可复用层次: + - 轻量 ERP 和 CRM 模块边界; + - 第三方与发票关联。 +- 成本与风险:领域覆盖广但架构迁移成本较高,且存在 GPL 风险。 +- 结论:仅参考模块目录和业务关系。 + +### 4.6 OpenProject + +- 来源:[OpenProject](https://github.com/opf/openproject)。 +- 活跃度与技术栈:高;Ruby 和 TypeScript。 +- 许可证:GPL-3.0。 +- 可复用层次:项目、工作包、活动记录和权限矩阵。 +- 成本与风险:面向项目管理而非企业平台内核,技术栈与许可证不匹配。 +- 结论:不作为底座,仅参考工作项审计。 + +### 4.7 Documenso + +- 来源:[Documenso](https://github.com/documenso/documenso)。 +- 活跃度与技术栈:高;TypeScript。 +- 许可证:AGPL-3.0。 +- 可复用层次:文档签署状态、参与者、签署证据和模板体验。 +- 成本与风险: + - 电子签名涉及合规、身份与证据链; + - 存在 AGPL 风险。 +- 结论:首期仅预留集成边界,不内嵌源码。 + +### 4.8 Paperless-ngx + +- 来源:[Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx)。 +- 活跃度与技术栈:高;Python 和 Django。 +- 许可证:GPL-3.0。 +- 可复用层次: + - 文档归档、标签和对应方; + - 全文检索、权限和消费管线。 +- 成本与风险: + - 适合作为独立文档系统,但不是合同事实主库; + - 存在 GPL 风险。 +- 结论:参考归档模型,未来可评估松耦合集成。 + +### 4.9 完整系统不直接采用的根因 + +- 产品边界不一致: + - ERPNext、Odoo 和 Dolibarr 以 ERP 全域为中心; + - Twenty 和 EspoCRM 以 CRM 为中心; + - OpenProject 以项目协作为中心; + - Documenso 和 Paperless-ngx 只覆盖文档生命周期的一部分。 +- 架构代价大于复用收益: + - 直接采用意味着把权限、扩展、数据迁移、前端和运维入口交给上游框架; + - PikaOA 仍需额外实现 CLI-first、YAML-first、UniDesk 交付和既定审计契约。 +- 许可证不确定性: + - GPL/AGPL 项目可合法研究、部署和集成,但复制、修改、分发或通过网络提供修改版时存在义务; + - PikaOA 的最终分发模式尚未确定,因此本阶段只提炼思想和公开接口,不复制实现。 + +## 5. 关键业务语义建议 + +### 5.1 合同不可变多版本 + +推荐把“合同身份”和“合同版本”分离: + +- `contract`: + - 保存稳定编号、业务伙伴、负责人、当前有效版本指针和总体状态; + - 不直接承载会被修订覆盖的条款正文。 +- `contract_version`: + - 保存版本号、结构化条款、金额、币种、日期、附件摘要、创建人和创建时间; + - `draft` 可编辑;一旦进入 `approved`、`signed`、`effective` 或 `superseded`,内容不可原地修改; + - 修订必须创建新版本,并记录 `previous_version_id`、修订原因和操作者。 +- 证据: + - 附件保存内容摘要、对象存储定位和媒体类型; + - 审批、签署、启用、终止和版本切换进入 append-only 审计记录; + - 查询必须能重建任一时点的有效版本。 + +该模型吸收 Frappe 的提交、取消、修订理念,但不复刻其 DocType 或框架实现。 + +### 5.2 发票作废而非物理删除 + +推荐状态至少区分: + +- `draft`:尚未形成对外事实,可按权限编辑或删除; +- `issued`:已开具,不允许物理删除或覆盖关键字段; +- `voided`:保留原编号、金额、关联合同与附件,并记录作废原因、时间和操作者; +- `credited`:未来需要会计冲销时,通过独立贷项记录关联原发票,不改写原事实。 + +约束: + +- 合同与发票使用稳定外键关联,同时保存开具时必要快照,避免伙伴名称后续变更破坏历史显示; +- `issued` 之后的任何纠正都产生新事实; +- 搜索默认可隐藏作废项,但审计、CLI 和明确过滤必须可查询; +- 数据保留策略不得把业务作废等同于数据库删除。 + +### 5.3 伙伴与甲方分类 + +采用统一 `business_partner`,不要为甲方、供应商、客户分别复制主表: + +- 稳定字段包括法定名称、统一标识、归档状态、联系人和地址; +- `partner_category` 支持层级、标签和有效期; +- “甲方”“供应商”“客户”是上下文角色或分类,可同时存在; +- 合同保存签约时的伙伴快照和角色,伙伴主数据后续变更不覆盖历史合同; +- 分类变更必须审计,归档代替物理删除。 + +### 5.4 员工、身份与权限 + +- 身份与员工档案分离: + - 身份负责登录、会话和外部身份提供方映射; + - 员工负责组织、岗位、在职状态和业务归属。 +- 首期采用 RBAC: + - 资源与动作使用稳定 capability; + - 角色只聚合 capability; + - 数据范围通过明确条件实现,不把前端路由可见性当作后端授权。 +- `Casbin` 适合首期进程内策略判定; +- 当出现跨服务关系授权、对象级共享或多租户关系图时,再评估 `OpenFGA`。 + +### 5.5 审计与 outbox + +- 业务写入、审计事件和 outbox 记录必须在同一 PostgreSQL 事务提交; +- outbox 记录包含稳定事件类型、聚合 ID、聚合版本、发生时间和脱敏载荷; +- worker 以至少一次语义处理,消费者通过事件 ID 或业务幂等键去重; +- `Watermill` 负责消息抽象和处理管线,不替代数据库事务真相; +- 审计记录和集成事件分开: + - 审计面向人和合规追溯; + - outbox 面向系统集成和未来服务提取。 + +### 5.6 模块扩展与未来微服务提取 + +首期保持模块化单体: + +- 每个模块拥有自己的 handler、use case、repository、迁移和事件定义; +- 跨模块写入只能通过应用服务或已声明接口,不允许直接修改他模块表; +- 共享内核只保留身份、伙伴、附件、审计、分页、错误和幂等契约; +- 只有满足以下事实后才提取微服务: + - 独立扩缩容或故障隔离需求持续存在; + - 数据所有权和事件契约稳定; + - 跨模块同步事务已消除; + - 独立部署收益高于网络、可观测性和运维成本。 + +## 6. Go 后端组件评估 + +### 6.1 直接采用 + +- HTTP: + - [chi](https://github.com/go-chi/chi),MIT; + - 轻量、标准库兼容、路由组合自然,适合模块化单体。 +- PostgreSQL: + - [pgx](https://github.com/jackc/pgx),MIT; + - PostgreSQL 原生能力完整,适配事务、批量和通知。 +- SQL 代码生成: + - [sqlc](https://github.com/sqlc-dev/sqlc),MIT; + - SQL-first、静态类型,便于审查真实查询。 +- 数据库迁移: + - [goose](https://github.com/pressly/goose),MIT; + - 简单、Go 生态成熟,适合显式 SQL 迁移。 +- CLI: + - [Cobra](https://github.com/spf13/cobra),Apache-2.0; + - 子命令和 help 生态成熟,输出契约仍遵循 PikaOA CLI 规格。 +- RBAC: + - [Casbin](https://github.com/casbin/casbin),Apache-2.0; + - 进程内、模型灵活,首期运维成本低。 +- 事件: + - [Watermill](https://github.com/ThreeDotsLabs/watermill),MIT; + - 消息处理抽象成熟,可与事务 outbox 结合。 + +### 6.2 仅比较或预留 + +- [Echo](https://github.com/labstack/echo),MIT: + - 功能完整但约定较多; + - 不与 chi 并用,仅在发现明确缺口时重新评估。 +- [Gin](https://github.com/gin-gonic/gin),MIT: + - 生态和认知度高,但自有 Context 侵入更明显; + - 不采用,避免无收益的框架抽象。 +- [Atlas](https://github.com/ariga/atlas),Apache-2.0: + - 部分能力与商业产品边界需核验; + - 声明式 schema 与检查能力强,但会引入第二套迁移模型; + - 首期不采用。 +- [OpenFGA](https://github.com/openfga/openfga),Apache-2.0: + - 适合复杂对象关系授权; + - 会增加独立服务和模型治理; + - 预留边界,首期不采用。 +- [Asynq](https://github.com/hibiken/asynq),MIT: + - Redis 队列成熟; + - 会增加首期基础设施和第二事实源; + - 首期不采用。 + +### 6.3 推荐最小组合 + +```text +Cobra CLI / Vue Web + | + chi HTTP + | +应用服务与模块边界 + | +sqlc + pgx + PostgreSQL + | +业务表 + audit_log + outbox + | +Watermill worker / 外部集成 +``` + +组合原则: + +- 不引入 ORM 作为默认数据访问层; +- 不同时维护 `goose` 与 `Atlas` 两套迁移真相; +- 不为异步任务额外引入 Redis,除非后续有独立证据; +- CLI 和 Web 必须调用同一 HTTP API 与业务用例,不形成旁路管理逻辑。 + +## 7. Vue 3 与 HWLAB v0.3 复用边界 + +### 7.1 直接采用 + +| 组件 | 许可证 | 用途 | +| --- | --- | --- | +| [Vue 3](https://github.com/vuejs/core) | MIT | 组件与响应式基础 | +| [Vue Router](https://github.com/vuejs/router) | MIT | 路由、模块入口和守卫 | +| [Pinia](https://github.com/vuejs/pinia) | MIT | 会话、访问摘要和跨页面客户端状态 | +| [Tailwind CSS](https://github.com/tailwindlabs/tailwindcss) | MIT | 设计令牌与响应式布局 | + +### 7.2 复用 HWLAB v0.3 的内容 + +- 复用同族技术栈、设计语言和已验证的响应式布局方法; +- 复用以下模式,而不是复制 HWLAB 业务模块: + - 应用壳、侧边导航、顶栏、内容区和移动端折叠逻辑; + - 登录态、路由守卫、403/404 和 API 错误呈现; + - 表格筛选、分页、详情抽屉、时间线和状态徽标; + - 可访问性、加载态、空态和失败可见性; + - 与 CLI 同源的 capability 驱动导航。 + +### 7.3 禁止复用 + +- 不复制 Workbench、AgentRun、Code Queue 等领域组件和状态投影; +- 不把 HWLAB 的 session、run、event 模型引入合同或发票领域; +- 不让前端 Pinia store 成为业务事实真相; +- 不在 CLI 后端契约完成前先冻结 Web 私有接口。 + +## 8. 可观测性方案 + +| 组件 | 许可证 | 结论 | +| --- | --- | --- | +| [OpenTelemetry Go](https://github.com/open-telemetry/opentelemetry-go) | Apache-2.0 | 直接采用,负责 trace、metric 和 context 传播 | +| [OpenTelemetry Collector](https://github.com/open-telemetry/opentelemetry-collector) | Apache-2.0 | 直接采用现有平台能力,应用只发 OTLP | +| [Prometheus Go client](https://github.com/prometheus/client_golang) | Apache-2.0 | 直接采用,暴露低基数业务与运行指标 | +| [Tempo](https://github.com/grafana/tempo) | AGPL-3.0 | 复用 UniDesk 独立运行服务,不把源码或服务端实现嵌入 PikaOA | + +### 8.1 关联方式 + +- HTTP、CLI 请求和 worker 消费均传播 `trace_id` 与 `span_id`; +- 结构化日志至少包含: + - 时间、级别、服务、模块、事件名; + - `trace_id`、`span_id`、request ID; + - 脱敏后的 actor、聚合类型和聚合 ID; + - 稳定错误码,不记录 Secret、令牌或合同敏感正文。 +- 指标保持低基数: + - 请求量、错误量、延迟; + - outbox backlog、处理成功和失败; + - 合同与发票状态变化计数; + - 禁止把员工 ID、合同 ID、发票号作为 metric label。 +- Collector、采样、保留和导出参数继续由 owning YAML 控制,本报告不固化数值。 + +## 9. 许可证边界 + +本节是工程风险提示,不替代法律意见。 + +- MIT、Apache-2.0: + - 可作为首选依赖; + - 仍需保留许可证和版权通知,并维护依赖清单。 +- LGPL-3.0: + - 动态链接、修改库和分发场景有不同义务; + - Odoo 必须按核心、社区模块、企业模块逐项确认,不以仓库总体印象替代检查。 +- GPL-3.0: + - ERPNext、Dolibarr、OpenProject 和 Paperless-ngx 等项目只提炼公开业务思想; + - 禁止复制源码、迁移脚本、模板或前端组件进入闭源可能性未知的仓库。 +- AGPL-3.0: + - EspoCRM、Documenso 和 Tempo 等网络服务软件需要特别审查修改版网络提供义务; + - 优先采用进程外、协议级集成,并保留独立部署和许可证材料; + - 任何修改、再分发或嵌入决定必须先完成法律与架构审查。 +- 上游可能调整许可证或采用多许可证: + - 实现任务必须锁定 commit/tag; + - 对锁定版本重新读取 `LICENSE`、`NOTICE`、依赖清单和商业功能说明; + - 自动生成软件物料清单并在发布前执行许可证扫描。 + +## 10. 实施建议 + +### 10.1 首期基线 + +- 建立 Go 模块化单体与单一 PostgreSQL 数据库; +- 用 chi、sqlc、pgx、goose、Cobra 建立最小 API/CLI 骨架; +- 先实现员工/RBAC、伙伴、合同版本、发票作废、审计和 outbox; +- 用 Casbin 做 capability 判定,策略存储与业务事务边界明确分离; +- 用 Watermill worker 消费 PostgreSQL outbox; +- 接入 OpenTelemetry Go、OTLP 和 Prometheus 指标; +- CLI 验收通过后再实现 Vue 3 Web。 + +### 10.2 需要保留的替换边界 + +- HTTP 框架只暴露在 transport 层,应用用例不依赖 chi 类型; +- Casbin 通过授权接口接入,未来可替换或桥接 OpenFGA; +- Watermill 通过事件发布与消费接口接入,未来可迁移 Kafka 等 broker; +- 对 Documenso、Paperless-ngx 等系统只定义外部连接器,不共享业务数据库; +- Tempo 作为 OTel 后端,不让业务代码依赖 Tempo 私有 API。 + +### 10.3 后续任务门槛 + +- R1.2 建仓前: + - 确认最终 Go module、Node package 和许可证清单; + - 明确依赖锁定和 SBOM 生成入口。 +- R1.3 实现前: + - 先把合同版本、发票作废、伙伴角色、授权和 outbox 写成 API/CLI 验收契约; + - 禁止从完整开源系统复制 schema 或代码。 +- R1.4 Web 开发前: + - CLI 后端主路径通过; + - 明确 HWLAB 可复用的布局、基础组件和设计令牌清单; + - Web 不得创建后端没有的隐式状态。 +- 评估微服务前: + - 收集独立扩缩容、故障隔离、团队所有权和数据边界证据; + - 没有证据时继续保持模块化单体。 + +## 11. 来源索引 + +### 11.1 业务系统 + +- ERPNext 仓库: +- Frappe 仓库: +- Frappe 单据状态文档: +- ERPNext 不可变账本说明: +- Odoo 仓库: +- Odoo 许可证说明: +- Odoo 贷项与退款文档: +- Twenty 仓库: +- EspoCRM 仓库: +- Dolibarr 仓库: +- OpenProject 仓库: +- Documenso 仓库: +- Paperless-ngx 仓库: +- Paperless-ngx 权限文档: + +### 11.2 Go 组件 + +- chi: +- Echo: +- Gin: +- pgx: +- sqlc: +- goose: +- Atlas: +- Cobra: +- Casbin: +- OpenFGA: +- Watermill: +- Asynq: + +### 11.3 前端与可观测性 + +- Vue 3: +- Vue Router: +- Pinia: +- Tailwind CSS: +- OpenTelemetry Go: +- OpenTelemetry Collector: +- Prometheus Go client: +- Tempo: + +## 12. 风险与未决项 + +- Odoo、Twenty、Atlas 等项目存在模块级、目录级或商业能力边界,不能只看仓库首页许可证; +- 电子签名、发票与会计凭证的法律效力依赖适用地区,后续需求需单独合规确认; +- Casbin 足以覆盖首期 RBAC,但对象级共享和关系授权增长后可能需要 OpenFGA; +- PostgreSQL outbox 可满足首期,但高吞吐、多消费者和跨区域需求出现后可能需要 Kafka 等 broker; +- HWLAB v0.3 的具体组件可复用清单应在 R1.4 基于当时源码逐项确认; +- 本报告没有发现阻塞 PJ2026-03 继续执行的问题。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md new file mode 100644 index 00000000..8daa92d0 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.2_Task_Report.md @@ -0,0 +1,19 @@ +# R1.2 任务报告 + +## 结果 + +- 产品仓库:`pikainc/pikaoa`,私有远端可通过既有 SSH 身份读取和推送。 +- 固定主工作区:`/root/pikaoa`,分支固定为 `master`。 +- 初始提交:`1fc2353 chore: initialize PikaOA repository`,已推送并建立 `origin/master` 跟踪关系。 +- 远端在初始化前无 refs,未覆盖任何既有提交或用户内容。 +- 后续执行型任务使用 `/root/pikaoa/.worktree/` 独立 worktree、独立 branch 和 PR。 + +## 验证 + +- `git ls-remote origin` 初始化前成功且无输出,证明仓库存在并为空。 +- `git push -u origin master` 成功,GitHub 返回 canonical repository `git@github.com:pikainc/pikaoa.git`。 +- UniDesk GitHub CLI 当前 token 对产品仓返回 `repo-not-found`;这不影响既有 SSH source authority,但产品仓 issue/PR 元数据写入需在后续修复 token scope,当前治理 issue 继续落在 `pikasTech/unidesk`。 + +## 结论 + +R1.2 已完成;R1.3 可以从 `origin/master` 创建独立 foundation worktree。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md new file mode 100644 index 00000000..72d68a91 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md @@ -0,0 +1,32 @@ +# R1.3.1 任务报告 + +## 结果 + +- `pikainc/pikaoa#1` 已通过主代理架构 review 和 guarded preflight,以 merge commit `042bf34` 合入 `master`。 +- 建立 Go 1.24 API、Worker、CLI 三入口,以及严格 YAML 配置、typed error、模块注册表和优雅停机。 +- 模块描述符覆盖稳定 ID、版本、状态、权限、API、事件、导航、迁移、审计动作和健康声明,并校验重复声明与权限引用。 +- 建立 pgx-compatible `Executor`/`UnitOfWork`,保证 repository、audit 和 outbox 可共享同一事务句柄。 +- goose foundation migration 使用严格 owned-object 创建和逐项非级联回滚,不掩盖 schema drift。 +- API、Worker、CLI 已接入 W3C trace 传播、稳定结构化日志字段和 Prometheus 指标;CLI `health`、`modules` 只调用公开 HTTP API。 +- 未连接 PK01 PostgreSQL,未同步 Secret,未执行 PaC bootstrap、K8s rollout、公网变更或 Web 开发。 + +## 验证 + +- Artificer 在 NC01 任务 worktree 完成 `gofmt -d`、`go test ./...`、`go vet ./...` 和三入口 build。 +- 真实 API 进程下 CLI text/JSON、typed readiness、完整 module JSON、API/Worker metrics、SIGTERM 和 OTLP 不可达降级均通过。 +- CLI `traceparent` 与 API 返回 `traceId` 一致;API/Worker 日志包含 `traceId`、`spanId`、`module`、`operation`、`result`、`errorCode`。 +- PR preflight:`MERGEABLE/CLEAN`,无 pending 或 failed check。 + +## 证据 + +- Issue:https://github.com/pikasTech/unidesk/issues/1977 +- PR:https://github.com/pikainc/pikaoa/pull/1 +- 实现提交:https://github.com/pikainc/pikaoa/commit/40ae8ae5836ccafbaf78c09d8f98463c45365e7d +- 合并提交:https://github.com/pikainc/pikaoa/commit/042bf34 +- Artificer post-task:https://github.com/pikasTech/unidesk/issues/1977#issuecomment-4964589017 + +## 后续边界 + +- 真实 PK01 migration apply/rollback、Secret、PaC bootstrap 和运行面验收由 R1.6-R1.7 承接。 +- post-task 提到的 GitHub 仓库凭据选择与大小写匹配已由 `#1978`、`#1980` 修复并关闭,不重复创建 issue。 +- 员工/RBAC、伙伴分类、合同多版本、发票废弃和审计/outbox 实现继续在 R1.3 下拆分独立子任务。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md new file mode 100644 index 00000000..3a54c2fe --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md @@ -0,0 +1,22 @@ +# R1.3.2 任务报告 + +## 结果 + +- 已在 `pikainc/pikaoa#2` 建立并合并可插拔模块 HTTP 运行时,产品 `master` 合并提交为 `ebe70a3`。 +- foundation 路由已由模块自身注册;disabled 模块不启动、不挂载 HTTP、不发布导航。 +- 模块挂载路由受 descriptor 的 API BasePath 与 health Path 约束,未声明路由和路由冲突稳定失败。 +- PostgreSQL 使用 `otelpgx` 覆盖 query/transaction,并禁用连接详情、SQL 文本和参数属性。 +- migration readiness 改为只读比较数据库当前 goose version 与本构建嵌入迁移最新版本。 +- Prometheus 已覆盖 HTTP in-flight、Go runtime/process、pgx pool、数据库/迁移 readiness 和 Worker 基线;累计 pool 指标为 Counter,当前状态为 Gauge。 +- 共享 observability 暴露标准 `prometheus.Registerer`,业务模块可在不扩展中央 Metrics 结构体的情况下注册自有 collector。 + +## 验证 + +- NC01 目标 worktree:`go test ./...`、`go vet ./...`、`make build`、`git diff --check` 全部通过。 +- 真实 API 下 CLI `health`、`modules` 的 text/JSON 通过,`/metrics` 类型与 readiness 脱敏通过。 +- 受控 PR preflight:`MERGEABLE/CLEAN`,合并前无 blocker。 + +## 边界与后续 + +- 本任务未连接 PK01、未执行生产 migration、未部署;这些动作保留给 R1.6-R1.7。 +- 身份/RBAC 与审计/outbox 从本合并提交创建独立 worktree,并使用不同 migration version 并行实施。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md new file mode 100644 index 00000000..ffb9675a --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md @@ -0,0 +1,24 @@ +# R1.3.3 任务报告 + +## 结论 + +员工身份、固定管理员/员工角色、会话、基础 capability 授权和 CLI 已合并到 `pikainc/pikaoa` `master`。完整身份/RBAC 契约不作为伙伴、附件、合同、发票或审计查询的开发与合并门禁;仅保留公网 MVP 的登录、固定角色和基础接口鉴权。 + +## 交付证据 + +- Issue:https://github.com/pikasTech/unidesk/issues/1984 +- PR:https://github.com/pikainc/pikaoa/pull/3 +- 合并后提交:`f024d6c` +- 最终实现提交:`3f6b3dc366fb606d9b58c245f15433c4964bebc6` + +## 验证 + +- `go test ./...` 通过。 +- `go vet ./...` 通过。 +- `make build` 通过。 +- 管理员与普通员工登录、权限拒绝、停用后禁止新登录及 Secret 不泄露路径通过。 +- 受控 PR preflight 为 `MERGEABLE/CLEAN`,PR 已合并。 + +## 剩余边界 + +动态角色治理、完整共享 RBAC 契约和进一步权限抽象不阻塞第一版 MVP;如后续业务需要,独立 issue 推进。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md new file mode 100644 index 00000000..35ed0d56 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md @@ -0,0 +1,33 @@ +# R1.3.4 任务报告 + +## 交付结果 + +- 产品 PR [pikainc/pikaoa#4](https://github.com/pikainc/pikaoa/pull/4) 已受控合并到 `master@e1b570188cf39c7cf5e1210b0a477aabe7fd5fb3`。 +- 通过共享 `transaction.Executor` 实现业务写入、append-only 审计和 PostgreSQL outbox 同事务提交或回滚。 +- outbox 提供 `SKIP LOCKED` 有界 claim、成功、重试、失败和处理中断恢复;Watermill handler registry 以事件 ID 为稳定幂等键。 +- Worker 暴露纯 liveness `/healthz`、typed readiness `/readyz` 和 Prometheus `/metrics`,处理链写入 OTel span,并保持低基数标签。 +- handler 原始错误不进入 `last_error`、日志、trace、metric 或返回值,仅保留稳定 `FailureCode`;Secret 输出保持 `valuesPrinted=false`。 + +## 主代理审核纠偏 + +- 补齐失败信息脱敏,真实 PostgreSQL 测试覆盖包含 password、token 和 secret 的 handler error,确认不泄漏。 +- 增加 database、migration、outbox 的 typed readiness 投影,保持 liveness 与依赖健康分离。 + +## 验证 + +- `go test -race ./internal/outbox ./internal/worker` +- `go test ./...` +- `go vet ./...` +- `make build` +- 隔离 PostgreSQL 16 集成测试验证事务回滚、提交后 claim、失败恢复、再次 claim 和 processed 终态。 +- PR preflight 为 `MERGEABLE/CLEAN`;仓库当前没有 status checks。 + +## 收口 + +- 原 Artificer 纠偏与 post-task command 均达到权威 `completed`。 +- `feat/r134-audit-outbox` 任务 worktree 和已吸收本地分支已清理。 +- 本任务按 issue 范围未部署、未连接 PK01;部署与公网验收留在后续 YAML-first/CI/CD 阶段。 + +## 残余风险 + +`RecoverProcessing` 会恢复全部 processing 事件,多 Worker 副本可能互相干扰。本阶段不新增租约或围栏,运行面在完成独立水平扩展规格前保持 Worker 单副本。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md new file mode 100644 index 00000000..3b114215 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md @@ -0,0 +1,32 @@ +# R1.5.1 任务报告 + +## 结论 + +PaC 新 consumer 首发 bootstrap 已具备 YAML 唯一匹配、selected repository effective credential、GitHub 上游只读预检、分阶段紧凑输出和 typed failure。该实现保持 GitHub PR merge 为唯一正式交付 authority,没有引入第二 source authority 或人工交付恢复入口。 + +## 变更 + +- 默认 human 和显式 JSON 共用有界投影,展示 `yaml-exact-match`、`github-upstream`、`gitea-repository` 前置条件,以及 Gitea、PaC、Tekton、GitOps/Argo 阶段。 +- YAML 零匹配、多匹配、GitHub 仓库不存在或无权限、Gitea 初始化失败和 PaC apply 失败使用独立 typed code。 +- GitHub 预检只校验 selected repositories 各自实际使用的 effective credential,不再被无关 Gitea admin 或默认 credential 形成伪 blocker。 +- YAML 匹配失败直接给 `fix-yaml`,失败态不提供 mirror sync、人工 PipelineRun、Argo refresh 等 mutation 恢复命令。 +- 未执行 `bootstrap --confirm`、部署或其他运行面 mutation。 + +## 验证 + +- Target:`NC01:/root/unidesk/.worktree/pikaoa-bootstrap-ux`。 +- 定向测试:19 pass,0 fail。 +- `sh -n scripts/src/platform-infra-gitea-remote.sh` 通过。 +- `platform-infra pipelines-as-code help platform-bootstrap` 通过。 +- `unidesk-host` 默认 credential dry-run 通过。 +- `selfmedia-nc01` repository override credential dry-run 通过。 +- PikaOA dry-run 返回 `yaml-repository-exact-match` 与 `github-repository-not-found-or-no-access`,后续阶段均为 `skipped`,`mutation=false`,准确暴露当前外部建仓权限阻塞。 +- `git diff --check` 通过。 + +## 交付 + +- 实现 commit:`697b6933`(`feat: 改进 PaC bootstrap 首发状态投影`)。 +- 纠偏 commit:`1a04b029`(`fix: 收窄 PaC bootstrap 预检凭据`)。 +- 目标分支已通过 `fa730e1e` 吸收上述实现,并通过 `e808b298` 补齐未执行阶段的 `skipped` 投影。 +- 子 issue:[pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955)。 +- Post-task 未发现需要另建 FEATURE/BUG issue 的残余问题。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md new file mode 100644 index 00000000..d0938854 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md @@ -0,0 +1,31 @@ +# R1.5.2 任务报告 + +## 结论 + +共享 observability 控制面已在同一 YAML-first 架构中加入 Prometheus。Prometheus 只作用于 `metricsBackend.targetIds` 选中的 target,OTel Collector 与 Tempo 继续承担 trace authority;指标故障和版本漂移只形成非阻塞 warning。 + +## 变更 + +- `config/platform-infra/observability.yaml` 拥有 Prometheus image、目标、存储、保留、端口、服务发现和查询预算。 +- NC01 渲染 ServiceAccount、RBAC、ConfigMap、PVC、Deployment 与 ClusterIP Service;D518/JD01 不渲染或访问 Prometheus。 +- `metrics-query` 提供受 YAML timeout、series 和 response bytes 预算约束的有界 PromQL 查询。 +- selector 使用严格 Kubernetes qualified name/value,渲染为锚定且转义的 RE2 字面量;label/annotation meta-label 统一规范化。 +- 显式 metrics path 优先,仅在 annotation 缺失时回退到 YAML `defaultPath`。 +- 未部署、未执行 confirm;真实 StorageClass、镜像拉取和 scrape target 留给 R1.6 受控 rollout 验证。 + +## 验证 + +- `bun test scripts/src/platform-infra-observability.test.ts`:8 pass,0 fail,100 assertions。 +- `platform-infra observability --help` 暴露 scoped `metrics-query`。 +- NC01 plan:`prometheus=enabled`,15 个对象。 +- D518/JD01 plan:`prometheus=disabled-for-target`,各 8 个对象,无 Prometheus manifest、status capture/probe 或远端 query。 +- 模块导入与 `git diff --check` 通过。 + +## 交付 + +- 实现 commit:`f516691a`。 +- target 隔离与发现规则纠偏:`ef3b5fde`。 +- selector 严格渲染纠偏:`c3762b31`。 +- 目标分支 merge commit:`2ee27b0c`。 +- 子 issue:[pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956)。 +- Post-task 未建议新增 FEATURE/BUG issue。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md new file mode 100644 index 00000000..b1c7f07f --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md @@ -0,0 +1,25 @@ +# R1.5.3 任务报告 + +## 结论 + +`platform-db postgres plan` 已改为默认返回有界 CLI-first 投影,并保留 `--full` 与 `--raw` 完整下钻。默认输出不会再触发 10 KiB 全局截断保护,Secret 值始终不披露。 + +## 变更 + +- 默认投影包含 target、config、全部角色和数据库的期望动作、Secret source 摘要、connection string export 摘要、检查汇总、远端事实摘要和下一步命令。 +- 失败检查才携带 480 字符以内的有界详情。 +- 远端失败输出分别限制为 1,200 字符 stdout/stderr 尾部。 +- `--full` 保留完整 `config`、`secrets`、`remoteFacts`、`desired`、`checks` 和 remote capture;`--raw` 继续附加原始 capture。 + +## 验证 + +- 默认 CLI 返回码为 0,stdout 为 9,589 字节,`outputTruncated=false`。 +- 默认投影明确包含 `pikaoa` role、`pikaoa` database 和 `pikaoa-database-url` 的 `DATABASE_URL` export,且 `valuesPrinted=false`。 +- `--full` 完整对象为 34,475 字节,由显式 disclosure policy 创建受保护 dump 并返回有界索引。 +- `bun test scripts/src/platform-db-monitor-reset.test.ts scripts/src/platform-db-nc01-monitor-config.test.ts`:8 pass,0 fail。 +- `git diff --check` 通过。 + +## 交付 + +- Commit:`88eb8fdb`(`fix: 收敛 PostgreSQL 计划输出`)。 +- 目标分支:`feat/pikaoa-platform`。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md new file mode 100644 index 00000000..0cd1aed5 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md @@ -0,0 +1,24 @@ +# R1.5.4 任务报告 + +## 结论 + +已修复 `platform-db postgres status` 与 `export-secrets` 默认输出截断,并将默认投影改为按配置规模有界的计数、异常/待处理预览与 omitted 摘要。confirmed export 明确区分 `before`、`action`、`after`,不会把写入前 presence 当成写入后事实。 + +## 交付 + +- 子 issue:pikasTech/unidesk#1958。 +- PR:pikasTech/unidesk#1960。 +- merge commit:`34f1f9339168b518ca1f706cc7bc44880924171d`。 +- 变更:`scripts/src/platform-db.ts`、`docs/reference/cli.md`。 + +## 验证 + +- 真实配置:status 4264 bytes,export dry-run 2679 bytes,plan 9589 bytes,均未截断。 +- 扩展规模:48 个 role/database/Secret、50 个 export 时,status 7589 bytes,export 5624 bytes。 +- confirmed fixture:首次 `before=false/false` 到 `after=true/true`;二次 confirmed 无 actionable 项。 +- 相关测试:8 pass、0 fail。 +- 合并后的原 CLI 复测准确显示 PikaOA role/database/export 缺失,`mutation=false`、`valuesPrinted=false`。 + +## 边界 + +未执行 PostgreSQL apply、Secret sync、PaC bootstrap 或运行面修改。Artificer post-task 评论为 issue #1958 comment 4962360252;未发现需要扩展本轮范围的问题。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md new file mode 100644 index 00000000..3c5e029d --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md @@ -0,0 +1,20 @@ +# R1.5.5 任务报告 + +## 结果 + +- Issue:`pikasTech/unidesk#1978`。 +- PR:`pikasTech/unidesk#1979`,合并提交 `9e4efb50`。 +- Owning YAML:`config/unidesk-cli.yaml#github.auth.repositoryOverrides`。 +- 为 `pikainc/pikaoa` 增加精确 repository override,复用既有 PikaInc token sourceRef;未读取、打印、复制或提交 token value。 + +## 验证 + +- `gh auth status --repo pikainc/pikaoa` 成功,命中 `scope=repository-override`、`valuesPrinted=false`。 +- `gh repo view pikainc/pikaoa` 成功,私有仓可见。 +- `gh auth status --repo pikasTech/unidesk` 成功,默认全局 sourceRef 未受影响。 +- 定向测试 3 pass、0 fail,`git diff --check` 通过。 +- 本任务无运行面部署,`rollout=not-applicable`。 + +## 边界 + +Artificer 的当前产品任务仍可通过 SSH 提交代码;产品 PR 的 create/review/merge 由合并后的受控 host CLI 完成。owner 大小写匹配问题已作为独立 BUG 候选登记,不改变本任务完成结论。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md new file mode 100644 index 00000000..c41b1558 --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md @@ -0,0 +1,19 @@ +# R1.5.6 任务报告 + +## 结果 + +- Issue:`pikasTech/unidesk#1980`。 +- PR:`pikasTech/unidesk#1982`,合并提交 `7eebc513`。 +- GitHub credential resolver 使用统一的小写 lookup key 比较请求仓库与 YAML repository override,并以相同规则拒绝仅大小写不同的重复配置。 +- YAML 原值、GitHub API path、CLI 输出和 token sourceRef/value 均未改写。 + +## 验证 + +- `pikainc/pikaoa`、`pikaInc/pikaoa`、`PIKAINC/PIKAOA` 均命中同一 repository override。 +- `pikasTech/unidesk` 继续命中全局 token;所有输出 `valuesPrinted=false`。 +- 定向测试 4 pass、0 fail,`bun --check` 和 `git diff --check` 通过。 +- 未部署,`rollout=not-applicable`。 + +## Post-task + +未发现需要继续转为正式 issue 的新反馈。 diff --git a/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md new file mode 100644 index 00000000..95dcbe6e --- /dev/null +++ b/docs/MDTODO/details/pikaoa-enterprise-platform/R1.5_Task_Report.md @@ -0,0 +1,34 @@ +# R1.5 任务报告 + +## 结论 + +PikaOA 的 UniDesk 交付基线已完成 YAML-first 声明,并补齐可复用的 PaC bootstrap、PostgreSQL 紧凑 plan/status/export、NC01 Prometheus 控制面和产品仓 GitHub token 路由。全部运行面变更仍处于源码、只读 plan/status 与 dry-run 阶段,未执行首次 bootstrap、数据库 apply 或部署。 + +## Owning YAML + +- `config/pikaoa.yaml`:NC01 独立 namespace、Web/API/Worker、Secret、资源、探针、OTel/metrics 标注和 `https://oa.pikapython.com` PK01 公网暴露。 +- `config/platform-db/postgres-pk01.yaml`:host PostgreSQL 的 PikaOA role、database 与 Secret export。 +- `config/secrets-distribution.yaml`:管理员、普通员工和数据库连接的 `sourceRef`/`targetKey`。 +- `config/platform-infra/gitea.yaml` 与 `config/platform-infra/pipelines-as-code.yaml`:Gitea repository、PaC consumer、Tekton/GitOps/Argo 首发基线。 +- `config/platform-infra/observability.yaml`:共享 OTel/Tempo/Prometheus 与 NC01 target 隔离。 +- `config/unidesk-cli.yaml`:`pikainc/pikaoa` GitHub repository token override。 + +## CLI 与工具改进 + +- PaC bootstrap 提供 YAML 精确匹配、effective credential、GitHub 上游预检、分阶段 typed 状态、skipped 阶段和唯一下一步。 +- `platform-db postgres plan/status/export-secrets` 默认输出按配置规模保持有界,披露 PikaOA role/database、Secret/export presence/fingerprint、mutation、typed failure 和完整下钻。 +- confirmed export 区分写入前、动作与写入后事实。 +- `platform-infra observability` 提供 target-scoped Prometheus plan/status/query;指标故障不阻塞业务。 +- GitHub CLI 可通过 YAML repository override 访问 PikaOA 私有仓,且保持 `valuesPrinted=false`。 + +## 验证与交付 + +- PaC bootstrap 定向测试 19 项通过;PikaOA dry-run 为 `mutation=false`。 +- Prometheus 8 tests、100 assertions 通过;NC01 enabled,其他 target disabled-for-target。 +- PostgreSQL status 4264 bytes、export dry-run 2679 bytes;扩大到 48 个 role/database/Secret 和 50 个 export 后仍低于预算;相关测试 8 pass。 +- GitHub repository override 定向测试 3 pass;合并后 PikaOA auth/repo view 和默认 UniDesk auth 均成功。 + +## 未完成边界 + +- PikaOA role/database 尚未 apply,`DATABASE_URL`、session secret 与两个初始账号密码尚未 materialize/sync。 +- 首次 bootstrap、正常 source PR 自动交付、NC01 rollout、OTel/Prometheus 运行面与公网入口验收属于 R1.6-R1.7。 diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R7_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R7_Task_Report.md new file mode 100644 index 00000000..6d6d397a --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R7_Task_Report.md @@ -0,0 +1,47 @@ +# R7 任务报告 + +## 结果 + +- 新增只读命令 `platform-infra pipelines-as-code delivery-timing --target --consumer `。 +- 命令从 `config/platform-infra/pipelines-as-code.yaml` 与 `config/platform-infra/gitea.yaml` 精确解析 consumer、上游 GitHub repository 和 branch。 +- 单次调用并行读取最新 PaC PipelineRun/TaskRun 与 status closeout,再按 source commit 查询 GitHub 关联 PR;不新增历史库或第二状态源。 +- 默认及 JSON 输出包含 source commit、PR、mergedAt、PipelineRun 起止、trigger wait、pipeline duration、end-to-end duration、Argo/runtime health 和 `evidenceGaps`。 +- PR、时间戳或 provenance 缺失时保持 `partial/unknown`,不使用 commit 时间冒充 mergedAt;入口固定 `mutation=false`。 + +## 真实只读验收 + +命令: + +`bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 --json` + +结果: + +- source commit:`11bfcf2657cd4f62e5e7221803872e53143e5f0c` +- GitHub PR:`pikainc/selfmedia#26` +- mergedAt:`2026-07-14T05:54:19Z` +- PipelineRun:`2026-07-14T05:54:26Z` 至 `2026-07-14T05:57:28Z` +- trigger wait:7 秒 +- PipelineRun:182 秒 +- 端到端:189 秒 +- TaskRun:181 秒 +- Argo:`Synced/Healthy` +- runtime:`1/1` ready +- 状态:`partial`,保留既有 `runtime-provenance-unknown` 缺口 +- mutation:`false` + +## 验证 + +- `bun --check scripts/src/platform-infra-pipelines-as-code.ts`:通过。 +- `bun scripts/cli.ts check --syntax-only`:11/11 通过。 +- `bun test scripts/src/cicd-pr-merge-auto-delivery-doc-contract.test.ts`:8/8 通过。 +- `quick_validate.py .agents/skills/unidesk-cicd`:通过。 +- `git diff --check`:通过。 +- 受控 scripts typecheck 已安装锁文件声明的 TypeScript 后执行;仓库现有 364 条诊断、94 个路径,本次新模块路径匹配 0 条。 + +## Skill 入口复核 + +已在 `unidesk-cicd/SKILL.md` 高频入口加入 SELFMEDIA 唯一推荐命令,并明确一次调用直接披露 `mergedAt`、trigger wait、PipelineRun/TaskRun、端到端耗时、Argo、runtime health 与 `evidenceGaps`;`partial` 保留已成立字段,入口固定 `mutation=false`。 + +`bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01` + +吸收最新 master 时保留 checkpoint `7e8b8396` 原生提交语义。由于 master 已并发占用顶层 MDTODO R7,索引将本任务记为“原 MDTODO R7”的 R6.1 completed 条目;本报告身份、issue #2005 与任务上下文仍保持 MDTODO R7。 diff --git a/docs/MDTODO/hwlab-web-product-experience.md b/docs/MDTODO/hwlab-web-product-experience.md index 51b2f7b0..136103a6 100644 --- a/docs/MDTODO/hwlab-web-product-experience.md +++ b/docs/MDTODO/hwlab-web-product-experience.md @@ -77,3 +77,7 @@ ## 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)。 + +## R3 [in_progress] + +设计并实现 HWLAB v0.3 AgentRun 智能体运行观察页([HWLAB #2541](https://github.com/pikasTech/HWLAB/issues/2541)):集成“智能体管理”,默认表格并可切换卡片/树状视图;三视图共享唯一 snapshot BFF、Pinia 规范化 store、selector 和页面级轮询控制器,禁止 SSE、组件级请求、状态猜测及任何 fallback;默认 5 秒且用户可在 Web 调频,页面隐藏/离线暂停,以 singleflight、取消、退避、抖动、条件请求和 BFF 请求合并防过载;可调参数 YAML-first,并在 NC01/v03 公网原入口完成响应式、可访问性、数据一致性和限载验收,完成任务后将详细报告写入[任务报告](./details/hwlab-web-product-experience/R3_Task_Report.md)。 diff --git a/docs/MDTODO/observability-trace-reliability.md b/docs/MDTODO/observability-trace-reliability.md index 8e651631..113aca61 100644 --- a/docs/MDTODO/observability-trace-reliability.md +++ b/docs/MDTODO/observability-trace-reliability.md @@ -109,4 +109,4 @@ ##### R4.4.2.10 [in_progress] -执行 [HWLAB #2538](https://github.com/pikasTech/HWLAB/issues/2538):删除六个架构 capability env 及 direct/live/refresh 分支,把 agentrun.event.v1→transactional projector→PostgreSQL facts/outbox→hwlab.event.v1→实时/回放 SSE 固定为代码不变量;新增 config/feature-config.schema.json 只描述 Trace、原始事件、调试重放和视图等产品功能,一个功能只用一个规范变量;保持 canonical duration、同 cursor/reducer 和纯 Kafka,禁止 HTTP 补链、页面轮询、双 authority、额外锁租约状态库,schema warning 不关闭功能、不改配置且不阻塞滚动上线,完成任务后将详细报告写入[任务报告](./details/observability-trace-reliability/R4.4.2.10_Task_Report.md)。 +执行 [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/pikaoa-enterprise-platform.md b/docs/MDTODO/pikaoa-enterprise-platform.md new file mode 100644 index 00000000..e30d67e3 --- /dev/null +++ b/docs/MDTODO/pikaoa-enterprise-platform.md @@ -0,0 +1,93 @@ +# PikaOA 企业办公平台 + +- 规格真相:`project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md`。 +- 主执行记录:[pikasTech/unidesk#1952](https://github.com/pikasTech/unidesk/issues/1952)。 +- 产品仓库:`pikaInc/pikaoa`,固定主工作区为 `/root/pikaoa`。 +- 目标运行面:owning YAML 选中的 NC01 Kubernetes 独立 namespace。 +- 公网原入口:`https://oa.pikapython.com`。 +- 数据真相:PK01 host PostgreSQL 中的独立数据库与角色。 +- 执行证据:GitHub issue/PR、任务报告、PaC/PipelineRun/GitOps/Argo 和 Web 原入口验证。 + + +## R1 [in_progress] + +依据 PJ2026-03 建设企业级可扩展 PikaOA:在 /root/pikaoa 建立 pikaInc 仓库,采用 HWLAB v0.3 同族 Vue 技术栈和 Go 后端;CLI-first 完成员工/RBAC、甲方分类、合同多版本、发票关联与废弃、审计、OTel/Prometheus,再开发 Web;通过独立 K8s namespace、PK01 host PostgreSQL、YAML-first、PaC bootstrap、自动 CI/CD 和 https://oa.pikapython.com 原入口交付,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1_Task_Report.md)。 + +### R1.1 [completed] + +调研成熟开源 OA、ERP、合同、发票、Go 微服务与 Vue 组件方案,形成采用/复用/不采用结论,依赖 R1 规格,执行记录见 [pikasTech/unidesk#1953](https://github.com/pikasTech/unidesk/issues/1953),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.1_Task_Report.md)。 + +### R1.2 [completed] + +建立 pikainc/pikaoa 私有仓库、/root/pikaoa 固定 master 和独立任务 worktree;远端已由 SSH 验证并以 [1fc2353](https://github.com/pikainc/pikaoa/commit/1fc2353) 建立唯一初始提交,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.2_Task_Report.md)。 + +### R1.3 [in_progress] + +实现企业模块内核、Go API/Worker、PostgreSQL 迁移、员工/RBAC、伙伴分类、合同版本、发票废弃、审计、OTel/Prometheus 与 CLI,并通过 CLI 后端验收,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3_Task_Report.md)。 + +#### R1.3.1 [completed] + +建立 Go API/Worker/CLI 企业模块共享内核、PostgreSQL 迁移与审计/outbox 端口、OTel/Prometheus 基线和 CLI health/modules 验收,执行记录见 [pikasTech/unidesk#1977](https://github.com/pikasTech/unidesk/issues/1977),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.1_Task_Report.md)。 +#### R1.3.2 [completed] + +采用 chi 建立可插拔模块 HTTP 运行时、disabled 模块关闭入口、示例模块扩展验收,并补齐 PostgreSQL OTel 与 HTTP/runtime/pool/migration Prometheus 基线,执行记录见 [pikasTech/unidesk#1983](https://github.com/pikasTech/unidesk/issues/1983),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.2_Task_Report.md)。 +#### R1.3.3 [completed] + +实现员工身份、Casbin RBAC、会话与 CLI login/logout/whoami/employees/roles,使用 YAML Secret 投影初始管理员和员工,执行记录见 [pikasTech/unidesk#1984](https://github.com/pikasTech/unidesk/issues/1984),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.3_Task_Report.md)。 +#### R1.3.4 [completed] + +实现 pgx 同事务审计、PostgreSQL outbox、Watermill handler pipeline、幂等 Worker 与 OTel/Prometheus,执行记录见 [pikasTech/unidesk#1985](https://github.com/pikasTech/unidesk/issues/1985),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.4_Task_Report.md)。 +#### R1.3.5 [in_progress] + +实现统一业务伙伴、甲方分类、角色、联系人、标签、归档、筛选和 CLI;持久化 REST 资源使用 UUIDv7,伙伴与分类只通过 typed ID 关联,并将业务、审计、outbox 保持同事务,执行记录见 [pikasTech/unidesk#1990](https://github.com/pikasTech/unidesk/issues/1990),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.5_Task_Report.md)。 + +#### R1.3.6 [in_progress] + +实现统一附件元数据、对象引用、可替换存储端口、完整性校验、授权 API 和 CLI;附件使用 UUIDv7 平级资源身份,业务对象仅通过 typed ID 关联,执行记录见 [pikasTech/unidesk#1991](https://github.com/pikasTech/unidesk/issues/1991),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.6_Task_Report.md)。 + +#### R1.3.7 + +实现合同与合同版本平级 UUIDv7 REST 资源,通过 contractId、previousVersionId、currentVersionId 建立 ID 关系,保证版本不可变、并发序号唯一、附件关联和全量 CLI,执行记录见 [pikasTech/unidesk#1992](https://github.com/pikasTech/unidesk/issues/1992),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.7_Task_Report.md)。 +#### R1.3.8 + +实现平级 UUIDv7 发票资源,通过 partnerId、contractId、contractVersionId 和 attachmentId 建立关联,支持定点金额、typed 一致性校验、不可删除废弃事实、筛选汇总和全量 CLI,执行记录见 [pikasTech/unidesk#1993](https://github.com/pikasTech/unidesk/issues/1993),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.8_Task_Report.md)。 +#### R1.3.9 [in_progress] + +复用 append-only 审计唯一真相实现 UUIDv7 平级审计资源、RBAC 查询 API、actor/resource/action/time/requestId/traceId 筛选和 audit list/get CLI,执行记录见 [pikasTech/unidesk#1994](https://github.com/pikasTech/unidesk/issues/1994),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.3.9_Task_Report.md)。 +### R1.4 + +在 R1.3 CLI 后端验收通过后实现 HWLAB v0.3 同族 Vue 工作台、响应式页面与同路径业务流,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.4_Task_Report.md)。 + +### R1.5 [completed] + +在 UniDesk 中声明 PK01 PostgreSQL、Secret、NC01 独立 namespace、Gitea/PaC、GitOps、OTel/Prometheus 和 PK01 公网暴露,并改进可复用 bootstrap 工具,依赖 R1.1-R1.2,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5_Task_Report.md)。 + +#### R1.5.1 [completed] + +改进 PaC bootstrap 的新服务首发预检、分阶段紧凑输出、typed failure 与中文说明,保持 PR merge 唯一交付 authority,执行记录见 [pikasTech/unidesk#1955](https://github.com/pikasTech/unidesk/issues/1955),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.1_Task_Report.md)。 +#### R1.5.2 [completed] + +扩展 NC01 共享 observability,使 OTel/Tempo 与 Prometheus 由同一 YAML-first 控制面管理并提供跨 namespace 指标抓取和有界查询,执行记录见 [pikasTech/unidesk#1956](https://github.com/pikasTech/unidesk/issues/1956),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.2_Task_Report.md)。 +#### R1.5.3 [completed] + +修复 `platform-db postgres plan` 默认 30KB JSON 被输出预算截断的可见性缺陷,提供数据库、角色、Secret/export、检查与下一步的紧凑 CLI-first 投影,执行记录沿用 [主 issue](https://github.com/pikasTech/unidesk/issues/1952),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.3_Task_Report.md)。 +#### R1.5.4 [completed] + +修复 `platform-db postgres status` 与 `export-secrets --dry-run` 默认输出超预算截断,提供与 plan 同族的 Secret/export 有界投影并保持 valuesPrinted=false,执行记录见 [pikasTech/unidesk#1958](https://github.com/pikasTech/unidesk/issues/1958),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.4_Task_Report.md)。 +#### R1.5.5 [completed] + +为 pikainc/pikaoa 补齐 YAML-first GitHub token repository override,使受控 CLI 可管理产品 PR 且不打印凭据,执行记录见 [pikasTech/unidesk#1978](https://github.com/pikasTech/unidesk/issues/1978),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.5_Task_Report.md)。 +#### R1.5.6 [completed] + +修复 GitHub repository override 对 owner/repo 大小写敏感,确保 pikaInc/pikaoa 与 canonical 名称选择同一受控 token,执行记录见 [pikasTech/unidesk#1980](https://github.com/pikasTech/unidesk/issues/1980),完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.5.6_Task_Report.md)。 +### R1.6 + +审核并合并双仓 PR,执行首次受控 PaC bootstrap,由正常 source PR merge 自动发布,依赖 R1.3-R1.5,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.6_Task_Report.md)。 + +### R1.7 + +通过 CLI、OTel、Prometheus 和 https://oa.pikapython.com 桌面/移动原入口验证完整主路径,依赖 R1.6,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.7_Task_Report.md)。 + +### R1.8 + +完成 issue、MDTODO、阶段报告、post-task、已吸收 worktree 与分支清理,依赖 R1.7,完成任务后将详细报告写入[任务报告](./details/pikaoa-enterprise-platform/R1.8_Task_Report.md)。 diff --git a/docs/MDTODO/pr-merge-driven-automatic-delivery.md b/docs/MDTODO/pr-merge-driven-automatic-delivery.md index 3f107843..161f014f 100644 --- a/docs/MDTODO/pr-merge-driven-automatic-delivery.md +++ b/docs/MDTODO/pr-merge-driven-automatic-delivery.md @@ -86,3 +86,19 @@ ## 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)。 + +### R6.1 [completed] + +原 MDTODO R7 依据 [UniDesk #2005](https://github.com/pikasTech/unidesk/issues/2005) 实现 SELFMEDIA CI/CD 单次只读查询:从 owning YAML 解析 selfmedia-nc01,在一次受控 CLI 调用内关联 pikainc/selfmedia GitHub mergedAt、PaC PipelineRun/TaskRun、Argo/runtime health,直接披露 trigger wait、pipeline duration、end-to-end duration 与证据缺口;同步 unidesk-cicd skill 的唯一推荐入口,禁止新增第二状态源或交付 mutation,完成任务后将详细报告写入[任务报告](./details/pr-merge-driven-automatic-delivery/R7_Task_Report.md)。最新 master 已占用顶层 R7,本条仅调整索引层级,不改写任务报告身份与 checkpoint 语义。 + +## 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 18477b8d..dd84fe23 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -15,3 +15,11 @@ ### 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/cli.md b/docs/reference/cli.md index d8da82dc..88c93a18 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -115,7 +115,11 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `gc plan|run --confirm|db-trace|policy|remote` 是主 server 和受控 provider 的磁盘高水位一次性缓解与长期防膨胀入口。`plan` 只读输出候选、风险、估算收益和保护对象;`run` 必须显式 `--confirm`;`gc remote ...` 通过 UniDesk SSH 透传执行远端 GC,`--target-use-percent N` 会在 `summary.target` 中报告目标水位所需释放量、候选估算、预计水位、缺口和 safe-stop 决策。默认只包含 allowlisted `/tmp` 诊断目录;非 allowlist stale `/tmp` 直接子项必须显式 `--include-stale-tmp`,并只允许删除 `/tmp` 一级子项且避开系统 socket/session 前缀。G14/HWLAB registry retention、受限 core dump、保护对象、safe-stop 线和长期收益表的权威规则见 `docs/reference/gc.md`。 - `server rebuild ` 创建异步 job,先构建目标服务镜像,随后在 `.state/locks/server-compose.lock` 串行保护下用 `--no-deps --force-recreate` 替换目标 service 并等待容器 `healthy/running`。Todo Note 的 CC01/旧 Compose 回退入口已在迁移验收后删除;正式发布只走 NC01 PaC/Argo。D601 Code Queue 执行面不由 `server rebuild` 管理;Rust backend-core 常规迭代不得用该命令在 master server 编译,只有明确的 backend-core 主 server 上线例外可以按限流、异步轮询和 health 证据执行,规则见 `docs/reference/dev-environment.md`。 - `provider attach [--master-server URL] [--up] [--force]` 在新计算节点生成两项配置的 provider-gateway 挂载包:`.state/provider-.env` 默认只包含 `UNIDESK_MASTER_SERVER` 与 `PROVIDER_ID`,`provider-.yml` 固定 Docker socket、`pid: "host"`、`restart: always`、只读 `/workspace` 和 SSH 维护私钥挂载;`--up` 会立即执行生成的 `docker compose up -d --build`。`provider triage [--observed-error text] [--observed-scope scope] [--microservice id ...] [--full|--raw]` 是只读多信号健康裁决入口,会把单路径 `provider is not online`、SSH 超时、registry 失败和 service proxy 失败归类成 `runner-local-observation-gap`、`service-degraded`、`provider-degraded` 或 `global-blocker`。默认输出只返回裁决、scope、失败/降级/未知信号和有界 evidence 摘要,完整 evidence 必须显式加 `--full` 或 `--raw`;推荐交叉验证命令仍包含 `debug health`、`debug dispatch host.ssh --wait-ms 15000`、`trans argv true`、`artifact-registry health --provider-id `、`microservice health k3sctl-adapter`、`microservice health code-queue` 和 `codex tasks --view supervisor --limit 20`。 -- `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL。`plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL;`apply --confirm` 默认创建本地异步 job,`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。输出不得打印密码或完整 `DATABASE_URL`。跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 +- `platform-db postgres plan|status|export-secrets|apply --config config/platform-db/postgres-pk01.yaml` 管理 YAML 声明的 PK01 host-native PostgreSQL: + - `plan`、`status` 和 `export-secrets` 默认返回有界摘要;配置项使用 total/passed/missing/actionable/omitted 计数,只预览固定数量的失败、缺失或待处理项,并保留 mutation、typed failure、`valuesPrinted=false` 和语义化下钻。只有显式 `--full` / `--raw` 才披露完整结构化结果。 + - `plan` 和 `status` 只读采集远端 host、PostgreSQL、TLS、DNS alias 和 Secret 形态;`export-secrets --confirm` 只按 YAML 物化本地 Secret source/export 文件,不触碰远端 PostgreSQL。export 结果用 `before` / `after` 区分写入前观察与写入后 presence,fingerprint 不披露 Secret value。 + - `apply --confirm` 默认创建本地异步 job;`apply --confirm --wait` 用远端 root-owned job 收敛 systemd PostgreSQL、TLS、`pg_hba`、role/database、Secret export 和备份 timer。 + - 输出不得打印密码或完整 `DATABASE_URL`;跨节点消费者使用 YAML 中的 `connectionHost` 直连 PK01 公网 endpoint,DNS alias 不作为 `sslmode=require` 切库 blocker,PK01 规则见 `docs/reference/pk01.md`。 - `trans [operation args...]` / `tran [operation args...]` 通过 backend-core 内网 WebSocket broker 和 provider-gateway 的 Host SSH / WSL SSH 维护桥连接目标节点: - `route` 基础形态是 provider id,例如 `D601` 或 `G14`;也可以扩展为纯定位路径 `provider:plane[:namespace:resource[:container]]`,例如 `D601:win`、`D601:win/c/test`、`G14:k3s`、`D601:k3s` 或 `G14:k3s::`。 - WSL provider 的 Windows plane 固定使用 `win`,不得使用 `win32`;Windows route 中 `win` 只负责定位,operation 直接写 `ps`、`cmd`、`git` 或 fs helper。 @@ -164,7 +168,7 @@ PipelineRun 失败或长时间未完成时,先按定点 `control-plane status - `hwlab g14 git-mirror status|apply|sync|flush [--dry-run|--confirm]` 是 `devops-infra` git mirror/relay 的受控维护入口:`apply` 渲染并 server-side apply `devops-infra/git-mirror.yaml`,同时删除遗留 `git-mirror-hwlab-sync` CronJob;`sync` 创建一次性 manual Job,把 GitHub allowlist refs 拉入本地 mirror;`flush` 创建一次性 manual Job,把本地 `v0.2-gitops` 快进推回 GitHub。 `status` 返回 read/write URL、last sync/write/flush、本地 ref、GitHub staging ref 和 pending flush 状态,并在 `cache.summary` 给出 `localV02`、`localGitops`、`githubGitops`、`pendingFlush`、`flushNeeded`、`githubInSync` 和下一条受控 `flushCommand`。confirmed `sync` 和 `flush` 默认创建 `.state/jobs/` 异步 job 并立刻返回可查询状态,只有现场同步调试才显式加 `--wait`;mirror 不设置 CronJob。 如果 PipelineRun 的 `gitops-promote` 阶段报 git mirror 控制面漂移或 refs 不一致,先执行 `hwlab g14 git-mirror apply --confirm` 重新应用当前 `devops-infra/git-mirror.yaml` hook/ConfigMap,再执行 `hwlab g14 git-mirror sync --confirm --wait` 复核 refs;失败的同名 PipelineRun 只能通过 `hwlab g14 control-plane cleanup-runs --lane --pipeline-run --confirm` 受控清理后重试,不要用原生 `kubectl delete` 或手工改 mirror hook。修复后仍必须用 `control-plane status --pipeline-run ` 和 `git-mirror status` 分别确认 runtime closeout 与 GitHub flush。 -- `platform-infra sub2api|langbot|n8n|wechat-archive ...` 是 `platform-infra` namespace 内平台公共服务和公共工作流的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作,`wechat-archive` 支持 `plan|apply|status|validate|pull`,用 `config/platform-infra/wechat-archive.yaml` 声明 LangBot/n8n/Baidu Netdisk 归档链路,以及 D601 Windows 隔离 PC 微信 + WeChatFerry host + D601 k3s 只读 collector 的 personal WeChat upstream;collector 必须复用 D601 已有 `platform-infra` namespace,`createNamespace=false`。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 +- `platform-infra sub2api|langbot|n8n|wechat-archive ...` 是 `platform-infra` namespace 内平台公共服务和公共工作流的受控入口;`sub2api` 支持 `plan|apply|status|validate|codex-pool`,其中用户反馈一键只读追踪见 `docs/reference/sub2api-user-feedback.md`;`langbot` 和 `n8n` 支持各自 YAML-controlled `plan|apply|status|logs|validate` 等公共服务操作,`wechat-archive` 支持 `plan|apply|status|validate|pull`,用 `config/platform-infra/wechat-archive.yaml` 声明 LangBot/n8n/Baidu Netdisk 归档链路,以及 D601 Windows 隔离 PC 微信 + WeChatFerry host + D601 k3s 只读 collector 的 personal WeChat upstream;collector 必须复用 D601 已有 `platform-infra` namespace,`createNamespace=false`。`--target` 选择运行目标,默认 `G14` 为 active runtime,`D601` 为同一 YAML 控制的 standby predeploy target。镜像版本和 target 边界由 `config/platform-infra/*.yaml` 控制,Codex 上游池、统一 API key Secret、FRP 公网端口和 master `~/.codex` 消费端由 `config/platform-infra/sub2api-codex-pool.yaml` 控制;完整 Sub2API 日常部署、上游增删、FRP 暴露、local Codex 配置、验收和排障步骤统一见 `$unidesk-sub2api`(`.agents/skills/unidesk-sub2api/SKILL.md`)。`docs/reference/platform-infra.md` 保留 namespace、YAML-first、路由、Secret 脱敏、PK01 Caddy+FRP 和探针开发边界。 - `platform-infra observability plan|apply|status|validate|trace --target ` 是 `platform-infra` 内 OTel Collector 和 Tempo trace backend 的受控入口,`--target` 必须选择 trace 所属运行面节点;具体 trace 查询、噪声压制、Code Agent/AgentRun 排障和“先改进 OTel”的操作面统一见 `$unidesk-otel`(`.agents/skills/unidesk-otel/SKILL.md`)。 - `secrets plan|sync|status --config config/secrets-distribution.yaml --scope platform-infra` 是平台服务本地 Secret sourceRef 到 Kubernetes Secret key 的受控下发入口。`plan` 只读展示 sourceRef、必需 key、目标 Secret/key、missingKeys 和 fingerprint;`sync --confirm` 只按 YAML 声明创建允许生成的本地 key 并下发到声明的目标 Secret;`status` 只验证 live Secret key presence。该入口禁止从 live pod 或 Kubernetes Secret 反推密码、API key、JWT secret、n8n encryption key 或 `DATABASE_URL`,输出也不得打印 base64、解码值或远端 raw transcript;即使显式 `--raw` 也只返回脱敏 summary 和 raw omission 标记。LangBot/n8n Secret 轮换和缺 key 修复规则见 `docs/reference/platform-infra.md#secret-distribution-boundary`。 - `hwlab g14 observability status|apply|query|targets|boundary|closeout [--lane v02] [--promql ] [--expect-count N] [--expect-value V] [--dry-run|--confirm]` 是 G14 `devops-infra` 共享监控基础设施和 HWLAB v0.2 监控 closeout 的受控入口。`apply` 固定安装 Prometheus Operator `v0.91.0`、Prometheus `v3.12.0`、Prometheus 发现 RBAC、`devops-infra` 内 Prometheus 实例和 ClusterIP query Service,并给被允许发现的 workload namespace 打低风险 label;它不把 Prometheus、Grafana 或 Alertmanager 部署到 `hwlab-v02`,也不接管 HWLAB runtime Deployment/Service。`status` 只读汇总 CRD、operator Deployment、Prometheus CR/pod/service、`hwlab-v02` ServiceMonitor/PrometheusRule 和 bounded `up` 查询;`query` 只通过 Kubernetes service proxy 查询 Prometheus,支持 `--expect-count` / `--expect-value` 输出 `assertion`、bad values 和 missing/extra series;`targets` 汇总 ServiceMonitor/PrometheusRule、metrics sidecar readiness/restart、三层指标值和 `metrics.k8s.io` 当前 CPU/内存资源快照;`boundary` 验证 workload namespace 没有 Prometheus/Alertmanager,并对 `19666/19667` 公网 `/metrics` 做负向验证;`closeout` 聚合平台 ready、scrape reachable、sidecar serving、business health probe、resource snapshot、namespace boundary 和 public metrics exposure 语义结论。长期边界见 `docs/reference/g14-observability-infra.md`。 diff --git a/docs/reference/observability.md b/docs/reference/observability.md index a109cdf6..760b58cc 100644 --- a/docs/reference/observability.md +++ b/docs/reference/observability.md @@ -2,6 +2,16 @@ UniDesk 的可观测性优先级高于静默成功。CLI、服务日志、Docker 日志和数据库状态都必须能通过短命令查询。 +## 共享 Prometheus 指标面 + +- `config/platform-infra/observability.yaml` 同时拥有 Collector、Tempo 和 Prometheus 的 image、retention、storage、port、目标 `targetIds`、服务发现与查询预算;TypeScript 只校验和渲染。 +- Prometheus 仅作用于 `metricsBackend.targetIds` 选中的 observability target;其他 target 的 plan 显示 `disabled-for-target`,manifest、status probe/capture 和 `metrics-query` 均不访问 Prometheus。 +- Prometheus 使用 Kubernetes 原生 pod 服务发现。YAML 通过结构化 `labelSelector.key/value` 声明稳定 label,并用 `prometheus.io/scrape=true`、`prometheus.io/path`、`prometheus.io/port` annotation 声明抓取;显式 path 优先,仅在 annotation 缺失时使用 `defaultPath`,不允许业务专属分支。 +- `bun scripts/cli.ts platform-infra observability plan --target ` 展示 Prometheus enabled/disabled、Service、发现选择器和 manifest 摘要。 +- `bun scripts/cli.ts platform-infra observability status --target ` 与 `validate` 将 Prometheus readiness 单列为 warning;不得改变业务 readiness 或交付成功结论。 +- `bun scripts/cli.ts platform-infra observability metrics-query --target --query ` 只执行 YAML 预算约束的瞬时 PromQL 查询;默认有界,`--full` 展开结构,`--raw` 仅用于解析诊断。 +- Collector + Tempo 继续是 trace authority;Prometheus 只承担 metrics,不成为第二业务状态库或交付门禁。 + ## Capability Boundary 版本、commit 和微服务契约漂移只属于可观测性信号。OTel、日志、diagnose 和 status 必须把它们投影为非阻塞 warning,并继续以真实业务结果为权威;不得把漂移告警升级为业务失败、admission blocker、readiness failure 或 CI/CD gate。完整边界见 [DevOps Hygiene](devops-hygiene.md#prohibited-deployment-truth)。 diff --git a/docs/reference/platform-infra.md b/docs/reference/platform-infra.md index 6f93cdb7..53ac73f3 100644 --- a/docs/reference/platform-infra.md +++ b/docs/reference/platform-infra.md @@ -24,6 +24,7 @@ - 同 deliveryId 与同 payload 必须幂等,异 payload 返回 `409`。PVC 不可用或容量满返回 `503` 且 readiness=false。inbox worker 按 YAML 有界 backoff 自动重试并在重启后恢复 `accepted` / `processing`;Caddy 只对尚未持久化的请求做小于 10 秒的 body-safe 有界重试。 - Durable inbox 只是 webhook delivery 的持久接收与重试 journal,不是业务 source/ref 的第二份真相,也不得被 PaC、Tekton、GitOps、Argo 或 runtime 当作源码/read model。最终 source authority 仍只有 Gitea authority branch 与 immutable snapshot;禁止新增 polling/read-model fallback 或第二 source authority。 - 默认 PaC 入口是 `bun scripts/cli.ts platform-infra pipelines-as-code status|history --target `。`status` 应显示 webhook、PipelineRun/TaskRun duration、image status、env identity、digest、GitOps commit、Argo revision 和 runtime provenance;`history` 必须读取 target node 上的实时对象、显式报告 read error,并支持 consumer 与 PipelineRun id 下钻。`history --id` 必须通过运行面 provenance 与 PipelineRun prefix 唯一解析实际 consumer;零匹配、多匹配或显式 consumer 不一致都 fail-closed,不能回退到默认 consumer 或默认 node。 +- 最新一次端到端耗时使用 `bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target --consumer ` 一次查询。命令从两份 owning YAML 解析 source repository、branch 与 consumer,按 PipelineRun source commit 关联 GitHub PR `mergedAt`,并投影 trigger wait、PipelineRun、端到端耗时、Argo/runtime closeout 与证据缺口;缺失关联时保持 `partial/unknown`,不得使用 commit 时间冒充 `mergedAt`,且始终 `mutation=false`。 - Sentinel 内部 publish capability 由 `config/platform-infra/pipelines-as-code.yaml#capabilities.sentinelInternalPublish` 单一拥有,默认 `enabled=false`。Pod env、ownerReferences、ServiceAccount、label 与 annotation 不能证明 creator;#1769 的 admission-owned provenance 与最小权限边界落地前不得启用。`.tekton` 继续走既有自动 `publish-current`,不能退化成人工 publish。 - PaC 观察必须把 PipelineRun 分类为外层 `outer-pac-event`、历史内层 `inner-deterministic-publish` 或 `unknown`。只有唯一外层 PaC push event 能驱动 latest、status、debug-step 与兼容 closeout;Web sentinel 的 source、image、artifact、GitOps 步骤必须直接位于该 outer PipelineRun,禁止再创建 nested PipelineRun。历史内层运行只作为未绑定执行观察,缺少 admission-owned 父子证据时标记 `unproven`,禁止按名称前缀推断关系。 - CI/CD OTel endpoint、service/resource 属性、采样与 exporter timeout/failure policy 由 `config/platform-infra/pipelines-as-code.yaml` 引用现有 `config/platform-infra/observability.yaml` 并负责渲染。outer PaC 在最早可控 step 生成 W3C trace context,后续 source、image、artifact、GitOps 与 helper 复用同一 traceId;export 失败或超时只输出 `warning=true`、`blocking=false` 的有界事件,不改变 PipelineRun、TaskRun 或业务 step 终态。 diff --git a/docs/reference/sub2api-user-feedback.md b/docs/reference/sub2api-user-feedback.md new file mode 100644 index 00000000..b56d700c --- /dev/null +++ b/docs/reference/sub2api-user-feedback.md @@ -0,0 +1,61 @@ +# Sub2API 用户反馈只读追踪 + +## 入口 + +- 按用户邮箱或正整数用户 ID 查询: + + ```bash + bun scripts/cli.ts platform-infra sub2api codex-pool feedback \ + --target PK01 \ + --user + ``` + +- 默认窗口为 `30m`;可显式选择 `5m|30m|1h|6h|24h|7d|30d`。 +- 默认输出 Kubernetes 列表风格紧凑文本;只有显式 `--json` 才输出完整脱敏 JSON。 +- 请求表固定返回 10 条,并输出 `returned`、`total`、`moreAvailable`、`nextPageToken`;下一页使用 CLI 给出的完整 `Next` 命令,不提供手工 `limit`。 +- `REQUEST ID INDEX` 输出当前页完整、可复制的 request ID;主表使用页内序号避免重复宽字段。 +- 按默认表格中的 client request ID 或内部 request ID 下钻: + + ```bash + bun scripts/cli.ts platform-infra sub2api codex-pool feedback \ + --target PK01 \ + --user \ + --request-id + ``` + +- 稳定游标下一页: + + ```bash + bun scripts/cli.ts platform-infra sub2api codex-pool feedback \ + --target PK01 \ + --user \ + --window 24h \ + --page-token + ``` + +## 证据组合 + +- 用户与 API Key:使用 Sub2API 原生 `admin users` 和用户 API Key 列表,输出邮箱、名称、分组与当前并发,不输出 Key 值。 +- 请求时间线:自动分页读取窗口内全部 `ops requests`,逐项显示相邻请求空窗、模型、状态、耗时、账号和 proxy。 +- ID 关联:一次读取 `ops system-logs`,在本地关联 client request ID 与内部 request ID;指定 `--request-id` 时披露关联日志。 +- 实时状态:组合用户、账号和分组并发,区分无入站证据、排队、在途与已完成。 +- 基础设施:组合账号可用性、账号当前状态、proxy 状态和 Sub2API 组件健康摘要。 + +## 归因边界 + +- `client-before-submit-or-no-inbound-evidence` 表示窗口内没有 Sub2API 入站记录且实时并发为零,不等价于证明客户端没有尝试调用。 +- `sub2api-queue` 需要实时等待队列证据;`in-flight` 需要实时并发证据。 +- `upstream-first-byte-stream`、`proxy-egress` 和 `client-connection` 只根据原生 Ops 日志、账号、proxy 与基础设施证据分类,不执行探针或写操作。 +- 无足够分层证据时返回 `sub2api-or-upstream-unknown`,禁止扩大结论。 + +## 脱敏与只读 + +- 入口只调用 GET 类 admin/ops 接口、管理员登录和运行组件健康读取。 +- 不调用账号测试、探针、调度、取消、清理、更新或其他运行面写接口。 +- JSON 和文本均不披露 API Key、管理员 token、账号 credentials、proxy 用户名、密码或其他 Secret。 +- 所有分页由 CLI 自动完成,不提供手工 `limit` 作为输出规避手段。 + +## 调用量 + +- issue `#2000` 的人工调查包含用户、请求、系统日志、两种 request ID trace、账号、proxy、进程/端口和 journal 共 9 次工具入口。 +- 本入口将用户侧工具调用收敛为 1 次,减少 8 次,即 `88.9%`;内部只读 API 来源在 JSON `sources` 字段披露。 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/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md new file mode 100644 index 00000000..2858e184 --- /dev/null +++ b/project-management/PJ2026-03/specs/PJ2026-03-pikaoa.md @@ -0,0 +1,511 @@ +# PJ2026-03 PikaOA 企业办公平台总规格 + +## 修改历史 + +| 版本 | 对应 commit id | 更新日期 | 变更说明 | +| --- | --- | --- | --- | +| v0.1 | 待本版本提交 | 2026-07-13 | 创建企业办公平台预期终态、模块架构和首批合同与发票能力规格。 | +| v0.2 | 待本版本提交 | 2026-07-14 | 统一平级 REST 资源、UUIDv7 全局标识和仅通过 typed ID 建立对象关联的契约。 | +| v0.3 | 待本版本提交 | 2026-07-14 | 将审计语义校验收敛为非阻塞 warning 和最小安全归一化,避免开发期复杂审计门禁阻塞业务。 | + +修改历史只记录规格语义变更,不记录实现进度、阶段基线或一次性证据。 + +## 正文 + +## PJ2026-03 PikaOA 企业办公平台总项目需求规格 + +## 1. 文档控制 + +| 字段 | 内容 | +| --- | --- | +| 编号 | PJ2026-03 | +| 短名 | PikaOA | +| 层级 | L0 总项目 | +| 规格状态 | 已生效 | +| 需求规格模板 | [ISO/IEC/IEEE 29148 需求规格模板](../../templates/iso-iec-ieee-29148-requirements-spec-template.md) | +| 规格治理索引 | 本文第 9 章 | +| 实现引用版本 | v0.1 | + +本文采用 ISO/IEC/IEEE 29148 需求规格模板的项目裁剪版。 + +本文只定义: + +- 企业办公平台的预期终态; +- 稳定业务边界; +- 可扩展模块架构; +- 首批合同与发票能力; +- 可重复执行的验收契约。 + +当前实现状态、阶段差距、提交、PR、作业、日志和截图进入阶段报告、任务报告或 GitHub issue,不进入本规格正文。 + +## 2. 目的和范围 + +### 2.1 项目使命 + +PikaOA 的使命是为公司提供长期演进的企业办公平台: + +- 统一承载员工、组织、业务伙伴、文档、流程、审计和业务模块; +- 让合同、发票等首批模块形成可追溯、可检索、可关联的业务事实; +- 让后续审批、采购、报销、项目、人事、资产和知识管理模块复用同一平台能力; +- 通过稳定模块边界避免每增加一个功能就复制账号、权限、审计、附件和部署逻辑; +- 在 Kubernetes 中以可审计、可重复、自动交付的方式运行。 + +### 2.2 预期终态 + +PikaOA 终态必须同时具备: + +- 企业平台内核: + - 统一员工身份、组织成员、角色、权限和会话; + - 统一业务伙伴、分类、标签、联系人和归档状态; + - 统一附件、审计、模块注册、搜索和分页契约; + - 统一 API 错误、字段校验和幂等写入语义; + - 统一持久化 REST 资源的 UUIDv7 全局标识和 typed ID 关联语义。 +- CLI-first 交付: + - 每个后端业务用例先提供稳定 CLI 入口; + - CLI 与 Web 复用同一 HTTP API、身份、权限和业务语义; + - 后端、数据库、审计和可观测性通过 CLI 原入口验收后,才实现对应前端工作流; + - 自动化和人工排障不依赖浏览器才能完成核心业务闭环。 +- 可扩展业务模块: + - 每个模块拥有独立领域模型、应用服务、持久化迁移、权限声明、HTTP API 和前端路由; + - 模块通过稳定端口、领域事件和对象引用协作; + - 新模块不得复制用户、组织、业务伙伴、审计或附件真相; + - 成熟模块可以沿既有端口从业务 API 中提取为独立微服务。 +- 首批可用能力: + - 合同管理支持多版本、当前版本、版本历史和关联业务伙伴; + - 发票管理支持合同关联、合同版本关联、正常与废弃状态以及废弃原因; + - 员工管理支持管理员和普通员工两类初始角色; + - 按不同甲方及其分类筛选合同、发票和统计摘要。 +- 企业交付能力: + - Web、业务 API 和异步 Worker 是可独立扩缩和发布的工作负载; + - PostgreSQL 是业务数据真相; + - 文件内容通过可替换存储端口管理,元数据与哈希进入业务数据; + - 配置、Secret、数据库绑定、运行目标和公网暴露均由 owning YAML 管理; + - GitHub 合并触发自动 CI/CD、GitOps 和运行面收敛。 +- 首版可观测能力: + - Web、CLI、API、Worker 和 CI/CD 统一传播 W3C trace context; + - Go 服务从第一版接入 OpenTelemetry traces、metrics 和结构化日志关联; + - Prometheus 从第一版采集服务、HTTP、数据库、领域用例、outbox 和运行时指标; + - trace、metric 和日志均携带稳定 service、module、operation 和结果属性; + - 可观测后端不可用时形成显式非阻塞 warning,不阻塞已经成功的用户业务。 + +### 2.3 范围内 + +- 员工账户、启停、角色、权限和登录会话。 +- 业务伙伴、甲方分类、联系人、标签和归档。 +- 合同主记录、多版本、附件、状态、检索和关联。 +- 发票主记录、合同/版本关联、附件、废弃标记和检索。 +- 审计记录、领域事件、模块注册和模块级导航。 +- CLI、Web 工作台、HTTP API、健康接口和异步任务入口。 +- OpenTelemetry Collector 接入、Prometheus 指标、结构化日志和 trace/metric 关联。 +- PostgreSQL、Kubernetes namespace、Secret、CI/CD、GitOps 和公网 HTTPS。 + +### 2.4 范围外 + +- 首期自动签章、电子税务局直连、OCR 自动识别、付款和银行流水对账。 +- 首期通用 BPMN 设计器、复杂会签、组织同步和单点登录。 +- 用合同版本覆盖历史版本或物理删除已废弃发票。 +- 为每个业务模块部署独立身份系统、独立业务伙伴表或独立审计实现。 +- 把运行面 Secret、Pod 环境变量或数据库现状反向保存为配置真相。 +- 未经后续规格确认的租约、选主、分布式事务协调器或第二权限体系。 + +## 3. 术语表 + +| 术语 | 定义 | +| --- | --- | +| 员工 | 可以登录 PikaOA 并按角色执行操作的公司成员。 | +| 管理员 | 可以管理员工、角色、业务伙伴分类和全部首批业务模块的员工角色。 | +| 普通员工 | 可以使用被授予模块能力但不能管理系统身份与权限的员工角色。 | +| 业务伙伴 | 与公司发生业务关系的外部组织或个人;甲方是业务伙伴的一种业务角色。 | +| 甲方分类 | 用于组织、筛选和汇总甲方的可维护分类。 | +| 合同主记录 | 表示一份合同长期身份、甲方、编号、状态和当前版本 ID 指针的独立业务资源。 | +| 合同版本 | 具有全局唯一 ID 的独立业务资源,通过合同 ID 和前一版本 ID 建立关联,并具有合同内单调递增的版本号。 | +| 当前版本 | 合同主记录明确指向、供默认查询和新关联使用的合同版本。 | +| 发票废弃 | 保留发票记录并记录废弃人、时间和原因,使其不再计入有效业务结果。 | +| Typed ID 关联 | 资源只保存关联对象的资源类型和全局唯一 ID,不复制关联对象私有载荷,也不以嵌套路径或表级从属关系定义资源身份。 | +| 模块 | 具有独立领域边界、权限、迁移、API、事件和前端入口的可插拔业务能力。 | +| 领域事件 | 模块完成业务事务后写入的结构化事实,用于同进程订阅或异步扩展。 | + +## 4. 系统边界和内部模块 + +### 4.1 外部边界 + +| 边界项 | 内容 | +| --- | --- | +| 外部使用者 | 管理员、普通员工和经批准的自动化调用方。 | +| 外部输入 | 登录凭据、员工信息、业务伙伴信息、合同版本、发票信息、附件和筛选条件。 | +| 受控资源 | 员工身份、业务伙伴、合同、发票、附件、审计记录、数据库和运行配置。 | +| 外部输出 | 工作台、列表、详情、版本历史、关联结果、状态、审计和健康摘要。 | +| 用户接口 | CLI、响应式 Web 与版本化 HTTP API。 | +| 系统边界 | PikaOA 管理内部办公业务事实;不替代税务、签章、银行和外部身份平台。 | + +### 4.2 L1 能力域 + +| 编号 | 短名 | 主责边界 | 下游支撑 | +| --- | --- | --- | --- | +| PJ2026-0301 | 组织权限 | 员工、角色、权限、会话和模块授权。 | 全部业务模块。 | +| PJ2026-0302 | 伙伴主数据 | 业务伙伴、甲方角色、分类、联系人和标签。 | 合同、发票及后续业务模块。 | +| PJ2026-0303 | 合同管理 | 合同身份、多版本、当前版本、状态、附件和历史。 | 发票、审批、项目和归档。 | +| PJ2026-0304 | 发票管理 | 发票、合同关联、合同版本关联、废弃和附件。 | 财务、报销、对账和统计。 | +| PJ2026-0305 | 平台内核 | 模块注册、审计、领域事件、附件端口、搜索和 API 规范。 | 全部业务模块和自动化。 | +| PJ2026-0306 | 平台交付 | Web/API/Worker 运行、数据库、Secret、CI/CD、GitOps 和公网入口。 | 全部产品能力。 | + +### 4.3 模块接入合同 + +每个业务模块必须声明: + +- 唯一模块标识、显示名称和生命周期状态; +- 领域实体、应用用例、数据库迁移和对象引用类型; +- 权限集合及其管理员、普通员工默认授权映射; +- 版本化 HTTP 路由、OpenAPI 描述和稳定错误码; +- 领域事件名称、版本和最小载荷; +- 前端路由、导航项、权限要求和列表/详情入口; +- 审计动作、资源类型和可安全披露的变更摘要; +- 健康、迁移和部署后验证入口。 + +所有持久化 REST 资源必须遵循统一对象契约: + +- 由服务端生成不可变 UUIDv7,业务编号、名称、版本号和外部标识不得充当资源主键; +- 伙伴、合同、合同版本、发票和附件等资源在 REST、领域模型和存储层均保持平级身份; +- 关联只使用 typed ID 字段或显式关联记录,不使用嵌套 REST 路径、级联从属对象或复制载荷形成第二关系真相; +- 删除、归档、废弃或改名不得改变其他资源的 ID,也不得使历史关联失效。 + +新增模块不得通过修改其他模块私有表完成集成。跨模块读取使用应用端口或稳定对象引用;异步扩展使用事务内写入的领域事件/outbox,不新增第二业务真相。 + +## 5. 目标架构和数据流 + +### 5.1 目标架构图 + +```mermaid +flowchart LR + U[管理员与员工] --> W[Vue Web 工作台] + U --> CLI[PikaOA CLI] + W --> A[Go 业务 API] + CLI --> A + A --> I[组织权限模块] + A --> P[伙伴主数据模块] + A --> C[合同模块] + A --> F[发票模块] + A --> K[平台内核] + I --> DB[(PostgreSQL)] + P --> DB + C --> DB + F --> DB + K --> DB + A --> S[文件存储端口] + A --> O[(事务 Outbox)] + O --> J[Go Worker] + J --> X[后续通知、索引与集成适配器] + W --> OT[OpenTelemetry Collector] + CLI --> OT + A --> OT + J --> OT + A --> PM[Prometheus metrics] + J --> PM + PM --> PR[Prometheus] + OT --> TB[Trace backend] +``` + +部署单元采用三类独立工作负载: + +- Web:使用与 HWLAB v0.3 同族的 Vue 3、Vite、TypeScript、Vue Router、Pinia 和 Tailwind 技术栈; +- API:使用成熟 Go HTTP/服务框架、PostgreSQL 驱动、迁移工具、RBAC 库和 OpenAPI 工具; +- Worker:复用 Go 领域与应用层,消费 PostgreSQL outbox,承载非请求内任务。 + +CLI 是后端业务的第一用户入口: + +- 使用与 Web 相同的公开 HTTP API,不通过数据库直连或内部 manager 绕过业务语义; +- 默认输出适合人工扫描的紧凑文本,机器调用显式请求 JSON; +- 长任务使用提交与短轮询,不保持无界阻塞连接; +- 输出包含请求标识和 traceId,但不显示密码、token、完整数据库连接串或附件正文; +- 前端开发必须以对应 CLI 用例已经通过为前置。 + +业务 API 在首期采用模块化单体内核: + +- 保持单事务、单数据库和低运维复杂度; +- 模块边界与端口从第一版生效; +- Web、API 和 Worker 已是独立工作负载; +- 当容量、团队或故障域需要时,单个模块可以沿既有 API、事件和数据所有权边界提取为独立微服务。 + +### 5.2 业务数据流 + +```mermaid +flowchart TD + R[用户请求] --> G[认证、授权与输入校验] + G --> M[目标模块应用用例] + M --> T[领域规则与事务] + T --> D[(模块拥有的数据表)] + T --> A[(统一审计记录)] + T --> O[(事务 Outbox)] + D --> Q[查询投影与分页] + Q --> V[Web/API 响应] + O --> W[异步 Worker] + W --> E[扩展适配器] +``` + +### 5.3 合同多版本时序 + +```mermaid +sequenceDiagram + participant U as 员工 + participant W as Web + participant A as 合同应用服务 + participant D as PostgreSQL + U->>W: 新建合同或创建新版本 + W->>A: 提交合同身份、甲方和版本内容 + A->>D: 校验权限、甲方和合同内版本号 + A->>D: 事务写入不可变版本、更新当前版本指针和审计/outbox + D-->>A: 返回合同与新版本 + A-->>W: 返回当前版本和完整版本摘要 + W-->>U: 展示详情与版本历史 +``` + +### 5.4 发票废弃与合同关联时序 + +```mermaid +sequenceDiagram + participant U as 员工 + participant W as Web + participant A as 发票应用服务 + participant D as PostgreSQL + U->>W: 登记发票并选择合同/版本 + W->>A: 提交发票与关联标识 + A->>D: 校验合同、版本、甲方一致性并写入发票 + D-->>A: 返回有效发票 + U->>W: 标记发票废弃并填写原因 + W->>A: 提交废弃动作 + A->>D: 原子写入废弃人、时间、原因和审计/outbox + D-->>A: 返回保留的废弃发票 + A-->>W: 展示废弃标识且默认排除有效统计 +``` + +## 6. 全局原子需求 + +### 6.1 PIKAOA-L0-REQ-001 模块化平台内核 + +PikaOA 必须以第 4.3 节的模块接入合同组织业务能力。模块必须拥有清晰数据所有权,复用统一身份、伙伴、附件、审计和事件能力,并能在不改写既有模块私有实现的情况下增加导航、权限、API、迁移和事件订阅。 + +接受标准包括: + +- 模块注册表可以枚举已启用模块及其权限和前端入口; +- 一个示例新增模块可以只通过自身声明与公共端口接入; +- 禁止跨模块直接更新私有表; +- API 与领域事件具有显式版本。 + +### 6.2 PIKAOA-L0-REQ-002 员工与权限 + +系统必须支持员工的创建、编辑、启用、停用、角色分配和登录。管理员与普通员工的初始账号由 owning YAML 的 Secret `sourceRef`/`targetKey` 注入,不在 Git、镜像、日志或前端代码中保存密码。 + +接受标准包括: + +- 管理员可以管理员工和角色; +- 普通员工不能管理员工、角色或 Secret; +- 停用员工不能建立新会话; +- 所有业务写入记录实际员工身份。 + +### 6.3 PIKAOA-L0-REQ-003 业务伙伴和甲方分类 + +系统必须维护业务伙伴的唯一身份、名称、统一社会信用代码或其他外部标识、角色、分类、标签、联系人、备注和归档状态。合同与发票通过稳定标识关联甲方,不复制甲方名称作为关系真相。 + +接受标准包括: + +- 管理员可以维护甲方分类; +- 员工可以按甲方、分类、标签和归档状态筛选; +- 同一甲方的合同与发票可以从伙伴详情统一查看; +- 甲方改名不破坏历史关联。 + +### 6.4 PIKAOA-L0-REQ-004 合同多版本 + +- 每份合同和每个合同版本都是具有独立 UUIDv7 的平级 REST 资源; +- 合同版本通过 `contractId` 关联合同,并通过可选 `previousVersionId` 关联前一版本; +- 合同通过 `currentVersionId` 关联当前版本; +- 合同内版本号单调递增,但版本号不得充当资源主键; +- 创建新版本必须保留旧版本、更新当前版本 ID 指针并记录变更说明、操作者和时间。 + +每个版本至少包含: + +- 版本号和版本说明; +- 合同标题、合同编号和甲方引用; +- 签署日期、生效日期、到期日期和币种; +- 金额、摘要、状态和附件引用; +- 创建人、创建时间和内容哈希。 + +接受标准包括: + +- 详情默认显示当前版本并可查看全部历史版本; +- 任一版本可以通过 `/api/v1/contract-versions/{id}` 独立读取,版本关系只由 ID 字段表达; +- 历史版本不可被当前编辑覆盖; +- 并发创建相同下一版本时只能有一个事务成功; +- 合同可以按甲方、分类、编号、状态和日期筛选。 + +### 6.5 PIKAOA-L0-REQ-005 发票管理和废弃 + +- 发票是具有独立 UUIDv7 的平级 REST 资源; +- 发票通过 `partnerId`、可选 `contractId` 和可选 `contractVersionId` 建立关联; +- 发票必须支持号码、类型、开票日期、金额、税额、价税合计和币种; +- 发票不得物理删除; +- 废弃动作必须保留原记录,并记录废弃人、废弃时间和非空原因。 + +接受标准包括: + +- 发票可以关联合同主记录与具体合同版本; +- 关联版本必须属于所选合同; +- 发票甲方与合同甲方不一致时返回明确校验错误; +- 默认有效列表和汇总排除废弃发票; +- 用户可以显式筛选全部、有效或废弃发票。 + +### 6.6 PIKAOA-L0-REQ-006 附件完整性 + +- 附件是具有独立 UUIDv7 的平级 REST 资源; +- 合同版本和发票通过显式 typed ID 关联记录引用附件,不把附件作为嵌套从属对象; +- 附件元数据必须记录原文件名、媒体类型、字节数、内容哈希、存储键、上传人和上传时间; +- 存储实现通过端口替换,业务表不保存宿主绝对路径。 + +接受标准包括: + +- 上传、下载和详情读取均执行权限检查; +- 相同对象的附件可独立增删,不改写历史合同版本内容; +- 内容哈希与下载内容一致; +- 文件存储不可用时不生成成功业务引用。 + +### 6.7 PIKAOA-L0-REQ-007 审计和领域事件 + +所有身份、伙伴、合同、版本、发票和模块配置写入必须生成不可变审计记录。需要异步扩展的业务事实必须在同一数据库事务中写入 outbox;Worker 只消费已提交事件,不反向成为业务真相。 + +首期审计校验采用最小、非阻塞语义: + +- 缺失可补足字段、格式偏差或安全摘要校验只产生低基数 `warning`,不得单独改变成功业务终态; +- 可安全解释的问题使用稳定 system identity、默认时间或省略摘要完成最小归一化,不建立复杂策略引擎或额外状态; +- 发现疑似 Secret、密码、会话、token 或附件正文时直接删除或替换对应摘要,不允许以 warning 为由披露敏感值; +- PostgreSQL 持久化、事务和 outbox 的真实故障仍按现有一致性契约处理,不把基础设施失败伪装成校验 warning。 + +接受标准包括: + +- 审计记录包含员工、动作、资源类型、资源标识、时间和安全摘要; +- Secret、密码、会话和附件正文不进入审计; +- 业务写入不因审计字段完整性、格式或安全摘要校验 warning 而失败; +- 事务回滚时审计和 outbox 同时回滚; +- Worker 重试不重复改变业务主记录。 + +### 6.8 PIKAOA-L0-REQ-008 企业工作台 + +Web 必须是高密度、可持续使用的办公工作台。首屏直接显示导航、筛选、列表、状态和主要动作,不使用营销式落地页。合同、发票、伙伴和员工页面必须具备分页、空态、加载态、错误态、权限态、创建/编辑表单和详情深链。 + +接受标准包括: + +- 桌面与移动视口无控件重叠或不可达操作; +- 列表筛选和详情选择进入 URL 并可刷新恢复; +- 版本历史、废弃标识和甲方分类可直接扫描; +- 图标按钮有可访问名称和工具提示。 + +### 6.9 PIKAOA-L0-REQ-009 自动交付和配置真相 + +PikaOA 必须运行在独立 Kubernetes namespace。目标、namespace、镜像、数据库引用、Secret、资源、探针、持久卷、公网域名和 CI/CD 参数均由 owning YAML 声明;代码只校验和渲染。 + +GitHub 目标分支合并必须自动驱动 Gitea 受控镜像、不可变快照、Pipelines-as-Code、Tekton、GitOps、Argo 和运行面收敛。首次 bootstrap 只初始化空镜像仓库和控制面,不得同步业务分支或人工创建 PipelineRun。 + +接受标准包括: + +- Web、API 和 Worker 在同一独立 namespace 内运行; +- API 直接连接 YAML 声明的 host PostgreSQL; +- 运行面 Secret 只显示对象、key、presence 和 fingerprint; +- 健康接口证明进程、数据库和迁移状态; +- 公网 HTTPS 入口通过 YAML 声明的 PK01 edge 暴露。 + +### 6.10 PIKAOA-L0-REQ-010 OpenTelemetry 与 Prometheus + +PikaOA 必须从首版提供 OpenTelemetry 与 Prometheus 可观测性,而不是在业务上线后补接。 + +OpenTelemetry 必须覆盖: + +- Web 和 CLI 发起请求时生成或传播 W3C `traceparent`/`baggage`; +- API HTTP server/client、认证、授权、领域用例、PostgreSQL、附件存储和 outbox 写入; +- Worker 消费、重试、处理结果和下游适配器; +- CI/CD 从 source、build、artifact、GitOps 到 runtime health 的同一 trace 上下文; +- 结构化日志中的 traceId、spanId、service、module、operation、result 和稳定错误码。 + +Prometheus 必须覆盖: + +- HTTP 请求量、延迟、状态族和在途请求; +- 登录成功/失败、授权拒绝和会话状态摘要; +- 合同创建、版本创建、发票创建、发票废弃等领域动作计数与延迟; +- PostgreSQL连接池、查询延迟和迁移状态; +- outbox pending、oldest age、processed、retry 和 failed; +- Worker 队列、处理耗时和错误; +- Go runtime、进程和健康状态。 + +可观测属性不得包含密码、token、完整合同正文、附件正文、个人敏感字段或无界业务载荷。甲方、合同、发票和员工只在明确需要下钻时使用不可逆或受权限保护的稳定标识,禁止把高基数自由文本作为 metric label。 + +接受标准包括: + +- 一次 CLI 合同版本操作可以从 CLI requestId/traceId 查询到 API、授权、数据库和审计/outbox span; +- 一次 Worker 处理可以通过同一业务 correlation 与来源事件关联; +- Prometheus 可以抓取 API 与 Worker 的 `/metrics` 并显示上述核心指标; +- 健康状态区分应用 ready、数据库 ready、迁移 ready、OTel exporter warning 和 metrics scrape 状态; +- OTel export 超时或 trace backend 暂时不可用只输出 `warning=true`、`blocking=false`,不改变成功业务事务终态。 + +### 6.11 PIKAOA-L0-REQ-011 CLI-first 业务验收 + +每个后端业务能力必须先通过 `pikaoa` CLI 验收,再实现对应 Web 页面。CLI 必须调用同一公开 HTTP API、使用同一用户身份与权限,并覆盖员工、伙伴、合同版本、发票废弃、审计和模块枚举。 + +接受标准包括: + +- CLI 支持登录、当前身份、员工管理、伙伴/分类管理、合同 CRUD/版本历史、发票 CRUD/废弃、审计查询和健康/metrics 摘要; +- 默认文本输出有界且非空,显式 JSON 输出为单一合法 JSON; +- 业务失败返回稳定非零退出码和 typed error,不以 traceback 或空输出代替; +- CLI 业务成功同时返回资源标识、requestId 和 traceId; +- 后端 CLI 验收未通过时,对应前端任务保持未开始。 + +### 6.12 PIKAOA-L0-REQ-012 非阻塞版本告警 + +Web、API、Worker 和 CI/CD 必须输出结构化日志、健康状态和 trace 上下文。源码版本、镜像版本和 API 版本漂移只能形成非阻塞 warning,不得阻塞可安全解释的用户业务。 + +## 7. API、数据与兼容边界 + +- 外部 HTTP API 使用 `/api/v1` 版本前缀。 +- 模块路由使用稳定复数资源名和标准分页参数。 +- 写请求返回稳定业务错误码、字段路径和可读消息。 +- 所有持久化 REST 资源 ID 使用服务端生成、不可枚举、不可变的 UUIDv7。 +- 集合和单资源路径使用平级复数资源名,例如 `/api/v1/contracts/{id}`、`/api/v1/contract-versions/{id}` 和 `/api/v1/invoices/{id}`;嵌套路径只可作为有界查询入口,不得成为资源唯一身份。 +- 时间以 UTC 保存并在用户界面按配置时区显示。 +- 金额使用定点十进制语义,不使用二进制浮点保存。 +- 数据库迁移单向、版本化、可审计;应用启动不静默修改未知结构。 +- 跨模块对象引用包含资源类型和 UUIDv7,只建立 ID 关联,不复制私有载荷或形成对象隶属真相。 +- 兼容归一化只处理可安全解释的旧字段;不可解释业务数据返回字段级错误。 + +## 8. 全局验收契约 + +### 8.1 功能验收 + +- 先使用 CLI 管理员账号登录,创建普通员工、甲方分类和甲方。 +- 通过 CLI 创建合同 v1,再创建 v2,确认 v1 保留且 v2 成为当前版本。 +- 通过 CLI 在甲方维度查看合同,并按甲方分类筛选合同。 +- 通过 CLI 创建关联合同 v2 的发票,确认合同、版本和甲方关系正确。 +- 通过 CLI 标记发票废弃,确认原因和审计存在,默认有效汇总不包含该发票。 +- 使用 CLI 普通员工账号确认允许的业务操作可用、管理员操作不可用。 +- 确认 CLI 后端验收通过后,再用 Web 重复同一业务主路径并比对结果。 + +### 8.2 扩展性验收 + +- 注册一个不修改首批模块私有实现的示例模块; +- 模块声明权限、路由、导航、迁移、审计动作和事件订阅; +- 示例模块可通过公共伙伴引用和领域事件完成集成; +- 禁用模块后,其导航和新请求入口关闭,但历史业务数据不被删除。 + +### 8.3 运行验收 + +- CI/CD 绑定 source commit、PipelineRun、镜像 digest、GitOps revision、Argo 状态和运行面 ready; +- Web、API 和 Worker 运行在 owning YAML 声明的 namespace; +- PostgreSQL 连接、迁移、附件存储和健康检查通过; +- OTel Collector 接收 Web/CLI/API/Worker trace,Prometheus 抓取 API/Worker 指标; +- CLI 主路径均返回可查询的 requestId/traceId,并能关联业务审计; +- `https://oa.pikapython.com` 返回登录页和可用业务工作台; +- 桌面与移动浏览器完成登录、合同版本和发票废弃主路径; +- 运行和验证输出不披露密码、数据库连接串、token 或附件正文。 + +## 9. 规格治理 + +- 本规格是 PikaOA L0 长期真相。 +- 稳定需求、模块边界、数据流、接口和验收口径变化先更新本规格或对应 L1 规格。 +- 实现进度、当前阻塞、提交、PR、PipelineRun、截图和一次性验证进入 GitHub issue、MDTODO 报告和阶段报告。 +- 本项目新增或修改的源码文件必须在文件头部或包级文档中标注 `SPEC: PJ2026-03 PikaOA v0.1`;自动生成文件、第三方代码、纯配置、锁文件和二进制产物例外,但其生成器或 owning 配置必须可追溯。 +- L1 规格按第 4.2 节编号建立;优先拆分具有独立生命周期、数据所有权或验收入口的能力域。 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/help.ts b/scripts/src/help.ts index 4b5d658e..d7af69c8 100644 --- a/scripts/src/help.ts +++ b/scripts/src/help.ts @@ -871,6 +871,8 @@ function platformInfraHelpSummary(): unknown { usage: [ "bun scripts/cli.ts platform-infra sub2api plan", "bun scripts/cli.ts platform-infra sub2api status [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2api ops diagnosis [--platform ] [--group ] [--id ] [--json]", + "bun scripts/cli.ts platform-infra sub2api ops channels [--platform ] [--channel ] [--window 7d|15d|30d] [--page-token |--record ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", diff --git a/scripts/src/platform-db.ts b/scripts/src/platform-db.ts index 96141acd..c5da285a 100644 --- a/scripts/src/platform-db.ts +++ b/scripts/src/platform-db.ts @@ -12,6 +12,7 @@ import { compactText, fingerprintEnvValues as fingerprintValues, parseEnvFile, p const defaultConfigPath = "config/platform-db/postgres-pk01.yaml"; const managedHbaStart = "# BEGIN unidesk managed pk01-platform-postgres"; const managedHbaEnd = "# END unidesk managed pk01-platform-postgres"; +const compactActionablePreviewLimit = 4; interface PlatformDbOptions { configPath: string; @@ -302,10 +303,10 @@ export function platformDbHelp(): unknown { command: "platform-db postgres plan|status|apply|export-secrets", output: "json", usage: [ - `bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath}`, + `bun scripts/cli.ts platform-db postgres plan --config ${defaultConfigPath} [--full|--raw]`, `bun scripts/cli.ts platform-db postgres status --config ${defaultConfigPath} [--full|--raw]`, - `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run`, - `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm`, + `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --dry-run [--full|--raw]`, + `bun scripts/cli.ts platform-db postgres export-secrets --config ${defaultConfigPath} --confirm [--full|--raw]`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --dry-run`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm`, `bun scripts/cli.ts platform-db postgres apply --config ${defaultConfigPath} --confirm --wait`, @@ -318,6 +319,7 @@ export function platformDbHelp(): unknown { configTruth: defaultConfigPath, defaultMutation: false, exportSecrets: "export-secrets only materializes YAML-declared local Secret source/export files; it does not touch the remote PostgreSQL service.", + disclosure: "plan, status and export-secrets use bounded summaries by default; --full and --raw explicitly disclose the complete structured result without printing Secret values.", apply: "apply --confirm returns an async UniDesk job; the job uses short trans calls and a remote PK01 job instead of keeping one long SSH session open.", monitorReset: "monitor reset is restricted by owning YAML to the dedicated web_probe_monitor database and requires --confirm; it cannot accept a database name or arbitrary SQL.", redaction: "Passwords and full DATABASE_URL values are never printed; output is limited to key names, presence, fingerprints and state.", @@ -439,14 +441,15 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< const remote = await remoteFacts(config, pg, null); const facts = remote.parsed; const checks = facts === null ? [] : desiredChecks(pg, facts, secrets); - return { + const desired = desiredSummary(pg, secrets, facts); + const result = { ok: remote.capture.exitCode === 0 && facts !== null && secrets.ok, action: "platform-db-postgres-plan", mutation: false, config: configSummary(pg), secrets: secretSummary(secrets), remoteFacts: facts, - desired: desiredSummary(pg, secrets, facts), + desired, checks, next: { apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`, @@ -456,6 +459,8 @@ async function plan(config: UniDeskConfig, options: PlatformDbOptions): Promise< remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }), ...(options.raw ? { raw: remote.capture } : {}), }; + if (options.full) return result; + return compactPlan(pg, secrets, facts, checks, remote.capture, result.ok); } async function status(config: UniDeskConfig, options: PlatformDbOptions): Promise> { @@ -485,7 +490,8 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis ...(secrets.ok ? [] : ["secrets-unhealthy"]), ...(endpointHealthy ? [] : [controllerConnection.attempted ? "connection-host-probe-failed" : "connection-host-probe-unavailable"]), ]; - return { + const exportTargets = inspectExportTargets(pg); + const result = { ok: remote.capture.exitCode === 0 && facts !== null && deploymentHealthy && secrets.ok, action: "platform-db-postgres-status", mutation: false, @@ -522,9 +528,12 @@ async function status(config: UniDeskConfig, options: PlatformDbOptions): Promis }, remoteFacts: facts, secrets: secretSummary(secrets), + exports: exportTargets, remote: compactCapture(remote.capture, { full: options.full || remote.capture.exitCode !== 0 }), ...(options.raw ? { raw: remote.capture } : {}), }; + if (options.full) return result; + return compactStatus(pg, secrets, exportTargets, facts, controllerConnection, remote.capture, result.summary, result.ok); } async function apply(config: UniDeskConfig, options: PlatformDbOptions): Promise> { @@ -600,7 +609,7 @@ async function exportSecrets(options: PlatformDbOptions): Promise { }; } +function compactPlan( + pg: PostgresHostConfig, + secrets: SecretInspection, + facts: RemoteFacts | null, + checks: Array>, + capture: SshCaptureResult, + ok: boolean, +): Record { + const observedRoles = facts?.postgres.roles ?? []; + const observedDatabases = facts?.postgres.databases ?? []; + return { + ok, + action: "platform-db-postgres-plan", + mutation: false, + target: { + node: pg.node.id, + route: pg.node.route, + mode: pg.node.mode, + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + pgVersion: pg.postgres.package.version, + connectionHost: pg.postgres.network.connectionHost, + publicDns: pg.postgres.network.publicDns, + port: pg.postgres.network.port, + transport: pg.postgres.network.transport, + sslmode: pg.postgres.network.sslmode, + }, + roles: pg.objects.roles.map((role) => ({ + name: role.name, + exists: observedRoles.find((item) => item.name === role.name)?.exists ?? null, + action: observedRoles.some((item) => item.name === role.name && item.exists) ? "none" : "create-or-update", + })), + databases: pg.objects.databases.map((database) => ({ + name: database.name, + owner: database.owner, + exists: observedDatabases.find((item) => item.name === database.name)?.exists ?? null, + action: observedDatabases.some((item) => item.name === database.name && item.exists) ? "none" : "create", + })), + secrets: { + ok: secrets.ok, + root: secrets.root, + entries: secrets.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + missingKeys: entry.missingKeys, + action: entry.action, + })), + valuesPrinted: false, + }, + exports: pg.exports.connectionStrings.map((item) => ({ + name: item.name, + sourceRef: item.sourceSecretRef, + targetRef: item.writeToSecretSource.sourceRef, + key: item.writeToSecretSource.key, + consumerScopes: item.consumers.map((consumer) => consumer.scope), + })), + checks: { + ok: checks.every((check) => check.ok === true), + passed: checks.filter((check) => check.ok === true).length, + total: checks.length, + items: checks.map((check) => ({ + name: check.name, + ok: check.ok, + ...(check.ok === true ? {} : { detail: compactText(JSON.stringify(check)).slice(0, 480) }), + })), + }, + remoteFacts: compactRemoteFacts(facts), + remote: compactPlanCapture(capture), + next: { + apply: `bun scripts/cli.ts platform-db postgres apply --config ${pg.configPath} --confirm`, + status: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath}`, + full: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath} --full`, + raw: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath} --raw`, + issues: pg.metadata.relatedIssues.map((issue) => `https://github.com/pikasTech/unidesk/issues/${issue}`), + }, + disclosure: { + compact: true, + omitted: ["package", "host policy", "TLS paths", "backup", "full remote facts"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactStatus( + pg: PostgresHostConfig, + secrets: SecretInspection, + exports: Array>, + facts: RemoteFacts | null, + controllerConnection: ControllerConnectionProbe, + capture: SshCaptureResult, + summary: Record | null, + ok: boolean, +): Record { + const blockers = summary === null + ? ["remote-facts-unavailable"] + : Array.isArray(summary.cutoverBlockers) + ? summary.cutoverBlockers + : []; + const roles = pg.objects.roles.map((role) => ({ + name: role.name, + exists: facts?.postgres.roles.find((item) => item.name === role.name)?.exists ?? null, + })); + const databases = pg.objects.databases.map((database) => ({ + name: database.name, + owner: database.owner, + exists: facts?.postgres.databases.find((item) => item.name === database.name)?.exists ?? null, + })); + const appConnections = facts?.postgres.appConnections ?? []; + return { + ok, + action: "platform-db-postgres-status", + mutation: false, + failure: ok ? null : { + type: capture.exitCode !== 0 || facts === null + ? "remote-facts-unavailable" + : secrets.ok + ? "deployment-unhealthy" + : "secrets-unhealthy", + blockers, + }, + target: { + node: pg.node.id, + route: pg.node.route, + mode: pg.node.mode, + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + pgVersion: pg.postgres.package.version, + connectionHost: pg.postgres.network.connectionHost, + port: pg.postgres.network.port, + sslmode: pg.postgres.network.sslmode, + }, + summary: summary === null ? null : { + healthy: summary.healthy, + deploymentHealthy: summary.deploymentHealthy, + cutoverReady: summary.cutoverReady, + cutoverBlockers: summary.cutoverBlockers, + packageInstalled: summary.packageInstalled, + serviceActive: summary.serviceActive, + serviceEnabled: summary.serviceEnabled, + sslOn: summary.sslOn, + port5432Listening: summary.port5432Listening, + dnsResolves: summary.dnsResolves, + dnsDisposition: summary.dnsDisposition, + secretsOk: summary.secretsOk, + connectionHostProbe: { + attempted: controllerConnection.attempted, + ok: controllerConnection.ok, + ssl: controllerConnection.ssl, + user: controllerConnection.user, + database: controllerConnection.database, + host: controllerConnection.host, + port: controllerConnection.port, + error: controllerConnection.error, + }, + }, + roles: compactActionableSummary(roles, { + passed: (item) => item.exists === true, + missing: (item) => item.exists === false, + actionable: (item) => item.exists !== true, + project: (item) => item, + }), + databases: compactActionableSummary(databases, { + passed: (item) => item.exists === true, + missing: (item) => item.exists === false, + actionable: (item) => item.exists !== true, + project: (item) => item, + }), + appConnections: compactActionableSummary(appConnections, { + passed: (item) => item.ok === true && item.ssl === true, + missing: (item) => item.ok !== true || item.ssl !== true, + actionable: (item) => item.ok !== true || item.ssl !== true, + project: (item) => ({ user: item.user, database: item.database, ok: item.ok, ssl: item.ssl, error: item.error }), + }), + secrets: compactSecretInspection(secrets), + exports: compactActionableSummary(exports, { + passed: (item) => item.present === true, + missing: (item) => item.present !== true, + actionable: (item) => item.present !== true, + project: (item) => ({ + targetRef: item.targetRef, + key: item.key, + exists: item.exists, + present: item.present, + fingerprint: item.fingerprint, + }), + }), + remote: compactPlanCapture(capture), + next: { + plan: `bun scripts/cli.ts platform-db postgres plan --config ${pg.configPath}`, + full: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --full`, + raw: `bun scripts/cli.ts platform-db postgres status --config ${pg.configPath} --raw`, + }, + disclosure: { + compact: true, + omitted: ["host facts", "package repository", "TLS paths", "raw remote capture"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactExportSecrets( + pg: PostgresHostConfig, + localState: { inspection: SecretInspection; summary: Record; exports: Array> }, + mode: "dry-run" | "confirmed", + mutation: boolean, + ok: boolean, +): Record { + const suffix = mode === "dry-run" ? " --dry-run" : " --confirm"; + const exports = localState.exports.map((item) => { + const before = asRecord(item.before, "export before"); + const after = asRecord(item.after, "export after"); + return { + name: item.name, + sourceRef: item.sourceRef, + targetRef: item.targetRef, + key: item.key, + before, + after, + action: item.action, + desiredFingerprint: item.desiredFingerprint, + }; + }); + return { + ok, + action: "platform-db-postgres-export-secrets", + mode, + mutation, + failure: ok ? null : { + type: "secrets-unhealthy", + blockers: localState.inspection.entries.filter((entry) => entry.missingKeys.length > 0).map((entry) => entry.sourceRef), + }, + config: { + path: pg.configPath, + id: pg.metadata.id, + secretRoot: localState.inspection.root, + }, + secrets: compactSecretInspection(localState.inspection), + exports: compactActionableSummary(exports, { + passed: (item) => item.action === "none" && item.after.present === true, + missing: (item) => item.after.present !== true, + actionable: (item) => item.action !== "none" || item.after.present !== true, + project: (item) => item, + }), + next: { + ...(mode === "dry-run" ? { confirm: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath} --confirm` } : {}), + full: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --full`, + raw: `bun scripts/cli.ts platform-db postgres export-secrets --config ${pg.configPath}${suffix} --raw`, + syncConsumers: "run the consumer-specific secret sync command after confirmed export", + }, + disclosure: { + compact: true, + omitted: ["source paths", "required key lists", "consumer Secret details"], + fullAvailable: true, + rawAvailable: true, + }, + valuesPrinted: false, + }; +} + +function compactSecretInspection(secrets: SecretInspection): Record { + const entries = secrets.entries.map((entry) => ({ + sourceRef: entry.sourceRef, + exists: entry.exists, + presentKeys: entry.presentKeys, + missingKeys: entry.missingKeys, + action: entry.action, + fingerprint: entry.fingerprint, + })); + return { + ok: secrets.ok, + root: secrets.root, + ...compactActionableSummary(entries, { + passed: (item) => item.exists && item.missingKeys.length === 0 && item.action === "none", + missing: (item) => !item.exists || item.missingKeys.length > 0, + actionable: (item) => item.action !== "none" || !item.exists || item.missingKeys.length > 0, + project: (item) => item, + }), + valuesPrinted: false, + }; +} + +function compactActionableSummary( + items: T[], + selectors: { + passed: (item: T) => boolean; + missing: (item: T) => boolean; + actionable: (item: T) => boolean; + project: (item: T) => unknown; + }, +): Record { + const actionableItems = items.filter(selectors.actionable); + const visibleItems = actionableItems.slice(0, compactActionablePreviewLimit); + return { + total: items.length, + passed: items.filter(selectors.passed).length, + missing: items.filter(selectors.missing).length, + actionable: actionableItems.length, + listed: visibleItems.length, + omitted: items.length - visibleItems.length, + actionableOmitted: actionableItems.length - visibleItems.length, + items: visibleItems.map(selectors.project), + }; +} + +function compactRemoteFacts(facts: RemoteFacts | null): Record | null { + if (facts === null) return null; + return { + ok: facts.ok, + observedAt: facts.observedAt, + host: { + hostname: facts.host.hostname, + cpuCount: facts.host.cpuCount, + memoryTotalMiB: facts.host.memoryTotalMiB, + swapTotalMiB: facts.host.swapTotalMiB, + rootAvailGiB: facts.host.rootAvailGiB, + targetDataAvailGiB: facts.host.targetDataAvailGiB, + }, + network: { + port5432Listening: facts.network.port5432Listening, + dns: facts.network.dns, + }, + repo: { + reachable: facts.repo.reachable, + releaseUrl: facts.repo.releaseUrl, + }, + postgres: { + packageInstalled: facts.postgres.packageInstalled, + serviceActive: facts.postgres.serviceActive, + serviceEnabled: facts.postgres.serviceEnabled, + roleCount: facts.postgres.roles.length, + databaseCount: facts.postgres.databases.length, + serverVersion: facts.postgres.serverVersion, + sslOn: facts.postgres.sslOn, + }, + }; +} + +function compactPlanCapture(result: SshCaptureResult): Record { + return { + exitCode: result.exitCode, + stdoutBytes: Buffer.byteLength(result.stdout, "utf8"), + stderrBytes: Buffer.byteLength(result.stderr, "utf8"), + stdoutTail: result.exitCode === 0 ? "" : result.stdout.slice(-1200), + stderrTail: result.exitCode === 0 ? "" : result.stderr.slice(-1200), + }; +} + function requireMonitorResetConfig(pg: PostgresHostConfig): MonitorResetConfig { const target = pg.managedOperations?.monitorReset; if (target === undefined) throw new Error(`${pg.configPath}.managedOperations.monitorReset is required`); @@ -1252,8 +1619,12 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI const rendered = renderConnectionString(item, source.values); const root = secretRoot(pg); const targetPath = join(root, item.writeToSecretSource.sourceRef); - const existing = existsSync(targetPath) ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; + const exists = existsSync(targetPath); + const existing = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; const before = existing[item.writeToSecretSource.key]; + const beforePresent = before !== undefined && before.length > 0; + const beforeFingerprint = beforePresent ? fingerprintValues({ [item.writeToSecretSource.key]: before }, [item.writeToSecretSource.key]) : null; + const desiredFingerprint = fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]); const next = { ...existing, [item.writeToSecretSource.key]: rendered }; if (materialize) writeEnvFile(targetPath, next); return { @@ -1261,13 +1632,45 @@ function connectionStringExportState(pg: PostgresHostConfig, inspection: SecretI sourceRef: item.sourceSecretRef, targetRef: item.writeToSecretSource.sourceRef, key: item.writeToSecretSource.key, + before: { + exists, + present: beforePresent, + fingerprint: beforeFingerprint, + }, + after: { + exists: materialize ? true : exists, + present: materialize ? true : beforePresent, + fingerprint: materialize ? desiredFingerprint : beforeFingerprint, + }, action: before === rendered ? "none" : before === undefined ? "create" : "update", - fingerprint: fingerprintValues({ [item.writeToSecretSource.key]: rendered }, [item.writeToSecretSource.key]), + desiredFingerprint, consumers: item.consumers, valuesPrinted: false, }; } +function inspectExportTargets(pg: PostgresHostConfig): Array> { + const root = secretRoot(pg); + return pg.exports.connectionStrings.map((item) => { + const targetPath = join(root, item.writeToSecretSource.sourceRef); + const exists = existsSync(targetPath); + const values = exists ? parseEnvFile(readFileSync(targetPath, "utf8")) : {}; + const value = values[item.writeToSecretSource.key]; + const present = value !== undefined && value.length > 0; + return { + name: item.name, + sourceRef: item.sourceSecretRef, + targetRef: item.writeToSecretSource.sourceRef, + key: item.writeToSecretSource.key, + exists, + present, + fingerprint: present ? fingerprintValues({ [item.writeToSecretSource.key]: value }, [item.writeToSecretSource.key]) : null, + consumerScopes: item.consumers.map((consumer) => consumer.scope), + valuesPrinted: false, + }; + }); +} + function renderConnectionString(item: ConnectionStringExportConfig, values: Record): string { let output = item.render.format; const merged = { ...values, ...(item.render.variables ?? {}) }; diff --git a/scripts/src/platform-infra-gitea-config.test.ts b/scripts/src/platform-infra-gitea-config.test.ts index eb512873..be9f6171 100644 --- a/scripts/src/platform-infra-gitea-config.test.ts +++ b/scripts/src/platform-infra-gitea-config.test.ts @@ -4,7 +4,7 @@ import { readGiteaConfig, resolveTarget, } from "./platform-infra-gitea-config"; -import { buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea"; +import { buildGiteaMirrorBootstrapCredentialPreflight, buildMirrorWebhookCredentialBlockedResult, renderManifest } from "./platform-infra-gitea"; import { renderMirrorWebhookCredentialBlocked } from "./platform-infra-gitea-render"; function syntheticMissingSelfmediaCredential(): Record { @@ -65,6 +65,28 @@ describe("platform-infra Gitea repository GitHub credentials", () => { expect(nc01Overrides.map((credential) => credential.sourceRef)).toContain("pikainc-selfmedia-gh-token.txt"); }); + test("bootstrap preflight only requires the selected repository effective credential", () => { + const config = readGiteaConfig(); + const repository = config.sourceAuthority.repositories.find((repo) => repo.key === "selfmedia-nc01"); + expect(repository).toBeDefined(); + const result = buildGiteaMirrorBootstrapCredentialPreflight(config, [repository!], (credential) => ( + credential.sourceRef === "pikainc-selfmedia-gh-token.txt" + ? { [credential.sourceKey]: "fixture-token" } + : null + )); + + expect(result.blockers).toEqual([]); + expect(result.credentials).toHaveLength(1); + expect(result.credentials[0]).toMatchObject({ + id: "github-upstream:selfmedia-nc01", + sourceRef: "pikainc-selfmedia-gh-token.txt", + present: true, + requiredKeysPresent: true, + }); + expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.admin.sourceRef); + expect(JSON.stringify(result)).not.toContain(config.sourceAuthority.credentials.github.sourceRef); + }); + test("does not inject the NC01 selfmedia token into JD01 bridge containers", () => { const config = readGiteaConfig(); const selfmediaTokenEnv = githubTokenEnvNameForSecretKey("github-token-pikainc-selfmedia"); diff --git a/scripts/src/platform-infra-gitea-remote.sh b/scripts/src/platform-infra-gitea-remote.sh index d429837f..f91c62f1 100644 --- a/scripts/src/platform-infra-gitea-remote.sh +++ b/scripts/src/platform-infra-gitea-remote.sh @@ -221,11 +221,45 @@ payload = { }, "valuesPrinted": False, } + print(json.dumps(payload, ensure_ascii=False, indent=2)) sys.exit(0 if payload["ok"] else 1) PY } +run_mirror_preflight() { + python3 - "$tmp/repos.json" <<'PY' +import base64, json, os, subprocess, sys +repos = json.load(open(sys.argv[1], encoding="utf-8")) +rows = [] +timeout = int(os.environ.get("UNIDESK_GITEA_WAIT_TIMEOUT_SECONDS", "20")) +for repo in repos: + credential = repo.get("githubCredential") or {} + token_env = credential.get("tokenEnv") or os.environ.get("UNIDESK_GITEA_GITHUB_DEFAULT_TOKEN_ENV", "") + token = os.environ.get(token_env, "") + clone_url = repo["upstream"]["cloneUrl"] + branch = repo["upstream"]["branch"] + if not token: + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-credential-unavailable", "valuesPrinted": False}) + continue + basic = base64.b64encode(("x-access-token:" + token).encode()).decode() + try: + completed = subprocess.run( + ["git", "-c", "http.extraHeader=Authorization: Basic " + basic, "ls-remote", "--exit-code", clone_url, "refs/heads/" + branch], + capture_output=True, + text=True, + timeout=timeout, + ) + error = " ".join(completed.stderr.split())[-800:] + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": completed.returncode == 0, "code": "github-upstream-available" if completed.returncode == 0 else "github-repository-not-found-or-no-access", "exitCode": completed.returncode, "errorTail": error, "valuesPrinted": False}) + except subprocess.TimeoutExpired: + rows.append({"key": repo["key"], "repository": repo["upstream"]["repository"], "branch": branch, "ok": False, "code": "github-upstream-timeout", "valuesPrinted": False}) +payload = {"ok": bool(rows) and all(row["ok"] for row in rows), "mutation": False, "repositories": rows, "valuesPrinted": False} +print(json.dumps(payload, ensure_ascii=False)) +sys.exit(0 if payload["ok"] else 1) +PY +} + run_status() { selector="app.kubernetes.io/name=$UNIDESK_GITEA_APP_NAME,app.kubernetes.io/component=gitea" capture_json namespace kubectl get namespace "$UNIDESK_GITEA_NAMESPACE" @@ -1125,6 +1159,7 @@ case "$UNIDESK_GITEA_ACTION" in apply) run_apply ;; status) run_status ;; validate) run_validate ;; + mirror-preflight) run_mirror_preflight ;; mirror-bootstrap) run_mirror_bootstrap ;; mirror-sync) run_mirror_sync ;; mirror-status) run_mirror_status ;; diff --git a/scripts/src/platform-infra-gitea.ts b/scripts/src/platform-infra-gitea.ts index 5cfb4e25..2e85410c 100644 --- a/scripts/src/platform-infra-gitea.ts +++ b/scripts/src/platform-infra-gitea.ts @@ -123,6 +123,69 @@ export async function runPlatformInfraGiteaCommand(config: UniDeskConfig, args: }; } +export async function runGiteaMirrorBootstrapPreflight( + config: UniDeskConfig, + targetId: string, + repoKey: string, +): Promise> { + const gitea = readGiteaConfig(); + const target = resolveCommandTarget(gitea, targetId); + const repos = selectedRepositories(gitea, target, repoKey); + const { credentials, blockers } = buildGiteaMirrorBootstrapCredentialPreflight(gitea, repos); + if (blockers.length > 0) { + return { ok: false, mutation: false, code: "github-credential-unavailable", blockers, repositories: repos.map((repo) => repositorySummary(gitea, repo)), valuesPrinted: false }; + } + const secrets = mirrorPreflightSecrets(gitea, repos); + const result = await capture(config, target.route, ["sh"], remoteScript("mirror-preflight", gitea, target, "", { targetId, repoKey, confirm: false, dryRun: true, wait: false, full: false, raw: false }, { repos, secrets }).script); + const parsed = parseJsonOutput(result.stdout); + return parsed ?? { ok: false, mutation: false, code: "github-upstream-preflight-failed", remote: compactCapture(result, { full: true }), valuesPrinted: false }; +} + +export function buildGiteaMirrorBootstrapCredentialPreflight( + gitea: GiteaConfig, + repositories: GiteaMirrorRepository[], + readCredential: (credential: GiteaGithubCredential) => Record | null = (credential) => readGithubCredential(gitea, credential), +): { credentials: Array>; blockers: string[] } { + const credentials = repositories.map((repo) => { + const github = githubCredentialForRepo(gitea, repo); + const values = readCredential(github); + const present = values !== null; + const requiredKeysPresent = Boolean(values?.[github.sourceKey]); + return { + id: `github-upstream:${repo.key}`, + repository: repo.upstream.repository, + sourceRef: github.sourceRef, + present, + requiredKeysPresent, + fingerprint: present ? fingerprintKeys(values, [github.sourceKey]) : null, + permissionResult: present && requiredKeysPresent + ? { status: "not-probed", permissions: github.permissions, error: null } + : { status: "blocked-missing-credential", permissions: github.permissions, error: "credential material is absent" }, + valuesPrinted: false, + }; + }); + return { credentials, blockers: credentialBlockers(credentials) }; +} + +function readGithubCredential(gitea: GiteaConfig, github: GiteaGithubCredential): Record | null { + const sourcePath = credentialPath(gitea, github.sourceRef); + return existsSync(sourcePath) ? parseGithubCredentialFile(sourcePath, github) : null; +} + +function mirrorPreflightSecrets(gitea: GiteaConfig, repositories: GiteaMirrorRepository[]): MirrorSecrets { + const githubTokens: Record = {}; + for (const repo of repositories) { + const github = githubCredentialForRepo(gitea, repo); + const githubPath = credentialPath(gitea, github.sourceRef); + if (!existsSync(githubPath)) throw new Error(`${github.sourceRef} is missing; cannot probe GitHub upstream`); + const githubEnv = parseGithubCredentialFile(githubPath, github); + const githubToken = githubEnv[github.sourceKey]; + if (!githubToken) throw new Error(`${github.sourceRef} must contain ${github.sourceKey}`); + githubTokens[github.gitFetchCredential.secretRef.key] = githubToken; + } + return { adminUsername: "", adminPassword: "", githubTokens, webhookSecret: "" }; +} + export function giteaHelp(scope?: string): Record { if (scope === "platform-bootstrap") { return { @@ -1116,7 +1179,7 @@ function envVars(gitea: GiteaConfig, target: GiteaTarget): string { value: ${yamlQuote(value)}`).join("\n"); } -function remoteScript(action: "apply" | "status" | "validate" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { +function remoteScript(action: "apply" | "status" | "validate" | "mirror-preflight" | "mirror-bootstrap" | "mirror-sync" | "mirror-status" | "mirror-webhook-apply" | "mirror-webhook-status", gitea: GiteaConfig, target: GiteaTarget, manifest: string, options: ApplyOptions | MirrorOptions, params: MirrorRemoteParams = {}): { script: string; payloadSummary: Record } { const frpcExposure = targetFrpcExposure(gitea, target); const sync = targetWebhookSync(gitea, target); const env: Record = { diff --git a/scripts/src/platform-infra-observability.test.ts b/scripts/src/platform-infra-observability.test.ts index 064b904c..8c4eec8a 100644 --- a/scripts/src/platform-infra-observability.test.ts +++ b/scripts/src/platform-infra-observability.test.ts @@ -3,7 +3,12 @@ import { spawnSync } from "node:child_process"; import { rootPath } from "./config"; import { isRenderedCliResult } from "./output"; import { runPlatformObservabilityCommand } from "./platform-infra-observability"; +import { resolveTarget } from "./platform-infra-observability/actions"; +import { isKubernetesLabelValue, isKubernetesQualifiedName, readObservabilityConfig } from "./platform-infra-observability/config"; import { renderObservabilityPlanFailure } from "./platform-infra-observability/plan-render"; +import { prometheusMetaLabel, re2Literal } from "./platform-infra-observability/prometheus"; +import { statusScript } from "./platform-infra-observability/search-script"; +import { renderManifest } from "./platform-infra-observability/trace-script"; describe("platform-infra observability progressive disclosure", () => { test("default plan is a bounded table while full and raw preserve the complete plan", async () => { @@ -13,6 +18,8 @@ describe("platform-infra observability progressive disclosure", () => { expect(Buffer.byteLength(compact.renderedText, "utf8")).toBeLessThan(10_240); expect(compact.renderedText).toContain("serviceConnections=8"); expect(compact.renderedText).toContain("configRefs=6/6 missing=0"); + expect(compact.renderedText).toContain("prometheus=enabled service=prometheus targets=NC01"); + expect(compact.renderedText).toContain("scrapeSelector=unidesk.ai/metrics=true"); expect(compact.renderedText).toContain("--full"); expect(compact.renderedText).toContain("--raw"); @@ -25,6 +32,7 @@ describe("platform-infra observability progressive disclosure", () => { renderPlan: { target: { id: "NC01", route: "NC01:k3s", namespace: "platform-infra" } }, }); expect((expanded as Record).renderPlan.instrumentation).toHaveLength(8); + expect((expanded as Record).config.metricsBackend).toMatchObject({ type: "prometheus", enabled: true }); } }); @@ -38,8 +46,66 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.stdout).not.toContain("/tmp/unidesk-cli-output"); }); - test("search, trace and diagnose-code-agent expose only scoped help", () => { - for (const operation of ["search", "trace", "diagnose-code-agent"]) { + test("Prometheus manifests and runtime access only apply to YAML-selected targets", async () => { + const observability = readObservabilityConfig(); + const nc01 = resolveTarget(observability, "NC01"); + const d518 = resolveTarget(observability, "D518"); + const jd01 = resolveTarget(observability, "JD01"); + const nc01Manifest = renderManifest(observability, nc01); + for (const target of [d518, jd01]) { + const manifest = renderManifest(observability, target); + expect(manifest).not.toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus"); + expect(manifest).not.toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data"); + expect(manifest).not.toContain("app.kubernetes.io/component: metrics-backend"); + const plan = await runPlatformObservabilityCommand({} as never, ["plan", "--target", target.id]); + expect(isRenderedCliResult(plan)).toBe(true); + if (!isRenderedCliResult(plan)) throw new Error("expected rendered plan"); + expect(plan.renderedText).toContain("prometheus=disabled-for-target"); + expect(plan.renderedText).toContain("objects=8"); + const script = statusScript(observability, target, false); + expect(script).not.toContain("capture_json metrics_deployments"); + expect(script).not.toContain("prometheus-ready"); + const query = await runPlatformObservabilityCommand({} as never, ["metrics-query", "--target", target.id, "--query", "up", "--full"]); + expect(query).toMatchObject({ metrics: { enabled: false, disposition: "disabled-for-target", targetIds: ["NC01"] } }); + } + expect(nc01Manifest).toContain("kind: ClusterRole\nmetadata:\n name: platform-infra-prometheus"); + expect(nc01Manifest).toContain("kind: PersistentVolumeClaim\nmetadata:\n name: prometheus-data"); + }); + + test("Prometheus relabel rules prefer an explicit path and only default when absent", () => { + const observability = readObservabilityConfig(); + const manifest = renderManifest(observability, resolveTarget(observability, "NC01")); + const explicitPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)"; + const defaultPath = "source_labels: [__meta_kubernetes_pod_annotation_prometheus_io_path]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: /metrics"; + expect(manifest).toContain("source_labels: [__meta_kubernetes_pod_label_unidesk_ai_metrics]"); + expect(manifest).toContain("regex: '^true$'"); + expect(manifest).toContain(explicitPath); + expect(manifest).toContain(defaultPath); + expect(manifest.indexOf(explicitPath)).toBeLessThan(manifest.indexOf(defaultPath)); + }); + + test("Prometheus selector rendering uses qualified names, stable meta labels and literal RE2", () => { + const observability = readObservabilityConfig(); + observability.metricsBackend.discovery.labelSelector = { + key: "metrics.example.com/release-track", + value: "v1.2-beta", + }; + observability.metricsBackend.discovery.annotationKeys.scrape = "prometheus.io/scrape-enabled"; + const manifest = renderManifest(observability, resolveTarget(observability, "NC01")); + expect(manifest).toContain("__meta_kubernetes_pod_label_metrics_example_com_release_track"); + expect(manifest).toContain("__meta_kubernetes_pod_annotation_prometheus_io_scrape_enabled"); + expect(manifest).toContain("regex: '^v1\\.2-beta$'"); + expect(prometheusMetaLabel("a.b/c-d_e")).toBe("a_b_c_d_e"); + expect(re2Literal("v1.2-beta")).toBe("^v1\\.2-beta$"); + expect(isKubernetesQualifiedName("metrics.example.com/release-track")).toBe(true); + expect(isKubernetesQualifiedName("metrics.example.com/release/track")).toBe(false); + expect(isKubernetesQualifiedName("Metrics.example.com/release-track")).toBe(false); + expect(isKubernetesQualifiedName(`${"a".repeat(64)}.example/release-track`)).toBe(false); + expect(isKubernetesLabelValue("v1.2-beta")).toBe(true); + }); + + test("query commands expose only scoped help", () => { + for (const operation of ["search", "trace", "diagnose-code-agent", "metrics-query"]) { const result = cli(["platform-infra", "observability", operation, "--help"]); expect(result.status).toBe(0); const payload = JSON.parse(result.stdout) as Record; @@ -57,7 +123,7 @@ describe("platform-infra observability progressive disclosure", () => { expect(result.status).toBe(0); const payload = JSON.parse(result.stdout) as Record; expect(payload.data.operations.map((item: Record) => item.name)).toEqual([ - "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", + "plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query", ]); expect(payload.data.scopedHelp).toContain(" --help"); expect(payload.data.usage).toBeUndefined(); diff --git a/scripts/src/platform-infra-observability/actions.ts b/scripts/src/platform-infra-observability/actions.ts index fbbc182e..ba8cbd14 100644 --- a/scripts/src/platform-infra-observability/actions.ts +++ b/scripts/src/platform-infra-observability/actions.ts @@ -27,6 +27,7 @@ import { compactStatus, configSummary, manifestObjectSummary, policyChecks, stat import { renderManifest } from "./trace-script"; import { formatTable, shortenEnd, textValue } from "./manifest"; import { apiPathField, configLabel, kubernetesNameField, stringField } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function parseStatusEndpoint(record: Record, index: number): StatusEndpoint { const path = `probes.statusEndpoints[${index}]`; @@ -57,6 +58,7 @@ export function plan(options: CommonOptions): Record { const target = resolveTarget(observability, options.targetId); const yaml = renderManifest(observability, target); const policy = policyChecks(yaml, target); + const metricsDisposition = metricsTargetDisposition(observability, target); return { ok: policy.every((check) => check.ok), action: "platform-infra-observability-plan", @@ -65,10 +67,16 @@ export function plan(options: CommonOptions): Record { renderPlan: { target: targetSummary(target), objects: manifestObjectSummary(yaml), + metrics: { + disposition: metricsDisposition, + targetIds: observability.metricsBackend.targetIds, + }, otlp: { collectorGrpcEndpoint: `${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.grpcPort}`, collectorHttpEndpoint: `http://${observability.collector.serviceName}.${target.namespace}.svc.cluster.local:${observability.collector.otlp.httpPort}`, backendGrpcEndpoint: `${observability.traceBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.traceBackend.otlp.grpcPort}`, + prometheusHttpEndpoint: metricsEnabledForTarget(observability, target) ? `http://${observability.metricsBackend.serviceName}.${target.namespace}.svc.cluster.local:${observability.metricsBackend.httpPort}` : null, + scrapeDiscovery: observability.metricsBackend.discovery, }, instrumentation: observability.instrumentation.serviceConnections, resourceAttributes: observability.resourceAttributes, @@ -80,6 +88,7 @@ export function plan(options: CommonOptions): Record { status: `bun scripts/cli.ts platform-infra observability status --target ${target.id}`, validate: `bun scripts/cli.ts platform-infra observability validate --target ${target.id}`, trace: `bun scripts/cli.ts platform-infra observability trace --target ${target.id} --trace-id `, + metricsQuery: `bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query `, }, }; } diff --git a/scripts/src/platform-infra-observability/config.ts b/scripts/src/platform-infra-observability/config.ts index 119f4b19..31c86b15 100644 --- a/scripts/src/platform-infra-observability/config.ts +++ b/scripts/src/platform-infra-observability/config.ts @@ -39,6 +39,12 @@ export function readObservabilityConfig(): ObservabilityConfig { const traceBackend = objectField(root, "traceBackend", ""); const traceBackendOtlp = objectField(traceBackend, "otlp", "traceBackend"); const traceBackendStorage = objectField(traceBackend, "storage", "traceBackend"); + const metricsBackend = objectField(root, "metricsBackend", ""); + const metricsStorage = objectField(metricsBackend, "storage", "metricsBackend"); + const metricsDiscovery = objectField(metricsBackend, "discovery", "metricsBackend"); + const metricsLabelSelector = objectField(metricsDiscovery, "labelSelector", "metricsBackend.discovery"); + const metricsAnnotations = objectField(metricsDiscovery, "annotationKeys", "metricsBackend.discovery"); + const metricsQuery = objectField(metricsBackend, "query", "metricsBackend"); const sampling = objectField(root, "sampling", ""); const instrumentation = objectField(root, "instrumentation", ""); const resourceAttributes = objectField(root, "resourceAttributes", ""); @@ -56,6 +62,7 @@ export function readObservabilityConfig(): ObservabilityConfig { images: { collector: imageSpec(objectField(images, "collector", "images"), "images.collector"), tempo: imageSpec(objectField(images, "tempo", "images"), "images.tempo"), + prometheus: imageSpec(objectField(images, "prometheus", "images"), "images.prometheus"), }, targets: arrayOfRecords(root.targets, "targets").map(parseTarget), collector: { @@ -79,6 +86,46 @@ export function readObservabilityConfig(): ObservabilityConfig { retention: stringField(traceBackendStorage, "retention", "traceBackend.storage"), }, }, + metricsBackend: { + type: enumField(metricsBackend, "type", "metricsBackend", ["prometheus"] as const), + enabled: booleanField(metricsBackend, "enabled", "metricsBackend"), + targetIds: stringArrayField(metricsBackend, "targetIds", "metricsBackend"), + deploymentName: kubernetesNameField(metricsBackend, "deploymentName", "metricsBackend"), + serviceName: kubernetesNameField(metricsBackend, "serviceName", "metricsBackend"), + configMapName: kubernetesNameField(metricsBackend, "configMapName", "metricsBackend"), + serviceAccountName: kubernetesNameField(metricsBackend, "serviceAccountName", "metricsBackend"), + clusterRoleName: kubernetesNameField(metricsBackend, "clusterRoleName", "metricsBackend"), + clusterRoleBindingName: kubernetesNameField(metricsBackend, "clusterRoleBindingName", "metricsBackend"), + persistentVolumeClaimName: kubernetesNameField(metricsBackend, "persistentVolumeClaimName", "metricsBackend"), + replicas: integerField(metricsBackend, "replicas", "metricsBackend"), + httpPort: portField(metricsBackend, "httpPort", "metricsBackend"), + storage: { + mode: enumField(metricsStorage, "mode", "metricsBackend.storage", ["persistentVolumeClaim"] as const), + size: stringField(metricsStorage, "size", "metricsBackend.storage"), + retention: stringField(metricsStorage, "retention", "metricsBackend.storage"), + }, + discovery: { + namespaces: stringArrayField(metricsDiscovery, "namespaces", "metricsBackend.discovery"), + labelSelector: { + key: stringField(metricsLabelSelector, "key", "metricsBackend.discovery.labelSelector"), + value: stringField(metricsLabelSelector, "value", "metricsBackend.discovery.labelSelector"), + }, + annotationKeys: { + scrape: stringField(metricsAnnotations, "scrape", "metricsBackend.discovery.annotationKeys"), + path: stringField(metricsAnnotations, "path", "metricsBackend.discovery.annotationKeys"), + port: stringField(metricsAnnotations, "port", "metricsBackend.discovery.annotationKeys"), + }, + defaultPath: apiPathField(metricsDiscovery, "defaultPath", "metricsBackend.discovery"), + scrapeInterval: stringField(metricsDiscovery, "scrapeInterval", "metricsBackend.discovery"), + scrapeTimeout: stringField(metricsDiscovery, "scrapeTimeout", "metricsBackend.discovery"), + }, + query: { + path: apiPathField(metricsQuery, "path", "metricsBackend.query"), + timeoutSeconds: integerField(metricsQuery, "timeoutSeconds", "metricsBackend.query"), + maxSeries: integerField(metricsQuery, "maxSeries", "metricsBackend.query"), + maxResponseBytes: integerField(metricsQuery, "maxResponseBytes", "metricsBackend.query"), + }, + }, sampling: { mode: enumField(sampling, "mode", "sampling", ["parentbased_traceidratio"] as const), ratio: numberField(sampling, "ratio", "sampling"), @@ -99,12 +146,37 @@ export function readObservabilityConfig(): ObservabilityConfig { }; if (config.targets.length === 0) throw new Error(`${configLabel}.targets must not be empty`); assertKnownEnabledTarget(config.targets, config.defaults.targetId, "defaults.targetId"); - if (config.collector.replicas < 0 || config.traceBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); + for (const targetId of config.metricsBackend.targetIds) assertKnownEnabledTarget(config.targets, targetId, "metricsBackend.targetIds"); + if (config.metricsBackend.enabled && config.metricsBackend.targetIds.length === 0) throw new Error(`${configLabel}.metricsBackend.targetIds must not be empty when metricsBackend.enabled is true`); + if (new Set(config.metricsBackend.targetIds.map((targetId) => targetId.toLowerCase())).size !== config.metricsBackend.targetIds.length) throw new Error(`${configLabel}.metricsBackend.targetIds must not contain duplicates`); + if (!isKubernetesQualifiedName(config.metricsBackend.discovery.labelSelector.key)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.key must be a Kubernetes qualified name`); + if (!isKubernetesLabelValue(config.metricsBackend.discovery.labelSelector.value)) throw new Error(`${configLabel}.metricsBackend.discovery.labelSelector.value must be a Kubernetes label value`); + if (config.collector.replicas < 0 || config.traceBackend.replicas < 0 || config.metricsBackend.replicas < 0) throw new Error(`${configLabel} replicas must be >= 0`); + if (config.metricsBackend.query.timeoutSeconds < 1 || config.metricsBackend.query.maxSeries < 1 || config.metricsBackend.query.maxResponseBytes < 1024) throw new Error(`${configLabel}.metricsBackend.query budgets must be positive and maxResponseBytes must be >= 1024`); if (config.sampling.ratio < 0 || config.sampling.ratio > 1) throw new Error(`${configLabel}.sampling.ratio must be between 0 and 1`); if (!config.probes.traceQueryPathTemplate.includes("{{traceId}}")) throw new Error(`${configLabel}.probes.traceQueryPathTemplate must include {{traceId}}`); return config; } +export function isKubernetesQualifiedName(value: string): boolean { + const slash = value.indexOf("/"); + if (slash !== value.lastIndexOf("/")) return false; + const prefix = slash < 0 ? null : value.slice(0, slash); + const name = slash < 0 ? value : value.slice(slash + 1); + if (!isKubernetesNamePart(name)) return false; + if (prefix === null) return true; + if (prefix.length === 0 || prefix.length > 253) return false; + return prefix.split(".").every((segment) => segment.length <= 63 && /^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(segment)); +} + +export function isKubernetesLabelValue(value: string): boolean { + return value.length <= 63 && (value === "" || isKubernetesNamePart(value)); +} + +function isKubernetesNamePart(value: string): boolean { + return value.length > 0 && value.length <= 63 && /^[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$/u.test(value); +} + export function imageSpec(record: Record, path: string): ImageSpec { const image = { repository: stringField(record, "repository", path), diff --git a/scripts/src/platform-infra-observability/metrics-target.ts b/scripts/src/platform-infra-observability/metrics-target.ts new file mode 100644 index 00000000..1fa27a3b --- /dev/null +++ b/scripts/src/platform-infra-observability/metrics-target.ts @@ -0,0 +1,12 @@ +import type { ObservabilityConfig, ObservabilityTarget } from "./types"; + +export type MetricsTargetDisposition = "enabled" | "disabled" | "disabled-for-target"; + +export function metricsTargetDisposition(observability: ObservabilityConfig, target: ObservabilityTarget): MetricsTargetDisposition { + if (!observability.metricsBackend.enabled) return "disabled"; + return observability.metricsBackend.targetIds.some((targetId) => targetId.toLowerCase() === target.id.toLowerCase()) ? "enabled" : "disabled-for-target"; +} + +export function metricsEnabledForTarget(observability: ObservabilityConfig, target: ObservabilityTarget): boolean { + return metricsTargetDisposition(observability, target) === "enabled"; +} diff --git a/scripts/src/platform-infra-observability/options.ts b/scripts/src/platform-infra-observability/options.ts index 46800fc9..b9bea663 100644 --- a/scripts/src/platform-infra-observability/options.ts +++ b/scripts/src/platform-infra-observability/options.ts @@ -19,18 +19,19 @@ import { capture, } from "../platform-infra-ops-library"; -import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, SearchOptions, TraceOptions } from "./types"; +import type { ApplyOptions, CommonOptions, DiagnoseCodeAgentOptions, MetricsQueryOptions, SearchOptions, TraceOptions } from "./types"; import { apply, plan, status, validate } from "./actions"; import { diagnoseCodeAgent, search, trace } from "./render"; import { renderObservabilityPlan, renderObservabilityPlanFailure } from "./plan-render"; +import { metricsQuery } from "./prometheus"; -const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent"] as const; +const observabilityOperations = ["plan", "apply", "status", "validate", "trace", "search", "diagnose-code-agent", "metrics-query"] as const; export function observabilityHelp(scope: string | null = null): Record { const scoped = scope === null ? null : observabilityOperationHelp(scope); if (scoped !== null) return scoped; return { - command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent", + command: "platform-infra observability plan|apply|status|validate|trace|search|diagnose-code-agent|metrics-query", output: "默认输出为有界摘要;--full/--raw 显式披露完整结构", configTruth: "config/platform-infra/observability.yaml", spec: "PJ2026-01060501 OTel追踪 draft-2026-06-19-p0", @@ -39,7 +40,7 @@ export function observabilityHelp(scope: string | null = null): Record --help", - boundary: "Prometheus 仍是指标来源;本命令只负责 platform-infra OTel Collector、Tempo readiness 与 trace 查询。", + boundary: "Collector + Tempo 是 trace authority;Prometheus 仅承担 metrics,故障和漂移保持非阻塞 warning。", }; } @@ -62,6 +63,7 @@ export async function runPlatformObservabilityCommand(config: UniDeskConfig, arg if (action === "trace") return await trace(config, parseTraceOptions(args.slice(1))); if (action === "search") return await search(config, parseSearchOptions(args.slice(1))); if (action === "diagnose-code-agent") return await diagnoseCodeAgent(config, parseDiagnoseCodeAgentOptions(args.slice(1))); + if (action === "metrics-query") return await metricsQuery(config, parseMetricsQueryOptions(args.slice(1))); return { ok: false, error: "unsupported-platform-infra-observability-command", args, help: observabilityHelp() }; } @@ -105,6 +107,12 @@ function observabilityOperationHelp(scope: string): Record | nu ], options: ["--trace-id <32-hex>", "--target ", "--grep ", "--limit <1..500>", "--full", "--raw"], }; + if (scope === "metrics-query") return { + ...common, + command: "platform-infra observability metrics-query", + usage: ["bun scripts/cli.ts platform-infra observability metrics-query --target --query [--full|--raw]"], + options: ["--target ", "--query ", "--full", "--raw"], + }; if (scope === "search") return { ...common, command: "platform-infra observability search", @@ -126,6 +134,27 @@ function observabilityOperationHelp(scope: string): Record | nu return null; } +export function parseMetricsQueryOptions(args: string[]): MetricsQueryOptions { + const commonArgs: string[] = []; + let query: string | null = null; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === "--query") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--query requires a value"); + query = value; + index += 1; + } else { + commonArgs.push(arg); + if (arg === "--target") { + commonArgs.push(args[index + 1] ?? ""); + index += 1; + } + } + } + return { ...parseCommonOptions(commonArgs), query }; +} + export function parseCommonOptions(args: string[]): CommonOptions { let targetId: string | null = null; let full = false; diff --git a/scripts/src/platform-infra-observability/plan-render.ts b/scripts/src/platform-infra-observability/plan-render.ts index cac6c86f..c41f365e 100644 --- a/scripts/src/platform-infra-observability/plan-render.ts +++ b/scripts/src/platform-infra-observability/plan-render.ts @@ -8,7 +8,9 @@ export function renderObservabilityPlan(result: Record): Render const images = record(config.images); const collector = record(config.collector); const traceBackend = record(config.traceBackend); + const metricsBackend = record(config.metricsBackend); const renderPlan = record(result.renderPlan); + const metricsPlan = record(renderPlan.metrics); const otlp = record(renderPlan.otlp); const connections = records(config.serviceConnections); const objects = records(renderPlan.objects); @@ -39,6 +41,8 @@ export function renderObservabilityPlan(result: Record): Render `target=${textValue(target.id)} route=${textValue(target.route)} namespace=${textValue(target.namespace)} role=${textValue(target.role)}`, `collector=${textValue(collector.serviceName)} replicas=${textValue(collector.replicas)} image=${textValue(images.collector)}`, `tempo=${textValue(traceBackend.serviceName)} replicas=${textValue(traceBackend.replicas)} retention=${textValue(record(traceBackend.storage).retention)} image=${textValue(images.traceBackend)}`, + `prometheus=${textValue(metricsPlan.disposition)} service=${textValue(metricsBackend.serviceName)} targets=${values(metricsPlan.targetIds).join(",")} replicas=${textValue(metricsBackend.replicas)} retention=${textValue(record(metricsBackend.storage).retention)} image=${textValue(images.metricsBackend)}`, + `scrapeSelector=${textValue(record(record(metricsBackend.discovery).labelSelector).key)}=${textValue(record(record(metricsBackend.discovery).labelSelector).value)} namespaces=${values(record(metricsBackend.discovery).namespaces).length === 0 ? "all" : values(record(metricsBackend.discovery).namespaces).join(",")} queryBudget=${textValue(record(metricsBackend.query).timeoutSeconds)}s/${textValue(record(metricsBackend.query).maxSeries)}series/${textValue(record(metricsBackend.query).maxResponseBytes)}bytes`, `serviceConnections=${connections.length} services=${serviceNames.size} nodes=${nodes.size} configRefs=${presentConfigRefs.length}/${configRefs.length} missing=${missingConfigRefs.length} objects=${objects.length}`, "", "Service connections:", @@ -77,6 +81,7 @@ export function renderObservabilityPlan(result: Record): Render ` collector-grpc: ${textValue(otlp.collectorGrpcEndpoint)}`, ` collector-http: ${textValue(otlp.collectorHttpEndpoint)}`, ` tempo-grpc: ${textValue(otlp.backendGrpcEndpoint)}`, + ` prometheus-http: ${textValue(otlp.prometheusHttpEndpoint)}`, "", "Next:", ` bun scripts/cli.ts platform-infra observability plan --target ${textValue(target.id)} --full`, diff --git a/scripts/src/platform-infra-observability/prometheus.ts b/scripts/src/platform-infra-observability/prometheus.ts new file mode 100644 index 00000000..0b1b893a --- /dev/null +++ b/scripts/src/platform-infra-observability/prometheus.ts @@ -0,0 +1,53 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; +import { capture, compactCapture, parseJsonOutput, redactSensitiveUnknown, shQuote } from "../platform-infra-ops-library"; +import { resolveTarget } from "./actions"; +import { readObservabilityConfig } from "./config"; +import { formatTable, shortenEnd, textValue } from "./manifest"; +import { imageReference, indent, targetSummary } from "./summary"; +import type { MetricsQueryOptions, ObservabilityConfig, ObservabilityTarget } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; + +export function prometheusManifests(observability: ObservabilityConfig, target: ObservabilityTarget): string[] { + const prometheus = observability.metricsBackend; + if (!metricsEnabledForTarget(observability, target)) return []; + const namespaceNames = prometheus.discovery.namespaces.length > 0 + ? `\n namespaces:\n names:\n${prometheus.discovery.namespaces.map((name) => ` - ${name}`).join("\n")}` + : ""; + const config = `global:\n scrape_interval: ${prometheus.discovery.scrapeInterval}\n scrape_timeout: ${prometheus.discovery.scrapeTimeout}\nscrape_configs:\n - job_name: kubernetes-pods\n kubernetes_sd_configs:\n - role: pod${namespaceNames}\n relabel_configs:\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.scrape)}]\n action: keep\n regex: \"true\"\n - source_labels: [__meta_kubernetes_pod_label_${prometheusMetaLabel(prometheus.discovery.labelSelector.key)}]\n action: keep\n regex: '${re2Literal(prometheus.discovery.labelSelector.value)}'\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: (/.+)\n - source_labels: [__address__, __meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.port)}]\n action: replace\n regex: ([^:]+)(?::\\d+)?;(\\d+)\n replacement: \$1:\$2\n target_label: __address__\n - source_labels: [__meta_kubernetes_pod_annotation_${prometheusMetaLabel(prometheus.discovery.annotationKeys.path)}]\n action: replace\n target_label: __metrics_path__\n regex: ^$\n replacement: ${prometheus.discovery.defaultPath}\n`; + const labels = ` app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n app.kubernetes.io/managed-by: unidesk`; + return [ + `apiVersion: v1\nkind: ServiceAccount\nmetadata:\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n labels:\n${labels}\n`, + `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRole\nmetadata:\n name: ${prometheus.clusterRoleName}\nrules:\n - apiGroups: [\"\"]\n resources: [\"nodes\", \"nodes/proxy\", \"services\", \"endpoints\", \"pods\"]\n verbs: [\"get\", \"list\", \"watch\"]\n`, + `apiVersion: rbac.authorization.k8s.io/v1\nkind: ClusterRoleBinding\nmetadata:\n name: ${prometheus.clusterRoleBindingName}\nroleRef:\n apiGroup: rbac.authorization.k8s.io\n kind: ClusterRole\n name: ${prometheus.clusterRoleName}\nsubjects:\n - kind: ServiceAccount\n name: ${prometheus.serviceAccountName}\n namespace: ${target.namespace}\n`, + `apiVersion: v1\nkind: ConfigMap\nmetadata:\n name: ${prometheus.configMapName}\n namespace: ${target.namespace}\n labels:\n${labels}\ndata:\n prometheus.yml: |\n${indent(config, 4)}\n`, + `apiVersion: v1\nkind: PersistentVolumeClaim\nmetadata:\n name: ${prometheus.persistentVolumeClaimName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n accessModes: [ReadWriteOnce]\n resources:\n requests:\n storage: ${prometheus.storage.size}\n`, + `apiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: ${prometheus.deploymentName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n replicas: ${prometheus.replicas}\n selector:\n matchLabels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n template:\n metadata:\n labels:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n app.kubernetes.io/part-of: platform-infra\n spec:\n serviceAccountName: ${prometheus.serviceAccountName}\n containers:\n - name: prometheus\n image: ${imageReference(observability.images.prometheus)}\n imagePullPolicy: ${observability.images.prometheus.pullPolicy}\n args:\n - --config.file=/etc/prometheus/prometheus.yml\n - --storage.tsdb.path=/prometheus\n - --storage.tsdb.retention.time=${prometheus.storage.retention}\n ports:\n - name: http\n containerPort: ${prometheus.httpPort}\n readinessProbe:\n httpGet:\n path: /-/ready\n port: http\n volumeMounts:\n - name: config\n mountPath: /etc/prometheus/prometheus.yml\n subPath: prometheus.yml\n readOnly: true\n - name: data\n mountPath: /prometheus\n volumes:\n - name: config\n configMap:\n name: ${prometheus.configMapName}\n - name: data\n persistentVolumeClaim:\n claimName: ${prometheus.persistentVolumeClaimName}\n`, + `apiVersion: v1\nkind: Service\nmetadata:\n name: ${prometheus.serviceName}\n namespace: ${target.namespace}\n labels:\n${labels}\nspec:\n type: ClusterIP\n selector:\n app.kubernetes.io/name: ${prometheus.deploymentName}\n app.kubernetes.io/component: metrics-backend\n ports:\n - name: http\n port: ${prometheus.httpPort}\n targetPort: http\n`, + ]; +} + +export async function metricsQuery(config: UniDeskConfig, options: MetricsQueryOptions): Promise | RenderedCliResult> { + if (options.query === null || options.query.trim() === "") throw new Error("observability metrics-query requires --query "); + const observability = readObservabilityConfig(); + const target = resolveTarget(observability, options.targetId); + const prometheus = observability.metricsBackend; + const disposition = metricsTargetDisposition(observability, target); + if (disposition !== "enabled") return { ok: false, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), metrics: { enabled: false, disposition, targetIds: prometheus.targetIds }, warnings: [`Prometheus is ${disposition} in config/platform-infra/observability.yaml`] }; + const path = `${prometheus.query.path}?query=${encodeURIComponent(options.query)}`; + const script = `set -u\npython3 - <<'PY'\nimport json, subprocess\npath = ${JSON.stringify(`/api/v1/namespaces/${target.namespace}/services/http:${prometheus.serviceName}:http/proxy${path}`)}\nproc = subprocess.run([\"kubectl\", \"get\", \"--raw\", path], text=True, capture_output=True, timeout=${prometheus.query.timeoutSeconds})\nbody = proc.stdout[:${prometheus.query.maxResponseBytes}]\ntry:\n parsed = json.loads(body) if body else None\nexcept Exception:\n parsed = None\nresult = parsed.get(\"data\", {}).get(\"result\", []) if isinstance(parsed, dict) else []\nprint(json.dumps({\"ok\": proc.returncode == 0 and isinstance(parsed, dict) and parsed.get(\"status\") == \"success\", \"exitCode\": proc.returncode, \"truncated\": len(proc.stdout) > ${prometheus.query.maxResponseBytes} or len(result) > ${prometheus.query.maxSeries}, \"resultType\": parsed.get(\"data\", {}).get(\"resultType\") if isinstance(parsed, dict) else None, \"result\": result[:${prometheus.query.maxSeries}], \"stderrTail\": proc.stderr[-2000:]}, ensure_ascii=False))\nPY`; + const result = await capture(config, target.route, ["sh"], script); + const parsed = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && parsed?.ok === true; + if (options.full || options.raw) return { ok, action: "platform-infra-observability-metrics-query", mutation: false, target: targetSummary(target), query: options.query, budgets: prometheus.query, result: parsed === null ? compactCapture(result, { full: true }) : redactSensitiveUnknown(parsed) }; + const rows = Array.isArray(parsed?.result) ? parsed.result.slice(0, 20).map((item: unknown) => [shortenEnd(JSON.stringify(item), 160)]) : []; + return { ok, command: "platform-infra observability metrics-query", contentType: "text/plain", renderedText: [`platform-infra observability metrics-query (${ok ? "ok" : "not-ok"})`, "", `target=${target.id} prometheus=${prometheus.serviceName}.${target.namespace}.svc.cluster.local:${prometheus.httpPort}`, `query=${options.query}`, `budget=timeout:${prometheus.query.timeoutSeconds}s series:${prometheus.query.maxSeries} bytes:${prometheus.query.maxResponseBytes} truncated=${textValue(parsed?.truncated)}`, "", formatTable(["RESULT"], rows.length > 0 ? rows : [["-"]]), "", `Next: bun scripts/cli.ts platform-infra observability metrics-query --target ${target.id} --query ${shQuote(options.query)} --full`].join("\n") }; +} + +export function prometheusMetaLabel(value: string): string { + return value.replace(/[^A-Za-z0-9_]/gu, "_"); +} + +export function re2Literal(value: string): string { + return `^${value.replace(/[\\.^$|?*+()[\]{}]/gu, "\\$&")}$`; +} diff --git a/scripts/src/platform-infra-observability/search-script.ts b/scripts/src/platform-infra-observability/search-script.ts index 1a1b0538..83295560 100644 --- a/scripts/src/platform-infra-observability/search-script.ts +++ b/scripts/src/platform-infra-observability/search-script.ts @@ -21,6 +21,7 @@ import { import type { ObservabilityConfig, ObservabilityTarget } from "./types"; import { fieldManager } from "./types"; +import { metricsEnabledForTarget, metricsTargetDisposition } from "./metrics-target"; export function tempoService(observability: ObservabilityConfig, target: ObservabilityTarget): string { return `apiVersion: v1 @@ -102,7 +103,15 @@ PY } export function statusScript(observability: ObservabilityConfig, target: ObservabilityTarget, full: boolean, generateValidationTrace = false): string { - const endpointsJson = JSON.stringify(observability.probes.statusEndpoints); + const metricsEnabled = metricsEnabledForTarget(observability, target); + const metricsDisposition = metricsTargetDisposition(observability, target); + const enabledEndpoints = metricsEnabled + ? observability.probes.statusEndpoints + : observability.probes.statusEndpoints.filter((endpoint) => endpoint.service !== observability.metricsBackend.serviceName); + const endpointsJson = JSON.stringify(enabledEndpoints); + const metricsCaptures = metricsEnabled + ? `capture_json metrics_deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.metricsBackend.deploymentName)} -o json\ncapture_json metrics_services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.metricsBackend.serviceName)} -o json\ncapture_json metrics_pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name=${observability.metricsBackend.deploymentName}`)} -o json` + : ""; return ` set -u tmp="$(mktemp -d)" @@ -123,6 +132,7 @@ capture_json namespace kubectl get namespace ${shQuote(target.namespace)} -o jso capture_json deployments kubectl -n ${shQuote(target.namespace)} get deployment ${shQuote(observability.collector.deploymentName)} ${shQuote(observability.traceBackend.deploymentName)} -o json capture_json services kubectl -n ${shQuote(target.namespace)} get service ${shQuote(observability.collector.serviceName)} ${shQuote(observability.traceBackend.serviceName)} -o json capture_json pods kubectl -n ${shQuote(target.namespace)} get pods -l ${shQuote(`app.kubernetes.io/name in (${observability.collector.deploymentName},${observability.traceBackend.deploymentName})`)} -o json +${metricsCaptures} capture_json events kubectl -n ${shQuote(target.namespace)} get events -o json python3 - "$tmp" '${endpointsJson.replaceAll("'", "'\"'\"'")}' <<'PY' import json, random, socket, subprocess, sys, time, urllib.error, urllib.request @@ -304,7 +314,9 @@ def generate_test_trace(): except subprocess.TimeoutExpired: proc.kill() proc.wait(timeout=3) -runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in probe_results) +blocking_probes = [item for item in probe_results if item.get("service") != "${observability.metricsBackend.serviceName}"] +metrics_probes = [item for item in probe_results if item.get("service") == "${observability.metricsBackend.serviceName}"] +runtime_ready = rc("namespace") == 0 and rc("deployments") == 0 and rc("services") == 0 and all(item["ok"] for item in blocking_probes) validation_trace = generate_test_trace() if (${generateValidationTrace ? "True" : "False"} and runtime_ready) else None payload = { "ok": runtime_ready and (validation_trace is None or validation_trace.get("ok") is True), @@ -316,8 +328,12 @@ payload = { "services": compact_section("services"), "pods": compact_section("pods"), "events": compact_section("events"), + "metricsDeployments": compact_section("metrics_deployments") if ${metricsEnabled ? "True" : "False"} else None, + "metricsServices": compact_section("metrics_services") if ${metricsEnabled ? "True" : "False"} else None, + "metricsPods": compact_section("metrics_pods") if ${metricsEnabled ? "True" : "False"} else None, }, "probes": probe_results, + "warnings": ([{"code": "prometheus-not-ready", "detail": item} for item in metrics_probes if item.get("ok") is not True] if ${metricsEnabled ? "True" : "False"} else [{"code": "prometheus-${metricsDisposition}"}]), "validationTrace": validation_trace, } print(json.dumps(payload, ensure_ascii=False, indent=2 if ${full ? "True" : "False"} else None)) diff --git a/scripts/src/platform-infra-observability/summary.ts b/scripts/src/platform-infra-observability/summary.ts index 3f530e1d..0c035f87 100644 --- a/scripts/src/platform-infra-observability/summary.ts +++ b/scripts/src/platform-infra-observability/summary.ts @@ -32,9 +32,11 @@ export function configSummary(observability: ObservabilityConfig, target: Observ images: { collector: imageReference(observability.images.collector), traceBackend: imageReference(observability.images.tempo), + metricsBackend: imageReference(observability.images.prometheus), }, collector: observability.collector, traceBackend: observability.traceBackend, + metricsBackend: observability.metricsBackend, sampling: observability.sampling, serviceConnections: observability.instrumentation.serviceConnections.map((item) => ({ serviceName: item.serviceName, @@ -62,8 +64,8 @@ export function targetSummary(target: ObservabilityTarget): Record> { return [ - { name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention and sampling values are read from config/platform-infra/observability.yaml." }, - { name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector and trace backend stay ClusterIP-only." }, + { name: "yaml-source-of-truth", ok: true, detail: "All concrete images, routes, namespace, ports, retention, storage, scrape discovery and query budgets are read from config/platform-infra/observability.yaml." }, + { name: "clusterip-only", ok: !/^\s*type:\s*(NodePort|LoadBalancer)\s*$/mu.test(yaml), detail: "Collector, Tempo and Prometheus stay ClusterIP-only." }, { name: "no-ingress", ok: !/^\s*kind:\s*Ingress\s*$/mu.test(yaml), detail: "No public ingress is rendered for the first tracing backend." }, { name: "no-host-network", ok: !/^\s*hostNetwork:\s*true\s*$/mu.test(yaml), detail: "Pods must not use host network." }, { name: "allow-all-network-policy", ok: yaml.includes("kind: NetworkPolicy") && yaml.includes("name: allow-all") && yaml.includes(`namespace: ${target.namespace}`), detail: `NetworkPolicy/allow-all is rendered in ${target.namespace}.` }, @@ -76,6 +78,9 @@ export function statusSummary(payload: Record): Record> : []; const readyDeployments = deployments.map((item) => deploymentSummary(item)); @@ -92,6 +97,12 @@ export function statusSummary(payload: Record): Record deploymentSummary(item)), + services: metricsServices.map((item) => metadataName(item)), + pods: metricsPods.map((item) => podSummary(item, podEvents)), + }, }; } @@ -106,6 +117,12 @@ export function sectionJson(sections: Record, name: string): un return section.json; } +function optionalSectionJson(sections: Record, name: string): unknown { + const value = sections[name]; + if (typeof value !== "object" || value === null || Array.isArray(value)) return null; + return (value as Record).json; +} + export function objectList(value: unknown): Record[] { if (typeof value !== "object" || value === null) return []; const items = (value as Record).items; diff --git a/scripts/src/platform-infra-observability/trace-script.ts b/scripts/src/platform-infra-observability/trace-script.ts index a9c123c3..acbda357 100644 --- a/scripts/src/platform-infra-observability/trace-script.ts +++ b/scripts/src/platform-infra-observability/trace-script.ts @@ -22,6 +22,7 @@ import { import type { ObservabilityConfig, ObservabilityTarget } from "./types"; import { tempoService } from "./search-script"; import { imageReference, indent } from "./summary"; +import { prometheusManifests } from "./prometheus"; export function asArray(value: unknown): unknown[] { return Array.isArray(value) ? value : []; @@ -43,6 +44,7 @@ export function renderManifest(observability: ObservabilityConfig, target: Obser tempoConfigMap(observability, target), tempoDeployment(observability, target, tempoImage), tempoService(observability, target), + ...prometheusManifests(observability, target), ].filter((item) => item.trim().length > 0).join("\n---\n"); } diff --git a/scripts/src/platform-infra-observability/types.ts b/scripts/src/platform-infra-observability/types.ts index 3fc94b97..629ff0d7 100644 --- a/scripts/src/platform-infra-observability/types.ts +++ b/scripts/src/platform-infra-observability/types.ts @@ -51,6 +51,7 @@ export interface ObservabilityConfig { images: { collector: ImageSpec; tempo: ImageSpec; + prometheus: ImageSpec; }; targets: ObservabilityTarget[]; collector: { @@ -71,6 +72,7 @@ export interface ObservabilityConfig { otlp: OtlpPorts; storage: { mode: "emptyDir"; retention: string }; }; + metricsBackend: PrometheusConfig; sampling: { mode: "parentbased_traceidratio"; ratio: number }; instrumentation: { contextPropagation: string[]; @@ -87,6 +89,31 @@ export interface ObservabilityConfig { }; } +export interface PrometheusConfig { + type: "prometheus"; + enabled: boolean; + targetIds: string[]; + deploymentName: string; + serviceName: string; + configMapName: string; + serviceAccountName: string; + clusterRoleName: string; + clusterRoleBindingName: string; + persistentVolumeClaimName: string; + replicas: number; + httpPort: number; + storage: { mode: "persistentVolumeClaim"; size: string; retention: string }; + discovery: { + namespaces: string[]; + labelSelector: { key: string; value: string }; + annotationKeys: { scrape: string; path: string; port: string }; + defaultPath: string; + scrapeInterval: string; + scrapeTimeout: string; + }; + query: { path: string; timeoutSeconds: number; maxSeries: number; maxResponseBytes: number }; +} + export interface ImageSpec { repository: string; tag: string; @@ -165,3 +192,7 @@ export interface DiagnoseCodeAgentOptions extends CommonOptions { candidateLimit: number; lookbackMinutes: number; } + +export interface MetricsQueryOptions extends CommonOptions { + query: string | null; +} diff --git a/scripts/src/platform-infra-pac-delivery-timing.ts b/scripts/src/platform-infra-pac-delivery-timing.ts new file mode 100644 index 00000000..9a68f400 --- /dev/null +++ b/scripts/src/platform-infra-pac-delivery-timing.ts @@ -0,0 +1,108 @@ +import { resolveToken } from "./gh/auth-and-safety"; +import { githubRequest, isGitHubError } from "./gh/client"; + +export interface PacDeliveryTimingBinding { + target: Record; + consumer: { + id: string; + node: string; + lane: string; + repository: string; + branch: string; + pipeline: string; + }; +} + +interface PacDeliveryTimingObservers { + history: () => Promise>; + status: () => Promise>; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function durationBetween(start: unknown, end: unknown): number | null { + if (typeof start !== "string" || typeof end !== "string") return null; + const a = Date.parse(start); + const b = Date.parse(end); + if (!Number.isFinite(a) || !Number.isFinite(b)) return null; + return Math.max(0, Math.round((b - a) / 1000)); +} + +export async function observePacDeliveryTiming( + binding: PacDeliveryTimingBinding, + observers: PacDeliveryTimingObservers, +): Promise> { + const [sourceOwner, sourceRepo] = binding.consumer.repository.split("/"); + if (!sourceOwner || !sourceRepo) throw new Error(`invalid owning YAML source repository ${binding.consumer.repository}`); + const [historyResult, statusResult] = await Promise.all([observers.history(), observers.status()]); + const row = records(historyResult.rows)[0] ?? {}; + const statusSummary = record(statusResult.summary); + const latest = record(statusSummary.latestPipelineRun); + const commit = typeof row.commit === "string" ? row.commit : typeof latest.sourceCommit === "string" ? latest.sourceCommit : null; + const evidenceGaps: string[] = []; + let pullRequest: Record | null = null; + let githubEvidence: Record = { repository: binding.consumer.repository, source: "github-commit-pulls", mutation: false }; + if (commit === null) { + evidenceGaps.push("source-commit-missing"); + } else { + const tokenInfo = resolveToken(binding.consumer.repository, false); + if (tokenInfo.token === null) { + evidenceGaps.push("github-token-unavailable"); + githubEvidence = { ...githubEvidence, ok: false, token: tokenInfo.probe }; + } else { + const response = await githubRequest(tokenInfo.token, "GET", `/repos/${sourceOwner}/${sourceRepo}/commits/${commit}/pulls`); + if (isGitHubError(response)) { + evidenceGaps.push("github-pr-lookup-failed"); + githubEvidence = { ...githubEvidence, ok: false, error: response }; + } else { + const prs = Array.isArray(response) ? response : []; + const merged = prs.find((item) => record(item).merged_at !== null && record(item).merged_at !== undefined) ?? prs[0]; + if (merged === undefined) { + evidenceGaps.push("github-pr-not-found"); + } else { + const item = record(merged); + pullRequest = { number: item.number ?? null, title: item.title ?? null, url: item.html_url ?? null, mergedAt: item.merged_at ?? null, mergeCommit: commit }; + if (typeof item.merged_at !== "string") evidenceGaps.push("github-mergedAt-missing"); + } + githubEvidence = { ...githubEvidence, ok: true, count: prs.length, token: tokenInfo.probe }; + } + } + } + const mergedAt = pullRequest?.mergedAt ?? null; + const startTime = row.startTime ?? null; + const completionTime = row.completionTime ?? null; + const argo = record(statusSummary.argo); + const runtime = record(statusSummary.runtime); + const provenance = record(statusSummary.provenance); + if (mergedAt === null) evidenceGaps.push("mergedAt-missing"); + if (startTime === null || completionTime === null) evidenceGaps.push("pipeline-timestamps-missing"); + if (argo.sync === undefined || argo.health === undefined) evidenceGaps.push("argo-closeout-missing"); + if (runtime.readyReplicas === undefined) evidenceGaps.push("runtime-health-missing"); + if (provenance.relation === "unknown") evidenceGaps.push("runtime-provenance-unknown"); + return { + ok: commit !== null && startTime !== null && completionTime !== null, + action: "platform-infra-pipelines-as-code-delivery-timing", + mutation: false, + state: evidenceGaps.length === 0 ? "complete" : "partial", + target: binding.target, + consumer: binding.consumer, + source: { commit, pullRequest, github: githubEvidence }, + timing: { + mergedAt, + pipelineStart: startTime, + pipelineCompletion: completionTime, + triggerWaitSeconds: durationBetween(mergedAt, startTime), + pipelineDurationSeconds: row.durationSeconds ?? durationBetween(startTime, completionTime), + endToEndSeconds: durationBetween(mergedAt, completionTime), + }, + pipeline: { id: row.id ?? latest.name ?? null, status: row.status ?? latest.status ?? null, taskRuns: row.taskRuns ?? statusSummary.taskRuns ?? null }, + runtime: { argo, health: runtime, provenance }, + evidenceGaps: [...new Set(evidenceGaps)], + }; +} diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index bb8d7bd8..da326b19 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { resolve } from "node:path"; import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; import { renderMirrorBootstrap } from "./platform-infra-gitea-render"; -import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; +import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; import { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code"; const mirror: GiteaMirrorRepository = { @@ -78,3 +78,64 @@ test("platform bootstrap help advertises the composite entry before low-level ap expect(usage[0]).toContain("pipelines-as-code bootstrap"); expect(usage).toContain("bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target --consumer --confirm"); }); + +test("PaC bootstrap projects typed stages and compact JSON", () => { + const result = { + ok: true, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "dry-run", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: true, mutation: false, repositories: [{ key: mirror.key, code: "github-upstream-available", ok: true }] }, + giteaBootstrap: { ok: true, mutation: false, mode: "dry-run", blockers: [] }, + pipelinesAsCodeApply: { ok: true, mutation: false, mode: "dry-run", remote: { ok: true, preflight: { repositoryExists: true } } }, + }; + const projection = projectPacBootstrapResult(result); + expect(projection.ok).toBe(true); + expect(JSON.stringify(projection)).not.toContain("pipelinesAsCodeApply"); + expect((projection.stages as Record[]).map((item) => item.id)).toEqual(["gitea-init", "pac-controller", "tekton-consumer", "gitops-argo"]); + const rendered = renderPacBootstrap(result).renderedText; + expect(rendered).toContain("PRECONDITIONS"); + expect(rendered).toContain("yaml-repository-exact-match"); + expect(rendered).toContain("confirm:"); +}); + +test("PaC bootstrap returns fix-yaml next for zero YAML matches", () => { + const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: no exact match")); + expect(projectPacBootstrapResult(result)).toBe(result); + expect((result.blockers as Record[])[0]?.code).toBe("yaml-repository-no-match"); + expect(result.next).toEqual({ + kind: "fix-yaml", + command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配", + }); +}); + +test("PaC bootstrap returns fix-yaml next for multiple YAML matches", () => { + const result = pacBootstrapYamlMatchFailure("NC01", "widgets", new Error("cannot resolve repository: 2 exact matches")); + expect(result.mutation).toBe(false); + expect((result.blockers as Record[])[0]?.code).toBe("yaml-repository-multiple-matches"); + expect((result.next as Record).kind).toBe("fix-yaml"); +}); + +test("PaC bootstrap distinguishes GitHub access failure from Gitea and PaC stages", () => { + const projection = projectPacBootstrapResult({ + ok: false, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "dry-run", + target: { id: "NC01" }, + consumer: { id: "widgets", node: "NC01", lane: "v01" }, + repository: { id: "widgets", namespace: "ci" }, + mirrorRepository: { key: mirror.key, upstreamRepository: mirror.upstream.repository, upstreamBranch: mirror.upstream.branch, owner: mirror.gitea.owner, repo: mirror.gitea.name }, + githubPreflight: { ok: false, mutation: false, repositories: [{ key: mirror.key, code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] }, + giteaBootstrap: { ok: false, mutation: false, mode: "skipped", githubPreflight: { ok: false, repositories: [{ code: "github-repository-not-found-or-no-access", errorTail: "repository unavailable" }] } }, + pipelinesAsCodeApply: { ok: false, mutation: false, mode: "skipped" }, + }); + expect((projection.blockers as Record[])).toEqual([{ code: "github-repository-not-found-or-no-access", stage: "github-upstream", reason: "repository unavailable" }]); + expect((projection.preconditions as Record[])[2]).toMatchObject({ ok: null, code: "gitea-bootstrap-skipped" }); + expect((projection.stages as Record[]).map((item) => item.state)).toEqual(["skipped", "skipped", "skipped", "skipped"]); + expect((projection.next as Record).kind).toBe("read-only-status"); +}); diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 29451758..86b3d309 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -1,4 +1,5 @@ import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; +import type { RenderedCliResult } from "./output"; export interface PacBootstrapRepositoryIdentity { readonly id: string; @@ -28,6 +29,181 @@ export function resolvePacBootstrapMirrorRepository( return candidates[0]; } +export function pacBootstrapYamlMatchFailure( + targetId: string, + consumerId: string, + error: unknown, +): Record { + const reason = error instanceof Error ? error.message : String(error); + const matchCount = reason.includes("no exact match") ? 0 : Number.parseInt(reason.match(/(\d+) exact matches/u)?.[1] ?? "-1", 10); + return { + ok: false, + action: "platform-infra-pipelines-as-code-bootstrap", + mutation: false, + mode: "precondition-failed", + target: { id: targetId }, + consumer: { id: consumerId }, + preconditions: [ + { id: "yaml-exact-match", ok: false, code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", matchCount, detail: reason }, + ], + stages: [], + blockers: [{ code: matchCount === 0 ? "yaml-repository-no-match" : "yaml-repository-multiple-matches", stage: "yaml-exact-match", reason }], + next: { + kind: "fix-yaml", + command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配", + }, + valuesPrinted: false, + }; +} + +export function projectPacBootstrapResult(result: Record): Record { + if (Array.isArray(result.preconditions) && Array.isArray(result.stages) && Array.isArray(result.blockers)) return result; + const target = record(result.target); + const consumer = record(result.consumer); + const repository = record(result.repository); + const mirror = record(result.mirrorRepository); + const githubPreflight = record(result.githubPreflight); + const githubRepository = records(githubPreflight.repositories)[0] ?? {}; + const gitea = record(result.giteaBootstrap); + const apply = record(result.pipelinesAsCodeApply); + const remote = record(apply.remote); + const blockers = bootstrapBlockers(githubPreflight, gitea, apply, remote); + const dryRun = result.mode === "dry-run"; + const repositoryMissing = remote.error === "gitea-repository-missing"; + const githubReady = githubPreflight.ok === true; + const giteaReady = gitea.ok === true; + const giteaSkipped = gitea.mode === "skipped"; + const applySkipped = apply.mode === "skipped"; + const applyReady = apply.ok === true || (dryRun && repositoryMissing); + const next = bootstrapNext(result, blockers); + return { + ok: blockers.length === 0 && giteaReady && applyReady, + action: result.action, + mutation: result.mutation === true, + mode: result.mode, + target: { id: target.id }, + consumer: { id: consumer.id, node: consumer.node, lane: consumer.lane }, + sourceAuthority: { + upstreamRepository: mirror.upstreamRepository, + upstreamBranch: mirror.upstreamBranch, + github: { ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, + giteaRepository: `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, + giteaKey: mirror.key, + valuesPrinted: false, + }, + preconditions: [ + { id: "yaml-exact-match", ok: true, code: "yaml-repository-exact-match", matchCount: 1 }, + { id: "github-upstream", ok: githubReady, code: stringValue(githubRepository.code, githubReady ? "github-upstream-available" : "github-upstream-preflight-failed") }, + { id: "gitea-repository", ok: giteaSkipped ? null : giteaReady, code: giteaSkipped ? "gitea-bootstrap-skipped" : giteaReady ? (dryRun ? "gitea-bootstrap-planned" : "gitea-bootstrap-ready") : "gitea-bootstrap-failed" }, + ], + stages: [ + { id: "gitea-init", ok: giteaSkipped ? null : giteaReady, state: giteaSkipped ? "skipped" : giteaReady ? (dryRun ? "planned" : "ready") : "failed", mutation: gitea.mutation === true }, + { id: "pac-controller", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "tekton-consumer", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + { id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, + ], + repository: { id: repository.id, namespace: repository.namespace }, + blockers, + next, + valuesPrinted: false, + }; +} + +export function renderPacBootstrap(result: Record): RenderedCliResult { + const projection = projectPacBootstrapResult(result); + const target = record(projection.target); + const consumer = record(projection.consumer); + const source = record(projection.sourceAuthority); + const preconditions = records(projection.preconditions); + const stages = records(projection.stages); + const blockers = records(projection.blockers); + const next = record(projection.next); + const lines = [ + "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", + ...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(projection.mode), boolText(projection.mutation), boolText(projection.ok)]]), + "", + "SOURCE AUTHORITY", + ...table(["UPSTREAM", "BRANCH", "GITEA_KEY", "GITEA_REPOSITORY"], [[stringValue(source.upstreamRepository), stringValue(source.upstreamBranch), stringValue(source.giteaKey), stringValue(source.giteaRepository)]]), + "", + "PRECONDITIONS", + ...table(["PRECONDITION", "OK", "CODE"], preconditions.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.code)])), + "", + "STAGES", + ...table(["STAGE", "OK", "STATE", "MUTATION"], stages.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.state), boolText(item.mutation)])), + ...(blockers.length === 0 ? [] : ["", "BLOCKERS", ...blockers.map((item) => ` ${stringValue(item.code)}: ${compactLine(stringValue(item.reason))}`)]), + "", + "NEXT", + ` ${stringValue(next.kind)}: ${stringValue(next.command)}`, + ]; + return { ok: projection.ok === true, command: "platform-infra pipelines-as-code bootstrap", renderedText: lines.join("\n"), contentType: "text/plain", projection }; +} + +function bootstrapBlockers(githubPreflight: Record, gitea: Record, apply: Record, remote: Record): Record[] { + const blockers: Record[] = []; + const githubRepository = records(githubPreflight.repositories)[0] ?? {}; + if (githubPreflight.ok === false) { + blockers.push({ code: stringValue(githubRepository.code, stringValue(githubPreflight.code, "github-upstream-preflight-failed")), stage: "github-upstream", reason: stringValue(githubRepository.errorTail, errorText(githubPreflight)) }); + return blockers; + } + for (const code of Array.isArray(gitea.blockers) ? gitea.blockers.map(String) : []) { + blockers.push({ code: code.includes("credential") ? "source-credential-unavailable" : "github-upstream-unavailable", stage: "github-upstream", reason: code }); + } + if (gitea.ok !== true && blockers.length === 0) blockers.push({ code: "gitea-bootstrap-failed", stage: "gitea-init", reason: errorText(gitea) }); + if (apply.ok !== true && remote.error !== "gitea-repository-missing") { + blockers.push({ code: remote.error === "gitea-credential-unavailable" ? "gitea-credential-unavailable" : "pac-apply-failed", stage: "pac-controller", reason: errorText(remote, apply) }); + } + return blockers; +} + +function bootstrapNext(result: Record, blockers: Record[]): Record { + const target = record(result.target); + const consumer = record(result.consumer); + const source = record(result.mirrorRepository); + if (blockers.length > 0) { + const yamlFailure = blockers.some((item) => String(item.code).startsWith("yaml-")); + return yamlFailure + ? { kind: "fix-yaml", command: "检查 config/platform-infra/gitea.yaml 与 config/platform-infra/pipelines-as-code.yaml 的 target、owner/name 和 read URL 精确匹配" } + : { kind: "read-only-status", command: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${target.id} --consumer ${consumer.id}` }; + } + if (result.mode === "dry-run") return { kind: "confirm", command: `bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target ${target.id} --consumer ${consumer.id} --confirm` }; + return { kind: "source-pr-merge", command: `合并 ${source.upstreamRepository}@${source.upstreamBranch} 的 GitHub PR,由自动链继续交付` }; +} + +function errorText(...values: Record[]): string { + for (const value of values) { + for (const candidate of [value.error, value.stderrTail, record(value.remote).error, record(value.remote).stderrTail]) { + if (typeof candidate === "string" && candidate.length > 0) return candidate; + } + } + return "unknown-bootstrap-failure"; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + return typeof value === "string" && value.length > 0 ? value : value === undefined || value === null ? fallback : String(value); +} + +function boolText(value: unknown): string { + return value === true ? "true" : value === false ? "false" : "-"; +} + +function compactLine(value: string): string { + return value.replace(/\s+/gu, " ").trim().slice(0, 240) || "-"; +} + +function table(headers: string[], rows: string[][]): string[] { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); + const format = (row: string[]) => row.map((cell, index) => cell.padEnd(widths[index])).join(" ").trimEnd(); + return [format(headers), format(widths.map((width) => "-".repeat(width))), ...rows.map(format)]; +} + function repositoryUrlIdentity(value: string): string { const parsed = new URL(value); const path = parsed.pathname.replace(/\/+$/u, ""); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 8946986c..97ce662f 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -28,10 +28,16 @@ import { } from "./platform-infra-pipelines-as-code-source-artifact"; import { pacAdmissionDesiredIdentity } from "./platform-infra-pac-provenance"; import { pacConsumerRbacDesiredIdentity, renderPacConsumerRbacDesiredFragment } from "./platform-infra-pac-consumer-rbac"; -import { runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; +import { runGiteaMirrorBootstrapPreflight, runPlatformInfraGiteaCommand } from "./platform-infra-gitea"; import { readGiteaConfig } from "./platform-infra-gitea-config"; -import { resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; +import { + pacBootstrapYamlMatchFailure, + projectPacBootstrapResult, + renderPacBootstrap, + resolvePacBootstrapMirrorRepository, +} from "./platform-infra-pipelines-as-code-bootstrap"; import { featureConfigSchemaHistoryFields, renderFeatureConfigSchemaLine } from "./platform-infra-pac-feature-config-projection"; +import { observePacDeliveryTiming } from "./platform-infra-pac-delivery-timing"; const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml"); const configLabel = "config/platform-infra/pipelines-as-code.yaml"; @@ -256,7 +262,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf if (action === "bootstrap") { const options = parseApplyOptions(args.slice(1)); const result = await bootstrap(config, options); - return options.json || options.full || options.raw ? result : renderBootstrap(result); + return options.full || options.raw ? result : options.json ? projectPacBootstrapResult(result) : renderPacBootstrap(result); } if (action === "status") { const options = parseCommonOptions(args.slice(1)); @@ -273,6 +279,11 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf const result = await history(config, options); return options.raw ? result : options.full ? compactHistoryJson(result, true) : options.json ? compactHistoryJson(result) : renderHistory(result); } + if (action === "delivery-timing" || action === "timing") { + const options = parseCommonOptions(args.slice(1)); + const result = await deliveryTiming(config, options); + return options.full || options.raw || options.json ? result : renderDeliveryTiming(result); + } if (action === "debug-step") { const options = parseHistoryOptions(args.slice(1)); const result = await debugStep(config, options); @@ -395,12 +406,13 @@ function help(scope: string | null): Record { }; } return { - command: "platform-infra pipelines-as-code plan|status|history|debug-step|source-artifact", + command: "platform-infra pipelines-as-code plan|status|history|delivery-timing|debug-step|source-artifact", configTruth: configLabel, usage: [ "bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01", "bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]", + "bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 [--json]", "bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id ", "bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer ] [--id ] [--json]", "bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree", @@ -975,11 +987,20 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`); } const repository = resolveRepository(pac, consumer.repositoryRef); - const mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository); + let mirror: ReturnType; + try { + mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, repository); + } catch (error) { + return pacBootstrapYamlMatchFailure(target.id, consumer.id, error); + } + const githubPreflight = record(await runGiteaMirrorBootstrapPreflight(config, target.id, mirror.key)); + if (githubPreflight.ok !== true) { + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false }); + } const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"]; const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs)); if (options.confirm && giteaBootstrap.ok !== true) { - return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, { + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, { ok: false, mutation: false, mode: "skipped", @@ -988,7 +1009,7 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< }); } const pacApply = await apply(config, options); - return bootstrapResult(target, consumer, repository, mirror, options, giteaBootstrap, pacApply); + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply); } function bootstrapResult( @@ -997,6 +1018,7 @@ function bootstrapResult( repository: PacRepository, mirror: ReturnType, options: ApplyOptions, + githubPreflight: Record, giteaBootstrap: Record, pacApply: Record, ): Record { @@ -1021,6 +1043,7 @@ function bootstrapResult( repo: mirror.gitea.name, valuesPrinted: false, }, + githubPreflight, steps: [ { id: "gitea-repository", ok: giteaBootstrap.ok === true, mutation: giteaBootstrap.mutation === true, mode: giteaBootstrap.mode, valuesPrinted: false }, { id: "pac-tekton-gitops", ok: pacReady, mutation: pacApply.mutation === true, mode: pacApply.mode, repositoryMissing, valuesPrinted: false }, @@ -2208,43 +2231,6 @@ function renderApply(result: Record): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code apply", lines); } -function renderBootstrap(result: Record): RenderedCliResult { - const target = record(result.target); - const consumer = record(result.consumer); - const mirror = record(result.mirrorRepository); - const steps = arrayRecords(result.steps); - const pacApply = record(result.pipelinesAsCodeApply); - const pacRemote = record(pacApply.remote); - const giteaBootstrap = record(result.giteaBootstrap); - const next = record(result.next); - const errorValue = [pacRemote.error, giteaBootstrap.error, pacApply.error] - .find((value): value is string => typeof value === "string" && value.length > 0) ?? ""; - const error = errorValue.length === 0 ? "" : compactLine(errorValue); - const lines = [ - "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", - ...table(["TARGET", "CONSUMER", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(consumer.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]), - "", - "SOURCE AUTHORITY", - ...table(["GITEA_KEY", "GITEA_REPOSITORY", "UPSTREAM", "BRANCH"], [[stringValue(mirror.key), `${stringValue(mirror.owner)}/${stringValue(mirror.repo)}`, stringValue(mirror.upstreamRepository), stringValue(mirror.upstreamBranch)]]), - "", - "STEPS", - ...table(["STEP", "OK", "MODE", "MUTATION", "DETAIL"], steps.map((step) => [ - stringValue(step.id), - boolText(step.ok), - stringValue(step.mode), - boolText(step.mutation), - step.repositoryMissing === true ? "repository-will-be-created-on-confirm" : "ready", - ])), - ...(error.length === 0 ? [] : ["", `ERROR ${error}`]), - "", - "NEXT", - ...(result.mode === "dry-run" - ? [` confirm: ${stringValue(next.confirm)}`, ` boundary: ${stringValue(next.boundary)}`] - : [` delivery: ${stringValue(next.deliveryTrigger)}`, ` investigate: ${stringValue(next.investigate)}`]), - ]; - return rendered(result, "platform-infra pipelines-as-code bootstrap", lines); -} - export function renderStatus(result: Record): RenderedCliResult { const summary = record(result.summary); const consumer = record(result.consumer); @@ -2423,6 +2409,49 @@ function renderCloseout(result: Record): RenderedCliResult { return rendered(result, "platform-infra pipelines-as-code closeout", lines); } +async function deliveryTiming(config: UniDeskConfig, options: CommonOptions): Promise> { + const pac = readPacConfig(); + const target = resolveTarget(pac, options.targetId); + const consumer = resolveConsumer(pac, options.consumerId); + if (consumer.node.toLowerCase() !== target.id.toLowerCase()) throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`); + const authority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane }); + if (authority.kind !== "pac-pr-merge") throw new Error(`delivery authority is ${authority.kind}: ${authority.reason}`); + const historyOptions: HistoryOptions = { ...options, limit: 1, detailId: null }; + return await observePacDeliveryTiming({ + target: targetSummary(target), + consumer: { + id: consumer.id, + node: consumer.node, + lane: consumer.lane, + repository: authority.consumer.sourceRepository, + branch: authority.consumer.sourceBranch, + pipeline: consumer.pipeline, + }, + }, { + history: async () => await history(config, historyOptions), + status: async () => await status(config, options), + }); +} + +function renderDeliveryTiming(result: Record): RenderedCliResult { + const timing = record(result.timing); + const source = record(result.source); + const pr = record(source.pullRequest); + const runtime = record(result.runtime); + const argo = record(runtime.argo); + const health = record(runtime.health); + const lines = [ + "PLATFORM-INFRA DELIVERY TIMING", + `STATE: ${stringValue(result.state)} MUTATION: ${boolText(result.mutation)}`, + `SOURCE: ${stringValue(record(result.consumer).repository)}@${stringValue(source.commit)} PR#${stringValue(pr.number)}`, + `MERGED_AT: ${stringValue(timing.mergedAt)} PIPELINE_START: ${stringValue(timing.pipelineStart)} PIPELINE_DONE: ${stringValue(timing.pipelineCompletion)}`, + `TRIGGER_WAIT_S: ${stringValue(timing.triggerWaitSeconds)} PIPELINE_S: ${stringValue(timing.pipelineDurationSeconds)} END_TO_END_S: ${stringValue(timing.endToEndSeconds)}`, + `ARGO: ${stringValue(argo.sync)}/${stringValue(argo.health)} RUNTIME_READY: ${stringValue(health.readyReplicas)}/${stringValue(health.replicas)}`, + `EVIDENCE_GAPS: ${(Array.isArray(result.evidenceGaps) ? result.evidenceGaps.map(String) : []).join(", ") || "-"}`, + ]; + return rendered(result, "platform-infra pipelines-as-code delivery-timing", lines); +} + export function renderHistory(result: Record): RenderedCliResult { const rows = arrayRecords(result.rows); const config = record(result.config); diff --git a/scripts/src/platform-infra-sub2api-codex/faults.ts b/scripts/src/platform-infra-sub2api-codex/faults.ts new file mode 100644 index 00000000..57e72890 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/faults.ts @@ -0,0 +1,620 @@ +import { createHash } from "node:crypto"; + +import type { UniDeskConfig } from "../config"; +import { CliInputError, type RenderedCliResult } from "../output"; + +import { boolField, compactCapture, parseJsonOutput, runRemoteCodexPoolScript } from "./remote"; +import { codexPoolRuntimeTarget, defaultCodexPoolRuntimeTargetId } from "./runtime-target"; + +type FaultLevel = "P0" | "P1" | "P2"; + +interface FaultOptions { + level: FaultLevel | null; + group: string | null; + account: string | null; + model: string | null; + stream: "sync" | "stream" | null; + endpoint: string | null; + requestId: string | null; + pageToken: string | null; + targetId: string; + json: boolean; +} + +const PAGE_SIZE = 20; + +export async function codexPoolFaults(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + if (args.includes("--help")) return renderFaultHelp(); + const options = parseFaultOptions(args); + const target = codexPoolRuntimeTarget(options.targetId); + const scope = faultScope(options); + const offset = decodePageToken(options.pageToken, scope); + const payload = { + filters: { + level: options.level, + group: options.group, + account: options.account, + model: options.model, + stream: options.stream, + endpoint: options.endpoint, + requestId: options.requestId, + }, + offset, + pageSize: PAGE_SIZE, + }; + const result = await runRemoteCodexPoolScript(config, "faults", faultScript(payload, target), target); + const parsed = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && boolField(parsed, "ok", false); + const data = parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record : null; + const pagination = data && typeof data.pagination === "object" && data.pagination !== null + ? data.pagination as Record + : null; + if (pagination?.hasMore === true) pagination.nextPageToken = encodePageToken(offset + PAGE_SIZE, scope); + if (pagination) { + pagination.pageSize = PAGE_SIZE; + pagination.pageToken = options.pageToken; + } + const response = { + ok, + action: "platform-infra-sub2api-codex-pool-faults", + target: { + id: target.id, + route: target.route, + runtimeMode: target.runtimeMode, + endpoint: target.serviceDns, + }, + source: { + kind: "sub2api-native-admin-ops", + versionBoundary: "Sub2API native admin Ops facts with explicit, version-neutral UniDesk CLI projection", + mutation: false, + }, + filters: payload.filters, + faults: data, + remote: compactCapture(result, { full: result.exitCode !== 0 || data === null }), + valuesPrinted: false, + }; + if (options.json) return response; + return renderFaults(response); +} + +function renderFaultHelp(): RenderedCliResult { + return { + ok: true, + command: "platform-infra sub2api codex-pool faults --help", + renderedText: [ + "SUB2API CODEX-POOL FAULTS", + "Usage:", + " bun scripts/cli.ts platform-infra sub2api codex-pool faults [options]", + "Options:", + " --level P0|P1|P2", + " --group ", + " --account ", + " --model ", + " --stream sync|stream", + " --endpoint ", + " --request-id ", + " --page-token ", + " --target ", + " --json", + "Notes:", + ` Fixed page size: ${PAGE_SIZE}; --limit is intentionally unsupported.`, + " Default output is a bounded Kubernetes-style table; JSON requires --json.", + ].join("\n"), + contentType: "text/plain", + }; +} + +function parseFaultOptions(args: string[]): FaultOptions { + let level: FaultLevel | null = null; + let group: string | null = null; + let account: string | null = null; + let model: string | null = null; + let stream: "sync" | "stream" | null = null; + let endpoint: string | null = null; + let requestId: string | null = null; + let pageToken: string | null = null; + let targetId = defaultCodexPoolRuntimeTargetId(); + let json = false; + const readValue = (index: number, name: string): [string, number] => { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + return [validateSelector(value, name), index + 1]; + }; + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--json") json = true; + else if (arg === "--level") { + const [value, next] = readValue(index, "--level"); + level = parseLevel(value); + index = next; + } else if (arg.startsWith("--level=")) level = parseLevel(arg.slice(8)); + else if (arg === "--group") [group, index] = readValue(index, "--group"); + else if (arg.startsWith("--group=")) group = validateSelector(arg.slice(8), "--group"); + else if (arg === "--account") [account, index] = readValue(index, "--account"); + else if (arg.startsWith("--account=")) account = validateSelector(arg.slice(10), "--account"); + else if (arg === "--model") [model, index] = readValue(index, "--model"); + else if (arg.startsWith("--model=")) model = validateSelector(arg.slice(8), "--model"); + else if (arg === "--stream") { + const [value, next] = readValue(index, "--stream"); + stream = parseStream(value); + index = next; + } else if (arg.startsWith("--stream=")) stream = parseStream(arg.slice(9)); + else if (arg === "--endpoint") [endpoint, index] = readValue(index, "--endpoint"); + else if (arg.startsWith("--endpoint=")) endpoint = validateSelector(arg.slice(11), "--endpoint"); + else if (arg === "--request-id") [requestId, index] = readValue(index, "--request-id"); + else if (arg.startsWith("--request-id=")) requestId = validateSelector(arg.slice(13), "--request-id"); + else if (arg === "--page-token") [pageToken, index] = readValue(index, "--page-token"); + else if (arg.startsWith("--page-token=")) pageToken = validateSelector(arg.slice(13), "--page-token"); + else if (arg === "--target") [targetId, index] = readValue(index, "--target"); + else if (arg.startsWith("--target=")) targetId = validateSelector(arg.slice(9), "--target"); + else if (arg === "--limit" || arg.startsWith("--limit=")) throw new Error("faults uses a fixed page size; use --page-token for progressive disclosure"); + else throw new Error(`unsupported faults option: ${arg}`); + } + if (level !== null && group === null) { + throw new CliInputError("--level requires --group; use the default command for the all-groups overview", { + code: "missing-required-option", + argument: "--group", + usage: [ + "bun scripts/cli.ts platform-infra sub2api codex-pool faults --target PK01", + "bun scripts/cli.ts platform-infra sub2api codex-pool faults --target PK01 --level P0 --group ", + ], + hint: "Run without --level for the all-groups overview, then drill down with --level and --group.", + }); + } + return { level, group, account, model, stream, endpoint, requestId, pageToken, targetId, json }; +} + +function parseLevel(value: string): FaultLevel { + const normalized = value.toUpperCase(); + if (normalized !== "P0" && normalized !== "P1" && normalized !== "P2") throw new Error("--level must be P0, P1, or P2"); + return normalized; +} + +function parseStream(value: string): "sync" | "stream" { + if (value !== "sync" && value !== "stream") throw new Error("--stream must be sync or stream"); + return value; +} + +function validateSelector(value: string, name: string): string { + const normalized = value.trim(); + if (!normalized || normalized.length > 512 || /[\r\n\0]/u.test(normalized)) throw new Error(`${name} has an unsupported format`); + return normalized; +} + +function faultScope(options: FaultOptions): string { + return createHash("sha256").update(JSON.stringify({ + level: options.level, + group: options.group, + account: options.account, + model: options.model, + stream: options.stream, + endpoint: options.endpoint, + requestId: options.requestId, + targetId: options.targetId, + })).digest("hex").slice(0, 16); +} + +function encodePageToken(offset: number, scope: string): string { + return Buffer.from(JSON.stringify({ v: 1, offset, scope }), "utf8").toString("base64url"); +} + +function decodePageToken(token: string | null, scope: string): number { + if (token === null) return 0; + try { + const parsed = JSON.parse(Buffer.from(token, "base64url").toString("utf8")) as Record; + if (parsed.v !== 1 || parsed.scope !== scope || !Number.isInteger(parsed.offset) || Number(parsed.offset) < 0) throw new Error("invalid"); + return Number(parsed.offset); + } catch { + throw new Error("--page-token is invalid or belongs to different filters"); + } +} + +function renderFaults(response: Record): RenderedCliResult { + const faults = response.faults && typeof response.faults === "object" ? response.faults as Record : {}; + const lines = ["PLATFORM-INFRA SUB2API FAULTS"]; + const window = record(faults.window); + lines.push(`WINDOW ${text(window.timeRange)} SOURCE native-admin-ops PROJECTION unidesk-cli`); + lines.push(""); + const summary = arrayOfRecords(faults.summary); + if (summary.length > 0) { + lines.push("GROUPS"); + lines.push(table(["GROUP", "P0", "CUSTOMER_ERRS", "GROUP_ERR%", "P1", "TTFT_P99", "P2", "UPSTREAM_ERRS", "GROUP_UP_ERR%", "ABSORB%"], summary.map((row) => [ + `${text(row.groupName)} (${text(row.groupId)})`, + text(row.p0), text(row.customerErrorCount), percent(row.customerErrorRatePercent), + text(row.p1), milliseconds(row.ttftP99Ms), + text(row.p2), text(row.upstreamErrorCount), percent(row.upstreamErrorRatePercent), percent(row.absorbedPercent), + ]))); + } + const details = arrayOfRecords(faults.details); + if (details.length > 0) { + lines.push("", `DETAILS ${text(faults.level ?? "SUMMARY")}`); + const columns = Array.isArray(faults.detailColumns) ? faults.detailColumns.map(String) : []; + lines.push(table(columns, details.map((row) => columns.map((column) => text(row[column]))))); + } + const boundary = record(faults.boundary); + lines.push("", "BOUNDARY"); + for (const value of Array.isArray(boundary.notes) ? boundary.notes : []) lines.push(`- ${String(value)}`); + const pagination = record(faults.pagination); + if (pagination.nextPageToken) lines.push("", `NEXT_PAGE_TOKEN ${text(pagination.nextPageToken)}`); + if (faults.next) lines.push("", "NEXT", ...String(faults.next).split("\n").map((line) => ` ${line}`)); + return { + ok: response.ok === true, + command: "platform-infra sub2api codex-pool faults", + renderedText: lines.join("\n"), + contentType: "text/plain", + projection: response, + }; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function arrayOfRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; +} + +function text(value: unknown): string { + if (value === null || value === undefined || value === "") return "-"; + if (typeof value === "boolean") return value ? "yes" : "no"; + return String(value).replace(/[\r\n\t]+/gu, " "); +} + +function percent(value: unknown): string { + return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)}%` : text(value); +} + +function milliseconds(value: unknown): string { + return typeof value === "number" && Number.isFinite(value) ? `${Math.round(value)}ms` : text(value); +} + +function table(headers: string[], rows: string[][]): string { + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => (row[index] ?? "").length))); + return [headers, ...rows].map((row) => row.map((cell, index) => cell.padEnd(widths[index]!)).join(" ").trimEnd()).join("\n"); +} + +function pyJson(value: unknown): string { + return `json.loads(${JSON.stringify(JSON.stringify(value))})`; +} + +function faultScript(payload: unknown, target: ReturnType): string { + return ` +set -u +python3 - <<'PY' +import base64 +import json +import math +import subprocess +import sys +from urllib.parse import urlencode + +RUNTIME_MODE = ${pyJson(target.runtimeMode)} +NAMESPACE = ${pyJson(target.namespace)} +HOST_DOCKER_APP_PORT = ${pyJson(target.hostDockerAppPort)} +APP_SECRET_NAME = ${pyJson(target.appSecretName)} +PAYLOAD = ${pyJson(payload)} +APP_POD = None + +def run(cmd, input_bytes=None): + return subprocess.run(cmd, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + +def docker(args): + proc = run(["docker", *args]) + if proc.returncode == 0: + return proc + sudo_proc = run(["sudo", "-n", "docker", *args]) + return sudo_proc if sudo_proc.returncode == 0 else proc + +def kubectl(args, input_bytes=None): + return run(["kubectl", *args], input_bytes) + +def kube_json(args, label): + proc = kubectl([*args, "-o", "json"]) + if proc.returncode != 0: + raise RuntimeError(label + " failed") + return json.loads(proc.stdout.decode("utf-8")) + +if RUNTIME_MODE != "host-docker": + pods = kube_json(["-n", NAMESPACE, "get", "pods", "-l", "app=sub2api"], "list pods").get("items") or [] + ready = [item for item in pods if item.get("status", {}).get("phase") == "Running"] + if not ready: + raise RuntimeError("sub2api app pod not found") + APP_POD = ready[0]["metadata"]["name"] + +def config_value(name, key, default=None): + if RUNTIME_MODE == "host-docker": + proc = docker(["inspect", "sub2api-app", "--format", "{{json .Config.Env}}"]) + if proc.returncode != 0: + return default + for item in json.loads(proc.stdout.decode("utf-8")): + if item.startswith(key + "="): + return item.split("=", 1)[1] + return default + data = kube_json(["-n", NAMESPACE, "get", "configmap", name], "configmap/" + name).get("data") or {} + return data.get(key, default) + +def secret_value(name, key): + if RUNTIME_MODE == "host-docker": + return config_value("", key) + data = kube_json(["-n", NAMESPACE, "get", "secret", name], "secret/" + name).get("data") or {} + return base64.b64decode(data[key]).decode("utf-8") if key in data else None + +def curl_api(method, path, bearer=None, payload=None): + body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8") + script = r'''set -eu +method="$1"; url="$2"; token="\${3:-}"; tmp="$(mktemp)"; trap 'rm -f "$tmp"' EXIT; cat > "$tmp" +args=""; [ -n "$token" ] && args="Authorization: Bearer $token" +if [ -n "$args" ] && [ -s "$tmp" ]; then curl -sS -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H "$args" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url" +elif [ -n "$args" ]; then curl -sS -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H "$args" "$url" +elif [ -s "$tmp" ]; then curl -sS -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url" +else curl -sS -w '\n__HTTP_CODE__:%{http_code}' -X "$method" "$url"; fi''' + base = f"http://127.0.0.1:{HOST_DOCKER_APP_PORT}" if RUNTIME_MODE == "host-docker" else "http://127.0.0.1:8080" + cmd = ["sh", "-c", script, "sh", method, base + path, bearer or ""] + proc = run(cmd, body) if RUNTIME_MODE == "host-docker" else run(["kubectl", "-n", NAMESPACE, "exec", "-i", APP_POD, "--", *cmd], body) + output = proc.stdout.decode("utf-8", errors="replace") + marker = "\\n__HTTP_CODE__:" + pos = output.rfind(marker) + status = int(output[pos + len(marker):].strip()[-3:]) if pos >= 0 else 0 + raw = output[:pos] if pos >= 0 else output + try: + parsed = json.loads(raw) if raw.strip() else None + except json.JSONDecodeError: + parsed = None + return {"ok": proc.returncode == 0 and 200 <= status < 300, "status": status, "json": parsed, "body": raw[:300]} + +def data_of(response, label): + parsed = response.get("json") + code = parsed.get("code") if isinstance(parsed, dict) else None + if response.get("ok") is not True or (code is not None and code != 0): + message = parsed.get("message") if isinstance(parsed, dict) else response.get("body") + raise RuntimeError(f"{label} failed: http={response.get('status')} message={message}") + return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed + +def login(): + email = config_value("sub2api-config", "ADMIN_EMAIL", "admin@example.com") + password = secret_value(APP_SECRET_NAME, "ADMIN_PASSWORD") + if not password: + raise RuntimeError("ADMIN_PASSWORD missing") + data = data_of(curl_api("POST", "/api/v1/auth/login", payload={"email": email, "password": password}), "admin login") + token = data.get("access_token") or data.get("token") if isinstance(data, dict) else None + if not token: + raise RuntimeError("admin login response has no token") + return token + +def get(token, path, params=None, label=None): + suffix = "?" + urlencode({key: value for key, value in (params or {}).items() if value is not None}) if params else "" + return data_of(curl_api("GET", path + suffix, bearer=token), label or path) + +def items_of(data): + if isinstance(data, list): + return data + if isinstance(data, dict): + for key in ("items", "groups", "accounts"): + if isinstance(data.get(key), list): + return data[key] + return [] + +def total_of(data): + return int(data.get("total", len(items_of(data)))) if isinstance(data, dict) else len(items_of(data)) + +def all_items(token, path, params, label): + page = 1 + page_size = 500 + rows = [] + while True: + data = get(token, path, {**params, "page": page, "page_size": page_size}, label) + batch = items_of(data) + rows.extend(batch) + total = total_of(data) + if not batch or len(rows) >= total: + return rows + page += 1 + +def percent(value): + if not isinstance(value, (int, float)): + return None + return round(value * 100 if value <= 1 else value, 4) + +def selector_id(value): + return int(value) if isinstance(value, str) and value.isdigit() else None + +def scope_filters(group_id=None): + result = {"time_range": "24h", "platform": "openai", "group_id": group_id} + return result + +def matches_detail_filters(item): + filters = PAYLOAD["filters"] + account = filters.get("account") + if account: + account_match = str(item.get("account_id") or "") == str(account) or str(item.get("account_name") or "") == str(account) + if not account_match: + return False + if filters.get("model") and str(item.get("requested_model") or item.get("model") or "") != str(filters["model"]): + return False + if filters.get("stream"): + expected_stream = filters["stream"] == "stream" + if item.get("stream") is not expected_stream: + return False + if filters.get("endpoint") and str(item.get("inbound_endpoint") or item.get("request_path") or item.get("path") or "") != str(filters["endpoint"]): + return False + if filters.get("requestId"): + request_id = filters["requestId"] + if item.get("request_id") != request_id and item.get("client_request_id") != request_id: + return False + return True + +def has_detail_filters(): + filters = PAYLOAD["filters"] + return any(filters.get(key) for key in ("account", "model", "stream", "endpoint", "requestId")) + +DETAIL_ROWS = {} + +def error_rows(token, path, group_id, apply_filters): + cache_key = (path, group_id, apply_filters) + if cache_key not in DETAIL_ROWS: + rows = all_items(token, path, {**scope_filters(group_id), "view": "all", "include_detail": 1}, "fault detail scan") + DETAIL_ROWS[cache_key] = [item for item in rows if matches_detail_filters(item)] if apply_filters else rows + return DETAIL_ROWS[cache_key] + +def selected_groups(groups): + selector = PAYLOAD["filters"].get("group") + if not selector: + return groups + selected = [item for item in groups if str(item.get("id")) == str(selector) or item.get("name") == selector] + if len(selected) != 1: + raise RuntimeError("group-not-found-or-ambiguous: " + str(selector)) + return selected + +def threshold_state(value, threshold): + if not isinstance(value, (int, float)) or not isinstance(threshold, (int, float)): + return "unavailable" + return "breach" if value >= threshold else "ok" + +def availability_by_account(token, group_id): + data = get(token, "/api/v1/admin/ops/account-availability", {"platform": "openai", "group_id": group_id}, "account availability") + raw = data.get("account") if isinstance(data, dict) else None + return raw if isinstance(raw, dict) else {} + +def detail_row(item, group_metrics, availability, level, absorbed=None): + account_id = item.get("account_id") + state = availability.get(str(account_id), {}) if isinstance(availability, dict) else {} + endpoint = item.get("inbound_endpoint") or item.get("request_path") or item.get("path") + base = { + "GROUP": f"{item.get('group_name') or group_metrics.get('groupName')} ({item.get('group_id') or group_metrics.get('groupId')})", + "ACCOUNT": f"{item.get('account_name') or '-'} ({account_id or '-'})", + "MODEL": item.get("requested_model") or item.get("model") or "-", + "MODE": "stream" if item.get("stream") is True else "sync" if item.get("stream") is False else "-", + "ENDPOINT": endpoint or "-", + "STATUS": item.get("status_code") or "-", + "REQUEST_ID": item.get("request_id") or item.get("client_request_id") or "-", + } + if level == "P0": + base.update({"CUSTOMER_ERR%": group_metrics.get("customerErrorRatePercent"), "ABSORBED": False}) + else: + unavailable_reason = str(state.get("unavailable_reason") or state.get("reason") or "").lower() + if state.get("is_available") is False and ("temporary" in unavailable_reason or "cooldown" in unavailable_reason): + temp_unsched = "active" + elif state.get("is_available") is True: + temp_unsched = "none" + else: + temp_unsched = "unavailable" + base.update({"ROOT": item.get("error_source") or item.get("message") or "upstream", "TEMP_UNSCHED": temp_unsched, "ABSORBED": absorbed}) + return base + +def execute(): + token = login() + thresholds = get(token, "/api/v1/admin/ops/settings/metric-thresholds", label="metric thresholds") + groups = selected_groups(items_of(get(token, "/api/v1/admin/groups/all", {"platform": "openai"}, "list groups"))) + summaries = [] + group_data = [] + for group in groups: + group_id = group.get("id") + native = get(token, "/api/v1/admin/ops/dashboard/overview", {"time_range": "24h", "platform": "openai", "group_id": group_id}, "dashboard overview") + if has_detail_filters(): + customer_total = len(error_rows(token, "/api/v1/admin/ops/request-errors", group_id, True)) + upstream_total = len(error_rows(token, "/api/v1/admin/ops/upstream-errors", group_id, True)) + else: + request_errors = get(token, "/api/v1/admin/ops/request-errors", {**scope_filters(group_id), "page": 1, "page_size": 1, "view": "all"}, "request errors") + upstream_errors = get(token, "/api/v1/admin/ops/upstream-errors", {**scope_filters(group_id), "page": 1, "page_size": 1, "view": "all"}, "upstream errors") + customer_total = total_of(request_errors) + upstream_total = total_of(upstream_errors) + absorbed = max(0, upstream_total - customer_total) + absorbed_percent = round(absorbed * 100 / upstream_total, 2) if upstream_total else None + customer_rate = percent(native.get("error_rate") if isinstance(native, dict) else None) + upstream_rate = percent(native.get("upstream_error_rate") if isinstance(native, dict) else None) + ttft = native.get("ttft") if isinstance(native, dict) and isinstance(native.get("ttft"), dict) else {} + metrics = { + "groupId": group_id, + "groupName": group.get("name"), + "customerErrorCount": customer_total, + "customerErrorRatePercent": customer_rate, + "upstreamErrorCount": upstream_total, + "upstreamErrorRatePercent": upstream_rate, + "absorbedCountProjection": absorbed, + "absorbedPercent": absorbed_percent, + "ttftP99Ms": ttft.get("p99_ms"), + } + metrics["p0"] = "active" if customer_total > 0 else "clear" + metrics["p1"] = threshold_state(metrics["ttftP99Ms"], thresholds.get("ttft_p99_ms_max")) + metrics["p2"] = threshold_state(upstream_rate, thresholds.get("upstream_error_rate_percent_max")) + summaries.append(metrics) + group_data.append((group, metrics)) + level = PAYLOAD["filters"].get("level") + offset = int(PAYLOAD.get("offset") or 0) + page = offset // int(PAYLOAD["pageSize"]) + 1 + details = [] + detail_columns = [] + total = 0 + if level in ("P0", "P2"): + for group, metrics in group_data: + group_id = group.get("id") + endpoint = "/api/v1/admin/ops/request-errors" if level == "P0" else "/api/v1/admin/ops/upstream-errors" + if has_detail_filters(): + rows = error_rows(token, endpoint, group_id, True) + total += len(rows) + rows = rows[offset:offset + PAYLOAD["pageSize"]] + else: + data = get(token, endpoint, {**scope_filters(group_id), "page": page, "page_size": PAYLOAD["pageSize"], "view": "all", "include_detail": 1}, level + " details") + total += total_of(data) + rows = items_of(data) + availability = availability_by_account(token, group_id) if level == "P2" else {} + customer_request_ids = set() + if level == "P2": + customer_request_ids = { + item.get("request_id") or item.get("client_request_id") + for item in error_rows(token, "/api/v1/admin/ops/request-errors", group_id, False) + if item.get("request_id") or item.get("client_request_id") + } + for item in rows: + absorbed = None + if level == "P2" and item.get("request_id"): + absorbed = item.get("request_id") not in customer_request_ids + details.append(detail_row(item, metrics, availability, level, absorbed)) + detail_columns = ["GROUP", "ACCOUNT", "MODEL", "MODE", "ENDPOINT", "STATUS", "CUSTOMER_ERR%", "ABSORBED", "REQUEST_ID"] if level == "P0" else ["GROUP", "ACCOUNT", "MODEL", "MODE", "ENDPOINT", "STATUS", "ROOT", "TEMP_UNSCHED", "ABSORBED", "REQUEST_ID"] + elif level == "P1": + total = len(group_data) * 2 + endpoint_names = ["/v1/responses", "/v1/responses/compact"] + for group, metrics in group_data: + for endpoint in endpoint_names: + if PAYLOAD["filters"].get("endpoint") and PAYLOAD["filters"].get("endpoint") != endpoint: + continue + details.append({ + "GROUP": f"{metrics.get('groupName')} ({metrics.get('groupId')})", + "ENDPOINT": endpoint, + "SAMPLES": "unavailable", + "P50": "unavailable", + "P95": "unavailable", + "P99": "unavailable", + "MAX": "unavailable", + "OUTLIER_REQUEST_ID": "unavailable", + "BOUNDARY": "native endpoint/request TTFT unavailable; total duration not substituted", + }) + detail_columns = ["GROUP", "ENDPOINT", "SAMPLES", "P50", "P95", "P99", "MAX", "OUTLIER_REQUEST_ID", "BOUNDARY"] + return { + "ok": True, + "level": level, + "window": {"timeRange": "24h"}, + "thresholds": thresholds, + "summary": summaries, + "details": details[:PAYLOAD["pageSize"]], + "detailColumns": detail_columns, + "pagination": {"offset": offset, "total": total, "hasMore": offset + PAYLOAD["pageSize"] < total}, + "boundary": {"notes": [ + "P0/P2 facts come from native admin ops request-errors, upstream-errors, overview, and account-availability.", + "P0/P1/P2 labels and failover/retry absorption are UniDesk CLI projections; Sub2API native severity is not rewritten.", + "P1 endpoint-level samples and request-level TTFT are unavailable in the observed native Ops response; total duration is never used as TTFT.", + "Absorption summary is a bounded count projection from native upstream/customer totals; P2 detail absorption is correlated by stable request ID.", + ]}, + "next": "Use --level P0|P1|P2 for details; reuse filters with --page-token for the next fixed page; use codex-pool trace --request-id for trace disclosure.", + "valuesPrinted": False, + } + +try: + output = execute() +except Exception as exc: + output = {"ok": False, "error": str(exc), "valuesPrinted": False} +print(json.dumps(output, ensure_ascii=False, indent=2)) +sys.exit(0 if output.get("ok") else 1) +PY +`; +} diff --git a/scripts/src/platform-infra-sub2api-codex/feedback.ts b/scripts/src/platform-infra-sub2api-codex/feedback.ts new file mode 100644 index 00000000..57ddea77 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-codex/feedback.ts @@ -0,0 +1,593 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; + +import { readCodexPoolConfig } from "./config"; +import { capture, compactCapture, parseJsonOutput } from "./remote"; +import { renderedCliResult, renderTable, shorten, shortIso, textValue } from "./render"; +import { codexPoolRuntimeTarget } from "./runtime-target"; +import type { CodexPoolConfig, CodexPoolRuntimeTarget, FeedbackOptions } from "./types"; + +function pyJson(value: unknown): string { + return `json.loads(${JSON.stringify(JSON.stringify(value))})`; +} + +export async function codexPoolFeedback(config: UniDeskConfig, options: FeedbackOptions): Promise | RenderedCliResult> { + const pool = readCodexPoolConfig(); + const target = codexPoolRuntimeTarget(options.targetId); + const result = await capture(config, target.route, ["sh"], feedbackScript(pool, options, target)); + const report = parseJsonOutput(result.stdout); + const ok = result.exitCode === 0 && report?.ok === true; + const remote = compactCapture(result, { full: result.exitCode !== 0 || report === null }); + if (options.json) { + return { + ok, + action: "platform-infra-sub2api-codex-pool-feedback", + target: { id: target.id, route: target.route, runtimeMode: target.runtimeMode }, + report, + remote, + valuesPrinted: false, + }; + } + return renderedCliResult(ok, "platform-infra sub2api codex-pool feedback", renderFeedbackReport(report, options, remote)); +} + +function feedbackScript(pool: CodexPoolConfig, options: FeedbackOptions, target: CodexPoolRuntimeTarget): string { + return ` +set -u +python3 - <<'PY' +import base64 +import json +import re +import subprocess +from datetime import datetime, timezone, timedelta +from urllib.parse import quote, urlencode + +RUNTIME_MODE = ${pyJson(target.runtimeMode)} +NAMESPACE = ${pyJson(target.namespace)} +APP_SECRET_NAME = ${pyJson(target.appSecretName)} +ADMIN_EMAIL_DEFAULT = ${pyJson(pool.adminEmailDefault)} +HOST_DOCKER_APP_PORT = ${pyJson(target.hostDockerAppPort)} +HOST_DOCKER_ENV_PATH = ${pyJson(target.hostDockerEnvPath)} +USER_SELECTOR = ${pyJson(options.user)} +WINDOW = ${pyJson(options.window)} +REQUEST_SELECTOR = ${pyJson(options.requestId)} +PAGE_TOKEN = ${pyJson(options.pageToken)} +APP_CONTAINER = "sub2api-app" +PAGE_SIZE = 10 + +def run(command, input_bytes=None): + return subprocess.run(command, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + +def text(value, limit=1000): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + return str(value or "")[-limit:] + +def docker(args): + proc = run(["docker", *args]) + if proc.returncode == 0: + return proc + fallback = run(["sudo", "-n", "docker", *args]) + return fallback if fallback.returncode == 0 else proc + +def read_host_env(): + if RUNTIME_MODE != "host-docker": + return {} + try: + with open(HOST_DOCKER_ENV_PATH, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except PermissionError: + proc = run(["sudo", "-n", "cat", HOST_DOCKER_ENV_PATH]) + if proc.returncode != 0: + raise RuntimeError("read host-docker env failed: " + text(proc.stderr)) + lines = proc.stdout.decode("utf-8", errors="replace").splitlines() + values = {} + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or "=" not in stripped: + continue + key, value = stripped.split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + values[key.strip()] = value + return values + +def runtime_secret(key): + if RUNTIME_MODE == "host-docker": + return read_host_env().get(key) + proc = run(["kubectl", "-n", NAMESPACE, "get", "secret", APP_SECRET_NAME, "-o", "json"]) + if proc.returncode != 0: + raise RuntimeError("read app secret failed: " + text(proc.stderr)) + raw = (json.loads(proc.stdout.decode("utf-8")).get("data") or {}).get(key) + return base64.b64decode(raw).decode("utf-8") if raw else None + +def runtime_config(key): + if RUNTIME_MODE == "host-docker": + return read_host_env().get(key) + proc = run(["kubectl", "-n", NAMESPACE, "get", "configmap", "sub2api-config", "-o", "json"]) + if proc.returncode != 0: + return None + return (json.loads(proc.stdout.decode("utf-8")).get("data") or {}).get(key) + +def parse_curl(proc): + stdout = proc.stdout.decode("utf-8", errors="replace") + marker = "\\n__HTTP_CODE__:" + position = stdout.rfind(marker) + if position < 0: + return {"ok": False, "httpStatus": 0, "json": None, "message": text(proc.stderr)} + body = stdout[:position] + try: + status = int(stdout[position + len(marker):].strip()[-3:]) + except Exception: + status = 0 + try: + parsed = json.loads(body) if body.strip() else None + except Exception: + parsed = None + message = parsed.get("message") if isinstance(parsed, dict) else text(body) + return {"ok": proc.returncode == 0 and 200 <= status < 300, "httpStatus": status, "json": parsed, "message": message} + +def curl_api(method, path, bearer=None, payload=None): + body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8") + script = r'''set -eu +method="$1" +url="$2" +token="\${3:-}" +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +cat > "$tmp" +args="" +if [ -n "$token" ]; then args="Authorization: Bearer $token"; fi +if [ "$method" = GET ] && [ ! -s "$tmp" ]; then + if [ -n "$args" ]; then curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -H "$args" "$url"; else curl -sS -w '\\n__HTTP_CODE__:%{http_code}' "$url"; fi +else + if [ -n "$args" ]; then curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "$args" --data-binary @"$tmp" "$url"; else curl -sS -w '\\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url"; fi +fi''' + if RUNTIME_MODE == "host-docker": + proc = run(["sh", "-c", script, "sh", method, f"http://127.0.0.1:{HOST_DOCKER_APP_PORT}{path}", bearer or ""], body) + else: + proc = run(["kubectl", "-n", NAMESPACE, "exec", "deploy/sub2api", "--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or ""], body) + return parse_curl(proc) + +def api_data(method, path, token=None, payload=None, label=None): + response = curl_api(method, path, token, payload) + parsed = response.get("json") + code = parsed.get("code") if isinstance(parsed, dict) else None + if not response.get("ok") or (code is not None and code != 0): + raise RuntimeError(f"{label or path} failed: http={response.get('httpStatus')} message={response.get('message')}") + return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed + +def find_token(value): + if isinstance(value, dict): + for key in ("access_token", "token"): + if isinstance(value.get(key), str) and value.get(key): + return value[key] + for item in value.values(): + token = find_token(item) + if token: + return token + return None + +def login(): + email = runtime_config("ADMIN_EMAIL") or ADMIN_EMAIL_DEFAULT + password = runtime_secret("ADMIN_PASSWORD") + if not password: + raise RuntimeError("ADMIN_PASSWORD missing") + token = find_token(api_data("POST", "/api/v1/auth/login", payload={"email": email, "password": password}, label="admin login")) + if not token: + raise RuntimeError("admin login returned no token") + return email, token + +def items(data): + if isinstance(data, list): + return data + if isinstance(data, dict): + if isinstance(data.get("items"), list): + return data["items"] + for key in ("users", "api_keys", "keys", "groups", "accounts", "proxies", "logs"): + if isinstance(data.get(key), list): + return data[key] + return [] + +def page_all(token, path, query=None, page_size=100): + query = dict(query or {}) + collected = [] + page = 1 + while True: + params = {**query, "page": page, "page_size": page_size} + separator = "&" if "?" in path else "?" + data = api_data("GET", path + separator + urlencode(params), token, label=path) + batch = items(data) + collected.extend(batch) + total = data.get("total") if isinstance(data, dict) else None + if not batch or (isinstance(total, int) and len(collected) >= total) or len(batch) < page_size: + break + page += 1 + return collected + +def exact_user(token): + if USER_SELECTOR.isdigit() and int(USER_SELECTOR) > 0: + user = api_data("GET", f"/api/v1/admin/users/{int(USER_SELECTOR)}", token, label="get user") + return user if isinstance(user, dict) else None + candidates = page_all(token, "/api/v1/admin/users", {"search": USER_SELECTOR}, 100) + exact = [user for user in candidates if isinstance(user, dict) and str(user.get("email") or "").lower() == USER_SELECTOR.lower()] + if len(exact) > 1: + raise RuntimeError("email matched multiple users") + return exact[0] if exact else None + +def safe_call(errors, name, callback, fallback): + try: + return callback() + except Exception as exc: + errors.append({"source": name, "error": redact_text(str(exc))}) + return fallback + +def redact_text(value): + value = str(value or "") + value = re.sub(r"(?i)(bearer\\s+)[A-Za-z0-9._~+\\-/=]+", r"\\1", value) + value = re.sub(r"(?i)((?:api[_-]?key|token|password)\\s*[=:]\\s*)[^\\s,;]+", r"\\1", value) + value = re.sub(r"\\bsk-[A-Za-z0-9_-]{8,}\\b", "sk-", value) + return value[:500] + +def iso_epoch(value): + if not isinstance(value, str) or not value: + return None + try: + match = re.match(r"^(\\d{4}-\\d\\d-\\d\\d[T ]\\d\\d:\\d\\d:\\d\\d)(?:\\.(\\d+))?(Z|[+-]\\d\\d:?\\d\\d)$", value) + if not match: + return None + base = datetime.strptime(match.group(1).replace(" ", "T"), "%Y-%m-%dT%H:%M:%S") + fraction = int((match.group(2) or "0")[:6].ljust(6, "0")) + zone = match.group(3) + if zone == "Z": + offset = timezone.utc + else: + sign = 1 if zone[0] == "+" else -1 + digits = zone[1:].replace(":", "") + offset = timezone(sign * timedelta(hours=int(digits[:2]), minutes=int(digits[2:]))) + return base.replace(microsecond=fraction, tzinfo=offset).timestamp() + except Exception: + return None + +def encode_page_token(row): + payload = json.dumps({"v": 1, "at": row.get("at"), "requestId": row.get("requestId")}, separators=(",", ":")).encode("utf-8") + return base64.urlsafe_b64encode(payload).decode("ascii").rstrip("=") + +def decode_page_token(value): + if not isinstance(value, str) or not value: + return None + try: + padded = value + "=" * (-len(value) % 4) + parsed = json.loads(base64.urlsafe_b64decode(padded.encode("ascii")).decode("utf-8")) + except Exception: + raise RuntimeError("invalid page token") + if not isinstance(parsed, dict) or parsed.get("v") != 1 or not parsed.get("at") or not parsed.get("requestId"): + raise RuntimeError("invalid page token") + return parsed + +def dict_by_id(rows): + return {str(row.get("id")): row for row in rows if isinstance(row, dict) and row.get("id") is not None} + +def keyed_map(value, key): + source = value.get(key) if isinstance(value, dict) else None + return source if isinstance(source, dict) else {} + +def runtime_health(): + if RUNTIME_MODE == "host-docker": + proc = docker(["inspect", APP_CONTAINER, "sub2api-redis"]) + if proc.returncode != 0: + return {"ok": False, "mode": RUNTIME_MODE, "error": text(proc.stderr)} + rows = [] + for item in json.loads(proc.stdout.decode("utf-8")): + state = item.get("State") or {} + health = state.get("Health") or {} + rows.append({"name": str(item.get("Name") or "").lstrip("/"), "running": state.get("Running"), "status": health.get("Status") or state.get("Status")}) + return {"ok": all(row.get("running") is True for row in rows), "mode": RUNTIME_MODE, "components": rows} + proc = run(["kubectl", "-n", NAMESPACE, "get", "pods", "-l", "app.kubernetes.io/name=sub2api", "-o", "json"]) + if proc.returncode != 0: + return {"ok": False, "mode": RUNTIME_MODE, "error": text(proc.stderr)} + rows = [] + for item in json.loads(proc.stdout.decode("utf-8")).get("items") or []: + status = item.get("status") or {} + containers = status.get("containerStatuses") or [] + rows.append({"name": (item.get("metadata") or {}).get("name"), "running": status.get("phase") == "Running", "status": "ready" if containers and all(entry.get("ready") is True for entry in containers) else status.get("phase")}) + return {"ok": bool(rows) and all(row.get("running") is True for row in rows), "mode": RUNTIME_MODE, "components": rows} + +def compact_log(row): + extra = row.get("extra") if isinstance(row.get("extra"), dict) else {} + allow = {} + for key in ("phase", "error_owner", "status_code", "duration_ms", "latency_ms", "time_to_first_token_ms", "upstream_status", "path", "stream"): + if key in extra: + allow[key] = extra.get(key) + return { + "id": row.get("id"), + "at": row.get("created_at"), + "level": row.get("level"), + "component": row.get("component"), + "message": redact_text(row.get("message")), + "requestId": row.get("request_id"), + "clientRequestId": row.get("client_request_id"), + "accountId": row.get("account_id"), + "extra": allow, + } + +def related_logs(request, logs): + ids = {str(request.get("request_id") or "")} + matched = [] + changed = True + while changed: + changed = False + for row in logs: + request_id = str(row.get("request_id") or "") + client_id = str(row.get("client_request_id") or "") + if row in matched or (request_id not in ids and client_id not in ids): + continue + matched.append(row) + before = len(ids) + ids.update(value for value in (request_id, client_id) if value) + changed = changed or len(ids) != before + return matched + +def classify(request, logs, account, proxy, queued): + evidence = " ".join([str(request.get("phase") or ""), str(request.get("message") or ""), *[str(log.get("component") or "") + " " + str(log.get("message") or "") for log in logs]]).lower() + if any(marker in evidence for marker in ("client disconnect", "broken pipe", "context canceled", "client canceled")): + return "client-connection" + if any(marker in evidence for marker in ("proxy", "dial tcp", "dns", "connection refused", "network")) or (proxy and proxy.get("status") not in (None, "active")): + return "proxy-egress" + if any(marker in evidence for marker in ("upstream", "provider", "first token", "stream")) or str(request.get("phase") or "") == "upstream": + return "upstream-first-byte-stream" + if queued > 0: + return "sub2api-queue" + if request.get("kind") == "error" or (isinstance(request.get("status_code"), int) and request.get("status_code") >= 400): + return "sub2api-or-upstream-unknown" + return "completed" + +def main(): + errors = [] + admin_email, token = login() + user = exact_user(token) + if not isinstance(user, dict): + return {"ok": False, "mode": "feedback", "error": "user-not-found", "selector": USER_SELECTOR, "valuesPrinted": False} + user_id = user.get("id") + groups = safe_call(errors, "groups", lambda: items(api_data("GET", "/api/v1/admin/groups/all", token, label="groups")), []) + keys = safe_call(errors, "api-keys", lambda: page_all(token, f"/api/v1/admin/users/{user_id}/api-keys", {}, 100), []) + requests = safe_call(errors, "ops-requests", lambda: page_all(token, "/api/v1/admin/ops/requests", {"user_id": user_id, "time_range": WINDOW, "kind": "all", "sort": "created_at_desc"}, 100), []) + logs = safe_call(errors, "ops-system-logs", lambda: page_all(token, "/api/v1/admin/ops/system-logs", {"user_id": user_id, "time_range": WINDOW}, 200), []) + concurrency = safe_call(errors, "ops-concurrency", lambda: api_data("GET", "/api/v1/admin/ops/concurrency", token, label="concurrency"), {}) + user_concurrency = safe_call(errors, "ops-user-concurrency", lambda: api_data("GET", "/api/v1/admin/ops/user-concurrency", token, label="user concurrency"), {}) + availability = safe_call(errors, "ops-account-availability", lambda: api_data("GET", "/api/v1/admin/ops/account-availability", token, label="account availability"), {}) + accounts = safe_call(errors, "accounts", lambda: page_all(token, "/api/v1/admin/accounts", {}, 100), []) + proxies = safe_call(errors, "proxies", lambda: page_all(token, "/api/v1/admin/proxies", {}, 100), []) + infrastructure = safe_call(errors, "infrastructure", runtime_health, {"ok": False, "mode": RUNTIME_MODE}) + + groups_by_id = dict_by_id(groups) + keys_by_id = dict_by_id(keys) + accounts_by_id = dict_by_id(accounts) + proxies_by_id = dict_by_id(proxies) + user_current_map = keyed_map(user_concurrency, "user") + account_concurrency_map = keyed_map(concurrency, "account") + account_availability_map = keyed_map(availability, "account") + current_user = user_current_map.get(str(user_id)) or {} + user_current = int(current_user.get("current_in_use") or user.get("current_concurrency") or 0) + user_waiting = int(current_user.get("waiting_in_queue") or 0) + + compact_keys = [] + for key in keys: + group = groups_by_id.get(str(key.get("group_id"))) or {} + compact_keys.append({ + "id": key.get("id"), "name": key.get("name"), "status": key.get("status"), + "groupId": key.get("group_id"), "groupName": group.get("name"), + "currentConcurrency": key.get("current_concurrency"), "keyPrinted": False, + }) + + timeline = [] + ordered = sorted([row for row in requests if isinstance(row, dict)], key=lambda row: iso_epoch(row.get("created_at")) or 0) + previous_epoch = None + for row in ordered: + matched_logs = related_logs(row, logs) + client_id = next((log.get("client_request_id") for log in matched_logs if log.get("client_request_id")), None) + account = accounts_by_id.get(str(row.get("account_id"))) or {} + proxy = proxies_by_id.get(str(account.get("proxy_id"))) or {} + account_load = account_concurrency_map.get(str(row.get("account_id"))) or {} + queued = int(account_load.get("waiting_in_queue") or 0) + epoch = iso_epoch(row.get("created_at")) + gap = round(epoch - previous_epoch, 1) if epoch is not None and previous_epoch is not None else None + previous_epoch = epoch if epoch is not None else previous_epoch + timeline.append({ + "at": row.get("created_at"), "gapSeconds": gap, "kind": row.get("kind"), + "requestId": row.get("request_id"), "clientRequestId": client_id, + "model": row.get("model"), "statusCode": row.get("status_code"), "durationMs": row.get("duration_ms"), + "apiKeyId": row.get("api_key_id"), "apiKeyName": (keys_by_id.get(str(row.get("api_key_id"))) or {}).get("name"), + "accountId": row.get("account_id"), "accountName": account.get("name"), + "proxyId": account.get("proxy_id"), "proxyName": proxy.get("name"), + "attribution": classify(row, matched_logs, account, proxy, queued), + "evidence": {"systemLogCount": len(matched_logs), "accountStatus": account.get("status"), "accountSchedulable": account.get("schedulable"), "proxyStatus": proxy.get("status")}, + }) + + all_timeline = timeline + max_gap = max([row.get("gapSeconds") for row in all_timeline if isinstance(row.get("gapSeconds"), (int, float))], default=None) + next_page_token = None + more_available = False + if REQUEST_SELECTOR: + selected_ids = {REQUEST_SELECTOR} + for row in all_timeline: + if row.get("requestId") == REQUEST_SELECTOR or row.get("clientRequestId") == REQUEST_SELECTOR: + selected_ids.update(value for value in (row.get("requestId"), row.get("clientRequestId")) if isinstance(value, str) and value) + timeline = [row for row in all_timeline if any(isinstance(value, str) and value in selected_ids for value in (row.get("requestId"), row.get("clientRequestId")))] + detail_logs = [compact_log(row) for row in logs if any(isinstance(value, str) and value in selected_ids for value in (row.get("request_id"), row.get("client_request_id")))] + else: + detail_logs = [] + ordered_desc = list(reversed(all_timeline)) + cursor = decode_page_token(PAGE_TOKEN) + start = 0 + if cursor: + start = next((index + 1 for index, row in enumerate(ordered_desc) if row.get("at") == cursor.get("at") and row.get("requestId") == cursor.get("requestId")), -1) + if start < 0: + raise RuntimeError("page token cursor is no longer present in the selected window") + timeline = ordered_desc[start:start + PAGE_SIZE] + more_available = start + len(timeline) < len(ordered_desc) + if more_available and timeline: + next_page_token = encode_page_token(timeline[-1]) + if not requests and user_waiting > 0: + conclusion = "sub2api-queue" + elif not requests and user_current > 0: + conclusion = "in-flight" + elif not requests: + conclusion = "client-before-submit-or-no-inbound-evidence" + elif timeline: + conclusion = timeline[0].get("attribution") + else: + conclusion = "request-selector-not-found" + + related_account_ids = {str(row.get("accountId")) for row in timeline if row.get("accountId") is not None} + account_evidence = [] + for account_id in sorted(related_account_ids): + account = accounts_by_id.get(account_id) or {} + proxy = proxies_by_id.get(str(account.get("proxy_id"))) or {} + load = account_concurrency_map.get(account_id) or {} + available = account_availability_map.get(account_id) or {} + account_evidence.append({ + "id": account.get("id"), "name": account.get("name"), "status": account.get("status"), "schedulable": account.get("schedulable"), + "currentInUse": load.get("current_in_use"), "waitingInQueue": load.get("waiting_in_queue"), "maxCapacity": load.get("max_capacity"), + "available": available.get("is_available"), "rateLimited": available.get("is_rate_limited"), "overloaded": available.get("is_overloaded"), + "proxyId": account.get("proxy_id"), "proxyName": proxy.get("name"), "proxyStatus": proxy.get("status"), + }) + + return { + "ok": True, + "mode": "feedback", + "selector": {"user": USER_SELECTOR, "requestId": REQUEST_SELECTOR, "pageToken": PAGE_TOKEN, "window": WINDOW}, + "user": {"id": user_id, "email": user.get("email"), "username": user.get("username"), "status": user.get("status"), "maxConcurrency": user.get("concurrency"), "currentInUse": user_current, "waitingInQueue": user_waiting}, + "apiKeys": compact_keys, + "lifecycle": {"notInbound": not requests and user_current == 0 and user_waiting == 0, "queued": user_waiting, "inFlight": user_current, "completed": len(requests)}, + "timeline": timeline, + "timelineSummary": {"returned": len(timeline), "total": len(all_timeline), "moreAvailable": more_available, "nextPageToken": next_page_token, "pageSize": PAGE_SIZE, "systemLogCount": len(logs), "maxGapSeconds": max_gap}, + "requestDetail": {"logs": detail_logs, "logCount": len(detail_logs)} if REQUEST_SELECTOR else None, + "accounts": account_evidence, + "infrastructure": infrastructure, + "conclusion": {"attribution": conclusion, "boundary": "只读证据归因;无入站记录不等于客户端未调用,运行中请求仅由实时并发佐证。"}, + "toolCallReduction": {"manualInvestigationCalls": 9, "feedbackCliCalls": 1, "reducedCalls": 8, "reductionPercent": 88.9, "basis": "issue-2000: user, requests, system logs, two request-id traces, account, proxy, process/port, journal"}, + "sources": ["admin users", "admin user api-keys", "ops requests", "ops system-logs", "ops concurrency", "ops user-concurrency", "ops account-availability", "admin accounts", "admin proxies", "runtime health"], + "errors": errors, + "admin": {"email": admin_email, "tokenPrinted": False}, + "valuesPrinted": False, + } + +try: + print(json.dumps(main(), ensure_ascii=False)) +except Exception as exc: + print(json.dumps({"ok": False, "mode": "feedback", "error": redact_text(str(exc)), "valuesPrinted": False}, ensure_ascii=False)) + raise +PY +`; +} + +function record(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; +} + +function records(value: unknown): Record[] { + return Array.isArray(value) ? value.filter((item): item is Record => typeof item === "object" && item !== null && !Array.isArray(item)) : []; +} + +function renderFeedbackReport(report: Record | null, options: FeedbackOptions, remote: Record): string { + if (report === null) { + return [ + `SUB2API FEEDBACK user=${options.user} unavailable`, + `remote_exit=${remote.exitCode ?? "?"} stdout_bytes=${remote.stdoutBytes ?? "?"} stderr_bytes=${remote.stderrBytes ?? "?"}`, + textValue(remote.stderrTail ?? remote.stdoutTail), + ].join("\n"); + } + if (report.ok !== true) { + return [ + `SUB2API FEEDBACK user=${options.user} ok=false`, + `error=${textValue(report.error)}`, + "JSON: add --json for the structured failure envelope.", + ].join("\n"); + } + const user = record(report.user); + const lifecycle = record(report.lifecycle); + const summary = record(report.timelineSummary); + const conclusion = record(report.conclusion); + const reduction = record(report.toolCallReduction); + const timeline = records(report.timeline); + const accounts = records(report.accounts); + const apiKeys = records(report.apiKeys); + const detail = record(report.requestDetail); + const detailLogs = records(detail.logs); + const infrastructure = record(report.infrastructure); + const components = records(infrastructure.components); + const errors = records(report.errors); + const lines: string[] = []; + lines.push(`SUB2API FEEDBACK user=${textValue(user.email)}#${textValue(user.id)} window=${options.window} ok=true`); + lines.push(`CONCLUSION attribution=${textValue(conclusion.attribution)} boundary=${textValue(conclusion.boundary)}`); + lines.push(`LIFECYCLE not_inbound=${textValue(lifecycle.notInbound)} queued=${textValue(lifecycle.queued)} in_flight=${textValue(lifecycle.inFlight)} completed=${textValue(lifecycle.completed)} max_gap_s=${textValue(summary.maxGapSeconds)}`); + lines.push(`TOOLS manual=${textValue(reduction.manualInvestigationCalls)} cli=${textValue(reduction.feedbackCliCalls)} reduced=${textValue(reduction.reducedCalls)} reduction=${textValue(reduction.reductionPercent)}%`); + if (apiKeys.length > 0) { + lines.push(""); + lines.push("API KEYS"); + lines.push(renderTable([ + ["ID", "NAME", "GROUP", "STATUS", "CURRENT"], + ...apiKeys.map((key) => [textValue(key.id), shorten(textValue(key.name), 30), shorten(textValue(key.groupName), 24), textValue(key.status), textValue(key.currentConcurrency)]), + ])); + } + lines.push(""); + lines.push(`REQUESTS returned=${textValue(summary.returned)} total=${textValue(summary.total)} moreAvailable=${textValue(summary.moreAvailable)} system_logs=${textValue(summary.systemLogCount)}`); + if (timeline.length === 0) { + lines.push("No matching completed request records in the selected window."); + } else { + lines.push(renderTable([ + ["#", "AT", "GAP_S", "STATUS", "DURATION", "MODEL", "ACCOUNT", "ATTRIBUTION"], + ...timeline.map((item, index) => [ + String(index + 1), shortIso(item.at), textValue(item.gapSeconds), textValue(item.statusCode ?? item.kind), textValue(item.durationMs), + shorten(textValue(item.model), 18), `${textValue(item.accountName)}#${textValue(item.accountId)}`, textValue(item.attribution), + ]), + ])); + lines.push(""); + lines.push("REQUEST ID INDEX"); + lines.push(renderTable([ + ["#", "REQUEST_ID", "CLIENT_ID"], + ...timeline.map((item, index) => [String(index + 1), textValue(item.requestId), textValue(item.clientRequestId)]), + ])); + } + if (accounts.length > 0 || components.length > 0) { + lines.push(""); + lines.push("ACCOUNT / INFRA"); + lines.push(renderTable([ + ["ACCOUNT", "STATUS", "SCHED", "IN_USE", "QUEUE", "CAP", "AVAILABLE", "PROXY", "P_STATUS"], + ...accounts.map((item) => [ + `${textValue(item.name)}#${textValue(item.id)}`, textValue(item.status), textValue(item.schedulable), + textValue(item.currentInUse), textValue(item.waitingInQueue), textValue(item.maxCapacity), textValue(item.available), + shorten(`${textValue(item.proxyName)}#${textValue(item.proxyId)}`, 24), textValue(item.proxyStatus), + ]), + ...components.map((item) => [shorten(textValue(item.name), 32), textValue(item.status), "-", "-", "-", "-", textValue(item.running), "runtime", textValue(infrastructure.mode)]), + ])); + } + if (options.requestId !== null) { + lines.push(""); + lines.push(`REQUEST DETAIL id=${options.requestId} logs=${textValue(detail.logCount)}`); + if (detailLogs.length > 0) { + lines.push(renderTable([ + ["LOG_ID", "AT", "LEVEL", "COMPONENT", "REQUEST_ID", "CLIENT_ID", "MESSAGE"], + ...detailLogs.map((item) => [ + textValue(item.id), shortIso(item.at), textValue(item.level), shorten(textValue(item.component), 22), + shorten(textValue(item.requestId), 18), shorten(textValue(item.clientRequestId), 18), shorten(textValue(item.message), 64), + ]), + ])); + } + } + if (errors.length > 0) { + lines.push(""); + lines.push("PARTIAL EVIDENCE"); + lines.push(renderTable([["SOURCE", "ERROR"], ...errors.map((item) => [textValue(item.source), shorten(textValue(item.error), 90)])])); + } + lines.push(""); + if (summary.moreAvailable === true && typeof summary.nextPageToken === "string") { + lines.push(`NEXT_PAGE_TOKEN ${summary.nextPageToken}`); + lines.push(`Next: bun scripts/cli.ts platform-infra sub2api codex-pool feedback --target ${options.targetId} --user ${options.user} --window ${options.window} --page-token ${summary.nextPageToken}`); + } + lines.push("Disclosure: rerun with --request-id for correlated indexed logs."); + lines.push("JSON: add --json for the complete redacted report."); + return lines.join("\n"); +} diff --git a/scripts/src/platform-infra-sub2api-codex/index.ts b/scripts/src/platform-infra-sub2api-codex/index.ts index 51e6ff6f..9ec51ffa 100644 --- a/scripts/src/platform-infra-sub2api-codex/index.ts +++ b/scripts/src/platform-infra-sub2api-codex/index.ts @@ -13,3 +13,4 @@ export * from "./remote-scripts"; export * from "./accounts"; export * from "./remote-python-sync"; export * from "./remote"; +export * from "./feedback"; diff --git a/scripts/src/platform-infra-sub2api-codex/options.ts b/scripts/src/platform-infra-sub2api-codex/options.ts index a17ddade..3b8a351e 100644 --- a/scripts/src/platform-infra-sub2api-codex/options.ts +++ b/scripts/src/platform-infra-sub2api-codex/options.ts @@ -21,10 +21,12 @@ import { import { parseEnvFile, readTextFile, redactRepoPath, requiredEnvValue } from "../secrets"; import { runSshCommandCapture, type SshCaptureResult } from "../ssh"; -import type { ConfirmOptions, DisclosureOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types"; +import type { ConfirmOptions, DisclosureOptions, FeedbackOptions, SentinelImageOptions, SentinelProbeOptions, SentinelReportOptions, SyncOptions, TraceOptions } from "./types"; import { codexPoolCleanupProbes, codexPoolConfigureLocal, codexPoolExpose, codexPoolPlan, codexPoolSentinelImage, codexPoolSentinelProbe, codexPoolSentinelReport, codexPoolSync, codexPoolTrace, codexPoolValidate } from "./actions"; +import { codexPoolFeedback } from "./feedback"; import { renderCodexPoolPlan } from "./render"; import { codexPoolRuntime } from "./runtime"; +import { codexPoolFaults } from "./faults"; import { defaultCodexPoolRuntimeTargetId } from "./runtime-target"; import { codexPoolHelp } from "./types"; @@ -38,7 +40,9 @@ export async function runCodexPoolCommand(config: UniDeskConfig, args: string[]) if (action === "sync") return await codexPoolSync(config, parseSyncOptions(args.slice(1))); if (action === "validate") return await codexPoolValidate(config, parseDisclosureOptions(args.slice(1))); if (action === "runtime") return await codexPoolRuntime(config, args.slice(1)); + if (action === "faults") return await codexPoolFaults(config, args.slice(1)); if (action === "trace") return await codexPoolTrace(config, parseTraceOptions(args.slice(1))); + if (action === "feedback") return await codexPoolFeedback(config, parseFeedbackOptions(args.slice(1))); if (action === "sentinel-image") return await codexPoolSentinelImage(config, parseSentinelImageOptions(args.slice(1))); if (action === "sentinel-probe") return await codexPoolSentinelProbe(config, parseSentinelProbeOptions(args.slice(1))); if (action === "sentinel-report") return await codexPoolSentinelReport(config, parseSentinelReportOptions(args.slice(1))); @@ -181,6 +185,7 @@ export function parseSentinelReportOptions(args: string[]): SentinelReportOption export function parseTraceOptions(args: string[]): TraceOptions { let requestId: string | null = null; + let pageToken: string | null = null; let since = "24h"; let tail = 20_000; let contextSeconds = 300; @@ -258,6 +263,64 @@ export function parseTraceOptions(args: string[]): TraceOptions { return { ...disclosure, requestId, since, tail, contextSeconds, showLines }; } +export function parseFeedbackOptions(args: string[]): FeedbackOptions { + let user: string | null = null; + let window: FeedbackOptions["window"] = "30m"; + let requestId: string | null = null; + let pageToken: string | null = null; + let json = false; + const targetArgs: string[] = []; + const windows = new Set(["5m", "30m", "1h", "6h", "24h", "7d", "30d"]); + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]!; + if (arg === "--json") { + json = true; + continue; + } + if (arg === "--target" || arg.startsWith("--target=")) { + targetArgs.push(arg); + if (arg === "--target") { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error("--target requires a value"); + targetArgs.push(value); + index += 1; + } + continue; + } + const readValue = (option: string): string => { + const value = args[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${option} requires a value`); + index += 1; + return value.trim(); + }; + if (arg === "--user") user = readValue(arg); + else if (arg.startsWith("--user=")) user = arg.slice("--user=".length).trim(); + else if (arg === "--window") { + const value = readValue(arg) as FeedbackOptions["window"]; + if (!windows.has(value)) throw new Error("--window must be one of 5m, 30m, 1h, 6h, 24h, 7d, 30d"); + window = value; + } else if (arg.startsWith("--window=")) { + const value = arg.slice("--window=".length) as FeedbackOptions["window"]; + if (!windows.has(value)) throw new Error("--window must be one of 5m, 30m, 1h, 6h, 24h, 7d, 30d"); + window = value; + } else if (arg === "--request-id") requestId = readValue(arg); + else if (arg.startsWith("--request-id=")) requestId = arg.slice("--request-id=".length).trim(); + else if (arg === "--page-token") pageToken = readValue(arg); + else if (arg.startsWith("--page-token=")) pageToken = arg.slice("--page-token=".length).trim(); + else throw new Error(`unsupported option: ${arg}`); + } + if (user === null || user.length === 0) throw new Error("feedback requires --user "); + if (user.length > 320 || /[\r\n]/u.test(user)) throw new Error("--user must be a single email or positive user id"); + if (requestId !== null && (requestId.length === 0 || requestId.length > 256 || /[\r\n]/u.test(requestId))) { + throw new Error("--request-id must be a non-empty stable request id up to 256 characters"); + } + if (pageToken !== null && (pageToken.length === 0 || pageToken.length > 1024 || !/^[A-Za-z0-9_-]+$/u.test(pageToken))) { + throw new Error("--page-token must be a URL-safe cursor token"); + } + if (requestId !== null && pageToken !== null) throw new Error("--request-id and --page-token cannot be combined"); + return { user, window, requestId, pageToken, json, targetId: parseTargetId(targetArgs) }; +} + export function parseKubectlDuration(raw: string): string { const value = raw.trim(); if (!/^[1-9][0-9]*(?:s|m|h)$/u.test(value)) throw new Error("--since must be a kubectl duration such as 24h, 90m, or 300s"); diff --git a/scripts/src/platform-infra-sub2api-codex/types.ts b/scripts/src/platform-infra-sub2api-codex/types.ts index aee11961..b0475a57 100644 --- a/scripts/src/platform-infra-sub2api-codex/types.ts +++ b/scripts/src/platform-infra-sub2api-codex/types.ts @@ -71,6 +71,15 @@ export interface TraceOptions extends DisclosureOptions { showLines: boolean; } +export interface FeedbackOptions { + user: string; + window: "5m" | "30m" | "1h" | "6h" | "24h" | "7d" | "30d"; + requestId: string | null; + pageToken: string | null; + json: boolean; + targetId: string; +} + export interface SentinelImageOptions extends DisclosureOptions { action: "status" | "build"; confirm: boolean; @@ -341,8 +350,8 @@ export function codexPoolHelp(): unknown { const pool = readCodexPoolConfig(); const runtimeTarget = codexPoolRuntimeTarget(); return { - command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|trace|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", - output: "json, except trace and sentinel-report default to low-noise text tables", + command: "platform-infra sub2api codex-pool plan|sync|validate|runtime|faults|trace|feedback|sentinel-image|sentinel-probe|sentinel-report|cleanup-probes|expose|configure-local", + output: "json, except trace, feedback, and sentinel-report default to low-noise text tables", usage: [ "bun scripts/cli.ts platform-infra sub2api codex-pool plan", "bun scripts/cli.ts platform-infra sub2api codex-pool plan --target D601", @@ -351,11 +360,13 @@ export function codexPoolHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool runtime list [--target PK01] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime get --account [--target PK01] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime errors [--account ] [--since 24h] [--tail 50000] [--target PK01] [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2api codex-pool faults [--level P0|P1|P2] [--group ] [--account ] [--model ] [--stream sync|stream] [--endpoint ] [--request-id ] [--page-token ] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --account --template [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime apply --accounts --template [--target PK01] [--confirm] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --account --kind temp-unschedulable [--target PK01] [--confirm]", "bun scripts/cli.ts platform-infra sub2api codex-pool runtime delete --accounts --kind temp-unschedulable [--target PK01] [--confirm] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api codex-pool trace [--target D601] --request-id [--since 24h|--tail 20000|--context-seconds 300|--show-lines|--raw]", + "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--target PK01] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status [--target D601]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image build [--target D601] --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe [--target D601] --account unidesk-codex-hy --confirm", diff --git a/scripts/src/platform-infra-sub2api-ops/index.ts b/scripts/src/platform-infra-sub2api-ops/index.ts new file mode 100644 index 00000000..3658604b --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/index.ts @@ -0,0 +1,75 @@ +import type { UniDeskConfig } from "../config"; +import type { RenderedCliResult } from "../output"; +import { defaultSub2ApiTargetId } from "../platform-infra/config"; + +import { projectSub2ApiOps } from "./projection"; +import { querySub2ApiOps } from "./query"; +import { renderSub2ApiOps } from "./render"; +import type { Sub2ApiOpsOptions } from "./types"; + +export async function runSub2ApiOpsCommand(config: UniDeskConfig, args: string[]): Promise | RenderedCliResult> { + const options = parseSub2ApiOpsOptions(args); + const raw = await querySub2ApiOps(config, options); + const projected = projectSub2ApiOps(raw, options); + return options.json ? projected : renderSub2ApiOps(projected); +} + +export function parseSub2ApiOpsOptions(args: string[]): Sub2ApiOpsOptions { + const [actionRaw, ...rest] = args; + if (actionRaw !== "diagnosis" && actionRaw !== "channels") { + throw new Error("sub2api ops usage: diagnosis|channels [--target ] [--platform ] [--json]"); + } + let targetId = defaultSub2ApiTargetId(); + let json = false; + let platform: string | null = null; + let group: string | null = null; + let diagnosisId: string | null = null; + let timeRange = "1h" as Sub2ApiOpsOptions["timeRange"]; + let channel: string | null = null; + let model: string | null = null; + let window = "7d" as Sub2ApiOpsOptions["window"]; + let pageToken: string | null = null; + let recordId: string | null = null; + for (let index = 0; index < rest.length; index += 1) { + const arg = rest[index]!; + const readValue = (name: string): string => { + const value = rest[index + 1]; + if (value === undefined || value.startsWith("--")) throw new Error(`${name} requires a value`); + index += 1; + return value.trim(); + }; + if (arg === "--json") json = true; + else if (arg === "--target") targetId = readValue(arg); + else if (arg === "--platform") platform = readValue(arg); + else if (arg === "--group") group = readValue(arg); + else if (arg === "--id") diagnosisId = readValue(arg); + else if (arg === "--time-range") timeRange = parseTimeRange(readValue(arg)); + else if (arg === "--channel") channel = readValue(arg); + else if (arg === "--model") model = readValue(arg); + else if (arg === "--window") window = parseWindow(readValue(arg)); + else if (arg === "--page-token") pageToken = parseRecordId(readValue(arg), arg); + else if (arg === "--record") recordId = parseRecordId(readValue(arg), arg); + else throw new Error(`unknown sub2api ops option: ${arg}`); + } + if (actionRaw === "diagnosis" && (channel !== null || model !== null || window !== "7d" || pageToken !== null || recordId !== null)) throw new Error("ops diagnosis does not accept channel history options"); + if (actionRaw === "channels" && (group !== null || diagnosisId !== null || timeRange !== "1h")) throw new Error("ops channels does not accept --group, --id, or --time-range"); + if (actionRaw === "channels" && model !== null && channel === null) throw new Error("--model requires --channel"); + if (actionRaw === "channels" && (pageToken !== null || recordId !== null) && channel === null) throw new Error("--page-token and --record require --channel"); + if (pageToken !== null && recordId !== null) throw new Error("use only one of --page-token or --record"); + return { action: actionRaw, targetId, json, platform, group, diagnosisId, timeRange, channel, model, window, pageToken, recordId }; +} + +function parseTimeRange(value: string): Sub2ApiOpsOptions["timeRange"] { + if (value === "5m" || value === "30m" || value === "1h" || value === "6h" || value === "24h") return value; + throw new Error("--time-range must be one of 5m, 30m, 1h, 6h, 24h"); +} + +function parseWindow(value: string): Sub2ApiOpsOptions["window"] { + if (value === "7d" || value === "15d" || value === "30d") return value; + throw new Error("--window must be one of 7d, 15d, 30d"); +} + +function parseRecordId(value: string, option: string): string { + if (/^[1-9][0-9]*$/u.test(value)) return value; + throw new Error(`${option} must be a positive native history record ID`); +} diff --git a/scripts/src/platform-infra-sub2api-ops/projection.ts b/scripts/src/platform-infra-sub2api-ops/projection.ts new file mode 100644 index 00000000..606027c3 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/projection.ts @@ -0,0 +1,419 @@ +import type { Sub2ApiOpsOptions } from "./types"; + +const upstreamVersion = "v0.1.155"; +const diagnosisRuleSource = "frontend/src/views/admin/ops/components/OpsDashboardHeader.vue"; +const diagnosisTextSource = "frontend/src/i18n/locales/zh/admin/ops.ts"; +const channelHistoryPageSize = 20; +const monitorResponseHeaderTimeoutMs = 30_000; +const monitorTotalRequestTimeoutMs = 45_000; + +export function projectSub2ApiOps(raw: Record, options: Sub2ApiOpsOptions): Record { + const target = record(raw.target); + if (raw.ok !== true) { + return { + ok: false, + action: `platform-infra-sub2api-ops-${options.action}`, + target, + query: querySummary(options), + source: sourceSummary(options.action, "unavailable"), + error: stringValue(raw.error, "Sub2API native source unavailable"), + valuesPrinted: false, + }; + } + const data = record(raw.data); + return options.action === "diagnosis" + ? projectDiagnosis(target, data, options) + : projectChannels(target, data, options); +} + +function projectDiagnosis(target: Record, data: Record, options: Sub2ApiOpsOptions): Record { + const runtimeVersion = record(data.runtimeVersion); + const groups = arrayRecords(data.groups).map((entry) => { + const group = record(entry.group); + const overview = record(entry.overview); + const scope = group.id === null || group.id === undefined ? "all" : `g${String(group.id)}`; + return { + id: scope, + groupId: group.id ?? null, + groupName: stringValue(group.name, "all"), + platform: stringValue(group.platform, "all"), + window: { + kind: "native-ops-time-range", + value: options.timeRange, + startAt: overview.start_time ?? null, + endAt: overview.end_time ?? null, + }, + sourceStatus: "available", + diagnoses: diagnosisItems(scope, overview), + metrics: compactOverview(overview), + }; + }); + const selectedDiagnosis = options.diagnosisId === null + ? null + : groups.flatMap((group) => arrayRecords(group.diagnoses)).find((item) => item.id === options.diagnosisId) ?? null; + return { + ok: options.diagnosisId === null || selectedDiagnosis !== null, + action: "platform-infra-sub2api-ops-diagnosis", + target, + query: querySummary(options), + source: sourceSummary("diagnosis", "available", runtimeVersion), + groups, + selectedDiagnosis, + evidence: options.diagnosisId === null ? null : projectEvidence(record(data.evidence)), + error: options.diagnosisId !== null && selectedDiagnosis === null ? `diagnosis id not found: ${options.diagnosisId}` : null, + valuesPrinted: false, + }; +} + +function diagnosisItems(scope: string, overview: Record): Record[] { + const items: Record[] = []; + const qps = numberAt(record(overview.qps).current, 0); + const errorRate = numberAt(overview.error_rate, 0); + if (qps === 0 && errorRate === 0) { + return [diagnosisItem(scope, "idle", "info", "system_activity", 0, "requests/s", "系统当前处于待机状态", "无活跃流量", null, overview)]; + } + const systemMetrics = record(overview.system_metrics); + if (systemMetrics.db_ok === false) items.push(diagnosisItem(scope, "db-down", "critical", "db_ok", false, "boolean", "数据库连接失败", "所有数据库操作将失败", "检查数据库服务状态、网络连接和连接配置", overview)); + if (systemMetrics.redis_ok === false) items.push(diagnosisItem(scope, "redis-down", "warning", "redis_ok", false, "boolean", "Redis连接失败", "缓存功能降级,性能可能下降", "检查Redis服务状态和网络连接", overview)); + const cpu = numberAt(systemMetrics.cpu_usage_percent, 0); + if (cpu > 90) items.push(diagnosisItem(scope, "cpu-critical", "critical", "cpu_usage_percent", cpu, "%", `CPU使用率严重过高 (${cpu.toFixed(1)}%)`, "系统响应变慢,可能影响所有请求", "检查CPU密集型任务,考虑扩容或优化代码", overview)); + else if (cpu > 80) items.push(diagnosisItem(scope, "cpu-high", "warning", "cpu_usage_percent", cpu, "%", `CPU使用率偏高 (${cpu.toFixed(1)}%)`, "系统负载较高,需要关注", "监控CPU趋势,准备扩容方案", overview)); + const memory = numberAt(systemMetrics.memory_usage_percent, 0); + if (memory > 90) items.push(diagnosisItem(scope, "memory-critical", "critical", "memory_usage_percent", memory, "%", `内存使用率严重过高 (${memory.toFixed(1)}%)`, "可能触发OOM,系统稳定性受威胁", "检查内存泄漏,考虑增加内存或优化内存使用", overview)); + else if (memory > 85) items.push(diagnosisItem(scope, "memory-high", "warning", "memory_usage_percent", memory, "%", `内存使用率偏高 (${memory.toFixed(1)}%)`, "内存压力较大,需要关注", "监控内存趋势,检查是否有内存泄漏", overview)); + const ttft = numberAt(record(overview.ttft).p99_ms, 0); + if (ttft > 500) items.push(diagnosisItem(scope, "ttft-high", "warning", "ttft.p99_ms", ttft, "ms", `首 Token 时间偏高 (${ttft.toFixed(0)}ms)`, "用户感知时长增加", "优化请求处理流程,减少前置逻辑耗时", overview)); + const upstreamRate = numberAt(overview.upstream_error_rate, 0) * 100; + if (upstreamRate > 5) items.push(diagnosisItem(scope, "upstream-critical", "critical", "upstream_error_rate", upstreamRate, "%", `上游错误率严重偏高 (${upstreamRate.toFixed(2)}%)`, "可能影响大量用户请求", "检查上游服务健康状态,启用降级策略", overview)); + else if (upstreamRate > 2) items.push(diagnosisItem(scope, "upstream-high", "warning", "upstream_error_rate", upstreamRate, "%", `上游错误率偏高 (${upstreamRate.toFixed(2)}%)`, "建议检查上游服务状态", "联系上游服务团队,准备降级方案", overview)); + const clientRate = errorRate * 100; + if (clientRate > 3) items.push(diagnosisItem(scope, "error-high", "critical", "error_rate", clientRate, "%", `错误率过高 (${clientRate.toFixed(2)}%)`, "大量请求失败", "查看错误日志,定位错误根因,紧急修复", overview)); + else if (clientRate > 0.5) items.push(diagnosisItem(scope, "error-elevated", "warning", "error_rate", clientRate, "%", `错误率偏高 (${clientRate.toFixed(2)}%)`, "建议检查错误日志", "分析错误类型和分布,制定修复计划", overview)); + const sla = numberAt(overview.sla, 0) * 100; + if (sla < 90) items.push(diagnosisItem(scope, "sla-critical", "critical", "sla", sla, "%", `SLA 严重低于目标 (${sla.toFixed(2)}%)`, "用户体验严重受损", "紧急排查错误原因,必要时采取限流保护", overview)); + else if (sla < 98) items.push(diagnosisItem(scope, "sla-low", "warning", "sla", sla, "%", `SLA 低于目标 (${sla.toFixed(2)}%)`, "需要关注服务质量", "分析SLA下降原因,优化系统性能", overview)); + const healthScore = finiteNumber(overview.health_score); + if (healthScore !== null && healthScore < 60) items.push(diagnosisItem(scope, "health-critical", "critical", "health_score", healthScore, "score", `综合健康评分过低 (${healthScore})`, "多个指标可能同时异常,建议优先排查错误与资源使用情况", "全面检查系统状态,优先处理critical级别问题", overview)); + else if (healthScore !== null && healthScore < 90) items.push(diagnosisItem(scope, "health-low", "warning", "health_score", healthScore, "score", `综合健康评分偏低 (${healthScore})`, "可能存在轻度波动,建议关注 SLA 与错误率", "监控指标趋势,预防问题恶化", overview)); + if (items.length === 0) items.push(diagnosisItem(scope, "healthy", "info", "health", "normal", "state", "所有系统指标正常", "服务运行稳定", null, overview)); + return items; +} + +function diagnosisItem(scope: string, category: string, severity: string, metric: string, value: unknown, unit: string, message: string, impact: string, advice: string | null, overview: Record): Record { + return { + id: `${scope}:${category}`, + category, + severity, + message, + metric: { name: metric, value, unit }, + impact, + advice, + adviceKind: advice === null ? null : "native-frontend-advice", + factRef: `${scope}.metrics`, + inferenceStatus: "not-verified", + }; +} + +function compactOverview(overview: Record): Record { + return { + requestCount: overview.request_count_total ?? null, + successCount: overview.success_count ?? null, + errorCount: overview.error_count_total ?? null, + errorRate: overview.error_rate ?? null, + upstreamErrorRate: overview.upstream_error_rate ?? null, + sla: overview.sla ?? null, + healthScore: overview.health_score ?? null, + qps: record(overview.qps).current ?? null, + ttftP99Ms: record(overview.ttft).p99_ms ?? null, + collectedAt: overview.end_time ?? null, + }; +} + +function projectEvidence(evidence: Record): Record | null { + if (Object.keys(evidence).length === 0) return null; + const records = arrayRecords(evidence.records).map((item) => ({ + requestId: item.request_id ?? null, + createdAt: item.created_at ?? null, + platform: item.platform ?? null, + model: item.model ?? null, + accountId: item.account_id ?? null, + accountName: item.account_name ?? null, + statusCode: item.status_code ?? item.upstream_status_code ?? null, + durationMs: item.duration_ms ?? null, + severity: item.severity ?? null, + message: item.message ?? null, + })); + return { diagnosisId: evidence.diagnosisId ?? null, recordCount: records.length, records }; +} + +function projectChannels(target: Record, data: Record, options: Sub2ApiOpsOptions): Record { + const runtimeVersion = record(data.runtimeVersion); + const channels = arrayRecords(data.channels).map((entry) => projectChannel(entry, options.window)); + const operational = channels.every((channel) => channel.status === "OPERATIONAL"); + const history = arrayRecords(data.history) + .map((item) => ({ + id: item.id ?? null, + model: item.model ?? null, + status: item.status ?? null, + latencyMs: item.latency_ms ?? null, + pingLatencyMs: item.ping_latency_ms ?? null, + message: item.message ?? null, + checkedAt: item.checked_at ?? null, + })) + .sort((left, right) => String(left.checkedAt).localeCompare(String(right.checkedAt))); + const historyProjection = options.channel === null ? null : paginateHistory(history, options, record(data.correlation), runtimeVersion); + const historyError = historyProjection === null ? null : historyProjection.error; + return { + ok: historyError === null, + action: "platform-infra-sub2api-ops-channels", + target, + query: querySummary(options), + source: sourceSummary("channels", "available", runtimeVersion), + timeoutPolicy: monitorTimeoutPolicy(runtimeVersion), + overallStatus: operational ? "OPERATIONAL" : "DEGRADED", + channels, + history: historyProjection, + error: historyError, + valuesPrinted: false, + }; +} + +function paginateHistory(history: Record[], options: Sub2ApiOpsOptions, correlation: Record, runtimeVersion: Record): Record { + if (options.recordId !== null) { + const selectedRecordRaw = history.find((item) => String(item.id) === options.recordId) ?? null; + const selectedRecord = selectedRecordRaw === null ? null : projectSelectedRecord(selectedRecordRaw, correlation, runtimeVersion); + return { + order: "PAST_TO_NOW", + pageSize: channelHistoryPageSize, + sourceRecordCount: history.length, + sourceCapReached: history.length === 1000, + records: [], + moreAvailable: false, + nextPageToken: null, + selectedRecord, + error: selectedRecord === null ? `channel history record not found: ${options.recordId}` : null, + }; + } + let end = history.length; + if (options.pageToken !== null) { + const cursorIndex = history.findIndex((item) => String(item.id) === options.pageToken); + if (cursorIndex < 0) { + return { + order: "PAST_TO_NOW", + pageSize: channelHistoryPageSize, + sourceRecordCount: history.length, + sourceCapReached: history.length === 1000, + records: [], + moreAvailable: false, + nextPageToken: null, + selectedRecord: null, + error: `channel history page token not found: ${options.pageToken}`, + }; + } + end = cursorIndex; + } + const start = Math.max(0, end - channelHistoryPageSize); + const records = history.slice(start, end); + return { + order: "PAST_TO_NOW", + pageSize: channelHistoryPageSize, + sourceRecordCount: history.length, + sourceCapReached: history.length === 1000, + records, + moreAvailable: start > 0, + nextPageToken: start > 0 && records.length > 0 ? records[0]?.id ?? null : null, + selectedRecord: null, + error: null, + }; +} + +function projectSelectedRecord(item: Record, correlation: Record, runtimeVersion: Record): Record { + const latencyMs = typeof item.latencyMs === "number" ? item.latencyMs : null; + const selected = record(correlation.selected); + const final = record(selected.final); + const failovers = arrayRecords(selected.failovers); + const selectFailures = arrayRecords(selected.selectFailures); + const totalLatencyMs = typeof final.latencyMs === "number" ? final.latencyMs : null; + const runtimeTag = typeof runtimeVersion.version === "string" && runtimeVersion.version.length > 0 ? runtimeVersion.version : null; + const runtimeVersionMismatch = runtimeTag?.replace(/^v/u, "") !== upstreamVersion.replace(/^v/u, ""); + return { + ...item, + timeout: { + observedKind: String(item.message ?? "").toLowerCase().includes("timeout awaiting response headers") ? "response-header" : "unknown", + responseHeaderTimeoutMs: monitorResponseHeaderTimeoutMs, + totalRequestTimeoutMs: monitorTotalRequestTimeoutMs, + sourceReferenceVersion: upstreamVersion, + runtimeVersion: runtimeTag, + runtimeVersionMismatch, + runtimePolicyStatus: runtimeVersionMismatch ? "unverified" : "source-version-verified", + remainingAfterResponseHeaderMs: monitorTotalRequestTimeoutMs - monitorResponseHeaderTimeoutMs, + observedLatencyDeltaMs: latencyMs === null ? null : latencyMs - monitorResponseHeaderTimeoutMs, + perUpstreamTimeout: { status: "unsupported", reason: "native monitor history does not expose per-upstream timeout" }, + overallGatewayDeadline: { status: "unsupported", reason: "native monitor history does not expose the gateway request deadline" }, + failoverReserve: { status: "unsupported", reason: "native monitor history does not expose a dedicated failover reserve" }, + }, + correlation: { + status: correlation.status ?? "unsupported", + reason: correlation.reason ?? null, + requestId: selected.requestId ?? null, + match: selected.match ?? null, + apiKeyName: selected.apiKeyName ?? null, + model: selected.model ?? null, + accounts: selected.accounts ?? [], + upstreamErrors: selected.upstreamErrors ?? [], + failovers, + selectFailures, + final: Object.keys(final).length === 0 ? null : final, + switchingObserved: failovers.length > 0, + selectionFailureObserved: selectFailures.length > 0, + sufficientForObservedCompletion: runtimeVersionMismatch || totalLatencyMs === null ? null : monitorTotalRequestTimeoutMs >= totalLatencyMs, + sufficiencyStatus: runtimeVersionMismatch ? "unverified-runtime-version-mismatch" : totalLatencyMs === null ? "unsupported-no-final-latency" : "evaluated-against-verified-source-version", + traceNext: selected.requestId ? `bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ${String(selected.requestId)}` : null, + candidates: correlation.status === "ambiguous" ? correlation.candidates ?? [] : [], + }, + }; +} + +function projectChannel(entry: Record, window: "7d" | "15d" | "30d"): Record { + const summary = record(entry.summary); + const detail = record(entry.detail); + const models = arrayRecords(detail.models).map((model) => ({ + model: model.model ?? null, + status: upperStatus(model.latest_status), + latencyMs: model.latest_latency_ms ?? null, + availability: availabilityFor(model, window), + availabilityWindow: window, + avgLatency7dMs: model.avg_latency_7d_ms ?? null, + })); + const timeline = arrayRecords(summary.timeline) + .map((point) => ({ + status: upperStatus(point.status), + latencyMs: point.latency_ms ?? null, + pingLatencyMs: point.ping_latency_ms ?? null, + checkedAt: point.checked_at ?? null, + })) + .sort((left, right) => String(left.checkedAt).localeCompare(String(right.checkedAt))); + const status = [summary.primary_status, ...arrayRecords(summary.extra_models).map((item) => item.status)] + .every((value) => String(value ?? "").toLowerCase() === "operational") ? "OPERATIONAL" : "DEGRADED"; + return { + id: summary.id ?? detail.id ?? null, + name: summary.name ?? detail.name ?? null, + groupName: summary.group_name ?? detail.group_name ?? null, + provider: summary.provider ?? detail.provider ?? null, + status, + primary: { + model: summary.primary_model ?? null, + status: upperStatus(summary.primary_status), + latencyMs: summary.primary_latency_ms ?? null, + pingLatencyMs: summary.primary_ping_latency_ms ?? null, + availability: availabilityFor(models.find((item) => item.model === summary.primary_model) ?? {}, window) ?? summary.availability_7d ?? null, + availabilityWindow: window, + }, + models, + recentRecordCount: timeline.length, + collectedAt: timeline.length === 0 ? null : timeline[timeline.length - 1]?.checkedAt ?? null, + refreshRemainingSeconds: null, + refreshSourceStatus: "unsupported-by-native-response", + timeline: { + order: "PAST_TO_NOW", + recordCount: timeline.length, + firstAt: timeline[0]?.checkedAt ?? null, + lastAt: timeline[timeline.length - 1]?.checkedAt ?? null, + records: [], + detail: "use --channel for native history pagination", + }, + }; +} + +function monitorTimeoutPolicy(runtimeVersion: Record): Record { + const runtimeTag = typeof runtimeVersion.version === "string" && runtimeVersion.version.length > 0 ? runtimeVersion.version : null; + const runtimeVersionMismatch = runtimeTag?.replace(/^v/u, "") !== upstreamVersion.replace(/^v/u, ""); + return { + runtime: { image: runtimeVersion.image ?? null, version: runtimeTag }, + sourceReferenceVersion: upstreamVersion, + runtimeVersionMismatch, + runtimePolicyStatus: runtimeVersionMismatch ? "unverified" : "source-version-verified", + sourceReference: { + responseHeaderTimeoutMs: monitorResponseHeaderTimeoutMs, + totalRequestTimeoutMs: monitorTotalRequestTimeoutMs, + sourceStatus: "official-source-constant-verified-for-reference-version", + }, + perUpstreamTimeout: { status: "unsupported", reason: "native monitor API does not expose this field" }, + overallGatewayDeadline: { status: "unsupported", reason: "native monitor API does not expose this field" }, + failoverReserve: { status: "unsupported", reason: "native monitor API does not expose this field" }, + }; +} + +function availabilityFor(model: Record, window: "7d" | "15d" | "30d"): unknown { + return model[`availability_${window}`] ?? model.availability ?? null; +} + +function sourceSummary(action: "diagnosis" | "channels", status: string, runtimeVersion: Record = {}): Record { + if (action === "diagnosis") { + return { + status, + sourceReferenceVersion: upstreamVersion, + runtimeVersion, + metricsEndpoint: "/api/v1/admin/ops/dashboard/overview", + nativeDiagnosisEndpoint: { status: "unsupported", reason: "v0.1.155 has no diagnosis/advice response schema" }, + projectionKind: "native-frontend-projection", + ruleSource: diagnosisRuleSource, + textSource: diagnosisTextSource, + evidenceBoundary: "metrics are native API facts; severity, impact and advice reproduce the official v0.1.155 frontend projection and are not server-returned root-cause proof", + }; + } + return { + status, + sourceReferenceVersion: upstreamVersion, + runtimeVersion, + listEndpoint: "/api/v1/channel-monitors", + detailEndpoint: "/api/v1/channel-monitors/:id/status", + historyEndpoint: "/api/v1/admin/channel-monitors/:id/history", + correlationSource: "heuristic projection by native record checked_at, latency_ms and model; request/failover facts come from request rows and bounded runtime logs when available", + windows: ["7d", "15d", "30d"], + overallStatusKind: "native-frontend-projection", + refreshRemaining: { status: "unsupported", reason: "native response does not include a refresh countdown" }, + }; +} + +function querySummary(options: Sub2ApiOpsOptions): Record { + return { + target: options.targetId, + platform: options.platform, + group: options.group, + diagnosisId: options.diagnosisId, + timeRange: options.action === "diagnosis" ? options.timeRange : undefined, + channel: options.channel, + model: options.model, + window: options.action === "channels" ? options.window : undefined, + pageToken: options.pageToken, + recordId: options.recordId, + snapshot: true, + foregroundRefresh: false, + }; +} + +function upperStatus(value: unknown): string { + const text = String(value ?? "unknown").trim(); + return text.length === 0 ? "UNKNOWN" : text.toUpperCase(); +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function stringValue(value: unknown, fallback: string): string { + return typeof value === "string" && value.length > 0 ? value : fallback; +} + +function finiteNumber(value: unknown): number | null { + return typeof value === "number" && Number.isFinite(value) ? value : null; +} + +function numberAt(value: unknown, fallback: number): number { + return finiteNumber(value) ?? fallback; +} diff --git a/scripts/src/platform-infra-sub2api-ops/query.ts b/scripts/src/platform-infra-sub2api-ops/query.ts new file mode 100644 index 00000000..e5ea7017 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/query.ts @@ -0,0 +1,408 @@ +import type { UniDeskConfig } from "../config"; +import { parseJsonOutput } from "../platform-infra-ops-library"; +import { runSshCommandCapture } from "../ssh"; +import { readSub2ApiConfig } from "../platform-infra/config"; +import { resolveTarget } from "../platform-infra/manifest"; + +import type { Sub2ApiOpsOptions, Sub2ApiOpsTarget } from "./types"; + +export async function querySub2ApiOps(config: UniDeskConfig, options: Sub2ApiOpsOptions): Promise> { + const target = resolveOpsTarget(options.targetId); + const result = await runSshCommandCapture(config, target.route, ["sh"], remoteQueryScript(target, options)); + const parsed = parseJsonOutput(result.stdout); + if (result.exitCode !== 0 || parsed === null) { + const stderr = result.stderr.trim().slice(-1200); + const stdout = result.stdout.trim().slice(-1200); + return { + ok: false, + sourceStatus: "unavailable", + error: stderr || stdout || "remote Sub2API ops query returned no JSON", + remote: { exitCode: result.exitCode, stdoutTail: stdout, stderrTail: stderr }, + target, + valuesPrinted: false, + }; + } + return { ...parsed, target, valuesPrinted: false }; +} + +function resolveOpsTarget(targetId: string): Sub2ApiOpsTarget { + const config = readSub2ApiConfig(); + const target = resolveTarget(config, targetId); + return { + id: target.id, + route: target.route, + namespace: target.namespace, + runtimeMode: target.runtimeMode, + hostDockerAppPort: target.hostDocker?.appPort ?? null, + hostDockerEnvPath: target.hostDocker?.envPath ?? null, + appSecretName: config.runtime.database.secretName, + }; +} + +function pyJson(value: unknown): string { + return `json.loads(${JSON.stringify(JSON.stringify(value))})`; +} + +function remoteQueryScript(target: Sub2ApiOpsTarget, options: Sub2ApiOpsOptions): string { + return ` +set -u +python3 - <<'PY' +import base64 +import json +import subprocess +import sys +from datetime import datetime +from urllib.parse import urlencode + +TARGET = ${pyJson(target)} +OPTIONS = ${pyJson(options)} + +def run(command, input_bytes=None): + return subprocess.run(command, input=input_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + +def docker(args): + proc = run(["docker", *args]) + if proc.returncode == 0: + return proc + sudo_proc = run(["sudo", "-n", "docker", *args]) + return sudo_proc if sudo_proc.returncode == 0 else proc + +def runtime_logs(since="24h", tail=20000): + if TARGET["runtimeMode"] == "host-docker": + return docker(["logs", f"--since={since}", f"--tail={tail}", "sub2api-app"]) + return run(["kubectl", "-n", TARGET["namespace"], "logs", "deployment/sub2api", f"--since={since}", f"--tail={tail}"]) + +def tail_text(value, limit=800): + if isinstance(value, bytes): + value = value.decode("utf-8", errors="replace") + return str(value)[-limit:] + +def kube_json(args, label): + proc = run(["kubectl", *args, "-o", "json"]) + if proc.returncode != 0: + raise RuntimeError(f"{label} failed: {tail_text(proc.stderr)}") + return json.loads(proc.stdout.decode("utf-8")) + +def read_host_env(): + path = TARGET.get("hostDockerEnvPath") + try: + with open(path, "r", encoding="utf-8") as handle: + lines = handle.read().splitlines() + except PermissionError: + proc = run(["sudo", "-n", "cat", path]) + if proc.returncode != 0: + raise RuntimeError("read host env failed: " + tail_text(proc.stderr)) + lines = proc.stdout.decode("utf-8", errors="replace").splitlines() + values = {} + for line in lines: + text = line.strip() + if not text or text.startswith("#") or "=" not in text: + continue + key, value = text.split("=", 1) + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in ("'", '"'): + value = value[1:-1] + values[key.strip()] = value + return values + +def select_app_pod(): + if TARGET["runtimeMode"] == "host-docker": + return "sub2api-app" + pods = kube_json(["-n", TARGET["namespace"], "get", "pods", "-l", "app.kubernetes.io/name=sub2api"], "sub2api pods").get("items") or [] + running = [item for item in pods if (item.get("status") or {}).get("phase") == "Running"] + if not running: + raise RuntimeError("sub2api app pod not found") + return running[0]["metadata"]["name"] + +APP_POD = select_app_pod() + +def runtime_version(): + if TARGET["runtimeMode"] == "host-docker": + proc = docker(["inspect", "--format", "{{.Config.Image}}", "sub2api-app"]) + image = proc.stdout.decode("utf-8", errors="replace").strip() if proc.returncode == 0 else "" + else: + pod = kube_json(["-n", TARGET["namespace"], "get", "pod", APP_POD], "sub2api pod") + containers = ((pod.get("spec") or {}).get("containers") or []) + image = containers[0].get("image") if containers else "" + tag = image.rsplit(":", 1)[1] if isinstance(image, str) and ":" in image else None + return {"image": image or None, "version": tag} + +def config_value(key): + if TARGET["runtimeMode"] == "host-docker": + return read_host_env().get(key) + data = kube_json(["-n", TARGET["namespace"], "get", "configmap", "sub2api-config"], "configmap/sub2api-config").get("data") or {} + return data.get(key) + +def secret_value(key): + if TARGET["runtimeMode"] == "host-docker": + return read_host_env().get(key) + data = kube_json(["-n", TARGET["namespace"], "get", "secret", TARGET["appSecretName"]], "sub2api secret").get("data") or {} + return base64.b64decode(data[key]).decode("utf-8") if key in data else None + +def parse_curl(proc): + output = proc.stdout.decode("utf-8", errors="replace") + marker = "\\n__HTTP_CODE__:" + position = output.rfind(marker) + if position < 0: + return {"ok": False, "httpStatus": 0, "json": None, "body": output, "stderr": tail_text(proc.stderr)} + body = output[:position] + try: + status = int(output[position + len(marker):].strip()[-3:]) + except ValueError: + status = 0 + try: + parsed = json.loads(body) if body.strip() else None + except json.JSONDecodeError: + parsed = None + return {"ok": proc.returncode == 0 and 200 <= status < 300, "httpStatus": status, "json": parsed, "body": body, "stderr": tail_text(proc.stderr)} + +def curl_api(method, path, bearer=None, payload=None): + body = b"" if payload is None else json.dumps(payload, separators=(",", ":")).encode("utf-8") + script = r''' +set -eu +method="$1" +url="$2" +token="\${3:-}" +tmp="$(mktemp)" +trap 'rm -f "$tmp"' EXIT +cat > "$tmp" +if [ -n "$token" ] && [ "$method" = GET ] && [ ! -s "$tmp" ]; then + curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H "Authorization: Bearer $token" "$url" +elif [ -n "$token" ]; then + curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' -H "Authorization: Bearer $token" --data-binary @"$tmp" "$url" +elif [ "$method" = GET ] && [ ! -s "$tmp" ]; then + curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" "$url" +else + curl -sS --max-time 20 -w '\n__HTTP_CODE__:%{http_code}' -X "$method" -H 'Content-Type: application/json' --data-binary @"$tmp" "$url" +fi +''' + if TARGET["runtimeMode"] == "host-docker": + command = ["sh", "-c", script, "sh", method, f"http://127.0.0.1:{TARGET['hostDockerAppPort']}{path}", bearer or ""] + else: + command = ["kubectl", "-n", TARGET["namespace"], "exec", "-i", APP_POD, "--", "sh", "-c", script, "sh", method, f"http://127.0.0.1:8080{path}", bearer or ""] + return parse_curl(run(command, body)) + +def data_of(response, label): + parsed = response.get("json") + code = parsed.get("code") if isinstance(parsed, dict) else None + if response.get("ok") is not True or (code is not None and code != 0): + message = parsed.get("message") if isinstance(parsed, dict) else tail_text(response.get("body"), 400) + raise RuntimeError(f"{label} failed: http={response.get('httpStatus')} message={message}") + return parsed.get("data") if isinstance(parsed, dict) and "data" in parsed else parsed + +def items_of(data): + if isinstance(data, list): + return data + if isinstance(data, dict) and isinstance(data.get("items"), list): + return data["items"] + return [] + +def get(token, path, params=None, label="GET"): + suffix = "?" + urlencode(params) if params else "" + return data_of(curl_api("GET", path + suffix, bearer=token), label) + +def login(): + email = config_value("ADMIN_EMAIL") + password = secret_value("ADMIN_PASSWORD") + if not email or not password: + raise RuntimeError("Sub2API admin credentials are unavailable") + data = data_of(curl_api("POST", "/api/v1/auth/login", payload={"email": email, "password": password}), "admin login") + token = data.get("access_token") or data.get("token") if isinstance(data, dict) else None + if not token: + raise RuntimeError("admin login response has no token") + return token + +def find_named(items, selector, label): + if selector is None: + return items + text = str(selector).strip().lower() + numeric = int(text) if text.isdigit() else None + matches = [item for item in items if (numeric is not None and item.get("id") == numeric) or str(item.get("id", "")).strip().lower() == text or str(item.get("name", "")).strip().lower() == text] + if not matches: + available = [f"{item.get('id')}:{item.get('name')}" for item in items] + raise RuntimeError(f"unknown {label} {selector}; available={available}") + return matches + +def log_item(line): + json_start = line.find("{") + if json_start < 0: + return None + try: + item = json.loads(line[json_start:]) + except Exception: + return None + if not isinstance(item, dict): + return None + prefix = line[:json_start].strip().split() + item["_at"] = prefix[0] if prefix else None + item["_message"] = " ".join(prefix[3:]) if len(prefix) >= 4 else " ".join(prefix[2:]) + return item + +def epoch_of(value): + if not isinstance(value, str) or not value: + return None + try: + return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() + except Exception: + return None + +def account_names(token, platform): + params = {"page": 1, "page_size": 500} + if platform: + params["platform"] = platform + rows = items_of(get(token, "/api/v1/admin/accounts", params, "list accounts for monitor correlation")) + return {item.get("id"): item.get("name") for item in rows if isinstance(item, dict) and item.get("id") is not None} + +def correlate_record(token, record, monitor): + checked_at = record.get("checked_at") + checked_epoch = epoch_of(checked_at) + latency_ms = record.get("latency_ms") + if checked_epoch is None or not isinstance(latency_ms, (int, float)): + return {"status": "unsupported", "reason": "native record has no usable checked_at/latency_ms", "candidates": []} + expected_start = checked_epoch - latency_ms / 1000.0 + record_model = str(record.get("model") or "") + request_rows = items_of(get(token, "/api/v1/admin/ops/requests", {"time_range": "24h", "page": 1, "page_size": 500, "kind": "all"}, "list request correlation candidates")) + request_candidates = [] + for item in request_rows: + request_id = item.get("request_id") + created_epoch = epoch_of(item.get("created_at")) + model_match = not record_model or str(item.get("model") or "") == record_model + if not isinstance(request_id, str) or not request_id or created_epoch is None or not model_match: + continue + start_delta_ms = round(abs(created_epoch - expected_start) * 1000) + if start_delta_ms <= 15000: + request_candidates.append((request_id, item, start_delta_ms)) + proc = runtime_logs() + names = account_names(token, monitor.get("provider")) + grouped = {} + candidate_ids = {item[0] for item in request_candidates} + for line in proc.stdout.decode("utf-8", errors="replace").splitlines() if proc.returncode == 0 else []: + item = log_item(line) + if item is None: + continue + request_id = item.get("request_id") + at_epoch = epoch_of(item.get("_at")) + if not isinstance(request_id, str) or not request_id: + continue + if request_id not in candidate_ids and (at_epoch is None or at_epoch < expected_start - 15 or at_epoch > checked_epoch + 90): + continue + grouped.setdefault(request_id, []).append(item) + candidates = [] + rows_by_id = {item[0]: (item[1], item[2]) for item in request_candidates} + all_request_ids = set(grouped.keys()) | set(rows_by_id.keys()) + for request_id in all_request_ids: + events = grouped.get(request_id, []) + events.sort(key=lambda item: epoch_of(item.get("_at")) or 0) + request_row, row_delta_ms = rows_by_id.get(request_id, ({}, None)) + first = events[0] if events else None + models = [str(item.get("model")) for item in events if item.get("model")] + first_epoch = epoch_of(first.get("_at")) if first is not None else epoch_of(request_row.get("created_at")) + start_delta_ms = row_delta_ms if row_delta_ms is not None else round(abs((first_epoch or expected_start) - expected_start) * 1000) + model_match = not record_model or record_model in models or str(request_row.get("model") or "") == record_model + if start_delta_ms > 15000 or not model_match: + continue + failovers = [item for item in events if "upstream_failover_switching" in str(item.get("_message") or "")] + select_failures = [item for item in events if "account_select_failed" in str(item.get("_message") or "")] + upstream_errors = [item for item in events if "account_upstream_error" in str(item.get("_message") or "")] + finals = [item for item in events if "http request completed" in str(item.get("_message") or "")] + final = finals[-1] if finals else None + account_ids = [] + for item in events: + account_id = item.get("account_id") + if isinstance(account_id, str) and account_id.isdigit(): + account_id = int(account_id) + if isinstance(account_id, int) and account_id not in account_ids: + account_ids.append(account_id) + candidates.append({ + "requestId": request_id, + "match": {"kind": "native-request-start-window", "startDeltaMs": start_delta_ms, "modelMatched": model_match}, + "firstAt": first.get("_at") if first is not None else request_row.get("created_at"), + "lastAt": events[-1].get("_at") if events else request_row.get("created_at"), + "model": next((item for item in models if item), request_row.get("model")), + "apiKeyName": next((item.get("api_key_name") for item in events if item.get("api_key_name")), None), + "accounts": [{"id": account_id, "name": names.get(account_id)} for account_id in account_ids] or ([{"id": request_row.get("account_id"), "name": request_row.get("account_name") or names.get(request_row.get("account_id"))}] if request_row.get("account_id") is not None else []), + "upstreamErrors": [{"at": item.get("_at"), "accountId": item.get("account_id"), "error": item.get("error")} for item in upstream_errors], + "failovers": [{"at": item.get("_at"), "accountId": item.get("account_id"), "upstreamStatus": item.get("upstream_status"), "switchCount": item.get("switch_count"), "maxSwitches": item.get("max_switches")} for item in failovers], + "selectFailures": [{"at": item.get("_at"), "error": item.get("error"), "excludedAccountCount": item.get("excluded_account_count")} for item in select_failures], + "final": {"at": request_row.get("created_at"), "statusCode": request_row.get("status_code") or request_row.get("upstream_status_code"), "accountId": request_row.get("account_id"), "latencyMs": request_row.get("duration_ms"), "path": request_row.get("path")} if final is None else {"at": final.get("_at"), "statusCode": final.get("status_code"), "accountId": final.get("account_id"), "latencyMs": final.get("latency_ms"), "path": final.get("path")}, + }) + candidates.sort(key=lambda item: item["match"]["startDeltaMs"]) + if not candidates: + return {"status": "unsupported", "reason": "no unique native request_id is available in the record; bounded log correlation found no candidate", "candidates": []} + best_delta = candidates[0]["match"]["startDeltaMs"] + best = [item for item in candidates if item["match"]["startDeltaMs"] == best_delta] + return {"status": "inferred" if len(best) == 1 else "ambiguous", "reason": "heuristic correlation by record start window and model; the native monitor record has no request_id" if len(best) == 1 else "multiple requests have the same nearest start time", "selected": best[0] if len(best) == 1 else None, "candidates": candidates[:5]} + +def diagnosis(token): + group_params = {"platform": OPTIONS["platform"]} if OPTIONS.get("platform") else None + groups = items_of(get(token, "/api/v1/admin/groups/all", group_params, "list groups")) + groups = find_named(groups, OPTIONS.get("group"), "group") + snapshots = [] + selected = groups if groups else [{"id": None, "name": "all", "platform": OPTIONS.get("platform") or "all"}] + for group in selected: + params = {"time_range": OPTIONS["timeRange"]} + platform = OPTIONS.get("platform") or group.get("platform") + if platform and platform != "all": + params["platform"] = platform + if group.get("id"): + params["group_id"] = group["id"] + overview = get(token, "/api/v1/admin/ops/dashboard/overview", params, "ops dashboard overview") + snapshots.append({"group": {"id": group.get("id"), "name": group.get("name"), "platform": platform or "all"}, "overview": overview}) + evidence = None + diagnosis_id = OPTIONS.get("diagnosisId") + if diagnosis_id: + scope, _, category = diagnosis_id.partition(":") + group = next((item["group"] for item in snapshots if ("g" + str(item["group"].get("id"))) == scope or scope == "all"), None) + if group is None: + raise RuntimeError(f"diagnosis id scope not found: {diagnosis_id}") + params = {"time_range": OPTIONS["timeRange"], "page": 1, "page_size": 20} + if group.get("id"): + params["group_id"] = group["id"] + if group.get("platform") and group.get("platform") != "all": + params["platform"] = group["platform"] + if category.startswith("upstream-"): + endpoint = "/api/v1/admin/ops/upstream-errors" + elif category.startswith("error-") or category.startswith("sla-"): + endpoint = "/api/v1/admin/ops/request-errors" + else: + endpoint = "/api/v1/admin/ops/requests" + params["kind"] = "all" + params["sort"] = "duration_desc" + evidence = {"diagnosisId": diagnosis_id, "records": items_of(get(token, endpoint, params, "diagnosis evidence"))} + return {"groups": snapshots, "evidence": evidence} + +def channels(token): + monitors = items_of(get(token, "/api/v1/channel-monitors", None, "list channel monitors")) + if OPTIONS.get("platform"): + monitors = [item for item in monitors if str(item.get("provider", "")).lower() == OPTIONS["platform"].lower()] + monitors = find_named(monitors, OPTIONS.get("channel"), "channel") + output = [] + for monitor in monitors: + detail = get(token, f"/api/v1/channel-monitors/{monitor['id']}/status", None, "channel monitor status") + output.append({"summary": monitor, "detail": detail}) + history = None + correlation = None + if OPTIONS.get("channel") and output: + monitor_id = output[0]["summary"]["id"] + params = {"limit": 1000} + if OPTIONS.get("model"): + params["model"] = OPTIONS["model"] + history = items_of(get(token, f"/api/v1/admin/channel-monitors/{monitor_id}/history", params, "channel monitor history")) + record_id = OPTIONS.get("recordId") + if record_id: + selected = next((item for item in history if str(item.get("id")) == str(record_id)), None) + if selected is not None: + correlation = correlate_record(token, selected, output[0]["summary"]) + return {"channels": output, "history": history, "correlation": correlation} + +try: + token = login() + data = diagnosis(token) if OPTIONS["action"] == "diagnosis" else channels(token) + data["runtimeVersion"] = runtime_version() + print(json.dumps({"ok": True, "sourceStatus": "available", "data": data}, ensure_ascii=False, separators=(",", ":"))) +except Exception as error: + print(json.dumps({"ok": False, "sourceStatus": "unavailable", "error": str(error), "data": None}, ensure_ascii=False, separators=(",", ":"))) + sys.exit(1) +PY +`; +} diff --git a/scripts/src/platform-infra-sub2api-ops/render.ts b/scripts/src/platform-infra-sub2api-ops/render.ts new file mode 100644 index 00000000..c2d8fed9 --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/render.ts @@ -0,0 +1,207 @@ +import type { RenderedCliResult } from "../output"; + +export function renderSub2ApiOps(result: Record): RenderedCliResult { + const action = String(result.action ?? "platform-infra-sub2api-ops"); + const lines = action.endsWith("diagnosis") ? renderDiagnosis(result) : renderChannels(result); + return { + ok: result.ok !== false, + command: action.replaceAll("-", " "), + renderedText: lines.join("\n"), + contentType: "text/plain", + }; +} + +function renderDiagnosis(result: Record): string[] { + const source = record(result.source); + const groups = arrayRecords(result.groups); + const rows = groups.flatMap((group) => arrayRecords(group.diagnoses).map((item) => { + const metric = record(item.metric); + return [ + stringValue(item.id), + stringValue(item.severity).toUpperCase(), + `${stringValue(metric.value)} ${stringValue(metric.unit)}`.trim(), + stringValue(item.message), + `${stringValue(group.groupName)} / ${stringValue(group.platform)}`, + ]; + })); + const lines = [ + "PLATFORM-INFRA SUB2API OPS DIAGNOSIS", + ...table(["ID", "SEVERITY", "VALUE", "DIAGNOSIS", "SCOPE"], rows), + "", + "SOURCE", + ...table(["FIELD", "VALUE"], [ + ["status", stringValue(source.status)], + ["upstreamVersion", stringValue(source.upstreamVersion)], + ["metrics", stringValue(source.metricsEndpoint)], + ["advice", stringValue(source.projectionKind)], + ["nativeDiagnosisEndpoint", stringValue(record(source.nativeDiagnosisEndpoint).status)], + ]), + ]; + const selected = record(result.selectedDiagnosis); + if (Object.keys(selected).length > 0) { + const metric = record(selected.metric); + lines.push( + "", + "DETAIL", + ...table(["FIELD", "VALUE"], [ + ["id", stringValue(selected.id)], + ["category", stringValue(selected.category)], + ["severity", stringValue(selected.severity)], + ["metric", `${stringValue(metric.name)}=${stringValue(metric.value)} ${stringValue(metric.unit)}`.trim()], + ["impact", stringValue(selected.impact)], + ["nativeAdvice", stringValue(selected.advice, "-")], + ["inference", stringValue(selected.inferenceStatus)], + ]), + ); + const evidence = record(result.evidence); + const evidenceRows = arrayRecords(evidence.records).map((item) => [ + stringValue(item.requestId, "-"), + stringValue(item.createdAt, "-"), + stringValue(item.accountName, stringValue(item.accountId, "-")), + stringValue(item.statusCode, "-"), + stringValue(item.message, "-").replace(/\s+/gu, " ").slice(0, 80), + ]); + lines.push("", "RELATED EVIDENCE", ...(evidenceRows.length === 0 ? ["-"] : table(["REQUEST_ID", "AT", "ACCOUNT", "STATUS", "SUMMARY"], evidenceRows))); + } + lines.push( + "", + "NEXT", + " detail: bun scripts/cli.ts platform-infra sub2api ops diagnosis --id ", + " json: bun scripts/cli.ts platform-infra sub2api ops diagnosis --json", + "", + `Evidence boundary: ${stringValue(source.evidenceBoundary)}`, + "Disclosure: one-shot read-only snapshot; no foreground refresh, Secret, token, or full log output.", + ); + if (result.error) lines.push(`Error: ${stringValue(result.error)}`); + return lines; +} + +function renderChannels(result: Record): string[] { + const source = record(result.source); + const channels = arrayRecords(result.channels); + const rows = channels.map((channel) => { + const primary = record(channel.primary); + return [ + stringValue(channel.id), + stringValue(channel.name), + stringValue(channel.provider), + stringValue(primary.model), + stringValue(channel.status), + stringValue(primary.latencyMs, "-"), + stringValue(primary.pingLatencyMs, "-"), + formatAvailability(primary.availability), + stringValue(channel.recentRecordCount, "0"), + stringValue(channel.collectedAt, "-"), + ]; + }); + const lines = [ + "PLATFORM-INFRA SUB2API OPS CHANNELS", + ...table(["ID", "CHANNEL", "PLATFORM", "MODEL", "STATUS", "LATENCY_MS", "PING_MS", "AVAIL", "RECORDS", "COLLECTED_AT"], rows), + "", + "SUMMARY", + ...table(["FIELD", "VALUE"], [ + ["overallStatus", stringValue(result.overallStatus)], + ["window", stringValue(record(result.query).window)], + ["sourceStatus", stringValue(source.status)], + ["refreshRemaining", stringValue(record(source.refreshRemaining).status)], + ]), + ]; + const history = record(result.history); + const historyRows = arrayRecords(history.records).map((item) => [ + stringValue(item.id), + stringValue(item.model), + stringValue(item.status), + stringValue(item.latencyMs, "-"), + stringValue(item.pingLatencyMs, "-"), + stringValue(item.checkedAt, "-"), + ]); + if (historyRows.length > 0) { + lines.push( + "", + "PAST → NOW", + ...table(["ID", "MODEL", "STATUS", "LATENCY_MS", "PING_MS", "CHECKED_AT"], historyRows), + `pageSize=${stringValue(history.pageSize)} moreAvailable=${stringValue(history.moreAvailable)} nextPageToken=${stringValue(history.nextPageToken)}`, + ); + } + const selectedRecord = record(history.selectedRecord); + if (Object.keys(selectedRecord).length > 0) { + const timeout = record(selectedRecord.timeout); + const correlation = record(selectedRecord.correlation); + const final = record(correlation.final); + const accounts = arrayRecords(correlation.accounts); + const failovers = arrayRecords(correlation.failovers); + const selectFailures = arrayRecords(correlation.selectFailures); + lines.push( + "", + "RECORD", + ...table(["FIELD", "VALUE"], [ + ["id", stringValue(selectedRecord.id)], + ["model", stringValue(selectedRecord.model)], + ["status", stringValue(selectedRecord.status)], + ["latencyMs", stringValue(selectedRecord.latencyMs)], + ["pingLatencyMs", stringValue(selectedRecord.pingLatencyMs)], + ["checkedAt", stringValue(selectedRecord.checkedAt)], + ["message", stringValue(selectedRecord.message)], + ["runtimeVersion", stringValue(timeout.runtimeVersion)], + ["sourceReferenceVersion", stringValue(timeout.sourceReferenceVersion)], + ["runtimePolicyStatus", stringValue(timeout.runtimePolicyStatus)], + ["sourceResponseHeaderMs", stringValue(timeout.responseHeaderTimeoutMs)], + ["sourceTotalRequestMs", stringValue(timeout.totalRequestTimeoutMs)], + ["remainingAfterHeaderMs", stringValue(timeout.remainingAfterResponseHeaderMs)], + ["correlationStatus", stringValue(correlation.status)], + ["requestId", stringValue(correlation.requestId)], + ["accounts", accounts.map((item) => `${stringValue(item.id)}:${stringValue(item.name)}`).join(",") || "-"], + ["failoverCount", String(failovers.length)], + ["selectFailureCount", String(selectFailures.length)], + ["finalStatus", stringValue(final.statusCode)], + ["finalLatencyMs", stringValue(final.latencyMs)], + ["timeoutSufficient", stringValue(correlation.sufficientForObservedCompletion)], + ["sufficiencyStatus", stringValue(correlation.sufficiencyStatus)], + ]), + ); + if (correlation.traceNext) lines.push(`Trace: ${stringValue(correlation.traceNext)}`); + if (correlation.reason) lines.push(`Correlation: ${stringValue(correlation.reason)}`); + } + lines.push( + "", + "NEXT", + " channel: bun scripts/cli.ts platform-infra sub2api ops channels --channel ", + " window: bun scripts/cli.ts platform-infra sub2api ops channels --window 7d|15d|30d", + " older page: bun scripts/cli.ts platform-infra sub2api ops channels --channel --page-token ", + " record: bun scripts/cli.ts platform-infra sub2api ops channels --channel --record ", + " json: bun scripts/cli.ts platform-infra sub2api ops channels --json", + "", + "Disclosure: one-shot read-only snapshot; native response has no refresh countdown, and the CLI does not simulate one.", + ); + if (result.error) lines.push(`Error: ${stringValue(result.error)}`); + return lines; +} + +function formatAvailability(value: unknown): string { + return typeof value === "number" && Number.isFinite(value) ? `${value.toFixed(2)}%` : stringValue(value, "-"); +} + +function table(headers: string[], rows: string[][]): string[] { + if (rows.length === 0) return ["-"]; + const widths = headers.map((header, index) => Math.max(header.length, ...rows.map((row) => visibleLength(row[index] ?? "")))); + const format = (row: string[]): string => row.map((cell, index) => `${cell}${" ".repeat(Math.max(0, widths[index]! - visibleLength(cell)))}`).join(" ").trimEnd(); + return [format(headers), format(widths.map((width) => "-".repeat(width))), ...rows.map(format)]; +} + +function visibleLength(value: string): number { + return [...value].length; +} + +function record(value: unknown): Record { + return value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +function arrayRecords(value: unknown): Record[] { + return Array.isArray(value) ? value.map(record) : []; +} + +function stringValue(value: unknown, fallback = "-"): string { + if (typeof value === "string") return value.length > 0 ? value : fallback; + if (typeof value === "number" || typeof value === "boolean") return String(value); + return fallback; +} diff --git a/scripts/src/platform-infra-sub2api-ops/types.ts b/scripts/src/platform-infra-sub2api-ops/types.ts new file mode 100644 index 00000000..1bd792cb --- /dev/null +++ b/scripts/src/platform-infra-sub2api-ops/types.ts @@ -0,0 +1,26 @@ +export type Sub2ApiOpsAction = "diagnosis" | "channels"; + +export interface Sub2ApiOpsOptions { + action: Sub2ApiOpsAction; + targetId: string; + json: boolean; + platform: string | null; + group: string | null; + diagnosisId: string | null; + timeRange: "5m" | "30m" | "1h" | "6h" | "24h"; + channel: string | null; + model: string | null; + window: "7d" | "15d" | "30d"; + pageToken: string | null; + recordId: string | null; +} + +export interface Sub2ApiOpsTarget { + id: string; + route: string; + namespace: string; + runtimeMode: "k3s" | "host-docker"; + hostDockerAppPort: number | null; + hostDockerEnvPath: string | null; + appSecretName: string; +} diff --git a/scripts/src/platform-infra/entry.ts b/scripts/src/platform-infra/entry.ts index d9116132..be0979fe 100644 --- a/scripts/src/platform-infra/entry.ts +++ b/scripts/src/platform-infra/entry.ts @@ -417,10 +417,13 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api apply [--target G14|D601] --confirm", "bun scripts/cli.ts platform-infra sub2api status [--target G14|D601] [--full|--raw]", "bun scripts/cli.ts platform-infra sub2api validate [--target G14|D601] [--full|--raw]", + "bun scripts/cli.ts platform-infra sub2api ops diagnosis [--target PK01] [--platform ] [--group ] [--time-range 5m|30m|1h|6h|24h] [--id ] [--json]", + "bun scripts/cli.ts platform-infra sub2api ops channels [--target PK01] [--platform ] [--channel ] [--model ] [--window 7d|15d|30d] [--page-token |--record ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool plan", "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", + "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-probe --account unidesk-codex-hy --confirm", "bun scripts/cli.ts platform-infra sub2rank plan [--target NC01]", @@ -503,6 +506,7 @@ export function platformInfraHelp(): unknown { "bun scripts/cli.ts platform-infra sub2api codex-pool sync --confirm", "bun scripts/cli.ts platform-infra sub2api codex-pool validate", "bun scripts/cli.ts platform-infra sub2api codex-pool trace --request-id ", + "bun scripts/cli.ts platform-infra sub2api codex-pool feedback --user [--window 30m] [--page-token |--request-id ] [--json]", "bun scripts/cli.ts platform-infra sub2api codex-pool sentinel-image status", ], module: "scripts/src/platform-infra-sub2api-codex.ts", diff --git a/scripts/src/platform-infra/options.ts b/scripts/src/platform-infra/options.ts index 24482b49..c4c049ce 100644 --- a/scripts/src/platform-infra/options.ts +++ b/scripts/src/platform-infra/options.ts @@ -108,6 +108,10 @@ export async function runPlatformInfraCommand(config: UniDeskConfig, args: strin return options.full || options.raw ? result : renderSub2ApiStatus(result); } if (action === "validate") return await validate(config, parseDisclosureOptions(args.slice(2))); + if (action === "ops") { + const { runSub2ApiOpsCommand } = await import("../platform-infra-sub2api-ops"); + return await runSub2ApiOpsCommand(config, args.slice(2)); + } if (action === "codex-pool") { const { runCodexPoolCommand } = await import("../platform-infra-sub2api-codex"); return await runCodexPoolCommand(config, args.slice(2));