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/CHANGELOG.md +61 -49
- package/README.md +64 -475
- package/configs/pipeline.yaml +90 -2
- package/package.json +11 -1
- package/src/lib/marker.mjs +108 -0
- package/src/lib/process-alive.mjs +11 -0
- package/src/lib/stop-command.mjs +82 -0
- package/src/runner.mjs +40 -6
- package/src/scripts/check-relevance.js +3 -1
- package/src/scripts/get-next-id.js +1 -1
- package/src/scripts/mark-blocked.js +160 -0
- package/src/scripts/move-ticket.js +100 -35
- package/src/scripts/pick-next-task.js +64 -35
- package/src/skills/__test-cal-001-1777553217513/SKILL.md +2 -0
- package/src/skills/__test-runner-1777553217483/SKILL.md +5 -0
- package/src/skills/coach/SKILL.md +2 -1
- package/src/skills/execute-task/SKILL.md +29 -2
- package/src/skills/review-result/SKILL.md +23 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workflow-ai",
|
|
3
|
-
"version": "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.
|
|
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
|
-
|
|
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();
|