workflow-ai 1.0.9 → 1.0.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/runner.mjs CHANGED
@@ -1,1492 +1,1492 @@
1
- #!/usr/bin/env node
2
-
3
- import fs from 'fs';
4
- import path from 'path';
5
- import { spawn, execSync } from 'child_process';
6
- import crypto from 'crypto';
7
- import yaml from 'js-yaml';
8
- import { findProjectRoot } from './lib/find-root.mjs';
9
-
10
- // ============================================================================
11
- // Logger — система логирования с уровнями INFO/WARN/ERROR
12
- // ============================================================================
13
- class Logger {
14
- static LEVELS = {
15
- INFO: 0,
16
- WARN: 1,
17
- ERROR: 2
18
- };
19
-
20
- static COLORS = {
21
- INFO: '\x1b[36m', // cyan
22
- WARN: '\x1b[33m', // yellow
23
- ERROR: '\x1b[31m', // red
24
- RESET: '\x1b[0m'
25
- };
26
-
27
- constructor(logFilePath, consoleLevel = Logger.LEVELS.INFO) {
28
- this.logFilePath = logFilePath;
29
- this.consoleLevel = consoleLevel;
30
- this.stats = {
31
- info: 0,
32
- warn: 0,
33
- error: 0,
34
- stagesStarted: 0,
35
- stagesCompleted: 0,
36
- stagesFailed: 0,
37
- cliCalls: 0,
38
- gotoTransitions: 0,
39
- retries: 0,
40
- startTime: null,
41
- endTime: null
42
- };
43
- }
44
-
45
- /**
46
- * Создаёт директорию для логов если она не существует
47
- */
48
- _ensureLogDirectory() {
49
- const logDir = path.dirname(this.logFilePath);
50
- if (!fs.existsSync(logDir)) {
51
- fs.mkdirSync(logDir, { recursive: true });
52
- console.log(`[Logger] Created log directory: ${logDir}`);
53
- }
54
- }
55
-
56
- /**
57
- * Создаёт директорию для логов если она не существует
58
- */
59
- _ensureLogDirectory() {
60
- const logDir = path.dirname(this.logFilePath);
61
- if (!fs.existsSync(logDir)) {
62
- fs.mkdirSync(logDir, { recursive: true });
63
- console.log(`[Logger] Created log directory: ${logDir}`);
64
- }
65
- }
66
-
67
- /**
68
- * Открывает файл для записи (append mode)
69
- */
70
- _openFile() {
71
- // Создаём директорию и файл если не существуют
72
- this._ensureLogDirectory();
73
- if (!fs.existsSync(this.logFilePath)) {
74
- fs.writeFileSync(this.logFilePath, '');
75
- }
76
- this.stats.startTime = new Date();
77
- }
78
-
79
- /**
80
- * Инициализирует logger
81
- */
82
- async init() {
83
- this._openFile();
84
- }
85
-
86
- /**
87
- * Форматирует timestamp для логов
88
- */
89
- _formatTimestamp() {
90
- const now = new Date();
91
- return now.toISOString().replace('T', ' ').substring(0, 19);
92
- }
93
-
94
- /**
95
- * Форматирует сообщение для вывода
96
- */
97
- _formatMessage(level, stage, message) {
98
- const timestamp = this._formatTimestamp();
99
- const stageTag = stage ? `[${stage}]` : '[Runner]';
100
- const prefix = `[${timestamp}] [${level}] ${stageTag} `;
101
- const lines = message.split('\n');
102
- if (lines.length === 1) {
103
- return `${prefix}${message}`;
104
- }
105
- const indent = ' '.repeat(prefix.length);
106
- return lines.map((line, i) => i === 0 ? `${prefix}${line}` : `${indent}${line}`).join('\n');
107
- }
108
-
109
- /**
110
- * Записывает лог в файл (синхронно)
111
- */
112
- _writeToFile(formattedMessage) {
113
- fs.appendFileSync(this.logFilePath, formattedMessage + '\n', 'utf8');
114
- }
115
-
116
- /**
117
- * Выводит в консоль с цветом
118
- */
119
- _writeToConsole(formattedMessage, level) {
120
- if (Logger.LEVELS[level] < this.consoleLevel) {
121
- return;
122
- }
123
-
124
- const color = Logger.COLORS[level];
125
- const reset = Logger.COLORS.RESET;
126
-
127
- if (level === 'ERROR') {
128
- console.error(`${color}${formattedMessage}${reset}`);
129
- } else if (level === 'WARN') {
130
- console.warn(`${color}${formattedMessage}${reset}`);
131
- } else {
132
- console.log(`${color}${formattedMessage}${reset}`);
133
- }
134
- }
135
-
136
- /**
137
- * Базовый метод логирования
138
- */
139
- _log(level, stage, message) {
140
- const formattedMessage = this._formatMessage(level, stage, message);
141
- this._writeToFile(formattedMessage);
142
- this._writeToConsole(formattedMessage, level);
143
-
144
- // Обновляем статистику
145
- if (level === 'INFO') this.stats.info++;
146
- else if (level === 'WARN') this.stats.warn++;
147
- else if (level === 'ERROR') this.stats.error++;
148
- }
149
-
150
- /**
151
- * Логгирует INFO сообщение
152
- */
153
- info(message, stage) {
154
- this._log('INFO', stage, message);
155
- }
156
-
157
- /**
158
- * Логгирует WARN сообщение
159
- */
160
- warn(message, stage) {
161
- this._log('WARN', stage, message);
162
- }
163
-
164
- /**
165
- * Логгирует ERROR сообщение
166
- */
167
- error(message, stage) {
168
- this._log('ERROR', stage, message);
169
- }
170
-
171
- /**
172
- * Логгирует старт stage
173
- */
174
- stageStart(stageId, agentId, skillId) {
175
- this.stats.stagesStarted++;
176
- this.info(`START stage="${stageId}" agent="${agentId}" skill="${skillId}"`, stageId);
177
- }
178
-
179
- /**
180
- * Логгирует завершение stage
181
- */
182
- stageComplete(stageId, status, exitCode) {
183
- this.stats.stagesCompleted++;
184
- this.info(`COMPLETE stage="${stageId}" status="${status}" exitCode=${exitCode}`, stageId);
185
- }
186
-
187
- /**
188
- * Логгирует ошибку stage
189
- */
190
- stageError(stageId, errorMessage) {
191
- this.stats.stagesFailed++;
192
- this.error(`ERROR stage="${stageId}" message="${errorMessage}"`, stageId);
193
- }
194
-
195
- /**
196
- * Логгирует goto переход
197
- */
198
- gotoTransition(fromStage, toStage, status, params = {}) {
199
- this.stats.gotoTransitions++;
200
- const paramsStr = Object.keys(params).length > 0 ? ` params=${JSON.stringify(params)}` : '';
201
- this.info(`GOTO ${fromStage} → ${toStage} status="${status}"${paramsStr}`, fromStage);
202
- }
203
-
204
- /**
205
- * Логгирует вызов CLI
206
- */
207
- cliCall(command, args, exitCode) {
208
- this.stats.cliCalls++;
209
- this.info(`CLI command="${command}" args="${args.join(' ')}" exitCode=${exitCode}`, 'CLI');
210
- }
211
-
212
- /**
213
- * Логгирует retry попытку
214
- */
215
- retry(stageId, attempt, maxAttempts) {
216
- this.stats.retries++;
217
- this.warn(`RETRY stage="${stageId}" attempt=${attempt}/${maxAttempts}`, stageId);
218
- }
219
-
220
- /**
221
- * Логгирует таймаут
222
- */
223
- timeout(stageId, timeoutSeconds) {
224
- this.error(`TIMEOUT stage="${stageId}" after ${timeoutSeconds}s`, stageId);
225
- }
226
-
227
- /**
228
- * Записывает итоговый summary
229
- */
230
- writeSummary() {
231
- this.stats.endTime = new Date();
232
- const duration = this.stats.endTime - this.stats.startTime;
233
-
234
- const summary = [
235
- '',
236
- '═══════════════════════════════════════════════════════════',
237
- ' PIPELINE SUMMARY',
238
- '═══════════════════════════════════════════════════════════',
239
- '',
240
- `Duration: ${(duration / 1000).toFixed(2)}s`,
241
- '',
242
- '┌─────────────────────────────────────────────────────────┐',
243
- '│ LOG STATISTICS │',
244
- '├─────────────────────────────────────────────────────────┤',
245
- `│ INFO messages: ${String(this.stats.info).padEnd(34)}│`,
246
- `│ WARN messages: ${String(this.stats.warn).padEnd(34)}│`,
247
- `│ ERROR messages: ${String(this.stats.error).padEnd(34)}│`,
248
- '├─────────────────────────────────────────────────────────┤',
249
- '│ STAGE STATISTICS │',
250
- '├─────────────────────────────────────────────────────────┤',
251
- `│ Stages started: ${String(this.stats.stagesStarted).padEnd(34)}│`,
252
- `│ Stages completed: ${String(this.stats.stagesCompleted).padEnd(34)}│`,
253
- `│ Stages failed: ${String(this.stats.stagesFailed).padEnd(34)}│`,
254
- '├─────────────────────────────────────────────────────────┤',
255
- '│ ACTIVITY STATISTICS │',
256
- '├─────────────────────────────────────────────────────────┤',
257
- `│ CLI calls: ${String(this.stats.cliCalls).padEnd(34)}│`,
258
- `│ GOTO transitions: ${String(this.stats.gotoTransitions).padEnd(34)}│`,
259
- `│ Retries: ${String(this.stats.retries).padEnd(34)}│`,
260
- '└─────────────────────────────────────────────────────────┘',
261
- '',
262
- '═══════════════════════════════════════════════════════════'
263
- ].join('\n');
264
-
265
- // Вывод summary в консоль (всегда, независимо от уровня)
266
- console.log(summary);
267
-
268
- // Запись summary в файл
269
- this._writeToFile(summary);
270
- }
271
-
272
- }
273
-
274
- // ============================================================================
275
- // PromptBuilder — формирует промпты для CLI-агентов с подстановкой контекста
276
- // ============================================================================
277
- class PromptBuilder {
278
- constructor(context, counters, previousResults = {}) {
279
- this.context = context;
280
- this.counters = counters;
281
- this.previousResults = previousResults;
282
- }
283
-
284
- /**
285
- * Формирует промпт для агента на основе skill инструкции
286
- * @param {object} stage - Stage из конфигурации
287
- * @param {string} stageId - ID stage
288
- * @returns {string} Промпт для агента
289
- */
290
- build(stage, stageId) {
291
- const parts = [stage.skill || stageId];
292
-
293
- // Добавляем контекст если есть непустые значения
294
- const contextEntries = Object.entries(this.context)
295
- .filter(([_, v]) => v !== undefined && v !== null && v !== '');
296
- if (contextEntries.length > 0) {
297
- parts.push('\n\nContext:');
298
- for (const [key, value] of contextEntries) {
299
- parts.push(` ${key}: ${value}`);
300
- }
301
- }
302
-
303
- // Добавляем счётчики если есть
304
- const counterEntries = Object.entries(this.counters)
305
- .filter(([_, v]) => v > 0);
306
- if (counterEntries.length > 0) {
307
- parts.push('\nCounters:');
308
- for (const [key, value] of counterEntries) {
309
- parts.push(` ${key}: ${value}`);
310
- }
311
- }
312
-
313
- return parts.join('\n');
314
- }
315
-
316
- /**
317
- * Форматирует контекст для вывода
318
- */
319
- formatContext() {
320
- const entries = Object.entries(this.context)
321
- .filter(([_, v]) => v !== undefined && v !== null && v !== '')
322
- .map(([k, v]) => ` ${k}: ${v}`);
323
- return entries.length > 0 ? entries.join('\n') : ' (пусто)';
324
- }
325
-
326
- /**
327
- * Форматирует счётчики для вывода
328
- */
329
- formatCounters() {
330
- const entries = Object.entries(this.counters)
331
- .map(([k, v]) => ` ${k}: ${v}`);
332
- return entries.length > 0 ? entries.join('\n') : ' (пусто)';
333
- }
334
-
335
- /**
336
- * Форматирует результаты предыдущих stages
337
- */
338
- formatPreviousResults() {
339
- const entries = Object.entries(this.previousResults)
340
- .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`);
341
- return entries.length > 0 ? entries.join('\n') : ' (нет результатов)';
342
- }
343
-
344
- /**
345
- * Интерполирует переменные в строке
346
- * Поддерживает: $result.field, $context.field, $counter.field
347
- * @param {string} template - Строка с переменными
348
- * @param {object} resultData - Данные результата для $result.*
349
- * @returns {string} Строка с подставленными значениями
350
- */
351
- interpolate(template, resultData = {}) {
352
- if (typeof template !== 'string') {
353
- return template;
354
- }
355
-
356
- let resolved = template;
357
-
358
- // $result.* - подстановка из результата
359
- resolved = resolved.replace(/\$result\.(\w+)/g, (_, key) => {
360
- return resultData[key] !== undefined ? resultData[key] : '';
361
- });
362
-
363
- // $context.* - подстановка из контекста
364
- resolved = resolved.replace(/\$context\.(\w+)/g, (_, key) => {
365
- return this.context[key] !== undefined ? this.context[key] : '';
366
- });
367
-
368
- // $counter.* - подстановка из счётчиков
369
- resolved = resolved.replace(/\$counter\.(\w+)/g, (_, key) => {
370
- return this.counters[key] !== undefined ? this.counters[key] : 0;
371
- });
372
-
373
- return resolved;
374
- }
375
- }
376
-
377
- // ============================================================================
378
- // ResultParser — парсит вывод агентов и извлекает структурированные данные
379
- // ============================================================================
380
- class ResultParser {
381
- // Карта нормализации статусов: синонимы → каноническое значение
382
- static STATUS_ALIASES = {
383
- pass: 'passed',
384
- approved: 'passed',
385
- success: 'passed',
386
- succeeded: 'passed',
387
- ok: 'passed',
388
- accepted: 'passed',
389
- lgtm: 'passed',
390
- fixed: 'passed',
391
- resolved: 'passed',
392
- fail: 'failed',
393
- rejected: 'failed',
394
- denied: 'failed',
395
- not_passed: 'failed',
396
- err: 'error',
397
- crash: 'error',
398
- timeout: 'error',
399
- };
400
-
401
- /**
402
- * Нормализует статус: приводит синонимы к каноническому значению
403
- * @param {string} status
404
- * @returns {string}
405
- */
406
- normalizeStatus(status) {
407
- const lower = status.toLowerCase();
408
- const canonical = ResultParser.STATUS_ALIASES[lower];
409
- if (canonical) {
410
- console.log(`[ResultParser] Normalized status: "${status}" → "${canonical}"`);
411
- return canonical;
412
- }
413
- return status;
414
- }
415
-
416
- /**
417
- * Парсит вывод агента и извлекает результат между маркерами
418
- * @param {string} output - stdout агента
419
- * @param {string} stageId - ID stage для логирования
420
- * @returns {{status: string, data: object, raw: string}}
421
- */
422
- parse(output, stageId) {
423
- const marker = '---RESULT---';
424
-
425
- // Попытка найти парные маркеры
426
- const startIdx = output.indexOf(marker);
427
- const endIdx = startIdx !== -1 ? output.indexOf(marker, startIdx + marker.length) : -1;
428
-
429
- if (startIdx !== -1 && endIdx !== -1) {
430
- // Найдены маркеры — парсим структурированный блок
431
- const resultBlock = output.substring(startIdx + marker.length, endIdx).trim();
432
- const data = this.parseResultBlock(resultBlock);
433
-
434
- const normalizedStatus = this.normalizeStatus(data.status || 'default');
435
- console.log(`[ResultParser] Parsed structured result for ${stageId}: status=${normalizedStatus}`);
436
-
437
- return {
438
- status: normalizedStatus,
439
- data: data.data || {},
440
- raw: output,
441
- parsed: true
442
- };
443
- }
444
-
445
- // Fallback: пытаемся парсить текстовый вывод
446
- console.log(`[ResultParser] No result markers found for ${stageId}, attempting fallback parsing`);
447
- return this.fallbackParse(output, stageId);
448
- }
449
-
450
- /**
451
- * Парсит блок результата в формате key: value
452
- * @param {string} block - Текстовый блок результата
453
- * @returns {{status: string, data: object}}
454
- */
455
- parseResultBlock(block) {
456
- const lines = block.split('\n');
457
- const data = {};
458
- let status = 'default';
459
-
460
- for (const line of lines) {
461
- const match = line.match(/^([^:]+):\s*(.*)$/);
462
- if (match) {
463
- const key = match[1].trim();
464
- const value = match[2].trim();
465
-
466
- if (key === 'status') {
467
- status = value;
468
- } else {
469
- data[key] = value;
470
- }
471
- }
472
- }
473
-
474
- return { status, data };
475
- }
476
-
477
- /**
478
- * Fallback-парсинг для вывода без маркеров
479
- * Пытается извлечь статус из текстового вывода
480
- * @param {string} output - stdout агента
481
- * @param {string} stageId - ID stage для логирования
482
- * @returns {{status: string, data: object, raw: string}}
483
- */
484
- fallbackParse(output, stageId) {
485
- const lines = output.split('\n');
486
- let status = 'default';
487
- const extractedData = {};
488
- let inResultSection = false;
489
-
490
- // Ищем паттерны вида "status: xxx" или "Status: xxx" в любом месте вывода
491
- for (const line of lines) {
492
- const trimmedLine = line.trim();
493
-
494
- // Паттерн для извлечения статуса
495
- const statusMatch = trimmedLine.match(/^(?:status|Status):\s*(\w+)/i);
496
- if (statusMatch) {
497
- status = statusMatch[1];
498
- inResultSection = true;
499
- continue;
500
- }
501
-
502
- // Если нашли статус, пытаемся извлечь дополнительные данные
503
- if (inResultSection) {
504
- const dataMatch = trimmedLine.match(/^(\w+):\s*(.+)$/i);
505
- if (dataMatch && dataMatch[1].toLowerCase() !== 'status') {
506
- extractedData[dataMatch[1]] = dataMatch[2];
507
- }
508
- }
509
- }
510
-
511
- // Если статус не найден, пытаемся определить по ключевым словам
512
- if (status === 'default') {
513
- const lowerOutput = output.toLowerCase();
514
- if (lowerOutput.includes('completed') || lowerOutput.includes('success') || lowerOutput.includes('done')) {
515
- status = 'default';
516
- extractedData._inferred = 'success_keywords';
517
- } else if (lowerOutput.includes('error') || lowerOutput.includes('failed')) {
518
- status = 'error';
519
- extractedData._inferred = 'error_keywords';
520
- }
521
- }
522
-
523
- const normalizedStatus = this.normalizeStatus(status);
524
- console.log(`[ResultParser] Fallback parsing for ${stageId}: status=${normalizedStatus}`);
525
-
526
- return {
527
- status: normalizedStatus,
528
- data: extractedData,
529
- raw: output,
530
- parsed: false
531
- };
532
- }
533
- }
534
-
535
- // ============================================================================
536
- // FileGuard — защита файлов от несанкционированного изменения агентами
537
- // ============================================================================
538
- class FileGuard {
539
- constructor(patterns, projectRoot = process.cwd()) {
540
- // Паттерны указаны относительно корня проекта
541
- this.patterns = (patterns || []).map(p => p.replace(/\\/g, '/'));
542
- this.enabled = this.patterns.length > 0;
543
- this.snapshots = new Map();
544
- // projectRoot — корневая директория проекта, относительно которой указаны паттерны
545
- this.projectRoot = projectRoot;
546
- }
547
-
548
- /**
549
- * Проверяет, соответствует ли путь файла защищённым паттернам
550
- * @param {string} filePath - Путь к файлу (нормализованный через /)
551
- * @returns {boolean}
552
- */
553
- matchesProtected(filePath) {
554
- // Получаем относительный путь от projectRoot для сопоставления с паттерном
555
- const relativePath = path.relative(this.projectRoot, filePath).replace(/\\/g, '/');
556
- return this.patterns.some(pattern => this._matchGlob(relativePath, pattern));
557
- }
558
-
559
- /**
560
- * Glob-сопоставление: поддерживает * (в пределах директории) и ** (через директории)
561
- * @param {string} filePath - Нормализованный путь
562
- * @param {string} pattern - Glob-паттерн
563
- * @returns {boolean}
564
- */
565
- _matchGlob(filePath, pattern) {
566
- const normalizedPattern = pattern.replace(/\\/g, '/');
567
- const regexStr = normalizedPattern
568
- .replace(/[.+^${}()|[\]\\]/g, '\\$&') // экранируем regex-символы (кроме *)
569
- .replace(/\*\*/g, '\x00') // ** → временный placeholder
570
- .replace(/\*/g, '[^/]*') // * → совпадение внутри директории
571
- .replace(/\x00/g, '.*'); // placeholder → .* (через директории)
572
- return new RegExp('^' + regexStr + '$').test(filePath);
573
- }
574
-
575
- /**
576
- * Извлекает базовую директорию из glob-паттерна (до первого wildcard)
577
- * @param {string} pattern - Glob-паттерн
578
- * @returns {string} Базовая директория
579
- */
580
- _getBaseDir(pattern) {
581
- const parts = pattern.replace(/\\/g, '/').split('/');
582
- const nonWildcardParts = [];
583
- for (const part of parts) {
584
- if (part.includes('*')) break;
585
- nonWildcardParts.push(part);
586
- }
587
- return nonWildcardParts.join('/') || '.';
588
- }
589
-
590
- /**
591
- * Рекурсивно получает все файлы в директории
592
- * @param {string} dir - Директория для сканирования
593
- * @returns {string[]} Список путей к файлам (нормализованных через /)
594
- */
595
- _getAllFiles(dir) {
596
- const files = [];
597
- if (!fs.existsSync(dir)) return files;
598
- const entries = fs.readdirSync(dir, { withFileTypes: true });
599
- for (const entry of entries) {
600
- const entryPath = path.join(dir, entry.name).replace(/\\/g, '/');
601
- if (entry.isDirectory()) {
602
- files.push(...this._getAllFiles(entryPath));
603
- } else {
604
- files.push(entryPath);
605
- }
606
- }
607
- return files;
608
- }
609
-
610
- /**
611
- * Вычисляет SHA256-хэш содержимого файла
612
- * @param {string} filePath - Путь к файлу
613
- * @returns {string|null} Хэш или null если файл не существует
614
- */
615
- _hashFile(filePath) {
616
- if (!fs.existsSync(filePath)) return null;
617
- const content = fs.readFileSync(filePath);
618
- return crypto.createHash('sha256').update(content).digest('hex');
619
- }
620
-
621
- /**
622
- * Снимает snapshot защищённых файлов перед выполнением stage
623
- */
624
- takeSnapshot() {
625
- if (!this.enabled) return;
626
- this.snapshots.clear();
627
-
628
- for (const pattern of this.patterns) {
629
- if (!pattern.includes('*')) {
630
- // Прямой путь к файлу — преобразуем в абсолютный
631
- const absolutePath = path.resolve(this.projectRoot, pattern);
632
- if (fs.existsSync(absolutePath)) {
633
- this.snapshots.set(absolutePath, this._hashFile(absolutePath));
634
- }
635
- } else {
636
- // Glob-паттерн: сканируем базовую директорию относительно projectRoot
637
- const baseDir = path.resolve(this.projectRoot, this._getBaseDir(pattern));
638
- const files = this._getAllFiles(baseDir);
639
- for (const filePath of files) {
640
- if (this.matchesProtected(filePath)) {
641
- this.snapshots.set(filePath, this._hashFile(filePath));
642
- }
643
- }
644
- }
645
- }
646
-
647
- console.log(`[FileGuard] Snapshot taken: ${this.snapshots.size} protected files`);
648
- }
649
-
650
- /**
651
- * Проверяет целостность защищённых файлов и откатывает несанкционированные изменения
652
- * @returns {string[]} Список изменённых (и откаченных) файлов
653
- */
654
- checkAndRollback() {
655
- if (!this.enabled || this.snapshots.size === 0) return [];
656
-
657
- const violations = [];
658
- for (const [filePath, originalHash] of this.snapshots) {
659
- const currentHash = this._hashFile(filePath);
660
- if (currentHash !== originalHash) {
661
- violations.push(filePath);
662
- console.warn(`[FileGuard] WARNING: Protected file modified: ${filePath}`);
663
- this._rollbackFile(filePath);
664
- }
665
- }
666
-
667
- if (violations.length > 0) {
668
- console.warn(`[FileGuard] WARNING: Rolled back ${violations.length} protected file(s): ${violations.join(', ')}`);
669
- } else {
670
- console.log('[FileGuard] No protected files were modified');
671
- }
672
-
673
- return violations;
674
- }
675
-
676
- /**
677
- * Откатывает файл к последнему зафиксированному состоянию через git
678
- * @param {string} filePath - Путь к файлу
679
- */
680
- _rollbackFile(filePath) {
681
- try {
682
- execSync(`git checkout -- "${filePath}"`, { stdio: 'pipe' });
683
- console.warn(`[FileGuard] WARNING: Rolled back: ${filePath}`);
684
- } catch (err) {
685
- const errMsg = err.stderr ? err.stderr.toString().trim() : err.message;
686
- console.error(`[FileGuard] ERROR: Failed to rollback ${filePath}: ${errMsg}`);
687
- }
688
- }
689
- }
690
-
691
- // ============================================================================
692
- // StageExecutor — выполняет stages через вызов CLI-агентов
693
- // ============================================================================
694
- class StageExecutor {
695
- constructor(config, context, counters, previousResults = {}, fileGuard = null, logger = null, projectRoot = process.cwd()) {
696
- this.config = config;
697
- this.context = context;
698
- this.counters = counters;
699
- this.previousResults = previousResults;
700
- this.pipeline = config.pipeline;
701
- this.projectRoot = projectRoot;
702
- this.fileGuard = fileGuard;
703
- this.logger = logger;
704
-
705
- // Инициализируем билдер и парсер
706
- this.promptBuilder = new PromptBuilder(context, counters, previousResults);
707
- this.resultParser = new ResultParser();
708
- }
709
-
710
- /**
711
- * Выполняет stage через CLI-агента с поддержкой fallback_agent
712
- * @param {string} stageId - ID stage из конфигурации
713
- * @returns {Promise<{status: string, output: string, result?: object}>}
714
- */
715
- async execute(stageId) {
716
- const stage = this.pipeline.stages[stageId];
717
- if (!stage) {
718
- throw new Error(`Stage not found: ${stageId}`);
719
- }
720
-
721
- // Выбираем агента: если есть agent_by_attempt и счётчик — ротация по попыткам
722
- let agentId = stage.agent || this.pipeline.default_agent;
723
- if (stage.agent_by_attempt && stage.counter) {
724
- const attempt = this.counters[stage.counter] || 0;
725
- if (stage.agent_by_attempt[attempt]) {
726
- agentId = stage.agent_by_attempt[attempt];
727
- if (this.logger) {
728
- this.logger.info(`Agent rotation: attempt ${attempt} → ${agentId}`, stageId);
729
- }
730
- }
731
- }
732
- const agent = this.pipeline.agents[agentId];
733
- if (!agent) {
734
- throw new Error(`Agent not found: ${agentId}`);
735
- }
736
-
737
- // Формируем промпт для агента через PromptBuilder
738
- const prompt = this.promptBuilder.build(stage, stageId);
739
-
740
- // Логгируем старт stage
741
- if (this.logger) {
742
- this.logger.stageStart(stageId, agentId, stage.skill);
743
- } else {
744
- console.log(`\n[StageExecutor] Executing stage: ${stageId}`);
745
- console.log(` Agent: ${agentId} (${agent.command})`);
746
- console.log(` Skill: ${stage.skill}`);
747
- }
748
-
749
- // Снимаем snapshot защищённых файлов перед выполнением
750
- if (this.fileGuard) {
751
- this.fileGuard.takeSnapshot();
752
- }
753
-
754
- // Вызываем CLI-агента с поддержкой fallback
755
- const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill, stage.fallback_agent);
756
-
757
- // Логгируем завершение stage
758
- if (this.logger) {
759
- this.logger.stageComplete(stageId, result.status, result.exitCode);
760
- }
761
-
762
- // Проверяем и откатываем несанкционированные изменения
763
- if (this.fileGuard) {
764
- const violations = this.fileGuard.checkAndRollback();
765
- if (violations.length > 0) {
766
- result.violations = violations;
767
- }
768
- }
769
-
770
- return result;
771
- }
772
-
773
- /**
774
- * Вызывает CLI-агента через child_process
775
- */
776
- callAgent(agent, prompt, stageId, skillId) {
777
- return new Promise((resolve, reject) => {
778
- const timeout = this.pipeline.execution?.timeout_per_stage || 300;
779
- const args = [...agent.args];
780
-
781
- // Промпт всегда передаётся последним аргументом
782
- args.push(prompt);
783
-
784
- // Логгируем команду перед запуском (вместо промпта — имя skill)
785
- if (this.logger) {
786
- this.logger.info(`RUN ${agent.command} ${[...args.slice(0, -1), skillId].join(' ')}`, stageId);
787
- // Логгируем входные параметры агента (context + counters)
788
- const promptLines = prompt.split('\n').filter(l => l.trim());
789
- if (promptLines.length > 1) {
790
- for (const line of promptLines.slice(1)) {
791
- this.logger.info(` ${line}`, stageId);
792
- }
793
- }
794
- }
795
-
796
- const child = spawn(agent.command, args, {
797
- cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
798
- stdio: ['pipe', 'pipe', 'pipe'],
799
- // npm-бинари (.cmd) на Windows требуют shell: true,
800
- // но нативные executables (node) — нет: shell: true обрезает многострочные аргументы
801
- shell: process.platform === 'win32' && agent.command !== 'node'
802
- });
803
-
804
- // Закрываем stdin чтобы агент не ждал дополнительного ввода
805
- child.stdin.end();
806
-
807
- let stdout = '';
808
- let stderr = '';
809
- let timedOut = false;
810
-
811
- // Таймаут
812
- const timeoutId = setTimeout(() => {
813
- timedOut = true;
814
- child.kill('SIGTERM');
815
- if (this.logger) {
816
- this.logger.timeout(stageId, timeout);
817
- }
818
- reject(new Error(`Stage "${stageId}" timed out after ${timeout}s`));
819
- }, timeout * 1000);
820
-
821
- let stdoutBuffer = '';
822
- child.stdout.on('data', (data) => {
823
- const chunk = data.toString();
824
- stdout += chunk;
825
- // Парсим stream-json и выводим только текст дельт
826
- stdoutBuffer += chunk;
827
- const lines = stdoutBuffer.split('\n');
828
- stdoutBuffer = lines.pop(); // незавершённая строка остаётся в буфере
829
- for (const line of lines) {
830
- if (!line.trim()) continue;
831
- try {
832
- const obj = JSON.parse(line);
833
- // Claude: content_block_delta с delta.text
834
- if (obj.type === 'content_block_delta' && obj.delta?.text) {
835
- process.stdout.write(obj.delta.text);
836
- }
837
- // Qwen/Claude: assistant message с content text
838
- else if (obj.type === 'assistant' && obj.message?.content) {
839
- for (const block of obj.message.content) {
840
- if (block.type === 'text' && block.text) {
841
- process.stdout.write(block.text);
842
- }
843
- }
844
- }
845
- // result содержит финальный текст (дублирует assistant) — пропускаем
846
- } catch {
847
- // не JSON — выводим как есть
848
- process.stdout.write(line + '\n');
849
- }
850
- }
851
- });
852
-
853
- child.stderr.on('data', (data) => {
854
- stderr += data.toString();
855
- process.stderr.write(data);
856
- });
857
-
858
- child.on('close', (code) => {
859
- clearTimeout(timeoutId);
860
- // Обрабатываем остаток буфера стриминга
861
- if (stdoutBuffer.trim()) {
862
- try {
863
- const obj = JSON.parse(stdoutBuffer);
864
- if (obj.type === 'content_block_delta' && obj.delta?.text) {
865
- process.stdout.write(obj.delta.text);
866
- }
867
- } catch {
868
- process.stdout.write(stdoutBuffer + '\n');
869
- }
870
- }
871
- process.stdout.write('\n');
872
-
873
- if (timedOut) return;
874
-
875
- // Логгируем CLI вызов
876
- if (this.logger) {
877
- this.logger.cliCall(agent.command, args, code);
878
- }
879
-
880
- // Парсим результат из вывода агента через ResultParser
881
- const result = this.resultParser.parse(stdout, stageId);
882
-
883
- // Если exit code ≠ 0 — это ошибка, которая может триггерить fallback
884
- if (code !== 0) {
885
- const err = new Error(`Agent exited with code ${code}`);
886
- err.code = 'NON_ZERO_EXIT';
887
- err.exitCode = code;
888
- if (this.logger) {
889
- this.logger.error(`Agent exited with code ${code}`, stageId);
890
- }
891
- reject(err);
892
- return;
893
- }
894
-
895
- resolve({
896
- status: result.status || 'default',
897
- output: stdout,
898
- stderr: stderr,
899
- result: result.data || {},
900
- exitCode: code,
901
- parsed: result.parsed
902
- });
903
- });
904
-
905
- child.on('error', (err) => {
906
- clearTimeout(timeoutId);
907
- if (!timedOut) {
908
- if (this.logger) {
909
- this.logger.error(`CLI error: ${err.message}`, stageId);
910
- }
911
- reject(err);
912
- }
913
- });
914
- });
915
- }
916
-
917
- /**
918
- * Вызывает CLI-агента с поддержкой fallback_agent
919
- * При ошибке основного агента (exit code ≠ 0, ENOENT, таймаут) переключается на fallback_agent
920
- * @param {object} agent - Основной агент из конфигурации
921
- * @param {string} prompt - Промпт для агента
922
- * @param {string} stageId - ID stage для логирования
923
- * @param {string} skillId - ID skill для логирования
924
- * @param {string|null} fallbackAgentId - ID fallback агента (опционально)
925
- * @returns {Promise<{status: string, output: string, result?: object, exitCode: number}>}
926
- */
927
- async callAgentWithFallback(agent, prompt, stageId, skillId, fallbackAgentId) {
928
- try {
929
- // Пытаемся вызвать основной агент
930
- return await this.callAgent(agent, prompt, stageId, skillId);
931
- } catch (err) {
932
- // Проверяем, есть ли fallback_agent
933
- if (!fallbackAgentId) {
934
- // Fallback не задан — пробрасываем ошибку
935
- throw err;
936
- }
937
-
938
- // Проверяем тип ошибки — должна быть retry-able
939
- const isRetryableError =
940
- err.code === 'ENOENT' || // Команда не найдена
941
- err.code === 'ETIMEDOUT' || // Таймаут
942
- err.code === 'NON_ZERO_EXIT' || // Exit code ≠ 0
943
- err.message.includes('timed out'); // Таймаут от timeoutId
944
-
945
- if (!isRetryableError) {
946
- // Неретраемая ошибка — пробрасываем
947
- throw err;
948
- }
949
-
950
- // Логгируем переключение на fallback_agent
951
- if (this.logger) {
952
- this.logger.warn(`Primary agent failed, switching to fallback: ${fallbackAgentId}`, stageId);
953
- } else {
954
- console.log(`[StageExecutor] Primary agent failed, switching to fallback: ${fallbackAgentId}`);
955
- }
956
-
957
- // Находим fallback агента в конфигурации
958
- const fallbackAgent = this.pipeline.agents[fallbackAgentId];
959
- if (!fallbackAgent) {
960
- const errMsg = `Fallback agent not found: ${fallbackAgentId}`;
961
- if (this.logger) {
962
- this.logger.error(errMsg, stageId);
963
- } else {
964
- console.error(`[StageExecutor] ${errMsg}`);
965
- }
966
- throw err; // Пробрасываем оригинальную ошибку
967
- }
968
-
969
- // Вызываем fallback агента
970
- try {
971
- return await this.callAgent(fallbackAgent, prompt, stageId, skillId);
972
- } catch (fallbackErr) {
973
- // Если fallback тоже упал — пробрасываем ошибку fallback агента
974
- if (this.logger) {
975
- this.logger.error(`Fallback agent also failed: ${fallbackErr.message}`, stageId);
976
- } else {
977
- console.error(`[StageExecutor] Fallback agent also failed: ${fallbackErr.message}`);
978
- }
979
- throw fallbackErr;
980
- }
981
- }
982
- }
983
- }
984
-
985
- // ============================================================================
986
- // PipelineRunner — основной цикл выполнения пайплайна
987
- // ============================================================================
988
- class PipelineRunner {
989
- constructor(config, args) {
990
- this.config = config;
991
- this.args = args;
992
- this.pipeline = config.pipeline;
993
- this.context = { ...this.pipeline.context };
994
- this.counters = {};
995
- this.stepCount = 0;
996
- this.tasksExecuted = 0;
997
- this.running = true;
998
- this.currentStage = this.pipeline.entry;
999
-
1000
- // Базовая директория проекта вычисляется динамически
1001
- const projectRoot = args.project ? path.resolve(args.project) : findProjectRoot();
1002
-
1003
- // Инициализация Logger — каждый запуск пишется в отдельный файл
1004
- const logDir = this.pipeline.execution?.log_file
1005
- ? path.dirname(path.resolve(projectRoot, this.pipeline.execution.log_file))
1006
- : path.resolve(projectRoot, '.workflow/logs');
1007
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').substring(0, 19);
1008
- const logFilePath = path.resolve(logDir, `pipeline_${timestamp}.log`);
1009
- this.logger = new Logger(logFilePath);
1010
- this.loggerInitialized = false;
1011
-
1012
- // Инициализация контекста из CLI аргументов
1013
- if (args.plan) {
1014
- this.context.plan_id = args.plan;
1015
- }
1016
-
1017
- // Инициализация FileGuard для защиты файлов от изменений агентами
1018
- const protectedPatterns = this.pipeline.protected_files || [];
1019
- this.fileGuard = new FileGuard(protectedPatterns, projectRoot);
1020
- this.projectRoot = projectRoot;
1021
-
1022
- // Настройка graceful shutdown
1023
- this.setupGracefulShutdown();
1024
- }
1025
-
1026
- /**
1027
- * Асинхронно инициализирует runner (logger)
1028
- */
1029
- async init() {
1030
- await this.logger.init();
1031
- this.loggerInitialized = true;
1032
-
1033
- // Логгируем после инициализации
1034
- const protectedPatterns = this.pipeline.protected_files || [];
1035
- if (protectedPatterns.length > 0) {
1036
- this.logger.info(`FileGuard enabled: ${protectedPatterns.length} pattern(s)`, 'PipelineRunner');
1037
- }
1038
-
1039
- if (this.context.plan_id) {
1040
- this.logger.info(`Plan ID: ${this.context.plan_id}`, 'PipelineRunner');
1041
- } else {
1042
- this.logger.info('No plan_id set — processing all tickets', 'PipelineRunner');
1043
- }
1044
- }
1045
-
1046
- /**
1047
- * Выполняет встроенный стейдж типа update-counter:
1048
- * инкрементирует счётчик и возвращает статус для goto-перехода.
1049
- *
1050
- * Конфигурация стейджа:
1051
- * type: update-counter
1052
- * counter: <name> — имя счётчика
1053
- * max: <number> — максимальное значение (опционально)
1054
- * goto:
1055
- * default: <stage> — следующий стейдж
1056
- * max_reached: <stage> — стейдж при достижении max
1057
- */
1058
- executeUpdateCounter(stageId, stage) {
1059
- const counterName = stage.counter;
1060
- if (!counterName) {
1061
- throw new Error(`Stage "${stageId}" has type update-counter but no counter specified`);
1062
- }
1063
-
1064
- this.counters[counterName] = (this.counters[counterName] || 0) + 1;
1065
- const value = this.counters[counterName];
1066
-
1067
- if (this.logger) {
1068
- this.logger.info(`Counter "${counterName}" incremented to ${value}`, stageId);
1069
- }
1070
-
1071
- const max = stage.max;
1072
- const status = (max && value >= max) ? 'max_reached' : 'default';
1073
-
1074
- return { status, result: { counter: counterName, value } };
1075
- }
1076
-
1077
- /**
1078
- * Запускает основной цикл выполнения
1079
- */
1080
- async run() {
1081
- // Инициализируем logger
1082
- await this.init();
1083
-
1084
- const maxSteps = this.pipeline.execution?.max_steps || 100;
1085
- const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
1086
-
1087
- this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
1088
- this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
1089
- this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
1090
- this.logger.info(`Context: ${JSON.stringify(this.context)}`, 'PipelineRunner');
1091
-
1092
- while (this.running && this.stepCount < maxSteps) {
1093
- this.stepCount++;
1094
-
1095
- this.logger.info(`Step ${this.stepCount}`, 'PipelineRunner');
1096
- this.logger.info(`Current stage: ${this.currentStage}`, 'PipelineRunner');
1097
-
1098
- if (this.currentStage === 'end') {
1099
- this.logger.info('Pipeline completed successfully!', 'PipelineRunner');
1100
- break;
1101
- }
1102
-
1103
- try {
1104
- // Выполняем stage
1105
- const stage = this.pipeline.stages[this.currentStage];
1106
- if (!stage) {
1107
- throw new Error(`Stage not found: ${this.currentStage}`);
1108
- }
1109
-
1110
- let result;
1111
-
1112
- // Встроенный тип стейджа: update-counter — инкрементирует счётчик без вызова агента
1113
- if (stage.type === 'update-counter') {
1114
- result = this.executeUpdateCounter(this.currentStage, stage);
1115
- } else {
1116
- const executor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
1117
- result = await executor.execute(this.currentStage);
1118
- }
1119
-
1120
- this.logger.info(`Stage ${this.currentStage} completed with status: ${result.status}`, 'PipelineRunner');
1121
-
1122
- // Определяем следующий stage по goto-логике
1123
- const nextStage = this.resolveNextStage(this.currentStage, result);
1124
-
1125
- // Считаем выполненные задачи (execute-task)
1126
- if (this.currentStage === 'execute-task' && result.status !== 'error') {
1127
- this.tasksExecuted++;
1128
- }
1129
-
1130
- // Переход к следующему stage
1131
- this.currentStage = nextStage;
1132
-
1133
- // Задержка между stages
1134
- if (nextStage !== 'end' && this.running) {
1135
- this.logger.info(`Waiting ${delayBetweenStages}s before next stage...`, 'PipelineRunner');
1136
- await this.sleep(delayBetweenStages * 1000);
1137
- }
1138
-
1139
- } catch (err) {
1140
- this.logger.error(`Error at stage "${this.currentStage}": ${err.message}`, 'PipelineRunner');
1141
-
1142
- // Пытаемся получить fallback transition
1143
- const stage = this.pipeline.stages[this.currentStage];
1144
- if (stage?.goto?.error) {
1145
- const errorTarget = typeof stage.goto.error === 'string' ? stage.goto.error : stage.goto.error.stage;
1146
- this.logger.info(`Transitioning to error handler: ${errorTarget}`, 'PipelineRunner');
1147
- this.currentStage = errorTarget;
1148
-
1149
- // Обновляем контекст параметрами из error transition
1150
- if (typeof stage.goto.error === 'object' && stage.goto.error.params) {
1151
- this.updateContext(stage.goto.error.params, { error: err.message });
1152
- }
1153
- } else {
1154
- this.logger.error('No error handler defined. Stopping.', 'PipelineRunner');
1155
- this.running = false;
1156
- }
1157
- }
1158
- }
1159
-
1160
- if (this.stepCount >= maxSteps) {
1161
- this.logger.error(`Stopped: reached max steps limit (${maxSteps})`, 'PipelineRunner');
1162
- }
1163
-
1164
- this.logger.info('=== Pipeline Runner Finished ===', 'PipelineRunner');
1165
- this.logger.info(`Total steps: ${this.stepCount}`, 'PipelineRunner');
1166
- this.logger.info(`Tasks executed: ${this.tasksExecuted}`, 'PipelineRunner');
1167
- this.logger.info(`Final context: ${JSON.stringify(this.context)}`, 'PipelineRunner');
1168
-
1169
- // Записываем итоговый summary
1170
- this.logger.writeSummary();
1171
-
1172
- return {
1173
- steps: this.stepCount,
1174
- tasksExecuted: this.tasksExecuted,
1175
- context: this.context,
1176
- failed: !this.running && this.stepCount < maxSteps
1177
- };
1178
- }
1179
-
1180
- /**
1181
- * Определяет следующий stage на основе результата и goto-конфигурации
1182
- * Также управляет retry-логикой с agent_by_attempt
1183
- */
1184
- resolveNextStage(stageId, result) {
1185
- const stage = this.pipeline.stages[stageId];
1186
- if (!stage || !stage.goto) {
1187
- this.logger.gotoTransition(stageId, 'end', result.status);
1188
- return 'end';
1189
- }
1190
-
1191
- const goto = stage.goto;
1192
- const status = result.status;
1193
-
1194
- // Проверяем точное совпадение статуса
1195
- if (goto[status]) {
1196
- const transition = goto[status];
1197
-
1198
- // Если переход задан строкой (shorthand: "stage-name")
1199
- if (typeof transition === 'string') {
1200
- this.logger.gotoTransition(stageId, transition, status);
1201
- return transition;
1202
- }
1203
-
1204
- // Обновляем контекст параметрами перехода
1205
- if (transition.params) {
1206
- this.updateContext(transition.params, result.result);
1207
- }
1208
-
1209
- const nextStage = transition.stage || 'end';
1210
- this.logger.gotoTransition(stageId, nextStage, status, transition.params);
1211
- return nextStage;
1212
- }
1213
-
1214
- // Fallback на default
1215
- if (goto.default) {
1216
- const transition = goto.default;
1217
-
1218
- if (typeof transition === 'string') {
1219
- this.logger.gotoTransition(stageId, transition, 'default');
1220
- return transition;
1221
- }
1222
-
1223
- if (transition.params) {
1224
- this.updateContext(transition.params, result.result);
1225
- }
1226
-
1227
- const nextStage = transition.stage || 'end';
1228
- this.logger.gotoTransition(stageId, nextStage, 'default', transition.params);
1229
- return nextStage;
1230
- }
1231
-
1232
- this.logger.gotoTransition(stageId, 'end', 'default');
1233
- return 'end';
1234
- }
1235
-
1236
- /**
1237
- * Обновляет контекст переменными из params с подстановкой значений
1238
- */
1239
- updateContext(params, resultData) {
1240
- if (!params) return;
1241
-
1242
- // Проверяем смену ticket_id для сброса счётчика попыток
1243
- const newTicketId = params.ticket_id ?
1244
- (typeof params.ticket_id === 'string' ?
1245
- params.ticket_id
1246
- .replace(/\$result\.(\w+)/g, (_, k) => resultData[k] || '')
1247
- .replace(/\$context\.(\w+)/g, (_, k) => this.context[k] || '')
1248
- : params.ticket_id)
1249
- : null;
1250
-
1251
- if (newTicketId && this.context.ticket_id && newTicketId !== this.context.ticket_id) {
1252
- // Тикет сменился — сбрасываем все счётчики попыток
1253
- for (const counterKey of Object.keys(this.counters)) {
1254
- if (counterKey.includes('attempt')) {
1255
- this.counters[counterKey] = 0;
1256
- if (this.logger) {
1257
- this.logger.info(`Reset counter "${counterKey}" due to ticket change (${this.context.ticket_id} → ${newTicketId})`, 'PipelineRunner');
1258
- }
1259
- }
1260
- }
1261
- }
1262
-
1263
- for (const [key, value] of Object.entries(params)) {
1264
- if (typeof value === 'string') {
1265
- // Подстановка переменных: $context.*, $result.*, $counter.*
1266
- let resolvedValue = value;
1267
-
1268
- // $result.*
1269
- resolvedValue = resolvedValue.replace(/\$result\.(\w+)/g, (_, k) => resultData[k] || '');
1270
-
1271
- // $context.*
1272
- resolvedValue = resolvedValue.replace(/\$context\.(\w+)/g, (_, k) => this.context[k] || '');
1273
-
1274
- // $counter.*
1275
- resolvedValue = resolvedValue.replace(/\$counter\.(\w+)/g, (_, k) => this.counters[k] || 0);
1276
-
1277
- this.context[key] = resolvedValue;
1278
- } else {
1279
- this.context[key] = value;
1280
- }
1281
- }
1282
-
1283
- if (this.logger) {
1284
- this.logger.info(`Context updated: ${JSON.stringify(this.context)}`, 'PipelineRunner');
1285
- }
1286
- }
1287
-
1288
- /**
1289
- * Утилита для задержки
1290
- */
1291
- sleep(ms) {
1292
- return new Promise(resolve => setTimeout(resolve, ms));
1293
- }
1294
-
1295
- /**
1296
- * Настройка graceful shutdown
1297
- */
1298
- setupGracefulShutdown() {
1299
- const shutdown = (signal) => {
1300
- if (this.logger) {
1301
- this.logger.info(`Received ${signal}. Shutting down gracefully...`, 'PipelineRunner');
1302
- }
1303
- this.running = false;
1304
- };
1305
-
1306
- process.on('SIGINT', () => shutdown('SIGINT'));
1307
- process.on('SIGTERM', () => shutdown('SIGTERM'));
1308
- }
1309
- }
1310
-
1311
- function parseArgs(argv) {
1312
- const args = {
1313
- plan: null,
1314
- config: null,
1315
- project: null,
1316
- help: false
1317
- };
1318
-
1319
- for (let i = 0; i < argv.length; i++) {
1320
- const arg = argv[i];
1321
-
1322
- switch (arg) {
1323
- case '--help':
1324
- case '-h':
1325
- args.help = true;
1326
- break;
1327
- case '--plan':
1328
- args.plan = argv[++i] || null;
1329
- break;
1330
- case '--config':
1331
- args.config = argv[++i] || null;
1332
- break;
1333
- case '--project':
1334
- args.project = argv[++i] || null;
1335
- break;
1336
- default:
1337
- if (arg.startsWith('--')) {
1338
- console.error(`Unknown option: ${arg}`);
1339
- process.exit(1);
1340
- }
1341
- }
1342
- }
1343
-
1344
- return args;
1345
- }
1346
-
1347
- function printHelp() {
1348
- console.log(`
1349
- Workflow Runner - Pipeline Orchestrator
1350
-
1351
- Usage: node runner.mjs [options]
1352
-
1353
- Options:
1354
- --plan PLAN-ID Plan ID to execute (e.g., PLAN-003)
1355
- --config PATH Path to pipeline.yaml config (default: .workflow/config/pipeline.yaml)
1356
- --project PATH Project root path (overrides auto-detection)
1357
- --help, -h Show this help message
1358
-
1359
- Examples:
1360
- node .workflow/src/runner.mjs --help
1361
- node .workflow/src/runner.mjs --plan PLAN-003
1362
- node runner.mjs --project /path/to/project --plan PLAN-003
1363
- `);
1364
- }
1365
-
1366
- function loadConfig(configPath) {
1367
- const fullPath = path.resolve(configPath);
1368
-
1369
- if (!fs.existsSync(fullPath)) {
1370
- throw new Error(`Config file not found: ${fullPath}`);
1371
- }
1372
-
1373
- const content = fs.readFileSync(fullPath, 'utf8');
1374
- const config = yaml.load(content);
1375
-
1376
- return config;
1377
- }
1378
-
1379
- function validateConfig(config) {
1380
- const errors = [];
1381
-
1382
- if (!config) {
1383
- errors.push('Config is empty');
1384
- return errors;
1385
- }
1386
-
1387
- if (!config.pipeline) {
1388
- errors.push('Missing required field: pipeline');
1389
- return errors;
1390
- }
1391
-
1392
- const pipeline = config.pipeline;
1393
-
1394
- if (!pipeline.name || typeof pipeline.name !== 'string') {
1395
- errors.push('Missing or invalid required field: pipeline.name (string)');
1396
- }
1397
-
1398
- if (!pipeline.version || typeof pipeline.version !== 'string') {
1399
- errors.push('Missing or invalid required field: pipeline.version (string)');
1400
- }
1401
-
1402
- if (!pipeline.agents || typeof pipeline.agents !== 'object') {
1403
- errors.push('Missing or invalid required field: pipeline.agents (object)');
1404
- }
1405
-
1406
- if (!pipeline.stages || typeof pipeline.stages !== 'object') {
1407
- errors.push('Missing or invalid required field: pipeline.stages (object)');
1408
- }
1409
-
1410
- if (pipeline.agents && pipeline.stages) {
1411
- const agentIds = Object.keys(pipeline.agents);
1412
- const stageIds = Object.keys(pipeline.stages);
1413
-
1414
- for (const [stageId, stage] of Object.entries(pipeline.stages)) {
1415
- const resolvedAgent = stage.agent || pipeline.default_agent;
1416
- if (resolvedAgent && !agentIds.includes(resolvedAgent)) {
1417
- errors.push(`Stage "${stageId}" references non-existent agent: ${resolvedAgent}`);
1418
- }
1419
-
1420
- if (stage.goto) {
1421
- for (const [status, transition] of Object.entries(stage.goto)) {
1422
- if (status === 'default') continue;
1423
- if (transition.stage && transition.stage !== 'end' && !stageIds.includes(transition.stage)) {
1424
- errors.push(`Stage "${stageId}" goto.${status} references non-existent stage: ${transition.stage}`);
1425
- }
1426
- }
1427
- }
1428
- }
1429
- }
1430
-
1431
- return errors;
1432
- }
1433
-
1434
- async function runPipeline(argv = process.argv.slice(2)) {
1435
- const args = parseArgs(argv);
1436
-
1437
- if (args.help) {
1438
- printHelp();
1439
- return { exitCode: 0, help: true };
1440
- }
1441
-
1442
- // Resolve config path
1443
- if (!args.config) {
1444
- const projectRoot = args.project ? path.resolve(args.project) : findProjectRoot();
1445
- args.config = path.resolve(projectRoot, '.workflow/config/pipeline.yaml');
1446
- }
1447
-
1448
- console.log('=== Workflow Runner ===');
1449
- console.log(`Config: ${args.config}`);
1450
- if (args.plan) console.log(`Plan: ${args.plan}`);
1451
- if (args.project) console.log(`Project: ${args.project}`);
1452
- console.log('');
1453
-
1454
- try {
1455
- const config = loadConfig(args.config);
1456
- const errors = validateConfig(config);
1457
-
1458
- if (errors.length > 0) {
1459
- console.error('Configuration validation failed:');
1460
- errors.forEach(err => console.error(` - ${err}`));
1461
- return { exitCode: 1, error: 'Configuration validation failed', details: errors };
1462
- }
1463
-
1464
- console.log(`Pipeline: ${config.pipeline.name} v${config.pipeline.version}`);
1465
- console.log(`Agents: ${Object.keys(config.pipeline.agents).join(', ')}`);
1466
- console.log(`Stages: ${Object.keys(config.pipeline.stages).join(', ')}`);
1467
- console.log('');
1468
- console.log('Configuration validated successfully!');
1469
-
1470
- // Запускаем пайплайн
1471
- const runner = new PipelineRunner(config, args);
1472
- const result = await runner.run();
1473
-
1474
- console.log('\n=== Summary ===');
1475
- console.log(`Steps executed: ${result.steps}`);
1476
- console.log(`Tasks completed: ${result.tasksExecuted}`);
1477
-
1478
- return { exitCode: result.failed ? 1 : 0, result };
1479
-
1480
- } catch (err) {
1481
- console.error(`\nError: ${err.message}`);
1482
- console.error(err.stack);
1483
-
1484
- // Даём файлу логов время записаться перед выходом
1485
- await new Promise(resolve => setTimeout(resolve, 100));
1486
-
1487
- return { exitCode: 1, error: err.message, stack: err.stack };
1488
- }
1489
- }
1490
-
1491
- // Export for use as ES module
1492
- export { runPipeline, parseArgs, PipelineRunner };
1
+ #!/usr/bin/env node
2
+
3
+ import fs from 'fs';
4
+ import path from 'path';
5
+ import { spawn, execSync } from 'child_process';
6
+ import crypto from 'crypto';
7
+ import yaml from 'js-yaml';
8
+ import { findProjectRoot } from './lib/find-root.mjs';
9
+
10
+ // ============================================================================
11
+ // Logger — система логирования с уровнями INFO/WARN/ERROR
12
+ // ============================================================================
13
+ class Logger {
14
+ static LEVELS = {
15
+ INFO: 0,
16
+ WARN: 1,
17
+ ERROR: 2
18
+ };
19
+
20
+ static COLORS = {
21
+ INFO: '\x1b[36m', // cyan
22
+ WARN: '\x1b[33m', // yellow
23
+ ERROR: '\x1b[31m', // red
24
+ RESET: '\x1b[0m'
25
+ };
26
+
27
+ constructor(logFilePath, consoleLevel = Logger.LEVELS.INFO) {
28
+ this.logFilePath = logFilePath;
29
+ this.consoleLevel = consoleLevel;
30
+ this.stats = {
31
+ info: 0,
32
+ warn: 0,
33
+ error: 0,
34
+ stagesStarted: 0,
35
+ stagesCompleted: 0,
36
+ stagesFailed: 0,
37
+ cliCalls: 0,
38
+ gotoTransitions: 0,
39
+ retries: 0,
40
+ startTime: null,
41
+ endTime: null
42
+ };
43
+ }
44
+
45
+ /**
46
+ * Создаёт директорию для логов если она не существует
47
+ */
48
+ _ensureLogDirectory() {
49
+ const logDir = path.dirname(this.logFilePath);
50
+ if (!fs.existsSync(logDir)) {
51
+ fs.mkdirSync(logDir, { recursive: true });
52
+ console.log(`[Logger] Created log directory: ${logDir}`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Создаёт директорию для логов если она не существует
58
+ */
59
+ _ensureLogDirectory() {
60
+ const logDir = path.dirname(this.logFilePath);
61
+ if (!fs.existsSync(logDir)) {
62
+ fs.mkdirSync(logDir, { recursive: true });
63
+ console.log(`[Logger] Created log directory: ${logDir}`);
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Открывает файл для записи (append mode)
69
+ */
70
+ _openFile() {
71
+ // Создаём директорию и файл если не существуют
72
+ this._ensureLogDirectory();
73
+ if (!fs.existsSync(this.logFilePath)) {
74
+ fs.writeFileSync(this.logFilePath, '');
75
+ }
76
+ this.stats.startTime = new Date();
77
+ }
78
+
79
+ /**
80
+ * Инициализирует logger
81
+ */
82
+ async init() {
83
+ this._openFile();
84
+ }
85
+
86
+ /**
87
+ * Форматирует timestamp для логов
88
+ */
89
+ _formatTimestamp() {
90
+ const now = new Date();
91
+ return now.toISOString().replace('T', ' ').substring(0, 19);
92
+ }
93
+
94
+ /**
95
+ * Форматирует сообщение для вывода
96
+ */
97
+ _formatMessage(level, stage, message) {
98
+ const timestamp = this._formatTimestamp();
99
+ const stageTag = stage ? `[${stage}]` : '[Runner]';
100
+ const prefix = `[${timestamp}] [${level}] ${stageTag} `;
101
+ const lines = message.split('\n');
102
+ if (lines.length === 1) {
103
+ return `${prefix}${message}`;
104
+ }
105
+ const indent = ' '.repeat(prefix.length);
106
+ return lines.map((line, i) => i === 0 ? `${prefix}${line}` : `${indent}${line}`).join('\n');
107
+ }
108
+
109
+ /**
110
+ * Записывает лог в файл (синхронно)
111
+ */
112
+ _writeToFile(formattedMessage) {
113
+ fs.appendFileSync(this.logFilePath, formattedMessage + '\n', 'utf8');
114
+ }
115
+
116
+ /**
117
+ * Выводит в консоль с цветом
118
+ */
119
+ _writeToConsole(formattedMessage, level) {
120
+ if (Logger.LEVELS[level] < this.consoleLevel) {
121
+ return;
122
+ }
123
+
124
+ const color = Logger.COLORS[level];
125
+ const reset = Logger.COLORS.RESET;
126
+
127
+ if (level === 'ERROR') {
128
+ console.error(`${color}${formattedMessage}${reset}`);
129
+ } else if (level === 'WARN') {
130
+ console.warn(`${color}${formattedMessage}${reset}`);
131
+ } else {
132
+ console.log(`${color}${formattedMessage}${reset}`);
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Базовый метод логирования
138
+ */
139
+ _log(level, stage, message) {
140
+ const formattedMessage = this._formatMessage(level, stage, message);
141
+ this._writeToFile(formattedMessage);
142
+ this._writeToConsole(formattedMessage, level);
143
+
144
+ // Обновляем статистику
145
+ if (level === 'INFO') this.stats.info++;
146
+ else if (level === 'WARN') this.stats.warn++;
147
+ else if (level === 'ERROR') this.stats.error++;
148
+ }
149
+
150
+ /**
151
+ * Логгирует INFO сообщение
152
+ */
153
+ info(message, stage) {
154
+ this._log('INFO', stage, message);
155
+ }
156
+
157
+ /**
158
+ * Логгирует WARN сообщение
159
+ */
160
+ warn(message, stage) {
161
+ this._log('WARN', stage, message);
162
+ }
163
+
164
+ /**
165
+ * Логгирует ERROR сообщение
166
+ */
167
+ error(message, stage) {
168
+ this._log('ERROR', stage, message);
169
+ }
170
+
171
+ /**
172
+ * Логгирует старт stage
173
+ */
174
+ stageStart(stageId, agentId, skillId) {
175
+ this.stats.stagesStarted++;
176
+ this.info(`START stage="${stageId}" agent="${agentId}" skill="${skillId}"`, stageId);
177
+ }
178
+
179
+ /**
180
+ * Логгирует завершение stage
181
+ */
182
+ stageComplete(stageId, status, exitCode) {
183
+ this.stats.stagesCompleted++;
184
+ this.info(`COMPLETE stage="${stageId}" status="${status}" exitCode=${exitCode}`, stageId);
185
+ }
186
+
187
+ /**
188
+ * Логгирует ошибку stage
189
+ */
190
+ stageError(stageId, errorMessage) {
191
+ this.stats.stagesFailed++;
192
+ this.error(`ERROR stage="${stageId}" message="${errorMessage}"`, stageId);
193
+ }
194
+
195
+ /**
196
+ * Логгирует goto переход
197
+ */
198
+ gotoTransition(fromStage, toStage, status, params = {}) {
199
+ this.stats.gotoTransitions++;
200
+ const paramsStr = Object.keys(params).length > 0 ? ` params=${JSON.stringify(params)}` : '';
201
+ this.info(`GOTO ${fromStage} → ${toStage} status="${status}"${paramsStr}`, fromStage);
202
+ }
203
+
204
+ /**
205
+ * Логгирует вызов CLI
206
+ */
207
+ cliCall(command, args, exitCode) {
208
+ this.stats.cliCalls++;
209
+ this.info(`CLI command="${command}" args="${args.join(' ')}" exitCode=${exitCode}`, 'CLI');
210
+ }
211
+
212
+ /**
213
+ * Логгирует retry попытку
214
+ */
215
+ retry(stageId, attempt, maxAttempts) {
216
+ this.stats.retries++;
217
+ this.warn(`RETRY stage="${stageId}" attempt=${attempt}/${maxAttempts}`, stageId);
218
+ }
219
+
220
+ /**
221
+ * Логгирует таймаут
222
+ */
223
+ timeout(stageId, timeoutSeconds) {
224
+ this.error(`TIMEOUT stage="${stageId}" after ${timeoutSeconds}s`, stageId);
225
+ }
226
+
227
+ /**
228
+ * Записывает итоговый summary
229
+ */
230
+ writeSummary() {
231
+ this.stats.endTime = new Date();
232
+ const duration = this.stats.endTime - this.stats.startTime;
233
+
234
+ const summary = [
235
+ '',
236
+ '═══════════════════════════════════════════════════════════',
237
+ ' PIPELINE SUMMARY',
238
+ '═══════════════════════════════════════════════════════════',
239
+ '',
240
+ `Duration: ${(duration / 1000).toFixed(2)}s`,
241
+ '',
242
+ '┌─────────────────────────────────────────────────────────┐',
243
+ '│ LOG STATISTICS │',
244
+ '├─────────────────────────────────────────────────────────┤',
245
+ `│ INFO messages: ${String(this.stats.info).padEnd(34)}│`,
246
+ `│ WARN messages: ${String(this.stats.warn).padEnd(34)}│`,
247
+ `│ ERROR messages: ${String(this.stats.error).padEnd(34)}│`,
248
+ '├─────────────────────────────────────────────────────────┤',
249
+ '│ STAGE STATISTICS │',
250
+ '├─────────────────────────────────────────────────────────┤',
251
+ `│ Stages started: ${String(this.stats.stagesStarted).padEnd(34)}│`,
252
+ `│ Stages completed: ${String(this.stats.stagesCompleted).padEnd(34)}│`,
253
+ `│ Stages failed: ${String(this.stats.stagesFailed).padEnd(34)}│`,
254
+ '├─────────────────────────────────────────────────────────┤',
255
+ '│ ACTIVITY STATISTICS │',
256
+ '├─────────────────────────────────────────────────────────┤',
257
+ `│ CLI calls: ${String(this.stats.cliCalls).padEnd(34)}│`,
258
+ `│ GOTO transitions: ${String(this.stats.gotoTransitions).padEnd(34)}│`,
259
+ `│ Retries: ${String(this.stats.retries).padEnd(34)}│`,
260
+ '└─────────────────────────────────────────────────────────┘',
261
+ '',
262
+ '═══════════════════════════════════════════════════════════'
263
+ ].join('\n');
264
+
265
+ // Вывод summary в консоль (всегда, независимо от уровня)
266
+ console.log(summary);
267
+
268
+ // Запись summary в файл
269
+ this._writeToFile(summary);
270
+ }
271
+
272
+ }
273
+
274
+ // ============================================================================
275
+ // PromptBuilder — формирует промпты для CLI-агентов с подстановкой контекста
276
+ // ============================================================================
277
+ class PromptBuilder {
278
+ constructor(context, counters, previousResults = {}) {
279
+ this.context = context;
280
+ this.counters = counters;
281
+ this.previousResults = previousResults;
282
+ }
283
+
284
+ /**
285
+ * Формирует промпт для агента на основе skill инструкции
286
+ * @param {object} stage - Stage из конфигурации
287
+ * @param {string} stageId - ID stage
288
+ * @returns {string} Промпт для агента
289
+ */
290
+ build(stage, stageId) {
291
+ const parts = [stage.skill || stageId];
292
+
293
+ // Добавляем контекст если есть непустые значения
294
+ const contextEntries = Object.entries(this.context)
295
+ .filter(([_, v]) => v !== undefined && v !== null && v !== '');
296
+ if (contextEntries.length > 0) {
297
+ parts.push('\n\nContext:');
298
+ for (const [key, value] of contextEntries) {
299
+ parts.push(` ${key}: ${value}`);
300
+ }
301
+ }
302
+
303
+ // Добавляем счётчики если есть
304
+ const counterEntries = Object.entries(this.counters)
305
+ .filter(([_, v]) => v > 0);
306
+ if (counterEntries.length > 0) {
307
+ parts.push('\nCounters:');
308
+ for (const [key, value] of counterEntries) {
309
+ parts.push(` ${key}: ${value}`);
310
+ }
311
+ }
312
+
313
+ return parts.join('\n');
314
+ }
315
+
316
+ /**
317
+ * Форматирует контекст для вывода
318
+ */
319
+ formatContext() {
320
+ const entries = Object.entries(this.context)
321
+ .filter(([_, v]) => v !== undefined && v !== null && v !== '')
322
+ .map(([k, v]) => ` ${k}: ${v}`);
323
+ return entries.length > 0 ? entries.join('\n') : ' (пусто)';
324
+ }
325
+
326
+ /**
327
+ * Форматирует счётчики для вывода
328
+ */
329
+ formatCounters() {
330
+ const entries = Object.entries(this.counters)
331
+ .map(([k, v]) => ` ${k}: ${v}`);
332
+ return entries.length > 0 ? entries.join('\n') : ' (пусто)';
333
+ }
334
+
335
+ /**
336
+ * Форматирует результаты предыдущих stages
337
+ */
338
+ formatPreviousResults() {
339
+ const entries = Object.entries(this.previousResults)
340
+ .map(([k, v]) => ` ${k}: ${JSON.stringify(v)}`);
341
+ return entries.length > 0 ? entries.join('\n') : ' (нет результатов)';
342
+ }
343
+
344
+ /**
345
+ * Интерполирует переменные в строке
346
+ * Поддерживает: $result.field, $context.field, $counter.field
347
+ * @param {string} template - Строка с переменными
348
+ * @param {object} resultData - Данные результата для $result.*
349
+ * @returns {string} Строка с подставленными значениями
350
+ */
351
+ interpolate(template, resultData = {}) {
352
+ if (typeof template !== 'string') {
353
+ return template;
354
+ }
355
+
356
+ let resolved = template;
357
+
358
+ // $result.* - подстановка из результата
359
+ resolved = resolved.replace(/\$result\.(\w+)/g, (_, key) => {
360
+ return resultData[key] !== undefined ? resultData[key] : '';
361
+ });
362
+
363
+ // $context.* - подстановка из контекста
364
+ resolved = resolved.replace(/\$context\.(\w+)/g, (_, key) => {
365
+ return this.context[key] !== undefined ? this.context[key] : '';
366
+ });
367
+
368
+ // $counter.* - подстановка из счётчиков
369
+ resolved = resolved.replace(/\$counter\.(\w+)/g, (_, key) => {
370
+ return this.counters[key] !== undefined ? this.counters[key] : 0;
371
+ });
372
+
373
+ return resolved;
374
+ }
375
+ }
376
+
377
+ // ============================================================================
378
+ // ResultParser — парсит вывод агентов и извлекает структурированные данные
379
+ // ============================================================================
380
+ class ResultParser {
381
+ // Карта нормализации статусов: синонимы → каноническое значение
382
+ static STATUS_ALIASES = {
383
+ pass: 'passed',
384
+ approved: 'passed',
385
+ success: 'passed',
386
+ succeeded: 'passed',
387
+ ok: 'passed',
388
+ accepted: 'passed',
389
+ lgtm: 'passed',
390
+ fixed: 'passed',
391
+ resolved: 'passed',
392
+ fail: 'failed',
393
+ rejected: 'failed',
394
+ denied: 'failed',
395
+ not_passed: 'failed',
396
+ err: 'error',
397
+ crash: 'error',
398
+ timeout: 'error',
399
+ };
400
+
401
+ /**
402
+ * Нормализует статус: приводит синонимы к каноническому значению
403
+ * @param {string} status
404
+ * @returns {string}
405
+ */
406
+ normalizeStatus(status) {
407
+ const lower = status.toLowerCase();
408
+ const canonical = ResultParser.STATUS_ALIASES[lower];
409
+ if (canonical) {
410
+ console.log(`[ResultParser] Normalized status: "${status}" → "${canonical}"`);
411
+ return canonical;
412
+ }
413
+ return status;
414
+ }
415
+
416
+ /**
417
+ * Парсит вывод агента и извлекает результат между маркерами
418
+ * @param {string} output - stdout агента
419
+ * @param {string} stageId - ID stage для логирования
420
+ * @returns {{status: string, data: object, raw: string}}
421
+ */
422
+ parse(output, stageId) {
423
+ const marker = '---RESULT---';
424
+
425
+ // Попытка найти парные маркеры
426
+ const startIdx = output.indexOf(marker);
427
+ const endIdx = startIdx !== -1 ? output.indexOf(marker, startIdx + marker.length) : -1;
428
+
429
+ if (startIdx !== -1 && endIdx !== -1) {
430
+ // Найдены маркеры — парсим структурированный блок
431
+ const resultBlock = output.substring(startIdx + marker.length, endIdx).trim();
432
+ const data = this.parseResultBlock(resultBlock);
433
+
434
+ const normalizedStatus = this.normalizeStatus(data.status || 'default');
435
+ console.log(`[ResultParser] Parsed structured result for ${stageId}: status=${normalizedStatus}`);
436
+
437
+ return {
438
+ status: normalizedStatus,
439
+ data: data.data || {},
440
+ raw: output,
441
+ parsed: true
442
+ };
443
+ }
444
+
445
+ // Fallback: пытаемся парсить текстовый вывод
446
+ console.log(`[ResultParser] No result markers found for ${stageId}, attempting fallback parsing`);
447
+ return this.fallbackParse(output, stageId);
448
+ }
449
+
450
+ /**
451
+ * Парсит блок результата в формате key: value
452
+ * @param {string} block - Текстовый блок результата
453
+ * @returns {{status: string, data: object}}
454
+ */
455
+ parseResultBlock(block) {
456
+ const lines = block.split('\n');
457
+ const data = {};
458
+ let status = 'default';
459
+
460
+ for (const line of lines) {
461
+ const match = line.match(/^([^:]+):\s*(.*)$/);
462
+ if (match) {
463
+ const key = match[1].trim();
464
+ const value = match[2].trim();
465
+
466
+ if (key === 'status') {
467
+ status = value;
468
+ } else {
469
+ data[key] = value;
470
+ }
471
+ }
472
+ }
473
+
474
+ return { status, data };
475
+ }
476
+
477
+ /**
478
+ * Fallback-парсинг для вывода без маркеров
479
+ * Пытается извлечь статус из текстового вывода
480
+ * @param {string} output - stdout агента
481
+ * @param {string} stageId - ID stage для логирования
482
+ * @returns {{status: string, data: object, raw: string}}
483
+ */
484
+ fallbackParse(output, stageId) {
485
+ const lines = output.split('\n');
486
+ let status = 'default';
487
+ const extractedData = {};
488
+ let inResultSection = false;
489
+
490
+ // Ищем паттерны вида "status: xxx" или "Status: xxx" в любом месте вывода
491
+ for (const line of lines) {
492
+ const trimmedLine = line.trim();
493
+
494
+ // Паттерн для извлечения статуса
495
+ const statusMatch = trimmedLine.match(/^(?:status|Status):\s*(\w+)/i);
496
+ if (statusMatch) {
497
+ status = statusMatch[1];
498
+ inResultSection = true;
499
+ continue;
500
+ }
501
+
502
+ // Если нашли статус, пытаемся извлечь дополнительные данные
503
+ if (inResultSection) {
504
+ const dataMatch = trimmedLine.match(/^(\w+):\s*(.+)$/i);
505
+ if (dataMatch && dataMatch[1].toLowerCase() !== 'status') {
506
+ extractedData[dataMatch[1]] = dataMatch[2];
507
+ }
508
+ }
509
+ }
510
+
511
+ // Если статус не найден, пытаемся определить по ключевым словам
512
+ if (status === 'default') {
513
+ const lowerOutput = output.toLowerCase();
514
+ if (lowerOutput.includes('completed') || lowerOutput.includes('success') || lowerOutput.includes('done')) {
515
+ status = 'default';
516
+ extractedData._inferred = 'success_keywords';
517
+ } else if (lowerOutput.includes('error') || lowerOutput.includes('failed')) {
518
+ status = 'error';
519
+ extractedData._inferred = 'error_keywords';
520
+ }
521
+ }
522
+
523
+ const normalizedStatus = this.normalizeStatus(status);
524
+ console.log(`[ResultParser] Fallback parsing for ${stageId}: status=${normalizedStatus}`);
525
+
526
+ return {
527
+ status: normalizedStatus,
528
+ data: extractedData,
529
+ raw: output,
530
+ parsed: false
531
+ };
532
+ }
533
+ }
534
+
535
+ // ============================================================================
536
+ // FileGuard — защита файлов от несанкционированного изменения агентами
537
+ // ============================================================================
538
+ class FileGuard {
539
+ constructor(patterns, projectRoot = process.cwd()) {
540
+ // Паттерны указаны относительно корня проекта
541
+ this.patterns = (patterns || []).map(p => p.replace(/\\/g, '/'));
542
+ this.enabled = this.patterns.length > 0;
543
+ this.snapshots = new Map();
544
+ // projectRoot — корневая директория проекта, относительно которой указаны паттерны
545
+ this.projectRoot = projectRoot;
546
+ }
547
+
548
+ /**
549
+ * Проверяет, соответствует ли путь файла защищённым паттернам
550
+ * @param {string} filePath - Путь к файлу (нормализованный через /)
551
+ * @returns {boolean}
552
+ */
553
+ matchesProtected(filePath) {
554
+ // Получаем относительный путь от projectRoot для сопоставления с паттерном
555
+ const relativePath = path.relative(this.projectRoot, filePath).replace(/\\/g, '/');
556
+ return this.patterns.some(pattern => this._matchGlob(relativePath, pattern));
557
+ }
558
+
559
+ /**
560
+ * Glob-сопоставление: поддерживает * (в пределах директории) и ** (через директории)
561
+ * @param {string} filePath - Нормализованный путь
562
+ * @param {string} pattern - Glob-паттерн
563
+ * @returns {boolean}
564
+ */
565
+ _matchGlob(filePath, pattern) {
566
+ const normalizedPattern = pattern.replace(/\\/g, '/');
567
+ const regexStr = normalizedPattern
568
+ .replace(/[.+^${}()|[\]\\]/g, '\\$&') // экранируем regex-символы (кроме *)
569
+ .replace(/\*\*/g, '\x00') // ** → временный placeholder
570
+ .replace(/\*/g, '[^/]*') // * → совпадение внутри директории
571
+ .replace(/\x00/g, '.*'); // placeholder → .* (через директории)
572
+ return new RegExp('^' + regexStr + '$').test(filePath);
573
+ }
574
+
575
+ /**
576
+ * Извлекает базовую директорию из glob-паттерна (до первого wildcard)
577
+ * @param {string} pattern - Glob-паттерн
578
+ * @returns {string} Базовая директория
579
+ */
580
+ _getBaseDir(pattern) {
581
+ const parts = pattern.replace(/\\/g, '/').split('/');
582
+ const nonWildcardParts = [];
583
+ for (const part of parts) {
584
+ if (part.includes('*')) break;
585
+ nonWildcardParts.push(part);
586
+ }
587
+ return nonWildcardParts.join('/') || '.';
588
+ }
589
+
590
+ /**
591
+ * Рекурсивно получает все файлы в директории
592
+ * @param {string} dir - Директория для сканирования
593
+ * @returns {string[]} Список путей к файлам (нормализованных через /)
594
+ */
595
+ _getAllFiles(dir) {
596
+ const files = [];
597
+ if (!fs.existsSync(dir)) return files;
598
+ const entries = fs.readdirSync(dir, { withFileTypes: true });
599
+ for (const entry of entries) {
600
+ const entryPath = path.join(dir, entry.name).replace(/\\/g, '/');
601
+ if (entry.isDirectory()) {
602
+ files.push(...this._getAllFiles(entryPath));
603
+ } else {
604
+ files.push(entryPath);
605
+ }
606
+ }
607
+ return files;
608
+ }
609
+
610
+ /**
611
+ * Вычисляет SHA256-хэш содержимого файла
612
+ * @param {string} filePath - Путь к файлу
613
+ * @returns {string|null} Хэш или null если файл не существует
614
+ */
615
+ _hashFile(filePath) {
616
+ if (!fs.existsSync(filePath)) return null;
617
+ const content = fs.readFileSync(filePath);
618
+ return crypto.createHash('sha256').update(content).digest('hex');
619
+ }
620
+
621
+ /**
622
+ * Снимает snapshot защищённых файлов перед выполнением stage
623
+ */
624
+ takeSnapshot() {
625
+ if (!this.enabled) return;
626
+ this.snapshots.clear();
627
+
628
+ for (const pattern of this.patterns) {
629
+ if (!pattern.includes('*')) {
630
+ // Прямой путь к файлу — преобразуем в абсолютный
631
+ const absolutePath = path.resolve(this.projectRoot, pattern);
632
+ if (fs.existsSync(absolutePath)) {
633
+ this.snapshots.set(absolutePath, this._hashFile(absolutePath));
634
+ }
635
+ } else {
636
+ // Glob-паттерн: сканируем базовую директорию относительно projectRoot
637
+ const baseDir = path.resolve(this.projectRoot, this._getBaseDir(pattern));
638
+ const files = this._getAllFiles(baseDir);
639
+ for (const filePath of files) {
640
+ if (this.matchesProtected(filePath)) {
641
+ this.snapshots.set(filePath, this._hashFile(filePath));
642
+ }
643
+ }
644
+ }
645
+ }
646
+
647
+ console.log(`[FileGuard] Snapshot taken: ${this.snapshots.size} protected files`);
648
+ }
649
+
650
+ /**
651
+ * Проверяет целостность защищённых файлов и откатывает несанкционированные изменения
652
+ * @returns {string[]} Список изменённых (и откаченных) файлов
653
+ */
654
+ checkAndRollback() {
655
+ if (!this.enabled || this.snapshots.size === 0) return [];
656
+
657
+ const violations = [];
658
+ for (const [filePath, originalHash] of this.snapshots) {
659
+ const currentHash = this._hashFile(filePath);
660
+ if (currentHash !== originalHash) {
661
+ violations.push(filePath);
662
+ console.warn(`[FileGuard] WARNING: Protected file modified: ${filePath}`);
663
+ this._rollbackFile(filePath);
664
+ }
665
+ }
666
+
667
+ if (violations.length > 0) {
668
+ console.warn(`[FileGuard] WARNING: Rolled back ${violations.length} protected file(s): ${violations.join(', ')}`);
669
+ } else {
670
+ console.log('[FileGuard] No protected files were modified');
671
+ }
672
+
673
+ return violations;
674
+ }
675
+
676
+ /**
677
+ * Откатывает файл к последнему зафиксированному состоянию через git
678
+ * @param {string} filePath - Путь к файлу
679
+ */
680
+ _rollbackFile(filePath) {
681
+ try {
682
+ execSync(`git checkout -- "${filePath}"`, { stdio: 'pipe' });
683
+ console.warn(`[FileGuard] WARNING: Rolled back: ${filePath}`);
684
+ } catch (err) {
685
+ const errMsg = err.stderr ? err.stderr.toString().trim() : err.message;
686
+ console.error(`[FileGuard] ERROR: Failed to rollback ${filePath}: ${errMsg}`);
687
+ }
688
+ }
689
+ }
690
+
691
+ // ============================================================================
692
+ // StageExecutor — выполняет stages через вызов CLI-агентов
693
+ // ============================================================================
694
+ class StageExecutor {
695
+ constructor(config, context, counters, previousResults = {}, fileGuard = null, logger = null, projectRoot = process.cwd()) {
696
+ this.config = config;
697
+ this.context = context;
698
+ this.counters = counters;
699
+ this.previousResults = previousResults;
700
+ this.pipeline = config.pipeline;
701
+ this.projectRoot = projectRoot;
702
+ this.fileGuard = fileGuard;
703
+ this.logger = logger;
704
+
705
+ // Инициализируем билдер и парсер
706
+ this.promptBuilder = new PromptBuilder(context, counters, previousResults);
707
+ this.resultParser = new ResultParser();
708
+ }
709
+
710
+ /**
711
+ * Выполняет stage через CLI-агента с поддержкой fallback_agent
712
+ * @param {string} stageId - ID stage из конфигурации
713
+ * @returns {Promise<{status: string, output: string, result?: object}>}
714
+ */
715
+ async execute(stageId) {
716
+ const stage = this.pipeline.stages[stageId];
717
+ if (!stage) {
718
+ throw new Error(`Stage not found: ${stageId}`);
719
+ }
720
+
721
+ // Выбираем агента: если есть agent_by_attempt и счётчик — ротация по попыткам
722
+ let agentId = stage.agent || this.pipeline.default_agent;
723
+ if (stage.agent_by_attempt && stage.counter) {
724
+ const attempt = this.counters[stage.counter] || 0;
725
+ if (stage.agent_by_attempt[attempt]) {
726
+ agentId = stage.agent_by_attempt[attempt];
727
+ if (this.logger) {
728
+ this.logger.info(`Agent rotation: attempt ${attempt} → ${agentId}`, stageId);
729
+ }
730
+ }
731
+ }
732
+ const agent = this.pipeline.agents[agentId];
733
+ if (!agent) {
734
+ throw new Error(`Agent not found: ${agentId}`);
735
+ }
736
+
737
+ // Формируем промпт для агента через PromptBuilder
738
+ const prompt = this.promptBuilder.build(stage, stageId);
739
+
740
+ // Логгируем старт stage
741
+ if (this.logger) {
742
+ this.logger.stageStart(stageId, agentId, stage.skill);
743
+ } else {
744
+ console.log(`\n[StageExecutor] Executing stage: ${stageId}`);
745
+ console.log(` Agent: ${agentId} (${agent.command})`);
746
+ console.log(` Skill: ${stage.skill}`);
747
+ }
748
+
749
+ // Снимаем snapshot защищённых файлов перед выполнением
750
+ if (this.fileGuard) {
751
+ this.fileGuard.takeSnapshot();
752
+ }
753
+
754
+ // Вызываем CLI-агента с поддержкой fallback
755
+ const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill, stage.fallback_agent);
756
+
757
+ // Логгируем завершение stage
758
+ if (this.logger) {
759
+ this.logger.stageComplete(stageId, result.status, result.exitCode);
760
+ }
761
+
762
+ // Проверяем и откатываем несанкционированные изменения
763
+ if (this.fileGuard) {
764
+ const violations = this.fileGuard.checkAndRollback();
765
+ if (violations.length > 0) {
766
+ result.violations = violations;
767
+ }
768
+ }
769
+
770
+ return result;
771
+ }
772
+
773
+ /**
774
+ * Вызывает CLI-агента через child_process
775
+ */
776
+ callAgent(agent, prompt, stageId, skillId) {
777
+ return new Promise((resolve, reject) => {
778
+ const timeout = this.pipeline.execution?.timeout_per_stage || 300;
779
+ const args = [...agent.args];
780
+
781
+ // Промпт всегда передаётся последним аргументом
782
+ args.push(prompt);
783
+
784
+ // Логгируем команду перед запуском (вместо промпта — имя skill)
785
+ if (this.logger) {
786
+ this.logger.info(`RUN ${agent.command} ${[...args.slice(0, -1), skillId].join(' ')}`, stageId);
787
+ // Логгируем входные параметры агента (context + counters)
788
+ const promptLines = prompt.split('\n').filter(l => l.trim());
789
+ if (promptLines.length > 1) {
790
+ for (const line of promptLines.slice(1)) {
791
+ this.logger.info(` ${line}`, stageId);
792
+ }
793
+ }
794
+ }
795
+
796
+ const child = spawn(agent.command, args, {
797
+ cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
798
+ stdio: ['pipe', 'pipe', 'pipe'],
799
+ // npm-бинари (.cmd) на Windows требуют shell: true,
800
+ // но нативные executables (node) — нет: shell: true обрезает многострочные аргументы
801
+ shell: process.platform === 'win32' && agent.command !== 'node'
802
+ });
803
+
804
+ // Закрываем stdin чтобы агент не ждал дополнительного ввода
805
+ child.stdin.end();
806
+
807
+ let stdout = '';
808
+ let stderr = '';
809
+ let timedOut = false;
810
+
811
+ // Таймаут
812
+ const timeoutId = setTimeout(() => {
813
+ timedOut = true;
814
+ child.kill('SIGTERM');
815
+ if (this.logger) {
816
+ this.logger.timeout(stageId, timeout);
817
+ }
818
+ reject(new Error(`Stage "${stageId}" timed out after ${timeout}s`));
819
+ }, timeout * 1000);
820
+
821
+ let stdoutBuffer = '';
822
+ child.stdout.on('data', (data) => {
823
+ const chunk = data.toString();
824
+ stdout += chunk;
825
+ // Парсим stream-json и выводим только текст дельт
826
+ stdoutBuffer += chunk;
827
+ const lines = stdoutBuffer.split('\n');
828
+ stdoutBuffer = lines.pop(); // незавершённая строка остаётся в буфере
829
+ for (const line of lines) {
830
+ if (!line.trim()) continue;
831
+ try {
832
+ const obj = JSON.parse(line);
833
+ // Claude: content_block_delta с delta.text
834
+ if (obj.type === 'content_block_delta' && obj.delta?.text) {
835
+ process.stdout.write(obj.delta.text);
836
+ }
837
+ // Qwen/Claude: assistant message с content text
838
+ else if (obj.type === 'assistant' && obj.message?.content) {
839
+ for (const block of obj.message.content) {
840
+ if (block.type === 'text' && block.text) {
841
+ process.stdout.write(block.text);
842
+ }
843
+ }
844
+ }
845
+ // result содержит финальный текст (дублирует assistant) — пропускаем
846
+ } catch {
847
+ // не JSON — выводим как есть
848
+ process.stdout.write(line + '\n');
849
+ }
850
+ }
851
+ });
852
+
853
+ child.stderr.on('data', (data) => {
854
+ stderr += data.toString();
855
+ process.stderr.write(data);
856
+ });
857
+
858
+ child.on('close', (code) => {
859
+ clearTimeout(timeoutId);
860
+ // Обрабатываем остаток буфера стриминга
861
+ if (stdoutBuffer.trim()) {
862
+ try {
863
+ const obj = JSON.parse(stdoutBuffer);
864
+ if (obj.type === 'content_block_delta' && obj.delta?.text) {
865
+ process.stdout.write(obj.delta.text);
866
+ }
867
+ } catch {
868
+ process.stdout.write(stdoutBuffer + '\n');
869
+ }
870
+ }
871
+ process.stdout.write('\n');
872
+
873
+ if (timedOut) return;
874
+
875
+ // Логгируем CLI вызов
876
+ if (this.logger) {
877
+ this.logger.cliCall(agent.command, args, code);
878
+ }
879
+
880
+ // Парсим результат из вывода агента через ResultParser
881
+ const result = this.resultParser.parse(stdout, stageId);
882
+
883
+ // Если exit code ≠ 0 — это ошибка, которая может триггерить fallback
884
+ if (code !== 0) {
885
+ const err = new Error(`Agent exited with code ${code}`);
886
+ err.code = 'NON_ZERO_EXIT';
887
+ err.exitCode = code;
888
+ if (this.logger) {
889
+ this.logger.error(`Agent exited with code ${code}`, stageId);
890
+ }
891
+ reject(err);
892
+ return;
893
+ }
894
+
895
+ resolve({
896
+ status: result.status || 'default',
897
+ output: stdout,
898
+ stderr: stderr,
899
+ result: result.data || {},
900
+ exitCode: code,
901
+ parsed: result.parsed
902
+ });
903
+ });
904
+
905
+ child.on('error', (err) => {
906
+ clearTimeout(timeoutId);
907
+ if (!timedOut) {
908
+ if (this.logger) {
909
+ this.logger.error(`CLI error: ${err.message}`, stageId);
910
+ }
911
+ reject(err);
912
+ }
913
+ });
914
+ });
915
+ }
916
+
917
+ /**
918
+ * Вызывает CLI-агента с поддержкой fallback_agent
919
+ * При ошибке основного агента (exit code ≠ 0, ENOENT, таймаут) переключается на fallback_agent
920
+ * @param {object} agent - Основной агент из конфигурации
921
+ * @param {string} prompt - Промпт для агента
922
+ * @param {string} stageId - ID stage для логирования
923
+ * @param {string} skillId - ID skill для логирования
924
+ * @param {string|null} fallbackAgentId - ID fallback агента (опционально)
925
+ * @returns {Promise<{status: string, output: string, result?: object, exitCode: number}>}
926
+ */
927
+ async callAgentWithFallback(agent, prompt, stageId, skillId, fallbackAgentId) {
928
+ try {
929
+ // Пытаемся вызвать основной агент
930
+ return await this.callAgent(agent, prompt, stageId, skillId);
931
+ } catch (err) {
932
+ // Проверяем, есть ли fallback_agent
933
+ if (!fallbackAgentId) {
934
+ // Fallback не задан — пробрасываем ошибку
935
+ throw err;
936
+ }
937
+
938
+ // Проверяем тип ошибки — должна быть retry-able
939
+ const isRetryableError =
940
+ err.code === 'ENOENT' || // Команда не найдена
941
+ err.code === 'ETIMEDOUT' || // Таймаут
942
+ err.code === 'NON_ZERO_EXIT' || // Exit code ≠ 0
943
+ err.message.includes('timed out'); // Таймаут от timeoutId
944
+
945
+ if (!isRetryableError) {
946
+ // Неретраемая ошибка — пробрасываем
947
+ throw err;
948
+ }
949
+
950
+ // Логгируем переключение на fallback_agent
951
+ if (this.logger) {
952
+ this.logger.warn(`Primary agent failed, switching to fallback: ${fallbackAgentId}`, stageId);
953
+ } else {
954
+ console.log(`[StageExecutor] Primary agent failed, switching to fallback: ${fallbackAgentId}`);
955
+ }
956
+
957
+ // Находим fallback агента в конфигурации
958
+ const fallbackAgent = this.pipeline.agents[fallbackAgentId];
959
+ if (!fallbackAgent) {
960
+ const errMsg = `Fallback agent not found: ${fallbackAgentId}`;
961
+ if (this.logger) {
962
+ this.logger.error(errMsg, stageId);
963
+ } else {
964
+ console.error(`[StageExecutor] ${errMsg}`);
965
+ }
966
+ throw err; // Пробрасываем оригинальную ошибку
967
+ }
968
+
969
+ // Вызываем fallback агента
970
+ try {
971
+ return await this.callAgent(fallbackAgent, prompt, stageId, skillId);
972
+ } catch (fallbackErr) {
973
+ // Если fallback тоже упал — пробрасываем ошибку fallback агента
974
+ if (this.logger) {
975
+ this.logger.error(`Fallback agent also failed: ${fallbackErr.message}`, stageId);
976
+ } else {
977
+ console.error(`[StageExecutor] Fallback agent also failed: ${fallbackErr.message}`);
978
+ }
979
+ throw fallbackErr;
980
+ }
981
+ }
982
+ }
983
+ }
984
+
985
+ // ============================================================================
986
+ // PipelineRunner — основной цикл выполнения пайплайна
987
+ // ============================================================================
988
+ class PipelineRunner {
989
+ constructor(config, args) {
990
+ this.config = config;
991
+ this.args = args;
992
+ this.pipeline = config.pipeline;
993
+ this.context = { ...this.pipeline.context };
994
+ this.counters = {};
995
+ this.stepCount = 0;
996
+ this.tasksExecuted = 0;
997
+ this.running = true;
998
+ this.currentStage = this.pipeline.entry;
999
+
1000
+ // Базовая директория проекта вычисляется динамически
1001
+ const projectRoot = args.project ? path.resolve(args.project) : findProjectRoot();
1002
+
1003
+ // Инициализация Logger — каждый запуск пишется в отдельный файл
1004
+ const logDir = this.pipeline.execution?.log_file
1005
+ ? path.dirname(path.resolve(projectRoot, this.pipeline.execution.log_file))
1006
+ : path.resolve(projectRoot, '.workflow/logs');
1007
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').substring(0, 19);
1008
+ const logFilePath = path.resolve(logDir, `pipeline_${timestamp}.log`);
1009
+ this.logger = new Logger(logFilePath);
1010
+ this.loggerInitialized = false;
1011
+
1012
+ // Инициализация контекста из CLI аргументов
1013
+ if (args.plan) {
1014
+ this.context.plan_id = args.plan;
1015
+ }
1016
+
1017
+ // Инициализация FileGuard для защиты файлов от изменений агентами
1018
+ const protectedPatterns = this.pipeline.protected_files || [];
1019
+ this.fileGuard = new FileGuard(protectedPatterns, projectRoot);
1020
+ this.projectRoot = projectRoot;
1021
+
1022
+ // Настройка graceful shutdown
1023
+ this.setupGracefulShutdown();
1024
+ }
1025
+
1026
+ /**
1027
+ * Асинхронно инициализирует runner (logger)
1028
+ */
1029
+ async init() {
1030
+ await this.logger.init();
1031
+ this.loggerInitialized = true;
1032
+
1033
+ // Логгируем после инициализации
1034
+ const protectedPatterns = this.pipeline.protected_files || [];
1035
+ if (protectedPatterns.length > 0) {
1036
+ this.logger.info(`FileGuard enabled: ${protectedPatterns.length} pattern(s)`, 'PipelineRunner');
1037
+ }
1038
+
1039
+ if (this.context.plan_id) {
1040
+ this.logger.info(`Plan ID: ${this.context.plan_id}`, 'PipelineRunner');
1041
+ } else {
1042
+ this.logger.info('No plan_id set — processing all tickets', 'PipelineRunner');
1043
+ }
1044
+ }
1045
+
1046
+ /**
1047
+ * Выполняет встроенный стейдж типа update-counter:
1048
+ * инкрементирует счётчик и возвращает статус для goto-перехода.
1049
+ *
1050
+ * Конфигурация стейджа:
1051
+ * type: update-counter
1052
+ * counter: <name> — имя счётчика
1053
+ * max: <number> — максимальное значение (опционально)
1054
+ * goto:
1055
+ * default: <stage> — следующий стейдж
1056
+ * max_reached: <stage> — стейдж при достижении max
1057
+ */
1058
+ executeUpdateCounter(stageId, stage) {
1059
+ const counterName = stage.counter;
1060
+ if (!counterName) {
1061
+ throw new Error(`Stage "${stageId}" has type update-counter but no counter specified`);
1062
+ }
1063
+
1064
+ this.counters[counterName] = (this.counters[counterName] || 0) + 1;
1065
+ const value = this.counters[counterName];
1066
+
1067
+ if (this.logger) {
1068
+ this.logger.info(`Counter "${counterName}" incremented to ${value}`, stageId);
1069
+ }
1070
+
1071
+ const max = stage.max;
1072
+ const status = (max && value >= max) ? 'max_reached' : 'default';
1073
+
1074
+ return { status, result: { counter: counterName, value } };
1075
+ }
1076
+
1077
+ /**
1078
+ * Запускает основной цикл выполнения
1079
+ */
1080
+ async run() {
1081
+ // Инициализируем logger
1082
+ await this.init();
1083
+
1084
+ const maxSteps = this.pipeline.execution?.max_steps || 100;
1085
+ const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
1086
+
1087
+ this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
1088
+ this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
1089
+ this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
1090
+ this.logger.info(`Context: ${JSON.stringify(this.context)}`, 'PipelineRunner');
1091
+
1092
+ while (this.running && this.stepCount < maxSteps) {
1093
+ this.stepCount++;
1094
+
1095
+ this.logger.info(`Step ${this.stepCount}`, 'PipelineRunner');
1096
+ this.logger.info(`Current stage: ${this.currentStage}`, 'PipelineRunner');
1097
+
1098
+ if (this.currentStage === 'end') {
1099
+ this.logger.info('Pipeline completed successfully!', 'PipelineRunner');
1100
+ break;
1101
+ }
1102
+
1103
+ try {
1104
+ // Выполняем stage
1105
+ const stage = this.pipeline.stages[this.currentStage];
1106
+ if (!stage) {
1107
+ throw new Error(`Stage not found: ${this.currentStage}`);
1108
+ }
1109
+
1110
+ let result;
1111
+
1112
+ // Встроенный тип стейджа: update-counter — инкрементирует счётчик без вызова агента
1113
+ if (stage.type === 'update-counter') {
1114
+ result = this.executeUpdateCounter(this.currentStage, stage);
1115
+ } else {
1116
+ const executor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
1117
+ result = await executor.execute(this.currentStage);
1118
+ }
1119
+
1120
+ this.logger.info(`Stage ${this.currentStage} completed with status: ${result.status}`, 'PipelineRunner');
1121
+
1122
+ // Определяем следующий stage по goto-логике
1123
+ const nextStage = this.resolveNextStage(this.currentStage, result);
1124
+
1125
+ // Считаем выполненные задачи (execute-task)
1126
+ if (this.currentStage === 'execute-task' && result.status !== 'error') {
1127
+ this.tasksExecuted++;
1128
+ }
1129
+
1130
+ // Переход к следующему stage
1131
+ this.currentStage = nextStage;
1132
+
1133
+ // Задержка между stages
1134
+ if (nextStage !== 'end' && this.running) {
1135
+ this.logger.info(`Waiting ${delayBetweenStages}s before next stage...`, 'PipelineRunner');
1136
+ await this.sleep(delayBetweenStages * 1000);
1137
+ }
1138
+
1139
+ } catch (err) {
1140
+ this.logger.error(`Error at stage "${this.currentStage}": ${err.message}`, 'PipelineRunner');
1141
+
1142
+ // Пытаемся получить fallback transition
1143
+ const stage = this.pipeline.stages[this.currentStage];
1144
+ if (stage?.goto?.error) {
1145
+ const errorTarget = typeof stage.goto.error === 'string' ? stage.goto.error : stage.goto.error.stage;
1146
+ this.logger.info(`Transitioning to error handler: ${errorTarget}`, 'PipelineRunner');
1147
+ this.currentStage = errorTarget;
1148
+
1149
+ // Обновляем контекст параметрами из error transition
1150
+ if (typeof stage.goto.error === 'object' && stage.goto.error.params) {
1151
+ this.updateContext(stage.goto.error.params, { error: err.message });
1152
+ }
1153
+ } else {
1154
+ this.logger.error('No error handler defined. Stopping.', 'PipelineRunner');
1155
+ this.running = false;
1156
+ }
1157
+ }
1158
+ }
1159
+
1160
+ if (this.stepCount >= maxSteps) {
1161
+ this.logger.error(`Stopped: reached max steps limit (${maxSteps})`, 'PipelineRunner');
1162
+ }
1163
+
1164
+ this.logger.info('=== Pipeline Runner Finished ===', 'PipelineRunner');
1165
+ this.logger.info(`Total steps: ${this.stepCount}`, 'PipelineRunner');
1166
+ this.logger.info(`Tasks executed: ${this.tasksExecuted}`, 'PipelineRunner');
1167
+ this.logger.info(`Final context: ${JSON.stringify(this.context)}`, 'PipelineRunner');
1168
+
1169
+ // Записываем итоговый summary
1170
+ this.logger.writeSummary();
1171
+
1172
+ return {
1173
+ steps: this.stepCount,
1174
+ tasksExecuted: this.tasksExecuted,
1175
+ context: this.context,
1176
+ failed: !this.running && this.stepCount < maxSteps
1177
+ };
1178
+ }
1179
+
1180
+ /**
1181
+ * Определяет следующий stage на основе результата и goto-конфигурации
1182
+ * Также управляет retry-логикой с agent_by_attempt
1183
+ */
1184
+ resolveNextStage(stageId, result) {
1185
+ const stage = this.pipeline.stages[stageId];
1186
+ if (!stage || !stage.goto) {
1187
+ this.logger.gotoTransition(stageId, 'end', result.status);
1188
+ return 'end';
1189
+ }
1190
+
1191
+ const goto = stage.goto;
1192
+ const status = result.status;
1193
+
1194
+ // Проверяем точное совпадение статуса
1195
+ if (goto[status]) {
1196
+ const transition = goto[status];
1197
+
1198
+ // Если переход задан строкой (shorthand: "stage-name")
1199
+ if (typeof transition === 'string') {
1200
+ this.logger.gotoTransition(stageId, transition, status);
1201
+ return transition;
1202
+ }
1203
+
1204
+ // Обновляем контекст параметрами перехода
1205
+ if (transition.params) {
1206
+ this.updateContext(transition.params, result.result);
1207
+ }
1208
+
1209
+ const nextStage = transition.stage || 'end';
1210
+ this.logger.gotoTransition(stageId, nextStage, status, transition.params);
1211
+ return nextStage;
1212
+ }
1213
+
1214
+ // Fallback на default
1215
+ if (goto.default) {
1216
+ const transition = goto.default;
1217
+
1218
+ if (typeof transition === 'string') {
1219
+ this.logger.gotoTransition(stageId, transition, 'default');
1220
+ return transition;
1221
+ }
1222
+
1223
+ if (transition.params) {
1224
+ this.updateContext(transition.params, result.result);
1225
+ }
1226
+
1227
+ const nextStage = transition.stage || 'end';
1228
+ this.logger.gotoTransition(stageId, nextStage, 'default', transition.params);
1229
+ return nextStage;
1230
+ }
1231
+
1232
+ this.logger.gotoTransition(stageId, 'end', 'default');
1233
+ return 'end';
1234
+ }
1235
+
1236
+ /**
1237
+ * Обновляет контекст переменными из params с подстановкой значений
1238
+ */
1239
+ updateContext(params, resultData) {
1240
+ if (!params) return;
1241
+
1242
+ // Проверяем смену ticket_id для сброса счётчика попыток
1243
+ const newTicketId = params.ticket_id ?
1244
+ (typeof params.ticket_id === 'string' ?
1245
+ params.ticket_id
1246
+ .replace(/\$result\.(\w+)/g, (_, k) => resultData[k] || '')
1247
+ .replace(/\$context\.(\w+)/g, (_, k) => this.context[k] || '')
1248
+ : params.ticket_id)
1249
+ : null;
1250
+
1251
+ if (newTicketId && this.context.ticket_id && newTicketId !== this.context.ticket_id) {
1252
+ // Тикет сменился — сбрасываем все счётчики попыток
1253
+ for (const counterKey of Object.keys(this.counters)) {
1254
+ if (counterKey.includes('attempt')) {
1255
+ this.counters[counterKey] = 0;
1256
+ if (this.logger) {
1257
+ this.logger.info(`Reset counter "${counterKey}" due to ticket change (${this.context.ticket_id} → ${newTicketId})`, 'PipelineRunner');
1258
+ }
1259
+ }
1260
+ }
1261
+ }
1262
+
1263
+ for (const [key, value] of Object.entries(params)) {
1264
+ if (typeof value === 'string') {
1265
+ // Подстановка переменных: $context.*, $result.*, $counter.*
1266
+ let resolvedValue = value;
1267
+
1268
+ // $result.*
1269
+ resolvedValue = resolvedValue.replace(/\$result\.(\w+)/g, (_, k) => resultData[k] || '');
1270
+
1271
+ // $context.*
1272
+ resolvedValue = resolvedValue.replace(/\$context\.(\w+)/g, (_, k) => this.context[k] || '');
1273
+
1274
+ // $counter.*
1275
+ resolvedValue = resolvedValue.replace(/\$counter\.(\w+)/g, (_, k) => this.counters[k] || 0);
1276
+
1277
+ this.context[key] = resolvedValue;
1278
+ } else {
1279
+ this.context[key] = value;
1280
+ }
1281
+ }
1282
+
1283
+ if (this.logger) {
1284
+ this.logger.info(`Context updated: ${JSON.stringify(this.context)}`, 'PipelineRunner');
1285
+ }
1286
+ }
1287
+
1288
+ /**
1289
+ * Утилита для задержки
1290
+ */
1291
+ sleep(ms) {
1292
+ return new Promise(resolve => setTimeout(resolve, ms));
1293
+ }
1294
+
1295
+ /**
1296
+ * Настройка graceful shutdown
1297
+ */
1298
+ setupGracefulShutdown() {
1299
+ const shutdown = (signal) => {
1300
+ if (this.logger) {
1301
+ this.logger.info(`Received ${signal}. Shutting down gracefully...`, 'PipelineRunner');
1302
+ }
1303
+ this.running = false;
1304
+ };
1305
+
1306
+ process.on('SIGINT', () => shutdown('SIGINT'));
1307
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1308
+ }
1309
+ }
1310
+
1311
+ function parseArgs(argv) {
1312
+ const args = {
1313
+ plan: null,
1314
+ config: null,
1315
+ project: null,
1316
+ help: false
1317
+ };
1318
+
1319
+ for (let i = 0; i < argv.length; i++) {
1320
+ const arg = argv[i];
1321
+
1322
+ switch (arg) {
1323
+ case '--help':
1324
+ case '-h':
1325
+ args.help = true;
1326
+ break;
1327
+ case '--plan':
1328
+ args.plan = argv[++i] || null;
1329
+ break;
1330
+ case '--config':
1331
+ args.config = argv[++i] || null;
1332
+ break;
1333
+ case '--project':
1334
+ args.project = argv[++i] || null;
1335
+ break;
1336
+ default:
1337
+ if (arg.startsWith('--')) {
1338
+ console.error(`Unknown option: ${arg}`);
1339
+ process.exit(1);
1340
+ }
1341
+ }
1342
+ }
1343
+
1344
+ return args;
1345
+ }
1346
+
1347
+ function printHelp() {
1348
+ console.log(`
1349
+ Workflow Runner - Pipeline Orchestrator
1350
+
1351
+ Usage: node runner.mjs [options]
1352
+
1353
+ Options:
1354
+ --plan PLAN-ID Plan ID to execute (e.g., PLAN-003)
1355
+ --config PATH Path to pipeline.yaml config (default: .workflow/config/pipeline.yaml)
1356
+ --project PATH Project root path (overrides auto-detection)
1357
+ --help, -h Show this help message
1358
+
1359
+ Examples:
1360
+ node .workflow/src/runner.mjs --help
1361
+ node .workflow/src/runner.mjs --plan PLAN-003
1362
+ node runner.mjs --project /path/to/project --plan PLAN-003
1363
+ `);
1364
+ }
1365
+
1366
+ function loadConfig(configPath) {
1367
+ const fullPath = path.resolve(configPath);
1368
+
1369
+ if (!fs.existsSync(fullPath)) {
1370
+ throw new Error(`Config file not found: ${fullPath}`);
1371
+ }
1372
+
1373
+ const content = fs.readFileSync(fullPath, 'utf8');
1374
+ const config = yaml.load(content);
1375
+
1376
+ return config;
1377
+ }
1378
+
1379
+ function validateConfig(config) {
1380
+ const errors = [];
1381
+
1382
+ if (!config) {
1383
+ errors.push('Config is empty');
1384
+ return errors;
1385
+ }
1386
+
1387
+ if (!config.pipeline) {
1388
+ errors.push('Missing required field: pipeline');
1389
+ return errors;
1390
+ }
1391
+
1392
+ const pipeline = config.pipeline;
1393
+
1394
+ if (!pipeline.name || typeof pipeline.name !== 'string') {
1395
+ errors.push('Missing or invalid required field: pipeline.name (string)');
1396
+ }
1397
+
1398
+ if (!pipeline.version || typeof pipeline.version !== 'string') {
1399
+ errors.push('Missing or invalid required field: pipeline.version (string)');
1400
+ }
1401
+
1402
+ if (!pipeline.agents || typeof pipeline.agents !== 'object') {
1403
+ errors.push('Missing or invalid required field: pipeline.agents (object)');
1404
+ }
1405
+
1406
+ if (!pipeline.stages || typeof pipeline.stages !== 'object') {
1407
+ errors.push('Missing or invalid required field: pipeline.stages (object)');
1408
+ }
1409
+
1410
+ if (pipeline.agents && pipeline.stages) {
1411
+ const agentIds = Object.keys(pipeline.agents);
1412
+ const stageIds = Object.keys(pipeline.stages);
1413
+
1414
+ for (const [stageId, stage] of Object.entries(pipeline.stages)) {
1415
+ const resolvedAgent = stage.agent || pipeline.default_agent;
1416
+ if (resolvedAgent && !agentIds.includes(resolvedAgent)) {
1417
+ errors.push(`Stage "${stageId}" references non-existent agent: ${resolvedAgent}`);
1418
+ }
1419
+
1420
+ if (stage.goto) {
1421
+ for (const [status, transition] of Object.entries(stage.goto)) {
1422
+ if (status === 'default') continue;
1423
+ if (transition.stage && transition.stage !== 'end' && !stageIds.includes(transition.stage)) {
1424
+ errors.push(`Stage "${stageId}" goto.${status} references non-existent stage: ${transition.stage}`);
1425
+ }
1426
+ }
1427
+ }
1428
+ }
1429
+ }
1430
+
1431
+ return errors;
1432
+ }
1433
+
1434
+ async function runPipeline(argv = process.argv.slice(2)) {
1435
+ const args = parseArgs(argv);
1436
+
1437
+ if (args.help) {
1438
+ printHelp();
1439
+ return { exitCode: 0, help: true };
1440
+ }
1441
+
1442
+ // Resolve config path
1443
+ if (!args.config) {
1444
+ const projectRoot = args.project ? path.resolve(args.project) : findProjectRoot();
1445
+ args.config = path.resolve(projectRoot, '.workflow/config/pipeline.yaml');
1446
+ }
1447
+
1448
+ console.log('=== Workflow Runner ===');
1449
+ console.log(`Config: ${args.config}`);
1450
+ if (args.plan) console.log(`Plan: ${args.plan}`);
1451
+ if (args.project) console.log(`Project: ${args.project}`);
1452
+ console.log('');
1453
+
1454
+ try {
1455
+ const config = loadConfig(args.config);
1456
+ const errors = validateConfig(config);
1457
+
1458
+ if (errors.length > 0) {
1459
+ console.error('Configuration validation failed:');
1460
+ errors.forEach(err => console.error(` - ${err}`));
1461
+ return { exitCode: 1, error: 'Configuration validation failed', details: errors };
1462
+ }
1463
+
1464
+ console.log(`Pipeline: ${config.pipeline.name} v${config.pipeline.version}`);
1465
+ console.log(`Agents: ${Object.keys(config.pipeline.agents).join(', ')}`);
1466
+ console.log(`Stages: ${Object.keys(config.pipeline.stages).join(', ')}`);
1467
+ console.log('');
1468
+ console.log('Configuration validated successfully!');
1469
+
1470
+ // Запускаем пайплайн
1471
+ const runner = new PipelineRunner(config, args);
1472
+ const result = await runner.run();
1473
+
1474
+ console.log('\n=== Summary ===');
1475
+ console.log(`Steps executed: ${result.steps}`);
1476
+ console.log(`Tasks completed: ${result.tasksExecuted}`);
1477
+
1478
+ return { exitCode: result.failed ? 1 : 0, result };
1479
+
1480
+ } catch (err) {
1481
+ console.error(`\nError: ${err.message}`);
1482
+ console.error(err.stack);
1483
+
1484
+ // Даём файлу логов время записаться перед выходом
1485
+ await new Promise(resolve => setTimeout(resolve, 100));
1486
+
1487
+ return { exitCode: 1, error: err.message, stack: err.stack };
1488
+ }
1489
+ }
1490
+
1491
+ // Export for use as ES module
1492
+ export { runPipeline, parseArgs, PipelineRunner };