agent-ecosystem/src/main/ipc/schedule.ts
iliya 1d3080f0f9 feat: add scheduled tasks with cron execution and rich log viewer
- Cron-based task scheduling with SchedulerService (create, pause, resume, delete)
- One-shot executor using `claude -p` with stream-json output for rich log display
- CLAUDECODE env var stripped to prevent nested session detection
- JsonScheduleRepository for persistent schedule/run/log storage
- Full IPC pipeline: handlers, preload bridge, API types, HttpClient stubs
- ScheduleSection UI with create/edit dialog, run history, status badges
- ScheduleRunLogDialog with CliLogsRichView (thinking blocks, tool cards, markdown)
- Real-time run status updates via store subscription
- Retry logic with configurable max retries and auto-pause on consecutive failures
- CronScheduleInput with human-readable descriptions via cronstrue
- 3 test suites: SchedulerService, ScheduledTaskExecutor, JsonScheduleRepository
2026-03-08 00:58:07 +02:00

204 lines
6.9 KiB
TypeScript

/**
* IPC handlers for scheduled tasks.
*
* Pattern: initializeScheduleHandlers(service) → registerScheduleHandlers(ipcMain)
* → removeScheduleHandlers(ipcMain)
*/
import {
SCHEDULE_CREATE,
SCHEDULE_DELETE,
SCHEDULE_GET,
SCHEDULE_GET_RUN_LOGS,
SCHEDULE_GET_RUNS,
SCHEDULE_LIST,
SCHEDULE_PAUSE,
SCHEDULE_RESUME,
SCHEDULE_TRIGGER_NOW,
SCHEDULE_UPDATE,
// eslint-disable-next-line boundaries/element-types -- IPC channel constants are shared between main and preload by design
} from '@preload/constants/ipcChannels';
import { createLogger } from '@shared/utils/logger';
import type { SchedulerService } from '../services/schedule/SchedulerService';
import type {
CreateScheduleInput,
IpcResult,
Schedule,
ScheduleRun,
UpdateSchedulePatch,
} from '@shared/types';
import type { IpcMain, IpcMainInvokeEvent } from 'electron';
const logger = createLogger('IPC:schedule');
let schedulerService: SchedulerService | null = null;
function getService(): SchedulerService {
if (!schedulerService) {
throw new Error('SchedulerService not initialized');
}
return schedulerService;
}
async function wrapScheduleHandler<T>(
operation: string,
handler: () => Promise<T>
): Promise<IpcResult<T>> {
try {
const data = await handler();
return { success: true, data };
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
logger.error(`[schedule:${operation}] ${message}`);
return { success: false, error: message };
}
}
// =============================================================================
// Handlers
// =============================================================================
async function handleList(_event: IpcMainInvokeEvent): Promise<IpcResult<Schedule[]>> {
return wrapScheduleHandler('list', () => getService().listSchedules());
}
async function handleGet(
_event: IpcMainInvokeEvent,
id: unknown
): Promise<IpcResult<Schedule | null>> {
if (typeof id !== 'string' || !id.trim()) {
return { success: false, error: 'id must be a non-empty string' };
}
return wrapScheduleHandler('get', () => getService().getSchedule(id));
}
async function handleCreate(
_event: IpcMainInvokeEvent,
input: unknown
): Promise<IpcResult<Schedule>> {
if (!input || typeof input !== 'object') {
return { success: false, error: 'input must be an object' };
}
const inp = input as CreateScheduleInput;
if (!inp.teamName || !inp.cronExpression || !inp.timezone || !inp.launchConfig) {
return {
success: false,
error: 'Missing required fields: teamName, cronExpression, timezone, launchConfig',
};
}
if (!inp.launchConfig.cwd || !inp.launchConfig.prompt) {
return { success: false, error: 'launchConfig requires cwd and prompt' };
}
return wrapScheduleHandler('create', () => getService().createSchedule(inp));
}
async function handleUpdate(
_event: IpcMainInvokeEvent,
id: unknown,
patch: unknown
): Promise<IpcResult<Schedule>> {
if (typeof id !== 'string' || !id.trim()) {
return { success: false, error: 'id must be a non-empty string' };
}
if (!patch || typeof patch !== 'object') {
return { success: false, error: 'patch must be an object' };
}
return wrapScheduleHandler('update', () =>
getService().updateSchedule(id, patch as UpdateSchedulePatch)
);
}
async function handleDelete(_event: IpcMainInvokeEvent, id: unknown): Promise<IpcResult<void>> {
if (typeof id !== 'string' || !id.trim()) {
return { success: false, error: 'id must be a non-empty string' };
}
return wrapScheduleHandler('delete', () => getService().deleteSchedule(id));
}
async function handlePause(_event: IpcMainInvokeEvent, id: unknown): Promise<IpcResult<void>> {
if (typeof id !== 'string' || !id.trim()) {
return { success: false, error: 'id must be a non-empty string' };
}
return wrapScheduleHandler('pause', () => getService().pauseSchedule(id));
}
async function handleResume(_event: IpcMainInvokeEvent, id: unknown): Promise<IpcResult<void>> {
if (typeof id !== 'string' || !id.trim()) {
return { success: false, error: 'id must be a non-empty string' };
}
return wrapScheduleHandler('resume', () => getService().resumeSchedule(id));
}
async function handleTriggerNow(
_event: IpcMainInvokeEvent,
id: unknown
): Promise<IpcResult<ScheduleRun>> {
if (typeof id !== 'string' || !id.trim()) {
return { success: false, error: 'id must be a non-empty string' };
}
return wrapScheduleHandler('triggerNow', () => getService().triggerNow(id));
}
async function handleGetRuns(
_event: IpcMainInvokeEvent,
scheduleId: unknown,
opts?: unknown
): Promise<IpcResult<ScheduleRun[]>> {
if (typeof scheduleId !== 'string' || !scheduleId.trim()) {
return { success: false, error: 'scheduleId must be a non-empty string' };
}
const parsedOpts =
opts && typeof opts === 'object' ? (opts as { limit?: number; offset?: number }) : undefined;
return wrapScheduleHandler('getRuns', () => getService().getRuns(scheduleId, parsedOpts));
}
async function handleGetRunLogs(
_event: IpcMainInvokeEvent,
scheduleId: unknown,
runId: unknown
): Promise<IpcResult<{ stdout: string; stderr: string }>> {
if (typeof scheduleId !== 'string' || !scheduleId.trim()) {
return { success: false, error: 'scheduleId must be a non-empty string' };
}
if (typeof runId !== 'string' || !runId.trim()) {
return { success: false, error: 'runId must be a non-empty string' };
}
return wrapScheduleHandler('getRunLogs', () => getService().getRunLogs(scheduleId, runId));
}
// =============================================================================
// Lifecycle
// =============================================================================
export function initializeScheduleHandlers(service: SchedulerService): void {
schedulerService = service;
}
export function registerScheduleHandlers(ipcMain: IpcMain): void {
ipcMain.handle(SCHEDULE_LIST, handleList);
ipcMain.handle(SCHEDULE_GET, handleGet);
ipcMain.handle(SCHEDULE_CREATE, handleCreate);
ipcMain.handle(SCHEDULE_UPDATE, handleUpdate);
ipcMain.handle(SCHEDULE_DELETE, handleDelete);
ipcMain.handle(SCHEDULE_PAUSE, handlePause);
ipcMain.handle(SCHEDULE_RESUME, handleResume);
ipcMain.handle(SCHEDULE_TRIGGER_NOW, handleTriggerNow);
ipcMain.handle(SCHEDULE_GET_RUNS, handleGetRuns);
ipcMain.handle(SCHEDULE_GET_RUN_LOGS, handleGetRunLogs);
logger.info('Schedule handlers registered');
}
export function removeScheduleHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(SCHEDULE_LIST);
ipcMain.removeHandler(SCHEDULE_GET);
ipcMain.removeHandler(SCHEDULE_CREATE);
ipcMain.removeHandler(SCHEDULE_UPDATE);
ipcMain.removeHandler(SCHEDULE_DELETE);
ipcMain.removeHandler(SCHEDULE_PAUSE);
ipcMain.removeHandler(SCHEDULE_RESUME);
ipcMain.removeHandler(SCHEDULE_TRIGGER_NOW);
ipcMain.removeHandler(SCHEDULE_GET_RUNS);
ipcMain.removeHandler(SCHEDULE_GET_RUN_LOGS);
logger.info('Schedule handlers removed');
}