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.
Files changed (40) hide show
  1. package/GNU-AGPL-3.0 +665 -0
  2. package/README.md +603 -0
  3. package/bin/jm2.js +24 -0
  4. package/package.json +70 -0
  5. package/src/cli/commands/add.js +206 -0
  6. package/src/cli/commands/config.js +212 -0
  7. package/src/cli/commands/edit.js +198 -0
  8. package/src/cli/commands/export.js +61 -0
  9. package/src/cli/commands/flush.js +132 -0
  10. package/src/cli/commands/history.js +179 -0
  11. package/src/cli/commands/import.js +180 -0
  12. package/src/cli/commands/list.js +174 -0
  13. package/src/cli/commands/logs.js +415 -0
  14. package/src/cli/commands/pause.js +97 -0
  15. package/src/cli/commands/remove.js +107 -0
  16. package/src/cli/commands/restart.js +68 -0
  17. package/src/cli/commands/resume.js +96 -0
  18. package/src/cli/commands/run.js +115 -0
  19. package/src/cli/commands/show.js +159 -0
  20. package/src/cli/commands/start.js +46 -0
  21. package/src/cli/commands/status.js +47 -0
  22. package/src/cli/commands/stop.js +48 -0
  23. package/src/cli/index.js +274 -0
  24. package/src/cli/utils/output.js +267 -0
  25. package/src/cli/utils/prompts.js +56 -0
  26. package/src/core/config.js +227 -0
  27. package/src/core/history-db.js +439 -0
  28. package/src/core/job.js +329 -0
  29. package/src/core/logger.js +382 -0
  30. package/src/core/storage.js +315 -0
  31. package/src/daemon/executor.js +409 -0
  32. package/src/daemon/index.js +873 -0
  33. package/src/daemon/scheduler.js +465 -0
  34. package/src/ipc/client.js +112 -0
  35. package/src/ipc/protocol.js +183 -0
  36. package/src/ipc/server.js +92 -0
  37. package/src/utils/cron.js +205 -0
  38. package/src/utils/datetime.js +237 -0
  39. package/src/utils/duration.js +226 -0
  40. package/src/utils/paths.js +164 -0
