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

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
const index = db
.prepare(

View File

@@ -507,7 +507,7 @@ describe("AutomationStore", () => {
// Let's just test that getDueSchedules works with disabled/enabled correctly.
// For the actual due test, verify the schedule is NOT due (nextRunAt is in the future)
const due = await store.getDueSchedules();
const due = await store.getDueSchedules("project");
// The schedule's nextRunAt is in the future after recordRun, so it shouldn't be due
// Instead, let's verify it returns enabled schedules only
expect(Array.isArray(due)).toBe(true);
@@ -523,7 +523,7 @@ describe("AutomationStore", () => {
enabled: false,
});
const due = await store.getDueSchedules();
const due = await store.getDueSchedules("project");
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
@@ -535,7 +535,7 @@ describe("AutomationStore", () => {
});
// nextRunAt is in the future by default
const due = await store.getDueSchedules();
const due = await store.getDueSchedules("project");
expect(due.some((d) => d.id === schedule.id)).toBe(false);
});
});

View File

@@ -71,6 +71,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
lastRunResult: fromJson<AutomationRunResult>(row.lastRunResult),
runCount: row.runCount || 0,
runHistory: fromJson<AutomationRunResult[]>(row.runHistory) || [],
scope: (row.scope as "global" | "project") || "project",
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -81,8 +82,8 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
INSERT OR REPLACE INTO automations (
id, name, description, scheduleType, cronExpression, command,
enabled, timeoutMs, steps, nextRunAt, lastRunAt, lastRunResult,
runCount, runHistory, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
runCount, runHistory, scope, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
schedule.id,
schedule.name,
@@ -98,6 +99,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
schedule.lastRunResult ? JSON.stringify(schedule.lastRunResult) : null,
schedule.runCount || 0,
JSON.stringify(schedule.runHistory || []),
schedule.scope ?? "project",
schedule.createdAt,
schedule.updatedAt,
);
@@ -237,6 +239,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
timeoutMs: input.timeoutMs,
steps: hasSteps ? input.steps : undefined,
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
scope: input.scope ?? "project",
createdAt: now,
updatedAt: now,
};
@@ -406,8 +409,21 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
/**
* Get all schedules that are due to run (nextRunAt <= now and enabled).
* Filters by scope: "global" or "project".
*/
async getDueSchedules(): Promise<ScheduledTask[]> {
async getDueSchedules(scope: "global" | "project"): Promise<ScheduledTask[]> {
const now = new Date().toISOString();
const rows = this.db.prepare(
'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ? AND scope = ?'
).all(now, scope) as any[];
return rows.map((row) => this.rowToSchedule(row));
}
/**
* Get all schedules that are due to run (nextRunAt <= now and enabled) for both scopes.
* Returns schedules from both "global" and "project" scopes.
*/
async getDueSchedulesAllScopes(): Promise<ScheduledTask[]> {
const now = new Date().toISOString();
const rows = this.db.prepare(
'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?'

View File

@@ -111,6 +111,8 @@ export interface ScheduledTask {
timeoutMs?: number;
/** History of recent run results (most recent first, capped at 50). */
runHistory: AutomationRunResult[];
/** Scope of this schedule: "global" (shared) or "project" (isolated). */
scope?: "global" | "project";
/** ISO-8601 timestamp of when this schedule was created. */
createdAt: string;
/** ISO-8601 timestamp of when this schedule was last updated. */
@@ -130,6 +132,8 @@ export interface ScheduledTaskCreateInput {
timeoutMs?: number;
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
steps?: AutomationStep[];
/** Scope of this schedule: "global" (shared) or "project" (isolated). Default: "project". */
scope?: "global" | "project";
}
/** Input for updating an existing scheduled task. */
@@ -143,6 +147,8 @@ export interface ScheduledTaskUpdateInput {
timeoutMs?: number;
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
steps?: AutomationStep[];
/** Scope of this schedule: "global" (shared) or "project" (isolated). */
scope?: "global" | "project";
}
/** Maximum number of run history entries to retain per schedule. */

View File

@@ -119,7 +119,7 @@ describe("Database", () => {
});
it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
});
it("seeds lastModified", () => {
@@ -142,7 +142,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
});
it("does not overwrite existing config on re-init", () => {
@@ -749,7 +749,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -774,11 +774,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
// Re-init should not fail
db.init();
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
db.close();
});
@@ -794,7 +794,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -818,7 +818,7 @@ describe("schema migrations", () => {
db.init();
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]);
@@ -922,7 +922,7 @@ describe("schema migrations", () => {
db.init();
// Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
// Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1288,7 +1288,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir);
db.init();
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
expect(db.getLastModified()).toBeGreaterThan(0);
db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 33;
const SCHEMA_VERSION = 34;
function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined,
@@ -1398,6 +1398,20 @@ export class Database {
`);
});
}
// Scope columns for automations and routines (FN-1714)
// Enables dual-lane execution: global scope (shared) and project scope (isolated)
if (version < 34) {
this.applyMigration(34, () => {
// Add scope column to automations table
this.addColumnIfMissing("automations", "scope", "TEXT DEFAULT 'project'");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxAutomationsScope ON automations(scope)`);
// Add scope column to routines table
this.addColumnIfMissing("routines", "scope", "TEXT DEFAULT 'project'");
this.db.exec(`CREATE INDEX IF NOT EXISTS idxRoutinesScope ON routines(scope)`);
});
}
}
/**

View File

@@ -776,7 +776,7 @@ describe("Migration: pre-33 DB upgrade", () => {
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
const db1 = createDatabase(legacyDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(33);
expect(db1.getSchemaVersion()).toBe(34);
db1.close();
// Step 2: Manually downgrade to version 32 and drop insight tables
@@ -811,7 +811,7 @@ describe("Migration: pre-33 DB upgrade", () => {
expect(tableNamesBefore).not.toContain("project_insight_runs");
// Now run init — this triggers the v32→v33 migration
db3.init();
expect(db3.getSchemaVersion()).toBe(33);
expect(db3.getSchemaVersion()).toBe(34);
// Step 4: Verify insight tables exist after migration
const tablesAfter = db3.prepare(
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
try {
const db1 = createDatabase(testDir);
db1.init();
expect(db1.getSchemaVersion()).toBe(33);
expect(db1.getSchemaVersion()).toBe(34);
db1.close();
const db2 = createDatabase(testDir);
expect(() => db2.init()).not.toThrow();
expect(db2.getSchemaVersion()).toBe(33);
expect(db2.getSchemaVersion()).toBe(34);
db2.close();
} finally {
rmSync(testDir, { recursive: true, force: true });

View File

@@ -2544,7 +2544,7 @@ describe("MissionStore", () => {
describe("Loop State & Validator Run Schema (v31)", () => {
it("schema version is 32 after migration", () => {
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
});
it("mission_features table has loop state columns", () => {

View File

@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
describe("schema version", () => {
it("schema version is 32 after init", () => {
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
});
});

View File

@@ -520,7 +520,7 @@ describe("RoutineStore", () => {
describe("getDueRoutines", () => {
it("returns empty array when no routines", async () => {
const due = await store.getDueRoutines();
const due = await store.getDueRoutines("project");
expect(due).toEqual([]);
});
@@ -532,7 +532,7 @@ describe("RoutineStore", () => {
enabled: false,
});
const due = await store.getDueRoutines();
const due = await store.getDueRoutines("project");
expect(due.some((d) => d.id === routine.id)).toBe(false);
});
@@ -544,7 +544,7 @@ describe("RoutineStore", () => {
});
// nextRunAt is in the future by default
const due = await store.getDueRoutines();
const due = await store.getDueRoutines("project");
expect(due.some((d) => d.id === routine.id)).toBe(false);
});
@@ -564,7 +564,7 @@ describe("RoutineStore", () => {
).run(pastDate, routine.id);
// Now getDueRoutines should include it
const due = await store.getDueRoutines();
const due = await store.getDueRoutines("project");
expect(due.some((d) => d.id === routine.id)).toBe(true);
});
});

View File

@@ -119,6 +119,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
runHistory: fromJson<RoutineExecutionResult[]>(row.runHistory) || [],
catchUpLimit: row.catchUpLimit ?? 5,
cronExpression: isCronTrigger(trigger) ? trigger.cronExpression : undefined,
scope: (row.scope as "global" | "project") || "project",
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
@@ -149,8 +150,8 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
id, agentId, name, description, triggerType, triggerConfig,
catchUpPolicy, executionPolicy, catchUpLimit, enabled,
lastRunAt, lastRunResult, nextRunAt,
runCount, runHistory, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
runCount, runHistory, scope, createdAt, updatedAt
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
routine.id,
routine.agentId,
@@ -167,6 +168,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
routine.nextRunAt ?? null,
routine.runCount || 0,
JSON.stringify(routine.runHistory || []),
routine.scope ?? "project",
routine.createdAt,
routine.updatedAt,
);
@@ -257,6 +259,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
enabled,
runCount: 0,
runHistory: [],
scope: input.scope ?? "project",
createdAt: now,
updatedAt: now,
};
@@ -450,8 +453,21 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
/**
* Get all routines that are due to run (nextRunAt <= now and enabled).
* Filters by scope: "global" or "project".
*/
async getDueRoutines(): Promise<Routine[]> {
async getDueRoutines(scope: "global" | "project"): Promise<Routine[]> {
const now = new Date().toISOString();
const rows = this.db.prepare(
"SELECT * FROM routines WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ? AND scope = ?"
).all(now, scope) as any[];
return rows.map((row) => this.rowToRoutine(row));
}
/**
* Get all routines that are due to run (nextRunAt <= now and enabled) for both scopes.
* Returns routines from both "global" and "project" scopes.
*/
async getDueRoutinesAllScopes(): Promise<Routine[]> {
const now = new Date().toISOString();
const rows = this.db.prepare(
"SELECT * FROM routines WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?"

View File

@@ -135,6 +135,8 @@ export interface Routine {
catchUpLimit?: number;
/** Optional cron expression stored directly for due-routine queries (derived from trigger). */
cronExpression?: string;
/** Scope of this routine: "global" (shared) or "project" (isolated). */
scope?: "global" | "project";
/** ISO-8601 timestamp of when this routine was created. */
createdAt: string;
/** ISO-8601 timestamp of when this routine was last updated. */
@@ -159,6 +161,8 @@ export interface RoutineCreateInput {
executionPolicy?: RoutineExecutionPolicy;
/** Whether enabled. Default: true. */
enabled?: boolean;
/** Scope of this routine: "global" (shared) or "project" (isolated). Default: "project". */
scope?: "global" | "project";
}
/** Input for updating an existing routine. */
@@ -175,6 +179,8 @@ export interface RoutineUpdateInput {
executionPolicy?: RoutineExecutionPolicy;
/** Whether enabled. */
enabled?: boolean;
/** Scope of this routine: "global" (shared) or "project" (isolated). */
scope?: "global" | "project";
}
// ── Constants ─────────────────────────────────────────────────────────

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
});
it("schema version is bumped to 28", () => {
expect(db.getSchemaVersion()).toBe(33);
expect(db.getSchemaVersion()).toBe(34);
});
});
});