@jl1990/pi-scheduler 0.1.0 → 0.2.0
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/README.md +126 -36
- package/extensions/scheduler/index.ts +303 -62
- package/extensions/scheduler/scheduler-core.cjs +387 -59
- package/package.json +8 -5
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
const { Cron } = require("croner");
|
|
2
|
+
|
|
1
3
|
const VALID_ACTIONS = new Set(["notify", "prompt", "shell", "message"]);
|
|
4
|
+
const VALID_TYPES = new Set(["once", "interval", "cron"]);
|
|
2
5
|
const VALID_STATUSES = new Set(["pending", "running", "fired", "cancelled", "failed"]);
|
|
6
|
+
const VALID_LAST_STATUSES = new Set(["success", "error", "running"]);
|
|
7
|
+
const VALID_SCOPES = new Set(["session", "cwd", "global"]);
|
|
8
|
+
const VALID_WAKE_ON = new Set(["always", "failure", "success", "never"]);
|
|
3
9
|
|
|
4
10
|
const SECOND = 1000;
|
|
5
11
|
const MINUTE = 60 * SECOND;
|
|
@@ -30,7 +36,14 @@ function unitToMs(unit) {
|
|
|
30
36
|
function parseDurationMs(text) {
|
|
31
37
|
let input = compactSpaces(text).toLowerCase();
|
|
32
38
|
if (!input) return null;
|
|
33
|
-
input = input
|
|
39
|
+
input = input
|
|
40
|
+
.replace(/^in\s+/, "")
|
|
41
|
+
.replace(/^every\s+/, "")
|
|
42
|
+
.replace(/^\+/, "")
|
|
43
|
+
.replace(/,/g, " ")
|
|
44
|
+
.replace(/\band\b/g, " ")
|
|
45
|
+
.replace(/\s+/g, " ")
|
|
46
|
+
.trim();
|
|
34
47
|
if (!input) return null;
|
|
35
48
|
|
|
36
49
|
const re = /(\d+(?:\.\d+)?)\s*(weeks?|w|days?|d|hours?|hrs?|hr|h|minutes?|mins?|min|m|seconds?|secs?|sec|s)/gi;
|
|
@@ -144,24 +157,100 @@ function normalizeAction(action) {
|
|
|
144
157
|
return value;
|
|
145
158
|
}
|
|
146
159
|
|
|
160
|
+
function normalizeType(type) {
|
|
161
|
+
const value = compactSpaces(type || "once").toLowerCase();
|
|
162
|
+
if (!VALID_TYPES.has(value)) throw new Error(`Invalid schedule type: ${value}`);
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeScope(scope) {
|
|
167
|
+
const value = compactSpaces(scope || "session").toLowerCase();
|
|
168
|
+
if (!VALID_SCOPES.has(value)) throw new Error(`Invalid scheduled task scope: ${value}`);
|
|
169
|
+
return value;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function normalizeWakeOn(wakeOn, hasPrompt = false) {
|
|
173
|
+
const value = compactSpaces(wakeOn || (hasPrompt ? "always" : "never")).toLowerCase();
|
|
174
|
+
if (!VALID_WAKE_ON.has(value)) throw new Error(`Invalid wakeOn value: ${value}`);
|
|
175
|
+
return value;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeMaxRuns(value) {
|
|
179
|
+
if (value === undefined || value === null || value === "") return undefined;
|
|
180
|
+
const n = Number(value);
|
|
181
|
+
if (!Number.isInteger(n) || n <= 0) throw new Error("maxRuns must be a positive integer");
|
|
182
|
+
return n;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function validateTimeoutMs(value) {
|
|
186
|
+
if (value === undefined || value === null) return undefined;
|
|
187
|
+
const n = Number(value);
|
|
188
|
+
if (!Number.isFinite(n) || n <= 0) throw new Error("timeoutMs must be a positive number");
|
|
189
|
+
return Math.round(n);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function validateTaskSchedule(typeValue, scheduleValue, nowValue = new Date()) {
|
|
193
|
+
const now = asDate(nowValue);
|
|
194
|
+
const type = normalizeType(typeValue);
|
|
195
|
+
const schedule = compactSpaces(scheduleValue);
|
|
196
|
+
if (!schedule) throw new Error("schedule is required");
|
|
197
|
+
|
|
198
|
+
if (type === "once") {
|
|
199
|
+
const parsed = parseWhen(schedule, now);
|
|
200
|
+
const nextRun = new Date(parsed.dueAtMs).toISOString();
|
|
201
|
+
return { type, schedule, nextRun, dueAt: nextRun, dueAtMs: parsed.dueAtMs, scheduleKind: parsed.kind };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
if (type === "interval") {
|
|
205
|
+
const intervalMs = parseDurationMs(schedule);
|
|
206
|
+
if (!intervalMs) throw new Error(`Invalid interval schedule: ${schedule}`);
|
|
207
|
+
const nextRun = new Date(now.getTime() + intervalMs).toISOString();
|
|
208
|
+
return { type, schedule, intervalMs, nextRun, dueAt: nextRun };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
try {
|
|
212
|
+
const cron = new Cron(schedule, { paused: true }, () => {});
|
|
213
|
+
const next = cron.nextRun(now);
|
|
214
|
+
cron.stop();
|
|
215
|
+
if (!next) throw new Error("No next run could be computed");
|
|
216
|
+
return { type, schedule, nextRun: next.toISOString(), dueAt: next.toISOString() };
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new Error(`Invalid cron schedule: ${schedule}${error instanceof Error ? ` (${error.message})` : ""}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function getScheduleInput(input) {
|
|
223
|
+
return compactSpaces(input.schedule ?? input.whenText ?? input.when ?? input.due ?? "");
|
|
224
|
+
}
|
|
225
|
+
|
|
147
226
|
function splitScheduleCommand(args, nowValue = new Date()) {
|
|
148
227
|
const text = compactSpaces(args);
|
|
149
228
|
if (!text) throw new Error("Usage: /schedule [notify|prompt|shell|message] <when> <payload>");
|
|
150
229
|
|
|
230
|
+
const parseLeft = (leftText) => {
|
|
231
|
+
const tokens = compactSpaces(leftText).split(" ").filter(Boolean);
|
|
232
|
+
let action = "notify";
|
|
233
|
+
if (tokens.length > 0 && VALID_ACTIONS.has(tokens[0].toLowerCase())) action = tokens.shift().toLowerCase();
|
|
234
|
+
|
|
235
|
+
let type = "once";
|
|
236
|
+
if (tokens[0]?.toLowerCase() === "every") {
|
|
237
|
+
type = "interval";
|
|
238
|
+
tokens.shift();
|
|
239
|
+
} else if (tokens.length > 0 && VALID_TYPES.has(tokens[0].toLowerCase())) {
|
|
240
|
+
type = tokens.shift().toLowerCase();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
const schedule = compactSpaces(tokens.join(" "));
|
|
244
|
+
validateTaskSchedule(type, schedule, nowValue);
|
|
245
|
+
return { action, type, schedule, whenText: schedule };
|
|
246
|
+
};
|
|
247
|
+
|
|
151
248
|
const separatorIndex = text.indexOf("::");
|
|
152
249
|
if (separatorIndex >= 0) {
|
|
153
250
|
const left = compactSpaces(text.slice(0, separatorIndex));
|
|
154
251
|
const payload = compactSpaces(text.slice(separatorIndex + 2));
|
|
155
252
|
if (!left || !payload) throw new Error("Usage with separator: /schedule [action] <when> :: <payload>");
|
|
156
|
-
|
|
157
|
-
let action = "notify";
|
|
158
|
-
let whenText = left;
|
|
159
|
-
if (VALID_ACTIONS.has(leftTokens[0].toLowerCase())) {
|
|
160
|
-
action = leftTokens[0].toLowerCase();
|
|
161
|
-
whenText = compactSpaces(leftTokens.slice(1).join(" "));
|
|
162
|
-
}
|
|
163
|
-
parseWhen(whenText, nowValue);
|
|
164
|
-
return { action, whenText, payload };
|
|
253
|
+
return { ...parseLeft(left), payload };
|
|
165
254
|
}
|
|
166
255
|
|
|
167
256
|
const tokens = text.split(" ");
|
|
@@ -175,12 +264,12 @@ function splitScheduleCommand(args, nowValue = new Date()) {
|
|
|
175
264
|
const maxPrefix = Math.min(restTokens.length - 1, 10);
|
|
176
265
|
let bestMatch = null;
|
|
177
266
|
for (let i = 1; i <= maxPrefix; i++) {
|
|
178
|
-
const
|
|
267
|
+
const schedule = restTokens.slice(0, i).join(" ");
|
|
179
268
|
const payload = restTokens.slice(i).join(" ").trim();
|
|
180
269
|
if (!payload) continue;
|
|
181
270
|
try {
|
|
182
|
-
|
|
183
|
-
bestMatch = { action, whenText, payload };
|
|
271
|
+
validateTaskSchedule("once", schedule, nowValue);
|
|
272
|
+
bestMatch = { action, type: "once", schedule, whenText: schedule, payload };
|
|
184
273
|
} catch {
|
|
185
274
|
// Try a longer prefix.
|
|
186
275
|
}
|
|
@@ -195,34 +284,41 @@ function generateId(nowValue = new Date()) {
|
|
|
195
284
|
return `task_${now.getTime().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
|
196
285
|
}
|
|
197
286
|
|
|
198
|
-
function validateTimeoutMs(value) {
|
|
199
|
-
if (value === undefined || value === null) return undefined;
|
|
200
|
-
const n = Number(value);
|
|
201
|
-
if (!Number.isFinite(n) || n <= 0) throw new Error("timeoutMs must be a positive number");
|
|
202
|
-
return Math.round(n);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
287
|
function createScheduledTask(input, nowValue = new Date(), idFn = generateId) {
|
|
206
288
|
const now = asDate(nowValue);
|
|
207
289
|
const action = normalizeAction(input.action);
|
|
208
|
-
const
|
|
209
|
-
const
|
|
290
|
+
const type = normalizeType(input.type);
|
|
291
|
+
const schedule = getScheduleInput(input);
|
|
292
|
+
const validated = validateTaskSchedule(type, schedule, now);
|
|
293
|
+
const enabled = input.enabled === undefined ? true : Boolean(input.enabled);
|
|
294
|
+
const hasShellPrompt = Boolean(input.followUpPrompt || input.successPrompt || input.failurePrompt);
|
|
295
|
+
|
|
210
296
|
const task = {
|
|
211
297
|
id: idFn(now),
|
|
212
298
|
action,
|
|
299
|
+
type,
|
|
300
|
+
schedule: validated.schedule,
|
|
301
|
+
whenText: compactSpaces(input.whenText ?? input.when ?? validated.schedule),
|
|
213
302
|
status: "pending",
|
|
303
|
+
enabled,
|
|
214
304
|
createdAt: now.toISOString(),
|
|
215
|
-
dueAt:
|
|
216
|
-
|
|
305
|
+
dueAt: validated.dueAt,
|
|
306
|
+
nextRun: enabled ? validated.nextRun : undefined,
|
|
307
|
+
runCount: 0,
|
|
308
|
+
scope: normalizeScope(input.scope),
|
|
217
309
|
};
|
|
218
310
|
|
|
219
|
-
|
|
220
|
-
const prompt = compactSpaces(input.prompt ?? input.payload ?? input.message ?? "");
|
|
221
|
-
const command = compactSpaces(input.command ?? input.payload ?? "");
|
|
222
|
-
|
|
311
|
+
if (validated.intervalMs !== undefined) task.intervalMs = validated.intervalMs;
|
|
223
312
|
if (input.title !== undefined) task.title = compactSpaces(input.title);
|
|
313
|
+
if (input.name !== undefined) task.name = compactSpaces(input.name);
|
|
314
|
+
if (input.description !== undefined) task.description = compactSpaces(input.description);
|
|
224
315
|
if (input.cwd) task.cwd = String(input.cwd);
|
|
225
316
|
if (input.sessionFile) task.sessionFile = String(input.sessionFile);
|
|
317
|
+
if (input.maxRuns !== undefined) task.maxRuns = normalizeMaxRuns(input.maxRuns);
|
|
318
|
+
|
|
319
|
+
const message = compactSpaces(input.message ?? input.payload ?? "");
|
|
320
|
+
const prompt = compactSpaces(input.prompt ?? input.payload ?? input.message ?? "");
|
|
321
|
+
const command = compactSpaces(input.command ?? input.payload ?? "");
|
|
226
322
|
|
|
227
323
|
if (action === "notify") {
|
|
228
324
|
if (!message) throw new Error("message is required for notify scheduled tasks");
|
|
@@ -236,7 +332,12 @@ function createScheduledTask(input, nowValue = new Date(), idFn = generateId) {
|
|
|
236
332
|
const timeoutMs = validateTimeoutMs(input.timeoutMs);
|
|
237
333
|
if (timeoutMs !== undefined) task.timeoutMs = timeoutMs;
|
|
238
334
|
const followUpPrompt = compactSpaces(input.followUpPrompt ?? "");
|
|
335
|
+
const successPrompt = compactSpaces(input.successPrompt ?? "");
|
|
336
|
+
const failurePrompt = compactSpaces(input.failurePrompt ?? "");
|
|
239
337
|
if (followUpPrompt) task.followUpPrompt = followUpPrompt;
|
|
338
|
+
if (successPrompt) task.successPrompt = successPrompt;
|
|
339
|
+
if (failurePrompt) task.failurePrompt = failurePrompt;
|
|
340
|
+
task.wakeOn = normalizeWakeOn(input.wakeOn, hasShellPrompt);
|
|
240
341
|
} else if (action === "message") {
|
|
241
342
|
if (!message) throw new Error("message is required for message scheduled tasks");
|
|
242
343
|
task.message = message;
|
|
@@ -251,29 +352,96 @@ function taskSummary(task) {
|
|
|
251
352
|
return raw.length > 100 ? `${raw.slice(0, 97)}...` : raw;
|
|
252
353
|
}
|
|
253
354
|
|
|
355
|
+
function isTerminal(task) {
|
|
356
|
+
return task.status === "fired" || task.status === "cancelled" || task.status === "failed";
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function normalizeTask(task, nowValue = new Date()) {
|
|
360
|
+
if (!task || typeof task !== "object") return undefined;
|
|
361
|
+
if (typeof task.id !== "string") return undefined;
|
|
362
|
+
let action;
|
|
363
|
+
try {
|
|
364
|
+
action = normalizeAction(task.action);
|
|
365
|
+
} catch {
|
|
366
|
+
return undefined;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const status = VALID_STATUSES.has(task.status) ? task.status : "pending";
|
|
370
|
+
let type;
|
|
371
|
+
try {
|
|
372
|
+
type = normalizeType(task.type);
|
|
373
|
+
} catch {
|
|
374
|
+
type = "once";
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const schedule = compactSpaces(task.schedule ?? task.whenText ?? task.when ?? task.due ?? task.dueAt ?? "");
|
|
378
|
+
if (!schedule) return undefined;
|
|
379
|
+
|
|
380
|
+
const migrated = { ...task, action, type, schedule, status };
|
|
381
|
+
migrated.enabled = task.enabled === undefined ? !isTerminal(migrated) : Boolean(task.enabled);
|
|
382
|
+
migrated.runCount = Number.isInteger(task.runCount) && task.runCount >= 0 ? task.runCount : 0;
|
|
383
|
+
migrated.scope = VALID_SCOPES.has(task.scope) ? task.scope : "session";
|
|
384
|
+
migrated.whenText = compactSpaces(task.whenText ?? task.when ?? schedule);
|
|
385
|
+
migrated.createdAt = Number.isNaN(Date.parse(task.createdAt)) ? asDate(nowValue).toISOString() : task.createdAt;
|
|
386
|
+
if (task.maxRuns !== undefined) migrated.maxRuns = normalizeMaxRuns(task.maxRuns);
|
|
387
|
+
if (task.lastStatus !== undefined && !VALID_LAST_STATUSES.has(task.lastStatus)) delete migrated.lastStatus;
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const validated = validateTaskSchedule(type, schedule, nowValue);
|
|
391
|
+
if (validated.intervalMs !== undefined) migrated.intervalMs = validated.intervalMs;
|
|
392
|
+
if (!task.nextRun && !task.dueAt) {
|
|
393
|
+
migrated.nextRun = migrated.enabled ? validated.nextRun : undefined;
|
|
394
|
+
migrated.dueAt = validated.dueAt;
|
|
395
|
+
}
|
|
396
|
+
} catch {
|
|
397
|
+
// Legacy one-shot absolute dueAt values may be in the past. Keep them for
|
|
398
|
+
// display/missed-run handling instead of dropping the task.
|
|
399
|
+
if (type !== "once" || Number.isNaN(Date.parse(task.dueAt ?? task.nextRun))) return undefined;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const preservedNext = task.nextRun ?? task.dueAt;
|
|
403
|
+
if (preservedNext && !Number.isNaN(Date.parse(preservedNext))) {
|
|
404
|
+
migrated.nextRun = migrated.enabled && !isTerminal(migrated) ? new Date(preservedNext).toISOString() : undefined;
|
|
405
|
+
migrated.dueAt = new Date(preservedNext).toISOString();
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (action === "shell") {
|
|
409
|
+
const hasShellPrompt = Boolean(task.followUpPrompt || task.successPrompt || task.failurePrompt);
|
|
410
|
+
try {
|
|
411
|
+
migrated.wakeOn = normalizeWakeOn(task.wakeOn, hasShellPrompt);
|
|
412
|
+
} catch {
|
|
413
|
+
migrated.wakeOn = hasShellPrompt ? "always" : "never";
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return migrated;
|
|
418
|
+
}
|
|
419
|
+
|
|
254
420
|
function isTaskShape(task) {
|
|
255
|
-
|
|
256
|
-
if (typeof task.id !== "string" || !VALID_ACTIONS.has(task.action)) return false;
|
|
257
|
-
if (!VALID_STATUSES.has(task.status)) return false;
|
|
258
|
-
if (Number.isNaN(Date.parse(task.dueAt))) return false;
|
|
259
|
-
return true;
|
|
421
|
+
return normalizeTask(task) !== undefined;
|
|
260
422
|
}
|
|
261
423
|
|
|
262
|
-
function sanitizeTasks(value) {
|
|
424
|
+
function sanitizeTasks(value, nowValue = new Date()) {
|
|
263
425
|
if (!Array.isArray(value)) return [];
|
|
264
|
-
return value.filter(
|
|
426
|
+
return value.map((task) => normalizeTask(task, nowValue)).filter(Boolean);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
function sortByNextRun(a, b) {
|
|
430
|
+
const aTime = Date.parse(a.nextRun ?? a.dueAt ?? "");
|
|
431
|
+
const bTime = Date.parse(b.nextRun ?? b.dueAt ?? "");
|
|
432
|
+
return (Number.isFinite(aTime) ? aTime : Number.POSITIVE_INFINITY) - (Number.isFinite(bTime) ? bTime : Number.POSITIVE_INFINITY);
|
|
265
433
|
}
|
|
266
434
|
|
|
267
435
|
function pendingTasks(tasks) {
|
|
268
436
|
return tasks
|
|
269
|
-
.filter((task) => task.
|
|
437
|
+
.filter((task) => task.enabled !== false && !isTerminal(task))
|
|
270
438
|
.slice()
|
|
271
|
-
.sort(
|
|
439
|
+
.sort(sortByNextRun);
|
|
272
440
|
}
|
|
273
441
|
|
|
274
442
|
function dueTasks(tasks, nowValue = new Date()) {
|
|
275
443
|
const now = asDate(nowValue).getTime();
|
|
276
|
-
return pendingTasks(tasks).filter((task) => Date.parse(task.dueAt) <= now);
|
|
444
|
+
return pendingTasks(tasks).filter((task) => task.status === "pending" && Date.parse(task.nextRun ?? task.dueAt) <= now);
|
|
277
445
|
}
|
|
278
446
|
|
|
279
447
|
function findTask(tasks, idOrPrefix) {
|
|
@@ -285,9 +453,88 @@ function cancelScheduledTask(tasks, idOrPrefix, nowValue = new Date()) {
|
|
|
285
453
|
const now = asDate(nowValue);
|
|
286
454
|
const task = findTask(tasks, idOrPrefix);
|
|
287
455
|
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
288
|
-
if (task.status
|
|
456
|
+
if (task.status === "cancelled") throw new Error(`Scheduled task ${task.id} is already cancelled`);
|
|
457
|
+
task.enabled = false;
|
|
289
458
|
task.status = "cancelled";
|
|
290
459
|
task.cancelledAt = now.toISOString();
|
|
460
|
+
task.nextRun = undefined;
|
|
461
|
+
return task;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function disableScheduledTask(tasks, idOrPrefix, nowValue = new Date()) {
|
|
465
|
+
const now = asDate(nowValue);
|
|
466
|
+
const task = findTask(tasks, idOrPrefix);
|
|
467
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
468
|
+
task.enabled = false;
|
|
469
|
+
task.disabledAt = now.toISOString();
|
|
470
|
+
task.nextRun = undefined;
|
|
471
|
+
if (task.status === "running") task.status = "pending";
|
|
472
|
+
return task;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function enableScheduledTask(tasks, idOrPrefix, nowValue = new Date()) {
|
|
476
|
+
const task = findTask(tasks, idOrPrefix);
|
|
477
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
478
|
+
if (task.status === "cancelled" || task.status === "failed" || task.status === "fired") task.status = "pending";
|
|
479
|
+
task.enabled = true;
|
|
480
|
+
const validated = validateTaskSchedule(task.type ?? "once", task.schedule ?? task.whenText ?? task.dueAt, nowValue);
|
|
481
|
+
task.type = validated.type;
|
|
482
|
+
task.schedule = validated.schedule;
|
|
483
|
+
if (validated.intervalMs !== undefined) task.intervalMs = validated.intervalMs;
|
|
484
|
+
task.nextRun = validated.nextRun;
|
|
485
|
+
task.dueAt = validated.dueAt;
|
|
486
|
+
return task;
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function removeScheduledTask(tasks, idOrPrefix) {
|
|
490
|
+
const task = findTask(tasks, idOrPrefix);
|
|
491
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
492
|
+
const index = tasks.indexOf(task);
|
|
493
|
+
if (index >= 0) tasks.splice(index, 1);
|
|
494
|
+
return task;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function updateScheduledTask(tasks, idOrPrefix, updates = {}, nowValue = new Date()) {
|
|
498
|
+
const task = findTask(tasks, idOrPrefix);
|
|
499
|
+
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
500
|
+
|
|
501
|
+
if (updates.action !== undefined) task.action = normalizeAction(updates.action);
|
|
502
|
+
if (updates.type !== undefined) task.type = normalizeType(updates.type);
|
|
503
|
+
if (updates.scope !== undefined) task.scope = normalizeScope(updates.scope);
|
|
504
|
+
if (updates.enabled !== undefined) task.enabled = Boolean(updates.enabled);
|
|
505
|
+
if (updates.name !== undefined) task.name = compactSpaces(updates.name);
|
|
506
|
+
if (updates.title !== undefined) task.title = compactSpaces(updates.title);
|
|
507
|
+
if (updates.description !== undefined) task.description = compactSpaces(updates.description);
|
|
508
|
+
if (updates.maxRuns !== undefined) task.maxRuns = normalizeMaxRuns(updates.maxRuns);
|
|
509
|
+
if (updates.cwd !== undefined) task.cwd = String(updates.cwd);
|
|
510
|
+
if (updates.sessionFile !== undefined) task.sessionFile = String(updates.sessionFile);
|
|
511
|
+
if (updates.timeoutMs !== undefined) task.timeoutMs = validateTimeoutMs(updates.timeoutMs);
|
|
512
|
+
if (updates.followUpPrompt !== undefined) task.followUpPrompt = compactSpaces(updates.followUpPrompt) || undefined;
|
|
513
|
+
if (updates.successPrompt !== undefined) task.successPrompt = compactSpaces(updates.successPrompt) || undefined;
|
|
514
|
+
if (updates.failurePrompt !== undefined) task.failurePrompt = compactSpaces(updates.failurePrompt) || undefined;
|
|
515
|
+
if (updates.wakeOn !== undefined) task.wakeOn = normalizeWakeOn(updates.wakeOn, true);
|
|
516
|
+
if (updates.prompt !== undefined) task.prompt = compactSpaces(updates.prompt);
|
|
517
|
+
if (updates.message !== undefined) task.message = compactSpaces(updates.message);
|
|
518
|
+
if (updates.command !== undefined) task.command = compactSpaces(updates.command);
|
|
519
|
+
if (updates.triggerTurn !== undefined) task.triggerTurn = Boolean(updates.triggerTurn);
|
|
520
|
+
|
|
521
|
+
if (updates.schedule !== undefined || updates.when !== undefined || updates.whenText !== undefined || updates.type !== undefined) {
|
|
522
|
+
const schedule = compactSpaces(updates.schedule ?? updates.whenText ?? updates.when ?? task.schedule ?? task.whenText ?? "");
|
|
523
|
+
const validated = validateTaskSchedule(task.type ?? "once", schedule, nowValue);
|
|
524
|
+
task.type = validated.type;
|
|
525
|
+
task.schedule = validated.schedule;
|
|
526
|
+
task.whenText = schedule;
|
|
527
|
+
task.dueAt = validated.dueAt;
|
|
528
|
+
task.nextRun = task.enabled === false ? undefined : validated.nextRun;
|
|
529
|
+
if (validated.intervalMs !== undefined) task.intervalMs = validated.intervalMs;
|
|
530
|
+
else delete task.intervalMs;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (task.enabled !== false && !isTerminal(task) && !task.nextRun) {
|
|
534
|
+
const validated = validateTaskSchedule(task.type ?? "once", task.schedule ?? task.whenText, nowValue);
|
|
535
|
+
task.nextRun = validated.nextRun;
|
|
536
|
+
task.dueAt = validated.dueAt;
|
|
537
|
+
}
|
|
291
538
|
return task;
|
|
292
539
|
}
|
|
293
540
|
|
|
@@ -295,32 +542,92 @@ function markScheduledTaskRunning(tasks, idOrPrefix, nowValue = new Date()) {
|
|
|
295
542
|
const now = asDate(nowValue);
|
|
296
543
|
const task = findTask(tasks, idOrPrefix);
|
|
297
544
|
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
298
|
-
if (task.
|
|
545
|
+
if (task.enabled === false || isTerminal(task)) return task;
|
|
299
546
|
task.status = "running";
|
|
547
|
+
task.lastStatus = "running";
|
|
300
548
|
task.startedAt = now.toISOString();
|
|
301
549
|
return task;
|
|
302
550
|
}
|
|
303
551
|
|
|
304
|
-
function
|
|
552
|
+
function finishTaskAfterRun(task, now, ok, result) {
|
|
553
|
+
task.runCount = (Number.isInteger(task.runCount) ? task.runCount : 0) + 1;
|
|
554
|
+
task.lastRun = now.toISOString();
|
|
555
|
+
task.lastStatus = ok ? "success" : "error";
|
|
556
|
+
if (ok) delete task.lastError;
|
|
557
|
+
if (result !== undefined) task.result = result;
|
|
558
|
+
|
|
559
|
+
const reachedMaxRuns = task.maxRuns !== undefined && task.runCount >= task.maxRuns;
|
|
560
|
+
if (task.type === "once" || reachedMaxRuns) {
|
|
561
|
+
task.enabled = false;
|
|
562
|
+
task.status = ok ? "fired" : "failed";
|
|
563
|
+
task.firedAt = ok ? now.toISOString() : task.firedAt;
|
|
564
|
+
task.failedAt = ok ? task.failedAt : now.toISOString();
|
|
565
|
+
task.nextRun = undefined;
|
|
566
|
+
return task;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
task.enabled = true;
|
|
570
|
+
task.status = "pending";
|
|
571
|
+
try {
|
|
572
|
+
const validated = validateTaskSchedule(task.type, task.schedule, now);
|
|
573
|
+
task.nextRun = validated.nextRun;
|
|
574
|
+
task.dueAt = validated.dueAt;
|
|
575
|
+
if (validated.intervalMs !== undefined) task.intervalMs = validated.intervalMs;
|
|
576
|
+
} catch {
|
|
577
|
+
task.enabled = false;
|
|
578
|
+
task.status = ok ? "fired" : "failed";
|
|
579
|
+
task.nextRun = undefined;
|
|
580
|
+
}
|
|
581
|
+
return task;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
function markScheduledTaskCompleted(tasks, idOrPrefix, nowValue = new Date(), result, options = {}) {
|
|
305
585
|
const now = asDate(nowValue);
|
|
306
586
|
const task = findTask(tasks, idOrPrefix);
|
|
307
587
|
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
308
588
|
if (task.status === "cancelled") return task;
|
|
309
|
-
task.
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
589
|
+
return finishTaskAfterRun(task, now, options.ok !== false, result);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function markScheduledTaskFired(tasks, idOrPrefix, nowValue = new Date(), result) {
|
|
593
|
+
return markScheduledTaskCompleted(tasks, idOrPrefix, nowValue, result, { ok: true });
|
|
313
594
|
}
|
|
314
595
|
|
|
315
596
|
function markScheduledTaskFailed(tasks, idOrPrefix, nowValue = new Date(), error) {
|
|
316
|
-
const now = asDate(nowValue);
|
|
317
597
|
const task = findTask(tasks, idOrPrefix);
|
|
318
598
|
if (!task) throw new Error(`Scheduled task not found: ${idOrPrefix}`);
|
|
319
|
-
|
|
320
|
-
task
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
599
|
+
task.lastError = error instanceof Error ? error.message : String(error);
|
|
600
|
+
return finishTaskAfterRun(task, asDate(nowValue), false, undefined);
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
function shellResultOk(result) {
|
|
604
|
+
if (typeof result?.ok === "boolean") return result.ok;
|
|
605
|
+
if (typeof result?.code === "number") return result.code === 0 && result.killed !== true;
|
|
606
|
+
return true;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function hasShellFollowUpPrompt(task) {
|
|
610
|
+
return Boolean(task.followUpPrompt || task.successPrompt || task.failurePrompt);
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
function shouldWakeForShellResult(task, result) {
|
|
614
|
+
if (!hasShellFollowUpPrompt(task) && !task.wakeOn) return false;
|
|
615
|
+
const wakeOn = normalizeWakeOn(task.wakeOn, hasShellFollowUpPrompt(task));
|
|
616
|
+
const ok = shellResultOk(result);
|
|
617
|
+
if (wakeOn === "never") return false;
|
|
618
|
+
if (wakeOn === "always") return true;
|
|
619
|
+
if (wakeOn === "success") return ok;
|
|
620
|
+
if (wakeOn === "failure") return !ok;
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function selectShellFollowUpPrompt(task, result) {
|
|
625
|
+
const ok = shellResultOk(result);
|
|
626
|
+
if (ok && task.successPrompt) return task.successPrompt;
|
|
627
|
+
if (!ok && task.failurePrompt) return task.failurePrompt;
|
|
628
|
+
if (task.followUpPrompt) return task.followUpPrompt;
|
|
629
|
+
if (task.wakeOn && task.wakeOn !== "never") return "Review this scheduled shell command result and decide next steps.";
|
|
630
|
+
return undefined;
|
|
324
631
|
}
|
|
325
632
|
|
|
326
633
|
function formatRelativeTime(dueAt, nowValue = new Date()) {
|
|
@@ -346,41 +653,62 @@ function formatRelativeTime(dueAt, nowValue = new Date()) {
|
|
|
346
653
|
return `in ${parts.join(" ") || "<1s"}`;
|
|
347
654
|
}
|
|
348
655
|
|
|
656
|
+
function formatSchedule(task) {
|
|
657
|
+
if (task.type === "interval") return `every ${task.schedule}`;
|
|
658
|
+
if (task.type === "cron") return `cron ${task.schedule}`;
|
|
659
|
+
return task.schedule ?? task.whenText ?? task.dueAt;
|
|
660
|
+
}
|
|
661
|
+
|
|
349
662
|
function formatTaskLine(task, nowValue = new Date()) {
|
|
350
|
-
const
|
|
351
|
-
const
|
|
352
|
-
|
|
663
|
+
const label = task.name || task.title || task.id;
|
|
664
|
+
const next = task.nextRun ? `${formatRelativeTime(task.nextRun, nowValue)} (${new Date(task.nextRun).toLocaleString()})` : "no next run";
|
|
665
|
+
const enabled = task.enabled === false ? "disabled" : "enabled";
|
|
666
|
+
const last = task.lastStatus ? ` last=${task.lastStatus}` : "";
|
|
667
|
+
return `- ${task.id} ${label} next=${next} [${task.action}/${task.type}] ${enabled} status=${task.status} runs=${task.runCount ?? 0}${last} schedule=${formatSchedule(task)} :: ${taskSummary(task)}`;
|
|
353
668
|
}
|
|
354
669
|
|
|
355
670
|
function formatTaskList(tasks, nowValue = new Date(), options = {}) {
|
|
356
|
-
const list = options.includeAll
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
if (list.length === 0) return options.includeAll ? "No scheduled tasks." : "No pending scheduled tasks.";
|
|
360
|
-
const title = options.includeAll ? "Scheduled tasks:" : "Pending scheduled tasks:";
|
|
671
|
+
const list = options.includeAll ? tasks.slice().sort(sortByNextRun) : pendingTasks(tasks);
|
|
672
|
+
if (list.length === 0) return options.includeAll ? "No scheduled tasks." : "No active scheduled tasks.";
|
|
673
|
+
const title = options.includeAll ? "Scheduled tasks:" : "Active scheduled tasks:";
|
|
361
674
|
return [title, ...list.map((task) => formatTaskLine(task, nowValue))].join("\n");
|
|
362
675
|
}
|
|
363
676
|
|
|
364
677
|
module.exports = {
|
|
365
678
|
VALID_ACTIONS,
|
|
679
|
+
VALID_TYPES,
|
|
366
680
|
VALID_STATUSES,
|
|
681
|
+
VALID_SCOPES,
|
|
682
|
+
VALID_WAKE_ON,
|
|
367
683
|
SECOND,
|
|
368
684
|
MINUTE,
|
|
369
685
|
HOUR,
|
|
370
686
|
DAY,
|
|
371
687
|
parseDurationMs,
|
|
372
688
|
parseWhen,
|
|
689
|
+
validateTaskSchedule,
|
|
373
690
|
splitScheduleCommand,
|
|
374
691
|
normalizeAction,
|
|
692
|
+
normalizeType,
|
|
693
|
+
normalizeScope,
|
|
694
|
+
normalizeWakeOn,
|
|
375
695
|
generateId,
|
|
376
696
|
createScheduledTask,
|
|
697
|
+
normalizeTask,
|
|
377
698
|
sanitizeTasks,
|
|
378
699
|
pendingTasks,
|
|
379
700
|
dueTasks,
|
|
380
701
|
cancelScheduledTask,
|
|
702
|
+
disableScheduledTask,
|
|
703
|
+
enableScheduledTask,
|
|
704
|
+
removeScheduledTask,
|
|
705
|
+
updateScheduledTask,
|
|
381
706
|
markScheduledTaskRunning,
|
|
707
|
+
markScheduledTaskCompleted,
|
|
382
708
|
markScheduledTaskFired,
|
|
383
709
|
markScheduledTaskFailed,
|
|
710
|
+
shouldWakeForShellResult,
|
|
711
|
+
selectShellFollowUpPrompt,
|
|
384
712
|
formatRelativeTime,
|
|
385
713
|
formatTaskLine,
|
|
386
714
|
formatTaskList,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jl1990/pi-scheduler",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Give Pi coding agents a clock: schedule reminders, shell commands, and self-waking prompts for CI polling and autonomous follow-ups.",
|
|
5
5
|
"author": "jl1990",
|
|
6
6
|
"license": "MIT",
|
|
@@ -30,6 +30,10 @@
|
|
|
30
30
|
"README.md",
|
|
31
31
|
"LICENSE.md"
|
|
32
32
|
],
|
|
33
|
+
"scripts": {
|
|
34
|
+
"test": "node --test test/*.test.cjs",
|
|
35
|
+
"pack:dry-run": "npm pack --dry-run"
|
|
36
|
+
},
|
|
33
37
|
"publishConfig": {
|
|
34
38
|
"access": "public"
|
|
35
39
|
},
|
|
@@ -58,8 +62,7 @@
|
|
|
58
62
|
"optional": true
|
|
59
63
|
}
|
|
60
64
|
},
|
|
61
|
-
"
|
|
62
|
-
"
|
|
63
|
-
"pack:dry-run": "npm pack --dry-run"
|
|
65
|
+
"dependencies": {
|
|
66
|
+
"croner": "10.0.1"
|
|
64
67
|
}
|
|
65
|
-
}
|
|
68
|
+
}
|