cli4ai 0.8.2 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +11 -10
- package/src/bin.ts +21 -21
- package/src/cli.ts +51 -1
- package/src/commands/add.ts +47 -6
- package/src/commands/browse.ts +1 -1
- package/src/commands/config.ts +31 -3
- package/src/commands/info.ts +1 -1
- package/src/commands/init.test.ts +61 -13
- package/src/commands/init.ts +177 -223
- package/src/commands/list.ts +11 -28
- package/src/commands/mcp-config.ts +14 -4
- package/src/commands/remove.ts +6 -0
- package/src/commands/routines.ts +19 -4
- package/src/commands/run.ts +4 -2
- package/src/commands/scheduler.ts +438 -0
- package/src/commands/secrets.ts +21 -2
- package/src/commands/update.ts +7 -8
- package/src/core/config.test.ts +3 -3
- package/src/core/config.ts +213 -39
- package/src/core/execute.ts +54 -30
- package/src/core/link.test.ts +5 -5
- package/src/core/link.ts +60 -20
- package/src/core/lockfile.test.ts +1 -1
- package/src/core/lockfile.ts +4 -2
- package/src/core/manifest.test.ts +2 -2
- package/src/core/manifest.ts +12 -4
- package/src/core/routine-engine.test.ts +1 -1
- package/src/core/routine-engine.ts +103 -1
- package/src/core/routines.ts +58 -1
- package/src/core/scheduler-daemon.ts +94 -0
- package/src/core/scheduler.test.ts +291 -0
- package/src/core/scheduler.ts +606 -0
- package/src/core/secrets.test.ts +1 -1
- package/src/lib/cli.ts +25 -5
- package/src/mcp/adapter.test.ts +1 -1
- package/src/mcp/adapter.ts +14 -6
- package/src/mcp/config-gen.test.ts +1 -1
- package/src/mcp/server.ts +11 -17
package/src/core/lockfile.ts
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* to detect tampered packages.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'fs';
|
|
11
|
+
import { readFileSync, writeFileSync, renameSync, existsSync, readdirSync, statSync } from 'fs';
|
|
12
12
|
import { resolve, join, relative } from 'path';
|
|
13
13
|
import { createHash } from 'crypto';
|
|
14
14
|
|
|
@@ -211,7 +211,9 @@ export function loadLockfile(projectDir: string): Lockfile {
|
|
|
211
211
|
export function saveLockfile(projectDir: string, lockfile: Lockfile): void {
|
|
212
212
|
const lockfilePath = getLockfilePath(projectDir);
|
|
213
213
|
const content = JSON.stringify(lockfile, null, 2) + '\n';
|
|
214
|
-
|
|
214
|
+
const tmpPath = lockfilePath + '.tmp';
|
|
215
|
+
writeFileSync(tmpPath, content);
|
|
216
|
+
renameSync(tmpPath, lockfilePath);
|
|
215
217
|
}
|
|
216
218
|
|
|
217
219
|
/**
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for manifest.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, test, expect, beforeEach, afterEach } from '
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
6
6
|
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { tmpdir } from 'os';
|
|
@@ -304,7 +304,7 @@ describe('manifest', () => {
|
|
|
304
304
|
expect(manifest.name).toBe('my-tool');
|
|
305
305
|
expect(manifest.version).toBe('1.0.0');
|
|
306
306
|
expect(manifest.entry).toBe('run.ts');
|
|
307
|
-
expect(manifest.runtime).toBe('
|
|
307
|
+
expect(manifest.runtime).toBe('node');
|
|
308
308
|
});
|
|
309
309
|
|
|
310
310
|
test('normalizes name', () => {
|
package/src/core/manifest.ts
CHANGED
|
@@ -73,8 +73,8 @@ export interface Manifest {
|
|
|
73
73
|
homepage?: string;
|
|
74
74
|
keywords?: string[];
|
|
75
75
|
|
|
76
|
-
// Runtime (
|
|
77
|
-
runtime?: '
|
|
76
|
+
// Runtime (node is default, bun kept for backwards compatibility)
|
|
77
|
+
runtime?: 'node' | 'bun';
|
|
78
78
|
|
|
79
79
|
// Commands (for MCP generation)
|
|
80
80
|
commands?: Record<string, CommandDef>;
|
|
@@ -134,6 +134,14 @@ export function validateManifest(manifest: unknown, source?: string): Manifest {
|
|
|
134
134
|
});
|
|
135
135
|
}
|
|
136
136
|
|
|
137
|
+
// SECURITY: Validate entry is a relative path that stays within package directory
|
|
138
|
+
if (m.entry.startsWith('/') || m.entry.startsWith('\\') || m.entry.includes('..')) {
|
|
139
|
+
throw new ManifestValidationError(
|
|
140
|
+
'Invalid "entry" - must be a relative path without ".." (security: path traversal)',
|
|
141
|
+
{ source, got: m.entry }
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
137
145
|
// Optional: runtime (deno not supported)
|
|
138
146
|
if (m.runtime !== undefined && !['bun', 'node'].includes(m.runtime as string)) {
|
|
139
147
|
throw new ManifestValidationError('Invalid "runtime" (must be bun or node)', {
|
|
@@ -230,7 +238,7 @@ export function loadFromPackageJson(dir: string): Manifest | null {
|
|
|
230
238
|
description: pkg.description,
|
|
231
239
|
author: pkg.author,
|
|
232
240
|
license: pkg.license,
|
|
233
|
-
runtime: '
|
|
241
|
+
runtime: 'node',
|
|
234
242
|
keywords: pkg.keywords
|
|
235
243
|
};
|
|
236
244
|
} catch {
|
|
@@ -312,7 +320,7 @@ export function createManifest(name: string, options: Partial<Manifest> = {}): M
|
|
|
312
320
|
name: name.toLowerCase().replace(/[^a-z0-9-]/g, '-'),
|
|
313
321
|
version: '1.0.0',
|
|
314
322
|
entry: 'run.ts',
|
|
315
|
-
runtime: '
|
|
323
|
+
runtime: 'node',
|
|
316
324
|
description: options.description || `${name} tool`,
|
|
317
325
|
...options
|
|
318
326
|
};
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* Tests for routine-engine.ts
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
-
import { describe, test, expect, beforeEach, afterEach } from '
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
6
6
|
import { mkdtempSync, rmSync, writeFileSync } from 'fs';
|
|
7
7
|
import { join } from 'path';
|
|
8
8
|
import { tmpdir } from 'os';
|
|
@@ -38,6 +38,27 @@ export class RoutineTemplateError extends Error {
|
|
|
38
38
|
|
|
39
39
|
export type StepCapture = 'inherit' | 'text' | 'json';
|
|
40
40
|
|
|
41
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
42
|
+
// SCHEDULE TYPES
|
|
43
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
|
+
|
|
45
|
+
export interface RoutineSchedule {
|
|
46
|
+
/** Cron expression (e.g., "0 9 * * *" for 9am daily) */
|
|
47
|
+
cron?: string;
|
|
48
|
+
/** Simple interval (e.g., "30s", "5m", "1h", "1d") */
|
|
49
|
+
interval?: string;
|
|
50
|
+
/** IANA timezone (e.g., "Pacific/Auckland"). Defaults to system timezone */
|
|
51
|
+
timezone?: string;
|
|
52
|
+
/** Whether this schedule is active. Defaults to true */
|
|
53
|
+
enabled?: boolean;
|
|
54
|
+
/** Number of retry attempts on failure. Defaults to 0 */
|
|
55
|
+
retries?: number;
|
|
56
|
+
/** Delay between retries in milliseconds. Defaults to 60000 */
|
|
57
|
+
retryDelayMs?: number;
|
|
58
|
+
/** What to do if previous run is still executing. Defaults to 'skip' */
|
|
59
|
+
concurrency?: 'skip' | 'queue';
|
|
60
|
+
}
|
|
61
|
+
|
|
41
62
|
export interface RoutineVarDef {
|
|
42
63
|
default?: string;
|
|
43
64
|
}
|
|
@@ -81,6 +102,8 @@ export interface RoutineDefinition {
|
|
|
81
102
|
description?: string;
|
|
82
103
|
mcp?: { expose?: boolean; description?: string };
|
|
83
104
|
vars?: Record<string, RoutineVarDef>;
|
|
105
|
+
/** Schedule configuration for automatic execution */
|
|
106
|
+
schedule?: RoutineSchedule;
|
|
84
107
|
steps: RoutineStep[];
|
|
85
108
|
result?: unknown;
|
|
86
109
|
}
|
|
@@ -140,6 +163,80 @@ export function loadRoutineDefinition(path: string): RoutineDefinition {
|
|
|
140
163
|
return validateRoutineDefinition(data, path);
|
|
141
164
|
}
|
|
142
165
|
|
|
166
|
+
const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Validate a schedule configuration.
|
|
170
|
+
* Exported for use by scheduler and tests.
|
|
171
|
+
*/
|
|
172
|
+
export function validateScheduleConfig(schedule: unknown, source?: string): RoutineSchedule {
|
|
173
|
+
if (!schedule || typeof schedule !== 'object') {
|
|
174
|
+
throw new RoutineValidationError('Schedule must be an object', { source });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const s = schedule as Record<string, unknown>;
|
|
178
|
+
|
|
179
|
+
// Must have either cron or interval (or both)
|
|
180
|
+
if (s.cron === undefined && s.interval === undefined) {
|
|
181
|
+
throw new RoutineValidationError('Schedule must have "cron" or "interval"', { source });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Validate cron (basic format check - full validation happens at runtime with cron-parser)
|
|
185
|
+
if (s.cron !== undefined) {
|
|
186
|
+
if (typeof s.cron !== 'string' || s.cron.trim().length === 0) {
|
|
187
|
+
throw new RoutineValidationError('Schedule "cron" must be a non-empty string', { source, got: s.cron });
|
|
188
|
+
}
|
|
189
|
+
// Basic cron format: should have 5 space-separated parts
|
|
190
|
+
const parts = s.cron.trim().split(/\s+/);
|
|
191
|
+
if (parts.length < 5 || parts.length > 6) {
|
|
192
|
+
throw new RoutineValidationError('Schedule "cron" must have 5 or 6 fields', { source, got: s.cron });
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Validate interval
|
|
197
|
+
if (s.interval !== undefined) {
|
|
198
|
+
if (typeof s.interval !== 'string') {
|
|
199
|
+
throw new RoutineValidationError('Schedule "interval" must be a string', { source, got: s.interval });
|
|
200
|
+
}
|
|
201
|
+
if (!INTERVAL_PATTERN.test(s.interval)) {
|
|
202
|
+
throw new RoutineValidationError('Schedule "interval" must be like "30s", "5m", "1h", or "1d"', { source, got: s.interval });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// Validate timezone (just check it's a string - actual validation happens at runtime)
|
|
207
|
+
if (s.timezone !== undefined && typeof s.timezone !== 'string') {
|
|
208
|
+
throw new RoutineValidationError('Schedule "timezone" must be a string', { source, got: s.timezone });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Validate enabled
|
|
212
|
+
if (s.enabled !== undefined && typeof s.enabled !== 'boolean') {
|
|
213
|
+
throw new RoutineValidationError('Schedule "enabled" must be a boolean', { source, got: s.enabled });
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Validate retries
|
|
217
|
+
if (s.retries !== undefined) {
|
|
218
|
+
if (typeof s.retries !== 'number' || !Number.isInteger(s.retries) || s.retries < 0) {
|
|
219
|
+
throw new RoutineValidationError('Schedule "retries" must be a non-negative integer', { source, got: s.retries });
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Validate retryDelayMs
|
|
224
|
+
if (s.retryDelayMs !== undefined) {
|
|
225
|
+
if (typeof s.retryDelayMs !== 'number' || !Number.isInteger(s.retryDelayMs) || s.retryDelayMs < 0) {
|
|
226
|
+
throw new RoutineValidationError('Schedule "retryDelayMs" must be a non-negative integer', { source, got: s.retryDelayMs });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// Validate concurrency
|
|
231
|
+
if (s.concurrency !== undefined) {
|
|
232
|
+
if (s.concurrency !== 'skip' && s.concurrency !== 'queue') {
|
|
233
|
+
throw new RoutineValidationError('Schedule "concurrency" must be "skip" or "queue"', { source, got: s.concurrency });
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return s as RoutineSchedule;
|
|
238
|
+
}
|
|
239
|
+
|
|
143
240
|
function validateRoutineDefinition(value: unknown, source?: string): RoutineDefinition {
|
|
144
241
|
if (!value || typeof value !== 'object') {
|
|
145
242
|
throw new RoutineValidationError('Routine must be an object', { source });
|
|
@@ -155,6 +252,11 @@ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefi
|
|
|
155
252
|
throw new RoutineValidationError('Invalid or missing "name"', { source, got: obj.name });
|
|
156
253
|
}
|
|
157
254
|
|
|
255
|
+
// Validate schedule if present
|
|
256
|
+
if (obj.schedule !== undefined) {
|
|
257
|
+
validateScheduleConfig(obj.schedule, source);
|
|
258
|
+
}
|
|
259
|
+
|
|
158
260
|
if (!Array.isArray(obj.steps)) {
|
|
159
261
|
throw new RoutineValidationError('Invalid or missing "steps" (must be an array)', { source });
|
|
160
262
|
}
|
|
@@ -255,7 +357,7 @@ function validateRoutineDefinition(value: unknown, source?: string): RoutineDefi
|
|
|
255
357
|
}
|
|
256
358
|
}
|
|
257
359
|
|
|
258
|
-
return obj as RoutineDefinition;
|
|
360
|
+
return obj as unknown as RoutineDefinition;
|
|
259
361
|
}
|
|
260
362
|
|
|
261
363
|
function getPath(root: unknown, segments: string[]): unknown {
|
package/src/core/routines.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
* - within a scope: .routine.json before .routine.sh
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { existsSync, readdirSync, statSync, readFileSync } from 'fs';
|
|
14
14
|
import { resolve } from 'path';
|
|
15
15
|
import { ensureCli4aiHome, ensureLocalDir, ROUTINES_DIR, LOCAL_ROUTINES_DIR } from './config.js';
|
|
16
|
+
import { validateScheduleConfig, type RoutineSchedule } from './routine-engine.js';
|
|
16
17
|
|
|
17
18
|
export type RoutineKind = 'json' | 'bash';
|
|
18
19
|
export type RoutineScope = 'local' | 'global';
|
|
@@ -109,3 +110,59 @@ export function resolveRoutine(name: string, projectDir: string, options: Resolv
|
|
|
109
110
|
return null;
|
|
110
111
|
}
|
|
111
112
|
|
|
113
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
114
|
+
// SCHEDULED ROUTINES
|
|
115
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
116
|
+
|
|
117
|
+
export interface ScheduledRoutineInfo extends RoutineInfo {
|
|
118
|
+
schedule: RoutineSchedule;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Get all routines that have a schedule defined.
|
|
123
|
+
* Only JSON routines can have schedules (bash scripts cannot).
|
|
124
|
+
* Searches both local (if projectDir provided) and global routines.
|
|
125
|
+
*/
|
|
126
|
+
export function getScheduledRoutines(projectDir?: string): ScheduledRoutineInfo[] {
|
|
127
|
+
const results: ScheduledRoutineInfo[] = [];
|
|
128
|
+
const seen = new Set<string>();
|
|
129
|
+
|
|
130
|
+
// Collect all JSON routines
|
|
131
|
+
const allRoutines: RoutineInfo[] = [];
|
|
132
|
+
|
|
133
|
+
if (projectDir) {
|
|
134
|
+
allRoutines.push(...getLocalRoutines(projectDir).filter(r => r.kind === 'json'));
|
|
135
|
+
}
|
|
136
|
+
allRoutines.push(...getGlobalRoutines().filter(r => r.kind === 'json'));
|
|
137
|
+
|
|
138
|
+
for (const routine of allRoutines) {
|
|
139
|
+
// Skip if we've already processed a routine with this name (local takes precedence)
|
|
140
|
+
if (seen.has(routine.name)) continue;
|
|
141
|
+
seen.add(routine.name);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const content = readFileSync(routine.path, 'utf-8');
|
|
145
|
+
const data = JSON.parse(content);
|
|
146
|
+
|
|
147
|
+
if (data.schedule && typeof data.schedule === 'object') {
|
|
148
|
+
// Check if schedule is enabled (defaults to true)
|
|
149
|
+
const enabled = (data.schedule as Record<string, unknown>).enabled !== false;
|
|
150
|
+
if (!enabled) continue;
|
|
151
|
+
|
|
152
|
+
try {
|
|
153
|
+
const schedule = validateScheduleConfig(data.schedule, routine.path);
|
|
154
|
+
results.push({
|
|
155
|
+
...routine,
|
|
156
|
+
schedule
|
|
157
|
+
});
|
|
158
|
+
} catch {
|
|
159
|
+
// Skip routines with invalid schedule configs (don't crash the scheduler)
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
} catch {
|
|
163
|
+
// Skip invalid JSON files
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return results;
|
|
168
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Scheduler daemon entry point.
|
|
4
|
+
*
|
|
5
|
+
* This script runs as a background process and manages scheduled routine execution.
|
|
6
|
+
* It's spawned by `cli4ai scheduler start` with detached mode.
|
|
7
|
+
*
|
|
8
|
+
* Usage: bun scheduler-daemon.ts [--project-dir <dir>]
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { Scheduler, writeDaemonPid, removeDaemonPid, appendSchedulerLog, SCHEDULER_LOG_FILE, ensureSchedulerDirs } from './scheduler.js';
|
|
12
|
+
import { createWriteStream } from 'fs';
|
|
13
|
+
import { ensureCli4aiHome } from './config.js';
|
|
14
|
+
|
|
15
|
+
// Parse arguments
|
|
16
|
+
const args = process.argv.slice(2);
|
|
17
|
+
let projectDir: string | undefined;
|
|
18
|
+
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
if (args[i] === '--project-dir' && args[i + 1]) {
|
|
21
|
+
projectDir = args[i + 1];
|
|
22
|
+
i++;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Ensure directories exist
|
|
27
|
+
ensureCli4aiHome();
|
|
28
|
+
ensureSchedulerDirs();
|
|
29
|
+
|
|
30
|
+
// Redirect stdout/stderr to log file (when running detached)
|
|
31
|
+
if (process.env.CLI4AI_DAEMON === 'true') {
|
|
32
|
+
const logStream = createWriteStream(SCHEDULER_LOG_FILE, { flags: 'a' });
|
|
33
|
+
|
|
34
|
+
// Redirect console output
|
|
35
|
+
const originalLog = console.log;
|
|
36
|
+
const originalError = console.error;
|
|
37
|
+
|
|
38
|
+
console.log = (...args) => {
|
|
39
|
+
const message = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
40
|
+
logStream.write(`[${new Date().toISOString()}] [STDOUT] ${message}\n`);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
console.error = (...args) => {
|
|
44
|
+
const message = args.map(a => typeof a === 'string' ? a : JSON.stringify(a)).join(' ');
|
|
45
|
+
logStream.write(`[${new Date().toISOString()}] [STDERR] ${message}\n`);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
// Handle uncaught errors
|
|
49
|
+
process.on('uncaughtException', (err) => {
|
|
50
|
+
appendSchedulerLog('error', `Uncaught exception: ${err.message}\n${err.stack}`);
|
|
51
|
+
process.exit(1);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
process.on('unhandledRejection', (reason) => {
|
|
55
|
+
appendSchedulerLog('error', `Unhandled rejection: ${reason}`);
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Write PID file
|
|
60
|
+
writeDaemonPid(process.pid);
|
|
61
|
+
appendSchedulerLog('info', `Daemon started with PID ${process.pid}`);
|
|
62
|
+
|
|
63
|
+
// Create and start scheduler
|
|
64
|
+
const scheduler = new Scheduler({ projectDir });
|
|
65
|
+
|
|
66
|
+
// Handle shutdown signals
|
|
67
|
+
let shuttingDown = false;
|
|
68
|
+
|
|
69
|
+
async function shutdown(signal: string): Promise<void> {
|
|
70
|
+
if (shuttingDown) return;
|
|
71
|
+
shuttingDown = true;
|
|
72
|
+
|
|
73
|
+
appendSchedulerLog('info', `Received ${signal}, shutting down...`);
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
await scheduler.stop();
|
|
77
|
+
removeDaemonPid();
|
|
78
|
+
appendSchedulerLog('info', 'Daemon stopped gracefully');
|
|
79
|
+
} catch (err) {
|
|
80
|
+
appendSchedulerLog('error', `Error during shutdown: ${err instanceof Error ? err.message : String(err)}`);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
process.exit(0);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
87
|
+
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
88
|
+
|
|
89
|
+
// Start the scheduler
|
|
90
|
+
scheduler.start().catch(err => {
|
|
91
|
+
appendSchedulerLog('error', `Failed to start scheduler: ${err instanceof Error ? err.message : String(err)}`);
|
|
92
|
+
removeDaemonPid();
|
|
93
|
+
process.exit(1);
|
|
94
|
+
});
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for scheduler core functionality.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, test, expect, beforeEach, afterEach } from 'vitest';
|
|
6
|
+
import { rmSync, existsSync, readdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
import {
|
|
9
|
+
parseInterval,
|
|
10
|
+
getNextRunTime,
|
|
11
|
+
isDaemonRunning,
|
|
12
|
+
getDaemonPid,
|
|
13
|
+
writeDaemonPid,
|
|
14
|
+
removeDaemonPid,
|
|
15
|
+
loadSchedulerState,
|
|
16
|
+
saveSchedulerState,
|
|
17
|
+
saveRunRecord,
|
|
18
|
+
getRunHistory,
|
|
19
|
+
Scheduler,
|
|
20
|
+
SCHEDULER_PID_FILE,
|
|
21
|
+
SCHEDULER_STATE_FILE,
|
|
22
|
+
SCHEDULER_RUNS_DIR,
|
|
23
|
+
type SchedulerState,
|
|
24
|
+
type SchedulerRunRecord
|
|
25
|
+
} from './scheduler.js';
|
|
26
|
+
import { validateScheduleConfig, type RoutineSchedule } from './routine-engine.js';
|
|
27
|
+
|
|
28
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
29
|
+
// INTERVAL PARSING TESTS
|
|
30
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
31
|
+
|
|
32
|
+
describe('parseInterval', () => {
|
|
33
|
+
test('parses seconds', () => {
|
|
34
|
+
expect(parseInterval('30s')).toBe(30 * 1000);
|
|
35
|
+
expect(parseInterval('1s')).toBe(1000);
|
|
36
|
+
expect(parseInterval('120s')).toBe(120 * 1000);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test('parses minutes', () => {
|
|
40
|
+
expect(parseInterval('5m')).toBe(5 * 60 * 1000);
|
|
41
|
+
expect(parseInterval('1m')).toBe(60 * 1000);
|
|
42
|
+
expect(parseInterval('60m')).toBe(60 * 60 * 1000);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('parses hours', () => {
|
|
46
|
+
expect(parseInterval('1h')).toBe(60 * 60 * 1000);
|
|
47
|
+
expect(parseInterval('24h')).toBe(24 * 60 * 60 * 1000);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test('parses days', () => {
|
|
51
|
+
expect(parseInterval('1d')).toBe(24 * 60 * 60 * 1000);
|
|
52
|
+
expect(parseInterval('7d')).toBe(7 * 24 * 60 * 60 * 1000);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('throws on invalid format', () => {
|
|
56
|
+
expect(() => parseInterval('10')).toThrow();
|
|
57
|
+
expect(() => parseInterval('10x')).toThrow();
|
|
58
|
+
expect(() => parseInterval('abc')).toThrow();
|
|
59
|
+
expect(() => parseInterval('')).toThrow();
|
|
60
|
+
});
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
// NEXT RUN TIME TESTS
|
|
65
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
describe('getNextRunTime', () => {
|
|
68
|
+
test('calculates next run from interval', () => {
|
|
69
|
+
const now = new Date('2024-01-01T10:00:00Z');
|
|
70
|
+
const schedule: RoutineSchedule = { interval: '1h' };
|
|
71
|
+
|
|
72
|
+
const nextRun = getNextRunTime(schedule, now);
|
|
73
|
+
|
|
74
|
+
expect(nextRun).not.toBeNull();
|
|
75
|
+
expect(nextRun!.getTime()).toBe(now.getTime() + (60 * 60 * 1000));
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test('calculates next run from cron', () => {
|
|
79
|
+
const now = new Date('2024-01-01T10:30:00Z');
|
|
80
|
+
const schedule: RoutineSchedule = { cron: '0 * * * *' }; // Every hour at :00
|
|
81
|
+
|
|
82
|
+
const nextRun = getNextRunTime(schedule, now);
|
|
83
|
+
|
|
84
|
+
expect(nextRun).not.toBeNull();
|
|
85
|
+
expect(nextRun!.getUTCMinutes()).toBe(0);
|
|
86
|
+
expect(nextRun!.getUTCHours()).toBe(11); // Next hour in UTC
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test('returns earliest when both cron and interval specified', () => {
|
|
90
|
+
const now = new Date('2024-01-01T10:55:00Z');
|
|
91
|
+
const schedule: RoutineSchedule = {
|
|
92
|
+
cron: '0 * * * *', // 5 minutes until :00
|
|
93
|
+
interval: '30m' // 30 minutes
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const nextRun = getNextRunTime(schedule, now);
|
|
97
|
+
|
|
98
|
+
expect(nextRun).not.toBeNull();
|
|
99
|
+
// Should be the cron time (5 min) not interval (30 min)
|
|
100
|
+
expect(nextRun!.getMinutes()).toBe(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
test('returns null for invalid schedule', () => {
|
|
104
|
+
const schedule: RoutineSchedule = {} as RoutineSchedule;
|
|
105
|
+
const nextRun = getNextRunTime(schedule);
|
|
106
|
+
expect(nextRun).toBeNull();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
111
|
+
// SCHEDULE VALIDATION TESTS
|
|
112
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
113
|
+
|
|
114
|
+
describe('validateScheduleConfig', () => {
|
|
115
|
+
test('validates interval schedule', () => {
|
|
116
|
+
const schedule = validateScheduleConfig({ interval: '1h' });
|
|
117
|
+
expect(schedule.interval).toBe('1h');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test('validates cron schedule', () => {
|
|
121
|
+
const schedule = validateScheduleConfig({ cron: '0 9 * * *' });
|
|
122
|
+
expect(schedule.cron).toBe('0 9 * * *');
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test('validates all optional fields', () => {
|
|
126
|
+
const schedule = validateScheduleConfig({
|
|
127
|
+
interval: '1h',
|
|
128
|
+
timezone: 'Pacific/Auckland',
|
|
129
|
+
enabled: true,
|
|
130
|
+
retries: 3,
|
|
131
|
+
retryDelayMs: 60000,
|
|
132
|
+
concurrency: 'skip'
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
expect(schedule.timezone).toBe('Pacific/Auckland');
|
|
136
|
+
expect(schedule.enabled).toBe(true);
|
|
137
|
+
expect(schedule.retries).toBe(3);
|
|
138
|
+
expect(schedule.retryDelayMs).toBe(60000);
|
|
139
|
+
expect(schedule.concurrency).toBe('skip');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
test('throws when missing cron and interval', () => {
|
|
143
|
+
expect(() => validateScheduleConfig({})).toThrow('Schedule must have "cron" or "interval"');
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test('throws on invalid interval format', () => {
|
|
147
|
+
expect(() => validateScheduleConfig({ interval: 'invalid' })).toThrow('must be like "30s"');
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('throws on invalid cron format', () => {
|
|
151
|
+
expect(() => validateScheduleConfig({ cron: '* *' })).toThrow('must have 5 or 6 fields');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('throws on invalid retries', () => {
|
|
155
|
+
expect(() => validateScheduleConfig({ interval: '1h', retries: -1 })).toThrow('non-negative integer');
|
|
156
|
+
expect(() => validateScheduleConfig({ interval: '1h', retries: 'abc' })).toThrow('non-negative integer');
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test('throws on invalid concurrency', () => {
|
|
160
|
+
expect(() => validateScheduleConfig({ interval: '1h', concurrency: 'invalid' })).toThrow('"skip" or "queue"');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
165
|
+
// STATE MANAGEMENT TESTS
|
|
166
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
167
|
+
|
|
168
|
+
describe('state management', () => {
|
|
169
|
+
// Note: These tests use the actual ~/.cli4ai/scheduler directory
|
|
170
|
+
// because paths are resolved at module import time
|
|
171
|
+
|
|
172
|
+
test('saves and loads scheduler state', () => {
|
|
173
|
+
// Clean up first
|
|
174
|
+
if (existsSync(SCHEDULER_STATE_FILE)) {
|
|
175
|
+
rmSync(SCHEDULER_STATE_FILE);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const state: SchedulerState = {
|
|
179
|
+
version: 1,
|
|
180
|
+
startedAt: new Date().toISOString(),
|
|
181
|
+
routines: {
|
|
182
|
+
'test-routine': {
|
|
183
|
+
name: 'test-routine',
|
|
184
|
+
path: '/path/to/routine.json',
|
|
185
|
+
schedule: { interval: '1h' },
|
|
186
|
+
nextRunAt: new Date().toISOString(),
|
|
187
|
+
lastRunAt: null,
|
|
188
|
+
lastStatus: null,
|
|
189
|
+
running: false,
|
|
190
|
+
retryCount: 0
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
saveSchedulerState(state);
|
|
196
|
+
const loaded = loadSchedulerState();
|
|
197
|
+
|
|
198
|
+
expect(loaded).not.toBeNull();
|
|
199
|
+
expect(loaded!.version).toBe(1);
|
|
200
|
+
expect(loaded!.routines['test-routine'].name).toBe('test-routine');
|
|
201
|
+
|
|
202
|
+
// Clean up
|
|
203
|
+
rmSync(SCHEDULER_STATE_FILE);
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test('returns null when no state file exists', () => {
|
|
207
|
+
// Ensure no state file exists
|
|
208
|
+
if (existsSync(SCHEDULER_STATE_FILE)) {
|
|
209
|
+
rmSync(SCHEDULER_STATE_FILE);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const state = loadSchedulerState();
|
|
213
|
+
expect(state).toBeNull();
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('saves and retrieves run records', () => {
|
|
217
|
+
// Clean up existing run files for this test
|
|
218
|
+
if (existsSync(SCHEDULER_RUNS_DIR)) {
|
|
219
|
+
const files = readdirSync(SCHEDULER_RUNS_DIR);
|
|
220
|
+
for (const f of files) {
|
|
221
|
+
if (f.startsWith('test-routine-')) {
|
|
222
|
+
rmSync(join(SCHEDULER_RUNS_DIR, f));
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const record: SchedulerRunRecord = {
|
|
228
|
+
routine: 'test-routine',
|
|
229
|
+
startedAt: new Date().toISOString(),
|
|
230
|
+
finishedAt: new Date().toISOString(),
|
|
231
|
+
status: 'success',
|
|
232
|
+
exitCode: 0,
|
|
233
|
+
durationMs: 1234,
|
|
234
|
+
retryAttempt: 0
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
saveRunRecord(record);
|
|
238
|
+
const history = getRunHistory('test-routine');
|
|
239
|
+
|
|
240
|
+
expect(history.length).toBeGreaterThanOrEqual(1);
|
|
241
|
+
expect(history[0].routine).toBe('test-routine');
|
|
242
|
+
expect(history[0].status).toBe('success');
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
247
|
+
// PID FILE TESTS
|
|
248
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
249
|
+
|
|
250
|
+
describe('daemon PID management', () => {
|
|
251
|
+
// Note: These tests use the actual ~/.cli4ai/scheduler directory
|
|
252
|
+
// because paths are resolved at module import time
|
|
253
|
+
|
|
254
|
+
beforeEach(() => {
|
|
255
|
+
// Clean up any existing PID file
|
|
256
|
+
if (existsSync(SCHEDULER_PID_FILE)) {
|
|
257
|
+
rmSync(SCHEDULER_PID_FILE);
|
|
258
|
+
}
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
afterEach(() => {
|
|
262
|
+
// Clean up
|
|
263
|
+
if (existsSync(SCHEDULER_PID_FILE)) {
|
|
264
|
+
rmSync(SCHEDULER_PID_FILE);
|
|
265
|
+
}
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('writes and reads PID file', () => {
|
|
269
|
+
writeDaemonPid(12345);
|
|
270
|
+
const pid = getDaemonPid();
|
|
271
|
+
expect(pid).toBe(12345);
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
test('removes PID file', () => {
|
|
275
|
+
writeDaemonPid(12345);
|
|
276
|
+
removeDaemonPid();
|
|
277
|
+
const pid = getDaemonPid();
|
|
278
|
+
expect(pid).toBeNull();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
test('returns null when no PID file', () => {
|
|
282
|
+
const pid = getDaemonPid();
|
|
283
|
+
expect(pid).toBeNull();
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test('isDaemonRunning returns false for non-existent process', () => {
|
|
287
|
+
writeDaemonPid(999999); // Unlikely to be running
|
|
288
|
+
const running = isDaemonRunning();
|
|
289
|
+
expect(running).toBe(false);
|
|
290
|
+
});
|
|
291
|
+
});
|