Merge branch 'worktree-schedule-feature' into dev
This commit is contained in:
commit
ecded5a799
40 changed files with 5287 additions and 120 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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
204
src/main/ipc/schedule.ts
Normal 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');
|
||||
}
|
||||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -15,3 +15,4 @@ export * from './error';
|
|||
export * from './infrastructure';
|
||||
export * from './parsing';
|
||||
export * from './team';
|
||||
export * from './schedule';
|
||||
|
|
|
|||
206
src/main/services/schedule/JsonScheduleRepository.ts
Normal file
206
src/main/services/schedule/JsonScheduleRepository.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
24
src/main/services/schedule/ScheduleRepository.ts
Normal file
24
src/main/services/schedule/ScheduleRepository.ts
Normal 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 }>;
|
||||
}
|
||||
224
src/main/services/schedule/ScheduledTaskExecutor.ts
Normal file
224
src/main/services/schedule/ScheduledTaskExecutor.ts
Normal 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 });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
850
src/main/services/schedule/SchedulerService.ts
Normal file
850
src/main/services/schedule/SchedulerService.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
14
src/main/services/schedule/index.ts
Normal file
14
src/main/services/schedule/index.ts
Normal 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';
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
142
src/main/utils/shellEnv.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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' },
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 () => {};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
254
src/renderer/components/team/schedule/CronScheduleInput.tsx
Normal file
254
src/renderer/components/team/schedule/CronScheduleInput.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
446
src/renderer/components/team/schedule/ScheduleDialog.tsx
Normal file
446
src/renderer/components/team/schedule/ScheduleDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
15
src/renderer/components/team/schedule/ScheduleEmptyState.tsx
Normal file
15
src/renderer/components/team/schedule/ScheduleEmptyState.tsx
Normal 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>
|
||||
);
|
||||
241
src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
Normal file
241
src/renderer/components/team/schedule/ScheduleRunLogDialog.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
107
src/renderer/components/team/schedule/ScheduleRunRow.tsx
Normal file
107
src/renderer/components/team/schedule/ScheduleRunRow.tsx
Normal 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>
|
||||
);
|
||||
352
src/renderer/components/team/schedule/ScheduleSection.tsx
Normal file
352
src/renderer/components/team/schedule/ScheduleSection.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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>;
|
||||
};
|
||||
|
|
@ -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
|
||||
|
|
|
|||
177
src/renderer/store/slices/scheduleSlice.ts
Normal file
177
src/renderer/store/slices/scheduleSlice.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
118
src/shared/types/schedule.ts
Normal file
118
src/shared/types/schedule.ts
Normal 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>;
|
||||
}
|
||||
268
test/main/services/schedule/JsonScheduleRepository.test.ts
Normal file
268
test/main/services/schedule/JsonScheduleRepository.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
476
test/main/services/schedule/ScheduledTaskExecutor.test.ts
Normal file
476
test/main/services/schedule/ScheduledTaskExecutor.test.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
817
test/main/services/schedule/SchedulerService.test.ts
Normal file
817
test/main/services/schedule/SchedulerService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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)) {
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue