claude-notification-plugin 1.0.59 → 1.0.63
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/.claude-plugin/plugin.json +1 -1
- package/README.md +42 -54
- package/bin/listener-cli.js +255 -0
- package/commands/listener.md +100 -0
- package/listener/listener.js +613 -0
- package/listener/logger.js +46 -0
- package/listener/message-parser.js +100 -0
- package/listener/task-runner.js +148 -0
- package/listener/telegram-poller.js +142 -0
- package/listener/work-queue.js +306 -0
- package/listener/worktree-manager.js +279 -0
- package/notifier/notifier.js +4 -2
- package/package.json +4 -2
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const COMMANDS = [
|
|
4
|
+
'/status', '/queue', '/cancel', '/drop', '/clear',
|
|
5
|
+
'/projects', '/worktrees', '/worktree', '/rmworktree',
|
|
6
|
+
'/history', '/stop', '/help',
|
|
7
|
+
];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Parse a Telegram message into a command or task.
|
|
11
|
+
*
|
|
12
|
+
* Formats:
|
|
13
|
+
* /command args → { type: 'command', cmd, args }
|
|
14
|
+
* @project/branch text → { type: 'task', project, branch, text }
|
|
15
|
+
* @project text → { type: 'task', project, branch: null, text }
|
|
16
|
+
* text → { type: 'task', project: 'default', branch: null, text }
|
|
17
|
+
*/
|
|
18
|
+
export function parseMessage (text) {
|
|
19
|
+
if (!text || typeof text !== 'string') {
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const trimmed = text.trim();
|
|
24
|
+
if (!trimmed) {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// Check for commands
|
|
29
|
+
if (trimmed.startsWith('/')) {
|
|
30
|
+
const parts = trimmed.split(/\s+/);
|
|
31
|
+
const cmd = parts[0].toLowerCase().replace(/@\w+$/, ''); // strip @botname
|
|
32
|
+
if (COMMANDS.includes(cmd)) {
|
|
33
|
+
return {
|
|
34
|
+
type: 'command',
|
|
35
|
+
cmd,
|
|
36
|
+
args: parts.slice(1).join(' '),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Check for @project/branch or @project prefix
|
|
42
|
+
const projectMatch = trimmed.match(/^@(\S+)\s+([\s\S]+)$/);
|
|
43
|
+
if (projectMatch) {
|
|
44
|
+
const target = projectMatch[1];
|
|
45
|
+
const taskText = projectMatch[2].trim();
|
|
46
|
+
|
|
47
|
+
// Split target into project and optional branch
|
|
48
|
+
const slashIndex = target.indexOf('/');
|
|
49
|
+
if (slashIndex > 0) {
|
|
50
|
+
return {
|
|
51
|
+
type: 'task',
|
|
52
|
+
project: target.substring(0, slashIndex),
|
|
53
|
+
branch: target.substring(slashIndex + 1),
|
|
54
|
+
text: taskText,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
type: 'task',
|
|
59
|
+
project: target,
|
|
60
|
+
branch: null,
|
|
61
|
+
text: taskText,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Plain text → default project
|
|
66
|
+
return {
|
|
67
|
+
type: 'task',
|
|
68
|
+
project: 'default',
|
|
69
|
+
branch: null,
|
|
70
|
+
text: trimmed,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Parse @project or @project/branch from command args.
|
|
76
|
+
* Returns { project, branch } or null.
|
|
77
|
+
*/
|
|
78
|
+
export function parseTarget (args) {
|
|
79
|
+
if (!args) {
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
const match = args.trim().match(/^@(\S+)/);
|
|
83
|
+
if (!match) {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
const target = match[1];
|
|
87
|
+
const slashIndex = target.indexOf('/');
|
|
88
|
+
if (slashIndex > 0) {
|
|
89
|
+
return {
|
|
90
|
+
project: target.substring(0, slashIndex),
|
|
91
|
+
branch: target.substring(slashIndex + 1),
|
|
92
|
+
rest: args.trim().substring(match[0].length).trim(),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
return {
|
|
96
|
+
project: target,
|
|
97
|
+
branch: null,
|
|
98
|
+
rest: args.trim().substring(match[0].length).trim(),
|
|
99
|
+
};
|
|
100
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
import { EventEmitter } from 'events';
|
|
5
|
+
|
|
6
|
+
const DEFAULT_TIMEOUT = 600_000; // 10 minutes
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Runs claude CLI tasks and emits events on completion.
|
|
10
|
+
*/
|
|
11
|
+
export class TaskRunner extends EventEmitter {
|
|
12
|
+
constructor (logger, timeout) {
|
|
13
|
+
super();
|
|
14
|
+
this.logger = logger;
|
|
15
|
+
this.timeout = timeout || DEFAULT_TIMEOUT;
|
|
16
|
+
this.activeProcesses = new Map(); // workDir → { child, timer, task }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Run a task in a specific workDir.
|
|
21
|
+
* @param {string} workDir - Working directory
|
|
22
|
+
* @param {object} task - Task object { id, text, telegramMessageId, ... }
|
|
23
|
+
* @returns {object} task with pid
|
|
24
|
+
*/
|
|
25
|
+
run (workDir, task) {
|
|
26
|
+
if (this.activeProcesses.has(workDir)) {
|
|
27
|
+
throw new Error(`Already running a task in ${workDir}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
this.logger.info(`Running task "${task.text}" in ${workDir}`);
|
|
31
|
+
|
|
32
|
+
const args = ['-p', task.text, '--output-format', 'text'];
|
|
33
|
+
const child = spawn('claude', args, {
|
|
34
|
+
cwd: workDir,
|
|
35
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
36
|
+
windowsHide: true,
|
|
37
|
+
shell: process.platform === 'win32',
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
let stdout = '';
|
|
41
|
+
let stderr = '';
|
|
42
|
+
|
|
43
|
+
child.stdout.on('data', (chunk) => {
|
|
44
|
+
stdout += chunk.toString();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
child.stderr.on('data', (chunk) => {
|
|
48
|
+
stderr += chunk.toString();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const timer = setTimeout(() => {
|
|
52
|
+
this.logger.warn(`Task "${task.id}" timed out in ${workDir}`);
|
|
53
|
+
this._killProcess(workDir);
|
|
54
|
+
this.emit('timeout', workDir, task);
|
|
55
|
+
}, this.timeout);
|
|
56
|
+
|
|
57
|
+
child.on('close', (code) => {
|
|
58
|
+
clearTimeout(timer);
|
|
59
|
+
this.activeProcesses.delete(workDir);
|
|
60
|
+
|
|
61
|
+
if (code === null) {
|
|
62
|
+
// Process was killed (timeout or cancel)
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (code === 0) {
|
|
67
|
+
this.logger.info(`Task "${task.id}" completed in ${workDir}`);
|
|
68
|
+
this.emit('complete', workDir, task, stdout.trim());
|
|
69
|
+
} else {
|
|
70
|
+
const errorMsg = stderr.trim() || `Process exited with code ${code}`;
|
|
71
|
+
this.logger.error(`Task "${task.id}" failed in ${workDir}: ${errorMsg}`);
|
|
72
|
+
this.emit('error', workDir, task, errorMsg);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
child.on('error', (err) => {
|
|
77
|
+
clearTimeout(timer);
|
|
78
|
+
this.activeProcesses.delete(workDir);
|
|
79
|
+
this.logger.error(`Task "${task.id}" spawn error: ${err.message}`);
|
|
80
|
+
this.emit('error', workDir, task, err.message);
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
task.pid = child.pid;
|
|
84
|
+
task.startedAt = new Date().toISOString();
|
|
85
|
+
this.activeProcesses.set(workDir, { child, timer, task });
|
|
86
|
+
|
|
87
|
+
return task;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Cancel the active task in a workDir.
|
|
92
|
+
*/
|
|
93
|
+
cancel (workDir) {
|
|
94
|
+
this._killProcess(workDir);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Check if a task is running in a workDir.
|
|
99
|
+
*/
|
|
100
|
+
isRunning (workDir) {
|
|
101
|
+
return this.activeProcesses.has(workDir);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Get active task info for a workDir.
|
|
106
|
+
*/
|
|
107
|
+
getActive (workDir) {
|
|
108
|
+
const entry = this.activeProcesses.get(workDir);
|
|
109
|
+
return entry?.task || null;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Cancel all active tasks (for graceful shutdown).
|
|
114
|
+
*/
|
|
115
|
+
cancelAll () {
|
|
116
|
+
for (const workDir of this.activeProcesses.keys()) {
|
|
117
|
+
this._killProcess(workDir);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
_killProcess (workDir) {
|
|
122
|
+
const entry = this.activeProcesses.get(workDir);
|
|
123
|
+
if (!entry) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
clearTimeout(entry.timer);
|
|
127
|
+
try {
|
|
128
|
+
if (process.platform === 'win32') {
|
|
129
|
+
spawn('taskkill', ['/PID', String(entry.child.pid), '/T', '/F'], {
|
|
130
|
+
stdio: 'ignore',
|
|
131
|
+
windowsHide: true,
|
|
132
|
+
});
|
|
133
|
+
} else {
|
|
134
|
+
entry.child.kill('SIGTERM');
|
|
135
|
+
setTimeout(() => {
|
|
136
|
+
try {
|
|
137
|
+
entry.child.kill('SIGKILL');
|
|
138
|
+
} catch {
|
|
139
|
+
// already dead
|
|
140
|
+
}
|
|
141
|
+
}, 3000);
|
|
142
|
+
}
|
|
143
|
+
} catch {
|
|
144
|
+
// process already dead
|
|
145
|
+
}
|
|
146
|
+
this.activeProcesses.delete(workDir);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const POLL_TIMEOUT = 30; // seconds
|
|
4
|
+
const MAX_MESSAGE_LENGTH = 4096;
|
|
5
|
+
|
|
6
|
+
export class TelegramPoller {
|
|
7
|
+
constructor (token, chatId, logger) {
|
|
8
|
+
this.token = token;
|
|
9
|
+
this.chatId = String(chatId);
|
|
10
|
+
this.logger = logger;
|
|
11
|
+
this.baseUrl = `https://api.telegram.org/bot${token}`;
|
|
12
|
+
this.offset = 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async getUpdates () {
|
|
16
|
+
try {
|
|
17
|
+
const url = `${this.baseUrl}/getUpdates?offset=${this.offset}&timeout=${POLL_TIMEOUT}&allowed_updates=["message"]`;
|
|
18
|
+
const res = await fetch(url, { signal: AbortSignal.timeout((POLL_TIMEOUT + 10) * 1000) });
|
|
19
|
+
const data = await res.json();
|
|
20
|
+
if (!data.ok) {
|
|
21
|
+
this.logger.error(`getUpdates failed: ${JSON.stringify(data)}`);
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
const messages = [];
|
|
25
|
+
for (const update of data.result || []) {
|
|
26
|
+
this.offset = update.update_id + 1;
|
|
27
|
+
const msg = update.message;
|
|
28
|
+
if (!msg || !msg.text) {
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
if (String(msg.chat.id) !== this.chatId) {
|
|
32
|
+
this.logger.warn(`Ignored message from chat ${msg.chat.id} (expected ${this.chatId})`);
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
messages.push({
|
|
36
|
+
messageId: msg.message_id,
|
|
37
|
+
text: msg.text,
|
|
38
|
+
chatId: msg.chat.id,
|
|
39
|
+
date: msg.date,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
return messages;
|
|
43
|
+
} catch (err) {
|
|
44
|
+
if (err.name !== 'TimeoutError' && err.name !== 'AbortError') {
|
|
45
|
+
this.logger.error(`getUpdates error: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
return [];
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async sendMessage (text, replyToMessageId) {
|
|
52
|
+
const chunks = splitMessage(text);
|
|
53
|
+
for (const chunk of chunks) {
|
|
54
|
+
try {
|
|
55
|
+
const body = {
|
|
56
|
+
chat_id: this.chatId,
|
|
57
|
+
text: chunk,
|
|
58
|
+
parse_mode: 'HTML',
|
|
59
|
+
};
|
|
60
|
+
if (replyToMessageId) {
|
|
61
|
+
body.reply_to_message_id = replyToMessageId;
|
|
62
|
+
}
|
|
63
|
+
const res = await fetch(`${this.baseUrl}/sendMessage`, {
|
|
64
|
+
method: 'POST',
|
|
65
|
+
headers: { 'Content-Type': 'application/json' },
|
|
66
|
+
body: JSON.stringify(body),
|
|
67
|
+
});
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
if (!data.ok) {
|
|
70
|
+
// Retry without HTML parse mode
|
|
71
|
+
await fetch(`${this.baseUrl}/sendMessage`, {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
headers: { 'Content-Type': 'application/json' },
|
|
74
|
+
body: JSON.stringify({
|
|
75
|
+
chat_id: this.chatId,
|
|
76
|
+
text: chunk,
|
|
77
|
+
...(replyToMessageId ? { reply_to_message_id: replyToMessageId } : {}),
|
|
78
|
+
}),
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
} catch (err) {
|
|
82
|
+
this.logger.error(`sendMessage error: ${err.message}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async sendDocument (buffer, filename, caption) {
|
|
88
|
+
try {
|
|
89
|
+
const formData = new FormData();
|
|
90
|
+
formData.append('chat_id', this.chatId);
|
|
91
|
+
formData.append('document', new Blob([buffer]), filename);
|
|
92
|
+
if (caption) {
|
|
93
|
+
formData.append('caption', caption.slice(0, 1024));
|
|
94
|
+
}
|
|
95
|
+
await fetch(`${this.baseUrl}/sendDocument`, {
|
|
96
|
+
method: 'POST',
|
|
97
|
+
body: formData,
|
|
98
|
+
});
|
|
99
|
+
} catch (err) {
|
|
100
|
+
this.logger.error(`sendDocument error: ${err.message}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function escapeHtml (text) {
|
|
106
|
+
return text
|
|
107
|
+
.replace(/&/g, '&')
|
|
108
|
+
.replace(/</g, '<')
|
|
109
|
+
.replace(/>/g, '>');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function splitMessage (text) {
|
|
113
|
+
if (text.length <= MAX_MESSAGE_LENGTH) {
|
|
114
|
+
return [text];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// For very long messages, send summary + file
|
|
118
|
+
if (text.length > 20000) {
|
|
119
|
+
const head = text.slice(0, 2000);
|
|
120
|
+
const tail = text.slice(-2000);
|
|
121
|
+
return [`${head}\n\n<i>... (truncated ${text.length} chars) ...</i>\n\n${tail}`];
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Split into chunks preserving line boundaries
|
|
125
|
+
const chunks = [];
|
|
126
|
+
let remaining = text;
|
|
127
|
+
while (remaining.length > 0) {
|
|
128
|
+
if (remaining.length <= MAX_MESSAGE_LENGTH) {
|
|
129
|
+
chunks.push(remaining);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
let splitAt = remaining.lastIndexOf('\n', MAX_MESSAGE_LENGTH);
|
|
133
|
+
if (splitAt <= 0) {
|
|
134
|
+
splitAt = MAX_MESSAGE_LENGTH;
|
|
135
|
+
}
|
|
136
|
+
chunks.push(remaining.slice(0, splitAt));
|
|
137
|
+
remaining = remaining.slice(splitAt + 1);
|
|
138
|
+
}
|
|
139
|
+
return chunks;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export { escapeHtml };
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import fs from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
const QUEUE_FILE = path.join(os.homedir(), '.claude', '.task_queues.json');
|
|
8
|
+
const HISTORY_FILE = path.join(os.homedir(), '.claude', '.task_history.json');
|
|
9
|
+
const MAX_HISTORY = 50;
|
|
10
|
+
|
|
11
|
+
let idCounter = 0;
|
|
12
|
+
|
|
13
|
+
function generateId () {
|
|
14
|
+
return `task_${Date.now()}_${(++idCounter).toString(36)}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Per-workDir task queue with FIFO ordering and single-active-task lock.
|
|
19
|
+
*/
|
|
20
|
+
export class WorkQueue {
|
|
21
|
+
constructor (logger, maxQueuePerWorkDir = 10, maxTotalTasks = 50) {
|
|
22
|
+
this.logger = logger;
|
|
23
|
+
this.maxQueuePerWorkDir = maxQueuePerWorkDir;
|
|
24
|
+
this.maxTotalTasks = maxTotalTasks;
|
|
25
|
+
this.queues = {}; // workDir → { project, branch, active, queue }
|
|
26
|
+
this._load();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Enqueue a task for a workDir. Returns the task object.
|
|
31
|
+
* If no active task, marks it as ready to run immediately.
|
|
32
|
+
*/
|
|
33
|
+
enqueue (workDir, project, branch, text, telegramMessageId) {
|
|
34
|
+
if (!this.queues[workDir]) {
|
|
35
|
+
this.queues[workDir] = {
|
|
36
|
+
project,
|
|
37
|
+
branch: branch || 'main',
|
|
38
|
+
active: null,
|
|
39
|
+
queue: [],
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const entry = this.queues[workDir];
|
|
44
|
+
const totalPending = this._countTotal();
|
|
45
|
+
|
|
46
|
+
if (totalPending >= this.maxTotalTasks) {
|
|
47
|
+
return { error: `Total task limit reached (${this.maxTotalTasks})` };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (entry.queue.length >= this.maxQueuePerWorkDir) {
|
|
51
|
+
return { error: `Queue limit reached for this workDir (${this.maxQueuePerWorkDir})` };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const task = {
|
|
55
|
+
id: generateId(),
|
|
56
|
+
text,
|
|
57
|
+
telegramMessageId,
|
|
58
|
+
addedAt: new Date().toISOString(),
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
if (!entry.active) {
|
|
62
|
+
// Ready to run immediately
|
|
63
|
+
entry.active = task;
|
|
64
|
+
this._save();
|
|
65
|
+
return { task, position: 0, immediate: true };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Add to queue
|
|
69
|
+
entry.queue.push(task);
|
|
70
|
+
this._save();
|
|
71
|
+
return {
|
|
72
|
+
task,
|
|
73
|
+
position: entry.queue.length,
|
|
74
|
+
immediate: false,
|
|
75
|
+
activeTask: entry.active,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Mark active task as started (update PID and startedAt).
|
|
81
|
+
*/
|
|
82
|
+
markStarted (workDir, pid) {
|
|
83
|
+
const entry = this.queues[workDir];
|
|
84
|
+
if (entry?.active) {
|
|
85
|
+
entry.active.pid = pid;
|
|
86
|
+
entry.active.startedAt = new Date().toISOString();
|
|
87
|
+
this._save();
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Complete the active task. Returns next task if available, or null.
|
|
93
|
+
*/
|
|
94
|
+
onTaskComplete (workDir, result) {
|
|
95
|
+
const entry = this.queues[workDir];
|
|
96
|
+
if (!entry) {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Record in history
|
|
101
|
+
if (entry.active) {
|
|
102
|
+
this._addHistory({
|
|
103
|
+
...entry.active,
|
|
104
|
+
project: entry.project,
|
|
105
|
+
branch: entry.branch,
|
|
106
|
+
workDir,
|
|
107
|
+
completedAt: new Date().toISOString(),
|
|
108
|
+
result: result ? result.slice(0, 500) : '',
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Get next task
|
|
113
|
+
if (entry.queue.length > 0) {
|
|
114
|
+
entry.active = entry.queue.shift();
|
|
115
|
+
this._save();
|
|
116
|
+
return entry.active;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
entry.active = null;
|
|
120
|
+
this._save();
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Cancel the active task in a workDir. Returns next task if available.
|
|
126
|
+
*/
|
|
127
|
+
cancelActive (workDir) {
|
|
128
|
+
const entry = this.queues[workDir];
|
|
129
|
+
if (!entry?.active) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
this._addHistory({
|
|
134
|
+
...entry.active,
|
|
135
|
+
project: entry.project,
|
|
136
|
+
branch: entry.branch,
|
|
137
|
+
workDir,
|
|
138
|
+
completedAt: new Date().toISOString(),
|
|
139
|
+
result: 'CANCELLED',
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (entry.queue.length > 0) {
|
|
143
|
+
entry.active = entry.queue.shift();
|
|
144
|
+
this._save();
|
|
145
|
+
return entry.active;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
entry.active = null;
|
|
149
|
+
this._save();
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Remove a task from the queue by index (1-based).
|
|
155
|
+
*/
|
|
156
|
+
removeFromQueue (workDir, index) {
|
|
157
|
+
const entry = this.queues[workDir];
|
|
158
|
+
if (!entry) {
|
|
159
|
+
return null;
|
|
160
|
+
}
|
|
161
|
+
const idx = index - 1;
|
|
162
|
+
if (idx < 0 || idx >= entry.queue.length) {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
const removed = entry.queue.splice(idx, 1)[0];
|
|
166
|
+
this._save();
|
|
167
|
+
return removed;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Clear all queued (non-active) tasks for a workDir.
|
|
172
|
+
*/
|
|
173
|
+
clearQueue (workDir) {
|
|
174
|
+
const entry = this.queues[workDir];
|
|
175
|
+
if (!entry) {
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
const count = entry.queue.length;
|
|
179
|
+
entry.queue = [];
|
|
180
|
+
this._save();
|
|
181
|
+
return count;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Get status for a project (all workDirs).
|
|
186
|
+
*/
|
|
187
|
+
getProjectStatus (projectAlias) {
|
|
188
|
+
const results = [];
|
|
189
|
+
for (const [workDir, entry] of Object.entries(this.queues)) {
|
|
190
|
+
if (entry.project === projectAlias) {
|
|
191
|
+
results.push({
|
|
192
|
+
workDir,
|
|
193
|
+
branch: entry.branch,
|
|
194
|
+
active: entry.active,
|
|
195
|
+
queueLength: entry.queue.length,
|
|
196
|
+
queue: entry.queue,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return results;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Get status for all projects.
|
|
205
|
+
*/
|
|
206
|
+
getAllStatus () {
|
|
207
|
+
const results = {};
|
|
208
|
+
for (const [workDir, entry] of Object.entries(this.queues)) {
|
|
209
|
+
if (!results[entry.project]) {
|
|
210
|
+
results[entry.project] = [];
|
|
211
|
+
}
|
|
212
|
+
results[entry.project].push({
|
|
213
|
+
workDir,
|
|
214
|
+
branch: entry.branch,
|
|
215
|
+
active: entry.active,
|
|
216
|
+
queueLength: entry.queue.length,
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
return results;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get recent task history.
|
|
224
|
+
*/
|
|
225
|
+
getHistory (limit = 10) {
|
|
226
|
+
return this._loadHistory().slice(-limit);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Watchdog: clean up stale active tasks (dead PIDs, expired timeouts).
|
|
231
|
+
*/
|
|
232
|
+
watchdog (taskTimeout) {
|
|
233
|
+
const now = Date.now();
|
|
234
|
+
const recovered = [];
|
|
235
|
+
for (const [workDir, entry] of Object.entries(this.queues)) {
|
|
236
|
+
if (!entry.active) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const startedAt = entry.active.startedAt ? new Date(entry.active.startedAt).getTime() : 0;
|
|
240
|
+
const isStale = startedAt > 0 && (now - startedAt) > taskTimeout;
|
|
241
|
+
|
|
242
|
+
if (isStale) {
|
|
243
|
+
this.logger.warn(`Watchdog: stale task "${entry.active.id}" in ${workDir}`);
|
|
244
|
+
const next = this.onTaskComplete(workDir, 'STALE (watchdog cleanup)');
|
|
245
|
+
recovered.push({ workDir, next });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return recovered;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
_countTotal () {
|
|
252
|
+
let count = 0;
|
|
253
|
+
for (const entry of Object.values(this.queues)) {
|
|
254
|
+
if (entry.active) {
|
|
255
|
+
count++;
|
|
256
|
+
}
|
|
257
|
+
count += entry.queue.length;
|
|
258
|
+
}
|
|
259
|
+
return count;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
_load () {
|
|
263
|
+
try {
|
|
264
|
+
if (fs.existsSync(QUEUE_FILE)) {
|
|
265
|
+
this.queues = JSON.parse(fs.readFileSync(QUEUE_FILE, 'utf-8'));
|
|
266
|
+
}
|
|
267
|
+
} catch (err) {
|
|
268
|
+
this.logger.error(`Failed to load queue file: ${err.message}`);
|
|
269
|
+
this.queues = {};
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
_save () {
|
|
274
|
+
try {
|
|
275
|
+
const dir = path.dirname(QUEUE_FILE);
|
|
276
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
277
|
+
fs.writeFileSync(QUEUE_FILE, JSON.stringify(this.queues, null, 2));
|
|
278
|
+
} catch (err) {
|
|
279
|
+
this.logger.error(`Failed to save queue file: ${err.message}`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
_addHistory (entry) {
|
|
284
|
+
const history = this._loadHistory();
|
|
285
|
+
history.push(entry);
|
|
286
|
+
while (history.length > MAX_HISTORY) {
|
|
287
|
+
history.shift();
|
|
288
|
+
}
|
|
289
|
+
try {
|
|
290
|
+
fs.writeFileSync(HISTORY_FILE, JSON.stringify(history, null, 2));
|
|
291
|
+
} catch {
|
|
292
|
+
// ignore
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
_loadHistory () {
|
|
297
|
+
try {
|
|
298
|
+
if (fs.existsSync(HISTORY_FILE)) {
|
|
299
|
+
return JSON.parse(fs.readFileSync(HISTORY_FILE, 'utf-8'));
|
|
300
|
+
}
|
|
301
|
+
} catch {
|
|
302
|
+
// ignore
|
|
303
|
+
}
|
|
304
|
+
return [];
|
|
305
|
+
}
|
|
306
|
+
}
|