Merge branch 'worktree-schedule-feature' into dev

This commit is contained in:
iliya 2026-03-08 00:58:17 +02:00
commit ecded5a799
40 changed files with 5287 additions and 120 deletions

View file

@ -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",

View file

@ -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

View file

@ -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);
}
});

View file

@ -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);

204
src/main/ipc/schedule.ts Normal file
View file

@ -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<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');
}

View file

@ -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 */

View file

@ -15,3 +15,4 @@ export * from './error';
export * from './infrastructure';
export * from './parsing';
export * from './team';
export * from './schedule';

View file

@ -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<Schedule[]> {
return this.readSchedulesFile();
}
async getSchedule(id: string): Promise<Schedule | null> {
const schedules = await this.readSchedulesFile();
return schedules.find((s) => s.id === id) ?? null;
}
async getSchedulesByTeam(teamName: string): Promise<Schedule[]> {
const schedules = await this.readSchedulesFile();
return schedules.filter((s) => s.teamName === teamName);
}
async saveSchedule(schedule: Schedule): Promise<void> {
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<void> {
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<ScheduleRun[]> {
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<ScheduleRun | null> {
const runs = await this.readRunsFile(scheduleId);
return runs[0] ?? null;
}
async saveRun(run: ScheduleRun): Promise<void> {
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<number> {
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<Schedule[]> {
return this.readJsonFile<Schedule[]>(this.schedulesFilePath, []);
}
private async writeSchedulesFile(schedules: Schedule[]): Promise<void> {
await atomicWriteAsync(this.schedulesFilePath, JSON.stringify(schedules, null, 2));
}
private async readRunsFile(scheduleId: string): Promise<ScheduleRun[]> {
return this.readJsonFile<ScheduleRun[]>(this.runsFilePath(scheduleId), []);
}
private async writeRunsFile(scheduleId: string, runs: ScheduleRun[]): Promise<void> {
await atomicWriteAsync(this.runsFilePath(scheduleId), JSON.stringify(runs, null, 2));
}
private async readJsonFile<T>(filePath: string, defaultValue: T): Promise<T> {
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<void> {
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<void> {
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 };
}
}

View file

@ -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<Schedule[]>;
getSchedule(id: string): Promise<Schedule | null>;
getSchedulesByTeam(teamName: string): Promise<Schedule[]>;
saveSchedule(schedule: Schedule): Promise<void>;
deleteSchedule(id: string): Promise<void>;
listRuns(scheduleId: string, opts?: { limit?: number; offset?: number }): Promise<ScheduleRun[]>;
getLatestRun(scheduleId: string): Promise<ScheduleRun | null>;
saveRun(run: ScheduleRun): Promise<void>;
pruneOldRuns(scheduleId: string, keepCount: number): Promise<number>;
saveRunLogs(scheduleId: string, runId: string, stdout: string, stderr: string): Promise<void>;
getRunLogs(scheduleId: string, runId: string): Promise<{ stdout: string; stderr: string }>;
}

View file

@ -0,0 +1,224 @@
/**
* One-shot executor for scheduled tasks.
*
* Spawns `claude -p <prompt>` 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<string, unknown>;
if (parsed.type !== 'assistant') continue;
const content = (parsed.content ??
(parsed.message as Record<string, unknown> | 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<string, ChildProcess>();
async execute(request: ExecutionRequest): Promise<ScheduledTaskResult> {
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 });
});
});
}
}

View file

@ -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<string, Cron>();
// Warm-up timers keyed by schedule ID (includes warm-up retry timers)
private warmUpTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Execution retry delay timers keyed by schedule ID
private retryDelayTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Active runs keyed by schedule ID (only one run per schedule at a time)
private activeRuns = new Map<string, ScheduleRun>();
// CWD exclusion lock: cwd → schedule ID (prevents two schedule runs on same dir)
private cwdLock = new Map<string, string>();
// 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<void> {
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<void> {
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<Schedule[]> {
return this.repository.listSchedules();
}
async getSchedule(id: string): Promise<Schedule | null> {
return this.repository.getSchedule(id);
}
async getSchedulesByTeam(teamName: string): Promise<Schedule[]> {
return this.repository.getSchedulesByTeam(teamName);
}
async createSchedule(input: CreateScheduleInput): Promise<Schedule> {
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<Schedule> {
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<void> {
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<void> {
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<void> {
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<ScheduleRun[]> {
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<ScheduleRun> {
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<void> {
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<void> {
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<void> {
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<void> {
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<boolean> {
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<void>((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<void> {
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<void> {
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<void> {
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<void> {
// 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<void> {
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);
}
}

View file

@ -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';

View file

@ -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<NodeJS.ProcessEnv> | 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<NodeJS.ProcessEnv> {
const envDump = await new Promise<string>((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<NodeJS.ProcessEnv> {
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<void> {
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<TeamProvisioningPrepareResult> {
async prepareForProvisioning(
cwd?: string,
opts?: { forceFresh?: boolean }
): Promise<TeamProvisioningPrepareResult> {
// 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;

View file

@ -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');
}

142
src/main/utils/shellEnv.ts Normal file
View file

@ -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<NodeJS.ProcessEnv> | 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<NodeJS.ProcessEnv> {
const envDump = await new Promise<string>((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<NodeJS.ProcessEnv> {
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;
}

View file

@ -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<TeamEventType, TeamNotificationConfig> =
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' },
};
// =============================================================================

View file

@ -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';

View file

@ -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[]>(SCHEDULE_LIST),
get: (id: string) => invokeIpcWithResult<Schedule | null>(SCHEDULE_GET, id),
create: (input: CreateScheduleInput) => invokeIpcWithResult<Schedule>(SCHEDULE_CREATE, input),
update: (id: string, patch: UpdateSchedulePatch) =>
invokeIpcWithResult<Schedule>(SCHEDULE_UPDATE, id, patch),
delete: (id: string) => invokeIpcWithResult<void>(SCHEDULE_DELETE, id),
pause: (id: string) => invokeIpcWithResult<void>(SCHEDULE_PAUSE, id),
resume: (id: string) => invokeIpcWithResult<void>(SCHEDULE_RESUME, id),
triggerNow: (id: string) => invokeIpcWithResult<ScheduleRun>(SCHEDULE_TRIGGER_NOW, id),
getRuns: (scheduleId: string, opts?: { limit?: number; offset?: number }) =>
invokeIpcWithResult<ScheduleRun[]>(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

View file

@ -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 () => {};
},
};
}

View file

@ -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
/>
</CollapsibleTeamSection>
<CollapsibleTeamSection
sectionId="schedules"
title="Schedules"
icon={<Clock size={14} />}
defaultOpen={false}
>
<ScheduleSection teamName={teamName} />
</CollapsibleTeamSection>
{(data.processes?.length ?? 0) > 0 && (
<CollapsibleTeamSection
sectionId="processes"

View file

@ -0,0 +1,254 @@
import React, { useMemo } from 'react';
import { Input } from '@renderer/components/ui/input';
import { Label } from '@renderer/components/ui/label';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@renderer/components/ui/select';
import { Cron } from 'croner';
import cronstrue from 'cronstrue/i18n';
import { AlertCircle, Calendar, Clock, Globe } from 'lucide-react';
// =============================================================================
// Common Timezone Presets
// =============================================================================
const TIMEZONE_PRESETS = [
{ value: 'UTC', label: 'UTC' },
{ value: 'America/New_York', label: 'New York (ET)' },
{ value: 'America/Chicago', label: 'Chicago (CT)' },
{ value: 'America/Denver', label: 'Denver (MT)' },
{ value: 'America/Los_Angeles', label: 'Los Angeles (PT)' },
{ value: 'Europe/London', label: 'London (GMT/BST)' },
{ value: 'Europe/Berlin', label: 'Berlin (CET)' },
{ value: 'Europe/Moscow', label: 'Moscow (MSK)' },
{ value: 'Asia/Tokyo', label: 'Tokyo (JST)' },
{ value: 'Asia/Shanghai', label: 'Shanghai (CST)' },
{ value: 'Asia/Kolkata', label: 'Kolkata (IST)' },
{ value: 'Australia/Sydney', label: 'Sydney (AEST)' },
] as const;
const WARMUP_OPTIONS = [
{ value: 0, label: 'No warm-up' },
{ value: 5, label: '5 min' },
{ value: 10, label: '10 min' },
{ value: 15, label: '15 min' },
{ value: 30, label: '30 min' },
] as const;
// =============================================================================
// Cron Presets
// =============================================================================
const CRON_PRESETS = [
{ label: 'Every hour', cron: '0 * * * *' },
{ label: 'Every 6 hours', cron: '0 */6 * * *' },
{ label: 'Daily at 9am', cron: '0 9 * * *' },
{ label: 'Weekdays at 9am', cron: '0 9 * * 1-5' },
{ label: 'Monday at 9am', cron: '0 9 * * 1' },
{ label: 'Every 30 min', cron: '*/30 * * * *' },
] as const;
// =============================================================================
// Props
// =============================================================================
interface CronScheduleInputProps {
cronExpression: string;
onCronExpressionChange: (value: string) => 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 (
<div className="space-y-3">
{/* Cron expression input */}
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<Calendar className="size-3.5" />
Cron expression
</Label>
<div className="flex items-center gap-2">
<Input
className="h-8 font-mono text-xs"
value={cronExpression}
onChange={(e) => onCronExpressionChange(e.target.value)}
placeholder="0 9 * * 1-5"
/>
</div>
{/* Presets */}
<div className="flex flex-wrap gap-1">
{CRON_PRESETS.map((preset) => (
<button
key={preset.cron}
type="button"
className="rounded border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-0.5 text-[10px] text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
onClick={() => onCronExpressionChange(preset.cron)}
>
{preset.label}
</button>
))}
</div>
{/* Description + validation */}
{cronInfo.valid && cronInfo.description ? (
<p className="text-xs text-emerald-400">{cronInfo.description}</p>
) : null}
{cronInfo.error && cronExpression.trim() ? (
<div className="flex items-center gap-1.5 text-xs text-red-400">
<AlertCircle className="size-3 shrink-0" />
<span>{cronInfo.error}</span>
</div>
) : null}
{cronInfo.highFrequencyWarning ? (
<div
className="flex items-center gap-1.5 text-xs"
style={{ color: 'var(--warning-text)' }}
>
<AlertCircle className="size-3 shrink-0" />
<span>High frequency schedule (less than 5 min interval)</span>
</div>
) : null}
</div>
{/* Next runs preview */}
{cronInfo.valid && cronInfo.nextRuns.length > 0 ? (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5">
<p className="mb-1.5 text-[11px] font-medium text-[var(--color-text-muted)]">
Next runs:
</p>
<div className="space-y-0.5">
{cronInfo.nextRuns.map((run, i) => (
<div
key={i}
className="flex items-center gap-1.5 text-xs text-[var(--color-text-secondary)]"
>
<Clock className="size-3 shrink-0 text-[var(--color-text-muted)]" />
<span>{formatNextRun(run)}</span>
</div>
))}
</div>
</div>
) : null}
{/* Timezone selector */}
<div className="space-y-1.5">
<Label className="flex items-center gap-1.5">
<Globe className="size-3.5" />
Timezone
</Label>
<Select value={timezone} onValueChange={onTimezoneChange}>
<SelectTrigger className="h-8 text-xs">
<SelectValue placeholder="Select timezone" />
</SelectTrigger>
<SelectContent>
{TIMEZONE_PRESETS.map((tz) => (
<SelectItem key={tz.value} value={tz.value} className="text-xs">
{tz.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Warm-up time */}
<div className="space-y-1.5">
<Label className="label-optional">Warm-up time</Label>
<Select
value={String(warmUpMinutes)}
onValueChange={(val) => onWarmUpMinutesChange(Number(val))}
>
<SelectTrigger className="h-8 text-xs">
<SelectValue />
</SelectTrigger>
<SelectContent>
{WARMUP_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={String(opt.value)} className="text-xs">
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-[11px] text-[var(--color-text-muted)]">
Pre-warms CLI environment before scheduled execution
</p>
</div>
</div>
);
};

View file

@ -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<CwdMode>('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<Project[]>([]);
const [projectsLoading, setProjectsLoading] = useState(false);
const [projectsError, setProjectsError] = useState<string | null>(null);
// --- Submission state ---
const [localError, setLocalError] = useState<string | null>(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 (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
>
<DialogContent className="max-h-[90vh] max-w-2xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm">
{isEditing ? 'Edit Schedule' : 'Create Schedule'}
</DialogTitle>
<DialogDescription className="text-xs">
{isEditing
? `Editing schedule for team "${teamName}"`
: `Schedule automatic runs for team "${teamName}"`}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Label */}
<div className="space-y-1.5">
<Label htmlFor="schedule-label" className="label-optional">
Label (optional)
</Label>
<Input
id="schedule-label"
className="h-8 text-xs"
value={label}
onChange={(e) => setLabel(e.target.value)}
placeholder="e.g., Daily code review, Nightly tests..."
/>
</div>
{/* Cron + Timezone + Warmup */}
<CronScheduleInput
cronExpression={cronExpression}
onCronExpressionChange={setCronExpression}
timezone={timezone}
onTimezoneChange={setTimezone}
warmUpMinutes={warmUpMinutes}
onWarmUpMinutesChange={setWarmUpMinutes}
/>
{/* Project / Working directory */}
<ProjectPathSelector
cwdMode={cwdMode}
onCwdModeChange={setCwdMode}
selectedProjectPath={selectedProjectPath}
onSelectedProjectPathChange={setSelectedProjectPath}
customCwd={customCwd}
onCustomCwdChange={setCustomCwd}
projects={projects}
projectsLoading={projectsLoading}
projectsError={projectsError}
/>
{/* Prompt (required for schedule) */}
<div className="space-y-1.5">
<Label htmlFor="schedule-prompt">
Prompt <span className="text-red-400">*</span>
</Label>
<Textarea
id="schedule-prompt"
className="min-h-[100px] text-xs"
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
placeholder="Instructions for Claude to execute on schedule..."
rows={4}
/>
<p className="text-[11px] text-[var(--color-text-muted)]">
This prompt will be passed to <code className="font-mono">claude -p</code> for
one-shot execution
</p>
</div>
{/* Model + Effort + Skip Permissions */}
<div>
<TeamModelSelector
value={selectedModel}
onValueChange={setSelectedModel}
id="schedule-model"
/>
<EffortLevelSelector
value={selectedEffort}
onValueChange={setSelectedEffort}
id="schedule-effort"
/>
<SkipPermissionsCheckbox
id="schedule-skip-permissions"
checked={skipPermissions}
onCheckedChange={setSkipPermissions}
/>
</div>
{/* Execution limits — single row */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label
htmlFor="schedule-max-turns"
className="text-[11px] text-[var(--color-text-muted)]"
>
Max turns
</Label>
<Input
id="schedule-max-turns"
type="number"
min={1}
max={500}
className="h-8 text-xs"
value={maxTurns}
onChange={(e) => setMaxTurns(Math.max(1, parseInt(e.target.value) || 50))}
/>
</div>
<div className="space-y-1">
<Label
htmlFor="schedule-max-budget"
className="text-[11px] text-[var(--color-text-muted)]"
>
Max budget (USD)
</Label>
<Input
id="schedule-max-budget"
type="number"
min={0}
step={0.5}
className="h-8 text-xs"
value={maxBudgetUsd}
onChange={(e) => setMaxBudgetUsd(e.target.value)}
placeholder="No limit"
/>
</div>
</div>
</div>
{/* Error display */}
{localError ? (
<div className="flex items-start gap-2 rounded border border-red-500/40 bg-red-500/10 p-2 text-xs text-red-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{localError}</span>
</div>
) : null}
<DialogFooter className="pt-4">
<Button variant="outline" size="sm" onClick={onClose}>
Cancel
</Button>
<Button
size="sm"
className="bg-emerald-600 text-white hover:bg-emerald-700"
disabled={isSubmitting || validationErrors.length > 0}
onClick={handleSubmit}
>
{isSubmitting ? (
<>
<Loader2 className="mr-1.5 size-3.5 animate-spin" />
{isEditing ? 'Saving...' : 'Creating...'}
</>
) : isEditing ? (
'Save Changes'
) : (
'Create Schedule'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,15 @@
import React from 'react';
import { Calendar } from 'lucide-react';
export const ScheduleEmptyState = (): React.JSX.Element => (
<div className="flex flex-col items-center justify-center gap-2 py-8 text-center">
<Calendar className="size-8 text-[var(--color-text-muted)]" />
<div className="space-y-1">
<p className="text-xs font-medium text-[var(--color-text-secondary)]">No schedules yet</p>
<p className="text-[11px] text-[var(--color-text-muted)]">
Create a schedule to run Claude tasks automatically on a cron schedule.
</p>
</div>
</div>
);

View file

@ -0,0 +1,241 @@
import React, { useEffect, useState } from 'react';
import { api } from '@renderer/api';
import { Button } from '@renderer/components/ui/button';
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@renderer/components/ui/dialog';
import { useStore } from '@renderer/store';
import { AlertTriangle, Clock, Loader2, Terminal } from 'lucide-react';
import { CliLogsRichView } from '../CliLogsRichView';
import { RunStatusBadge } from './ScheduleStatusBadge';
import type { ScheduleRun } from '@shared/types';
// =============================================================================
// Props
// =============================================================================
interface ScheduleRunLogDialogProps {
open: boolean;
/** Initial run snapshot — used to identify the run; live data comes from store */
run: ScheduleRun | null;
scheduleId: string;
onClose: () => void;
}
// =============================================================================
// Helpers
// =============================================================================
function formatTime(isoString: string): string {
try {
return new Date(isoString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false,
});
} catch {
return isoString;
}
}
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
// =============================================================================
// Component
// =============================================================================
export const ScheduleRunLogDialog = ({
open,
run: initialRun,
scheduleId,
onClose,
}: ScheduleRunLogDialogProps): React.JSX.Element => {
// Read live run data from store — falls back to initial prop if not found
const liveRun = useStore((s) => {
if (!initialRun) return null;
const runs = s.scheduleRuns[scheduleId] ?? [];
return runs.find((r) => r.id === initialRun.id) ?? initialRun;
});
const run = liveRun ?? initialRun;
const [logs, setLogs] = useState<{ stdout: string; stderr: string } | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const runStatus = run?.status;
const runId = run?.id;
useEffect(() => {
if (!open || !run) {
setLogs(null);
setError(null);
return;
}
// Only fetch logs for completed/failed runs (not running/pending)
const hasLogs =
runStatus === 'completed' || runStatus === 'failed' || runStatus === 'failed_interrupted';
if (!hasLogs) return;
let cancelled = false;
setLoading(true);
setError(null);
void (async () => {
try {
const result = await api.schedules.getRunLogs(scheduleId, runId!);
if (!cancelled) {
setLogs(result);
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Failed to load logs');
}
} finally {
if (!cancelled) setLoading(false);
}
})();
return () => {
cancelled = true;
};
}, [open, runId, runStatus, scheduleId]);
if (!run) return <></>;
const isRunning =
run.status === 'running' ||
run.status === 'warming_up' ||
run.status === 'warm' ||
run.status === 'pending';
const hasStderr = logs?.stderr && logs.stderr.trim().length > 0;
return (
<Dialog
open={open}
onOpenChange={(nextOpen) => {
if (!nextOpen) onClose();
}}
>
<DialogContent className="max-h-[85vh] max-w-4xl overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-sm">
<Terminal className="size-4" />
Run Log
</DialogTitle>
</DialogHeader>
{/* Metadata */}
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-[11px]">
<RunStatusBadge status={run.status} />
<span className="flex items-center gap-1 text-[var(--color-text-muted)]">
<Clock className="size-3" />
{formatTime(run.startedAt)}
</span>
{run.durationMs != null ? (
<span className="text-[var(--color-text-muted)]">{formatDuration(run.durationMs)}</span>
) : null}
{run.exitCode != null ? (
<span
className={`font-mono ${run.exitCode === 0 ? 'text-emerald-400' : 'text-red-400'}`}
>
exit {run.exitCode}
</span>
) : null}
{run.retryCount > 0 ? (
<span className="text-[var(--color-text-muted)]">retry {run.retryCount}/2</span>
) : null}
</div>
{/* Content */}
<div className="space-y-3">
{/* Running state */}
{isRunning ? (
<div className="flex items-center gap-2 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-4 text-xs text-[var(--color-text-secondary)]">
<Loader2 className="size-4 animate-spin" />
Task is still running...
{run.summary ? (
<span className="ml-2 truncate text-[var(--color-text-muted)]">{run.summary}</span>
) : null}
</div>
) : null}
{/* Loading */}
{loading ? (
<div className="flex items-center justify-center py-6 text-xs text-[var(--color-text-muted)]">
<Loader2 className="mr-2 size-4 animate-spin" />
Loading logs...
</div>
) : null}
{/* Error loading logs */}
{error ? (
<div className="flex items-start gap-2 rounded border border-red-500/40 bg-red-500/10 p-3 text-xs text-red-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span>{error}</span>
</div>
) : null}
{/* Stdout — rich stream-json view (falls back to plain text for old logs) */}
{logs ? (
<>
<CliLogsRichView
cliLogsTail={logs.stdout}
order="oldest-first"
className="max-h-[400px]"
/>
{/* Stderr */}
{hasStderr ? (
<div>
<div className="mb-1 text-[11px] font-medium text-red-400">Errors</div>
<pre className="max-h-[200px] overflow-auto whitespace-pre-wrap rounded border border-red-500/30 bg-red-500/5 p-3 font-mono text-xs leading-relaxed text-red-300">
{logs.stderr}
</pre>
</div>
) : null}
</>
) : null}
{/* Run error message (from ScheduleRun.error) */}
{!isRunning && run.error && !logs ? (
<div className="flex items-start gap-2 rounded border border-red-500/40 bg-red-500/10 p-3 text-xs text-red-300">
<AlertTriangle className="mt-0.5 size-3.5 shrink-0" />
<span className="whitespace-pre-wrap">{run.error}</span>
</div>
) : null}
</div>
<DialogFooter className="pt-2">
<Button variant="outline" size="sm" onClick={onClose}>
Close
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};

View file

@ -0,0 +1,107 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { Clock } from 'lucide-react';
import { RunStatusBadge } from './ScheduleStatusBadge';
import type { ScheduleRun } from '@shared/types';
// =============================================================================
// Props
// =============================================================================
interface ScheduleRunRowProps {
run: ScheduleRun;
onClick?: (run: ScheduleRun) => void;
}
// =============================================================================
// Helpers
// =============================================================================
function formatDuration(ms: number): string {
if (ms < 1000) return `${ms}ms`;
const seconds = Math.floor(ms / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
function formatTime(isoString: string): string {
try {
return new Date(isoString).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
} catch {
return isoString;
}
}
// =============================================================================
// Component
// =============================================================================
export const ScheduleRunRow = ({ run, onClick }: ScheduleRunRowProps): React.JSX.Element => (
<div
className={`flex items-center gap-2 border-t border-[var(--color-border)] px-2 py-1.5 text-[11px]${
onClick ? 'cursor-pointer transition-colors hover:bg-[var(--color-surface-raised)]' : ''
}`}
onClick={onClick ? () => onClick(run) : undefined}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
onKeyDown={
onClick
? (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onClick(run);
}
}
: undefined
}
>
<RunStatusBadge status={run.status} />
<span className="flex items-center gap-1 text-[var(--color-text-muted)]">
<Clock className="size-3" />
{formatTime(run.startedAt)}
</span>
{run.durationMs != null ? (
<span className="text-[var(--color-text-muted)]">{formatDuration(run.durationMs)}</span>
) : null}
{run.summary ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate text-[var(--color-text-secondary)]">
{run.summary.slice(0, 80)}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p className="whitespace-pre-wrap text-xs">{run.summary.slice(0, 500)}</p>
</TooltipContent>
</Tooltip>
) : null}
{run.error ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="min-w-0 flex-1 truncate text-red-400">{run.error.slice(0, 60)}</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-sm">
<p className="whitespace-pre-wrap text-xs text-red-300">{run.error.slice(0, 500)}</p>
</TooltipContent>
</Tooltip>
) : null}
</div>
);

View file

@ -0,0 +1,352 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Button } from '@renderer/components/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@renderer/components/ui/popover';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
import { useStore } from '@renderer/store';
import {
ChevronDown,
ChevronRight,
MoreHorizontal,
Pause,
Pencil,
Play,
Plus,
Trash2,
Zap,
} from 'lucide-react';
import cronstrue from 'cronstrue/i18n';
import { ScheduleDialog } from './ScheduleDialog';
import { ScheduleEmptyState } from './ScheduleEmptyState';
import { ScheduleRunLogDialog } from './ScheduleRunLogDialog';
import { ScheduleRunRow } from './ScheduleRunRow';
import { ScheduleStatusBadge } from './ScheduleStatusBadge';
import type { Schedule, ScheduleRun } from '@shared/types';
// =============================================================================
// Props
// =============================================================================
interface ScheduleSectionProps {
teamName: string;
}
// =============================================================================
// Helpers
// =============================================================================
function formatNextRun(isoString?: string): string {
if (!isoString) return 'N/A';
try {
const date = new Date(isoString);
const now = Date.now();
const diffMs = date.getTime() - now;
if (diffMs < 0) return 'overdue';
const hours = Math.floor(diffMs / 3600_000);
const minutes = Math.floor((diffMs % 3600_000) / 60_000);
if (hours > 24) {
return date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
});
}
if (hours > 0) return `in ${hours}h ${minutes}m`;
if (minutes > 0) return `in ${minutes}m`;
return 'soon';
} catch {
return isoString;
}
}
function getCronDescription(expression: string): string {
try {
return cronstrue.toString(expression, { locale: 'en', use24HourTimeFormat: true });
} catch {
return expression;
}
}
// =============================================================================
// ScheduleRow
// =============================================================================
interface ScheduleRowProps {
schedule: Schedule;
onEdit: (schedule: Schedule) => void;
onDelete: (id: string) => void;
onPause: (id: string) => void;
onResume: (id: string) => void;
onTriggerNow: (id: string) => void;
}
const ScheduleRow = ({
schedule,
onEdit,
onDelete,
onPause,
onResume,
onTriggerNow,
}: ScheduleRowProps): React.JSX.Element => {
const [expanded, setExpanded] = useState(false);
const [selectedRun, setSelectedRun] = useState<ScheduleRun | null>(null);
const runs = useStore((s) => s.scheduleRuns[schedule.id] ?? []);
const runsLoading = useStore((s) => s.scheduleRunsLoading[schedule.id] ?? false);
const fetchRunHistory = useStore((s) => s.fetchRunHistory);
const handleExpand = useCallback(() => {
const next = !expanded;
setExpanded(next);
if (next && runs.length === 0 && !runsLoading) {
void fetchRunHistory(schedule.id);
}
}, [expanded, runs.length, runsLoading, fetchRunHistory, schedule.id]);
return (
<div className="rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
{/* Header row */}
<div className="flex items-center gap-2 px-3 py-2">
{/* Expand toggle */}
<button
type="button"
className="shrink-0 text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
onClick={handleExpand}
>
{expanded ? <ChevronDown className="size-3.5" /> : <ChevronRight className="size-3.5" />}
</button>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-xs font-medium text-[var(--color-text)]">
{schedule.label || getCronDescription(schedule.cronExpression)}
</span>
<ScheduleStatusBadge status={schedule.status} />
</div>
<div className="mt-0.5 flex items-center gap-3 text-[11px] text-[var(--color-text-muted)]">
{schedule.label ? <span>{getCronDescription(schedule.cronExpression)}</span> : null}
<Tooltip>
<TooltipTrigger asChild>
<span className="cursor-default">Next: {formatNextRun(schedule.nextRunAt)}</span>
</TooltipTrigger>
{schedule.nextRunAt ? (
<TooltipContent side="top" className="text-xs">
{new Date(schedule.nextRunAt).toLocaleString()}
</TooltipContent>
) : null}
</Tooltip>
<span>{schedule.timezone}</span>
</div>
</div>
{/* Actions */}
<div className="flex shrink-0 items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="sm"
className="size-7 p-0"
onClick={() => onTriggerNow(schedule.id)}
disabled={schedule.status !== 'active'}
>
<Zap className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="top">Run now</TooltipContent>
</Tooltip>
<Popover>
<PopoverTrigger asChild>
<Button variant="ghost" size="sm" className="size-7 p-0">
<MoreHorizontal className="size-3.5" />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-40 p-1">
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => onEdit(schedule)}
>
<Pencil className="mr-2 size-3.5" />
Edit
</button>
{schedule.status === 'active' ? (
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => onPause(schedule.id)}
>
<Pause className="mr-2 size-3.5" />
Pause
</button>
) : (
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-[var(--color-text)] hover:bg-[var(--color-surface-raised)]"
onClick={() => onResume(schedule.id)}
>
<Play className="mr-2 size-3.5" />
Resume
</button>
)}
<button
type="button"
className="flex w-full items-center rounded-sm px-2 py-1.5 text-xs text-red-400 hover:bg-[var(--color-surface-raised)]"
onClick={() => onDelete(schedule.id)}
>
<Trash2 className="mr-2 size-3.5" />
Delete
</button>
</PopoverContent>
</Popover>
</div>
</div>
{/* Expanded: Run history */}
{expanded ? (
<div className="border-t border-[var(--color-border)]">
{runsLoading ? (
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--color-text-muted)]">
Loading run history...
</div>
) : runs.length === 0 ? (
<div className="flex items-center justify-center py-3 text-[11px] text-[var(--color-text-muted)]">
No runs yet
</div>
) : (
<div className="max-h-[200px] overflow-y-auto">
{runs.slice(0, 10).map((run) => (
<ScheduleRunRow key={run.id} run={run} onClick={setSelectedRun} />
))}
</div>
)}
</div>
) : null}
{/* Run Log Dialog */}
<ScheduleRunLogDialog
open={selectedRun != null}
run={selectedRun}
scheduleId={schedule.id}
onClose={() => setSelectedRun(null)}
/>
</div>
);
};
// =============================================================================
// ScheduleSection
// =============================================================================
export const ScheduleSection = ({ teamName }: ScheduleSectionProps): React.JSX.Element => {
const schedules = useStore((s) => s.schedules.filter((sch) => sch.teamName === teamName));
const pauseSchedule = useStore((s) => s.pauseSchedule);
const resumeSchedule = useStore((s) => s.resumeSchedule);
const deleteSchedule = useStore((s) => s.deleteSchedule);
const triggerNow = useStore((s) => s.triggerNow);
const [dialogOpen, setDialogOpen] = useState(false);
const [editingSchedule, setEditingSchedule] = useState<Schedule | null>(null);
// Fetch schedules on mount
const fetchSchedules = useStore((s) => s.fetchSchedules);
useEffect(() => {
void fetchSchedules();
}, [fetchSchedules]);
const handleEdit = useCallback((schedule: Schedule) => {
setEditingSchedule(schedule);
setDialogOpen(true);
}, []);
const handleCreate = useCallback(() => {
setEditingSchedule(null);
setDialogOpen(true);
}, []);
const handleClose = useCallback(() => {
setDialogOpen(false);
setEditingSchedule(null);
}, []);
const handleDelete = useCallback(
async (id: string) => {
try {
await deleteSchedule(id);
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to delete schedule:', err);
}
},
[deleteSchedule]
);
const handleTriggerNow = useCallback(
async (id: string) => {
try {
await triggerNow(id);
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to trigger schedule:', err);
}
},
[triggerNow]
);
return (
<div className="space-y-2 p-3">
{/* Header with create button */}
<div className="flex items-center justify-between">
<span className="text-[11px] font-medium text-[var(--color-text-muted)]">
{schedules.length > 0
? `${schedules.length} schedule${schedules.length > 1 ? 's' : ''}`
: ''}
</span>
<Button
variant="outline"
size="sm"
className="h-6 gap-1 px-2 text-[11px]"
onClick={handleCreate}
>
<Plus className="size-3" />
Add Schedule
</Button>
</div>
{/* Schedule list or empty state */}
{schedules.length === 0 ? (
<ScheduleEmptyState />
) : (
<div className="space-y-2">
{schedules.map((schedule) => (
<ScheduleRow
key={schedule.id}
schedule={schedule}
onEdit={handleEdit}
onDelete={(id) => void handleDelete(id)}
onPause={(id) => void pauseSchedule(id)}
onResume={(id) => void resumeSchedule(id)}
onTriggerNow={(id) => void handleTriggerNow(id)}
/>
))}
</div>
)}
{/* Create/Edit Dialog */}
<ScheduleDialog
open={dialogOpen}
teamName={teamName}
schedule={editingSchedule}
onClose={handleClose}
/>
</div>
);
};

View file

@ -0,0 +1,55 @@
import React from 'react';
import type { ScheduleStatus, ScheduleRunStatus } from '@shared/types';
// =============================================================================
// Schedule Status Badge
// =============================================================================
const SCHEDULE_STATUS_CONFIG: Record<ScheduleStatus, { label: string; className: string }> = {
active: {
label: 'Active',
className: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/20',
},
paused: { label: 'Paused', className: 'bg-amber-500/15 text-amber-400 border-amber-500/20' },
disabled: { label: 'Disabled', className: 'bg-zinc-500/15 text-zinc-400 border-zinc-500/20' },
};
interface ScheduleStatusBadgeProps {
status: ScheduleStatus;
}
export const ScheduleStatusBadge = ({ status }: ScheduleStatusBadgeProps): React.JSX.Element => {
const config = SCHEDULE_STATUS_CONFIG[status];
return (
<span
className={`inline-flex items-center rounded-full border px-1.5 py-0.5 text-[10px] font-medium ${config.className}`}
>
{config.label}
</span>
);
};
// =============================================================================
// Run Status Badge
// =============================================================================
const RUN_STATUS_CONFIG: Record<ScheduleRunStatus, { label: string; className: string }> = {
pending: { label: 'Pending', className: 'text-zinc-400' },
warming_up: { label: 'Warming up', className: 'text-blue-400' },
warm: { label: 'Warm', className: 'text-cyan-400' },
running: { label: 'Running', className: 'text-emerald-400' },
completed: { label: 'Completed', className: 'text-emerald-400' },
failed: { label: 'Failed', className: 'text-red-400' },
failed_interrupted: { label: 'Interrupted', className: 'text-amber-400' },
cancelled: { label: 'Cancelled', className: 'text-zinc-400' },
};
interface RunStatusBadgeProps {
status: ScheduleRunStatus;
}
export const RunStatusBadge = ({ status }: RunStatusBadgeProps): React.JSX.Element => {
const config = RUN_STATUS_CONFIG[status];
return <span className={`text-[10px] font-medium ${config.className}`}>{config.label}</span>;
};

View file

@ -14,6 +14,7 @@ import { createContextSlice } from './slices/contextSlice';
import { createConversationSlice } from './slices/conversationSlice';
import { createEditorSlice } from './slices/editorSlice';
import { createNotificationSlice } from './slices/notificationSlice';
import { createScheduleSlice } from './slices/scheduleSlice';
import { createPaneSlice } from './slices/paneSlice';
import { createProjectSlice } from './slices/projectSlice';
import { createRepositorySlice } from './slices/repositorySlice';
@ -31,6 +32,7 @@ import type { AppState } from './types';
import type {
CliInstallerProgress,
LeadContextUsage,
ScheduleChangeEvent,
TeamChangeEvent,
ToolApprovalEvent,
ToolApprovalRequest,
@ -61,6 +63,7 @@ export const useStore = create<AppState>()((...args) => ({
...createChangeReviewSlice(...args),
...createCliInstallerSlice(...args),
...createEditorSlice(...args),
...createScheduleSlice(...args),
}));
// =============================================================================
@ -98,6 +101,7 @@ export function initializeNotificationListeners(): () => void {
useStore.getState().fetchAllTasks(),
useStore.getState().fetchTeams(),
useStore.getState().fetchNotifications(),
useStore.getState().fetchSchedules(),
]);
})();
@ -482,6 +486,19 @@ export function initializeNotificationListeners(): () => void {
}
}
// Listen for schedule change events from main process
if (api.schedules?.onScheduleChange) {
const cleanup = api.schedules.onScheduleChange((_event: unknown, data: unknown) => {
const event = data as ScheduleChangeEvent;
if (event?.scheduleId) {
void useStore.getState().applyScheduleChange(event.scheduleId);
}
});
if (typeof cleanup === 'function') {
cleanupFns.push(cleanup);
}
}
// fetchCliStatus() is deferred 5s after app start (heavy on Windows).
// Listen for CLI installer progress events from main process

View file

@ -0,0 +1,177 @@
import { api } from '@renderer/api';
import { createLogger } from '@shared/utils/logger';
import type { AppState } from '../types';
import type { StateCreator } from 'zustand';
import type {
CreateScheduleInput,
Schedule,
ScheduleRun,
UpdateSchedulePatch,
} from '@shared/types';
const logger = createLogger('scheduleSlice');
// =============================================================================
// Slice Interface
// =============================================================================
export interface ScheduleSlice {
// --- State ---
schedules: Schedule[];
schedulesLoading: boolean;
schedulesError: string | null;
scheduleRuns: Record<string, ScheduleRun[]>;
scheduleRunsLoading: Record<string, boolean>;
// --- Actions ---
fetchSchedules(): Promise<void>;
createSchedule(input: CreateScheduleInput): Promise<Schedule>;
updateSchedule(id: string, patch: UpdateSchedulePatch): Promise<Schedule>;
deleteSchedule(id: string): Promise<void>;
pauseSchedule(id: string): Promise<void>;
resumeSchedule(id: string): Promise<void>;
triggerNow(id: string): Promise<ScheduleRun>;
fetchRunHistory(scheduleId: string): Promise<void>;
/** Optimistic in-memory update from SCHEDULE_CHANGE events */
applyScheduleChange(scheduleId: string): Promise<void>;
}
// =============================================================================
// Slice Creator
// =============================================================================
export const createScheduleSlice: StateCreator<AppState, [], [], ScheduleSlice> = (set, get) => ({
schedules: [],
schedulesLoading: false,
schedulesError: null,
scheduleRuns: {},
scheduleRunsLoading: {},
async fetchSchedules(): Promise<void> {
// Guard: prevent concurrent fetches
if (get().schedulesLoading) return;
set({ schedulesLoading: true, schedulesError: null });
try {
const schedules = await api.schedules.list();
set({ schedules, schedulesLoading: false });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed to fetch schedules';
logger.error('fetchSchedules failed:', message);
set({ schedulesError: message, schedulesLoading: false });
}
},
async createSchedule(input: CreateScheduleInput): Promise<Schedule> {
const schedule = await api.schedules.create(input);
set((state) => ({ schedules: [...state.schedules, schedule] }));
return schedule;
},
async updateSchedule(id: string, patch: UpdateSchedulePatch): Promise<Schedule> {
const updated = await api.schedules.update(id, patch);
set((state) => ({
schedules: state.schedules.map((s) => (s.id === id ? updated : s)),
}));
return updated;
},
async deleteSchedule(id: string): Promise<void> {
await api.schedules.delete(id);
set((state) => ({
schedules: state.schedules.filter((s) => s.id !== id),
scheduleRuns: Object.fromEntries(
Object.entries(state.scheduleRuns).filter(([key]) => key !== id)
),
}));
},
async pauseSchedule(id: string): Promise<void> {
await api.schedules.pause(id);
// Optimistic update — set status locally, then refetch for accuracy
set((state) => ({
schedules: state.schedules.map((s) =>
s.id === id ? { ...s, status: 'paused' as const, updatedAt: new Date().toISOString() } : s
),
}));
// Refetch to get server-side state
void get().applyScheduleChange(id);
},
async resumeSchedule(id: string): Promise<void> {
await api.schedules.resume(id);
// Optimistic update
set((state) => ({
schedules: state.schedules.map((s) =>
s.id === id ? { ...s, status: 'active' as const, updatedAt: new Date().toISOString() } : s
),
}));
// Refetch to get server-side state (includes nextRunAt recalculation)
void get().applyScheduleChange(id);
},
async triggerNow(id: string): Promise<ScheduleRun> {
const run = await api.schedules.triggerNow(id);
set((state) => ({
scheduleRuns: {
...state.scheduleRuns,
[id]: [run, ...(state.scheduleRuns[id] ?? [])],
},
}));
return run;
},
async fetchRunHistory(scheduleId: string): Promise<void> {
if (get().scheduleRunsLoading[scheduleId]) return;
set((state) => ({
scheduleRunsLoading: { ...state.scheduleRunsLoading, [scheduleId]: true },
}));
try {
const runs = await api.schedules.getRuns(scheduleId);
set((state) => ({
scheduleRuns: { ...state.scheduleRuns, [scheduleId]: runs },
scheduleRunsLoading: { ...state.scheduleRunsLoading, [scheduleId]: false },
}));
} catch (err) {
logger.error(`fetchRunHistory(${scheduleId}) failed:`, err);
set((state) => ({
scheduleRunsLoading: { ...state.scheduleRunsLoading, [scheduleId]: false },
}));
}
},
async applyScheduleChange(scheduleId: string): Promise<void> {
try {
// Refresh the specific schedule
const schedule = await api.schedules.get(scheduleId);
set((state) => {
if (!schedule) {
// Schedule was deleted
return {
schedules: state.schedules.filter((s) => s.id !== scheduleId),
};
}
const exists = state.schedules.some((s) => s.id === scheduleId);
return {
schedules: exists
? state.schedules.map((s) => (s.id === scheduleId ? schedule : s))
: [...state.schedules, schedule],
};
});
// Also refresh runs if we have them loaded
if (get().scheduleRuns[scheduleId]) {
const runs = await api.schedules.getRuns(scheduleId);
set((state) => ({
scheduleRuns: { ...state.scheduleRuns, [scheduleId]: runs },
}));
}
} catch (err) {
logger.error('applyScheduleChange failed:', err);
}
},
});

View file

@ -14,6 +14,7 @@ import type { NotificationSlice } from './slices/notificationSlice';
import type { PaneSlice } from './slices/paneSlice';
import type { ProjectSlice } from './slices/projectSlice';
import type { RepositorySlice } from './slices/repositorySlice';
import type { ScheduleSlice } from './slices/scheduleSlice';
import type { SessionDetailSlice } from './slices/sessionDetailSlice';
import type { SessionSlice } from './slices/sessionSlice';
import type { SubagentSlice } from './slices/subagentSlice';
@ -98,4 +99,5 @@ export type AppState = ProjectSlice &
UpdateSlice &
ChangeReviewSlice &
CliInstallerSlice &
EditorSlice;
EditorSlice &
ScheduleSlice;

View file

@ -16,6 +16,13 @@ import type {
NotificationTrigger,
TriggerTestResult,
} from './notifications';
import type {
CreateScheduleInput,
Schedule,
ScheduleChangeEvent,
ScheduleRun,
UpdateSchedulePatch,
} from './schedule';
import type {
AgentChangeSet,
ApplyReviewRequest,
@ -524,6 +531,27 @@ export interface TeamsAPI {
onToolApprovalEvent: (callback: (event: unknown, data: ToolApprovalEvent) => void) => () => void;
}
// =============================================================================
// Schedule API
// =============================================================================
export interface ScheduleAPI {
list: () => Promise<Schedule[]>;
get: (id: string) => Promise<Schedule | null>;
create: (input: CreateScheduleInput) => Promise<Schedule>;
update: (id: string, patch: UpdateSchedulePatch) => Promise<Schedule>;
delete: (id: string) => Promise<void>;
pause: (id: string) => Promise<void>;
resume: (id: string) => Promise<void>;
triggerNow: (id: string) => Promise<ScheduleRun>;
getRuns: (
scheduleId: string,
opts?: { limit?: number; offset?: number }
) => Promise<ScheduleRun[]>;
getRunLogs: (scheduleId: string, runId: string) => Promise<{ stdout: string; stderr: string }>;
onScheduleChange: (callback: (event: unknown, data: ScheduleChangeEvent) => void) => () => void;
}
// =============================================================================
// Review API
// =============================================================================
@ -745,6 +773,9 @@ export interface ElectronAPI {
// Project Editor API (file browser + CodeMirror)
editor: EditorAPI;
// Schedule API (cron-based task execution)
schedules: ScheduleAPI;
}
// =============================================================================

View file

@ -27,6 +27,9 @@ export type * from './ipc';
// Re-export Team Management types
export type * from './team';
// Re-export Schedule types
export type * from './schedule';
// Re-export Review types (Phase 1)
export type * from './review';

View file

@ -58,7 +58,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 */

View file

@ -0,0 +1,118 @@
/**
* Schedule types shared between main and renderer processes.
*
* Supports automatic cron-based execution of Claude tasks (one-shot `claude -p` mode).
* Repository Pattern abstraction allows swapping storage backend (JSON sql.js/Drizzle).
*/
import type { EffortLevel } from './team';
// =============================================================================
// Schedule Status Types
// =============================================================================
export type ScheduleStatus = 'active' | 'paused' | 'disabled';
export type ScheduleRunStatus =
| 'pending'
| 'warming_up'
| 'warm'
| 'running'
| 'completed'
| 'failed'
| 'failed_interrupted'
| 'cancelled';
// =============================================================================
// Core Entities
// =============================================================================
export interface Schedule {
id: string;
teamName: string;
label?: string;
cronExpression: string;
timezone: string;
status: ScheduleStatus;
warmUpMinutes: number;
maxConsecutiveFailures: number;
consecutiveFailures: number;
maxTurns: number;
maxBudgetUsd?: number;
createdAt: string;
updatedAt: string;
lastRunAt?: string;
nextRunAt?: string;
launchConfig: ScheduleLaunchConfig;
}
export interface ScheduleLaunchConfig {
cwd: string;
prompt: string;
model?: string;
effort?: EffortLevel;
skipPermissions?: boolean;
allowedTools?: string[];
disallowedTools?: string[];
}
export interface ScheduleRun {
id: string;
scheduleId: string;
teamName: string;
status: ScheduleRunStatus;
scheduledFor: string;
startedAt: string;
warmUpCompletedAt?: string;
executionStartedAt?: string;
completedAt?: string;
durationMs?: number;
exitCode?: number | null;
error?: string;
retryCount: number;
/** First ~500 chars of stdout for quick UI display */
summary?: string;
}
// =============================================================================
// Events
// =============================================================================
export type ScheduleChangeType =
| 'schedule-updated'
| 'run-started'
| 'run-completed'
| 'run-failed'
| 'schedule-paused';
export interface ScheduleChangeEvent {
type: ScheduleChangeType;
scheduleId: string;
teamName: string;
detail?: string;
}
// =============================================================================
// API Input/Patch Types
// =============================================================================
export interface CreateScheduleInput {
teamName: string;
label?: string;
cronExpression: string;
timezone: string;
warmUpMinutes?: number;
maxTurns?: number;
maxBudgetUsd?: number;
launchConfig: ScheduleLaunchConfig;
}
export interface UpdateSchedulePatch {
label?: string;
cronExpression?: string;
timezone?: string;
warmUpMinutes?: number;
maxTurns?: number;
maxBudgetUsd?: number;
launchConfig?: Partial<ScheduleLaunchConfig>;
}

View file

@ -0,0 +1,268 @@
import { JsonScheduleRepository } from '@main/services/schedule/JsonScheduleRepository';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { Schedule, ScheduleRun } from '@shared/types';
// Mock pathDecoder to use temp dir
let tempDir: string;
vi.mock('@main/utils/pathDecoder', () => ({
getSchedulesBasePath: () => tempDir,
}));
function makeSchedule(overrides?: Partial<Schedule>): Schedule {
const now = new Date().toISOString();
return {
id: 'sched-1',
teamName: 'test-team',
cronExpression: '0 9 * * 1-5',
timezone: 'UTC',
status: 'active',
warmUpMinutes: 15,
maxConsecutiveFailures: 3,
consecutiveFailures: 0,
maxTurns: 50,
createdAt: now,
updatedAt: now,
launchConfig: {
cwd: '/tmp/project',
prompt: 'Run tests',
},
...overrides,
};
}
function makeRun(overrides?: Partial<ScheduleRun>): ScheduleRun {
const now = new Date().toISOString();
return {
id: 'run-1',
scheduleId: 'sched-1',
teamName: 'test-team',
status: 'completed',
scheduledFor: now,
startedAt: now,
retryCount: 0,
...overrides,
};
}
describe('JsonScheduleRepository', () => {
let repo: JsonScheduleRepository;
beforeEach(async () => {
tempDir = path.join(os.tmpdir(), `schedule-test-${Date.now()}-${Math.random().toString(36).slice(2)}`);
await fs.promises.mkdir(tempDir, { recursive: true });
repo = new JsonScheduleRepository();
});
afterEach(async () => {
await fs.promises.rm(tempDir, { recursive: true, force: true });
});
// =========================================================================
// Schedule CRUD
// =========================================================================
describe('schedules', () => {
it('lists empty schedules when no file exists', async () => {
const result = await repo.listSchedules();
expect(result).toEqual([]);
});
it('saves and retrieves a schedule', async () => {
const schedule = makeSchedule();
await repo.saveSchedule(schedule);
const retrieved = await repo.getSchedule('sched-1');
expect(retrieved).toEqual(schedule);
});
it('updates an existing schedule', async () => {
const schedule = makeSchedule();
await repo.saveSchedule(schedule);
const updated = { ...schedule, label: 'Daily tests' };
await repo.saveSchedule(updated);
const retrieved = await repo.getSchedule('sched-1');
expect(retrieved?.label).toBe('Daily tests');
const all = await repo.listSchedules();
expect(all).toHaveLength(1);
});
it('filters schedules by team', async () => {
await repo.saveSchedule(makeSchedule({ id: 's1', teamName: 'team-a' }));
await repo.saveSchedule(makeSchedule({ id: 's2', teamName: 'team-b' }));
await repo.saveSchedule(makeSchedule({ id: 's3', teamName: 'team-a' }));
const teamA = await repo.getSchedulesByTeam('team-a');
expect(teamA).toHaveLength(2);
expect(teamA.map((s) => s.id).sort()).toEqual(['s1', 's3']);
});
it('deletes a schedule and its runs/logs', async () => {
const schedule = makeSchedule();
await repo.saveSchedule(schedule);
const run = makeRun();
await repo.saveRun(run);
// Create log files
const logsDir = path.join(tempDir, 'logs', 'sched-1');
await fs.promises.mkdir(logsDir, { recursive: true });
await fs.promises.writeFile(path.join(logsDir, 'run-1.log'), 'stdout');
await fs.promises.writeFile(path.join(logsDir, 'run-1.err'), 'stderr');
await repo.deleteSchedule('sched-1');
expect(await repo.getSchedule('sched-1')).toBeNull();
expect(await repo.listRuns('sched-1')).toEqual([]);
// Logs dir cleaned up
await expect(fs.promises.stat(logsDir)).rejects.toThrow();
});
it('returns null for non-existent schedule', async () => {
const result = await repo.getSchedule('non-existent');
expect(result).toBeNull();
});
});
// =========================================================================
// Run CRUD
// =========================================================================
describe('runs', () => {
it('lists empty runs when no file exists', async () => {
const result = await repo.listRuns('sched-1');
expect(result).toEqual([]);
});
it('saves and retrieves runs (newest first)', async () => {
const run1 = makeRun({ id: 'run-1', startedAt: '2026-01-01T09:00:00Z' });
const run2 = makeRun({ id: 'run-2', startedAt: '2026-01-02T09:00:00Z' });
await repo.saveRun(run1);
await repo.saveRun(run2);
const runs = await repo.listRuns('sched-1');
expect(runs).toHaveLength(2);
// run2 added later → newest first (unshift)
expect(runs[0].id).toBe('run-2');
expect(runs[1].id).toBe('run-1');
});
it('updates an existing run in place', async () => {
const run = makeRun({ status: 'running' });
await repo.saveRun(run);
const updated = { ...run, status: 'completed' as const, exitCode: 0 };
await repo.saveRun(updated);
const runs = await repo.listRuns('sched-1');
expect(runs).toHaveLength(1);
expect(runs[0].status).toBe('completed');
expect(runs[0].exitCode).toBe(0);
});
it('getLatestRun returns newest run', async () => {
await repo.saveRun(makeRun({ id: 'run-1' }));
await repo.saveRun(makeRun({ id: 'run-2' }));
const latest = await repo.getLatestRun('sched-1');
expect(latest?.id).toBe('run-2');
});
it('getLatestRun returns null for empty schedule', async () => {
const latest = await repo.getLatestRun('sched-1');
expect(latest).toBeNull();
});
it('supports pagination via offset and limit', async () => {
for (let i = 0; i < 10; i++) {
await repo.saveRun(makeRun({ id: `run-${i}` }));
}
const page = await repo.listRuns('sched-1', { limit: 3, offset: 2 });
expect(page).toHaveLength(3);
});
});
// =========================================================================
// Pruning
// =========================================================================
describe('pruneOldRuns', () => {
it('prunes old runs beyond keep count', async () => {
for (let i = 0; i < 10; i++) {
await repo.saveRun(makeRun({ id: `run-${i}` }));
}
const removed = await repo.pruneOldRuns('sched-1', 5);
expect(removed).toBe(5);
const remaining = await repo.listRuns('sched-1');
expect(remaining).toHaveLength(5);
});
it('returns 0 when nothing to prune', async () => {
await repo.saveRun(makeRun({ id: 'run-1' }));
const removed = await repo.pruneOldRuns('sched-1', 10);
expect(removed).toBe(0);
});
it('deletes log files for pruned runs', async () => {
const logsDir = path.join(tempDir, 'logs', 'sched-1');
await fs.promises.mkdir(logsDir, { recursive: true });
for (let i = 0; i < 5; i++) {
await repo.saveRun(makeRun({ id: `run-${i}` }));
await fs.promises.writeFile(path.join(logsDir, `run-${i}.log`), `log ${i}`);
}
await repo.pruneOldRuns('sched-1', 2);
const remaining = await repo.listRuns('sched-1');
expect(remaining).toHaveLength(2);
// Pruned run logs should be deleted
const logFiles = await fs.promises.readdir(logsDir);
// Only newest 2 runs logs remain (run-4, run-3 since newest first)
expect(logFiles.length).toBeLessThanOrEqual(2);
});
});
// =========================================================================
// Edge Cases
// =========================================================================
describe('edge cases', () => {
it('handles corrupted JSON gracefully', async () => {
await fs.promises.mkdir(path.dirname(path.join(tempDir, 'schedules.json')), {
recursive: true,
});
await fs.promises.writeFile(path.join(tempDir, 'schedules.json'), 'not valid json');
// Logger uses console.warn internally — expect it for corrupted file
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const result = await repo.listSchedules();
expect(result).toEqual([]);
expect(warnSpy).toHaveBeenCalled();
warnSpy.mockRestore();
});
it('handles concurrent saves without data loss', async () => {
const promises = Array.from({ length: 5 }, (_, i) =>
repo.saveSchedule(makeSchedule({ id: `sched-${i}` }))
);
await Promise.all(promises);
const schedules = await repo.listSchedules();
// At least some should be saved (atomic writes prevent corruption)
expect(schedules.length).toBeGreaterThan(0);
});
});
});

View file

@ -0,0 +1,476 @@
/**
* ScheduledTaskExecutor tests covers process spawning, output capture,
* argument building, cancellation, and error handling.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { EventEmitter } from 'events';
import type { ExecutionRequest } from '../../../../src/main/services/schedule/ScheduledTaskExecutor';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
const mockSpawnCli = vi.fn();
const mockKillProcessTree = vi.fn();
const mockResolve = vi.fn();
const mockResolveShellEnv = vi.fn();
vi.mock('@main/utils/childProcess', () => ({
spawnCli: (...args: unknown[]) => mockSpawnCli(...args),
killProcessTree: (...args: unknown[]) => mockKillProcessTree(...args),
}));
vi.mock('@main/utils/shellEnv', () => ({
resolveInteractiveShellEnv: () => mockResolveShellEnv(),
}));
vi.mock('../../../../src/main/services/team/ClaudeBinaryResolver', () => ({
ClaudeBinaryResolver: {
resolve: () => mockResolve(),
},
}));
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
/**
* Flush pending microtasks so that execute()'s internal awaits
* (ClaudeBinaryResolver.resolve, resolveInteractiveShellEnv) complete
* and spawnCli gets called.
*/
function flushAsync(): Promise<void> {
return new Promise((r) => setTimeout(r, 0));
}
function createMockProcess() {
const proc = new EventEmitter() as EventEmitter & {
stdout: EventEmitter;
stderr: EventEmitter;
pid: number;
};
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.pid = 12345;
return proc;
}
function makeRequest(overrides?: Partial<ExecutionRequest>): ExecutionRequest {
return {
runId: 'run-001',
config: {
cwd: '/tmp/project',
prompt: 'Run the tests',
},
maxTurns: 50,
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('ScheduledTaskExecutor', () => {
let ScheduledTaskExecutor: typeof import('../../../../src/main/services/schedule/ScheduledTaskExecutor').ScheduledTaskExecutor;
beforeEach(async () => {
vi.clearAllMocks();
mockResolve.mockResolvedValue('/usr/local/bin/claude');
mockResolveShellEnv.mockResolvedValue({ SHELL: '/bin/zsh' });
const mod = await import('../../../../src/main/services/schedule/ScheduledTaskExecutor');
ScheduledTaskExecutor = mod.ScheduledTaskExecutor;
});
afterEach(() => {
vi.restoreAllMocks();
});
// --- Basic Execution ---
it('executes and returns result on successful exit', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
// Flush microtasks so execute() reaches spawnCli and sets up listeners
await flushAsync();
proc.stdout.emit('data', Buffer.from('Task completed'));
proc.emit('close', 0);
const result = await resultPromise;
expect(result.exitCode).toBe(0);
expect(result.stdout).toBe('Task completed');
expect(result.stderr).toBe('');
expect(result.summary).toBe('Task completed');
expect(result.durationMs).toBeGreaterThanOrEqual(0);
});
it('returns non-zero exit code on failure', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
proc.stderr.emit('data', Buffer.from('Error: something broke'));
proc.emit('close', 1);
const result = await resultPromise;
expect(result.exitCode).toBe(1);
expect(result.stderr).toBe('Error: something broke');
});
it('rejects on process error event', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
proc.emit('error', new Error('ENOENT'));
await expect(resultPromise).rejects.toThrow('ENOENT');
});
it('throws when binary not found', async () => {
mockResolve.mockResolvedValue(null);
const executor = new ScheduledTaskExecutor();
await expect(executor.execute(makeRequest())).rejects.toThrow('Claude CLI binary not found');
});
// --- Output Truncation ---
it('truncates stdout at 512KB', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
// Send 640KB in chunks (exceeds 512KB limit)
const chunk = Buffer.alloc(64 * 1024, 'A');
for (let i = 0; i < 10; i++) {
proc.stdout.emit('data', chunk);
}
proc.emit('close', 0);
const result = await resultPromise;
// Should be capped around 512KB
expect(result.stdout.length).toBeLessThanOrEqual(512 * 1024);
// Should not be empty (captures at least some)
expect(result.stdout.length).toBeGreaterThan(0);
});
it('truncates stderr at 16KB', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
// Send 32KB in chunks
const chunk = Buffer.alloc(8 * 1024, 'E');
for (let i = 0; i < 4; i++) {
proc.stderr.emit('data', chunk);
}
proc.emit('close', 1);
const result = await resultPromise;
expect(result.stderr.length).toBeLessThanOrEqual(16_384);
expect(result.stderr.length).toBeGreaterThan(0);
});
it('truncates summary at 500 chars from stream-json text', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
const longText = 'X'.repeat(1000);
const streamLine = JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: longText }] });
proc.stdout.emit('data', Buffer.from(streamLine + '\n'));
proc.emit('close', 0);
const result = await resultPromise;
expect(result.summary.length).toBeLessThanOrEqual(500);
expect(result.summary).toBe(longText.slice(0, 500));
});
it('extracts summary from last assistant text block', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
const lines = [
JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: 'First message' }] }),
JSON.stringify({ type: 'assistant', content: [{ type: 'text', text: 'All tests passed.' }] }),
].join('\n') + '\n';
proc.stdout.emit('data', Buffer.from(lines));
proc.emit('close', 0);
const result = await resultPromise;
expect(result.summary).toBe('All tests passed.');
});
it('falls back to raw stdout slice when no assistant text blocks', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
const resultPromise = executor.execute(makeRequest());
await flushAsync();
const line = JSON.stringify({ type: 'result', subtype: 'success' }) + '\n';
proc.stdout.emit('data', Buffer.from(line));
proc.emit('close', 0);
const result = await resultPromise;
// Fallback: first 500 chars of raw stdout (includes the JSON line)
expect(result.summary).toContain('"type":"result"');
expect(result.summary.length).toBeLessThanOrEqual(500);
});
// --- Argument Building ---
it('builds basic args with required fields', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest());
await flushAsync();
const args = mockSpawnCli.mock.calls[0][1] as string[];
expect(args).toContain('-p');
expect(args).toContain('Run the tests');
expect(args).toContain('--output-format');
expect(args).toContain('stream-json');
expect(args).toContain('--verbose');
expect(args).toContain('--max-turns');
expect(args).toContain('50');
expect(args).toContain('--no-session-persistence');
// skipPermissions defaults to true (undefined !== false)
expect(args).toContain('--dangerously-skip-permissions');
proc.emit('close', 0);
});
it('includes --max-budget-usd when specified', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({ maxBudgetUsd: 5.0 }));
await flushAsync();
const args = mockSpawnCli.mock.calls[0][1] as string[];
expect(args).toContain('--max-budget-usd');
expect(args).toContain('5');
proc.emit('close', 0);
});
it('includes --model when specified', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({
config: {
cwd: '/tmp/project',
prompt: 'do it',
model: 'claude-sonnet-4-5-20250514',
},
}));
await flushAsync();
const args = mockSpawnCli.mock.calls[0][1] as string[];
expect(args).toContain('--model');
expect(args).toContain('claude-sonnet-4-5-20250514');
proc.emit('close', 0);
});
it('excludes --dangerously-skip-permissions when skipPermissions is false', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({
config: {
cwd: '/tmp/project',
prompt: 'do it',
skipPermissions: false,
},
}));
await flushAsync();
const args = mockSpawnCli.mock.calls[0][1] as string[];
expect(args).not.toContain('--dangerously-skip-permissions');
proc.emit('close', 0);
});
it('includes --allowed-tools and --disallowed-tools when specified', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({
config: {
cwd: '/tmp/project',
prompt: 'do it',
allowedTools: ['Read', 'Write'],
disallowedTools: ['Bash'],
},
}));
await flushAsync();
const args = mockSpawnCli.mock.calls[0][1] as string[];
expect(args).toContain('--allowed-tools');
expect(args).toContain('Read,Write');
expect(args).toContain('--disallowed-tools');
expect(args).toContain('Bash');
proc.emit('close', 0);
});
// --- Cancellation ---
it('cancel() kills process and returns true when found', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({ runId: 'run-cancel-test' }));
await flushAsync();
expect(executor.activeCount).toBe(1);
const cancelled = executor.cancel('run-cancel-test');
expect(cancelled).toBe(true);
expect(mockKillProcessTree).toHaveBeenCalledWith(proc, 'SIGTERM');
expect(executor.activeCount).toBe(0);
// Emit close so the promise settles (prevents unhandled rejection)
proc.emit('close', null);
});
it('cancel() returns false when run not found', () => {
const executor = new ScheduledTaskExecutor();
expect(executor.cancel('nonexistent')).toBe(false);
expect(mockKillProcessTree).not.toHaveBeenCalled();
});
it('cancelAll() kills all active processes', async () => {
const proc1 = createMockProcess();
const proc2 = createMockProcess();
mockSpawnCli.mockReturnValueOnce(proc1).mockReturnValueOnce(proc2);
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({ runId: 'run-1' }));
void executor.execute(makeRequest({ runId: 'run-2' }));
await flushAsync();
expect(executor.activeCount).toBe(2);
executor.cancelAll();
expect(mockKillProcessTree).toHaveBeenCalledTimes(2);
expect(executor.activeCount).toBe(0);
// Emit close for both
proc1.emit('close', null);
proc2.emit('close', null);
});
// --- Active Tracking ---
it('activeCount reflects number of running processes', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
const executor = new ScheduledTaskExecutor();
expect(executor.activeCount).toBe(0);
const resultPromise = executor.execute(makeRequest());
await flushAsync();
expect(executor.activeCount).toBe(1);
proc.emit('close', 0);
await resultPromise;
expect(executor.activeCount).toBe(0);
});
// --- CWD and Environment ---
it('passes correct cwd and env to spawnCli', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
mockResolveShellEnv.mockResolvedValue({ MY_VAR: 'test' });
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest({
config: { cwd: '/home/user/project', prompt: 'test' },
}));
await flushAsync();
const opts = mockSpawnCli.mock.calls[0][2];
expect(opts.cwd).toBe('/home/user/project');
expect(opts.env.MY_VAR).toBe('test');
expect(opts.stdio).toEqual(['ignore', 'pipe', 'pipe']);
proc.emit('close', 0);
});
it('strips CLAUDECODE env var to avoid nested session detection', async () => {
const proc = createMockProcess();
mockSpawnCli.mockReturnValue(proc);
mockResolveShellEnv.mockResolvedValue({});
// Simulate CLAUDECODE being set in parent process
const originalClaudeCode = process.env.CLAUDECODE;
process.env.CLAUDECODE = '1';
try {
const executor = new ScheduledTaskExecutor();
void executor.execute(makeRequest());
await flushAsync();
const opts = mockSpawnCli.mock.calls[0][2];
expect(opts.env.CLAUDECODE).toBeUndefined();
proc.emit('close', 0);
} finally {
if (originalClaudeCode === undefined) {
delete process.env.CLAUDECODE;
} else {
process.env.CLAUDECODE = originalClaudeCode;
}
}
});
});