@@ -0,0 +1,329 @@
1
+ /**
2
+ * Job model and validation for JM2
3
+ * Defines job structure and provides validation utilities
4
+ */
5
+
6
+ import { parseDuration } from '../utils/duration.js';
7
+ import { validateCronExpression } from '../utils/cron.js';
8
+
9
+ /**
10
+ * Job status constants
11
+ */
12
+ export const JobStatus = {
13
+ ACTIVE: 'active',
14
+ PAUSED: 'paused',
15
+ COMPLETED: 'completed',
16
+ FAILED: 'failed',
17
+ };
18
+
19
+ /**
20
+ * Job type constants
21
+ */
22
+ export const JobType = {
23
+ CRON: 'cron', // Periodic job with cron expression
24
+ ONCE: 'once', // One-time job (--at or --in)
25
+ };
26
+
27
+ /**
28
+ * Default job values
29
+ */
30
+ export const JOB_DEFAULTS = {
31
+ status: JobStatus.ACTIVE,
32
+ tags: [],
33
+ env: {},
34
+ cwd: null,
35
+ shell: null,
36
+ timeout: null,
37
+ retry: 0,
38
+ retryCount: 0,
39
+ lastRun: null,
40
+ lastResult: null,
41
+ nextRun: null,
42
+ runCount: 0,
43
+ };
44
+
45
+ /**
46
+ * Create a new job object with defaults
47
+ * @param {object} data - Job data
48
+ * @returns {object} Job object with defaults applied
49
+ */
50
+ export function createJob(data) {
51
+ const now = new Date().toISOString();
52
+
53
+ return {
54
+ ...JOB_DEFAULTS,
55
+ ...data,
56
+ createdAt: data.createdAt || now,
57
+ updatedAt: now,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Validate a job object
63
+ * @param {object} job - Job object to validate
64
+ * @returns {object} Validation result { valid: boolean, errors: string[] }
65
+ */
66
+ export function validateJob(job) {
67
+ const errors = [];
68
+
69
+ // Required fields
70
+ if (!job.command || typeof job.command !== 'string' || job.command.trim() === '') {
71
+ errors.push('command is required and must be a non-empty string');
72
+ }
73
+
74
+ // Must have either cron or runAt
75
+ if (!job.cron && !job.runAt) {
76
+ errors.push('Either cron expression or runAt datetime is required');
77
+ }
78
+
79
+ if (job.cron && job.runAt) {
80
+ errors.push('Cannot specify both cron and runAt');
81
+ }
82
+
83
+ // Validate job type
84
+ if (job.type && !Object.values(JobType).includes(job.type)) {
85
+ errors.push(`Invalid job type: ${job.type}. Must be one of: ${Object.values(JobType).join(', ')}`);
86
+ }
87
+
88
+ // Validate status
89
+ if (job.status && !Object.values(JobStatus).includes(job.status)) {
90
+ errors.push(`Invalid job status: ${job.status}. Must be one of: ${Object.values(JobStatus).join(', ')}`);
91
+ }
92
+
93
+ // Validate name if provided
94
+ if (job.name !== undefined && job.name !== null) {
95
+ if (typeof job.name !== 'string') {
96
+ errors.push('name must be a string');
97
+ } else if (job.name.trim() === '') {
98
+ errors.push('name cannot be empty');
99
+ } else if (!/^[a-zA-Z0-9_-]+$/.test(job.name)) {
100
+ errors.push('name can only contain letters, numbers, underscores, and hyphens');
101
+ }
102
+ }
103
+
104
+ // Validate tags
105
+ if (job.tags !== undefined) {
106
+ if (!Array.isArray(job.tags)) {
107
+ errors.push('tags must be an array');
108
+ } else {
109
+ for (const tag of job.tags) {
110
+ if (typeof tag !== 'string' || tag.trim() === '') {
111
+ errors.push('Each tag must be a non-empty string');
112
+ break;
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ // Validate env
119
+ if (job.env !== undefined && job.env !== null) {
120
+ if (typeof job.env !== 'object' || Array.isArray(job.env)) {
121
+ errors.push('env must be an object');
122
+ }
123
+ }
124
+
125
+ // Validate timeout
126
+ if (job.timeout !== undefined && job.timeout !== null) {
127
+ if (typeof job.timeout === 'string') {
128
+ try {
129
+ parseDuration(job.timeout);
130
+ } catch {
131
+ errors.push(`Invalid timeout format: ${job.timeout}`);
132
+ }
133
+ } else if (typeof job.timeout !== 'number' || job.timeout <= 0) {
134
+ errors.push('timeout must be a positive number (milliseconds) or duration string');
135
+ }
136
+ }
137
+
138
+ // Validate retry
139
+ if (job.retry !== undefined && job.retry !== null) {
140
+ if (typeof job.retry !== 'number' || job.retry < 0 || !Number.isInteger(job.retry)) {
141
+ errors.push('retry must be a non-negative integer');
142
+ }
143
+ }
144
+
145
+ // Validate cron expression using cron-parser
146
+ if (job.cron) {
147
+ if (typeof job.cron !== 'string') {
148
+ errors.push('cron must be a string');
149
+ } else {
150
+ const validation = validateCronExpression(job.cron);
151
+ if (!validation.valid) {
152
+ errors.push(`Invalid cron expression: ${validation.error}`);
153
+ }
154
+ }
155
+ }
156
+
157
+ // Validate runAt
158
+ if (job.runAt) {
159
+ if (typeof job.runAt !== 'string') {
160
+ errors.push('runAt must be a string (ISO 8601 datetime)');
161
+ } else {
162
+ const date = new Date(job.runAt);
163
+ if (isNaN(date.getTime())) {
164
+ errors.push('runAt must be a valid datetime');
165
+ }
166
+ }
167
+ }
168
+
169
+ return {
170
+ valid: errors.length === 0,
171
+ errors,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Normalize a job object (apply defaults and transformations)
177
+ * @param {object} job - Job object to normalize
178
+ * @returns {object} Normalized job object
179
+ */
180
+ export function normalizeJob(job) {
181
+ const normalized = { ...job };
182
+
183
+ // Determine job type
184
+ if (job.cron) {
185
+ normalized.type = JobType.CRON;
186
+ } else if (job.runAt) {
187
+ normalized.type = JobType.ONCE;
188
+ }
189
+
190
+ // Normalize tags
191
+ if (normalized.tags) {
192
+ normalized.tags = normalized.tags.map(tag => tag.trim().toLowerCase());
193
+ normalized.tags = [...new Set(normalized.tags)]; // Remove duplicates
194
+ }
195
+
196
+ // Normalize timeout to milliseconds
197
+ if (normalized.timeout && typeof normalized.timeout === 'string') {
198
+ normalized.timeout = parseDuration(normalized.timeout);
199
+ }
200
+
201
+ // Trim command
202
+ if (normalized.command) {
203
+ normalized.command = normalized.command.trim();
204
+ }
205
+
206
+ // Trim name
207
+ if (normalized.name) {
208
+ normalized.name = normalized.name.trim();
209
+ }
210
+
211
+ return normalized;
212
+ }
213
+
214
+ /**
215
+ * Check if a job is a one-time job
216
+ * @param {object} job - Job object
217
+ * @returns {boolean} True if one-time job
218
+ */
219
+ export function isOneTimeJob(job) {
220
+ return job.type === JobType.ONCE || !!job.runAt;
221
+ }
222
+
223
+ /**
224
+ * Check if a job is a periodic job
225
+ * @param {object} job - Job object
226
+ * @returns {boolean} True if periodic job
227
+ */
228
+ export function isPeriodicJob(job) {
229
+ return job.type === JobType.CRON || !!job.cron;
230
+ }
231
+
232
+ /**
233
+ * Check if a job is active
234
+ * @param {object} job - Job object
235
+ * @returns {boolean} True if active
236
+ */
237
+ export function isJobActive(job) {
238
+ return job.status === JobStatus.ACTIVE;
239
+ }
240
+
241
+ /**
242
+ * Check if a job is paused
243
+ * @param {object} job - Job object
244
+ * @returns {boolean} True if paused
245
+ */
246
+ export function isJobPaused(job) {
247
+ return job.status === JobStatus.PAUSED;
248
+ }
249
+
250
+ /**
251
+ * Check if a job is completed
252
+ * @param {object} job - Job object
253
+ * @returns {boolean} True if completed
254
+ */
255
+ export function isJobCompleted(job) {
256
+ return job.status === JobStatus.COMPLETED;
257
+ }
258
+
259
+ /**
260
+ * Check if a one-time job has expired (runAt is in the past)
261
+ * @param {object} job - Job object
262
+ * @returns {boolean} True if expired
263
+ */
264
+ export function isJobExpired(job) {
265
+ if (!isOneTimeJob(job) || !job.runAt) {
266
+ return false;
267
+ }
268
+ return new Date(job.runAt) < new Date();
269
+ }
270
+
271
+ /**
272
+ * Format job for display
273
+ * @param {object} job - Job object
274
+ * @returns {object} Formatted job object for display
275
+ */
276
+ export function formatJobForDisplay(job) {
277
+ return {
278
+ id: job.id,
279
+ name: job.name,
280
+ command: job.command,
281
+ type: job.type,
282
+ schedule: job.cron || `at ${job.runAt}`,
283
+ status: job.status,
284
+ tags: job.tags?.join(', ') || '',
285
+ nextRun: job.nextRun,
286
+ lastRun: job.lastRun,
287
+ runCount: job.runCount,
288
+ createdAt: job.createdAt,
289
+ };
290
+ }
291
+
292
+ /**
293
+ * Create a job execution result object
294
+ * @param {object} data - Result data
295
+ * @returns {object} Execution result object
296
+ */
297
+ export function createExecutionResult(data) {
298
+ return {
299
+ jobId: data.jobId,
300
+ jobName: data.jobName,
301
+ startTime: data.startTime || new Date().toISOString(),
302
+ endTime: data.endTime,
303
+ duration: data.duration,
304
+ exitCode: data.exitCode,
305
+ success: data.exitCode === 0,
306
+ stdout: data.stdout || '',
307
+ stderr: data.stderr || '',
308
+ error: data.error || null,
309
+ triggeredBy: data.triggeredBy || 'scheduled',
310
+ attempt: data.attempt || 1,
311
+ };
312
+ }
313
+
314
+ export default {
315
+ JobStatus,
316
+ JobType,
317
+ JOB_DEFAULTS,
318
+ createJob,
319
+ validateJob,
320
+ normalizeJob,
321
+ isOneTimeJob,
322
+ isPeriodicJob,
323
+ isJobActive,
324
+ isJobPaused,
325
+ isJobCompleted,
326
+ isJobExpired,
327
+ formatJobForDisplay,
328
+ createExecutionResult,
329
+ };
@@ -0,0 +1,382 @@
1
+ /**
2
+ * Logger utilities for JM2
3
+ * Provides consistent logging for daemon and job execution
4
+ */
5
+
6
+ import { appendFileSync, writeFileSync, existsSync, mkdirSync, renameSync, statSync, unlinkSync } from 'node:fs';
7
+ import { dirname } from 'node:path';
8
+ import { getDaemonLogFile, getJobLogFile, ensureLogsDir } from '../utils/paths.js';
9
+ import { getConfigValue } from './config.js';
10
+
11
+ /**
12
+ * Log levels
13
+ */
14
+ export const LogLevel = {
15
+ DEBUG: 'DEBUG',
16
+ INFO: 'INFO',
17
+ WARN: 'WARN',
18
+ ERROR: 'ERROR',
19
+ };
20
+
21
+ /**
22
+ * Log level priority (higher = more severe)
23
+ */
24
+ const LOG_LEVEL_PRIORITY = {
25
+ [LogLevel.DEBUG]: 0,
26
+ [LogLevel.INFO]: 1,
27
+ [LogLevel.WARN]: 2,
28
+ [LogLevel.ERROR]: 3,
29
+ };
30
+
31
+ /**
32
+ * Current minimum log level (can be set via environment variable)
33
+ */
34
+ let minLogLevel = process.env.JM2_LOG_LEVEL?.toUpperCase() || LogLevel.INFO;
35
+
36
+ /**
37
+ * Set the minimum log level
38
+ * @param {string} level - Log level (DEBUG, INFO, WARN, ERROR)
39
+ */
40
+ export function setLogLevel(level) {
41
+ const upperLevel = level.toUpperCase();
42
+ if (LOG_LEVEL_PRIORITY[upperLevel] !== undefined) {
43
+ minLogLevel = upperLevel;
44
+ }
45
+ }
46
+
47
+ /**
48
+ * Get the current minimum log level
49
+ * @returns {string} Current log level
50
+ */
51
+ export function getLogLevel() {
52
+ return minLogLevel;
53
+ }
54
+
55
+ /**
56
+ * Check if a log level should be logged based on current minimum level
57
+ * @param {string} level - Log level to check
58
+ * @returns {boolean} True if should be logged
59
+ */
60
+ function shouldLog(level) {
61
+ return LOG_LEVEL_PRIORITY[level] >= LOG_LEVEL_PRIORITY[minLogLevel];
62
+ }
63
+
64
+ /**
65
+ * Format a timestamp for logging
66
+ * @param {Date} date - Date to format
67
+ * @returns {string} Formatted timestamp
68
+ */
69
+ function formatTimestamp(date = new Date()) {
70
+ return date.toISOString();
71
+ }
72
+
73
+ /**
74
+ * Format a log message
75
+ * @param {string} level - Log level
76
+ * @param {string} message - Log message
77
+ * @param {object} meta - Additional metadata
78
+ * @returns {string} Formatted log line
79
+ */
80
+ function formatLogLine(level, message, meta = {}) {
81
+ const timestamp = formatTimestamp();
82
+ const metaStr = Object.keys(meta).length > 0 ? ` ${JSON.stringify(meta)}` : '';
83
+ return `[${timestamp}] [${level}] ${message}${metaStr}`;
84
+ }
85
+
86
+ /**
87
+ * Ensure the directory for a file exists
88
+ * @param {string} filePath - File path
89
+ */
90
+ function ensureFileDir(filePath) {
91
+ const dir = dirname(filePath);
92
+ if (!existsSync(dir)) {
93
+ mkdirSync(dir, { recursive: true });
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Write a log line to a file with rotation support
99
+ * @param {string} filePath - File path
100
+ * @param {string} line - Log line
101
+ * @param {object} rotation - Rotation options
102
+ * @param {number} rotation.maxSize - Maximum file size in bytes
103
+ * @param {number} rotation.maxFiles - Maximum number of log files to keep
104
+ */
105
+ function writeToFile(filePath, line, rotation = null) {
106
+ ensureFileDir(filePath);
107
+
108
+ // Check for rotation if options provided
109
+ if (rotation && rotation.maxSize > 0) {
110
+ checkAndRotate(filePath, rotation.maxSize, rotation.maxFiles || 3);
111
+ }
112
+
113
+ appendFileSync(filePath, line + '\n', 'utf8');
114
+ }
115
+
116
+ /**
117
+ * Get file size in bytes
118
+ * @param {string} filePath - File path
119
+ * @returns {number} File size in bytes, or 0 if file doesn't exist
120
+ */
121
+ function getFileSize(filePath) {
122
+ try {
123
+ if (existsSync(filePath)) {
124
+ return statSync(filePath).size;
125
+ }
126
+ } catch {
127
+ // Ignore errors
128
+ }
129
+ return 0;
130
+ }
131
+
132
+ /**
133
+ * Rotate log files
134
+ * Renames existing log files: file.log -> file.log.1 -> file.log.2 -> ...
135
+ * @param {string} filePath - Base log file path
136
+ * @param {number} maxFiles - Maximum number of log files to keep (including current)
137
+ */
138
+ function rotateLogs(filePath, maxFiles) {
139
+ // Start from the oldest file and work backwards
140
+ for (let i = maxFiles - 1; i >= 0; i--) {
141
+ const srcPath = i === 0 ? filePath : `${filePath}.${i}`;
142
+ const destPath = `${filePath}.${i + 1}`;
143
+
144
+ try {
145
+ if (existsSync(srcPath)) {
146
+ if (i === maxFiles - 1) {
147
+ // Delete the oldest file
148
+ try {
149
+ unlinkSync(srcPath);
150
+ } catch {
151
+ // Ignore unlink errors
152
+ }
153
+ } else {
154
+ renameSync(srcPath, destPath);
155
+ }
156
+ }
157
+ } catch {
158
+ // Ignore rotation errors
159
+ }
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Check if log rotation is needed and perform rotation
165
+ * @param {string} filePath - Log file path
166
+ * @param {number} maxSize - Maximum file size in bytes
167
+ * @param {number} maxFiles - Maximum number of log files to keep
168
+ */
169
+ function checkAndRotate(filePath, maxSize, maxFiles) {
170
+ const fileSize = getFileSize(filePath);
171
+ if (fileSize >= maxSize) {
172
+ rotateLogs(filePath, maxFiles);
173
+ }
174
+ }
175
+
176
+ /**
177
+ * Parse size string to bytes
178
+ * Supports formats like: 100, 10kb, 5mb, 1gb
179
+ * @param {string|number} size - Size string or number (in bytes)
180
+ * @returns {number} Size in bytes
181
+ */
182
+ export function parseSize(size) {
183
+ if (typeof size === 'number') {
184
+ return size;
185
+ }
186
+
187
+ const match = String(size).trim().toLowerCase().match(/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb)?$/);
188
+ if (!match) {
189
+ return 0;
190
+ }
191
+
192
+ const value = parseFloat(match[1]);
193
+ const unit = match[2] || 'b';
194
+
195
+ const multipliers = {
196
+ 'b': 1,
197
+ 'kb': 1024,
198
+ 'mb': 1024 * 1024,
199
+ 'gb': 1024 * 1024 * 1024,
200
+ };
201
+
202
+ return Math.floor(value * multipliers[unit]);
203
+ }
204
+
205
+ /**
206
+ * Format bytes to human readable string
207
+ * @param {number} bytes - Size in bytes
208
+ * @returns {string} Human readable size (e.g., "10MB")
209
+ */
210
+ export function formatSize(bytes) {
211
+ if (bytes === 0) return '0B';
212
+
213
+ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
214
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
215
+ const value = bytes / Math.pow(1024, i);
216
+
217
+ return `${value.toFixed(i === 0 ? 0 : 1)}${units[i]}`;
218
+ }
219
+
220
+ /**
221
+ * Create a logger instance
222
+ * @param {object} options - Logger options
223
+ * @param {string} options.name - Logger name (for prefixing)
224
+ * @param {string} options.file - Log file path (optional)
225
+ * @param {boolean} options.console - Log to console (default: false for daemon)
226
+ * @param {object} options.rotation - Rotation options
227
+ * @param {number} options.rotation.maxSize - Maximum file size in bytes
228
+ * @param {number} options.rotation.maxFiles - Maximum number of log files to keep
229
+ * @returns {object} Logger instance
230
+ */
231
+ export function createLogger(options = {}) {
232
+ const { name = 'JM2', file = null, console: logToConsole = false, rotation = null } = options;
233
+
234
+ const log = (level, message, meta = {}) => {
235
+ if (!shouldLog(level)) {
236
+ return;
237
+ }
238
+
239
+ const line = formatLogLine(level, `[${name}] ${message}`, meta);
240
+
241
+ if (file) {
242
+ writeToFile(file, line, rotation);
243
+ }
244
+
245
+ if (logToConsole) {
246
+ const consoleFn = level === LogLevel.ERROR ? console.error :
247
+ level === LogLevel.WARN ? console.warn :
248
+ console.log;
249
+ consoleFn(line);
250
+ }
251
+ };
252
+
253
+ return {
254
+ debug: (message, meta) => log(LogLevel.DEBUG, message, meta),
255
+ info: (message, meta) => log(LogLevel.INFO, message, meta),
256
+ warn: (message, meta) => log(LogLevel.WARN, message, meta),
257
+ error: (message, meta) => log(LogLevel.ERROR, message, meta),
258
+ log,
259
+ };
260
+ }
261
+
262
+ /**
263
+ * Create a daemon logger
264
+ * Logs to ~/.jm2/daemon.log
265
+ * @param {object} options - Additional options
266
+ * @returns {object} Logger instance
267
+ */
268
+ export function createDaemonLogger(options = {}) {
269
+ // Get rotation settings from config
270
+ const maxFileSize = getConfigValue('logging.maxFileSize', 10 * 1024 * 1024);
271
+ const maxFiles = getConfigValue('logging.maxFiles', 5);
272
+
273
+ return createLogger({
274
+ name: 'daemon',
275
+ file: getDaemonLogFile(),
276
+ console: options.foreground || false,
277
+ rotation: {
278
+ maxSize: maxFileSize,
279
+ maxFiles: maxFiles,
280
+ },
281
+ ...options,
282
+ });
283
+ }
284
+
285
+ /**
286
+ * Create a job logger
287
+ * Logs to ~/.jm2/logs/{jobName}.log
288
+ * @param {string} jobName - Job name
289
+ * @param {object} options - Additional options
290
+ * @returns {object} Logger instance
291
+ */
292
+ export function createJobLogger(jobName, options = {}) {
293
+ ensureLogsDir();
294
+
295
+ // Get rotation settings from config
296
+ const maxFileSize = getConfigValue('logging.maxFileSize', 10 * 1024 * 1024);
297
+ const maxFiles = getConfigValue('logging.maxFiles', 5);
298
+
299
+ return createLogger({
300
+ name: jobName,
301
+ file: getJobLogFile(jobName),
302
+ console: false,
303
+ rotation: {
304
+ maxSize: maxFileSize,
305
+ maxFiles: maxFiles,
306
+ },
307
+ ...options,
308
+ });
309
+ }
310
+
311
+ /**
312
+ * Log job execution start
313
+ * @param {object} logger - Logger instance
314
+ * @param {object} job - Job object
315
+ * @param {string} triggeredBy - What triggered the execution (scheduled, manual)
316
+ */
317
+ export function logJobStart(logger, job, triggeredBy = 'scheduled') {
318
+ logger.info(`Job execution started`, {
319
+ jobId: job.id,
320
+ jobName: job.name,
321
+ command: job.command,
322
+ triggeredBy,
323
+ });
324
+ }
325
+
326
+ /**
327
+ * Log job execution completion
328
+ * @param {object} logger - Logger instance
329
+ * @param {object} job - Job object
330
+ * @param {object} result - Execution result
331
+ */
332
+ export function logJobComplete(logger, job, result) {
333
+ const level = result.exitCode === 0 ? LogLevel.INFO : LogLevel.ERROR;
334
+ logger.log(level, `Job execution completed`, {
335
+ jobId: job.id,
336
+ jobName: job.name,
337
+ exitCode: result.exitCode,
338
+ duration: result.duration,
339
+ success: result.exitCode === 0,
340
+ });
341
+ }
342
+
343
+ /**
344
+ * Log job output (stdout/stderr)
345
+ * @param {object} logger - Logger instance
346
+ * @param {string} stream - 'stdout' or 'stderr'
347
+ * @param {string} data - Output data
348
+ */
349
+ export function logJobOutput(logger, stream, data) {
350
+ const lines = data.toString().split('\n').filter(line => line.trim());
351
+ for (const line of lines) {
352
+ if (stream === 'stderr') {
353
+ logger.error(`[${stream}] ${line}`);
354
+ } else {
355
+ logger.info(`[${stream}] ${line}`);
356
+ }
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Clear a log file
362
+ * @param {string} filePath - File path to clear
363
+ */
364
+ export function clearLogFile(filePath) {
365
+ ensureFileDir(filePath);
366
+ writeFileSync(filePath, '', 'utf8');
367
+ }
368
+
369
+ export default {
370
+ LogLevel,
371
+ setLogLevel,
372
+ getLogLevel,
373
+ createLogger,
374
+ createDaemonLogger,
375
+ createJobLogger,
376
+ logJobStart,
377
+ logJobComplete,
378
+ logJobOutput,
379
+ clearLogFile,
380
+ parseSize,
381
+ formatSize,
382
+ };