@crewx/cron 0.1.10-rc.15 → 0.1.10-rc.150

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,24 +33,33 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.DATA_PATHS = 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.getProjectRoot = getProjectRoot;
39
+ exports.acquireWorkspaceLock = acquireWorkspaceLock;
40
+ exports.releaseWorkspaceLock = releaseWorkspaceLock;
41
+ exports.migrateLegacyMemoryDebounceSchedule = migrateLegacyMemoryDebounceSchedule;
40
42
  exports.normalizeMode = normalizeMode;
43
+ exports.createScheduleStore = createScheduleStore;
41
44
  exports.loadSchedules = loadSchedules;
42
45
  exports.saveSchedules = saveSchedules;
46
+ exports.getRemovedBuiltins = getRemovedBuiltins;
47
+ exports.seedBuiltinSchedules = seedBuiltinSchedules;
43
48
  exports.acquireLock = acquireLock;
44
49
  exports.releaseLock = releaseLock;
50
+ exports.releaseRunLock = releaseRunLock;
45
51
  exports.shouldRunNow = shouldRunNow;
46
52
  exports.describeCron = describeCron;
47
53
  exports.calculateNextRun = calculateNextRun;
48
54
  exports.isDaemonRunning = isDaemonRunning;
49
55
  exports.startDaemon = startDaemon;
50
56
  exports.stopDaemon = stopDaemon;
57
+ exports.startDaemonSafe = startDaemonSafe;
51
58
  exports.ensureDaemonRunning = ensureDaemonRunning;
52
59
  exports.getDaemonStatus = getDaemonStatus;
53
60
  exports.executeSchedule = executeSchedule;
61
+ exports.getLocalMinuteKey = getLocalMinuteKey;
62
+ exports.buildOccurrenceKey = buildOccurrenceKey;
54
63
  exports.daemonTick = daemonTick;
55
64
  exports.runDaemonForeground = runDaemonForeground;
56
65
  exports.addSchedule = addSchedule;
@@ -59,26 +68,59 @@ exports.enableSchedule = enableSchedule;
59
68
  exports.disableSchedule = disableSchedule;
60
69
  exports.updateSchedule = updateSchedule;
61
70
  const child_process_1 = require("child_process");
71
+ const crypto_1 = require("crypto");
62
72
  const path = __importStar(require("path"));
63
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; } });
64
76
  const sdk_1 = require("@crewx/sdk");
65
- const DATA_DIR = path.join(process.cwd(), '.crewx');
77
+ const builtins_1 = require("./builtins");
78
+ Object.defineProperty(exports, "BUILTIN_SCHEDULES", { enumerable: true, get: function () { return builtins_1.BUILTIN_SCHEDULES; } });
79
+ Object.defineProperty(exports, "getProjectRoot", { enumerable: true, get: function () { return builtins_1.getProjectRoot; } });
80
+ const DEFAULT_WORKSPACE_PATH = path.resolve(process.cwd());
81
+ const DATA_DIR = path.join(DEFAULT_WORKSPACE_PATH, '.crewx');
66
82
  const SCHEDULES_FILE = path.join(DATA_DIR, '.cron-data.json');
67
83
  const LOG_FILE = path.join(DATA_DIR, '.cron-daemon.log');
68
84
  const LOCK_FILE = path.join(DATA_DIR, '.cron-data.json.lock');
69
85
  const USAGE_LOG_FILE = path.join(DATA_DIR, 'cron-usage.log');
70
86
  const PID_FILE = path.join(DATA_DIR, '.cron-daemon.pid');
87
+ const BUILTINS_META_FILE = path.join(DATA_DIR, '.cron-builtins.json');
71
88
  function resolveDataDir(dir) {
72
- return dir ? path.join(dir, '.crewx') : DATA_DIR;
89
+ return path.join(path.resolve(dir ?? DEFAULT_WORKSPACE_PATH), '.crewx');
73
90
  }
74
91
  function resolveSchedulesFile(dir) {
75
92
  return path.join(resolveDataDir(dir), '.cron-data.json');
76
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
+ }
77
100
  function resolvePidFile(dir) {
78
101
  return path.join(resolveDataDir(dir), '.cron-daemon.pid');
79
102
  }
103
+ function resolveBuiltinsMetaFile(dir) {
104
+ return path.join(resolveDataDir(dir), '.cron-builtins.json');
105
+ }
106
+ function reserveAdmission(taskId, scheduleId, timeoutMs) {
107
+ const reserved = (0, sdk_1.reserveUpdateAdmission)(taskId, scheduleId, timeoutMs);
108
+ if (!reserved) {
109
+ console.error(`[${getLocalTimestamp()}] Failed to record update-admission reservation for task ${taskId}`);
110
+ }
111
+ return reserved;
112
+ }
113
+ function releaseAdmissionReservation(taskId) {
114
+ (0, sdk_1.releaseUpdateAdmissionReservation)(taskId);
115
+ }
116
+ function resolveRunLockFile(scheduleId, dir) {
117
+ return path.join(resolveDataDir(dir), `.cron-run-${scheduleId}.lock`);
118
+ }
80
119
  const DAEMON_INTERVAL_MS = 60 * 1000;
