@lifeaitools/clauth 1.30.2 → 1.30.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.
Files changed (35) hide show
  1. package/.clauth-skill/SKILL.md +111 -111
  2. package/cli/api.classify.test.js +75 -75
  3. package/cli/assets/codevelop/launcher-active.cmd.template +20 -20
  4. package/cli/assets/codevelop/launcher-static.cmd.template +7 -7
  5. package/cli/assets/codevelop/windows-terminal.profiles.json +48 -48
  6. package/cli/assets/watchdog.ps1 +42 -42
  7. package/cli/commands/agent-cron.js +396 -396
  8. package/cli/commands/agent-pool.js +22 -6
  9. package/cli/commands/codevelop.js +1190 -1190
  10. package/cli/commands/doctor.js +302 -302
  11. package/cli/commands/install.js +10 -10
  12. package/cli/commands/invite.js +175 -175
  13. package/cli/commands/join.js +179 -179
  14. package/cli/commands/npm.js +182 -182
  15. package/cli/commands/scrub.js +327 -327
  16. package/cli/commands/scrub.test.js +115 -115
  17. package/cli/commands/serve.js +1070 -144
  18. package/cli/commands/watchdog.js +209 -209
  19. package/cli/conf-path.js +21 -21
  20. package/cli/enrollment-script.js +62 -0
  21. package/cli/index.js +1050 -1089
  22. package/cli/lib/fs-git.js +282 -282
  23. package/cli/recovery.js +101 -101
  24. package/cli/studio-debug.js +1095 -1087
  25. package/cli/watchdog-registry.js +209 -209
  26. package/cli/watchdog-registry.test.js +89 -89
  27. package/install.ps1 +21 -21
  28. package/package.json +68 -68
  29. package/scripts/bin/bootstrap-linux +0 -0
  30. package/scripts/bin/bootstrap-macos +0 -0
  31. package/scripts/bin/bootstrap-win.exe +0 -0
  32. package/supabase/migrations/001_clauth_schema.sql +12 -12
  33. package/supabase/migrations/003_clauth_config.sql +13 -13
  34. package/supabase/migrations/003_machine_enrollments.sql +39 -39
  35. package/cli/commands/serve/tools/fs.js +0 -1055
