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

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 {
346
+ }
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);
197
395
  }
198
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 { }
656
+ }
657
+ }
658
+ function withRunLeaseStoreLock(workspacePath, scheduleId, operation) {
659
+ const owner = acquireWorkspaceLock(workspacePath);
660
+ try {
661
+ return operation(resolveRunLockFile(scheduleId, workspacePath));
662
+ }
663
+ finally {
664
+ releaseWorkspaceLock(owner);
288
665
  }
289
- return false;
290
666
  }
291
- function releaseLock() {
667
+ function getLeaseAgeMs(lockFile) {
292
668
  try {
293
- fs.unlinkSync(LOCK_FILE);
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;
325
- }
326
- catch {
327
- continue;
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
+ };
328
732
  }
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,
@@ -702,11 +1180,16 @@ function spawnDaemon(dir, spawnImpl = child_process_1.spawn) {
702
1180
  }
703
1181
  ensureDataDir(dir);
704
1182
  const cliPath = getDaemonCliPath();
705
- const child = spawnImpl('node', [cliPath, 'daemon', '--foreground'], {
1183
+ const daemonEnv = { ...process.env };
1184
+ if (process.versions.electron) {
1185
+ daemonEnv.ELECTRON_RUN_AS_NODE = '1';
1186
+ }
1187
+ const child = spawnImpl(process.execPath, [cliPath, 'daemon', '--foreground'], {
706
1188
  cwd: dir || undefined,
707
1189
  detached: true,
708
1190
  stdio: 'ignore',
709
1191
  windowsHide: true,
1192
+ env: daemonEnv,
710
1193
  });
711
1194
  if (!child.pid) {
712
1195
  throw new Error('Failed to start daemon: no PID returned');
@@ -769,7 +1252,7 @@ function ensureDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
769
1252
  }
770
1253
  }
771
1254
  function getDaemonStatus(dir) {
772
- const schedules = loadSchedules(dir);
1255
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
773
1256
  const enabled = schedules.filter(s => s.enabled).length;
774
1257
  const running = isDaemonRunning(dir);
775
1258
  const pid = running ? readDaemonPid(dir) : null;
@@ -795,19 +1278,28 @@ function getDaemonStatus(dir) {
795
1278
  }
796
1279
  async function executeSchedule(schedule, options) {
797
1280
  const dir = options?.dir;
1281
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
798
1282
  const trigger = options?.trigger ?? 'cron';
799
1283
  const timeoutMs = schedule.timeout_ms || 600000;
800
1284
  const taskId = (0, sdk_1.generateId)('tsk');
1285
+ let runLease;
801
1286
  if (!schedule.allow_concurrent) {
802
1287
  const lock = acquireRunLock(schedule.id, taskId, timeoutMs, dir);
803
1288
  if (!lock.acquired) {
804
1289
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} already running (task ${lock.runningTaskId}), skipping`);
805
- return { status: 'conflict', runningTaskId: lock.runningTaskId };
1290
+ return {
1291
+ status: 'conflict',
1292
+ runningTaskId: lock.runningTaskId,
1293
+ reason: lock.reason,
1294
+ };
806
1295
  }
1296
+ runLease = lock.lease;
807
1297
  }
808
1298
  const releaseLease = () => {
809
- if (!schedule.allow_concurrent)
810
- releaseRunLock(schedule.id, dir);
1299
+ if (runLease) {
1300
+ releaseRunLock(schedule.id, runLease.leaseId, dir);
1301
+ runLease = undefined;
1302
+ }
811
1303
  releaseAdmissionReservation(taskId);
812
1304
  };
813
1305
  if (!reserveAdmission(taskId, schedule.id, timeoutMs)) {
@@ -820,6 +1312,34 @@ async function executeSchedule(schedule, options) {
820
1312
  releaseLease();
821
1313
  return { status: 'blocked', reason: 'UPDATE_QUIESCING' };
822
1314
  }
1315
+ try {
1316
+ const store = createScheduleStore(workspacePath);
1317
+ const startedAt = new Date().toISOString();
1318
+ if (options?.occurrenceKey !== undefined) {
1319
+ const claim = store.claimOccurrence(schedule.id, {
1320
+ occurrenceKey: options.occurrenceKey,
1321
+ startedAt,
1322
+ });
1323
+ if (!claim.claimed) {
1324
+ releaseLease();
1325
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} occurrence ${options.occurrenceKey} already claimed, skipping`);
1326
+ return {
1327
+ status: 'conflict',
1328
+ runningTaskId: '',
1329
+ reason: 'occurrence_claim',
1330
+ };
1331
+ }
1332
+ }
1333
+ else {
1334
+ if (store.get(schedule.id)) {
1335
+ store.recordStarted(schedule.id, { startedAt });
1336
+ }
1337
+ }
1338
+ }
1339
+ catch (error) {
1340
+ releaseLease();
1341
+ throw error;
1342
+ }
823
1343
  const workingDir = schedule.working_directory || options?.dir || (0, builtins_1.getProjectRoot)();
824
1344
  const mode = schedule.mode;
825
1345
  const modeFlag = mode === 'execute' ? 'x' : 'q';
@@ -859,18 +1379,16 @@ async function executeSchedule(schedule, options) {
859
1379
  Object.assign(error, { code: 'CLI_UNRESOLVABLE' });
860
1380
  throw error;
861
1381
  }
862
- const childEnv = {
863
- ...env,
864
- CREWX_CLI_ARGV: JSON.stringify(resolution.argv),
865
- };
1382
+ const childInvocation = (0, sdk_1.createCrewxChildInvocation)(resolution, { baseEnv: env });
866
1383
  const program = (0, sdk_1.resolveWindowsSpawnProgram)({
867
- command: resolution.argv[0],
868
- env: childEnv,
1384
+ command: childInvocation.command,
1385
+ env: childInvocation.env,
869
1386
  cwd: workingDir,
870
1387
  packageName: 'crewx',
1388
+ allowShellFallback: true,
871
1389
  });
872
1390
  const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, [
873
- ...resolution.argv.slice(1),
1391
+ ...childInvocation.args,
874
1392
  ...cliArgs,
875
1393
  ]);
