@voidlabs.gg/voidshift-agents 0.4.2 → 0.4.4

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 CHANGED
@@ -5,7 +5,7 @@ One CLI for the Void dev tools. Install it once, run `vsf` (short for
5
5
  defaults you can change. Everything setup does can be seen and changed later
6
6
  from the command line.
7
7
 
8
- > Early release. macOS only for now; Windows and Linux are next.
8
+ > Early release. macOS and Linux (awake is macOS only); Windows is next.
9
9
 
10
10
  ```sh
11
11
  npm i -g @voidlabs.gg/voidshift
@@ -50,15 +50,24 @@ Five agents, installed into Claude Code (and Codex if you use it):
50
50
  | QA | CI, then code review, then a usability check in a headless browser |
51
51
 
52
52
  Use them yourself (`/vs-plan ENG-123` in Claude Code), or let a **workflow**
53
- run them. A workflow starts when one of your Linear cards gets a label, runs
54
- agents in order in a project (a repo on your Mac), and sends work back to the
55
- step that made it when a check fails:
53
+ run them. A workflow starts when one of your Linear or VoidTrack cards gets a
54
+ label, or on a schedule, runs agents in order in a project (a repo on your
55
+ machine), and sends work back to the step that made it when a check fails:
56
56
 
57
57
  ```
58
58
  when ENG + vs-plan: Dev → QA in tether-relay
59
59
  ```
60
60
 
61
- The runner checks Linear every minute, works in its own copy of the repo so
61
+ Triggers, as `--trigger` takes them:
62
+
63
+ ```
64
+ ENG:vs-plan # a Linear card in ENG assigned to you gets vs-plan
65
+ voidtrack:ORG:vs-start # the same for VoidTrack (vsf agents key --voidtrack)
66
+ "cron:0 5 * * 1:Update dependencies" # every Monday 05:00, no card, on its own branch
67
+ ```
68
+
69
+ A schedule fires once when due (a missed one is made up within a day) and
70
+ `vsf agents run --workflow NAME` fires it now. The runner checks every minute, works in its own copy of the repo so
62
71
  your checkout is never touched, and keeps two labels on the card as it goes: the
63
72
  stage (`vs-planning`, `vs-plan-review`, `vs-dev`, `vs-qa`, then `vs-qa-verified`
64
73
  or `vs-parked`) and the round (`vs-v1`, `vs-v2`). On Linear it posts the plan and
@@ -104,10 +113,13 @@ to trust the recall hook once: run `/hooks` in Codex.
104
113
  - **Your own logins.** Agents run the official `claude` and `codex` CLIs you
105
114
  already use, signed in with your own account. VoidShift never reads or
106
115
  stores those credentials. The Linear API key for the runner lives in your
107
- macOS Keychain.
116
+ macOS Keychain, or on Linux in `~/.config/voidshift/agents/secrets.json`
117
+ (mode 0600).
108
118
  - **Trust.** The Dev agent can run shell commands inside its copy of the repo
109
119
  so it can install and test. Review its pull requests like any other.
110
120
  - **Your config** lives in `~/.config/voidshift`.
121
+ - **Linux.** The runner is a systemd user timer. Run
122
+ `sudo loginctl enable-linger $USER` so it keeps going after you log out.
111
123
 
112
124
  ```sh
113
125
  vsf doctor # check everything is set up, and whether an update is out
package/dist/config.d.ts CHANGED
@@ -13,11 +13,21 @@ export declare const AGENTS: Record<string, {
13
13
  }>;
14
14
  export declare const visibleAgents: () => string[];
15
15
  export declare const stepEnabled: (step: StepId, enabled: string[]) => boolean;
16
- export interface Trigger {
17
- source: 'linear';
16
+ export type Tracker = 'linear' | 'voidtrack';
17
+ export interface CardTrigger {
18
+ source: Tracker;
18
19
  team: string;
19
20
  label: string;
20
21
  }
22
+ export interface CronTrigger {
23
+ source: 'cron';
24
+ schedule: string;
25
+ task: string;
26
+ }
27
+ export type Trigger = CardTrigger | CronTrigger;
28
+ export declare const isCardTrigger: (t: Trigger) => t is CardTrigger;
29
+ export declare function triggerSpec(t: Trigger): string;
30
+ export declare function describeTrigger(t: Trigger): string;
21
31
  export interface Project {
22
32
  path: string;
23
33
  base?: string;
package/dist/config.js CHANGED
@@ -19,6 +19,19 @@ export const AGENTS = {
19
19
  };
20
20
  export const visibleAgents = () => Object.keys(AGENTS);
21
21
  export const stepEnabled = (step, enabled) => enabled.includes(step);
22
+ export const isCardTrigger = (t) => t.source !== 'cron';
23
+ // The form `--trigger` takes: "ENG:vs-plan" (Linear), "voidtrack:ORG:vs-start",
24
+ // or "cron:0 5 * * 1:Update the dependencies".
25
+ export function triggerSpec(t) {
26
+ if (t.source === 'cron')
27
+ return `cron:${t.schedule}:${t.task}`;
28
+ return t.source === 'voidtrack' ? `voidtrack:${t.team}:${t.label}` : `${t.team}:${t.label}`;
29
+ }
30
+ export function describeTrigger(t) {
31
+ if (t.source === 'cron')
32
+ return `cron ${t.schedule}`;
33
+ return `${t.source === 'voidtrack' ? 'VoidTrack ' : ''}${t.team} + ${t.label}`;
34
+ }
22
35
  // Step lists to start a workflow from.
23
36
  export const TEMPLATES = {
24
37
  build: { steps: ['vs-dev', 'vs-qa'], maxRounds: 3 },
@@ -40,7 +53,7 @@ const configPath = (configDir) => join(agentsDir(configDir), 'config.json');
40
53
  export const hasWork = (workflow) => workflow.steps.some((s) => STEPS[s].output === 'commits');
41
54
  export const hasProducer = (workflow) => workflow.steps.some(isProducer);
42
55
  export const describeSteps = (steps) => steps.map((s) => STEPS[s].label).join(' → ');
43
- export const describeTriggers = (triggers) => triggers.map((t) => `${t.team} + ${t.label}`).join(', ');
56
+ export const describeTriggers = (triggers) => triggers.map(describeTrigger).join(', ');
44
57
  export const describeWorkflow = (c) => `when ${describeTriggers(c.triggers)}: ${describeSteps(c.steps)} in ${c.project}`;
45
58
  // Step ids that were folded into QA.
46
59
  const RENAMED = { 'vs-qa-review': 'vs-qa', ci: null, 'vs-qa-ui': null };
package/dist/control.d.ts CHANGED
@@ -1,5 +1,8 @@
1
+ import { type AgentsConfig, type Tracker as Kind } from './config.ts';
1
2
  import { type Current, type RunEntry } from './state.ts';
2
3
  import type { Check, ToolCommand, ToolContext } from './types.ts';
4
+ export { parsePlistArgs } from './launchd.ts';
5
+ export declare const trackersInUse: (config: Pick<AgentsConfig, "workflows">) => Kind[];
3
6
  export declare function parseAge(input: string): number | null;
4
7
  export declare function formatDuration(ms: number): string;
5
8
  export declare function stampDate(stamp: string): Date;
@@ -12,13 +15,12 @@ export declare function scanRunnerErrors(text: string, now: number): {
12
15
  count: number;
13
16
  last: string;
14
17
  };
15
- export declare function parsePlistArgs(plist: string): string[];
16
18
  export declare const controlUsage: Record<string, string>;
17
19
  export interface CancelDeps {
18
20
  ours: (pid: number) => boolean;
19
21
  kill: (pid: number, signal: 'SIGTERM' | 'SIGKILL') => void;
20
22
  sleep: (ms: number) => Promise<void>;
21
- cancelCard: (card: string, label: string) => Promise<boolean>;
23
+ cancelCard: (card: string, label: string, source: Kind) => Promise<boolean>;
22
24
  }
23
25
  export declare function cancelCurrent(ctx: ToolContext, c: Current, deps: CancelDeps): Promise<void>;
24
26
  export declare const controlCommands: Record<string, ToolCommand>;
package/dist/control.js CHANGED
@@ -2,11 +2,10 @@ import { spawnSync } from 'node:child_process';
2
2
  import { closeSync, existsSync, openSync, readFileSync, readSync, readdirSync, rmSync, statSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { parseArgs } from 'node:util';
5
- import { agentsDir, readAgentsConfig } from "./config.js";
6
- import { readLinearKey } from "./keychain.js";
5
+ import { agentsDir, isCardTrigger, readAgentsConfig } from "./config.js";
7
6
  import { isPaused, listRuns, ours, clearCurrent, liveCurrent, readCurrent, setPaused, writeRun } from "./state.js";
8
- import { plistPath, runnerLoaded, startRunner, stopRunner } from "./launchd.js";
9
- import { createLinear } from "./linear.js";
7
+ import { runnerService } from "./service.js";
8
+ import { TRACKERS, trackersFor } from "./tracker.js";
10
9
  import { parseStep, resolveName, stepNames } from "./names.js";
11
10
  import { isStatusLabel } from "./workflow.js";
12
11
  import { cancelCard, tick } from "./runner.js";
@@ -14,11 +13,28 @@ const HOUR = 3_600_000;
14
13
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
15
14
  const CARD_ID = /^[A-Za-z][A-Za-z0-9]*-\d+$/;
16
15
  const ISO_LINE = /^(\d{4}-\d\d-\d\dT[\d:.]+Z)\s*(.*)$/;
17
- function linear() {
18
- const key = readLinearKey();
19
- if (!key)
20
- throw new Error('No Linear API key. Run vsf agents key');
21
- return createLinear(key);
16
+ export { parsePlistArgs } from "./launchd.js";
17
+ // Trackers the workflows' card triggers use.
18
+ export const trackersInUse = (config) => [
19
+ ...new Set(Object.values(config.workflows).flatMap((w) => w.triggers.filter(isCardTrigger).map((t) => t.source))),
20
+ ];
21
+ // A card by id, from the tracker of a workflow that starts on its team.
22
+ async function findCard(config, trackers, id) {
23
+ const team = id.split('-')[0].toUpperCase();
24
+ const sources = [...new Set(Object.values(config.workflows).flatMap((w) => w.triggers.filter(isCardTrigger).filter((t) => t.team === team).map((t) => t.source)))];
25
+ if (!sources.length)
26
+ throw new Error(`No workflow starts on team ${team}.`);
27
+ let last = null;
28
+ for (const source of sources) {
29
+ try {
30
+ const tracker = trackers(source);
31
+ return { card: await tracker.card(id), tracker, source };
32
+ }
33
+ catch (err) {
34
+ last = err;
35
+ }
36
+ }
37
+ throw last;
22
38
  }
23
39
  const logPath = (configDir) => join(agentsDir(configDir), 'runner.log');
24
40
  const program = (ctx) => [...ctx.selfCommand, 'agents', 'tick'];
@@ -88,12 +104,6 @@ export function scanRunnerErrors(text, now) {
88
104
  }
89
105
  return { count: bad.length, last: bad.at(-1) ?? '' };
90
106
  }
91
- // The <string> values of a launchd plist's ProgramArguments.
92
- export function parsePlistArgs(plist) {
93
- const block = /<key>ProgramArguments<\/key>\s*<array>([\s\S]*?)<\/array>/.exec(plist)?.[1] ?? '';
94
- const unxml = (s) => s.replace(/&lt;/g, '<').replace(/&gt;/g, '>').replace(/&amp;/g, '&');
95
- return [...block.matchAll(/<string>([\s\S]*?)<\/string>/g)].map((m) => unxml(m[1]));
96
- }
97
107
  function lastTickOf(text) {
98
108
  for (const line of text.split('\n').reverse()) {
99
109
  const m = ISO_LINE.exec(line);
@@ -160,7 +170,7 @@ const guard = (fn) => async (args, ctx) => {
160
170
  };
161
171
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
162
172
  function runnerState(ctx) {
163
- if (!runnerLoaded())
173
+ if (!runnerService().loaded())
164
174
  return 'stopped';
165
175
  return isPaused(ctx.configDir) ? 'paused' : 'running';
166
176
  }
@@ -184,7 +194,7 @@ export async function cancelCurrent(ctx, c, deps) {
184
194
  // gone just now
185
195
  }
186
196
  }
187
- ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check of Linear.');
197
+ ctx.log(c.card ? `Cancelled ${c.card}.` : 'Stopped a check.');
188
198
  return;
189
199
  }
190
200
  // Dead or a reused pid: no signal, just tidy up what it left behind.
@@ -198,47 +208,58 @@ export async function cancelCurrent(ctx, c, deps) {
198
208
  // the record is only a nicety
199
209
  }
200
210
  }
201
- if (c.card) {
202
- const label = readAgentsConfig(ctx.configDir).workflows[c.workflow ?? '']?.triggers[0]?.label ?? "the workflow's label";
203
- await deps.cancelCard(c.card, label);
211
+ if (c.card && c.source !== 'cron') {
212
+ const first = readAgentsConfig(ctx.configDir).workflows[c.workflow ?? '']?.triggers.find(isCardTrigger);
213
+ await deps.cancelCard(c.card, first?.label ?? "the workflow's label", c.source ?? 'linear');
204
214
  ctx.log(`Cleared a stale lock and cancelled ${c.card}.`);
205
215
  }
206
216
  else
207
217
  ctx.log('Cleared a stale lock.');
208
218
  clearCurrent(ctx.configDir);
209
219
  }
210
- const realDeps = (ctx, l) => ({
220
+ const realDeps = (ctx) => ({
211
221
  ours,
212
222
  kill: (pid, signal) => process.kill(pid, signal),
213
223
  sleep,
214
- cancelCard: (card, label) => cancelCard(l(), ctx.configDir, card, label),
224
+ cancelCard: async (card, label, source) => {
225
+ try {
226
+ return await cancelCard(trackersFor(ctx.configDir)(source), ctx.configDir, card, label, source);
227
+ }
228
+ catch {
229
+ return false;
230
+ }
231
+ },
215
232
  });
216
233
  async function startCmd(ctx) {
217
234
  if (!Object.keys(readAgentsConfig(ctx.configDir).workflows).length) {
218
235
  ctx.log('No workflows yet. Create one first: vsf agents workflow add.');
219
236
  return 1;
220
237
  }
221
- try {
222
- await linear().viewer();
223
- }
224
- catch (err) {
225
- const msg = err.message;
226
- ctx.log(/^No Linear API key/.test(msg) ? msg : `Linear did not accept the key: ${msg}. Run vsf agents key.`);
227
- return 1;
238
+ const trackers = trackersFor(ctx.configDir);
239
+ for (const kind of trackersInUse(readAgentsConfig(ctx.configDir))) {
240
+ try {
241
+ await trackers(kind).viewer();
242
+ }
243
+ catch (err) {
244
+ const msg = err.message;
245
+ const flag = kind === 'linear' ? '' : ` --${kind}`;
246
+ ctx.log(/^No .* API key/.test(msg) ? msg : `${TRACKERS[kind].name} did not accept the key: ${msg}. Run vsf agents key${flag}.`);
247
+ return 1;
248
+ }
228
249
  }
229
250
  setPaused(ctx.configDir, false);
230
- if (!startRunner(ctx.home, program(ctx), logPath(ctx.configDir))) {
251
+ if (!runnerService().start(ctx.home, program(ctx), logPath(ctx.configDir))) {
231
252
  ctx.log('Could not start the runner. Check vsf agents logs.');
232
253
  return 1;
233
254
  }
234
255
  return 0;
235
256
  }
236
257
  async function stopCmd(ctx) {
237
- stopRunner(ctx.home);
238
- // launchd may have stopped the pass; finish or tidy up whatever the lock still names.
258
+ runnerService().stop(ctx.home);
259
+ // Stopping the runner may have stopped the pass; finish or tidy up whatever the lock still names.
239
260
  const c = readCurrent(ctx.configDir);
240
261
  if (c)
241
- await cancelCurrent(ctx, c, realDeps(ctx, linear));
262
+ await cancelCurrent(ctx, c, realDeps(ctx));
242
263
  ctx.log('Runner stopped.');
243
264
  return 0;
244
265
  }
@@ -271,7 +292,7 @@ const status = guard(async (args, ctx) => {
271
292
  return 0;
272
293
  }
273
294
  const working = current && !current.card
274
- ? ' Checking Linear now.'
295
+ ? ' Checking now.'
275
296
  : current
276
297
  ? ` Working on ${current.card ?? 'a card'}${current.title ? ` "${current.title}"` : ''} (${[current.workflow, current.step, current.round !== undefined ? `round ${current.round + 1}` : '', `${current.elapsedMinutes}m`].filter(Boolean).join(', ')}).`
277
298
  : '';
@@ -296,7 +317,7 @@ const start = guard(async (args, ctx) => {
296
317
  return p;
297
318
  const code = await startCmd(ctx);
298
319
  if (!code)
299
- ctx.log('Runner started. It checks Linear every minute for cards that start your workflows.');
320
+ ctx.log('Runner started. Every minute it checks for cards and schedules that start your workflows.');
300
321
  return code;
301
322
  });
302
323
  const stop = guard(async (args, ctx) => {
@@ -358,7 +379,7 @@ const run = guard(async (args, ctx) => {
358
379
  return 2;
359
380
  }
360
381
  }
361
- const code = await tick({ configDir: ctx.configDir, config, linear: linear(), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
382
+ const code = await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: ctx.log, selfCommand: ctx.selfCommand }, card, { workflow, from });
362
383
  if (!card && code === 0) {
363
384
  if (isPaused(ctx.configDir))
364
385
  ctx.log('The runner is paused, so nothing was picked up. vsf agents resume, or name a card.');
@@ -383,7 +404,7 @@ const cancel = guard(async (args, ctx) => {
383
404
  ctx.log(`${c.card ?? 'Another card'} is running, not ${card}.`);
384
405
  return 1;
385
406
  }
386
- await cancelCurrent(ctx, c, realDeps(ctx, linear));
407
+ await cancelCurrent(ctx, c, realDeps(ctx));
387
408
  return 0;
388
409
  });
389
410
  const retry = guard(async (args, ctx) => {
@@ -399,9 +420,17 @@ const retry = guard(async (args, ctx) => {
399
420
  }
400
421
  const config = readAgentsConfig(ctx.configDir);
401
422
  const names = Object.keys(config.workflows);
402
- const l = linear();
403
- const card = await l.card(id);
404
- const startsOn = (n) => config.workflows[n].triggers.some((t) => t.team === card.teamKey);
423
+ let found;
424
+ try {
425
+ found = await findCard(config, trackersFor(ctx.configDir), id);
426
+ }
427
+ catch (err) {
428
+ ctx.log(err.message);
429
+ return 1;
430
+ }
431
+ const { card, source } = found;
432
+ const l = found.tracker;
433
+ const startsOn = (n) => config.workflows[n].triggers.some((t) => isCardTrigger(t) && t.source === source && t.team === card.teamKey);
405
434
  let chosen;
406
435
  if (p.values.workflow) {
407
436
  const r = resolveName(names, p.values.workflow, 'workflow');
@@ -428,7 +457,7 @@ const retry = guard(async (args, ctx) => {
428
457
  }
429
458
  chosen = candidates[0];
430
459
  }
431
- const label = config.workflows[chosen].triggers.find((t) => t.team === card.teamKey).label;
460
+ const label = config.workflows[chosen].triggers.filter(isCardTrigger).find((t) => t.source === source && t.team === card.teamKey).label;
432
461
  const next = card.labels.filter((n) => !isStatusLabel(n));
433
462
  if (!next.includes(label))
434
463
  next.push(label);
@@ -479,7 +508,7 @@ const show = guard(async (args, ctx) => {
479
508
  }
480
509
  let labels = null;
481
510
  try {
482
- labels = (await linear().card(id)).labels;
511
+ labels = (await findCard(readAgentsConfig(ctx.configDir), trackersFor(ctx.configDir), id)).card.labels;
483
512
  }
484
513
  catch {
485
514
  // live labels are optional
@@ -646,16 +675,12 @@ const clean = guard(async (args, ctx) => {
646
675
  ctx.log(`Removed ${plural(worktrees.length, 'worktree')} and ${plural(old.length, 'run')}.`);
647
676
  return 0;
648
677
  });
649
- // Internal: launchd runs this every minute.
678
+ // Internal: the runner (launchd or systemd) runs this every minute.
650
679
  const tickCmd = async (_args, ctx) => {
651
680
  const stamped = (l) => ctx.log(`${new Date().toISOString()} ${l}`);
652
681
  try {
653
682
  const config = readAgentsConfig(ctx.configDir);
654
- if (!readLinearKey()) {
655
- stamped('No Linear API key. Run vsf agents key.');
656
- return 1;
657
- }
658
- return await tick({ configDir: ctx.configDir, config, linear: linear(), log: stamped, selfCommand: ctx.selfCommand });
683
+ return await tick({ configDir: ctx.configDir, config, trackers: trackersFor(ctx.configDir), log: stamped, selfCommand: ctx.selfCommand });
659
684
  }
660
685
  catch (err) {
661
686
  stamped(`Could not run the check: ${err.message}`);
@@ -686,13 +711,14 @@ export async function controlChecks(ctx) {
686
711
  const checks = [];
687
712
  const state = runnerState(ctx);
688
713
  checks.push(state === 'running'
689
- ? { name: 'agent runner', status: 'ok', detail: 'polling Linear every minute' }
714
+ ? { name: 'agent runner', status: 'ok', detail: 'checking every minute' }
690
715
  : state === 'paused'
691
716
  ? { name: 'agent runner', status: 'warn', detail: 'paused', fix: 'vsf agents resume' }
692
717
  : { name: 'agent runner', status: 'warn', detail: 'not running', fix: 'vsf agents start' });
693
- const plist = plistPath(ctx.home);
694
- if (existsSync(plist)) {
695
- const missing = parsePlistArgs(readFileSync(plist, 'utf8')).filter((a) => a.startsWith('/') && !existsSync(a));
718
+ const service = runnerService();
719
+ const installed = service.programArgs(ctx.home);
720
+ if (installed) {
721
+ const missing = installed.filter((a) => a.startsWith('/') && !existsSync(a));
696
722
  checks.push(missing.length
697
723
  ? { name: 'runner program', status: 'fail', detail: `runner points at a missing ${missing[0]}`, fix: 'vsf agents restart' }
698
724
  : { name: 'runner program', status: 'ok', detail: 'found' });
@@ -706,6 +732,7 @@ export async function controlChecks(ctx) {
706
732
  ? { name: 'runner errors', status: 'warn', detail: `${plural(count, 'error')} in the last hour, last: ${last.slice(0, 120)}`, fix: 'vsf agents logs' }
707
733
  : { name: 'runner errors', status: 'ok', detail: 'no errors in the last hour' });
708
734
  }
735
+ checks.push(...service.checks());
709
736
  checks.push(engineLogin(readAgentsConfig(ctx.configDir).engine));
710
737
  return checks;
711
738
  }
package/dist/cron.d.ts ADDED
@@ -0,0 +1,9 @@
1
+ export interface Schedule {
2
+ sets: Set<number>[];
3
+ anyDom: boolean;
4
+ anyDow: boolean;
5
+ }
6
+ export declare function parseCron(schedule: string): Schedule | null;
7
+ export declare function matches(s: Schedule, d: Date): boolean;
8
+ export declare const CATCH_UP_MS: number;
9
+ export declare function lastDue(s: Schedule, since: Date, now: Date): Date | null;
package/dist/cron.js ADDED
@@ -0,0 +1,66 @@
1
+ // Five field cron schedules: minute hour day-of-month month day-of-week, each
2
+ // a list of *, numbers, ranges and steps (*/15, 1-5, 0,30). Sunday is 0 or 7.
3
+ // When both day fields are restricted, either may match, as in classic cron.
4
+ const FIELDS = [
5
+ [0, 59],
6
+ [0, 23],
7
+ [1, 31],
8
+ [1, 12],
9
+ [0, 7],
10
+ ];
11
+ function field(text, [min, max]) {
12
+ const out = new Set();
13
+ for (const part of text.split(',')) {
14
+ const m = /^(\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/.exec(part);
15
+ if (!m)
16
+ return null;
17
+ const step = m[4] ? Number(m[4]) : 1;
18
+ const lo = m[1] === '*' ? min : Number(m[2]);
19
+ const hi = m[1] === '*' ? max : m[3] ? Number(m[3]) : m[4] ? max : lo;
20
+ if (step < 1 || lo < min || hi > max || lo > hi)
21
+ return null;
22
+ for (let v = lo; v <= hi; v += step)
23
+ out.add(v);
24
+ }
25
+ return out;
26
+ }
27
+ export function parseCron(schedule) {
28
+ const parts = schedule.trim().split(/\s+/);
29
+ if (parts.length !== 5)
30
+ return null;
31
+ const sets = [];
32
+ for (let i = 0; i < 5; i++) {
33
+ const s = field(parts[i], FIELDS[i]);
34
+ if (!s)
35
+ return null;
36
+ sets.push(s);
37
+ }
38
+ if (sets[4].has(7))
39
+ sets[4].add(0);
40
+ return { sets, anyDom: parts[2] === '*', anyDow: parts[4] === '*' };
41
+ }
42
+ export function matches(s, d) {
43
+ const [min, hour, dom, month, dow] = s.sets;
44
+ if (!min.has(d.getMinutes()) || !hour.has(d.getHours()) || !month.has(d.getMonth() + 1))
45
+ return false;
46
+ const domOk = dom.has(d.getDate());
47
+ const dowOk = dow.has(d.getDay());
48
+ if (s.anyDom || s.anyDow)
49
+ return domOk && dowOk;
50
+ return domOk || dowOk;
51
+ }
52
+ const MINUTE = 60_000;
53
+ // How far back a missed schedule is still made up for, e.g. after the runner
54
+ // was busy or the machine was off. Older misses are skipped, not queued.
55
+ export const CATCH_UP_MS = 24 * 60 * MINUTE;
56
+ // The latest minute after `since` and up to `now` that the schedule matches,
57
+ // or null. Only the latest counts: several missed runs fire once.
58
+ export function lastDue(s, since, now) {
59
+ const floor = Math.max(since.getTime(), now.getTime() - CATCH_UP_MS);
60
+ const t = new Date(now);
61
+ t.setSeconds(0, 0);
62
+ for (; t.getTime() > floor; t.setTime(t.getTime() - MINUTE))
63
+ if (matches(s, t))
64
+ return new Date(t);
65
+ return null;
66
+ }