Commit ec77ccb9ea7d36eb4c13eb9931fb6979180c0b14

Authored by yibo
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)
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 };
... ...