From 0347b23429780b7a0fbab61c9ac8d3868ce74af2 Mon Sep 17 00:00:00 2001 From: realDuang <250407778@qq.com> Date: Thu, 26 Mar 2026 11:34:17 +0800 Subject: [PATCH 01/28] fix: kill CLI subprocesses before shutdown to prevent NAPI crash MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit During app exit, session.close() uses unref'd timers to schedule subprocess termination, which means app.exit(0) tears down NAPI modules while the CLI subprocess is still alive and sending callbacks through its NAPI threadsafe function — causing a fatal napi_ref_threadsafe_function crash during node::FreeEnvironment. Fix by directly SIGTERM-ing the subprocess and awaiting its exit before calling session.close(), ensuring no NAPI callbacks are in-flight when the native module is destroyed. Also stop @parcel/watcher file watchers early in the will-quit handler to prevent its NAPI threadsafe functions from firing during teardown. Co-Authored-By: Claude Opus 4.6 --- electron/main/engines/claude/index.ts | 45 +++++++++++++++++++++++++-- electron/main/index.ts | 5 +++ 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/electron/main/engines/claude/index.ts b/electron/main/engines/claude/index.ts index b2ac9367..52439d3a 100644 --- a/electron/main/engines/claude/index.ts +++ b/electron/main/engines/claude/index.ts @@ -321,14 +321,47 @@ export class ClaudeCodeAdapter extends EngineAdapter { await Promise.race([Promise.allSettled(interruptPromises), hardTimeout]); } - // Now close all V2 sessions cleanly + // Now close all V2 sessions. + // + // session.close() internally schedules abort after 5s via setTimeout().unref(), + // and transport.close() then schedules SIGTERM after another 2s (also .unref()). + // Since .unref() timers don't prevent the event loop from exiting, app.exit(0) + // tears down the NAPI modules while the CLI subprocess is still alive and + // sending callbacks through its NAPI threadsafe function — causing the fatal + // "napi_ref_threadsafe_function" crash during node::FreeEnvironment. + // + // Fix: directly kill the subprocess BEFORE calling session.close(), then wait + // for it to actually exit. This guarantees no NAPI callbacks are in flight + // when app.exit(0) destroys the native module. + const exitPromises: Promise[] = []; for (const [sessionId, info] of this.v2Sessions) { try { + const transport = (info.session as any)?.query?.transport; + const proc = transport?.process as import("child_process").ChildProcess | undefined; + if (proc && proc.exitCode === null && !proc.killed) { + const exitPromise = new Promise((resolve) => { + proc.once("exit", () => resolve()); + // Safety: resolve anyway after 3s if the process doesn't exit + setTimeout(resolve, 3000).unref(); + }); + proc.kill("SIGTERM"); + exitPromises.push(exitPromise); + claudeLog.info(`[Claude][${sessionId}] Sent SIGTERM to CLI subprocess (pid=${proc.pid})`); + } info.session.close(); } catch (e) { claudeLog.warn(`Error closing Claude session ${sessionId}:`, e); } } + + // Wait for all CLI subprocesses to actually exit before returning. + // This ensures no NAPI threadsafe function callbacks are pending when + // app.exit(0) destroys the native module environment. + if (exitPromises.length > 0) { + await Promise.all(exitPromises); + claudeLog.info("All CLI subprocesses exited"); + } + this.v2Sessions.clear(); // Abort any remaining active requests (sessions without V2 info) @@ -1182,9 +1215,15 @@ export class ClaudeCodeAdapter extends EngineAdapter { return new Promise((resolve) => { this.pendingQuestions.set(questionId, { resolve: (answer: string) => { + const trimmed = answer.trim(); + const lower = trimmed.toLowerCase(); const approved = - answer.toLowerCase().includes("approve") || - answer === "0"; // first option index + lower.includes("approve") || + trimmed.includes("同意") || + trimmed.includes("批准") || + trimmed.includes("确认") || + trimmed === "1" || // 1-based: first option = Approve (Feishu/DingTalk display) + trimmed === "0"; // 0-based: backward compat with frontend UI if (approved) { resolve({ behavior: "allow", updatedInput: input }); } else { diff --git a/electron/main/index.ts b/electron/main/index.ts index 48e89328..984a4703 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -1,6 +1,7 @@ import { app, BrowserWindow } from "electron"; import fixPath from "fix-path"; import { mainLog } from "./services/logger"; +import { unwatchAll } from "./services/file-service"; // dev restart trigger // Fix $PATH for packaged macOS/Linux apps launched from GUI. @@ -249,6 +250,10 @@ if (!gotTheLock) { try { trayManager.destroy(); + // Stop native file watchers early — @parcel/watcher uses NAPI threadsafe + // functions that must be torn down before Node.js module cleanup begins. + unwatchAll(); + // Flush conversation store before quit await conversationStore.flushAll(); From 1c8543ecc1f8739d8a961fc83f46300ca8f2d35f Mon Sep 17 00:00:00 2001 From: realDuang <250407778@qq.com> Date: Thu, 26 Mar 2026 13:01:24 +0800 Subject: [PATCH 02/28] feat: add desktop-level scheduled tasks with full lifecycle management Implement persistent scheduled tasks that survive app restarts, matching Claude Desktop's local scheduled task capability. Features include: - Backend ScheduledTaskService with disk persistence, CRUD operations, interval/daily/weekly scheduling, missed-run catch-up (7-day window), deterministic jitter, and auto-approve permissions - Gateway WebSocket API (6 request types + 3 notification types) - Frontend sidebar section with countdown timer, action buttons, and collapsible animation using CSS grid-template-rows trick - Create/edit modal with frequency presets (5m-12h intervals, daily, weekly multi-day select) - Graceful shutdown with running task wait (5s timeout) and autoApproveSessions size-limit fallback to prevent memory leak - Notification toast repositioned to bottom-right to avoid title bar - Projects section title for clear visual separation in sidebar - i18n support for English, Chinese, and Russian Co-Authored-By: Claude Opus 4.6 --- electron/main/gateway/ws-server.ts | 43 ++ electron/main/index.ts | 6 + electron/main/services/logger.ts | 1 + .../main/services/scheduled-task-service.ts | 583 ++++++++++++++++++ src/components/NotificationToast.tsx | 8 +- src/components/ScheduledTaskModal.tsx | 485 +++++++++++++++ src/components/ScheduledTaskSection.tsx | 361 +++++++++++ src/components/SessionSidebar.tsx | 48 +- src/index.css | 16 + src/lib/gateway-api.ts | 45 ++ src/lib/gateway-client.ts | 35 ++ src/locales/en.ts | 97 +++ src/locales/ru.ts | 48 ++ src/locales/zh.ts | 48 ++ src/pages/Chat.tsx | 83 ++- src/stores/scheduled-task.ts | 13 + src/types/unified.ts | 76 +++ 17 files changed, 1986 insertions(+), 10 deletions(-) create mode 100644 electron/main/services/scheduled-task-service.ts create mode 100644 src/components/ScheduledTaskModal.tsx create mode 100644 src/components/ScheduledTaskSection.tsx create mode 100644 src/stores/scheduled-task.ts diff --git a/electron/main/gateway/ws-server.ts b/electron/main/gateway/ws-server.ts index eeb378ec..d4844393 100644 --- a/electron/main/gateway/ws-server.ts +++ b/electron/main/gateway/ws-server.ts @@ -16,6 +16,7 @@ import { import { gatewayLog } from "../services/logger"; import log from "../services/logger"; import { conversationStore } from "../services/conversation-store"; +import { scheduledTaskService } from "../services/scheduled-task-service"; import { GatewayRequestType, GatewayNotificationType, @@ -32,6 +33,8 @@ import { type ModeSetRequest, type SessionImportPreviewRequest, type SessionImportExecuteRequest, + type ScheduledTaskCreateRequest, + type ScheduledTaskUpdateRequest, } from "../../../src/types/unified"; interface ClientConnection { @@ -391,6 +394,26 @@ export class GatewayServer { return { success: true }; } + // Scheduled Tasks + case GatewayRequestType.SCHEDULED_TASK_LIST: + return scheduledTaskService.list(); + + case GatewayRequestType.SCHEDULED_TASK_GET: + return scheduledTaskService.get(p.id); + + case GatewayRequestType.SCHEDULED_TASK_CREATE: + return scheduledTaskService.create(p as ScheduledTaskCreateRequest); + + case GatewayRequestType.SCHEDULED_TASK_UPDATE: + return scheduledTaskService.update(p as ScheduledTaskUpdateRequest); + + case GatewayRequestType.SCHEDULED_TASK_DELETE: + scheduledTaskService.delete(p.id); + return { success: true }; + + case GatewayRequestType.SCHEDULED_TASK_RUN_NOW: + return scheduledTaskService.runNow(p.id); + default: throw Object.assign( new Error(`Unknown request type: ${type}`), @@ -487,6 +510,26 @@ export class GatewayServer { payload: data, }); }); + + // Scheduled Task events + scheduledTaskService.on("task.fired", (data) => { + this.broadcast({ + type: GatewayNotificationType.SCHEDULED_TASK_FIRED, + payload: data, + }); + }); + scheduledTaskService.on("task.failed", (data) => { + this.broadcast({ + type: GatewayNotificationType.SCHEDULED_TASK_FAILED, + payload: data, + }); + }); + scheduledTaskService.on("tasks.changed", (data) => { + this.broadcast({ + type: GatewayNotificationType.SCHEDULED_TASKS_CHANGED, + payload: data, + }); + }); } private broadcast(notification: GatewayNotification): void { diff --git a/electron/main/index.ts b/electron/main/index.ts index 984a4703..62248799 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -41,6 +41,7 @@ import { WeComAdapter } from "./channels/wecom/wecom-adapter"; import { TeamsAdapter } from "./channels/teams/teams-adapter"; import { updateManager } from "./services/update-manager"; import { trayManager } from "./services/tray-manager"; +import { scheduledTaskService } from "./services/scheduled-task-service"; import { ensureDefaultWorkspace } from "./services/default-workspace"; import { GATEWAY_PORT, OPENCODE_PORT, WEBHOOK_PORT, WEB_PORT } from "../../shared/ports"; @@ -113,6 +114,9 @@ if (!gotTheLock) { // Rebuild engine routing tables from persisted ConversationStore data engineManager.initFromStore(); + // Initialize scheduled task service (persistent desktop-level scheduled tasks) + scheduledTaskService.init(engineManager); + // Register IPC handlers registerIpcHandlers(); @@ -241,6 +245,7 @@ if (!gotTheLock) { if (updateManager.isInstallingUpdate()) { trayManager.destroy(); await conversationStore.flushAll(); + await scheduledTaskService.shutdown(); gatewayServer.stop(); return; } @@ -263,6 +268,7 @@ if (!gotTheLock) { webhookServer.stop(), engineManager.stopAll(), productionServer.stop(), + scheduledTaskService.shutdown(), ]); gatewayServer.stop(); diff --git a/electron/main/services/logger.ts b/electron/main/services/logger.ts index 9aa0e19e..e150bb2f 100644 --- a/electron/main/services/logger.ts +++ b/electron/main/services/logger.ts @@ -147,6 +147,7 @@ export const dingtalkLog = log.scope("dingtalk"); export const telegramLog = log.scope("telegram"); export const wecomLog = log.scope("wecom"); export const teamsLog = log.scope("teams"); +export const scheduledTaskLog = log.scope("sched-task"); // Re-export the root logger for ad-hoc usage and renderer log forwarding export default log; diff --git a/electron/main/services/scheduled-task-service.ts b/electron/main/services/scheduled-task-service.ts new file mode 100644 index 00000000..808997a8 --- /dev/null +++ b/electron/main/services/scheduled-task-service.ts @@ -0,0 +1,583 @@ +// ============================================================================ +// Desktop-level Scheduled Task Service +// Persistent scheduled tasks that survive app restarts. +// Each trigger creates a new session (never reuses existing ones). +// Permissions are auto-approved (same pattern as channel adapters). +// ============================================================================ + +import { EventEmitter } from "events"; +import { randomUUID } from "crypto"; +import { app, Notification } from "electron"; +import path from "node:path"; +import fs from "node:fs"; +import { scheduledTaskLog } from "./logger"; +import type { EngineManager } from "../gateway/engine-manager"; +import type { + ScheduledTask, + ScheduledTaskCreateRequest, + ScheduledTaskUpdateRequest, + ScheduledTaskRunResult, + ScheduledTaskFrequency, + EngineType, +} from "../../../src/types/unified"; + +/** Max setTimeout value (~24.8 days). Timers longer than this overflow to 1. */ +const MAX_TIMEOUT = 2_147_483_647; + +/** Maximum number of run history entries kept per task. */ +const MAX_RUN_HISTORY = 50; + +/** Missed run catch-up window (7 days). */ +const MISSED_RUN_WINDOW_MS = 7 * 24 * 60 * 60 * 1000; + +/** Debounce delay for persisting to disk. */ +const SAVE_DEBOUNCE_MS = 500; + +/** Maximum jitter offset (10 minutes). */ +const MAX_JITTER_MS = 10 * 60 * 1000; + +// --------------------------------------------------------------------------- +// Persistence file format +// --------------------------------------------------------------------------- + +interface TaskFileFormat { + version: 1; + tasks: ScheduledTask[]; +} + +// --------------------------------------------------------------------------- +// Service +// --------------------------------------------------------------------------- + +export class ScheduledTaskService extends EventEmitter { + private tasks = new Map(); + private timers = new Map>(); + private engineManager: EngineManager | null = null; + private saveTimer: ReturnType | null = null; + private initialized = false; + /** Session IDs created by scheduled tasks — auto-approve permissions for these. */ + private autoApproveSessions = new Set(); + /** Task IDs currently being executed (for graceful shutdown). */ + private runningTasks = new Set(); + + // --- Lifecycle ------------------------------------------------------- + + /** + * Initialize the service. + * Must be called after `app.whenReady()` and after `engineManager.initFromStore()`. + */ + init(engineManager: EngineManager): void { + if (this.initialized) return; + this.engineManager = engineManager; + this.loadFromDisk(); + this.initialized = true; + + // Subscribe to permission events for auto-approval + this.subscribePermissionAutoApprove(); + + // Schedule all enabled non-manual tasks + for (const task of this.tasks.values()) { + if (task.enabled && task.frequency.type !== "manual") { + this.scheduleTask(task); + } + } + + // Check for missed runs + this.checkMissedRuns(); + + scheduledTaskLog.info(`Initialized with ${this.tasks.size} task(s)`); + } + + /** Graceful shutdown: clear timers, wait for running tasks, flush pending writes. */ + async shutdown(): Promise { + // Clear all scheduling timers (prevent new triggers) + for (const [id, timer] of this.timers.entries()) { + clearTimeout(timer); + this.timers.delete(id); + } + + // Wait for currently executing tasks to finish (max 5 seconds) + if (this.runningTasks.size > 0) { + scheduledTaskLog.info( + `Waiting for ${this.runningTasks.size} running task(s) to finish...`, + ); + const deadline = Date.now() + 5000; + while (this.runningTasks.size > 0 && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 100)); + } + if (this.runningTasks.size > 0) { + scheduledTaskLog.warn( + `${this.runningTasks.size} task(s) still running at shutdown, proceeding anyway`, + ); + } + } + + // Flush pending save + if (this.saveTimer) { + clearTimeout(this.saveTimer); + this.saveTimer = null; + this.writeToDisk(); + } + + this.autoApproveSessions.clear(); + this.runningTasks.clear(); + this.initialized = false; + scheduledTaskLog.info("Shut down"); + } + + // --- Auto-approve permissions (same pattern as channel adapters) ------ + + private subscribePermissionAutoApprove(): void { + if (!this.engineManager) return; + + this.engineManager.on("permission.asked", (data: any) => { + const permission = data.permission ?? data; + const sessionId = permission.sessionId; + if (!sessionId || !this.autoApproveSessions.has(sessionId)) return; + + // Find an accept/allow option + const acceptOption = permission.options?.find( + (o: any) => + o.type?.includes("accept") || + o.type?.includes("allow") || + o.label?.toLowerCase().includes("allow"), + ); + + if (acceptOption) { + scheduledTaskLog.info(`Auto-approving permission ${permission.id} for session ${sessionId}`); + this.engineManager!.replyPermission(permission.id, { optionId: acceptOption.id }); + } + }); + } + + // --- CRUD ----------------------------------------------------------- + + list(): ScheduledTask[] { + return Array.from(this.tasks.values()); + } + + get(id: string): ScheduledTask | null { + return this.tasks.get(id) ?? null; + } + + create(req: ScheduledTaskCreateRequest): ScheduledTask { + const jitterMs = this.computeJitter(req.name); + const now = Date.now(); + + const task: ScheduledTask = { + id: randomUUID(), + name: req.name, + description: req.description, + prompt: req.prompt, + engineType: req.engineType, + directory: req.directory, + frequency: req.frequency, + enabled: req.enabled ?? true, + jitterMs, + createdAt: now, + lastRunAt: null, + nextRunAt: null, + runHistory: [], + }; + + // Compute nextRunAt for non-manual tasks + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, now); + } + + this.tasks.set(task.id, task); + this.scheduleSave(); + this.emitChanged(); + + // Schedule if enabled and non-manual + if (task.enabled && task.frequency.type !== "manual") { + this.scheduleTask(task); + } + + scheduledTaskLog.info(`Created task "${task.name}" (${task.id})`); + return task; + } + + update(req: ScheduledTaskUpdateRequest): ScheduledTask { + const task = this.tasks.get(req.id); + if (!task) { + throw Object.assign(new Error(`Task not found: ${req.id}`), { code: "NOT_FOUND" }); + } + + // Apply partial updates + if (req.name !== undefined) task.name = req.name; + if (req.description !== undefined) task.description = req.description; + if (req.prompt !== undefined) task.prompt = req.prompt; + if (req.engineType !== undefined) task.engineType = req.engineType; + if (req.directory !== undefined) task.directory = req.directory; + if (req.enabled !== undefined) task.enabled = req.enabled; + + // Recompute jitter if name changed + if (req.name !== undefined) { + task.jitterMs = this.computeJitter(req.name); + } + + // Reschedule if frequency or enabled changed + const frequencyChanged = req.frequency !== undefined; + if (frequencyChanged) { + task.frequency = req.frequency!; + } + + // Clear existing timer + this.clearTaskTimer(task.id); + + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, Date.now()); + this.scheduleTask(task); + } else { + task.nextRunAt = null; + } + + this.scheduleSave(); + this.emitChanged(); + scheduledTaskLog.info(`Updated task "${task.name}" (${task.id})`); + return task; + } + + delete(id: string): void { + const task = this.tasks.get(id); + if (!task) { + throw Object.assign(new Error(`Task not found: ${id}`), { code: "NOT_FOUND" }); + } + + this.clearTaskTimer(id); + this.tasks.delete(id); + this.scheduleSave(); + this.emitChanged(); + scheduledTaskLog.info(`Deleted task "${task.name}" (${id})`); + } + + // --- Execution ------------------------------------------------------ + + async runNow(taskId: string): Promise { + const task = this.tasks.get(taskId); + if (!task) { + throw Object.assign(new Error(`Task not found: ${taskId}`), { code: "NOT_FOUND" }); + } + + return this.executeTask(task); + } + + private async executeTask(task: ScheduledTask): Promise { + if (!this.engineManager) { + throw new Error("ScheduledTaskService not initialized"); + } + + scheduledTaskLog.info(`Executing task "${task.name}" (${task.id})`); + this.runningTasks.add(task.id); + + try { + // 1. Create a new session + const session = await this.engineManager.createSession( + task.engineType as EngineType, + task.directory, + ); + + // 2. Register session for auto-approve (with size limit fallback) + if (this.autoApproveSessions.size > 200) { + // Keep only the most recent 100 entries (Set preserves insertion order) + const recent = [...this.autoApproveSessions].slice(-100); + this.autoApproveSessions.clear(); + for (const id of recent) this.autoApproveSessions.add(id); + } + this.autoApproveSessions.add(session.id); + + // 3. Send the prompt as the first message + await this.engineManager.sendMessage(session.id, [ + { type: "text", text: task.prompt }, + ]); + + // 4. Update task state + task.lastRunAt = Date.now(); + task.runHistory.unshift(session.id); + if (task.runHistory.length > MAX_RUN_HISTORY) { + task.runHistory = task.runHistory.slice(0, MAX_RUN_HISTORY); + } + + // 5. Reschedule next run + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, Date.now()); + this.scheduleTask(task); + } + + this.scheduleSave(); + this.emit("task.fired", { taskId: task.id, conversationId: session.id }); + this.emitChanged(); + + // Desktop notification + this.showNotification( + `Scheduled task "${task.name}" started`, + `New session created for: ${task.prompt.slice(0, 100)}`, + ); + + return { taskId: task.id, conversationId: session.id }; + } catch (err: any) { + scheduledTaskLog.error(`Task "${task.name}" execution failed:`, err); + this.emit("task.failed", { taskId: task.id, error: err.message }); + + this.showNotification( + `Scheduled task "${task.name}" failed`, + err.message ?? "Unknown error", + ); + + throw err; + } finally { + this.runningTasks.delete(task.id); + } + } + + // --- Scheduling ----------------------------------------------------- + + private scheduleTask(task: ScheduledTask): void { + this.clearTaskTimer(task.id); + + if (!task.enabled || task.frequency.type === "manual" || task.nextRunAt === null) { + return; + } + + const delay = Math.max(0, task.nextRunAt - Date.now()); + + // Handle setTimeout overflow (max ~24.8 days) + if (delay > MAX_TIMEOUT) { + const timer = setTimeout(() => { + this.scheduleTask(task); + }, MAX_TIMEOUT); + this.timers.set(task.id, timer); + return; + } + + const timer = setTimeout(async () => { + this.timers.delete(task.id); + try { + await this.executeTask(task); + } catch { + // Error already logged and emitted in executeTask + // Still reschedule next run + if (task.enabled && task.frequency.type !== "manual") { + task.nextRunAt = this.computeNextRun(task.frequency, task.jitterMs, Date.now()); + this.scheduleTask(task); + this.scheduleSave(); + } + } + }, delay); + + this.timers.set(task.id, timer); + scheduledTaskLog.info( + `Scheduled task "${task.name}" next run in ${Math.round(delay / 1000)}s`, + ); + } + + private clearTaskTimer(id: string): void { + const timer = this.timers.get(id); + if (timer) { + clearTimeout(timer); + this.timers.delete(id); + } + } + + // --- Next-run computation ------------------------------------------- + + /** + * Compute the next run timestamp for a given frequency. + * @param frequency The task frequency configuration + * @param jitterMs Deterministic jitter offset in ms + * @param afterMs Compute the next run after this timestamp (usually Date.now()) + */ + computeNextRun( + frequency: ScheduledTaskFrequency, + jitterMs: number, + afterMs: number, + ): number | null { + if (frequency.type === "manual") return null; + + switch (frequency.type) { + case "interval": { + const intervalMs = (frequency.intervalMinutes ?? 60) * 60_000; + // Next run = afterMs + interval + jitter (capped to not exceed interval) + const cappedJitter = Math.min(jitterMs, intervalMs * 0.1); + return afterMs + intervalMs + cappedJitter; + } + + case "daily": { + const hour = frequency.hour ?? 9; + const minute = frequency.minute ?? 0; + const next = new Date(afterMs); + next.setHours(hour, minute, 0, 0); + let ts = next.getTime() + jitterMs; + if (ts <= afterMs) { + next.setDate(next.getDate() + 1); + ts = next.getTime() + jitterMs; + } + return ts; + } + + case "weekly": { + const hour = frequency.hour ?? 9; + const minute = frequency.minute ?? 0; + const targetDays = frequency.daysOfWeek ?? [1]; // Default Monday + + if (targetDays.length === 0) return null; + + // Find the earliest next occurrence among the target days + const candidates: number[] = []; + for (const targetDay of targetDays) { + const next = new Date(afterMs); + next.setHours(hour, minute, 0, 0); + + const currentDay = next.getDay(); + let daysUntil = (targetDay - currentDay + 7) % 7; + if (daysUntil === 0) { + // Same day — check if the time has passed + const ts = next.getTime() + jitterMs; + if (ts <= afterMs) { + daysUntil = 7; + } + } + next.setDate(next.getDate() + daysUntil); + candidates.push(new Date(next).setHours(hour, minute, 0, 0) + jitterMs); + } + + return Math.min(...candidates); + } + + default: + return null; + } + } + + // --- Jitter --------------------------------------------------------- + + /** + * Compute a deterministic jitter value (0–600000 ms) from the task name. + * Uses a simple hash so the same name always gets the same offset. + */ + private computeJitter(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + const ch = name.charCodeAt(i); + hash = ((hash << 5) - hash + ch) | 0; + } + return Math.abs(hash) % MAX_JITTER_MS; + } + + // --- Missed-run catch-up ------------------------------------------- + + /** + * On startup, check each task for missed runs within the 7-day window. + * If a task should have run while the app was offline, execute it once now. + */ + private checkMissedRuns(): void { + const now = Date.now(); + + for (const task of this.tasks.values()) { + if (!task.enabled || task.frequency.type === "manual") continue; + + const lastRun = task.lastRunAt ?? task.createdAt; + const expectedNext = this.computeNextRun(task.frequency, task.jitterMs, lastRun); + + if (expectedNext === null) continue; + + // If the expected next run is in the past but within the 7-day window + if (expectedNext < now && (now - expectedNext) < MISSED_RUN_WINDOW_MS) { + scheduledTaskLog.info( + `Missed run detected for "${task.name}" (expected at ${new Date(expectedNext).toISOString()})`, + ); + + this.executeTask(task).catch((err) => { + scheduledTaskLog.error(`Missed-run catch-up failed for "${task.name}":`, err); + }); + } + } + } + + // --- Persistence ---------------------------------------------------- + + private getFilePath(): string { + return path.join(app.getPath("userData"), "scheduled-tasks.json"); + } + + private loadFromDisk(): void { + const filePath = this.getFilePath(); + try { + if (!fs.existsSync(filePath)) { + scheduledTaskLog.info("No scheduled-tasks.json found, starting empty"); + return; + } + + const raw = fs.readFileSync(filePath, "utf-8"); + const data: TaskFileFormat = JSON.parse(raw); + + if (data.version !== 1 || !Array.isArray(data.tasks)) { + scheduledTaskLog.warn("Invalid scheduled-tasks.json format, ignoring"); + return; + } + + for (const task of data.tasks) { + this.tasks.set(task.id, task); + } + + scheduledTaskLog.info(`Loaded ${data.tasks.length} task(s) from disk`); + } catch (err) { + scheduledTaskLog.error("Failed to load scheduled-tasks.json:", err); + } + } + + private writeToDisk(): void { + const filePath = this.getFilePath(); + const data: TaskFileFormat = { + version: 1, + tasks: Array.from(this.tasks.values()), + }; + + try { + const dir = path.dirname(filePath); + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir, { recursive: true }); + } + + // Atomic write: write to .tmp then rename + const tmpPath = `${filePath}.tmp`; + fs.writeFileSync(tmpPath, JSON.stringify(data, null, 2), "utf-8"); + fs.renameSync(tmpPath, filePath); + } catch (err) { + scheduledTaskLog.error("Failed to write scheduled-tasks.json:", err); + } + } + + /** Debounced save — coalesces rapid changes into one disk write. */ + private scheduleSave(): void { + if (this.saveTimer) { + clearTimeout(this.saveTimer); + } + this.saveTimer = setTimeout(() => { + this.saveTimer = null; + this.writeToDisk(); + }, SAVE_DEBOUNCE_MS); + } + + // --- Notifications -------------------------------------------------- + + private showNotification(title: string, body: string): void { + try { + if (Notification.isSupported()) { + new Notification({ title, body }).show(); + } + } catch (err) { + scheduledTaskLog.warn("Failed to show notification:", err); + } + } + + // --- Events --------------------------------------------------------- + + private emitChanged(): void { + this.emit("tasks.changed", { tasks: this.list() }); + } +} + +// Singleton +export const scheduledTaskService = new ScheduledTaskService(); diff --git a/src/components/NotificationToast.tsx b/src/components/NotificationToast.tsx index c2998795..5bf3780e 100644 --- a/src/components/NotificationToast.tsx +++ b/src/components/NotificationToast.tsx @@ -1,6 +1,6 @@ /** * NotificationToast — renders stacked toast notifications. - * Positioned at the top-right of the viewport. + * Positioned at the bottom-right of the viewport. */ import { For } from "solid-js"; @@ -22,7 +22,7 @@ const typeIcons: Record = { export function NotificationToast() { const { t } = useI18n(); return ( -
+
{(n) => (
diff --git a/src/components/ScheduledTaskModal.tsx b/src/components/ScheduledTaskModal.tsx new file mode 100644 index 00000000..487a974b --- /dev/null +++ b/src/components/ScheduledTaskModal.tsx @@ -0,0 +1,485 @@ +import { createSignal, createEffect, Show, For } from "solid-js"; +import { useI18n } from "../lib/i18n"; +import { isElectron } from "../lib/platform"; +import { systemAPI } from "../lib/electron-api"; +import type { + ScheduledTask, + ScheduledTaskCreateRequest, + ScheduledTaskUpdateRequest, + ScheduledTaskFrequencyType, + DayOfWeek, + UnifiedProject, + EngineInfo, +} from "../types/unified"; + +interface ScheduledTaskModalProps { + isOpen: boolean; + editingTask?: ScheduledTask; + projects: UnifiedProject[]; + engines: EngineInfo[]; + onClose: () => void; + onSave: (req: ScheduledTaskCreateRequest | ScheduledTaskUpdateRequest) => Promise; +} + +export function ScheduledTaskModal(props: ScheduledTaskModalProps) { + const { t } = useI18n(); + + // Form state + const [name, setName] = createSignal(""); + const [description, setDescription] = createSignal(""); + const [prompt, setPrompt] = createSignal(""); + const [engineType, setEngineType] = createSignal(""); + const [directory, setDirectory] = createSignal(""); + const [frequencyType, setFrequencyType] = createSignal("daily"); + const [intervalMinutes, setIntervalMinutes] = createSignal(60); + const [hour, setHour] = createSignal(9); + const [minute, setMinute] = createSignal(0); + const [daysOfWeek, setDaysOfWeek] = createSignal([1]); // Monday + const [enabled, setEnabled] = createSignal(true); + + const [loading, setLoading] = createSignal(false); + const [error, setError] = createSignal(null); + + // Reset form when modal opens/closes or editing task changes + createEffect(() => { + if (props.isOpen) { + const task = props.editingTask; + if (task) { + setName(task.name); + setDescription(task.description); + setPrompt(task.prompt); + setEngineType(task.engineType); + setDirectory(task.directory); + setFrequencyType(task.frequency.type); + setIntervalMinutes(task.frequency.intervalMinutes ?? 60); + setHour(task.frequency.hour ?? 9); + setMinute(task.frequency.minute ?? 0); + setDaysOfWeek(task.frequency.daysOfWeek ?? [1]); + setEnabled(task.enabled); + } else { + // Create mode defaults + setName(""); + setDescription(""); + setPrompt(""); + setEngineType(props.engines.find(e => e.status === "running")?.type || props.engines[0]?.type || ""); + setDirectory(props.projects[0]?.directory || ""); + setFrequencyType("daily"); + setIntervalMinutes(60); + setHour(9); + setMinute(0); + setDaysOfWeek([1]); + setEnabled(true); + } + setError(null); + } + }); + + const isEdit = () => !!props.editingTask; + + const showTimeFields = () => + frequencyType() === "daily" || frequencyType() === "weekly"; + + const showDaysOfWeek = () => frequencyType() === "weekly"; + const showInterval = () => frequencyType() === "interval"; + + const handleSave = async () => { + if (!name().trim()) { + setError(t().scheduledTask.name + " is required"); + return; + } + if (!prompt().trim()) { + setError(t().scheduledTask.prompt + " is required"); + return; + } + if (!engineType()) { + setError(t().scheduledTask.engineType + " is required"); + return; + } + if (!directory().trim()) { + setError(t().scheduledTask.directory + " is required"); + return; + } + if (frequencyType() === "weekly" && daysOfWeek().length === 0) { + setError(t().scheduledTask.dayOfWeek + " is required"); + return; + } + + setLoading(true); + setError(null); + + try { + const frequency: any = { type: frequencyType() }; + if (showInterval()) { + frequency.intervalMinutes = intervalMinutes(); + } + if (showTimeFields()) { + frequency.hour = hour(); + frequency.minute = minute(); + } + if (showDaysOfWeek()) { + frequency.daysOfWeek = [...daysOfWeek()].sort(); + } + + if (isEdit()) { + const req: ScheduledTaskUpdateRequest = { + id: props.editingTask!.id, + name: name().trim(), + description: description().trim(), + prompt: prompt().trim(), + engineType: engineType() as any, + directory: directory().trim(), + frequency, + enabled: enabled(), + }; + await props.onSave(req); + } else { + const req: ScheduledTaskCreateRequest = { + name: name().trim(), + description: description().trim(), + prompt: prompt().trim(), + engineType: engineType() as any, + directory: directory().trim(), + frequency, + enabled: enabled(), + }; + await props.onSave(req); + } + props.onClose(); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + setError(msg); + } finally { + setLoading(false); + } + }; + + const handleClose = () => { + setError(null); + props.onClose(); + }; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") handleClose(); + }; + + const handleBrowseDirectory = async () => { + const selected = await systemAPI.selectDirectory(); + if (selected) setDirectory(selected); + }; + + const toggleDayOfWeek = (day: DayOfWeek) => { + setDaysOfWeek((prev) => { + if (prev.includes(day)) { + return prev.filter((d) => d !== day); + } + return [...prev, day]; + }); + }; + + // Options + const hours = Array.from({ length: 24 }, (_, i) => i); + const minutes = Array.from({ length: 12 }, (_, i) => i * 5); + + const intervalOptions = [ + { value: 5, label: () => t().scheduledTask.interval5m }, + { value: 10, label: () => t().scheduledTask.interval10m }, + { value: 30, label: () => t().scheduledTask.interval30m }, + { value: 60, label: () => t().scheduledTask.interval1h }, + { value: 120, label: () => t().scheduledTask.interval2h }, + { value: 360, label: () => t().scheduledTask.interval6h }, + { value: 720, label: () => t().scheduledTask.interval12h }, + ]; + + const frequencyOptions: { value: ScheduledTaskFrequencyType; label: () => string }[] = [ + { value: "manual", label: () => t().scheduledTask.frequencyManual }, + { value: "interval", label: () => t().scheduledTask.frequencyInterval }, + { value: "daily", label: () => t().scheduledTask.frequencyDaily }, + { value: "weekly", label: () => t().scheduledTask.frequencyWeekly }, + ]; + + // Shared input classes + const inputClass = "w-full px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-lg text-gray-900 dark:text-white placeholder-gray-400 focus:outline-none focus:ring-1 focus:ring-blue-500"; + const selectClass = "w-full px-3 py-1.5 text-sm bg-white dark:bg-slate-800 border border-gray-300 dark:border-slate-600 rounded-lg text-gray-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-blue-500"; + const labelClass = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1"; + + return ( + +
+