lsh-framework 0.5.4
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/.env.example +51 -0
- package/README.md +399 -0
- package/dist/app.js +33 -0
- package/dist/cicd/analytics.js +261 -0
- package/dist/cicd/auth.js +269 -0
- package/dist/cicd/cache-manager.js +172 -0
- package/dist/cicd/data-retention.js +305 -0
- package/dist/cicd/performance-monitor.js +224 -0
- package/dist/cicd/webhook-receiver.js +634 -0
- package/dist/cli.js +500 -0
- package/dist/commands/api.js +343 -0
- package/dist/commands/self.js +318 -0
- package/dist/commands/theme.js +257 -0
- package/dist/commands/zsh-import.js +240 -0
- package/dist/components/App.js +1 -0
- package/dist/components/Divider.js +29 -0
- package/dist/components/REPL.js +43 -0
- package/dist/components/Terminal.js +232 -0
- package/dist/components/UserInput.js +30 -0
- package/dist/daemon/api-server.js +315 -0
- package/dist/daemon/job-registry.js +554 -0
- package/dist/daemon/lshd.js +822 -0
- package/dist/daemon/monitoring-api.js +220 -0
- package/dist/examples/supabase-integration.js +106 -0
- package/dist/lib/api-error-handler.js +183 -0
- package/dist/lib/associative-arrays.js +285 -0
- package/dist/lib/base-api-server.js +290 -0
- package/dist/lib/base-command-registrar.js +286 -0
- package/dist/lib/base-job-manager.js +293 -0
- package/dist/lib/brace-expansion.js +160 -0
- package/dist/lib/builtin-commands.js +439 -0
- package/dist/lib/cloud-config-manager.js +347 -0
- package/dist/lib/command-validator.js +190 -0
- package/dist/lib/completion-system.js +344 -0
- package/dist/lib/cron-job-manager.js +364 -0
- package/dist/lib/daemon-client-helper.js +141 -0
- package/dist/lib/daemon-client.js +501 -0
- package/dist/lib/database-persistence.js +638 -0
- package/dist/lib/database-schema.js +259 -0
- package/dist/lib/enhanced-history-system.js +246 -0
- package/dist/lib/env-validator.js +265 -0
- package/dist/lib/executors/builtin-executor.js +52 -0
- package/dist/lib/extended-globbing.js +411 -0
- package/dist/lib/extended-parameter-expansion.js +227 -0
- package/dist/lib/floating-point-arithmetic.js +256 -0
- package/dist/lib/history-system.js +245 -0
- package/dist/lib/interactive-shell.js +460 -0
- package/dist/lib/job-builtins.js +580 -0
- package/dist/lib/job-manager.js +386 -0
- package/dist/lib/job-storage-database.js +156 -0
- package/dist/lib/job-storage-memory.js +73 -0
- package/dist/lib/logger.js +274 -0
- package/dist/lib/lshrc-init.js +177 -0
- package/dist/lib/pathname-expansion.js +216 -0
- package/dist/lib/prompt-system.js +328 -0
- package/dist/lib/script-runner.js +226 -0
- package/dist/lib/secrets-manager.js +193 -0
- package/dist/lib/shell-executor.js +2504 -0
- package/dist/lib/shell-parser.js +958 -0
- package/dist/lib/shell-types.js +6 -0
- package/dist/lib/shell.lib.js +40 -0
- package/dist/lib/supabase-client.js +58 -0
- package/dist/lib/theme-manager.js +476 -0
- package/dist/lib/variable-expansion.js +385 -0
- package/dist/lib/zsh-compatibility.js +658 -0
- package/dist/lib/zsh-import-manager.js +699 -0
- package/dist/lib/zsh-options.js +328 -0
- package/dist/pipeline/job-tracker.js +491 -0
- package/dist/pipeline/mcli-bridge.js +302 -0
- package/dist/pipeline/pipeline-service.js +1116 -0
- package/dist/pipeline/workflow-engine.js +867 -0
- package/dist/services/api/api.js +58 -0
- package/dist/services/api/auth.js +35 -0
- package/dist/services/api/config.js +7 -0
- package/dist/services/api/file.js +22 -0
- package/dist/services/cron/cron-registrar.js +235 -0
- package/dist/services/cron/cron.js +9 -0
- package/dist/services/daemon/daemon-registrar.js +565 -0
- package/dist/services/daemon/daemon.js +9 -0
- package/dist/services/lib/lib.js +86 -0
- package/dist/services/log-file-extractor.js +170 -0
- package/dist/services/secrets/secrets.js +94 -0
- package/dist/services/shell/shell.js +28 -0
- package/dist/services/supabase/supabase-registrar.js +367 -0
- package/dist/services/supabase/supabase.js +9 -0
- package/dist/services/zapier.js +16 -0
- package/dist/simple-api-server.js +148 -0
- package/dist/store/store.js +31 -0
- package/dist/util/lib.util.js +11 -0
- package/package.json +144 -0
|
@@ -0,0 +1,822 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* LSH Job Daemon - Persistent job execution service
|
|
4
|
+
* Runs independently of LSH shell processes to ensure reliable job execution
|
|
5
|
+
*/
|
|
6
|
+
import { exec } from 'child_process';
|
|
7
|
+
import { promisify } from 'util';
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
import * as net from 'net';
|
|
11
|
+
import { EventEmitter } from 'events';
|
|
12
|
+
import JobManager from '../lib/job-manager.js';
|
|
13
|
+
import { LSHApiServer } from './api-server.js';
|
|
14
|
+
import { validateCommand } from '../lib/command-validator.js';
|
|
15
|
+
import { validateEnvironment, printValidationResults } from '../lib/env-validator.js';
|
|
16
|
+
import { createLogger } from '../lib/logger.js';
|
|
17
|
+
const execAsync = promisify(exec);
|
|
18
|
+
export class LSHJobDaemon extends EventEmitter {
|
|
19
|
+
config;
|
|
20
|
+
jobManager;
|
|
21
|
+
isRunning = false;
|
|
22
|
+
checkTimer;
|
|
23
|
+
logStream;
|
|
24
|
+
ipcServer; // Unix socket server for communication
|
|
25
|
+
lastRunTimes = new Map(); // Track last run time per job
|
|
26
|
+
apiServer; // API server instance
|
|
27
|
+
logger = createLogger('LSHJobDaemon');
|
|
28
|
+
constructor(config) {
|
|
29
|
+
super();
|
|
30
|
+
const userSuffix = process.env.USER ? `-${process.env.USER}` : '';
|
|
31
|
+
this.config = {
|
|
32
|
+
pidFile: `/tmp/lsh-job-daemon${userSuffix}.pid`,
|
|
33
|
+
logFile: `/tmp/lsh-job-daemon${userSuffix}.log`,
|
|
34
|
+
jobsFile: `/tmp/lsh-daemon-jobs${userSuffix}.json`,
|
|
35
|
+
socketPath: `/tmp/lsh-job-daemon${userSuffix}.sock`,
|
|
36
|
+
checkInterval: 2000, // 2 seconds for better cron accuracy
|
|
37
|
+
maxLogSize: 10 * 1024 * 1024, // 10MB
|
|
38
|
+
autoRestart: true,
|
|
39
|
+
apiEnabled: process.env.LSH_API_ENABLED === 'true' || false,
|
|
40
|
+
apiPort: parseInt(process.env.LSH_API_PORT || '3030'),
|
|
41
|
+
apiKey: process.env.LSH_API_KEY,
|
|
42
|
+
enableWebhooks: process.env.LSH_ENABLE_WEBHOOKS === 'true',
|
|
43
|
+
...config
|
|
44
|
+
};
|
|
45
|
+
this.jobManager = new JobManager(this.config.jobsFile);
|
|
46
|
+
this.setupLogging();
|
|
47
|
+
this.setupIPC();
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Start the daemon
|
|
51
|
+
*/
|
|
52
|
+
async start() {
|
|
53
|
+
if (this.isRunning) {
|
|
54
|
+
throw new Error('Daemon is already running');
|
|
55
|
+
}
|
|
56
|
+
// Validate environment variables
|
|
57
|
+
this.log('INFO', 'Validating environment configuration');
|
|
58
|
+
const envValidation = validateEnvironment();
|
|
59
|
+
// Print validation results
|
|
60
|
+
if (envValidation.errors.length > 0 || envValidation.warnings.length > 0) {
|
|
61
|
+
printValidationResults(envValidation, false);
|
|
62
|
+
}
|
|
63
|
+
// Fail fast in production if validation fails
|
|
64
|
+
if (!envValidation.isValid && process.env.NODE_ENV === 'production') {
|
|
65
|
+
this.log('ERROR', 'Environment validation failed in production');
|
|
66
|
+
throw new Error('Invalid environment configuration. Check logs for details.');
|
|
67
|
+
}
|
|
68
|
+
// Log warnings even in development
|
|
69
|
+
if (envValidation.warnings.length > 0) {
|
|
70
|
+
envValidation.warnings.forEach(warn => this.log('WARN', warn));
|
|
71
|
+
}
|
|
72
|
+
// Check if daemon is already running
|
|
73
|
+
if (await this.isDaemonRunning()) {
|
|
74
|
+
throw new Error('Another daemon instance is already running');
|
|
75
|
+
}
|
|
76
|
+
this.log('INFO', 'Starting LSH Job Daemon');
|
|
77
|
+
// Write PID file
|
|
78
|
+
await fs.promises.writeFile(this.config.pidFile, process.pid.toString());
|
|
79
|
+
this.isRunning = true;
|
|
80
|
+
this.startJobScheduler();
|
|
81
|
+
this.startIPCServer();
|
|
82
|
+
// Start API server if enabled
|
|
83
|
+
if (this.config.apiEnabled) {
|
|
84
|
+
try {
|
|
85
|
+
this.apiServer = new LSHApiServer(this, {
|
|
86
|
+
port: this.config.apiPort,
|
|
87
|
+
apiKey: this.config.apiKey,
|
|
88
|
+
enableWebhooks: this.config.enableWebhooks,
|
|
89
|
+
webhookEndpoints: this.config.webhookEndpoints
|
|
90
|
+
});
|
|
91
|
+
await this.apiServer.start();
|
|
92
|
+
this.log('INFO', `API Server started on port ${this.config.apiPort}`);
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
this.log('ERROR', `Failed to start API server: ${error.message}`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Setup cleanup handlers
|
|
99
|
+
this.setupSignalHandlers();
|
|
100
|
+
this.log('INFO', `Daemon started with PID ${process.pid}`);
|
|
101
|
+
this.emit('started');
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Stop the daemon gracefully
|
|
105
|
+
*/
|
|
106
|
+
async stop() {
|
|
107
|
+
if (!this.isRunning) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
this.log('INFO', 'Stopping LSH Job Daemon');
|
|
111
|
+
this.isRunning = false;
|
|
112
|
+
// Stop API server if running
|
|
113
|
+
if (this.apiServer) {
|
|
114
|
+
await this.apiServer.stop();
|
|
115
|
+
this.log('INFO', 'API Server stopped');
|
|
116
|
+
}
|
|
117
|
+
if (this.checkTimer) {
|
|
118
|
+
clearInterval(this.checkTimer);
|
|
119
|
+
}
|
|
120
|
+
// Stop all running jobs gracefully
|
|
121
|
+
await this.stopAllJobs();
|
|
122
|
+
// Cleanup IPC
|
|
123
|
+
if (this.ipcServer) {
|
|
124
|
+
this.ipcServer.close();
|
|
125
|
+
}
|
|
126
|
+
// Remove PID file
|
|
127
|
+
try {
|
|
128
|
+
await fs.promises.unlink(this.config.pidFile);
|
|
129
|
+
}
|
|
130
|
+
catch (_error) {
|
|
131
|
+
// Ignore if file doesn\'t exist
|
|
132
|
+
}
|
|
133
|
+
// Close log stream
|
|
134
|
+
if (this.logStream) {
|
|
135
|
+
this.logStream.end();
|
|
136
|
+
}
|
|
137
|
+
this.log('INFO', 'Daemon stopped');
|
|
138
|
+
this.emit('stopped');
|
|
139
|
+
}
|
|
140
|
+
/**
|
|
141
|
+
* Restart the daemon
|
|
142
|
+
*/
|
|
143
|
+
async restart() {
|
|
144
|
+
await this.stop();
|
|
145
|
+
await new Promise(resolve => setTimeout(resolve, 1000)); // Wait 1 second
|
|
146
|
+
await this.start();
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* Get daemon status
|
|
150
|
+
*/
|
|
151
|
+
async getStatus() {
|
|
152
|
+
const stats = this.jobManager.getJobStats();
|
|
153
|
+
const uptime = process.uptime();
|
|
154
|
+
return {
|
|
155
|
+
isRunning: this.isRunning,
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
uptime,
|
|
158
|
+
jobs: stats,
|
|
159
|
+
config: this.config,
|
|
160
|
+
memoryUsage: process.memoryUsage(),
|
|
161
|
+
cpuUsage: process.cpuUsage()
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Add a job to the daemon
|
|
166
|
+
*/
|
|
167
|
+
async addJob(jobSpec) {
|
|
168
|
+
this.log('INFO', `Adding job: ${jobSpec.name || 'unnamed'}`);
|
|
169
|
+
const job = await this.jobManager.createJob(jobSpec);
|
|
170
|
+
return job;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Start a job
|
|
174
|
+
*/
|
|
175
|
+
async startJob(jobId) {
|
|
176
|
+
this.log('INFO', `Starting job: ${jobId}`);
|
|
177
|
+
const job = await this.jobManager.startJob(jobId);
|
|
178
|
+
return job;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* Trigger a job to run immediately (returns sanitized result with output)
|
|
182
|
+
*/
|
|
183
|
+
async triggerJob(jobId) {
|
|
184
|
+
this.log('INFO', `Triggering job: ${jobId}`);
|
|
185
|
+
try {
|
|
186
|
+
// Get the job details
|
|
187
|
+
const job = await this.jobManager.getJob(jobId);
|
|
188
|
+
if (!job) {
|
|
189
|
+
throw new Error(`Job ${jobId} not found`);
|
|
190
|
+
}
|
|
191
|
+
// Validate command for security issues
|
|
192
|
+
const validation = validateCommand(job.command, {
|
|
193
|
+
allowDangerousCommands: process.env.LSH_ALLOW_DANGEROUS_COMMANDS === 'true',
|
|
194
|
+
maxLength: 10000
|
|
195
|
+
});
|
|
196
|
+
if (!validation.isValid) {
|
|
197
|
+
const errorMsg = `Command validation failed: ${validation.errors.join(', ')}`;
|
|
198
|
+
this.log('ERROR', `${errorMsg} - Risk level: ${validation.riskLevel}`);
|
|
199
|
+
throw new Error(errorMsg);
|
|
200
|
+
}
|
|
201
|
+
// Log warnings if any
|
|
202
|
+
if (validation.warnings.length > 0) {
|
|
203
|
+
this.log('WARN', `Command warnings for job ${jobId}: ${validation.warnings.join(', ')}`);
|
|
204
|
+
}
|
|
205
|
+
// Execute the job command directly and capture output
|
|
206
|
+
const { stdout, stderr } = await execAsync(job.command, {
|
|
207
|
+
cwd: job.cwd || process.cwd(),
|
|
208
|
+
env: { ...process.env, ...job.env },
|
|
209
|
+
timeout: job.timeout || 30000 // 30 second timeout
|
|
210
|
+
});
|
|
211
|
+
this.log('INFO', `Job ${jobId} triggered successfully`);
|
|
212
|
+
return {
|
|
213
|
+
success: true,
|
|
214
|
+
output: stdout || stderr || 'Job completed with no output',
|
|
215
|
+
warnings: validation.warnings.length > 0 ? validation.warnings : undefined
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
this.log('ERROR', `Failed to trigger job ${jobId}: ${error.message}`);
|
|
220
|
+
return {
|
|
221
|
+
success: false,
|
|
222
|
+
error: error.message,
|
|
223
|
+
output: error.stdout || error.stderr
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
/**
|
|
228
|
+
* Stop a job
|
|
229
|
+
*/
|
|
230
|
+
async stopJob(jobId, signal = 'SIGTERM') {
|
|
231
|
+
this.log('INFO', `Stopping job: ${jobId} with signal ${signal}`);
|
|
232
|
+
const job = await this.jobManager.killJob(jobId, signal);
|
|
233
|
+
return job;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Get job information
|
|
237
|
+
*/
|
|
238
|
+
async getJob(jobId) {
|
|
239
|
+
const job = await this.jobManager.getJob(jobId);
|
|
240
|
+
return job ? this.sanitizeJobForSerialization(job) : undefined;
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Sanitize job objects for safe JSON serialization
|
|
244
|
+
*/
|
|
245
|
+
sanitizeJobForSerialization(job) {
|
|
246
|
+
// Use a whitelist approach - only include safe properties
|
|
247
|
+
const sanitized = {
|
|
248
|
+
id: job.id,
|
|
249
|
+
name: job.name,
|
|
250
|
+
command: job.command,
|
|
251
|
+
args: job.args,
|
|
252
|
+
type: job.type,
|
|
253
|
+
status: job.status,
|
|
254
|
+
priority: job.priority,
|
|
255
|
+
pid: job.pid,
|
|
256
|
+
ppid: job.ppid,
|
|
257
|
+
createdAt: job.createdAt,
|
|
258
|
+
startedAt: job.startedAt,
|
|
259
|
+
completedAt: job.completedAt,
|
|
260
|
+
cpuUsage: job.cpuUsage,
|
|
261
|
+
memoryUsage: job.memoryUsage,
|
|
262
|
+
env: job.env,
|
|
263
|
+
cwd: job.cwd,
|
|
264
|
+
user: job.user,
|
|
265
|
+
maxMemory: job.maxMemory,
|
|
266
|
+
maxCpu: job.maxCpu,
|
|
267
|
+
timeout: typeof job.timeout === 'number' ? job.timeout : undefined,
|
|
268
|
+
stdout: job.stdout,
|
|
269
|
+
stderr: job.stderr,
|
|
270
|
+
exitCode: job.exitCode,
|
|
271
|
+
error: job.error,
|
|
272
|
+
tags: job.tags,
|
|
273
|
+
maxRetries: job.maxRetries,
|
|
274
|
+
retryCount: job.retryCount,
|
|
275
|
+
killSignal: job.killSignal,
|
|
276
|
+
killed: job.killed,
|
|
277
|
+
description: job.description,
|
|
278
|
+
workingDirectory: job.workingDirectory,
|
|
279
|
+
databaseSync: job.databaseSync
|
|
280
|
+
};
|
|
281
|
+
// Handle schedule object safely
|
|
282
|
+
if (job.schedule) {
|
|
283
|
+
sanitized.schedule = {
|
|
284
|
+
cron: job.schedule.cron,
|
|
285
|
+
interval: job.schedule.interval,
|
|
286
|
+
nextRun: job.schedule.nextRun
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
// Remove any undefined properties to keep the object clean
|
|
290
|
+
Object.keys(sanitized).forEach(key => {
|
|
291
|
+
if (sanitized[key] === undefined) {
|
|
292
|
+
delete sanitized[key];
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
return sanitized;
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* List all jobs
|
|
299
|
+
*/
|
|
300
|
+
async listJobs(filter, limit) {
|
|
301
|
+
try {
|
|
302
|
+
const jobs = await this.jobManager.listJobs(filter);
|
|
303
|
+
// Sanitize jobs to remove circular references before serialization
|
|
304
|
+
const sanitizedJobs = jobs.map(job => this.sanitizeJobForSerialization(job));
|
|
305
|
+
// Apply limit if specified
|
|
306
|
+
if (limit && limit > 0) {
|
|
307
|
+
return sanitizedJobs.slice(0, limit);
|
|
308
|
+
}
|
|
309
|
+
// Default limit to prevent oversized responses
|
|
310
|
+
const defaultLimit = 100;
|
|
311
|
+
return sanitizedJobs.slice(0, defaultLimit);
|
|
312
|
+
}
|
|
313
|
+
catch (error) {
|
|
314
|
+
this.log('ERROR', `Failed to list jobs: ${error.message}`);
|
|
315
|
+
return [];
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Remove a job
|
|
320
|
+
*/
|
|
321
|
+
async removeJob(jobId, force = false) {
|
|
322
|
+
this.log('INFO', `Removing job: ${jobId}, force: ${force}`);
|
|
323
|
+
return await this.jobManager.removeJob(jobId, force);
|
|
324
|
+
}
|
|
325
|
+
async isDaemonRunning() {
|
|
326
|
+
try {
|
|
327
|
+
// First, kill any existing daemon processes for this socket path
|
|
328
|
+
await this.killExistingDaemons();
|
|
329
|
+
const pidData = await fs.promises.readFile(this.config.pidFile, 'utf8');
|
|
330
|
+
const pid = parseInt(pidData.trim());
|
|
331
|
+
// Check if process is running
|
|
332
|
+
try {
|
|
333
|
+
process.kill(pid, 0); // Signal 0 just checks if process exists
|
|
334
|
+
return true;
|
|
335
|
+
}
|
|
336
|
+
catch (_error) {
|
|
337
|
+
// Process doesn't exist, remove stale PID file
|
|
338
|
+
await fs.promises.unlink(this.config.pidFile);
|
|
339
|
+
return false;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
catch (_error) {
|
|
343
|
+
return false; // PID file doesn't exist
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
async killExistingDaemons() {
|
|
347
|
+
try {
|
|
348
|
+
// Find all lshd processes with the same socket path
|
|
349
|
+
const { stdout } = await execAsync(`ps aux | grep "lshd.js" | grep "${this.config.socketPath}" | grep -v grep || true`);
|
|
350
|
+
if (stdout.trim()) {
|
|
351
|
+
const lines = stdout.trim().split('\n');
|
|
352
|
+
for (const line of lines) {
|
|
353
|
+
const parts = line.trim().split(/\s+/);
|
|
354
|
+
const pid = parseInt(parts[1]);
|
|
355
|
+
if (pid && pid !== process.pid) {
|
|
356
|
+
try {
|
|
357
|
+
this.log('INFO', `Killing existing daemon process ${pid}`);
|
|
358
|
+
process.kill(pid, 9); // Force kill
|
|
359
|
+
}
|
|
360
|
+
catch (_error) {
|
|
361
|
+
// Process might already be dead
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
catch (_error) {
|
|
368
|
+
// ps command failed, ignore
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
startJobScheduler() {
|
|
372
|
+
try {
|
|
373
|
+
this.log('INFO', `📅 Starting job scheduler with ${this.config.checkInterval}ms interval`);
|
|
374
|
+
this.checkTimer = setInterval(() => {
|
|
375
|
+
try {
|
|
376
|
+
this.checkScheduledJobs();
|
|
377
|
+
this.cleanupCompletedJobs();
|
|
378
|
+
this.rotateLogs();
|
|
379
|
+
}
|
|
380
|
+
catch (error) {
|
|
381
|
+
this.log('ERROR', `❌ Scheduler error: ${error.message}`);
|
|
382
|
+
}
|
|
383
|
+
}, this.config.checkInterval);
|
|
384
|
+
this.log('INFO', `✅ Job scheduler started successfully`);
|
|
385
|
+
}
|
|
386
|
+
catch (error) {
|
|
387
|
+
this.log('ERROR', `❌ Failed to start job scheduler: ${error.message}`);
|
|
388
|
+
throw error;
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
async checkScheduledJobs() {
|
|
392
|
+
// Debug: Log scheduler activity periodically
|
|
393
|
+
if (Date.now() % 60000 < 5000) { // Log once per minute approximately
|
|
394
|
+
this.log('DEBUG', `🔄 Scheduler check: Looking for jobs to run...`);
|
|
395
|
+
}
|
|
396
|
+
// Check both created and completed jobs (for recurring schedules)
|
|
397
|
+
const jobs = await this.jobManager.listJobs({ status: ['created', 'completed'] });
|
|
398
|
+
const now = new Date();
|
|
399
|
+
for (const job of jobs) {
|
|
400
|
+
if (job.schedule) {
|
|
401
|
+
let shouldRun = false;
|
|
402
|
+
// Check cron schedule
|
|
403
|
+
if (job.schedule.cron) {
|
|
404
|
+
// For completed jobs, check if cron schedule matches (allow re-run)
|
|
405
|
+
// For created jobs, check if we haven't run this job in the current minute
|
|
406
|
+
const currentMinute = Math.floor(now.getTime() / 60000);
|
|
407
|
+
const lastRun = this.lastRunTimes.get(job.id);
|
|
408
|
+
if (job.status === 'completed') {
|
|
409
|
+
// Always check cron for completed jobs to allow recurring execution
|
|
410
|
+
shouldRun = this.shouldRunByCron(job.schedule.cron, now);
|
|
411
|
+
}
|
|
412
|
+
else if (!lastRun || lastRun < currentMinute) {
|
|
413
|
+
shouldRun = this.shouldRunByCron(job.schedule.cron, now);
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Check interval schedule
|
|
417
|
+
if (job.schedule.interval && job.schedule.nextRun) {
|
|
418
|
+
shouldRun = now >= job.schedule.nextRun;
|
|
419
|
+
}
|
|
420
|
+
if (shouldRun) {
|
|
421
|
+
try {
|
|
422
|
+
// For completed cron jobs, reset to created status before starting
|
|
423
|
+
if (job.schedule.cron && job.status === 'completed') {
|
|
424
|
+
job.status = 'created';
|
|
425
|
+
job.completedAt = undefined;
|
|
426
|
+
job.stdout = '';
|
|
427
|
+
job.stderr = '';
|
|
428
|
+
this.jobManager.persistJobs();
|
|
429
|
+
this.log('INFO', `🔄 Reset completed job for recurring execution: ${job.id} (${job.name})`);
|
|
430
|
+
}
|
|
431
|
+
// Track that we're running this job now
|
|
432
|
+
if (job.schedule.cron) {
|
|
433
|
+
const currentMinute = Math.floor(now.getTime() / 60000);
|
|
434
|
+
this.lastRunTimes.set(job.id, currentMinute);
|
|
435
|
+
}
|
|
436
|
+
this.log('INFO', `Started scheduled job: ${job.id} (${job.name})`);
|
|
437
|
+
await this.jobManager.startJob(job.id);
|
|
438
|
+
// Schedule next run for interval jobs
|
|
439
|
+
if (job.schedule.interval) {
|
|
440
|
+
job.schedule.nextRun = new Date(now.getTime() + job.schedule.interval);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
catch (error) {
|
|
444
|
+
this.log('ERROR', `❌ Failed to start scheduled job ${job.id}: ${error.message}`);
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
shouldRunByCron(cronExpr, now) {
|
|
451
|
+
try {
|
|
452
|
+
const [minute, hour, day, month, weekday] = cronExpr.split(' ');
|
|
453
|
+
// We check if we're at the exact minute/second to avoid duplicate runs
|
|
454
|
+
// Only run in the first 30 seconds of the target minute
|
|
455
|
+
// This gives us a wider window with 2-second check intervals
|
|
456
|
+
if (now.getSeconds() > 30) {
|
|
457
|
+
return false;
|
|
458
|
+
}
|
|
459
|
+
// Check minute field
|
|
460
|
+
if (!this.matchesCronField(minute, now.getMinutes(), 0, 59)) {
|
|
461
|
+
return false;
|
|
462
|
+
}
|
|
463
|
+
// Check hour field
|
|
464
|
+
if (!this.matchesCronField(hour, now.getHours(), 0, 23)) {
|
|
465
|
+
return false;
|
|
466
|
+
}
|
|
467
|
+
// Check day field
|
|
468
|
+
if (!this.matchesCronField(day, now.getDate(), 1, 31)) {
|
|
469
|
+
return false;
|
|
470
|
+
}
|
|
471
|
+
// Check month field
|
|
472
|
+
if (!this.matchesCronField(month, now.getMonth() + 1, 1, 12)) {
|
|
473
|
+
return false;
|
|
474
|
+
}
|
|
475
|
+
// Check weekday field (0 = Sunday, 6 = Saturday)
|
|
476
|
+
if (!this.matchesCronField(weekday, now.getDay(), 0, 6)) {
|
|
477
|
+
return false;
|
|
478
|
+
}
|
|
479
|
+
return true;
|
|
480
|
+
}
|
|
481
|
+
catch (error) {
|
|
482
|
+
this.log('ERROR', `Invalid cron expression: ${cronExpr} - ${error.message}`);
|
|
483
|
+
return false;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
matchesCronField(field, currentValue, _min, _max) {
|
|
487
|
+
// Handle wildcard
|
|
488
|
+
if (field === '*') {
|
|
489
|
+
return true;
|
|
490
|
+
}
|
|
491
|
+
// Handle specific number (e.g., "0", "2", "15")
|
|
492
|
+
if (/^\d+$/.test(field)) {
|
|
493
|
+
return parseInt(field) === currentValue;
|
|
494
|
+
}
|
|
495
|
+
// Handle intervals (e.g., "*/5", "*/2", "*/30")
|
|
496
|
+
if (field.startsWith('*/')) {
|
|
497
|
+
const interval = parseInt(field.substring(2));
|
|
498
|
+
return currentValue % interval === 0;
|
|
499
|
+
}
|
|
500
|
+
// Handle ranges (e.g., "1-5", "10-15")
|
|
501
|
+
if (field.includes('-')) {
|
|
502
|
+
const [start, end] = field.split('-').map(x => parseInt(x));
|
|
503
|
+
return currentValue >= start && currentValue <= end;
|
|
504
|
+
}
|
|
505
|
+
// Handle lists (e.g., "1,3,5", "10,20,30")
|
|
506
|
+
if (field.includes(',')) {
|
|
507
|
+
const values = field.split(',').map(x => parseInt(x));
|
|
508
|
+
return values.includes(currentValue);
|
|
509
|
+
}
|
|
510
|
+
// Handle step values (e.g., "1-10/2" = every 2 from 1 to 10)
|
|
511
|
+
if (field.includes('/')) {
|
|
512
|
+
const [range, step] = field.split('/');
|
|
513
|
+
const stepNum = parseInt(step);
|
|
514
|
+
if (range.includes('-')) {
|
|
515
|
+
const [start, end] = range.split('-').map(x => parseInt(x));
|
|
516
|
+
if (currentValue < start || currentValue > end)
|
|
517
|
+
return false;
|
|
518
|
+
return (currentValue - start) % stepNum === 0;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
/**
|
|
524
|
+
* Reset job status for recurring cron jobs after completion
|
|
525
|
+
*/
|
|
526
|
+
async resetRecurringJobStatus(jobId) {
|
|
527
|
+
try {
|
|
528
|
+
const job = await this.jobManager.getJob(jobId);
|
|
529
|
+
if (job && job.schedule?.cron && job.status === 'completed') {
|
|
530
|
+
// Reset status for next scheduled run
|
|
531
|
+
job.status = 'created';
|
|
532
|
+
job.completedAt = undefined;
|
|
533
|
+
job.stdout = '';
|
|
534
|
+
job.stderr = '';
|
|
535
|
+
// Force persistence by calling internal method via reflection
|
|
536
|
+
// Note: This is a temporary workaround for private method access
|
|
537
|
+
this.jobManager.persistJobs();
|
|
538
|
+
this.log('INFO', `🔄 Reset recurring job status: ${jobId} (${job.name}) for next scheduled run`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
catch (error) {
|
|
542
|
+
this.log('ERROR', `Failed to reset recurring job status ${jobId}: ${error.message}`);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
async cleanupCompletedJobs() {
|
|
546
|
+
const cleaned = await this.jobManager.cleanupJobs(24); // Clean jobs older than 24 hours
|
|
547
|
+
if (cleaned > 0) {
|
|
548
|
+
this.log('INFO', `Cleaned up ${cleaned} old jobs`);
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
async stopAllJobs() {
|
|
552
|
+
const runningJobs = await this.jobManager.listJobs({ status: ['running', 'stopped'] });
|
|
553
|
+
for (const job of runningJobs) {
|
|
554
|
+
try {
|
|
555
|
+
await this.jobManager.killJob(job.id, 'SIGTERM');
|
|
556
|
+
this.log('INFO', `Stopped job: ${job.id}`);
|
|
557
|
+
}
|
|
558
|
+
catch (error) {
|
|
559
|
+
this.log('ERROR', `Failed to stop job ${job.id}: ${error.message}`);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
setupLogging() {
|
|
564
|
+
// Create log directory if it doesn't exist
|
|
565
|
+
const logDir = path.dirname(this.config.logFile);
|
|
566
|
+
if (!fs.existsSync(logDir)) {
|
|
567
|
+
fs.mkdirSync(logDir, { recursive: true });
|
|
568
|
+
}
|
|
569
|
+
this.logStream = fs.createWriteStream(this.config.logFile, { flags: 'a' });
|
|
570
|
+
// Log uncaught exceptions
|
|
571
|
+
process.on('uncaughtException', (error) => {
|
|
572
|
+
this.log('FATAL', `Uncaught exception: ${error.message}`);
|
|
573
|
+
this.log('FATAL', error.stack || '');
|
|
574
|
+
if (this.config.autoRestart) {
|
|
575
|
+
this.restart();
|
|
576
|
+
}
|
|
577
|
+
else {
|
|
578
|
+
process.exit(1);
|
|
579
|
+
}
|
|
580
|
+
});
|
|
581
|
+
process.on('unhandledRejection', (reason) => {
|
|
582
|
+
this.log('ERROR', `Unhandled promise rejection: ${reason}`);
|
|
583
|
+
});
|
|
584
|
+
}
|
|
585
|
+
setupIPC() {
|
|
586
|
+
// Setup Unix domain socket for communication with LSH clients
|
|
587
|
+
// Remove existing socket file
|
|
588
|
+
try {
|
|
589
|
+
fs.unlinkSync(this.config.socketPath);
|
|
590
|
+
}
|
|
591
|
+
catch (_error) {
|
|
592
|
+
// Ignore if doesn't exist
|
|
593
|
+
}
|
|
594
|
+
this.ipcServer = net.createServer((socket) => {
|
|
595
|
+
socket.on('data', async (data) => {
|
|
596
|
+
let messageId;
|
|
597
|
+
try {
|
|
598
|
+
const message = JSON.parse(data.toString());
|
|
599
|
+
messageId = message.id;
|
|
600
|
+
const response = await this.handleIPCMessage(message);
|
|
601
|
+
socket.write(JSON.stringify({
|
|
602
|
+
success: true,
|
|
603
|
+
data: response,
|
|
604
|
+
id: messageId
|
|
605
|
+
}));
|
|
606
|
+
}
|
|
607
|
+
catch (error) {
|
|
608
|
+
socket.write(JSON.stringify({
|
|
609
|
+
success: false,
|
|
610
|
+
error: error.message,
|
|
611
|
+
id: messageId
|
|
612
|
+
}));
|
|
613
|
+
}
|
|
614
|
+
});
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
startIPCServer() {
|
|
618
|
+
if (this.ipcServer) {
|
|
619
|
+
// Clean up any existing socket file
|
|
620
|
+
try {
|
|
621
|
+
if (fs.existsSync(this.config.socketPath)) {
|
|
622
|
+
fs.unlinkSync(this.config.socketPath);
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
catch (_error) {
|
|
626
|
+
// Ignore cleanup errors
|
|
627
|
+
}
|
|
628
|
+
this.ipcServer.listen(this.config.socketPath, () => {
|
|
629
|
+
this.log('INFO', `IPC server listening on ${this.config.socketPath}`);
|
|
630
|
+
});
|
|
631
|
+
this.ipcServer.on('error', (error) => {
|
|
632
|
+
this.log('ERROR', `IPC server error: ${error.message}`);
|
|
633
|
+
if (error.message.includes('EADDRINUSE')) {
|
|
634
|
+
this.log('INFO', 'Socket already in use, attempting cleanup...');
|
|
635
|
+
try {
|
|
636
|
+
fs.unlinkSync(this.config.socketPath);
|
|
637
|
+
// Retry after cleanup
|
|
638
|
+
setTimeout(() => {
|
|
639
|
+
this.ipcServer.listen(this.config.socketPath);
|
|
640
|
+
}, 1000);
|
|
641
|
+
}
|
|
642
|
+
catch (cleanupError) {
|
|
643
|
+
this.log('ERROR', `Failed to cleanup socket: ${cleanupError.message}`);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
});
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
async handleIPCMessage(message) {
|
|
650
|
+
const { command, args } = message;
|
|
651
|
+
switch (command) {
|
|
652
|
+
case 'status':
|
|
653
|
+
return await this.getStatus();
|
|
654
|
+
case 'addJob':
|
|
655
|
+
return await this.addJob(args.jobSpec);
|
|
656
|
+
case 'startJob':
|
|
657
|
+
return await this.startJob(args.jobId);
|
|
658
|
+
case 'triggerJob':
|
|
659
|
+
return await this.triggerJob(args.jobId);
|
|
660
|
+
case 'stopJob':
|
|
661
|
+
return await this.stopJob(args.jobId, args.signal);
|
|
662
|
+
case 'listJobs':
|
|
663
|
+
return this.listJobs(args.filter, args.limit);
|
|
664
|
+
case 'getJob':
|
|
665
|
+
return this.getJob(args.jobId);
|
|
666
|
+
case 'removeJob':
|
|
667
|
+
return await this.removeJob(args.jobId, args.force);
|
|
668
|
+
case 'restart':
|
|
669
|
+
await this.restart();
|
|
670
|
+
return { message: 'Daemon restarted' };
|
|
671
|
+
case 'stop':
|
|
672
|
+
await this.stop();
|
|
673
|
+
return { message: 'Daemon stopped' };
|
|
674
|
+
default:
|
|
675
|
+
throw new Error(`Unknown command: ${command}`);
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
log(level, message) {
|
|
679
|
+
const timestamp = new Date().toISOString();
|
|
680
|
+
const logEntry = `[${timestamp}] ${level}: ${message}\n`;
|
|
681
|
+
// Write to log file
|
|
682
|
+
if (this.logStream) {
|
|
683
|
+
this.logStream.write(logEntry);
|
|
684
|
+
}
|
|
685
|
+
// Also output using logger
|
|
686
|
+
switch (level.toUpperCase()) {
|
|
687
|
+
case 'DEBUG':
|
|
688
|
+
this.logger.debug(message);
|
|
689
|
+
break;
|
|
690
|
+
case 'INFO':
|
|
691
|
+
this.logger.info(message);
|
|
692
|
+
break;
|
|
693
|
+
case 'WARN':
|
|
694
|
+
case 'WARNING':
|
|
695
|
+
this.logger.warn(message);
|
|
696
|
+
break;
|
|
697
|
+
case 'ERROR':
|
|
698
|
+
case 'FATAL':
|
|
699
|
+
this.logger.error(message);
|
|
700
|
+
break;
|
|
701
|
+
default:
|
|
702
|
+
this.logger.info(message);
|
|
703
|
+
}
|
|
704
|
+
this.emit('log', level, message);
|
|
705
|
+
}
|
|
706
|
+
rotateLogs() {
|
|
707
|
+
try {
|
|
708
|
+
const stats = fs.statSync(this.config.logFile);
|
|
709
|
+
if (stats.size > this.config.maxLogSize) {
|
|
710
|
+
const backupFile = `${this.config.logFile}.${Date.now()}`;
|
|
711
|
+
fs.renameSync(this.config.logFile, backupFile);
|
|
712
|
+
// Close current stream and create new one
|
|
713
|
+
if (this.logStream) {
|
|
714
|
+
this.logStream.end();
|
|
715
|
+
this.logStream = fs.createWriteStream(this.config.logFile, { flags: 'a' });
|
|
716
|
+
}
|
|
717
|
+
this.log('INFO', `Rotated log file to ${backupFile}`);
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
catch (_error) {
|
|
721
|
+
// Ignore rotation errors
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
setupSignalHandlers() {
|
|
725
|
+
process.on('SIGTERM', async () => {
|
|
726
|
+
this.log('INFO', 'Received SIGTERM, shutting down gracefully');
|
|
727
|
+
await this.stop();
|
|
728
|
+
process.exit(0);
|
|
729
|
+
});
|
|
730
|
+
process.on('SIGINT', async () => {
|
|
731
|
+
this.log('INFO', 'Received SIGINT, shutting down gracefully');
|
|
732
|
+
await this.stop();
|
|
733
|
+
process.exit(0);
|
|
734
|
+
});
|
|
735
|
+
process.on('SIGHUP', async () => {
|
|
736
|
+
this.log('INFO', 'Received SIGHUP, restarting');
|
|
737
|
+
await this.restart();
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
// Module-level logger for CLI operations
|
|
742
|
+
const cliLogger = createLogger('LSHDaemonCLI');
|
|
743
|
+
// CLI interface for the daemon
|
|
744
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
745
|
+
const command = process.argv[2];
|
|
746
|
+
const subCommand = process.argv[3];
|
|
747
|
+
const _args = process.argv.slice(4);
|
|
748
|
+
// Handle job commands
|
|
749
|
+
if (command === 'job-add') {
|
|
750
|
+
(async () => {
|
|
751
|
+
try {
|
|
752
|
+
const jobCommand = subCommand;
|
|
753
|
+
if (!jobCommand) {
|
|
754
|
+
cliLogger.error('Usage: lshd job-add "command-to-run"');
|
|
755
|
+
process.exit(1);
|
|
756
|
+
}
|
|
757
|
+
const client = new (await import('../lib/daemon-client.js')).default();
|
|
758
|
+
if (!client.isDaemonRunning()) {
|
|
759
|
+
cliLogger.error('Daemon is not running. Start it with: lsh daemon start');
|
|
760
|
+
process.exit(1);
|
|
761
|
+
}
|
|
762
|
+
await client.connect();
|
|
763
|
+
const jobSpec = {
|
|
764
|
+
id: `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
|
765
|
+
name: `Manual Job - ${jobCommand}`,
|
|
766
|
+
command: jobCommand,
|
|
767
|
+
type: 'manual',
|
|
768
|
+
schedule: { interval: 0 }, // Run once
|
|
769
|
+
env: process.env,
|
|
770
|
+
cwd: process.cwd(),
|
|
771
|
+
user: process.env.USER,
|
|
772
|
+
priority: 5,
|
|
773
|
+
tags: ['manual'],
|
|
774
|
+
enabled: true,
|
|
775
|
+
maxRetries: 0,
|
|
776
|
+
timeout: 0,
|
|
777
|
+
};
|
|
778
|
+
const result = await client.addJob(jobSpec);
|
|
779
|
+
cliLogger.info('Job added successfully', { id: result.id, command: result.command, status: result.status });
|
|
780
|
+
// Start the job immediately
|
|
781
|
+
await client.startJob(result.id);
|
|
782
|
+
cliLogger.info(`Job ${result.id} started`);
|
|
783
|
+
client.disconnect();
|
|
784
|
+
process.exit(0);
|
|
785
|
+
}
|
|
786
|
+
catch (error) {
|
|
787
|
+
cliLogger.error('Failed to add job', error);
|
|
788
|
+
process.exit(1);
|
|
789
|
+
}
|
|
790
|
+
})();
|
|
791
|
+
}
|
|
792
|
+
else {
|
|
793
|
+
const socketPath = subCommand;
|
|
794
|
+
const daemon = new LSHJobDaemon(socketPath ? { socketPath } : undefined);
|
|
795
|
+
switch (command) {
|
|
796
|
+
case 'start':
|
|
797
|
+
daemon.start().catch((error) => cliLogger.error('Failed to start daemon', error));
|
|
798
|
+
// Keep the process alive
|
|
799
|
+
process.stdin.resume();
|
|
800
|
+
break;
|
|
801
|
+
case 'stop':
|
|
802
|
+
daemon.stop().catch((error) => cliLogger.error('Failed to stop daemon', error));
|
|
803
|
+
break;
|
|
804
|
+
case 'restart':
|
|
805
|
+
daemon.restart().catch((error) => cliLogger.error('Failed to restart daemon', error));
|
|
806
|
+
// Keep the process alive
|
|
807
|
+
process.stdin.resume();
|
|
808
|
+
break;
|
|
809
|
+
case 'status':
|
|
810
|
+
daemon.getStatus().then(status => {
|
|
811
|
+
cliLogger.info(JSON.stringify(status, null, 2));
|
|
812
|
+
process.exit(0);
|
|
813
|
+
}).catch((error) => cliLogger.error('Failed to get daemon status', error));
|
|
814
|
+
break;
|
|
815
|
+
default:
|
|
816
|
+
cliLogger.info('Usage: lshd {start|stop|restart|status|job-add}');
|
|
817
|
+
cliLogger.info(' lshd job-add "command" - Add and start a job');
|
|
818
|
+
process.exit(1);
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
export default LSHJobDaemon;
|