feat: add runtime event handling for providers in ProvidersSection feat: update routing to include Channels and Agents pages feat: extend route types and navigation items for Channels and Agents feat: implement agents store for managing agent data and interactions fix: update chat store to utilize agents store for agent-related functionality chore: export agents store from index fix: enhance runtime types for better event handling fix: update Vite config to handle dev server URL correctly
418 lines
12 KiB
TypeScript
418 lines
12 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import type {
|
|
CronJob,
|
|
CronJobCreateInput,
|
|
CronJobDelivery,
|
|
CronJobLastRun,
|
|
CronJobUpdateInput,
|
|
CronSchedule,
|
|
} from '@src/lib/cron-types';
|
|
import { getUserDataDir } from './paths';
|
|
|
|
interface StoredCronJob extends CronJob {
|
|
agentId?: string | null;
|
|
}
|
|
|
|
interface CronStore {
|
|
jobs: StoredCronJob[];
|
|
}
|
|
|
|
const CRON_STORE_PATH = path.join(getUserDataDir(), 'cron', 'jobs.json');
|
|
const MAX_CRON_LOOKAHEAD_MINUTES = 366 * 24 * 60;
|
|
|
|
function readStore(): CronStore {
|
|
try {
|
|
if (fs.existsSync(CRON_STORE_PATH)) {
|
|
const parsed = JSON.parse(fs.readFileSync(CRON_STORE_PATH, 'utf-8')) as Partial<CronStore>;
|
|
return {
|
|
jobs: Array.isArray(parsed.jobs) ? parsed.jobs : [],
|
|
};
|
|
}
|
|
} catch {
|
|
// Fall back to an empty store on malformed JSON.
|
|
}
|
|
|
|
return { jobs: [] };
|
|
}
|
|
|
|
function writeStore(store: CronStore): void {
|
|
fs.mkdirSync(path.dirname(CRON_STORE_PATH), { recursive: true });
|
|
fs.writeFileSync(CRON_STORE_PATH, JSON.stringify(store, null, 2), 'utf-8');
|
|
}
|
|
|
|
function normalizeString(value: unknown): string {
|
|
return String(value ?? '').trim();
|
|
}
|
|
|
|
function normalizeIsoDate(value: unknown, fallback?: string): string {
|
|
const raw = normalizeString(value);
|
|
const ms = Date.parse(raw);
|
|
if (Number.isFinite(ms)) {
|
|
return new Date(ms).toISOString();
|
|
}
|
|
|
|
if (fallback) {
|
|
return fallback;
|
|
}
|
|
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function normalizeDelivery(value: unknown): CronJobDelivery | undefined {
|
|
if (!value || typeof value !== 'object') {
|
|
return { mode: 'none' };
|
|
}
|
|
|
|
const input = value as Partial<CronJobDelivery>;
|
|
const mode = input.mode === 'announce' ? 'announce' : 'none';
|
|
if (mode === 'announce') {
|
|
const channel = normalizeString(input.channel);
|
|
const to = normalizeString(input.to);
|
|
const accountId = normalizeString(input.accountId);
|
|
|
|
return {
|
|
mode,
|
|
channel: channel || undefined,
|
|
to: to || undefined,
|
|
accountId: accountId || undefined,
|
|
};
|
|
}
|
|
|
|
return { mode: 'none' };
|
|
}
|
|
|
|
function normalizeLastRun(value: unknown): CronJobLastRun | undefined {
|
|
if (!value || typeof value !== 'object') return undefined;
|
|
|
|
const input = value as Partial<CronJobLastRun>;
|
|
const time = normalizeString(input.time);
|
|
if (!time) return undefined;
|
|
|
|
return {
|
|
time: normalizeIsoDate(time),
|
|
success: input.success !== false,
|
|
error: normalizeString(input.error) || undefined,
|
|
duration: typeof input.duration === 'number' && Number.isFinite(input.duration) ? input.duration : undefined,
|
|
};
|
|
}
|
|
|
|
function normalizeSchedule(value: unknown, fallback?: CronJob['schedule']): CronJob['schedule'] | null {
|
|
if (typeof value === 'string') {
|
|
const trimmed = value.trim();
|
|
return trimmed || fallback || null;
|
|
}
|
|
|
|
if (!value || typeof value !== 'object') {
|
|
return fallback || null;
|
|
}
|
|
|
|
const input = value as Partial<CronSchedule>;
|
|
if (input.kind === 'at') {
|
|
const at = normalizeString(input.at);
|
|
return at ? { kind: 'at', at: normalizeIsoDate(at) } : fallback || null;
|
|
}
|
|
|
|
if (input.kind === 'every') {
|
|
const everyMs = typeof input.everyMs === 'number' && Number.isFinite(input.everyMs) ? input.everyMs : 0;
|
|
if (everyMs <= 0) return fallback || null;
|
|
|
|
return {
|
|
kind: 'every',
|
|
everyMs,
|
|
anchorMs: typeof input.anchorMs === 'number' && Number.isFinite(input.anchorMs) ? input.anchorMs : undefined,
|
|
};
|
|
}
|
|
|
|
if (input.kind === 'cron') {
|
|
const expr = normalizeString(input.expr);
|
|
return expr
|
|
? {
|
|
kind: 'cron',
|
|
expr,
|
|
tz: normalizeString(input.tz) || undefined,
|
|
}
|
|
: fallback || null;
|
|
}
|
|
|
|
return fallback || null;
|
|
}
|
|
|
|
function parseCronNumberToken(token: string, min: number, max: number, isDayOfWeek = false): number | null {
|
|
if (!/^\d+$/.test(token)) return null;
|
|
|
|
const parsed = Number(token);
|
|
if (!Number.isFinite(parsed)) return null;
|
|
if (isDayOfWeek && parsed === 7) return 0;
|
|
if (parsed < min || parsed > max) return null;
|
|
return parsed;
|
|
}
|
|
|
|
function matchesCronField(expression: string, value: number, min: number, max: number, isDayOfWeek = false): boolean {
|
|
const trimmed = expression.trim();
|
|
if (!trimmed) return false;
|
|
if (trimmed === '*') return true;
|
|
|
|
return trimmed.split(',').some((segment) => {
|
|
const part = segment.trim();
|
|
if (!part) return false;
|
|
if (part === '*') return true;
|
|
|
|
const [rangeExpression, stepExpression] = part.split('/');
|
|
const step = stepExpression ? Number(stepExpression) : 1;
|
|
if (!Number.isFinite(step) || step <= 0) return false;
|
|
|
|
if (rangeExpression === '*') {
|
|
return (value - min) % step === 0;
|
|
}
|
|
|
|
if (rangeExpression.includes('-')) {
|
|
const [startRaw, endRaw] = rangeExpression.split('-');
|
|
const start = parseCronNumberToken(startRaw.trim(), min, max, isDayOfWeek);
|
|
const end = parseCronNumberToken(endRaw.trim(), min, max, isDayOfWeek);
|
|
if (start == null || end == null || start > end) return false;
|
|
if (value < start || value > end) return false;
|
|
return (value - start) % step === 0;
|
|
}
|
|
|
|
const literal = parseCronNumberToken(rangeExpression.trim(), min, max, isDayOfWeek);
|
|
if (literal == null) return false;
|
|
return value === literal;
|
|
});
|
|
}
|
|
|
|
function estimateNextCronRun(expr: string, now = new Date()): string | undefined {
|
|
const parts = expr.trim().split(/\s+/);
|
|
if (parts.length !== 5) return undefined;
|
|
|
|
const [minuteExpr, hourExpr, dayOfMonthExpr, monthExpr, dayOfWeekExpr] = parts;
|
|
const candidate = new Date(now);
|
|
candidate.setSeconds(0, 0);
|
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
|
|
for (let index = 0; index < MAX_CRON_LOOKAHEAD_MINUTES; index += 1) {
|
|
const minute = candidate.getMinutes();
|
|
const hour = candidate.getHours();
|
|
const dayOfMonth = candidate.getDate();
|
|
const month = candidate.getMonth() + 1;
|
|
const dayOfWeek = candidate.getDay();
|
|
|
|
if (
|
|
matchesCronField(minuteExpr, minute, 0, 59)
|
|
&& matchesCronField(hourExpr, hour, 0, 23)
|
|
&& matchesCronField(dayOfMonthExpr, dayOfMonth, 1, 31)
|
|
&& matchesCronField(monthExpr, month, 1, 12)
|
|
&& matchesCronField(dayOfWeekExpr, dayOfWeek, 0, 7, true)
|
|
) {
|
|
return candidate.toISOString();
|
|
}
|
|
|
|
candidate.setMinutes(candidate.getMinutes() + 1);
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
function estimateNextRun(schedule: CronJob['schedule'], enabled: boolean): string | undefined {
|
|
if (!enabled) return undefined;
|
|
|
|
if (typeof schedule === 'string') {
|
|
return estimateNextCronRun(schedule);
|
|
}
|
|
|
|
if (schedule.kind === 'at') {
|
|
const atMs = Date.parse(schedule.at);
|
|
if (!Number.isFinite(atMs) || atMs <= Date.now()) return undefined;
|
|
return new Date(atMs).toISOString();
|
|
}
|
|
|
|
if (schedule.kind === 'every') {
|
|
if (!Number.isFinite(schedule.everyMs) || schedule.everyMs <= 0) return undefined;
|
|
const nowMs = Date.now();
|
|
const anchorMs = typeof schedule.anchorMs === 'number' && Number.isFinite(schedule.anchorMs)
|
|
? schedule.anchorMs
|
|
: nowMs;
|
|
const delta = Math.max(nowMs - anchorMs, 0);
|
|
const steps = Math.floor(delta / schedule.everyMs) + 1;
|
|
return new Date(anchorMs + steps * schedule.everyMs).toISOString();
|
|
}
|
|
|
|
return estimateNextCronRun(schedule.expr);
|
|
}
|
|
|
|
function normalizeStoredJob(input: Partial<StoredCronJob> | null | undefined): StoredCronJob | null {
|
|
if (!input || typeof input !== 'object') return null;
|
|
|
|
const id = normalizeString(input.id);
|
|
const name = normalizeString(input.name);
|
|
const message = normalizeString(input.message);
|
|
const schedule = normalizeSchedule(input.schedule);
|
|
if (!id || !name || !message || !schedule) {
|
|
return null;
|
|
}
|
|
|
|
const createdAt = normalizeIsoDate(input.createdAt);
|
|
const updatedAt = normalizeIsoDate(input.updatedAt, createdAt);
|
|
const enabled = input.enabled !== false;
|
|
|
|
return {
|
|
id,
|
|
name,
|
|
message,
|
|
schedule,
|
|
delivery: normalizeDelivery(input.delivery),
|
|
enabled,
|
|
createdAt,
|
|
updatedAt,
|
|
lastRun: normalizeLastRun(input.lastRun),
|
|
nextRun: estimateNextRun(schedule, enabled),
|
|
agentId: normalizeString(input.agentId) || undefined,
|
|
};
|
|
}
|
|
|
|
function listNormalizedJobs(): StoredCronJob[] {
|
|
return readStore().jobs
|
|
.map((job) => normalizeStoredJob(job))
|
|
.filter((job): job is StoredCronJob => Boolean(job))
|
|
.sort((left, right) => Date.parse(right.updatedAt) - Date.parse(left.updatedAt));
|
|
}
|
|
|
|
function writeJobs(jobs: StoredCronJob[]): StoredCronJob[] {
|
|
writeStore({ jobs });
|
|
return jobs;
|
|
}
|
|
|
|
function ensureCreateInput(input: CronJobCreateInput): void {
|
|
if (!normalizeString(input.name)) {
|
|
throw new Error('name is required');
|
|
}
|
|
|
|
if (!normalizeString(input.message)) {
|
|
throw new Error('message is required');
|
|
}
|
|
|
|
if (!normalizeString(input.schedule)) {
|
|
throw new Error('schedule is required');
|
|
}
|
|
}
|
|
|
|
export function listCronJobs(): CronJob[] {
|
|
return listNormalizedJobs();
|
|
}
|
|
|
|
export function createCronJob(input: CronJobCreateInput & { agentId?: string | null }): CronJob {
|
|
ensureCreateInput(input);
|
|
|
|
const jobs = listNormalizedJobs();
|
|
const now = new Date().toISOString();
|
|
const schedule = normalizeSchedule(input.schedule);
|
|
if (!schedule) {
|
|
throw new Error('schedule is required');
|
|
}
|
|
|
|
const nextJob = normalizeStoredJob({
|
|
id: `cron-${randomUUID()}`,
|
|
name: input.name,
|
|
message: input.message,
|
|
schedule,
|
|
delivery: input.delivery,
|
|
enabled: input.enabled !== false,
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
agentId: input.agentId,
|
|
});
|
|
|
|
if (!nextJob) {
|
|
throw new Error('Failed to create cron job');
|
|
}
|
|
|
|
writeJobs([...jobs, nextJob]);
|
|
return nextJob;
|
|
}
|
|
|
|
export function updateCronJob(jobId: string, input: CronJobUpdateInput & { agentId?: string | null }): CronJob {
|
|
const normalizedJobId = normalizeString(jobId);
|
|
if (!normalizedJobId) {
|
|
throw new Error('id is required');
|
|
}
|
|
|
|
const jobs = listNormalizedJobs();
|
|
const index = jobs.findIndex((job) => job.id === normalizedJobId);
|
|
if (index === -1) {
|
|
throw new Error(`Cron job "${normalizedJobId}" not found`);
|
|
}
|
|
|
|
const currentJob = jobs[index];
|
|
const schedule = normalizeSchedule(input.schedule, currentJob.schedule);
|
|
const nextJob = normalizeStoredJob({
|
|
...currentJob,
|
|
name: normalizeString(input.name) || currentJob.name,
|
|
message: normalizeString(input.message) || currentJob.message,
|
|
schedule,
|
|
delivery: typeof input.delivery === 'undefined' ? currentJob.delivery : input.delivery,
|
|
enabled: typeof input.enabled === 'boolean' ? input.enabled : currentJob.enabled,
|
|
updatedAt: new Date().toISOString(),
|
|
agentId: typeof input.agentId === 'undefined' ? currentJob.agentId : input.agentId,
|
|
});
|
|
|
|
if (!nextJob) {
|
|
throw new Error(`Cron job "${normalizedJobId}" could not be normalized`);
|
|
}
|
|
|
|
jobs[index] = nextJob;
|
|
writeJobs(jobs);
|
|
return nextJob;
|
|
}
|
|
|
|
export function deleteCronJob(jobId: string): { id: string } {
|
|
const normalizedJobId = normalizeString(jobId);
|
|
if (!normalizedJobId) {
|
|
throw new Error('id is required');
|
|
}
|
|
|
|
const jobs = listNormalizedJobs();
|
|
const nextJobs = jobs.filter((job) => job.id !== normalizedJobId);
|
|
if (nextJobs.length === jobs.length) {
|
|
throw new Error(`Cron job "${normalizedJobId}" not found`);
|
|
}
|
|
|
|
writeJobs(nextJobs);
|
|
return { id: normalizedJobId };
|
|
}
|
|
|
|
export function toggleCronJob(jobId: string, enabled: boolean): CronJob {
|
|
return updateCronJob(jobId, { enabled });
|
|
}
|
|
|
|
export function triggerCronJob(jobId: string): CronJob {
|
|
const normalizedJobId = normalizeString(jobId);
|
|
if (!normalizedJobId) {
|
|
throw new Error('id is required');
|
|
}
|
|
|
|
const jobs = listNormalizedJobs();
|
|
const index = jobs.findIndex((job) => job.id === normalizedJobId);
|
|
if (index === -1) {
|
|
throw new Error(`Cron job "${normalizedJobId}" not found`);
|
|
}
|
|
|
|
const currentJob = jobs[index];
|
|
const nextJob = normalizeStoredJob({
|
|
...currentJob,
|
|
updatedAt: new Date().toISOString(),
|
|
lastRun: {
|
|
time: new Date().toISOString(),
|
|
success: true,
|
|
},
|
|
});
|
|
|
|
if (!nextJob) {
|
|
throw new Error(`Cron job "${normalizedJobId}" could not be normalized`);
|
|
}
|
|
|
|
jobs[index] = nextJob;
|
|
writeJobs(jobs);
|
|
return nextJob;
|
|
}
|