diff --git a/package.json b/package.json index 5f750461..30fb3b1d 100644 --- a/package.json +++ b/package.json @@ -115,6 +115,8 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "1.0.4", + "croner": "^10.0.1", + "cronstrue": "^3.13.0", "date-fns": "^3.6.0", "diff": "^8.0.3", "dompurify": "^3.3.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab8c9cf2..9d25bb05 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -158,6 +158,12 @@ importers: cmdk: specifier: 1.0.4 version: 1.0.4(@types/react-dom@18.3.7(@types/react@18.3.27))(@types/react@18.3.27)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + croner: + specifier: ^10.0.1 + version: 10.0.1 + cronstrue: + specifier: ^3.13.0 + version: 3.13.0 date-fns: specifier: ^3.6.0 version: 3.6.0 @@ -3055,6 +3061,14 @@ packages: crelt@1.0.6: resolution: {integrity: sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==} + croner@10.0.1: + resolution: {integrity: sha512-ixNtAJndqh173VQ4KodSdJEI6nuioBWI0V1ITNKhZZsO0pEMoDxz539T4FTTbSZ/xIOSuDnzxLVRqBVSvPNE2g==} + engines: {node: '>=18.0'} + + cronstrue@3.13.0: + resolution: {integrity: sha512-M06cKwRIN46AyuM8BOmF1HUkBTkd3/h7uYImnrH1T3wtRKBGOibVo3jZ42VheEvx8LtgZbG/4GI35vfIxYxMug==} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -9762,6 +9776,10 @@ snapshots: crelt@1.0.6: {} + croner@10.0.1: {} + + cronstrue@3.13.0: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 diff --git a/src/main/index.ts b/src/main/index.ts index a8a0376d..e1e6e29b 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -20,8 +20,12 @@ import { ChangeExtractorService } from '@main/services/team/ChangeExtractorServi import { FileContentResolver } from '@main/services/team/FileContentResolver'; import { GitDiffFallback } from '@main/services/team/GitDiffFallback'; import { ReviewApplierService } from '@main/services/team/ReviewApplierService'; +import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository'; +import { ScheduledTaskExecutor } from '@main/services/schedule/ScheduledTaskExecutor'; +import { SchedulerService } from '@main/services/schedule/SchedulerService'; import { CONTEXT_CHANGED, + SCHEDULE_CHANGE, SSH_STATUS, TEAM_CHANGE, TEAM_TOOL_APPROVAL_EVENT, @@ -317,6 +321,7 @@ let teamProvisioningService: TeamProvisioningService; let cliInstallerService: CliInstallerService; let ptyTerminalService: PtyTerminalService; let httpServer: HttpServer; +let schedulerService: SchedulerService; // File watcher event cleanup functions let fileChangeCleanup: (() => void) | null = null; @@ -648,6 +653,20 @@ function initializeServices(): void { const fileContentResolver = new FileContentResolver(teamMemberLogsFinder, gitDiffFallback); const reviewApplier = new ReviewApplierService(); + // Create SchedulerService for cron-based task execution + const scheduleRepository = new JsonScheduleRepository(); + const scheduledTaskExecutor = new ScheduledTaskExecutor(); + schedulerService = new SchedulerService( + scheduleRepository, + scheduledTaskExecutor, + async (cwd: string) => { + const result = await teamProvisioningService.prepareForProvisioning(cwd, { + forceFresh: true, + }); + return { ready: result.ready, message: result.message }; + } + ); + // warmup() and ensureInstalled() are deferred to after window creation // (did-finish-load handler) to avoid thread pool contention at startup. httpServer = new HttpServer(); @@ -661,6 +680,13 @@ function initializeServices(): void { }; teamProvisioningService.setTeamChangeEmitter(teamChangeEmitter); + // Allow SchedulerService to push schedule events to renderer + schedulerService.setChangeEmitter((event) => { + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send(SCHEDULE_CHANGE, event); + } + }); + teamProvisioningService.setToolApprovalEventEmitter((event) => { if (mainWindow && !mainWindow.isDestroyed()) { mainWindow.webContents.send(TEAM_TOOL_APPROVAL_EVENT, event); @@ -684,6 +710,7 @@ function initializeServices(): void { full: onContextSwitched, onClaudeRootPathUpdated: (_claudeRootPath: string | null) => { reconfigureLocalContextForClaudeRoot(); + void schedulerService?.reloadForClaudeRootChange(); }, }, { @@ -695,7 +722,8 @@ function initializeServices(): void { reviewApplier, gitDiffFallback, cliInstallerService, - ptyTerminalService + ptyTerminalService, + schedulerService ); // Forward SSH state changes to renderer and HTTP SSE clients @@ -804,6 +832,11 @@ function shutdownServices(): void { teamDataService.stopProcessHealthPolling(); } + // Stop scheduled task execution and croner jobs + if (schedulerService) { + void schedulerService.stop(); + } + // Kill all PTY processes if (ptyTerminalService) { ptyTerminalService.killAll(); @@ -951,6 +984,7 @@ function createWindow(): void { setTimeout(() => { void teamProvisioningService.warmup(); teamDataService.startProcessHealthPolling(); + void schedulerService?.start(); }, 5000); } }); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c17fee2a..4f7fffb9 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -44,6 +44,11 @@ import { } from './projects'; import { registerRendererLogHandlers, removeRendererLogHandlers } from './rendererLogs'; import { initializeReviewHandlers, registerReviewHandlers, removeReviewHandlers } from './review'; +import { + initializeScheduleHandlers, + registerScheduleHandlers, + removeScheduleHandlers, +} from './schedule'; import { initializeSearchHandlers, registerSearchHandlers, removeSearchHandlers } from './search'; import { initializeSessionHandlers, @@ -88,6 +93,7 @@ import type { UpdaterService, } from '../services'; import type { HttpServer } from '../services/infrastructure/HttpServer'; +import type { SchedulerService } from '../services/schedule/SchedulerService'; /** * Initializes IPC handlers with service registry. @@ -114,7 +120,8 @@ export function initializeIpcHandlers( reviewApplier?: ReviewApplierService, gitDiffFallback?: GitDiffFallback, cliInstaller?: CliInstallerService, - ptyTerminal?: PtyTerminalService + ptyTerminal?: PtyTerminalService, + schedulerService?: SchedulerService ): void { // Initialize domain handlers with registry initializeProjectHandlers(registry); @@ -147,6 +154,10 @@ export function initializeIpcHandlers( } initializeEditorHandlers(); + if (schedulerService) { + initializeScheduleHandlers(schedulerService); + } + if (changeExtractor) { initializeReviewHandlers({ extractor: changeExtractor, @@ -173,6 +184,7 @@ export function initializeIpcHandlers( registerEditorHandlers(ipcMain); registerWindowHandlers(ipcMain); registerRendererLogHandlers(ipcMain); + registerScheduleHandlers(ipcMain); if (cliInstaller) { registerCliInstallerHandlers(ipcMain); } @@ -207,6 +219,7 @@ export function removeIpcHandlers(): void { removeEditorHandlers(ipcMain); removeWindowHandlers(ipcMain); removeRendererLogHandlers(ipcMain); + removeScheduleHandlers(ipcMain); removeCliInstallerHandlers(ipcMain); removeTerminalHandlers(ipcMain); removeHttpServerHandlers(ipcMain); diff --git a/src/main/ipc/schedule.ts b/src/main/ipc/schedule.ts new file mode 100644 index 00000000..95c55322 --- /dev/null +++ b/src/main/ipc/schedule.ts @@ -0,0 +1,204 @@ +/** + * 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( + operation: string, + handler: () => Promise +): Promise> { + 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> { + return wrapScheduleHandler('list', () => getService().listSchedules()); +} + +async function handleGet( + _event: IpcMainInvokeEvent, + id: unknown +): Promise> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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> { + 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'); +} diff --git a/src/main/services/error/ErrorMessageBuilder.ts b/src/main/services/error/ErrorMessageBuilder.ts index 1aaf2eed..28d888c3 100644 --- a/src/main/services/error/ErrorMessageBuilder.ts +++ b/src/main/services/error/ErrorMessageBuilder.ts @@ -57,7 +57,9 @@ export interface DetectedError { | 'lead_inbox' | 'user_inbox' | 'task_clarification' - | 'task_status_change'; + | 'task_status_change' + | 'schedule_completed' + | 'schedule_failed'; /** Explicit key for storage deduplication. Two notifications with the same dedupeKey won't be stored twice. */ dedupeKey?: string; /** Additional context about the error */ diff --git a/src/main/services/index.ts b/src/main/services/index.ts index 08a5642a..81fcb8e4 100644 --- a/src/main/services/index.ts +++ b/src/main/services/index.ts @@ -15,3 +15,4 @@ export * from './error'; export * from './infrastructure'; export * from './parsing'; export * from './team'; +export * from './schedule'; diff --git a/src/main/services/schedule/JsonScheduleRepository.ts b/src/main/services/schedule/JsonScheduleRepository.ts new file mode 100644 index 00000000..bbb8461c --- /dev/null +++ b/src/main/services/schedule/JsonScheduleRepository.ts @@ -0,0 +1,206 @@ +/** + * JSON-based ScheduleRepository implementation. + * + * Storage layout: + * {getSchedulesBasePath()}/ + * schedules.json — Schedule[] + * runs/{scheduleId}.json — ScheduleRun[] (newest first, max 50) + * logs/{scheduleId}/{runId}.log — stdout (max 64KB) + * logs/{scheduleId}/{runId}.err — stderr (max 16KB) + */ + +import { atomicWriteAsync } from '@main/utils/atomicWrite'; +import { getSchedulesBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs'; +import * as path from 'path'; + +import type { Schedule, ScheduleRun } from '@shared/types'; +import type { ScheduleRepository } from './ScheduleRepository'; + +const logger = createLogger('Service:JsonScheduleRepo'); + +const READ_TIMEOUT_MS = 5_000; +const MAX_RUNS_PER_SCHEDULE = 50; + +export class JsonScheduleRepository implements ScheduleRepository { + private get basePath(): string { + return getSchedulesBasePath(); + } + + private get schedulesFilePath(): string { + return path.join(this.basePath, 'schedules.json'); + } + + private runsFilePath(scheduleId: string): string { + return path.join(this.basePath, 'runs', `${scheduleId}.json`); + } + + private logsDir(scheduleId: string): string { + return path.join(this.basePath, 'logs', scheduleId); + } + + // --------------------------------------------------------------------------- + // Schedule CRUD + // --------------------------------------------------------------------------- + + async listSchedules(): Promise { + return this.readSchedulesFile(); + } + + async getSchedule(id: string): Promise { + const schedules = await this.readSchedulesFile(); + return schedules.find((s) => s.id === id) ?? null; + } + + async getSchedulesByTeam(teamName: string): Promise { + const schedules = await this.readSchedulesFile(); + return schedules.filter((s) => s.teamName === teamName); + } + + async saveSchedule(schedule: Schedule): Promise { + const schedules = await this.readSchedulesFile(); + const idx = schedules.findIndex((s) => s.id === schedule.id); + if (idx >= 0) { + schedules[idx] = schedule; + } else { + schedules.push(schedule); + } + await this.writeSchedulesFile(schedules); + } + + async deleteSchedule(id: string): Promise { + const schedules = await this.readSchedulesFile(); + const filtered = schedules.filter((s) => s.id !== id); + if (filtered.length !== schedules.length) { + await this.writeSchedulesFile(filtered); + } + // Clean up runs and logs + const runsFile = this.runsFilePath(id); + await fs.promises.unlink(runsFile).catch(() => undefined); + const logsPath = this.logsDir(id); + await fs.promises.rm(logsPath, { recursive: true, force: true }).catch(() => undefined); + } + + // --------------------------------------------------------------------------- + // Run CRUD + // --------------------------------------------------------------------------- + + async listRuns( + scheduleId: string, + opts?: { limit?: number; offset?: number } + ): Promise { + const runs = await this.readRunsFile(scheduleId); + const offset = opts?.offset ?? 0; + const limit = opts?.limit ?? runs.length; + return runs.slice(offset, offset + limit); + } + + async getLatestRun(scheduleId: string): Promise { + const runs = await this.readRunsFile(scheduleId); + return runs[0] ?? null; + } + + async saveRun(run: ScheduleRun): Promise { + const runs = await this.readRunsFile(run.scheduleId); + const idx = runs.findIndex((r) => r.id === run.id); + if (idx >= 0) { + runs[idx] = run; + } else { + runs.unshift(run); // newest first + } + // Enforce max limit + const trimmed = runs.slice(0, MAX_RUNS_PER_SCHEDULE); + await this.writeRunsFile(run.scheduleId, trimmed); + } + + async pruneOldRuns(scheduleId: string, keepCount: number): Promise { + const runs = await this.readRunsFile(scheduleId); + if (runs.length <= keepCount) { + return 0; + } + const removed = runs.slice(keepCount); + const kept = runs.slice(0, keepCount); + await this.writeRunsFile(scheduleId, kept); + + // Clean up log files for pruned runs + for (const run of removed) { + await this.deleteRunLogs(scheduleId, run.id); + } + + return removed.length; + } + + // --------------------------------------------------------------------------- + // Internal I/O + // --------------------------------------------------------------------------- + + private async readSchedulesFile(): Promise { + return this.readJsonFile(this.schedulesFilePath, []); + } + + private async writeSchedulesFile(schedules: Schedule[]): Promise { + await atomicWriteAsync(this.schedulesFilePath, JSON.stringify(schedules, null, 2)); + } + + private async readRunsFile(scheduleId: string): Promise { + return this.readJsonFile(this.runsFilePath(scheduleId), []); + } + + private async writeRunsFile(scheduleId: string, runs: ScheduleRun[]): Promise { + await atomicWriteAsync(this.runsFilePath(scheduleId), JSON.stringify(runs, null, 2)); + } + + private async readJsonFile(filePath: string, defaultValue: T): Promise { + try { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), READ_TIMEOUT_MS); + try { + const content = await fs.promises.readFile(filePath, { + encoding: 'utf8', + signal: controller.signal, + }); + return JSON.parse(content) as T; + } finally { + clearTimeout(timeoutId); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return defaultValue; + } + logger.warn( + `Failed to read ${filePath}: ${error instanceof Error ? error.message : String(error)}` + ); + return defaultValue; + } + } + + private async deleteRunLogs(scheduleId: string, runId: string): Promise { + const dir = this.logsDir(scheduleId); + await fs.promises.unlink(path.join(dir, `${runId}.log`)).catch(() => undefined); + await fs.promises.unlink(path.join(dir, `${runId}.err`)).catch(() => undefined); + } + + async saveRunLogs( + scheduleId: string, + runId: string, + stdout: string, + stderr: string + ): Promise { + const dir = this.logsDir(scheduleId); + await fs.promises.mkdir(dir, { recursive: true }); + await Promise.all([ + fs.promises.writeFile(path.join(dir, `${runId}.log`), stdout, 'utf8'), + fs.promises.writeFile(path.join(dir, `${runId}.err`), stderr, 'utf8'), + ]); + } + + async getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }> { + const dir = this.logsDir(scheduleId); + const [stdout, stderr] = await Promise.all([ + fs.promises.readFile(path.join(dir, `${runId}.log`), 'utf8').catch(() => ''), + fs.promises.readFile(path.join(dir, `${runId}.err`), 'utf8').catch(() => ''), + ]); + return { stdout, stderr }; + } +} diff --git a/src/main/services/schedule/ScheduleRepository.ts b/src/main/services/schedule/ScheduleRepository.ts new file mode 100644 index 00000000..aebc14c7 --- /dev/null +++ b/src/main/services/schedule/ScheduleRepository.ts @@ -0,0 +1,24 @@ +/** + * Schedule repository interface — abstracts storage backend. + * + * Current implementation: JsonScheduleRepository (JSON files on disk). + * Future upgrade path: Drizzle + sql.js (WASM, no native modules). + */ + +import type { Schedule, ScheduleRun } from '@shared/types'; + +export interface ScheduleRepository { + listSchedules(): Promise; + getSchedule(id: string): Promise; + getSchedulesByTeam(teamName: string): Promise; + saveSchedule(schedule: Schedule): Promise; + deleteSchedule(id: string): Promise; + + listRuns(scheduleId: string, opts?: { limit?: number; offset?: number }): Promise; + getLatestRun(scheduleId: string): Promise; + saveRun(run: ScheduleRun): Promise; + pruneOldRuns(scheduleId: string, keepCount: number): Promise; + + saveRunLogs(scheduleId: string, runId: string, stdout: string, stderr: string): Promise; + getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }>; +} diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts new file mode 100644 index 00000000..6dab6fd8 --- /dev/null +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -0,0 +1,224 @@ +/** + * One-shot executor for scheduled tasks. + * + * Spawns `claude -p ` as a child process with stream-json output, + * captures stdout/stderr, and returns the result when the process exits. + * + * Uses `--output-format stream-json` so the renderer can display rich logs + * (thinking blocks, tool cards, markdown) via CliLogsRichView. + */ + +import { killProcessTree, spawnCli } from '@main/utils/childProcess'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; +import { createLogger } from '@shared/utils/logger'; + +import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; + +import type { ScheduleLaunchConfig, ScheduleRun } from '@shared/types'; +import type { ChildProcess } from 'child_process'; + +const logger = createLogger('Service:ScheduledTaskExecutor'); + +const STDOUT_MAX_BYTES = 512 * 1024; // 512KB — stream-json is verbose (JSON wrappers, thinking, tool_use) +const STDERR_MAX_BYTES = 16 * 1024; // 16KB +const SUMMARY_MAX_CHARS = 500; + +/** + * Extracts a human-readable summary from stream-json stdout. + * Finds the last assistant message's text content blocks. + * Falls back to raw stdout slice if parsing yields nothing. + */ +function extractSummaryFromStreamJson(stdout: string): string { + const lines = stdout.split('\n'); + let lastText = ''; + + for (let i = lines.length - 1; i >= 0; i--) { + const trimmed = lines[i].trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as Record; + if (parsed.type !== 'assistant') continue; + + const content = (parsed.content ?? + (parsed.message as Record | undefined)?.content) as + | Array<{ type?: string; text?: string }> + | undefined; + if (!Array.isArray(content)) continue; + + for (const block of content) { + if (block?.type === 'text' && typeof block.text === 'string' && block.text.trim()) { + lastText = block.text.trim(); + } + } + if (lastText) break; + } catch { + // skip non-JSON lines + } + } + + return (lastText || stdout).slice(0, SUMMARY_MAX_CHARS); +} + +export interface ScheduledTaskResult { + exitCode: number | null; + stdout: string; + stderr: string; + summary: string; + durationMs: number; +} + +export interface ExecutionRequest { + runId: string; + config: ScheduleLaunchConfig; + maxTurns: number; + maxBudgetUsd?: number; +} + +/** + * Internal extension of ScheduleRun with pinned storage path. + * Used by SchedulerService to ensure writes go to the correct path + * even if claudeRootPath changes mid-run. + */ +export interface InternalScheduleRun extends ScheduleRun { + storageBasePath: string; +} + +export class ScheduledTaskExecutor { + private activeProcesses = new Map(); + + async execute(request: ExecutionRequest): Promise { + const startTime = Date.now(); + + const binaryPath = await ClaudeBinaryResolver.resolve(); + if (!binaryPath) { + throw new Error('Claude CLI binary not found'); + } + + const shellEnv = await resolveInteractiveShellEnv(); + + const args = this.buildArgs(request); + + logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); + + const child = spawnCli(binaryPath, args, { + cwd: request.config.cwd, + env: { ...process.env, ...shellEnv, CLAUDECODE: undefined }, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + this.activeProcesses.set(request.runId, child); + + try { + const result = await this.waitForExit(child, request.runId); + const durationMs = Date.now() - startTime; + + return { + exitCode: result.exitCode, + stdout: result.stdout, + stderr: result.stderr, + summary: extractSummaryFromStreamJson(result.stdout), + durationMs, + }; + } finally { + this.activeProcesses.delete(request.runId); + } + } + + cancel(runId: string): boolean { + const child = this.activeProcesses.get(runId); + if (!child) { + return false; + } + logger.info(`[${runId}] Cancelling active run`); + killProcessTree(child, 'SIGTERM'); + this.activeProcesses.delete(runId); + return true; + } + + cancelAll(): void { + for (const [runId, child] of this.activeProcesses) { + logger.info(`[${runId}] Cancelling (shutdown)`); + killProcessTree(child, 'SIGTERM'); + } + this.activeProcesses.clear(); + } + + get activeCount(): number { + return this.activeProcesses.size; + } + + private buildArgs(request: ExecutionRequest): string[] { + const { config, maxTurns, maxBudgetUsd } = request; + const args: string[] = [ + '-p', + config.prompt, + '--output-format', + 'stream-json', + '--verbose', + '--max-turns', + String(maxTurns), + '--no-session-persistence', + ]; + + if (maxBudgetUsd != null) { + args.push('--max-budget-usd', String(maxBudgetUsd)); + } + + if (config.model) { + args.push('--model', config.model); + } + + if (config.skipPermissions !== false) { + args.push('--dangerously-skip-permissions'); + } + + if (config.allowedTools?.length) { + args.push('--allowed-tools', config.allowedTools.join(',')); + } + + if (config.disallowedTools?.length) { + args.push('--disallowed-tools', config.disallowedTools.join(',')); + } + + return args; + } + + private waitForExit( + child: ChildProcess, + runId: string + ): Promise<{ exitCode: number | null; stdout: string; stderr: string }> { + return new Promise((resolve, reject) => { + const stdoutChunks: Buffer[] = []; + const stderrChunks: Buffer[] = []; + let stdoutBytes = 0; + let stderrBytes = 0; + + child.stdout?.on('data', (chunk: Buffer) => { + if (stdoutBytes < STDOUT_MAX_BYTES) { + stdoutChunks.push(chunk); + stdoutBytes += chunk.length; + } + }); + + child.stderr?.on('data', (chunk: Buffer) => { + if (stderrBytes < STDERR_MAX_BYTES) { + stderrChunks.push(chunk); + stderrBytes += chunk.length; + } + }); + + child.once('error', (error) => { + logger.error(`[${runId}] Process error: ${error.message}`); + reject(error); + }); + + child.once('close', (code) => { + const stdout = Buffer.concat(stdoutChunks).toString('utf8').slice(0, STDOUT_MAX_BYTES); + const stderr = Buffer.concat(stderrChunks).toString('utf8').slice(0, STDERR_MAX_BYTES); + + logger.info(`[${runId}] Process exited with code ${code}`); + resolve({ exitCode: code, stdout, stderr }); + }); + }); + } +} diff --git a/src/main/services/schedule/SchedulerService.ts b/src/main/services/schedule/SchedulerService.ts new file mode 100644 index 00000000..94bcb803 --- /dev/null +++ b/src/main/services/schedule/SchedulerService.ts @@ -0,0 +1,850 @@ +/** + * SchedulerService — orchestrates scheduled task execution via croner. + * + * Manages cron jobs, warm-up timers, execution lifecycle, and concurrency locks. + * Uses one-shot `claude -p` executor (NOT launchTeam stream-json). + */ + +import { getSchedulesBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import { Cron } from 'croner'; +import { randomUUID } from 'crypto'; + +import type { + CreateScheduleInput, + Schedule, + ScheduleChangeEvent, + ScheduleRun, + ScheduleRunStatus, + UpdateSchedulePatch, +} from '@shared/types'; +import type { ScheduleRepository } from './ScheduleRepository'; +import type { ScheduledTaskExecutor } from './ScheduledTaskExecutor'; + +const logger = createLogger('Service:Scheduler'); + +// ============================================================================= +// Constants +// ============================================================================= + +const WARMUP_RETRY_DELAY_MS = 60_000; +const WARMUP_MAX_RETRIES = 3; +const EXECUTION_MAX_RETRIES = 2; +const EXECUTION_RETRY_DELAY_MS = 90_000; // 90s between retries + +// ============================================================================= +// Types +// ============================================================================= + +type ChangeEmitter = (event: ScheduleChangeEvent) => void; + +/** Warm-up function injected from main process (wraps prepareForProvisioning) */ +export type WarmUpFn = (cwd: string) => Promise<{ ready: boolean; message: string }>; + +// ============================================================================= +// SchedulerService +// ============================================================================= + +export class SchedulerService { + private repository: ScheduleRepository; + private executor: ScheduledTaskExecutor; + private warmUpFn: WarmUpFn; + private changeEmitter: ChangeEmitter | null = null; + + // Croner jobs keyed by schedule ID + private cronJobs = new Map(); + + // Warm-up timers keyed by schedule ID (includes warm-up retry timers) + private warmUpTimers = new Map>(); + + // Execution retry delay timers keyed by schedule ID + private retryDelayTimers = new Map>(); + + // Active runs keyed by schedule ID (only one run per schedule at a time) + private activeRuns = new Map(); + + // CWD exclusion lock: cwd → schedule ID (prevents two schedule runs on same dir) + private cwdLock = new Map(); + + // Flag to prevent retry timers from firing after stop() + private stopped = false; + + constructor( + repository: ScheduleRepository, + executor: ScheduledTaskExecutor, + warmUpFn?: WarmUpFn + ) { + this.repository = repository; + this.executor = executor; + this.warmUpFn = warmUpFn ?? (async () => ({ ready: true, message: 'warm-up skipped' })); + } + + setChangeEmitter(emitter: ChangeEmitter): void { + this.changeEmitter = emitter; + } + + // =========================================================================== + // Lifecycle + // =========================================================================== + + async start(): Promise { + logger.info(`Scheduler starting, basePath=${getSchedulesBasePath()}`); + + this.stopped = false; + + // Recovery: mark interrupted runs from previous session + await this.recoverInterruptedRuns(); + + // Load all schedules and create cron jobs for active ones + const schedules = await this.repository.listSchedules(); + let activeCount = 0; + + for (const schedule of schedules) { + if (schedule.status === 'active') { + this.createCronJob(schedule); + activeCount++; + } + } + + logger.info( + `Scheduler started: ${activeCount} active jobs out of ${schedules.length} schedules` + ); + } + + async stop(): Promise { + logger.info('Scheduler stopping'); + + // Prevent retry timers from dispatching new work after stop + this.stopped = true; + + // Cancel all active executions + this.executor.cancelAll(); + + // Stop all cron jobs + for (const [id, job] of this.cronJobs) { + job.stop(); + logger.debug(`Stopped cron job: ${id}`); + } + this.cronJobs.clear(); + + // Clear all warm-up timers + for (const [id, timer] of this.warmUpTimers) { + clearTimeout(timer); + logger.debug(`Cleared warm-up timer: ${id}`); + } + this.warmUpTimers.clear(); + + // Clear execution retry delay timers + for (const [id, timer] of this.retryDelayTimers) { + clearTimeout(timer); + logger.debug(`Cleared retry delay timer: ${id}`); + } + this.retryDelayTimers.clear(); + + // Clear locks + this.activeRuns.clear(); + this.cwdLock.clear(); + + logger.info('Scheduler stopped'); + } + + // =========================================================================== + // CRUD + // =========================================================================== + + async listSchedules(): Promise { + return this.repository.listSchedules(); + } + + async getSchedule(id: string): Promise { + return this.repository.getSchedule(id); + } + + async getSchedulesByTeam(teamName: string): Promise { + return this.repository.getSchedulesByTeam(teamName); + } + + async createSchedule(input: CreateScheduleInput): Promise { + const now = new Date().toISOString(); + const schedule: Schedule = { + id: randomUUID(), + teamName: input.teamName, + label: input.label, + cronExpression: input.cronExpression, + timezone: input.timezone, + status: 'active', + warmUpMinutes: input.warmUpMinutes ?? 15, + maxConsecutiveFailures: 3, + consecutiveFailures: 0, + maxTurns: input.maxTurns ?? 50, + maxBudgetUsd: input.maxBudgetUsd, + createdAt: now, + updatedAt: now, + launchConfig: input.launchConfig, + }; + + // Compute nextRunAt before saving + schedule.nextRunAt = this.computeNextRunAt(schedule) ?? undefined; + + await this.repository.saveSchedule(schedule); + this.createCronJob(schedule); + + this.emitChange({ + type: 'schedule-updated', + scheduleId: schedule.id, + teamName: schedule.teamName, + detail: 'created', + }); + + logger.info(`Schedule created: ${schedule.id} for team ${schedule.teamName}`); + return schedule; + } + + async updateSchedule(id: string, patch: UpdateSchedulePatch): Promise { + const existing = await this.repository.getSchedule(id); + if (!existing) { + throw new Error(`Schedule not found: ${id}`); + } + + const updated: Schedule = { + ...existing, + ...(patch.label !== undefined && { label: patch.label }), + ...(patch.cronExpression !== undefined && { cronExpression: patch.cronExpression }), + ...(patch.timezone !== undefined && { timezone: patch.timezone }), + ...(patch.warmUpMinutes !== undefined && { warmUpMinutes: patch.warmUpMinutes }), + ...(patch.maxTurns !== undefined && { maxTurns: patch.maxTurns }), + ...(patch.maxBudgetUsd !== undefined && { maxBudgetUsd: patch.maxBudgetUsd }), + ...(patch.launchConfig && { + launchConfig: { ...existing.launchConfig, ...patch.launchConfig }, + }), + updatedAt: new Date().toISOString(), + }; + + // Reschedule cron job if expression or timezone changed + const cronChanged = patch.cronExpression !== undefined || patch.timezone !== undefined; + + if (cronChanged || patch.warmUpMinutes !== undefined) { + this.removeCronJob(id); + if (updated.status === 'active') { + updated.nextRunAt = this.computeNextRunAt(updated) ?? undefined; + this.createCronJob(updated); + } + } + + await this.repository.saveSchedule(updated); + this.emitChange({ + type: 'schedule-updated', + scheduleId: updated.id, + teamName: updated.teamName, + detail: 'updated', + }); + + return updated; + } + + async deleteSchedule(id: string): Promise { + const existing = await this.repository.getSchedule(id); + if (!existing) { + throw new Error(`Schedule not found: ${id}`); + } + + // Cancel active run if any + const activeRun = this.activeRuns.get(id); + if (activeRun) { + this.executor.cancel(activeRun.id); + } + this.removeCronJob(id); + this.releaseRunLocks(id); + + await this.repository.deleteSchedule(id); + this.emitChange({ + type: 'schedule-updated', + scheduleId: id, + teamName: existing.teamName, + detail: 'deleted', + }); + + logger.info(`Schedule deleted: ${id}`); + } + + async pauseSchedule(id: string): Promise { + const existing = await this.repository.getSchedule(id); + if (!existing) { + throw new Error(`Schedule not found: ${id}`); + } + + // Pause cron job + const job = this.cronJobs.get(id); + if (job) { + job.pause(); + } + + // Clear warm-up timer + this.clearWarmUpTimer(id); + + const updated: Schedule = { + ...existing, + status: 'paused', + updatedAt: new Date().toISOString(), + }; + + await this.repository.saveSchedule(updated); + this.emitChange({ + type: 'schedule-paused', + scheduleId: id, + teamName: existing.teamName, + }); + + logger.info(`Schedule paused: ${id}`); + } + + async resumeSchedule(id: string): Promise { + const existing = await this.repository.getSchedule(id); + if (!existing) { + throw new Error(`Schedule not found: ${id}`); + } + + // Remove old job and recreate to get fresh next-run timing + this.removeCronJob(id); + + const updated: Schedule = { + ...existing, + status: 'active', + consecutiveFailures: 0, + updatedAt: new Date().toISOString(), + }; + + updated.nextRunAt = this.computeNextRunAt(updated) ?? undefined; + this.createCronJob(updated); + + await this.repository.saveSchedule(updated); + this.emitChange({ + type: 'schedule-updated', + scheduleId: id, + teamName: existing.teamName, + detail: 'resumed', + }); + + logger.info(`Schedule resumed: ${id}`); + } + + // =========================================================================== + // Run History + // =========================================================================== + + async getRuns( + scheduleId: string, + opts?: { limit?: number; offset?: number } + ): Promise { + return this.repository.listRuns(scheduleId, opts); + } + + async getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }> { + return this.repository.getRunLogs(scheduleId, runId); + } + + // =========================================================================== + // Trigger Now + // =========================================================================== + + async triggerNow(id: string): Promise { + const schedule = await this.repository.getSchedule(id); + if (!schedule) { + throw new Error(`Schedule not found: ${id}`); + } + + // Check locks + if (this.activeRuns.has(id)) { + throw new Error(`Schedule ${id} already has an active run`); + } + + const cwd = schedule.launchConfig.cwd; + const cwdHolder = this.cwdLock.get(cwd); + if (cwdHolder && cwdHolder !== id) { + throw new Error(`Working directory "${cwd}" is locked by another schedule: ${cwdHolder}`); + } + + const now = new Date().toISOString(); + const run: ScheduleRun = { + id: randomUUID(), + scheduleId: id, + teamName: schedule.teamName, + status: 'running', + scheduledFor: now, + startedAt: now, + executionStartedAt: now, + retryCount: 0, + }; + + await this.repository.saveRun(run); + this.emitChange({ type: 'run-started', scheduleId: id, teamName: schedule.teamName }); + + // Execute in background (don't await — triggerNow returns immediately) + void this.executeRunInBackground(schedule, run); + + return run; + } + + // =========================================================================== + // claudeRootPath Change + // =========================================================================== + + async reloadForClaudeRootChange(): Promise { + logger.info('Reloading schedules for claudeRootPath change'); + await this.stop(); + await this.start(); + } + + // =========================================================================== + // Cron Job Management + // =========================================================================== + + private createCronJob(schedule: Schedule): void { + if (this.cronJobs.has(schedule.id)) { + this.removeCronJob(schedule.id); + } + + try { + const job = new Cron(schedule.cronExpression, { timezone: schedule.timezone }, () => { + void this.onCronTick(schedule.id); + }); + + this.cronJobs.set(schedule.id, job); + + // Set warm-up timer for the next run + this.scheduleWarmUp(schedule); + + logger.info( + `Cron job created for schedule ${schedule.id}: "${schedule.cronExpression}" ` + + `(timezone: ${schedule.timezone}, next: ${job.nextRun()?.toISOString() ?? 'never'})` + ); + } catch (err) { + logger.error(`Failed to create cron job for ${schedule.id}: ${err}`); + } + } + + private removeCronJob(scheduleId: string): void { + const job = this.cronJobs.get(scheduleId); + if (job) { + job.stop(); + this.cronJobs.delete(scheduleId); + } + this.clearWarmUpTimer(scheduleId); + } + + // =========================================================================== + // Warm-Up Timer + // =========================================================================== + + private scheduleWarmUp(schedule: Schedule): void { + this.clearWarmUpTimer(schedule.id); + + if (schedule.warmUpMinutes <= 0) return; + + const job = this.cronJobs.get(schedule.id); + if (!job) return; + + const msToNext = job.msToNext(); + if (msToNext == null) return; + + const warmUpMs = schedule.warmUpMinutes * 60_000; + const warmUpDelayMs = Math.max(0, msToNext - warmUpMs); + + const timer = setTimeout(() => { + this.warmUpTimers.delete(schedule.id); + void this.performWarmUp(schedule); + }, warmUpDelayMs); + + // Don't block Electron quit + timer.unref(); + this.warmUpTimers.set(schedule.id, timer); + + logger.debug( + `Warm-up scheduled for ${schedule.id}: in ${Math.round(warmUpDelayMs / 1000)}s ` + + `(${schedule.warmUpMinutes}min before next run)` + ); + } + + private clearWarmUpTimer(scheduleId: string): void { + const timer = this.warmUpTimers.get(scheduleId); + if (timer) { + clearTimeout(timer); + this.warmUpTimers.delete(scheduleId); + } + } + + private async performWarmUp(schedule: Schedule, retryCount = 0): Promise { + logger.info( + `[${schedule.id}] Starting warm-up (attempt ${retryCount + 1}/${WARMUP_MAX_RETRIES})` + ); + + try { + const result = await this.warmUpFn(schedule.launchConfig.cwd); + + if (result.ready) { + logger.info(`[${schedule.id}] Warm-up successful: ${result.message}`); + return; + } + + logger.warn(`[${schedule.id}] Warm-up not ready: ${result.message}`); + } catch (err) { + logger.warn(`[${schedule.id}] Warm-up error: ${err}`); + } + + // Retry + if (retryCount < WARMUP_MAX_RETRIES - 1) { + const retryTimer = setTimeout(() => { + this.warmUpTimers.delete(schedule.id); + void this.performWarmUp(schedule, retryCount + 1); + }, WARMUP_RETRY_DELAY_MS); + retryTimer.unref(); + // Store in warmUpTimers so clearWarmUpTimer/stop() can cancel it + this.warmUpTimers.set(schedule.id, retryTimer); + } else { + logger.warn(`[${schedule.id}] Warm-up failed after ${WARMUP_MAX_RETRIES} attempts`); + } + } + + // =========================================================================== + // Cron Tick → Execution + // =========================================================================== + + private async onCronTick(scheduleId: string): Promise { + const schedule = await this.repository.getSchedule(scheduleId); + if (!schedule || schedule.status !== 'active') { + logger.debug(`Cron tick for ${scheduleId} skipped (not active)`); + return; + } + + // Check schedule-level lock + if (this.activeRuns.has(scheduleId)) { + logger.warn(`[${scheduleId}] Cron tick skipped: previous run still active`); + return; + } + + // Check cwd lock + const cwd = schedule.launchConfig.cwd; + const cwdHolder = this.cwdLock.get(cwd); + if (cwdHolder && cwdHolder !== scheduleId) { + logger.warn( + `[${scheduleId}] Cron tick skipped: cwd "${cwd}" locked by schedule ${cwdHolder}` + ); + return; + } + + const now = new Date().toISOString(); + const run: ScheduleRun = { + id: randomUUID(), + scheduleId, + teamName: schedule.teamName, + status: 'running', + scheduledFor: now, + startedAt: now, + executionStartedAt: now, + retryCount: 0, + }; + + await this.repository.saveRun(run); + this.emitChange({ type: 'run-started', scheduleId, teamName: schedule.teamName }); + + void this.executeRunInBackground(schedule, run); + } + + // =========================================================================== + // Execution + // =========================================================================== + + private async executeRunInBackground(schedule: Schedule, run: ScheduleRun): Promise { + const { id: scheduleId, launchConfig } = schedule; + + // Acquire locks + this.activeRuns.set(scheduleId, run); + this.cwdLock.set(launchConfig.cwd, scheduleId); + + let retriedInternally = false; + + try { + const result = await this.executor.execute({ + runId: run.id, + config: launchConfig, + maxTurns: schedule.maxTurns, + maxBudgetUsd: schedule.maxBudgetUsd, + }); + + if (result.exitCode === 0) { + // Success — save logs, complete run + await this.repository.saveRunLogs(scheduleId, run.id, result.stdout, result.stderr); + await this.completeRun(run, 'completed', result.exitCode, result.summary); + await this.resetConsecutiveFailures(schedule); + logger.info( + `[${scheduleId}] Run ${run.id} completed successfully (${result.durationMs}ms)` + ); + } else { + // Failure — save logs before handling + await this.repository.saveRunLogs(scheduleId, run.id, result.stdout, result.stderr); + const errorMsg = result.stderr.slice(0, 500) || `Exit code: ${result.exitCode}`; + retriedInternally = await this.handleRunFailure(schedule, run, result.exitCode, errorMsg); + } + } catch (err) { + const errorMsg = err instanceof Error ? err.message : String(err); + retriedInternally = await this.handleRunFailure(schedule, run, null, errorMsg); + } finally { + // Skip cleanup if retry took over — retry's own finally will handle it + if (retriedInternally) return; + + // Release locks only if this run still owns them (prevents double-release race) + const currentActive = this.activeRuns.get(scheduleId); + if (currentActive?.id === run.id) { + this.releaseRunLocks(scheduleId); + } + + // Update schedule's lastRunAt and nextRunAt + await this.updateScheduleTimestamps(schedule); + + // Schedule next warm-up + const freshSchedule = await this.repository.getSchedule(scheduleId); + if (freshSchedule?.status === 'active') { + this.scheduleWarmUp(freshSchedule); + } + } + } + + /** + * Handle a failed run. Returns `true` if a retry was dispatched + * (meaning the caller's finally block should skip cleanup). + */ + private async handleRunFailure( + schedule: Schedule, + run: ScheduleRun, + exitCode: number | null, + error: string + ): Promise { + logger.warn(`[${schedule.id}] Run ${run.id} failed: ${error}`); + + // Retry logic + if (run.retryCount < EXECUTION_MAX_RETRIES) { + logger.info( + `[${schedule.id}] Scheduling retry ${run.retryCount + 1}/${EXECUTION_MAX_RETRIES}` + ); + + const retryRun: ScheduleRun = { + ...run, + status: 'pending', + retryCount: run.retryCount + 1, + error, + }; + await this.repository.saveRun(retryRun); + + // Release locks before retry delay + this.releaseRunLocks(schedule.id); + + await new Promise((resolve) => { + const timer = setTimeout(resolve, EXECUTION_RETRY_DELAY_MS); + timer.unref(); + this.retryDelayTimers.set(schedule.id, timer); + }); + this.retryDelayTimers.delete(schedule.id); + + // Bail if service was stopped during delay + if (this.stopped) { + await this.completeRun(retryRun, 'failed', exitCode, undefined, error); + return false; + } + + // Re-acquire locks and retry + if (this.activeRuns.has(schedule.id)) { + // Something else started — skip retry + await this.completeRun(retryRun, 'failed', exitCode, undefined, error); + return false; + } + + const freshSchedule = await this.repository.getSchedule(schedule.id); + if (!freshSchedule || freshSchedule.status !== 'active') { + await this.completeRun(retryRun, 'failed', exitCode, undefined, error); + return false; + } + + // Dispatch retry — caller's finally must not run cleanup + void this.executeRunInBackground(freshSchedule, retryRun); + return true; + } + + // Max retries exhausted + await this.completeRun(run, 'failed', exitCode, undefined, error); + await this.incrementConsecutiveFailures(schedule); + return false; + } + + private async completeRun( + run: ScheduleRun, + status: ScheduleRunStatus, + exitCode: number | null, + summary?: string, + error?: string + ): Promise { + const completedAt = new Date().toISOString(); + const startedAt = new Date(run.startedAt).getTime(); + const durationMs = Date.now() - startedAt; + + const updatedRun: ScheduleRun = { + ...run, + status, + completedAt, + durationMs, + exitCode, + summary: summary ?? run.summary, + error: error ?? run.error, + }; + + await this.repository.saveRun(updatedRun); + + const eventType = status === 'completed' ? 'run-completed' : 'run-failed'; + this.emitChange({ + type: eventType, + scheduleId: run.scheduleId, + teamName: run.teamName, + detail: error, + }); + } + + // =========================================================================== + // Consecutive Failure Tracking + // =========================================================================== + + private async resetConsecutiveFailures(schedule: Schedule): Promise { + if (schedule.consecutiveFailures === 0) return; + + const updated: Schedule = { + ...schedule, + consecutiveFailures: 0, + updatedAt: new Date().toISOString(), + }; + await this.repository.saveSchedule(updated); + } + + private async incrementConsecutiveFailures(schedule: Schedule): Promise { + const newCount = schedule.consecutiveFailures + 1; + const shouldAutoPause = newCount >= schedule.maxConsecutiveFailures; + + const updated: Schedule = { + ...schedule, + consecutiveFailures: newCount, + status: shouldAutoPause ? 'paused' : schedule.status, + updatedAt: new Date().toISOString(), + }; + + if (shouldAutoPause) { + logger.warn(`[${schedule.id}] Auto-pausing after ${newCount} consecutive failures`); + const job = this.cronJobs.get(schedule.id); + if (job) job.pause(); + this.clearWarmUpTimer(schedule.id); + } + + await this.repository.saveSchedule(updated); + + if (shouldAutoPause) { + this.emitChange({ + type: 'schedule-paused', + scheduleId: schedule.id, + teamName: schedule.teamName, + detail: `auto-paused after ${newCount} consecutive failures`, + }); + } + } + + // =========================================================================== + // Schedule Timestamp Updates + // =========================================================================== + + private async updateScheduleTimestamps(schedule: Schedule): Promise { + // Reload fresh from repo to avoid overwriting changes from incrementConsecutiveFailures + const fresh = await this.repository.getSchedule(schedule.id); + if (!fresh) return; + + const now = new Date().toISOString(); + const nextRunAt = this.computeNextRunAt(fresh); + + const updated: Schedule = { + ...fresh, + lastRunAt: now, + nextRunAt: nextRunAt ?? undefined, + updatedAt: now, + }; + + await this.repository.saveSchedule(updated); + this.emitChange({ + type: 'schedule-updated', + scheduleId: fresh.id, + teamName: fresh.teamName, + }); + } + + // =========================================================================== + // Recovery + // =========================================================================== + + private async recoverInterruptedRuns(): Promise { + const schedules = await this.repository.listSchedules(); + let recoveredCount = 0; + + for (const schedule of schedules) { + const runs = await this.repository.listRuns(schedule.id, { limit: 5 }); + + for (const run of runs) { + if ( + run.status === 'warming_up' || + run.status === 'warm' || + run.status === 'running' || + run.status === 'pending' + ) { + const updated: ScheduleRun = { + ...run, + status: 'failed_interrupted', + completedAt: new Date().toISOString(), + error: 'Interrupted by app restart', + }; + await this.repository.saveRun(updated); + recoveredCount++; + } + } + } + + if (recoveredCount > 0) { + logger.info(`Recovered ${recoveredCount} interrupted runs as failed_interrupted`); + } + } + + // =========================================================================== + // Helpers + // =========================================================================== + + private computeNextRunAt(schedule: Schedule): string | null { + try { + const job = new Cron(schedule.cronExpression, { + timezone: schedule.timezone, + paused: true, + }); + const next = job.nextRun(); + job.stop(); + return next?.toISOString() ?? null; + } catch { + return null; + } + } + + private releaseRunLocks(scheduleId: string): void { + this.activeRuns.delete(scheduleId); + + // Release cwd lock for this schedule + for (const [cwd, holder] of this.cwdLock) { + if (holder === scheduleId) { + this.cwdLock.delete(cwd); + break; + } + } + } + + private emitChange(event: ScheduleChangeEvent): void { + this.changeEmitter?.(event); + } +} diff --git a/src/main/services/schedule/index.ts b/src/main/services/schedule/index.ts new file mode 100644 index 00000000..c5ae7f0c --- /dev/null +++ b/src/main/services/schedule/index.ts @@ -0,0 +1,14 @@ +/** + * Schedule services barrel export. + */ + +export { JsonScheduleRepository } from './JsonScheduleRepository'; +export type { ScheduleRepository } from './ScheduleRepository'; +export { ScheduledTaskExecutor } from './ScheduledTaskExecutor'; +export type { + ExecutionRequest, + InternalScheduleRun, + ScheduledTaskResult, +} from './ScheduledTaskExecutor'; +export { SchedulerService } from './SchedulerService'; +export type { WarmUpFn } from './SchedulerService'; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 781e2484..d278b194 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -2,6 +2,7 @@ import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; import { killProcessTree, spawnCli } from '@main/utils/childProcess'; import { FileReadTimeoutError, readFileUtf8WithTimeout } from '@main/utils/fsRead'; +import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { encodePath, extractBaseDir, @@ -68,7 +69,6 @@ const STDERR_RING_LIMIT = 64 * 1024; const STDOUT_RING_LIMIT = 64 * 1024; const LOG_PROGRESS_THROTTLE_MS = 300; const UI_LOGS_TAIL_LIMIT = 128 * 1024; -const SHELL_ENV_TIMEOUT_MS = 12000; const PROBE_CACHE_TTL_MS = 36 * 60 * 60 * 1000; const PREFLIGHT_TIMEOUT_MS = 60000; const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000; @@ -270,117 +270,6 @@ async function tryReadRegularFileUtf8( } } -let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; -let shellEnvResolvePromise: Promise | null = null; - -function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv { - const parsed: NodeJS.ProcessEnv = {}; - const lines = content.split('\0'); - for (const line of lines) { - if (!line) { - continue; - } - const separatorIndex = line.indexOf('='); - if (separatorIndex <= 0) { - continue; - } - const key = line.slice(0, separatorIndex); - const value = line.slice(separatorIndex + 1); - parsed[key] = value; - } - return parsed; -} - -async function readShellEnv(shellPath: string, args: string[]): Promise { - const envDump = await new Promise((resolve, reject) => { - const child = spawn(shellPath, args, { - env: process.env, - stdio: ['ignore', 'pipe', 'ignore'], - }); - const chunks: Buffer[] = []; - let settled = false; - let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => { - timeoutHandle = null; - child.kill(); - // SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc) - setTimeout(() => { - try { - child.kill('SIGKILL'); - } catch { - /* already dead */ - } - }, 3000); - if (!settled) { - settled = true; - reject(new Error('shell env resolve timeout')); - } - }, SHELL_ENV_TIMEOUT_MS); - - child.stdout?.on('data', (chunk: Buffer) => { - chunks.push(chunk); - }); - child.once('error', (error) => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - timeoutHandle = null; - } - if (!settled) { - settled = true; - reject(error); - } - }); - child.once('close', () => { - if (timeoutHandle) { - clearTimeout(timeoutHandle); - } - if (!settled) { - settled = true; - resolve(Buffer.concat(chunks).toString('utf8')); - } - }); - }); - return parseNullSeparatedEnv(envDump); -} - -async function resolveInteractiveShellEnv(): Promise { - if (cachedInteractiveShellEnv) { - return cachedInteractiveShellEnv; - } - if (shellEnvResolvePromise) { - return shellEnvResolvePromise; - } - if (process.platform === 'win32') { - cachedInteractiveShellEnv = {}; - return cachedInteractiveShellEnv; - } - - shellEnvResolvePromise = (async () => { - const shellPath = process.env.SHELL || '/bin/zsh'; - try { - const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']); - cachedInteractiveShellEnv = loginEnv; - return loginEnv; - } catch (loginError) { - const loginMessage = loginError instanceof Error ? loginError.message : String(loginError); - logger.warn(`Failed to resolve login shell env: ${loginMessage}`); - try { - const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']); - cachedInteractiveShellEnv = interactiveEnv; - return interactiveEnv; - } catch (interactiveError) { - const interactiveMessage = - interactiveError instanceof Error ? interactiveError.message : String(interactiveError); - logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`); - return {}; - } - } finally { - shellEnvResolvePromise = null; - } - })(); - - return shellEnvResolvePromise; -} - async function ensureCwdExists(cwd: string): Promise { await fs.promises.mkdir(cwd, { recursive: true }); const stat = await fs.promises.stat(cwd); @@ -1328,13 +1217,21 @@ export class TeamProvisioningService { } } - async prepareForProvisioning(cwd?: string): Promise { + async prepareForProvisioning( + cwd?: string, + opts?: { forceFresh?: boolean } + ): Promise { // Always validate cwd even when cache is available const targetCwdForValidation = cwd?.trim() || process.cwd(); if (targetCwdForValidation && path.isAbsolute(targetCwdForValidation)) { await ensureCwdExists(targetCwdForValidation); } + // Allow callers (e.g. scheduler warm-up) to bypass the 36h probe cache + if (opts?.forceFresh) { + cachedProbeResult = null; + } + const cached = this.getFreshCachedProbeResult(); if (cached) { const { warning, authSource } = cached; diff --git a/src/main/utils/pathDecoder.ts b/src/main/utils/pathDecoder.ts index 4b37b158..0556ba07 100644 --- a/src/main/utils/pathDecoder.ts +++ b/src/main/utils/pathDecoder.ts @@ -361,3 +361,10 @@ export function getTasksBasePath(): string { export function getToolsBasePath(): string { return path.join(getClaudeBasePath(), 'tools'); } + +/** + * Get the schedules directory path (~/.claude/claude-devtools-schedules). + */ +export function getSchedulesBasePath(): string { + return path.join(getClaudeBasePath(), 'claude-devtools-schedules'); +} diff --git a/src/main/utils/shellEnv.ts b/src/main/utils/shellEnv.ts new file mode 100644 index 00000000..0ec590cb --- /dev/null +++ b/src/main/utils/shellEnv.ts @@ -0,0 +1,142 @@ +/** + * Interactive shell environment resolver. + * + * Resolves the user's interactive shell environment (PATH, etc.) by spawning + * a login/interactive shell and reading its exported variables. The result is + * cached for the lifetime of the process. + * + * Extracted from TeamProvisioningService for reuse by ScheduledTaskExecutor + * and any other service that needs the user's shell environment. + */ + +import { createLogger } from '@shared/utils/logger'; +import { spawn } from 'child_process'; + +const logger = createLogger('Utils:shellEnv'); + +const SHELL_ENV_TIMEOUT_MS = 12_000; + +let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null; +let shellEnvResolvePromise: Promise | null = null; + +function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv { + const parsed: NodeJS.ProcessEnv = {}; + const lines = content.split('\0'); + for (const line of lines) { + if (!line) { + continue; + } + const separatorIndex = line.indexOf('='); + if (separatorIndex <= 0) { + continue; + } + const key = line.slice(0, separatorIndex); + const value = line.slice(separatorIndex + 1); + parsed[key] = value; + } + return parsed; +} + +async function readShellEnv(shellPath: string, args: string[]): Promise { + const envDump = await new Promise((resolve, reject) => { + const child = spawn(shellPath, args, { + env: process.env, + stdio: ['ignore', 'pipe', 'ignore'], + }); + const chunks: Buffer[] = []; + let settled = false; + let timeoutHandle: NodeJS.Timeout | null = setTimeout(() => { + timeoutHandle = null; + child.kill(); + // SIGKILL fallback if SIGTERM is ignored (e.g., shell stuck on .zshrc) + setTimeout(() => { + try { + child.kill('SIGKILL'); + } catch { + /* already dead */ + } + }, 3000); + if (!settled) { + settled = true; + reject(new Error('shell env resolve timeout')); + } + }, SHELL_ENV_TIMEOUT_MS); + + child.stdout?.on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + child.once('error', (error) => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + timeoutHandle = null; + } + if (!settled) { + settled = true; + reject(error); + } + }); + child.once('close', () => { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + if (!settled) { + settled = true; + resolve(Buffer.concat(chunks).toString('utf8')); + } + }); + }); + return parseNullSeparatedEnv(envDump); +} + +/** + * Resolve the user's interactive shell environment. + * + * Tries login shell first (`-lic`), falls back to interactive (`-ic`). + * On Windows returns empty object. Result is cached after first success. + */ +export async function resolveInteractiveShellEnv(): Promise { + if (cachedInteractiveShellEnv) { + return cachedInteractiveShellEnv; + } + if (shellEnvResolvePromise) { + return shellEnvResolvePromise; + } + if (process.platform === 'win32') { + cachedInteractiveShellEnv = {}; + return cachedInteractiveShellEnv; + } + + shellEnvResolvePromise = (async () => { + const shellPath = process.env.SHELL || '/bin/zsh'; + try { + const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']); + cachedInteractiveShellEnv = loginEnv; + return loginEnv; + } catch (loginError) { + const loginMessage = loginError instanceof Error ? loginError.message : String(loginError); + logger.warn(`Failed to resolve login shell env: ${loginMessage}`); + try { + const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']); + cachedInteractiveShellEnv = interactiveEnv; + return interactiveEnv; + } catch (interactiveError) { + const interactiveMessage = + interactiveError instanceof Error ? interactiveError.message : String(interactiveError); + logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`); + return {}; + } + } finally { + shellEnvResolvePromise = null; + } + })(); + + return shellEnvResolvePromise; +} + +/** + * Clear the cached shell environment. Useful for testing. + */ +export function clearShellEnvCache(): void { + cachedInteractiveShellEnv = null; + shellEnvResolvePromise = null; +} diff --git a/src/main/utils/teamNotificationBuilder.ts b/src/main/utils/teamNotificationBuilder.ts index 6f0dc403..e731944d 100644 --- a/src/main/utils/teamNotificationBuilder.ts +++ b/src/main/utils/teamNotificationBuilder.ts @@ -19,7 +19,9 @@ export type TeamEventType = | 'lead_inbox' | 'user_inbox' | 'task_clarification' - | 'task_status_change'; + | 'task_status_change' + | 'schedule_completed' + | 'schedule_failed'; /** * Domain payload for team notifications. @@ -59,6 +61,8 @@ const TEAM_NOTIFICATION_CONFIG: Record = user_inbox: { triggerName: 'User Inbox', triggerColor: 'green' }, task_clarification: { triggerName: 'Clarification', triggerColor: 'orange' }, task_status_change: { triggerName: 'Status Change', triggerColor: 'purple' }, + schedule_completed: { triggerName: 'Schedule Done', triggerColor: 'green' }, + schedule_failed: { triggerName: 'Schedule Failed', triggerColor: 'red' }, }; // ============================================================================= diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 18b29dc0..35aa3620 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -512,3 +512,40 @@ export const EDITOR_CHANGE = 'editor:change'; /** List project files by path (for @file mentions, independent of editor state) */ export const PROJECT_LIST_FILES = 'project:listFiles'; + +// ============================================================================= +// Schedule Channels +// ============================================================================= + +/** List all schedules */ +export const SCHEDULE_LIST = 'schedule:list'; + +/** Get a schedule by ID */ +export const SCHEDULE_GET = 'schedule:get'; + +/** Create a new schedule */ +export const SCHEDULE_CREATE = 'schedule:create'; + +/** Update an existing schedule */ +export const SCHEDULE_UPDATE = 'schedule:update'; + +/** Delete a schedule */ +export const SCHEDULE_DELETE = 'schedule:delete'; + +/** Pause a schedule */ +export const SCHEDULE_PAUSE = 'schedule:pause'; + +/** Resume a paused schedule */ +export const SCHEDULE_RESUME = 'schedule:resume'; + +/** Trigger immediate run of a schedule */ +export const SCHEDULE_TRIGGER_NOW = 'schedule:triggerNow'; + +/** Get run history for a schedule */ +export const SCHEDULE_GET_RUNS = 'schedule:getRuns'; + +/** Get full stdout/stderr logs for a specific run */ +export const SCHEDULE_GET_RUN_LOGS = 'schedule:getRunLogs'; + +/** Schedule change events (main -> renderer) */ +export const SCHEDULE_CHANGE = 'schedule:change'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 2eb9d7fa..19002ec8 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -35,6 +35,17 @@ import { RENDERER_BOOT, RENDERER_HEARTBEAT, RENDERER_LOG, + SCHEDULE_CHANGE, + SCHEDULE_CREATE, + SCHEDULE_DELETE, + SCHEDULE_GET, + SCHEDULE_GET_RUN_LOGS, + SCHEDULE_GET_RUNS, + SCHEDULE_LIST, + SCHEDULE_PAUSE, + SCHEDULE_RESUME, + SCHEDULE_TRIGGER_NOW, + SCHEDULE_UPDATE, REVIEW_APPLY_DECISIONS, REVIEW_CHECK_CONFLICT, REVIEW_CLEAR_DECISIONS, @@ -174,6 +185,7 @@ import type { CommentAttachmentPayload, ConflictCheckResult, ContextInfo, + CreateScheduleInput, CreateTaskRequest, ElectronAPI, FileChangeWithContent, @@ -188,6 +200,9 @@ import type { NotificationTrigger, RejectResult, ReplaceMembersRequest, + Schedule, + ScheduleChangeEvent, + ScheduleRun, SendMessageRequest, SendMessageResult, SessionsByIdsOptions, @@ -221,6 +236,7 @@ import type { ToolApprovalEvent, TriggerTestResult, UpdateKanbanPatch, + UpdateSchedulePatch, WslClaudeRootCandidate, } from '@shared/types'; import type { @@ -1254,6 +1270,36 @@ const electronAPI: ElectronAPI = { }; }, }, + + schedules: { + list: () => invokeIpcWithResult(SCHEDULE_LIST), + get: (id: string) => invokeIpcWithResult(SCHEDULE_GET, id), + create: (input: CreateScheduleInput) => invokeIpcWithResult(SCHEDULE_CREATE, input), + update: (id: string, patch: UpdateSchedulePatch) => + invokeIpcWithResult(SCHEDULE_UPDATE, id, patch), + delete: (id: string) => invokeIpcWithResult(SCHEDULE_DELETE, id), + pause: (id: string) => invokeIpcWithResult(SCHEDULE_PAUSE, id), + resume: (id: string) => invokeIpcWithResult(SCHEDULE_RESUME, id), + triggerNow: (id: string) => invokeIpcWithResult(SCHEDULE_TRIGGER_NOW, id), + getRuns: (scheduleId: string, opts?: { limit?: number; offset?: number }) => + invokeIpcWithResult(SCHEDULE_GET_RUNS, scheduleId, opts), + getRunLogs: (scheduleId: string, runId: string) => + invokeIpcWithResult<{ stdout: string; stderr: string }>( + SCHEDULE_GET_RUN_LOGS, + scheduleId, + runId + ), + onScheduleChange: ( + callback: (event: unknown, data: ScheduleChangeEvent) => void + ): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: ScheduleChangeEvent): void => + callback(null, data); + ipcRenderer.on(SCHEDULE_CHANGE, listener); + return (): void => { + ipcRenderer.removeListener(SCHEDULE_CHANGE, listener); + }; + }, + }, }; // Use contextBridge to securely expose the API to the renderer process diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index ec49058e..c555415d 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -28,6 +28,8 @@ import type { PaginatedSessionsResult, Project, RepositoryGroup, + Schedule, + ScheduleRun, SearchSessionsResult, SendMessageRequest, SendMessageResult, @@ -1073,4 +1075,44 @@ export class HttpAPIClient implements ElectronAPI { return () => {}; }, }; + + schedules = { + list: async () => { + console.warn('Schedules not available in browser mode'); + return [] as Schedule[]; + }, + get: async () => { + console.warn('Schedules not available in browser mode'); + return null; + }, + create: async () => { + throw new Error('Schedules not available in browser mode'); + }, + update: async () => { + throw new Error('Schedules not available in browser mode'); + }, + delete: async () => { + throw new Error('Schedules not available in browser mode'); + }, + pause: async () => { + throw new Error('Schedules not available in browser mode'); + }, + resume: async () => { + throw new Error('Schedules not available in browser mode'); + }, + triggerNow: async () => { + throw new Error('Schedules not available in browser mode'); + }, + getRuns: async () => { + console.warn('Schedules not available in browser mode'); + return [] as ScheduleRun[]; + }, + getRunLogs: async () => { + console.warn('Schedules not available in browser mode'); + return { stdout: '', stderr: '' }; + }, + onScheduleChange: () => { + return () => {}; + }, + }; } diff --git a/src/renderer/components/team/TeamDetailView.tsx b/src/renderer/components/team/TeamDetailView.tsx index 90d56af7..90e4d039 100644 --- a/src/renderer/components/team/TeamDetailView.tsx +++ b/src/renderer/components/team/TeamDetailView.tsx @@ -37,6 +37,7 @@ import { CheckCheck, ChevronsDownUp, ChevronsUpDown, + Clock, Code, Columns3, FolderOpen, @@ -81,6 +82,7 @@ import { ChangeReviewDialog } from './review/ChangeReviewDialog'; import { ClaudeLogsSection } from './ClaudeLogsSection'; import { CollapsibleTeamSection } from './CollapsibleTeamSection'; import { ProcessesSection } from './ProcessesSection'; +import { ScheduleSection } from './schedule/ScheduleSection'; import { TeamProvisioningBanner } from './TeamProvisioningBanner'; import { TeamSessionsSection } from './TeamSessionsSection'; @@ -1419,6 +1421,15 @@ export const TeamDetailView = ({ teamName }: TeamDetailViewProps): React.JSX.Ele /> + } + defaultOpen={false} + > + + + {(data.processes?.length ?? 0) > 0 && ( void; + timezone: string; + onTimezoneChange: (value: string) => void; + warmUpMinutes: number; + onWarmUpMinutesChange: (value: number) => void; +} + +// ============================================================================= +// Component +// ============================================================================= + +export const CronScheduleInput = ({ + cronExpression, + onCronExpressionChange, + timezone, + onTimezoneChange, + warmUpMinutes, + onWarmUpMinutesChange, +}: CronScheduleInputProps): React.JSX.Element => { + // Parse and validate cron expression + const cronInfo = useMemo(() => { + if (!cronExpression.trim()) { + return { valid: false, description: null, nextRuns: [], error: 'Enter a cron expression' }; + } + + try { + const job = new Cron(cronExpression.trim(), { timezone, paused: true }); + const runs = job.nextRuns(3); + job.stop(); + + let description: string; + try { + description = cronstrue.toString(cronExpression.trim(), { + locale: 'en', + use24HourTimeFormat: true, + }); + } catch { + description = ''; + } + + // Warn if interval is less than 5 minutes + const isHighFrequency = + runs.length >= 2 && runs[1].getTime() - runs[0].getTime() < 5 * 60 * 1000; + + return { + valid: true, + description, + nextRuns: runs, + error: null, + highFrequencyWarning: isHighFrequency, + }; + } catch (err) { + return { + valid: false, + description: null, + nextRuns: [], + error: err instanceof Error ? err.message : 'Invalid cron expression', + }; + } + }, [cronExpression, timezone]); + + const formatNextRun = (date: Date): string => { + return date.toLocaleString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + hour12: false, + timeZone: timezone, + }); + }; + + return ( +
+ {/* Cron expression input */} +
+ + +
+ onCronExpressionChange(e.target.value)} + placeholder="0 9 * * 1-5" + /> +
+ + {/* Presets */} +
+ {CRON_PRESETS.map((preset) => ( + + ))} +
+ + {/* Description + validation */} + {cronInfo.valid && cronInfo.description ? ( +

{cronInfo.description}

+ ) : null} + + {cronInfo.error && cronExpression.trim() ? ( +
+ + {cronInfo.error} +
+ ) : null} + + {cronInfo.highFrequencyWarning ? ( +
+ + High frequency schedule (less than 5 min interval) +
+ ) : null} +
+ + {/* Next runs preview */} + {cronInfo.valid && cronInfo.nextRuns.length > 0 ? ( +
+

+ Next runs: +

+
+ {cronInfo.nextRuns.map((run, i) => ( +
+ + {formatNextRun(run)} +
+ ))} +
+
+ ) : null} + + {/* Timezone selector */} +
+ + +
+ + {/* Warm-up time */} +
+ + +