81
- const runningSchedules = new Set();
120
+ const STORE_LOCK_TIMEOUT_MS = 5000;
121
+ const STORE_LOCK_POLL_MS = 25;
122
+ const STORE_LOCK_STALE_MS = 30000;
123
+ const RUN_LOCK_STALE_GRACE_MS = 5000;
82
124
  function getLocalTimestamp() {
83
125
  const now = new Date();
84
126
  const y = now.getFullYear();
@@ -89,12 +131,45 @@ function getLocalTimestamp() {
89
131
  const s = String(now.getSeconds()).padStart(2, '0');
90
132
  return `${y}-${m}-${d} ${h}:${min}:${s}`;
91
133
  }
92
- function logUsage(...args) {
93
- 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);
94
169
  const timestamp = getLocalTimestamp();
95
- const message = args.join(' ');
170
+ const message = messageParts.join(' ');
96
171
  const logLine = `${timestamp} | [cron] ${message}\n`;
97
- fs.appendFileSync(USAGE_LOG_FILE, logLine);
172
+ fs.appendFileSync(resolveUsageLogFile(workspacePath), logLine);
98
173
  }
99
174
  function ensureDataDir(dir) {
100
175
  const dataDir = resolveDataDir(dir);
@@ -110,27 +185,86 @@ function safeReadJsonFile(filePath) {
110
185
  return null;
111
186
  }
112
187
  }
113
- function getProjectRoot() {
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) {
114
193
  try {
115
- return (0, child_process_1.execSync)('git rev-parse --show-toplevel', { encoding: 'utf8' }).trim();
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;
116
201
  }
117
202
  catch {
118
- return process.cwd();
203
+ return null;
119
204
  }
120
205
  }
121
- function getBuiltinMemoryCliPath() {
122
- return path.join(getProjectRoot(), 'packages', 'built-in', 'memory', 'dist', 'cli.js');
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
+ }
123
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
+ }
258
+ const MEMORY_DEBOUNCE_BUILTIN = (0, builtins_1.findBuiltinById)('memory-debounce');
124
259
  function maybeRewriteLegacyMemoryCommand(command) {
125
260
  if (!command.includes('/skills/memory-v2/memory-v2.js')) {
126
261
  return command;
127
262
  }
128
- const builtinMemoryCli = getBuiltinMemoryCliPath();
129
- return `node ${builtinMemoryCli} summarize-dirty`;
263
+ return MEMORY_DEBOUNCE_BUILTIN.buildCommand();
130
264
  }
