@hank-warren/pi-loop 0.6.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.
- package/CHANGELOG.md +60 -0
- package/README.md +25 -52
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +155 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +54 -13
- package/src/decide.ts +13 -16
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +204 -10
- package/src/loop.ts +185 -55
- package/src/manager.ts +80 -11
- package/src/messages.ts +12 -5
- package/src/objective.ts +13 -2
- package/src/planning.ts +98 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +119 -0
- package/src/settings.ts +48 -17
- package/src/start-tool.ts +56 -2
- package/src/state.ts +40 -20
- package/src/widget.ts +103 -11
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/widget.ts
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The loop widget:
|
|
3
|
-
* footer status (interval · iteration/cap · next wake), with the loop focus
|
|
2
|
+
* The loop widget: one themed line above the editor, with the loop's focus
|
|
4
3
|
* dimmed below it when set.
|
|
5
4
|
*
|
|
5
|
+
* Two rules shape what goes on that line.
|
|
6
|
+
*
|
|
7
|
+
* **Show progress, not consumption.** The line used to lead with the interval
|
|
8
|
+
* and then report turns against the turn cap. The interval is a fallback
|
|
9
|
+
* heartbeat — a settle-paced loop can run its whole life without delivering
|
|
10
|
+
* one — and turns-against-cap is budget burn, which says nothing about how
|
|
11
|
+
* much of the objective is done. Criteria met over criteria total is the
|
|
12
|
+
* progress number, and it leads.
|
|
13
|
+
*
|
|
14
|
+
* **One surface, one story.** The widget and the footer status render the same
|
|
15
|
+
* state, so they render it from the same function. They disagreed before:
|
|
16
|
+
* `setStatus` handled `loop.waiting` and the widget did not, so a loop blocked
|
|
17
|
+
* on CI showed an ordinary "next 17:53" above the editor while the footer said
|
|
18
|
+
* it was waiting.
|
|
19
|
+
*
|
|
20
|
+
* States are ordered by how much they want a human, because the top of that
|
|
21
|
+
* order is the whole reason to glance at the line: paused and blocked and
|
|
22
|
+
* expiring come before the ordinary running line.
|
|
23
|
+
*
|
|
6
24
|
* Presentation only: every entry point tolerates a host without setWidget
|
|
7
25
|
* (test fixtures, print mode) and swallows render-side failures, because a
|
|
8
26
|
* widget must never interrupt loop state transitions.
|
|
9
27
|
*/
|
|
10
28
|
|
|
11
29
|
import { Text } from "@earendil-works/pi-tui";
|
|
12
|
-
import { formatClock,
|
|
30
|
+
import { formatClock, formatElapsed } from "./interval.js";
|
|
13
31
|
import type { LoopState } from "./state.js";
|
|
14
32
|
|
|
15
33
|
export const LOOP_WIDGET_KEY = "loop";
|
|
@@ -21,27 +39,61 @@ interface WidgetTheme {
|
|
|
21
39
|
|
|
22
40
|
type WidgetHost = { setWidget?: unknown };
|
|
23
41
|
|
|
24
|
-
|
|
42
|
+
/** Criteria progress, absent when the loop has no readable ledger. */
|
|
43
|
+
export interface CriteriaProgress {
|
|
44
|
+
met: number;
|
|
45
|
+
total: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The loop is being drafted with the user and has not started. */
|
|
49
|
+
export interface LoopPlanningView {
|
|
50
|
+
kind: "planning";
|
|
51
|
+
/** Criteria in the proposed draft, once one has been put up for approval. */
|
|
52
|
+
proposedCriteria?: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export interface LoopRunningView {
|
|
56
|
+
kind: "loop";
|
|
25
57
|
loop: LoopState;
|
|
26
58
|
/** A wake is held for the next idle boundary. */
|
|
27
59
|
wakePending: boolean;
|
|
28
60
|
/** Epoch ms of the next scheduled tick, when armed. */
|
|
29
61
|
nextWakeAt: number | undefined;
|
|
62
|
+
criteria?: CriteriaProgress;
|
|
63
|
+
/**
|
|
64
|
+
* How long the session has been busy with no completed turn. Set only past
|
|
65
|
+
* the stall threshold, where a blocking prompt is the likely explanation.
|
|
66
|
+
*/
|
|
67
|
+
blockedForMs?: number;
|
|
68
|
+
/** Injected so the elapsed span is deterministic in tests. */
|
|
69
|
+
now?: number;
|
|
30
70
|
}
|
|
31
71
|
|
|
72
|
+
export type LoopWidgetView = LoopPlanningView | LoopRunningView;
|
|
73
|
+
|
|
74
|
+
/** How urgently a line wants a human; picks the colour. */
|
|
75
|
+
type Tone = "normal" | "attention" | "planning";
|
|
76
|
+
|
|
32
77
|
export function updateLoopWidget(ui: WidgetHost, view: LoopWidgetView | undefined) {
|
|
33
78
|
const setWidget = resolveSetWidget(ui);
|
|
34
79
|
if (!setWidget) return;
|
|
35
80
|
try {
|
|
36
|
-
if (!view || view.loop.status === "stopped") {
|
|
81
|
+
if (!view || (view.kind === "loop" && view.loop.status === "stopped")) {
|
|
37
82
|
setWidget(LOOP_WIDGET_KEY, undefined);
|
|
38
83
|
return;
|
|
39
84
|
}
|
|
40
85
|
setWidget(LOOP_WIDGET_KEY, (_tui: unknown, theme: WidgetTheme) => {
|
|
41
86
|
const bold = theme.bold ?? identity;
|
|
87
|
+
const paint = (tone: Tone, text: string) => {
|
|
88
|
+
if (tone === "normal") return text;
|
|
89
|
+
return theme.fg?.(tone === "attention" ? "warning" : "accent", text) ?? text;
|
|
90
|
+
};
|
|
42
91
|
const dim = (text: string) => theme.fg?.("dim", text) ?? text;
|
|
43
|
-
const focus =
|
|
44
|
-
|
|
92
|
+
const focus =
|
|
93
|
+
view.kind === "loop" && view.loop.prompt
|
|
94
|
+
? `\n${dim(` focus: ${view.loop.prompt}`)}`
|
|
95
|
+
: "";
|
|
96
|
+
return new Text(`${paint(widgetTone(view), bold(loopWidgetLine(view)))}${focus}`);
|
|
45
97
|
});
|
|
46
98
|
} catch {
|
|
47
99
|
// Presentation only; a widget failure must never break a loop transition.
|
|
@@ -58,16 +110,56 @@ export function clearLoopWidget(ui: WidgetHost) {
|
|
|
58
110
|
}
|
|
59
111
|
}
|
|
60
112
|
|
|
61
|
-
|
|
113
|
+
/** Exported for tests: the tone the line renders in. */
|
|
114
|
+
export function widgetTone(view: LoopWidgetView): Tone {
|
|
115
|
+
if (view.kind === "planning") return "planning";
|
|
62
116
|
const loop = view.loop;
|
|
63
|
-
if (loop.status === "paused"
|
|
64
|
-
|
|
117
|
+
if (loop.status === "paused" || loop.expiring || view.blockedForMs !== undefined) {
|
|
118
|
+
return "attention";
|
|
119
|
+
}
|
|
120
|
+
return "normal";
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function loopWidgetLine(view: LoopWidgetView): string {
|
|
124
|
+
if (view.kind === "planning") {
|
|
125
|
+
return view.proposedCriteria === undefined
|
|
126
|
+
? "◆ loop planning · drafting an objective"
|
|
127
|
+
: `◆ loop planning · ${view.proposedCriteria} criteria proposed · approve to start`;
|
|
128
|
+
}
|
|
129
|
+
const loop = view.loop;
|
|
130
|
+
|
|
131
|
+
// Ordered by how much the state wants a human. A paused or blocked loop is
|
|
132
|
+
// not making progress, so reporting progress numbers first would bury the
|
|
133
|
+
// only fact that matters.
|
|
134
|
+
if (loop.status === "paused") {
|
|
135
|
+
return `⏸ loop paused${loop.pauseCause ? ` · ${loop.pauseCause}` : ""}`;
|
|
136
|
+
}
|
|
137
|
+
if (loop.expiring) return "⚠ loop expiring · write your state into the ledger";
|
|
138
|
+
if (view.blockedForMs !== undefined) {
|
|
139
|
+
// The engine cannot see the prompt itself: it only knows the session has
|
|
140
|
+
// been busy without completing a turn, which a blocking prompt explains
|
|
141
|
+
// and ordinary long work also explains. Say which one is being reported.
|
|
142
|
+
return `⚠ loop blocked · no turn for ${formatElapsed(view.blockedForMs)} · a prompt may be waiting`;
|
|
143
|
+
}
|
|
144
|
+
if (loop.waiting) {
|
|
145
|
+
const until =
|
|
146
|
+
loop.waiting.resumeAt === undefined
|
|
147
|
+
? "no deadline"
|
|
148
|
+
: `until ${formatClock(loop.waiting.resumeAt)}`;
|
|
149
|
+
return `⏳ loop waiting · ${loop.waiting.reason} · ${until}`;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cap = loop.maxTurns === null ? "∞" : `${loop.maxTurns}`;
|
|
65
153
|
const next = view.wakePending
|
|
66
154
|
? "next on idle"
|
|
67
155
|
: view.nextWakeAt !== undefined
|
|
68
156
|
? `next ${formatClock(view.nextWakeAt)}`
|
|
69
157
|
: "next unscheduled";
|
|
70
|
-
|
|
158
|
+
const elapsed = formatElapsed((view.now ?? Date.now()) - loop.startedAt);
|
|
159
|
+
// Progress leads when there is progress to report. Turns are still shown,
|
|
160
|
+
// but as the budget they are, not as the headline.
|
|
161
|
+
const progress = view.criteria ? `${view.criteria.met}/${view.criteria.total} done · ` : "";
|
|
162
|
+
return `⟳ loop ${progress}turn ${loop.automaticTurns}/${cap} · ${elapsed} · ${next}`;
|
|
71
163
|
}
|
|
72
164
|
|
|
73
165
|
function identity(text: string) {
|
package/src/schedule/command.ts
DELETED
|
@@ -1,255 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Deterministic `/schedule` parsing. Grammar:
|
|
3
|
-
*
|
|
4
|
-
* /schedule manager TUI
|
|
5
|
-
* /schedule list
|
|
6
|
-
* /schedule every <dur> [flags] <prompt...>
|
|
7
|
-
* /schedule at <ISO|+dur> [flags] <prompt...>
|
|
8
|
-
* /schedule cron "<m h dom mon dow>" [flags] <prompt...>
|
|
9
|
-
* /schedule pause|resume|delete|run|status <id>
|
|
10
|
-
*
|
|
11
|
-
* Flags: --run (headless instead of an in-session prompt), --cwd <path>,
|
|
12
|
-
* --max <n|unlimited>, --wake always|failure|success|never, --name <text>.
|
|
13
|
-
*
|
|
14
|
-
* The extension owns this grammar, never the model — same reason `/loop`
|
|
15
|
-
* does. A model that can schedule its own future turns can schedule its way
|
|
16
|
-
* around every limit the loop imposes.
|
|
17
|
-
*/
|
|
18
|
-
|
|
19
|
-
import { parseDuration } from "../interval.js";
|
|
20
|
-
import { parseCron } from "./cron.js";
|
|
21
|
-
import {
|
|
22
|
-
MIN_INTERVAL_MS,
|
|
23
|
-
type ScheduleSpec,
|
|
24
|
-
type TaskSpec,
|
|
25
|
-
WAKE_ON_VALUES,
|
|
26
|
-
type WakeOn,
|
|
27
|
-
} from "./model.js";
|
|
28
|
-
|
|
29
|
-
export const SCHEDULE_SUBCOMMANDS = [
|
|
30
|
-
"list",
|
|
31
|
-
"pause",
|
|
32
|
-
"resume",
|
|
33
|
-
"delete",
|
|
34
|
-
"run",
|
|
35
|
-
"status",
|
|
36
|
-
] as const;
|
|
37
|
-
export type ScheduleSubcommand = (typeof SCHEDULE_SUBCOMMANDS)[number];
|
|
38
|
-
|
|
39
|
-
export interface ScheduleCreateCommand {
|
|
40
|
-
kind: "create";
|
|
41
|
-
name: string;
|
|
42
|
-
schedule: ScheduleSpec;
|
|
43
|
-
task: TaskSpec;
|
|
44
|
-
maxRuns?: number | null;
|
|
45
|
-
/** Set when a sub-minute interval was raised to the floor. */
|
|
46
|
-
clampedFrom?: number;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
export type ScheduleCommand =
|
|
50
|
-
| { kind: "show" }
|
|
51
|
-
| { kind: "list" }
|
|
52
|
-
| { kind: ScheduleSubcommand; id: string }
|
|
53
|
-
| ScheduleCreateCommand
|
|
54
|
-
| { kind: "error"; message: string };
|
|
55
|
-
|
|
56
|
-
export interface ParseScheduleOptions {
|
|
57
|
-
/** Default working directory for a headless run. */
|
|
58
|
-
cwd: string;
|
|
59
|
-
now?: number;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
export function parseScheduleCommand(
|
|
63
|
-
args: string,
|
|
64
|
-
options: ParseScheduleOptions,
|
|
65
|
-
): ScheduleCommand {
|
|
66
|
-
const trimmed = args.trim();
|
|
67
|
-
if (!trimmed) return { kind: "show" };
|
|
68
|
-
const tokens = [...args.matchAll(/\S+/g)].map((match) => ({
|
|
69
|
-
text: match[0],
|
|
70
|
-
index: match.index,
|
|
71
|
-
}));
|
|
72
|
-
const head = tokens[0]?.text ?? "";
|
|
73
|
-
if (head === "list") return { kind: "list" };
|
|
74
|
-
if ((SCHEDULE_SUBCOMMANDS as readonly string[]).includes(head)) {
|
|
75
|
-
const id = tokens[1]?.text;
|
|
76
|
-
if (!id) return { kind: "error", message: `/schedule ${head} needs a task id.` };
|
|
77
|
-
return { kind: head as ScheduleSubcommand, id };
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
const now = options.now ?? Date.now();
|
|
81
|
-
let position = 1;
|
|
82
|
-
let schedule: ScheduleSpec | undefined;
|
|
83
|
-
let clampedFrom: number | undefined;
|
|
84
|
-
if (head === "every") {
|
|
85
|
-
const value = tokens[1]?.text;
|
|
86
|
-
if (!value) return { kind: "error", message: "/schedule every needs a duration, e.g. 30m." };
|
|
87
|
-
const everyMs = parseDuration(value);
|
|
88
|
-
if (everyMs === undefined) {
|
|
89
|
-
return {
|
|
90
|
-
kind: "error",
|
|
91
|
-
message: `Invalid interval: ${value}. Use <number><unit> with unit s, m, h, or d.`,
|
|
92
|
-
};
|
|
93
|
-
}
|
|
94
|
-
if (everyMs < MIN_INTERVAL_MS) clampedFrom = everyMs;
|
|
95
|
-
schedule = { kind: "interval", everyMs: Math.max(MIN_INTERVAL_MS, everyMs) };
|
|
96
|
-
position = 2;
|
|
97
|
-
} else if (head === "at") {
|
|
98
|
-
const value = tokens[1]?.text;
|
|
99
|
-
if (!value) {
|
|
100
|
-
return {
|
|
101
|
-
kind: "error",
|
|
102
|
-
message: "/schedule at needs a time: an ISO timestamp, or +<duration> such as +90m.",
|
|
103
|
-
};
|
|
104
|
-
}
|
|
105
|
-
const at = parseAt(value, now);
|
|
106
|
-
if (at === undefined) {
|
|
107
|
-
return {
|
|
108
|
-
kind: "error",
|
|
109
|
-
message: `Invalid time: ${value}. Use an ISO timestamp (2026-01-31T09:00) or +<duration> such as +90m.`,
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
if (at <= now) return { kind: "error", message: `That time is in the past: ${value}.` };
|
|
113
|
-
schedule = { kind: "once", at };
|
|
114
|
-
position = 2;
|
|
115
|
-
} else if (head === "cron") {
|
|
116
|
-
const quoted = readQuoted(args, tokens[1]?.index ?? 0);
|
|
117
|
-
const expression = quoted?.value ?? tokens.slice(1, 6).map((token) => token.text).join(" ");
|
|
118
|
-
const parsed = parseCron(expression);
|
|
119
|
-
if (!parsed.ok) return { kind: "error", message: `Invalid cron expression: ${parsed.error}.` };
|
|
120
|
-
schedule = { kind: "cron", expression: parsed.spec.expression };
|
|
121
|
-
position = quoted ? tokenIndexAfter(tokens, quoted.end) : 6;
|
|
122
|
-
} else {
|
|
123
|
-
return {
|
|
124
|
-
kind: "error",
|
|
125
|
-
message: `Unknown /schedule form: ${head}. Use every <dur>, at <time>, cron "<expr>", list, pause, resume, run, status, or delete.`,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
let headless = false;
|
|
130
|
-
let cwd = options.cwd;
|
|
131
|
-
let wakeOn: WakeOn = "failure";
|
|
132
|
-
let maxRuns: number | null | undefined;
|
|
133
|
-
let name: string | undefined;
|
|
134
|
-
while (position < tokens.length) {
|
|
135
|
-
const token = tokens[position];
|
|
136
|
-
if (token === undefined || !token.text.startsWith("--")) break;
|
|
137
|
-
const [flag, inline] = splitFlag(token.text);
|
|
138
|
-
const value = inline ?? tokens[position + 1]?.text;
|
|
139
|
-
if (flag === "--run") {
|
|
140
|
-
headless = true;
|
|
141
|
-
position += 1;
|
|
142
|
-
continue;
|
|
143
|
-
}
|
|
144
|
-
if (value === undefined) return { kind: "error", message: `${flag} needs a value.` };
|
|
145
|
-
if (flag === "--cwd") {
|
|
146
|
-
cwd = value;
|
|
147
|
-
} else if (flag === "--wake") {
|
|
148
|
-
if (!WAKE_ON_VALUES.includes(value as WakeOn)) {
|
|
149
|
-
return {
|
|
150
|
-
kind: "error",
|
|
151
|
-
message: `Invalid --wake value: ${value}. Use ${WAKE_ON_VALUES.join(", ")}.`,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
wakeOn = value as WakeOn;
|
|
155
|
-
} else if (flag === "--max") {
|
|
156
|
-
if (value === "unlimited" || value === "null") maxRuns = null;
|
|
157
|
-
else {
|
|
158
|
-
const parsed = Number(value);
|
|
159
|
-
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
|
160
|
-
return {
|
|
161
|
-
kind: "error",
|
|
162
|
-
message: `Invalid --max value: ${value}. Use a positive whole number or unlimited.`,
|
|
163
|
-
};
|
|
164
|
-
}
|
|
165
|
-
maxRuns = parsed;
|
|
166
|
-
}
|
|
167
|
-
} else if (flag === "--name") {
|
|
168
|
-
name = value;
|
|
169
|
-
} else {
|
|
170
|
-
return {
|
|
171
|
-
kind: "error",
|
|
172
|
-
message: `Unknown flag: ${flag}. Known flags: --run, --cwd, --max, --wake, --name.`,
|
|
173
|
-
};
|
|
174
|
-
}
|
|
175
|
-
position += inline === undefined ? 2 : 1;
|
|
176
|
-
}
|
|
177
|
-
|
|
178
|
-
const promptToken = tokens[position];
|
|
179
|
-
const prompt = promptToken === undefined ? "" : args.slice(promptToken.index).trim();
|
|
180
|
-
if (!prompt) {
|
|
181
|
-
return { kind: "error", message: "A scheduled task needs a prompt to run." };
|
|
182
|
-
}
|
|
183
|
-
return {
|
|
184
|
-
kind: "create",
|
|
185
|
-
name: name ?? summarize(prompt),
|
|
186
|
-
schedule,
|
|
187
|
-
task: headless ? { kind: "run", prompt, cwd, wakeOn } : { kind: "prompt", prompt },
|
|
188
|
-
...(maxRuns === undefined ? {} : { maxRuns }),
|
|
189
|
-
...(clampedFrom === undefined ? {} : { clampedFrom }),
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function parseAt(value: string, now: number): number | undefined {
|
|
194
|
-
if (value.startsWith("+")) {
|
|
195
|
-
const delay = parseDuration(value.slice(1));
|
|
196
|
-
return delay === undefined ? undefined : now + delay;
|
|
197
|
-
}
|
|
198
|
-
const parsed = Date.parse(value);
|
|
199
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/** Read a quoted string starting at `from`, or undefined when unquoted. */
|
|
203
|
-
function readQuoted(args: string, from: number): { value: string; end: number } | undefined {
|
|
204
|
-
const quote = args[from];
|
|
205
|
-
if (quote !== '"' && quote !== "'") return undefined;
|
|
206
|
-
const end = args.indexOf(quote, from + 1);
|
|
207
|
-
if (end === -1) return undefined;
|
|
208
|
-
return { value: args.slice(from + 1, end), end };
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
function tokenIndexAfter(
|
|
212
|
-
tokens: ReadonlyArray<{ text: string; index: number }>,
|
|
213
|
-
offset: number,
|
|
214
|
-
): number {
|
|
215
|
-
for (let index = 0; index < tokens.length; index += 1) {
|
|
216
|
-
const token = tokens[index];
|
|
217
|
-
if (token && token.index > offset) return index;
|
|
218
|
-
}
|
|
219
|
-
return tokens.length;
|
|
220
|
-
}
|
|
221
|
-
|
|
222
|
-
function splitFlag(token: string): [string, string | undefined] {
|
|
223
|
-
const equals = token.indexOf("=");
|
|
224
|
-
if (equals === -1) return [token, undefined];
|
|
225
|
-
return [token.slice(0, equals), token.slice(equals + 1)];
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
function summarize(prompt: string): string {
|
|
229
|
-
const collapsed = prompt.replace(/\s+/gu, " ").trim();
|
|
230
|
-
return collapsed.length <= 60 ? collapsed : `${collapsed.slice(0, 59)}…`;
|
|
231
|
-
}
|
|
232
|
-
|
|
233
|
-
export interface ScheduleArgumentCompletion {
|
|
234
|
-
value: string;
|
|
235
|
-
label: string;
|
|
236
|
-
description?: string;
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
const COMPLETIONS: readonly ScheduleArgumentCompletion[] = [
|
|
240
|
-
{ value: "every", label: "every", description: "Repeat on an interval, e.g. every 30m" },
|
|
241
|
-
{ value: "at", label: "at", description: "Fire once, e.g. at +90m or an ISO timestamp" },
|
|
242
|
-
{ value: "cron", label: "cron", description: 'Fire on a cron expression, e.g. cron "0 9 * * 1"' },
|
|
243
|
-
{ value: "list", label: "list", description: "List scheduled tasks" },
|
|
244
|
-
{ value: "pause", label: "pause", description: "Pause a task" },
|
|
245
|
-
{ value: "resume", label: "resume", description: "Resume a paused task" },
|
|
246
|
-
{ value: "run", label: "run", description: "Run a task now" },
|
|
247
|
-
{ value: "status", label: "status", description: "Show one task in detail" },
|
|
248
|
-
{ value: "delete", label: "delete", description: "Delete a task" },
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
export function completeScheduleArguments(prefix: string): ScheduleArgumentCompletion[] | null {
|
|
252
|
-
const trimmed = prefix.trimStart();
|
|
253
|
-
const matches = COMPLETIONS.filter((candidate) => candidate.value.startsWith(trimmed));
|
|
254
|
-
return matches.length > 0 ? matches : null;
|
|
255
|
-
}
|
package/src/schedule/cron.ts
DELETED
|
@@ -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
|
-
}
|