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