jm2 0.1.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/GNU-AGPL-3.0 +665 -0
- package/README.md +603 -0
- package/bin/jm2.js +24 -0
- package/package.json +70 -0
- package/src/cli/commands/add.js +206 -0
- package/src/cli/commands/config.js +212 -0
- package/src/cli/commands/edit.js +198 -0
- package/src/cli/commands/export.js +61 -0
- package/src/cli/commands/flush.js +132 -0
- package/src/cli/commands/history.js +179 -0
- package/src/cli/commands/import.js +180 -0
- package/src/cli/commands/list.js +174 -0
- package/src/cli/commands/logs.js +415 -0
- package/src/cli/commands/pause.js +97 -0
- package/src/cli/commands/remove.js +107 -0
- package/src/cli/commands/restart.js +68 -0
- package/src/cli/commands/resume.js +96 -0
- package/src/cli/commands/run.js +115 -0
- package/src/cli/commands/show.js +159 -0
- package/src/cli/commands/start.js +46 -0
- package/src/cli/commands/status.js +47 -0
- package/src/cli/commands/stop.js +48 -0
- package/src/cli/index.js +274 -0
- package/src/cli/utils/output.js +267 -0
- package/src/cli/utils/prompts.js +56 -0
- package/src/core/config.js +227 -0
- package/src/core/history-db.js +439 -0
- package/src/core/job.js +329 -0
- package/src/core/logger.js +382 -0
- package/src/core/storage.js +315 -0
- package/src/daemon/executor.js +409 -0
- package/src/daemon/index.js +873 -0
- package/src/daemon/scheduler.js +465 -0
- package/src/ipc/client.js +112 -0
- package/src/ipc/protocol.js +183 -0
- package/src/ipc/server.js +92 -0
- package/src/utils/cron.js +205 -0
- package/src/utils/datetime.js +237 -0
- package/src/utils/duration.js +226 -0
- package/src/utils/paths.js +164 -0
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Storage utilities for JM2
|
|
3
|
+
* Provides JSON file persistence for jobs, config, and history
|
|
4
|
+
* History is now stored in SQLite (via history-db.js) for better performance
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
8
|
+
import { ensureDataDir, getJobsFile, getConfigFile } from '../utils/paths.js';
|
|
9
|
+
import {
|
|
10
|
+
addHistoryEntry as dbAddHistoryEntry,
|
|
11
|
+
getHistory as dbGetHistory,
|
|
12
|
+
getJobHistory as dbGetJobHistory,
|
|
13
|
+
clearHistoryBefore as dbClearHistoryBefore,
|
|
14
|
+
clearAllHistory as dbClearAllHistory,
|
|
15
|
+
} from './history-db.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Read a JSON file and parse its contents
|
|
19
|
+
* @param {string} filePath - Path to the JSON file
|
|
20
|
+
* @param {*} defaultValue - Default value if file doesn't exist
|
|
21
|
+
* @returns {*} Parsed JSON content or default value
|
|
22
|
+
*/
|
|
23
|
+
export function readJsonFile(filePath, defaultValue = null) {
|
|
24
|
+
try {
|
|
25
|
+
if (!existsSync(filePath)) {
|
|
26
|
+
return defaultValue;
|
|
27
|
+
}
|
|
28
|
+
const content = readFileSync(filePath, 'utf8');
|
|
29
|
+
return JSON.parse(content);
|
|
30
|
+
} catch (error) {
|
|
31
|
+
if (error.code === 'ENOENT') {
|
|
32
|
+
return defaultValue;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Failed to read JSON file ${filePath}: ${error.message}`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Write data to a JSON file
|
|
40
|
+
* @param {string} filePath - Path to the JSON file
|
|
41
|
+
* @param {*} data - Data to write
|
|
42
|
+
* @param {boolean} pretty - Whether to pretty-print the JSON (default: true)
|
|
43
|
+
*/
|
|
44
|
+
export function writeJsonFile(filePath, data, pretty = true) {
|
|
45
|
+
try {
|
|
46
|
+
ensureDataDir();
|
|
47
|
+
const content = pretty ? JSON.stringify(data, null, 2) : JSON.stringify(data);
|
|
48
|
+
writeFileSync(filePath, content, 'utf8');
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw new Error(`Failed to write JSON file ${filePath}: ${error.message}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Jobs storage operations
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Get all jobs from storage
|
|
60
|
+
* @returns {Array} Array of job objects
|
|
61
|
+
*/
|
|
62
|
+
export function getJobs() {
|
|
63
|
+
return readJsonFile(getJobsFile(), []);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Save all jobs to storage
|
|
68
|
+
* @param {Array} jobs - Array of job objects
|
|
69
|
+
*/
|
|
70
|
+
export function saveJobs(jobs) {
|
|
71
|
+
writeJsonFile(getJobsFile(), jobs);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Get a job by ID
|
|
76
|
+
* @param {number} id - Job ID
|
|
77
|
+
* @returns {object|null} Job object or null if not found
|
|
78
|
+
*/
|
|
79
|
+
export function getJobById(id) {
|
|
80
|
+
const jobs = getJobs();
|
|
81
|
+
return jobs.find(job => job.id === id) || null;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Get a job by name
|
|
86
|
+
* @param {string} name - Job name
|
|
87
|
+
* @returns {object|null} Job object or null if not found
|
|
88
|
+
*/
|
|
89
|
+
export function getJobByName(name) {
|
|
90
|
+
const jobs = getJobs();
|
|
91
|
+
return jobs.find(job => job.name === name) || null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Get a job by ID or name
|
|
96
|
+
* @param {string|number} identifier - Job ID or name
|
|
97
|
+
* @returns {object|null} Job object or null if not found
|
|
98
|
+
*/
|
|
99
|
+
export function getJob(identifier) {
|
|
100
|
+
const id = parseInt(identifier, 10);
|
|
101
|
+
if (!isNaN(id)) {
|
|
102
|
+
const job = getJobById(id);
|
|
103
|
+
if (job) return job;
|
|
104
|
+
}
|
|
105
|
+
return getJobByName(String(identifier));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get the next available job ID
|
|
110
|
+
* @returns {number} Next available ID
|
|
111
|
+
*/
|
|
112
|
+
export function getNextJobId() {
|
|
113
|
+
const jobs = getJobs();
|
|
114
|
+
if (jobs.length === 0) {
|
|
115
|
+
return 1;
|
|
116
|
+
}
|
|
117
|
+
const maxId = Math.max(...jobs.map(job => job.id));
|
|
118
|
+
return maxId + 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Add a new job to storage
|
|
123
|
+
* @param {object} job - Job object (without ID)
|
|
124
|
+
* @returns {object} Job object with assigned ID
|
|
125
|
+
*/
|
|
126
|
+
export function addJob(job) {
|
|
127
|
+
const jobs = getJobs();
|
|
128
|
+
const newJob = {
|
|
129
|
+
...job,
|
|
130
|
+
id: job.id || getNextJobId(),
|
|
131
|
+
createdAt: job.createdAt || new Date().toISOString(),
|
|
132
|
+
updatedAt: new Date().toISOString(),
|
|
133
|
+
};
|
|
134
|
+
jobs.push(newJob);
|
|
135
|
+
saveJobs(jobs);
|
|
136
|
+
return newJob;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Update an existing job
|
|
141
|
+
* @param {number} id - Job ID
|
|
142
|
+
* @param {object} updates - Fields to update
|
|
143
|
+
* @returns {object|null} Updated job or null if not found
|
|
144
|
+
*/
|
|
145
|
+
export function updateJob(id, updates) {
|
|
146
|
+
const jobs = getJobs();
|
|
147
|
+
const index = jobs.findIndex(job => job.id === id);
|
|
148
|
+
if (index === -1) {
|
|
149
|
+
return null;
|
|
150
|
+
}
|
|
151
|
+
jobs[index] = {
|
|
152
|
+
...jobs[index],
|
|
153
|
+
...updates,
|
|
154
|
+
id, // Ensure ID cannot be changed
|
|
155
|
+
updatedAt: new Date().toISOString(),
|
|
156
|
+
};
|
|
157
|
+
saveJobs(jobs);
|
|
158
|
+
return jobs[index];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Remove a job from storage
|
|
163
|
+
* @param {number} id - Job ID
|
|
164
|
+
* @returns {boolean} True if job was removed, false if not found
|
|
165
|
+
*/
|
|
166
|
+
export function removeJob(id) {
|
|
167
|
+
const jobs = getJobs();
|
|
168
|
+
const index = jobs.findIndex(job => job.id === id);
|
|
169
|
+
if (index === -1) {
|
|
170
|
+
return false;
|
|
171
|
+
}
|
|
172
|
+
jobs.splice(index, 1);
|
|
173
|
+
saveJobs(jobs);
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Check if a job name already exists
|
|
179
|
+
* @param {string} name - Job name to check
|
|
180
|
+
* @param {number} excludeId - Optional job ID to exclude from check (for updates)
|
|
181
|
+
* @returns {boolean} True if name exists
|
|
182
|
+
*/
|
|
183
|
+
export function jobNameExists(name, excludeId = null) {
|
|
184
|
+
const jobs = getJobs();
|
|
185
|
+
return jobs.some(job => job.name === name && job.id !== excludeId);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Generate a unique job name with auto-suffix
|
|
190
|
+
* @param {string} baseName - Base name to use
|
|
191
|
+
* @returns {string} Unique name (baseName, baseName-2, baseName-3, etc.)
|
|
192
|
+
*/
|
|
193
|
+
export function generateUniqueName(baseName) {
|
|
194
|
+
if (!jobNameExists(baseName)) {
|
|
195
|
+
return baseName;
|
|
196
|
+
}
|
|
197
|
+
let suffix = 2;
|
|
198
|
+
while (jobNameExists(`${baseName}-${suffix}`)) {
|
|
199
|
+
suffix++;
|
|
200
|
+
}
|
|
201
|
+
return `${baseName}-${suffix}`;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* Generate an auto job name (job-1, job-2, etc.)
|
|
206
|
+
* @returns {string} Auto-generated job name
|
|
207
|
+
*/
|
|
208
|
+
export function generateAutoName() {
|
|
209
|
+
return generateUniqueName('job-1').replace('job-1-', 'job-');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Get jobs filtered by tag
|
|
214
|
+
* @param {string} tag - Tag to filter by
|
|
215
|
+
* @returns {Array} Array of jobs with the specified tag
|
|
216
|
+
*/
|
|
217
|
+
export function getJobsByTag(tag) {
|
|
218
|
+
const jobs = getJobs();
|
|
219
|
+
return jobs.filter(job => job.tags && job.tags.includes(tag));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Get jobs filtered by status
|
|
224
|
+
* @param {string} status - Status to filter by ('active', 'paused', 'completed')
|
|
225
|
+
* @returns {Array} Array of jobs with the specified status
|
|
226
|
+
*/
|
|
227
|
+
export function getJobsByStatus(status) {
|
|
228
|
+
const jobs = getJobs();
|
|
229
|
+
return jobs.filter(job => job.status === status);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* History storage operations
|
|
234
|
+
* Delegated to SQLite database via history-db.js
|
|
235
|
+
*/
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Get execution history
|
|
239
|
+
* @param {object} options - Query options (limit, offset, jobId, status, since, until, order)
|
|
240
|
+
* @returns {Array} Array of history entries
|
|
241
|
+
*/
|
|
242
|
+
export function getHistory(options = {}) {
|
|
243
|
+
return dbGetHistory(options);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Save execution history (deprecated, no-op for SQLite)
|
|
248
|
+
* @param {Array} history - Array of history entries
|
|
249
|
+
* @deprecated History is now automatically saved to SQLite
|
|
250
|
+
*/
|
|
251
|
+
export function saveHistory(history) {
|
|
252
|
+
// No-op: History is now managed by SQLite
|
|
253
|
+
// This function is kept for API compatibility
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Add a history entry
|
|
258
|
+
* @param {object} entry - History entry
|
|
259
|
+
* @returns {object} Added history entry with timestamp and id
|
|
260
|
+
*/
|
|
261
|
+
export function addHistoryEntry(entry) {
|
|
262
|
+
return dbAddHistoryEntry(entry);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Get history for a specific job
|
|
267
|
+
* @param {number} jobId - Job ID
|
|
268
|
+
* @param {number} limit - Maximum number of entries to return
|
|
269
|
+
* @returns {Array} Array of history entries for the job
|
|
270
|
+
*/
|
|
271
|
+
export function getJobHistory(jobId, limit = 10) {
|
|
272
|
+
return dbGetJobHistory(jobId, limit);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Clear history older than a certain date
|
|
277
|
+
* @param {Date} beforeDate - Clear entries before this date
|
|
278
|
+
* @returns {number} Number of entries removed
|
|
279
|
+
*/
|
|
280
|
+
export function clearHistoryBefore(beforeDate) {
|
|
281
|
+
return dbClearHistoryBefore(beforeDate);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Clear all history
|
|
286
|
+
* @returns {number} Number of entries removed
|
|
287
|
+
*/
|
|
288
|
+
export function clearAllHistory() {
|
|
289
|
+
return dbClearAllHistory();
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export default {
|
|
293
|
+
readJsonFile,
|
|
294
|
+
writeJsonFile,
|
|
295
|
+
getJobs,
|
|
296
|
+
saveJobs,
|
|
297
|
+
getJobById,
|
|
298
|
+
getJobByName,
|
|
299
|
+
getJob,
|
|
300
|
+
getNextJobId,
|
|
301
|
+
addJob,
|
|
302
|
+
updateJob,
|
|
303
|
+
removeJob,
|
|
304
|
+
jobNameExists,
|
|
305
|
+
generateUniqueName,
|
|
306
|
+
generateAutoName,
|
|
307
|
+
getJobsByTag,
|
|
308
|
+
getJobsByStatus,
|
|
309
|
+
getHistory,
|
|
310
|
+
saveHistory,
|
|
311
|
+
addHistoryEntry,
|
|
312
|
+
getJobHistory,
|
|
313
|
+
clearHistoryBefore,
|
|
314
|
+
clearAllHistory,
|
|
315
|
+
};
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Command executor for JM2 daemon
|
|
3
|
+
* Handles job execution with process management, timeout, and logging
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { spawn, exec } from 'node:child_process';
|
|
7
|
+
import { addHistoryEntry } from '../core/storage.js';
|
|
8
|
+
import { getJobLogFile, ensureLogsDir } from '../utils/paths.js';
|
|
9
|
+
import { createLogger } from '../core/logger.js';
|
|
10
|
+
import { parseDuration } from '../utils/duration.js';
|
|
11
|
+
import { promisify } from 'node:util';
|
|
12
|
+
|
|
13
|
+
const execAsync = promisify(exec);
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Execution result status
|
|
17
|
+
*/
|
|
18
|
+
export const ExecutionStatus = {
|
|
19
|
+
SUCCESS: 'success',
|
|
20
|
+
FAILED: 'failed',
|
|
21
|
+
TIMEOUT: 'timeout',
|
|
22
|
+
KILLED: 'killed',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Default execution options
|
|
27
|
+
*/
|
|
28
|
+
const DEFAULT_OPTIONS = {
|
|
29
|
+
shell: '/bin/sh',
|
|
30
|
+
timeout: null, // No timeout by default
|
|
31
|
+
cwd: null,
|
|
32
|
+
env: null,
|
|
33
|
+
captureOutput: true,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Execute a job command
|
|
38
|
+
* @param {object} job - Job object to execute
|
|
39
|
+
* @param {object} options - Execution options
|
|
40
|
+
* @returns {Promise<object>} Execution result
|
|
41
|
+
*/
|
|
42
|
+
export function executeJob(job, options = {}) {
|
|
43
|
+
const execOptions = {
|
|
44
|
+
...DEFAULT_OPTIONS,
|
|
45
|
+
...options,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return new Promise((resolve) => {
|
|
49
|
+
const startTime = new Date();
|
|
50
|
+
const startTimeISO = startTime.toISOString();
|
|
51
|
+
const jobId = job.id;
|
|
52
|
+
const jobName = job.name || `job-${jobId}`;
|
|
53
|
+
|
|
54
|
+
// Setup logging - use custom name or just job ID for logger name
|
|
55
|
+
const logFile = getJobLogFile(jobName);
|
|
56
|
+
ensureLogsDir();
|
|
57
|
+
const loggerName = job.name || String(jobId);
|
|
58
|
+
const jobLogger = createLogger({ name: loggerName, file: logFile });
|
|
59
|
+
|
|
60
|
+
// Log execution start
|
|
61
|
+
jobLogger.info(`Starting execution: ${job.command}`);
|
|
62
|
+
jobLogger.info(`Working directory: ${job.cwd || process.cwd()}`);
|
|
63
|
+
|
|
64
|
+
// Prepare spawn options
|
|
65
|
+
const spawnOptions = {
|
|
66
|
+
shell: job.shell || execOptions.shell,
|
|
67
|
+
cwd: job.cwd || execOptions.cwd || process.cwd(),
|
|
68
|
+
env: { ...process.env, ...(job.env || execOptions.env || {}) },
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
// Parse timeout
|
|
72
|
+
let timeoutMs = null;
|
|
73
|
+
if (job.timeout) {
|
|
74
|
+
try {
|
|
75
|
+
timeoutMs = parseDuration(job.timeout);
|
|
76
|
+
jobLogger.info(`Timeout set: ${job.timeout} (${timeoutMs}ms)`);
|
|
77
|
+
} catch (error) {
|
|
78
|
+
jobLogger.warn(`Invalid timeout format: ${job.timeout}`);
|
|
79
|
+
}
|
|
80
|
+
} else if (execOptions.timeout) {
|
|
81
|
+
timeoutMs = execOptions.timeout;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Spawn the process with detached mode to create a new process group
|
|
85
|
+
let childProcess;
|
|
86
|
+
try {
|
|
87
|
+
childProcess = spawn(job.command, [], { ...spawnOptions, detached: true });
|
|
88
|
+
} catch (error) {
|
|
89
|
+
const result = {
|
|
90
|
+
status: ExecutionStatus.FAILED,
|
|
91
|
+
exitCode: null,
|
|
92
|
+
startTime: startTimeISO,
|
|
93
|
+
endTime: new Date().toISOString(),
|
|
94
|
+
duration: 0,
|
|
95
|
+
error: error.message,
|
|
96
|
+
stdout: '',
|
|
97
|
+
stderr: '',
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
jobLogger.error(`Failed to spawn process: ${error.message}`);
|
|
101
|
+
recordHistory(job, result);
|
|
102
|
+
resolve(result);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
let stdout = '';
|
|
107
|
+
let stderr = '';
|
|
108
|
+
let timeoutId = null;
|
|
109
|
+
let timedOut = false;
|
|
110
|
+
|
|
111
|
+
// Handle timeout
|
|
112
|
+
if (timeoutMs && timeoutMs > 0) {
|
|
113
|
+
timeoutId = setTimeout(() => {
|
|
114
|
+
timedOut = true;
|
|
115
|
+
jobLogger.warn(`Job timed out after ${timeoutMs}ms, killing process...`);
|
|
116
|
+
|
|
117
|
+
// Kill the entire process group (negative PID)
|
|
118
|
+
// This ensures child processes spawned by the shell are also killed
|
|
119
|
+
try {
|
|
120
|
+
process.kill(-childProcess.pid, 'SIGTERM');
|
|
121
|
+
} catch (err) {
|
|
122
|
+
// Fallback to single process kill if process group kill fails
|
|
123
|
+
childProcess.kill('SIGTERM');
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Force kill after grace period
|
|
127
|
+
setTimeout(() => {
|
|
128
|
+
try {
|
|
129
|
+
process.kill(-childProcess.pid, 'SIGKILL');
|
|
130
|
+
} catch (err) {
|
|
131
|
+
if (!childProcess.killed) {
|
|
132
|
+
jobLogger.warn('Process did not terminate gracefully, forcing kill...');
|
|
133
|
+
childProcess.kill('SIGKILL');
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}, 1000);
|
|
137
|
+
}, timeoutMs);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Capture stdout
|
|
141
|
+
if (childProcess.stdout) {
|
|
142
|
+
childProcess.stdout.on('data', (data) => {
|
|
143
|
+
const chunk = data.toString();
|
|
144
|
+
stdout += chunk;
|
|
145
|
+
jobLogger.info(`[stdout] ${chunk.trim()}`);
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Capture stderr
|
|
150
|
+
if (childProcess.stderr) {
|
|
151
|
+
childProcess.stderr.on('data', (data) => {
|
|
152
|
+
const chunk = data.toString();
|
|
153
|
+
stderr += chunk;
|
|
154
|
+
jobLogger.warn(`[stderr] ${chunk.trim()}`);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Handle process completion
|
|
159
|
+
childProcess.on('close', (exitCode, signal) => {
|
|
160
|
+
// Clear timeout if set
|
|
161
|
+
if (timeoutId) {
|
|
162
|
+
clearTimeout(timeoutId);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const endTime = new Date();
|
|
166
|
+
const duration = endTime - startTime;
|
|
167
|
+
|
|
168
|
+
// Determine execution status
|
|
169
|
+
let status = ExecutionStatus.SUCCESS;
|
|
170
|
+
let error = null;
|
|
171
|
+
|
|
172
|
+
if (timedOut) {
|
|
173
|
+
status = ExecutionStatus.TIMEOUT;
|
|
174
|
+
error = `Job timed out after ${timeoutMs}ms`;
|
|
175
|
+
} else if (signal) {
|
|
176
|
+
status = ExecutionStatus.KILLED;
|
|
177
|
+
error = `Job killed with signal ${signal}`;
|
|
178
|
+
} else if (exitCode !== 0) {
|
|
179
|
+
status = ExecutionStatus.FAILED;
|
|
180
|
+
error = `Process exited with code ${exitCode}`;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const result = {
|
|
184
|
+
status,
|
|
185
|
+
exitCode: exitCode ?? null,
|
|
186
|
+
signal: signal || null,
|
|
187
|
+
startTime: startTimeISO,
|
|
188
|
+
endTime: endTime.toISOString(),
|
|
189
|
+
duration,
|
|
190
|
+
stdout,
|
|
191
|
+
stderr,
|
|
192
|
+
error,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Log completion
|
|
196
|
+
if (status === ExecutionStatus.SUCCESS) {
|
|
197
|
+
jobLogger.info(`Execution completed successfully in ${duration}ms`);
|
|
198
|
+
} else {
|
|
199
|
+
jobLogger.error(`Execution ${status}: ${error}`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Record history
|
|
203
|
+
recordHistory(job, result);
|
|
204
|
+
|
|
205
|
+
resolve(result);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
// Handle spawn errors
|
|
209
|
+
childProcess.on('error', (error) => {
|
|
210
|
+
if (timeoutId) {
|
|
211
|
+
clearTimeout(timeoutId);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const endTime = new Date();
|
|
215
|
+
const duration = endTime - startTime;
|
|
216
|
+
|
|
217
|
+
const result = {
|
|
218
|
+
status: ExecutionStatus.FAILED,
|
|
219
|
+
exitCode: null,
|
|
220
|
+
signal: null,
|
|
221
|
+
startTime: startTimeISO,
|
|
222
|
+
endTime: endTime.toISOString(),
|
|
223
|
+
duration,
|
|
224
|
+
stdout,
|
|
225
|
+
stderr,
|
|
226
|
+
error: error.message,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
jobLogger.error(`Process error: ${error.message}`);
|
|
230
|
+
recordHistory(job, result);
|
|
231
|
+
|
|
232
|
+
resolve(result);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Record execution history
|
|
239
|
+
* @param {object} job - Job object
|
|
240
|
+
* @param {object} result - Execution result
|
|
241
|
+
*/
|
|
242
|
+
function recordHistory(job, result) {
|
|
243
|
+
try {
|
|
244
|
+
addHistoryEntry({
|
|
245
|
+
jobId: job.id,
|
|
246
|
+
jobName: job.name,
|
|
247
|
+
command: job.command,
|
|
248
|
+
status: result.status,
|
|
249
|
+
exitCode: result.exitCode,
|
|
250
|
+
startTime: result.startTime,
|
|
251
|
+
endTime: result.endTime,
|
|
252
|
+
duration: result.duration,
|
|
253
|
+
error: result.error,
|
|
254
|
+
});
|
|
255
|
+
} catch (error) {
|
|
256
|
+
// Don't let history recording failures break execution
|
|
257
|
+
console.error('Failed to record history:', error.message);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Execute a job with retry logic
|
|
263
|
+
* @param {object} job - Job object to execute
|
|
264
|
+
* @param {object} options - Execution options
|
|
265
|
+
* @returns {Promise<object>} Final execution result
|
|
266
|
+
*/
|
|
267
|
+
export async function executeJobWithRetry(job, options = {}) {
|
|
268
|
+
const maxRetries = job.retry || 0;
|
|
269
|
+
const retryDelay = options.retryDelay || 1000; // Default 1 second delay between retries
|
|
270
|
+
|
|
271
|
+
let lastResult = null;
|
|
272
|
+
let attempt = 0;
|
|
273
|
+
|
|
274
|
+
while (attempt <= maxRetries) {
|
|
275
|
+
attempt++;
|
|
276
|
+
|
|
277
|
+
// Update job retry count for tracking
|
|
278
|
+
if (attempt > 1) {
|
|
279
|
+
const logger = createLogger({ name: 'executor' });
|
|
280
|
+
logger.info(`Retry attempt ${attempt - 1} of ${maxRetries} for job ${job.id}`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
lastResult = await executeJob(job, options);
|
|
284
|
+
|
|
285
|
+
// If successful, return immediately
|
|
286
|
+
if (lastResult.status === ExecutionStatus.SUCCESS) {
|
|
287
|
+
return {
|
|
288
|
+
...lastResult,
|
|
289
|
+
attempts: attempt,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// If not the last attempt, wait before retrying
|
|
294
|
+
if (attempt <= maxRetries) {
|
|
295
|
+
await delay(retryDelay);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// All retries exhausted
|
|
300
|
+
return {
|
|
301
|
+
...lastResult,
|
|
302
|
+
attempts: attempt,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Delay for a specified time
|
|
308
|
+
* @param {number} ms - Milliseconds to delay
|
|
309
|
+
* @returns {Promise<void>}
|
|
310
|
+
*/
|
|
311
|
+
function delay(ms) {
|
|
312
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Kill a running job process
|
|
317
|
+
* @param {object} childProcess - Child process to kill
|
|
318
|
+
* @param {number} gracePeriodMs - Grace period before force kill (default 5000ms)
|
|
319
|
+
* @returns {Promise<boolean>} True if killed successfully
|
|
320
|
+
*/
|
|
321
|
+
export function killJob(childProcess, gracePeriodMs = 5000) {
|
|
322
|
+
return new Promise((resolve) => {
|
|
323
|
+
if (!childProcess || childProcess.killed) {
|
|
324
|
+
resolve(true);
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Try graceful termination of the entire process group first
|
|
329
|
+
try {
|
|
330
|
+
process.kill(-childProcess.pid, 'SIGTERM');
|
|
331
|
+
} catch (err) {
|
|
332
|
+
childProcess.kill('SIGTERM');
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Force kill after grace period
|
|
336
|
+
const forceKillTimeout = setTimeout(() => {
|
|
337
|
+
try {
|
|
338
|
+
process.kill(-childProcess.pid, 'SIGKILL');
|
|
339
|
+
} catch (err) {
|
|
340
|
+
if (!childProcess.killed) {
|
|
341
|
+
childProcess.kill('SIGKILL');
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}, gracePeriodMs);
|
|
345
|
+
|
|
346
|
+
childProcess.on('close', () => {
|
|
347
|
+
clearTimeout(forceKillTimeout);
|
|
348
|
+
resolve(true);
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
// Timeout if process doesn't close
|
|
352
|
+
setTimeout(() => {
|
|
353
|
+
try {
|
|
354
|
+
process.kill(-childProcess.pid, 'SIGKILL');
|
|
355
|
+
} catch (err) {
|
|
356
|
+
if (!childProcess.killed) {
|
|
357
|
+
childProcess.kill('SIGKILL');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
resolve(false);
|
|
361
|
+
}, gracePeriodMs + 1000);
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/**
|
|
366
|
+
* Format duration in human-readable format
|
|
367
|
+
* @param {number} ms - Duration in milliseconds
|
|
368
|
+
* @returns {string} Formatted duration
|
|
369
|
+
*/
|
|
370
|
+
export function formatDuration(ms) {
|
|
371
|
+
if (ms < 1000) {
|
|
372
|
+
return `${ms}ms`;
|
|
373
|
+
}
|
|
374
|
+
if (ms < 60000) {
|
|
375
|
+
return `${(ms / 1000).toFixed(1)}s`;
|
|
376
|
+
}
|
|
377
|
+
if (ms < 3600000) {
|
|
378
|
+
const minutes = Math.floor(ms / 60000);
|
|
379
|
+
const seconds = ((ms % 60000) / 1000).toFixed(0);
|
|
380
|
+
return `${minutes}m${seconds}s`;
|
|
381
|
+
}
|
|
382
|
+
const hours = Math.floor(ms / 3600000);
|
|
383
|
+
const minutes = Math.floor((ms % 3600000) / 60000);
|
|
384
|
+
return `${hours}h${minutes}m`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Create an executor instance
|
|
389
|
+
* @param {object} options - Executor options
|
|
390
|
+
* @param {object} options.logger - Logger instance
|
|
391
|
+
* @returns {object} Executor instance with executeJob and executeJobWithRetry methods
|
|
392
|
+
*/
|
|
393
|
+
export function createExecutor(options = {}) {
|
|
394
|
+
const { logger } = options;
|
|
395
|
+
|
|
396
|
+
return {
|
|
397
|
+
executeJob: (job, execOptions = {}) => executeJob(job, execOptions),
|
|
398
|
+
executeJobWithRetry: (job, execOptions = {}) => executeJobWithRetry(job, execOptions),
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
export default {
|
|
403
|
+
executeJob,
|
|
404
|
+
executeJobWithRetry,
|
|
405
|
+
killJob,
|
|
406
|
+
formatDuration,
|
|
407
|
+
ExecutionStatus,
|
|
408
|
+
createExecutor,
|
|
409
|
+
};
|