View file

@ -0,0 +1,817 @@
/**
* SchedulerService tests covers cron job lifecycle, warm-up, execution flow,
* concurrency locks, auto-pause on consecutive failures, and recovery.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { randomUUID } from 'crypto';
import type { Schedule, ScheduleChangeEvent, ScheduleRun } from '@shared/types';
import type { ScheduleRepository } from '../../../../src/main/services/schedule/ScheduleRepository';
import type { ExecutionRequest, ScheduledTaskResult } from '../../../../src/main/services/schedule/ScheduledTaskExecutor';
import type { WarmUpFn } from '../../../../src/main/services/schedule/SchedulerService';
// ---------------------------------------------------------------------------
// Mocks
// ---------------------------------------------------------------------------
vi.mock('croner', () => {
const jobs = new Map<string, { callback: () => void; paused: boolean; stopped: boolean }>();
class MockCron {
private id: string;
private callback: () => void;
private _paused: boolean;
private _stopped = false;
private _expression: string;
private _timezone: string;
constructor(
expression: string,
optsOrCallback: Record<string, unknown> | (() => void),
maybeCallback?: () => void
) {
this.id = randomUUID();
this._expression = expression;
if (typeof optsOrCallback === 'function') {
this.callback = optsOrCallback;
this._paused = false;
this._timezone = 'UTC';
} else {
this.callback = maybeCallback!;
this._paused = !!optsOrCallback.paused;
this._timezone = (optsOrCallback.timezone as string) ?? 'UTC';
}
jobs.set(this.id, { callback: this.callback, paused: this._paused, stopped: this._stopped });
}
nextRun(): Date | null {
if (this._stopped) return null;
return new Date(Date.now() + 3600_000); // 1 hour from now
}
nextRuns(count: number): Date[] {
if (this._stopped) return [];
return Array.from({ length: count }, (_, i) =>
new Date(Date.now() + (i + 1) * 3600_000)
);
}
msToNext(): number | null {
if (this._stopped || this._paused) return null;
return 3600_000; // 1 hour
}
pause(): void {
this._paused = true;
}
resume(): void {
this._paused = false;
}
stop(): void {
this._stopped = true;
jobs.delete(this.id);
}
/** Test helper: simulate a cron tick */
_trigger(): void {
if (!this._stopped && !this._paused) {
this.callback();
}
}
}
return { Cron: MockCron };
});
vi.mock('@main/utils/pathDecoder', () => ({
getSchedulesBasePath: () => '/tmp/test-schedules',
}));
// ---------------------------------------------------------------------------
// Test Helpers
// ---------------------------------------------------------------------------
function createMockRepository(): ScheduleRepository {
const schedules = new Map<string, Schedule>();
const runs = new Map<string, ScheduleRun[]>();
return {
listSchedules: vi.fn(async () => [...schedules.values()]),
getSchedule: vi.fn(async (id: string) => schedules.get(id) ?? null),
getSchedulesByTeam: vi.fn(async (teamName: string) =>
[...schedules.values()].filter((s) => s.teamName === teamName)
),
saveSchedule: vi.fn(async (schedule: Schedule) => {
schedules.set(schedule.id, schedule);
}),
deleteSchedule: vi.fn(async (id: string) => {
schedules.delete(id);
runs.delete(id);
}),
listRuns: vi.fn(async (scheduleId: string) => runs.get(scheduleId) ?? []),
getLatestRun: vi.fn(async (scheduleId: string) => {
const r = runs.get(scheduleId);
return r?.[0] ?? null;
}),
saveRun: vi.fn(async (run: ScheduleRun) => {
const existing = runs.get(run.scheduleId) ?? [];
const index = existing.findIndex((r) => r.id === run.id);
if (index >= 0) {
existing[index] = run;
} else {
existing.unshift(run);
}
runs.set(run.scheduleId, existing);
}),
pruneOldRuns: vi.fn(async () => 0),
saveRunLogs: vi.fn(async () => undefined),
getRunLogs: vi.fn(async () => ({ stdout: '', stderr: '' })),
};
}
function createMockExecutor() {
const executeFn = vi.fn<(req: ExecutionRequest) => Promise<ScheduledTaskResult>>();
executeFn.mockResolvedValue({
exitCode: 0,
stdout: 'Task completed successfully',
stderr: '',
summary: 'Task completed successfully',
durationMs: 1234,
});
return {
execute: executeFn,
cancel: vi.fn(() => true),
cancelAll: vi.fn(),
get activeCount() {
return 0;
},
};
}
function makeSchedule(overrides?: Partial<Schedule>): Schedule {
const now = new Date().toISOString();
return {
id: randomUUID(),
teamName: 'test-team',
label: 'Test Schedule',
cronExpression: '0 9 * * 1-5',
timezone: 'UTC',
status: 'active',
warmUpMinutes: 15,
maxConsecutiveFailures: 3,
consecutiveFailures: 0,
maxTurns: 50,
createdAt: now,
updatedAt: now,
launchConfig: {
cwd: '/tmp/test-project',
prompt: 'Run tests and report results',
},
...overrides,
};
}
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
describe('SchedulerService', () => {
let repo: ScheduleRepository;
let executor: ReturnType<typeof createMockExecutor>;
let warmUpFn: ReturnType<typeof vi.fn<WarmUpFn>>;
let events: ScheduleChangeEvent[];
// Dynamic import to apply mocks
let SchedulerService: typeof import('../../../../src/main/services/schedule/SchedulerService').SchedulerService;
beforeEach(async () => {
vi.useFakeTimers({ shouldAdvanceTime: false });
repo = createMockRepository();
executor = createMockExecutor();
warmUpFn = vi.fn<WarmUpFn>().mockResolvedValue({ ready: true, message: 'ready' });
events = [];
const mod = await import('../../../../src/main/services/schedule/SchedulerService');
SchedulerService = mod.SchedulerService;
});
afterEach(() => {
vi.useRealTimers();
});
function createService() {
const service = new SchedulerService(repo, executor as any, warmUpFn);
service.setChangeEmitter((event) => events.push(event));
return service;
}
// --- Lifecycle ---
it('start() loads schedules and creates cron jobs for active ones', async () => {
const active = makeSchedule({ status: 'active' });
const paused = makeSchedule({ status: 'paused' });
(repo.saveSchedule as any)(active);
(repo.saveSchedule as any)(paused);
const service = createService();
await service.start();
// listSchedules should be called
expect(repo.listSchedules).toHaveBeenCalled();
await service.stop();
});
it('stop() cancels all active executions and clears state', async () => {
const service = createService();
await service.start();
await service.stop();
expect(executor.cancelAll).toHaveBeenCalled();
});
// --- CRUD ---
it('createSchedule() saves and emits change event', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
expect(schedule.id).toBeTruthy();
expect(schedule.teamName).toBe('my-team');
expect(schedule.status).toBe('active');
expect(schedule.warmUpMinutes).toBe(15);
expect(schedule.maxTurns).toBe(50);
expect(schedule.nextRunAt).toBeTruthy();
expect(repo.saveSchedule).toHaveBeenCalled();
expect(events).toHaveLength(1);
expect(events[0].type).toBe('schedule-updated');
await service.stop();
});
it('updateSchedule() persists changes and emits event', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
const updated = await service.updateSchedule(schedule.id, {
label: 'New Label',
maxTurns: 100,
});
expect(updated.label).toBe('New Label');
expect(updated.maxTurns).toBe(100);
expect(events.length).toBeGreaterThanOrEqual(2);
await service.stop();
});
it('deleteSchedule() removes schedule and emits event', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.deleteSchedule(schedule.id);
expect(repo.deleteSchedule).toHaveBeenCalledWith(schedule.id);
// executor.cancel not called when no active run exists for this schedule
expect(executor.cancel).not.toHaveBeenCalled();
const deleteEvent = events.find((e) => e.detail === 'deleted');
expect(deleteEvent).toBeTruthy();
await service.stop();
});
it('pauseSchedule() sets status to paused', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.pauseSchedule(schedule.id);
const saved = await service.getSchedule(schedule.id);
expect(saved?.status).toBe('paused');
const pauseEvent = events.find((e) => e.type === 'schedule-paused');
expect(pauseEvent).toBeTruthy();
await service.stop();
});
it('resumeSchedule() resets failures and sets status to active', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.pauseSchedule(schedule.id);
await service.resumeSchedule(schedule.id);
const saved = await service.getSchedule(schedule.id);
expect(saved?.status).toBe('active');
expect(saved?.consecutiveFailures).toBe(0);
await service.stop();
});
// --- Trigger Now ---
it('triggerNow() creates a run and starts execution', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
const run = await service.triggerNow(schedule.id);
expect(run.status).toBe('running');
expect(run.scheduleId).toBe(schedule.id);
expect(repo.saveRun).toHaveBeenCalled();
// Let the background execution complete
await vi.advanceTimersByTimeAsync(100);
expect(executor.execute).toHaveBeenCalledWith(
expect.objectContaining({
runId: run.id,
config: schedule.launchConfig,
maxTurns: 50,
})
);
await service.stop();
});
it('triggerNow() rejects if run already active for same schedule', async () => {
executor.execute.mockImplementation(
() => new Promise(() => {}) // never resolves
);
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.triggerNow(schedule.id);
await vi.advanceTimersByTimeAsync(50);
await expect(service.triggerNow(schedule.id)).rejects.toThrow('already has an active run');
await service.stop();
});
it('triggerNow() rejects if cwd is locked by another schedule', async () => {
executor.execute.mockImplementation(
() => new Promise(() => {}) // never resolves
);
const service = createService();
const schedule1 = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/shared-project', prompt: 'job 1' },
});
const schedule2 = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 10 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/shared-project', prompt: 'job 2' },
});
await service.triggerNow(schedule1.id);
await vi.advanceTimersByTimeAsync(50);
await expect(service.triggerNow(schedule2.id)).rejects.toThrow('locked by another schedule');
await service.stop();
});
// --- Execution result ---
it('successful execution emits run-completed and resets failures', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.triggerNow(schedule.id);
await vi.advanceTimersByTimeAsync(500);
const completedEvent = events.find((e) => e.type === 'run-completed');
expect(completedEvent).toBeTruthy();
await service.stop();
});
it('failed execution increments consecutive failures', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
executor.execute.mockResolvedValue({
exitCode: 1,
stdout: '',
stderr: 'Something went wrong',
summary: '',
durationMs: 500,
});
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.triggerNow(schedule.id);
// Advance through retries (each retry has EXECUTION_RETRY_DELAY_MS = 90s)
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(100_000);
}
// Should have retry attempts
expect(executor.execute).toHaveBeenCalledTimes(3); // initial + 2 retries
const failEvent = events.find((e) => e.type === 'run-failed');
expect(failEvent).toBeTruthy();
warnSpy.mockRestore();
await service.stop();
});
// --- Recovery ---
it('start() marks interrupted runs as failed_interrupted', async () => {
// Seed a "running" run before start
const schedule = makeSchedule();
await repo.saveSchedule(schedule);
await repo.saveRun({
id: randomUUID(),
scheduleId: schedule.id,
teamName: schedule.teamName,
status: 'running',
scheduledFor: new Date().toISOString(),
startedAt: new Date().toISOString(),
retryCount: 0,
});
const service = createService();
await service.start();
// Check that the run was marked as failed_interrupted
const runs = await repo.listRuns(schedule.id);
expect(runs[0].status).toBe('failed_interrupted');
expect(runs[0].error).toBe('Interrupted by app restart');
await service.stop();
});
// --- reloadForClaudeRootChange ---
it('reloadForClaudeRootChange() stops and restarts', async () => {
const service = createService();
await service.start();
await service.reloadForClaudeRootChange();
// cancelAll called at least once during stop
expect(executor.cancelAll).toHaveBeenCalled();
await service.stop();
});
// --- Warm-Up ---
it('warm-up is triggered before scheduled run', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
warmUpMinutes: 15,
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
// Mock Cron returns msToNext() = 3600000 (1 hour)
// warmUpMinutes = 15 → warmUpDelayMs = 3600000 - 900000 = 2700000 (45 min)
expect(warmUpFn).not.toHaveBeenCalled();
// Advance to just before warm-up should fire (45 min - 1s)
await vi.advanceTimersByTimeAsync(2_699_000);
expect(warmUpFn).not.toHaveBeenCalled();
// Advance 1 more second — warm-up fires
await vi.advanceTimersByTimeAsync(1_000);
expect(warmUpFn).toHaveBeenCalledWith(schedule.launchConfig.cwd);
await service.stop();
});
it('warm-up retries on failure up to 3 times', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
warmUpFn
.mockResolvedValueOnce({ ready: false, message: 'not ready' })
.mockResolvedValueOnce({ ready: false, message: 'still not ready' })
.mockResolvedValueOnce({ ready: true, message: 'ready' });
const service = createService();
await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
warmUpMinutes: 15,
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
// Fire warm-up timer
await vi.advanceTimersByTimeAsync(2_700_000);
expect(warmUpFn).toHaveBeenCalledTimes(1);
// First retry after 60s
await vi.advanceTimersByTimeAsync(60_000);
expect(warmUpFn).toHaveBeenCalledTimes(2);
// Second retry after another 60s
await vi.advanceTimersByTimeAsync(60_000);
expect(warmUpFn).toHaveBeenCalledTimes(3);
warnSpy.mockRestore();
await service.stop();
});
it('warm-up is skipped when warmUpMinutes <= 0', async () => {
const service = createService();
await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
warmUpMinutes: 0,
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
// Even after a long time, warm-up never fires
await vi.advanceTimersByTimeAsync(7_200_000);
expect(warmUpFn).not.toHaveBeenCalled();
await service.stop();
});
// --- Auto-Pause on Consecutive Failures ---
it('auto-pauses schedule after maxConsecutiveFailures via retries', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
// Executor always returns failure (non-zero exit code)
executor.execute.mockResolvedValue({
exitCode: 1,
stdout: '',
stderr: 'persistent failure',
summary: '',
durationMs: 100,
});
// Start with consecutiveFailures = 2, threshold = 3
// After the retry chain exhausts (initial + 2 retries), incrementConsecutiveFailures
// bumps to 3 → auto-pause.
const schedule = makeSchedule({
consecutiveFailures: 2,
maxConsecutiveFailures: 3,
});
await repo.saveSchedule(schedule);
const service = createService();
await service.triggerNow(schedule.id);
// Advance past all retry delays (2 × 90s = 180s)
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(100_000);
}
// Verify all 3 attempts executed
expect(executor.execute).toHaveBeenCalledTimes(3);
const saved = await repo.getSchedule(schedule.id);
expect(saved?.status).toBe('paused');
expect(saved?.consecutiveFailures).toBeGreaterThanOrEqual(3);
// Verify auto-pause event was emitted
const pauseEvent = events.find(
(e) => e.type === 'schedule-paused' && e.detail?.includes('auto-paused')
);
expect(pauseEvent).toBeTruthy();
warnSpy.mockRestore();
await service.stop();
});
// --- Delete While Running ---
it('deleteSchedule() cancels active run by runId', async () => {
executor.execute.mockImplementation(
() => new Promise(() => {}) // never resolves
);
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
const run = await service.triggerNow(schedule.id);
await vi.advanceTimersByTimeAsync(50);
// Delete while run is active
await service.deleteSchedule(schedule.id);
// executor.cancel called with the run's ID (not schedule ID)
expect(executor.cancel).toHaveBeenCalledWith(run.id);
await service.stop();
});
// --- Stop During Retry ---
it('stop() during retry delay prevents retry from executing', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
executor.execute.mockResolvedValue({
exitCode: 1,
stdout: '',
stderr: 'fail',
summary: '',
durationMs: 100,
});
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.triggerNow(schedule.id);
// Let the first execution complete and enter retry delay
await vi.advanceTimersByTimeAsync(1_000);
expect(executor.execute).toHaveBeenCalledTimes(1);
// Stop service while retry timer is pending (90s delay)
await service.stop();
// Advance past the retry delay — retry should NOT fire
await vi.advanceTimersByTimeAsync(100_000);
expect(executor.execute).toHaveBeenCalledTimes(1);
warnSpy.mockRestore();
});
// --- Lock Ownership Check ---
it('finally block does not release locks owned by a different run', async () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
let callCount = 0;
executor.execute.mockImplementation(async () => {
callCount++;
if (callCount === 1) {
return {
exitCode: 1, stdout: '', stderr: 'fail first',
summary: '', durationMs: 100,
};
}
// Second call (retry) takes a while
return new Promise((resolve) => {
setTimeout(() => resolve({
exitCode: 0, stdout: 'ok', stderr: '',
summary: 'ok', durationMs: 200,
}), 500);
});
});
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
await service.triggerNow(schedule.id);
// Advance through first failure + retry delay + retry execution
for (let i = 0; i < 5; i++) {
await vi.advanceTimersByTimeAsync(100_000);
}
// Service should complete without errors (no lock race crash)
expect(executor.execute).toHaveBeenCalledTimes(2);
warnSpy.mockRestore();
await service.stop();
});
// --- Cron Update Reschedule ---
it('updateSchedule() recreates cron job when expression changes', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
const updated = await service.updateSchedule(schedule.id, {
cronExpression: '0 18 * * *',
});
// nextRunAt should be recomputed
expect(updated.nextRunAt).toBeTruthy();
expect(updated.cronExpression).toBe('0 18 * * *');
await service.stop();
});
it('updateSchedule() recreates cron job when timezone changes', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
const updated = await service.updateSchedule(schedule.id, {
timezone: 'America/New_York',
});
expect(updated.timezone).toBe('America/New_York');
expect(updated.nextRunAt).toBeTruthy();
await service.stop();
});
// --- Timestamp Updates ---
it('successful run updates lastRunAt and nextRunAt on schedule', async () => {
const service = createService();
const schedule = await service.createSchedule({
teamName: 'my-team',
cronExpression: '0 9 * * *',
timezone: 'UTC',
launchConfig: { cwd: '/tmp/project', prompt: 'do stuff' },
});
// Before trigger, no lastRunAt
expect(schedule.lastRunAt).toBeUndefined();
await service.triggerNow(schedule.id);
await vi.advanceTimersByTimeAsync(500);
const saved = await service.getSchedule(schedule.id);
expect(saved?.lastRunAt).toBeTruthy();
expect(saved?.nextRunAt).toBeTruthy();
await service.stop();
});
});

