import type { CommitPlanInput, ComponentWorkflowInput, DbSyncPlanInput, ExecResult, FinishTaskInput, PushPlanInput, RemoteHealthCheckInput, RepoType, ResolvedProject, } from "../types.js"; import { tryExecFile, tryPowerShell } from "../core/exec.js"; import { detectRepoType, parseGitStatusFiles } from "../core/project.js"; import { getGlobalReviewCommand, resolveProject, toPosix, workspaceRoot, } from "../core/workspace.js"; import { cleanupHistoryArtifacts } from "./cleanup.js"; import { karpathyChangedFileChecklistItem, karpathyFinishNote, karpathyStopCondition, } from "./karpathy.js"; import { buildGlobalCodeReview, defaultReviewProjects } from "./review.js"; import { buildVerificationPlan } from "./verification.js"; import { buildWorkstreamCloseout } from "./closeout.js"; interface RepoSnapshot { branch: string; changedFiles: string[]; project: ResolvedProject; remote: string; repoType: RepoType; status: string; } function normalizeProjectKeys(projects?: string[]): string[] { const selected = (projects?.length ?? 0) > 0 ? projects! : [...defaultReviewProjects]; return Array.from(new Set(selected.filter((project) => project !== "root"))); } function formatShanghaiTimestamp(date = new Date()): string { const parts = new Intl.DateTimeFormat("en-CA", { day: "2-digit", hour: "2-digit", hour12: false, minute: "2-digit", month: "2-digit", second: "2-digit", timeZone: "Asia/Shanghai", year: "numeric", }).formatToParts(date); const map = Object.fromEntries(parts.map((part) => [part.type, part.value])); return `${map.year}${map.month}${map.day}_${map.hour}${map.minute}${map.second}`; } function normalizeDbIdentifier(value: string): string { return value .replace(/[^a-zA-Z0-9_]/g, "_") .replace(/_+/g, "_") .slice(0, 64); } async function collectRepoSnapshot(projectKey: string): Promise { const project = resolveProject(projectKey); const repoType = detectRepoType(project.path); const status = repoType === "git" ? await tryExecFile("git", ["status", "--short"], project.path) : null; const branch = repoType === "git" ? await tryExecFile("git", ["branch", "--show-current"], project.path) : null; const remote = repoType === "git" ? await tryExecFile("git", ["remote", "-v"], project.path) : null; return { branch: branch?.ok ? branch.stdout : "", changedFiles: status?.ok ? parseGitStatusFiles(status.stdout) : [], project, remote: remote?.ok ? remote.stdout : "", repoType, status: status?.ok ? status.stdout : status?.stderr || "", }; } function suggestCommitMessage(snapshot: RepoSnapshot): string { const files = snapshot.changedFiles.join("\n"); if (snapshot.project.alias === "mcp") { return "feat(workflow): 扩展KT工作流能力"; } if (snapshot.project.alias === "api" && /qqbot/i.test(files)) { return "fix(qqbot): 修复QQBot运行链路"; } if ( snapshot.project.alias === "admin" && /\.env|authentication/.test(files) ) { return "fix(admin): 收敛环境配置和登录页"; } if ( snapshot.project.alias === "web" && /deploy\.json|\.gitignore/.test(files) ) { return "chore(web): 清理旧部署配置"; } if (snapshot.project.alias === "blog") { return "fix(blog): 优化博客前台体验"; } if (snapshot.project.alias === "knife4j") { return "feat(knife4j): 升级接口文档插件能力"; } return `chore(${snapshot.project.alias || "repo"}): 更新项目改动`; } export async function buildFinishTask( input: FinishTaskInput = {}, ): Promise> { const projectKeys = normalizeProjectKeys(input.projects); const snapshots = await Promise.all(projectKeys.map(collectRepoSnapshot)); const changedSnapshots = snapshots.filter( (snapshot) => snapshot.changedFiles.length > 0, ); const verificationPlans = changedSnapshots.map((snapshot) => buildVerificationPlan({ changeType: input.changeType || "general", project: snapshot.project.alias || snapshot.project.relativePath, }), ); const validationRuns: Array<{ command: string; project: ResolvedProject; result: ExecResult; }> = []; if (input.runValidation) { for (const plan of verificationPlans) { for (const command of plan.commands.filter( (item) => !item.includes("global-review"), )) { validationRuns.push({ command, project: plan.project, result: await tryPowerShell(command, plan.project.path), }); } } } const review = await buildGlobalCodeReview({ contentScanMode: input.contentScanMode ?? "changed", includeContentScan: true, includeRootScan: input.includeRootScan ?? true, maxFindingsPerProject: input.maxFindingsPerProject ?? 20, projects: projectKeys, }); const cleanup = cleanupHistoryArtifacts({ dryRun: !(input.cleanupHistory ?? false), keep: input.keepHistory ?? 3, }); const cleanupDeleted = Array.isArray(cleanup.deleted) ? cleanup.deleted : []; const cleanupPendingCount = cleanup.dryRun ? cleanupDeleted.length : 0; return { cleanup, closeout: buildWorkstreamCloseout({ changedProjects: changedSnapshots.map( (snapshot) => snapshot.project.relativePath, ), problemRecords: [], reusablePatterns: ["none"], stableSolutions: [], title: "kt_finish_task 收尾审计", verificationEvidence: validationRuns .filter((item) => item.result.ok) .map((item) => `${item.project.relativePath}: ${item.command}`), }), mode: input.runValidation ? "validation_executed" : "plan_only", notes: [ "默认只生成收尾结果;runValidation=true 时才执行验证命令。", "cleanupHistory=true 时才真实清理历史产物,否则仅预览;预览中 deleted>0 说明测试/验证历史还未满足最近 3 轮规则。", "提交和推送仍需用户明确要求后执行。", karpathyFinishNote, ], review, snapshots, stopConditions: [ "review.findings 存在 P1/P2 时先判定真实风险或工具误报;真实风险修代码,工具误报修 ktWorkflow,再复跑 kt_global_code_review。", "validationRuns 中任一 result.ok=false 时先处理失败命令。", "cleanup.dryRun=true 且 cleanup.deleted 非空时,先执行 kt_cleanup_history dryRun=false 或 pnpm run cleanup-history -- --execute,再报告测试/验证历史已合规。", "发现真实 env、生产 Secret 或非本次范围文件时停止提交。", karpathyStopCondition, ], summary: { changedProjectCount: changedSnapshots.length, changedProjects: changedSnapshots.map( (snapshot) => snapshot.project.relativePath, ), cleanupPendingCount, validationRunCount: validationRuns.length, }, validationRuns, verificationPlans, }; } export async function buildCommitPlan( input: CommitPlanInput = {}, ): Promise> { const snapshots = await Promise.all( normalizeProjectKeys(input.projects).map(collectRepoSnapshot), ); const changedSnapshots = snapshots.filter( (snapshot) => snapshot.changedFiles.length > 0, ); return { checks: [ "每个独立 Git 仓库单独提交。", karpathyChangedFileChecklistItem, "提交前先跑对应轻量验证和 kt_global_code_review。", "kt_global_code_review findings 必须为 0;确认误报时先修 ktWorkflow 规则,不把误报当作长期上下文。", "后端真实 .env.development/.env.production 不提交;前端 env 可提交但只能包含客户端公开配置。", "KT 根目录是本地治理仓库;如没有 remote,只本地提交并在最终回复说明。", ], includePush: input.includePush ?? false, projects: changedSnapshots.map((snapshot) => { const message = input.message || suggestCommitMessage(snapshot); return { branch: snapshot.branch, changedFiles: snapshot.changedFiles, commands: [ "git status --short", "git add -A", `git commit -m "${message}"`, ...(input.includePush ? [`git push origin ${snapshot.branch || "main"}`] : []), ], message, project: snapshot.project, status: snapshot.status, }; }), reviewCommand: getGlobalReviewCommand(), summary: { changedProjectCount: changedSnapshots.length, totalChangedFiles: changedSnapshots.reduce( (count, snapshot) => count + snapshot.changedFiles.length, 0, ), }, }; } export async function buildPushPlan( input: PushPlanInput = {}, ): Promise> { const snapshots = await Promise.all( normalizeProjectKeys(input.projects).map(collectRepoSnapshot), ); const remote = input.remote || "origin"; return { checks: [ "推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。", "推送前复跑 kt_global_code_review,findings 必须为 0;如出现误报先升级 ktWorkflow 规则。", "如果 Jenkins/Gitea host key、mirror、hooks 异常,先修远程仓库状态再重试。", ], projects: snapshots .filter((snapshot) => snapshot.repoType === "git") .map((snapshot) => { const branch = input.branch || snapshot.branch || "main"; return { branch, commands: [ "git status --short", `git log --oneline ${remote}/${branch}..${branch}`, `git push ${remote} ${branch}`, ], project: snapshot.project, remote: snapshot.remote, status: snapshot.status, }; }), reviewCommand: getGlobalReviewCommand(), }; } function buildRemoteHealthCommands(input: RemoteHealthCheckInput = {}): Array<{ command: string; service: string; }> { const host = input.sshTarget || input.host || process.env.KT_NAS_SSH_TARGET || "nas.kwitsukasa.top"; const port = input.port ?? Number(process.env.KT_NAS_SSH_PORT || 2202); const ssh = (remoteCommand: string) => `ssh -p ${port} ${host} "${remoteCommand}"`; const allCommands: Record = { docker: ssh("docker ps --format '{{.Names}} {{.Status}} {{.Ports}}'"), gitea: ssh( "docker ps --format '{{.Names}} {{.Status}}' | grep -i gitea || true", ), jenkins: ssh( "docker ps --format '{{.Names}} {{.Status}}' | grep -i jenkins || true", ), k3d: ssh( "kubectl get pods -A -o wide 2>/dev/null || k3d cluster list 2>/dev/null || true", ), minio: ssh( "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i minio || true", ), mosquitto: ssh( "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -i mosquitto || true", ), mysql: ssh( "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'mysql|mariadb' || true", ), napcat: ssh( "docker ps --format '{{.Names}} {{.Status}} {{.Ports}}' | grep -Ei 'napcat|qqbot' || true", ), ssh: ssh("hostname && date"), }; const services = (input.services?.length ?? 0) > 0 ? input.services! : Object.keys(allCommands); return services .filter((service) => allCommands[service]) .map((service) => ({ command: allCommands[service], service, })); } export async function buildRemoteHealthCheck( input: RemoteHealthCheckInput = {}, ): Promise> { const commands = buildRemoteHealthCommands(input); const results = input.execute ? await Promise.all( commands.map(async (item) => ({ ...item, result: await tryPowerShell(item.command, workspaceRoot), })), ) : []; return { commands, execute: input.execute ?? false, notes: [ "默认 execute=false,只生成只读探测命令。", "远程命令只做 hostname/date/docker ps/kubectl get 等只读检查。", "真实密码、token、Secret 不从仓库读取,也不写入输出。", ], results, }; } export function buildDbSyncPlan( input: DbSyncPlanInput = {}, ): Record { const source = input.source || "local"; const target = input.target || "remote"; const backupName = normalizeDbIdentifier( input.backupName || `kt_template_backup_${formatShanghaiTimestamp()}_${Math.random().toString(36).slice(2, 8)}`, ); const dumpPath = `.kt-workspace/db-sync/${backupName}/kt_template.sql`; return { backupName, commands: [ "从 Node/kt-template-online-api/.env.development 和 .env.production 读取连接信息,只在本机 shell 环境使用,不写入 Git。", "KT 当前约定:127.0.0.1:3306 是本地 MySQL,127.0.0.1:3307 是远程 NAS 打洞连接;3307 不通时先建立 ssh -N -L 127.0.0.1:3307:127.0.0.1:3306 nas。", `mkdir -p .kt-workspace/db-sync/${backupName}`, `mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers kt_template > ${dumpPath}`, `mysql -e "CREATE DATABASE ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`, `mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers kt_template | mysql ${backupName}`, `mysql kt_template < ${dumpPath}`, "对比源库/目标库表数量、视图/函数/过程数量和关键表行数。", `验证通过后再执行:mysql -e "DROP DATABASE ${backupName};"`, ], guardrails: [ "默认只生成计划,不执行覆盖、drop、truncate。", "同步前必须确认 source/target 方向,不能靠变量名猜。", "3306/3307 端口方向先用只读 SELECT 预检确认,避免把远程库误当成本地库。", "目标库备份校验通过前不得删除备份库。", "涉及生产库时优先只读验证,批量删除必须二次确认。", ], source, target, verification: [ "SHOW TABLES 数量一致。", "admin_user、admin_menu、qqbot_account、qqbot_message_log 等关键表行数符合预期。", "应用登录和核心接口 smoke 通过。", "全局 review findings=0。", ], }; } export function buildComponentWorkflow( input: ComponentWorkflowInput = {}, ): Record { const target = input.target || "KtTable"; const workflows = { AdminAuth: { antiPatterns: [ "无 token 时主动 refresh", "跨端回跳时清空 Admin 已有授权态", "把 redirect 写丢或重复编码", ], files: [ "Vue/kt-template-admin/apps/web-antdv-next/src/store/auth.ts", "Vue/kt-template-online-web/src/api/auth.ts", "Vue/kt-template-online-playground/src/api/auth.ts", ], guardrails: [ "先画清楚 Admin/API/Web/Playground token 链路", "401 时先判断旧 token 是否存在", "Admin 已登录时直接带回授权态", ], verification: [ "清空 token 测无循环", "Admin 已登录测直接回跳", "刷新页面测持久化", ], }, BlogArgon: { antiPatterns: [ "使用具名 id 控制 DOM", "SCSS 类名逃出 kt-blog 命名空间", "主题变量散落在 scss 文件", "亮色模式仍套暗色遮罩", ], files: [ "Vue/kt-blog-web/src/hooks/useBlogTheme.ts", "Vue/kt-blog-web/src/styles", "Vue/kt-blog-web/src/components/blog", ], guardrails: [ "样式统一 kt-blog BEM 命名空间", "主题变量收敛到 useBlogTheme", "动画用 Vue Transition", "和线上 Argon DOM/视觉逐项对照", ], verification: [ "亮/暗模式切换", "搜索 Enter 和动画", "文章切换动画", "无感知滚动加载", "背景图滚动到底部不断层", ], }, Knife4jSwagger: { antiPatterns: [ "视觉只大致相似", "TSX 里用 function render helper", "TabPane 默认插槽不显式传 default", "把请求入参编辑拆成无法复用的零散状态", ], files: [ "Plugins/knife4j-swagger-vue3/packages/knife4j-vue3-ui", "Plugins/knife4j-swagger-vue3/packages/openapi-parser", "Plugins/knife4j-swagger-vue3/apps/playground", ], guardrails: [ "以 Knife4j 原始 UI 为 1:1 视觉源", "全部 Vue 视图使用 TSX", "嵌套组件 slot 显式 default", "路由使用 hash path 参数,不用 query 兼容旧逻辑", "调试台按 OpenAPI 标准合并 path/query/header/body/auth/global 参数", ], verification: [ "pnpm run typecheck", "真实后端 48085 文档页面", "控制台 warning/error 检查", "Body/Headers/Auth/全局参数发送链路", "小宽度 @media 与左侧调试栏固定", ], }, KtTable: { antiPatterns: [ "自定义纵向滚动条覆盖 Antdv 原生滚动", "scroll/resize 高频写响应式状态", "表格高度参与展开收起动画", "固定列透明导致透视", ], files: [ "Vue/kt-template-admin/apps/web-antdv-next/src/components/ktTable", ], guardrails: [ "优先使用 antdv-next 原生 table 能力", "统计列固定到底部但不参与表单动画", "序号列和操作列固定最小/最大宽", "resize 用 RAF 节流和非深度监听", ], verification: [ "横向滚动性能", "纵向滚动表头对齐", "列宽/行高 resize", "summary 固定底部", "宽屏无无意义横向滚动条", ], }, QQBot: { antiPatterns: [ "把 NapCat token 写进仓库", "删除账号不清理专属容器", "白名单/黑名单只做前端过滤", "远程服务不可达时伪装成业务成功", "更新登录时用旧 isLogin 状态短路", ], files: [ "Node/kt-template-online-api/src/qqbot", "Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot", "ci/fnos-k8s/deploy-qqbot-runtime.ps1", ], guardrails: [ "OneBot 反向 WS token 校验", "权限名单底层先走 QQ号过滤", "群/频道精确 QQ 过滤由开关控制", "扫码取消/过期/失败清理未绑定容器", "更新登录走 SSE 进度,后台重置登录态后再生成二维码", ], verification: [ "账号在线", "扫码新增/更新登录", "scan/events 每步进度", "自动回复规则命中", "权限名单三类过滤", "消息日志和发送日志闭环", ], }, FF14Plugin: { antiPatterns: [ "把 boss/服务器/职业映射硬编码到插件里", "FFLogs 英文任务名直接回显给中文用户", "外部 API 失败时吞掉错误上下文", "字典缺失时返回空白结果", ], files: [ "Node/kt-template-online-api/src/qqbot/plugins/ff14Market", "Node/kt-template-online-api/src/qqbot/plugins/fflogs", "Node/kt-template-online-api/src/admin/dict", "Vue/kt-template-admin/apps/web-antdv-next/src/views/system/dict", ], guardrails: [ "FF14 查价和 FFLogs 映射优先走字典表维护", "中文任务名查询和回显都要经过字典", "命令注册表和在线命令列表同步验证", "保留最近 logs 的颜色/评分/dps/adps/rdps/ndps/hps 字段语义", ], verification: [ "/logs 角色 服务器 高难任务中文名", "/logs 角色 服务器 最近10次", "FF14 查价中文物品名", "字典管理页面可维护映射", ], }, FnosK8s: { antiPatterns: [ "把 fnOS 原生面板改成 Docker 应用", "在 ui/config 显式配置公网端口", "提交 _build/_dist 打包产物", "只测桌面视口不测 fnOS 内嵌窗口和窄屏", "用非官方或旧缓存图标替代 Kubernetes 官方图标", ], files: [ "Plugins/fnos-k8s-dashboard-fpk/src/server.ts", "Plugins/fnos-k8s-dashboard-fpk/src/ui", "Plugins/fnos-k8s-dashboard-fpk/apps/kt-k8s-dashboard/fnos", "Plugins/fnos-k8s-dashboard-fpk/scripts", ], guardrails: [ "保持非 Docker .fpk 形态", "Web 面板源码只放 src/ui,由打包脚本复制进 app.tgz", "fnOS 桌面入口使用 ui/config iframe 和 /kt-k8s-dashboard/ 代理路径", "应用标题与图标保持 Kubernetes 官方语义", "集群配置文件只作为运行时数据,不提交真实 kubeconfig", ], verification: [ "bun run typecheck", "bun run build:fpk", "bun run verify:fpk", "本地 /api/health 和首页请求", "Playwright 桌面与 390px 窄屏截图", "线上应用中心/桌面入口只在用户允许远程测试时验证", ], }, NapCatLogin: { antiPatterns: [ "更新登录复用已过期二维码 URL", "前端等待长接口导致超时", "手动刷新每次重启容器", "后端拿旧 login state 判断成功", "把 ACCOUNT 当成登录凭据,忽略 QQ 历史登录态、账号密码或扫码兜底", "在请求日志、Loki 或列表接口里暴露 QQ 登录密码", "把 proofWaterUrl 当作验证码完成链接,或伪造 ticket/randstr 继续登录", ], files: [ "Node/kt-template-online-api/src/qqbot/account", "Node/kt-template-online-api/src/qqbot/napcat", "Vue/kt-template-admin/apps/web-antdv-next/src/views/qqbot", ], guardrails: [ "创建账号和更新登录共用同一套二维码生成事实源", "更新登录顺序固定为 ACCOUNT/-q 快速登录 -> 账号保存的密码登录 -> reset runtime login state 并生成二维码", "看门狗自动登录只跑 quick -> password,扫码必须由管理员手动触发", "账号登录密码前端只传 RSA-OAEP 加密字段 encryptedLoginPassword,后端用 QQBOT_ACCOUNT_SECRET_KEY 二次加密落库,列表不回显", "ACCOUNT 只指定目标账号;免扫码优先依赖数据卷历史登录态,密码登录失败或触发强制扫码时再进二维码", "密码登录触发验证码时保持 scan session pending,Admin 通过 TencentCaptcha 拿 ticket/randstr/sid,再由 API 调用同一 NapCat 容器 /api/QQLogin/CaptchaLogin", "密码登录验证码日志窗口必须从 NAPCAT_QUICK_PASSWORD 环境重建前开始,覆盖重建容器和后续 restart,避免 proofWaterUrl 被清理前漏抓", "如果验证码状态先于 Docker 日志 URL 到达,必须短窗口轮询当前容器日志拿 proofWaterUrl,再进入清理或失败兜底", "API Pod SSH 读取验证码 Docker 日志不能复用 5 秒运行态快检超时,默认读取超时不得低于 15 秒", "验证码 pending、cancel、expire、success 和失败终态都必须清理临时密码环境变量,清理失败要阻断成功或二维码兜底", "SSE 返回 step/status/qrcode/error", "容器重启只在必要时触发,优先直接通信 NapCat API", ], verification: [ "账号被踢下线后点击更新登录", "docker inspect 确认已绑定容器存在 ACCOUNT=", "docker restart 后 WebUI CheckLoginStatus/GetQQLoginInfo 验证 isLogin/online/uin;失败时检查 NapCat 日志是否为无历史登录记录、密码登录失败或需扫码验证", "密码验证码触发时用 watcher 或 scan/status 证明 API 返回 captcha pending 且不暴露完整 proofWaterUrl", "验证码状态先到但 URL 滞后时,Jest 需要证明短轮询后仍保持 captcha pending,不进入二维码或 error", "NAS 本地日志已有验证码但 API 仍无 captchaUrl 时,必须检查 API Pod SSH 日志读取是否超时", "scan/events 能看到 quick-login-start/quick-login-fallback/password-login-start/password-login-fallback/重置/生成二维码/等待扫码/成功或失败", "目标 Jest 覆盖 quick 成功、password 成功、password 失败进二维码、验证码 CaptchaLogin 回传、密文保存、容器 env 注入和清理失败阻断", "手动刷新不超时", "过期二维码不再复用", ], }, SystemLog: { antiPatterns: [ "把真实 Loki 凭据写入仓库", "只改 Dockerfile 不校验线上 env/Secret", "日志时间格式前后端不一致", "global-review 把历史脱敏占位符或中文显示标签当作真实凭据输出", ], files: [ "Node/kt-template-online-api/src/common/logger", "Node/kt-template-online-api/src/admin/system-log", "Vue/kt-template-admin/apps/web-antdv-next/src/views/system/log", "mcp/ktWorkflow/src/tools/review.ts", ], guardrails: [ "pino + Loki env 走 Jenkins 私有 env 或 K8s Secret", "后端时间字段经 DTO/Entity 装饰器统一 YYYY-MM-DD HH:mm:ss", "系统日志页面走 /system/logs 系列接口", "review 默认只对变更文件做 P2/P3 内容扫描,确认误报要沉淀到 review.ts", ], verification: [ "API typecheck/lint/test", "Loki /ready 和 query_range", "Admin #/system/logs 无 console error", "global-review findings 不含已知脱敏占位符或中文显示标签误报", ], }, } satisfies Record>; return { target, workflow: workflows[target], }; }