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 { formatDateInShanghai, 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'))); } 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, }); 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 时才真实清理历史产物,否则仅预览。', '提交和推送仍需用户明确要求后执行。', karpathyFinishNote, ], review, snapshots, stopConditions: [ 'review.findings 存在 P1/P2 时先判定真实风险或工具误报;真实风险修代码,工具误报修 ktWorkflow,再复跑 kt_global_code_review。', 'validationRuns 中任一 result.ok=false 时先处理失败命令。', '发现真实 env、生产 Secret 或非本次范围文件时停止提交。', karpathyStopCondition, ], summary: { changedProjectCount: changedSnapshots.length, changedProjects: changedSnapshots.map((snapshot) => snapshot.project.relativePath), 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 = input.backupName || `kt_template_backup_${formatDateInShanghai().replaceAll('-', '')}`; 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。', 'mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers kt_template > .codex-db-sync/kt_template.sql', `mysql -e "CREATE DATABASE IF NOT EXISTS ${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 < .codex-db-sync/kt_template.sql', '对比源库/目标库表数量、视图/函数/过程数量和关键表行数。', `验证通过后再执行: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 判断成功'], 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: ['创建账号和更新登录共用同一套二维码生成事实源', '更新登录先 reset runtime login state', 'SSE 返回 step/status/qrcode/error', '容器重启只在必要时触发,优先直接通信 NapCat API'], verification: ['账号被踢下线后点击更新登录', 'scan/events 能看到重置/生成二维码/等待扫码/成功或失败', '手动刷新不超时', '过期二维码不再复用'], }, 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], }; }