workflow-ai 1.0.0

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