876
1394
  child = (0, child_process_1.spawn)(invocation.command, invocation.argv, {
@@ -878,7 +1396,7 @@ async function executeSchedule(schedule, options) {
878
1396
  stdio: 'ignore',
879
1397
  windowsHide: invocation.windowsHide,
880
1398
  detached: true,
881
- env: childEnv,
1399
+ env: childInvocation.env,
882
1400
  shell: invocation.shell ?? false,
883
1401
  });
884
1402
  }
@@ -893,9 +1411,27 @@ async function executeSchedule(schedule, options) {
893
1411
  releaseLease();
894
1412
  throw err;
895
1413
  }
1414
+ if (runLease && child.pid) {
1415
+ const handedOff = handoffRunLock(runLease, child.pid, captureProcessIdentity(child.pid));
1416
+ if (!handedOff) {
1417
+ const error = new Error('Cron execution lease was replaced before child ownership was established');
1418
+ console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed to hand off its execution lease: ${error.message}`);
1419
+ try {
1420
+ child.kill('SIGTERM');
1421
+ }
1422
+ catch {
1423
+ }
1424
+ releaseLease();
1425
+ throw error;
1426
+ }
1427
+ }
896
1428
  child.unref();
897
1429
  const done = new Promise((resolve) => {
1430
+ let timedOut = false;
1431
+ let settled = false;
1432
+ let graceTimer;
898
1433
  const timeout = setTimeout(() => {
1434
+ timedOut = true;
899
1435
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} timeout, killing process`);
900
1436
  try {
901
1437
  child.kill('SIGTERM');
@@ -904,35 +1440,69 @@ async function executeSchedule(schedule, options) {
904
1440
  const err = e;
905
1441
  console.error(`Failed to kill process: ${err.message}`);
906
1442
  }
907
- releaseLease();
908
- resolve({ success: false, error: 'Timeout' });
1443
+ graceTimer = setTimeout(() => {
1444
+ try {
1445
+ child.kill('SIGKILL');
1446
+ }
1447
+ catch {
1448
+ }
1449
+ }, RUN_LOCK_STALE_GRACE_MS);
1450
+ if (typeof graceTimer === 'object' && graceTimer !== null && 'unref' in graceTimer) {
1451
+ graceTimer.unref();
1452
+ }
1453
+ if (!settled) {
1454
+ settled = true;
1455
+ resolve({ success: false, error: 'Timeout' });
1456
+ }
909
1457
  }, timeoutMs);
910
1458
  child.on('close', (code) => {
911
1459
  clearTimeout(timeout);
1460
+ if (graceTimer)
1461
+ clearTimeout(graceTimer);
912
1462
  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);
1463
+ const finishedAt = new Date().toISOString();
1464
+ const outcome = timedOut ? 'timeout' : code === 0 ? 'success' : 'failure';
1465
+ try {
1466
+ createScheduleStore(workspacePath).recordFinished(schedule.id, {
1467
+ finishedAt,
1468
+ outcome,
1469
+ });
1470
+ }
1471
+ catch (error) {
1472
+ const err = error;
1473
+ if (!(error instanceof types_1.CronStoreError && error.code === 'CRON_SCHEDULE_NOT_FOUND')) {
1474
+ console.error(`[${getLocalTimestamp()}] Failed to record schedule ${schedule.id} completion: ${err.message}`);
1475
+ }
1476
+ }
1477
+ if (timedOut) {
1478
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} terminated after timeout`);
1479
+ if (!settled) {
1480
+ settled = true;
1481
+ resolve({ success: false, error: 'Timeout' });
1482
+ }
921
1483
  }
922
- if (code === 0) {
1484
+ else if (code === 0) {
923
1485
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} completed successfully`);
924
- resolve({ success: true });
1486
+ if (!settled) {
1487
+ settled = true;
1488
+ resolve({ success: true });
1489
+ }
925
1490
  }
