Compare commits
31 Commits
12d3abe1f4
...
490160189e
| Author | SHA1 | Date | |
|---|---|---|---|
| 490160189e | |||
| 598180615c | |||
| b8b46a04c3 | |||
| e2a50f03b3 | |||
| 1abfcbf59d | |||
| f9ce9b6015 | |||
| 36da962bb0 | |||
| 4fc7c4a776 | |||
| d53b17f6f1 | |||
| 30222fd85e | |||
| fc750ddcf1 | |||
| 6fc5e65986 | |||
| b767732fd3 | |||
| c16a867c60 | |||
| 3f7ed99aec | |||
| 99408d3cec | |||
| 7619dd5928 | |||
| 1f60506c89 | |||
| 4b18c1bec8 | |||
| d5e9bf5b01 | |||
| e29ce3be86 | |||
| 9052ecbc2a | |||
| efc6f50199 | |||
| 32d02b5a8f | |||
| 7075d863c2 | |||
| c2377f96ef | |||
| 8ec21c8f46 | |||
| 81d8fc67f8 | |||
| ae2231d508 | |||
| 1c768c1e79 | |||
| e559e0ee55 |
40
README.md
40
README.md
@ -15,15 +15,18 @@
|
||||
- 生成页面测试用例:内置“先写用例、可视化证据、事不过三”的测试闭环。
|
||||
- 生成接口测试计划:接口改动后输出真实调用命令和统一返回结构断言。
|
||||
- 生成业务链路测试计划:固化 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 入口。
|
||||
- 生成多仓库提交/推送计划:按仓库分组、建议提交信息、列出提交和推送前检查。
|
||||
- 生成远程只读健康检查和数据库同步安全向导:覆盖飞牛 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 的防踩坑清单和验证点。
|
||||
- 生成验证进程清理计划:按项目路径和端口给出 PowerShell 检查命令,不直接杀进程。
|
||||
- 清理历史产物:统一治理 `.kt-workspace` 下的测试/验证产物,按目录最近修改时间只保留最近 3 轮,模板目录永久保留;CLI 默认 dry-run,真实清理必须显式传 `--execute`。
|
||||
- 检查 env 策略和变更风险:区分后端真实 env 与前端客户端 `.env*`,提醒锁文件、核心表格组件、API 时间序列化 KtDateTime 列/DTO 装饰器入口、部署链路和 Vue TSX 插槽写法等高风险改动。
|
||||
- 全局 CodeReview 只读扫描:汇总全部 KT 子仓库的 Git 状态、敏感文件跟踪、冲突标记、运行时调试输出、疑似凭据字面量、NapCat `latest` 镜像漂移风险、QQBot 重型服务单测导入风险、QQBot OneBot/QQ 登录态边界回归、NapCat 密码登录验证码 wait 阶段、日志窗口锚点、验证码状态早于日志 URL 的短轮询、验证码 pending 被状态轮询误清理、API Pod SSH 日志读取超时和清理前 pending 回归、普通处理中状态误吃旧验证码日志回归、根目录生成产物、`TASKS.md` 最近记录字段结构和当前变更风险;默认只对变更文件做内容扫描,并放过短中文显示标签和明显测试占位 token,避免历史误报污染上下文;任何文件改动后都要跑一遍。
|
||||
- 全局 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,避免历史误报污染上下文;任何文件改动后都要跑一遍。
|
||||
- 生成或写入 `TASKS.md` 最近记录:默认 `dryRun=true`,确认后再落盘。
|
||||
- 生成提交前检查清单:校验 KT commit message 约定。
|
||||
|
||||
@ -38,13 +41,29 @@ pnpm run obsidian-context -- --module ktWorkflow
|
||||
pnpm run obsidian-validate
|
||||
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 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 -- --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
|
||||
```
|
||||
|
||||
如果本机 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 客户端配置
|
||||
|
||||
把下面配置加入支持 stdio MCP 的客户端配置里:
|
||||
@ -91,8 +110,15 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||
| `kt_commit_plan` | 生成多仓库提交计划、建议 commit message 和检查项 |
|
||||
| `kt_push_plan` | 生成多仓库推送计划和远程异常提醒 |
|
||||
| `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_remote_health_check` | 生成或执行远程只读健康检查命令 |
|
||||
| `kt_deploy_observation` | 生成或执行 API 发布后的部署观测,汇总 Jenkins SCM revision、K8s、Pod、`/health/runtime` 和任务 smoke 证据 |
|
||||
| `kt_db_sync_plan` | 生成数据库同步安全向导 |
|
||||
| `kt_component_workflow` | 输出专项组件/链路防踩坑工作流,包含 FnosK8s |
|
||||
| `kt_obsidian_context` | 读取 Obsidian 索引上下文,支持按 `module` / `query` 返回模块页、文档矩阵、Canvas 摘要、代码锚点和相关规则文档 |
|
||||
@ -106,7 +132,14 @@ 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 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 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 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-validate` | 校验 KT Obsidian vault 结构和链接;默认 warning 不让脚本失败,需要严格模式时传 `--fail-on-warnings`。 |
|
||||
| `pnpm run obsidian-sync` | 审计 Obsidian 工作流入口是否连通,并联动执行 validate。 |
|
||||
@ -145,12 +178,15 @@ pnpm run admin-login -- --url http://127.0.0.1:5999/#/auth/login
|
||||
- 要收尾时调用 `kt_finish_task`,默认只生成计划和 review;需要执行验证时显式传 `runValidation=true`。
|
||||
- 大方向结束前调用 `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` 并复跑。
|
||||
- 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_workflow_loop_audit`,确认测试证据、文档同步、历史清理最终 `deleted=0`、问题固化、ktWorkflow 升级、KT 全局 review 和 Superpowers code review 都过门;清理证据必须显式传入,不能依赖缺省值。
|
||||
- 要提交或推送时先调用 `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_blocker_resolution`;第二次仍失败时先写入 `TASKS.md` 或补成脚本,再继续。
|
||||
- 远程服务排查先调用 `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` 转储目录、带时间戳的备份库和校验点。
|
||||
- 改 KtTable、BlogArgon、AdminAuth、QQBot、FF14Plugin、NapCatLogin、SystemLog、Knife4jSwagger、FnosK8s 前调用 `kt_component_workflow`,先看专项禁区。
|
||||
- 不确定任务边界时调用 `kt_guardrails`,先拿到“能做什么、不能做什么、怎么验证”。
|
||||
|
||||
118
ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile
Normal file
118
ci/jenkins/KT-NapCatQQ-Runtime-Release.Jenkinsfile
Normal file
@ -0,0 +1,118 @@
|
||||
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.')
|
||||
}
|
||||
}
|
||||
65
ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile
Normal file
65
ci/jenkins/KT-NapCatQQ-Upstream-Sync.Jenkinsfile
Normal file
@ -0,0 +1,65 @@
|
||||
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,7 +7,14 @@
|
||||
"scripts": {
|
||||
"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",
|
||||
"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",
|
||||
"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-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",
|
||||
|
||||
41
prompts/napcat/remote-dev-handoff.md
Normal file
41
prompts/napcat/remote-dev-handoff.md
Normal file
@ -0,0 +1,41 @@
|
||||
# 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."]
|
||||
}
|
||||
```
|
||||
34
prompts/napcat/runtime-release-readiness.md
Normal file
34
prompts/napcat/runtime-release-readiness.md
Normal file
@ -0,0 +1,34 @@
|
||||
# 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."
|
||||
}
|
||||
```
|
||||
34
prompts/napcat/schemas/runtime-release-readiness.schema.json
Normal file
34
prompts/napcat/schemas/runtime-release-readiness.schema.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
33
prompts/napcat/schemas/upstream-audit.schema.json
Normal file
33
prompts/napcat/schemas/upstream-audit.schema.json
Normal file
@ -0,0 +1,33 @@
|
||||
{
|
||||
"$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
|
||||
}
|
||||
}
|
||||
}
|
||||
43
prompts/napcat/sync-candidate-review.md
Normal file
43
prompts/napcat/sync-candidate-review.md
Normal file
@ -0,0 +1,43 @@
|
||||
# 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."]
|
||||
}
|
||||
```
|
||||
53
prompts/napcat/upstream-audit.md
Normal file
53
prompts/napcat/upstream-audit.md
Normal file
@ -0,0 +1,53 @@
|
||||
# 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,5 +1,10 @@
|
||||
import type {
|
||||
DeployObservationInput,
|
||||
GlobalCodeReviewInput,
|
||||
NapcatRemoteDevHandoffInput,
|
||||
NapcatRuntimeReleaseReadinessInput,
|
||||
NapcatSyncCandidateReviewInput,
|
||||
NasCodexBootstrapPlanInput,
|
||||
ObsidianContextInput,
|
||||
ObsidianSyncInput,
|
||||
ObsidianValidateInput,
|
||||
@ -7,6 +12,7 @@ import type {
|
||||
WorkstreamReusablePattern,
|
||||
} from '../types.js';
|
||||
import { workstreamReusablePatternValues } from './constants.js';
|
||||
import type { NapcatUpstreamAuditInput } from '../tools/napcatAutomation.types.js';
|
||||
|
||||
function parseContentScanMode(argv: string[]): 'all' | 'changed' {
|
||||
if (argv.includes('--content-scan-all') || argv.includes('--contentScanMode=all')) {
|
||||
@ -61,6 +67,16 @@ function readOptionList(argv: string[], names: string[]): string[] {
|
||||
.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 {
|
||||
const patternSet = new Set<string>(workstreamReusablePatternValues);
|
||||
const patterns = readOptionList(argv, ['--pattern', '--reusable-pattern'])
|
||||
@ -90,6 +106,167 @@ 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 {
|
||||
return {
|
||||
checkLayout: !argv.includes('--no-layout'),
|
||||
|
||||
@ -69,8 +69,15 @@ export const registeredToolNames = [
|
||||
'kt_commit_plan',
|
||||
'kt_push_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_remote_health_check',
|
||||
'kt_deploy_observation',
|
||||
'kt_db_sync_plan',
|
||||
'kt_component_workflow',
|
||||
'kt_obsidian_context',
|
||||
@ -85,7 +92,9 @@ export const defaultHistoryRoots = [
|
||||
'.kt-workspace/test-logs',
|
||||
'.kt-workspace/verify',
|
||||
'.kt-workspace/playwright-mcp',
|
||||
'.kt-workspace/subagents',
|
||||
'.kt-workspace/db-sync',
|
||||
'.kt-workspace/exports',
|
||||
'.kt-workspace/tmp',
|
||||
'.kt-workspace/logs',
|
||||
'.kt-workspace/deploy',
|
||||
|
||||
@ -12,10 +12,24 @@ import { buildWorkflowLoopAudit } from './tools/loop.js';
|
||||
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
||||
import { buildGlobalCodeReview, defaultReviewProjects } from './tools/review.js';
|
||||
import { appendTaskRecord, createCommitChecklist, prepareTask, readWorkflowContext } from './tools/task.js';
|
||||
import { buildBusinessTestPlan, createApiTestPlan, createPageTestCase } from './tools/testing.js';
|
||||
import {
|
||||
buildBusinessTestPlan,
|
||||
buildNapcatDeviceProfileCheck,
|
||||
createApiTestPlan,
|
||||
createPageTestCase,
|
||||
} from './tools/testing.js';
|
||||
import { buildVerificationPlan } from './tools/verification.js';
|
||||
import { buildWorkstreamCloseout } from './tools/closeout.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 { workstreamReusablePatternValues } from './core/constants.js';
|
||||
export function response(data: unknown): McpTextResponse {
|
||||
@ -29,6 +43,11 @@ 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 {
|
||||
server.registerTool(
|
||||
'kt_read_context',
|
||||
@ -159,7 +178,7 @@ export function registerTools(server: McpServer): void {
|
||||
server.registerTool(
|
||||
'kt_cleanup_history',
|
||||
{
|
||||
description: '清理 KT 运行态历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮,template/_templates 等模板目录永久保留。',
|
||||
description: '清理 KT 运行态历史产物。默认 dryRun=true,只预览;执行时按 LastWriteTime 每个 .kt-workspace 历史目录仅保留最近 3 轮;可复用模板和参考资产应迁入 workspace-assets。',
|
||||
inputSchema: {
|
||||
dryRun: z.boolean().default(true),
|
||||
keep: z.number().int().min(0).max(50).default(3),
|
||||
@ -364,6 +383,145 @@ export function registerTools(server: McpServer): void {
|
||||
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(
|
||||
'kt_blocker_resolution',
|
||||
{
|
||||
@ -400,6 +558,37 @@ export function registerTools(server: McpServer): void {
|
||||
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(
|
||||
'kt_db_sync_plan',
|
||||
{
|
||||
|
||||
1519
src/selfTest.ts
1519
src/selfTest.ts
File diff suppressed because it is too large
Load Diff
@ -3,7 +3,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
||||
|
||||
import {
|
||||
parseNasCodexBootstrapCliArgs,
|
||||
parseDeployObservationCliArgs,
|
||||
parseGlobalReviewCliArgs,
|
||||
parseNapcatRemoteDevHandoffCliArgs,
|
||||
parseNapcatRuntimeReleaseReadinessCliArgs,
|
||||
parseNapcatSyncCandidateReviewCliArgs,
|
||||
parseNapcatUpstreamAuditCliArgs,
|
||||
parseObsidianContextCliArgs,
|
||||
parseObsidianCliArgs,
|
||||
parseObsidianSyncCliArgs,
|
||||
@ -11,10 +17,19 @@ import {
|
||||
} from './core/cli.js';
|
||||
import { cleanupHistoryArtifacts, parseCliCleanupArgs } from './tools/cleanup.js';
|
||||
import { buildWorkstreamCloseout } from './tools/closeout.js';
|
||||
import { buildDeployObservation } from './tools/deployObservation.js';
|
||||
import { readObsidianContext, syncObsidianWorkflow, validateObsidianVault } from './tools/obsidian.js';
|
||||
import { buildGlobalCodeReview } from './tools/review.js';
|
||||
import { registerTools } from './registerTools.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')) {
|
||||
console.log(JSON.stringify(cleanupHistoryArtifacts(parseCliCleanupArgs(process.argv)), null, 2));
|
||||
@ -32,10 +47,52 @@ if (process.argv.includes('--cleanup-history')) {
|
||||
console.log(JSON.stringify(readObsidianContext(parseObsidianContextCliArgs(process.argv)), null, 2));
|
||||
} else if (process.argv.includes('--obsidian-sync')) {
|
||||
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')) {
|
||||
await runSelfTest();
|
||||
} else if (process.argv.includes('--workstream-closeout')) {
|
||||
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 {
|
||||
const server = new McpServer({
|
||||
name: 'kt-workflow',
|
||||
|
||||
@ -36,32 +36,31 @@ interface HistoryEntry {
|
||||
type: 'directory' | 'file';
|
||||
}
|
||||
|
||||
const alwaysPreservedTemplateNames = new Set(['_template', '_templates', 'template', 'templates']);
|
||||
const workspaceRootPreservedNames = new Set([
|
||||
'backup',
|
||||
'db-sync',
|
||||
'deploy',
|
||||
'external',
|
||||
'exports',
|
||||
'logs',
|
||||
'playwright-mcp',
|
||||
'sources',
|
||||
'subagents',
|
||||
'test-artifacts',
|
||||
'test-logs',
|
||||
'tmp',
|
||||
'verify',
|
||||
]);
|
||||
|
||||
function isTemplateEntryName(name: string): boolean {
|
||||
return alwaysPreservedTemplateNames.has(name.toLowerCase());
|
||||
}
|
||||
|
||||
function buildPreservedNames(rootRelativePath: string, childNames: string[]): Set<string> {
|
||||
/**
|
||||
* Builds the directory names that represent generated artifact categories under
|
||||
* `.kt-workspace`; reusable templates and references are intentionally not
|
||||
* preserved here because they belong in `workspace-assets`.
|
||||
*
|
||||
* @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>();
|
||||
|
||||
for (const name of childNames) {
|
||||
if (isTemplateEntryName(name)) preservedNames.add(name);
|
||||
}
|
||||
|
||||
if (rootRelativePath === '.kt-workspace') {
|
||||
for (const name of workspaceRootPreservedNames) {
|
||||
preservedNames.add(name);
|
||||
@ -126,8 +125,7 @@ export function cleanupHistoryArtifacts(input: CleanupHistoryInput = {}): Record
|
||||
continue;
|
||||
}
|
||||
|
||||
const childNames = readdirSync(rootPath, { withFileTypes: true }).map((item) => item.name);
|
||||
const preservedNames = buildPreservedNames(rootRelativePath, childNames);
|
||||
const preservedNames = buildPreservedNames(rootRelativePath);
|
||||
const entries = listHistoryEntries(rootPath, preservedNames);
|
||||
const preserved = entries.slice(0, keep);
|
||||
const deleting = entries.slice(keep);
|
||||
|
||||
@ -26,9 +26,9 @@ const upgradeTargets = {
|
||||
prompt: '把反复使用且容易写错的命令模板封装到工作流、业务测试计划或脚本。',
|
||||
},
|
||||
'deploy-observation': {
|
||||
files: ['src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
|
||||
files: ['src/tools/deployObservation.ts', 'src/tools/workflow.ts', 'src/tools/testing.ts', 'README.md'],
|
||||
label: '部署观测',
|
||||
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、日志和 smoke 观测步骤写成固定计划。',
|
||||
prompt: '把 Jenkins/K8s build、commit、镜像、Deployment、Pod、/health/runtime、日志和 smoke 观测步骤写成固定计划。',
|
||||
},
|
||||
none: {
|
||||
files: [],
|
||||
|
||||
757
src/tools/deployObservation.ts
Normal file
757
src/tools/deployObservation.ts
Normal file
@ -0,0 +1,757 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
64
src/tools/napcatAutomation.prompts.ts
Normal file
64
src/tools/napcatAutomation.prompts.ts
Normal file
@ -0,0 +1,64 @@
|
||||
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');
|
||||
}
|
||||
1376
src/tools/napcatAutomation.ts
Normal file
1376
src/tools/napcatAutomation.ts
Normal file
File diff suppressed because it is too large
Load Diff
130
src/tools/napcatAutomation.types.ts
Normal file
130
src/tools/napcatAutomation.types.ts
Normal file
@ -0,0 +1,130 @@
|
||||
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,7 +172,6 @@ const requiredCorePlugins = [
|
||||
const expectedIgnoredRoots = [
|
||||
'.git/',
|
||||
'.kt-workspace/',
|
||||
'_exports/',
|
||||
'Node/',
|
||||
'Plugins/',
|
||||
'Vue/',
|
||||
@ -278,7 +277,6 @@ const ignoredDirectoryNames = new Set([
|
||||
'.pnpm',
|
||||
'.turbo',
|
||||
'.vite',
|
||||
'_exports',
|
||||
'build',
|
||||
'coverage',
|
||||
'dist',
|
||||
|
||||
@ -41,10 +41,15 @@ const rootGeneratedArtifactNames = new Set([
|
||||
".codex-test-logs",
|
||||
".codex-verify",
|
||||
".playwright-mcp",
|
||||
"_exports",
|
||||
"codex-test-logs",
|
||||
"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 =
|
||||
/(^|\/)(\.env($|\..+)|deploy\.json|secrets?\.(env|json|ya?ml)|id_rsa|[^/]+\.(pem|key))$/i;
|
||||
|
||||
@ -230,6 +235,126 @@ 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(
|
||||
lines: string[],
|
||||
context: {
|
||||
@ -505,6 +630,46 @@ 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\s+([A-Za-z0-9_]*LogSinceMs)\s*=\s*Date\.now\(\)[\s\S]*?ensureRuntimeLoginEnv/,
|
||||
);
|
||||
@ -757,6 +922,14 @@ function collectProjectContentFindings(
|
||||
file,
|
||||
project: project.relativePath,
|
||||
}),
|
||||
...findQqbotPluginSmokeImportFindings(lines, {
|
||||
file,
|
||||
project: project.relativePath,
|
||||
}),
|
||||
...findQqbotPluginPackageProductionPathFindings(lines, {
|
||||
file,
|
||||
project: project.relativePath,
|
||||
}),
|
||||
...findQqbotStatusBoundaryFindings(lines, {
|
||||
file,
|
||||
project: project.relativePath,
|
||||
@ -822,6 +995,64 @@ function findRootGeneratedArtifacts(): string[] {
|
||||
.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(
|
||||
projectKey: string,
|
||||
input: Required<
|
||||
@ -920,6 +1151,8 @@ export async function buildGlobalCodeReview(
|
||||
: findWorkspaceFilesByName(new Set(["deploy.js", "deploy.json"]), 50);
|
||||
const rootGeneratedArtifacts =
|
||||
input.includeRootScan === false ? [] : findRootGeneratedArtifacts();
|
||||
const workspaceAssetPolicyFindings =
|
||||
input.includeRootScan === false ? [] : findWorkspaceAssetPolicyFindings();
|
||||
const taskRecordFindings =
|
||||
input.includeRootScan === false
|
||||
? []
|
||||
@ -947,13 +1180,14 @@ export async function buildGlobalCodeReview(
|
||||
message: "根目录存在生成态临时产物。",
|
||||
project: "root",
|
||||
suggestion:
|
||||
"统一迁入 .kt-workspace 对应子目录,并更新生成脚本或测试流程。",
|
||||
"生成证据迁入 .kt-workspace;可复用资产迁入 workspace-assets,并更新生成脚本或测试流程。",
|
||||
}),
|
||||
);
|
||||
const allFindings = [
|
||||
...findings,
|
||||
...rootFindings,
|
||||
...rootGeneratedFindings,
|
||||
...workspaceAssetPolicyFindings,
|
||||
...taskRecordFindings,
|
||||
];
|
||||
|
||||
|
||||
@ -1,5 +1,122 @@
|
||||
import type { ApiTestPlanInput, BusinessFlow, BusinessTestPlanInput, PageTestCaseInput, PageTestCaseResult } from '../types.js';
|
||||
import { existsSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import type {
|
||||
ApiTestPlanInput,
|
||||
BusinessFlow,
|
||||
BusinessTestPlanInput,
|
||||
NapcatDeviceProfileCheckInput,
|
||||
PageTestCaseInput,
|
||||
PageTestCaseResult,
|
||||
} from '../types.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 {
|
||||
const project = resolveProject(input.project);
|
||||
const title = input.title || `${project.label} 页面级测试`;
|
||||
@ -115,13 +232,26 @@ export function buildBusinessTestPlan(input: BusinessTestPlanInput = {}): Record
|
||||
},
|
||||
'qqbot-account-scan': {
|
||||
assertions: [
|
||||
'扫码创建返回 qrcode/containerName/webuiPort。',
|
||||
'扫码创建接口先返回 pending sessionId,再通过 SSE/status 展示容器创建、二维码生成、等待扫码和终态。',
|
||||
'新增账号首次容器创建必须在第一轮 Docker run 注入设备身份和中文桌面 runtime profile,而不是登录后补 env。',
|
||||
'取消/过期/失败会清理未绑定 NapCat 容器。',
|
||||
'若真实接口 smoke 会创建线上 QQBot 账号或 NapCat 容器,必须先确认清理方案;不能造脏数据时用 RED/GREEN、只读线上计数复核和未执行真实造号原因替代。',
|
||||
'删除账号会二次确认并删除专属容器。',
|
||||
],
|
||||
preflight: ['NAS SSH 可达', 'NapCat WebUI 可达', 'API QQBot env 配置完整'],
|
||||
preflight: [
|
||||
'NAS SSH 可达',
|
||||
'NapCat WebUI 可达',
|
||||
'API QQBot env 配置完整',
|
||||
'确认本轮是否允许创建真实 QQBot 账号和 NapCat 容器',
|
||||
],
|
||||
projects: ['api', 'admin'],
|
||||
steps: ['打开账号连接页', '点击扫码新增账号', '轮询二维码状态', '确认账号回填', '测试删除联动容器'],
|
||||
steps: [
|
||||
'打开账号连接页',
|
||||
'点击扫码新增账号并确认接口不等待 Docker/WebUI 长耗时',
|
||||
'订阅 scan/events 或轮询 status 确认中文进度',
|
||||
'确认账号回填和 runtime/profile 归属',
|
||||
'测试删除联动容器或记录未造号的替代验证证据',
|
||||
],
|
||||
},
|
||||
'qqbot-login-sse': {
|
||||
assertions: [
|
||||
|
||||
148
src/types.ts
148
src/types.ts
@ -185,6 +185,44 @@ export interface BusinessTestPlanInput {
|
||||
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 {
|
||||
attempts?: number;
|
||||
command?: string;
|
||||
@ -205,6 +243,116 @@ export interface RemoteHealthCheckInput {
|
||||
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 {
|
||||
backupName?: string;
|
||||
source?: 'local' | 'remote';
|
||||
|
||||
Loading…
Reference in New Issue
Block a user