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
This commit is contained in:
@@ -54,15 +54,37 @@ export interface CronRunnerOptions {
|
||||
schedule: ScheduledTask,
|
||||
result: AutomationRunResult,
|
||||
) => void | Promise<void>;
|
||||
/**
|
||||
* Scope to poll for due schedules.
|
||||
* - "project": Only poll schedules scoped to this project (default)
|
||||
* - "global": Only poll global/shared schedules
|
||||
* - "all": Poll both project and global scopes
|
||||
*
|
||||
* When polling "all", the runner executes schedules from both scopes but
|
||||
* deduplicates by schedule ID to prevent double-execution.
|
||||
*/
|
||||
scope?: "global" | "project" | "all";
|
||||
}
|
||||
|
||||
/**
|
||||
* CronRunner polls the AutomationStore for due schedules and executes them.
|
||||
*
|
||||
* UTILITY PATH: This component runs on the utility lane and does NOT receive the
|
||||
* task-lane semaphore. Automation execution is independent of task execution concurrency.
|
||||
*
|
||||
* - Respects `globalPause` and `enginePaused` settings — skips execution when either is true.
|
||||
* - Prevents concurrent runs of the same schedule.
|
||||
* - Enforces per-schedule timeouts and output size limits.
|
||||
* - Uses a re-entrance guard like Scheduler to prevent overlapping ticks.
|
||||
* - Supports scope-aware polling: "global", "project", or "all" (both scopes).
|
||||
*
|
||||
* SCOPED POLLING SEMANTICS:
|
||||
* - scope="project" (default): Only polls schedules scoped to this project
|
||||
* - scope="global": Only polls global/shared schedules (e.g., memory insight extraction)
|
||||
* - scope="all": Polls both scopes with deterministic de-duplication by schedule ID
|
||||
*
|
||||
* Each ProjectEngine instance runs with scope="project", ensuring project isolation.
|
||||
* A separate global engine (FN-1714) runs with scope="global" for shared schedules.
|
||||
*/
|
||||
export class CronRunner {
|
||||
private running = false;
|
||||
@@ -73,6 +95,8 @@ export class CronRunner {
|
||||
private onScheduleRunProcessed?: CronRunnerOptions["onScheduleRunProcessed"];
|
||||
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
|
||||
private inFlight = new Set<string>();
|
||||
/** Scope to poll: "global", "project", or "all" (both scopes). */
|
||||
private scope: "global" | "project" | "all";
|
||||
|
||||
constructor(
|
||||
private store: TaskStore,
|
||||
@@ -85,13 +109,14 @@ export class CronRunner {
|
||||
);
|
||||
this.aiPromptExecutor = options.aiPromptExecutor;
|
||||
this.onScheduleRunProcessed = options.onScheduleRunProcessed;
|
||||
this.scope = options.scope ?? "project";
|
||||
}
|
||||
|
||||
/** Start the polling loop. */
|
||||
start(): void {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
log.log(`Started (poll every ${this.pollIntervalMs / 1000}s)`);
|
||||
log.log(`Started (poll every ${this.pollIntervalMs / 1000}s, scope: ${this.scope})`);
|
||||
|
||||
// Run first tick immediately
|
||||
void this.tick();
|
||||
@@ -127,9 +152,20 @@ export class CronRunner {
|
||||
return;
|
||||
}
|
||||
|
||||
const dueSchedules = await this.automationStore.getDueSchedules();
|
||||
// Get due schedules based on configured scope
|
||||
let dueSchedules: ScheduledTask[];
|
||||
if (this.scope === "all") {
|
||||
// Poll both scopes and deduplicate by ID
|
||||
dueSchedules = await this.automationStore.getDueSchedulesAllScopes();
|
||||
} else {
|
||||
dueSchedules = await this.automationStore.getDueSchedules(this.scope);
|
||||
}
|
||||
|
||||
if (dueSchedules.length === 0) return;
|
||||
|
||||
// Track executed schedule IDs to prevent double-execution when polling all scopes
|
||||
const executedIds = new Set<string>();
|
||||
|
||||
for (const schedule of dueSchedules) {
|
||||
// Skip if already in-flight (prevents concurrent runs of same schedule)
|
||||
if (this.inFlight.has(schedule.id)) {
|
||||
@@ -137,6 +173,22 @@ export class CronRunner {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Skip if already executed this tick (de-duplication across scopes)
|
||||
if (executedIds.has(schedule.id)) {
|
||||
log.log(`Skipping ${schedule.name} (${schedule.id}) — already executed from another scope this tick`);
|
||||
continue;
|
||||
}
|
||||
executedIds.add(schedule.id);
|
||||
|
||||
// Log which scope this schedule is from
|
||||
const scheduleScope = schedule.scope ?? "project";
|
||||
if (scheduleScope !== this.scope && this.scope !== "all") {
|
||||
log.log(`Skipping ${schedule.name} (${schedule.id}) — belongs to ${scheduleScope} scope, not polling`);
|
||||
continue;
|
||||
}
|
||||
|
||||
log.log(`Executing ${schedule.name} (${schedule.id}) [scope: ${scheduleScope}]`);
|
||||
|
||||
// Re-check pause on each schedule (may have changed mid-loop)
|
||||
const currentSettings = await this.store.getSettings();
|
||||
if (currentSettings.globalPause || currentSettings.enginePaused) {
|
||||
|
||||
@@ -165,6 +165,7 @@ export class ProjectEngine {
|
||||
this.cronRunner = new CronRunner(store, this.automationStore, {
|
||||
aiPromptExecutor,
|
||||
onScheduleRunProcessed: this.buildInsightRunHandler(cwd),
|
||||
scope: "project", // Project-scoped execution — global schedules run separately
|
||||
});
|
||||
|
||||
// Sync insight extraction automation on startup
|
||||
|
||||
@@ -1,12 +1,23 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -28,6 +39,13 @@ export interface RoutineSchedulerOptions {
|
||||
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";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,6 +56,8 @@ export class RoutineScheduler {
|
||||
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;
|
||||
@@ -48,6 +68,7 @@ export class RoutineScheduler {
|
||||
this.routineStore = options.routineStore;
|
||||
this.routineRunner = options.routineRunner;
|
||||
this.pollIntervalMs = Math.max(10000, options.pollIntervalMs ?? 60000);
|
||||
this.scope = options.scope ?? "project";
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -60,7 +81,7 @@ export class RoutineScheduler {
|
||||
}
|
||||
|
||||
this.running = true;
|
||||
logger.log(`RoutineScheduler started with ${this.pollIntervalMs}ms poll interval`);
|
||||
logger.log(`RoutineScheduler started with ${this.pollIntervalMs}ms poll interval (scope: ${this.scope})`);
|
||||
|
||||
// Run first tick immediately
|
||||
void this.tick();
|
||||
@@ -119,16 +140,42 @@ export class RoutineScheduler {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get due routines
|
||||
const dueRoutines = await this.getDueRoutines();
|
||||
// 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`);
|
||||
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) {
|
||||
@@ -178,18 +225,6 @@ export class RoutineScheduler {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get routines that are due for execution.
|
||||
*/
|
||||
private async getDueRoutines(): Promise<Routine[]> {
|
||||
try {
|
||||
return await this.routineStore.getDueRoutines();
|
||||
} catch (err) {
|
||||
logger.error(`Failed to get due routines: ${err}`);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger a routine manually via the API.
|
||||
*/
|
||||
|
||||
@@ -463,6 +463,7 @@ export class InProcessRuntime
|
||||
routineStore,
|
||||
routineRunner: this.routineRunner,
|
||||
pollIntervalMs: 60000,
|
||||
scope: "project", // Project-scoped execution — global routines run separately
|
||||
});
|
||||
this.routineScheduler.start();
|
||||
runtimeLog.log("RoutineScheduler initialized and started");
|
||||
|
||||
Reference in New Issue
Block a user