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/README.md +76 -0
- package/agent-templates/CLAUDE.md.tpl +42 -0
- package/agent-templates/QWEN.md.tpl +34 -0
- package/bin/workflow.mjs +3 -0
- package/configs/config.yaml +117 -0
- package/configs/pipeline.yaml +422 -0
- package/package.json +24 -0
- package/src/cli.mjs +131 -0
- package/src/init.mjs +441 -0
- package/src/lib/find-root.mjs +33 -0
- package/src/lib/utils.mjs +66 -0
- package/src/runner.mjs +1466 -0
- package/src/scripts/check-anomalies.js +161 -0
- package/src/scripts/move-ticket.js +228 -0
- package/src/scripts/move-to-ready.js +110 -0
- package/src/scripts/move-to-review.js +88 -0
- package/src/scripts/pick-next-task.js +345 -0
- package/src/skills/analyze-report/SKILL.md +110 -0
- package/src/skills/check-conditions/SKILL.md +140 -0
- package/src/skills/create-plan/SKILL.md +98 -0
- package/src/skills/create-report/SKILL.md +156 -0
- package/src/skills/decompose-gaps/SKILL.md +122 -0
- package/src/skills/decompose-plan/SKILL.md +109 -0
- package/src/skills/execute-task/SKILL.md +117 -0
- package/src/skills/review-result/SKILL.md +274 -0
- package/templates/plan-template.md +116 -0
- package/templates/report-template.md +178 -0
- package/templates/ticket-template.md +103 -0
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 };
|