feat: refactor HomePage to integrate agents store and update related components
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
This commit is contained in:
417
electron/utils/cron-store.ts
Normal file
417
electron/utils/cron-store.ts
Normal file
@@ -0,0 +1,417 @@
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user