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:
Fusion
2026-04-15 16:32:16 -07:00
committed by gsxdsm
parent e0cc066b42
commit 03863986bf
18 changed files with 208 additions and 49 deletions

View File

@@ -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) {

View File

@@ -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

View File

@@ -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.
*/

View File

@@ -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");