+ Pre-warms CLI environment before scheduled execution +

+
+
+ ); +}; diff --git a/src/renderer/components/team/schedule/ScheduleDialog.tsx b/src/renderer/components/team/schedule/ScheduleDialog.tsx new file mode 100644 index 00000000..80dcde6d --- /dev/null +++ b/src/renderer/components/team/schedule/ScheduleDialog.tsx @@ -0,0 +1,446 @@ +import React, { useEffect, useMemo, useState } from 'react'; + +import { api } from '@renderer/api'; +import { Button } from '@renderer/components/ui/button'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@renderer/components/ui/dialog'; +import { Input } from '@renderer/components/ui/input'; +import { Label } from '@renderer/components/ui/label'; +import { Textarea } from '@renderer/components/ui/textarea'; +import { useStore } from '@renderer/store'; +import { AlertTriangle, Loader2 } from 'lucide-react'; + +import { EffortLevelSelector } from '../dialogs/EffortLevelSelector'; +import { ProjectPathSelector } from '../dialogs/ProjectPathSelector'; +import { SkipPermissionsCheckbox } from '../dialogs/SkipPermissionsCheckbox'; +import { TeamModelSelector } from '../dialogs/TeamModelSelector'; +import { CronScheduleInput } from './CronScheduleInput'; + +import type { CwdMode } from '../dialogs/ProjectPathSelector'; +import type { + CreateScheduleInput, + EffortLevel, + Project, + Schedule, + UpdateSchedulePatch, +} from '@shared/types'; + +// ============================================================================= +// Props +// ============================================================================= + +interface ScheduleDialogProps { + open: boolean; + teamName: string; + /** When provided, dialog works in edit mode */ + schedule?: Schedule | null; + onClose: () => void; +} + +// ============================================================================= +// Helpers +// ============================================================================= + +function getLocalTimezone(): string { + try { + return Intl.DateTimeFormat().resolvedOptions().timeZone; + } catch { + return 'UTC'; + } +} + +// ============================================================================= +// Component +// ============================================================================= + +export const ScheduleDialog = ({ + open, + teamName, + schedule, + onClose, +}: ScheduleDialogProps): React.JSX.Element => { + const isEditing = !!schedule; + + // --- Form state --- + const [label, setLabel] = useState(''); + const [cronExpression, setCronExpression] = useState('0 9 * * 1-5'); + const [timezone, setTimezone] = useState(getLocalTimezone); + const [warmUpMinutes, setWarmUpMinutes] = useState(15); + const [maxTurns, setMaxTurns] = useState(50); + const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); + const [prompt, setPrompt] = useState(''); + const [cwdMode, setCwdMode] = useState('project'); + const [selectedProjectPath, setSelectedProjectPath] = useState(''); + const [customCwd, setCustomCwd] = useState(''); + const [selectedModel, setSelectedModelRaw] = useState(() => { + const stored = localStorage.getItem('schedule:lastSelectedModel') ?? ''; + return stored === '__default__' ? '' : stored; + }); + const [skipPermissions, setSkipPermissionsRaw] = useState(true); + const [selectedEffort, setSelectedEffortRaw] = useState( + () => localStorage.getItem('schedule:lastSelectedEffort') ?? '' + ); + + // --- Projects state --- + const [projects, setProjects] = useState([]); + const [projectsLoading, setProjectsLoading] = useState(false); + const [projectsError, setProjectsError] = useState(null); + + // --- Submission state --- + const [localError, setLocalError] = useState(null); + const [isSubmitting, setIsSubmitting] = useState(false); + + // --- Store actions --- + const createSchedule = useStore((s) => s.createSchedule); + const updateSchedule = useStore((s) => s.updateSchedule); + + // --- Persist preferences --- + const setSelectedModel = (value: string): void => { + setSelectedModelRaw(value); + localStorage.setItem('schedule:lastSelectedModel', value); + }; + + const setSkipPermissions = (value: boolean): void => { + setSkipPermissionsRaw(value); + }; + + const setSelectedEffort = (value: string): void => { + setSelectedEffortRaw(value); + localStorage.setItem('schedule:lastSelectedEffort', value); + }; + + // --- Populate form in edit mode --- + useEffect(() => { + if (!open) return; + + if (schedule) { + setLabel(schedule.label ?? ''); + setCronExpression(schedule.cronExpression); + setTimezone(schedule.timezone); + setWarmUpMinutes(schedule.warmUpMinutes); + setMaxTurns(schedule.maxTurns); + setMaxBudgetUsd(schedule.maxBudgetUsd != null ? String(schedule.maxBudgetUsd) : ''); + setPrompt(schedule.launchConfig.prompt); + setCustomCwd(schedule.launchConfig.cwd); + setCwdMode('custom'); + setSelectedModelRaw(schedule.launchConfig.model ?? ''); + setSkipPermissionsRaw(schedule.launchConfig.skipPermissions !== false); + setSelectedEffortRaw(schedule.launchConfig.effort ?? ''); + } else { + // Reset for create mode + setLabel(''); + setCronExpression('0 9 * * 1-5'); + setTimezone(getLocalTimezone()); + setWarmUpMinutes(15); + setMaxTurns(50); + setMaxBudgetUsd(''); + setPrompt(''); + setCwdMode('project'); + setSelectedProjectPath(''); + setCustomCwd(''); + } + + setLocalError(null); + setIsSubmitting(false); + }, [open, schedule]); + + // --- Load projects --- + const repositoryGroups = useStore((s) => s.repositoryGroups); + + useEffect(() => { + if (!open) return; + + setProjectsLoading(true); + setProjectsError(null); + + let cancelled = false; + void (async () => { + try { + const apiProjects = await api.getProjects(); + if (cancelled) return; + + const pathSet = new Set(apiProjects.map((p) => p.path)); + const extras: Project[] = []; + for (const repo of repositoryGroups) { + for (const wt of repo.worktrees) { + if (!pathSet.has(wt.path)) { + pathSet.add(wt.path); + extras.push({ + id: wt.id, + path: wt.path, + name: wt.name, + sessions: [], + totalSessions: 0, + createdAt: wt.createdAt ?? Date.now(), + }); + } + } + } + + setProjects([...apiProjects, ...extras]); + } catch (error) { + if (cancelled) return; + setProjectsError(error instanceof Error ? error.message : 'Failed to load projects'); + setProjects([]); + } finally { + if (!cancelled) setProjectsLoading(false); + } + })(); + + return () => { + cancelled = true; + }; + }, [open, repositoryGroups]); + + // --- Pre-select project --- + useEffect(() => { + if (!open || cwdMode !== 'project' || selectedProjectPath || projects.length === 0) return; + setSelectedProjectPath(projects[0].path); + }, [open, cwdMode, projects, selectedProjectPath]); + + const effectiveCwd = cwdMode === 'project' ? selectedProjectPath.trim() : customCwd.trim(); + + // --- Validation --- + const validationErrors = useMemo(() => { + const errors: string[] = []; + if (!effectiveCwd) errors.push('Working directory is required'); + if (!prompt.trim()) errors.push('Prompt is required'); + if (!cronExpression.trim()) errors.push('Cron expression is required'); + return errors; + }, [effectiveCwd, prompt, cronExpression]); + + // --- Submit --- + const handleSubmit = (): void => { + if (validationErrors.length > 0) { + setLocalError(validationErrors[0]); + return; + } + + setLocalError(null); + setIsSubmitting(true); + + const parsedBudget = maxBudgetUsd ? parseFloat(maxBudgetUsd) : undefined; + + void (async () => { + try { + if (isEditing && schedule) { + const patch: UpdateSchedulePatch = { + label: label.trim() || undefined, + cronExpression: cronExpression.trim(), + timezone, + warmUpMinutes, + maxTurns, + maxBudgetUsd: parsedBudget, + launchConfig: { + cwd: effectiveCwd, + prompt: prompt.trim(), + model: selectedModel || undefined, + effort: (selectedEffort as EffortLevel) || undefined, + skipPermissions, + }, + }; + await updateSchedule(schedule.id, patch); + } else { + const input: CreateScheduleInput = { + teamName, + label: label.trim() || undefined, + cronExpression: cronExpression.trim(), + timezone, + warmUpMinutes, + maxTurns, + maxBudgetUsd: parsedBudget, + launchConfig: { + cwd: effectiveCwd, + prompt: prompt.trim(), + model: selectedModel || undefined, + effort: (selectedEffort as EffortLevel) || undefined, + skipPermissions, + }, + }; + await createSchedule(input); + } + onClose(); + } catch (err) { + setLocalError(err instanceof Error ? err.message : 'Failed to save schedule'); + } finally { + setIsSubmitting(false); + } + })(); + }; + + return ( + { + if (!nextOpen) onClose(); + }} + > + + + + {isEditing ? 'Edit Schedule' : 'Create Schedule'} + + + {isEditing + ? `Editing schedule for team "${teamName}"` + : `Schedule automatic runs for team "${teamName}"`} + + + +
+ {/* Label */} +
+ + setLabel(e.target.value)} + placeholder="e.g., Daily code review, Nightly tests..." + /> +
+ + {/* Cron + Timezone + Warmup */} + + + {/* Project / Working directory */} + + + {/* Prompt (required for schedule) */} +
+ +