workflow-ai 1.0.60 → 1.0.62

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