Compare commits

..

No commits in common. "7075d863c26c441d5eeb48c9868a39fc2b612355" and "12d3abe1f40d2462b992cd115ae51ee6d2088ad4" have entirely different histories.

12 changed files with 8 additions and 1918 deletions

View File

@ -15,17 +15,15 @@
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。 - 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。 - 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
- 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、更新登录 SSE、FFLogs 命令、系统日志可视化、Web/Playground 回跳。 - 生成业务链路测试计划:固化 Admin 登录、博客 CRUD、QQBot 扫码/自动回复、更新登录 SSE、FFLogs 命令、系统日志可视化、Web/Playground 回跳。
- 静态检查 NapCat 设备身份护栏:确认 API 仍保留 QQNT 可见 hostname、稳定 `02:42:*` MAC、QQNT `machine-info` 写入、持久化 runtime dir 和 `DB_TIMEZONE` 默认值。
- 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。 - 生成卡点固化记录:把超时、卡进程、远程命令误写、重复失败整理成“问题点 / 稳定解法 / 后续入口 / 验证证据”,避免原样重试。
- 生成改动文档同步计划:按变更文件自动提示需要同步的 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 约定。
@ -40,10 +38,8 @@ 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-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
``` ```
@ -95,10 +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_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 摘要、代码锚点和相关规则文档 |
@ -112,9 +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-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。 |
@ -153,14 +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 状态。
- 代码或配置改动后调用 `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`,先拿到“能做什么、不能做什么、怎么验证”。

View File

@ -7,9 +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",
"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",

View File

