lsh-framework 3.2.5 → 3.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +72 -34
  3. package/dist/commands/ipfs.js +7 -12
  4. package/dist/commands/self.js +22 -16
  5. package/dist/commands/sync.js +49 -38
  6. package/dist/constants/config.js +3 -0
  7. package/dist/lib/floating-point-arithmetic.js +2 -2
  8. package/dist/lib/ipfs-client-manager.js +51 -13
  9. package/dist/lib/ipfs-secrets-storage.js +21 -16
  10. package/dist/lib/ipfs-sync.js +88 -14
  11. package/dist/lib/secrets-manager.js +117 -47
  12. package/dist/lib/sync-key-store.js +87 -0
  13. package/dist/services/secrets/secrets.js +77 -39
  14. package/package.json +16 -16
  15. package/dist/__tests__/fixtures/job-fixtures.js +0 -204
  16. package/dist/__tests__/fixtures/supabase-mocks.js +0 -252
  17. package/dist/daemon/job-registry.js +0 -556
  18. package/dist/daemon/lshd.js +0 -968
  19. package/dist/daemon/saas-api-routes.js +0 -599
  20. package/dist/daemon/saas-api-server.js +0 -231
  21. package/dist/examples/supabase-integration.js +0 -106
  22. package/dist/lib/api-response.js +0 -226
  23. package/dist/lib/base-command-registrar.js +0 -287
  24. package/dist/lib/base-job-manager.js +0 -295
  25. package/dist/lib/cloud-config-manager.js +0 -348
  26. package/dist/lib/cron-job-manager.js +0 -368
  27. package/dist/lib/daemon-client-helper.js +0 -145
  28. package/dist/lib/daemon-client.js +0 -513
  29. package/dist/lib/database-persistence.js +0 -727
  30. package/dist/lib/database-schema.js +0 -259
  31. package/dist/lib/database-types.js +0 -90
  32. package/dist/lib/enhanced-history-system.js +0 -247
  33. package/dist/lib/history-system.js +0 -246
  34. package/dist/lib/job-manager.js +0 -436
  35. package/dist/lib/job-storage-database.js +0 -164
  36. package/dist/lib/job-storage-memory.js +0 -73
  37. package/dist/lib/local-storage-adapter.js +0 -507
  38. package/dist/lib/optimized-job-scheduler.js +0 -356
  39. package/dist/lib/saas-audit.js +0 -215
  40. package/dist/lib/saas-auth.js +0 -465
  41. package/dist/lib/saas-billing.js +0 -503
  42. package/dist/lib/saas-email.js +0 -403
  43. package/dist/lib/saas-encryption.js +0 -221
  44. package/dist/lib/saas-organizations.js +0 -662
  45. package/dist/lib/saas-secrets.js +0 -408
  46. package/dist/lib/saas-types.js +0 -165
  47. package/dist/lib/supabase-client.js +0 -125
  48. package/dist/lib/supabase-utils.js +0 -396
  49. package/dist/services/cron/cron-registrar.js +0 -240
  50. package/dist/services/cron/cron.js +0 -9
  51. package/dist/services/daemon/daemon-registrar.js +0 -585
  52. package/dist/services/daemon/daemon.js +0 -9
  53. package/dist/services/supabase/supabase-registrar.js +0 -375
  54. package/dist/services/supabase/supabase.js +0 -9
