Spaces:
Running
Running
File size: 8,838 Bytes
8eb73a3 6334671 8eb73a3 6334671 8eb73a3 6334671 8eb73a3 6334671 8eb73a3 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 | import crypto from 'node:crypto';
import fs from 'node:fs';
import path from 'node:path';
import { CronExpressionParser } from 'cron-parser';
import { DATA_DIR } from './config.js';
export const CRONS_FILE = path.join(DATA_DIR, 'crons.json');
const MAX_TIMER_MS = 2_147_000_000;
const VALID_STATES = new Set(['running', 'stopped']);
let jobs = [];
let fireJob = null;
const timers = new Map();
const cleanText = (value, field, max = 160) => {
if (typeof value !== 'string' || !value.trim()) throw new Error(`${field} required`);
const text = value.trim();
if (text.length > max) throw new Error(`${field} is too long (max ${max} characters)`);
return text;
};
const clone = (value) => JSON.parse(JSON.stringify(value));
function persist() {
try {
fs.mkdirSync(path.dirname(CRONS_FILE), { recursive: true });
const tmp = `${CRONS_FILE}.tmp`;
fs.writeFileSync(tmp, JSON.stringify(jobs, null, 2), { mode: 0o600 });
fs.renameSync(tmp, CRONS_FILE);
} catch (e) {
// A transient bucket/FUSE write must not take down the process that owns
// every live terminal. Match the session store's failure posture.
console.error('[crons.persist]', e && e.message);
}
}
export function validateSchedule(value, id = '') {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('schedule required');
const cron = cleanText(value.cron, 'schedule.cron', 120).replace(/\s+/g, ' ');
if (cron.split(' ').length !== 5) throw new Error('schedule.cron must use the standard five fields: minute hour day month weekday');
const tz = cleanText(value.tz, 'schedule.tz', 100);
try {
// Intl is the runtime authority for IANA zone names; cron-parser then
// applies that zone (including DST) when it advances the expression.
new Intl.DateTimeFormat('en', { timeZone: tz }).format(new Date());
CronExpressionParser.parse(cron, { tz, hashSeed: id || 'agent-manager-cron' }).next();
} catch (e) {
throw new Error(`invalid schedule: ${e && e.message ? e.message : e}`);
}
return { cron, tz };
}
export function nextOccurrence(schedule, after = new Date(), id = '') {
const valid = validateSchedule(schedule, id);
return CronExpressionParser.parse(valid.cron, {
currentDate: after,
tz: valid.tz,
hashSeed: id || 'agent-manager-cron',
}).next().toISOString();
}
function normalizeInput(input, existing = null) {
const src = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
const merged = existing ? {
...existing,
...src,
agent: src.agent === undefined ? existing.agent : src.agent,
schedule: src.schedule === undefined ? existing.schedule : src.schedule,
} : src;
const agent = merged.agent;
if (!agent || typeof agent !== 'object' || Array.isArray(agent)) throw new Error('agent required');
const state = merged.state === undefined ? 'running' : merged.state;
if (!VALID_STATES.has(state)) throw new Error("state must be 'running' or 'stopped'");
const id = existing?.id || `cron_${crypto.randomBytes(5).toString('hex')}`;
return {
...(existing || {}),
id,
name: cleanText(merged.name, 'name'),
agent: {
name: cleanText(agent.name, 'agent.name'),
cli: cleanText(agent.cli, 'agent.cli', 64),
},
prompt: cleanText(merged.prompt, 'prompt', 100_000),
schedule: validateSchedule(merged.schedule, id),
runOnRestart: merged.runOnRestart === true,
state,
};
}
function clearTimer(id) {
const timer = timers.get(id);
if (timer) clearTimeout(timer);
timers.delete(id);
}
function armExisting(job) {
clearTimer(job.id);
if (!fireJob || job.state !== 'running' || !job.next) return;
const target = Date.parse(job.next);
if (!Number.isFinite(target)) return;
const delay = target - Date.now();
const timer = setTimeout(() => {
timers.delete(job.id);
const current = jobs.find((candidate) => candidate.id === job.id);
if (!current || current.state !== 'running' || current.next !== job.next) return;
if (Date.now() + 250 < target) {
armExisting(current);
return;
}
// Advance before dispatch. If delivery is slow, fails, or overlaps another
// run, this occurrence is still consumed exactly once. Computing from now
// deliberately skips times missed while the process was unavailable.
current.next = nextOccurrence(current.schedule, new Date(Math.max(Date.now(), target)), current.id);
persist();
armExisting(current);
Promise.resolve(fireJob(current.id, 'schedule')).catch((e) =>
console.error('[crons.fire]', current.id, e && e.message));
}, Math.max(0, Math.min(MAX_TIMER_MS, delay)));
timer.unref?.();
timers.set(job.id, timer);
}
function resetNext(job, now = new Date()) {
job.next = job.state === 'running' ? nextOccurrence(job.schedule, now, job.id) : null;
}
export function init(now = new Date()) {
for (const id of timers.keys()) clearTimer(id);
fireJob = null;
try {
const parsed = JSON.parse(fs.readFileSync(CRONS_FILE, 'utf8'));
jobs = Array.isArray(parsed) ? parsed : [];
} catch {
jobs = [];
}
const valid = [];
for (const raw of jobs) {
try {
const job = normalizeInput(raw, raw && raw.id ? raw : null);
job.createdAt = raw.createdAt || now.toISOString();
job.updatedAt = raw.updatedAt || job.createdAt;
if (raw.last && typeof raw.last === 'object') job.last = raw.last;
resetNext(job, now); // stale persisted times are never replayed
valid.push(job);
} catch (e) {
console.error('[crons.load]', raw && raw.id, e && e.message);
}
}
jobs = valid;
persist();
return list();
}
export function list() {
return jobs.map(clone);
}
export function get(id) {
const job = jobs.find((candidate) => candidate.id === id);
return job ? clone(job) : null;
}
export function create(input, now = new Date()) {
const job = normalizeInput(input);
job.createdAt = now.toISOString();
job.updatedAt = job.createdAt;
resetNext(job, now);
jobs.push(job);
persist();
armExisting(job);
return clone(job);
}
export function update(id, patch, now = new Date()) {
const index = jobs.findIndex((job) => job.id === id);
if (index < 0) return null;
const before = jobs[index];
const job = normalizeInput(patch, before);
job.updatedAt = now.toISOString();
const scheduleChanged = job.schedule.cron !== before.schedule.cron || job.schedule.tz !== before.schedule.tz;
const resumed = before.state !== 'running' && job.state === 'running';
if (job.state !== 'running') job.next = null;
else if (scheduleChanged || resumed || !before.next) resetNext(job, now);
jobs[index] = job;
persist();
armExisting(job);
return clone(job);
}
export function remove(id) {
const before = jobs.length;
jobs = jobs.filter((job) => job.id !== id);
if (jobs.length === before) return false;
clearTimer(id);
persist();
return true;
}
export function recordLast(id, last) {
const job = jobs.find((candidate) => candidate.id === id);
if (!job) return null; // deleting a firing job must not recreate it
// Overlap is allowed. Completion order therefore need not be start order;
// never let an older, slower delivery replace the genuinely latest fire.
if (job.last && Date.parse(job.last.at) > Date.parse(last.at)) return clone(job);
job.last = clone(last);
persist();
return clone(job);
}
export function startScheduler(handler, { restartDelayMs = 1_500 } = {}) {
fireJob = handler;
const restartAt = Date.now() + restartDelayMs;
// Capture the boot-time occurrence before arming it. By the restart callback
// it may already have fired and advanced `next`, which would hide the very
// collision this check prevents.
const restartJobs = jobs
.filter((job) => job.state === 'running' && job.runOnRestart)
.map((job) => ({ id: job.id, scheduledAt: Date.parse(job.next) }));
for (const job of jobs) armExisting(job);
if (restartJobs.length) {
const timer = setTimeout(() => {
for (const { id, scheduledAt } of restartJobs) {
const current = jobs.find((job) => job.id === id);
if (!current || current.state !== 'running' || !current.runOnRestart) continue;
// One boot intent must not become two prompts. A scheduled occurrence
// within one restart-delay of the planned restart fire substitutes for
// it; this is startup de-duplication, not an overlap guard for ordinary
// runs. Use the captured time so this still holds if schedule fired first.
if (Number.isFinite(scheduledAt) && Math.abs(scheduledAt - restartAt) <= restartDelayMs) continue;
Promise.resolve(fireJob(id, 'restart')).catch((e) =>
console.error('[crons.restart]', id, e && e.message));
}
}, restartDelayMs);
timer.unref?.();
}
}
|