926
1491
  else {
927
1492
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed with code ${code}`);
928
- resolve({ success: false, error: `Exit code: ${code}` });
1493
+ if (!settled) {
1494
+ settled = true;
1495
+ resolve({ success: false, error: `Exit code: ${code}` });
1496
+ }
929
1497
  }
930
1498
  });
931
1499
  child.on('error', (err) => {
932
1500
  clearTimeout(timeout);
933
- releaseLease();
934
1501
  console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} error: ${err.message}`);
935
- resolve({ success: false, error: err.message });
1502
+ if (!settled) {
1503
+ settled = true;
1504
+ resolve({ success: false, error: err.message });
1505
+ }
936
1506
  });
937
1507
  });
938
1508
  return { status: 'running', taskId, done };
@@ -948,13 +1518,16 @@ function getLocalMinuteKey(now, timezone) {
948
1518
  });
949
1519
  return fmt.format(now);
950
1520
  }
1521
+ function buildOccurrenceKey(cronExpression, scheduledMinute) {
1522
+ return `${cronExpression.trim()}|${scheduledMinute}`;
1523
+ }
951
1524
  async function daemonTick(dir) {
952
1525
  if ((0, sdk_1.isDurablyQuiescing)()) {
953
1526
  console.log(`[${getLocalTimestamp()}] Daemon tick skipped: a desktop update is draining active work`);
954
1527
  return;
955
1528
  }
956
1529
  const now = new Date();
957
- const schedules = loadSchedules(dir);
1530
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
958
1531
  const enabledSchedules = schedules.filter(s => s.enabled);
959
1532
  if (enabledSchedules.length === 0) {
960
1533
  return;
@@ -962,17 +1535,9 @@ async function daemonTick(dir) {
962
1535
  for (const schedule of enabledSchedules) {
963
1536
  if (shouldRunNow(schedule.cron, now, schedule.timezone)) {
964
1537
  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
- }
1538
+ const occurrenceKey = buildOccurrenceKey(schedule.cron, minuteKey);
971
1539
  try {
972
- const outcome = await executeSchedule(schedule, { dir });
973
- if (outcome.status === 'running') {
974
- await outcome.done;
975
- }
1540
+ await executeSchedule(schedule, { dir, occurrenceKey });
976
1541
  }
977
1542
  catch (e) {
978
1543
  const err = e;
@@ -981,6 +1546,9 @@ async function daemonTick(dir) {
981
1546
  }
982
1547
  }
983
1548
  }
1549
+ var runtime_1 = require("./runtime");
1550
+ Object.defineProperty(exports, "createCronRuntime", { enumerable: true, get: function () { return runtime_1.createCronRuntime; } });
1551
+ Object.defineProperty(exports, "CRON_RUNTIME_PROCESS_LEASE_FILENAME", { enumerable: true, get: function () { return runtime_1.CRON_RUNTIME_PROCESS_LEASE_FILENAME; } });
984
1552
  function runDaemonForeground(dir) {
985
1553
  writeDaemonPid(process.pid, dir);
986
1554
  let cleanedUp = false;
@@ -1005,7 +1573,7 @@ function runDaemonForeground(dir) {
1005
1573
  console.log(`[${getLocalTimestamp()}] Cron daemon starting in foreground mode`);
1006
1574
  console.log(`[${getLocalTimestamp()}] Check interval: 1 minute`);
1007
1575
  console.log(`[${getLocalTimestamp()}] Press Ctrl+C to stop\n`);
1008
- const schedules = loadSchedules(dir);
1576
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
1009
1577
  const enabled = schedules.filter(s => s.enabled).length;
1010
1578
  console.log(`[${getLocalTimestamp()}] Active schedules: ${enabled}/${schedules.length}\n`);
1011
1579
  void daemonTick(dir);
@@ -1040,19 +1608,10 @@ function addSchedule(cron, command, options, dataDir) {
1040
1608
  schedule.provider = options.provider;
1041
1609
  }
1042
1610
  schedule.next_run = calculateNextRun(cron, schedule.timezone);
1043
- const schedules = loadSchedules(dataDir);
1044
- schedules.push(schedule);
1045
- saveSchedules(schedules, dataDir);
1046
- return schedule;
1611
+ return createScheduleStore(dataDir ?? DEFAULT_WORKSPACE_PATH).create(schedule);
1047
1612
  }
1048
1613
  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);
1614
+ const removed = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).remove(id);
1056
1615
  const matchedBuiltin = (0, builtins_1.findBuiltinByName)(removed.name);
1057
1616
  if (matchedBuiltin) {
1058
1617
  recordRemovedBuiltin(matchedBuiltin.builtin_id, dir);
@@ -1060,100 +1619,82 @@ function removeSchedule(id, dir) {
1060
1619
  return removed;
1061
1620
  }
1062
1621
  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;
1622
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, true);
1073
1623
  }
1074
1624
  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;
1625
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, false);
1084
1626
  }