131
- function migrateLegacyMemoryDebounceSchedule() {
132
- ensureDataDir();
133
- const legacyFile = path.join(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');
134
268
  if (!fs.existsSync(legacyFile)) {
135
269
  return;
136
270
  }
@@ -138,12 +272,13 @@ function migrateLegacyMemoryDebounceSchedule() {
138
272
  if (!Array.isArray(legacySchedules)) {
139
273
  return;
140
274
  }
141
- const legacySchedule = legacySchedules.find(s => s.name === 'memory-debounce');
275
+ const legacySchedule = legacySchedules.find(s => s.name === MEMORY_DEBOUNCE_BUILTIN.name);
142
276
  if (!legacySchedule) {
143
277
  return;
144
278
  }
145
- const targetSchedules = fs.existsSync(SCHEDULES_FILE) ? safeReadJsonFile(SCHEDULES_FILE) ?? [] : [];
146
- if (targetSchedules.some(s => s.name === 'memory-debounce')) {
279
+ const store = createScheduleStore(normalizedWorkspacePath);
280
+ const targetSchedules = store.list();
281
+ if (targetSchedules.some(s => s.name === MEMORY_DEBOUNCE_BUILTIN.name)) {
147
282
  return;
148
283
  }
149
284
  const migratedSchedule = {
@@ -152,7 +287,12 @@ function migrateLegacyMemoryDebounceSchedule() {
152
287
  updated_at: new Date().toISOString(),
153
288
  next_run: calculateNextRun(legacySchedule.cron, legacySchedule.timezone),
154
289
  };
155
- saveSchedules([...targetSchedules, migratedSchedule]);
290
+ store.create(migratedSchedule);
291
+ }
292
+ function warnIfOverdriveInCommand(command) {
293
+ if (command.includes('--overdrive')) {
294
+ console.warn('warning: --overdrive in cron command is ignored and may silently override --model');
295
+ }
156
296
  }
157
297
  function normalizeMode(mode) {
158
298
  if (mode === 'query')
@@ -161,69 +301,506 @@ function normalizeMode(mode) {
161
301
  return 'command';
162
302
  return 'execute';
163
303
  }
164
- function loadSchedules(dir) {
165
- ensureDataDir(dir);
166
- if (!dir) {
167
- 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');
168
314
  }
169
- const schedulesFile = resolveSchedulesFile(dir);
170
- if (!fs.existsSync(schedulesFile)) {
171
- return [];
315
+ catch (error) {
316
+ const err = error;
317
+ if (err.code === 'ENOENT')
318
+ return [];
319
+ throw error;
172
320
  }
321
+ let parsed;
173
322
  try {
174
- const data = fs.readFileSync(schedulesFile, 'utf8');
175
- return JSON.parse(data);
323
+ parsed = JSON.parse(data);
176
324
  }
177
- catch (e) {
178
- const err = e;
179
- console.error('Failed to load schedules:', err.message);
180
- return [];
325
+ catch (error) {
326
+ throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store is not valid JSON: ${schedulesFile}`, schedulesFile);
181
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;
182
332
  }
183
- function saveSchedules(schedules, dir) {
184
- ensureDataDir(dir);
185
- const schedulesFile = resolveSchedulesFile(dir);
186
- const tempFile = `${schedulesFile}.tmp`;
187
- fs.writeFileSync(tempFile, JSON.stringify(schedules, null, 2));
188
- fs.renameSync(tempFile, schedulesFile);
189
- }
190
- async function acquireLock(timeout = 5000) {
191
- ensureDataDir();
192
- const startTime = Date.now();
193
- while (Date.now() - startTime < timeout) {
333
+ function syncDirectory(directory) {
334
+ if (process.platform === 'win32')
335
+ return;
336
+ try {
337
+ const fd = fs.openSync(directory, 'r');
194
338
  try {
195
- fs.writeFileSync(LOCK_FILE, String(process.pid), { flag: 'wx' });
196
- return true;
339
+ fs.fsyncSync(fd);
197
340
  }
198
- catch (e) {
199
- const err = e;
200
- if (err.code === 'EEXIST') {
201
- try {
202
- const stat = fs.statSync(LOCK_FILE);
203
- if (Date.now() - stat.mtimeMs > 30000) {
204
- fs.unlinkSync(LOCK_FILE);
205
- continue;
206
- }
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);
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}`);
207
418
  }
208
- catch {
209
- continue;
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 };
210
449
  }
211
- await new Promise(resolve => setTimeout(resolve, 100));
212
- }
213
- else {
214
- throw e;
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
+ }
542
+ function saveSchedules(schedules, dir) {
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
+ }
553
+ }
554
+ function loadBuiltinsMeta(dir) {
555
+ const data = safeReadJsonFile(resolveBuiltinsMetaFile(dir));
556
+ return { removed_builtins: Array.isArray(data?.removed_builtins) ? data.removed_builtins : [] };
557
+ }
558
+ function saveBuiltinsMeta(meta, dir) {
559
+ ensureDataDir(dir);
560
+ const metaFile = resolveBuiltinsMetaFile(dir);
561
+ const tempFile = `${metaFile}.tmp`;
562
+ fs.writeFileSync(tempFile, JSON.stringify(meta, null, 2));
563
+ fs.renameSync(tempFile, metaFile);
564
+ }
565
+ function getRemovedBuiltins(dir) {
566
+ return loadBuiltinsMeta(dir).removed_builtins;
567
+ }
568
+ function recordRemovedBuiltin(builtinId, dir) {
569
+ const meta = loadBuiltinsMeta(dir);
570
+ if (!meta.removed_builtins.includes(builtinId)) {
571
+ meta.removed_builtins.push(builtinId);
572
+ saveBuiltinsMeta(meta, dir);
573
+ }
574
+ }
575
+ function seedBuiltinSchedules(dir) {
576
+ const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
577
+ const schedules = store.list();
578
+ const existingNames = new Set(schedules.map(s => s.name));
579
+ const removedBuiltins = new Set(getRemovedBuiltins(dir));
580
+ const seeded = [];
581
+ for (const def of builtins_1.BUILTIN_SCHEDULES) {
582
+ if (existingNames.has(def.name))
583
+ continue;
584
+ if (removedBuiltins.has(def.builtin_id))
585
+ continue;
586
+ const now = new Date().toISOString();
587
+ const schedule = {
588
+ id: (0, sdk_1.generateId)('cron'),
589
+ cron: def.cron,
590
+ command: def.buildCommand(),
591
+ mode: def.mode,
592
+ enabled: true,
593
+ name: def.name,
594
+ working_directory: null,
595
+ timezone: null,
596
+ timeout_ms: 600000,
597
+ allow_concurrent: false,
598
+ created_at: now,
599
+ updated_at: now,
600
+ run_count: 0,
601
+ next_run: calculateNextRun(def.cron, null),
602
+ };
603
+ store.create(schedule);
604
+ existingNames.add(def.name);
605
+ seeded.push(def.builtin_id);
606
+ }
607
+ return seeded;
608
+ }
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);
215
649
  }
650
+ catch { }
216
651
  }
652
+ try {
653
+ fs.unlinkSync(tempFile);
654
+ }
655
+ catch { }
217
656
  }
218
- return false;
219
657
  }
220
- function releaseLock() {
658
+ function withRunLeaseStoreLock(workspacePath, scheduleId, operation) {
659
+ const owner = acquireWorkspaceLock(workspacePath);
221
660
  try {
222
- 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);
223
670
  }
224
671
  catch {
672
+ return null;
225
673
  }
226
674
  }
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;
708
+ }
709
+ function acquireRunLock(scheduleId, taskId, timeoutMs, dir) {
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++) {
726
+ try {
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
+ };
732
+ }
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' };
740
+ }
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
+ };
757
+ }
758
+ }
759
+ const existing = readRunLease(lockFile);
760
+ return {
761
+ acquired: false,
762
+ runningTaskId: existing?.taskId ?? 'unknown',
763
+ reason: 'execution_lease',
764
+ };
765
+ });
766
+ }
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
+ });
803
+ }
227
804
  const DAY_NAMES = {
228
805
  SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
229
806
  };
