/** * Cron Task Service * * Manages user-defined recurring tasks that run on cron schedules. * Unlike scheduled messages (agent-created/cancellable), cron tasks * can only be cancelled by user or orchestrator. * * Storage: Per-team at ~/.crewly/teams/{teamId}/cron-tasks.json * Migration: Global ~/.crewly/cron-tasks.json is auto-migrated on first startup. * * @module services/workflow/cron-task.service */ import type { CronTask, CreateCronTaskRequest, UpdateCronTaskRequest } from '../../types/cron-task.types.js'; /** * Extract date/time parts from a Date in a specific IANA timezone. * * Uses Intl.DateTimeFormat to convert the UTC instant into the target * timezone, then extracts numeric minute, hour, dayOfWeek, dayOfMonth, * and month fields. * * @param date - The Date object (represents a UTC instant) * @param tz - IANA timezone string (e.g. "America/New_York", "Asia/Shanghai") * @returns Object with minute, hour, dayOfWeek (0=Sun), dayOfMonth, month (1-12) */ export declare function getDatePartsInTimezone(date: Date, tz: string): { minute: number; hour: number; dayOfWeek: number; dayOfMonth: number; month: number; }; /** * Validate an IANA timezone, falling back to UTC for an invalid/empty one. * * `new Intl.DateTimeFormat(..., { timeZone })` THROWS `RangeError` on a bad * tz. That throw was previously uncaught inside `getNextRunTime`'s minute scan, * so a single cron task with a malformed/empty timezone would crash the whole * `evaluateTasks` tick and stall EVERY cron. Defaulting to UTC (with a one-time * warn) keeps the scheduler resilient to one bad task. * * @param tz - Candidate IANA timezone * @returns The tz if valid, otherwise 'UTC' */ export declare function safeTimezone(tz: string | undefined | null): string; /** * Parse a single cron field spec into a set of allowed values. * Supports: `*`, single number, ranges (`1-5`), lists (`1,3,5`), * and step values (`*\/5`, `10-30/5`). * * @param spec - The cron field string (e.g. "*", "5", "1-5", "1,3,5", "*\/10") * @param min - Minimum allowed value for this field (inclusive) * @param max - Maximum allowed value for this field (inclusive) * @returns Set of matching integer values, or null for wildcard (match any) */ export declare function parseCronField(spec: string, min: number, max: number): Set | null; /** * Parse a cron expression and calculate the next run time. * Supports standard 5-field cron: minute hour day-of-month month day-of-week. * Each field supports: `*`, numbers, ranges (`1-5`), lists (`1,3,5`), * and step values (`*\/5`). * * All field matching is done in the specified timezone so that a cron like * "0 9 * * *" in Asia/Shanghai fires at 09:00 Shanghai time regardless of * the server's local timezone. * * The search covers `CRON_SCHEDULE_CONSTANTS.NEXT_RUN_HORIZON_DAYS` (a full * year) without stepping every minute: a day whose date fields do not match * is skipped in one hop (to just before its end, then hour-by-hour to the * boundary), and an hour that does not match is skipped to the next hour * boundary. Every hop stays inside the unit it is skipping, so no matching * minute can be jumped over — including across DST transitions. * * @param cronExpression - Standard 5-field cron expression * @param timezone - IANA timezone (e.g. "UTC", "America/New_York") * @param after - Calculate next run after this date (defaults to now) * @returns ISO string of next run time (always UTC). For an expression with * no match inside the horizon (e.g. "0 0 31 2 *") returns now+24h, * which is NOT schedule-aligned, and logs a WARN naming the expression * @throws Error if the expression does not have exactly 5 fields */ export declare function getNextRunTime(cronExpression: string, timezone: string, after?: Date): string; /** * Callback type for checking whether an agent is online. * Should return true if the agent is reachable and can receive tasks. * * @param sessionName - The agent's PTY session name * @param teamId - The team ID the agent belongs to * @returns True if the agent is online and ready */ export type AgentStatusCallback = (sessionName: string, teamId: string) => Promise; /** * Callback type for auto-starting an offline agent. * * @param sessionName - The agent's PTY session name * @param teamId - The team ID the agent belongs to * @returns True if the agent was successfully started */ export type AgentStartCallback = (sessionName: string, teamId: string) => Promise; /** * Service for managing cron tasks with per-team scoped storage. * * Storage layout: * ~/.crewly/teams/{teamId}/cron-tasks.json * * On first startup, migrates the legacy global file * (~/.crewly/cron-tasks.json) by grouping tasks by targetTeamId * and writing per-team files. The global file is renamed to * cron-tasks.json.migrated to prevent re-migration. * * Lifecycle: * 1. Get singleton via `getInstance()` * 2. Wire callbacks: `setExecutionCallback()`, `setAgentStatusCallback()`, `setAgentStartCallback()` * 3. Call `migrateGlobalToTeamScoped()` to auto-migrate legacy storage * 4. Call `start()` to begin the evaluation loop * 5. On startup, call `recalculateAllNextRunTimes()` to self-heal stale values */ export declare class CronTaskService { private static instance; private logger; /** Store files found corrupt and already copied aside (copy once, not on every read). */ private readonly setAsideStores; /** Root crewly home directory (e.g. ~/.crewly) */ private crewlyHome; private timer; private executionCallback; private agentStatusCallback; private agentStartCallback; constructor(crewlyHome?: string); /** * Get singleton instance. */ static getInstance(): CronTaskService; /** * Reset singleton (for testing). */ static resetInstance(): void; /** * Get the store file path for a specific team. * * @param teamId - Team ID * @returns Absolute path to the team's cron-tasks.json */ getStoreFile(teamId: string): string; /** * Get the legacy global store file path. * * @returns Absolute path to the global cron-tasks.json */ private getGlobalStoreFile; /** * Migrate legacy global cron-tasks.json to per-team files. * * Orchestrator-targeted tasks (targetTeamId === 'orchestrator') are kept * in the global file since they don't belong to any specific team. * All other tasks are grouped by targetTeamId and written to per-team files. * * After migration, the global file is rewritten to contain only orchestrator * tasks, and a .migrated marker is created to prevent re-running. * * Idempotent: skips if the .migrated marker already exists or no global file exists. * * @returns Number of tasks migrated to per-team files (excludes orchestrator tasks kept in global) */ migrateGlobalToTeamScoped(): Promise; /** * Set the callback invoked when a cron task is due for execution. * The callback receives the task and should delegate it to the target agent. * * @param callback - Async function that executes a cron task */ setExecutionCallback(callback: (task: CronTask) => Promise): void; /** * Set the callback for checking agent online status. * Called before executing a task to verify the target agent is reachable. * Should treat both 'active' and 'started' states as online. * * @param callback - Async function returning true if agent is online */ setAgentStatusCallback(callback: AgentStatusCallback): void; /** * Set the callback for auto-starting an offline agent. * Called when a cron task fires but the target agent is offline. * * @param callback - Async function that starts an agent, returns true on success */ setAgentStartCallback(callback: AgentStartCallback): void; /** * Start the cron task evaluation loop. * Checks every minute for tasks whose nextRunAt has passed. */ start(): void; /** * Stop the cron task evaluation loop. */ stop(): void; /** * Whether the service is running. */ isRunning(): boolean; /** * Recalculate nextRunAt for all enabled tasks using the stored timezone. * This self-heals stale values from older versions that computed nextRunAt * in the server's local timezone instead of the task's specified timezone. * * Should be called once on backend startup, before start(). * * @returns Number of tasks whose nextRunAt was updated */ recalculateAllNextRunTimes(): Promise; /** * Run `fn` on the global store and on every team store, each on its own: * a store that cannot be read (or saved) is logged and skipped, and the * rest still run. `fn` returns true when the store changed and must be * saved. * * @param action - What is being done, for the log * @param fn - Work on one store */ private forEachStore; /** * Create a new cron task. * * @param request - Cron task creation parameters * @returns The created cron task */ create(request: CreateCronTaskRequest): Promise; /** * List all cron tasks across all teams, optionally filtered. * * @param filter - Optional filter criteria * @returns Array of matching cron tasks */ list(filter?: { targetAgent?: string; enabled?: boolean; }): Promise; /** * List cron tasks for a specific team. * * @param teamId - Team ID to filter by * @param filter - Optional additional filter criteria * @returns Array of matching cron tasks for the team */ getByTeam(teamId: string, filter?: { targetAgent?: string; enabled?: boolean; }): Promise; /** * Get a single cron task by ID. Searches across all teams. * * @param id - Cron task ID * @returns The cron task or null */ get(id: string): Promise; /** * Update a cron task. Searches across all teams to find the task. * * @param id - Cron task ID * @param updates - Fields to update * @returns Updated task or null if not found */ update(id: string, updates: UpdateCronTaskRequest): Promise; /** * Delete a cron task. Searches across all teams to find and remove it. * * @param id - Cron task ID * @returns true if deleted, false if not found */ delete(id: string): Promise; /** * Evaluate all enabled tasks and execute those whose nextRunAt has passed. * If an agentStatusCallback is set, checks whether the target agent is online * before execution. If the agent is offline and an agentStartCallback is set, * attempts to auto-start the agent before executing. */ evaluateTasks(): Promise; /** * Evaluate a single cron task: check if due, verify agent status, execute. * Mutates the task in-place (lastRunAt, nextRunAt). * * @param task - The cron task to evaluate * @param now - Current time for comparison * @returns true if the task was modified (needs saving) */ private evaluateSingleTask; /** * Load cron task store for a specific team. * * @param teamId - Team ID * @returns Team's cron task store (empty if file doesn't exist) */ private loadTeamStore; /** * Read a cron store file. * * - missing: empty; * - corrupt (a truncated write, a full disk): copied aside once to * `.corrupt-` and logged as an error, then empty — the owner's * schedules survive in the copy when the next save replaces the file; * - the copy fails, or the file cannot be read at all (EMFILE, EIO…): * throws, so no read-modify-write can save over it. * * specs/2026-10-03-usage-ledger-durability.md * * @param filePath - Store file * @returns Store (empty when missing or corrupt) */ private readStore; /** * Save cron task store for a specific team. * * @param teamId - Team ID * @param store - Store data to persist */ private saveTeamStore; /** * Load the global cron task store (orchestrator tasks only). * * @returns Global store (empty if file doesn't exist) */ private loadGlobalStore; /** * Save the global cron task store (orchestrator tasks only). * * @param store - Store data to persist */ private saveGlobalStore; /** * Load all tasks from all team stores plus the global store, * aggregated into a single array. * * @returns All cron tasks across all teams and the global orchestrator store */ private loadAllTasks; /** * Discover all team IDs that have cron task files. * * Scans ~/.crewly/teams/ for directories containing cron-tasks.json. * * @returns Array of team IDs */ private getTeamIds; } //# sourceMappingURL=cron-task.service.d.ts.map