From 8502c93b7163141d41e0cd60ab035387350c42cb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=B9=9B=E5=85=AE?= Date: Fri, 12 Jun 2026 06:29:40 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B3=A8=E5=85=A5=20Agent=20Runbook=20?= =?UTF-8?q?=E4=B8=8A=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/agent/agent-runbook.service.ts | 178 +++++++++++++++++++++++++++++ src/agent/agent.module.ts | 15 ++- src/agent/agent.service.spec.ts | 16 +++ src/agent/agent.service.ts | 60 +++++++--- 4 files changed, 253 insertions(+), 16 deletions(-) create mode 100644 src/agent/agent-runbook.service.ts diff --git a/src/agent/agent-runbook.service.ts b/src/agent/agent-runbook.service.ts new file mode 100644 index 0000000..7599484 --- /dev/null +++ b/src/agent/agent-runbook.service.ts @@ -0,0 +1,178 @@ +/** + * Agent Runbook 知识源只维护可公开给模型的运维规则和当前项目上下文摘要。 + * 这里不读取密钥、不调用外部系统,也不生成伪造结论。 + */ +import { Injectable } from '@nestjs/common'; +import { DeployRunSummary } from '../deploy-runs/deploy-run.types'; +import { ProjectSummary } from '../projects/project.types'; +import { AgentPurpose } from './agent.types'; + +export type AgentRunbookEntry = { + title: string; + lines: string[]; +}; + +export type AgentRunbookContext = { + purpose: AgentPurpose; + project?: ProjectSummary; + run?: DeployRunSummary; +}; + +@Injectable() +export class AgentRunbookService { + /** + * 返回当前 Agent 调用可使用的白名单 Runbook 条目。 + */ + buildEntries(context: AgentRunbookContext): AgentRunbookEntry[] { + const entries: AgentRunbookEntry[] = [ + this.releaseFlowEntry(), + this.jenkinsFailureEntry(), + this.notificationEntry(), + this.agentSafetyEntry(), + ]; + + if (context.project) { + entries.unshift(this.projectEntry(context.project)); + + if (this.isSelfManagedProject(context.project)) { + entries.push(this.selfManagedProjectEntry(context.project)); + } + } + + if (context.run) { + entries.unshift(this.runEntry(context.run)); + } + + if (context.purpose === 'runbook-qa') { + entries.push(this.runbookQaEntry()); + } + + return entries; + } + + private projectEntry(project: ProjectSummary): AgentRunbookEntry { + return { + title: '当前项目发布边界', + lines: [ + `项目:${project.key} / ${project.name}`, + `仓库:${project.repositoryUrl}`, + `默认分支:${project.defaultBranch}`, + `项目状态:${project.status}`, + ...project.environments.map( + (environment) => + `环境 ${environment.displayName}(${environment.name}):releasePolicy=${environment.releasePolicy},refPattern=${environment.refPattern},Jenkins=${environment.jenkinsJobPath}`, + ), + ], + }; + } + + private runEntry(run: DeployRunSummary): AgentRunbookEntry { + return { + title: '当前发布单处理建议', + lines: [ + `Run:${run.id}`, + `状态:${run.status}`, + `目标:${run.projectKey}/${run.environment}/${run.ref}`, + `Jenkins:queue=${run.jenkinsQueueId ?? '无'},build=${run.jenkinsBuildNumber ?? '无'}`, + `下一步:${this.nextActionForRun(run)}`, + ], + }; + } + + private releaseFlowEntry(): AgentRunbookEntry { + return { + title: '标准发布流程', + lines: [ + '发布单必须先校验项目、环境和 ref,再触发 Jenkins。', + '生产环境必须带 productionConfirmation.confirmed=true。', + 'BPMN 节点顺序为 start-release、gitea-check、jenkins-build、health-check、notify-wecom、finish-release。', + 'Jenkins 成功后推进 health-check 和 finish-release;通知未配置时 notify 节点 skipped,不阻断发布成功。', + ], + }; + } + + private jenkinsFailureEntry(): AgentRunbookEntry { + return { + title: 'Jenkins 失败排查', + lines: [ + '先查看 run 的 currentStep、failureSummary、step.errorSummary 和 step.logExcerpt。', + '需要完整日志时通过平台读取 /deploy-runs/:id/jenkins-log,前端不直接访问 Jenkins。', + 'queued/running 状态先执行 sync-jenkins,失败或取消终态才允许 retry。', + '并发同步可能返回 409/CONFLICT,前端应刷新最新 run 后再操作。', + ], + }; + } + + private notificationEntry(): AgentRunbookEntry { + return { + title: '通知与 outbox', + lines: [ + '通知 provider 支持 wecom、feishu、generic,webhook 只允许保存在后端配置。', + '未配置 webhook 时发布流程继续执行,outbox 记录 failed/dead 摘要用于后续排查。', + '超级管理员可在 Settings 查看 notification-outbox,并对 failed/dead 消息手动重发。', + '审计和响应不得返回 webhook URL、token、cookie、authorization header 或连接串。', + ], + }; + } + + private agentSafetyEntry(): AgentRunbookEntry { + return { + title: 'Agent 安全边界', + lines: [ + 'Agent 只能围绕发布风险、失败诊断、Runbook、发布说明和事故复盘回答。', + '前端不保存 LLM key、baseURL 或 model,真实调用统一由后端代理。', + '未配置 LLM 时必须返回失败状态和配置提示,不能返回 mock 成功结果。', + 'Agent 配置变更必须写 AGENT_CONFIG_UPDATED 审计,只记录状态和变化字段,不记录 key/baseURL 原文。', + ], + }; + } + + private selfManagedProjectEntry(project: ProjectSummary): AgentRunbookEntry { + return { + title: 'DevOps 平台自管理发布', + lines: [ + `${project.key} 只配置 production 环境,master 合并后发布生产。`, + '该项目测试和生产视为同一套个人运维环境,不额外拆测试域名。', + '平台页面应能看到自身仓库、Jenkins job、最近构建和发布记录状态。', + ], + }; + } + + private runbookQaEntry(): AgentRunbookEntry { + return { + title: 'Runbook 问答规则', + lines: [ + '回答必须优先引用当前项目、发布单、Jenkins/Gitea 诊断和通知 outbox 状态。', + '无法从当前上下文确认的外部事实要明确说明需要进一步查看真实系统。', + '不得建议在前端直接配置或调用 Jenkins、Gitea、通知平台或 LLM 供应商。', + ], + }; + } + + private nextActionForRun(run: DeployRunSummary): string { + if (run.status === 'queued' || run.status === 'running') { + return '同步 Jenkins 状态,等待 queue/build 结果后再判断是否需要取消。'; + } + + if (run.status === 'failed') { + return '查看 Jenkins 日志和失败节点,确认失败原因后决定重试或回滚。'; + } + + if (run.status === 'canceled') { + return '确认取消原因和 Jenkins 停止结果,必要时基于原参数重试。'; + } + + if (run.status === 'success') { + return '核对 health-check、通知 outbox、审计记录和线上入口。'; + } + + return '等待发布单进入 Jenkins queue 或执行取消。'; + } + + private isSelfManagedProject(project: ProjectSummary): boolean { + return ( + project.key === 'devops-platform-api' || + project.key === 'devops-platform-web' + ); + } +} diff --git a/src/agent/agent.module.ts b/src/agent/agent.module.ts index 7802ecb..b07cda5 100644 --- a/src/agent/agent.module.ts +++ b/src/agent/agent.module.ts @@ -5,6 +5,7 @@ import { AuthModule } from '../auth/auth.module'; import { DeployRunsModule } from '../deploy-runs/deploy-runs.module'; import { ProjectsModule } from '../projects/projects.module'; import { AgentInvocationRepository } from './agent-invocation.repository'; +import { AgentRunbookService } from './agent-runbook.service'; import { AgentController } from './agent.controller'; import { AgentService } from './agent.service'; import { LlmClient } from './llm.client'; @@ -18,7 +19,17 @@ import { LlmClient } from './llm.client'; ProjectsModule, ], controllers: [AgentController], - providers: [AgentInvocationRepository, AgentService, LlmClient], - exports: [AgentInvocationRepository, AgentService, LlmClient], + providers: [ + AgentInvocationRepository, + AgentRunbookService, + AgentService, + LlmClient, + ], + exports: [ + AgentInvocationRepository, + AgentRunbookService, + AgentService, + LlmClient, + ], }) export class AgentModule {} diff --git a/src/agent/agent.service.spec.ts b/src/agent/agent.service.spec.ts index a0173df..c40a18a 100644 --- a/src/agent/agent.service.spec.ts +++ b/src/agent/agent.service.spec.ts @@ -5,6 +5,7 @@ import { AppError } from '../common/errors/app-error'; import { DeployRunsService } from '../deploy-runs/deploy-runs.service'; import { ProjectsService } from '../projects/projects.service'; import { AgentInvocationRepository } from './agent-invocation.repository'; +import { AgentRunbookService } from './agent-runbook.service'; import { AgentService } from './agent.service'; import { AgentInvocation } from './agent.types'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; @@ -118,12 +119,21 @@ describe('AgentService', () => { } satisfies AgentInvocation), ), } satisfies Pick; + const agentRunbook = { + buildEntries: jest.fn(() => [ + { + title: '测试 Runbook', + lines: ['只允许 DevOps 运维上下文进入模型。'], + }, + ]), + } satisfies Pick; return { audit, auth, deployRuns, agentInvocations, + agentRunbook, llm, projects, service: new AgentService( @@ -133,6 +143,7 @@ describe('AgentService', () => { deployRuns as unknown as DeployRunsService, projects as unknown as ProjectsService, agentInvocations as unknown as AgentInvocationRepository, + agentRunbook as unknown as AgentRunbookService, ), }; } @@ -188,6 +199,11 @@ describe('AgentService', () => { userPrompt: expect.stringContaining('## 项目上下文'), }), ); + expect(llm.complete).toHaveBeenCalledWith( + expect.objectContaining({ + userPrompt: expect.stringContaining('## 运维 Runbook 知识'), + }), + ); expect(audit.record).toHaveBeenCalledWith( expect.objectContaining({ after: expect.objectContaining({ diff --git a/src/agent/agent.service.ts b/src/agent/agent.service.ts index f3d7dab..8b8c310 100644 --- a/src/agent/agent.service.ts +++ b/src/agent/agent.service.ts @@ -12,6 +12,10 @@ import { DeployRunsService } from '../deploy-runs/deploy-runs.service'; import { ProjectSummary } from '../projects/project.types'; import { ProjectsService } from '../projects/projects.service'; import { AgentInvocationRepository } from './agent-invocation.repository'; +import { + AgentRunbookEntry, + AgentRunbookService, +} from './agent-runbook.service'; import { AgentInvocation, AgentPurpose } from './agent.types'; import { CreateAgentInvocationInput } from './create-agent-invocation.dto'; import { LlmClient } from './llm.client'; @@ -42,6 +46,7 @@ type AgentInvocationContext = { nextStart: number; text: string; }; + runbookEntries: AgentRunbookEntry[]; warnings: string[]; }; @@ -54,6 +59,7 @@ export class AgentService { private readonly deployRunsService: DeployRunsService, private readonly projectsService: ProjectsService, private readonly agentInvocationRepository: AgentInvocationRepository, + private readonly agentRunbookService: AgentRunbookService, ) {} async listInvocations( @@ -80,14 +86,9 @@ export class AgentService { operator: AuthenticatedUser, ): Promise { if (!allowedTypes.includes(input.type)) { - throw new AppError( - 'VALIDATION_FAILED', - '不支持的 Agent 调用类型', - 400, - { - type: input.type, - }, - ); + throw new AppError('VALIDATION_FAILED', '不支持的 Agent 调用类型', 400, { + type: input.type, + }); } await this.validateAgentScope(input, operator); @@ -125,7 +126,8 @@ export class AgentService { invocation = await this.agentInvocationRepository.create({ ...baseInvocation, status: 'success', - resultMarkdown: completion.text || 'Agent 返回了空结果,请检查模型配置。', + resultMarkdown: + completion.text || 'Agent 返回了空结果,请检查模型配置。', model: completion.model, tokenEstimate: this.estimateTokenCount( systemPrompt, @@ -163,7 +165,8 @@ export class AgentService { } } else { const errorSummary = - llmHealth.message ?? 'Agent 配置不完整,请先配置 key、baseURL 和 model。'; + llmHealth.message ?? + 'Agent 配置不完整,请先配置 key、baseURL 和 model。'; invocation = await this.agentInvocationRepository.create({ ...baseInvocation, @@ -306,19 +309,31 @@ export class AgentService { const context: AgentInvocationContext = { project, run, + runbookEntries: [], warnings, }; + context.runbookEntries = this.agentRunbookService.buildEntries({ + purpose: input.type, + project, + run, + }); if (!includeJenkinsLog || !run?.jenkinsBuildNumber) { if (run && !run.jenkinsBuildNumber) { - warnings.push('当前 Run 尚未关联 Jenkins build,无法提供完整构建日志。'); + warnings.push( + '当前 Run 尚未关联 Jenkins build,无法提供完整构建日志。', + ); } return context; } try { - const log = await this.deployRunsService.getJenkinsLog(run.id, '0', operator); + const log = await this.deployRunsService.getJenkinsLog( + run.id, + '0', + operator, + ); context.jenkinsLog = { buildNumber: log.buildNumber, hasMore: log.hasMore, @@ -341,7 +356,12 @@ export class AgentService { context.jenkinsLog ? `jenkinsBuild=#${context.jenkinsLog.buildNumber} logChars=${context.jenkinsLog.text.length}` : undefined, - context.warnings.length ? `warnings=${context.warnings.length}` : undefined, + context.runbookEntries.length + ? `runbookEntries=${context.runbookEntries.length}` + : undefined, + context.warnings.length + ? `warnings=${context.warnings.length}` + : undefined, ].filter(Boolean); return parts.length ? parts.join(';') : '未绑定项目上下文'; @@ -365,8 +385,13 @@ export class AgentService { '## BPMN 步骤', this.stepContextLines(context.run).join('\n') || '未提供步骤。', '', + '## 运维 Runbook 知识', + this.runbookContextLines(context.runbookEntries).join('\n') || + '未提供 Runbook。', + '', '## Jenkins 构建日志', - this.jenkinsLogContextLines(context).join('\n') || '未提供 Jenkins 日志。', + this.jenkinsLogContextLines(context).join('\n') || + '未提供 Jenkins 日志。', '', '## 上下文警告', context.warnings.join('\n') || '无。', @@ -374,6 +399,13 @@ export class AgentService { ); } + private runbookContextLines(entries: AgentRunbookEntry[]): string[] { + return entries.flatMap((entry) => [ + `### ${entry.title}`, + ...entry.lines.map((line) => `- ${line}`), + ]); + } + private projectContextLines(project?: ProjectSummary): string[] { if (!project) { return [];