@@ -452,8 +1029,10 @@ function calculateNextRun(cronExpr, timezone) {
452
1029
  function readDaemonPid(dir) {
453
1030
  try {
454
1031
  const raw = fs.readFileSync(resolvePidFile(dir), 'utf8').trim();
455
- const pid = Number.parseInt(raw, 10);
456
- return Number.isInteger(pid) && pid > 0 ? pid : null;
1032
+ if (!/^\d+$/.test(raw))
1033
+ return null;
1034
+ const pid = Number(raw);
1035
+ return Number.isSafeInteger(pid) && pid > 0 ? pid : null;
457
1036
  }
458
1037
  catch {
459
1038
  return null;
@@ -476,44 +1055,141 @@ function isPidAlive(pid) {
476
1055
  return true;
477
1056
  }
478
1057
  catch (e) {
479
- const err = e;
480
- if (err.code === 'EPERM')
481
- return true;
482
- return false;
1058
+ const error = e;
1059
+ return error.code === 'ESRCH' ? false : 'unknown';
483
1060
  }
484
1061
  }
485
- function getDaemonCliPath() {
486
- const distCli = path.join(__dirname, '..', 'cli.js');
487
- if (fs.existsSync(distCli)) {
488
- return distCli;
1062
+ function getProcessCommandLine(pid) {
1063
+ try {
1064
+ const output = process.platform === 'win32'
1065
+ ? (0, child_process_1.execFileSync)('powershell.exe', [
1066
+ '-NoLogo',
1067
+ '-NoProfile',
1068
+ '-NonInteractive',
1069
+ '-Command',
1070
+ `(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CommandLine`,
1071
+ ], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
1072
+ : (0, child_process_1.execFileSync)('ps', ['-ww', '-p', String(pid), '-o', 'command='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
1073
+ return output.trim() || null;
1074
+ }
1075
+ catch {
1076
+ return null;
1077
+ }
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;
489
1094
  }
490
- throw new Error(`Cron CLI entrypoint not found: ${distCli}`);
491
1095
  }
492
- function isDaemonRunning(dir) {
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
+ }
1103
+ const DEFAULT_DAEMON_PROCESS_PROBE = {
1104
+ isAlive: isPidAlive,
1105
+ getCommandLine: getProcessCommandLine,
1106
+ };
1107
+ function normalizeCommandLine(value) {
1108
+ const normalized = value.replace(/\\/g, '/');
1109
+ return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
1110
+ }
1111
+ function getDaemonCliPathTail() {
1112
+ try {
1113
+ const segments = normalizeCommandLine(getDaemonCliPath()).split('/').filter(Boolean);
1114
+ const tail = segments.slice(-3).join('/');
1115
+ return tail || null;
1116
+ }
1117
+ catch {
1118
+ return null;
1119
+ }
1120
+ }
1121
+ function classifyDaemonIdentity(commandLine) {
1122
+ if (!commandLine?.trim())
1123
+ return 'unknown';
1124
+ const cliPathTail = getDaemonCliPathTail();
1125
+ if (!cliPathTail)
1126
+ return 'unknown';
1127
+ const normalizedCommandLine = normalizeCommandLine(commandLine);
1128
+ return normalizedCommandLine.includes('daemon --foreground')
1129
+ && normalizedCommandLine.includes(cliPathTail)
1130
+ ? 'running'
1131
+ : 'stale';
1132
+ }
1133
+ function getDaemonCliPath() {
1134
+ const candidates = [
1135
+ path.join(__dirname, '..', 'cli.js'),
1136
+ path.join(__dirname, '..', 'dist', 'cli.js'),
1137
+ ];
1138
+ const cliPath = candidates.find(candidate => fs.existsSync(candidate));
1139
+ if (cliPath) {
1140
+ return cliPath;
1141
+ }
1142
+ throw new Error(`Cron CLI entrypoint not found: ${candidates[0]}`);
1143
+ }
1144
+ function inspectDaemon(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
493
1145
  const pid = readDaemonPid(dir);
494
1146
  if (!pid) {
495
- return false;
1147
+ removeDaemonPidFile(dir);
1148
+ return 'stale';
1149
+ }
1150
+ let alive;
1151
+ try {
1152
+ alive = probe.isAlive(pid);
1153
+ }
1154
+ catch {
1155
+ alive = 'unknown';
496
1156
  }
497
- const alive = isPidAlive(pid);
498
1157
  if (!alive) {
499
1158
  removeDaemonPidFile(dir);
1159
+ return 'stale';
500
1160
  }
501
- return alive;
1161
+ let commandLine;
1162
+ try {
1163
+ commandLine = probe.getCommandLine(pid);
1164
+ }
1165
+ catch {
1166
+ return 'unknown';
1167
+ }
1168
+ const liveness = classifyDaemonIdentity(commandLine);
1169
+ if (liveness === 'stale') {
1170
+ removeDaemonPidFile(dir);
1171
+ }
1172
+ return liveness;
502
1173
  }
503
- function startDaemon(dir) {
504
- if (isDaemonRunning(dir)) {
505
- const pid = readDaemonPid(dir);
506
- if (pid)
507
- return pid;
508
- throw new Error('Daemon is already running');
1174
+ function isDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
1175
+ return inspectDaemon(dir, probe) === 'running';
1176
+ }
1177
+ function spawnDaemon(dir, spawnImpl = child_process_1.spawn) {
1178
+ if ((0, sdk_1.isDurablyQuiescing)()) {
1179
+ throw new Error('Cannot start cron daemon: a desktop update is draining active work');
509
1180
  }
510
1181
  ensureDataDir(dir);
511
1182
  const cliPath = getDaemonCliPath();
512
- const child = (0, child_process_1.spawn)('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'], {
513
1188
  cwd: dir || undefined,
514
1189
  detached: true,
515
1190
  stdio: 'ignore',
516
1191
  windowsHide: true,
1192
+ env: daemonEnv,
517
1193
  });
518
1194
  if (!child.pid) {
519
1195
  throw new Error('Failed to start daemon: no PID returned');
@@ -522,37 +1198,64 @@ function startDaemon(dir) {
522
1198
  writeDaemonPid(child.pid, dir);
523
1199
  return child.pid;
524
1200
  }
525
- function stopDaemon(dir) {
526
- const pid = readDaemonPid(dir);
527
- if (!pid) {
528
- removeDaemonPidFile(dir);
529
- return false;
1201
+ function startDaemon(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
1202
+ const liveness = inspectDaemon(dir, probe);
1203
+ if (liveness === 'running') {
1204
+ const pid = readDaemonPid(dir);
1205
+ if (pid)
1206
+ return pid;
1207
+ throw new Error('Daemon is already running');
530
1208
  }
531
- if (!isPidAlive(pid)) {
532
- removeDaemonPidFile(dir);
533
- return false;
1209
+ if (liveness === 'unknown') {
1210
+ const pid = readDaemonPid(dir);
1211
+ throw new Error(`Unable to determine cron daemon liveness (pid=${pid ?? 'unknown'}, pidFile=${resolvePidFile(dir)}); refusing to start`);
534
1212
  }
1213
+ return spawnDaemon(dir, probe.spawnImpl);
1214
+ }
1215
+ function stopDaemon(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
1216
+ if (inspectDaemon(dir, probe) !== 'running')
1217
+ return false;
1218
+ const pid = readDaemonPid(dir);
1219
+ if (!pid)
1220
+ return false;
535
1221
  process.kill(pid, 'SIGTERM');
536
1222
  return true;
537
1223
  }
538
- function ensureDaemonRunning(dir) {
539
- if (isDaemonRunning(dir))
540
- return;
541
- console.log('⚡ cron 데몬이 꺼져 있어서 자동 시작합니다...');
1224
+ function startDaemonSafe(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
1225
+ const liveness = inspectDaemon(dir, probe);
1226
+ if (liveness === 'running') {
1227
+ return { started: false };
1228
+ }
1229
+ if (liveness === 'unknown') {
1230
+ return {
1231
+ started: false,
1232
+ error: `Unable to determine cron daemon liveness (pid=${readDaemonPid(dir) ?? 'unknown'}, pidFile=${resolvePidFile(dir)}); refusing to start`,
1233
+ };
1234
+ }
542
1235
  try {
543
- startDaemon(dir);
1236
+ const pid = spawnDaemon(dir, probe.spawnImpl);
1237
+ return { started: true, pid };
544
1238
  }
545
1239
  catch (e) {
546
1240
  const err = e;
547
- console.error('Failed to auto-start daemon:', err.message);
1241
+ return { started: false, error: err.message };
1242
+ }
1243
+ }
1244
+ function ensureDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
1245
+ if (isDaemonRunning(dir, probe))
1246
+ return;
1247
+ console.log('⚡ Cron daemon is not running; starting it automatically...');
1248
+ const result = startDaemonSafe(dir, probe);
1249
+ if (!result.started) {
1250
+ console.error('Failed to auto-start daemon:', result.error);
548
1251
  process.exit(1);
549
1252
  }
550
1253
  }
551
1254
  function getDaemonStatus(dir) {
552
- const schedules = loadSchedules(dir);
1255
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
553
1256
  const enabled = schedules.filter(s => s.enabled).length;
554
- const pid = readDaemonPid(dir);
555
- const running = pid !== null && isDaemonRunning(dir);
1257
+ const running = isDaemonRunning(dir);
1258
+ const pid = running ? readDaemonPid(dir) : null;
556
1259
  let started_at = null;
557
1260
  const pidFile = resolvePidFile(dir);
558
1261
  if (running && fs.existsSync(pidFile)) {
@@ -573,34 +1276,162 @@ function getDaemonStatus(dir) {
573
1276
  },
574
1277
  };
575
1278
  }
576
- async function executeSchedule(schedule, dir) {
577
- if (!schedule.allow_concurrent && runningSchedules.has(schedule.id)) {
578
- console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} already running, skipping`);
579
- return { success: false, error: 'Already running' };
1279
+ async function executeSchedule(schedule, options) {
1280
+ const dir = options?.dir;
1281
+ const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
1282
+ const trigger = options?.trigger ?? 'cron';
1283
+ const timeoutMs = schedule.timeout_ms || 600000;
1284
+ const taskId = (0, sdk_1.generateId)('tsk');
1285
+ let runLease;
1286
+ if (!schedule.allow_concurrent) {
1287
+ const lock = acquireRunLock(schedule.id, taskId, timeoutMs, dir);
1288
+ if (!lock.acquired) {
1289
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} already running (task ${lock.runningTaskId}), skipping`);
1290
+ return {
1291
+ status: 'conflict',
1292
+ runningTaskId: lock.runningTaskId,
1293
+ reason: lock.reason,
1294
+ };
1295
+ }
1296
+ runLease = lock.lease;
1297
+ }
1298
+ const releaseLease = () => {
1299
+ if (runLease) {
1300
+ releaseRunLock(schedule.id, runLease.leaseId, dir);
1301
+ runLease = undefined;
1302
+ }
1303
+ releaseAdmissionReservation(taskId);
1304
+ };
1305
+ if (!reserveAdmission(taskId, schedule.id, timeoutMs)) {
1306
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} blocked: could not durably record the update-admission reservation`);
1307
+ releaseLease();
1308
+ return { status: 'blocked', reason: 'ADMISSION_UNKNOWN' };
1309
+ }
1310
+ if ((0, sdk_1.isDurablyQuiescing)()) {
1311
+ console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} blocked: a desktop update is draining active work`);
1312
+ releaseLease();
1313
+ return { status: 'blocked', reason: 'UPDATE_QUIESCING' };
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
+ }
580
1338
  }
581
- runningSchedules.add(schedule.id);
582
- const workingDir = schedule.working_directory || getProjectRoot();
1339
+ catch (error) {
1340
+ releaseLease();
1341
+ throw error;
1342
+ }
1343
+ const workingDir = schedule.working_directory || options?.dir || (0, builtins_1.getProjectRoot)();
583
1344
  const mode = schedule.mode;
584
1345
  const modeFlag = mode === 'execute' ? 'x' : 'q';
585
- console.log(`[${getLocalTimestamp()}] Executing schedule ${schedule.id}: ${schedule.name ?? schedule.cron}`);
1346
+ const effectiveModel = options?.overrides?.model ?? schedule.model;
1347
+ const effectiveProvider = options?.overrides?.provider ?? schedule.provider;
1348
+ console.log(`[${getLocalTimestamp()}] Executing schedule ${schedule.id}: ${schedule.name ?? schedule.cron} (task ${taskId})`);
586
1349
  console.log(`[${getLocalTimestamp()}] Command: ${schedule.command}`);
587
- return new Promise((resolve) => {
588
- const env = { ...process.env, ...schedule.env };
589
- delete env['CLAUDECODE'];
590
- const child = mode === 'command'
591
- ? (0, child_process_1.spawn)('sh', ['-c', schedule.command], {
1350
+ const env = (0, sdk_1.withoutCrewxRootContext)({ ...process.env, ...schedule.env, CREWX_TRACE_ID: taskId });
1351
+ delete env['CLAUDECODE'];
1352
+ if (!schedule.env || !('CREWX_CONFIG' in schedule.env)) {
1353
+ delete env['CREWX_CONFIG'];
1354
+ }
1355
+ env['CREWX_WORKSPACE'] = workingDir;
1356
+ const cliArgs = [modeFlag, schedule.command];
1357
+ if (effectiveModel) {
1358
+ cliArgs.push('--model', effectiveModel);
1359
+ }
1360
+ if (mode !== 'command' && effectiveProvider) {
1361
+ cliArgs.push('--provider', effectiveProvider);
1362
+ }
1363
+ cliArgs.push('--metadata', JSON.stringify({ trigger, schedule_id: schedule.id }));
1364
+ let child;
1365
+ try {
1366
+ if (mode === 'command') {
1367
+ child = (0, child_process_1.spawn)('sh', ['-c', schedule.command], {
592
1368
  cwd: workingDir,
593
- stdio: 'inherit',
1369
+ stdio: 'ignore',
594
1370
  windowsHide: true,
1371
+ detached: true,
595
1372
  env,
596
- })
597
- : (0, child_process_1.spawn)('crewx', [modeFlag, schedule.command], {
1373
+ });
1374
+ }
1375
+ else {
1376
+ const resolution = (0, sdk_1.resolveCrewxExecutable)({ env, cwd: workingDir });
1377
+ if (!resolution.ok) {
1378
+ const error = new Error((0, sdk_1.formatCrewxExecutableFailure)(resolution));
1379
+ Object.assign(error, { code: 'CLI_UNRESOLVABLE' });
1380
+ throw error;
1381
+ }
1382
+ const childInvocation = (0, sdk_1.createCrewxChildInvocation)(resolution, { baseEnv: env });
1383
+ const program = (0, sdk_1.resolveWindowsSpawnProgram)({
1384
+ command: childInvocation.command,
1385
+ env: childInvocation.env,
598
1386
  cwd: workingDir,
599
- stdio: 'inherit',
600
- windowsHide: true,
601
- env,
1387
+ packageName: 'crewx',
1388
+ allowShellFallback: true,
1389
+ });
1390
+ const invocation = (0, sdk_1.materializeWindowsSpawnProgram)(program, [
1391
+ ...childInvocation.args,
1392
+ ...cliArgs,
1393
+ ]);
1394
+ child = (0, child_process_1.spawn)(invocation.command, invocation.argv, {
1395
+ cwd: workingDir,
1396
+ stdio: 'ignore',
1397
+ windowsHide: invocation.windowsHide,
1398
+ detached: true,
1399
+ env: childInvocation.env,
1400
+ shell: invocation.shell ?? false,
602
1401
  });
1402
+ }
1403
+ await new Promise((resolve, reject) => {
1404
+ child.once('spawn', () => resolve());
1405
+ child.once('error', (err) => reject(err));
1406
+ });
1407
+ }
1408
+ catch (e) {
1409
+ const err = e;
1410
+ console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed to spawn: ${err.message}`);
1411
+ releaseLease();
1412
+ throw err;
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
+ }
1428
+ child.unref();
1429
+ const done = new Promise((resolve) => {
1430
+ let timedOut = false;
1431
+ let settled = false;
1432
+ let graceTimer;
603
1433
  const timeout = setTimeout(() => {
1434
+ timedOut = true;
604
1435
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} timeout, killing process`);
605
1436
  try {
606
1437
  child.kill('SIGTERM');
@@ -609,37 +1440,72 @@ async function executeSchedule(schedule, dir) {
609
1440
  const err = e;
610
1441
  console.error(`Failed to kill process: ${err.message}`);
611
1442
  }
612
- runningSchedules.delete(schedule.id);
613
- resolve({ success: false, error: 'Timeout' });
614
- }, schedule.timeout_ms || 600000);
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
+ }
1457
+ }, timeoutMs);
615
1458
  child.on('close', (code) => {
616
1459
  clearTimeout(timeout);
617
- runningSchedules.delete(schedule.id);
618
- const schedules = loadSchedules(dir);
619
- const idx = schedules.findIndex(s => s.id === schedule.id);
620
- if (idx !== -1) {
621
- const s = schedules[idx];
622
- s.last_run = new Date().toISOString();
623
- s.run_count = (s.run_count || 0) + 1;
624
- s.next_run = calculateNextRun(schedule.cron, schedule.timezone);
625
- saveSchedules(schedules, dir);
1460
+ if (graceTimer)
1461
+ clearTimeout(graceTimer);
1462
+ releaseLease();
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
+ }
626
1476
  }
627
- if (code === 0) {
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
+ }
1483
+ }
1484
+ else if (code === 0) {
628
1485
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} completed successfully`);
629
- resolve({ success: true });
1486
+ if (!settled) {
1487
+ settled = true;
1488
+ resolve({ success: true });
1489
+ }
630
1490
  }
631
1491
  else {
632
1492
  console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed with code ${code}`);
633
- resolve({ success: false, error: `Exit code: ${code}` });
1493
+ if (!settled) {
1494
+ settled = true;
1495
+ resolve({ success: false, error: `Exit code: ${code}` });
1496
+ }
634
1497
  }
635
1498
  });
636
1499
  child.on('error', (err) => {
637
1500
  clearTimeout(timeout);
638
- runningSchedules.delete(schedule.id);
639
1501
  console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} error: ${err.message}`);
640
- resolve({ success: false, error: err.message });
1502
+ if (!settled) {
1503
+ settled = true;
1504
+ resolve({ success: false, error: err.message });
1505
+ }
641
1506
  });
642
1507
  });
1508
+ return { status: 'running', taskId, done };
643
1509
  }
644
1510
  function getLocalMinuteKey(now, timezone) {
645
1511
  if (!timezone || !isValidTimezone(timezone)) {
@@ -652,9 +1518,16 @@ function getLocalMinuteKey(now, timezone) {
652
1518
  });
653
1519
  return fmt.format(now);
654
1520
  }
1521
+ function buildOccurrenceKey(cronExpression, scheduledMinute) {
1522
+ return `${cronExpression.trim()}|${scheduledMinute}`;
1523
+ }
655
1524
  async function daemonTick(dir) {
1525
+ if ((0, sdk_1.isDurablyQuiescing)()) {
1526
+ console.log(`[${getLocalTimestamp()}] Daemon tick skipped: a desktop update is draining active work`);
1527
+ return;
1528
+ }
656
1529
  const now = new Date();
657
- const schedules = loadSchedules(dir);
1530
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
658
1531
  const enabledSchedules = schedules.filter(s => s.enabled);
659
1532
  if (enabledSchedules.length === 0) {
660
1533
  return;
@@ -662,16 +1535,20 @@ async function daemonTick(dir) {
662
1535
  for (const schedule of enabledSchedules) {
663
1536
  if (shouldRunNow(schedule.cron, now, schedule.timezone)) {
664
1537
  const minuteKey = getLocalMinuteKey(now, schedule.timezone);
665
- if (schedule.last_run) {
666
- const lastRunMinuteKey = getLocalMinuteKey(new Date(schedule.last_run), schedule.timezone);
667
- if (lastRunMinuteKey === minuteKey) {
668
- continue;
669
- }
1538
+ const occurrenceKey = buildOccurrenceKey(schedule.cron, minuteKey);
1539
+ try {
1540
+ await executeSchedule(schedule, { dir, occurrenceKey });
1541
+ }
1542
+ catch (e) {
1543
+ const err = e;
1544
+ console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed to spawn: ${err.message}`);
670
1545
  }
671
- await executeSchedule(schedule, dir);
672
1546
  }