View file

@ -92,6 +92,8 @@ describe('buildDetectedErrorFromTeam', () => {
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' },
};
for (const [eventType, expected] of Object.entries(EXPECTED_CONFIG)) {

View file

@ -9,9 +9,10 @@ import { createCliInstallerSlice } from '../../../src/renderer/store/slices/cliI
import { createConfigSlice } from '../../../src/renderer/store/slices/configSlice';
import { createConnectionSlice } from '../../../src/renderer/store/slices/connectionSlice';
import { createContextSlice } from '../../../src/renderer/store/slices/contextSlice';
import { createEditorSlice } from '../../../src/renderer/store/slices/editorSlice';
import { createConversationSlice } from '../../../src/renderer/store/slices/conversationSlice';
import { createEditorSlice } from '../../../src/renderer/store/slices/editorSlice';
import { createNotificationSlice } from '../../../src/renderer/store/slices/notificationSlice';
import { createScheduleSlice } from '../../../src/renderer/store/slices/scheduleSlice';
import { createPaneSlice } from '../../../src/renderer/store/slices/paneSlice';
import { createProjectSlice } from '../../../src/renderer/store/slices/projectSlice';
import { createRepositorySlice } from '../../../src/renderer/store/slices/repositorySlice';
@ -51,6 +52,7 @@ export function createTestStore() {
...createChangeReviewSlice(...args),
...createCliInstallerSlice(...args),
...createEditorSlice(...args),
...createScheduleSlice(...args),
}));
}

View file

@ -48,6 +48,10 @@ vi.mock('@renderer/api', () => ({
getAllTasks: vi.fn(async () => []),
list: vi.fn(async () => []),
},
schedules: {
list: vi.fn(async () => []),
onScheduleChange: vi.fn(() => () => undefined),
},
},
}));