1085
1627
  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
1628
  if (options.timezone !== undefined && options.timezone !== null && !isValidTimezone(options.timezone)) {
1092
1629
  throw new Error(`Invalid timezone: ${options.timezone}`);
1093
1630
  }
1631
+ const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
1632
+ const current = store.get(id);
1633
+ if (!current) {
1634
+ throw new Error(`Schedule not found: ${id}`);
1635
+ }
1094
1636
  const changes = [];
1637
+ const patch = {};
1095
1638
  if (options.cron !== undefined) {
1096
- schedule.cron = options.cron;
1639
+ patch.cron = options.cron;
1097
1640
  changes.push(`cron: ${options.cron}`);
1098
1641
  }
1099
1642
  if (options.command !== undefined) {
1100
1643
  warnIfOverdriveInCommand(options.command);
1101
- schedule.command = options.command;
1644
+ patch.command = options.command;
1102
1645
  changes.push(`command: ${options.command}`);
1103
1646
  }
1104
1647
  if (options.name !== undefined) {
1105
- schedule.name = options.name;
1648
+ patch.name = options.name;
1106
1649
  changes.push(`name: ${options.name}`);
1107
1650
  }
1108
1651
  if (options.mode !== undefined) {
1109
- schedule.mode = normalizeMode(options.mode);
1110
- changes.push(`mode: ${schedule.mode}`);
1652
+ patch.mode = normalizeMode(options.mode);
1653
+ changes.push(`mode: ${patch.mode}`);
1111
1654
  }
1112
1655
  if (options.timezone !== undefined) {
1113
- schedule.timezone = options.timezone;
1656
+ patch.timezone = options.timezone;
1114
1657
  changes.push(`timezone: ${options.timezone}`);
1115
1658
  }
1116
1659
  if (options.working_directory !== undefined) {
1117
- schedule.working_directory = options.working_directory;
1660
+ patch.working_directory = options.working_directory;
1118
1661
  changes.push(`working_directory: ${options.working_directory}`);
1119
1662
  }
1120
1663
  if (options.timeout_ms !== undefined) {
1121
- schedule.timeout_ms = options.timeout_ms;
1664
+ patch.timeout_ms = options.timeout_ms;
1122
1665
  changes.push(`timeout: ${options.timeout_ms}ms`);
1123
1666
  }
1124
1667
  if (options.allow_concurrent !== undefined) {
1125
- schedule.allow_concurrent = options.allow_concurrent;
1668
+ patch.allow_concurrent = options.allow_concurrent;
1126
1669
  changes.push(`allow_concurrent: ${options.allow_concurrent}`);
1127
1670
  }
1128
1671
  if (options.model !== undefined) {
1129
1672
  if (options.model === null) {
1130
- delete schedule.model;
1673
+ patch.model = null;
1131
1674
  changes.push('model: (cleared, reverts to agent default)');
1132
1675
  }
1133
1676
  else {
1134
- schedule.model = options.model;
1677
+ patch.model = options.model;
1135
1678
  changes.push(`model: ${options.model}`);
1136
1679
  }
1137
1680
  }
1138
1681
  if (options.provider !== undefined) {
1139
1682
  if (options.provider === null) {
1140
- delete schedule.provider;
1683
+ patch.provider = null;
1141
1684
  changes.push('provider: (cleared, reverts to agent default)');
1142
1685
  }
1143
1686
  else {
1144
- schedule.provider = options.provider;
1687
+ patch.provider = options.provider;
1145
1688
  changes.push(`provider: ${options.provider}`);
1146
1689
  }
1147
1690
  }
1148
- schedule.updated_at = new Date().toISOString();
1149
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
1150
- saveSchedules(schedules, dir);
1151
- return { schedule, changes };
1691
+ return { schedule: store.update(current.id, patch), changes };
1152
1692
  }
1153
1693
  exports.DATA_PATHS = {
1154
1694
  DATA_DIR,
1155
1695
  SCHEDULES_FILE,
1156
1696
  LOG_FILE,
1697
+ LOCK_FILE,
1157
1698
  USAGE_LOG_FILE,
1158
1699
  PID_FILE,
1159
1700
  BUILTINS_META_FILE,