@hank-warren/pi-loop 0.7.0 → 0.8.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,182 +0,0 @@
1
- /**
2
- * A five-field cron parser, minute granularity, stdlib only.
3
- *
4
- * Deliberately small and numeric: `minute hour day-of-month month
5
- * day-of-week`, each field `*`, a number, `a-b`, `a,b,c`, `*/n`, or
6
- * `a-b/n`. No names, no `@daily`, no seconds, no timezones beyond the host's
7
- * local time. Every one of those is a place where two implementations
8
- * disagree, and a scheduler whose semantics are debatable is worse than one
9
- * that refuses the expression.
10
- *
11
- * Day-of-month and day-of-week follow the traditional cron rule: when both
12
- * are restricted, a day matching *either* fires. That is genuinely surprising
13
- * behaviour, but it is what every crontab in the world means, and inventing a
14
- * more sensible rule here would be the bigger trap.
15
- */
16
-
17
- export interface CronSpec {
18
- expression: string;
19
- minutes: ReadonlySet<number>;
20
- hours: ReadonlySet<number>;
21
- daysOfMonth: ReadonlySet<number>;
22
- months: ReadonlySet<number>;
23
- daysOfWeek: ReadonlySet<number>;
24
- /** Both day fields restricted: match either, per traditional cron. */
25
- dayUnion: boolean;
26
- }
27
-
28
- interface FieldRange {
29
- min: number;
30
- max: number;
31
- }
32
-
33
- const FIELDS: ReadonlyArray<{ name: string; range: FieldRange }> = [
34
- { name: "minute", range: { min: 0, max: 59 } },
35
- { name: "hour", range: { min: 0, max: 23 } },
36
- { name: "day-of-month", range: { min: 1, max: 31 } },
37
- { name: "month", range: { min: 1, max: 12 } },
38
- { name: "day-of-week", range: { min: 0, max: 7 } },
39
- ];
40
-
41
- export type CronParseResult =
42
- | { ok: true; spec: CronSpec }
43
- | { ok: false; error: string };
44
-
45
- export function parseCron(expression: string): CronParseResult {
46
- const fields = expression.trim().split(/\s+/u);
47
- if (fields.length !== 5) {
48
- return {
49
- ok: false,
50
- error: `a cron expression has 5 fields (minute hour day-of-month month day-of-week), got ${fields.length}`,
51
- };
52
- }
53
- const parsed: Array<Set<number>> = [];
54
- for (let index = 0; index < FIELDS.length; index += 1) {
55
- const field = FIELDS[index];
56
- const text = fields[index];
57
- if (!field || text === undefined) return { ok: false, error: "malformed cron expression" };
58
- const values = parseField(text, field.range);
59
- if (!values) return { ok: false, error: `invalid ${field.name} field: ${text}` };
60
- parsed.push(values);
61
- }
62
- const [minutes, hours, daysOfMonth, months, rawDaysOfWeek] = parsed as [
63
- Set<number>,
64
- Set<number>,
65
- Set<number>,
66
- Set<number>,
67
- Set<number>,
68
- ];
69
- // 7 and 0 are both Sunday.
70
- const daysOfWeek = new Set([...rawDaysOfWeek].map((day) => (day === 7 ? 0 : day)));
71
- return {
72
- ok: true,
73
- spec: {
74
- expression: fields.join(" "),
75
- minutes,
76
- hours,
77
- daysOfMonth,
78
- months,
79
- daysOfWeek,
80
- dayUnion: fields[2] !== "*" && fields[4] !== "*",
81
- },
82
- };
83
- }
84
-
85
- function parseField(text: string, range: FieldRange): Set<number> | undefined {
86
- const values = new Set<number>();
87
- for (const part of text.split(",")) {
88
- const [spec, stepText] = part.split("/");
89
- if (spec === undefined || spec === "") return undefined;
90
- let step = 1;
91
- if (stepText !== undefined) {
92
- step = Number(stepText);
93
- if (!Number.isSafeInteger(step) || step <= 0) return undefined;
94
- }
95
- let from: number;
96
- let to: number;
97
- if (spec === "*") {
98
- from = range.min;
99
- to = range.max;
100
- } else if (spec.includes("-")) {
101
- const [fromText, toText, ...rest] = spec.split("-");
102
- if (rest.length > 0) return undefined;
103
- from = Number(fromText);
104
- to = Number(toText);
105
- } else {
106
- from = Number(spec);
107
- to = from;
108
- }
109
- if (!Number.isSafeInteger(from) || !Number.isSafeInteger(to) || from > to) return undefined;
110
- if (from < range.min || to > range.max) return undefined;
111
- for (let value = from; value <= to; value += step) values.add(value);
112
- }
113
- return values.size > 0 ? values : undefined;
114
- }
115
-
116
- /** Whether a local-time date matches the spec, to the minute. */
117
- export function cronMatches(spec: CronSpec, date: Date): boolean {
118
- if (!spec.minutes.has(date.getMinutes())) return false;
119
- if (!spec.hours.has(date.getHours())) return false;
120
- if (!spec.months.has(date.getMonth() + 1)) return false;
121
- const domMatch = spec.daysOfMonth.has(date.getDate());
122
- const dowMatch = spec.daysOfWeek.has(date.getDay());
123
- return spec.dayUnion ? domMatch || dowMatch : domMatch && dowMatch;
124
- }
125
-
126
- /** How far ahead a next-fire search gives up, in days. */
127
- const SEARCH_LIMIT_DAYS = 5 * 366;
128
-
129
- /**
130
- * The next local-time minute at or after `after` that matches, or undefined
131
- * when the expression can never fire again (30 February and friends).
132
- *
133
- * Scans day by day and only walks minutes inside a matching day, so an
134
- * expression that fires once a year costs a few thousand cheap comparisons
135
- * rather than half a million.
136
- */
137
- export function nextCronFire(spec: CronSpec, after: number): number | undefined {
138
- const cursor = new Date(after);
139
- cursor.setSeconds(0, 0);
140
- cursor.setMinutes(cursor.getMinutes() + 1);
141
- for (let day = 0; day <= SEARCH_LIMIT_DAYS; day += 1) {
142
- if (dayCouldMatch(spec, cursor)) {
143
- const hit = firstMatchingMinuteOfDay(spec, cursor, day === 0);
144
- if (hit !== undefined) return hit;
145
- }
146
- cursor.setDate(cursor.getDate() + 1);
147
- cursor.setHours(0, 0, 0, 0);
148
- }
149
- return undefined;
150
- }
151
-
152
- function dayCouldMatch(spec: CronSpec, date: Date): boolean {
153
- if (!spec.months.has(date.getMonth() + 1)) return false;
154
- const domMatch = spec.daysOfMonth.has(date.getDate());
155
- const dowMatch = spec.daysOfWeek.has(date.getDay());
156
- return spec.dayUnion ? domMatch || dowMatch : domMatch && dowMatch;
157
- }
158
-
159
- function firstMatchingMinuteOfDay(
160
- spec: CronSpec,
161
- dayStart: Date,
162
- respectCursorTime: boolean,
163
- ): number | undefined {
164
- const startHour = respectCursorTime ? dayStart.getHours() : 0;
165
- const startMinute = respectCursorTime ? dayStart.getMinutes() : 0;
166
- for (const hour of sorted(spec.hours)) {
167
- if (hour < startHour) continue;
168
- for (const minute of sorted(spec.minutes)) {
169
- if (hour === startHour && minute < startMinute) continue;
170
- const candidate = new Date(dayStart);
171
- candidate.setHours(hour, minute, 0, 0);
172
- // A DST jump can move the wall clock off the requested slot; the
173
- // match check is authoritative.
174
- if (cronMatches(spec, candidate)) return candidate.getTime();
175
- }
176
- }
177
- return undefined;
178
- }
179
-
180
- function sorted(values: ReadonlySet<number>): number[] {
181
- return [...values].sort((a, b) => a - b);
182
- }
@@ -1,129 +0,0 @@
1
- /**
2
- * The `/schedule` manager, in the same shape as the `/loop` manager: Pi's
3
- * native dialog primitives, and a plain notification in non-TUI modes so
4
- * every action is reachable without a menu.
5
- */
6
-
7
- import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
- import { describeSchedule, nextFireAt, type ScheduledTask } from "./model.js";
9
- import type { Scheduler } from "./runner.js";
10
-
11
- export async function showScheduleManager(
12
- scheduler: Scheduler,
13
- ctx: ExtensionCommandContext,
14
- ): Promise<void> {
15
- if (ctx.mode !== "tui") {
16
- ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
17
- return;
18
- }
19
- for (;;) {
20
- const tasks = scheduler.tasks();
21
- if (tasks.length === 0) {
22
- ctx.ui.notify(
23
- 'No scheduled tasks. Create one with /schedule every 30m <prompt>, /schedule at +2h <prompt>, or /schedule cron "0 9 * * 1" <prompt>.',
24
- "info",
25
- );
26
- return;
27
- }
28
- const rows = tasks.map((task) => summarizeRow(task));
29
- const choice = await ctx.ui.select("Scheduled tasks", rows);
30
- if (choice === undefined) return;
31
- const task = tasks[rows.indexOf(choice)];
32
- if (!task) return;
33
- if (!(await manageTask(scheduler, ctx, task))) return;
34
- }
35
- }
36
-
37
- /** Returns false when the manager should close. */
38
- async function manageTask(
39
- scheduler: Scheduler,
40
- ctx: ExtensionCommandContext,
41
- task: ScheduledTask,
42
- ): Promise<boolean> {
43
- const actions = [
44
- "Details",
45
- task.status === "paused" ? "Resume" : "Pause",
46
- "Run now",
47
- "Delete",
48
- "Back",
49
- ];
50
- const action = await ctx.ui.select(`${task.name} · ${task.status}`, actions);
51
- if (action === undefined || action === "Back") return true;
52
- switch (action) {
53
- case "Details":
54
- ctx.ui.notify(describeTask(task).join("\n"), "info");
55
- return true;
56
- case "Pause":
57
- scheduler.update({ ...task, status: "paused" });
58
- ctx.ui.notify(`Paused "${task.name}".`, "info");
59
- return true;
60
- case "Resume":
61
- scheduler.update({ ...task, status: "active" });
62
- ctx.ui.notify(`Resumed "${task.name}".`, "info");
63
- return true;
64
- case "Run now":
65
- scheduler.fireNow(task);
66
- ctx.ui.notify(`Running "${task.name}" now.`, "info");
67
- return true;
68
- case "Delete": {
69
- const confirmed = await ctx.ui.confirm("Delete task?", `Delete "${task.name}"?`);
70
- if (!confirmed) return true;
71
- scheduler.remove(task.id);
72
- ctx.ui.notify(`Deleted "${task.name}".`, "info");
73
- return true;
74
- }
75
- default:
76
- return true;
77
- }
78
- }
79
-
80
- export function listTasks(scheduler: Scheduler): string[] {
81
- const tasks = scheduler.tasks();
82
- if (tasks.length === 0) {
83
- return [
84
- "No scheduled tasks.",
85
- 'Create one with /schedule every 30m <prompt>, /schedule at +2h <prompt>, or /schedule cron "0 9 * * 1" <prompt>.',
86
- ];
87
- }
88
- return tasks.map((task) => summarizeRow(task));
89
- }
90
-
91
- function summarizeRow(task: ScheduledTask): string {
92
- const next = nextFireAt(task, Date.now());
93
- // A finished or paused task has no "next" to report; saying "next done"
94
- // reads as though something were still scheduled.
95
- const when =
96
- task.status !== "active"
97
- ? task.status
98
- : next === undefined
99
- ? "never again"
100
- : `next ${new Date(next).toLocaleString()}`;
101
- const result = task.lastResult ? ` · last ${task.lastResult.ok ? "ok" : "failed"}` : "";
102
- return `${task.id} · ${task.task.kind} · ${describeSchedule(task.schedule)} · ${when}${result} · ${task.name}`;
103
- }
104
-
105
- export function describeTask(task: ScheduledTask): string[] {
106
- const next = nextFireAt(task, Date.now());
107
- const lines = [
108
- `Task: ${task.name}`,
109
- `Id: ${task.id}`,
110
- `Kind: ${task.task.kind === "run" ? "headless run" : "in-session prompt"}`,
111
- `Schedule: ${describeSchedule(task.schedule)}`,
112
- `Status: ${task.status}`,
113
- `Runs: ${task.runs}${task.maxRuns === null ? " (unlimited)" : ` of ${task.maxRuns}`}`,
114
- `Next fire: ${next === undefined ? "never again" : new Date(next).toLocaleString()}`,
115
- `Expires: ${new Date(task.expiresAt).toLocaleString()}`,
116
- ];
117
- if (task.task.kind === "run") {
118
- lines.push(`Working directory: ${task.task.cwd}`, `Wake the session: ${task.task.wakeOn}`);
119
- }
120
- if (task.lastResult) {
121
- lines.push(
122
- `Last result: ${task.lastResult.ok ? "ok" : "failed"} at ${new Date(task.lastResult.at).toLocaleString()}${
123
- task.lastResult.detail ? ` — ${task.lastResult.detail}` : ""
124
- }`,
125
- );
126
- }
127
- lines.push(`Prompt: ${task.task.prompt}`);
128
- return lines;
129
- }
@@ -1,237 +0,0 @@
1
- /**
2
- * The scheduled-task model: what a task is, when it next fires, and how it is
3
- * normalized back off disk.
4
- *
5
- * Two task kinds, deliberately different in lifetime:
6
- *
7
- * - `prompt` injects a prompt into the owning session. It is **session
8
- * scoped** — it lives in memory and dies with the session, because a prompt
9
- * with no session to arrive in is not a task, it is a leak.
10
- * - `run` spawns a headless `pi -p` in a working directory. It outlives the
11
- * session, so it is the only kind persisted, and the only kind that needs
12
- * the single-writer lease.
13
- *
14
- * Every task is bounded twice: `maxRuns` (default 25, `unlimited` is an
15
- * explicit opt-in, mirroring the loop caps) and a hard 90-day expiry. A
16
- * forgotten schedule that fires forever is the failure mode this whole family
17
- * of features is prone to.
18
- */
19
-
20
- import { type CronSpec, nextCronFire, parseCron } from "./cron.js";
21
-
22
- export const DEFAULT_MAX_RUNS = 25;
23
- export const TASK_LIFETIME_MS = 90 * 86_400_000;
24
- /** Interval floor, matching the loop's own 1-minute clamp. */
25
- export const MIN_INTERVAL_MS = 60_000;
26
-
27
- export type ScheduleSpec =
28
- | { kind: "once"; at: number }
29
- | { kind: "interval"; everyMs: number }
30
- | { kind: "cron"; expression: string };
31
-
32
- export type WakeOn = "always" | "failure" | "success" | "never";
33
- export const WAKE_ON_VALUES: readonly WakeOn[] = ["always", "failure", "success", "never"];
34
-
35
- export type TaskSpec =
36
- | { kind: "prompt"; prompt: string }
37
- | { kind: "run"; prompt: string; cwd: string; wakeOn: WakeOn };
38
-
39
- export type TaskStatus = "active" | "paused" | "done";
40
-
41
- export interface ScheduledTask {
42
- id: string;
43
- name: string;
44
- schedule: ScheduleSpec;
45
- task: TaskSpec;
46
- status: TaskStatus;
47
- createdAt: number;
48
- expiresAt: number;
49
- /** null means unlimited, and must be asked for explicitly. */
50
- maxRuns: number | null;
51
- runs: number;
52
- lastRunAt?: number;
53
- lastResult?: { at: number; ok: boolean; detail?: string };
54
- }
55
-
56
- export function isPersistentTask(task: ScheduledTask): boolean {
57
- return task.task.kind === "run";
58
- }
59
-
60
- /**
61
- * The next fire at or after `after`, or undefined when the task will never
62
- * fire again (expired, done, paused, a one-shot already past, or a cron
63
- * expression with no future match).
64
- *
65
- * A missed one-shot still returns its own time, in the past: catching up is
66
- * the caller's decision, and it is exactly one fire, never one per missed
67
- * occurrence.
68
- */
69
- export function nextFireAt(task: ScheduledTask, after: number): number | undefined {
70
- if (task.status !== "active") return undefined;
71
- if (after >= task.expiresAt) return undefined;
72
- if (task.maxRuns !== null && task.runs >= task.maxRuns) return undefined;
73
- const fire = computeNextFire(task, after);
74
- if (fire === undefined || fire >= task.expiresAt) return undefined;
75
- return fire;
76
- }
77
-
78
- function computeNextFire(task: ScheduledTask, after: number): number | undefined {
79
- switch (task.schedule.kind) {
80
- case "once":
81
- // Still pending if it has never run, whether or not it is overdue.
82
- return task.runs === 0 ? task.schedule.at : undefined;
83
- case "interval": {
84
- const base = task.lastRunAt ?? task.createdAt;
85
- const elapsed = after - base;
86
- if (elapsed < task.schedule.everyMs) return base + task.schedule.everyMs;
87
- // Overdue by any number of intervals: one fire, now. Firing once per
88
- // missed interval is how a laptop that slept for a weekend wakes up
89
- // to two hundred queued turns.
90
- return after;
91
- }
92
- case "cron": {
93
- const parsed = parseCron(task.schedule.expression);
94
- if (!parsed.ok) return undefined;
95
- return nextCronFireForTask(parsed.spec, task, after);
96
- }
97
- }
98
- }
99
-
100
- function nextCronFireForTask(
101
- spec: CronSpec,
102
- task: ScheduledTask,
103
- after: number,
104
- ): number | undefined {
105
- // A cron occurrence missed while nothing was running is coalesced the same
106
- // way: the search starts from the last run, and anything already past
107
- // collapses into a single fire at `after`.
108
- const from = task.lastRunAt ?? task.createdAt;
109
- const next = nextCronFire(spec, Math.max(from, task.createdAt) - 1);
110
- if (next === undefined) return undefined;
111
- return next <= after ? after : next;
112
- }
113
-
114
- export function describeSchedule(schedule: ScheduleSpec): string {
115
- switch (schedule.kind) {
116
- case "once":
117
- return `once at ${new Date(schedule.at).toLocaleString()}`;
118
- case "interval":
119
- return `every ${formatMs(schedule.everyMs)}`;
120
- case "cron":
121
- return `cron ${schedule.expression}`;
122
- }
123
- }
124
-
125
- function formatMs(ms: number): string {
126
- for (const [unit, size] of [
127
- ["d", 86_400_000],
128
- ["h", 3_600_000],
129
- ["m", 60_000],
130
- ] as const) {
131
- if (ms >= size && ms % size === 0) return `${ms / size}${unit}`;
132
- }
133
- return `${Math.round(ms / 1_000)}s`;
134
- }
135
-
136
- // --- normalization ---
137
-
138
- const MAX_TEXT = 100_000;
139
- const MAX_TIMESTAMP = 8_640_000_000_000_000;
140
-
141
- export function normalizeTask(value: unknown): ScheduledTask | undefined {
142
- const record = asRecord(value);
143
- if (!record) return undefined;
144
- const id = text(record.id, 200);
145
- const name = text(record.name, 200);
146
- if (!id || !name || /[\s/]/.test(id)) return undefined;
147
- const schedule = normalizeSchedule(record.schedule);
148
- const task = normalizeTaskSpec(record.task);
149
- if (!schedule || !task) return undefined;
150
- const status = record.status;
151
- if (status !== "active" && status !== "paused" && status !== "done") return undefined;
152
- if (!isTimestamp(record.createdAt) || !isTimestamp(record.expiresAt)) return undefined;
153
- const maxRuns = record.maxRuns;
154
- if (maxRuns !== null && !isPositiveInteger(maxRuns)) return undefined;
155
- const runs = record.runs;
156
- if (typeof runs !== "number" || !Number.isSafeInteger(runs) || runs < 0) return undefined;
157
- if (record.lastRunAt !== undefined && !isTimestamp(record.lastRunAt)) return undefined;
158
- const lastResult = normalizeResult(record.lastResult);
159
- if (lastResult === false) return undefined;
160
- return {
161
- id,
162
- name,
163
- schedule,
164
- task,
165
- status,
166
- createdAt: record.createdAt as number,
167
- expiresAt: record.expiresAt as number,
168
- maxRuns: maxRuns as number | null,
169
- runs,
170
- ...(record.lastRunAt === undefined ? {} : { lastRunAt: record.lastRunAt as number }),
171
- ...(lastResult === undefined ? {} : { lastResult }),
172
- };
173
- }
174
-
175
- function normalizeSchedule(value: unknown): ScheduleSpec | undefined {
176
- const record = asRecord(value);
177
- if (!record) return undefined;
178
- if (record.kind === "once") {
179
- return isTimestamp(record.at) ? { kind: "once", at: record.at as number } : undefined;
180
- }
181
- if (record.kind === "interval") {
182
- const everyMs = record.everyMs;
183
- return isPositiveInteger(everyMs) && everyMs >= MIN_INTERVAL_MS
184
- ? { kind: "interval", everyMs }
185
- : undefined;
186
- }
187
- if (record.kind === "cron") {
188
- const expression = text(record.expression, 200);
189
- if (!expression || !parseCron(expression).ok) return undefined;
190
- return { kind: "cron", expression };
191
- }
192
- return undefined;
193
- }
194
-
195
- function normalizeTaskSpec(value: unknown): TaskSpec | undefined {
196
- const record = asRecord(value);
197
- if (!record) return undefined;
198
- const prompt = text(record.prompt, MAX_TEXT);
199
- if (!prompt) return undefined;
200
- if (record.kind === "prompt") return { kind: "prompt", prompt };
201
- if (record.kind !== "run") return undefined;
202
- const cwd = text(record.cwd, 4_096);
203
- const wakeOn = record.wakeOn;
204
- if (!cwd || !WAKE_ON_VALUES.includes(wakeOn as WakeOn)) return undefined;
205
- return { kind: "run", prompt, cwd, wakeOn: wakeOn as WakeOn };
206
- }
207
-
208
- function normalizeResult(value: unknown): ScheduledTask["lastResult"] | undefined | false {
209
- if (value === undefined) return undefined;
210
- const record = asRecord(value);
211
- if (!record || !isTimestamp(record.at) || typeof record.ok !== "boolean") return false;
212
- const detail = record.detail === undefined ? undefined : text(record.detail, 4_000);
213
- if (record.detail !== undefined && !detail) return false;
214
- return { at: record.at as number, ok: record.ok, ...(detail ? { detail } : {}) };
215
- }
216
-
217
- function asRecord(value: unknown): Record<string, unknown> | undefined {
218
- return value && typeof value === "object" && !Array.isArray(value)
219
- ? (value as Record<string, unknown>)
220
- : undefined;
221
- }
222
-
223
- function text(value: unknown, max: number): string | undefined {
224
- if (typeof value !== "string") return undefined;
225
- const trimmed = value.trim();
226
- return trimmed && trimmed.length <= max ? trimmed : undefined;
227
- }
228
-
229
- function isPositiveInteger(value: unknown): value is number {
230
- return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
231
- }
232
-
233
- function isTimestamp(value: unknown): value is number {
234
- return (
235
- typeof value === "number" && Number.isSafeInteger(value) && value >= 0 && value <= MAX_TIMESTAMP
236
- );
237
- }