@crewx/cron 0.1.10-rc.145 → 0.1.10-rc.146
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -42
- package/dist/cli.js.map +1 -1
- package/dist/src/engine.d.ts +17 -5
- package/dist/src/engine.d.ts.map +1 -1
- package/dist/src/engine.js +728 -190
- package/dist/src/engine.js.map +1 -1
- package/dist/src/runtime.d.ts +128 -0
- package/dist/src/runtime.d.ts.map +1 -0
- package/dist/src/runtime.js +612 -0
- package/dist/src/runtime.js.map +1 -0
- package/dist/src/types.d.ts +63 -0
- package/dist/src/types.d.ts.map +1 -1
- package/dist/src/types.js +11 -0
- package/dist/src/types.js.map +1 -1
- package/package.json +3 -3
package/dist/src/engine.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
170
|
+
const message = messageParts.join(' ');
|
|
118
171
|
const logLine = `${timestamp} | [cron] ${message}\n`;
|
|
119
|
-
fs.appendFileSync(
|
|
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
|
-
|
|
144
|
-
const legacyFile = path.join(
|
|
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
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
191
|
-
return JSON.parse(data);
|
|
323
|
+
parsed = JSON.parse(data);
|
|
192
324
|
}
|
|
193
|
-
catch (
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
325
|
+
catch (error) {
|
|
326
|
+
throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store is not valid JSON: ${schedulesFile}`, schedulesFile);
|
|
327
|
+
}
|
|
328
|
+
if (!Array.isArray(parsed)) {
|
|
329
|
+
throw new types_1.CronStoreError('CRON_STORE_CORRUPT', `Cron schedule store must contain a top-level array: ${schedulesFile}`, schedulesFile);
|
|
330
|
+
}
|
|
331
|
+
return parsed;
|
|
332
|
+
}
|
|
333
|
+
function syncDirectory(directory) {
|
|
334
|
+
if (process.platform === 'win32')
|
|
335
|
+
return;
|
|
336
|
+
try {
|
|
337
|
+
const fd = fs.openSync(directory, 'r');
|
|
338
|
+
try {
|
|
339
|
+
fs.fsyncSync(fd);
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
fs.closeSync(fd);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
catch {
|
|
197
346
|
}
|
|
198
347
|
}
|
|
348
|
+
function writeSchedulesAtomic(schedulesFile, schedules) {
|
|
349
|
+
const directory = path.dirname(schedulesFile);
|
|
350
|
+
const tempFile = path.join(directory, `.${path.basename(schedulesFile)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
|
|
351
|
+
const contents = JSON.stringify(schedules, null, 2);
|
|
352
|
+
let fd;
|
|
353
|
+
try {
|
|
354
|
+
fd = fs.openSync(tempFile, 'wx', 0o600);
|
|
355
|
+
fs.writeFileSync(fd, contents);
|
|
356
|
+
fs.fsyncSync(fd);
|
|
357
|
+
fs.closeSync(fd);
|
|
358
|
+
fd = undefined;
|
|
359
|
+
fs.renameSync(tempFile, schedulesFile);
|
|
360
|
+
syncDirectory(directory);
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
if (fd !== undefined) {
|
|
364
|
+
try {
|
|
365
|
+
fs.closeSync(fd);
|
|
366
|
+
}
|
|
367
|
+
catch {
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
try {
|
|
371
|
+
fs.unlinkSync(tempFile);
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
function requireSchedule(schedules, id) {
|
|
378
|
+
const schedule = schedules.find(item => item.id === id);
|
|
379
|
+
if (!schedule) {
|
|
380
|
+
throw new types_1.CronStoreError('CRON_SCHEDULE_NOT_FOUND', `Schedule not found: ${id}`);
|
|
381
|
+
}
|
|
382
|
+
return schedule;
|
|
383
|
+
}
|
|
384
|
+
function withStoreLock(workspacePath, operation) {
|
|
385
|
+
const lock = acquireWorkspaceLock(workspacePath);
|
|
386
|
+
try {
|
|
387
|
+
const schedulesFile = resolveSchedulesFile(workspacePath);
|
|
388
|
+
const schedules = readSchedulesFile(schedulesFile);
|
|
389
|
+
const result = operation(schedules);
|
|
390
|
+
writeSchedulesAtomic(schedulesFile, schedules);
|
|
391
|
+
return result;
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
releaseWorkspaceLock(lock);
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
function createScheduleStore(workspacePath = DEFAULT_WORKSPACE_PATH) {
|
|
398
|
+
const normalizedWorkspacePath = path.resolve(workspacePath);
|
|
399
|
+
const dataDir = resolveDataDir(normalizedWorkspacePath);
|
|
400
|
+
const schedulesFile = resolveSchedulesFile(normalizedWorkspacePath);
|
|
401
|
+
const lockFile = resolveLockFile(normalizedWorkspacePath);
|
|
402
|
+
return {
|
|
403
|
+
workspacePath: normalizedWorkspacePath,
|
|
404
|
+
dataDir,
|
|
405
|
+
schedulesFile,
|
|
406
|
+
lockFile,
|
|
407
|
+
list() {
|
|
408
|
+
return readSchedulesFile(schedulesFile).map(cloneSchedule);
|
|
409
|
+
},
|
|
410
|
+
get(id) {
|
|
411
|
+
const schedule = readSchedulesFile(schedulesFile).find(item => item.id === id);
|
|
412
|
+
return schedule ? cloneSchedule(schedule) : undefined;
|
|
413
|
+
},
|
|
414
|
+
create(schedule) {
|
|
415
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
416
|
+
if (schedules.some(item => item.id === schedule.id)) {
|
|
417
|
+
throw new types_1.CronStoreError('CRON_SCHEDULE_EXISTS', `Schedule already exists: ${schedule.id}`);
|
|
418
|
+
}
|
|
419
|
+
schedules.push(cloneSchedule(schedule));
|
|
420
|
+
return cloneSchedule(schedule);
|
|
421
|
+
});
|
|
422
|
+
},
|
|
423
|
+
update(id, changes) {
|
|
424
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
425
|
+
const schedule = requireSchedule(schedules, id);
|
|
426
|
+
if (changes.cron !== undefined)
|
|
427
|
+
schedule.cron = changes.cron;
|
|
428
|
+
if (changes.command !== undefined)
|
|
429
|
+
schedule.command = changes.command;
|
|
430
|
+
if (changes.mode !== undefined)
|
|
431
|
+
schedule.mode = changes.mode;
|
|
432
|
+
if (changes.enabled !== undefined)
|
|
433
|
+
schedule.enabled = changes.enabled;
|
|
434
|
+
if (changes.name !== undefined)
|
|
435
|
+
schedule.name = changes.name;
|
|
436
|
+
if (changes.working_directory !== undefined)
|
|
437
|
+
schedule.working_directory = changes.working_directory;
|
|
438
|
+
if (changes.timezone !== undefined)
|
|
439
|
+
schedule.timezone = changes.timezone;
|
|
440
|
+
if (changes.timeout_ms !== undefined)
|
|
441
|
+
schedule.timeout_ms = changes.timeout_ms;
|
|
442
|
+
if (changes.allow_concurrent !== undefined)
|
|
443
|
+
schedule.allow_concurrent = changes.allow_concurrent;
|
|
444
|
+
if (changes.env !== undefined) {
|
|
445
|
+
if (changes.env === null)
|
|
446
|
+
delete schedule.env;
|
|
447
|
+
else
|
|
448
|
+
schedule.env = { ...changes.env };
|
|
449
|
+
}
|
|
450
|
+
if (changes.model !== undefined) {
|
|
451
|
+
if (changes.model === null)
|
|
452
|
+
delete schedule.model;
|
|
453
|
+
else
|
|
454
|
+
schedule.model = changes.model;
|
|
455
|
+
}
|
|
456
|
+
if (changes.provider !== undefined) {
|
|
457
|
+
if (changes.provider === null)
|
|
458
|
+
delete schedule.provider;
|
|
459
|
+
else
|
|
460
|
+
schedule.provider = changes.provider;
|
|
461
|
+
}
|
|
462
|
+
schedule.updated_at = new Date().toISOString();
|
|
463
|
+
schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
|
|
464
|
+
return cloneSchedule(schedule);
|
|
465
|
+
});
|
|
466
|
+
},
|
|
467
|
+
remove(id) {
|
|
468
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
469
|
+
const index = schedules.findIndex(item => item.id === id);
|
|
470
|
+
if (index === -1) {
|
|
471
|
+
throw new types_1.CronStoreError('CRON_SCHEDULE_NOT_FOUND', `Schedule not found: ${id}`);
|
|
472
|
+
}
|
|
473
|
+
const [removed] = schedules.splice(index, 1);
|
|
474
|
+
return cloneSchedule(removed);
|
|
475
|
+
});
|
|
476
|
+
},
|
|
477
|
+
toggle(id, enabled) {
|
|
478
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
479
|
+
const schedule = requireSchedule(schedules, id);
|
|
480
|
+
schedule.enabled = enabled ?? !schedule.enabled;
|
|
481
|
+
schedule.updated_at = new Date().toISOString();
|
|
482
|
+
if (schedule.enabled) {
|
|
483
|
+
schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
|
|
484
|
+
}
|
|
485
|
+
return cloneSchedule(schedule);
|
|
486
|
+
});
|
|
487
|
+
},
|
|
488
|
+
claimOccurrence(id, options) {
|
|
489
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
490
|
+
const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
|
|
491
|
+
if (schedule.last_occurrence_key === options.occurrenceKey) {
|
|
492
|
+
return {
|
|
493
|
+
claimed: false,
|
|
494
|
+
schedule: cloneSchedule(schedule),
|
|
495
|
+
};
|
|
496
|
+
}
|
|
497
|
+
const startedAt = options.startedAt ?? new Date().toISOString();
|
|
498
|
+
schedule.last_started_at = startedAt;
|
|
499
|
+
schedule.last_occurrence_key = options.occurrenceKey;
|
|
500
|
+
schedule.updated_at = startedAt;
|
|
501
|
+
return {
|
|
502
|
+
claimed: true,
|
|
503
|
+
schedule: cloneSchedule(schedule),
|
|
504
|
+
};
|
|
505
|
+
});
|
|
506
|
+
},
|
|
507
|
+
recordStarted(id, options) {
|
|
508
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
509
|
+
const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
|
|
510
|
+
const normalizedOptions = typeof options === 'string' ? { startedAt: options } : options;
|
|
511
|
+
const startedAt = normalizedOptions?.startedAt ?? new Date().toISOString();
|
|
512
|
+
schedule.last_started_at = startedAt;
|
|
513
|
+
if (normalizedOptions?.occurrenceKey !== undefined) {
|
|
514
|
+
schedule.last_occurrence_key = normalizedOptions.occurrenceKey;
|
|
515
|
+
}
|
|
516
|
+
schedule.updated_at = startedAt;
|
|
517
|
+
return cloneSchedule(schedule);
|
|
518
|
+
});
|
|
519
|
+
},
|
|
520
|
+
recordFinished(id, options) {
|
|
521
|
+
return withStoreLock(normalizedWorkspacePath, schedules => {
|
|
522
|
+
const schedule = requireSchedule(schedules, typeof id === 'string' ? id : id.id);
|
|
523
|
+
const finishedAt = options?.finishedAt ?? new Date().toISOString();
|
|
524
|
+
schedule.last_run = finishedAt;
|
|
525
|
+
schedule.run_count = (schedule.run_count || 0) + 1;
|
|
526
|
+
schedule.next_run = options?.nextRun !== undefined
|
|
527
|
+
? options.nextRun
|
|
528
|
+
: calculateNextRun(schedule.cron, schedule.timezone);
|
|
529
|
+
if (options?.outcome !== undefined)
|
|
530
|
+
schedule.last_outcome = options.outcome;
|
|
531
|
+
else if (options?.success !== undefined)
|
|
532
|
+
schedule.last_outcome = options.success ? 'success' : 'failure';
|
|
533
|
+
schedule.updated_at = finishedAt;
|
|
534
|
+
return cloneSchedule(schedule);
|
|
535
|
+
});
|
|
536
|
+
},
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
function loadSchedules(dir) {
|
|
540
|
+
return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
|
|
541
|
+
}
|
|
199
542
|
function saveSchedules(schedules, dir) {
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
const
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
609
|
+
const legacyLockOwners = new Map();
|
|
610
|
+
async function acquireLock(timeout = STORE_LOCK_TIMEOUT_MS, dir) {
|
|
611
|
+
const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
|
|
612
|
+
try {
|
|
613
|
+
legacyLockOwners.set(workspacePath, acquireWorkspaceLock(workspacePath, timeout));
|
|
614
|
+
return true;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (error instanceof types_1.CronStoreError && error.code === 'CRON_STORE_LOCK_TIMEOUT')
|
|
618
|
+
return false;
|
|
619
|
+
throw error;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
function releaseLock(dir) {
|
|
623
|
+
const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
|
|
624
|
+
const owner = legacyLockOwners.get(workspacePath);
|
|
625
|
+
if (!owner)
|
|
626
|
+
return;
|
|
627
|
+
legacyLockOwners.delete(workspacePath);
|
|
628
|
+
releaseWorkspaceLock(owner);
|
|
629
|
+
}
|
|
630
|
+
function readRunLease(lockFile) {
|
|
631
|
+
return safeReadJsonFile(lockFile);
|
|
632
|
+
}
|
|
633
|
+
function writeRunLeaseAtomic(lockFile, lease) {
|
|
634
|
+
const directory = path.dirname(lockFile);
|
|
635
|
+
const tempFile = path.join(directory, `.${path.basename(lockFile)}.${process.pid}.${(0, crypto_1.randomUUID)()}.tmp`);
|
|
636
|
+
let fd;
|
|
637
|
+
try {
|
|
638
|
+
fd = fs.openSync(tempFile, 'wx', 0o600);
|
|
639
|
+
fs.writeFileSync(fd, JSON.stringify(lease));
|
|
640
|
+
fs.fsyncSync(fd);
|
|
641
|
+
fs.closeSync(fd);
|
|
642
|
+
fd = undefined;
|
|
643
|
+
fs.renameSync(tempFile, lockFile);
|
|
644
|
+
}
|
|
645
|
+
finally {
|
|
646
|
+
if (fd !== undefined) {
|
|
647
|
+
try {
|
|
648
|
+
fs.closeSync(fd);
|
|
286
649
|
}
|
|
650
|
+
catch { }
|
|
651
|
+
}
|
|
652
|
+
try {
|
|
653
|
+
fs.unlinkSync(tempFile);
|
|
287
654
|
}
|
|
655
|
+
catch { }
|
|
288
656
|
}
|
|
289
|
-
return false;
|
|
290
657
|
}
|
|
291
|
-
function
|
|
658
|
+
function withRunLeaseStoreLock(workspacePath, scheduleId, operation) {
|
|
659
|
+
const owner = acquireWorkspaceLock(workspacePath);
|
|
292
660
|
try {
|
|
293
|
-
|
|
661
|
+
return operation(resolveRunLockFile(scheduleId, workspacePath));
|
|
662
|
+
}
|
|
663
|
+
finally {
|
|
664
|
+
releaseWorkspaceLock(owner);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
function getLeaseAgeMs(lockFile) {
|
|
668
|
+
try {
|
|
669
|
+
return Math.max(0, Date.now() - fs.statSync(lockFile).mtimeMs);
|
|
294
670
|
}
|
|
295
671
|
catch {
|
|
672
|
+
return null;
|
|
296
673
|
}
|
|
297
674
|
}
|
|
298
|
-
function
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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
|
-
|
|
727
|
+
fs.writeFileSync(lockFile, JSON.stringify(lease), { flag: 'wx', mode: 0o600 });
|
|
728
|
+
return {
|
|
729
|
+
acquired: true,
|
|
730
|
+
lease: { scheduleId, workspacePath, lockFile, leaseId, ownerToken, taskId },
|
|
731
|
+
};
|
|
325
732
|
}
|
|
326
|
-
catch {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
335
|
-
|
|
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
|
-
|
|
341
|
-
|
|
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
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
767
|
+
function handoffRunLock(lease, childPid, childIdentity) {
|
|
768
|
+
return withRunLeaseStoreLock(lease.workspacePath, lease.scheduleId, lockFile => {
|
|
769
|
+
const current = readRunLease(lockFile);
|
|
770
|
+
if (!current ||
|
|
771
|
+
current.leaseId !== lease.leaseId ||
|
|
772
|
+
current.ownerToken !== lease.ownerToken) {
|
|
773
|
+
return false;
|
|
774
|
+
}
|
|
775
|
+
writeRunLeaseAtomic(lockFile, {
|
|
776
|
+
...current,
|
|
777
|
+
phase: 'child',
|
|
778
|
+
pid: childPid,
|
|
779
|
+
childPid,
|
|
780
|
+
childIdentity,
|
|
781
|
+
});
|
|
782
|
+
return true;
|
|
783
|
+
});
|
|
784
|
+
}
|
|
785
|
+
function releaseRunLock(scheduleId, leaseId, dir) {
|
|
786
|
+
const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
|
|
787
|
+
ensureDataDir(workspacePath);
|
|
788
|
+
return withRunLeaseStoreLock(workspacePath, scheduleId, lockFile => {
|
|
789
|
+
const current = readRunLease(lockFile);
|
|
790
|
+
if (!current || current.leaseId !== leaseId)
|
|
791
|
+
return false;
|
|
792
|
+
try {
|
|
793
|
+
fs.unlinkSync(lockFile);
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
catch (error) {
|
|
797
|
+
const err = error;
|
|
798
|
+
if (err.code === 'ENOENT')
|
|
799
|
+
return false;
|
|
800
|
+
throw error;
|
|
801
|
+
}
|
|
802
|
+
});
|
|
349
803
|
}
|
|
350
804
|
const DAY_NAMES = {
|
|
351
805
|
SUN: 0, MON: 1, TUE: 2, WED: 3, THU: 4, FRI: 5, SAT: 6,
|
|
@@ -622,6 +1076,30 @@ function getProcessCommandLine(pid) {
|
|
|
622
1076
|
return null;
|
|
623
1077
|
}
|
|
624
1078
|
}
|
|
1079
|
+
function getProcessStartTime(pid) {
|
|
1080
|
+
try {
|
|
1081
|
+
const output = process.platform === 'win32'
|
|
1082
|
+
? (0, child_process_1.execFileSync)('powershell.exe', [
|
|
1083
|
+
'-NoLogo',
|
|
1084
|
+
'-NoProfile',
|
|
1085
|
+
'-NonInteractive',
|
|
1086
|
+
'-Command',
|
|
1087
|
+
`(Get-CimInstance Win32_Process -Filter "ProcessId = ${pid}").CreationDate.ToUniversalTime().ToString("o")`,
|
|
1088
|
+
], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
|
1089
|
+
: (0, child_process_1.execFileSync)('ps', ['-p', String(pid), '-o', 'lstart='], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
1090
|
+
return output.trim() || null;
|
|
1091
|
+
}
|
|
1092
|
+
catch {
|
|
1093
|
+
return null;
|
|
1094
|
+
}
|
|
1095
|
+
}
|
|
1096
|
+
function captureProcessIdentity(pid) {
|
|
1097
|
+
const identity = {
|
|
1098
|
+
commandLine: getProcessCommandLine(pid),
|
|
1099
|
+
startTime: getProcessStartTime(pid),
|
|
1100
|
+
};
|
|
1101
|
+
return identity.commandLine !== null || identity.startTime !== null ? identity : null;
|
|
1102
|
+
}
|
|
625
1103
|
const DEFAULT_DAEMON_PROCESS_PROBE = {
|
|
626
1104
|
isAlive: isPidAlive,
|
|
627
1105
|
getCommandLine: getProcessCommandLine,
|
|
@@ -769,7 +1247,7 @@ function ensureDaemonRunning(dir, probe = DEFAULT_DAEMON_PROCESS_PROBE) {
|
|
|
769
1247
|
}
|
|
770
1248
|
}
|
|
771
1249
|
function getDaemonStatus(dir) {
|
|
772
|
-
const schedules =
|
|
1250
|
+
const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
|
|
773
1251
|
const enabled = schedules.filter(s => s.enabled).length;
|
|
774
1252
|
const running = isDaemonRunning(dir);
|
|
775
1253
|
const pid = running ? readDaemonPid(dir) : null;
|
|
@@ -795,19 +1273,28 @@ function getDaemonStatus(dir) {
|
|
|
795
1273
|
}
|
|
796
1274
|
async function executeSchedule(schedule, options) {
|
|
797
1275
|
const dir = options?.dir;
|
|
1276
|
+
const workspacePath = path.resolve(dir ?? DEFAULT_WORKSPACE_PATH);
|
|
798
1277
|
const trigger = options?.trigger ?? 'cron';
|
|
799
1278
|
const timeoutMs = schedule.timeout_ms || 600000;
|
|
800
1279
|
const taskId = (0, sdk_1.generateId)('tsk');
|
|
1280
|
+
let runLease;
|
|
801
1281
|
if (!schedule.allow_concurrent) {
|
|
802
1282
|
const lock = acquireRunLock(schedule.id, taskId, timeoutMs, dir);
|
|
803
1283
|
if (!lock.acquired) {
|
|
804
1284
|
console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} already running (task ${lock.runningTaskId}), skipping`);
|
|
805
|
-
return {
|
|
1285
|
+
return {
|
|
1286
|
+
status: 'conflict',
|
|
1287
|
+
runningTaskId: lock.runningTaskId,
|
|
1288
|
+
reason: lock.reason,
|
|
1289
|
+
};
|
|
806
1290
|
}
|
|
1291
|
+
runLease = lock.lease;
|
|
807
1292
|
}
|
|
808
1293
|
const releaseLease = () => {
|
|
809
|
-
if (
|
|
810
|
-
releaseRunLock(schedule.id, dir);
|
|
1294
|
+
if (runLease) {
|
|
1295
|
+
releaseRunLock(schedule.id, runLease.leaseId, dir);
|
|
1296
|
+
runLease = undefined;
|
|
1297
|
+
}
|
|
811
1298
|
releaseAdmissionReservation(taskId);
|
|
812
1299
|
};
|
|
813
1300
|
if (!reserveAdmission(taskId, schedule.id, timeoutMs)) {
|
|
@@ -820,6 +1307,34 @@ async function executeSchedule(schedule, options) {
|
|
|
820
1307
|
releaseLease();
|
|
821
1308
|
return { status: 'blocked', reason: 'UPDATE_QUIESCING' };
|
|
822
1309
|
}
|
|
1310
|
+
try {
|
|
1311
|
+
const store = createScheduleStore(workspacePath);
|
|
1312
|
+
const startedAt = new Date().toISOString();
|
|
1313
|
+
if (options?.occurrenceKey !== undefined) {
|
|
1314
|
+
const claim = store.claimOccurrence(schedule.id, {
|
|
1315
|
+
occurrenceKey: options.occurrenceKey,
|
|
1316
|
+
startedAt,
|
|
1317
|
+
});
|
|
1318
|
+
if (!claim.claimed) {
|
|
1319
|
+
releaseLease();
|
|
1320
|
+
console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} occurrence ${options.occurrenceKey} already claimed, skipping`);
|
|
1321
|
+
return {
|
|
1322
|
+
status: 'conflict',
|
|
1323
|
+
runningTaskId: '',
|
|
1324
|
+
reason: 'occurrence_claim',
|
|
1325
|
+
};
|
|
1326
|
+
}
|
|
1327
|
+
}
|
|
1328
|
+
else {
|
|
1329
|
+
if (store.get(schedule.id)) {
|
|
1330
|
+
store.recordStarted(schedule.id, { startedAt });
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
catch (error) {
|
|
1335
|
+
releaseLease();
|
|
1336
|
+
throw error;
|
|
1337
|
+
}
|
|
823
1338
|
const workingDir = schedule.working_directory || options?.dir || (0, builtins_1.getProjectRoot)();
|
|
824
1339
|
const mode = schedule.mode;
|
|
825
1340
|
const modeFlag = mode === 'execute' ? 'x' : 'q';
|
|
@@ -893,9 +1408,27 @@ async function executeSchedule(schedule, options) {
|
|
|
893
1408
|
releaseLease();
|
|
894
1409
|
throw err;
|
|
895
1410
|
}
|
|
1411
|
+
if (runLease && child.pid) {
|
|
1412
|
+
const handedOff = handoffRunLock(runLease, child.pid, captureProcessIdentity(child.pid));
|
|
1413
|
+
if (!handedOff) {
|
|
1414
|
+
const error = new Error('Cron execution lease was replaced before child ownership was established');
|
|
1415
|
+
console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed to hand off its execution lease: ${error.message}`);
|
|
1416
|
+
try {
|
|
1417
|
+
child.kill('SIGTERM');
|
|
1418
|
+
}
|
|
1419
|
+
catch {
|
|
1420
|
+
}
|
|
1421
|
+
releaseLease();
|
|
1422
|
+
throw error;
|
|
1423
|
+
}
|
|
1424
|
+
}
|
|
896
1425
|
child.unref();
|
|
897
1426
|
const done = new Promise((resolve) => {
|
|
1427
|
+
let timedOut = false;
|
|
1428
|
+
let settled = false;
|
|
1429
|
+
let graceTimer;
|
|
898
1430
|
const timeout = setTimeout(() => {
|
|
1431
|
+
timedOut = true;
|
|
899
1432
|
console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} timeout, killing process`);
|
|
900
1433
|
try {
|
|
901
1434
|
child.kill('SIGTERM');
|
|
@@ -904,35 +1437,69 @@ async function executeSchedule(schedule, options) {
|
|
|
904
1437
|
const err = e;
|
|
905
1438
|
console.error(`Failed to kill process: ${err.message}`);
|
|
906
1439
|
}
|
|
907
|
-
|
|
908
|
-
|
|
1440
|
+
graceTimer = setTimeout(() => {
|
|
1441
|
+
try {
|
|
1442
|
+
child.kill('SIGKILL');
|
|
1443
|
+
}
|
|
1444
|
+
catch {
|
|
1445
|
+
}
|
|
1446
|
+
}, RUN_LOCK_STALE_GRACE_MS);
|
|
1447
|
+
if (typeof graceTimer === 'object' && graceTimer !== null && 'unref' in graceTimer) {
|
|
1448
|
+
graceTimer.unref();
|
|
1449
|
+
}
|
|
1450
|
+
if (!settled) {
|
|
1451
|
+
settled = true;
|
|
1452
|
+
resolve({ success: false, error: 'Timeout' });
|
|
1453
|
+
}
|
|
909
1454
|
}, timeoutMs);
|
|
910
1455
|
child.on('close', (code) => {
|
|
911
1456
|
clearTimeout(timeout);
|
|
1457
|
+
if (graceTimer)
|
|
1458
|
+
clearTimeout(graceTimer);
|
|
912
1459
|
releaseLease();
|
|
913
|
-
const
|
|
914
|
-
const
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
saveSchedules(schedules, dir);
|
|
1460
|
+
const finishedAt = new Date().toISOString();
|
|
1461
|
+
const outcome = timedOut ? 'timeout' : code === 0 ? 'success' : 'failure';
|
|
1462
|
+
try {
|
|
1463
|
+
createScheduleStore(workspacePath).recordFinished(schedule.id, {
|
|
1464
|
+
finishedAt,
|
|
1465
|
+
outcome,
|
|
1466
|
+
});
|
|
921
1467
|
}
|
|
922
|
-
|
|
1468
|
+
catch (error) {
|
|
1469
|
+
const err = error;
|
|
1470
|
+
if (!(error instanceof types_1.CronStoreError && error.code === 'CRON_SCHEDULE_NOT_FOUND')) {
|
|
1471
|
+
console.error(`[${getLocalTimestamp()}] Failed to record schedule ${schedule.id} completion: ${err.message}`);
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
if (timedOut) {
|
|
1475
|
+
console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} terminated after timeout`);
|
|
1476
|
+
if (!settled) {
|
|
1477
|
+
settled = true;
|
|
1478
|
+
resolve({ success: false, error: 'Timeout' });
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
else if (code === 0) {
|
|
923
1482
|
console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} completed successfully`);
|
|
924
|
-
|
|
1483
|
+
if (!settled) {
|
|
1484
|
+
settled = true;
|
|
1485
|
+
resolve({ success: true });
|
|
1486
|
+
}
|
|
925
1487
|
}
|
|
926
1488
|
else {
|
|
927
1489
|
console.log(`[${getLocalTimestamp()}] Schedule ${schedule.id} failed with code ${code}`);
|
|
928
|
-
|
|
1490
|
+
if (!settled) {
|
|
1491
|
+
settled = true;
|
|
1492
|
+
resolve({ success: false, error: `Exit code: ${code}` });
|
|
1493
|
+
}
|
|
929
1494
|
}
|
|
930
1495
|
});
|
|
931
1496
|
child.on('error', (err) => {
|
|
932
1497
|
clearTimeout(timeout);
|
|
933
|
-
releaseLease();
|
|
934
1498
|
console.error(`[${getLocalTimestamp()}] Schedule ${schedule.id} error: ${err.message}`);
|
|
935
|
-
|
|
1499
|
+
if (!settled) {
|
|
1500
|
+
settled = true;
|
|
1501
|
+
resolve({ success: false, error: err.message });
|
|
1502
|
+
}
|
|
936
1503
|
});
|
|
937
1504
|
});
|
|
938
1505
|
return { status: 'running', taskId, done };
|
|
@@ -948,13 +1515,16 @@ function getLocalMinuteKey(now, timezone) {
|
|
|
948
1515
|
});
|
|
949
1516
|
return fmt.format(now);
|
|
950
1517
|
}
|
|
1518
|
+
function buildOccurrenceKey(cronExpression, scheduledMinute) {
|
|
1519
|
+
return `${cronExpression.trim()}|${scheduledMinute}`;
|
|
1520
|
+
}
|
|
951
1521
|
async function daemonTick(dir) {
|
|
952
1522
|
if ((0, sdk_1.isDurablyQuiescing)()) {
|
|
953
1523
|
console.log(`[${getLocalTimestamp()}] Daemon tick skipped: a desktop update is draining active work`);
|
|
954
1524
|
return;
|
|
955
1525
|
}
|
|
956
1526
|
const now = new Date();
|
|
957
|
-
const schedules =
|
|
1527
|
+
const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
|
|
958
1528
|
const enabledSchedules = schedules.filter(s => s.enabled);
|
|
959
1529
|
if (enabledSchedules.length === 0) {
|
|
960
1530
|
return;
|
|
@@ -962,17 +1532,9 @@ async function daemonTick(dir) {
|
|
|
962
1532
|
for (const schedule of enabledSchedules) {
|
|
963
1533
|
if (shouldRunNow(schedule.cron, now, schedule.timezone)) {
|
|
964
1534
|
const minuteKey = getLocalMinuteKey(now, schedule.timezone);
|
|
965
|
-
|
|
966
|
-
const lastRunMinuteKey = getLocalMinuteKey(new Date(schedule.last_run), schedule.timezone);
|
|
967
|
-
if (lastRunMinuteKey === minuteKey) {
|
|
968
|
-
continue;
|
|
969
|
-
}
|
|
970
|
-
}
|
|
1535
|
+
const occurrenceKey = buildOccurrenceKey(schedule.cron, minuteKey);
|
|
971
1536
|
try {
|
|
972
|
-
|
|
973
|
-
if (outcome.status === 'running') {
|
|
974
|
-
await outcome.done;
|
|
975
|
-
}
|
|
1537
|
+
await executeSchedule(schedule, { dir, occurrenceKey });
|
|
976
1538
|
}
|
|
977
1539
|
catch (e) {
|
|
978
1540
|
const err = e;
|
|
@@ -981,6 +1543,9 @@ async function daemonTick(dir) {
|
|
|
981
1543
|
}
|
|
982
1544
|
}
|
|
983
1545
|
}
|
|
1546
|
+
var runtime_1 = require("./runtime");
|
|
1547
|
+
Object.defineProperty(exports, "createCronRuntime", { enumerable: true, get: function () { return runtime_1.createCronRuntime; } });
|
|
1548
|
+
Object.defineProperty(exports, "CRON_RUNTIME_PROCESS_LEASE_FILENAME", { enumerable: true, get: function () { return runtime_1.CRON_RUNTIME_PROCESS_LEASE_FILENAME; } });
|
|
984
1549
|
function runDaemonForeground(dir) {
|
|
985
1550
|
writeDaemonPid(process.pid, dir);
|
|
986
1551
|
let cleanedUp = false;
|
|
@@ -1005,7 +1570,7 @@ function runDaemonForeground(dir) {
|
|
|
1005
1570
|
console.log(`[${getLocalTimestamp()}] Cron daemon starting in foreground mode`);
|
|
1006
1571
|
console.log(`[${getLocalTimestamp()}] Check interval: 1 minute`);
|
|
1007
1572
|
console.log(`[${getLocalTimestamp()}] Press Ctrl+C to stop\n`);
|
|
1008
|
-
const schedules =
|
|
1573
|
+
const schedules = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).list();
|
|
1009
1574
|
const enabled = schedules.filter(s => s.enabled).length;
|
|
1010
1575
|
console.log(`[${getLocalTimestamp()}] Active schedules: ${enabled}/${schedules.length}\n`);
|
|
1011
1576
|
void daemonTick(dir);
|
|
@@ -1040,19 +1605,10 @@ function addSchedule(cron, command, options, dataDir) {
|
|
|
1040
1605
|
schedule.provider = options.provider;
|
|
1041
1606
|
}
|
|
1042
1607
|
schedule.next_run = calculateNextRun(cron, schedule.timezone);
|
|
1043
|
-
|
|
1044
|
-
schedules.push(schedule);
|
|
1045
|
-
saveSchedules(schedules, dataDir);
|
|
1046
|
-
return schedule;
|
|
1608
|
+
return createScheduleStore(dataDir ?? DEFAULT_WORKSPACE_PATH).create(schedule);
|
|
1047
1609
|
}
|
|
1048
1610
|
function removeSchedule(id, dir) {
|
|
1049
|
-
const
|
|
1050
|
-
const idx = schedules.findIndex(s => s.id === id);
|
|
1051
|
-
if (idx === -1) {
|
|
1052
|
-
throw new Error(`Schedule not found: ${id}`);
|
|
1053
|
-
}
|
|
1054
|
-
const removed = schedules.splice(idx, 1)[0];
|
|
1055
|
-
saveSchedules(schedules, dir);
|
|
1611
|
+
const removed = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).remove(id);
|
|
1056
1612
|
const matchedBuiltin = (0, builtins_1.findBuiltinByName)(removed.name);
|
|
1057
1613
|
if (matchedBuiltin) {
|
|
1058
1614
|
recordRemovedBuiltin(matchedBuiltin.builtin_id, dir);
|
|
@@ -1060,100 +1616,82 @@ function removeSchedule(id, dir) {
|
|
|
1060
1616
|
return removed;
|
|
1061
1617
|
}
|
|
1062
1618
|
function enableSchedule(id, dir) {
|
|
1063
|
-
|
|
1064
|
-
const schedule = schedules.find(s => s.id === id);
|
|
1065
|
-
if (!schedule) {
|
|
1066
|
-
throw new Error(`Schedule not found: ${id}`);
|
|
1067
|
-
}
|
|
1068
|
-
schedule.enabled = true;
|
|
1069
|
-
schedule.updated_at = new Date().toISOString();
|
|
1070
|
-
schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
|
|
1071
|
-
saveSchedules(schedules, dir);
|
|
1072
|
-
return schedule;
|
|
1619
|
+
return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, true);
|
|
1073
1620
|
}
|
|
1074
1621
|
function disableSchedule(id, dir) {
|
|
1075
|
-
|
|
1076
|
-
const schedule = schedules.find(s => s.id === id);
|
|
1077
|
-
if (!schedule) {
|
|
1078
|
-
throw new Error(`Schedule not found: ${id}`);
|
|
1079
|
-
}
|
|
1080
|
-
schedule.enabled = false;
|
|
1081
|
-
schedule.updated_at = new Date().toISOString();
|
|
1082
|
-
saveSchedules(schedules, dir);
|
|
1083
|
-
return schedule;
|
|
1622
|
+
return createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH).toggle(id, false);
|
|
1084
1623
|
}
|
|
1085
1624
|
function updateSchedule(id, options, dir) {
|
|
1086
|
-
const schedules = loadSchedules(dir);
|
|
1087
|
-
const schedule = schedules.find(s => s.id === id);
|
|
1088
|
-
if (!schedule) {
|
|
1089
|
-
throw new Error(`Schedule not found: ${id}`);
|
|
1090
|
-
}
|
|
1091
1625
|
if (options.timezone !== undefined && options.timezone !== null && !isValidTimezone(options.timezone)) {
|
|
1092
1626
|
throw new Error(`Invalid timezone: ${options.timezone}`);
|
|
1093
1627
|
}
|
|
1628
|
+
const store = createScheduleStore(dir ?? DEFAULT_WORKSPACE_PATH);
|
|
1629
|
+
const current = store.get(id);
|
|
1630
|
+
if (!current) {
|
|
1631
|
+
throw new Error(`Schedule not found: ${id}`);
|
|
1632
|
+
}
|
|
1094
1633
|
const changes = [];
|
|
1634
|
+
const patch = {};
|
|
1095
1635
|
if (options.cron !== undefined) {
|
|
1096
|
-
|
|
1636
|
+
patch.cron = options.cron;
|
|
1097
1637
|
changes.push(`cron: ${options.cron}`);
|
|
1098
1638
|
}
|
|
1099
1639
|
if (options.command !== undefined) {
|
|
1100
1640
|
warnIfOverdriveInCommand(options.command);
|
|
1101
|
-
|
|
1641
|
+
patch.command = options.command;
|
|
1102
1642
|
changes.push(`command: ${options.command}`);
|
|
1103
1643
|
}
|
|
1104
1644
|
if (options.name !== undefined) {
|
|
1105
|
-
|
|
1645
|
+
patch.name = options.name;
|
|
1106
1646
|
changes.push(`name: ${options.name}`);
|
|
1107
1647
|
}
|
|
1108
1648
|
if (options.mode !== undefined) {
|
|
1109
|
-
|
|
1110
|
-
changes.push(`mode: ${
|
|
1649
|
+
patch.mode = normalizeMode(options.mode);
|
|
1650
|
+
changes.push(`mode: ${patch.mode}`);
|
|
1111
1651
|
}
|
|
1112
1652
|
if (options.timezone !== undefined) {
|
|
1113
|
-
|
|
1653
|
+
patch.timezone = options.timezone;
|
|
1114
1654
|
changes.push(`timezone: ${options.timezone}`);
|
|
1115
1655
|
}
|
|
1116
1656
|
if (options.working_directory !== undefined) {
|
|
1117
|
-
|
|
1657
|
+
patch.working_directory = options.working_directory;
|
|
1118
1658
|
changes.push(`working_directory: ${options.working_directory}`);
|
|
1119
1659
|
}
|
|
1120
1660
|
if (options.timeout_ms !== undefined) {
|
|
1121
|
-
|
|
1661
|
+
patch.timeout_ms = options.timeout_ms;
|
|
1122
1662
|
changes.push(`timeout: ${options.timeout_ms}ms`);
|
|
1123
1663
|
}
|
|
1124
1664
|
if (options.allow_concurrent !== undefined) {
|
|
1125
|
-
|
|
1665
|
+
patch.allow_concurrent = options.allow_concurrent;
|
|
1126
1666
|
changes.push(`allow_concurrent: ${options.allow_concurrent}`);
|
|
1127
1667
|
}
|
|
1128
1668
|
if (options.model !== undefined) {
|
|
1129
1669
|
if (options.model === null) {
|
|
1130
|
-
|
|
1670
|
+
patch.model = null;
|
|
1131
1671
|
changes.push('model: (cleared, reverts to agent default)');
|
|
1132
1672
|
}
|
|
1133
1673
|
else {
|
|
1134
|
-
|
|
1674
|
+
patch.model = options.model;
|
|
1135
1675
|
changes.push(`model: ${options.model}`);
|
|
1136
1676
|
}
|
|
1137
1677
|
}
|
|
1138
1678
|
if (options.provider !== undefined) {
|
|
1139
1679
|
if (options.provider === null) {
|
|
1140
|
-
|
|
1680
|
+
patch.provider = null;
|
|
1141
1681
|
changes.push('provider: (cleared, reverts to agent default)');
|
|
1142
1682
|
}
|
|
1143
1683
|
else {
|
|
1144
|
-
|
|
1684
|
+
patch.provider = options.provider;
|
|
1145
1685
|
changes.push(`provider: ${options.provider}`);
|
|
1146
1686
|
}
|
|
1147
1687
|
}
|
|
1148
|
-
|
|
1149
|
-
schedule.next_run = calculateNextRun(schedule.cron, schedule.timezone);
|
|
1150
|
-
saveSchedules(schedules, dir);
|
|
1151
|
-
return { schedule, changes };
|
|
1688
|
+
return { schedule: store.update(current.id, patch), changes };
|
|
1152
1689
|
}
|
|
1153
1690
|
exports.DATA_PATHS = {
|
|
1154
1691
|
DATA_DIR,
|
|
1155
1692
|
SCHEDULES_FILE,
|
|
1156
1693
|
LOG_FILE,
|
|
1694
|
+
LOCK_FILE,
|
|
1157
1695
|
USAGE_LOG_FILE,
|
|
1158
1696
|
PID_FILE,
|
|
1159
1697
|
BUILTINS_META_FILE,
|