@@ -1,396 +1,396 @@
1
- // agent-cron.js — scheduled call_agent (cron) for the clauth daemon (WP-P5).
2
- //
3
- // A single setInterval inside the daemon reads `call_agent_schedules`
4
- // (enabled=true, next_run_at <= now) and, for each due row, fires the EXISTING
5
- // call_agent path in async mode (so it persists to monkey_scratchpad exactly
6
- // like any other call_agent job), then advances last_run_at + next_run_at from
7
- // the row's 5-field cron expression.
8
- //
9
- // Design constraints (see WP-P5):
10
- // * OFF by default. The whole scheduler is gated behind CLAUTH_AGENT_CRON;
11
- // unset/`0`/`false` ⇒ nothing runs. The live host keeps it off until the
12
- // supervisor enables it post-review.
13
- // * Dependency-light. A minimal pure 5-field cron parser is vendored here — no
14
- // heavy npm dep is added to the credential daemon.
15
- // * Resilient. A due-schedule firing must NOT crash the daemon: every fire is
16
- // wrapped in try/catch, logged, and the loop continues. Slow agents never
17
- // block the interval — dispatch is fire-and-forget async. Concurrent fires
18
- // are bounded so the 2-worker pool isn't flooded; over-budget due rows are
19
- // simply left for the next tick. Cron never touches interactive call_agent
20
- // beyond sharing the same async dispatch entrypoint.
21
- //
22
- // This module is self-contained and daemon-free so its core (parser, due
23
- // selection, fire-with-mocked-dispatch) is unit-testable WITHOUT the clauth
24
- // vault, boot.key, Supabase, or a live `claude` binary.
25
-
26
- // ── 5-field cron parser ──────────────────────────────────────────────────────
27
- // Fields: minute hour day-of-month month day-of-week
28
- // minute 0-59
29
- // hour 0-23
30
- // day-of-month 1-31
31
- // month 1-12
32
- // day-of-week 0-6 (0 = Sunday; 7 also accepted as Sunday)
33
- // Supported per field: `*`, `a`, `a-b`, `*/n`, `a-b/n`, and comma lists of those.
34
- // Day-of-month / day-of-week follow standard cron OR semantics: when BOTH are
35
- // restricted (neither is `*`), a minute matches if EITHER matches. When one is
36
- // `*`, only the other constrains. Evaluation is in UTC (the daemon stores
37
- // timestamptz; UTC is unambiguous and matches Supabase `now()`).
38
-
39
- const FIELD_RANGES = [
40
- [0, 59], // minute
41
- [0, 23], // hour
42
- [1, 31], // day of month
43
- [1, 12], // month
44
- [0, 6], // day of week (7 normalized to 0)
45
- ];
46
-
47
- function parseField(spec, idx) {
48
- const [min, max] = FIELD_RANGES[idx];
49
- const set = new Set();
50
- const addRange = (lo, hi, step) => {
51
- for (let v = lo; v <= hi; v += step) set.add(v);
52
- };
53
- for (const partRaw of String(spec).split(",")) {
54
- const part = partRaw.trim();
55
- if (part === "") throw new Error(`empty cron field segment in "${spec}"`);
56
- let step = 1;
57
- let rangePart = part;
58
- const slash = part.indexOf("/");
59
- if (slash >= 0) {
60
- rangePart = part.slice(0, slash);
61
- step = Number(part.slice(slash + 1));
62
- if (!Number.isInteger(step) || step <= 0) {
63
- throw new Error(`invalid step in cron field "${part}"`);
64
- }
65
- }
66
- let lo;
67
- let hi;
68
- if (rangePart === "*") {
69
- lo = min;
70
- hi = max;
71
- } else if (rangePart.includes("-")) {
72
- const [a, b] = rangePart.split("-");
73
- lo = Number(a);
74
- hi = Number(b);
75
- } else {
76
- lo = Number(rangePart);
77
- hi = lo;
78
- }
79
- if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
80
- throw new Error(`non-numeric cron field "${part}"`);
81
- }
82
- // Day-of-week: normalize 7 -> 0 (Sunday).
83
- if (idx === 4) {
84
- if (lo === 7) lo = 0;
85
- if (hi === 7) hi = 0;
86
- }
87
- if (lo > hi) throw new Error(`reversed range in cron field "${part}"`);
88
- if (lo < min || hi > max) {
89
- throw new Error(`cron field "${part}" out of range [${min},${max}]`);
90
- }
91
- addRange(lo, hi, step);
92
- }
93
- return set;
94
- }
95
-
96
- /**
97
- * Parse a 5-field cron expression into per-field match sets plus the raw
98
- * wildcard flags needed for day-of-month/day-of-week OR semantics.
99
- * @param {string} expr
100
- * @returns {{minute:Set,hour:Set,dom:Set,month:Set,dow:Set,domStar:boolean,dowStar:boolean}}
101
- */
102
- export function parseCron(expr) {
103
- if (typeof expr !== "string") throw new Error("cron expression must be a string");
104
- const fields = expr.trim().split(/\s+/);
105
- if (fields.length !== 5) {
106
- throw new Error(`cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
107
- }
108
- return {
109
- minute: parseField(fields[0], 0),
110
- hour: parseField(fields[1], 1),
111
- dom: parseField(fields[2], 2),
112
- month: parseField(fields[3], 3),
113
- dow: parseField(fields[4], 4),
114
- domStar: fields[2].trim() === "*",
115
- dowStar: fields[4].trim() === "*",
116
- };
117
- }
118
-
119
- /** True iff `date` (UTC) matches the parsed cron at minute granularity. */
120
- export function cronMatches(parsed, date) {
121
- const minute = date.getUTCMinutes();
122
- const hour = date.getUTCHours();
123
- const dom = date.getUTCDate();
124
- const month = date.getUTCMonth() + 1;
125
- const dow = date.getUTCDay(); // 0-6, Sunday=0
126
-
127
- if (!parsed.minute.has(minute)) return false;
128
- if (!parsed.hour.has(hour)) return false;
129
- if (!parsed.month.has(month)) return false;
130
-
131
- // Standard cron day OR semantics.
132
- if (parsed.domStar && parsed.dowStar) return true;
133
- if (parsed.domStar) return parsed.dow.has(dow);
134
- if (parsed.dowStar) return parsed.dom.has(dom);
135
- return parsed.dom.has(dom) || parsed.dow.has(dow);
136
- }
137
-
138
- /**
139
- * Compute the next UTC instant strictly AFTER `from` that matches the cron.
140
- * Minute-granular; scans forward minute-by-minute with a bounded horizon
141
- * (~4 years) so a never-matching field (e.g. Feb 30) returns null instead of
142
- * spinning forever.
143
- * @param {string} expr 5-field cron
144
- * @param {Date} [from] base instant (default now)
145
- * @returns {Date|null}
146
- */
147
- export function nextRun(expr, from = new Date()) {
148
- const parsed = parseCron(expr);
149
- // Start at the next whole minute after `from` (cron fires at minute boundaries).
150
- const d = new Date(from.getTime());
151
- d.setUTCSeconds(0, 0);
152
- d.setUTCMinutes(d.getUTCMinutes() + 1);
153
- const HORIZON_MIN = 366 * 4 * 24 * 60; // ~4 years of minutes
154
- for (let i = 0; i < HORIZON_MIN; i++) {
155
- if (cronMatches(parsed, d)) return new Date(d.getTime());
156
- d.setUTCMinutes(d.getUTCMinutes() + 1);
157
- }
158
- return null;
159
- }
160
-
161
- /** Validate a cron expression; returns { ok, error }. Never throws. */
162
- export function validateCron(expr) {
163
- try {
164
- parseCron(expr);
165
- return { ok: true };
166
- } catch (e) {
167
- return { ok: false, error: e.message };
168
- }
169
- }
170
-
171
- // ── Due selection ────────────────────────────────────────────────────────────
172
-
173
- /**
174
- * From a list of schedule rows, pick the ones that are due to fire at `now`.
175
- * A row is due iff enabled AND (next_run_at is null OR next_run_at <= now). A
176
- * null next_run_at means "never computed" — fire it now and seed its next run.
177
- * Rows with an unparseable cron are skipped (and surfaced via onInvalid) so one
178
- * bad row cannot wedge the scheduler.
179
- * @param {Array<object>} rows
180
- * @param {Date} now
181
- * @param {(row:object, error:string)=>void} [onInvalid]
182
- * @returns {Array<object>} due rows (input order preserved)
183
- */
184
- export function selectDue(rows, now = new Date(), onInvalid) {
185
- const due = [];
186
- for (const row of Array.isArray(rows) ? rows : []) {
187
- if (!row || row.enabled === false) continue;
188
- const check = validateCron(row.cron);
189
- if (!check.ok) {
190
- if (typeof onInvalid === "function") onInvalid(row, check.error);
191
- continue;
192
- }
193
- const next = row.next_run_at ? new Date(row.next_run_at) : null;
194
- if (next == null || Number.isNaN(next.getTime()) || next.getTime() <= now.getTime()) {
195
- due.push(row);
196
- }
197
- }
198
- return due;
199
- }
200
-
201
- // ── Gating ───────────────────────────────────────────────────────────────────
202
-
203
- /** True iff the scheduler is enabled via CLAUTH_AGENT_CRON (OFF by default). */
204
- export function cronEnabled(env = process.env) {
205
- const v = String(env.CLAUTH_AGENT_CRON ?? "").trim().toLowerCase();
206
- return v === "1" || v === "true" || v === "on" || v === "yes";
207
- }
208
-
209
- // ── Scheduler ────────────────────────────────────────────────────────────────
210
-
211
- export const DEFAULT_TICK_MS = 30000;
212
- // Cap concurrent scheduled fires per tick so cron can't flood the warm pool and
213
- // starve interactive call_agent. Over-budget due rows wait for the next tick.
214
- export const DEFAULT_MAX_CONCURRENT_FIRES = 2;
215
- // Live back-pressure budget: cron skips firing this tick when the shared
216
- // AgentPool's pending queue is already at/above this depth, so scheduled jobs
217
- // never pile onto the unbounded pool queue ahead of interactive call_agent.
218
- // Defaults to maxConcurrentFires when unset (see AgentCron constructor).
219
-
220
- /**
221
- * AgentCron — a self-contained scheduler. It does NOT import the daemon; the
222
- * daemon injects three thin callbacks so this module stays testable in
223
- * isolation:
224
- * - listDueSchedules(now) → Promise<Array<row>> read due rows (Supabase)
225
- * - fireSchedule(row) → Promise<{jobId?}> dispatch via call_agent
226
- * - updateScheduleRun(row, { last_run_at, next_run_at, last_job_id })
227
- * → Promise<void> persist the new run window
228
- * - poolQueueDepth() → number OPTIONAL: current pending
229
- * depth of the shared AgentPool queue (live
230
- * back-pressure). When supplied and the depth is at/
231
- * above maxQueueDepth, the tick fires NOTHING and
232
- * leaves all due rows due (their next_run_at is
233
- * untouched), so cron self-throttles against the pool
234
- * and never starves interactive call_agent. The tick
235
- * interval bounds re-evaluation, so a saturated pool
236
- * does not hot-loop.
237
- *
238
- * Any of these may throw/reject; the scheduler wraps each per-row fire in
239
- * try/catch and continues. A slow fireSchedule never blocks the tick because
240
- * fires run concurrently (bounded) and the tick does not await long agent work
241
- * — async dispatch returns a jobId immediately.
242
- */
243
- export class AgentCron {
244
- /**
245
- * @param {object} opts
246
- * @param {(now:Date)=>Promise<Array<object>>} opts.listDueSchedules
247
- * @param {(row:object)=>Promise<{jobId?:string}>} opts.fireSchedule
248
- * @param {(row:object, run:object)=>Promise<void>} opts.updateScheduleRun
249
- * @param {()=>number} [opts.poolQueueDepth] live pool-queue depth probe
250
- * @param {number} [opts.maxQueueDepth] back-pressure budget (default = maxConcurrentFires)
251
- * @param {number} [opts.tickMs] sweep cadence (default 30s)
252
- * @param {number} [opts.maxConcurrentFires] per-tick fire budget (default 2)
253
- * @param {(msg:string, meta?:object)=>void} [opts.log]
254
- * @param {()=>Date} [opts.now] injectable clock for tests
255
- */
256
- constructor(opts = {}) {
257
- this.listDueSchedules = opts.listDueSchedules;
258
- this.fireSchedule = opts.fireSchedule;
259
- this.updateScheduleRun = opts.updateScheduleRun;
260
- this.poolQueueDepth = typeof opts.poolQueueDepth === "function" ? opts.poolQueueDepth : null;
261
- this.tickMs = Number.isFinite(opts.tickMs) ? Math.max(1000, opts.tickMs) : DEFAULT_TICK_MS;
262
- this.maxConcurrentFires = Number.isFinite(opts.maxConcurrentFires)
263
- ? Math.max(1, opts.maxConcurrentFires)
264
- : DEFAULT_MAX_CONCURRENT_FIRES;
265
- // Back-pressure budget: when the live pool queue is at/above this, skip the
266
- // whole tick. Defaults to the per-tick fire budget so a tick never queues
267
- // more than one fire-budget's worth ahead of interactive call_agent.
268
- this.maxQueueDepth = Number.isFinite(opts.maxQueueDepth)
269
- ? Math.max(1, opts.maxQueueDepth)
270
- : this.maxConcurrentFires;
271
- this.log = typeof opts.log === "function" ? opts.log : () => {};
272
- this._now = typeof opts.now === "function" ? opts.now : () => new Date();
273
-
274
- this._timer = null;
275
- this._ticking = false; // overlap guard — a long tick must not re-enter
276
- this.stats = { ticks: 0, fired: 0, failed: 0, throttled: 0, lastTickAt: null, lastError: null };
277
- }
278
-
279
- /** Start the periodic sweep. Idempotent. */
280
- start() {
281
- if (this._timer) return;
282
- this._timer = setInterval(() => {
283
- this.tick().catch((e) => {
284
- this.stats.lastError = e?.message || String(e);
285
- this.log(`[agent-cron] tick error: ${this.stats.lastError}`);
286
- });
287
- }, this.tickMs);
288
- // unref so the scheduler never keeps the process alive on its own; the
289
- // daemon owns lifecycle and stops it on shutdown.
290
- if (this._timer && this._timer.unref) this._timer.unref();
291
- this.log(`[agent-cron] started (tick=${this.tickMs}ms, maxFires=${this.maxConcurrentFires})`);
292
- }
293
-
294
- /** Stop the sweep. Idempotent. */
295
- stop() {
296
- if (this._timer) {
297
- clearInterval(this._timer);
298
- this._timer = null;
299
- this.log("[agent-cron] stopped");
300
- }
301
- }
302
-
303
- /**
304
- * One sweep: read due rows, fire up to the budget, advance their run windows.
305
- * Never throws — every failure mode is caught and logged so the daemon and the
306
- * interval survive. Returns a small summary (used by tests).
307
- */
308
- async tick() {
309
- if (this._ticking) return { skipped: "overlap" }; // previous tick still running
310
- this._ticking = true;
311
- const now = this._now();
312
- this.stats.ticks++;
313
- this.stats.lastTickAt = now.toISOString();
314
- let fired = 0;
315
- let failed = 0;
316
- try {
317
- // Live back-pressure gate: if the shared AgentPool queue is already at/
318
- // above budget, fire NOTHING this tick. Due rows stay due (next_run_at
319
- // untouched) and fire on a later tick once the pool drains — so scheduled
320
- // jobs never pile onto the unbounded pool queue ahead of interactive
321
- // call_agent. The tick interval bounds re-checks; no hot-loop.
322
- if (this.poolQueueDepth) {
323
- let depth = NaN;
324
- try {
325
- depth = Number(this.poolQueueDepth());
326
- } catch (e) {
327
- // A failing probe must not wedge cron; treat as "unknown" and proceed.
328
- this.log(`[agent-cron] poolQueueDepth probe failed: ${e?.message || e}`);
329
- depth = NaN;
330
- }
331
- if (Number.isFinite(depth) && depth >= this.maxQueueDepth) {
332
- this.stats.throttled++;
333
- this.log(`[agent-cron] throttled: pool queue depth ${depth} >= budget ${this.maxQueueDepth}; skipping fires this tick`);
334
- return { fired: 0, failed: 0, throttled: true, queueDepth: depth };
335
- }
336
- }
337
- let due = [];
338
- try {
339
- due = (await this.listDueSchedules(now)) || [];
340
- } catch (e) {
341
- this.stats.lastError = `list: ${e?.message || e}`;
342
- this.log(`[agent-cron] listDueSchedules failed: ${this.stats.lastError}`);
343
- return { fired: 0, failed: 0, error: this.stats.lastError };
344
- }
345
- // Bound concurrent fires this tick; leftover due rows wait for next tick.
346
- const batch = due.slice(0, this.maxConcurrentFires);
347
- const results = await Promise.allSettled(batch.map((row) => this._fireOne(row, now)));
348
- for (const r of results) {
349
- if (r.status === "fulfilled" && r.value && r.value.ok) fired++;
350
- else failed++;
351
- }
352
- this.stats.fired += fired;
353
- this.stats.failed += failed;
354
- return { fired, failed, due: due.length, considered: batch.length };
355
- } finally {
356
- this._ticking = false;
357
- }
358
- }
359
-
360
- /**
361
- * Fire one due schedule and advance its run window. Isolated try/catch so one
362
- * bad schedule cannot affect the others or the loop. Always attempts to
363
- * advance next_run_at (even on a fire failure) so a persistently-broken
364
- * schedule does not re-fire every single tick.
365
- */
366
- async _fireOne(row, now) {
367
- let jobId = null;
368
- let fireOk = false;
369
- let fireErr = null;
370
- try {
371
- const res = await this.fireSchedule(row);
372
- jobId = res && res.jobId ? res.jobId : null;
373
- fireOk = !!(res && (res.ok !== false));
374
- } catch (e) {
375
- fireErr = e?.message || String(e);
376
- this.log(`[agent-cron] fire "${row?.name || row?.id}" failed: ${fireErr}`);
377
- }
378
-
379
- // Advance the run window regardless, so we don't hot-loop on a bad row.
380
- try {
381
- const next = nextRun(row.cron, now);
382
- await this.updateScheduleRun(row, {
383
- last_run_at: now.toISOString(),
384
- next_run_at: next ? next.toISOString() : null,
385
- last_job_id: jobId,
386
- });
387
- } catch (e) {
388
- // A failed persist means we may re-fire next tick; log but don't crash.
389
- this.log(`[agent-cron] updateScheduleRun "${row?.name || row?.id}" failed: ${e?.message || e}`);
390
- }
391
-
392
- return { ok: fireOk && !fireErr, jobId, error: fireErr };
393
- }
394
- }
395
-
396
- export default AgentCron;
1
+ // agent-cron.js — scheduled call_agent (cron) for the clauth daemon (WP-P5).
2
+ //
3
+ // A single setInterval inside the daemon reads `call_agent_schedules`
4
+ // (enabled=true, next_run_at <= now) and, for each due row, fires the EXISTING
5
+ // call_agent path in async mode (so it persists to monkey_scratchpad exactly
6
+ // like any other call_agent job), then advances last_run_at + next_run_at from
7
+ // the row's 5-field cron expression.
8
+ //
9
+ // Design constraints (see WP-P5):
10
+ // * OFF by default. The whole scheduler is gated behind CLAUTH_AGENT_CRON;
11
+ // unset/`0`/`false` ⇒ nothing runs. The live host keeps it off until the
12
+ // supervisor enables it post-review.
13
+ // * Dependency-light. A minimal pure 5-field cron parser is vendored here — no
14
+ // heavy npm dep is added to the credential daemon.
15
+ // * Resilient. A due-schedule firing must NOT crash the daemon: every fire is
16
+ // wrapped in try/catch, logged, and the loop continues. Slow agents never
17
+ // block the interval — dispatch is fire-and-forget async. Concurrent fires
18
+ // are bounded so the 2-worker pool isn't flooded; over-budget due rows are
19
+ // simply left for the next tick. Cron never touches interactive call_agent
20
+ // beyond sharing the same async dispatch entrypoint.
21
+ //
22
+ // This module is self-contained and daemon-free so its core (parser, due
23
+ // selection, fire-with-mocked-dispatch) is unit-testable WITHOUT the clauth
24
+ // vault, boot.key, Supabase, or a live `claude` binary.
25
+
26
+ // ── 5-field cron parser ──────────────────────────────────────────────────────
27
+ // Fields: minute hour day-of-month month day-of-week
28
+ // minute 0-59
29
+ // hour 0-23
30
+ // day-of-month 1-31
31
+ // month 1-12
32
+ // day-of-week 0-6 (0 = Sunday; 7 also accepted as Sunday)
33
+ // Supported per field: `*`, `a`, `a-b`, `*/n`, `a-b/n`, and comma lists of those.
34
+ // Day-of-month / day-of-week follow standard cron OR semantics: when BOTH are
35
+ // restricted (neither is `*`), a minute matches if EITHER matches. When one is
36
+ // `*`, only the other constrains. Evaluation is in UTC (the daemon stores
37
+ // timestamptz; UTC is unambiguous and matches Supabase `now()`).
38
+
39
+ const FIELD_RANGES = [
40
+ [0, 59], // minute
41
+ [0, 23], // hour
42
+ [1, 31], // day of month
43
+ [1, 12], // month
44
+ [0, 6], // day of week (7 normalized to 0)
45
+ ];
46
+
47
+ function parseField(spec, idx) {
48
+ const [min, max] = FIELD_RANGES[idx];
49
+ const set = new Set();
50
+ const addRange = (lo, hi, step) => {
51
+ for (let v = lo; v <= hi; v += step) set.add(v);
52
+ };
53
+ for (const partRaw of String(spec).split(",")) {
54
+ const part = partRaw.trim();
55
+ if (part === "") throw new Error(`empty cron field segment in "${spec}"`);
56
+ let step = 1;
57
+ let rangePart = part;
58
+ const slash = part.indexOf("/");
59
+ if (slash >= 0) {
60
+ rangePart = part.slice(0, slash);
61
+ step = Number(part.slice(slash + 1));
62
+ if (!Number.isInteger(step) || step <= 0) {
63
+ throw new Error(`invalid step in cron field "${part}"`);
64
+ }
65
+ }
66
+ let lo;
67
+ let hi;
68
+ if (rangePart === "*") {
69
+ lo = min;
70
+ hi = max;
71
+ } else if (rangePart.includes("-")) {
72
+ const [a, b] = rangePart.split("-");
73
+ lo = Number(a);
74
+ hi = Number(b);
75
+ } else {
76
+ lo = Number(rangePart);
77
+ hi = lo;
78
+ }
79
+ if (!Number.isInteger(lo) || !Number.isInteger(hi)) {
80
+ throw new Error(`non-numeric cron field "${part}"`);
81
+ }
82
+ // Day-of-week: normalize 7 -> 0 (Sunday).
83
+ if (idx === 4) {
84
+ if (lo === 7) lo = 0;
85
+ if (hi === 7) hi = 0;
86
+ }
87
+ if (lo > hi) throw new Error(`reversed range in cron field "${part}"`);
88
+ if (lo < min || hi > max) {
89
+ throw new Error(`cron field "${part}" out of range [${min},${max}]`);
90
+ }
91
+ addRange(lo, hi, step);
92
+ }
93
+ return set;
94
+ }
95
+
96
+ /**
97
+ * Parse a 5-field cron expression into per-field match sets plus the raw
98
+ * wildcard flags needed for day-of-month/day-of-week OR semantics.
99
+ * @param {string} expr
100
+ * @returns {{minute:Set,hour:Set,dom:Set,month:Set,dow:Set,domStar:boolean,dowStar:boolean}}
101
+ */
102
+ export function parseCron(expr) {
103
+ if (typeof expr !== "string") throw new Error("cron expression must be a string");
104
+ const fields = expr.trim().split(/\s+/);
105
+ if (fields.length !== 5) {
106
+ throw new Error(`cron expression must have exactly 5 fields, got ${fields.length}: "${expr}"`);
107
+ }
108
+ return {
109
+ minute: parseField(fields[0], 0),
110
+ hour: parseField(fields[1], 1),
111
+ dom: parseField(fields[2], 2),
112
+ month: parseField(fields[3], 3),
113
+ dow: parseField(fields[4], 4),
114
+ domStar: fields[2].trim() === "*",
115
+ dowStar: fields[4].trim() === "*",
116
+ };
117
+ }
118
+
119
+ /** True iff `date` (UTC) matches the parsed cron at minute granularity. */
120
+ export function cronMatches(parsed, date) {
121
+ const minute = date.getUTCMinutes();
122
+ const hour = date.getUTCHours();
123
+ const dom = date.getUTCDate();
124
+ const month = date.getUTCMonth() + 1;
125
+ const dow = date.getUTCDay(); // 0-6, Sunday=0
126
+
127
+ if (!parsed.minute.has(minute)) return false;
128
+ if (!parsed.hour.has(hour)) return false;
129
+ if (!parsed.month.has(month)) return false;
130
+
131
+ // Standard cron day OR semantics.
132
+ if (parsed.domStar && parsed.dowStar) return true;
133
+ if (parsed.domStar) return parsed.dow.has(dow);
134
+ if (parsed.dowStar) return parsed.dom.has(dom);
135
+ return parsed.dom.has(dom) || parsed.dow.has(dow);
136
+ }
137
+
138
+ /**
139
+ * Compute the next UTC instant strictly AFTER `from` that matches the cron.
140
+ * Minute-granular; scans forward minute-by-minute with a bounded horizon
141
+ * (~4 years) so a never-matching field (e.g. Feb 30) returns null instead of
142
+ * spinning forever.
143
+ * @param {string} expr 5-field cron
144
+ * @param {Date} [from] base instant (default now)
145
+ * @returns {Date|null}
146
+ */
147
+ export function nextRun(expr, from = new Date()) {
148
+ const parsed = parseCron(expr);
149
+ // Start at the next whole minute after `from` (cron fires at minute boundaries).
150
+ const d = new Date(from.getTime());
151
+ d.setUTCSeconds(0, 0);
152
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
153
+ const HORIZON_MIN = 366 * 4 * 24 * 60; // ~4 years of minutes
154
+ for (let i = 0; i < HORIZON_MIN; i++) {
155
+ if (cronMatches(parsed, d)) return new Date(d.getTime());
156
+ d.setUTCMinutes(d.getUTCMinutes() + 1);
157
+ }
158
+ return null;
159
+ }
160
+
161
+ /** Validate a cron expression; returns { ok, error }. Never throws. */
162
+ export function validateCron(expr) {
163
+ try {
164
+ parseCron(expr);
165
+ return { ok: true };
166
+ } catch (e) {
167
+ return { ok: false, error: e.message };
168
+ }
169
+ }
170
+
171
+ // ── Due selection ────────────────────────────────────────────────────────────
172
+
173
+ /**
174
+ * From a list of schedule rows, pick the ones that are due to fire at `now`.
175
+ * A row is due iff enabled AND (next_run_at is null OR next_run_at <= now). A
176
+ * null next_run_at means "never computed" — fire it now and seed its next run.
177
+ * Rows with an unparseable cron are skipped (and surfaced via onInvalid) so one
178
+ * bad row cannot wedge the scheduler.
179
+ * @param {Array<object>} rows
180
+ * @param {Date} now
181
+ * @param {(row:object, error:string)=>void} [onInvalid]
182
+ * @returns {Array<object>} due rows (input order preserved)
183
+ */
184
+ export function selectDue(rows, now = new Date(), onInvalid) {
185
+ const due = [];
186
+ for (const row of Array.isArray(rows) ? rows : []) {
187
+ if (!row || row.enabled === false) continue;
188
+ const check = validateCron(row.cron);
189
+ if (!check.ok) {
190
+ if (typeof onInvalid === "function") onInvalid(row, check.error);
191
+ continue;
192
+ }
193
+ const next = row.next_run_at ? new Date(row.next_run_at) : null;
194
+ if (next == null || Number.isNaN(next.getTime()) || next.getTime() <= now.getTime()) {
195
+ due.push(row);
196
+ }
197
+ }
198
+ return due;
199
+ }
200
+
201
+ // ── Gating ───────────────────────────────────────────────────────────────────
202
+
203
+ /** True iff the scheduler is enabled via CLAUTH_AGENT_CRON (OFF by default). */
204
+ export function cronEnabled(env = process.env) {
205
+ const v = String(env.CLAUTH_AGENT_CRON ?? "").trim().toLowerCase();
206
+ return v === "1" || v === "true" || v === "on" || v === "yes";
207
+ }
208
+
209
+ // ── Scheduler ────────────────────────────────────────────────────────────────
210
+
211
+ export const DEFAULT_TICK_MS = 30000;
212
+ // Cap concurrent scheduled fires per tick so cron can't flood the warm pool and
213
+ // starve interactive call_agent. Over-budget due rows wait for the next tick.
214
+ export const DEFAULT_MAX_CONCURRENT_FIRES = 2;
215
+ // Live back-pressure budget: cron skips firing this tick when the shared
216
+ // AgentPool's pending queue is already at/above this depth, so scheduled jobs
217
+ // never pile onto the unbounded pool queue ahead of interactive call_agent.
218
+ // Defaults to maxConcurrentFires when unset (see AgentCron constructor).
219
+
220
+ /**
221
+ * AgentCron — a self-contained scheduler. It does NOT import the daemon; the
222
+ * daemon injects three thin callbacks so this module stays testable in
223
+ * isolation:
224
+ * - listDueSchedules(now) → Promise<Array<row>> read due rows (Supabase)
225
+ * - fireSchedule(row) → Promise<{jobId?}> dispatch via call_agent
226
+ * - updateScheduleRun(row, { last_run_at, next_run_at, last_job_id })
227
+ * → Promise<void> persist the new run window
228
+ * - poolQueueDepth() → number OPTIONAL: current pending
229
+ * depth of the shared AgentPool queue (live
230
+ * back-pressure). When supplied and the depth is at/
231
+ * above maxQueueDepth, the tick fires NOTHING and
232
+ * leaves all due rows due (their next_run_at is
233
+ * untouched), so cron self-throttles against the pool
234
+ * and never starves interactive call_agent. The tick
235
+ * interval bounds re-evaluation, so a saturated pool
236
+ * does not hot-loop.
237
+ *
238
+ * Any of these may throw/reject; the scheduler wraps each per-row fire in
239
+ * try/catch and continues. A slow fireSchedule never blocks the tick because
240
+ * fires run concurrently (bounded) and the tick does not await long agent work
241
+ * — async dispatch returns a jobId immediately.
242
+ */
243
+ export class AgentCron {
244
+ /**
245
+ * @param {object} opts
246
+ * @param {(now:Date)=>Promise<Array<object>>} opts.listDueSchedules
247
+ * @param {(row:object)=>Promise<{jobId?:string}>} opts.fireSchedule
248
+ * @param {(row:object, run:object)=>Promise<void>} opts.updateScheduleRun
249
+ * @param {()=>number} [opts.poolQueueDepth] live pool-queue depth probe
250
+ * @param {number} [opts.maxQueueDepth] back-pressure budget (default = maxConcurrentFires)
251
+ * @param {number} [opts.tickMs] sweep cadence (default 30s)
252
+ * @param {number} [opts.maxConcurrentFires] per-tick fire budget (default 2)
253
+ * @param {(msg:string, meta?:object)=>void} [opts.log]
254
+ * @param {()=>Date} [opts.now] injectable clock for tests
255
+ */
256
+ constructor(opts = {}) {
257
+ this.listDueSchedules = opts.listDueSchedules;
258
+ this.fireSchedule = opts.fireSchedule;
259
+ this.updateScheduleRun = opts.updateScheduleRun;
260
+ this.poolQueueDepth = typeof opts.poolQueueDepth === "function" ? opts.poolQueueDepth : null;
261
+ this.tickMs = Number.isFinite(opts.tickMs) ? Math.max(1000, opts.tickMs) : DEFAULT_TICK_MS;
262
+ this.maxConcurrentFires = Number.isFinite(opts.maxConcurrentFires)
263
+ ? Math.max(1, opts.maxConcurrentFires)
264
+ : DEFAULT_MAX_CONCURRENT_FIRES;
265
+ // Back-pressure budget: when the live pool queue is at/above this, skip the
266
+ // whole tick. Defaults to the per-tick fire budget so a tick never queues
267
+ // more than one fire-budget's worth ahead of interactive call_agent.
268
+ this.maxQueueDepth = Number.isFinite(opts.maxQueueDepth)
269
+ ? Math.max(1, opts.maxQueueDepth)
270
+ : this.maxConcurrentFires;
271
+ this.log = typeof opts.log === "function" ? opts.log : () => {};
272
+ this._now = typeof opts.now === "function" ? opts.now : () => new Date();
273
+
274
+ this._timer = null;
275
+ this._ticking = false; // overlap guard — a long tick must not re-enter
276
+ this.stats = { ticks: 0, fired: 0, failed: 0, throttled: 0, lastTickAt: null, lastError: null };
277
+ }
278
+
279
+ /** Start the periodic sweep. Idempotent. */
280
+ start() {
281
+ if (this._timer) return;
282
+ this._timer = setInterval(() => {
283
+ this.tick().catch((e) => {
284
+ this.stats.lastError = e?.message || String(e);
285
+ this.log(`[agent-cron] tick error: ${this.stats.lastError}`);
286
+ });
287
+ }, this.tickMs);
288
+ // unref so the scheduler never keeps the process alive on its own; the
289
+ // daemon owns lifecycle and stops it on shutdown.
290
+ if (this._timer && this._timer.unref) this._timer.unref();
291
+ this.log(`[agent-cron] started (tick=${this.tickMs}ms, maxFires=${this.maxConcurrentFires})`);
292
+ }
293
+
294
+ /** Stop the sweep. Idempotent. */
295
+ stop() {
296
+ if (this._timer) {
297
+ clearInterval(this._timer);
298
+ this._timer = null;
299
+ this.log("[agent-cron] stopped");
300
+ }
301
+ }
302
+
303
+ /**
304
+ * One sweep: read due rows, fire up to the budget, advance their run windows.
305
+ * Never throws — every failure mode is caught and logged so the daemon and the
306
+ * interval survive. Returns a small summary (used by tests).
307
+ */
308
+ async tick() {
309
+ if (this._ticking) return { skipped: "overlap" }; // previous tick still running
310
+ this._ticking = true;
311
+ const now = this._now();
312
+ this.stats.ticks++;
313
+ this.stats.lastTickAt = now.toISOString();
314
+ let fired = 0;
315
+ let failed = 0;
316
+ try {
317
+ // Live back-pressure gate: if the shared AgentPool queue is already at/
318
+ // above budget, fire NOTHING this tick. Due rows stay due (next_run_at
319
+ // untouched) and fire on a later tick once the pool drains — so scheduled
320
+ // jobs never pile onto the unbounded pool queue ahead of interactive
321
+ // call_agent. The tick interval bounds re-checks; no hot-loop.
322
+ if (this.poolQueueDepth) {
323
+ let depth = NaN;
324
+ try {
325
+ depth = Number(this.poolQueueDepth());
326
+ } catch (e) {
327
+ // A failing probe must not wedge cron; treat as "unknown" and proceed.
328
+ this.log(`[agent-cron] poolQueueDepth probe failed: ${e?.message || e}`);
329
+ depth = NaN;
330
+ }
331
+ if (Number.isFinite(depth) && depth >= this.maxQueueDepth) {
332
+ this.stats.throttled++;
333
+ this.log(`[agent-cron] throttled: pool queue depth ${depth} >= budget ${this.maxQueueDepth}; skipping fires this tick`);
334
+ return { fired: 0, failed: 0, throttled: true, queueDepth: depth };
335
+ }
336
+ }
337
+ let due = [];
338
+ try {
339
+ due = (await this.listDueSchedules(now)) || [];
340
+ } catch (e) {
341
+ this.stats.lastError = `list: ${e?.message || e}`;
342
+ this.log(`[agent-cron] listDueSchedules failed: ${this.stats.lastError}`);
343
+ return { fired: 0, failed: 0, error: this.stats.lastError };
344
+ }
345
+ // Bound concurrent fires this tick; leftover due rows wait for next tick.
346
+ const batch = due.slice(0, this.maxConcurrentFires);
347
+ const results = await Promise.allSettled(batch.map((row) => this._fireOne(row, now)));
348
+ for (const r of results) {
349
+ if (r.status === "fulfilled" && r.value && r.value.ok) fired++;
350
+ else failed++;
351
+ }
352
+ this.stats.fired += fired;
353
+ this.stats.failed += failed;
354
+ return { fired, failed, due: due.length, considered: batch.length };
355
+ } finally {
356
+ this._ticking = false;
357
+ }
358
+ }
359
+
360
+ /**
361
+ * Fire one due schedule and advance its run window. Isolated try/catch so one
362
+ * bad schedule cannot affect the others or the loop. Always attempts to
363
+ * advance next_run_at (even on a fire failure) so a persistently-broken
364
+ * schedule does not re-fire every single tick.
365
+ */
366
+ async _fireOne(row, now) {
367
+ let jobId = null;
368
+ let fireOk = false;
369
+ let fireErr = null;
370
+ try {
371
+ const res = await this.fireSchedule(row);
372
+ jobId = res && res.jobId ? res.jobId : null;
373
+ fireOk = !!(res && (res.ok !== false));
374
+ } catch (e) {
375
+ fireErr = e?.message || String(e);
376
+ this.log(`[agent-cron] fire "${row?.name || row?.id}" failed: ${fireErr}`);
377
+ }
378
+
379
+ // Advance the run window regardless, so we don't hot-loop on a bad row.
380
+ try {
381
+ const next = nextRun(row.cron, now);
382
+ await this.updateScheduleRun(row, {
383
+ last_run_at: now.toISOString(),
384
+ next_run_at: next ? next.toISOString() : null,
385
+ last_job_id: jobId,
386
+ });
387
+ } catch (e) {
388
+ // A failed persist means we may re-fire next tick; log but don't crash.
389
+ this.log(`[agent-cron] updateScheduleRun "${row?.name || row?.id}" failed: ${e?.message || e}`);
390
+ }
391
+
392
+ return { ok: fireOk && !fireErr, jobId, error: fireErr };
393
+ }
394
+ }
395
+
396
+ export default AgentCron;