Commit ec77ccb9ea7d36eb4c13eb9931fb6979180c0b14
1 parent
34584a52
feat(company): implement Phase 4 - SOP Pipeline execution engine
- PipelineService: define pipelines with ordered agent-assigned steps - Run lifecycle: start → markStepStarted → advanceStep → complete - Step output automatically flows as next step input - Pipeline CRUD + Run management REST API routes - E2E test: 106/106 passed (Phase 1 + 2 + 3 + 4)
Showing
4 changed files
with
485 additions
and
7 deletions
data/templates/_registry.json
| ... | ... | @@ -6,7 +6,7 @@ |
| 6 | 6 | "description": "公司战略规划、团队管理、OKR 制定、任务委派", |
| 7 | 7 | "benchmarkScore": 97, |
| 8 | 8 | "trainedAt": "2026-03-20", |
| 9 | - "usedCount": 3 | |
| 9 | + "usedCount": 4 | |
| 10 | 10 | }, |
| 11 | 11 | { |
| 12 | 12 | "id": "qa-judge", |
| ... | ... | @@ -15,6 +15,6 @@ |
| 15 | 15 | "description": "质量验收、OKR 进度审查、标准化仲裁", |
| 16 | 16 | "benchmarkScore": 95, |
| 17 | 17 | "trainedAt": "2026-03-25", |
| 18 | - "usedCount": 3 | |
| 18 | + "usedCount": 4 | |
| 19 | 19 | } |
| 20 | 20 | ] |
| 21 | 21 | \ No newline at end of file | ... | ... |
server/routes/company.js
| ... | ... | @@ -192,4 +192,89 @@ router.get('/:companyId/goals/:goalId/progress', (req, res) => { |
| 192 | 192 | res.json(progress); |
| 193 | 193 | }); |
| 194 | 194 | |
| 195 | +// ═══════════════════════════════════════ | |
| 196 | +// Pipeline / SOP 流水线 | |
| 197 | +// ═══════════════════════════════════════ | |
| 198 | + | |
| 199 | +const { PipelineService } = require('../services/pipeline-service'); | |
| 200 | + | |
| 201 | +// POST /api/company/:companyId/pipelines — 创建流水线 | |
| 202 | +router.post('/:companyId/pipelines', (req, res) => { | |
| 203 | + try { | |
| 204 | + const pSvc = new PipelineService(req.params.companyId); | |
| 205 | + const pipeline = pSvc.createPipeline(req.body); | |
| 206 | + res.status(201).json(pipeline); | |
| 207 | + } catch (err) { | |
| 208 | + res.status(400).json({ error: err.message }); | |
| 209 | + } | |
| 210 | +}); | |
| 211 | + | |
| 212 | +// GET /api/company/:companyId/pipelines — 列出流水线 | |
| 213 | +router.get('/:companyId/pipelines', (req, res) => { | |
| 214 | + const pSvc = new PipelineService(req.params.companyId); | |
| 215 | + res.json(pSvc.listPipelines()); | |
| 216 | +}); | |
| 217 | + | |
| 218 | +// GET /api/company/:companyId/pipelines/:pipelineId — 获取流水线定义 | |
| 219 | +router.get('/:companyId/pipelines/:pipelineId', (req, res) => { | |
| 220 | + const pSvc = new PipelineService(req.params.companyId); | |
| 221 | + const pipeline = pSvc.getPipeline(req.params.pipelineId); | |
| 222 | + if (!pipeline) return res.status(404).json({ error: '流水线不存在' }); | |
| 223 | + res.json(pipeline); | |
| 224 | +}); | |
| 225 | + | |
| 226 | +// PUT /api/company/:companyId/pipelines/:pipelineId — 更新流水线 | |
| 227 | +router.put('/:companyId/pipelines/:pipelineId', (req, res) => { | |
| 228 | + const pSvc = new PipelineService(req.params.companyId); | |
| 229 | + const pipeline = pSvc.updatePipeline(req.params.pipelineId, req.body); | |
| 230 | + if (!pipeline) return res.status(404).json({ error: '流水线不存在' }); | |
| 231 | + res.json(pipeline); | |
| 232 | +}); | |
| 233 | + | |
| 234 | +// POST /api/company/:companyId/pipelines/:pipelineId/run — 启动运行 | |
| 235 | +router.post('/:companyId/pipelines/:pipelineId/run', (req, res) => { | |
| 236 | + try { | |
| 237 | + const pSvc = new PipelineService(req.params.companyId); | |
| 238 | + const run = pSvc.startRun(req.params.pipelineId, req.body); | |
| 239 | + res.status(201).json(run); | |
| 240 | + } catch (err) { | |
| 241 | + res.status(400).json({ error: err.message }); | |
| 242 | + } | |
| 243 | +}); | |
| 244 | + | |
| 245 | +// GET /api/company/:companyId/pipelines/runs/list — 列出运行记录 | |
| 246 | +router.get('/:companyId/pipelines/runs/list', (req, res) => { | |
| 247 | + const pSvc = new PipelineService(req.params.companyId); | |
| 248 | + res.json(pSvc.listRuns(req.query.pipelineId)); | |
| 249 | +}); | |
| 250 | + | |
| 251 | +// GET /api/company/:companyId/pipelines/runs/:runId — 获取运行详情 | |
| 252 | +router.get('/:companyId/pipelines/runs/:runId', (req, res) => { | |
| 253 | + const pSvc = new PipelineService(req.params.companyId); | |
| 254 | + const run = pSvc.getRun(req.params.runId); | |
| 255 | + if (!run) return res.status(404).json({ error: '运行记录不存在' }); | |
| 256 | + res.json(run); | |
| 257 | +}); | |
| 258 | + | |
| 259 | +// POST /api/company/:companyId/pipelines/runs/:runId/advance — 推进步骤 | |
| 260 | +router.post('/:companyId/pipelines/runs/:runId/advance', (req, res) => { | |
| 261 | + try { | |
| 262 | + const pSvc = new PipelineService(req.params.companyId); | |
| 263 | + const { stepIndex, output, status } = req.body; | |
| 264 | + if (stepIndex === undefined) return res.status(400).json({ error: 'stepIndex 为必填项' }); | |
| 265 | + const run = pSvc.advanceStep(req.params.runId, Number(stepIndex), { output, status }); | |
| 266 | + res.json(run); | |
| 267 | + } catch (err) { | |
| 268 | + res.status(400).json({ error: err.message }); | |
| 269 | + } | |
| 270 | +}); | |
| 271 | + | |
| 272 | +// GET /api/company/:companyId/pipelines/runs/:runId/current — 获取当前任务 | |
| 273 | +router.get('/:companyId/pipelines/runs/:runId/current', (req, res) => { | |
| 274 | + const pSvc = new PipelineService(req.params.companyId); | |
| 275 | + const task = pSvc.getCurrentTask(req.params.runId); | |
| 276 | + if (!task) return res.json({ message: '无就绪任务' }); | |
| 277 | + res.json(task); | |
| 278 | +}); | |
| 279 | + | |
| 195 | 280 | module.exports = router; | ... | ... |
server/scripts/test-company-e2e.js
| ... | ... | @@ -19,6 +19,7 @@ const fs = require('fs'); |
| 19 | 19 | // 直接引入 service 层 (不需要 HTTP) |
| 20 | 20 | const { CompanyService } = require('../services/company-service'); |
| 21 | 21 | const { GoalsService } = require('../services/goals'); |
| 22 | +const { PipelineService } = require('../services/pipeline-service'); | |
| 22 | 23 | |
| 23 | 24 | const DATA_DIR = path.resolve(__dirname, '../../data'); |
| 24 | 25 | const TEST_COMPANY_ID = 'test-media-co'; |
| ... | ... | @@ -292,8 +293,86 @@ async function main() { |
| 292 | 293 | const hasCompanyGoal = globalList.some(g => g.id === 'G-001' && g.title === '30天粉丝破万'); |
| 293 | 294 | assert(!hasCompanyGoal, '全局 GoalsService 看不到公司级 OKR (隔离)'); |
| 294 | 295 | |
| 295 | - // ─── 14. 更新公司 ─── | |
| 296 | - console.log('\n✏️ Phase 14: 更新公司'); | |
| 296 | + // ─── 14. Phase 4: Pipeline SOP 流水线 ─── | |
| 297 | + console.log('\n🛠️ Phase 14: Pipeline SOP 流水线'); | |
| 298 | + | |
| 299 | + const pSvc = new PipelineService(TEST_COMPANY_ID); | |
| 300 | + | |
| 301 | + // 14a: 创建流水线 | |
| 302 | + const pipeline = pSvc.createPipeline({ | |
| 303 | + id: 'daily-content', | |
| 304 | + name: '每日内容产出', | |
| 305 | + description: '选题 → 写稿 → 审核 → 发布', | |
| 306 | + steps: [ | |
| 307 | + { name: '热点搜索与选题', agent: 'hot-topic-hunter', prompt: '搜索今日热点并提供3个选题建议' }, | |
| 308 | + { name: '内容写作', agent: 'content-writer', prompt: '根据选题写一篇3000字文章' }, | |
| 309 | + { name: '质量审核', agent: 'qa-judge', prompt: '审核文章质量,给出修改意见' }, | |
| 310 | + { name: '发布推送', agent: 'publisher', prompt: '排版并发布到公众号' }, | |
| 311 | + ], | |
| 312 | + }); | |
| 313 | + assert(pipeline.id === 'daily-content', '流水线 ID 正确'); | |
| 314 | + assert(pipeline.steps.length === 4, `步骤数 = 4 (实际: ${pipeline.steps.length})`); | |
| 315 | + assert(pipeline.steps[0].agent === 'hot-topic-hunter', '第1步 agent 正确'); | |
| 316 | + assert(pipeline.steps[1].dependsOn === 0, '第2步依赖第1步'); | |
| 317 | + | |
| 318 | + // 14b: 列出流水线 | |
| 319 | + const pipelines = pSvc.listPipelines(); | |
| 320 | + assert(pipelines.length === 1, `流水线数量 = 1 (实际: ${pipelines.length})`); | |
| 321 | + | |
| 322 | + // 14c: 获取流水线定义 | |
| 323 | + const fetPipeline = pSvc.getPipeline('daily-content'); | |
| 324 | + assert(fetPipeline !== null, '获取流水线定义成功'); | |
| 325 | + assert(fetPipeline.name === '每日内容产出', '流水线名称正确'); | |
| 326 | + | |
| 327 | + // 14d: 启动运行 | |
| 328 | + const run = pSvc.startRun('daily-content', { | |
| 329 | + trigger: 'heartbeat', | |
| 330 | + input: { date: '2026-03-25' }, | |
| 331 | + }); | |
| 332 | + assert(run.status === 'running', 'Run 状态为 running'); | |
| 333 | + assert(run.steps.length === 4, `Run 步骤数 = 4`); | |
| 334 | + assert(run.steps[0].status === 'ready', '第1步状态为 ready'); | |
| 335 | + assert(run.steps[1].status === 'waiting', '第2步状态为 waiting'); | |
| 336 | + assert(run.currentStep === 0, '当前步骤 = 0'); | |
| 337 | + | |
| 338 | + // 14e: 获取当前任务 | |
| 339 | + const curTask = pSvc.getCurrentTask(run.runId); | |
| 340 | + assert(curTask !== null, '当前任务不为空'); | |
| 341 | + assert(curTask.agent === 'hot-topic-hunter', '当前任务分配给 hot-topic-hunter'); | |
| 342 | + assert(curTask.stepIndex === 0, '当前任务是第0步'); | |
| 343 | + | |
| 344 | + // 14f: 标记步骤开始 | |
| 345 | + pSvc.markStepStarted(run.runId, 0); | |
| 346 | + const runAfterStart = pSvc.getRun(run.runId); | |
| 347 | + assert(runAfterStart.steps[0].status === 'in_progress', '第1步状态变为 in_progress'); | |
| 348 | + | |
| 349 | + // 14g: 推进步骤 0 → 1 | |
| 350 | + const run1 = pSvc.advanceStep(run.runId, 0, { | |
| 351 | + output: { topics: ['AI提效工具', '副业经济', '新能源资讯'] }, | |
| 352 | + }); | |
| 353 | + assert(run1.steps[0].status === 'done', '第1步完成'); | |
| 354 | + assert(run1.steps[1].status === 'ready', '第2步变为 ready'); | |
| 355 | + assert(run1.currentStep === 1, '当前步骤推进到 1'); | |
| 356 | + assert(run1.steps[1].input !== null, '第2步的 input 为第1步的 output'); | |
| 357 | + | |
| 358 | + // 14h: 推进到最后一步 | |
| 359 | + pSvc.advanceStep(run.runId, 1, { output: { draft: '文章初稿...' } }); | |
| 360 | + pSvc.advanceStep(run.runId, 2, { output: { approved: true, score: 92 } }); | |
| 361 | + const runFinal = pSvc.advanceStep(run.runId, 3, { output: { publishUrl: 'https://mp.weixin.qq.com/s/xxx' } }); | |
| 362 | + assert(runFinal.status === 'completed', 'Run 全部完成'); | |
| 363 | + assert(runFinal.output !== null, 'Run 携带最终 output'); | |
| 364 | + | |
| 365 | + // 14i: 列出运行记录 | |
| 366 | + const runs = pSvc.listRuns(); | |
| 367 | + assert(runs.length === 1, `运行记录数 = 1 (实际: ${runs.length})`); | |
| 368 | + assert(runs[0].status === 'completed', '运行状态为 completed'); | |
| 369 | + | |
| 370 | + // 14j: 更新流水线 | |
| 371 | + const updPipeline = pSvc.updatePipeline('daily-content', { name: '每日内容产出 v2' }); | |
| 372 | + assert(updPipeline.name === '每日内容产出 v2', '流水线名称更新成功'); | |
| 373 | + | |
| 374 | + // ─── 15. 更新公司 ─── | |
| 375 | + console.log('\n✏️ Phase 15: 更新公司'); | |
| 297 | 376 | const updated = svc.update(TEST_COMPANY_ID, { |
| 298 | 377 | status: 'running', |
| 299 | 378 | heartbeat: { enabled: true }, |
| ... | ... | @@ -301,8 +380,8 @@ async function main() { |
| 301 | 380 | assert(updated.status === 'running', '状态更新为 running'); |
| 302 | 381 | assert(updated.heartbeat.enabled === true, '心跳已开启'); |
| 303 | 382 | |
| 304 | - // ─── 15. 关停公司 ─── | |
| 305 | - console.log('\n🛑 Phase 15: 关停公司'); | |
| 383 | + // ─── 16. 关停公司 ─── | |
| 384 | + console.log('\n🛑 Phase 16: 关停公司'); | |
| 306 | 385 | const shutdown = svc.shutdown(TEST_COMPANY_ID); |
| 307 | 386 | assert(shutdown.status === 'shutdown', '状态变为 shutdown'); |
| 308 | 387 | assert(shutdown.heartbeat.enabled === false, '心跳已关闭'); |
| ... | ... | @@ -324,7 +403,7 @@ async function main() { |
| 324 | 403 | console.log(`\n${'═'.repeat(50)}`); |
| 325 | 404 | console.log(` 📊 测试结果: ${passed} 通过, ${failed} 失败 (共 ${passed + failed})`); |
| 326 | 405 | if (failed === 0) { |
| 327 | - console.log(' 🎉 ALL PASSED — Phase 1 + Phase 2 + Phase 3 生产就绪!\n'); | |
| 406 | + console.log(' 🎉 ALL PASSED — Phase 1 + 2 + 3 + 4 生产就绪!\n'); | |
| 328 | 407 | } else { |
| 329 | 408 | console.log(` ⚠️ ${failed} 个测试未通过\n`); |
| 330 | 409 | process.exit(1); | ... | ... |
server/services/pipeline-service.js
0 → 100644
| 1 | +/** | |
| 2 | + * PipelineService — SOP 流水线管理与执行 | |
| 3 | + * | |
| 4 | + * 数据目录: data/{companyId}/pipelines/ | |
| 5 | + * 流水线定义: data/{companyId}/pipelines/{pipelineId}.json | |
| 6 | + * 运行记录: data/{companyId}/pipelines/runs/{runId}.json | |
| 7 | + * | |
| 8 | + * 核心概念: | |
| 9 | + * Pipeline = 一条 SOP 流水线定义 (选题 → 写稿 → 审核 → 发布) | |
| 10 | + * Step = Pipeline 中的一个步骤 (分配给一个 Agent) | |
| 11 | + * Run = Pipeline 的一次实际执行 (含每步的输入/输出/状态) | |
| 12 | + */ | |
| 13 | + | |
| 14 | +const fs = require('fs'); | |
| 15 | +const path = require('path'); | |
| 16 | + | |
| 17 | +const DATA_DIR = path.resolve(__dirname, '../../data'); | |
| 18 | + | |
| 19 | +class PipelineService { | |
| 20 | + | |
| 21 | + constructor(companyId) { | |
| 22 | + if (!companyId) throw new Error('PipelineService 必须指定 companyId'); | |
| 23 | + this.companyId = companyId; | |
| 24 | + this.pipelinesDir = path.join(DATA_DIR, companyId, 'pipelines'); | |
| 25 | + this.runsDir = path.join(this.pipelinesDir, 'runs'); | |
| 26 | + | |
| 27 | + if (!fs.existsSync(this.pipelinesDir)) fs.mkdirSync(this.pipelinesDir, { recursive: true }); | |
| 28 | + if (!fs.existsSync(this.runsDir)) fs.mkdirSync(this.runsDir, { recursive: true }); | |
| 29 | + } | |
| 30 | + | |
| 31 | + // ═══════════════════════════════════════ | |
| 32 | + // Pipeline 定义 CRUD | |
| 33 | + // ═══════════════════════════════════════ | |
| 34 | + | |
| 35 | + /** | |
| 36 | + * 创建流水线定义 | |
| 37 | + * @param {object} opts - { id, name, description, steps: [ { name, agent, prompt, ... } ] } | |
| 38 | + */ | |
| 39 | + createPipeline({ id, name, description = '', steps = [] }) { | |
| 40 | + if (!id || !name) throw new Error('id 和 name 为必填项'); | |
| 41 | + | |
| 42 | + const fp = path.join(this.pipelinesDir, `${id}.json`); | |
| 43 | + if (fs.existsSync(fp)) throw new Error(`流水线 ${id} 已存在`); | |
| 44 | + | |
| 45 | + // 给每个 step 分配序号 | |
| 46 | + const numberedSteps = steps.map((step, idx) => ({ | |
| 47 | + index: idx, | |
| 48 | + name: step.name || `步骤${idx + 1}`, | |
| 49 | + agent: step.agent, // 负责的 Agent ID | |
| 50 | + prompt: step.prompt || '', // 发送给 Agent 的指令模板 | |
| 51 | + dependsOn: step.dependsOn ?? (idx > 0 ? idx - 1 : null), // 前置步骤 | |
| 52 | + timeout: step.timeout || 30, // 超时(分钟) | |
| 53 | + required: step.required !== false, // 是否必须 | |
| 54 | + })); | |
| 55 | + | |
| 56 | + const now = new Date().toISOString(); | |
| 57 | + const pipeline = { | |
| 58 | + id, | |
| 59 | + name, | |
| 60 | + description, | |
| 61 | + companyId: this.companyId, | |
| 62 | + steps: numberedSteps, | |
| 63 | + status: 'active', | |
| 64 | + createdAt: now, | |
| 65 | + updatedAt: now, | |
| 66 | + }; | |
| 67 | + | |
| 68 | + fs.writeFileSync(fp, JSON.stringify(pipeline, null, 2), 'utf-8'); | |
| 69 | + console.log(`[Pipeline] 📋 创建流水线: ${id} (${name}, ${steps.length} 步)`); | |
| 70 | + return pipeline; | |
| 71 | + } | |
| 72 | + | |
| 73 | + /** | |
| 74 | + * 获取流水线定义 | |
| 75 | + */ | |
| 76 | + getPipeline(pipelineId) { | |
| 77 | + const fp = path.join(this.pipelinesDir, `${pipelineId}.json`); | |
| 78 | + if (!fs.existsSync(fp)) return null; | |
| 79 | + return JSON.parse(fs.readFileSync(fp, 'utf-8')); | |
| 80 | + } | |
| 81 | + | |
| 82 | + /** | |
| 83 | + * 列出所有流水线 | |
| 84 | + */ | |
| 85 | + listPipelines() { | |
| 86 | + return fs.readdirSync(this.pipelinesDir) | |
| 87 | + .filter(f => f.endsWith('.json') && f !== 'runs') | |
| 88 | + .map(f => { | |
| 89 | + try { | |
| 90 | + const data = JSON.parse(fs.readFileSync(path.join(this.pipelinesDir, f), 'utf-8')); | |
| 91 | + return { | |
| 92 | + id: data.id, | |
| 93 | + name: data.name, | |
| 94 | + stepCount: data.steps?.length || 0, | |
| 95 | + status: data.status, | |
| 96 | + }; | |
| 97 | + } catch { return null; } | |
| 98 | + }) | |
| 99 | + .filter(Boolean); | |
| 100 | + } | |
| 101 | + | |
| 102 | + /** | |
| 103 | + * 更新流水线定义 | |
| 104 | + */ | |
| 105 | + updatePipeline(pipelineId, updates) { | |
| 106 | + const pipeline = this.getPipeline(pipelineId); | |
| 107 | + if (!pipeline) return null; | |
| 108 | + | |
| 109 | + if (updates.name !== undefined) pipeline.name = updates.name; | |
| 110 | + if (updates.description !== undefined) pipeline.description = updates.description; | |
| 111 | + if (updates.status !== undefined) pipeline.status = updates.status; | |
| 112 | + if (updates.steps !== undefined) { | |
| 113 | + pipeline.steps = updates.steps.map((step, idx) => ({ | |
| 114 | + index: idx, | |
| 115 | + name: step.name || `步骤${idx + 1}`, | |
| 116 | + agent: step.agent, | |
| 117 | + prompt: step.prompt || '', | |
| 118 | + dependsOn: step.dependsOn ?? (idx > 0 ? idx - 1 : null), | |
| 119 | + timeout: step.timeout || 30, | |
| 120 | + required: step.required !== false, | |
| 121 | + })); | |
| 122 | + } | |
| 123 | + pipeline.updatedAt = new Date().toISOString(); | |
| 124 | + | |
| 125 | + fs.writeFileSync( | |
| 126 | + path.join(this.pipelinesDir, `${pipelineId}.json`), | |
| 127 | + JSON.stringify(pipeline, null, 2), 'utf-8' | |
| 128 | + ); | |
| 129 | + return pipeline; | |
| 130 | + } | |
| 131 | + | |
| 132 | + // ═══════════════════════════════════════ | |
| 133 | + // Pipeline 运行 (Run) | |
| 134 | + // ═══════════════════════════════════════ | |
| 135 | + | |
| 136 | + /** | |
| 137 | + * 启动一次流水线运行 | |
| 138 | + * @param {string} pipelineId | |
| 139 | + * @param {object} opts - { trigger, input } | |
| 140 | + * @returns {object} run | |
| 141 | + */ | |
| 142 | + startRun(pipelineId, { trigger = 'manual', input = {} } = {}) { | |
| 143 | + const pipeline = this.getPipeline(pipelineId); | |
| 144 | + if (!pipeline) throw new Error(`流水线 ${pipelineId} 不存在`); | |
| 145 | + if (pipeline.status !== 'active') throw new Error(`流水线 ${pipelineId} 未激活`); | |
| 146 | + | |
| 147 | + const runId = `R-${Date.now().toString(36)}`; | |
| 148 | + const now = new Date().toISOString(); | |
| 149 | + | |
| 150 | + const stepStates = pipeline.steps.map(step => ({ | |
| 151 | + index: step.index, | |
| 152 | + name: step.name, | |
| 153 | + agent: step.agent, | |
| 154 | + status: step.index === 0 ? 'ready' : 'waiting', // 第一步 ready,其余 waiting | |
| 155 | + input: step.index === 0 ? input : null, | |
| 156 | + output: null, | |
| 157 | + startedAt: null, | |
| 158 | + completedAt: null, | |
| 159 | + })); | |
| 160 | + | |
| 161 | + const run = { | |
| 162 | + runId, | |
| 163 | + pipelineId, | |
| 164 | + pipelineName: pipeline.name, | |
| 165 | + companyId: this.companyId, | |
| 166 | + trigger, | |
| 167 | + status: 'running', // running → completed / failed / cancelled | |
| 168 | + currentStep: 0, | |
| 169 | + steps: stepStates, | |
| 170 | + input, | |
| 171 | + output: null, | |
| 172 | + createdAt: now, | |
| 173 | + updatedAt: now, | |
| 174 | + }; | |
| 175 | + | |
| 176 | + this._saveRun(run); | |
| 177 | + console.log(`[Pipeline] 🚀 启动运行: ${runId} (${pipeline.name})`); | |
| 178 | + return run; | |
| 179 | + } | |
| 180 | + | |
| 181 | + /** | |
| 182 | + * 推进步骤 — 标记当前步骤完成,激活下一步 | |
| 183 | + * @param {string} runId | |
| 184 | + * @param {number} stepIndex | |
| 185 | + * @param {object} opts - { output, status? } | |
| 186 | + */ | |
| 187 | + advanceStep(runId, stepIndex, { output = null, status = 'done' } = {}) { | |
| 188 | + const run = this.getRun(runId); | |
| 189 | + if (!run) throw new Error(`Run ${runId} 不存在`); | |
| 190 | + | |
| 191 | + const step = run.steps[stepIndex]; | |
| 192 | + if (!step) throw new Error(`Step ${stepIndex} 不存在`); | |
| 193 | + | |
| 194 | + const now = new Date().toISOString(); | |
| 195 | + | |
| 196 | + // 更新当前步骤 | |
| 197 | + step.status = status; // done / skipped / failed | |
| 198 | + step.output = output; | |
| 199 | + step.completedAt = now; | |
| 200 | + | |
| 201 | + if (status === 'failed') { | |
| 202 | + run.status = 'failed'; | |
| 203 | + run.updatedAt = now; | |
| 204 | + this._saveRun(run); | |
| 205 | + console.log(`[Pipeline] ❌ 运行 ${runId} 步骤 ${stepIndex} 失败`); | |
| 206 | + return run; | |
| 207 | + } | |
| 208 | + | |
| 209 | + // 查找下一个步骤 | |
| 210 | + const nextIndex = stepIndex + 1; | |
| 211 | + if (nextIndex < run.steps.length) { | |
| 212 | + // 激活下一步 | |
| 213 | + run.steps[nextIndex].status = 'ready'; | |
| 214 | + run.steps[nextIndex].input = output; // 前一步的输出是下一步的输入 | |
| 215 | + run.currentStep = nextIndex; | |
| 216 | + console.log(`[Pipeline] ➡️ 运行 ${runId} 进入步骤 ${nextIndex}: ${run.steps[nextIndex].name}`); | |
| 217 | + } else { | |
| 218 | + // 所有步骤完成 | |
| 219 | + run.status = 'completed'; | |
| 220 | + run.output = output; | |
| 221 | + console.log(`[Pipeline] ✅ 运行 ${runId} 全部完成`); | |
| 222 | + } | |
| 223 | + | |
| 224 | + run.updatedAt = now; | |
| 225 | + this._saveRun(run); | |
| 226 | + return run; | |
| 227 | + } | |
| 228 | + | |
| 229 | + /** | |
| 230 | + * 标记步骤开始执行 | |
| 231 | + */ | |
| 232 | + markStepStarted(runId, stepIndex) { | |
| 233 | + const run = this.getRun(runId); | |
| 234 | + if (!run) return null; | |
| 235 | + | |
| 236 | + if (run.steps[stepIndex]) { | |
| 237 | + run.steps[stepIndex].status = 'in_progress'; | |
| 238 | + run.steps[stepIndex].startedAt = new Date().toISOString(); | |
| 239 | + run.updatedAt = new Date().toISOString(); | |
| 240 | + this._saveRun(run); | |
| 241 | + } | |
| 242 | + return run; | |
| 243 | + } | |
| 244 | + | |
| 245 | + /** | |
| 246 | + * 获取运行状态 | |
| 247 | + */ | |
| 248 | + getRun(runId) { | |
| 249 | + const fp = path.join(this.runsDir, `${runId}.json`); | |
| 250 | + if (!fs.existsSync(fp)) return null; | |
| 251 | + return JSON.parse(fs.readFileSync(fp, 'utf-8')); | |
| 252 | + } | |
| 253 | + | |
| 254 | + /** | |
| 255 | + * 列出所有运行记录 | |
| 256 | + */ | |
| 257 | + listRuns(pipelineId = null) { | |
| 258 | + if (!fs.existsSync(this.runsDir)) return []; | |
| 259 | + | |
| 260 | + return fs.readdirSync(this.runsDir) | |
| 261 | + .filter(f => f.endsWith('.json')) | |
| 262 | + .map(f => { | |
| 263 | + try { | |
| 264 | + const data = JSON.parse(fs.readFileSync(path.join(this.runsDir, f), 'utf-8')); | |
| 265 | + if (pipelineId && data.pipelineId !== pipelineId) return null; | |
| 266 | + return { | |
| 267 | + runId: data.runId, | |
| 268 | + pipelineId: data.pipelineId, | |
| 269 | + pipelineName: data.pipelineName, | |
| 270 | + status: data.status, | |
| 271 | + currentStep: data.currentStep, | |
| 272 | + totalSteps: data.steps.length, | |
| 273 | + trigger: data.trigger, | |
| 274 | + createdAt: data.createdAt, | |
| 275 | + }; | |
| 276 | + } catch { return null; } | |
| 277 | + }) | |
| 278 | + .filter(Boolean) | |
| 279 | + .sort((a, b) => b.createdAt.localeCompare(a.createdAt)); | |
| 280 | + } | |
| 281 | + | |
| 282 | + /** | |
| 283 | + * 获取当前待执行的步骤 (ready 状态) | |
| 284 | + */ | |
| 285 | + getCurrentTask(runId) { | |
| 286 | + const run = this.getRun(runId); | |
| 287 | + if (!run || run.status !== 'running') return null; | |
| 288 | + | |
| 289 | + const readyStep = run.steps.find(s => s.status === 'ready'); | |
| 290 | + if (!readyStep) return null; | |
| 291 | + | |
| 292 | + return { | |
| 293 | + runId, | |
| 294 | + pipelineId: run.pipelineId, | |
| 295 | + stepIndex: readyStep.index, | |
| 296 | + stepName: readyStep.name, | |
| 297 | + agent: readyStep.agent, | |
| 298 | + input: readyStep.input, | |
| 299 | + }; | |
| 300 | + } | |
| 301 | + | |
| 302 | + // ═══════════════════════════════════════ | |
| 303 | + // 私有方法 | |
| 304 | + // ═══════════════════════════════════════ | |
| 305 | + | |
| 306 | + _saveRun(run) { | |
| 307 | + fs.writeFileSync( | |
| 308 | + path.join(this.runsDir, `${run.runId}.json`), | |
| 309 | + JSON.stringify(run, null, 2), 'utf-8' | |
| 310 | + ); | |
| 311 | + } | |
| 312 | +} | |
| 313 | + | |
| 314 | +module.exports = { PipelineService }; | ... | ... |