Compare commits
No commits in common. "490160189ef306bcbbed9d829a8c34e89bd105a4" and "12d3abe1f40d2462b992cd115ae51ee6d2088ad4" have entirely different histories.
490160189e
...
12d3abe1f4
40
README.md
40
README.md
@ -15,18 +15,15 @@
|
|||||||
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
|
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
|
||||||
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
|
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
|
||||||
- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、更新登录 SSE、FFLogs 命令、系统日志可视化、Web/Playground 回跳。
|
- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、更新登录 SSE、FFLogs 命令、系统日志可视化、Web/Playground 回跳。
|
||||||
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、实体 OUI 风格 MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。
|
|
||||||
- 生成 NapCatQQ 上游同步与 runtime 发布自动化入口:MCP/CLI 默认 dry-run,覆盖 upstream audit、sync candidate review、runtime readiness、remote dev handoff 和 NAS Codex bootstrap plan;upstream audit 在显式 `execute=true` 且 `useCodex=true` 时可运行 Codex,NAS Codex bootstrap 在显式 `execute=true` 时只返回目录准备、nvm 管理的 Node 22.14.0 / pnpm 10.28.2 / Codex CLI 安装计划,不复制 secrets、不写 systemd、不触发发布。
|
|
||||||
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
|
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
|
||||||
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。
|
- 生成改动文档同步计划:按变更文件自动提示需要同步的 README、API、AGENTS、docs、Obsidian、skill 和 ktWorkflow 入口。
|
||||||
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
|
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
|
||||||
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。
|
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 NAS 服务探测、GTID、`.kt-workspace/db-sync` 转储、备份库和行数校验。
|
||||||
- 生成或执行部署观测:把 Jenkins build、`build.xml` SCM revision、日志尾部、K8s Deployment、Pod、`/health/runtime` 和任务 smoke 汇总成 `.kt-workspace/test-artifacts/deploy-observation` 下的运行态证据;Jenkins 状态以日志尾部最终 `Finished:` 为准,日志存在但缺少最终态时不认定发布完成。
|
|
||||||
- 生成专项组件工作流:KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。
|
- 生成专项组件工作流:KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 的防踩坑清单和验证点。
|
||||||
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
|
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
|
||||||
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。
|
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。
|
||||||
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
|
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
|
||||||
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
|
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
|
||||||
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
|
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
|
||||||
- 生成提交前检查清单:校验 KT commit message 约定。
|
- 生成提交前检查清单:校验 KT commit message 约定。
|
||||||
|
|
||||||
@ -41,29 +38,13 @@ pnpm run obsidian-context -- --module ktWorkflow
|
|||||||
pnpm run obsidian-validate
|
pnpm run obsidian-validate
|
||||||
pnpm run obsidian-sync
|
pnpm run obsidian-sync
|
||||||
pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法"
|
pnpm run workstream-closeout -- --title "发布闭环" --verification "Jenkins SUCCESS" --doc-sync "无需文档更新" --cleanup "cleanup-history dry-run deleted=0" --cleanup-final-deleted 0 --review "global-review findings=0" --superpowers-review "Superpowers reviewer completed; no Critical/Important findings" --problem "无新卡点" --solution "无新增稳定解法"
|
||||||
pnpm run napcat-upstream-audit -- --artifact-root .kt-workspace/test-artifacts/napcat-upstream-sync
|
|
||||||
pnpm run napcat-sync-candidate-review
|
|
||||||
pnpm run napcat-runtime-release-readiness
|
|
||||||
pnpm run napcat-remote-dev-handoff
|
|
||||||
pnpm run nas-codex-bootstrap
|
|
||||||
pnpm run napcat-device-profile-check
|
|
||||||
pnpm run cleanup-history -- --dry-run
|
pnpm run cleanup-history -- --dry-run
|
||||||
pnpm run cleanup-history -- --execute
|
pnpm run cleanup-history -- --execute
|
||||||
pnpm run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"
|
|
||||||
pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||||
```
|
```
|
||||||
|
|
||||||
如果本机 Node/npm/pnpm 版本不对,先执行 `nvm ls` 查看已安装版本,再用 `nvm use <version>` 切换到目标版本;不要先扫盘找 `node.exe` 路径。
|
如果本机 Node/npm/pnpm 版本不对,先执行 `nvm ls` 查看已安装版本,再用 `nvm use <version>` 切换到目标版本;不要先扫盘找 `node.exe` 路径。
|
||||||
|
|
||||||
## Jenkins 入口模板
|
|
||||||
|
|
||||||
- `ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile` 是 NapCatQQ 上游审计入口,默认只调用 ktWorkflow 的 `napcat-upstream-audit` dry-run,保留人工审查边界。
|
|
||||||
- Codex 上游审计必须同时设置 `RUN_CODEX_AUDIT=true` 并填写 `CONFIRM_CODEX_WORKSPACE_WRITE=RUN_CODEX_WORKSPACE_WRITE_AUDIT`;这是因为 Codex runner 使用 workspace-write sandbox,不能作为定时任务默认行为。
|
|
||||||
- `ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile` 是运行时发布入口,先调用 `napcat-runtime-release-readiness`,再通过显式参数触发运行时镜像构建和 API promotion;下游 job 使用白名单 choice,避免任意 Jenkins job 名被传入。
|
|
||||||
- 生产 API promotion 除了 `PROMOTE_API_RUNTIME=true` 和明确 image/profile 参数外,还必须填写 `CONFIRM_API_PROMOTION_TARGET=PROMOTE_KT_TEMPLATE_API_MAIN`。
|
|
||||||
- 两个模板默认使用 NAS 工作区 `/vol1/docker/kt-codex/workspace/KT`,运行证据落在 `/vol1/docker/kt-codex/artifacts` 下。
|
|
||||||
- 模板只保存入口和参数形状,不记录 Jenkins credential、Token、SSH key 或其他真实凭据。
|
|
||||||
|
|
||||||
## MCP 客户端配置
|
## MCP 客户端配置
|
||||||
|
|
||||||
把下面配置加入支持 stdio MCP 的客户端配置里:
|
把下面配置加入支持 stdio MCP 的客户端配置里:
|
||||||
@ -110,15 +91,8 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
|||||||
| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 |
|
| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 |
|
||||||
| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 |
|
| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 |
|
||||||
| `kt_business_test_plan` | 生成固化业务链路测试计划,包含 QQBot SSE、FFLogs 和系统日志 |
|
| `kt_business_test_plan` | 生成固化业务链路测试计划,包含 QQBot SSE、FFLogs 和系统日志 |
|
||||||
| `kt_napcat_upstream_audit` | 生成或执行 NapCatQQ upstream release 审计,默认 dry-run,显式 `execute/useCodex` 后才运行 Codex |
|
|
||||||
| `kt_napcat_sync_candidate_review` | 生成 NapCat sync candidate review 的 prompt/context/只读命令骨架,Task 5 不执行 Codex 或 Git 写操作 |
|
|
||||||
| `kt_napcat_runtime_release_readiness` | 生成 NapCat runtime release readiness 的 prompt/context/只读检查骨架,不自动构建或发布 |
|
|
||||||
| `kt_napcat_remote_dev_handoff` | 生成 NapCat 自动化远程开发 handoff 的 prompt/context 骨架,不创建线程或同步仓库 |
|
|
||||||
| `kt_nas_codex_bootstrap_plan` | 生成可信 NAS Codex bootstrap dry-run/execute command plan;execute 只规划目录准备和 nvm 管理的 Node/pnpm/Codex 安装,不复制 secrets、不写 systemd |
|
|
||||||
| `kt_napcat_device_profile_check` | 静态检查 API NapCat 设备身份护栏,覆盖 hostname、MAC、`machine-info`、runtime dir 和 DB timezone |
|
|
||||||
| `kt_blocker_resolution` | 生成或写入卡点固化记录,提醒停止原样重试 |
|
| `kt_blocker_resolution` | 生成或写入卡点固化记录,提醒停止原样重试 |
|
||||||
| `kt_remote_health_check` | 生成或执行远程只读健康检查命令 |
|
| `kt_remote_health_check` | 生成或执行远程只读健康检查命令 |
|
||||||
| `kt_deploy_observation` | 生成或执行 API 发布后的部署观测,汇总 Jenkins SCM revision、K8s、Pod、`/health/runtime` 和任务 smoke 证据 |
|
|
||||||
| `kt_db_sync_plan` | 生成数据库同步安全向导 |
|
| `kt_db_sync_plan` | 生成数据库同步安全向导 |
|
||||||
| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流,包含 FnosK8s |
|
| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流,包含 FnosK8s |
|
||||||
| `kt_obsidian_context` | 读取 Obsidian 索引上下文,支持按 `module` / `query` 返回模块页、文档矩阵、Canvas 摘要、代码锚点和相关规则文档 |
|
| `kt_obsidian_context` | 读取 Obsidian 索引上下文,支持按 `module` / `query` 返回模块页、文档矩阵、Canvas 摘要、代码锚点和相关规则文档 |
|
||||||
@ -132,14 +106,7 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
|||||||
| 脚本 | 用途 |
|
| 脚本 | 用途 |
|
||||||
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
|
| `pnpm run admin-login` | 使用可见 Edge 打开 Admin 登录页,填写账号密码,拖动滑块,保存登录态和截图。默认账号来自初始化数据 `admin/123456`,生产或个人账号用 `KT_ADMIN_USERNAME` / `KT_ADMIN_PASSWORD` 或 CLI 参数覆盖。 |
|
||||||
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移、QQBot 重型服务单测导入风险、QQBot 插件 registry/controller smoke 未 mock 具体插件实现风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
|
| `pnpm run global-review` | 对 KT 全部子仓库做只读全局 CodeReview 扫描,默认仅对变更文件做内容深扫,输出 JSON 复审报告,并校验 `TASKS.md` 最近记录只保留范围、关键词、验证字段、NapCat `latest` 漂移、QQBot 重型服务单测导入风险、OneBot/QQ 登录态边界、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时及清理前 pending 回归,以及普通处理中状态误吃旧验证码日志回归;明显测试占位 token 不报凭据风险,确认误报时优先升级 `src/tools/review.ts`,不要把误报沉积到上下文。 |
|
||||||
| `pnpm run napcat-upstream-audit` | 默认 dry-run 输出 NapCatQQ 上游 release 审计分类、只读 Git 命令和 artifact 路径;显式 `--execute --use-codex` 才运行 Codex。 |
|
|
||||||
| `pnpm run napcat-sync-candidate-review` | 输出 sync candidate review 的 prompt/context/只读命令骨架,不执行 Codex 或 Git 写操作。 |
|
|
||||||
| `pnpm run napcat-runtime-release-readiness` | 输出 runtime release readiness 的 prompt/context/只读检查骨架,不构建镜像、不调用 kubectl、不发布。 |
|
|
||||||
| `pnpm run napcat-remote-dev-handoff` | 输出远程开发 handoff 的 prompt/context 骨架,不创建线程、不同步仓库、不执行 Codex。 |
|
|
||||||
| `pnpm run nas-codex-bootstrap` | 输出可信 NAS Codex bootstrap dry-run plan;传 `--execute` 只生成目录准备和 nvm 管理的 Node/pnpm/Codex 安装命令计划,不真实执行 SSH、不复制 secrets、不写 systemd。 |
|
|
||||||
| `pnpm run napcat-device-profile-check` | 静态检查 API NapCat 设备身份护栏,防止回退到未知设备风险配置。 |
|
|
||||||
| `pnpm run deploy-observation` | 默认 dry-run 输出只读 NAS 观测命令;传 `--execute` 时执行 Jenkins/K8s/health/smoke 观测并写入 `.kt-workspace/test-artifacts/deploy-observation`。 |
|
|
||||||
| `pnpm run obsidian-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 |
|
| `pnpm run obsidian-context` | 输出 Obsidian 索引上下文;可传 `--module Admin`、`--query QQBot`、`--max-documents 10`。 |
|
||||||
| `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 |
|
| `pnpm run obsidian-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 |
|
||||||
| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 |
|
| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 |
|
||||||
@ -178,15 +145,12 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
|||||||
- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review;需要执行验证时显式传 `runValidation=true`。
|
- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review;需要执行验证时显式传 `runValidation=true`。
|
||||||
- 大方向结束前调用 `kt_workstream_closeout` 或 `pnpm run workstream-closeout`;必须分别传入 `--review` 的 KT 全局审查证据和 `--superpowers-review` 的 Superpowers code review 证据;如果识别到测试流、卡点、误报、清理、部署观测或命令模板,要先升级对应 ktWorkflow 规则再报告完成。
|
- 大方向结束前调用 `kt_workstream_closeout` 或 `pnpm run workstream-closeout`;必须分别传入 `--review` 的 KT 全局审查证据和 `--superpowers-review` 的 Superpowers code review 证据;如果识别到测试流、卡点、误报、清理、部署观测或命令模板,要先升级对应 ktWorkflow 规则再报告完成。
|
||||||
- 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。`findings` 先判定真实风险或工具误报,真实风险修业务代码,误报修 `mcp/ktWorkflow/src/tools/review.ts` 并复跑。
|
- 文件改动完成并验证后调用 `kt_global_code_review`,或运行 `pnpm run global-review`;它只读扫描,不删除文件、不提交代码。`findings` 先判定真实风险或工具误报,真实风险修业务代码,误报修 `mcp/ktWorkflow/src/tools/review.ts` 并复跑。
|
||||||
- QQBot NapCat 登录 review 会检查验证码 pending、Docker 日志窗口、API Pod 读取超时,以及 `needNewDevice/jumpUrl` 是否进入 `GetNewDeviceQRCode -> PollNewDeviceQR -> NewDeviceLogin` 或兼容的 `deviceVerifyUrl` pending 状态。
|
|
||||||
- QQBot NapCat 二维码刷新卡在生成阶段时,优先计数当前容器近 5-20 分钟日志里的“重置已失效登录服务后重新生成二维码”、`Login Error,ErrType: 7 ErrCode: 4` 和“二维码已保存/二维码解码URL”;如果 reset/error 数远高于二维码产出,按 `docs/qqbot-nas-runtime.md` 的 v5 稳定解法排查 NapCat native login service reset 风暴,先修 fork/镜像,再看 API SSE。
|
|
||||||
- 代码或配置改动后调用 `kt_change_doc_sync`,把需要同步的 README/API/AGENTS/docs/Obsidian/skill/ktWorkflow 入口补齐;无需同步时把原因写进收尾证据。
|
- 代码或配置改动后调用 `kt_change_doc_sync`,把需要同步的 README/API/AGENTS/docs/Obsidian/skill/ktWorkflow 入口补齐;无需同步时把原因写进收尾证据。
|
||||||
- 报告非平凡任务完成前调用 `kt_workflow_loop_audit`,确认测试证据、文档同步、历史清理最终 `deleted=0`、问题固化、ktWorkflow 升级、KT 全局 review 和 Superpowers code review 都过门;清理证据必须显式传入,不能依赖缺省值。
|
- 报告非平凡任务完成前调用 `kt_workflow_loop_audit`,确认测试证据、文档同步、历史清理最终 `deleted=0`、问题固化、ktWorkflow 升级、KT 全局 review 和 Superpowers code review 都过门;清理证据必须显式传入,不能依赖缺省值。
|
||||||
- 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。
|
- 要提交或推送时先调用 `kt_commit_plan` / `kt_push_plan`,按仓库分组确认范围。
|
||||||
- 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply`、`qqbot-login-sse`、`fflogs-command`、`system-log-visualization` 等 flow。
|
- 要测真实业务链路时调用 `kt_business_test_plan`,选择 `admin-login`、`qqbot-auto-reply`、`qqbot-login-sse`、`fflogs-command`、`system-log-visualization` 等 flow。
|
||||||
- 遇到同一命令或同一远程步骤重复卡住时调用 `kt_blocker_resolution`;第二次仍失败时先写入 `TASKS.md` 或补成脚本,再继续。
|
- 遇到同一命令或同一远程步骤重复卡住时调用 `kt_blocker_resolution`;第二次仍失败时先写入 `TASKS.md` 或补成脚本,再继续。
|
||||||
- 远程服务排查先调用 `kt_remote_health_check`,默认只生成只读命令;需要执行时显式传 `execute=true`。
|
- 远程服务排查先调用 `kt_remote_health_check`,默认只生成只读命令;需要执行时显式传 `execute=true`。
|
||||||
- API 推送触发 Jenkins/K8s 后先运行 `pnpm run deploy-observation -- --project api --job KT-Template/KT-Template-API/main --namespace kt-prod --deployment kt-template-online-api --container api --health-url http://127.0.0.1:48085/health/runtime --smoke "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime"` 查看 dry-run 命令;确认需要线上只读观测时再加 `--execute`。Deployment/Pod 成功只算发布证据,功能完成还必须看 smoke 输出。
|
|
||||||
- 数据库同步前调用 `kt_db_sync_plan`,先确认源库、目标库、`.kt-workspace/db-sync` 转储目录、带时间戳的备份库和校验点。
|
- 数据库同步前调用 `kt_db_sync_plan`,先确认源库、目标库、`.kt-workspace/db-sync` 转储目录、带时间戳的备份库和校验点。
|
||||||
- 改 KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 前调用 `kt_component_workflow`,先看专项禁区。
|
- 改 KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 前调用 `kt_component_workflow`,先看专项禁区。
|
||||||
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
|
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
|
||||||
|
|||||||
@ -1,118 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
|
|
||||||
options {
|
|
||||||
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
|
|
||||||
disableConcurrentBuilds()
|
|
||||||
skipDefaultCheckout(true)
|
|
||||||
timestamps()
|
|
||||||
timeout(time: 60, unit: 'MINUTES')
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters {
|
|
||||||
string(name: 'NAPCAT_FORK_REF', defaultValue: 'codex/qr-refresh-login-state', description: 'Reviewed NapCatQQ fork branch, tag, or commit.')
|
|
||||||
string(name: 'UPSTREAM_RELEASE_TAG', defaultValue: '', description: 'Reviewed upstream release tag, when applicable.')
|
|
||||||
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', defaultValue: '', description: 'Explicit runtime image tag to build or promote, for example kt-napcat-desktop-cn:desktop-cn-v9.')
|
|
||||||
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', defaultValue: '', description: 'Explicit API desktop profile version override.')
|
|
||||||
booleanParam(name: 'BUILD_RUNTIME_IMAGE', defaultValue: false, description: 'When true, trigger the reviewed Jenkins runtime image build/publish job.')
|
|
||||||
choice(name: 'RUNTIME_BUILD_JOB', choices: ['KT-NapCatQQ/Runtime-Image-Build'], description: 'Whitelisted Jenkins job that performs the reviewed runtime image build and publish.')
|
|
||||||
booleanParam(name: 'PROMOTE_API_RUNTIME', defaultValue: false, description: 'When true, trigger API promotion with explicit image/profile overrides.')
|
|
||||||
choice(name: 'API_PROMOTION_JOB', choices: ['KT-Template/KT-Template-API/main'], description: 'Whitelisted API Jenkins job that accepts NapCat runtime override parameters.')
|
|
||||||
string(name: 'CONFIRM_API_PROMOTION_TARGET', defaultValue: '', description: 'Type PROMOTE_KT_TEMPLATE_API_MAIN before triggering the production API promotion job.')
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
KT_WORKSPACE_ROOT = '/vol1/docker/kt-codex/workspace/KT'
|
|
||||||
KT_WORKFLOW_DIR = '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow'
|
|
||||||
KT_ARTIFACT_ROOT = '/vol1/docker/kt-codex/artifacts'
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('ktWorkflow readiness') {
|
|
||||||
steps {
|
|
||||||
sh '''
|
|
||||||
set -e
|
|
||||||
pnpm --dir "$KT_WORKFLOW_DIR" run napcat-runtime-release-readiness -- \
|
|
||||||
--artifact-root "$KT_ARTIFACT_ROOT/napcat-runtime-release" \
|
|
||||||
--fork-branch "$NAPCAT_FORK_REF" \
|
|
||||||
--upstream-release-tag "$UPSTREAM_RELEASE_TAG" \
|
|
||||||
--napcat-image-tag "$QQBOT_NAPCAT_IMAGE_OVERRIDE" \
|
|
||||||
--profile "$QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('Runtime image build and publish') {
|
|
||||||
when {
|
|
||||||
expression { return params.BUILD_RUNTIME_IMAGE }
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
requireRuntimeOverrides(
|
|
||||||
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
|
|
||||||
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
|
|
||||||
)
|
|
||||||
build job: params.RUNTIME_BUILD_JOB,
|
|
||||||
wait: true,
|
|
||||||
parameters: [
|
|
||||||
string(name: 'NAPCAT_FORK_REF', value: params.NAPCAT_FORK_REF),
|
|
||||||
string(name: 'UPSTREAM_RELEASE_TAG', value: params.UPSTREAM_RELEASE_TAG),
|
|
||||||
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE),
|
|
||||||
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE),
|
|
||||||
string(name: 'KT_ARTIFACT_ROOT', value: "${env.KT_ARTIFACT_ROOT}/napcat-runtime-release")
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('API promotion') {
|
|
||||||
when {
|
|
||||||
expression { return params.PROMOTE_API_RUNTIME }
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
if (params.CONFIRM_API_PROMOTION_TARGET != 'PROMOTE_KT_TEMPLATE_API_MAIN') {
|
|
||||||
error('CONFIRM_API_PROMOTION_TARGET must equal PROMOTE_KT_TEMPLATE_API_MAIN before API promotion runs.')
|
|
||||||
}
|
|
||||||
requireRuntimeOverrides(
|
|
||||||
params.QQBOT_NAPCAT_IMAGE_OVERRIDE,
|
|
||||||
params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE
|
|
||||||
)
|
|
||||||
build job: params.API_PROMOTION_JOB,
|
|
||||||
wait: true,
|
|
||||||
parameters: [
|
|
||||||
string(name: 'QQBOT_NAPCAT_IMAGE_OVERRIDE', value: params.QQBOT_NAPCAT_IMAGE_OVERRIDE),
|
|
||||||
string(name: 'QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE', value: params.QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE)
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
sh '''
|
|
||||||
set +e
|
|
||||||
rm -rf "$WORKSPACE/artifacts"
|
|
||||||
mkdir -p "$WORKSPACE/artifacts/napcat-runtime-release"
|
|
||||||
cp -a "$KT_ARTIFACT_ROOT/napcat-runtime-release/." "$WORKSPACE/artifacts/napcat-runtime-release/" 2>/dev/null || true
|
|
||||||
'''
|
|
||||||
archiveArtifacts artifacts: 'artifacts/napcat-runtime-release/**/*', allowEmptyArchive: true, fingerprint: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stops runtime build or promotion unless both release override values are explicit.
|
|
||||||
* @param imageTag Reviewed NapCat runtime image tag supplied by the operator.
|
|
||||||
* @param profile Reviewed desktop profile version supplied by the operator.
|
|
||||||
* @return Nothing; aborts this pipeline through `error` when a required value is missing.
|
|
||||||
*/
|
|
||||||
void requireRuntimeOverrides(String imageTag, String profile) {
|
|
||||||
if (!imageTag?.trim()) {
|
|
||||||
error('QQBOT_NAPCAT_IMAGE_OVERRIDE is required before build or promotion.')
|
|
||||||
}
|
|
||||||
if (!profile?.trim()) {
|
|
||||||
error('QQBOT_NAPCAT_DESKTOP_PROFILE_VERSION_OVERRIDE is required before build or promotion.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,65 +0,0 @@
|
|||||||
pipeline {
|
|
||||||
agent any
|
|
||||||
|
|
||||||
options {
|
|
||||||
buildDiscarder(logRotator(numToKeepStr: '30', artifactNumToKeepStr: '30'))
|
|
||||||
disableConcurrentBuilds()
|
|
||||||
skipDefaultCheckout(true)
|
|
||||||
timestamps()
|
|
||||||
timeout(time: 30, unit: 'MINUTES')
|
|
||||||
}
|
|
||||||
|
|
||||||
parameters {
|
|
||||||
booleanParam(name: 'RUN_CODEX_AUDIT', defaultValue: false, description: 'When true, allow the optional Codex workspace-write audit after the default dry-run audit.')
|
|
||||||
string(name: 'CONFIRM_CODEX_WORKSPACE_WRITE', defaultValue: '', description: 'Type RUN_CODEX_WORKSPACE_WRITE_AUDIT to confirm Codex may run with a workspace-write sandbox.')
|
|
||||||
}
|
|
||||||
|
|
||||||
environment {
|
|
||||||
KT_WORKFLOW_DIR = '/vol1/docker/kt-codex/workspace/KT/mcp/ktWorkflow'
|
|
||||||
KT_ARTIFACT_ROOT = '/vol1/docker/kt-codex/artifacts'
|
|
||||||
}
|
|
||||||
|
|
||||||
stages {
|
|
||||||
stage('ktWorkflow upstream audit dry-run') {
|
|
||||||
steps {
|
|
||||||
sh '''
|
|
||||||
set -e
|
|
||||||
pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \
|
|
||||||
--artifact-root "$KT_ARTIFACT_ROOT/napcat-upstream-sync/dry-run"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
stage('ktWorkflow upstream audit with Codex') {
|
|
||||||
when {
|
|
||||||
expression { return params.RUN_CODEX_AUDIT }
|
|
||||||
}
|
|
||||||
steps {
|
|
||||||
script {
|
|
||||||
if (params.CONFIRM_CODEX_WORKSPACE_WRITE != 'RUN_CODEX_WORKSPACE_WRITE_AUDIT') {
|
|
||||||
error('CONFIRM_CODEX_WORKSPACE_WRITE must equal RUN_CODEX_WORKSPACE_WRITE_AUDIT before Codex workspace-write audit runs.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
sh '''
|
|
||||||
set -e
|
|
||||||
pnpm --dir "$KT_WORKFLOW_DIR" run napcat-upstream-audit -- \
|
|
||||||
--execute \
|
|
||||||
--use-codex \
|
|
||||||
--artifact-root "$KT_ARTIFACT_ROOT/napcat-upstream-sync/codex"
|
|
||||||
'''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
post {
|
|
||||||
always {
|
|
||||||
sh '''
|
|
||||||
set +e
|
|
||||||
rm -rf "$WORKSPACE/artifacts"
|
|
||||||
mkdir -p "$WORKSPACE/artifacts/napcat-upstream-sync"
|
|
||||||
cp -a "$KT_ARTIFACT_ROOT/napcat-upstream-sync/." "$WORKSPACE/artifacts/napcat-upstream-sync/" 2>/dev/null || true
|
|
||||||
'''
|
|
||||||
archiveArtifacts artifacts: 'artifacts/napcat-upstream-sync/**/*', allowEmptyArchive: true, fingerprint: false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -7,14 +7,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
|
"admin-login": "node --import ./node_modules/tsx/dist/loader.mjs scripts/admin-login-smoke.ts",
|
||||||
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
|
"cleanup-history": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --cleanup-history",
|
||||||
"deploy-observation": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --deploy-observation",
|
|
||||||
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",
|
"global-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --global-review",
|
||||||
"napcat-device-profile-check": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-device-profile-check",
|
|
||||||
"napcat-remote-dev-handoff": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-remote-dev-handoff",
|
|
||||||
"napcat-runtime-release-readiness": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-runtime-release-readiness",
|
|
||||||
"napcat-sync-candidate-review": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-sync-candidate-review",
|
|
||||||
"napcat-upstream-audit": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --napcat-upstream-audit",
|
|
||||||
"nas-codex-bootstrap": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --nas-codex-bootstrap",
|
|
||||||
"obsidian-context": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-context",
|
"obsidian-context": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-context",
|
||||||
"obsidian-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-sync",
|
"obsidian-sync": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-sync",
|
||||||
"obsidian-validate": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-validate",
|
"obsidian-validate": "node --import ./node_modules/tsx/dist/loader.mjs src/server.ts --obsidian-validate",
|
||||||
|
|||||||
@ -1,41 +0,0 @@
|
|||||||
# NapCat Remote Dev Handoff
|
|
||||||
|
|
||||||
You are summarizing a supplied remote development context packet for the next human-controlled step.
|
|
||||||
|
|
||||||
Do not edit files.
|
|
||||||
Do not run merge, commit, push, deploy, or Docker mutation commands.
|
|
||||||
Use only the supplied context packet.
|
|
||||||
Return JSON that matches the requested schema.
|
|
||||||
|
|
||||||
## Handoff Scope
|
|
||||||
|
|
||||||
Summarize only packet evidence for:
|
|
||||||
|
|
||||||
- repository branches and dirty status
|
|
||||||
- artifact directories and notable generated outputs
|
|
||||||
- Jenkins status, build numbers, commit/image pointers, and unknown deployment state
|
|
||||||
- blockers, missing evidence, or unsafe assumptions
|
|
||||||
- safe next commands that are read-only or dry-run oriented
|
|
||||||
|
|
||||||
Do not ask Codex to modify files. Do not include merge, commit, push, deploy, Docker mutation, or destructive cleanup commands as next commands.
|
|
||||||
|
|
||||||
## Requested Schema
|
|
||||||
|
|
||||||
Return only this JSON object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"repoStates": [
|
|
||||||
{
|
|
||||||
"repo": "Repository path or label.",
|
|
||||||
"branch": "Current branch or unknown.",
|
|
||||||
"dirtyStatus": "clean | dirty | unknown",
|
|
||||||
"head": "Commit hash or unknown."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"artifactDirectories": ["Artifact directory or missing evidence note."],
|
|
||||||
"jenkinsStatus": "Jenkins status summary or missing evidence note.",
|
|
||||||
"blockers": ["Blocker or missing evidence note."],
|
|
||||||
"safeNextCommands": ["Read-only or dry-run command."]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
# NapCat Runtime Release Readiness
|
|
||||||
|
|
||||||
You are reviewing a supplied NapCat runtime release packet before a human promotes an image/profile into the API runtime.
|
|
||||||
|
|
||||||
Do not edit files.
|
|
||||||
Do not run merge, commit, push, deploy, or Docker mutation commands.
|
|
||||||
Use only the supplied context packet.
|
|
||||||
Return JSON that matches the requested schema.
|
|
||||||
|
|
||||||
## Readiness Checks
|
|
||||||
|
|
||||||
Base every section on packet evidence:
|
|
||||||
|
|
||||||
- source validation: branch, commit, upstream base, fork patch marker, and dirty status
|
|
||||||
- image validation: image tag, image inspect/build evidence, marker evidence, and runtime asset presence
|
|
||||||
- API promotion readiness: explicit image/profile values, configuration alignment, and Jenkins/API promotion prerequisites
|
|
||||||
- online smoke readiness: QQBot/NapCat login, WebUI, command smoke, and rollback smoke expectations
|
|
||||||
- rollback pointer: last known good branch, commit, image tag, profile, and API runtime values
|
|
||||||
|
|
||||||
If any section lacks evidence, state the missing evidence plainly.
|
|
||||||
|
|
||||||
## Requested Schema
|
|
||||||
|
|
||||||
Return only this JSON object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"sourceValidation": "Evidence-backed source validation status.",
|
|
||||||
"imageValidation": "Evidence-backed image validation status.",
|
|
||||||
"apiPromotionReadiness": "Evidence-backed API promotion readiness status.",
|
|
||||||
"onlineSmokeReadiness": "Evidence-backed online smoke readiness status.",
|
|
||||||
"rollbackPointer": "Specific rollback pointer or the missing evidence needed to name one."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": [
|
|
||||||
"sourceValidation",
|
|
||||||
"imageValidation",
|
|
||||||
"apiPromotionReadiness",
|
|
||||||
"onlineSmokeReadiness",
|
|
||||||
"rollbackPointer"
|
|
||||||
],
|
|
||||||
"properties": {
|
|
||||||
"sourceValidation": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"imageValidation": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"apiPromotionReadiness": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"onlineSmokeReadiness": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
},
|
|
||||||
"rollbackPointer": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
||||||
"type": "object",
|
|
||||||
"additionalProperties": false,
|
|
||||||
"required": ["classification", "reasonCodes", "recommendedAction", "summary"],
|
|
||||||
"properties": {
|
|
||||||
"classification": {
|
|
||||||
"enum": ["safe-candidate", "manual-review", "blocked"]
|
|
||||||
},
|
|
||||||
"reasonCodes": {
|
|
||||||
"type": "array",
|
|
||||||
"minItems": 1,
|
|
||||||
"items": {
|
|
||||||
"enum": [
|
|
||||||
"NO_UPSTREAM_CHANGE",
|
|
||||||
"HOT_ZONE_CHANGED",
|
|
||||||
"FORK_PATCH_OVERLAP",
|
|
||||||
"DRY_MERGE_CONFLICT",
|
|
||||||
"BUILD_GRAPH_CHANGED",
|
|
||||||
"METADATA_UNAVAILABLE",
|
|
||||||
"CODEX_SCHEMA_INVALID"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"recommendedAction": {
|
|
||||||
"enum": ["no-op", "create-candidate-branch", "request-human-review", "block"]
|
|
||||||
},
|
|
||||||
"summary": {
|
|
||||||
"type": "string",
|
|
||||||
"minLength": 1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
# NapCat Sync Candidate Review
|
|
||||||
|
|
||||||
You are reviewing a supplied NapCat candidate branch context packet before a human decides whether to continue the sync.
|
|
||||||
|
|
||||||
Do not edit files.
|
|
||||||
Do not run merge, commit, push, deploy, or Docker mutation commands.
|
|
||||||
Use only the supplied context packet.
|
|
||||||
Return JSON that matches the requested schema.
|
|
||||||
|
|
||||||
## Review Scope
|
|
||||||
|
|
||||||
Review only evidence present in the packet. Cover these areas:
|
|
||||||
|
|
||||||
- login service reset behavior and stale native login service state
|
|
||||||
- QR refresh, stale QR prevention, QR expiry, and scan confirmation state
|
|
||||||
- WebUI login runtime state, reconciled QQ online state, and false online signals
|
|
||||||
- captcha and new-device boundaries, including pending state preservation
|
|
||||||
- package, shell, framework, webui build output, and runtime asset layout
|
|
||||||
- test gaps, missing smoke evidence, and unverified online behavior
|
|
||||||
|
|
||||||
## Finding Rules
|
|
||||||
|
|
||||||
Flag only actionable risks grounded in packet evidence. If evidence is missing, report it as a test gap or blocked review item instead of guessing.
|
|
||||||
|
|
||||||
## Requested Schema
|
|
||||||
|
|
||||||
Return only this JSON object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"decision": "approve | manual-review | block",
|
|
||||||
"summary": "Short review summary.",
|
|
||||||
"findings": [
|
|
||||||
{
|
|
||||||
"severity": "critical | important | minor",
|
|
||||||
"area": "login-reset | qr-refresh | webui-runtime | captcha-new-device | package-build | test-gap",
|
|
||||||
"evidence": "Packet evidence for the finding.",
|
|
||||||
"requiredAction": "Human-readable action before promotion."
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"testGaps": ["Missing validation evidence."]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
@ -1,53 +0,0 @@
|
|||||||
# NapCat Upstream Audit
|
|
||||||
|
|
||||||
You are reviewing a supplied NapCat upstream release context packet for KT automation. Decide whether the upstream release can become a candidate branch, needs manual review, or is blocked.
|
|
||||||
|
|
||||||
Do not edit files.
|
|
||||||
Do not run merge, commit, push, deploy, or Docker mutation commands.
|
|
||||||
Use only the supplied context packet.
|
|
||||||
Return JSON that matches the requested schema.
|
|
||||||
|
|
||||||
## Inputs
|
|
||||||
|
|
||||||
Use the packet sections for upstream release metadata, fork patch range, changed files, dry-merge result, build graph diff, and Codex parser status. Treat missing packet sections as unavailable metadata.
|
|
||||||
|
|
||||||
## Classification Rules
|
|
||||||
|
|
||||||
Choose exactly one `classification`:
|
|
||||||
|
|
||||||
- `safe-candidate`: no upstream change exists, or upstream changes are outside hot zones, do not overlap fork patches, dry merge is clean, build graph is stable, and metadata is available.
|
|
||||||
- `manual-review`: upstream changed login/runtime/build-adjacent areas or fork-adjacent code, but the packet does not prove a hard blocker.
|
|
||||||
- `blocked`: dry merge conflicts, build graph changes that require maintainer action, missing required metadata, or invalid Codex output schema.
|
|
||||||
|
|
||||||
Use one or more `reasonCodes` from this exact list:
|
|
||||||
|
|
||||||
- `NO_UPSTREAM_CHANGE`
|
|
||||||
- `HOT_ZONE_CHANGED`
|
|
||||||
- `FORK_PATCH_OVERLAP`
|
|
||||||
- `DRY_MERGE_CONFLICT`
|
|
||||||
- `BUILD_GRAPH_CHANGED`
|
|
||||||
- `METADATA_UNAVAILABLE`
|
|
||||||
- `CODEX_SCHEMA_INVALID`
|
|
||||||
|
|
||||||
## Hot Zones
|
|
||||||
|
|
||||||
Treat these as NapCat login/runtime hot zones:
|
|
||||||
|
|
||||||
- login service reset and native login service state
|
|
||||||
- QR refresh, stale QR, QR expiry, and scan confirmation state
|
|
||||||
- WebUI login runtime state and reconciled QQ online state
|
|
||||||
- captcha and new-device verification boundaries
|
|
||||||
- package, shell, framework, webui build graph or output layout
|
|
||||||
|
|
||||||
## Requested Schema
|
|
||||||
|
|
||||||
Return only this JSON object:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"classification": "safe-candidate | manual-review | blocked",
|
|
||||||
"reasonCodes": ["NO_UPSTREAM_CHANGE"],
|
|
||||||
"recommendedAction": "no-op | create-candidate-branch | request-human-review | block",
|
|
||||||
"summary": "Short evidence-based decision."
|
|
||||||
}
|
|
||||||
```
|
|
||||||
177
src/core/cli.ts
177
src/core/cli.ts
@ -1,10 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
DeployObservationInput,
|
|
||||||
GlobalCodeReviewInput,
|
GlobalCodeReviewInput,
|
||||||
NapcatRemoteDevHandoffInput,
|
|
||||||
NapcatRuntimeReleaseReadinessInput,
|
|
||||||
NapcatSyncCandidateReviewInput,
|
|
||||||
NasCodexBootstrapPlanInput,
|
|
||||||
ObsidianContextInput,
|
ObsidianContextInput,
|
||||||
ObsidianSyncInput,
|
ObsidianSyncInput,
|
||||||
ObsidianValidateInput,
|
ObsidianValidateInput,
|
||||||
@ -12,7 +7,6 @@ import type {
|
|||||||
WorkstreamReusablePattern,
|
WorkstreamReusablePattern,
|
||||||
} from '../types.js';
|
} from '../types.js';
|
||||||
import { workstreamReusablePatternValues } from './constants.js';
|
import { workstreamReusablePatternValues } from './constants.js';
|
||||||
import type { NapcatUpstreamAuditInput } from '../tools/napcatAutomation.types.js';
|
|
||||||
|
|
||||||
function parseContentScanMode(argv: string[]): 'all' | 'changed' {
|
function parseContentScanMode(argv: string[]): 'all' | 'changed' {
|
||||||
if (argv.includes('--content-scan-all') || argv.includes('--contentScanMode=all')) {
|
if (argv.includes('--content-scan-all') || argv.includes('--contentScanMode=all')) {
|
||||||
@ -67,16 +61,6 @@ function readOptionList(argv: string[], names: string[]): string[] {
|
|||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks whether any supported boolean CLI flag is present in the raw argv list.
|
|
||||||
* @param argv - Full process argv including node, script, mode flag, and user options.
|
|
||||||
* @param names - Flag names to accept in dashed or camelCase form.
|
|
||||||
* @returns True when the caller provided one of the named flags.
|
|
||||||
*/
|
|
||||||
function hasFlag(argv: string[], names: string[]): boolean {
|
|
||||||
return names.some((name) => argv.includes(name));
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseWorkstreamCloseoutCliArgs(argv: string[]): WorkstreamCloseoutInput {
|
export function parseWorkstreamCloseoutCliArgs(argv: string[]): WorkstreamCloseoutInput {
|
||||||
const patternSet = new Set<string>(workstreamReusablePatternValues);
|
const patternSet = new Set<string>(workstreamReusablePatternValues);
|
||||||
const patterns = readOptionList(argv, ['--pattern', '--reusable-pattern'])
|
const patterns = readOptionList(argv, ['--pattern', '--reusable-pattern'])
|
||||||
@ -106,167 +90,6 @@ export function parseWorkstreamCloseoutCliArgs(argv: string[]): WorkstreamCloseo
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function parseDeployObservationCliArgs(argv: string[]): DeployObservationInput {
|
|
||||||
const sshPort = Number(readOption(argv, ['--ssh-port', '--sshPort']));
|
|
||||||
|
|
||||||
return {
|
|
||||||
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
|
|
||||||
buildNumber: readOption(argv, ['--build', '--build-number', '--buildNumber']),
|
|
||||||
container: readOption(argv, ['--container']),
|
|
||||||
deployment: readOption(argv, ['--deployment']),
|
|
||||||
execute: argv.includes('--execute'),
|
|
||||||
expectedCommit: readOption(argv, [
|
|
||||||
'--commit',
|
|
||||||
'--expected-commit',
|
|
||||||
'--expectedCommit',
|
|
||||||
]),
|
|
||||||
healthUrl: readOption(argv, ['--health-url', '--healthUrl']),
|
|
||||||
imageTag: readOption(argv, ['--image-tag', '--imageTag']),
|
|
||||||
jenkinsHome: readOption(argv, ['--jenkins-home', '--jenkinsHome']),
|
|
||||||
jobName: readOption(argv, ['--job', '--job-name', '--jobName']),
|
|
||||||
kubeconfigPath: readOption(argv, [
|
|
||||||
'--kubeconfig',
|
|
||||||
'--kubeconfig-path',
|
|
||||||
'--kubeconfigPath',
|
|
||||||
]),
|
|
||||||
namespace: readOption(argv, ['--namespace']),
|
|
||||||
project: readOption(argv, ['--project']),
|
|
||||||
selector: readOption(argv, ['--selector']),
|
|
||||||
smoke: readOption(argv, ['--smoke']),
|
|
||||||
sshPort: Number.isFinite(sshPort) ? sshPort : undefined,
|
|
||||||
sshTarget: readOption(argv, ['--ssh', '--ssh-target', '--sshTarget']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the NapCat upstream audit CLI mode into the deterministic audit builder input.
|
|
||||||
* @param argv - Full process argv from `src/server.ts`; `--execute` and `--use-codex` must both be explicit before Codex can run.
|
|
||||||
* @returns Upstream audit input with dry-run defaults for all optional metadata lists.
|
|
||||||
*/
|
|
||||||
export function parseNapcatUpstreamAuditCliArgs(
|
|
||||||
argv: string[],
|
|
||||||
): NapcatUpstreamAuditInput {
|
|
||||||
return {
|
|
||||||
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
|
|
||||||
codexHome: readOption(argv, ['--codex-home', '--codexHome']),
|
|
||||||
createCandidateBranch: hasFlag(argv, ['--create-candidate-branch', '--createCandidateBranch']),
|
|
||||||
dryMergeConflict: hasFlag(argv, ['--dry-merge-conflict', '--dryMergeConflict']),
|
|
||||||
execute: hasFlag(argv, ['--execute']),
|
|
||||||
forkBranch: readOption(argv, ['--fork-branch', '--forkBranch']),
|
|
||||||
forkPatchFiles: readOptionList(argv, [
|
|
||||||
'--fork-patch-file',
|
|
||||||
'--fork-patch-files',
|
|
||||||
'--forkPatchFile',
|
|
||||||
'--forkPatchFiles',
|
|
||||||
]),
|
|
||||||
forkRepo: readOption(argv, ['--fork-repo', '--forkRepo']),
|
|
||||||
lastAcceptedUpstreamBase: readOption(argv, [
|
|
||||||
'--last-accepted-upstream-base',
|
|
||||||
'--lastAcceptedUpstreamBase',
|
|
||||||
]),
|
|
||||||
outputSchemaPath: readOption(argv, ['--output-schema', '--outputSchemaPath']),
|
|
||||||
upstreamChangedFiles: readOptionList(argv, [
|
|
||||||
'--upstream-changed-file',
|
|
||||||
'--upstream-changed-files',
|
|
||||||
'--upstreamChangedFile',
|
|
||||||
'--upstreamChangedFiles',
|
|
||||||
]),
|
|
||||||
upstreamReleaseTag: readOption(argv, [
|
|
||||||
'--upstream-release-tag',
|
|
||||||
'--upstream-release-ref',
|
|
||||||
'--upstreamReleaseTag',
|
|
||||||
]),
|
|
||||||
upstreamRepo: readOption(argv, ['--upstream-repo', '--upstreamRepo']),
|
|
||||||
useCodex: hasFlag(argv, ['--use-codex', '--useCodex']),
|
|
||||||
workspaceRoot: readOption(argv, ['--workspace-root', '--workspaceRoot']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses common dry-run NapCat prompt tool options shared by candidate review, readiness, and handoff modes.
|
|
||||||
* @param argv - Full process argv from a NapCat automation CLI script.
|
|
||||||
* @returns Prompt skeleton input with `execute` false unless explicitly requested by the caller.
|
|
||||||
*/
|
|
||||||
function parseNapcatPromptToolCliArgs(argv: string[]): NapcatSyncCandidateReviewInput {
|
|
||||||
return {
|
|
||||||
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
|
|
||||||
candidateBranch: readOption(argv, ['--candidate-branch', '--candidateBranch']),
|
|
||||||
execute: hasFlag(argv, ['--execute']),
|
|
||||||
forkBranch: readOption(argv, ['--fork-branch', '--forkBranch']),
|
|
||||||
forkRepo: readOption(argv, ['--fork-repo', '--forkRepo']),
|
|
||||||
lastAcceptedUpstreamBase: readOption(argv, [
|
|
||||||
'--last-accepted-upstream-base',
|
|
||||||
'--lastAcceptedUpstreamBase',
|
|
||||||
]),
|
|
||||||
upstreamReleaseTag: readOption(argv, [
|
|
||||||
'--upstream-release-tag',
|
|
||||||
'--upstream-release-ref',
|
|
||||||
'--upstreamReleaseTag',
|
|
||||||
]),
|
|
||||||
upstreamRepo: readOption(argv, ['--upstream-repo', '--upstreamRepo']),
|
|
||||||
useCodex: hasFlag(argv, ['--use-codex', '--useCodex']),
|
|
||||||
workspaceRoot: readOption(argv, ['--workspace-root', '--workspaceRoot']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the NapCat sync-candidate review CLI mode.
|
|
||||||
* @param argv - Full process argv from `pnpm run napcat-sync-candidate-review`.
|
|
||||||
* @returns Dry-run prompt skeleton input for human/Codex candidate review handoff.
|
|
||||||
*/
|
|
||||||
export function parseNapcatSyncCandidateReviewCliArgs(
|
|
||||||
argv: string[],
|
|
||||||
): NapcatSyncCandidateReviewInput {
|
|
||||||
return parseNapcatPromptToolCliArgs(argv);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the NapCat runtime release readiness CLI mode.
|
|
||||||
* @param argv - Full process argv from `pnpm run napcat-runtime-release-readiness`.
|
|
||||||
* @returns Dry-run prompt skeleton input with image/profile hints and no default mutation.
|
|
||||||
*/
|
|
||||||
export function parseNapcatRuntimeReleaseReadinessCliArgs(
|
|
||||||
argv: string[],
|
|
||||||
): NapcatRuntimeReleaseReadinessInput {
|
|
||||||
return {
|
|
||||||
...parseNapcatPromptToolCliArgs(argv),
|
|
||||||
apiImageTag: readOption(argv, ['--api-image-tag', '--apiImageTag']),
|
|
||||||
napcatImageTag: readOption(argv, ['--napcat-image-tag', '--napcatImageTag']),
|
|
||||||
profile: readOption(argv, ['--profile']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the NapCat remote development handoff CLI mode.
|
|
||||||
* @param argv - Full process argv from `src/server.ts --napcat-remote-dev-handoff`.
|
|
||||||
* @returns Dry-run prompt skeleton input for a remote workspace handoff packet.
|
|
||||||
*/
|
|
||||||
export function parseNapcatRemoteDevHandoffCliArgs(
|
|
||||||
argv: string[],
|
|
||||||
): NapcatRemoteDevHandoffInput {
|
|
||||||
return {
|
|
||||||
...parseNapcatPromptToolCliArgs(argv),
|
|
||||||
targetHost: readOption(argv, ['--target-host', '--targetHost']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the NAS Codex bootstrap planning CLI mode.
|
|
||||||
* @param argv - Full process argv from `pnpm run nas-codex-bootstrap`.
|
|
||||||
* @returns Dry-run bootstrap plan input; `execute` is false unless the caller explicitly opts in.
|
|
||||||
*/
|
|
||||||
export function parseNasCodexBootstrapCliArgs(
|
|
||||||
argv: string[],
|
|
||||||
): NasCodexBootstrapPlanInput {
|
|
||||||
return {
|
|
||||||
artifactRoot: readOption(argv, ['--artifact-root', '--artifactRoot']),
|
|
||||||
codexHome: readOption(argv, ['--codex-home', '--codexHome']),
|
|
||||||
execute: hasFlag(argv, ['--execute']),
|
|
||||||
serviceName: readOption(argv, ['--service-name', '--serviceName']),
|
|
||||||
workspaceRoot: readOption(argv, ['--workspace-root', '--workspaceRoot']),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function parseObsidianCliArgs(argv: string[]): ObsidianValidateInput {
|
export function parseObsidianCliArgs(argv: string[]): ObsidianValidateInput {
|
||||||
return {
|
return {
|
||||||
checkLayout: !argv.includes('--no-layout'),
|
checkLayout: !argv.includes('--no-layout'),
|
||||||
|
|||||||
@ -69,15 +69,8 @@ export const registeredToolNames = [
|
|||||||
'kt_commit_plan',
|
'kt_commit_plan',
|
||||||
'kt_push_plan',
|
'kt_push_plan',
|
||||||
'kt_business_test_plan',
|
'kt_business_test_plan',
|
||||||
'kt_napcat_upstream_audit',
|
|
||||||
'kt_napcat_sync_candidate_review',
|
|
||||||
'kt_napcat_runtime_release_readiness',
|
|
||||||
'kt_napcat_remote_dev_handoff',
|
|
||||||
'kt_nas_codex_bootstrap_plan',
|
|
||||||
'kt_napcat_device_profile_check',
|
|
||||||
'kt_blocker_resolution',
|
'kt_blocker_resolution',
|
||||||
'kt_remote_health_check',
|
'kt_remote_health_check',
|
||||||
'kt_deploy_observation',
|
|
||||||
'kt_db_sync_plan',
|
'kt_db_sync_plan',
|
||||||
'kt_component_workflow',
|
'kt_component_workflow',
|
||||||
'kt_obsidian_context',
|
'kt_obsidian_context',
|
||||||
@ -92,9 +85,7 @@ export const defaultHistoryRoots = [
|
|||||||
'.kt-workspace/test-logs',
|
'.kt-workspace/test-logs',
|
||||||
'.kt-workspace/verify',
|
'.kt-workspace/verify',
|
||||||
'.kt-workspace/playwright-mcp',
|
'.kt-workspace/playwright-mcp',
|
||||||
'.kt-workspace/subagents',
|
|
||||||
'.kt-workspace/db-sync',
|
'.kt-workspace/db-sync',
|
||||||
'.kt-workspace/exports',
|
|
||||||
'.kt-workspace/tmp',
|
'.kt-workspace/tmp',
|
||||||
'.kt-workspace/logs',
|
'.kt-workspace/logs',
|
||||||
'.kt-workspace/deploy',
|
'.kt-workspace/deploy',
|
||||||
|
|||||||
@ -12,24 +12,10 @@ import { buildWorkflowLoopAudit } from './tools/loop.js';
|
|||||||
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
||||||
import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js';
|
import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js';
|
||||||
import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js';
|
import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js';
|
||||||
import {
|
import { buildBusinessTestPlan, createApiTestPlan, createPageTestCase } from './tools/testing.js';
|
||||||
buildBusinessTestPlan,
|
|
||||||
buildNapcatDeviceProfileCheck,
|
|
||||||
createApiTestPlan,
|
|
||||||
createPageTestCase,
|
|
||||||
} from './tools/testing.js';
|
|
||||||
import { buildVerificationPlan } from './tools/verification.js';
|
import { buildVerificationPlan } from './tools/verification.js';
|
||||||
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
||||||
import { buildChangeDocSync } from './tools/docSync.js';
|
import { buildChangeDocSync } from './tools/docSync.js';
|
||||||
import { buildDeployObservation } from './tools/deployObservation.js';
|
|
||||||
import {
|
|
||||||
buildNapcatRemoteDevHandoff,
|
|
||||||
buildNapcatRuntimeReleaseReadiness,
|
|
||||||
buildNapcatSyncCandidateReview,
|
|
||||||
buildNapcatUpstreamAudit,
|
|
||||||
buildNasCodexBootstrapPlan,
|
|
||||||
napcatAutomationDefaults,
|
|
||||||
} from './tools/napcatAutomation.js';
|
|
||||||
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
|
import { buildCommitPlan, buildComponentWorkflow, buildDbSyncPlan, buildFinishTask, buildPushPlan, buildRemoteHealthCheck } from './tools/workflow.js';
|
||||||
import { workstreamReusablePatternValues } from './core/constants.js';
|
import { workstreamReusablePatternValues } from './core/constants.js';
|
||||||
export function response(data: unknown): McpTextResponse {
|
export function response(data: unknown): McpTextResponse {
|
||||||
@ -43,11 +29,6 @@ export function response(data: unknown): McpTextResponse {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Registers every ktWorkflow MCP tool against the provided MCP server instance.
|
|
||||||
* @param server - MCP SDK server that owns tool names, schemas, descriptions, and async handlers.
|
|
||||||
* @returns Nothing; handlers are attached to `server` for later stdio transport use.
|
|
||||||
*/
|
|
||||||
export function registerTools(server: McpServer): void {
|
export function registerTools(server: McpServer): void {
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'kt_read_context',
|
'kt_read_context',
|
||||||
@ -178,7 +159,7 @@ export function registerTools(server: McpServer): void {
|
|||||||
server.registerTool(
|
server.registerTool(
|
||||||
'kt_cleanup_history',
|
'kt_cleanup_history',
|
||||||
{
|
{
|
||||||
description: '清理 KT 运行态历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮;可复用模板和参考资产应迁入 workspace-assets。',
|
description: '清理 KT 运行态历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮,template/_templates 等模板目录永久保留。',
|
||||||
inputSchema: {
|
inputSchema: {
|
||||||
dryRun: z.boolean().default(true),
|
dryRun: z.boolean().default(true),
|
||||||
keep: z.number().int().min(0).max(50).default(3),
|
keep: z.number().int().min(0).max(50).default(3),
|
||||||
@ -383,145 +364,6 @@ export function registerTools(server: McpServer): void {
|
|||||||
async (input) => response(buildBusinessTestPlan(input)),
|
async (input) => response(buildBusinessTestPlan(input)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_napcat_upstream_audit',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'生成或执行 NapCatQQ 上游 release 审计:默认 dry-run,只读输出 hot zone、fork patch overlap、dry merge 和 Codex 审计入口;execute/useCodex 均需显式开启才会运行 Codex。',
|
|
||||||
inputSchema: {
|
|
||||||
artifactRoot: z
|
|
||||||
.string()
|
|
||||||
.default('.kt-workspace/test-artifacts/napcat-upstream-audit'),
|
|
||||||
codexHome: z.string().optional(),
|
|
||||||
createCandidateBranch: z.boolean().default(false),
|
|
||||||
dryMergeConflict: z.boolean().default(false),
|
|
||||||
execute: z.boolean().default(false),
|
|
||||||
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
|
|
||||||
forkPatchFiles: z.array(z.string()).default([]),
|
|
||||||
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
|
|
||||||
lastAcceptedUpstreamBase: z
|
|
||||||
.string()
|
|
||||||
.default(napcatAutomationDefaults.lastAcceptedUpstreamBase),
|
|
||||||
outputSchemaPath: z.string().optional(),
|
|
||||||
upstreamChangedFiles: z.array(z.string()).default([]),
|
|
||||||
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
|
|
||||||
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
|
|
||||||
useCodex: z.boolean().default(false),
|
|
||||||
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
|
|
||||||
},
|
|
||||||
title: 'KT NapCat Upstream Audit',
|
|
||||||
},
|
|
||||||
async (input) => response(buildNapcatUpstreamAudit(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_napcat_sync_candidate_review',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'生成 NapCatQQ sync candidate 人工/Codex 复审的 dry-run prompt、上下文和只读命令骨架;Task 5 不执行 Codex 或 Git 写操作。',
|
|
||||||
inputSchema: {
|
|
||||||
artifactRoot: z
|
|
||||||
.string()
|
|
||||||
.default('.kt-workspace/test-artifacts/napcat-automation/sync-candidate-review'),
|
|
||||||
candidateBranch: z.string().default(napcatAutomationDefaults.candidateBranch),
|
|
||||||
execute: z.boolean().default(false),
|
|
||||||
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
|
|
||||||
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
|
|
||||||
lastAcceptedUpstreamBase: z
|
|
||||||
.string()
|
|
||||||
.default(napcatAutomationDefaults.lastAcceptedUpstreamBase),
|
|
||||||
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
|
|
||||||
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
|
|
||||||
useCodex: z.boolean().default(false),
|
|
||||||
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
|
|
||||||
},
|
|
||||||
title: 'KT NapCat Sync Candidate Review',
|
|
||||||
},
|
|
||||||
async (input) => response(buildNapcatSyncCandidateReview(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_napcat_runtime_release_readiness',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'生成 NapCat runtime release readiness dry-run prompt、上下文和只读检查骨架;不自动构建镜像、不调用 kubectl、不发布。',
|
|
||||||
inputSchema: {
|
|
||||||
apiImageTag: z.string().default(napcatAutomationDefaults.apiImageTag),
|
|
||||||
artifactRoot: z
|
|
||||||
.string()
|
|
||||||
.default('.kt-workspace/test-artifacts/napcat-automation/runtime-release-readiness'),
|
|
||||||
candidateBranch: z.string().default(napcatAutomationDefaults.candidateBranch),
|
|
||||||
execute: z.boolean().default(false),
|
|
||||||
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
|
|
||||||
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
|
|
||||||
napcatImageTag: z.string().default(napcatAutomationDefaults.runtimeImageTag),
|
|
||||||
profile: z.string().default(napcatAutomationDefaults.runtimeProfile),
|
|
||||||
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
|
|
||||||
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
|
|
||||||
useCodex: z.boolean().default(false),
|
|
||||||
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
|
|
||||||
},
|
|
||||||
title: 'KT NapCat Runtime Release Readiness',
|
|
||||||
},
|
|
||||||
async (input) => response(buildNapcatRuntimeReleaseReadiness(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_napcat_remote_dev_handoff',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'生成 NapCat 自动化远程开发 handoff 的 dry-run prompt 和上下文骨架;不创建线程、不执行 Codex、不同步仓库。',
|
|
||||||
inputSchema: {
|
|
||||||
artifactRoot: z
|
|
||||||
.string()
|
|
||||||
.default('.kt-workspace/test-artifacts/napcat-automation/remote-dev-handoff'),
|
|
||||||
candidateBranch: z.string().default(napcatAutomationDefaults.candidateBranch),
|
|
||||||
execute: z.boolean().default(false),
|
|
||||||
forkBranch: z.string().default(napcatAutomationDefaults.forkBranch),
|
|
||||||
forkRepo: z.string().default(napcatAutomationDefaults.forkRepo),
|
|
||||||
targetHost: z.string().default(napcatAutomationDefaults.targetHost),
|
|
||||||
upstreamReleaseTag: z.string().default(napcatAutomationDefaults.upstreamReleaseRef),
|
|
||||||
upstreamRepo: z.string().default(napcatAutomationDefaults.upstreamRepo),
|
|
||||||
useCodex: z.boolean().default(false),
|
|
||||||
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
|
|
||||||
},
|
|
||||||
title: 'KT NapCat Remote Dev Handoff',
|
|
||||||
},
|
|
||||||
async (input) => response(buildNapcatRemoteDevHandoff(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_nas_codex_bootstrap_plan',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'生成可信 NAS Codex 自动化环境 bootstrap 的 dry-run plan;Task 5 仅输出只读检查和占位计划,不安装、不同步、不写 systemd。',
|
|
||||||
inputSchema: {
|
|
||||||
artifactRoot: z
|
|
||||||
.string()
|
|
||||||
.default('.kt-workspace/test-artifacts/napcat-automation/nas-codex-bootstrap'),
|
|
||||||
codexHome: z.string().default(napcatAutomationDefaults.codexHome),
|
|
||||||
execute: z.boolean().default(false),
|
|
||||||
serviceName: z.string().default(napcatAutomationDefaults.bootstrapServiceName),
|
|
||||||
workspaceRoot: z.string().default(napcatAutomationDefaults.workspaceRoot),
|
|
||||||
},
|
|
||||||
title: 'KT NAS Codex Bootstrap Plan',
|
|
||||||
},
|
|
||||||
async (input) => response(buildNasCodexBootstrapPlan(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_napcat_device_profile_check',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'静态检查 API NapCat 设备身份护栏:QQNT 可见 hostname、实体 OUI MAC、machine-info、runtime dir 和 DB_TIMEZONE。',
|
|
||||||
inputSchema: {
|
|
||||||
project: z.string().default('api'),
|
|
||||||
},
|
|
||||||
title: 'KT NapCat Device Profile Check',
|
|
||||||
},
|
|
||||||
async (input) => response(buildNapcatDeviceProfileCheck(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'kt_blocker_resolution',
|
'kt_blocker_resolution',
|
||||||
{
|
{
|
||||||
@ -558,37 +400,6 @@ export function registerTools(server: McpServer): void {
|
|||||||
async (input) => response(await buildRemoteHealthCheck(input)),
|
async (input) => response(await buildRemoteHealthCheck(input)),
|
||||||
);
|
);
|
||||||
|
|
||||||
server.registerTool(
|
|
||||||
'kt_deploy_observation',
|
|
||||||
{
|
|
||||||
description:
|
|
||||||
'生成或执行 API 发布后的只读部署观测:Jenkins、K8s Deployment、Pod、/health/runtime 和任务 smoke,并输出运行态证据。',
|
|
||||||
inputSchema: {
|
|
||||||
artifactRoot: z.string().optional(),
|
|
||||||
buildNumber: z.string().optional(),
|
|
||||||
container: z.string().default('api'),
|
|
||||||
deployment: z.string().default('kt-template-online-api'),
|
|
||||||
execute: z.boolean().default(false),
|
|
||||||
expectedCommit: z.string().optional(),
|
|
||||||
healthUrl: z.string().default('http://127.0.0.1:48085/health/runtime'),
|
|
||||||
imageTag: z.string().optional(),
|
|
||||||
jenkinsHome: z.string().default('/vol1/docker/jenkins/jenkins_home'),
|
|
||||||
jobName: z.string().default('KT-Template/KT-Template-API/main'),
|
|
||||||
kubeconfigPath: z
|
|
||||||
.string()
|
|
||||||
.default('/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml'),
|
|
||||||
namespace: z.string().default('kt-prod'),
|
|
||||||
project: z.string().default('api'),
|
|
||||||
selector: z.string().default('app=kt-template-online-api'),
|
|
||||||
smoke: z.string().optional(),
|
|
||||||
sshPort: z.number().int().min(1).max(65_535).optional(),
|
|
||||||
sshTarget: z.string().default('nas'),
|
|
||||||
},
|
|
||||||
title: 'KT Deploy Observation',
|
|
||||||
},
|
|
||||||
async (input) => response(await buildDeployObservation(input)),
|
|
||||||
);
|
|
||||||
|
|
||||||
server.registerTool(
|
server.registerTool(
|
||||||
'kt_db_sync_plan',
|
'kt_db_sync_plan',
|
||||||
{
|
{
|
||||||
|
|||||||
1519
src/selfTest.ts
1519
src/selfTest.ts
File diff suppressed because it is too large
Load Diff
@ -3,13 +3,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|||||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
parseNasCodexBootstrapCliArgs,
|
|
||||||
parseDeployObservationCliArgs,
|
|
||||||
parseGlobalReviewCliArgs,
|
parseGlobalReviewCliArgs,
|
||||||
parseNapcatRemoteDevHandoffCliArgs,
|
|
||||||
parseNapcatRuntimeReleaseReadinessCliArgs,
|
|
||||||
parseNapcatSyncCandidateReviewCliArgs,
|
|
||||||
parseNapcatUpstreamAuditCliArgs,
|
|
||||||
parseObsidianContextCliArgs,
|
parseObsidianContextCliArgs,
|
||||||
parseObsidianCliArgs,
|
parseObsidianCliArgs,
|
||||||
parseObsidianSyncCliArgs,
|
parseObsidianSyncCliArgs,
|
||||||
@ -17,19 +11,10 @@ import {
|
|||||||
} from './core/cli.js';
|
} from './core/cli.js';
|
||||||
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
|
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
|
||||||
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
||||||
import { buildDeployObservation } from './tools/deployObservation.js';
|
|
||||||
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
||||||
import { buildGlobalCodeReview } from './tools/review.js';
|
import { buildGlobalCodeReview } from './tools/review.js';
|
||||||
import { registerTools } from './registerTools.js';
|
import { registerTools } from './registerTools.js';
|
||||||
import { runSelfTest } from './selfTest.js';
|
import { runSelfTest } from './selfTest.js';
|
||||||
import { buildNapcatDeviceProfileCheck } from './tools/testing.js';
|
|
||||||
import {
|
|
||||||
buildNapcatRemoteDevHandoff,
|
|
||||||
buildNapcatRuntimeReleaseReadiness,
|
|
||||||
buildNapcatSyncCandidateReview,
|
|
||||||
buildNapcatUpstreamAudit,
|
|
||||||
buildNasCodexBootstrapPlan,
|
|
||||||
} from './tools/napcatAutomation.js';
|
|
||||||
|
|
||||||
if (process.argv.includes('--cleanup-history')) {
|
if (process.argv.includes('--cleanup-history')) {
|
||||||
console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2));
|
console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2));
|
||||||
@ -47,52 +32,10 @@ if (process.argv.includes('--cleanup-history')) {
|
|||||||
console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
|
console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
|
||||||
} else if (process.argv.includes('--obsidian-sync')) {
|
} else if (process.argv.includes('--obsidian-sync')) {
|
||||||
console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2));
|
console.log(JSON.stringify(syncObsidianWorkflow(parseObsidianSyncCliArgs(process.argv)), null, 2));
|
||||||
} else if (process.argv.includes('--deploy-observation')) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
await buildDeployObservation(parseDeployObservationCliArgs(process.argv)),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (process.argv.includes('--napcat-upstream-audit')) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(buildNapcatUpstreamAudit(parseNapcatUpstreamAuditCliArgs(process.argv)), null, 2),
|
|
||||||
);
|
|
||||||
} else if (process.argv.includes('--napcat-sync-candidate-review')) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
buildNapcatSyncCandidateReview(parseNapcatSyncCandidateReviewCliArgs(process.argv)),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (process.argv.includes('--napcat-runtime-release-readiness')) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
buildNapcatRuntimeReleaseReadiness(parseNapcatRuntimeReleaseReadinessCliArgs(process.argv)),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (process.argv.includes('--napcat-remote-dev-handoff')) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(
|
|
||||||
buildNapcatRemoteDevHandoff(parseNapcatRemoteDevHandoffCliArgs(process.argv)),
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (process.argv.includes('--nas-codex-bootstrap')) {
|
|
||||||
console.log(
|
|
||||||
JSON.stringify(buildNasCodexBootstrapPlan(parseNasCodexBootstrapCliArgs(process.argv)), null, 2),
|
|
||||||
);
|
|
||||||
} else if (process.argv.includes('--self-test')) {
|
} else if (process.argv.includes('--self-test')) {
|
||||||
await runSelfTest();
|
await runSelfTest();
|
||||||
} else if (process.argv.includes('--workstream-closeout')) {
|
} else if (process.argv.includes('--workstream-closeout')) {
|
||||||
console.log(JSON.stringify(buildWorkstreamCloseout(parseWorkstreamCloseoutCliArgs(process.argv)), null, 2));
|
console.log(JSON.stringify(buildWorkstreamCloseout(parseWorkstreamCloseoutCliArgs(process.argv)), null, 2));
|
||||||
} else if (process.argv.includes('--napcat-device-profile-check')) {
|
|
||||||
console.log(JSON.stringify(buildNapcatDeviceProfileCheck({ project: 'api' }), null, 2));
|
|
||||||
} else {
|
} else {
|
||||||
const server = new McpServer({
|
const server = new McpServer({
|
||||||
name: 'kt-workflow',
|
name: 'kt-workflow',
|
||||||
|
|||||||
@ -36,31 +36,32 @@ interface HistoryEntry {
|
|||||||
type: 'directory' | 'file';
|
type: 'directory' | 'file';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const alwaysPreservedTemplateNames = new Set(['_template', '_templates', 'template', 'templates']);
|
||||||
const workspaceRootPreservedNames = new Set([
|
const workspaceRootPreservedNames = new Set([
|
||||||
'backup',
|
'backup',
|
||||||
'db-sync',
|
'db-sync',
|
||||||
'deploy',
|
'deploy',
|
||||||
'exports',
|
'external',
|
||||||
'logs',
|
'logs',
|
||||||
'playwright-mcp',
|
'playwright-mcp',
|
||||||
'subagents',
|
'sources',
|
||||||
'test-artifacts',
|
'test-artifacts',
|
||||||
'test-logs',
|
'test-logs',
|
||||||
'tmp',
|
'tmp',
|
||||||
'verify',
|
'verify',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
/**
|
function isTemplateEntryName(name: string): boolean {
|
||||||
* Builds the directory names that represent generated artifact categories under
|
return alwaysPreservedTemplateNames.has(name.toLowerCase());
|
||||||
* `.kt-workspace`; reusable templates and references are intentionally not
|
}
|
||||||
* preserved here because they belong in `workspace-assets`.
|
|
||||||
*
|
function buildPreservedNames(rootRelativePath: string, childNames: string[]): Set<string> {
|
||||||
* @param rootRelativePath - Cleanup root relative to the KT workspace.
|
|
||||||
* @returns Category names that cleanup should skip when pruning `.kt-workspace`.
|
|
||||||
*/
|
|
||||||
function buildPreservedNames(rootRelativePath: string): Set<string> {
|
|
||||||
const preservedNames = new Set<string>();
|
const preservedNames = new Set<string>();
|
||||||
|
|
||||||
|
for (const name of childNames) {
|
||||||
|
if (isTemplateEntryName(name)) preservedNames.add(name);
|
||||||
|
}
|
||||||
|
|
||||||
if (rootRelativePath === '.kt-workspace') {
|
if (rootRelativePath === '.kt-workspace') {
|
||||||
for (const name of workspaceRootPreservedNames) {
|
for (const name of workspaceRootPreservedNames) {
|
||||||
preservedNames.add(name);
|
preservedNames.add(name);
|
||||||
@ -125,7 +126,8 @@ export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const preservedNames = buildPreservedNames(rootRelativePath);
|
const childNames = readdirSync(rootPath, { withFileTypes: true }).map((item) => item.name);
|
||||||
|
const preservedNames = buildPreservedNames(rootRelativePath, childNames);
|
||||||
const entries = listHistoryEntries(rootPath, preservedNames);
|
const entries = listHistoryEntries(rootPath, preservedNames);
|
||||||
const preserved = entries.slice(0, keep);
|
const preserved = entries.slice(0, keep);
|
||||||
const deleting = entries.slice(keep);
|
const deleting = entries.slice(keep);
|
||||||
|
|||||||
@ -26,9 +26,9 @@ const upgradeTargets = {
|
|||||||
prompt: '把反复使用且容易写错的命令模板封装到工作流、业务测试计划或脚本。',
|
prompt: '把反复使用且容易写错的命令模板封装到工作流、业务测试计划或脚本。',
|
||||||
},
|
},
|
||||||
'deploy-observation': {
|
'deploy-observation': {
|
||||||
files: ['src/tools/deployObservation.ts', 'src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
|
files: ['src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
|
||||||
label: '部署观测',
|
label: '部署观测',
|
||||||
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、/health/runtime、日志和 smoke 观测步骤写成固定计划。',
|
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、日志和 smoke 观测步骤写成固定计划。',
|
||||||
},
|
},
|
||||||
none: {
|
none: {
|
||||||
files: [],
|
files: [],
|
||||||
|
|||||||
@ -1,757 +0,0 @@
|
|||||||
import { mkdirSync, writeFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
DeployObservationAssertion,
|
|
||||||
DeployObservationCommand,
|
|
||||||
DeployObservationDeploymentEvidence,
|
|
||||||
DeployObservationEvidence,
|
|
||||||
DeployObservationInput,
|
|
||||||
DeployObservationJenkinsEvidence,
|
|
||||||
DeployObservationPodEvidence,
|
|
||||||
DeployObservationResult,
|
|
||||||
DeployObservationRuntimeHealthEvidence,
|
|
||||||
DeployObservationStatus,
|
|
||||||
} from '../types.js';
|
|
||||||
import { tryPowerShell } from '../core/exec.js';
|
|
||||||
import {
|
|
||||||
formatDateInShanghai,
|
|
||||||
resolveInsideRoot,
|
|
||||||
workspaceRoot,
|
|
||||||
} from '../core/workspace.js';
|
|
||||||
|
|
||||||
const defaultArtifactRoot = '.kt-workspace/test-artifacts/deploy-observation';
|
|
||||||
const unsafeHereStringPattern = /[\r\n]|'@|"@/;
|
|
||||||
|
|
||||||
interface NormalizedInput
|
|
||||||
extends Required<
|
|
||||||
Pick<
|
|
||||||
DeployObservationInput,
|
|
||||||
| 'artifactRoot'
|
|
||||||
| 'container'
|
|
||||||
| 'deployment'
|
|
||||||
| 'healthUrl'
|
|
||||||
| 'jenkinsHome'
|
|
||||||
| 'jobName'
|
|
||||||
| 'kubeconfigPath'
|
|
||||||
| 'namespace'
|
|
||||||
| 'project'
|
|
||||||
| 'selector'
|
|
||||||
| 'sshTarget'
|
|
||||||
>
|
|
||||||
> {
|
|
||||||
buildNumber?: string;
|
|
||||||
execute: boolean;
|
|
||||||
expectedCommit?: string;
|
|
||||||
imageTag?: string;
|
|
||||||
smoke?: string;
|
|
||||||
sshPort?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NormalizeEvidenceInput {
|
|
||||||
checkedAt?: Date;
|
|
||||||
input: DeployObservationInput;
|
|
||||||
sectionMap: Map<string, string>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeArtifactRoot(value?: string): string {
|
|
||||||
const artifactRoot = value?.trim() || defaultArtifactRoot;
|
|
||||||
assertSafeStructuredInput('artifactRoot', artifactRoot);
|
|
||||||
const base = resolveInsideRoot(defaultArtifactRoot);
|
|
||||||
const target = resolveInsideRoot(artifactRoot);
|
|
||||||
const relative = path.relative(base, target);
|
|
||||||
|
|
||||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
||||||
throw new Error(
|
|
||||||
`Deploy observation artifacts must stay under ${defaultArtifactRoot}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return artifactRoot;
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertSafeStructuredInput(field: string, value?: string): void {
|
|
||||||
if (!value) return;
|
|
||||||
if (unsafeHereStringPattern.test(value)) {
|
|
||||||
throw new Error(
|
|
||||||
`Unsafe deploy observation input "${field}": CR/LF and PowerShell here-string terminators are not allowed.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function cleanStructuredInput(
|
|
||||||
field: string,
|
|
||||||
value: string | undefined,
|
|
||||||
): string | undefined {
|
|
||||||
const trimmed = value?.trim();
|
|
||||||
assertSafeStructuredInput(field, trimmed);
|
|
||||||
return trimmed || undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSshTarget(value?: string, execute = false): string {
|
|
||||||
const target = cleanStructuredInput('sshTarget', value) || 'nas';
|
|
||||||
if (target.startsWith('-')) {
|
|
||||||
throw new Error(
|
|
||||||
'Unsafe deploy observation input "sshTarget": SSH option injection is not allowed.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (execute && target !== 'nas') {
|
|
||||||
throw new Error(
|
|
||||||
'Unsafe deploy observation input "sshTarget": execute mode only allows the stabilized "nas" SSH target.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return target;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeHttpUrl(field: string, value: string): string {
|
|
||||||
let parsed: URL;
|
|
||||||
try {
|
|
||||||
parsed = new URL(value);
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported deploy observation ${field}: value must be a valid http(s) URL.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported deploy observation ${field}: URL must use http or https.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (parsed.username || parsed.password) {
|
|
||||||
throw new Error(
|
|
||||||
`Unsupported deploy observation ${field}: URL credentials are not allowed.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return parsed.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSmokeCommand(value?: string): string | undefined {
|
|
||||||
const smoke = value?.trim();
|
|
||||||
if (!smoke) return undefined;
|
|
||||||
assertSafeStructuredInput('smoke', smoke);
|
|
||||||
|
|
||||||
const match = smoke.match(
|
|
||||||
/^curl\s+-fsS\s+--max-time\s+([1-9]\d?)\s+(['"]?)(https?:\/\/[^\s'"`;$|&<>\\]+)\2$/,
|
|
||||||
);
|
|
||||||
if (!match) {
|
|
||||||
throw new Error(
|
|
||||||
'Unsupported deploy observation smoke command: only bounded curl -fsS --max-time <seconds> <http-url> GET smoke is allowed.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const timeout = Number(match[1]);
|
|
||||||
const url = match[3];
|
|
||||||
if (!Number.isInteger(timeout) || timeout < 1 || timeout > 30) {
|
|
||||||
throw new Error(
|
|
||||||
'Unsupported deploy observation smoke command: --max-time must be between 1 and 30 seconds.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const parsedUrl = normalizeHttpUrl('smoke command', url);
|
|
||||||
|
|
||||||
return `curl -fsS --max-time ${timeout} ${shellSingleQuote(parsedUrl)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeInput(input: DeployObservationInput = {}): NormalizedInput {
|
|
||||||
const deployment =
|
|
||||||
cleanStructuredInput('deployment', input.deployment) ||
|
|
||||||
'kt-template-online-api';
|
|
||||||
const healthUrl =
|
|
||||||
cleanStructuredInput('healthUrl', input.healthUrl) ||
|
|
||||||
'http://127.0.0.1:48085/health/runtime';
|
|
||||||
const execute = input.execute === true;
|
|
||||||
|
|
||||||
return {
|
|
||||||
artifactRoot: normalizeArtifactRoot(input.artifactRoot),
|
|
||||||
buildNumber: cleanStructuredInput('buildNumber', input.buildNumber),
|
|
||||||
container: cleanStructuredInput('container', input.container) || 'api',
|
|
||||||
deployment,
|
|
||||||
execute,
|
|
||||||
expectedCommit: cleanStructuredInput(
|
|
||||||
'expectedCommit',
|
|
||||||
input.expectedCommit,
|
|
||||||
),
|
|
||||||
healthUrl: normalizeHttpUrl('healthUrl', healthUrl),
|
|
||||||
imageTag: cleanStructuredInput('imageTag', input.imageTag),
|
|
||||||
jenkinsHome:
|
|
||||||
cleanStructuredInput('jenkinsHome', input.jenkinsHome) ||
|
|
||||||
'/vol1/docker/jenkins/jenkins_home',
|
|
||||||
jobName:
|
|
||||||
cleanStructuredInput('jobName', input.jobName) ||
|
|
||||||
'KT-Template/KT-Template-API/main',
|
|
||||||
kubeconfigPath:
|
|
||||||
cleanStructuredInput('kubeconfigPath', input.kubeconfigPath) ||
|
|
||||||
'/vol1/docker/kt-k8s/kubeconfig/kt-nas.jenkins.yaml',
|
|
||||||
namespace: cleanStructuredInput('namespace', input.namespace) || 'kt-prod',
|
|
||||||
project: cleanStructuredInput('project', input.project) || 'api',
|
|
||||||
selector: cleanStructuredInput('selector', input.selector) || `app=${deployment}`,
|
|
||||||
smoke: normalizeSmokeCommand(input.smoke),
|
|
||||||
sshPort:
|
|
||||||
typeof input.sshPort === 'number' && Number.isFinite(input.sshPort)
|
|
||||||
? input.sshPort
|
|
||||||
: undefined,
|
|
||||||
sshTarget: normalizeSshTarget(input.sshTarget, execute),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function shellSingleQuote(value: string): string {
|
|
||||||
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function powerShellArgument(value: string): string {
|
|
||||||
if (/^[A-Za-z0-9._@:/-]+$/.test(value)) return value;
|
|
||||||
return `'${value.replaceAll("'", "''")}'`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function numberOrNull(value: unknown): number | null {
|
|
||||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function stringOrNull(value: unknown): string | null {
|
|
||||||
return typeof value === 'string' && value.length > 0 ? value : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseJsonObject(value: string): Record<string, unknown> | null {
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(value);
|
|
||||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
||||||
? (parsed as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPath(source: unknown, keys: string[]): unknown {
|
|
||||||
return keys.reduce<unknown>((current, key) => {
|
|
||||||
if (!current || typeof current !== 'object') return undefined;
|
|
||||||
return (current as Record<string, unknown>)[key];
|
|
||||||
}, source);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getArray(source: unknown, keys: string[]): unknown[] {
|
|
||||||
const value = getPath(source, keys);
|
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function objectAt(value: unknown): Record<string, unknown> | null {
|
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
|
||||||
? (value as Record<string, unknown>)
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function findNamedObject(
|
|
||||||
values: unknown[],
|
|
||||||
name: string,
|
|
||||||
): Record<string, unknown> | null {
|
|
||||||
const objects = values
|
|
||||||
.map(objectAt)
|
|
||||||
.filter((item): item is Record<string, unknown> => Boolean(item));
|
|
||||||
return objects.find((item) => stringOrNull(item.name) === name) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readKeyValueSection(value: string): Record<string, string> {
|
|
||||||
return Object.fromEntries(
|
|
||||||
value
|
|
||||||
.split(/\r?\n/)
|
|
||||||
.map((line) => {
|
|
||||||
const index = line.indexOf('=');
|
|
||||||
return index >= 0
|
|
||||||
? [line.slice(0, index).trim(), line.slice(index + 1).trim()]
|
|
||||||
: null;
|
|
||||||
})
|
|
||||||
.filter((item): item is [string, string] => Boolean(item?.[0])),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildRemoteScript(input: NormalizedInput): string {
|
|
||||||
const smokeBlock = input.smoke
|
|
||||||
? [
|
|
||||||
'section smoke_text',
|
|
||||||
'set +e',
|
|
||||||
`${input.smoke} 2>&1`,
|
|
||||||
'SMOKE_EXIT=$?',
|
|
||||||
'set -u',
|
|
||||||
'section smoke_status',
|
|
||||||
'printf "exitCode=%s\\n" "$SMOKE_EXIT"',
|
|
||||||
].join('\n')
|
|
||||||
: [
|
|
||||||
'section smoke_text',
|
|
||||||
"echo 'no task smoke command provided'",
|
|
||||||
'section smoke_status',
|
|
||||||
'printf "exitCode=\\n"',
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return [
|
|
||||||
'set -u',
|
|
||||||
'section() { printf "\\n__KT_SECTION:%s__\\n" "$1"; }',
|
|
||||||
`JOB_NAME=${shellSingleQuote(input.jobName)}`,
|
|
||||||
`BUILD_NUMBER=${shellSingleQuote(input.buildNumber || '')}`,
|
|
||||||
`JENKINS_HOME=${shellSingleQuote(input.jenkinsHome)}`,
|
|
||||||
`KUBECONFIG_PATH=${shellSingleQuote(input.kubeconfigPath)}`,
|
|
||||||
`NAMESPACE=${shellSingleQuote(input.namespace)}`,
|
|
||||||
`DEPLOYMENT=${shellSingleQuote(input.deployment)}`,
|
|
||||||
`SELECTOR=${shellSingleQuote(input.selector)}`,
|
|
||||||
`HEALTH_URL=${shellSingleQuote(input.healthUrl)}`,
|
|
||||||
'KUBECONFIG_ACTIVE="$KUBECONFIG_PATH"',
|
|
||||||
'KUBECONFIG_TEMP=""',
|
|
||||||
'cleanup_kubeconfig_temp() { if [ -n "$KUBECONFIG_TEMP" ]; then rm -f "$KUBECONFIG_TEMP"; fi; }',
|
|
||||||
'trap cleanup_kubeconfig_temp EXIT',
|
|
||||||
'if [ -f "$KUBECONFIG_PATH" ] && grep -q "https://k3d-kt-nas-serverlb:6443" "$KUBECONFIG_PATH"; then',
|
|
||||||
' K3D_API_PORT="$(docker port k3d-kt-nas-serverlb 6443/tcp 2>/dev/null | tail -1 | rev | cut -d: -f1 | rev || true)"',
|
|
||||||
' if [ -n "$K3D_API_PORT" ]; then',
|
|
||||||
' KUBECONFIG_TEMP="/tmp/kt-deploy-observation-kubeconfig-$$.yaml"',
|
|
||||||
' sed "s#https://k3d-kt-nas-serverlb:6443#https://127.0.0.1:${K3D_API_PORT}#g" "$KUBECONFIG_PATH" > "$KUBECONFIG_TEMP"',
|
|
||||||
' KUBECONFIG_ACTIVE="$KUBECONFIG_TEMP"',
|
|
||||||
' fi',
|
|
||||||
'fi',
|
|
||||||
'JOB_DIR="$JENKINS_HOME/jobs/$JOB_NAME/builds"',
|
|
||||||
'if [ ! -d "$JOB_DIR" ]; then',
|
|
||||||
' JOB_SLASH_COUNT="$(printf "%s" "$JOB_NAME" | awk -F/ \'{print NF-1}\')"',
|
|
||||||
' if [ "$JOB_SLASH_COUNT" = "2" ]; then',
|
|
||||||
' JOB_FOLDER="${JOB_NAME%%/*}"',
|
|
||||||
' JOB_BRANCH="${JOB_NAME##*/}"',
|
|
||||||
' JOB_PIPELINE="${JOB_NAME#*/}"',
|
|
||||||
' JOB_PIPELINE="${JOB_PIPELINE%/*}"',
|
|
||||||
' MULTIBRANCH_JOB_DIR="$JENKINS_HOME/jobs/$JOB_FOLDER/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds"',
|
|
||||||
' if [ -d "$MULTIBRANCH_JOB_DIR" ]; then',
|
|
||||||
' JOB_DIR="$MULTIBRANCH_JOB_DIR"',
|
|
||||||
' else',
|
|
||||||
' FOUND_JOB_DIR="$(find "$JENKINS_HOME/jobs" -path "*/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds" -type d -print -quit 2>/dev/null || true)"',
|
|
||||||
' if [ -z "$FOUND_JOB_DIR" ]; then FOUND_JOB_DIR="$(find "$JENKINS_HOME/jobs" -path "*/jobs/$JOB_PIPELINE/branches/main/builds" -type d -print -quit 2>/dev/null || true)"; fi',
|
|
||||||
' if [ -n "$FOUND_JOB_DIR" ]; then JOB_DIR="$FOUND_JOB_DIR"; fi',
|
|
||||||
' fi',
|
|
||||||
' fi',
|
|
||||||
'fi',
|
|
||||||
'if [ -z "$BUILD_NUMBER" ] && [ -d "$JOB_DIR" ]; then BUILD_NUMBER="$(find "$JOB_DIR" -maxdepth 1 -type d -printf "%f\\n" 2>/dev/null | grep -E "^[0-9]+$" | sort -n | tail -1 || true)"; fi',
|
|
||||||
'BUILD_DIR="$JOB_DIR/$BUILD_NUMBER"',
|
|
||||||
'section jenkins_meta',
|
|
||||||
'printf "jobName=%s\\n" "$JOB_NAME"',
|
|
||||||
'printf "buildNumber=%s\\n" "$BUILD_NUMBER"',
|
|
||||||
'if [ -f "$BUILD_DIR/build.xml" ]; then RESULT="$(grep -Eo "<result>[^<]*</result>" "$BUILD_DIR/build.xml" 2>/dev/null | sed -E "s#</?result>##g" | tail -1 || true)"; else RESULT=""; fi',
|
|
||||||
'printf "result=%s\\n" "$RESULT"',
|
|
||||||
'section jenkins_build_xml',
|
|
||||||
'if [ -f "$BUILD_DIR/build.xml" ]; then grep -E "<(hash|sha1)>[0-9a-fA-F]{7,40}</(hash|sha1)>" "$BUILD_DIR/build.xml" 2>/dev/null | head -n 20 || true; else echo "jenkins build.xml not found: $BUILD_DIR/build.xml"; fi',
|
|
||||||
'section jenkins_log_tail',
|
|
||||||
'if [ -f "$BUILD_DIR/log" ]; then tail -n 160 "$BUILD_DIR/log"; else echo "jenkins log not found: $BUILD_DIR/log"; fi',
|
|
||||||
'section deployment_json',
|
|
||||||
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get deployment "$DEPLOYMENT" -o json 2>&1 || true',
|
|
||||||
'section pods_json',
|
|
||||||
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get pods -l "$SELECTOR" -o json 2>&1 || true',
|
|
||||||
'section events_tail',
|
|
||||||
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE" --request-timeout=8s -n "$NAMESPACE" get events --sort-by=.lastTimestamp 2>&1 | tail -n 80 || true',
|
|
||||||
'section health_json',
|
|
||||||
'curl -fsS --max-time 8 "$HEALTH_URL" 2>&1 || true',
|
|
||||||
smokeBlock,
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDeployObservationCommands(
|
|
||||||
input: DeployObservationInput = {},
|
|
||||||
): DeployObservationCommand[] {
|
|
||||||
const normalized = normalizeInput(input);
|
|
||||||
const sshParts = [
|
|
||||||
'ssh',
|
|
||||||
...(normalized.sshPort ? ['-p', String(normalized.sshPort)] : []),
|
|
||||||
powerShellArgument(normalized.sshTarget),
|
|
||||||
'"tr -d \'\\015\' | bash -s"',
|
|
||||||
];
|
|
||||||
const command = [
|
|
||||||
"$script = @'",
|
|
||||||
buildRemoteScript(normalized),
|
|
||||||
"'@",
|
|
||||||
`$script | ${sshParts.join(' ')}`,
|
|
||||||
].join('\n');
|
|
||||||
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
command,
|
|
||||||
name: 'nas-deploy-observation',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDeployObservationSectionMap(
|
|
||||||
stdout: string,
|
|
||||||
): Map<string, string> {
|
|
||||||
const sections = new Map<string, string>();
|
|
||||||
let current: string | null = null;
|
|
||||||
let buffer: string[] = [];
|
|
||||||
|
|
||||||
for (const line of stdout.split(/\r?\n/)) {
|
|
||||||
const marker = line.match(/^__KT_SECTION:([A-Za-z0-9_-]+)__$/);
|
|
||||||
if (marker) {
|
|
||||||
if (current) sections.set(current, buffer.join('\n').trim());
|
|
||||||
current = marker[1];
|
|
||||||
buffer = [];
|
|
||||||
} else if (current) {
|
|
||||||
buffer.push(line);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current) sections.set(current, buffer.join('\n').trim());
|
|
||||||
|
|
||||||
return sections;
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeJenkinsEvidence(
|
|
||||||
input: NormalizedInput,
|
|
||||||
sectionMap: Map<string, string>,
|
|
||||||
): DeployObservationJenkinsEvidence {
|
|
||||||
const meta = readKeyValueSection(sectionMap.get('jenkins_meta') || '');
|
|
||||||
const logTail = sectionMap.get('jenkins_log_tail') || '';
|
|
||||||
const buildXmlRevision = sectionMap.get('jenkins_build_xml') || '';
|
|
||||||
const commitHaystack = `${logTail}\n${buildXmlRevision}`.toLowerCase();
|
|
||||||
const logFinishedStatuses = [...logTail.matchAll(/Finished:\s*([A-Z_]+)/gi)];
|
|
||||||
const logFinishedStatus =
|
|
||||||
logFinishedStatuses.at(-1)?.[1]?.toUpperCase() || null;
|
|
||||||
const logTailAvailable =
|
|
||||||
logTail.trim().length > 0 && !/jenkins log not found:/i.test(logTail);
|
|
||||||
const finishedStatus =
|
|
||||||
logFinishedStatus ||
|
|
||||||
(logTailAvailable ? null : meta.result?.toUpperCase() || null);
|
|
||||||
const expectedCommit = input.expectedCommit || null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
buildNumber: meta.buildNumber || input.buildNumber || null,
|
|
||||||
commitMatched: expectedCommit
|
|
||||||
? commitHaystack.includes(expectedCommit.toLowerCase())
|
|
||||||
: null,
|
|
||||||
expectedCommit,
|
|
||||||
finishedStatus,
|
|
||||||
jobName: input.jobName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDeploymentEvidence(
|
|
||||||
input: NormalizedInput,
|
|
||||||
deploymentJson: Record<string, unknown> | null,
|
|
||||||
): DeployObservationDeploymentEvidence {
|
|
||||||
const container = findNamedObject(
|
|
||||||
getArray(deploymentJson, ['spec', 'template', 'spec', 'containers']),
|
|
||||||
input.container,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
container: input.container,
|
|
||||||
containerFound: Boolean(container),
|
|
||||||
desiredReplicas:
|
|
||||||
numberOrNull(getPath(deploymentJson, ['spec', 'replicas'])) ??
|
|
||||||
numberOrNull(getPath(deploymentJson, ['status', 'replicas'])) ??
|
|
||||||
1,
|
|
||||||
generation: numberOrNull(getPath(deploymentJson, ['metadata', 'generation'])),
|
|
||||||
image: stringOrNull(container?.image),
|
|
||||||
namespace: input.namespace,
|
|
||||||
observedGeneration: numberOrNull(
|
|
||||||
getPath(deploymentJson, ['status', 'observedGeneration']),
|
|
||||||
),
|
|
||||||
readyReplicas: numberOrNull(
|
|
||||||
getPath(deploymentJson, ['status', 'readyReplicas']),
|
|
||||||
),
|
|
||||||
updatedReplicas: numberOrNull(
|
|
||||||
getPath(deploymentJson, ['status', 'updatedReplicas']),
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function scorePod(
|
|
||||||
pod: Record<string, unknown>,
|
|
||||||
input: NormalizedInput,
|
|
||||||
): number {
|
|
||||||
const phase = stringOrNull(getPath(pod, ['status', 'phase']));
|
|
||||||
const statuses = getArray(pod, ['status', 'containerStatuses']);
|
|
||||||
const status = findNamedObject(statuses, input.container);
|
|
||||||
const image = stringOrNull(status?.image);
|
|
||||||
return (
|
|
||||||
(phase === 'Running' ? 4 : 0) +
|
|
||||||
(image && input.imageTag && image.includes(input.imageTag) ? 2 : 0) +
|
|
||||||
(status?.ready === true ? 1 : 0)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizePodEvidence(
|
|
||||||
input: NormalizedInput,
|
|
||||||
podsJson: Record<string, unknown> | null,
|
|
||||||
): DeployObservationPodEvidence {
|
|
||||||
const pods = getArray(podsJson, ['items'])
|
|
||||||
.map(objectAt)
|
|
||||||
.filter((item): item is Record<string, unknown> => Boolean(item))
|
|
||||||
.sort((left, right) => scorePod(right, input) - scorePod(left, input));
|
|
||||||
const pod = pods[0] || null;
|
|
||||||
const status = findNamedObject(
|
|
||||||
getArray(pod, ['status', 'containerStatuses']),
|
|
||||||
input.container,
|
|
||||||
);
|
|
||||||
const specContainer = findNamedObject(
|
|
||||||
getArray(pod, ['spec', 'containers']),
|
|
||||||
input.container,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
containerFound: Boolean(status || specContainer),
|
|
||||||
image: stringOrNull(status?.image) || stringOrNull(specContainer?.image),
|
|
||||||
name: stringOrNull(getPath(pod, ['metadata', 'name'])),
|
|
||||||
phase: stringOrNull(getPath(pod, ['status', 'phase'])),
|
|
||||||
ready: typeof status?.ready === 'boolean' ? status.ready : null,
|
|
||||||
restartCount: numberOrNull(status?.restartCount),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeRuntimeHealthEvidence(
|
|
||||||
healthJson: Record<string, unknown> | null,
|
|
||||||
): DeployObservationRuntimeHealthEvidence {
|
|
||||||
const checks = getArray(healthJson, ['checks']);
|
|
||||||
|
|
||||||
return {
|
|
||||||
checkCount: checks.length || null,
|
|
||||||
service: stringOrNull(healthJson?.service),
|
|
||||||
status: stringOrNull(healthJson?.status),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseSmokeExitCode(sectionMap: Map<string, string>): number | null {
|
|
||||||
const status = readKeyValueSection(sectionMap.get('smoke_status') || '');
|
|
||||||
if (!status.exitCode?.trim()) return null;
|
|
||||||
const exitCode = Number(status.exitCode);
|
|
||||||
return Number.isInteger(exitCode) && exitCode >= 0 ? exitCode : null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildAssertion(
|
|
||||||
name: string,
|
|
||||||
passed: boolean,
|
|
||||||
message: string,
|
|
||||||
critical = true,
|
|
||||||
): DeployObservationAssertion {
|
|
||||||
return {
|
|
||||||
critical,
|
|
||||||
message,
|
|
||||||
name,
|
|
||||||
passed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function aggregateStatus(
|
|
||||||
assertions: DeployObservationAssertion[],
|
|
||||||
): DeployObservationStatus {
|
|
||||||
if (assertions.every((item) => item.passed)) return 'passed';
|
|
||||||
if (assertions.some((item) => item.critical && !item.passed)) return 'failed';
|
|
||||||
return 'blocked';
|
|
||||||
}
|
|
||||||
|
|
||||||
function toFilenameSlug(value: string): string {
|
|
||||||
const slug = value
|
|
||||||
.replace(/[^A-Za-z0-9_-]+/g, '-')
|
|
||||||
.replace(/^-+|-+$/g, '')
|
|
||||||
.slice(0, 80);
|
|
||||||
return slug || 'project';
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeDeployObservationEvidence({
|
|
||||||
checkedAt = new Date(),
|
|
||||||
input,
|
|
||||||
sectionMap,
|
|
||||||
}: NormalizeEvidenceInput): DeployObservationEvidence {
|
|
||||||
const normalized = normalizeInput(input);
|
|
||||||
const jenkins = normalizeJenkinsEvidence(normalized, sectionMap);
|
|
||||||
const deployment = normalizeDeploymentEvidence(
|
|
||||||
normalized,
|
|
||||||
parseJsonObject(sectionMap.get('deployment_json') || ''),
|
|
||||||
);
|
|
||||||
const pod = normalizePodEvidence(
|
|
||||||
normalized,
|
|
||||||
parseJsonObject(sectionMap.get('pods_json') || ''),
|
|
||||||
);
|
|
||||||
const runtimeHealth = normalizeRuntimeHealthEvidence(
|
|
||||||
parseJsonObject(sectionMap.get('health_json') || ''),
|
|
||||||
);
|
|
||||||
const smokeOutput = sectionMap.get('smoke_text') || '';
|
|
||||||
const smokeExitCode = parseSmokeExitCode(sectionMap);
|
|
||||||
const expectedImageTag = normalized.imageTag || normalized.expectedCommit;
|
|
||||||
const deploymentObserved =
|
|
||||||
deployment.generation !== null &&
|
|
||||||
deployment.observedGeneration !== null &&
|
|
||||||
deployment.observedGeneration >= deployment.generation;
|
|
||||||
const deploymentReplicasReady =
|
|
||||||
deployment.updatedReplicas !== null &&
|
|
||||||
deployment.readyReplicas !== null &&
|
|
||||||
deployment.desiredReplicas !== null &&
|
|
||||||
deployment.updatedReplicas >= deployment.desiredReplicas &&
|
|
||||||
deployment.readyReplicas >= deployment.desiredReplicas;
|
|
||||||
const runtimeHealthStatusAllowed =
|
|
||||||
runtimeHealth.status === 'ready' ||
|
|
||||||
runtimeHealth.status === 'live' ||
|
|
||||||
runtimeHealth.status === 'degraded';
|
|
||||||
const smokeSuccess = Boolean(normalized.smoke && smokeExitCode === 0);
|
|
||||||
const assertions = [
|
|
||||||
buildAssertion(
|
|
||||||
'jenkins-build-success',
|
|
||||||
jenkins.finishedStatus === 'SUCCESS',
|
|
||||||
`Jenkins ${jenkins.jobName} #${jenkins.buildNumber || 'unknown'} result=${jenkins.finishedStatus || 'missing'}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'jenkins-commit-match',
|
|
||||||
!jenkins.expectedCommit || jenkins.commitMatched === true,
|
|
||||||
jenkins.expectedCommit
|
|
||||||
? `Expected commit ${jenkins.expectedCommit} matched=${jenkins.commitMatched}`
|
|
||||||
: 'No expected commit supplied',
|
|
||||||
Boolean(jenkins.expectedCommit),
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'deployment-container-found',
|
|
||||||
deployment.containerFound,
|
|
||||||
`Deployment container=${deployment.container} found=${deployment.containerFound}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'deployment-image-tag',
|
|
||||||
Boolean(
|
|
||||||
deployment.image &&
|
|
||||||
(!expectedImageTag || deployment.image.includes(expectedImageTag)),
|
|
||||||
),
|
|
||||||
`Deployment image=${deployment.image || 'missing'}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'deployment-generation-observed',
|
|
||||||
deploymentObserved,
|
|
||||||
`Deployment generation=${deployment.generation} observedGeneration=${deployment.observedGeneration}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'deployment-replicas-ready',
|
|
||||||
deploymentReplicasReady,
|
|
||||||
`Deployment desired=${deployment.desiredReplicas} ready=${deployment.readyReplicas} updated=${deployment.updatedReplicas}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'pod-container-found',
|
|
||||||
pod.containerFound,
|
|
||||||
`Pod container=${normalized.container} found=${pod.containerFound}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'pod-running',
|
|
||||||
pod.phase === 'Running' && pod.ready === true,
|
|
||||||
`Pod ${pod.name || 'missing'} phase=${pod.phase || 'missing'} ready=${pod.ready}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'pod-image-tag',
|
|
||||||
Boolean(pod.image && (!expectedImageTag || pod.image.includes(expectedImageTag))),
|
|
||||||
`Pod image=${pod.image || 'missing'}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'pod-restart-count-zero',
|
|
||||||
pod.restartCount === 0,
|
|
||||||
`Pod restartCount=${pod.restartCount}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'runtime-health-available',
|
|
||||||
runtimeHealth.service === 'kt-template-online-api' &&
|
|
||||||
runtimeHealthStatusAllowed,
|
|
||||||
`Runtime health status=${runtimeHealth.status || 'missing'}`,
|
|
||||||
),
|
|
||||||
buildAssertion(
|
|
||||||
'task-smoke-success',
|
|
||||||
smokeSuccess,
|
|
||||||
normalized.smoke
|
|
||||||
? `Task smoke exitCode=${smokeExitCode ?? 'missing'}`
|
|
||||||
: 'No task smoke command supplied; rollout evidence is not functional evidence',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
assertions,
|
|
||||||
details: {
|
|
||||||
deployment,
|
|
||||||
eventsTail: sectionMap.get('events_tail') || '',
|
|
||||||
jenkins,
|
|
||||||
pod,
|
|
||||||
runtimeHealth,
|
|
||||||
smoke: {
|
|
||||||
command: normalized.smoke || null,
|
|
||||||
exitCode: smokeExitCode,
|
|
||||||
output: smokeOutput,
|
|
||||||
success: smokeSuccess,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
endedAt: checkedAt.toISOString(),
|
|
||||||
environment: 'production',
|
|
||||||
operation: 'kt_deploy_observation',
|
|
||||||
project: normalized.project,
|
|
||||||
schemaVersion: 1,
|
|
||||||
startedAt: checkedAt.toISOString(),
|
|
||||||
status: aggregateStatus(assertions),
|
|
||||||
target: `${normalized.namespace}/${normalized.deployment}`,
|
|
||||||
taskType: 'deploy',
|
|
||||||
title: 'KT API deployment observation',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeEvidenceArtifact(
|
|
||||||
artifactRoot: string,
|
|
||||||
evidence: DeployObservationEvidence,
|
|
||||||
): string {
|
|
||||||
const root = resolveInsideRoot(artifactRoot);
|
|
||||||
const directory = path.join(root, formatDateInShanghai());
|
|
||||||
const safeProject = toFilenameSlug(evidence.project);
|
|
||||||
const filePath = path.resolve(
|
|
||||||
directory,
|
|
||||||
`${safeProject}-${Date.now()}-deploy-observation.json`,
|
|
||||||
);
|
|
||||||
const relative = path.relative(root, filePath);
|
|
||||||
if (relative.startsWith('..') || path.isAbsolute(relative)) {
|
|
||||||
throw new Error(
|
|
||||||
`Deploy observation artifact path escaped artifact root: ${filePath}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(directory, { recursive: true });
|
|
||||||
writeFileSync(filePath, `${JSON.stringify(evidence, null, 2)}\n`, 'utf8');
|
|
||||||
return filePath;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function buildDeployObservation(
|
|
||||||
input: DeployObservationInput = {},
|
|
||||||
): Promise<DeployObservationResult> {
|
|
||||||
const normalized = normalizeInput(input);
|
|
||||||
const commands = buildDeployObservationCommands(normalized);
|
|
||||||
const requiredEvidence = [
|
|
||||||
'Jenkins job/build number and final Finished status',
|
|
||||||
'Expected commit hash match when a commit is supplied',
|
|
||||||
'K8s Deployment target container, desiredReplicas, updatedReplicas, readyReplicas, generation, observedGeneration',
|
|
||||||
'Running Pod selected by target container/current image tag with restartCount',
|
|
||||||
'GET /health/runtime response with service/status/check count',
|
|
||||||
'Task-specific smoke command output',
|
|
||||||
];
|
|
||||||
const notes = [
|
|
||||||
'Default execute=false only returns the bounded read-only command.',
|
|
||||||
'execute=true runs through ssh nas with CRLF-stripped here-string and writes local JSON evidence.',
|
|
||||||
'Jenkins/K8s rollout evidence is deployment evidence; task smoke is still required for functional completion.',
|
|
||||||
'The command reads Jenkins build files, kubectl status, events, and HTTP health only; it does not read Secrets or mutate remote state.',
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!normalized.execute) {
|
|
||||||
return {
|
|
||||||
commands,
|
|
||||||
execute: false,
|
|
||||||
notes,
|
|
||||||
requiredEvidence,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const command = commands[0]?.command || '';
|
|
||||||
const result = await tryPowerShell(command, workspaceRoot, 180_000);
|
|
||||||
const sectionMap = buildDeployObservationSectionMap(result.stdout);
|
|
||||||
const evidence = normalizeDeployObservationEvidence({
|
|
||||||
input: normalized,
|
|
||||||
sectionMap,
|
|
||||||
});
|
|
||||||
const artifactPath = writeEvidenceArtifact(normalized.artifactRoot, evidence);
|
|
||||||
|
|
||||||
return {
|
|
||||||
artifactPath,
|
|
||||||
commands,
|
|
||||||
evidence,
|
|
||||||
execute: true,
|
|
||||||
notes,
|
|
||||||
requiredEvidence,
|
|
||||||
result,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,64 +0,0 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { fileURLToPath } from 'node:url';
|
|
||||||
|
|
||||||
export const napcatAutomationPromptNames = [
|
|
||||||
'upstream-audit',
|
|
||||||
'sync-candidate-review',
|
|
||||||
'runtime-release-readiness',
|
|
||||||
'remote-dev-handoff',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export type NapcatAutomationPromptName =
|
|
||||||
(typeof napcatAutomationPromptNames)[number];
|
|
||||||
|
|
||||||
const promptsRoot = path.resolve(
|
|
||||||
fileURLToPath(new URL('../..', import.meta.url)),
|
|
||||||
'prompts',
|
|
||||||
'napcat',
|
|
||||||
);
|
|
||||||
const promptsRootBoundary = promptsRoot.endsWith(path.sep)
|
|
||||||
? promptsRoot
|
|
||||||
: `${promptsRoot}${path.sep}`;
|
|
||||||
const napcatAutomationPromptNameSet = new Set<string>(
|
|
||||||
napcatAutomationPromptNames,
|
|
||||||
);
|
|
||||||
const napcatAutomationPromptFileNames = {
|
|
||||||
'upstream-audit': 'upstream-audit.md',
|
|
||||||
'sync-candidate-review': 'sync-candidate-review.md',
|
|
||||||
'runtime-release-readiness': 'runtime-release-readiness.md',
|
|
||||||
'remote-dev-handoff': 'remote-dev-handoff.md',
|
|
||||||
} satisfies Record<NapcatAutomationPromptName, string>;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses an external prompt name into the source-controlled NapCat prompt enum.
|
|
||||||
* @param value - Raw prompt name from CLI, JSON, MCP input, or a typed caller; only exact entries in `napcatAutomationPromptNames` are accepted.
|
|
||||||
* @returns A stable prompt name that can be mapped to a fixed file under `prompts/napcat`.
|
|
||||||
*/
|
|
||||||
export function parseNapcatAutomationPromptName(
|
|
||||||
value: string,
|
|
||||||
): NapcatAutomationPromptName {
|
|
||||||
if (napcatAutomationPromptNameSet.has(value)) {
|
|
||||||
return value as NapcatAutomationPromptName;
|
|
||||||
}
|
|
||||||
throw new Error(`Unsupported NapCat automation prompt: ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads a source-controlled NapCat automation prompt by stable prompt name.
|
|
||||||
* @param name - Prompt identifier from `napcatAutomationPromptNames`; runtime callers may still pass untyped external strings, so the loader re-validates and resolves only fixed filenames.
|
|
||||||
* @returns UTF-8 prompt text used by Codex CLI automation.
|
|
||||||
*/
|
|
||||||
export function loadNapcatAutomationPrompt(
|
|
||||||
name: NapcatAutomationPromptName,
|
|
||||||
): string {
|
|
||||||
const promptName = parseNapcatAutomationPromptName(String(name));
|
|
||||||
const promptPath = path.resolve(
|
|
||||||
promptsRoot,
|
|
||||||
napcatAutomationPromptFileNames[promptName],
|
|
||||||
);
|
|
||||||
if (!promptPath.startsWith(promptsRootBoundary)) {
|
|
||||||
throw new Error(`NapCat automation prompt escaped prompt root: ${name}`);
|
|
||||||
}
|
|
||||||
return readFileSync(promptPath, 'utf8');
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@ -1,130 +0,0 @@
|
|||||||
import { z } from 'zod';
|
|
||||||
|
|
||||||
export const napcatAutomationClassifications = [
|
|
||||||
'safe-candidate',
|
|
||||||
'manual-review',
|
|
||||||
'blocked',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const napcatAutomationReasonCodes = [
|
|
||||||
'NO_UPSTREAM_CHANGE',
|
|
||||||
'HOT_ZONE_CHANGED',
|
|
||||||
'FORK_PATCH_OVERLAP',
|
|
||||||
'DRY_MERGE_CONFLICT',
|
|
||||||
'BUILD_GRAPH_CHANGED',
|
|
||||||
'METADATA_UNAVAILABLE',
|
|
||||||
'CODEX_SCHEMA_INVALID',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const napcatAutomationRecommendedActions = [
|
|
||||||
'no-op',
|
|
||||||
'create-candidate-branch',
|
|
||||||
'request-human-review',
|
|
||||||
'block',
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export const upstreamAuditOutputSchema = z.object({
|
|
||||||
classification: z.enum(napcatAutomationClassifications),
|
|
||||||
reasonCodes: z.array(z.enum(napcatAutomationReasonCodes)).min(1),
|
|
||||||
recommendedAction: z.enum(napcatAutomationRecommendedActions),
|
|
||||||
summary: z.string().min(1),
|
|
||||||
}).strict();
|
|
||||||
|
|
||||||
export type NapcatAutomationClassification =
|
|
||||||
(typeof napcatAutomationClassifications)[number];
|
|
||||||
export type NapcatAutomationReasonCode =
|
|
||||||
(typeof napcatAutomationReasonCodes)[number];
|
|
||||||
export type NapcatAutomationRecommendedAction =
|
|
||||||
(typeof napcatAutomationRecommendedActions)[number];
|
|
||||||
|
|
||||||
export interface NapcatAuditClassificationInput {
|
|
||||||
dryMergeConflict: boolean;
|
|
||||||
forkPatchFiles: string[];
|
|
||||||
upstreamChangedFiles: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatAuditClassificationResult {
|
|
||||||
classification: NapcatAutomationClassification;
|
|
||||||
reasonCodes: NapcatAutomationReasonCode[];
|
|
||||||
recommendedAction: NapcatAutomationRecommendedAction;
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatAuditCommand {
|
|
||||||
command: string;
|
|
||||||
description: string;
|
|
||||||
readOnly: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatCodexExecCommandInput {
|
|
||||||
artifactRoot: string;
|
|
||||||
codexHome?: string;
|
|
||||||
contextPacketPath: string;
|
|
||||||
outputSchemaPath: string;
|
|
||||||
promptName: 'upstream-audit';
|
|
||||||
workspaceRoot: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatCodexExecResult {
|
|
||||||
ok: boolean;
|
|
||||||
stderr: string;
|
|
||||||
stdout: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type NapcatCodexExecutor = (
|
|
||||||
command: string,
|
|
||||||
) => NapcatCodexExecResult;
|
|
||||||
|
|
||||||
export interface NapcatUpstreamAuditInput
|
|
||||||
extends NapcatAuditClassificationInput {
|
|
||||||
artifactRoot?: string;
|
|
||||||
codexExecutor?: NapcatCodexExecutor;
|
|
||||||
codexHome?: string;
|
|
||||||
createCandidateBranch?: boolean;
|
|
||||||
execute?: boolean;
|
|
||||||
forkBranch?: string;
|
|
||||||
forkRepo?: string;
|
|
||||||
lastAcceptedUpstreamBase?: string;
|
|
||||||
outputSchemaPath?: string;
|
|
||||||
promptName?: 'upstream-audit';
|
|
||||||
upstreamReleaseTag?: string;
|
|
||||||
upstreamRepo?: string;
|
|
||||||
useCodex?: boolean;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatAutomationArtifact {
|
|
||||||
path: string;
|
|
||||||
type: 'context' | 'json' | 'jsonl' | 'log' | 'markdown' | 'script';
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatAutomationResult {
|
|
||||||
artifacts: NapcatAutomationArtifact[];
|
|
||||||
classification: NapcatAutomationClassification;
|
|
||||||
execute: boolean;
|
|
||||||
recommendedAction: string;
|
|
||||||
summary: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatUpstreamAuditResult
|
|
||||||
extends NapcatAuditClassificationResult {
|
|
||||||
artifacts: NapcatAutomationArtifact[];
|
|
||||||
commands: NapcatAuditCommand[];
|
|
||||||
execute: boolean;
|
|
||||||
hotZonePatterns: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parses the upstream audit classification emitted by deterministic collectors or Codex.
|
|
||||||
* @param value - Raw classification string from deterministic audit metadata or Codex JSON output; must match the shared NapCat automation taxonomy.
|
|
||||||
* @returns A supported NapCat automation classification for downstream workflow decisions.
|
|
||||||
*/
|
|
||||||
export function parseNapcatAutomationClassification(
|
|
||||||
value: string,
|
|
||||||
): NapcatAutomationClassification {
|
|
||||||
const parsed = z.enum(napcatAutomationClassifications).safeParse(value);
|
|
||||||
if (!parsed.success) {
|
|
||||||
throw new Error(`Unsupported NapCat audit classification: ${value}`);
|
|
||||||
}
|
|
||||||
return parsed.data;
|
|
||||||
}
|
|
||||||
@ -172,6 +172,7 @@ const requiredCorePlugins = [
|
|||||||
const expectedIgnoredRoots = [
|
const expectedIgnoredRoots = [
|
||||||
'.git/',
|
'.git/',
|
||||||
'.kt-workspace/',
|
'.kt-workspace/',
|
||||||
|
'_exports/',
|
||||||
'Node/',
|
'Node/',
|
||||||
'Plugins/',
|
'Plugins/',
|
||||||
'Vue/',
|
'Vue/',
|
||||||
@ -277,6 +278,7 @@ const ignoredDirectoryNames = new Set([
|
|||||||
'.pnpm',
|
'.pnpm',
|
||||||
'.turbo',
|
'.turbo',
|
||||||
'.vite',
|
'.vite',
|
||||||
|
'_exports',
|
||||||
'build',
|
'build',
|
||||||
'coverage',
|
'coverage',
|
||||||
'dist',
|
'dist',
|
||||||
|
|||||||
@ -41,15 +41,10 @@ const rootGeneratedArtifactNames = new Set([
|
|||||||
".codex-test-logs",
|
".codex-test-logs",
|
||||||
".codex-verify",
|
".codex-verify",
|
||||||
".playwright-mcp",
|
".playwright-mcp",
|
||||||
"_exports",
|
|
||||||
"codex-test-logs",
|
"codex-test-logs",
|
||||||
"test-artifacts",
|
"test-artifacts",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const workspaceAssetBlockedFilePattern =
|
|
||||||
/(^|\/)(\.env($|\..+)|.*\.(7z|dump|gz|rar|sql|storageState[^/]*|tar|tgz|zip)|id_rsa|[^/]+\.(key|pem|pfx|p12))$/i;
|
|
||||||
const workspaceAssetMaxFileBytes = 2 * 1024 * 1024;
|
|
||||||
|
|
||||||
const sensitiveTrackedFilePattern =
|
const sensitiveTrackedFilePattern =
|
||||||
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
|
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
|
||||||
|
|
||||||
@ -235,126 +230,6 @@ export function findQqbotCommandServiceTestImportFindings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findQqbotPluginSmokeImportFindings(
|
|
||||||
lines: string[],
|
|
||||||
context: {
|
|
||||||
file: string;
|
|
||||||
project: string;
|
|
||||||
},
|
|
||||||
): ReviewFinding[] {
|
|
||||||
const normalized = context.file.replaceAll("\\", "/");
|
|
||||||
if (
|
|
||||||
context.project !== "Node/kt-template-online-api" ||
|
|
||||||
!normalized.startsWith("test/modules/qqbot/") ||
|
|
||||||
!normalized.endsWith(".ts") ||
|
|
||||||
!/(plugin-(registry|controller).*\.spec\.ts|qqbot-core-module-contract\.spec\.ts)$/.test(
|
|
||||||
normalized,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const source = lines.join("\n");
|
|
||||||
const pluginImportPattern =
|
|
||||||
/from\s+['"]([^'"]*modules\/qqbot\/plugins\/(bangDream|ff14Market|fflogs|repeater)\/[^'"]*(?:\.plugin|client\.service|renderer\.facade|application\.service))['"]/g;
|
|
||||||
const findings: ReviewFinding[] = [];
|
|
||||||
const seenPlugins = new Set<string>();
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
|
|
||||||
while ((match = pluginImportPattern.exec(source))) {
|
|
||||||
const pluginName = match[2];
|
|
||||||
if (seenPlugins.has(pluginName)) continue;
|
|
||||||
seenPlugins.add(pluginName);
|
|
||||||
|
|
||||||
const escapedPluginName = pluginName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
||||||
const hasMock = new RegExp(
|
|
||||||
`jest\\.mock\\s*\\(\\s*['"][^'"]*/modules/qqbot/plugins/${escapedPluginName}/`,
|
|
||||||
"m",
|
|
||||||
).test(source);
|
|
||||||
if (hasMock) continue;
|
|
||||||
|
|
||||||
const lineIndex = lines.findIndex((line) => line.includes(match?.[1] || ""));
|
|
||||||
findings.push({
|
|
||||||
category: "qqbot-plugin-smoke-heavy-import",
|
|
||||||
file: context.file,
|
|
||||||
line: lineIndex >= 0 ? lineIndex + 1 : undefined,
|
|
||||||
level: "P2" as const,
|
|
||||||
message:
|
|
||||||
"QQBot 插件 registry/controller 契约或 HTTP smoke 导入真实插件实现但未 mock,容易拖入渲染、定时器或外部请求依赖。",
|
|
||||||
project: context.project,
|
|
||||||
suggestion:
|
|
||||||
"用 jest.mock mock 具体插件 service token,只测试 registry/controller 的别名解析和路由包装边界。",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return findings;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds regressions that would make descriptor-backed QQBot built-in plugins disappear from production dist images.
|
|
||||||
* @param lines - Source lines from the package path policy file being reviewed.
|
|
||||||
* @param context - Project and file identity for precise review findings.
|
|
||||||
* @returns Findings when the policy no longer supports production dist roots or compiled JavaScript entries.
|
|
||||||
*/
|
|
||||||
export function findQqbotPluginPackageProductionPathFindings(
|
|
||||||
lines: string[],
|
|
||||||
context: {
|
|
||||||
file: string;
|
|
||||||
project: string;
|
|
||||||
},
|
|
||||||
): ReviewFinding[] {
|
|
||||||
const normalized = context.file.replaceAll("\\", "/");
|
|
||||||
if (
|
|
||||||
context.project !== "Node/kt-template-online-api" ||
|
|
||||||
normalized !==
|
|
||||||
"src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts"
|
|
||||||
) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = lines.join("\n");
|
|
||||||
const findings: ReviewFinding[] = [];
|
|
||||||
const hasProductionDistRoot =
|
|
||||||
content.includes("'dist'") &&
|
|
||||||
content.includes("'modules'") &&
|
|
||||||
content.includes("'qqbot'") &&
|
|
||||||
content.includes("'plugins'");
|
|
||||||
const hasCompiledEntryFallback =
|
|
||||||
content.includes("resolveCompiledEntryFile") &&
|
|
||||||
content.includes("entryFile.endsWith('.ts')") &&
|
|
||||||
content.includes("}.js`");
|
|
||||||
|
|
||||||
if (!hasProductionDistRoot) {
|
|
||||||
findings.push({
|
|
||||||
category: "qqbot-plugin-production-dist-root-missing",
|
|
||||||
file: context.file,
|
|
||||||
line: 1,
|
|
||||||
level: "P2",
|
|
||||||
message:
|
|
||||||
"QQBot 插件包路径策略缺少生产 dist 根,生产镜像只复制 dist 时内置插件会发现为空。",
|
|
||||||
project: context.project,
|
|
||||||
suggestion:
|
|
||||||
"默认受控根应在源码根不存在时扫描 dist/modules/qqbot/plugins,并保留源码态优先顺序。",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasCompiledEntryFallback) {
|
|
||||||
findings.push({
|
|
||||||
category: "qqbot-plugin-compiled-entry-fallback-missing",
|
|
||||||
file: context.file,
|
|
||||||
line: 1,
|
|
||||||
level: "P2",
|
|
||||||
message:
|
|
||||||
"QQBot 插件 manifest 的 src/index.ts entry 缺少生产编译后 .js sibling fallback。",
|
|
||||||
project: context.project,
|
|
||||||
suggestion:
|
|
||||||
"entry 仍应保持源码结构声明,但生产运行时要在同一 package root 内解析到编译后的 src/index.js。",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return findings;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function findQqbotStatusBoundaryFindings(
|
export function findQqbotStatusBoundaryFindings(
|
||||||
lines: string[],
|
lines: string[],
|
||||||
context: {
|
context: {
|
||||||
@ -630,46 +505,6 @@ export function findQqbotNapcatCaptchaFlowFindings(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasLegacyDeviceVerifyPending =
|
|
||||||
content.includes("keepDeviceVerifyPending") &&
|
|
||||||
content.includes("deviceVerifyUrl");
|
|
||||||
const hasNapcatNewDeviceApiFlow =
|
|
||||||
content.includes("GetNewDeviceQRCode") &&
|
|
||||||
content.includes("PollNewDeviceQR") &&
|
|
||||||
content.includes("NewDeviceLogin") &&
|
|
||||||
content.includes("deviceVerifyUrl") &&
|
|
||||||
content.includes("newDeviceQrcode") &&
|
|
||||||
content.includes("newDeviceStatus") &&
|
|
||||||
content.includes("session.captchaUrl = undefined");
|
|
||||||
const hasNapcatNewDeviceWrapperFlow =
|
|
||||||
content.includes("startNewDeviceVerification") &&
|
|
||||||
content.includes("pollNewDeviceVerification") &&
|
|
||||||
content.includes("completePasswordLoginAfterChallenge") &&
|
|
||||||
content.includes("deviceVerifyUrl") &&
|
|
||||||
content.includes("newDeviceQrcode") &&
|
|
||||||
content.includes("newDeviceStatus") &&
|
|
||||||
content.includes("session.captchaUrl = undefined");
|
|
||||||
if (
|
|
||||||
content.includes("needNewDevice") &&
|
|
||||||
!hasLegacyDeviceVerifyPending &&
|
|
||||||
!hasNapcatNewDeviceApiFlow &&
|
|
||||||
!hasNapcatNewDeviceWrapperFlow
|
|
||||||
) {
|
|
||||||
findings.push({
|
|
||||||
category: "qqbot-napcat-new-device-drops-pending",
|
|
||||||
file: context.file,
|
|
||||||
line:
|
|
||||||
lines.findIndex((line) => line.includes("needNewDevice")) + 1 ||
|
|
||||||
undefined,
|
|
||||||
level: "P2",
|
|
||||||
message:
|
|
||||||
"NapCat 验证码通过后仍可能要求新设备验证,直接提示或失败会丢掉可继续的 jumpUrl。",
|
|
||||||
project: context.project,
|
|
||||||
suggestion:
|
|
||||||
"CaptchaLogin 返回 needNewDevice/jumpUrl 时,应保持同一 scan session pending,返回 deviceVerifyUrl,清掉旧 captchaUrl,并继续轮询同一容器。",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const logAnchorMatch = passwordReloginBody.match(
|
const logAnchorMatch = passwordReloginBody.match(
|
||||||
/const\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
|
/const\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
|
||||||
);
|
);
|
||||||
@ -922,14 +757,6 @@ function collectProjectContentFindings(
|
|||||||
file,
|
file,
|
||||||
project: project.relativePath,
|
project: project.relativePath,
|
||||||
}),
|
}),
|
||||||
...findQqbotPluginSmokeImportFindings(lines, {
|
|
||||||
file,
|
|
||||||
project: project.relativePath,
|
|
||||||
}),
|
|
||||||
...findQqbotPluginPackageProductionPathFindings(lines, {
|
|
||||||
file,
|
|
||||||
project: project.relativePath,
|
|
||||||
}),
|
|
||||||
...findQqbotStatusBoundaryFindings(lines, {
|
...findQqbotStatusBoundaryFindings(lines, {
|
||||||
file,
|
file,
|
||||||
project: project.relativePath,
|
project: project.relativePath,
|
||||||
@ -995,64 +822,6 @@ function findRootGeneratedArtifacts(): string[] {
|
|||||||
.map((entry) => entry.name);
|
.map((entry) => entry.name);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Finds files that would make `workspace-assets` behave like another runtime
|
|
||||||
* dump/cache directory instead of a small reusable asset store.
|
|
||||||
*
|
|
||||||
* @returns Review findings for large, archived, dump-like, or secret-like files
|
|
||||||
* found under `workspace-assets`.
|
|
||||||
*/
|
|
||||||
function findWorkspaceAssetPolicyFindings(): ReviewFinding[] {
|
|
||||||
const assetRoot = path.join(workspaceRoot, "workspace-assets");
|
|
||||||
if (!existsSync(assetRoot)) return [];
|
|
||||||
|
|
||||||
const findings: ReviewFinding[] = [];
|
|
||||||
|
|
||||||
const walk = (currentPath: string) => {
|
|
||||||
for (const entry of readdirSync(currentPath, { withFileTypes: true })) {
|
|
||||||
const entryPath = path.join(currentPath, entry.name);
|
|
||||||
const relativePath = toPosix(path.relative(workspaceRoot, entryPath));
|
|
||||||
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
if (reviewSkipDirectoryNames.has(entry.name)) continue;
|
|
||||||
walk(entryPath);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!entry.isFile()) continue;
|
|
||||||
|
|
||||||
const stats = statSync(entryPath);
|
|
||||||
if (workspaceAssetBlockedFilePattern.test(relativePath)) {
|
|
||||||
findings.push({
|
|
||||||
category: "workspace-assets-blocked-file",
|
|
||||||
file: relativePath,
|
|
||||||
level: "P1",
|
|
||||||
message: "workspace-assets 中出现禁止跟踪的归档、dump、环境或密钥类文件。",
|
|
||||||
project: "root",
|
|
||||||
suggestion:
|
|
||||||
"移回 .kt-workspace 或外部安全存储;workspace-assets 只保留小型可复用资产。",
|
|
||||||
});
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stats.size > workspaceAssetMaxFileBytes) {
|
|
||||||
findings.push({
|
|
||||||
category: "workspace-assets-large-file",
|
|
||||||
file: relativePath,
|
|
||||||
level: "P2",
|
|
||||||
message: "workspace-assets 中出现超过 2MB 的文件,可能是缓存或第三方源码。",
|
|
||||||
project: "root",
|
|
||||||
suggestion:
|
|
||||||
"确认是否必须 Git 管理;大体积参考材料应放到外部仓库或 .kt-workspace 缓存。",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
walk(assetRoot);
|
|
||||||
return findings;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function reviewProject(
|
async function reviewProject(
|
||||||
projectKey: string,
|
projectKey: string,
|
||||||
input: Required<
|
input: Required<
|
||||||
@ -1151,8 +920,6 @@ export async function buildGlobalCodeReview(
|
|||||||
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
|
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
|
||||||
const rootGeneratedArtifacts =
|
const rootGeneratedArtifacts =
|
||||||
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
|
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
|
||||||
const workspaceAssetPolicyFindings =
|
|
||||||
input.includeRootScan === false ? [] : findWorkspaceAssetPolicyFindings();
|
|
||||||
const taskRecordFindings =
|
const taskRecordFindings =
|
||||||
input.includeRootScan === false
|
input.includeRootScan === false
|
||||||
? []
|
? []
|
||||||
@ -1180,14 +947,13 @@ export async function buildGlobalCodeReview(
|
|||||||
message: "根目录存在生成态临时产物。",
|
message: "根目录存在生成态临时产物。",
|
||||||
project: "root",
|
project: "root",
|
||||||
suggestion:
|
suggestion:
|
||||||
"生成证据迁入 .kt-workspace;可复用资产迁入 workspace-assets,并更新生成脚本或测试流程。",
|
"统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。",
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
const allFindings = [
|
const allFindings = [
|
||||||
...findings,
|
...findings,
|
||||||
...rootFindings,
|
...rootFindings,
|
||||||
...rootGeneratedFindings,
|
...rootGeneratedFindings,
|
||||||
...workspaceAssetPolicyFindings,
|
|
||||||
...taskRecordFindings,
|
...taskRecordFindings,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@ -1,122 +1,5 @@
|
|||||||
import { existsSync, readFileSync } from 'node:fs';
|
import type { ApiTestPlanInput, BusinessFlow, BusinessTestPlanInput, PageTestCaseInput, PageTestCaseResult } from '../types.js';
|
||||||
import path from 'node:path';
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ApiTestPlanInput,
|
|
||||||
BusinessFlow,
|
|
||||||
BusinessTestPlanInput,
|
|
||||||
NapcatDeviceProfileCheckInput,
|
|
||||||
PageTestCaseInput,
|
|
||||||
PageTestCaseResult,
|
|
||||||
} from '../types.js';
|
|
||||||
import { resolveProject } from '../core/workspace.js';
|
import { resolveProject } from '../core/workspace.js';
|
||||||
|
|
||||||
/**
|
|
||||||
* Reads a project-relative source file for static NapCat device profile checks.
|
|
||||||
* @param projectPath - Absolute project path returned by `resolveProject()`.
|
|
||||||
* @param relativePath - File path inside the API project.
|
|
||||||
* @returns UTF-8 file content, or an empty string when the file is absent.
|
|
||||||
*/
|
|
||||||
function readProjectFile(projectPath: string, relativePath: string): string {
|
|
||||||
const filePath = path.join(projectPath, relativePath);
|
|
||||||
return existsSync(filePath) ? readFileSync(filePath, 'utf8') : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds a boolean static check with a human-readable recovery hint.
|
|
||||||
* @param name - Stable check identifier for reports and self-test output.
|
|
||||||
* @param passed - Whether the source text still contains the expected guardrail.
|
|
||||||
* @param evidence - Short description of the expected code-level evidence.
|
|
||||||
* @returns Check result consumed by `buildNapcatDeviceProfileCheck()`.
|
|
||||||
*/
|
|
||||||
function createNapcatCheck(
|
|
||||||
name: string,
|
|
||||||
passed: boolean,
|
|
||||||
evidence: string,
|
|
||||||
): Record<string, unknown> {
|
|
||||||
return {
|
|
||||||
evidence,
|
|
||||||
name,
|
|
||||||
passed,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Checks that the API project still contains the NapCat device-profile guardrails needed to avoid unknown QQ devices.
|
|
||||||
* @param input - Project alias or path to scan; defaults to the API backend.
|
|
||||||
* @returns Static guardrail report covering hostname, physical-style MAC, machine-info, runtime dir, and DB timezone.
|
|
||||||
*/
|
|
||||||
export function buildNapcatDeviceProfileCheck(
|
|
||||||
input: NapcatDeviceProfileCheckInput = {},
|
|
||||||
): Record<string, unknown> {
|
|
||||||
const project = resolveProject(input.project || 'api');
|
|
||||||
const deviceIdentity = readProjectFile(
|
|
||||||
project.path,
|
|
||||||
'src/modules/qqbot/napcat/infrastructure/integration/device/napcat-device-identity.service.ts',
|
|
||||||
);
|
|
||||||
const dockerOptions = readProjectFile(
|
|
||||||
project.path,
|
|
||||||
'src/modules/qqbot/napcat/infrastructure/integration/container/napcat-docker-device-options.ts',
|
|
||||||
);
|
|
||||||
const containerService = readProjectFile(
|
|
||||||
project.path,
|
|
||||||
'src/modules/qqbot/napcat/infrastructure/integration/container/qqbot-napcat-container.service.ts',
|
|
||||||
);
|
|
||||||
const appModule = readProjectFile(project.path, 'src/app.module.ts');
|
|
||||||
const runtimeConfig = readProjectFile(
|
|
||||||
project.path,
|
|
||||||
'src/runtime/config/runtime-config.service.ts',
|
|
||||||
);
|
|
||||||
const checks = [
|
|
||||||
createNapcatCheck(
|
|
||||||
'hostname-strategy',
|
|
||||||
deviceIdentity.includes('qqnt-visible-hostname-v1') &&
|
|
||||||
deviceIdentity.includes('pc-') &&
|
|
||||||
deviceIdentity.includes('hash.slice(0, 8)'),
|
|
||||||
'device identity uses qqnt-visible-hostname-v1 and pc-<8hex>',
|
|
||||||
),
|
|
||||||
createNapcatCheck(
|
|
||||||
'physical-oui-mac-strategy',
|
|
||||||
deviceIdentity.includes('physical-oui-mac-v1') &&
|
|
||||||
deviceIdentity.includes('hasPhysicalOuiMacPrefix') &&
|
|
||||||
deviceIdentity.includes('isRejectedVirtualMacPrefix') &&
|
|
||||||
deviceIdentity.includes('NAPCAT_PHYSICAL_OUI_PREFIXES'),
|
|
||||||
'device identity uses physical-oui-mac-v1 and rejects Docker/QEMU virtual MAC prefixes',
|
|
||||||
),
|
|
||||||
createNapcatCheck(
|
|
||||||
'machine-info-options',
|
|
||||||
dockerOptions.includes('machineInfoPath') &&
|
|
||||||
dockerOptions.includes('macAddressHyphen'),
|
|
||||||
'docker device options carry machineInfoPath and hyphenated MAC',
|
|
||||||
),
|
|
||||||
createNapcatCheck(
|
|
||||||
'machine-info-script',
|
|
||||||
containerService.includes('MACHINE_INFO_PATH') &&
|
|
||||||
containerService.includes('NAPCAT_MAC_HYPHEN') &&
|
|
||||||
containerService.includes("tr 'A-Za-z' 'N-ZA-Mn-za-m'") &&
|
|
||||||
containerService.includes("printf '\\\\000\\\\000\\\\000\\\\021'"),
|
|
||||||
'remote create script writes QQNT machine-info using ROT13 MAC format',
|
|
||||||
),
|
|
||||||
createNapcatCheck(
|
|
||||||
'runtime-dir-mount',
|
|
||||||
containerService.includes('$DATA_DIR/runtime:/tmp/runtime-napcat'),
|
|
||||||
'remote create script persists XDG_RUNTIME_DIR under the account data dir',
|
|
||||||
),
|
|
||||||
createNapcatCheck(
|
|
||||||
'db-timezone',
|
|
||||||
appModule.includes('DB_TIMEZONE') &&
|
|
||||||
appModule.includes("'+08:00'") &&
|
|
||||||
runtimeConfig.includes('DB_TIMEZONE'),
|
|
||||||
'TypeORM and runtime config expose DB_TIMEZONE with +08:00 default',
|
|
||||||
),
|
|
||||||
];
|
|
||||||
|
|
||||||
return {
|
|
||||||
checks,
|
|
||||||
ok: checks.every((item) => item.passed === true),
|
|
||||||
project,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult {
|
export function createPageTestCase(input: PageTestCaseInput): PageTestCaseResult {
|
||||||
const project = resolveProject(input.project);
|
const project = resolveProject(input.project);
|
||||||
const title = input.title || `${project.label} 页面级测试`;
|
const title = input.title || `${project.label} 页面级测试`;
|
||||||
@ -232,26 +115,13 @@ export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record
|
|||||||
},
|
},
|
||||||
'qqbot-account-scan': {
|
'qqbot-account-scan': {
|
||||||
assertions: [
|
assertions: [
|
||||||
'扫码创建接口先返回 pending sessionId,再通过 SSE/status 展示容器创建、二维码生成、等待扫码和终态。',
|
'扫码创建返回 qrcode/containerName/webuiPort。',
|
||||||
'新增账号首次容器创建必须在第一轮 Docker run 注入设备身份和中文桌面 runtime profile,而不是登录后补 env。',
|
|
||||||
'取消/过期/失败会清理未绑定 NapCat 容器。',
|
'取消/过期/失败会清理未绑定 NapCat 容器。',
|
||||||
'若真实接口 smoke 会创建线上 QQBot 账号或 NapCat 容器,必须先确认清理方案;不能造脏数据时用 RED/GREEN、只读线上计数复核和未执行真实造号原因替代。',
|
|
||||||
'删除账号会二次确认并删除专属容器。',
|
'删除账号会二次确认并删除专属容器。',
|
||||||
],
|
],
|
||||||
preflight: [
|
preflight: ['NAS SSH 可达', 'NapCat WebUI 可达', 'API QQBot env 配置完整'],
|
||||||
'NAS SSH 可达',
|
|
||||||
'NapCat WebUI 可达',
|
|
||||||
'API QQBot env 配置完整',
|
|
||||||
'确认本轮是否允许创建真实 QQBot 账号和 NapCat 容器',
|
|
||||||
],
|
|
||||||
projects: ['api', 'admin'],
|
projects: ['api', 'admin'],
|
||||||
steps: [
|
steps: ['打开账号连接页', '点击扫码新增账号', '轮询二维码状态', '确认账号回填', '测试删除联动容器'],
|
||||||
'打开账号连接页',
|
|
||||||
'点击扫码新增账号并确认接口不等待 Docker/WebUI 长耗时',
|
|
||||||
'订阅 scan/events 或轮询 status 确认中文进度',
|
|
||||||
'确认账号回填和 runtime/profile 归属',
|
|
||||||
'测试删除联动容器或记录未造号的替代验证证据',
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
'qqbot-login-sse': {
|
'qqbot-login-sse': {
|
||||||
assertions: [
|
assertions: [
|
||||||
|
|||||||
148
src/types.ts
148
src/types.ts
@ -185,44 +185,6 @@ export interface BusinessTestPlanInput {
|
|||||||
flow?: BusinessFlow | 'all';
|
flow?: BusinessFlow | 'all';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NapcatDeviceProfileCheckInput {
|
|
||||||
project?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatPromptToolInput {
|
|
||||||
artifactRoot?: string;
|
|
||||||
candidateBranch?: string;
|
|
||||||
execute?: boolean;
|
|
||||||
forkBranch?: string;
|
|
||||||
forkRepo?: string;
|
|
||||||
upstreamReleaseTag?: string;
|
|
||||||
upstreamRepo?: string;
|
|
||||||
useCodex?: boolean;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatSyncCandidateReviewInput extends NapcatPromptToolInput {
|
|
||||||
lastAcceptedUpstreamBase?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatRuntimeReleaseReadinessInput extends NapcatPromptToolInput {
|
|
||||||
apiImageTag?: string;
|
|
||||||
napcatImageTag?: string;
|
|
||||||
profile?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NapcatRemoteDevHandoffInput extends NapcatPromptToolInput {
|
|
||||||
targetHost?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface NasCodexBootstrapPlanInput {
|
|
||||||
artifactRoot?: string;
|
|
||||||
codexHome?: string;
|
|
||||||
execute?: boolean;
|
|
||||||
serviceName?: string;
|
|
||||||
workspaceRoot?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BlockerResolutionInput {
|
export interface BlockerResolutionInput {
|
||||||
attempts?: number;
|
attempts?: number;
|
||||||
command?: string;
|
command?: string;
|
||||||
@ -243,116 +205,6 @@ export interface RemoteHealthCheckInput {
|
|||||||
sshTarget?: string;
|
sshTarget?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DeployObservationStatus =
|
|
||||||
| 'passed'
|
|
||||||
| 'failed'
|
|
||||||
| 'blocked'
|
|
||||||
| 'skipped';
|
|
||||||
|
|
||||||
export interface DeployObservationInput {
|
|
||||||
artifactRoot?: string;
|
|
||||||
buildNumber?: string;
|
|
||||||
container?: string;
|
|
||||||
deployment?: string;
|
|
||||||
execute?: boolean;
|
|
||||||
expectedCommit?: string;
|
|
||||||
healthUrl?: string;
|
|
||||||
imageTag?: string;
|
|
||||||
jobName?: string;
|
|
||||||
jenkinsHome?: string;
|
|
||||||
kubeconfigPath?: string;
|
|
||||||
namespace?: string;
|
|
||||||
project?: string;
|
|
||||||
selector?: string;
|
|
||||||
smoke?: string;
|
|
||||||
sshPort?: number;
|
|
||||||
sshTarget?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationAssertion {
|
|
||||||
critical: boolean;
|
|
||||||
message: string;
|
|
||||||
name: string;
|
|
||||||
passed: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationCommand {
|
|
||||||
command: string;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationJenkinsEvidence {
|
|
||||||
buildNumber: string | null;
|
|
||||||
commitMatched: boolean | null;
|
|
||||||
expectedCommit: string | null;
|
|
||||||
finishedStatus: string | null;
|
|
||||||
jobName: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationDeploymentEvidence {
|
|
||||||
container: string;
|
|
||||||
containerFound: boolean;
|
|
||||||
desiredReplicas: number | null;
|
|
||||||
generation: number | null;
|
|
||||||
image: string | null;
|
|
||||||
namespace: string;
|
|
||||||
observedGeneration: number | null;
|
|
||||||
readyReplicas: number | null;
|
|
||||||
updatedReplicas: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationPodEvidence {
|
|
||||||
containerFound: boolean;
|
|
||||||
image: string | null;
|
|
||||||
name: string | null;
|
|
||||||
phase: string | null;
|
|
||||||
ready: boolean | null;
|
|
||||||
restartCount: number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationRuntimeHealthEvidence {
|
|
||||||
checkCount: number | null;
|
|
||||||
service: string | null;
|
|
||||||
status: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationEvidence {
|
|
||||||
assertions: DeployObservationAssertion[];
|
|
||||||
details: {
|
|
||||||
deployment: DeployObservationDeploymentEvidence;
|
|
||||||
eventsTail: string;
|
|
||||||
jenkins: DeployObservationJenkinsEvidence;
|
|
||||||
pod: DeployObservationPodEvidence;
|
|
||||||
runtimeHealth: DeployObservationRuntimeHealthEvidence;
|
|
||||||
smoke: {
|
|
||||||
command: string | null;
|
|
||||||
exitCode: number | null;
|
|
||||||
output: string;
|
|
||||||
success: boolean;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
endedAt: string;
|
|
||||||
environment: 'production';
|
|
||||||
operation: 'kt_deploy_observation';
|
|
||||||
project: string;
|
|
||||||
schemaVersion: 1;
|
|
||||||
startedAt: string;
|
|
||||||
status: DeployObservationStatus;
|
|
||||||
target: string;
|
|
||||||
taskType: 'deploy';
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DeployObservationResult {
|
|
||||||
artifactPath?: string;
|
|
||||||
commands: DeployObservationCommand[];
|
|
||||||
evidence?: DeployObservationEvidence;
|
|
||||||
execute: boolean;
|
|
||||||
notes: string[];
|
|
||||||
requiredEvidence: string[];
|
|
||||||
result?: ExecResult;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface DbSyncPlanInput {
|
export interface DbSyncPlanInput {
|
||||||
backupName?: string;
|
backupName?: string;
|
||||||
source?: 'local' | 'remote';
|
source?: 'local' | 'remote';
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user