@lifeaitools/clauth 1.30.13 → 1.30.14
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/.clauth-skill/SKILL.md +75 -17
- package/README.md +70 -10
- package/cli/api.classify.test.js +75 -0
- package/cli/api.js +110 -11
- package/cli/commands/agent-cron.js +396 -0
- package/cli/commands/agent-pool.js +1962 -0
- package/cli/commands/scrub.js +205 -109
- package/cli/commands/scrub.test.js +115 -0
- package/cli/commands/serve.js +3488 -1068
- package/cli/enrollment-script.js +82 -0
- package/cli/index.js +23 -57
- package/cli/studio-debug.js +679 -8
- package/cli/webdav-service.js +339 -0
- package/package.json +11 -3
- package/scripts/postinstall.js +25 -0
|
@@ -0,0 +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;
|