@ -1,5 +1,4 @@
import type { import type {
DeployObservationInput,
GlobalCodeReviewInput, GlobalCodeReviewInput,
ObsidianContextInput, ObsidianContextInput,
ObsidianSyncInput, ObsidianSyncInput,
@ -91,38 +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']),
};
}
export function parseObsidianCliArgs(argv: string[]): ObsidianValidateInput { export function parseObsidianCliArgs(argv: string[]): ObsidianValidateInput {
return { return {
checkLayout: !argv.includes('--no-layout'), checkLayout: !argv.includes('--no-layout'),

View File

@ -69,10 +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_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',

View File

@ -12,16 +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 { 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 {
@ -370,19 +364,6 @@ export function registerTools(server: McpServer): void {
async (input) => response(buildBusinessTestPlan(input)), async (input) => response(buildBusinessTestPlan(input)),
); );
server.registerTool(
'kt_napcat_device_profile_check',
{
description:
'静态检查 API NapCat 设备身份护栏QQNT 可见 hostname、02:42 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',
{ {
@ -419,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',
{ {

View File

@ -2,7 +2,6 @@ import { mkdirSync, writeFileSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { import {
parseDeployObservationCliArgs,
parseGlobalReviewCliArgs, parseGlobalReviewCliArgs,
parseWorkstreamCloseoutCliArgs, parseWorkstreamCloseoutCliArgs,
} from "./core/cli.js"; } from "./core/cli.js";
@ -29,23 +28,13 @@ import {
findNapcatImageGovernanceFindings, findNapcatImageGovernanceFindings,
findQqbotNapcatCaptchaFlowFindings, findQqbotNapcatCaptchaFlowFindings,
findQqbotCommandServiceTestImportFindings, findQqbotCommandServiceTestImportFindings,
findQqbotPluginPackageProductionPathFindings,
findQqbotPluginSmokeImportFindings,
findQqbotStatusBoundaryFindings, findQqbotStatusBoundaryFindings,
findTaskRecordGovernanceFindings, findTaskRecordGovernanceFindings,
isBenignCredentialReviewValue, isBenignCredentialReviewValue,
} from "./tools/review.js"; } from "./tools/review.js";
import { import { buildBusinessTestPlan } from "./tools/testing.js";
buildBusinessTestPlan,
buildNapcatDeviceProfileCheck,
} 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 {
buildDeployObservation,
buildDeployObservationSectionMap,
normalizeDeployObservationEvidence,
} from "./tools/deployObservation.js";
import { import {
buildCommitPlan, buildCommitPlan,
buildComponentWorkflow, buildComponentWorkflow,
@ -120,95 +109,6 @@ export async function runSelfTest(): Promise<void> {
); );
} }
const qqbotPluginSmokeImportFindings = findQqbotPluginSmokeImportFindings(
[
"import { QqbotBangDreamPluginService } from '../../../../src/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin';",
],
{
file: "test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts",
project: "Node/kt-template-online-api",
},
);
if (
!qqbotPluginSmokeImportFindings.some(
(item) => item.category === "qqbot-plugin-smoke-heavy-import",
)
) {
throw new Error("QQBot plugin smoke import governance self-check failed");
}
const qqbotPluginSmokeMockedFindings = findQqbotPluginSmokeImportFindings(
[
"jest.mock(",
" '@/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin',",
" () => ({}),",
");",
"import { QqbotBangDreamPluginService } from '../../../../src/modules/qqbot/plugins/bangDream/qqbot-bangdream.plugin';",
],
{
file: "test/modules/qqbot/plugins/plugin-controller-http-smoke.spec.ts",
project: "Node/kt-template-online-api",
},
);
if (qqbotPluginSmokeMockedFindings.length > 0) {
throw new Error(
"QQBot plugin smoke import mocked self-check produced false positive",
);
}
const qqbotPluginProductionPathFindings =
findQqbotPluginPackageProductionPathFindings(
[
"const DEFAULT_BUILTIN_PACKAGE_ROOT_SEGMENTS = [",
" ['src', 'modules', 'qqbot', 'plugins'],",
"];",
"private resolveEntryFile(entryFile: string) {",
" return entryFile;",
"}",
],
{
file: "src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts",
project: "Node/kt-template-online-api",
},
);
if (
!qqbotPluginProductionPathFindings.some(
(item) =>
item.category === "qqbot-plugin-production-dist-root-missing",
) ||
!qqbotPluginProductionPathFindings.some(
(item) =>
item.category === "qqbot-plugin-compiled-entry-fallback-missing",
)
) {
throw new Error("QQBot plugin production path governance self-check failed");
}
const qqbotPluginProductionPathValidFindings =
findQqbotPluginPackageProductionPathFindings(
[
"const DEFAULT_BUILTIN_PACKAGE_ROOT_SEGMENTS = [",
" ['src', 'modules', 'qqbot', 'plugins'],",
" ['dist', 'modules', 'qqbot', 'plugins'],",
"];",
"private resolveCompiledEntryFile(entryFile: string): string {",
" if (entryFile.endsWith('.ts')) {",
" return `${entryFile.slice(0, -3)}.js`;",
" }",
" return entryFile;",
"}",
],
{
file: "src/modules/qqbot/plugin-platform/infrastructure/integration/package/plugin-package-path-policy.service.ts",
project: "Node/kt-template-online-api",
},
);
if (qqbotPluginProductionPathValidFindings.length > 0) {
throw new Error(
"QQBot plugin production path governance valid self-check false positive",
);
}
const qqbotStatusBoundaryFindings = [ const qqbotStatusBoundaryFindings = [
...findQqbotStatusBoundaryFindings( ...findQqbotStatusBoundaryFindings(
[ [
@ -267,9 +167,6 @@ export async function runSelfTest(): Promise<void> {
" return this.failCaptchaLogin(session, container, status.loginError);", " return this.failCaptchaLogin(session, container, status.loginError);",
" }", " }",
"}", "}",
"if (captchaResult?.needNewDevice && captchaResult.jumpUrl) {",
" return this.keepSessionPending(session, '请在 NapCat WebUI 继续完成', true);",
"}",
"private async tryPasswordRelogin(session, container) {", "private async tryPasswordRelogin(session, container) {",
" await this.containerService.ensureRuntimeLoginEnv(container, { loginPassword: 'x' });", " await this.containerService.ensureRuntimeLoginEnv(container, { loginPassword: 'x' });",
" session.lastRestartedAt = Date.now();", " session.lastRestartedAt = Date.now();",
@ -288,7 +185,6 @@ export async function runSelfTest(): Promise<void> {
"qqbot-napcat-captcha-status-before-log-url", "qqbot-napcat-captcha-status-before-log-url",
"qqbot-napcat-captcha-status-clears-pending", "qqbot-napcat-captcha-status-clears-pending",
"qqbot-napcat-captcha-log-window-late", "qqbot-napcat-captcha-log-window-late",
"qqbot-napcat-new-device-drops-pending",
]) { ]) {
if (!qqbotCaptchaFlowFindings.some((item) => item.category === category)) { if (!qqbotCaptchaFlowFindings.some((item) => item.category === category)) {
throw new Error(`QQBot captcha flow self-check failed: ${category}`); throw new Error(`QQBot captcha flow self-check failed: ${category}`);
@ -312,19 +208,6 @@ export async function runSelfTest(): Promise<void> {
" }", " }",
" }", " }",
"}", "}",
"if (captchaResult?.needNewDevice && captchaResult.jumpUrl) {",
" return this.startNewDeviceVerification(session, container, captchaResult);",
"}",
"private async startNewDeviceVerification(session, container, captchaResult) {",
" session.deviceVerifyUrl = captchaResult.jumpUrl;",
" session.captchaUrl = undefined;",
" session.newDeviceQrcode = await this.client.GetNewDeviceQRCode();",
" session.newDeviceStatus = 'qr-pending';",
"}",
"private async pollNewDeviceVerification(session) {",
" await this.client.PollNewDeviceQR();",
" await this.client.NewDeviceLogin();",
"}",
"private async detectPasswordCaptchaUrl(container, sinceMs, allowTailFallback = true) {", "private async detectPasswordCaptchaUrl(container, sinceMs, allowTailFallback = true) {",
" await this.containerService.detectRuntimeCaptchaUrl(container, sinceMs);", " await this.containerService.detectRuntimeCaptchaUrl(container, sinceMs);",
" await this.containerService.detectRuntimeCaptchaUrl(container);", " await this.containerService.detectRuntimeCaptchaUrl(container);",
@ -353,8 +236,7 @@ export async function runSelfTest(): Promise<void> {
(item) => (item) =>
item.category === "qqbot-napcat-captcha-log-window-late" || item.category === "qqbot-napcat-captcha-log-window-late" ||
item.category === "qqbot-napcat-captcha-status-before-log-url" || item.category === "qqbot-napcat-captcha-status-before-log-url" ||
item.category === "qqbot-napcat-captcha-status-clears-pending" || item.category === "qqbot-napcat-captcha-status-clears-pending",
item.category === "qqbot-napcat-new-device-drops-pending",
) )
) { ) {
throw new Error("QQBot captcha flow valid self-check produced false positive"); throw new Error("QQBot captcha flow valid self-check produced false positive");
@ -453,526 +335,6 @@ export async function runSelfTest(): Promise<void> {
throw new Error("workstream closeout review CLI parser self-check failed"); throw new Error("workstream closeout review CLI parser self-check failed");
} }
const deployObservationInput = parseDeployObservationCliArgs([
"node",
"server",
"--deploy-observation",
"--project",
"api",
"--job",
"kt-template-online-api",
"--build",
"132",
"--commit",
"abc1234",
"--image-tag",
"abc1234",
"--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",
]);
if (
deployObservationInput.project !== "api" ||
deployObservationInput.jobName !== "kt-template-online-api" ||
deployObservationInput.buildNumber !== "132" ||
deployObservationInput.expectedCommit !== "abc1234" ||
deployObservationInput.imageTag !== "abc1234" ||
deployObservationInput.namespace !== "kt-prod" ||
deployObservationInput.deployment !== "kt-template-online-api" ||
deployObservationInput.container !== "api" ||
deployObservationInput.healthUrl !==
"http://127.0.0.1:48085/health/runtime" ||
deployObservationInput.smoke !==
"curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime" ||
deployObservationInput.execute !== false
) {
throw new Error("deploy observation CLI parser self-check failed");
}
const buildDeployObservationTestSections = (overrides: {
desiredReplicas?: number;
healthStatus?: string;
jenkinsBuildXml?: string;
logFinishedStatus?: string;
logCommit?: string;
omitFinishedStatus?: boolean;
omitSmoke?: boolean;
readyReplicas?: number;
smokeExitCode?: number;
containerName?: string;
podContainerName?: string;
updatedReplicas?: number;
} = {}) =>
buildDeployObservationSectionMap([
"__KT_SECTION:jenkins_meta__",
"buildNumber=132",
"result=SUCCESS",
"__KT_SECTION:jenkins_log_tail__",
overrides.logCommit === ""
? `Finished: ${overrides.logFinishedStatus ?? "SUCCESS"}`
: `Checking out Revision ${overrides.logCommit ?? "abc1234"}`,
...(overrides.omitFinishedStatus
? []
: [`Finished: ${overrides.logFinishedStatus ?? "SUCCESS"}`]),
"__KT_SECTION:jenkins_build_xml__",
overrides.jenkinsBuildXml ?? "",
"__KT_SECTION:deployment_json__",
JSON.stringify({
metadata: { generation: 12 },
spec: {
replicas: overrides.desiredReplicas ?? 1,
template: {
spec: {
containers: [
{
image:
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234",
name: overrides.containerName ?? "api",
},
],
},
},
},
status: {
observedGeneration: 12,
readyReplicas: overrides.readyReplicas ?? 1,
updatedReplicas: overrides.updatedReplicas ?? 1,
},
}),
"__KT_SECTION:pods_json__",
JSON.stringify({
items: [
{
metadata: {
name: "kt-template-online-api-abc",
},
spec: {
containers: [
{
image:
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234",
name: overrides.podContainerName ?? "api",
},
],
},
status: {
containerStatuses: [
{
image:
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234",
name: overrides.podContainerName ?? "api",
ready: true,
restartCount: 0,
},
],
phase: "Running",
startTime: "2026-06-14T00:00:00Z",
},
},
],
}),
"__KT_SECTION:health_json__",
JSON.stringify({
checkedAt: "2026-06-14T00:00:01.000Z",
checks: [
{
critical: true,
message: "NestJS process answered runtime health request",
name: "process",
status: "live",
},
],
service: "kt-template-online-api",
status: overrides.healthStatus ?? "ready",
}),
"__KT_SECTION:smoke_text__",
overrides.omitSmoke
? "no task smoke command provided"
: '{"service":"kt-template-online-api","status":"ready"}',
"__KT_SECTION:smoke_status__",
overrides.omitSmoke ? "exitCode=" : `exitCode=${overrides.smokeExitCode ?? 0}`,
].join("\n"));
const deployObservationSections = buildDeployObservationTestSections();
const deployObservationEvidence = normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: deployObservationSections,
});
const deployObservationSmoke = deployObservationEvidence.details.smoke as {
exitCode?: number | null;
success?: boolean | null;
};
if (
deployObservationEvidence.status !== "passed" ||
deployObservationEvidence.details.deployment.image !==
"k3d-kt-registry.localhost:5000/kt-template-online-api:abc1234" ||
deployObservationEvidence.details.deployment.observedGeneration !== 12 ||
deployObservationEvidence.details.pod.name !==
"kt-template-online-api-abc" ||
deployObservationEvidence.details.pod.restartCount !== 0 ||
deployObservationEvidence.details.runtimeHealth.status !== "ready" ||
deployObservationSmoke.exitCode !== 0 ||
deployObservationSmoke.success !== true ||
!deployObservationEvidence.assertions.every((item) => item.passed)
) {
throw new Error("deploy observation evidence normalization self-check failed");
}
const deployObservationDegradedHealthEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
healthStatus: "degraded",
}),
});
if (
deployObservationDegradedHealthEvidence.status !== "passed" ||
!deployObservationDegradedHealthEvidence.assertions.every(
(item) => item.passed,
)
) {
throw new Error("deploy observation degraded health self-check failed");
}
const deployObservationBuildXmlCommitEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
jenkinsBuildXml: [
"<jenkins.scm.api.SCMRevisionAction>",
"<revision>",
"<hash>abc1234</hash>",
"</revision>",
"</jenkins.scm.api.SCMRevisionAction>",
].join("\n"),
logCommit: "",
}),
});
if (
deployObservationBuildXmlCommitEvidence.status !== "passed" ||
deployObservationBuildXmlCommitEvidence.details.jenkins.commitMatched !== true
) {
throw new Error("deploy observation build.xml commit self-check failed");
}
const deployObservationLogFailureBeatsXmlEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
logFinishedStatus: "FAILURE",
}),
});
if (
deployObservationLogFailureBeatsXmlEvidence.status === "passed" ||
deployObservationLogFailureBeatsXmlEvidence.details.jenkins
.finishedStatus !== "FAILURE"
) {
throw new Error("deploy observation log failure precedence self-check failed");
}
const deployObservationRunningLogWithoutFinalStatusEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
omitFinishedStatus: true,
}),
});
if (
deployObservationRunningLogWithoutFinalStatusEvidence.status === "passed" ||
deployObservationRunningLogWithoutFinalStatusEvidence.details.jenkins
.finishedStatus !== null
) {
throw new Error("deploy observation running log status self-check failed");
}
const deployObservationFailedSmokeEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
smokeExitCode: 7,
}),
});
const deployObservationFailedSmoke =
deployObservationFailedSmokeEvidence.details.smoke as {
exitCode?: number | null;
success?: boolean | null;
};
if (
deployObservationFailedSmokeEvidence.status === "passed" ||
deployObservationFailedSmoke.exitCode !== 7 ||
deployObservationFailedSmoke.success !== false
) {
throw new Error("deploy observation failed smoke self-check failed");
}
const deployObservationReplicaLagEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
desiredReplicas: 2,
readyReplicas: 1,
updatedReplicas: 2,
}),
});
if (deployObservationReplicaLagEvidence.status === "passed") {
throw new Error("deploy observation replica lag self-check failed");
}
const deployObservationDesiredReplicaLagEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
desiredReplicas: 2,
readyReplicas: 1,
updatedReplicas: 1,
}),
});
if (deployObservationDesiredReplicaLagEvidence.status === "passed") {
throw new Error("deploy observation desired replica lag self-check failed");
}
const deployObservationWrongContainerEvidence =
normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: deployObservationInput,
sectionMap: buildDeployObservationTestSections({
containerName: "sidecar",
podContainerName: "sidecar",
}),
});
if (deployObservationWrongContainerEvidence.status === "passed") {
throw new Error("deploy observation wrong container self-check failed");
}
const deployObservationNoSmokeEvidence = normalizeDeployObservationEvidence({
checkedAt: new Date("2026-06-14T00:00:02.000Z"),
input: { ...deployObservationInput, smoke: undefined },
sectionMap: buildDeployObservationTestSections({
omitSmoke: true,
}),
});
const deployObservationNoSmoke = deployObservationNoSmokeEvidence.details
.smoke as {
exitCode?: number | null;
success?: boolean | null;
};
if (
deployObservationNoSmokeEvidence.status === "passed" ||
deployObservationNoSmoke.exitCode !== null ||
deployObservationNoSmoke.success !== false
) {
throw new Error("deploy observation no-smoke self-check failed");
}
const deployObservationDryRun = await buildDeployObservation({
deployment: "kt-template-online-api",
execute: false,
imageTag: "abc1234",
jobName: "kt-template-online-api",
namespace: "kt-prod",
project: "api",
smoke: "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime",
});
const deployObservationCommandText = deployObservationDryRun.commands
.map((item) => item.command)
.join("\n");
if (
deployObservationDryRun.execute !== false ||
!deployObservationCommandText.includes("tr -d '\\015' | bash -s") ||
!deployObservationCommandText.includes("kubectl") ||
!deployObservationCommandText.includes(
"http://127.0.0.1:48085/health/runtime",
) ||
!deployObservationCommandText.includes("curl -fsS --max-time 8") ||
deployObservationDryRun.evidence
) {
throw new Error("deploy observation dry-run self-check failed");
}
for (const expected of [
"KUBECONFIG_ACTIVE",
"docker port k3d-kt-nas-serverlb 6443/tcp",
"/tmp/kt-deploy-observation-kubeconfig-$$.yaml",
"127.0.0.1:${K3D_API_PORT}",
'rm -f "$KUBECONFIG_TEMP"',
'kubectl --kubeconfig "$KUBECONFIG_ACTIVE"',
"section jenkins_build_xml",
]) {
if (!deployObservationCommandText.includes(expected)) {
throw new Error(`deploy observation kubeconfig self-check failed: ${expected}`);
}
}
if (
deployObservationCommandText.includes('sed -E "s#.*:([0-9]+)$#') ||
!deployObservationCommandText.includes("rev | cut -d: -f1 | rev")
) {
throw new Error("deploy observation docker port parser self-check failed");
}
const deployObservationDefaultDryRun = await buildDeployObservation({
deployment: "kt-template-online-api",
execute: false,
namespace: "kt-prod",
project: "api",
});
const deployObservationDefaultCommandText =
deployObservationDefaultDryRun.commands.map((item) => item.command).join("\n");
if (
!deployObservationDefaultCommandText.includes(
"/vol1/docker/jenkins/jenkins_home",
) ||
!deployObservationDefaultCommandText.includes(
"JOB_NAME='KT-Template/KT-Template-API/main'",
)
) {
throw new Error("deploy observation Jenkins defaults self-check failed");
}
for (const expected of [
'JOB_DIR="$JENKINS_HOME/jobs/$JOB_NAME/builds"',
'jobs/$JOB_FOLDER/jobs/$JOB_PIPELINE/branches/$JOB_BRANCH/builds',
'find "$JENKINS_HOME/jobs"',
]) {
if (!deployObservationDefaultCommandText.includes(expected)) {
throw new Error(
`deploy observation Jenkins path self-check failed: ${expected}`,
);
}
}
const deployObservationExplicitJenkinsDryRun = await buildDeployObservation({
execute: false,
jenkinsHome: "/custom/jenkins",
jobName: "custom-job",
});
const deployObservationExplicitJenkinsCommandText =
deployObservationExplicitJenkinsDryRun.commands
.map((item) => item.command)
.join("\n");
if (
!deployObservationExplicitJenkinsCommandText.includes(
"JENKINS_HOME='/custom/jenkins'",
) ||
!deployObservationExplicitJenkinsCommandText.includes("JOB_NAME='custom-job'")
) {
throw new Error("deploy observation explicit Jenkins input self-check failed");
}
const deployObservationExplicitHealthDryRun = await buildDeployObservation({
deployment: "kt-template-online-api",
execute: false,
healthUrl: "http://127.0.0.1:30085/health/runtime",
imageTag: "abc1234",
jobName: "kt-template-online-api",
namespace: "kt-prod",
project: "api",
});
const deployObservationExplicitHealthCommandText =
deployObservationExplicitHealthDryRun.commands
.map((item) => item.command)
.join("\n");
if (
!deployObservationExplicitHealthCommandText.includes(
"http://127.0.0.1:30085/health/runtime",
)
) {
throw new Error("deploy observation explicit health URL self-check failed");
}
const deployObservationUnsafeProjectDryRun = await buildDeployObservation({
deployment: "kt-template-online-api",
execute: false,
healthUrl: "http://127.0.0.1:48085/health/runtime",
imageTag: "abc1234",
jobName: "kt-template-online-api",
namespace: "kt-prod",
project: "../escape",
smoke: "curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime",
});
if (
deployObservationUnsafeProjectDryRun.execute !== false ||
deployObservationUnsafeProjectDryRun.artifactPath ||
deployObservationUnsafeProjectDryRun.evidence ||
JSON.stringify(deployObservationUnsafeProjectDryRun.commands).includes(
"../escape",
)
) {
throw new Error("deploy observation project dry-run safety self-check failed");
}
let unsupportedSmokeRejected = false;
try {
await buildDeployObservation({
execute: false,
smoke:
"curl -fsS --max-time 8 http://127.0.0.1:48085/health/runtime; rm -rf /",
});
} catch (error) {
unsupportedSmokeRejected = String(error).includes(
"Unsupported deploy observation smoke command",
);
}
if (!unsupportedSmokeRejected) {
throw new Error("deploy observation unsafe smoke self-check failed");
}
let unsafeHealthUrlRejected = false;
try {
await buildDeployObservation({
execute: false,
healthUrl: "http://user:pass@127.0.0.1:48085/health/runtime",
});
} catch (error) {
unsafeHealthUrlRejected = String(error).includes(
"Unsupported deploy observation healthUrl",
);
}
if (!unsafeHealthUrlRejected) {
throw new Error("deploy observation unsafe health URL self-check failed");
}
let unsafeSshTargetRejected = false;
try {
await buildDeployObservation({
execute: true,
sshTarget: "root@unexpected-host.example",
});
} catch (error) {
unsafeSshTargetRejected = String(error).includes(
'Unsafe deploy observation input "sshTarget"',
);
}
if (!unsafeSshTargetRejected) {
throw new Error("deploy observation unsafe ssh target self-check failed");
}
let unsafeInputRejected = false;
try {
await buildDeployObservation({
execute: false,
jobName: "bad\njob",
});
} catch (error) {
unsafeInputRejected = String(error).includes(
"Unsafe deploy observation input",
);
}
if (!unsafeInputRejected) {
throw new Error("deploy observation unsafe input self-check failed");
}
const refactorGuardrails = buildGuardrails({ const refactorGuardrails = buildGuardrails({
project: "mcp", project: "mcp",
taskType: "refactor", taskType: "refactor",
@ -1214,13 +576,6 @@ export async function runSelfTest(): Promise<void> {
throw new Error("DB sync plan safety self-check failed"); throw new Error("DB sync plan safety self-check failed");
} }
const napcatDeviceProfileCheck = buildNapcatDeviceProfileCheck({
project: "api",
});
if ((napcatDeviceProfileCheck.ok as boolean) !== true) {
throw new Error("NapCat device profile guardrail self-check failed");
}
const data = { const data = {
context: readWorkflowContext({ taskRecordCount: 2 }), context: readWorkflowContext({ taskRecordCount: 2 }),
guardrails: buildGuardrails({ guardrails: buildGuardrails({
@ -1261,7 +616,6 @@ export async function runSelfTest(): Promise<void> {
businessTestPlan: buildBusinessTestPlan({ businessTestPlan: buildBusinessTestPlan({
flow: "system-log-visualization", flow: "system-log-visualization",
}), }),
napcatDeviceProfileCheck,
closeoutComplete, closeoutComplete,
closeoutNeedsUpgrade, closeoutNeedsUpgrade,
obsidianContext, obsidianContext,

View File

@ -3,7 +3,6 @@ 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 {
parseDeployObservationCliArgs,
parseGlobalReviewCliArgs, parseGlobalReviewCliArgs,
parseObsidianContextCliArgs, parseObsidianContextCliArgs,
parseObsidianCliArgs, parseObsidianCliArgs,
@ -12,12 +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';
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));
@ -35,20 +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('--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',

View File

@ -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: [],

View File

@ -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,
};
}

View File

@ -230,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: {
@ -625,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/,
); );
@ -917,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,

View File

@ -1,120 +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, 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(
'docker-bridge-mac-strategy',
deviceIdentity.includes('docker-bridge-mac-v1') &&
deviceIdentity.includes('02:42:'),
'device identity uses docker-bridge-mac-v1 and stable 02:42:* MACs',
),
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} 页面级测试`;

View File

@ -185,10 +185,6 @@ export interface BusinessTestPlanInput {
flow?: BusinessFlow | 'all'; flow?: BusinessFlow | 'all';
} }
export interface NapcatDeviceProfileCheckInput {
project?: string;
}
export interface BlockerResolutionInput { export interface BlockerResolutionInput {
attempts?: number; attempts?: number;
command?: string; command?: string;
@ -209,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';