@crewx/cron 0.1.10-rc.145 → 0.1.10-rc.146

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.
@@ -33,16 +33,21 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DATA_PATHS = exports.getProjectRoot = exports.BUILTIN_SCHEDULES = void 0;
36
+ exports.DATA_PATHS = exports.CRON_RUNTIME_PROCESS_LEASE_FILENAME = exports.createCronRuntime = exports.getProjectRoot = exports.BUILTIN_SCHEDULES = exports.CronStoreError = void 0;
37
37
  exports.getLocalTimestamp = getLocalTimestamp;
38
38
  exports.logUsage = logUsage;
39
+ exports.acquireWorkspaceLock = acquireWorkspaceLock;
40
+ exports.releaseWorkspaceLock = releaseWorkspaceLock;
41
+ exports.migrateLegacyMemoryDebounceSchedule = migrateLegacyMemoryDebounceSchedule;
39
42
  exports.normalizeMode = normalizeMode;
43
+ exports.createScheduleStore = createScheduleStore;
40
44
  exports.loadSchedules = loadSchedules;
41
45
  exports.saveSchedules = saveSchedules;
42
46
  exports.getRemovedBuiltins = getRemovedBuiltins;
43
47
  exports.seedBuiltinSchedules = seedBuiltinSchedules;
44
48
  exports.acquireLock = acquireLock;
45
49
  exports.releaseLock = releaseLock;
50
+ exports.releaseRunLock = releaseRunLock;
46
51
  exports.shouldRunNow = shouldRunNow;
47
52
  exports.describeCron = describeCron;
48
53
  exports.calculateNextRun = calculateNextRun;
@@ -53,6 +58,8 @@ exports.startDaemonSafe = startDaemonSafe;
53
58
  exports.ensureDaemonRunning = ensureDaemonRunning;
54
59
  exports.getDaemonStatus = getDaemonStatus;
55
60
  exports.executeSchedule = executeSchedule;
61
+ exports.getLocalMinuteKey = getLocalMinuteKey;
62
+ exports.buildOccurrenceKey = buildOccurrenceKey;
56
63
  exports.daemonTick = daemonTick;
57
64
  exports.runDaemonForeground = runDaemonForeground;
58
65
  exports.addSchedule = addSchedule;
@@ -61,13 +68,17 @@ exports.enableSchedule = enableSchedule;
61
68
  exports.disableSchedule = disableSchedule;
62
69
  exports.updateSchedule = updateSchedule;
63
70
  const child_process_1 = require("child_process");
71
+ const crypto_1 = require("crypto");
64
72
  const path = __importStar(require("path"));
65
73
  const fs = __importStar(require("fs"));
74
+ const types_1 = require("./types");
75
+ Object.defineProperty(exports, "CronStoreError", { enumerable: true, get: function () { return types_1.CronStoreError; } });
66
76
  const sdk_1 = require("@crewx/sdk");
67
77
  const builtins_1 = require("./builtins");
68
78
  Object.defineProperty(exports, "BUILTIN_SCHEDULES", { enumerable: true, get: function () { return builtins_1.BUILTIN_SCHEDULES; } });
69
79
  Object.defineProperty(exports, "getProjectRoot", { enumerable: true, get: function () { return builtins_1.getProjectRoot; } });
70
- const DATA_DIR = path.join(process.cwd(), '.crewx');
80
+ const DEFAULT_WORKSPACE_PATH = path.resolve(process.cwd());
81
+ const DATA_DIR = path.join(DEFAULT_WORKSPACE_PATH, '.crewx');
71
82
  const SCHEDULES_FILE = path.join(DATA_DIR, '.cron-data.json');
72
83
  const LOG_FILE = path.join(DATA_DIR, '.cron-daemon.log');
73
84
  const LOCK_FILE = path.join(DATA_DIR, '.cron-data.json.lock');
@@ -75,11 +86,17 @@ const USAGE_LOG_FILE = path.join(DATA_DIR, 'cron-usage.log');
75
86
  const PID_FILE = path.join(DATA_DIR, '.cron-daemon.pid');
76
87
  const BUILTINS_META_FILE = path.join(DATA_DIR, '.cron-builtins.json');
77
88
  function resolveDataDir(dir) {
78
- return dir ? path.join(dir, '.crewx') : DATA_DIR;
89
+ return path.join(path.resolve(dir ?? DEFAULT_WORKSPACE_PATH), '.crewx');
79
90
  }
80
91
  function resolveSchedulesFile(dir) {
81
92
  return path.join(resolveDataDir(dir), '.cron-data.json');
82
93
  }
94
+ function resolveLockFile(dir) {
95
+ return path.join(resolveDataDir(dir), '.cron-data.json.lock');
96
+ }
97
+ function resolveUsageLogFile(dir) {
98
+ return path.join(resolveDataDir(dir), 'cron-usage.log');
99
+ }
83
100
  function resolvePidFile(dir) {
84
101
  return path.join(resolveDataDir(dir), '.cron-daemon.pid');
85
102
  }
@@ -100,6 +117,9 @@ function resolveRunLockFile(scheduleId, dir) {
100
117
  return path.join(resolveDataDir(dir), `.cron-run-${scheduleId}.lock`);
101
118
  }
102
119
  const DAEMON_INTERVAL_MS = 60 * 1000;
120
+ const STORE_LOCK_TIMEOUT_MS = 5000;
121
+ const STORE_LOCK_POLL_MS = 25;
122
+ const STORE_LOCK_STALE_MS = 30000;
103
123
  const RUN_LOCK_STALE_GRACE_MS = 5000;
