ktworkflow-mcp/src/tools/workflow.ts

369 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<RepoSnapshot> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
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<Record<string, unknown>> {
const snapshots = await Promise.all(normalizeProjectKeys(input.projects).map(collectRepoSnapshot));
const remote = input.remote || 'origin';
return {
checks: [
'推送前工作区应干净;如仍有改动,先提交或说明为什么暂不提交。',
'推送前复跑 kt_global_code_reviewfindings 必须为 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<string, string> = {
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<Record<string, unknown>> {
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<string, unknown> {
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 是本地 MySQL127.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 <source-conn> kt_template > .codex-db-sync/kt_template.sql',
`mysql <target-conn> -e "CREATE DATABASE IF NOT EXISTS ${backupName} DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;"`,
`mysqldump --set-gtid-purged=OFF --single-transaction --routines --triggers <target-conn> kt_template | mysql <target-conn> ${backupName}`,
'mysql <target-conn> kt_template < .codex-db-sync/kt_template.sql',
'对比源库/目标库表数量、视图/函数/过程数量和关键表行数。',
`验证通过后再执行mysql <target-conn> -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<string, unknown> {
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<string, Record<string, unknown>>;
return {
target,
workflow: workflows[target],
};
}