Files
fusion/packages/engine/src/routine-scheduler.ts
Fusion 08a0d7157a feat(FN-1715): add scope-aware automation and routine scheduling
- Add scope field to automations and routines for granular control
- Update automation-store and routine-store with scope-aware query methods
- Extend database schema with scope column for automations and routines
- Update cron-runner and routine-scheduler to respect scope boundaries
- Add project context injection to in-process runtime for scoped execution
- Include changeset for minor version bump
2026-04-15 16:32:16 -07:00

272 lines
8.4 KiB
TypeScript

/**
* RoutineScheduler — polls for due routines and triggers their execution via RoutineRunner.
*
* UTILITY PATH: This component runs on the utility lane and does NOT receive the
* task-lane semaphore. Routine execution is independent of task execution concurrency.
*
* Handles:
* - Polling interval with configurable interval
* - Re-entrance guard (prevents overlapping polls)
* - Pause awareness (globalPause / enginePaused)
* - Catch-up execution before normal due execution
* - Per-routine failure isolation
* - Scope-aware polling: "global", "project", or "all" (both scopes)
*
* SCOPED POLLING SEMANTICS:
* - scope="project" (default): Only polls routines scoped to this project
* - scope="global": Only polls global/shared routines
* - scope="all": Polls both scopes with deterministic de-duplication by routine ID
*
* Each ProjectEngine instance runs with scope="project", ensuring project isolation.
*/
import { CronExpressionParser } from "cron-parser";
import type { Routine, RoutineStore, TaskStore } from "@fusion/core";
import { RoutineRunner } from "./routine-runner.js";
import { createLogger } from "./logger.js";
const logger = createLogger("routine-scheduler");
/**
* Options for RoutineScheduler.
*/
export interface RoutineSchedulerOptions {
/** TaskStore for checking pause state */
taskStore: TaskStore;
/** RoutineStore for querying routines */
routineStore: RoutineStore;
/** RoutineRunner for executing routines */
routineRunner: RoutineRunner;
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
pollIntervalMs?: number;
/**
* Scope to poll for due routines.
* - "project": Only poll routines scoped to this project (default)
* - "global": Only poll global/shared routines
* - "all": Poll both project and global scopes
*/
scope?: "global" | "project" | "all";
}
/**
* RoutineScheduler polls for due routines and triggers their execution.
*/
export class RoutineScheduler {
private taskStore: TaskStore;
private routineStore: RoutineStore;
private routineRunner: RoutineRunner;
private pollIntervalMs: number;
/** Scope to poll: "global", "project", or "all". */
private scope: "global" | "project" | "all";
private running: boolean = false;
private ticking: boolean = false;
private pollInterval: ReturnType<typeof setInterval> | null = null;
constructor(options: RoutineSchedulerOptions) {
this.taskStore = options.taskStore;
this.routineStore = options.routineStore;
this.routineRunner = options.routineRunner;
this.pollIntervalMs = Math.max(10000, options.pollIntervalMs ?? 60000);
this.scope = options.scope ?? "project";
}
/**
* Start the scheduler.
*/
start(): void {
if (this.running) {
logger.log("RoutineScheduler already running");
return;
}
this.running = true;
logger.log(`RoutineScheduler started with ${this.pollIntervalMs}ms poll interval (scope: ${this.scope})`);
// Run first tick immediately
void this.tick();
// Start polling interval
this.pollInterval = setInterval(() => {
void this.tick();
}, this.pollIntervalMs);
}
/**
* Stop the scheduler.
*/
stop(): void {
if (!this.running) {
return;
}
this.running = false;
logger.log("RoutineScheduler stopping");
if (this.pollInterval) {
clearInterval(this.pollInterval);
this.pollInterval = null;
}
logger.log("RoutineScheduler stopped");
}
/**
* Check if the scheduler is active (running).
*/
isActive(): boolean {
return this.running;
}
/**
* Process a single tick — poll for due routines and execute them.
*/
async tick(): Promise<void> {
// Re-entrance guard
if (this.ticking) {
logger.log("Tick already in progress, skipping");
return;
}
this.ticking = true;
try {
// Check pause state
const settings = await this.taskStore.getSettings();
if (settings.globalPause || settings.enginePaused) {
logger.log(
`Paused: globalPause=${settings.globalPause}, enginePaused=${settings.enginePaused}`
);
return;
}
// Get due routines based on configured scope
let dueRoutines: Routine[];
if (this.scope === "all") {
// Poll both scopes and deduplicate by ID
dueRoutines = await this.routineStore.getDueRoutinesAllScopes();
} else {
dueRoutines = await this.routineStore.getDueRoutines(this.scope);
}
if (dueRoutines.length === 0) {
return;
}
logger.log(`Found ${dueRoutines.length} due routines (scope: ${this.scope})`);
// Track executed routine IDs to prevent double-execution when polling all scopes
const executedIds = new Set<string>();
// Process each routine
for (const routine of dueRoutines) {
// Skip if already executed this tick (de-duplication across scopes)
if (executedIds.has(routine.id)) {
logger.log(`[${routine.id}] Skipped: already executed from another scope this tick`);
continue;
}
executedIds.add(routine.id);
// Log which scope this routine is from
const routineScope = routine.scope ?? "project";
if (routineScope !== this.scope && this.scope !== "all") {
logger.log(`[${routine.id}] Skipped: belongs to ${routineScope} scope, not polling`);
continue;
}
logger.log(`[${routine.id}] Processing [scope: ${routineScope}]`);
// Re-check pause state (may have changed mid-loop)
const currentSettings = await this.taskStore.getSettings();
if (currentSettings.globalPause || currentSettings.enginePaused) {
logger.log("Paused mid-loop, stopping processing");
break;
}
try {
await this.processRoutine(routine);
} catch (err) {
logger.error(`[${routine.id}] Failed to process: ${err}`);
// Continue to next routine
}
}
} finally {
this.ticking = false;
}
}
/**
* Process a single routine.
*/
private async processRoutine(routine: Routine): Promise<void> {
const routineId = routine.id;
// Skip if disabled
if (!routine.enabled) {
logger.log(`[${routineId}] Skipped: routine is disabled`);
return;
}
// Handle catch-up
await this.routineRunner.handleCatchUp(routine);
// Execute the routine
await this.routineRunner.executeRoutine(routineId, "cron");
// Update next run time
if (routine.cronExpression) {
try {
const _nextRun = CronExpressionParser.parse(routine.cronExpression).next();
// Note: We can't update nextRunAt directly as it's derived from trigger
// The RoutineStore handles this internally
} catch (err) {
logger.error(`[${routineId}] Failed to calculate next run: ${err}`);
}
}
}
/**
* Trigger a routine manually via the API.
*/
async triggerManual(routineId: string): Promise<import("@fusion/core").RoutineExecutionResult> {
return this.routineRunner.executeRoutine(routineId, "api");
}
/**
* Trigger a routine via webhook.
*/
async triggerWebhook(
routineId: string,
payload: Record<string, unknown>,
signature?: string
): Promise<import("@fusion/core").RoutineExecutionResult> {
// Load routine to validate webhook trigger type
const routine = await this.routineStore.getRoutine(routineId);
if (routine.trigger.type !== "webhook") {
throw new Error(`Routine '${routineId}' does not have webhook trigger type`);
}
// Verify webhook signature if secret is configured
const webhookSecret = process.env.FUSION_ROUTINE_WEBHOOK_SECRET;
if (webhookSecret) {
if (!signature) {
throw new Error("Missing webhook signature");
}
const { createHmac, timingSafeEqual } = await import("node:crypto");
const [algo, expectedSig] = signature.split("=");
if (algo !== "sha256") {
throw new Error("Invalid webhook signature algorithm");
}
const computed = createHmac("sha256", webhookSecret).update(JSON.stringify(payload)).digest("hex");
const sigBuffer = Buffer.from(expectedSig, "hex");
const computedBuffer = Buffer.from(computed, "hex");
if (sigBuffer.length !== computedBuffer.length || !timingSafeEqual(sigBuffer, computedBuffer)) {
throw new Error("Invalid webhook signature");
}
}
return this.routineRunner.executeRoutine(routineId, "webhook", { webhookPayload: payload });
}
}