workflow-ai 1.2.1 → 1.3.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.2.1",
3
+ "version": "1.3.1",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,9 +36,19 @@
36
36
  "scripts": {
37
37
  "test": "node --test src/tests/*.test.mjs",
38
38
  "test:skills": "node src/scripts/run-skill-tests.js --all",
39
+ "coverage": "c8 node --test src/tests/*.test.mjs",
40
+ "coverage:mark-blocked": "c8 node --test src/tests/scripts-mark-blocked.test.mjs",
41
+ "coverage:pick-next-task": "node scripts/coverage-pick-next-task.js",
42
+ "coverage:move-ticket": "c8 node --test src/tests/scripts-move-ticket-approval-hook.test.mjs",
43
+ "mutation-test": "stryker run",
39
44
  "release": "npm version patch && npm publish"
40
45
  },
41
46
  "dependencies": {
42
47
  "js-yaml": "^4.1.0"
48
+ },
49
+ "devDependencies": {
50
+ "@stryker-mutator/core": "^9.6.1",
51
+ "@stryker-mutator/javascript-mutator": "^4.0.0",
52
+ "c8": "^11.0.0"
43
53
  }
44
54
  }
@@ -0,0 +1,108 @@
1
+ import fs from 'fs';
2
+ import path from 'path';
3
+
4
+ const MARKER_FILE = '.pipeline.lock';
5
+ const LOGS_DIR = '.workflow/logs';
6
+
7
+ /**
8
+ * Ensures the logs directory exists
9
+ */
10
+ function ensureLogsDir(projectRoot) {
11
+ const logsPath = path.join(projectRoot, LOGS_DIR);
12
+ if (!fs.existsSync(logsPath)) {
13
+ fs.mkdirSync(logsPath, { recursive: true });
14
+ }
15
+ }
16
+
17
+ /**
18
+ * Atomic write via temp file + rename.
19
+ * Fallback to O_EXCL (wx flag) on EXDEV/ENOTSUP.
20
+ * Throws Error if marker file already exists (to prevent race conditions).
21
+ */
22
+ export function writeMarker(projectRoot, payload) {
23
+ ensureLogsDir(projectRoot);
24
+ const markerPath = path.join(projectRoot, LOGS_DIR, MARKER_FILE);
25
+ const content = JSON.stringify(payload, null, 2);
26
+
27
+ // First, try exclusive create (O_EXCL) - this guarantees atomicity
28
+ // and prevents race conditions where two processes both think they created the marker
29
+ try {
30
+ const fd = fs.openSync(markerPath, 'wx');
31
+ fs.writeFileSync(fd, content, 'utf-8');
32
+ fs.closeSync(fd);
33
+ return;
34
+ } catch (openErr) {
35
+ if (openErr.code === 'EEXIST') {
36
+ // Marker already exists - another process created it
37
+ throw new Error(`Marker file already exists at ${markerPath}`);
38
+ }
39
+ // If EXDEV/ENOTSUP (cross-device link), fall back to temp+rename
40
+ if (openErr.code !== 'EXDEV' && openErr.code !== 'ENOTSUP') {
41
+ throw openErr;
42
+ }
43
+ }
44
+
45
+ // Fallback: temp file + rename (less atomic but works across devices)
46
+ const tempPath = markerPath + '.tmp.' + process.pid + '.' + Date.now();
47
+ try {
48
+ fs.writeFileSync(tempPath, content, 'utf-8');
49
+ fs.renameSync(tempPath, markerPath);
50
+ return;
51
+ } catch (renameErr) {
52
+ // Cleanup temp file if it still exists
53
+ try {
54
+ fs.unlinkSync(tempPath);
55
+ } catch {
56
+ // ignore
57
+ }
58
+
59
+ // If rename failed because marker was created by another process in the meantime
60
+ if (renameErr.code === 'EEXIST' || !fs.existsSync(tempPath)) {
61
+ throw new Error(`Marker file already exists at ${markerPath}`);
62
+ }
63
+
64
+ throw renameErr;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Reads and parses marker file. Returns null if file doesn't exist or is invalid.
70
+ */
71
+ export function readMarker(projectRoot) {
72
+ const markerPath = path.join(projectRoot, LOGS_DIR, MARKER_FILE);
73
+ try {
74
+ const data = fs.readFileSync(markerPath, 'utf-8');
75
+ return JSON.parse(data);
76
+ } catch (err) {
77
+ // ENOENT (file missing) or SyntaxError (invalid JSON) → return null
78
+ return null;
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Silently removes marker file. Ignores ENOENT.
84
+ */
85
+ export function removeMarker(projectRoot) {
86
+ const markerPath = path.join(projectRoot, LOGS_DIR, MARKER_FILE);
87
+ try {
88
+ fs.unlinkSync(markerPath);
89
+ } catch (err) {
90
+ // Ignore ENOENT — file already gone
91
+ if (err.code !== 'ENOENT') {
92
+ // Log warning but don't throw — silent unlink
93
+ console.warn(`[marker] failed to remove ${markerPath}: ${err.message}`);
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validates that marker exists and its pid matches expectedPid.
100
+ * Returns true only if both conditions hold, false otherwise.
101
+ */
102
+ export function validateMarker(projectRoot, expectedPid) {
103
+ const marker = readMarker(projectRoot);
104
+ if (!marker) {
105
+ return false;
106
+ }
107
+ return marker.pid === expectedPid;
108
+ }
@@ -0,0 +1,11 @@
1
+ export function processAlive(pid) {
2
+ if (!Number.isInteger(pid) || pid <= 0) return false;
3
+ try {
4
+ process.kill(pid, 0); // signal 0: existence check, не убивает процесс
5
+ return true;
6
+ } catch (err) {
7
+ // ESRCH = no process; EPERM = process exists but no permission
8
+ if (err.code === 'EPERM') return true;
9
+ return false;
10
+ }
11
+ }
@@ -0,0 +1,82 @@
1
+ import { readMarker, removeMarker } from './marker.mjs';
2
+ import { processAlive } from './process-alive.mjs';
3
+ import { execSync } from 'node:child_process';
4
+ import { existsSync } from 'node:fs';
5
+
6
+ /**
7
+ * Sleep helper
8
+ */
9
+ function sleep(ms) {
10
+ return new Promise(resolve => setTimeout(resolve, ms));
11
+ }
12
+
13
+ /**
14
+ * Gracefully stops a running pipeline.
15
+ *
16
+ * @param {string} projectRoot - Absolute path to project root
17
+ * @param {object} options - Options
18
+ * @param {number} [options.graceSec=10] - Grace period in seconds before SIGKILL
19
+ * @returns {object} Result object
20
+ * - { ok: true, pid: number, escalated: boolean, duration_ms: number } on success
21
+ * - { ok: true, was_stale: true } if marker existed but process was dead
22
+ * - { ok: false, code: 'NOT_RUNNING' } if no marker found
23
+ */
24
+ export async function stopPipeline(projectRoot, options = {}) {
25
+ const graceSec = options.graceSec ?? 10;
26
+ const marker = readMarker(projectRoot);
27
+
28
+ if (!marker) {
29
+ return { ok: false, code: 'NOT_RUNNING' };
30
+ }
31
+
32
+ if (!processAlive(marker.pid)) {
33
+ removeMarker(projectRoot);
34
+ return { ok: true, was_stale: true };
35
+ }
36
+
37
+ const startMs = Date.now();
38
+ const isWindows = process.platform === 'win32';
39
+
40
+ if (isWindows) {
41
+ // taskkill /T /F /PID <pid> — kills process tree immediately (forceful)
42
+ // No graceful period on Windows — taskkill /T does tree kill in one shot
43
+ try {
44
+ execSync(`taskkill /T /F /PID ${marker.pid}`, { stdio: 'ignore' });
45
+ } catch (err) {
46
+ // Ignore errors — process may have exited already
47
+ }
48
+ } else {
49
+ // POSIX: send SIGTERM, wait, then escalate to SIGKILL if needed
50
+ try {
51
+ process.kill(marker.pid, 'SIGTERM');
52
+ } catch (err) {
53
+ // Process may have exited between check and kill
54
+ }
55
+
56
+ let escalated = false;
57
+ const deadline = Date.now() + graceSec * 1000;
58
+
59
+ while (processAlive(marker.pid)) {
60
+ if (Date.now() >= deadline) {
61
+ try {
62
+ process.kill(marker.pid, 'SIGKILL');
63
+ escalated = true;
64
+ } catch (err) {
65
+ // Process may have already exited
66
+ }
67
+ break;
68
+ }
69
+ await sleep(200);
70
+ }
71
+ }
72
+
73
+ // Ensure marker is removed
74
+ removeMarker(projectRoot);
75
+
76
+ return {
77
+ ok: true,
78
+ pid: marker.pid,
79
+ escalated: isWindows ? false : (escalated || false),
80
+ duration_ms: Date.now() - startMs
81
+ };
82
+ }
package/src/runner.mjs CHANGED
@@ -9,6 +9,7 @@ import { findProjectRoot } from './lib/find-root.mjs';
9
9
  import { loadRules, scanStderrForFatalRule, classify } from './lib/error-classifier.mjs';
10
10
  import { snapshot, diff, isEmpty } from './lib/artifact-snapshot.mjs';
11
11
  import { markUnhealthy, isHealthy } from './lib/agent-health-registry.mjs';
12
+ import { writeMarker, removeMarker } from './lib/marker.mjs';
12
13
 
13
14
  // ============================================================================
14
15
  // Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
@@ -176,7 +177,8 @@ class Logger {
176
177
  */
177
178
  stageStart(stageId, agentId, skillId) {
178
179
  this.stats.stagesStarted++;
179
- this.info(`START stage="${stageId}" agent="${agentId}" skill="${skillId}"`, stageId);
180
+ const ticketInfo = this.context && this.context.ticket_id ? ` ticket="${this.context.ticket_id}"` : "";
181
+ this.info(`START stage="${stageId}" agent="${agentId}" skill="${skillId}"${ticketInfo}`, stageId);
180
182
  }
181
183
 
182
184
  /**
@@ -1563,7 +1565,7 @@ class PipelineRunner {
1563
1565
  }
1564
1566
  throw err;
1565
1567
  }
1566
- }
1568
+ }
1567
1569
 
1568
1570
  /**
1569
1571
  * Читает approval-файл и парсит его как JSON.
@@ -1586,7 +1588,7 @@ class PipelineRunner {
1586
1588
  // Перебрасываем другие ошибки (например, fs-ошибки) без изменений
1587
1589
  throw err;
1588
1590
  }
1589
- }
1591
+ }
1590
1592
 
1591
1593
  /**
1592
1594
  * Выполняет встроенный стейдж типа update-counter:
@@ -1755,7 +1757,7 @@ class PipelineRunner {
1755
1757
  this.logger.warn(`[${stageId}] manual-gate: aborted (runner stopped)`, stageId);
1756
1758
  }
1757
1759
  return { status: 'aborted', result: { step_id: stepId } };
1758
- }
1760
+ }
1759
1761
 
1760
1762
  /**
1761
1763
  * Запускает основной цикл выполнения
@@ -1801,7 +1803,7 @@ class PipelineRunner {
1801
1803
  this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
1802
1804
  result = await this.currentExecutor.execute(this.currentStage);
1803
1805
  this.currentExecutor = null;
1804
- }
1806
+ }
1805
1807
 
1806
1808
  this.logger.info(`Stage ${this.currentStage} completed with status: ${result.status}`, 'PipelineRunner');
1807
1809
 
@@ -2145,18 +2147,40 @@ async function runPipeline(argv = process.argv.slice(2)) {
2145
2147
  return { exitCode: 0, help: true };
2146
2148
  }
2147
2149
 
2148
- // Resolve config path
2150
+ // Resolve config path and projectRoot
2149
2151
  if (!args.config) {
2150
2152
  const projectRoot = args.project ? path.resolve(args.project) : findProjectRoot();
2151
2153
  args.config = path.resolve(projectRoot, '.workflow/config/pipeline.yaml');
2154
+ args.projectRoot = projectRoot;
2152
2155
  }
2153
2156
 
2157
+ const projectRoot = args.projectRoot || (args.project ? path.resolve(args.project) : findProjectRoot());
2158
+
2154
2159
  console.log('=== Workflow Runner ===');
2155
2160
  console.log(`Config: ${args.config}`);
2156
2161
  if (args.plan) console.log(`Plan: ${args.plan}`);
2157
2162
  if (args.project) console.log(`Project: ${args.project}`);
2158
2163
  console.log('');
2159
2164
 
2165
+ // Write marker to protect against stale processes
2166
+ try {
2167
+ writeMarker(projectRoot, {
2168
+ pid: process.pid,
2169
+ timestamp: new Date().toISOString()
2170
+ });
2171
+ } catch (err) {
2172
+ console.error(`[runner] failed to write marker: ${err.message}`);
2173
+ return { exitCode: 1, error: 'Failed to acquire pipeline lock', details: err.message };
2174
+ }
2175
+
2176
+ // Register signal handlers for cleanup
2177
+ const cleanup = () => {
2178
+ removeMarker(projectRoot);
2179
+ process.exit(130); // 128 + SIGINT(2) — standard exit code for signal-terminated
2180
+ };
2181
+ process.once('SIGINT', cleanup);
2182
+ process.once('SIGTERM', cleanup);
2183
+
2160
2184
  try {
2161
2185
  const config = loadConfig(args.config);
2162
2186
  const errors = validateConfig(config);
@@ -2191,6 +2215,16 @@ async function runPipeline(argv = process.argv.slice(2)) {
2191
2215
  await new Promise(resolve => setTimeout(resolve, 100));
2192
2216
 
2193
2217
  return { exitCode: 1, error: err.message, stack: err.stack };
2218
+ } finally {
2219
+ // Ensure marker is cleaned up on normal exit
2220
+ try {
2221
+ removeMarker(projectRoot);
2222
+ } catch (err) {
2223
+ // ENOENT is expected, log warnings for other errors
2224
+ if (err.code && err.code !== 'ENOENT') {
2225
+ console.warn(`[runner] cleanup warning: ${err.message}`);
2226
+ }
2227
+ }
2194
2228
  }
2195
2229
  }
2196
2230
 
@@ -100,10 +100,12 @@ function findTicketInColumns(ticketId) {
100
100
 
101
101
  async function checkRelevance(ticketPath) {
102
102
  if (!fs.existsSync(ticketPath)) {
103
+ // file_not_found — это не ошибка скрипта (verdict=relevant — fail-safe).
104
+ // Не выходим с exit=1, чтобы pipeline продолжил выполнение следующего стейджа.
103
105
  return {
104
106
  verdict: "relevant",
105
107
  reason: "file_not_found",
106
- error: `Ticket file not found: ${ticketPath}`,
108
+ warning: `Ticket file not found: ${ticketPath}`,
107
109
  };
108
110
  }
109
111
 
@@ -85,7 +85,7 @@ function readPrefixesFromConfig() {
85
85
  throw new Error(`config.yaml not found: ${configPath}`);
86
86
  }
87
87
 
88
- const text = fs.readFileSync(configPath, "utf8");
88
+ const text = fs.readFileSync(configPath, "utf8");
89
89
  const lines = text.split(/\r?\n/);
90
90
 
91
91
  const prefixes = [];
@@ -0,0 +1,160 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * mark-blocked.js - Скрипт для обновления frontmatter тикета и записи в alerts.jsonl
5
+ *
6
+ * Использование:
7
+ * node mark-blocked.js <ticket_id> --attempts=N --reason=<str>
8
+ *
9
+ * Примеры:
10
+ * node mark-blocked.js IMPL-59 --attempts=6 --reason=max_review_attempts
11
+ * node mark-blocked.js QA-40 --attempts=3 --reason=human_gate_rejected
12
+ */
13
+
14
+ import fs from "fs";
15
+ import path from "path";
16
+ import YAML from "workflow-ai/lib/js-yaml.mjs";
17
+ import { findProjectRoot } from "workflow-ai/lib/find-root.mjs";
18
+ import {
19
+ parseFrontmatter,
20
+ printResult,
21
+ serializeFrontmatter,
22
+ } from "workflow-ai/lib/utils.mjs";
23
+
24
+ // Корень проекта
25
+ const PROJECT_DIR = findProjectRoot();
26
+ // Базовая директория workflow
27
+ const WORKFLOW_DIR = path.join(PROJECT_DIR, ".workflow");
28
+ const TICKETS_DIR = path.join(WORKFLOW_DIR, "tickets");
29
+ // Директория state
30
+ const STATE_DIR = path.join(PROJECT_DIR, ".workflow", "state");
31
+ const ALERTS_FILE = path.join(STATE_DIR, "alerts.jsonl");
32
+
33
+ // Парсинг аргументов
34
+ const args = process.argv.slice(2);
35
+ if (args.length < 3) {
36
+ console.error("Ошибка: недостаточно аргументов");
37
+ console.error("Использование: node mark-blocked.js <ticket_id> --attempts=N --reason=<str>");
38
+ process.exit(1);
39
+ }
40
+
41
+ const ticketId = args[0];
42
+ let attempts = null;
43
+ let reason = null;
44
+
45
+ // Парсинг флагов
46
+ for (let i = 1; i < args.length; i++) {
47
+ const arg = args[i];
48
+ if (arg.startsWith("--attempts=")) {
49
+ attempts = parseInt(arg.substring("--attempts=".length), 10);
50
+ if (isNaN(attempts)) {
51
+ console.error("Ошибка: некорректный формат --attempts");
52
+ process.exit(1);
53
+ }
54
+ } else if (arg.startsWith("--reason=")) {
55
+ reason = arg.substring("--reason=".length);
56
+ }
57
+ }
58
+
59
+ // Проверка обязательного параметра reason
60
+ if (!reason) {
61
+ console.error("Ошибка: параметр --reason обязателен");
62
+ process.exit(1);
63
+ }
64
+
65
+ // Поиск файла тикета рекурсивно
66
+ function findTicketFile(ticketId, searchDir) {
67
+ try {
68
+ const files = fs.readdirSync(searchDir, { withFileTypes: true });
69
+
70
+ for (const file of files) {
71
+ const fullPath = path.join(searchDir, file.name);
72
+
73
+ if (file.isDirectory()) {
74
+ // Рекурсивный поиск в поддиректориях
75
+ const found = findTicketFile(ticketId, fullPath);
76
+ if (found) return found;
77
+ } else if (file.isFile() && file.name.endsWith('.md') && file.name.startsWith(ticketId)) {
78
+ return fullPath;
79
+ }
80
+ }
81
+ } catch (error) {
82
+ console.error(`Ошибка при чтении директории ${searchDir}:`, error.message);
83
+ }
84
+
85
+ return null;
86
+ }
87
+
88
+ // Основная функция
89
+ function main() {
90
+ try {
91
+ // Поиск файла тикета
92
+ const ticketFile = findTicketFile(ticketId, TICKETS_DIR);
93
+ if (!ticketFile) {
94
+ console.error(`Ошибка: тикет ${ticketId} не найден в ${TICKETS_DIR}`);
95
+ process.exit(1);
96
+ }
97
+
98
+ // Чтение файла тикета
99
+ const content = fs.readFileSync(ticketFile, 'utf8');
100
+ const { frontmatter, body } = parseFrontmatter(content);
101
+
102
+ // Обновление frontmatter
103
+ const now = new Date().toISOString();
104
+ frontmatter.auto_blocked_reason = reason;
105
+ frontmatter.auto_blocked_attempts = attempts;
106
+ frontmatter.auto_blocked_at = now;
107
+
108
+ // Сериализация и запись обратно в файл
109
+ const newContent = serializeFrontmatter(frontmatter) + body;
110
+ fs.writeFileSync(ticketFile, newContent, 'utf8');
111
+
112
+ console.log(`✅ Frontmatter тикета ${ticketId} обновлен`);
113
+
114
+ // Попытка записи в alerts.jsonl
115
+ try {
116
+ // Создание директории state если не существует
117
+ if (!fs.existsSync(STATE_DIR)) {
118
+ fs.mkdirSync(STATE_DIR, { recursive: true });
119
+ console.log(`✅ Директория ${STATE_DIR} создана`);
120
+ }
121
+
122
+ // Формирование JSONL записи
123
+ const alertEntry = {
124
+ timestamp: now,
125
+ severity: "warning",
126
+ kind: "ticket_auto_blocked",
127
+ project: path.basename(PROJECT_DIR),
128
+ ticket_id: ticketId,
129
+ attempts: attempts,
130
+ reason: reason,
131
+ stage: "review-result"
132
+ };
133
+
134
+ // Append-only запись в alerts.jsonl
135
+ fs.appendFileSync(ALERTS_FILE, JSON.stringify(alertEntry) + '\n', 'utf8');
136
+ console.log(`✅ Запись добавлена в ${ALERTS_FILE}`);
137
+
138
+ } catch (alertError) {
139
+ console.warn(`⚠️ Предупреждение: не удалось записать в alerts.jsonl: ${alertError.message}`);
140
+ console.log(`ℹ️ Frontmatter обновлен, запись в alerts пропущена`);
141
+ }
142
+
143
+ // Вывод результата
144
+ printResult({
145
+ ticket_id: ticketId,
146
+ reason: reason,
147
+ attempts: attempts,
148
+ blocked_at: now,
149
+ alerts_file: ALERTS_FILE,
150
+ status: "completed"
151
+ });
152
+
153
+ } catch (error) {
154
+ console.error(`Ошибка: ${error.message}`);
155
+ process.exit(1);
156
+ }
157
+ }
158
+
159
+ // Запуск скрипта
160
+ main();