673
1547
  }
674
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; } });
675
1552
  function runDaemonForeground(dir) {
676
1553
  writeDaemonPid(process.pid, dir);
677
1554
  let cleanedUp = false;
@@ -696,7 +1573,7 @@ function runDaemonForeground(dir) {
696
1573
  console.log(`[${getLocalTimestamp()}] Cron daemon starting in foreground mode`);
697
1574
  console.log(`[${getLocalTimestamp()}] Check interval: 1 minute`);
698
1575
  console.log(`[${getLocalTimestamp()}] Press Ctrl+C to stop\n`);
699
- const schedules = loadSchedules(dir);
1576
+ const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
700
1577
  const enabled = schedules.filter(s => s.enabled).length;
701
1578
  console.log(`[${getLocalTimestamp()}] Active schedules: ${enabled}/${schedules.length}\n`);
702
1579
  void daemonTick(dir);
@@ -708,6 +1585,7 @@ function addSchedule(cron, command, options, dataDir) {
708
1585
  if (options.timezone && !isValidTimezone(options.timezone)) {
709
1586
  throw new Error(`Invalid timezone: ${options.timezone}`);
710
1587
  }
1588
+ warnIfOverdriveInCommand(command);
711
1589
  const schedule = {
712
1590
  id: (0, sdk_1.generateId)('cron'),
713
1591
  cron,
@@ -723,97 +1601,102 @@ function addSchedule(cron, command, options, dataDir) {
723
1601
  updated_at: new Date().toISOString(),
724
1602
  run_count: 0,
725
1603
  };
1604
+ if (options.model) {
1605
+ schedule.model = options.model;
1606
+ }
1607
+ if (options.provider) {
1608
+ schedule.provider = options.provider;
1609
+ }
726
1610
  schedule.next_run = calculateNextRun(cron, schedule.timezone);
727
- const schedules = loadSchedules(dataDir);
728
- schedules.push(schedule);
729
- saveSchedules(schedules, dataDir);
730
- return schedule;
1611
+ return createScheduleStore(dataDir ?? DEFAULT_WORKSPACE_PATH).create(schedule);
731
1612
  }
732
1613
  function removeSchedule(id, dir) {
733
- const schedules = loadSchedules(dir);
734
- const idx = schedules.findIndex(s => s.id === id);
735
- if (idx === -1) {
736
- throw new Error(`Schedule not found: ${id}`);
1614
+ const removed = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).remove(id);
1615
+ const matchedBuiltin = (0, builtins_1.findBuiltinByName)(removed.name);
1616
+ if (matchedBuiltin) {
1617
+ recordRemovedBuiltin(matchedBuiltin.builtin_id, dir);
737
1618
  }
738
- const removed = schedules.splice(idx, 1)[0];
739
- saveSchedules(schedules, dir);
740
1619
  return removed;
741
1620
  }
742
1621
  function enableSchedule(id, dir) {
743
- const schedules = loadSchedules(dir);
744
- const schedule = schedules.find(s => s.id === id);
745
- if (!schedule) {
746
- throw new Error(`Schedule not found: ${id}`);
747
- }
748
- schedule.enabled = true;
749
- schedule.updated_at = new Date().toISOString();
750
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
751
- saveSchedules(schedules, dir);
752
- return schedule;
1622
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, true);
753
1623
  }
754
1624
  function disableSchedule(id, dir) {
755
- const schedules = loadSchedules(dir);
756
- const schedule = schedules.find(s => s.id === id);
757
- if (!schedule) {
758
- throw new Error(`Schedule not found: ${id}`);
759
- }
760
- schedule.enabled = false;
761
- schedule.updated_at = new Date().toISOString();
762
- saveSchedules(schedules, dir);
763
- return schedule;
1625
+ return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, false);
764
1626
  }
765
1627
  function updateSchedule(id, options, dir) {
766
- const schedules = loadSchedules(dir);
767
- const schedule = schedules.find(s => s.id === id);
768
- if (!schedule) {
769
- throw new Error(`Schedule not found: ${id}`);
770
- }
771
1628
  if (options.timezone !== undefined && options.timezone !== null && !isValidTimezone(options.timezone)) {
772
1629
  throw new Error(`Invalid timezone: ${options.timezone}`);
773
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
+ }
774
1636
  const changes = [];
1637
+ const patch = {};
775
1638
  if (options.cron !== undefined) {
776
- schedule.cron = options.cron;
1639
+ patch.cron = options.cron;
777
1640
  changes.push(`cron: ${options.cron}`);
778
1641
  }
779
1642
  if (options.command !== undefined) {
780
- schedule.command = options.command;
1643
+ warnIfOverdriveInCommand(options.command);
1644
+ patch.command = options.command;
781
1645
  changes.push(`command: ${options.command}`);
782
1646
  }
783
1647
  if (options.name !== undefined) {
784
- schedule.name = options.name;
1648
+ patch.name = options.name;
785
1649
  changes.push(`name: ${options.name}`);
786
1650
  }
787
1651
  if (options.mode !== undefined) {
788
- schedule.mode = normalizeMode(options.mode);
789
- changes.push(`mode: ${schedule.mode}`);
1652
+ patch.mode = normalizeMode(options.mode);
1653
+ changes.push(`mode: ${patch.mode}`);
790
1654
  }
791
1655
  if (options.timezone !== undefined) {
792
- schedule.timezone = options.timezone;
1656
+ patch.timezone = options.timezone;
793
1657
  changes.push(`timezone: ${options.timezone}`);
794
1658
  }
795
1659
  if (options.working_directory !== undefined) {
796
- schedule.working_directory = options.working_directory;
1660
+ patch.working_directory = options.working_directory;
797
1661
  changes.push(`working_directory: ${options.working_directory}`);
798
1662
  }
799
1663
  if (options.timeout_ms !== undefined) {
800
- schedule.timeout_ms = options.timeout_ms;
1664
+ patch.timeout_ms = options.timeout_ms;
801
1665
  changes.push(`timeout: ${options.timeout_ms}ms`);
802
1666
  }
803
1667
  if (options.allow_concurrent !== undefined) {
804
- schedule.allow_concurrent = options.allow_concurrent;
1668
+ patch.allow_concurrent = options.allow_concurrent;
805
1669
  changes.push(`allow_concurrent: ${options.allow_concurrent}`);
806
1670
  }
807
- schedule.updated_at = new Date().toISOString();
808
- schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
809
- saveSchedules(schedules, dir);
810
- return { schedule, changes };
1671
+ if (options.model !== undefined) {
1672
+ if (options.model === null) {
1673
+ patch.model = null;
1674
+ changes.push('model: (cleared, reverts to agent default)');
1675
+ }
1676
+ else {
1677
+ patch.model = options.model;
1678
+ changes.push(`model: ${options.model}`);
1679
+ }
1680
+ }
1681
+ if (options.provider !== undefined) {
1682
+ if (options.provider === null) {
1683
+ patch.provider = null;
1684
+ changes.push('provider: (cleared, reverts to agent default)');
1685
+ }
1686
+ else {
1687
+ patch.provider = options.provider;
1688
+ changes.push(`provider: ${options.provider}`);
1689
+ }
1690
+ }
1691
+ return { schedule: store.update(current.id, patch), changes };
811
1692
  }
812
1693
  exports.DATA_PATHS = {
813
1694
  DATA_DIR,
814
1695
  SCHEDULES_FILE,
815
1696
  LOG_FILE,
1697
+ LOCK_FILE,
816
1698
  USAGE_LOG_FILE,
817
1699
  PID_FILE,
1700
+ BUILTINS_META_FILE,
818
1701
  };
819
1702
  //# sourceMappingURL=engine.js.map