@@ -1,968 +0,0 @@
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 { validateCommand } from '../lib/command-validator.js';
14
- import { validateEnvironment, printValidationResults } from '../lib/env-validator.js';
15
- import { createLogger } from '../lib/logger.js';
16
- import { getPlatformPaths } from '../lib/platform-utils.js';
17
- import { ENV_VARS, DEFAULTS, ERRORS } from '../constants/index.js';
18
- import { OptimizedJobScheduler } from '../lib/optimized-job-scheduler.js';
19
- import { PerformanceProfiler } from '../lib/metrics/performance-profiler.js';
20
- const execAsync = promisify(exec);
21
- export class LSHJobDaemon extends EventEmitter {
22
- config;
23
- jobManager;
24
- isRunning = false;
25
- checkTimer;
26
- logStream;
27
- ipcServer; // IPC server (Unix sockets or Named Pipes)
28
- lastRunTimes = new Map(); // Track last run time per job
29
- logger = createLogger('LSHJobDaemon');
30
- optimizedScheduler; // Priority queue scheduler (Issue #108)
31
- startupProfiler; // Startup performance profiler
32
- constructor(config) {
33
- super();
34
- // Use cross-platform paths
35
- const platformPaths = getPlatformPaths('lsh');
36
- const jobsFilePath = path.join(platformPaths.tmpDir, `lsh-daemon-jobs-${platformPaths.user}.json`);
37
- this.config = {
38
- pidFile: platformPaths.pidFile,
39
- logFile: platformPaths.logFile,
40
- jobsFile: jobsFilePath,
41
- socketPath: platformPaths.socketPath,
42
- checkInterval: DEFAULTS.CHECK_INTERVAL_MS,
43
- maxLogSize: DEFAULTS.MAX_LOG_SIZE_BYTES,
44
- autoRestart: true,
45
- apiEnabled: process.env[ENV_VARS.LSH_API_ENABLED] === 'true' || false,
46
- apiPort: parseInt(process.env[ENV_VARS.LSH_API_PORT] || String(DEFAULTS.API_PORT)),
47
- apiKey: process.env[ENV_VARS.LSH_API_KEY],
48
- enableWebhooks: process.env[ENV_VARS.LSH_ENABLE_WEBHOOKS] === 'true',
49
- useOptimizedScheduler: process.env[ENV_VARS.LSH_USE_OPTIMIZED_SCHEDULER] === 'true',
50
- ...config
51
- };
52
- this.jobManager = new JobManager(this.config.jobsFile);
53
- this.setupLogging();
54
- this.setupIPC();
55
- // Initialize optimized scheduler if enabled (Issue #108)
56
- if (this.config.useOptimizedScheduler) {
57
- this.initializeOptimizedScheduler();
58
- }
59
- }
60
- /**
61
- * Initialize the optimized job scheduler (Issue #108)
62
- * Uses a priority queue-based approach for O(log n) scheduling vs O(n) linear scan
63
- */
64
- initializeOptimizedScheduler() {
65
- this.optimizedScheduler = new OptimizedJobScheduler({
66
- minCheckInterval: DEFAULTS.SCHEDULER_MIN_CHECK_INTERVAL_MS,
67
- maxCheckInterval: DEFAULTS.SCHEDULER_MAX_CHECK_INTERVAL_MS,
68
- dueBuffer: DEFAULTS.SCHEDULER_DUE_BUFFER_MS,
69
- debug: process.env[ENV_VARS.LSH_LOG_LEVEL] === 'debug',
70
- });
71
- // Handle jobs that are due
72
- this.optimizedScheduler.on('jobDue', async (job) => {
73
- try {
74
- await this.executeScheduledJob(job);
75
- }
76
- catch (error) {
77
- this.log('ERROR', `Failed to execute scheduled job ${job.id}: ${error.message}`);
78
- }
79
- });
80
- this.log('INFO', 'Optimized job scheduler initialized (Issue #108)');
81
- }
82
- /**
83
- * Execute a scheduled job (used by optimized scheduler)
84
- */
85
- async executeScheduledJob(job) {
86
- // For completed cron jobs, reset to created status before starting
87
- if (job.schedule?.cron && job.status === 'completed') {
88
- job.status = 'created';
89
- job.completedAt = undefined;
90
- job.stdout = '';
91
- job.stderr = '';
92
- await this.jobManager.persistJobs();
93
- this.log('INFO', `Reset completed job for recurring execution: ${job.id} (${job.name})`);
94
- }
95
- this.log('INFO', `Started scheduled job: ${job.id} (${job.name})`);
96
- await this.jobManager.startJob(job.id);
97
- // Schedule next run for interval jobs
98
- if (job.schedule?.interval) {
99
- job.schedule.nextRun = new Date(Date.now() + job.schedule.interval);
100
- }
101
- }
102
- /**
103
- * Start the daemon
104
- */
105
- async start() {
106
- if (this.isRunning) {
107
- throw new Error('Daemon is already running');
108
- }
109
- // Initialize startup profiler for performance monitoring
110
- const enableProfiling = process.env[ENV_VARS.LSH_LOG_LEVEL] === 'debug';
111
- if (enableProfiling) {
112
- this.startupProfiler = new PerformanceProfiler({
113
- profilingEnabled: true,
114
- profilingSampleRate: 1.0,
115
- maxMetricsInMemory: 100,
116
- });
117
- this.startupProfiler.startProfile('daemon-startup', { pid: process.pid });
118
- }
119
- // Validate environment variables
120
- this.log('INFO', 'Validating environment configuration');
121
- const envValidation = validateEnvironment();
122
- this.startupProfiler?.checkpoint('daemon-startup', 'env-validation');
123
- // Print validation results
124
- if (envValidation.errors.length > 0 || envValidation.warnings.length > 0) {
125
- printValidationResults(envValidation, false);
126
- }
127
- // Fail fast in production if validation fails
128
- if (!envValidation.isValid && process.env[ENV_VARS.NODE_ENV] === 'production') {
129
- this.log('ERROR', 'Environment validation failed in production');
130
- throw new Error(ERRORS.INVALID_ENV_CONFIG);
131
- }
132
- // Log warnings even in development
133
- if (envValidation.warnings.length > 0) {
134
- envValidation.warnings.forEach(warn => this.log('WARN', warn));
135
- }
136
- // Check if daemon is already running
137
- if (await this.isDaemonRunning()) {
138
- throw new Error('Another daemon instance is already running');
139
- }
140
- this.startupProfiler?.checkpoint('daemon-startup', 'pid-check');
141
- this.log('INFO', 'Starting LSH Job Daemon');
142
- // Write PID file with secure permissions (mode 0o600 = rw-------)
143
- await fs.promises.writeFile(this.config.pidFile, process.pid.toString(), { mode: 0o600 });
144
- this.startupProfiler?.checkpoint('daemon-startup', 'pid-file-written');
145
- this.isRunning = true;
146
- this.startJobScheduler();
147
- this.startupProfiler?.checkpoint('daemon-startup', 'scheduler-started');
148
- this.startIPCServer();
149
- this.startupProfiler?.checkpoint('daemon-startup', 'ipc-server-started');
150
- // Setup cleanup handlers
151
- this.setupSignalHandlers();
152
- this.startupProfiler?.checkpoint('daemon-startup', 'signal-handlers');
153
- // End startup profiling and log results
154
- if (this.startupProfiler) {
155
- const profile = this.startupProfiler.endProfile('daemon-startup');
156
- if (profile) {
157
- this.log('INFO', `Daemon startup completed in ${profile.duration.toFixed(2)}ms`);
158
- this.log('DEBUG', `Startup profile checkpoints: ${JSON.stringify(profile.checkpoints.map(cp => ({ label: cp.label, time: cp.relativeTime.toFixed(2) + 'ms' })))}`);
159
- this.log('DEBUG', `Memory delta: heap=${(profile.memoryDelta.heapUsed / 1024 / 1024).toFixed(2)}MB, rss=${(profile.memoryDelta.rss / 1024 / 1024).toFixed(2)}MB`);
160
- }
161
- }
162
- this.log('INFO', `Daemon started with PID ${process.pid}`);
163
- this.emit('started');
164
- }
165
- /**
166
- * Stop the daemon gracefully
167
- */
168
- async stop() {
169
- if (!this.isRunning) {
170
- return;
171
- }
172
- this.log('INFO', 'Stopping LSH Job Daemon');
173
- this.isRunning = false;
174
- if (this.checkTimer) {
175
- clearInterval(this.checkTimer);
176
- }
177
- // Stop optimized scheduler if enabled (Issue #108)
178
- if (this.optimizedScheduler) {
179
- this.optimizedScheduler.stop();
180
- }
181
- // Stop all running jobs gracefully
182
- await this.stopAllJobs();
183
- // Cleanup IPC
184
- if (this.ipcServer) {
185
- this.ipcServer.close();
186
- }
187
- // Remove PID file
188
- try {
189
- await fs.promises.unlink(this.config.pidFile);
190
- }
191
- catch (_error) {
192
- // Ignore if file doesn't exist
193
- }
194
- // Log before closing stream
195
- this.log('INFO', 'Daemon stopped');
196
- // Close log stream AFTER logging
197
- if (this.logStream) {
198
- this.logStream.end();
199
- this.logStream = undefined;
200
- }
201
- this.emit('stopped');
202
- }
203
- /**
204
- * Restart the daemon
205
- */
206
- async restart() {
207
- await this.stop();
208
- await new Promise(resolve => {
209
- setTimeout(resolve, DEFAULTS.DAEMON_RESTART_DELAY_MS);
210
- });
211
- await this.start();
212
- }
213
- /**
214
- * Get daemon status
215
- */
216
- async getStatus() {
217
- const stats = this.jobManager.getJobStats();
218
- const uptime = process.uptime();
219
- const memUsage = process.memoryUsage();
220
- const status = {
221
- running: this.isRunning,
222
- pid: process.pid,
223
- uptime,
224
- jobCount: stats.total || 0,
225
- memoryUsage: {
226
- heapUsed: memUsage.heapUsed,
227
- heapTotal: memUsage.heapTotal,
228
- external: memUsage.external
229
- },
230
- jobs: {
231
- total: stats.total || 0,
232
- running: stats.running || 0,
233
- completed: stats.completed,
234
- failed: stats.failed
235
- }
236
- };
237
- // Include scheduler metrics if optimized scheduler is enabled (Issue #108)
238
- if (this.optimizedScheduler) {
239
- status.scheduler = this.optimizedScheduler.getMetrics();
240
- }
241
- return status;
242
- }
243
- /**
244
- * Add a job to the daemon
245
- */
246
- async addJob(jobSpec) {
247
- this.log('INFO', `Adding job: ${jobSpec.name || 'unnamed'}`);
248
- const job = await this.jobManager.createJob(jobSpec);
249
- // Add to optimized scheduler if enabled (Issue #108)
250
- if (this.optimizedScheduler && job.schedule) {
251
- this.optimizedScheduler.addJob(job);
252
- }
253
- return job;
254
- }
255
- /**
256
- * Start a job
257
- */
258
- async startJob(jobId) {
259
- this.log('INFO', `Starting job: ${jobId}`);
260
- const job = await this.jobManager.startJob(jobId);
261
- return job;
262
- }
263
- /**
264
- * Trigger a job to run immediately (returns sanitized result with output)
265
- */
266
- async triggerJob(jobId) {
267
- this.log('INFO', `Triggering job: ${jobId}`);
268
- try {
269
- // Get the job details
270
- const job = await this.jobManager.getJob(jobId);
271
- if (!job) {
272
- throw new Error(`Job ${jobId} not found`);
273
- }
274
- // Validate command for security issues
275
- const validation = validateCommand(job.command, {
276
- allowDangerousCommands: process.env[ENV_VARS.LSH_ALLOW_DANGEROUS_COMMANDS] === 'true',
277
- maxLength: DEFAULTS.MAX_COMMAND_LENGTH
278
- });
279
- if (!validation.isValid) {
280
- const errorMsg = `Command validation failed: ${validation.errors.join(', ')}`;
281
- this.log('ERROR', `${errorMsg} - Risk level: ${validation.riskLevel}`);
282
- throw new Error(errorMsg);
283
- }
284
- // Log warnings if any
285
- if (validation.warnings.length > 0) {
286
- this.log('WARN', `Command warnings for job ${jobId}: ${validation.warnings.join(', ')}`);
287
- }
288
- // Execute the job command directly and capture output
289
- const { stdout, stderr } = await execAsync(job.command, {
290
- cwd: job.cwd || process.cwd(),
291
- env: { ...process.env, ...job.env },
292
- timeout: job.timeout || 30000 // 30 second timeout
293
- });
294
- this.log('INFO', `Job ${jobId} triggered successfully`);
295
- return {
296
- success: true,
297
- output: stdout || stderr || 'Job completed with no output',
298
- warnings: validation.warnings.length > 0 ? validation.warnings : undefined
299
- };
300
- }
301
- catch (error) {
302
- this.log('ERROR', `Failed to trigger job ${jobId}: ${error.message}`);
303
- return {
304
- success: false,
305
- error: error.message,
306
- output: error.stdout || error.stderr
307
- };
308
- }
309
- }
310
- /**
311
- * Stop a job
312
- */
313
- async stopJob(jobId, signal = 'SIGTERM') {
314
- this.log('INFO', `Stopping job: ${jobId} with signal ${signal}`);
315
- const job = await this.jobManager.killJob(jobId, signal);
316
- return job;
317
- }
318
- /**
319
- * Get job information
320
- */
321
- async getJob(jobId) {
322
- const job = await this.jobManager.getJob(jobId);
323
- return job ? this.sanitizeJobForSerialization(job) : undefined;
324
- }
325
- /**
326
- * Sanitize job objects for safe JSON serialization
327
- */
328
- sanitizeJobForSerialization(job) {
329
- // Use a whitelist approach - only include safe properties
330
- const sanitized = {
331
- id: job.id,
332
- name: job.name,
333
- command: job.command,
334
- args: job.args,
335
- type: job.type,
336
- status: job.status,
337
- priority: job.priority,
338
- pid: job.pid,
339
- ppid: job.ppid,
340
- createdAt: job.createdAt,
341
- startedAt: job.startedAt,
342
- completedAt: job.completedAt,
343
- cpuUsage: job.cpuUsage,
344
- memoryUsage: job.memoryUsage,
345
- env: job.env,
346
- cwd: job.cwd,
347
- user: job.user,
348
- maxMemory: job.maxMemory,
349
- maxCpu: job.maxCpu,
350
- timeout: typeof job.timeout === 'number' ? job.timeout : undefined,
351
- stdout: job.stdout,
352
- stderr: job.stderr,
353
- exitCode: job.exitCode,
354
- error: job.error,
355
- tags: job.tags,
356
- maxRetries: job.maxRetries,
357
- retryCount: job.retryCount,
358
- killSignal: job.killSignal,
359
- killed: job.killed,
360
- description: job.description,
361
- workingDirectory: job.workingDirectory,
362
- databaseSync: job.databaseSync
363
- };
364
- // Handle schedule object safely
365
- if (job.schedule) {
366
- sanitized.schedule = {
367
- cron: job.schedule.cron,
368
- interval: job.schedule.interval,
369
- nextRun: job.schedule.nextRun
370
- };
371
- }
372
- // Remove any undefined properties to keep the object clean
373
- Object.keys(sanitized).forEach(key => {
374
- if (sanitized[key] === undefined) {
375
- delete sanitized[key];
376
- }
377
- });
378
- return sanitized;
379
- }
380
- /**
381
- * List all jobs
382
- */
383
- async listJobs(filter, limit) {
384
- try {
385
- const jobs = await this.jobManager.listJobs(filter);
386
- // Sanitize jobs to remove circular references before serialization
387
- const sanitizedJobs = jobs.map(job => this.sanitizeJobForSerialization(job));
388
- // Apply limit if specified
389
- if (limit && limit > 0) {
390
- return sanitizedJobs.slice(0, limit);
391
- }
392
- // Default limit to prevent oversized responses
393
- return sanitizedJobs.slice(0, DEFAULTS.MAX_EVENTS_LIMIT);
394
- }
395
- catch (error) {
396
- this.log('ERROR', `Failed to list jobs: ${error.message}`);
397
- return [];
398
- }
399
- }
400
- /**
401
- * Remove a job
402
- */
403
- async removeJob(jobId, force = false) {
404
- this.log('INFO', `Removing job: ${jobId}, force: ${force}`);
405
- // Remove from optimized scheduler if enabled (Issue #108)
406
- if (this.optimizedScheduler) {
407
- this.optimizedScheduler.removeJob(jobId);
408
- }
409
- return await this.jobManager.removeJob(jobId, force);
410
- }
411
- async isDaemonRunning() {
412
- try {
413
- // First, kill any existing daemon processes for this socket path
414
- await this.killExistingDaemons();
415
- const pidData = await fs.promises.readFile(this.config.pidFile, 'utf8');
416
- const pid = parseInt(pidData.trim());
417
- // Check if process is running
418
- try {
419
- process.kill(pid, 0); // Signal 0 just checks if process exists
420
- return true;
421
- }
422
- catch (_error) {
423
- // Process doesn't exist, remove stale PID file
424
- await fs.promises.unlink(this.config.pidFile);
425
- return false;
426
- }
427
- }
428
- catch (_error) {
429
- return false; // PID file doesn't exist
430
- }
431
- }
432
- async killExistingDaemons() {
433
- try {
434
- // Find all lshd processes with the same socket path
435
- const { stdout } = await execAsync(`ps aux | grep "lshd.js" | grep "${this.config.socketPath}" | grep -v grep || true`);
436
- if (stdout.trim()) {
437
- const lines = stdout.trim().split('\n');
438
- for (const line of lines) {
439
- const parts = line.trim().split(/\s+/);
440
- const pid = parseInt(parts[1]);
441
- if (pid && pid !== process.pid) {
442
- try {
443
- this.log('INFO', `Killing existing daemon process ${pid}`);
444
- process.kill(pid, 9); // Force kill
445
- }
446
- catch (_error) {
447
- // Process might already be dead
448
- }
449
- }
450
- }
451
- }
452
- }
453
- catch (_error) {
454
- // ps command failed, ignore
455
- }
456
- }
457
- startJobScheduler() {
458
- try {
459
- // Use optimized scheduler if enabled (Issue #108)
460
- if (this.config.useOptimizedScheduler && this.optimizedScheduler) {
461
- this.startOptimizedScheduler();
462
- }
463
- else {
464
- this.startLegacyScheduler();
465
- }
466
- }
467
- catch (error) {
468
- this.log('ERROR', `❌ Failed to start job scheduler: ${error.message}`);
469
- throw error;
470
- }
471
- }
472
- /**
473
- * Start the optimized priority queue-based scheduler (Issue #108)
474
- * Uses O(log n) operations instead of O(n) linear scans
475
- */
476
- async startOptimizedScheduler() {
477
- if (!this.optimizedScheduler) {
478
- return;
479
- }
480
- this.log('INFO', '📅 Starting OPTIMIZED job scheduler (Issue #108)');
481
- // Load existing jobs into the scheduler
482
- const jobs = await this.jobManager.listJobs({ status: ['created', 'completed'] });
483
- for (const job of jobs) {
484
- if (job.schedule) {
485
- this.optimizedScheduler.addJob(job);
486
- }
487
- }
488
- // Start the scheduler
489
- this.optimizedScheduler.start();
490
- // Still run cleanup and log rotation on the legacy interval
491
- this.checkTimer = setInterval(() => {
492
- try {
493
- this.cleanupCompletedJobs();
494
- this.rotateLogs();
495
- }
496
- catch (error) {
497
- this.log('ERROR', `❌ Maintenance error: ${error.message}`);
498
- }
499
- }, this.config.checkInterval);
500
- const metrics = this.optimizedScheduler.getMetrics();
501
- this.log('INFO', `✅ Optimized scheduler started with ${metrics.totalJobs} scheduled jobs`);
502
- }
503
- /**
504
- * Start the legacy interval-based scheduler
505
- * Uses O(n) linear scan every checkInterval milliseconds
506
- */
507
- startLegacyScheduler() {
508
- this.log('INFO', `📅 Starting LEGACY job scheduler with ${this.config.checkInterval}ms interval`);
509
- this.checkTimer = setInterval(() => {
510
- try {
511
- this.checkScheduledJobs();
512
- this.cleanupCompletedJobs();
513
- this.rotateLogs();
514
- }
515
- catch (error) {
516
- this.log('ERROR', `❌ Scheduler error: ${error.message}`);
517
- }
518
- }, this.config.checkInterval);
519
- this.log('INFO', `✅ Legacy scheduler started successfully`);
520
- }
521
- async checkScheduledJobs() {
522
- // Debug: Log scheduler activity periodically
523
- if (Date.now() % 60000 < 5000) { // Log once per minute approximately
524
- this.log('DEBUG', `🔄 Scheduler check: Looking for jobs to run...`);
525
- }
526
- // Check both created and completed jobs (for recurring schedules)
527
- const jobs = await this.jobManager.listJobs({ status: ['created', 'completed'] });
528
- const now = new Date();
529
- for (const job of jobs) {
530
- if (job.schedule) {
531
- let shouldRun = false;
532
- // Check cron schedule
533
- if (job.schedule.cron) {
534
- // For completed jobs, check if cron schedule matches (allow re-run)
535
- // For created jobs, check if we haven't run this job in the current minute
536
- const currentMinute = Math.floor(now.getTime() / 60000);
537
- const lastRun = this.lastRunTimes.get(job.id);
538
- if (job.status === 'completed') {
539
- // Always check cron for completed jobs to allow recurring execution
540
- shouldRun = this.shouldRunByCron(job.schedule.cron, now);
541
- }
542
- else if (!lastRun || lastRun < currentMinute) {
543
- shouldRun = this.shouldRunByCron(job.schedule.cron, now);
544
- }
545
- }
546
- // Check interval schedule
547
- if (job.schedule.interval && job.schedule.nextRun) {
548
- shouldRun = now >= job.schedule.nextRun;
549
- }
550
- if (shouldRun) {
551
- try {
552
- // For completed cron jobs, reset to created status before starting
553
- if (job.schedule.cron && job.status === 'completed') {
554
- job.status = 'created';
555
- job.completedAt = undefined;
556
- job.stdout = '';
557
- job.stderr = '';
558
- await this.jobManager.persistJobs();
559
- this.log('INFO', `🔄 Reset completed job for recurring execution: ${job.id} (${job.name})`);
560
- }
561
- // Track that we're running this job now
562
- if (job.schedule.cron) {
563
- const currentMinute = Math.floor(now.getTime() / 60000);
564
- this.lastRunTimes.set(job.id, currentMinute);
565
- }
566
- this.log('INFO', `Started scheduled job: ${job.id} (${job.name})`);
567
- await this.jobManager.startJob(job.id);
568
- // Schedule next run for interval jobs
569
- if (job.schedule.interval) {
570
- job.schedule.nextRun = new Date(now.getTime() + job.schedule.interval);
571
- }
572
- }
573
- catch (error) {
574
- this.log('ERROR', `❌ Failed to start scheduled job ${job.id}: ${error.message}`);
575
- }
576
- }
577
- }
578
- }
579
- }
580
- shouldRunByCron(cronExpr, now) {
581
- try {
582
- const [minute, hour, day, month, weekday] = cronExpr.split(' ');
583
- // We check if we're at the exact minute/second to avoid duplicate runs
584
- // Only run in the first 30 seconds of the target minute
585
- // This gives us a wider window with 2-second check intervals
586
- if (now.getSeconds() > 30) {
587
- return false;
588
- }
589
- // Check minute field
590
- if (!this.matchesCronField(minute, now.getMinutes(), 0, 59)) {
591
- return false;
592
- }
593
- // Check hour field
594
- if (!this.matchesCronField(hour, now.getHours(), 0, 23)) {
595
- return false;
596
- }
597
- // Check day field
598
- if (!this.matchesCronField(day, now.getDate(), 1, 31)) {
599
- return false;
600
- }
601
- // Check month field
602
- if (!this.matchesCronField(month, now.getMonth() + 1, 1, 12)) {
603
- return false;
604
- }
605
- // Check weekday field (0 = Sunday, 6 = Saturday)
606
- if (!this.matchesCronField(weekday, now.getDay(), 0, 6)) {
607
- return false;
608
- }
609
- return true;
610
- }
611
- catch (error) {
612
- this.log('ERROR', `Invalid cron expression: ${cronExpr} - ${error.message}`);
613
- return false;
614
- }
615
- }
616
- matchesCronField(field, currentValue, _min, _max) {
617
- // Handle wildcard
618
- if (field === '*') {
619
- return true;
620
- }
621
- // Handle specific number (e.g., "0", "2", "15")
622
- if (/^\d+$/.test(field)) {
623
- return parseInt(field) === currentValue;
624
- }
625
- // Handle intervals (e.g., "*/5", "*/2", "*/30")
626
- if (field.startsWith('*/')) {
627
- const interval = parseInt(field.substring(2));
628
- return currentValue % interval === 0;
629
- }
630
- // Handle ranges (e.g., "1-5", "10-15")
631
- if (field.includes('-')) {
632
- const [start, end] = field.split('-').map(x => parseInt(x));
633
- return currentValue >= start && currentValue <= end;
634
- }
635
- // Handle lists (e.g., "1,3,5", "10,20,30")
636
- if (field.includes(',')) {
637
- const values = field.split(',').map(x => parseInt(x));
638
- return values.includes(currentValue);
639
- }
640
- // Handle step values (e.g., "1-10/2" = every 2 from 1 to 10)
641
- if (field.includes('/')) {
642
- const [range, step] = field.split('/');
643
- const stepNum = parseInt(step);
644
- if (range.includes('-')) {
645
- const [start, end] = range.split('-').map(x => parseInt(x));
646
- if (currentValue < start || currentValue > end)
647
- return false;
648
- return (currentValue - start) % stepNum === 0;
649
- }
650
- }
651
- return false;
652
- }
653
- /**
654
- * Reset job status for recurring cron jobs after completion
655
- */
656
- async resetRecurringJobStatus(jobId) {
657
- try {
658
- const job = await this.jobManager.getJob(jobId);
659
- if (job && job.schedule?.cron && job.status === 'completed') {
660
- // Reset status for next scheduled run
661
- job.status = 'created';
662
- job.completedAt = undefined;
663
- job.stdout = '';
664
- job.stderr = '';
665
- // Force persistence by calling internal method via reflection
666
- // Note: This is a temporary workaround for private method access
667
- await this.jobManager.persistJobs();
668
- this.log('INFO', `🔄 Reset recurring job status: ${jobId} (${job.name}) for next scheduled run`);
669
- }
670
- }
671
- catch (error) {
672
- this.log('ERROR', `Failed to reset recurring job status ${jobId}: ${error.message}`);
673
- }
674
- }
675
- async cleanupCompletedJobs() {
676
- const cleaned = await this.jobManager.cleanupJobs(24); // Clean jobs older than 24 hours
677
- if (cleaned > 0) {
678
- this.log('INFO', `Cleaned up ${cleaned} old jobs`);
679
- }
680
- }
681
- async stopAllJobs() {
682
- const runningJobs = await this.jobManager.listJobs({ status: ['running', 'stopped'] });
683
- for (const job of runningJobs) {
684
- try {
685
- await this.jobManager.killJob(job.id, 'SIGTERM');
686
- this.log('INFO', `Stopped job: ${job.id}`);
687
- }
688
- catch (error) {
689
- this.log('ERROR', `Failed to stop job ${job.id}: ${error.message}`);
690
- }
691
- }
692
- }
693
- setupLogging() {
694
- // Create log directory if it doesn't exist
695
- const logDir = path.dirname(this.config.logFile);
696
- if (!fs.existsSync(logDir)) {
697
- fs.mkdirSync(logDir, { recursive: true });
698
- }
699
- this.logStream = fs.createWriteStream(this.config.logFile, { flags: 'a' });
700
- // Log uncaught exceptions
701
- process.on('uncaughtException', (error) => {
702
- this.log('FATAL', `Uncaught exception: ${error.message}`);
703
- this.log('FATAL', error.stack || '');
704
- if (this.config.autoRestart) {
705
- this.restart();
706
- }
707
- else {
708
- process.exit(1);
709
- }
710
- });
711
- process.on('unhandledRejection', (reason) => {
712
- this.log('ERROR', `Unhandled promise rejection: ${reason}`);
713
- });
714
- }
715
- setupIPC() {
716
- // Setup Unix domain socket for communication with LSH clients
717
- // Remove existing socket file
718
- try {
719
- fs.unlinkSync(this.config.socketPath);
720
- }
721
- catch (_error) {
722
- // Ignore if doesn't exist
723
- }
724
- this.ipcServer = net.createServer((socket) => {
725
- socket.on('data', async (data) => {
726
- let messageId;
727
- try {
728
- const message = JSON.parse(data.toString());
729
- messageId = message.id;
730
- const response = await this.handleIPCMessage(message);
731
- socket.write(JSON.stringify({
732
- success: true,
733
- data: response,
734
- id: messageId
735
- }));
736
- }
737
- catch (error) {
738
- socket.write(JSON.stringify({
739
- success: false,
740
- error: error.message,
741
- id: messageId
742
- }));
743
- }
744
- });
745
- });
746
- }
747
- startIPCServer() {
748
- if (this.ipcServer) {
749
- // Clean up any existing socket file
750
- try {
751
- if (fs.existsSync(this.config.socketPath)) {
752
- fs.unlinkSync(this.config.socketPath);
753
- }
754
- }
755
- catch (_error) {
756
- // Ignore cleanup errors
757
- }
758
- this.ipcServer.listen(this.config.socketPath, () => {
759
- this.log('INFO', `IPC server listening on ${this.config.socketPath}`);
760
- });
761
- this.ipcServer.on('error', (error) => {
762
- this.log('ERROR', `IPC server error: ${error.message}`);
763
- if (error.message.includes('EADDRINUSE')) {
764
- this.log('INFO', 'Socket already in use, attempting cleanup...');
765
- try {
766
- fs.unlinkSync(this.config.socketPath);
767
- // Retry after cleanup
768
- setTimeout(() => {
769
- this.ipcServer?.listen(this.config.socketPath);
770
- }, 1000);
771
- }
772
- catch (cleanupError) {
773
- this.log('ERROR', `Failed to cleanup socket: ${cleanupError.message}`);
774
- }
775
- }
776
- });
777
- }
778
- }
779
- async handleIPCMessage(message) {
780
- const { command, args = {} } = message;
781
- switch (command) {
782
- case 'status':
783
- return await this.getStatus();
784
- case 'addJob':
785
- return await this.addJob(args.jobSpec);
786
- case 'startJob':
787
- return await this.startJob(args.jobId);
788
- case 'triggerJob':
789
- return await this.triggerJob(args.jobId);
790
- case 'stopJob':
791
- return await this.stopJob(args.jobId, args.signal);
792
- case 'listJobs':
793
- return this.listJobs(args.filter, args.limit);
794
- case 'getJob':
795
- return this.getJob(args.jobId);
796
- case 'removeJob':
797
- return await this.removeJob(args.jobId, args.force);
798
- case 'restart':
799
- await this.restart();
800
- return { message: 'Daemon restarted' };
801
- case 'stop':
802
- await this.stop();
803
- return { message: 'Daemon stopped' };
804
- default:
805
- throw new Error(`Unknown command: ${command}`);
806
- }
807
- }
808
- log(level, message) {
809
- const timestamp = new Date().toISOString();
810
- const logEntry = `[${timestamp}] ${level}: ${message}\n`;
811
- // Write to log file
812
- if (this.logStream) {
813
- this.logStream.write(logEntry);
814
- }
815
- // Also output using logger
816
- switch (level.toUpperCase()) {
817
- case 'DEBUG':
818
- this.logger.debug(message);
819
- break;
820
- case 'INFO':
821
- this.logger.info(message);
822
- break;
823
- case 'WARN':
824
- case 'WARNING':
825
- this.logger.warn(message);
826
- break;
827
- case 'ERROR':
828
- case 'FATAL':
829
- this.logger.error(message);
830
- break;
831
- default:
832
- this.logger.info(message);
833
- }
834
- this.emit('log', level, message);
835
- }
836
- rotateLogs() {
837
- try {
838
- const stats = fs.statSync(this.config.logFile);
839
- if (stats.size > this.config.maxLogSize) {
840
- const backupFile = `${this.config.logFile}.${Date.now()}`;
841
- fs.renameSync(this.config.logFile, backupFile);
842
- // Close current stream and create new one
843
- if (this.logStream) {
844
- this.logStream.end();
845
- this.logStream = fs.createWriteStream(this.config.logFile, { flags: 'a' });
846
- }
847
- this.log('INFO', `Rotated log file to ${backupFile}`);
848
- }
849
- }
850
- catch (_error) {
851
- // Ignore rotation errors
852
- }
853
- }
854
- setupSignalHandlers() {
855
- process.on('SIGTERM', async () => {
856
- this.log('INFO', 'Received SIGTERM, shutting down gracefully');
857
- await this.stop();
858
- process.exit(0);
859
- });
860
- process.on('SIGINT', async () => {
861
- this.log('INFO', 'Received SIGINT, shutting down gracefully');
862
- await this.stop();
863
- process.exit(0);
864
- });
865
- process.on('SIGHUP', async () => {
866
- this.log('INFO', 'Received SIGHUP, restarting');
867
- await this.restart();
868
- });
869
- }
870
- }
871
- // Module-level logger for CLI operations
872
- const cliLogger = createLogger('LSHDaemonCLI');
873
- // Helper to check if this module is run directly (ESM-compatible)
874
- // Uses indirect eval to avoid parse-time errors in CommonJS/Jest environments
875
- const isMainModule = () => {
876
- try {
877
- // Use Function constructor to avoid parse-time errors with import.meta in CommonJS/Jest environments.
878
- // This is intentional - import.meta cannot be accessed directly in all environments.
879
- // eslint-disable-next-line no-new-func
880
- const getImportMetaUrl = new Function('return import.meta.url');
881
- const metaUrl = getImportMetaUrl();
882
- return metaUrl === `file://${process.argv[1]}`;
883
- }
884
- catch {
885
- // Fallback for CommonJS or environments that don't support import.meta
886
- return false;
887
- }
888
- };
889
- // CLI interface for the daemon
890
- if (isMainModule()) {
891
- const command = process.argv[2];
892
- const subCommand = process.argv[3];
893
- const _args = process.argv.slice(4);
894
- // Handle job commands
895
- if (command === 'job-add') {
896
- (async () => {
897
- try {
898
- const jobCommand = subCommand;
899
- if (!jobCommand) {
900
- cliLogger.error('Usage: lshd job-add "command-to-run"');
901
- process.exit(1);
902
- }
903
- const client = new (await import('../lib/daemon-client.js')).default();
904
- if (!client.isDaemonRunning()) {
905
- cliLogger.error('Daemon is not running. Start it with: lsh daemon start');
906
- process.exit(1);
907
- }
908
- await client.connect();
909
- const jobSpec = {
910
- id: `job_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
911
- name: `Manual Job - ${jobCommand}`,
912
- command: jobCommand,
913
- type: 'shell',
914
- schedule: { interval: 0 }, // Run once
915
- env: process.env,
916
- cwd: process.cwd(),
917
- user: process.env[ENV_VARS.USER],
918
- priority: 5,
919
- tags: ['manual'],
920
- maxRetries: 0,
921
- timeout: 0,
922
- };
923
- const result = await client.addJob(jobSpec);
924
- cliLogger.info('Job added successfully', { id: result.id, command: result.command, status: result.status });
925
- // Start the job immediately
926
- await client.startJob(result.id);
927
- cliLogger.info(`Job ${result.id} started`);
928
- client.disconnect();
929
- process.exit(0);
930
- }
931
- catch (error) {
932
- const err = error;
933
- cliLogger.error('Failed to add job', err);
934
- process.exit(1);
935
- }
936
- })();
937
- }
938
- else {
939
- const socketPath = subCommand;
940
- const daemon = new LSHJobDaemon(socketPath ? { socketPath } : undefined);
941
- switch (command) {
942
- case 'start':
943
- daemon.start().catch((error) => cliLogger.error('Failed to start daemon', error));
944
- // Keep the process alive
945
- process.stdin.resume();
946
- break;
947
- case 'stop':
948
- daemon.stop().catch((error) => cliLogger.error('Failed to stop daemon', error));
949
- break;
950
- case 'restart':
951
- daemon.restart().catch((error) => cliLogger.error('Failed to restart daemon', error));
952
- // Keep the process alive
953
- process.stdin.resume();
954
- break;
955
- case 'status':
956
- daemon.getStatus().then(status => {
957
- cliLogger.info(JSON.stringify(status, null, 2));
958
- process.exit(0);
959
- }).catch((error) => cliLogger.error('Failed to get daemon status', error));
960
- break;
961
- default:
962
- cliLogger.info('Usage: lshd {start|stop|restart|status|job-add}');
963
- cliLogger.info(' lshd job-add "command" - Add and start a job');
964
- process.exit(1);
965
- }
966
- }
967
- }
968
- export default LSHJobDaemon;