104
124
  function getLocalTimestamp() {
105
125
  const now = new Date();
@@ -111,12 +131,45 @@ function getLocalTimestamp() {
111
131
  const s = String(now.getSeconds()).padStart(2, '0');
112
132
  return `${y}-${m}-${d} ${h}:${min}:${s}`;
113
133
  }
114
- function logUsage(...args) {
115
- ensureDataDir();
134
+ function logUsage(dirOrMessage, ...args) {
135
+ const isWorkspacePath = (value) => value !== undefined && (value === '.' ||
136
+ value === '..' ||
137
+ path.isAbsolute(value) ||
138
+ value.includes(path.sep) ||
139
+ value.includes('/') ||
140
+ value.includes('\\') ||
141
+ value.endsWith('.crewx') ||
142
+ (() => {
143
+ try {
144
+ return fs.statSync(value).isDirectory();
145
+ }
146
+ catch {
147
+ return false;
148
+ }
149
+ })());
150
+ let workspacePath;
151
+ let messageParts;
152
+ if (args.length > 0 && (dirOrMessage === undefined || isWorkspacePath(dirOrMessage))) {
153
+ workspacePath = dirOrMessage;
154
+ messageParts = args;
155
+ }
156
+ else if (args.length === 0 && isWorkspacePath(dirOrMessage)) {
157
+ workspacePath = dirOrMessage;
158
+ messageParts = [];
159
+ }
160
+ else if (args.length === 1 && isWorkspacePath(args[0])) {
161
+ workspacePath = args[0];
162
+ messageParts = dirOrMessage === undefined ? [] : [dirOrMessage];
163
+ }
164
+ else {
165
+ workspacePath = undefined;
166
+ messageParts = dirOrMessage === undefined ? args : [dirOrMessage, ...args];
167
+ }
168
+ ensureDataDir(workspacePath);
116
169
  const timestamp = getLocalTimestamp();
117
- const message = args.join(' ');
170
+ const message = messageParts.join(' ');
118
171
  const logLine = `${timestamp} | [cron] ${message}\n`;
119
- fs.appendFileSync(USAGE_LOG_FILE, logLine);
172
+ fs.appendFileSync(resolveUsageLogFile(workspacePath), logLine);
120
173
  }
121
174
  function ensureDataDir(dir) {
122
175
  const dataDir = resolveDataDir(dir);
@@ -132,6 +185,76 @@ function safeReadJsonFile(filePath) {
132
185
  return null;
133
186
  }
134
187
  }
188
+ const STORE_LOCK_WAIT_BUFFER = new Int32Array(new SharedArrayBuffer(4));
189
+ function waitForStoreLock() {
190
+ Atomics.wait(STORE_LOCK_WAIT_BUFFER, 0, 0, STORE_LOCK_POLL_MS);
191
+ }
192
+ function readStoreLock(lockPath) {
193
+ try {
194
+ const parsed = JSON.parse(fs.readFileSync(lockPath, 'utf8'));
195
+ if (typeof parsed.token !== 'string' ||
196
+ typeof parsed.pid !== 'number' ||
197
+ typeof parsed.created_at !== 'number') {
198
+ return null;
199
+ }
200
+ return parsed;
201
+ }
202
+ catch {
203
+ return null;
204
+ }
205
+ }
206
+ function acquireWorkspaceLock(workspacePath, timeout = STORE_LOCK_TIMEOUT_MS) {
207
+ const normalizedWorkspacePath = path.resolve(workspacePath);
208
+ ensureDataDir(normalizedWorkspacePath);
209
+ const lockPath = resolveLockFile(normalizedWorkspacePath);
210
+ const token = (0, crypto_1.randomUUID)();
211
+ const deadline = Date.now() + Math.max(0, timeout);
212
+ const owner = {
213
+ workspacePath: normalizedWorkspacePath,
214
+ lockPath,
215
+ token,
216
+ };
217
+ while (true) {
218
+ const payload = {
219
+ token,
220
+ pid: process.pid,
221
+ created_at: Date.now(),
222
+ };
223
+ try {
224
+ fs.writeFileSync(lockPath, JSON.stringify(payload), { flag: 'wx', mode: 0o600 });
225
+ return owner;
226
+ }
227
+ catch (error) {
228
+ const err = error;
229
+ if (err.code !== 'EEXIST')
230
+ throw error;
231
+ const existing = readStoreLock(lockPath);
232
+ if (existing && Date.now() - existing.created_at > STORE_LOCK_STALE_MS) {
233
+ releaseWorkspaceLock({ ...owner, token: existing.token });
234
+ continue;
235
+ }
236
+ if (Date.now() >= deadline) {
237
+ throw new types_1.CronStoreError('CRON_STORE_LOCK_TIMEOUT', `Timed out waiting for cron store lock: ${lockPath}`, lockPath);
238
+ }
239
+ waitForStoreLock();
240
+ }
241
+ }
242
+ }
243
+ function releaseWorkspaceLock(owner) {
244
+ const current = readStoreLock(owner.lockPath);
245
+ if (!current || current.token !== owner.token)
246
+ return false;
247
+ try {
248
+ fs.unlinkSync(owner.lockPath);
249
+ return true;
250
+ }
251
+ catch (error) {
252
+ const err = error;
253
+ if (err.code === 'ENOENT')
254
+ return false;
255
+ throw error;
256
+ }
257
+ }
135
258
  const MEMORY_DEBOUNCE_BUILTIN = (0, builtins_1.findBuiltinById)('memory-debounce');
136
259
  function maybeRewriteLegacyMemoryCommand(command) {
137
260
  if (!command.includes('/skills/memory-v2/memory-v2.js')) {
@@ -139,9 +262,9 @@ function maybeRewriteLegacyMemoryCommand(command) {
139
262
  }
140
263
  return MEMORY_DEBOUNCE_BUILTIN.buildCommand();
141
264
  }
142
- function migrateLegacyMemoryDebounceSchedule() {
143
- ensureDataDir();
144
- const legacyFile = path.join((0, builtins_1.getProjectRoot)(), 'skills', 'cron', '.cron-data.json');
265
+ function migrateLegacyMemoryDebounceSchedule(workspacePath = (0, builtins_1.getProjectRoot)()) {
266
+ const normalizedWorkspacePath = path.resolve(workspacePath);
267
+ const legacyFile = path.join(normalizedWorkspacePath, 'skills', 'cron', '.cron-data.json');
145
268
  if (!fs.existsSync(legacyFile)) {
146
269
  return;
147
270
  }
@@ -153,7 +276,8 @@ function migrateLegacyMemoryDebounceSchedule() {
153
276
  if (!legacySchedule) {
154
277
  return;
155
278
  }
156
- const targetSchedules = fs.existsSync(SCHEDULES_FILE) ? safeReadJsonFile(SCHEDULES_FILE) ?? [] : [];
279
+ const store = createScheduleStore(normalizedWorkspacePath);
280
+ const targetSchedules = store.list();
157
281
  if (targetSchedules.some(s => s.name === MEMORY_DEBOUNCE_BUILTIN.name)) {
158
282
  return;
159
283
  }
@@ -163,7 +287,7 @@ function migrateLegacyMemoryDebounceSchedule() {
163
287
  updated_at: new Date().toISOString(),
164
288
  next_run: calculateNextRun(legacySchedule.cron, legacySchedule.timezone),
165
289
  };
166
- saveSchedules([...targetSchedules, migratedSchedule]);
290
+ store.create(migratedSchedule);
167
291
  }
168
292
  function warnIfOverdriveInCommand(command) {
169
293
  if (command.includes('--overdrive')) {
@@ -177,31 +301,255 @@ function normalizeMode(mode) {
177
301
  return 'command';
178
302
  return 'execute';
179
303
  }
180
- function loadSchedules(dir) {
181
- ensureDataDir(dir);
182
- if (!dir) {
183
- migrateLegacyMemoryDebounceSchedule();
304
+ function cloneSchedule(schedule) {
305
+ return {
306
+ ...schedule,
307
+ ...(schedule.env ? { env: { ...schedule.env } } : {}),
308
+ };
309
+ }
310
+ function readSchedulesFile(schedulesFile) {
311
+ let data;
312
+ try {
313
+ data = fs.readFileSync(schedulesFile, 'utf8');
184
314
  }
185
- const schedulesFile = resolveSchedulesFile(dir);
186
- if (!fs.existsSync(schedulesFile)) {
187
- return [];
315
+ catch (error) {
316
+ const err = error;
317
+ if (err.code === 'ENOENT')
318
+ return [];
319
+ throw error;
188
320
  }
321
+ let parsed;
189
322
  try {
190
- const data = fs.readFileSync(schedulesFile, 'utf8');
191
- return JSON.parse(data);
323
+ parsed = JSON.parse(data);
192
324
  }
193
- catch (e) {
194
- const err = e;
195
- console.error('Failed to load schedules:', err.message);
196
- return [];
325
+ catch (error) {
326
+ throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store is not valid JSON: ${schedulesFile}`, schedulesFile);
327
+ }
328
+ if (!Array.isArray(parsed)) {
329
+ throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store must contain a top-level array: ${schedulesFile}`, schedulesFile);
330
+ }
331
+ return parsed;
332
+ }
333
+ function syncDirectory(directory) {
334
+ if (process.platform === 'win32')
335
+ return;
336
+ try {
337
+ const fd = fs.openSync(directory, 'r');
338
+ try {
339
+ fs.fsyncSync(fd);
340
+ }
341
+ finally {
342
+ fs.closeSync(fd);
343
+ }
344
+ }
345
+ catch {
197
346
  }
198
347
  }
348
+ function writeSchedulesAtomic(schedulesFile, schedules) {
349
+ const directory = path.dirname(schedulesFile);
350
+ const tempFile = path.join(directory, `.${path.basename(schedulesFile)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
351
+ const contents = JSON.stringify(schedules, null, 2);
352
+ let fd;
353
+ try {
354
+ fd = fs.openSync(tempFile, 'wx', 0o600);
355
+ fs.writeFileSync(fd, contents);
356
+ fs.fsyncSync(fd);
357
+ fs.closeSync(fd);
358
+ fd = undefined;
359
+ fs.renameSync(tempFile, schedulesFile);
360
+ syncDirectory(directory);
361
+ }
362
+ finally {
363
+ if (fd !== undefined) {
364
+ try {
365
+ fs.closeSync(fd);
366
+ }
367
+ catch {
368
+ }
369
+ }
370
+ try {
371
+ fs.unlinkSync(tempFile);
372
+ }
373
+ catch {
374
+ }
375
+ }
376
+ }
377
+ function requireSchedule(schedules, id) {
378
+ const schedule = schedules.find(item => item.id === id);
379
+ if (!schedule) {
380
+ throw new types_1.CronStoreError('CRON_SCHEDULE_NOT_FOUND', `Schedule not found: ${id}`);
381
+ }
382
+ return schedule;
383
+ }
384
+ function withStoreLock(workspacePath, operation) {
385
+ const lock = acquireWorkspaceLock(workspacePath);
386
+ try {
387
+ const schedulesFile = resolveSchedulesFile(workspacePath);
388
+ const schedules = readSchedulesFile(schedulesFile);
389
+ const result = operation(schedules);
390
+ writeSchedulesAtomic(schedulesFile, schedules);
391
+ return result;
392
+ }
393
+ finally {
394
+ releaseWorkspaceLock(lock);
395
+ }
396
+ }
397
+ function createScheduleStore(workspacePath = DEFAULT_WORKSPACE_PATH) {
398
+ const normalizedWorkspacePath = path.resolve(workspacePath);
399
+ const dataDir = resolveDataDir(normalizedWorkspacePath);
400
+ const schedulesFile = resolveSchedulesFile(normalizedWorkspacePath);
401
+ const lockFile = resolveLockFile(normalizedWorkspacePath);
402
+ return {
403
+ workspacePath: normalizedWorkspacePath,
404
+ dataDir,
405
+ schedulesFile,
406
+ lockFile,
407
+ list() {
408
+ return readSchedulesFile(schedulesFile).map(cloneSchedule);
409
+ },
410
+ get(id) {
411
+ const schedule = readSchedulesFile(schedulesFile).find(item => item.id === id);
412
+ return schedule ? cloneSchedule(schedule) : undefined;
413
+ },
414
+ create(schedule) {
415
+ return withStoreLock(normalizedWorkspacePath, schedules => {
416
+ if (schedules.some(item => item.id === schedule.id)) {
417
+ throw new types_1.CronStoreError('CRON_SCHEDULE_EXISTS', `Schedule already exists: ${schedule.id}`);
418
+ }
419
+ schedules.push(cloneSchedule(schedule));
420
+ return cloneSchedule(schedule);
421
+ });
422
+ },
423
+ update(id, changes) {
424
+ return withStoreLock(normalizedWorkspacePath, schedules => {
425
+ const schedule = requireSchedule(schedules, id);
426
+ if (changes.cron !== undefined)
427
+ schedule.cron = changes.cron;
428
+ if (changes.command !== undefined)
429
+ schedule.command = changes.command;
430
+ if (changes.mode !== undefined)
431
+ schedule.mode = changes.mode;
432
+ if (changes.enabled !== undefined)
433
+ schedule.enabled = changes.enabled;
434
+ if (changes.name !== undefined)
435
+ schedule.name = changes.name;
436
+ if (changes.working_directory !== undefined)
437
+ schedule.working_directory = changes.working_directory;
438
+ if (changes.timezone !== undefined)
439
+ schedule.timezone = changes.timezone;
440
+ if (changes.timeout_ms !== undefined)
441
+ schedule.timeout_ms = changes.timeout_ms;
442
+ if (changes.allow_concurrent !== undefined)
443
+ schedule.allow_concurrent = changes.allow_concurrent;
444
+ if (changes.env !== undefined) {
445
+ if (changes.env === null)
446
+ delete schedule.env;
447
+ else
448
+ schedule.env = { ...changes.env };
449
+ }
450
+ if (changes.model !== undefined) {
451
+ if (changes.model === null)
452
+ delete schedule.model;
453
+ else
454
+ schedule.model = changes.model;
455
+ }
456
+ if (changes.provider !== undefined) {
457
+ if (changes.provider === null)
458
+ delete schedule.provider;
459
+ else
460
+ schedule.provider = changes.provider;
461
+ }
462
+ schedule.updated_at = new Date().toISOString();
463
+ schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
464
+ return cloneSchedule(schedule);
465
+ });
466
+ },
467
+ remove(id) {
468
+ return withStoreLock(normalizedWorkspacePath, schedules => {
469
+ const index = schedules.findIndex(item => item.id === id);
470
+ if (index === -1) {
471
+ throw new types_1.CronStoreError('CRON_SCHEDULE_NOT_FOUND', `Schedule not found: ${id}`);
472
+ }
473
+ const [removed] = schedules.splice(index, 1);
474
+ return cloneSchedule(removed);
475
+ });
476
+ },
477
+ toggle(id, enabled) {
478
+ return withStoreLock(normalizedWorkspacePath, schedules => {
479
+ const schedule = requireSchedule(schedules, id);
480
+ schedule.enabled = enabled ?? !schedule.enabled;
481
+ schedule.updated_at = new Date().toISOString();
482
+ if (schedule.enabled) {
483
+ schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
484
+ }
485
+ return cloneSchedule(schedule);
486
+ });
487
+ },
488
+ claimOccurrence(id, options) {
489
+ return withStoreLock(normalizedWorkspacePath, schedules => {
490
+ const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
491
+ if (schedule.last_occurrence_key === options.occurrenceKey) {
492
+ return {
493
+ claimed: false,
494
+ schedule: cloneSchedule(schedule),
495
+ };
496
+ }
497
+ const startedAt = options.startedAt ?? new Date().toISOString();
498
+ schedule.last_started_at = startedAt;
499
+ schedule.last_occurrence_key = options.occurrenceKey;
500
+ schedule.updated_at = startedAt;
501
+ return {
502
+ claimed: true,
503
+ schedule: cloneSchedule(schedule),
504
+ };
505
+ });
506
+ },
507
+ recordStarted(id, options) {
508
+ return withStoreLock(normalizedWorkspacePath, schedules => {
509
+ const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
510
+ const normalizedOptions = typeof options === 'string' ? { startedAt: options } : options;
511
+ const startedAt = normalizedOptions?.startedAt ?? new Date().toISOString();
512
+ schedule.last_started_at = startedAt;
513
+ if (normalizedOptions?.occurrenceKey !== undefined) {
514
+ schedule.last_occurrence_key = normalizedOptions.occurrenceKey;
515
+ }
516
+ schedule.updated_at = startedAt;
517
+ return cloneSchedule(schedule);
518
+ });
519
+ },
520
+ recordFinished(id, options) {
521
+ return withStoreLock(normalizedWorkspacePath, schedules => {
522
+ const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
523
+ const finishedAt = options?.finishedAt ?? new Date().toISOString();
524
+ schedule.last_run = finishedAt;
525
+ schedule.run_count = (schedule.run_count || 0) + 1;
526
+ schedule.next_run = options?.nextRun !== undefined
527
+ ? options.nextRun
528
+ : calculateNextRun(schedule.cron, schedule.timezone);
529
+ if (options?.outcome !== undefined)
530
+ schedule.last_outcome = options.outcome;
531
+ else if (options?.success !== undefined)
532
+ schedule.last_outcome = options.success ? 'success' : 'failure';
533
+ schedule.updated_at = finishedAt;
534
+ return cloneSchedule(schedule);
535
+ });
536
+ },
537
+ };
538
+ }
539
+ function loadSchedules(dir) {
540
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
541
+ }
199
542
  function saveSchedules(schedules, dir) {
200
- ensureDataDir(dir);
201
- const schedulesFile = resolveSchedulesFile(dir);
202
- const tempFile = `${schedulesFile}.tmp`;
203
- fs.writeFileSync(tempFile, JSON.stringify(schedules, null, 2));
204
- fs.renameSync(tempFile, schedulesFile);
543
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
544
+ const lock = acquireWorkspaceLock(workspacePath);
545
+ try {
546
+ const schedulesFile = resolveSchedulesFile(workspacePath);
547
+ readSchedulesFile(schedulesFile);
548
+ writeSchedulesAtomic(schedulesFile, schedules.map(cloneSchedule));
549
+ }
550
+ finally {
551
+ releaseWorkspaceLock(lock);
552
+ }
205
553
  }
206
554
  function loadBuiltinsMeta(dir) {
207
555
  const data = safeReadJsonFile(resolveBuiltinsMetaFile(dir));
@@ -225,7 +573,8 @@ function recordRemovedBuiltin(builtinId, dir) {
225
573
  }
226
574
  }
227
575
  function seedBuiltinSchedules(dir) {
228
- const schedules = loadSchedules(dir);
576
+ const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
577
+ const schedules = store.list();
229
578
  const existingNames = new Set(schedules.map(s => s.name));
230
579
  const removedBuiltins = new Set(getRemovedBuiltins(dir));
231
580
  const seeded = [];
@@ -235,7 +584,7 @@ function seedBuiltinSchedules(dir) {
235
584
  if (removedBuiltins.has(def.builtin_id))
236
585
  continue;
237
586
  const now = new Date().toISOString();
238
- schedules.push({
587
+ const schedule = {
239
588
  id: (0, sdk_1.generateId)('cron'),
240
589
  cron: def.cron,
241
590
  command: def.buildCommand(),
@@ -250,102 +599,207 @@ function seedBuiltinSchedules(dir) {
250
599
  updated_at: now,
251
600
  run_count: 0,
252
601
  next_run: calculateNextRun(def.cron, null),
253
- });
602
+ };
603
+ store.create(schedule);
604
+ existingNames.add(def.name);
254
605
  seeded.push(def.builtin_id);
255
606
  }
256
- if (seeded.length > 0) {
257
- saveSchedules(schedules, dir);
258
- }
259
607
  return seeded;
260
608
  }
261
- async function acquireLock(timeout = 5000) {
262
- ensureDataDir();
263
- const startTime = Date.now();
264
- while (Date.now() - startTime < timeout) {
265
- try {
266
- fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
267
- return true;
268
- }
269
- catch (e) {
270
- const err = e;
271
- if (err.code === 'EEXIST') {
272
- try {
273
- const stat = fs.statSync(LOCK_FILE);
274
- if (Date.now() - stat.mtimeMs > 30000) {
275
- fs.unlinkSync(LOCK_FILE);
276
- continue;
277
- }
278
- }
279
- catch {
280
- continue;
281
- }
282
- await new Promise(resolve => setTimeout(resolve, 100));
283
- }
284
- else {
285
- throw e;
609
+ const legacyLockOwners = new Map();
610
+ async function acquireLock(timeout = STORE_LOCK_TIMEOUT_MS, dir) {
611
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
612
+ try {
613
+ legacyLockOwners.set(workspacePath, acquireWorkspaceLock(workspacePath, timeout));
614
+ return true;
615
+ }
616
+ catch (error) {
617
+ if (error instanceof types_1.CronStoreError && error.code === 'CRON_STORE_LOCK_TIMEOUT')
618
+ return false;
619
+ throw error;
620
+ }
621
+ }
622
+ function releaseLock(dir) {
623
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
624
+ const owner = legacyLockOwners.get(workspacePath);
625
+ if (!owner)
626
+ return;
627
+ legacyLockOwners.delete(workspacePath);
628
+ releaseWorkspaceLock(owner);
629
+ }
630
+ function readRunLease(lockFile) {
631
+ return safeReadJsonFile(lockFile);
632
+ }
633
+ function writeRunLeaseAtomic(lockFile, lease) {
634
+ const directory = path.dirname(lockFile);
635
+ const tempFile = path.join(directory, `.${path.basename(lockFile)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
636
+ let fd;
637
+ try {
638
+ fd = fs.openSync(tempFile, 'wx', 0o600);
639
+ fs.writeFileSync(fd, JSON.stringify(lease));
640
+ fs.fsyncSync(fd);
641
+ fs.closeSync(fd);
642
+ fd = undefined;
643
+ fs.renameSync(tempFile, lockFile);
644
+ }
645
+ finally {
646
+ if (fd !== undefined) {
647
+ try {
648
+ fs.closeSync(fd);
286
649
  }
650
+ catch { }
651
+ }
652
+ try {
653
+ fs.unlinkSync(tempFile);
287
654
  }
655
+ catch { }
288
656
  }
289
- return false;
290
657
  }
291
- function releaseLock() {
658
+ function withRunLeaseStoreLock(workspacePath, scheduleId, operation) {
659
+ const owner = acquireWorkspaceLock(workspacePath);
292
660
  try {
293
- fs.unlinkSync(LOCK_FILE);
661
+ return operation(resolveRunLockFile(scheduleId, workspacePath));
662
+ }
663
+ finally {
664
+ releaseWorkspaceLock(owner);
665
+ }
666
+ }
667
+ function getLeaseAgeMs(lockFile) {
668
+ try {
669
+ return Math.max(0, Date.now() - fs.statSync(lockFile).mtimeMs);
294
670
  }
295
671
  catch {
672
+ return null;
296
673
  }
297
674
  }
298
- function readRunLease(lockFile) {
299
- return safeReadJsonFile(lockFile);
675
+ function compareProcessIdentity(expected, actual) {
676
+ if (expected.startTime !== null && actual.startTime !== null) {
677
+ return expected.startTime === actual.startTime;
678
+ }
679
+ if (expected.commandLine !== null && actual.commandLine !== null) {
680
+ return expected.commandLine === actual.commandLine;
681
+ }
682
+ return null;
683
+ }
684
+ function isRunLeaseStale(lease, lockFile, timeoutMs) {
685
+ if (lease.phase !== 'child') {
686
+ const ageMs = getLeaseAgeMs(lockFile);
687
+ if (ageMs === null)
688
+ return false;
689
+ const ownerPid = lease.hostPid ?? lease.pid;
690
+ const ownerAlive = isPidAlive(ownerPid);
691
+ return ownerAlive === false || ageMs > timeoutMs + RUN_LOCK_STALE_GRACE_MS;
692
+ }
693
+ const childPid = lease.childPid ?? lease.pid;
694
+ const childAlive = isPidAlive(childPid);
695
+ if (childAlive === false)
696
+ return true;
697
+ if (childAlive !== true)
698
+ return false;
699
+ if (!lease.childIdentity)
700
+ return false;
701
+ const currentIdentity = captureProcessIdentity(childPid);
702
+ if (!currentIdentity)
703
+ return false;
704
+ const identityMatch = compareProcessIdentity(lease.childIdentity, currentIdentity);
705
+ if (identityMatch === false)
706
+ return true;
707
+ return false;
300
708
  }
301
709
  function acquireRunLock(scheduleId, taskId, timeoutMs, dir) {
302
- ensureDataDir(dir);
303
- const lockFile = resolveRunLockFile(scheduleId, dir);
304
- const lease = { pid: process.pid, taskId, startedAt: new Date().toISOString() };
305
- for (let attempt = 0; attempt < 2; attempt++) {
306
- try {
307
- fs.writeFileSync(lockFile, JSON.stringify(lease), { flag: 'wx' });
308
- return { acquired: true };
309
- }
310
- catch (e) {
311
- const err = e;
312
- if (err.code !== 'EEXIST')
313
- throw err;
314
- const existing = readRunLease(lockFile);
315
- if (!existing) {
316
- try {
317
- fs.unlinkSync(lockFile);
318
- }
319
- catch { }
320
- continue;
321
- }
322
- let ageMs;
710
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
711
+ ensureDataDir(workspacePath);
712
+ const lockFile = resolveRunLockFile(scheduleId, workspacePath);
713
+ const leaseId = (0, crypto_1.randomUUID)();
714
+ const ownerToken = (0, crypto_1.randomUUID)();
715
+ const lease = {
716
+ leaseId,
717
+ ownerToken,
718
+ phase: 'admitting',
719
+ hostPid: process.pid,
720
+ pid: process.pid,
721
+ taskId,
722
+ startedAt: new Date().toISOString(),
723
+ };
724
+ return withRunLeaseStoreLock(workspacePath, scheduleId, () => {
725
+ for (let attempt = 0; attempt < 2; attempt++) {
323
726
  try {
324
- ageMs = Date.now() - fs.statSync(lockFile).mtimeMs;
727
+ fs.writeFileSync(lockFile, JSON.stringify(lease), { flag: 'wx', mode: 0o600 });
728
+ return {
729
+ acquired: true,
730
+ lease: { scheduleId, workspacePath, lockFile, leaseId, ownerToken, taskId },
731
+ };
325
732
  }
326
- catch {
327
- continue;
328
- }
329
- const stale = !isPidAlive(existing.pid) || ageMs > timeoutMs + RUN_LOCK_STALE_GRACE_MS;
330
- if (stale) {
331
- try {
332
- fs.unlinkSync(lockFile);
733
+ catch (e) {
734
+ const err = e;
735
+ if (err.code !== 'EEXIST')
736
+ throw err;
737
+ const existing = readRunLease(lockFile);
738
+ if (!existing) {
739
+ return { acquired: false, runningTaskId: 'unknown', reason: 'execution_lease' };
333
740
  }
334
- catch { }
335
- continue;
741
+ if (isRunLeaseStale(existing, lockFile, timeoutMs)) {
742
+ const current = readRunLease(lockFile);
743
+ if (current?.leaseId !== undefined && current.leaseId !== existing.leaseId) {
744
+ continue;
745
+ }
746
+ try {
747
+ fs.unlinkSync(lockFile);
748
+ }
749
+ catch { }
750
+ continue;
751
+ }
752
+ return {
753
+ acquired: false,
754
+ runningTaskId: existing.taskId,
755
+ reason: 'execution_lease',
756
+ };
336
757
  }
337
- return { acquired: false, runningTaskId: existing.taskId };
338
758
  }
339
- }
340
- const existing = readRunLease(lockFile);
341
- return { acquired: false, runningTaskId: existing?.taskId ?? taskId };
759
+ const existing = readRunLease(lockFile);
760
+ return {
761
+ acquired: false,
762
+ runningTaskId: existing?.taskId ?? 'unknown',
763
+ reason: 'execution_lease',
764
+ };
765
+ });
342
766
  }
343
- function releaseRunLock(scheduleId, dir) {
344
- try {
345
- fs.unlinkSync(resolveRunLockFile(scheduleId, dir));
346
- }
347
- catch {
348
- }
767
+ function handoffRunLock(lease, childPid, childIdentity) {
768
+ return withRunLeaseStoreLock(lease.workspacePath, lease.scheduleId, lockFile => {
769
+ const current = readRunLease(lockFile);
770
+ if (!current ||
771
+ current.leaseId !== lease.leaseId ||
772
+ current.ownerToken !== lease.ownerToken) {
773
+ return false;
774
+ }
775
+ writeRunLeaseAtomic(lockFile, {
776
+ ...current,
777
+ phase: 'child',
778
+ pid: childPid,
779
+ childPid,
780
+ childIdentity,
781
+ });
782
+ return true;
783
+ });
784
+ }
785
+ function releaseRunLock(scheduleId, leaseId, dir) {
786
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
787
+ ensureDataDir(workspacePath);
788
+ return withRunLeaseStoreLock(workspacePath, scheduleId, lockFile => {
789
+ const current = readRunLease(lockFile);
790
+ if (!current || current.leaseId !== leaseId)
791
+ return false;
792
+ try {
793
+ fs.unlinkSync(lockFile);
794
+ return true;
795
+ }
796
+ catch (error) {
797
+ const err = error;
798
+ if (err.code === 'ENOENT')
799
+ return false;
800
+ throw error;
801
+ }
802
+ });
349
803
  }
350
804
  const DAY_NAMES = {
351
805
  SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
@@ -622,6 +1076,30 @@ function getProcessCommandLine(pid) {
622
1076
  return null;
623
1077
  }
624
1078
  }
1079
+ function getProcessStartTime(pid) {
1080
+ try {
1081
+ const output = process.platform === 'win32'
1082
+ ? (0, child_process_1.execFileSync)('powershell.exe', [
1083
+ '-NoLogo',
1084
+ '-NoProfile',
1085
+ '-NonInteractive',
1086
+ '-Command',
1087
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CreationDate.ToUniversalTime().ToString("o")`,
1088
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
1089
+ : (0, child_process_1.execFileSync)('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
1090
+ return output.trim() || null;
1091
+ }
1092
+ catch {
1093
+ return null;
1094
+ }
1095
+ }
1096
+ function captureProcessIdentity(pid) {
1097
+ const identity = {
1098
+ commandLine: getProcessCommandLine(pid),
1099
+ startTime: getProcessStartTime(pid),
1100
+ };
1101
+ return identity.commandLine !== null || identity.startTime !== null ? identity : null;
1102
+ }
625
1103
  const DEFAULT_DAEMON_PROCESS_PROBE = {
626
1104
  isAlive: isPidAlive,
627
1105
  getCommandLine: getProcessCommandLine,
@@ -769,7 +1247,7 @@ function ensureDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
769
1247
  }
770
1248
  }
771
1249
  function getDaemonStatus(dir) {
772
- const schedules = loadSchedules(dir);
1250
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
773
1251
  const enabled = schedules.filter(s => s.enabled).length;
774
1252
  const running = isDaemonRunning(dir);
775
1253
  const pid = running ? readDaemonPid(dir) : null;
@@ -795,19 +1273,28 @@ function getDaemonStatus(dir) {
795
1273
  }
796
1274
  async function executeSchedule(schedule, options) {
797
1275
  const dir = options?.dir;
1276
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
798
1277
  const trigger = options?.trigger ?? 'cron';
799
1278
  const timeoutMs = schedule.timeout_ms || 600000;
800
1279
  const taskId = (0, sdk_1.generateId)('tsk');
1280
+ let runLease;
801
1281
  if (!schedule.allow_concurrent) {
802
1282
  const lock = acquireRunLock(schedule.id, taskId, timeoutMs, dir);
803
1283
  if (!lock.acquired) {
804
1284
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} already running (task ${lock.runningTaskId}), skipping`);
805
- return { status: 'conflict', runningTaskId: lock.runningTaskId };
1285
+ return {
1286
+ status: 'conflict',
1287
+ runningTaskId: lock.runningTaskId,
1288
+ reason: lock.reason,
1289
+ };
806
1290
  }
1291
+ runLease = lock.lease;
807
1292
  }
808
1293
  const releaseLease = () => {
809
- if (!schedule.allow_concurrent)
810
- releaseRunLock(schedule.id, dir);
1294
+ if (runLease) {
1295
+ releaseRunLock(schedule.id, runLease.leaseId, dir);
1296
+ runLease = undefined;
1297
+ }
811
1298
  releaseAdmissionReservation(taskId);
812
1299
  };
813
1300
  if (!reserveAdmission(taskId, schedule.id, timeoutMs)) {
@@ -820,6 +1307,34 @@ async function executeSchedule(schedule, options) {
820
1307
  releaseLease();
821
1308
  return { status: 'blocked', reason: 'UPDATE_QUIESCING' };
822
1309
  }
1310
+ try {
1311
+ const store = createScheduleStore(workspacePath);
1312
+ const startedAt = new Date().toISOString();
1313
+ if (options?.occurrenceKey !== undefined) {
1314
+ const claim = store.claimOccurrence(schedule.id, {
1315
+ occurrenceKey: options.occurrenceKey,
1316
+ startedAt,
1317
+ });
1318
+ if (!claim.claimed) {
1319
+ releaseLease();
1320
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} occurrence ${options.occurrenceKey} already claimed, skipping`);
1321
+ return {
1322
+ status: 'conflict',
1323
+ runningTaskId: '',
1324
+ reason: 'occurrence_claim',
1325
+ };
1326
+ }
1327
+ }
1328
+ else {
1329
+ if (store.get(schedule.id)) {
1330
+ store.recordStarted(schedule.id, { startedAt });
1331
+ }
1332
+ }
1333
+ }
1334
+ catch (error) {
1335
+ releaseLease();
1336
+ throw error;
1337
+ }
823
1338
  const workingDir = schedule.working_directory || options?.dir || (0, builtins_1.getProjectRoot)();
824
1339
  const mode = schedule.mode;
825
1340
  const modeFlag = mode === 'execute' ? 'x' : 'q';
@@ -893,9 +1408,27 @@ async function executeSchedule(schedule, options) {
893
1408
  releaseLease();
894
1409
  throw err;
895
1410
  }
1411
+ if (runLease && child.pid) {
1412
+ const handedOff = handoffRunLock(runLease, child.pid, captureProcessIdentity(child.pid));
1413
+ if (!handedOff) {
1414
+ const error = new Error('Cron execution lease was replaced before child ownership was established');
1415
+ console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed to hand off its execution lease: ${error.message}`);
1416
+ try {
1417
+ child.kill('SIGTERM');
1418
+ }
1419
+ catch {
1420
+ }
1421
+ releaseLease();
1422
+ throw error;
1423
+ }
1424
+ }
896
1425
  child.unref();
897
1426
  const done = new Promise((resolve) => {
1427
+ let timedOut = false;
1428
+ let settled = false;
1429
+ let graceTimer;
898
1430
  const timeout = setTimeout(() => {
1431
+ timedOut = true;
899
1432
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} timeout, killing process`);
900
1433
  try {
901
1434
  child.kill('SIGTERM');
@@ -904,35 +1437,69 @@ async function executeSchedule(schedule, options) {
904
1437
  const err = e;
905
1438
  console.error(`Failed to kill process: ${err.message}`);
906
1439
  }
907
- releaseLease();
908
- resolve({ success: false, error: 'Timeout' });
1440
+ graceTimer = setTimeout(() => {
1441
+ try {
1442
+ child.kill('SIGKILL');
1443
+ }
1444
+ catch {
1445
+ }
1446
+ }, RUN_LOCK_STALE_GRACE_MS);
1447
+ if (typeof graceTimer === 'object' && graceTimer !== null && 'unref' in graceTimer) {
1448
+ graceTimer.unref();
1449
+ }
1450
+ if (!settled) {
1451
+ settled = true;
1452
+ resolve({ success: false, error: 'Timeout' });
1453
+ }
909
1454
  }, timeoutMs);
910
1455
  child.on('close', (code) => {
911
1456
  clearTimeout(timeout);
1457
+ if (graceTimer)
1458
+ clearTimeout(graceTimer);
912
1459
  releaseLease();
913
- const schedules = loadSchedules(dir);
914
- const idx = schedules.findIndex(s => s.id === schedule.id);
915
- if (idx !== -1) {
916
- const s = schedules[idx];
917
- s.last_run = new Date().toISOString();
918
- s.run_count = (s.run_count || 0) + 1;
919
- s.next_run = calculateNextRun(schedule.cron, schedule.timezone);
920
- saveSchedules(schedules, dir);
1460
+ const finishedAt = new Date().toISOString();
1461
+ const outcome = timedOut ? 'timeout' : code === 0 ? 'success' : 'failure';
1462
+ try {
1463
+ createScheduleStore(workspacePath).recordFinished(schedule.id, {
1464
+ finishedAt,
1465
+ outcome,
1466
+ });
921
1467
  }
922
- if (code === 0) {
1468
+ catch (error) {
1469
+ const err = error;
1470
+ if (!(error instanceof types_1.CronStoreError && error.code === 'CRON_SCHEDULE_NOT_FOUND')) {
1471
+ console.error(`[${getLocalTimestamp()}] Failed to record schedule ${schedule.id} completion: ${err.message}`);
1472
+ }
1473
+ }
1474
+ if (timedOut) {
1475
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} terminated after timeout`);
1476
+ if (!settled) {
1477
+ settled = true;
1478
+ resolve({ success: false, error: 'Timeout' });
1479
+ }
1480
+ }
1481
+ else if (code === 0) {
923
1482
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} completed successfully`);
924
- resolve({ success: true });
1483
+ if (!settled) {
1484
+ settled = true;
1485
+ resolve({ success: true });
1486
+ }
925
1487
  }
926
1488
  else {
927
1489
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed with code ${code}`);
928
- resolve({ success: false, error: `Exit code: ${code}` });
1490
+ if (!settled) {
1491
+ settled = true;
1492
+ resolve({ success: false, error: `Exit code: ${code}` });
1493
+ }
929
1494
  }
930
1495
  });
931
1496
  child.on('error', (err) => {
932
1497
  clearTimeout(timeout);
933
- releaseLease();
934
1498
  console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} error: ${err.message}`);
935
- resolve({ success: false, error: err.message });
1499
+ if (!settled) {
1500
+ settled = true;
1501
+ resolve({ success: false, error: err.message });
1502
+ }
936
1503
  });
937
1504
  });
938
1505
  return { status: 'running', taskId, done };
@@ -948,13 +1515,16 @@ function getLocalMinuteKey(now, timezone) {
948
1515
  });
949
1516
  return fmt.format(now);
950
1517
  }
1518
+ function buildOccurrenceKey(cronExpression, scheduledMinute) {
1519
+ return `${cronExpression.trim()}|${scheduledMinute}`;
1520
+ }
951
1521
  async function daemonTick(dir) {
952
1522
  if ((0, sdk_1.isDurablyQuiescing)()) {
953
1523
  console.log(`[${getLocalTimestamp()}] Daemon tick skipped: a desktop update is draining active work`);
954
1524
  return;
955
1525
  }
956
1526
  const now = new Date();
957
- const schedules = loadSchedules(dir);
1527
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
958
1528
  const enabledSchedules = schedules.filter(s => s.enabled);
959
1529
  if (enabledSchedules.length === 0) {
960
1530
  return;
@@ -962,17 +1532,9 @@ async function daemonTick(dir) {
962
1532
  for (const schedule of enabledSchedules) {
963
1533
  if (shouldRunNow(schedule.cron, now, schedule.timezone)) {
964
1534
  const minuteKey = getLocalMinuteKey(now, schedule.timezone);
965
- if (schedule.last_run) {
966
- const lastRunMinuteKey = getLocalMinuteKey(new Date(schedule.last_run), schedule.timezone);
967
- if (lastRunMinuteKey === minuteKey) {
968
- continue;
969
- }
970
- }
1535
+ const occurrenceKey = buildOccurrenceKey(schedule.cron, minuteKey);
971
1536
  try {
972
- const outcome = await executeSchedule(schedule, { dir });
973
- if (outcome.status === 'running') {
974
- await outcome.done;
975
- }
1537
+ await executeSchedule(schedule, { dir, occurrenceKey });
976
1538
  }
977
1539
  catch (e) {
978
1540
  const err = e;
@@ -981,6 +1543,9 @@ async function daemonTick(dir) {
981
1543
  }
982
1544
  }
983
1545
  }
1546
+ var runtime_1 = require("./runtime");
1547
+ Object.defineProperty(exports, "createCronRuntime", { enumerable: true, get: function () { return runtime_1.createCronRuntime; } });
1548
+ Object.defineProperty(exports, "CRON_RUNTIME_PROCESS_LEASE_FILENAME", { enumerable: true, get: function () { return runtime_1.CRON_RUNTIME_PROCESS_LEASE_FILENAME; } });
984
1549
  function runDaemonForeground(dir) {
985
1550
  writeDaemonPid(process.pid, dir);
986
1551
  let cleanedUp = false;
@@ -1005,7 +1570,7 @@ function runDaemonForeground(dir) {
1005
1570
  console.log(`[${getLocalTimestamp()}] Cron daemon starting in foreground mode`);
1006
1571
  console.log(`[${getLocalTimestamp()}] Check interval: 1 minute`);
1007
1572
  console.log(`[${getLocalTimestamp()}] Press Ctrl+C to stop\n`);
1008
- const schedules = loadSchedules(dir);
1573
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
1009
1574
  const enabled = schedules.filter(s => s.enabled).length;
1010
1575
  console.log(`[${getLocalTimestamp()}] Active schedules: ${enabled}/${schedules.length}\n`);
1011
1576
  void daemonTick(dir);
@@ -1040,19 +1605,10 @@ function addSchedule(cron, command, options, dataDir) {
1040
1605
  schedule.provider = options.provider;
1041
1606
  }
1042
1607
  schedule.next_run = calculateNextRun(cron, schedule.timezone);
1043
- const schedules = loadSchedules(dataDir);
1044
- schedules.push(schedule);
1045
- saveSchedules(schedules, dataDir);
1046
- return schedule;
1608
+ return createScheduleStore(dataDir ?? DEFAULT_WORKSPACE_PATH).create(schedule);
1047
1609
  }
1048
1610
  function removeSchedule(id, dir) {
1049
- const schedules = loadSchedules(dir);
1050
- const idx = schedules.findIndex(s => s.id === id);
1051
- if (idx === -1) {
1052
- throw new Error(`Schedule not found: ${id}`);
1053
- }
1054
- const removed = schedules.splice(idx, 1)[0];
1055
- saveSchedules(schedules, dir);
1611
+ const removed = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).remove(id);
1056
1612
  const matchedBuiltin = (0, builtins_1.findBuiltinByName)(removed.name);
1057
1613
  if (matchedBuiltin) {
1058
1614
  recordRemovedBuiltin(matchedBuiltin.builtin_id, dir);
@@ -1060,100 +1616,82 @@ function removeSchedule(id, dir) {
1060
1616
  return removed;
1061
1617
  }
1062
1618
  function enableSchedule(id, dir) {
1063
- const schedules = loadSchedules(dir);
1064
- const schedule = schedules.find(s => s.id === id);
1065
- if (!schedule) {
1066
- throw new Error(`Schedule not found: ${id}`);
1067
- }
1068
- schedule.enabled = true;
1069
- schedule.updated_at = new Date().toISOString();
1070
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
1071
- saveSchedules(schedules, dir);
1072
- return schedule;
1619
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, true);
1073
1620
  }
1074
1621
  function disableSchedule(id, dir) {
1075
- const schedules = loadSchedules(dir);
1076
- const schedule = schedules.find(s => s.id === id);
1077
- if (!schedule) {
1078
- throw new Error(`Schedule not found: ${id}`);
1079
- }
1080
- schedule.enabled = false;
1081
- schedule.updated_at = new Date().toISOString();
1082
- saveSchedules(schedules, dir);
1083
- return schedule;
1622
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, false);
1084
1623
  }
1085
1624
  function updateSchedule(id, options, dir) {
1086
- const schedules = loadSchedules(dir);
1087
- const schedule = schedules.find(s => s.id === id);
1088
- if (!schedule) {
1089
- throw new Error(`Schedule not found: ${id}`);
1090
- }
1091
1625
  if (options.timezone !== undefined && options.timezone !== null && !isValidTimezone(options.timezone)) {
1092
1626
  throw new Error(`Invalid timezone: ${options.timezone}`);
1093
1627
  }
1628
+ const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
1629
+ const current = store.get(id);
1630
+ if (!current) {
1631
+ throw new Error(`Schedule not found: ${id}`);
1632
+ }
1094
1633
  const changes = [];
1634
+ const patch = {};
1095
1635
  if (options.cron !== undefined) {
1096
- schedule.cron = options.cron;
1636
+ patch.cron = options.cron;
1097
1637
  changes.push(`cron: ${options.cron}`);
1098
1638
  }
1099
1639
  if (options.command !== undefined) {
1100
1640
  warnIfOverdriveInCommand(options.command);
1101
- schedule.command = options.command;
1641
+ patch.command = options.command;
1102
1642
  changes.push(`command: ${options.command}`);
1103
1643
  }
1104
1644
  if (options.name !== undefined) {
1105
- schedule.name = options.name;
1645
+ patch.name = options.name;
1106
1646
  changes.push(`name: ${options.name}`);
1107
1647
  }
1108
1648
  if (options.mode !== undefined) {
1109
- schedule.mode = normalizeMode(options.mode);
1110
- changes.push(`mode: ${schedule.mode}`);
1649
+ patch.mode = normalizeMode(options.mode);
1650
+ changes.push(`mode: ${patch.mode}`);
1111
1651
  }
1112
1652
  if (options.timezone !== undefined) {
1113
- schedule.timezone = options.timezone;
1653
+ patch.timezone = options.timezone;
1114
1654
  changes.push(`timezone: ${options.timezone}`);
1115
1655
  }
1116
1656
  if (options.working_directory !== undefined) {
1117
- schedule.working_directory = options.working_directory;
1657
+ patch.working_directory = options.working_directory;
1118
1658
  changes.push(`working_directory: ${options.working_directory}`);
1119
1659
  }
1120
1660
  if (options.timeout_ms !== undefined) {
1121
- schedule.timeout_ms = options.timeout_ms;
1661
+ patch.timeout_ms = options.timeout_ms;
1122
1662
  changes.push(`timeout: ${options.timeout_ms}ms`);
1123
1663
  }
1124
1664
  if (options.allow_concurrent !== undefined) {
1125
- schedule.allow_concurrent = options.allow_concurrent;
1665
+ patch.allow_concurrent = options.allow_concurrent;
1126
1666
  changes.push(`allow_concurrent: ${options.allow_concurrent}`);
1127
1667
  }
1128
1668
  if (options.model !== undefined) {
1129
1669
  if (options.model === null) {
1130
- delete schedule.model;
1670
+ patch.model = null;
1131
1671
  changes.push('model: (cleared, reverts to agent default)');
1132
1672
  }
1133
1673
  else {
1134
- schedule.model = options.model;
1674
+ patch.model = options.model;
1135
1675
  changes.push(`model: ${options.model}`);
1136
1676
  }
1137
1677
  }
1138
1678
  if (options.provider !== undefined) {
1139
1679
  if (options.provider === null) {
1140
- delete schedule.provider;
1680
+ patch.provider = null;
1141
1681
  changes.push('provider: (cleared, reverts to agent default)');
1142
1682
  }
1143
1683
  else {
1144
- schedule.provider = options.provider;
1684
+ patch.provider = options.provider;
1145
1685
  changes.push(`provider: ${options.provider}`);
1146
1686
  }
1147
1687
  }
1148
- schedule.updated_at = new Date().toISOString();
1149
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
1150
- saveSchedules(schedules, dir);
1151
- return { schedule, changes };
1688
+ return { schedule: store.update(current.id, patch), changes };
1152
1689
  }
1153
1690
  exports.DATA_PATHS = {
1154
1691
  DATA_DIR,
1155
1692
  SCHEDULES_FILE,
1156
1693
  LOG_FILE,
1694
+ LOCK_FILE,
1157
1695
  USAGE_LOG_FILE,
1158
1696
  PID_FILE,
1159
1697
  BUILTINS_META_FILE,