@jl1990/pi-scheduler 0.1.1 → 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.
@@ -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.replace(/^in\s+/, "").replace(/,/g, " ").replace(/\band\b/g, " ").replace(/\s+/g, " ").trim();
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
- const leftTokens = left.split(" ");
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 whenText = restTokens.slice(0, i).join(" ");
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
- parseWhen(whenText, nowValue);
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 whenText = compactSpaces(input.whenText ?? input.when ?? input.due ?? "");
209
- const parsed = parseWhen(whenText, now);
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: new Date(parsed.dueAtMs).toISOString(),
216
- whenText,
305
+ dueAt: validated.dueAt,
306
+ nextRun: enabled ? validated.nextRun : undefined,
307
+ runCount: 0,
308
+ scope: normalizeScope(input.scope),
217
309
  };
218
310
 
219
- const message = compactSpaces(input.message ?? input.payload ?? "");
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
- if (!task || typeof task !== "object") return false;
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(isTaskShape);
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.status === "pending")
437
+ .filter((task) => task.enabled !== false && !isTerminal(task))
270
438
  .slice()
271
- .sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt));
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 !== "pending") throw new Error(`Scheduled task ${task.id} is already ${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.status !== "pending") return 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 markScheduledTaskFired(tasks, idOrPrefix, nowValue = new Date(), result) {
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.status = "fired";
310
- task.firedAt = now.toISOString();
311
- if (result !== undefined) task.result = result;
312
- return task;
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
- if (task.status === "cancelled") return task;
320
- task.status = "failed";
321
- task.failedAt = now.toISOString();
322
- task.error = error instanceof Error ? error.message : String(error);
323
- return task;
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 due = new Date(task.dueAt);
351
- const status = task.status === "pending" ? formatRelativeTime(task.dueAt, nowValue) : task.status;
352
- return `- ${task.id} ${status} (${due.toLocaleString()}) [${task.action}] ${taskSummary(task)}`;
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
- ? tasks.slice().sort((a, b) => Date.parse(a.dueAt) - Date.parse(b.dueAt))
358
- : pendingTasks(tasks);
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.1.1",
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",
@@ -61,5 +61,8 @@
61
61
  "typebox": {
62
62
  "optional": true
63
63
  }
64
+ },
65
+ "dependencies": {
66
+ "croner": "10.0.1"
64
67
  }
65
68
  }