cli4ai 0.8.1 → 0.8.3
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 +4 -3
- package/src/bin.ts +21 -21
- package/src/cli.ts +50 -0
- package/src/commands/add.ts +43 -0
- package/src/commands/browse.ts +1 -1
- package/src/commands/config.ts +31 -3
- 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 +1 -1
- 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 +1 -1
- package/src/core/config.ts +200 -37
- package/src/core/execute.ts +56 -15
- package/src/core/link.ts +52 -9
- package/src/core/lockfile.ts +4 -2
- 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 +601 -0
- package/src/lib/cli.ts +25 -5
- package/src/mcp/adapter.ts +14 -6
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduler core - manages scheduled routine execution.
|
|
3
|
+
*
|
|
4
|
+
* Storage structure:
|
|
5
|
+
* ~/.cli4ai/scheduler/
|
|
6
|
+
* ├── scheduler.pid # Daemon PID
|
|
7
|
+
* ├── state.json # Next runs, last results
|
|
8
|
+
* ├── runs/ # Execution records
|
|
9
|
+
* │ └── <routine>-<ts>.json
|
|
10
|
+
* └── logs/
|
|
11
|
+
* └── scheduler.log # Daemon logs
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync } from 'fs';
|
|
15
|
+
import { resolve, join } from 'path';
|
|
16
|
+
import parser from 'cron-parser';
|
|
17
|
+
import { SCHEDULER_DIR, ensureCli4aiHome } from './config.js';
|
|
18
|
+
import { getScheduledRoutines, type ScheduledRoutineInfo } from './routines.js';
|
|
19
|
+
import { loadRoutineDefinition, runRoutine, type RoutineSchedule, type RoutineRunSummary } from './routine-engine.js';
|
|
20
|
+
|
|
21
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
22
|
+
// PATHS
|
|
23
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
24
|
+
|
|
25
|
+
export const SCHEDULER_PID_FILE = resolve(SCHEDULER_DIR, 'scheduler.pid');
|
|
26
|
+
export const SCHEDULER_STATE_FILE = resolve(SCHEDULER_DIR, 'state.json');
|
|
27
|
+
export const SCHEDULER_RUNS_DIR = resolve(SCHEDULER_DIR, 'runs');
|
|
28
|
+
export const SCHEDULER_LOGS_DIR = resolve(SCHEDULER_DIR, 'logs');
|
|
29
|
+
export const SCHEDULER_LOG_FILE = resolve(SCHEDULER_LOGS_DIR, 'scheduler.log');
|
|
30
|
+
|
|
31
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
32
|
+
// TYPES
|
|
33
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
34
|
+
|
|
35
|
+
export interface ScheduledRoutineState {
|
|
36
|
+
name: string;
|
|
37
|
+
path: string;
|
|
38
|
+
schedule: RoutineSchedule;
|
|
39
|
+
nextRunAt: string | null;
|
|
40
|
+
lastRunAt: string | null;
|
|
41
|
+
lastStatus: 'success' | 'failed' | null;
|
|
42
|
+
running: boolean;
|
|
43
|
+
retryCount: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface SchedulerState {
|
|
47
|
+
version: 1;
|
|
48
|
+
startedAt: string;
|
|
49
|
+
routines: Record<string, ScheduledRoutineState>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface SchedulerRunRecord {
|
|
53
|
+
routine: string;
|
|
54
|
+
startedAt: string;
|
|
55
|
+
finishedAt: string;
|
|
56
|
+
status: 'success' | 'failed';
|
|
57
|
+
exitCode: number;
|
|
58
|
+
durationMs: number;
|
|
59
|
+
retryAttempt: number;
|
|
60
|
+
error?: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
64
|
+
// INTERVAL PARSING
|
|
65
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
66
|
+
|
|
67
|
+
const INTERVAL_PATTERN = /^(\d+)(s|m|h|d)$/;
|
|
68
|
+
|
|
69
|
+
const INTERVAL_MULTIPLIERS: Record<string, number> = {
|
|
70
|
+
s: 1000,
|
|
71
|
+
m: 60 * 1000,
|
|
72
|
+
h: 60 * 60 * 1000,
|
|
73
|
+
d: 24 * 60 * 60 * 1000
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Parse an interval string like "30s", "5m", "1h", "1d" into milliseconds.
|
|
78
|
+
*/
|
|
79
|
+
export function parseInterval(interval: string): number {
|
|
80
|
+
const match = interval.match(INTERVAL_PATTERN);
|
|
81
|
+
if (!match) {
|
|
82
|
+
throw new Error(`Invalid interval format: ${interval}`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const value = parseInt(match[1], 10);
|
|
86
|
+
const unit = match[2];
|
|
87
|
+
return value * INTERVAL_MULTIPLIERS[unit];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Calculate the next run time for a schedule.
|
|
92
|
+
* If both cron and interval are specified, returns the earlier of the two.
|
|
93
|
+
*/
|
|
94
|
+
export function getNextRunTime(schedule: RoutineSchedule, after: Date = new Date()): Date | null {
|
|
95
|
+
const candidates: Date[] = [];
|
|
96
|
+
|
|
97
|
+
// Calculate from cron expression
|
|
98
|
+
if (schedule.cron) {
|
|
99
|
+
try {
|
|
100
|
+
const options: parser.ParserOptions = {
|
|
101
|
+
currentDate: after,
|
|
102
|
+
tz: schedule.timezone
|
|
103
|
+
};
|
|
104
|
+
const cronExpr = parser.parseExpression(schedule.cron, options);
|
|
105
|
+
candidates.push(cronExpr.next().toDate());
|
|
106
|
+
} catch {
|
|
107
|
+
// Invalid cron expression - skip
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Calculate from interval
|
|
112
|
+
if (schedule.interval) {
|
|
113
|
+
try {
|
|
114
|
+
const intervalMs = parseInterval(schedule.interval);
|
|
115
|
+
candidates.push(new Date(after.getTime() + intervalMs));
|
|
116
|
+
} catch {
|
|
117
|
+
// Invalid interval - skip
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (candidates.length === 0) {
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Return the earliest next run time
|
|
126
|
+
return candidates.reduce((earliest, candidate) =>
|
|
127
|
+
candidate < earliest ? candidate : earliest
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
132
|
+
// DAEMON LIFECYCLE
|
|
133
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Get the PID of the running daemon, or null if not running.
|
|
137
|
+
*/
|
|
138
|
+
export function getDaemonPid(): number | null {
|
|
139
|
+
if (!existsSync(SCHEDULER_PID_FILE)) {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const pid = parseInt(readFileSync(SCHEDULER_PID_FILE, 'utf-8').trim(), 10);
|
|
145
|
+
if (isNaN(pid)) return null;
|
|
146
|
+
return pid;
|
|
147
|
+
} catch {
|
|
148
|
+
return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Check if a process with the given PID is running.
|
|
154
|
+
*/
|
|
155
|
+
function isProcessRunning(pid: number): boolean {
|
|
156
|
+
try {
|
|
157
|
+
// Sending signal 0 doesn't actually send a signal, but checks if process exists
|
|
158
|
+
process.kill(pid, 0);
|
|
159
|
+
return true;
|
|
160
|
+
} catch {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Check if the scheduler daemon is running.
|
|
167
|
+
*/
|
|
168
|
+
export function isDaemonRunning(): boolean {
|
|
169
|
+
const pid = getDaemonPid();
|
|
170
|
+
if (pid === null) return false;
|
|
171
|
+
return isProcessRunning(pid);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Write the daemon PID file.
|
|
176
|
+
*/
|
|
177
|
+
export function writeDaemonPid(pid: number): void {
|
|
178
|
+
ensureSchedulerDirs();
|
|
179
|
+
writeFileSync(SCHEDULER_PID_FILE, String(pid));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Remove the daemon PID file.
|
|
184
|
+
*/
|
|
185
|
+
export function removeDaemonPid(): void {
|
|
186
|
+
if (existsSync(SCHEDULER_PID_FILE)) {
|
|
187
|
+
unlinkSync(SCHEDULER_PID_FILE);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
192
|
+
// STATE MANAGEMENT
|
|
193
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
194
|
+
|
|
195
|
+
export function ensureSchedulerDirs(): void {
|
|
196
|
+
ensureCli4aiHome();
|
|
197
|
+
if (!existsSync(SCHEDULER_RUNS_DIR)) {
|
|
198
|
+
mkdirSync(SCHEDULER_RUNS_DIR, { recursive: true });
|
|
199
|
+
}
|
|
200
|
+
if (!existsSync(SCHEDULER_LOGS_DIR)) {
|
|
201
|
+
mkdirSync(SCHEDULER_LOGS_DIR, { recursive: true });
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Load scheduler state from disk.
|
|
207
|
+
*/
|
|
208
|
+
export function loadSchedulerState(): SchedulerState | null {
|
|
209
|
+
if (!existsSync(SCHEDULER_STATE_FILE)) {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const content = readFileSync(SCHEDULER_STATE_FILE, 'utf-8');
|
|
215
|
+
return JSON.parse(content) as SchedulerState;
|
|
216
|
+
} catch {
|
|
217
|
+
return null;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Save scheduler state to disk.
|
|
223
|
+
*/
|
|
224
|
+
export function saveSchedulerState(state: SchedulerState): void {
|
|
225
|
+
ensureSchedulerDirs();
|
|
226
|
+
writeFileSync(SCHEDULER_STATE_FILE, JSON.stringify(state, null, 2));
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Save a run record to disk.
|
|
231
|
+
*/
|
|
232
|
+
export function saveRunRecord(record: SchedulerRunRecord): void {
|
|
233
|
+
ensureSchedulerDirs();
|
|
234
|
+
const filename = `${record.routine}-${new Date(record.startedAt).getTime()}.json`;
|
|
235
|
+
const filepath = resolve(SCHEDULER_RUNS_DIR, filename);
|
|
236
|
+
writeFileSync(filepath, JSON.stringify(record, null, 2));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Get run history for a routine (or all routines).
|
|
241
|
+
*/
|
|
242
|
+
export function getRunHistory(routineName?: string, limit: number = 20): SchedulerRunRecord[] {
|
|
243
|
+
ensureSchedulerDirs();
|
|
244
|
+
|
|
245
|
+
if (!existsSync(SCHEDULER_RUNS_DIR)) {
|
|
246
|
+
return [];
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
const files = readdirSync(SCHEDULER_RUNS_DIR)
|
|
250
|
+
.filter(f => f.endsWith('.json'))
|
|
251
|
+
.filter(f => !routineName || f.startsWith(`${routineName}-`))
|
|
252
|
+
.sort()
|
|
253
|
+
.reverse()
|
|
254
|
+
.slice(0, limit);
|
|
255
|
+
|
|
256
|
+
const records: SchedulerRunRecord[] = [];
|
|
257
|
+
|
|
258
|
+
for (const file of files) {
|
|
259
|
+
try {
|
|
260
|
+
const content = readFileSync(resolve(SCHEDULER_RUNS_DIR, file), 'utf-8');
|
|
261
|
+
records.push(JSON.parse(content) as SchedulerRunRecord);
|
|
262
|
+
} catch {
|
|
263
|
+
// Skip invalid files
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return records;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
271
|
+
// LOGGING
|
|
272
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
273
|
+
|
|
274
|
+
export type LogLevel = 'info' | 'warn' | 'error' | 'debug';
|
|
275
|
+
|
|
276
|
+
export function appendSchedulerLog(level: LogLevel, message: string): void {
|
|
277
|
+
ensureSchedulerDirs();
|
|
278
|
+
const timestamp = new Date().toISOString();
|
|
279
|
+
const line = `[${timestamp}] [${level.toUpperCase()}] ${message}\n`;
|
|
280
|
+
const fd = require('fs').appendFileSync(SCHEDULER_LOG_FILE, line);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
284
|
+
// SCHEDULER CLASS
|
|
285
|
+
// ═══════════════════════════════════════════════════════════════════════════
|
|
286
|
+
|
|
287
|
+
export interface SchedulerOptions {
|
|
288
|
+
tickIntervalMs?: number;
|
|
289
|
+
projectDir?: string;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export class Scheduler {
|
|
293
|
+
private state: SchedulerState;
|
|
294
|
+
private tickIntervalMs: number;
|
|
295
|
+
private projectDir: string | undefined;
|
|
296
|
+
private running: boolean = false;
|
|
297
|
+
private tickTimer: NodeJS.Timeout | null = null;
|
|
298
|
+
private routineChainByName: Map<string, Promise<void>> = new Map();
|
|
299
|
+
|
|
300
|
+
constructor(options: SchedulerOptions = {}) {
|
|
301
|
+
this.tickIntervalMs = options.tickIntervalMs ?? 10000; // 10 seconds default
|
|
302
|
+
this.projectDir = options.projectDir;
|
|
303
|
+
|
|
304
|
+
// Load or initialize state
|
|
305
|
+
const existingState = loadSchedulerState();
|
|
306
|
+
if (existingState) {
|
|
307
|
+
this.state = existingState;
|
|
308
|
+
} else {
|
|
309
|
+
this.state = {
|
|
310
|
+
version: 1,
|
|
311
|
+
startedAt: new Date().toISOString(),
|
|
312
|
+
routines: {}
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/**
|
|
318
|
+
* Refresh the list of scheduled routines from disk.
|
|
319
|
+
*/
|
|
320
|
+
refreshRoutines(): void {
|
|
321
|
+
const scheduled = getScheduledRoutines(this.projectDir);
|
|
322
|
+
|
|
323
|
+
// Remove routines that no longer exist or are disabled
|
|
324
|
+
const currentNames = new Set(scheduled.map(r => r.name));
|
|
325
|
+
for (const name of Object.keys(this.state.routines)) {
|
|
326
|
+
if (!currentNames.has(name)) {
|
|
327
|
+
delete this.state.routines[name];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Add or update routines
|
|
332
|
+
for (const routine of scheduled) {
|
|
333
|
+
const existing = this.state.routines[routine.name];
|
|
334
|
+
|
|
335
|
+
if (existing) {
|
|
336
|
+
// Update schedule if changed
|
|
337
|
+
existing.schedule = routine.schedule;
|
|
338
|
+
existing.path = routine.path;
|
|
339
|
+
// Recalculate next run if schedule changed
|
|
340
|
+
if (!existing.nextRunAt) {
|
|
341
|
+
existing.nextRunAt = getNextRunTime(routine.schedule)?.toISOString() ?? null;
|
|
342
|
+
}
|
|
343
|
+
} else {
|
|
344
|
+
// New routine
|
|
345
|
+
this.state.routines[routine.name] = {
|
|
346
|
+
name: routine.name,
|
|
347
|
+
path: routine.path,
|
|
348
|
+
schedule: routine.schedule,
|
|
349
|
+
nextRunAt: getNextRunTime(routine.schedule)?.toISOString() ?? null,
|
|
350
|
+
lastRunAt: null,
|
|
351
|
+
lastStatus: null,
|
|
352
|
+
running: false,
|
|
353
|
+
retryCount: 0
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
saveSchedulerState(this.state);
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Start the scheduler loop.
|
|
363
|
+
*/
|
|
364
|
+
async start(): Promise<void> {
|
|
365
|
+
if (this.running) return;
|
|
366
|
+
|
|
367
|
+
this.running = true;
|
|
368
|
+
this.state.startedAt = new Date().toISOString();
|
|
369
|
+
|
|
370
|
+
appendSchedulerLog('info', 'Scheduler started');
|
|
371
|
+
this.refreshRoutines();
|
|
372
|
+
|
|
373
|
+
// Run tick loop
|
|
374
|
+
this.scheduleTick();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Stop the scheduler loop.
|
|
379
|
+
*/
|
|
380
|
+
async stop(): Promise<void> {
|
|
381
|
+
this.running = false;
|
|
382
|
+
|
|
383
|
+
if (this.tickTimer) {
|
|
384
|
+
clearTimeout(this.tickTimer);
|
|
385
|
+
this.tickTimer = null;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
appendSchedulerLog('info', 'Scheduler stopped');
|
|
389
|
+
saveSchedulerState(this.state);
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Get current scheduler state (for status display).
|
|
394
|
+
*/
|
|
395
|
+
getState(): SchedulerState {
|
|
396
|
+
return this.state;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
private scheduleTick(): void {
|
|
400
|
+
if (!this.running) return;
|
|
401
|
+
|
|
402
|
+
this.tickTimer = setTimeout(async () => {
|
|
403
|
+
await this.tick();
|
|
404
|
+
this.scheduleTick();
|
|
405
|
+
}, this.tickIntervalMs);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private isRoutineActive(name: string): boolean {
|
|
409
|
+
return this.routineChainByName.has(name);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
private enqueueRoutineExecution(routine: ScheduledRoutineState): void {
|
|
413
|
+
const name = routine.name;
|
|
414
|
+
const prev = this.routineChainByName.get(name) ?? Promise.resolve();
|
|
415
|
+
const next = prev
|
|
416
|
+
.catch(() => {
|
|
417
|
+
// Keep queue alive even if a previous run rejected unexpectedly.
|
|
418
|
+
})
|
|
419
|
+
.then(async () => {
|
|
420
|
+
try {
|
|
421
|
+
await this.executeRoutine(routine);
|
|
422
|
+
} catch (err) {
|
|
423
|
+
appendSchedulerLog(
|
|
424
|
+
'error',
|
|
425
|
+
`Unexpected error executing routine ${routine.name}: ${err instanceof Error ? err.message : String(err)}`
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
this.routineChainByName.set(name, next);
|
|
431
|
+
next.finally(() => {
|
|
432
|
+
if (this.routineChainByName.get(name) === next) {
|
|
433
|
+
this.routineChainByName.delete(name);
|
|
434
|
+
}
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Execute one tick - check for routines that need to run.
|
|
440
|
+
*/
|
|
441
|
+
private async tick(): Promise<void> {
|
|
442
|
+
const now = new Date();
|
|
443
|
+
|
|
444
|
+
// Refresh routines periodically to pick up changes
|
|
445
|
+
this.refreshRoutines();
|
|
446
|
+
|
|
447
|
+
for (const [name, routine] of Object.entries(this.state.routines)) {
|
|
448
|
+
if (!routine.nextRunAt) continue;
|
|
449
|
+
|
|
450
|
+
const nextRun = new Date(routine.nextRunAt);
|
|
451
|
+
if (nextRun > now) continue;
|
|
452
|
+
|
|
453
|
+
const concurrency = routine.schedule.concurrency ?? 'skip';
|
|
454
|
+
|
|
455
|
+
// Advance next run time immediately to avoid re-triggering the same run.
|
|
456
|
+
routine.nextRunAt = getNextRunTime(routine.schedule, now)?.toISOString() ?? null;
|
|
457
|
+
saveSchedulerState(this.state);
|
|
458
|
+
|
|
459
|
+
if (this.isRoutineActive(name)) {
|
|
460
|
+
if (concurrency === 'skip') {
|
|
461
|
+
appendSchedulerLog('debug', `Skipping ${name}: still running from previous invocation`);
|
|
462
|
+
continue;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
appendSchedulerLog('debug', `Queueing ${name}: previous invocation still running`);
|
|
466
|
+
this.enqueueRoutineExecution(routine);
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// Execute the routine (async, doesn't block the tick loop)
|
|
471
|
+
this.enqueueRoutineExecution(routine);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Execute a routine (async, doesn't block the tick loop).
|
|
477
|
+
*/
|
|
478
|
+
private async executeRoutine(routine: ScheduledRoutineState): Promise<void> {
|
|
479
|
+
const startedAt = new Date();
|
|
480
|
+
|
|
481
|
+
routine.running = true;
|
|
482
|
+
saveSchedulerState(this.state);
|
|
483
|
+
|
|
484
|
+
appendSchedulerLog('info', `Starting routine: ${routine.name}`);
|
|
485
|
+
|
|
486
|
+
let result: RoutineRunSummary | null = null;
|
|
487
|
+
let error: string | undefined;
|
|
488
|
+
|
|
489
|
+
try {
|
|
490
|
+
const def = loadRoutineDefinition(routine.path);
|
|
491
|
+
const invocationDir = this.projectDir ?? process.cwd();
|
|
492
|
+
result = await runRoutine(def, {}, invocationDir);
|
|
493
|
+
} catch (err) {
|
|
494
|
+
error = err instanceof Error ? err.message : String(err);
|
|
495
|
+
appendSchedulerLog('error', `Routine ${routine.name} failed: ${error}`);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const finishedAt = new Date();
|
|
499
|
+
const status: 'success' | 'failed' = result?.status === 'success' ? 'success' : 'failed';
|
|
500
|
+
|
|
501
|
+
// Log routine output
|
|
502
|
+
if (result) {
|
|
503
|
+
for (const step of result.steps) {
|
|
504
|
+
// Log stderr (where progress messages typically go)
|
|
505
|
+
if (step.stderr) {
|
|
506
|
+
for (const line of step.stderr.split('\n')) {
|
|
507
|
+
if (line.trim()) {
|
|
508
|
+
appendSchedulerLog('info', `[${routine.name}] ${line}`);
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
// Log stdout if it's not JSON (JSON output is typically for machine consumption)
|
|
513
|
+
if (step.stdout && !step.json) {
|
|
514
|
+
for (const line of step.stdout.split('\n')) {
|
|
515
|
+
if (line.trim()) {
|
|
516
|
+
appendSchedulerLog('info', `[${routine.name}] ${line}`);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Save run record
|
|
524
|
+
const record: SchedulerRunRecord = {
|
|
525
|
+
routine: routine.name,
|
|
526
|
+
startedAt: startedAt.toISOString(),
|
|
527
|
+
finishedAt: finishedAt.toISOString(),
|
|
528
|
+
status,
|
|
529
|
+
exitCode: result?.exitCode ?? 1,
|
|
530
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
531
|
+
retryAttempt: routine.retryCount,
|
|
532
|
+
error
|
|
533
|
+
};
|
|
534
|
+
saveRunRecord(record);
|
|
535
|
+
|
|
536
|
+
// Update routine state
|
|
537
|
+
routine.running = false;
|
|
538
|
+
routine.lastRunAt = finishedAt.toISOString();
|
|
539
|
+
routine.lastStatus = status;
|
|
540
|
+
|
|
541
|
+
// Handle retries
|
|
542
|
+
const maxRetries = routine.schedule.retries ?? 0;
|
|
543
|
+
if (status === 'failed' && routine.retryCount < maxRetries) {
|
|
544
|
+
routine.retryCount++;
|
|
545
|
+
const retryDelay = routine.schedule.retryDelayMs ?? 60000;
|
|
546
|
+
routine.nextRunAt = new Date(finishedAt.getTime() + retryDelay).toISOString();
|
|
547
|
+
appendSchedulerLog('info', `Scheduling retry ${routine.retryCount}/${maxRetries} for ${routine.name}`);
|
|
548
|
+
} else {
|
|
549
|
+
// Reset retry count and calculate next run
|
|
550
|
+
routine.retryCount = 0;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
saveSchedulerState(this.state);
|
|
554
|
+
appendSchedulerLog('info', `Finished routine: ${routine.name} (${status})`);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* Manually trigger a routine to run now.
|
|
559
|
+
*/
|
|
560
|
+
async runNow(routineName: string): Promise<SchedulerRunRecord> {
|
|
561
|
+
const routine = this.state.routines[routineName];
|
|
562
|
+
if (!routine) {
|
|
563
|
+
throw new Error(`Routine not found: ${routineName}`);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const startedAt = new Date();
|
|
567
|
+
let result: RoutineRunSummary | null = null;
|
|
568
|
+
let error: string | undefined;
|
|
569
|
+
|
|
570
|
+
try {
|
|
571
|
+
const def = loadRoutineDefinition(routine.path);
|
|
572
|
+
const invocationDir = this.projectDir ?? process.cwd();
|
|
573
|
+
result = await runRoutine(def, {}, invocationDir);
|
|
574
|
+
} catch (err) {
|
|
575
|
+
error = err instanceof Error ? err.message : String(err);
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const finishedAt = new Date();
|
|
579
|
+
const status: 'success' | 'failed' = result?.status === 'success' ? 'success' : 'failed';
|
|
580
|
+
|
|
581
|
+
const record: SchedulerRunRecord = {
|
|
582
|
+
routine: routineName,
|
|
583
|
+
startedAt: startedAt.toISOString(),
|
|
584
|
+
finishedAt: finishedAt.toISOString(),
|
|
585
|
+
status,
|
|
586
|
+
exitCode: result?.exitCode ?? 1,
|
|
587
|
+
durationMs: finishedAt.getTime() - startedAt.getTime(),
|
|
588
|
+
retryAttempt: 0,
|
|
589
|
+
error
|
|
590
|
+
};
|
|
591
|
+
saveRunRecord(record);
|
|
592
|
+
|
|
593
|
+
// Update state
|
|
594
|
+
routine.lastRunAt = finishedAt.toISOString();
|
|
595
|
+
routine.lastStatus = status;
|
|
596
|
+
routine.nextRunAt = getNextRunTime(routine.schedule, finishedAt)?.toISOString() ?? null;
|
|
597
|
+
saveSchedulerState(this.state);
|
|
598
|
+
|
|
599
|
+
return record;
|
|
600
|
+
}
|
|
601
|
+
}
|
package/src/lib/cli.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* cli4ai -
|
|
2
|
+
* cli4ai - cli4ai.com
|
|
3
3
|
* Standardized CLI framework for AI agent tools
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { Command } from 'commander';
|
|
7
7
|
|
|
8
|
-
export const BRAND = 'cli4ai -
|
|
9
|
-
export const VERSION = '0.
|
|
8
|
+
export const BRAND = 'cli4ai - cli4ai.com';
|
|
9
|
+
export const VERSION = '0.8.3';
|
|
10
10
|
|
|
11
11
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
12
12
|
// TYPES
|
|
@@ -36,10 +36,29 @@ export const ErrorCodes = {
|
|
|
36
36
|
PARSE_ERROR: 'PARSE_ERROR',
|
|
37
37
|
MANIFEST_ERROR: 'MANIFEST_ERROR',
|
|
38
38
|
INSTALL_ERROR: 'INSTALL_ERROR',
|
|
39
|
+
NPM_ERROR: 'NPM_ERROR',
|
|
39
40
|
} as const;
|
|
40
41
|
|
|
41
42
|
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
|
|
42
43
|
|
|
44
|
+
const EXIT_CODES: Record<string, number> = {
|
|
45
|
+
[ErrorCodes.NOT_FOUND]: 2,
|
|
46
|
+
[ErrorCodes.INVALID_INPUT]: 3,
|
|
47
|
+
[ErrorCodes.ENV_MISSING]: 4,
|
|
48
|
+
[ErrorCodes.MANIFEST_ERROR]: 4,
|
|
49
|
+
[ErrorCodes.INSTALL_ERROR]: 4,
|
|
50
|
+
[ErrorCodes.AUTH_FAILED]: 6,
|
|
51
|
+
[ErrorCodes.NETWORK_ERROR]: 7,
|
|
52
|
+
[ErrorCodes.RATE_LIMITED]: 8,
|
|
53
|
+
[ErrorCodes.TIMEOUT]: 9,
|
|
54
|
+
[ErrorCodes.PARSE_ERROR]: 10,
|
|
55
|
+
[ErrorCodes.NPM_ERROR]: 11,
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function getExitCode(code: string): number {
|
|
59
|
+
return EXIT_CODES[code] ?? 1;
|
|
60
|
+
}
|
|
61
|
+
|
|
43
62
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
44
63
|
// ENV VALIDATION
|
|
45
64
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
@@ -106,14 +125,15 @@ export function output(data: unknown): void {
|
|
|
106
125
|
export function outputError(
|
|
107
126
|
code: string,
|
|
108
127
|
message: string,
|
|
109
|
-
details?: Record<string, unknown
|
|
128
|
+
details?: Record<string, unknown>,
|
|
129
|
+
exitCodeOverride?: number
|
|
110
130
|
): never {
|
|
111
131
|
console.error(JSON.stringify({
|
|
112
132
|
error: code,
|
|
113
133
|
message,
|
|
114
134
|
...details
|
|
115
135
|
}));
|
|
116
|
-
process.exit(
|
|
136
|
+
process.exit(exitCodeOverride ?? getExitCode(code));
|
|
117
137
|
}
|
|
118
138
|
|
|
119
139
|
/**
|
package/src/mcp/adapter.ts
CHANGED
|
@@ -73,18 +73,26 @@ export async function executeTool(
|
|
|
73
73
|
entryPath: string,
|
|
74
74
|
runtime: string,
|
|
75
75
|
command: string,
|
|
76
|
-
args: Record<string, string
|
|
76
|
+
args: Record<string, string>,
|
|
77
|
+
argOrder?: string[]
|
|
77
78
|
): Promise<McpToolResult> {
|
|
78
79
|
return new Promise((resolve) => {
|
|
79
80
|
// Build command arguments
|
|
80
81
|
const cmdArgs = [command];
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
|
|
83
|
+
// Prefer manifest-defined arg order (positional), fall back to stable ordering.
|
|
84
|
+
const orderedKeys = argOrder ?? Object.keys(args).sort();
|
|
85
|
+
for (const key of orderedKeys) {
|
|
86
|
+
const value = args[key];
|
|
87
|
+
if (value !== undefined && value !== '') cmdArgs.push(value);
|
|
85
88
|
}
|
|
86
89
|
|
|
87
|
-
const
|
|
90
|
+
const runtimeArgs =
|
|
91
|
+
runtime === 'node'
|
|
92
|
+
? [entryPath, ...cmdArgs]
|
|
93
|
+
: ['run', entryPath, ...cmdArgs];
|
|
94
|
+
|
|
95
|
+
const proc = spawn(runtime, runtimeArgs, {
|
|
88
96
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
89
97
|
env: { ...process.env }
|
|
90
98
|
});
|