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:
12
.changeset/scope-aware-scheduling.md
Normal file
12
.changeset/scope-aware-scheduling.md
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
---
|
||||||
|
"@gsxdsm/fusion": minor
|
||||||
|
---
|
||||||
|
|
||||||
|
Add scope-aware automation and routine scheduling support
|
||||||
|
|
||||||
|
- Added `scope` column to automations and routines database tables with migration v34
|
||||||
|
- Added scope-aware `getDueSchedules(scope)` and `getDueRoutines(scope)` query methods to stores
|
||||||
|
- Updated CronRunner to poll schedules by scope with "project", "global", or "all" options
|
||||||
|
- Updated RoutineScheduler to poll routines by scope with "project", "global", or "all" options
|
||||||
|
- Added scope-aware diagnostics to identify which scope lane produced each run
|
||||||
|
- Deterministic de-duplication prevents double-execution when polling both scopes
|
||||||
@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
|
|||||||
|
|
||||||
expect(tableNames.has("task_documents")).toBe(true);
|
expect(tableNames.has("task_documents")).toBe(true);
|
||||||
expect(tableNames.has("task_document_revisions")).toBe(true);
|
expect(tableNames.has("task_document_revisions")).toBe(true);
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
|
|
||||||
const index = db
|
const index = db
|
||||||
.prepare(
|
.prepare(
|
||||||
|
|||||||
@@ -507,7 +507,7 @@ describe("AutomationStore", () => {
|
|||||||
// Let's just test that getDueSchedules works with disabled/enabled correctly.
|
// 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)
|
// 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
|
// 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
|
// Instead, let's verify it returns enabled schedules only
|
||||||
expect(Array.isArray(due)).toBe(true);
|
expect(Array.isArray(due)).toBe(true);
|
||||||
@@ -523,7 +523,7 @@ describe("AutomationStore", () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const due = await store.getDueSchedules();
|
const due = await store.getDueSchedules("project");
|
||||||
expect(due.some((d) => d.id === schedule.id)).toBe(false);
|
expect(due.some((d) => d.id === schedule.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -535,7 +535,7 @@ describe("AutomationStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// nextRunAt is in the future by default
|
// 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);
|
expect(due.some((d) => d.id === schedule.id)).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
|||||||
lastRunResult: fromJson<AutomationRunResult>(row.lastRunResult),
|
lastRunResult: fromJson<AutomationRunResult>(row.lastRunResult),
|
||||||
runCount: row.runCount || 0,
|
runCount: row.runCount || 0,
|
||||||
runHistory: fromJson<AutomationRunResult[]>(row.runHistory) || [],
|
runHistory: fromJson<AutomationRunResult[]>(row.runHistory) || [],
|
||||||
|
scope: (row.scope as "global" | "project") || "project",
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -81,8 +82,8 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
|||||||
INSERT OR REPLACE INTO automations (
|
INSERT OR REPLACE INTO automations (
|
||||||
id, name, description, scheduleType, cronExpression, command,
|
id, name, description, scheduleType, cronExpression, command,
|
||||||
enabled, timeoutMs, steps, nextRunAt, lastRunAt, lastRunResult,
|
enabled, timeoutMs, steps, nextRunAt, lastRunAt, lastRunResult,
|
||||||
runCount, runHistory, createdAt, updatedAt
|
runCount, runHistory, scope, createdAt, updatedAt
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
schedule.id,
|
schedule.id,
|
||||||
schedule.name,
|
schedule.name,
|
||||||
@@ -98,6 +99,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
|||||||
schedule.lastRunResult ? JSON.stringify(schedule.lastRunResult) : null,
|
schedule.lastRunResult ? JSON.stringify(schedule.lastRunResult) : null,
|
||||||
schedule.runCount || 0,
|
schedule.runCount || 0,
|
||||||
JSON.stringify(schedule.runHistory || []),
|
JSON.stringify(schedule.runHistory || []),
|
||||||
|
schedule.scope ?? "project",
|
||||||
schedule.createdAt,
|
schedule.createdAt,
|
||||||
schedule.updatedAt,
|
schedule.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -237,6 +239,7 @@ export class AutomationStore extends EventEmitter<AutomationStoreEvents> {
|
|||||||
timeoutMs: input.timeoutMs,
|
timeoutMs: input.timeoutMs,
|
||||||
steps: hasSteps ? input.steps : undefined,
|
steps: hasSteps ? input.steps : undefined,
|
||||||
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
|
nextRunAt: enabled ? this.computeNextRun(cronExpression) : undefined,
|
||||||
|
scope: input.scope ?? "project",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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).
|
* 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 now = new Date().toISOString();
|
||||||
const rows = this.db.prepare(
|
const rows = this.db.prepare(
|
||||||
'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?'
|
'SELECT * FROM automations WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?'
|
||||||
|
|||||||
@@ -111,6 +111,8 @@ export interface ScheduledTask {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** History of recent run results (most recent first, capped at 50). */
|
/** History of recent run results (most recent first, capped at 50). */
|
||||||
runHistory: AutomationRunResult[];
|
runHistory: AutomationRunResult[];
|
||||||
|
/** Scope of this schedule: "global" (shared) or "project" (isolated). */
|
||||||
|
scope?: "global" | "project";
|
||||||
/** ISO-8601 timestamp of when this schedule was created. */
|
/** ISO-8601 timestamp of when this schedule was created. */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** ISO-8601 timestamp of when this schedule was last updated. */
|
/** ISO-8601 timestamp of when this schedule was last updated. */
|
||||||
@@ -130,6 +132,8 @@ export interface ScheduledTaskCreateInput {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
|
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
|
||||||
steps?: AutomationStep[];
|
steps?: AutomationStep[];
|
||||||
|
/** Scope of this schedule: "global" (shared) or "project" (isolated). Default: "project". */
|
||||||
|
scope?: "global" | "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input for updating an existing scheduled task. */
|
/** Input for updating an existing scheduled task. */
|
||||||
@@ -143,6 +147,8 @@ export interface ScheduledTaskUpdateInput {
|
|||||||
timeoutMs?: number;
|
timeoutMs?: number;
|
||||||
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
|
/** Multi-step workflow. When provided, `command` is ignored in favor of sequential step execution. */
|
||||||
steps?: AutomationStep[];
|
steps?: AutomationStep[];
|
||||||
|
/** Scope of this schedule: "global" (shared) or "project" (isolated). */
|
||||||
|
scope?: "global" | "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Maximum number of run history entries to retain per schedule. */
|
/** Maximum number of run history entries to retain per schedule. */
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe("Database", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("seeds schema version", () => {
|
it("seeds schema version", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds lastModified", () => {
|
it("seeds lastModified", () => {
|
||||||
@@ -142,7 +142,7 @@ describe("Database", () => {
|
|||||||
|
|
||||||
it("is idempotent - calling init() twice does not fail", () => {
|
it("is idempotent - calling init() twice does not fail", () => {
|
||||||
expect(() => db.init()).not.toThrow();
|
expect(() => db.init()).not.toThrow();
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not overwrite existing config on re-init", () => {
|
it("does not overwrite existing config on re-init", () => {
|
||||||
@@ -749,7 +749,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29 (includes v1→v2 through v26→v29)
|
// 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
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
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);
|
const db = new Database(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
|
|
||||||
// Re-init should not fail
|
// Re-init should not fail
|
||||||
db.init();
|
db.init();
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
});
|
});
|
||||||
@@ -794,7 +794,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "agentRatings" }]);
|
||||||
@@ -818,7 +818,7 @@ describe("schema migrations", () => {
|
|||||||
|
|
||||||
db.init();
|
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 }>;
|
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" }]);
|
expect(tables).toEqual([{ name: "mission_events" }]);
|
||||||
@@ -922,7 +922,7 @@ describe("schema migrations", () => {
|
|||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
// Verify version bumped to 29
|
// Verify version bumped to 29
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
|
|
||||||
// Verify new columns exist and existing data is intact
|
// Verify new columns exist and existing data is intact
|
||||||
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
|
||||||
@@ -1288,7 +1288,7 @@ describe("createDatabase factory", () => {
|
|||||||
const db = createDatabase(kbDir);
|
const db = createDatabase(kbDir);
|
||||||
db.init();
|
db.init();
|
||||||
|
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
expect(db.getLastModified()).toBeGreaterThan(0);
|
expect(db.getLastModified()).toBeGreaterThan(0);
|
||||||
|
|
||||||
db.close();
|
db.close();
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
|
|||||||
|
|
||||||
// ── Schema Definition ────────────────────────────────────────────────
|
// ── Schema Definition ────────────────────────────────────────────────
|
||||||
|
|
||||||
const SCHEMA_VERSION = 33;
|
const SCHEMA_VERSION = 34;
|
||||||
|
|
||||||
function normalizeTaskComments(
|
function normalizeTaskComments(
|
||||||
steeringComments: SteeringComment[] | undefined,
|
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)`);
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -776,7 +776,7 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
// Step 1: Create a fresh database at v33 (runs all migrations up to 33)
|
||||||
const db1 = createDatabase(legacyDir);
|
const db1 = createDatabase(legacyDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(33);
|
expect(db1.getSchemaVersion()).toBe(34);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
// Step 2: Manually downgrade to version 32 and drop insight tables
|
// 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");
|
expect(tableNamesBefore).not.toContain("project_insight_runs");
|
||||||
// Now run init — this triggers the v32→v33 migration
|
// Now run init — this triggers the v32→v33 migration
|
||||||
db3.init();
|
db3.init();
|
||||||
expect(db3.getSchemaVersion()).toBe(33);
|
expect(db3.getSchemaVersion()).toBe(34);
|
||||||
|
|
||||||
// Step 4: Verify insight tables exist after migration
|
// Step 4: Verify insight tables exist after migration
|
||||||
const tablesAfter = db3.prepare(
|
const tablesAfter = db3.prepare(
|
||||||
@@ -842,12 +842,12 @@ describe("Migration: pre-33 DB upgrade", () => {
|
|||||||
try {
|
try {
|
||||||
const db1 = createDatabase(testDir);
|
const db1 = createDatabase(testDir);
|
||||||
db1.init();
|
db1.init();
|
||||||
expect(db1.getSchemaVersion()).toBe(33);
|
expect(db1.getSchemaVersion()).toBe(34);
|
||||||
db1.close();
|
db1.close();
|
||||||
|
|
||||||
const db2 = createDatabase(testDir);
|
const db2 = createDatabase(testDir);
|
||||||
expect(() => db2.init()).not.toThrow();
|
expect(() => db2.init()).not.toThrow();
|
||||||
expect(db2.getSchemaVersion()).toBe(33);
|
expect(db2.getSchemaVersion()).toBe(34);
|
||||||
db2.close();
|
db2.close();
|
||||||
} finally {
|
} finally {
|
||||||
rmSync(testDir, { recursive: true, force: true });
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
|||||||
@@ -2544,7 +2544,7 @@ describe("MissionStore", () => {
|
|||||||
|
|
||||||
describe("Loop State & Validator Run Schema (v31)", () => {
|
describe("Loop State & Validator Run Schema (v31)", () => {
|
||||||
it("schema version is 32 after migration", () => {
|
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", () => {
|
it("mission_features table has loop state columns", () => {
|
||||||
|
|||||||
@@ -739,7 +739,7 @@ describe("RoadmapStore", () => {
|
|||||||
|
|
||||||
describe("schema version", () => {
|
describe("schema version", () => {
|
||||||
it("schema version is 32 after init", () => {
|
it("schema version is 32 after init", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -520,7 +520,7 @@ describe("RoutineStore", () => {
|
|||||||
|
|
||||||
describe("getDueRoutines", () => {
|
describe("getDueRoutines", () => {
|
||||||
it("returns empty array when no routines", async () => {
|
it("returns empty array when no routines", async () => {
|
||||||
const due = await store.getDueRoutines();
|
const due = await store.getDueRoutines("project");
|
||||||
expect(due).toEqual([]);
|
expect(due).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -532,7 +532,7 @@ describe("RoutineStore", () => {
|
|||||||
enabled: false,
|
enabled: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const due = await store.getDueRoutines();
|
const due = await store.getDueRoutines("project");
|
||||||
expect(due.some((d) => d.id === routine.id)).toBe(false);
|
expect(due.some((d) => d.id === routine.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -544,7 +544,7 @@ describe("RoutineStore", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// nextRunAt is in the future by default
|
// 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);
|
expect(due.some((d) => d.id === routine.id)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -564,7 +564,7 @@ describe("RoutineStore", () => {
|
|||||||
).run(pastDate, routine.id);
|
).run(pastDate, routine.id);
|
||||||
|
|
||||||
// Now getDueRoutines should include it
|
// 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);
|
expect(due.some((d) => d.id === routine.id)).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -119,6 +119,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
|||||||
runHistory: fromJson<RoutineExecutionResult[]>(row.runHistory) || [],
|
runHistory: fromJson<RoutineExecutionResult[]>(row.runHistory) || [],
|
||||||
catchUpLimit: row.catchUpLimit ?? 5,
|
catchUpLimit: row.catchUpLimit ?? 5,
|
||||||
cronExpression: isCronTrigger(trigger) ? trigger.cronExpression : undefined,
|
cronExpression: isCronTrigger(trigger) ? trigger.cronExpression : undefined,
|
||||||
|
scope: (row.scope as "global" | "project") || "project",
|
||||||
createdAt: row.createdAt,
|
createdAt: row.createdAt,
|
||||||
updatedAt: row.updatedAt,
|
updatedAt: row.updatedAt,
|
||||||
};
|
};
|
||||||
@@ -149,8 +150,8 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
|||||||
id, agentId, name, description, triggerType, triggerConfig,
|
id, agentId, name, description, triggerType, triggerConfig,
|
||||||
catchUpPolicy, executionPolicy, catchUpLimit, enabled,
|
catchUpPolicy, executionPolicy, catchUpLimit, enabled,
|
||||||
lastRunAt, lastRunResult, nextRunAt,
|
lastRunAt, lastRunResult, nextRunAt,
|
||||||
runCount, runHistory, createdAt, updatedAt
|
runCount, runHistory, scope, createdAt, updatedAt
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(
|
`).run(
|
||||||
routine.id,
|
routine.id,
|
||||||
routine.agentId,
|
routine.agentId,
|
||||||
@@ -167,6 +168,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
|||||||
routine.nextRunAt ?? null,
|
routine.nextRunAt ?? null,
|
||||||
routine.runCount || 0,
|
routine.runCount || 0,
|
||||||
JSON.stringify(routine.runHistory || []),
|
JSON.stringify(routine.runHistory || []),
|
||||||
|
routine.scope ?? "project",
|
||||||
routine.createdAt,
|
routine.createdAt,
|
||||||
routine.updatedAt,
|
routine.updatedAt,
|
||||||
);
|
);
|
||||||
@@ -257,6 +259,7 @@ export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
|||||||
enabled,
|
enabled,
|
||||||
runCount: 0,
|
runCount: 0,
|
||||||
runHistory: [],
|
runHistory: [],
|
||||||
|
scope: input.scope ?? "project",
|
||||||
createdAt: now,
|
createdAt: now,
|
||||||
updatedAt: 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).
|
* 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 now = new Date().toISOString();
|
||||||
const rows = this.db.prepare(
|
const rows = this.db.prepare(
|
||||||
"SELECT * FROM routines WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?"
|
"SELECT * FROM routines WHERE enabled = 1 AND nextRunAt IS NOT NULL AND nextRunAt <= ?"
|
||||||
|
|||||||
@@ -135,6 +135,8 @@ export interface Routine {
|
|||||||
catchUpLimit?: number;
|
catchUpLimit?: number;
|
||||||
/** Optional cron expression stored directly for due-routine queries (derived from trigger). */
|
/** Optional cron expression stored directly for due-routine queries (derived from trigger). */
|
||||||
cronExpression?: string;
|
cronExpression?: string;
|
||||||
|
/** Scope of this routine: "global" (shared) or "project" (isolated). */
|
||||||
|
scope?: "global" | "project";
|
||||||
/** ISO-8601 timestamp of when this routine was created. */
|
/** ISO-8601 timestamp of when this routine was created. */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
/** ISO-8601 timestamp of when this routine was last updated. */
|
/** ISO-8601 timestamp of when this routine was last updated. */
|
||||||
@@ -159,6 +161,8 @@ export interface RoutineCreateInput {
|
|||||||
executionPolicy?: RoutineExecutionPolicy;
|
executionPolicy?: RoutineExecutionPolicy;
|
||||||
/** Whether enabled. Default: true. */
|
/** Whether enabled. Default: true. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Scope of this routine: "global" (shared) or "project" (isolated). Default: "project". */
|
||||||
|
scope?: "global" | "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Input for updating an existing routine. */
|
/** Input for updating an existing routine. */
|
||||||
@@ -175,6 +179,8 @@ export interface RoutineUpdateInput {
|
|||||||
executionPolicy?: RoutineExecutionPolicy;
|
executionPolicy?: RoutineExecutionPolicy;
|
||||||
/** Whether enabled. */
|
/** Whether enabled. */
|
||||||
enabled?: boolean;
|
enabled?: boolean;
|
||||||
|
/** Scope of this routine: "global" (shared) or "project" (isolated). */
|
||||||
|
scope?: "global" | "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Constants ─────────────────────────────────────────────────────────
|
// ── Constants ─────────────────────────────────────────────────────────
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ describe("Run Audit", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("schema version is bumped to 28", () => {
|
it("schema version is bumped to 28", () => {
|
||||||
expect(db.getSchemaVersion()).toBe(33);
|
expect(db.getSchemaVersion()).toBe(34);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,15 +54,37 @@ export interface CronRunnerOptions {
|
|||||||
schedule: ScheduledTask,
|
schedule: ScheduledTask,
|
||||||
result: AutomationRunResult,
|
result: AutomationRunResult,
|
||||||
) => void | Promise<void>;
|
) => 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.
|
* 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.
|
* - Respects `globalPause` and `enginePaused` settings — skips execution when either is true.
|
||||||
* - Prevents concurrent runs of the same schedule.
|
* - Prevents concurrent runs of the same schedule.
|
||||||
* - Enforces per-schedule timeouts and output size limits.
|
* - Enforces per-schedule timeouts and output size limits.
|
||||||
* - Uses a re-entrance guard like Scheduler to prevent overlapping ticks.
|
* - 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 {
|
export class CronRunner {
|
||||||
private running = false;
|
private running = false;
|
||||||
@@ -73,6 +95,8 @@ export class CronRunner {
|
|||||||
private onScheduleRunProcessed?: CronRunnerOptions["onScheduleRunProcessed"];
|
private onScheduleRunProcessed?: CronRunnerOptions["onScheduleRunProcessed"];
|
||||||
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
|
/** Schedule IDs currently being executed — prevents concurrent runs of the same schedule. */
|
||||||
private inFlight = new Set<string>();
|
private inFlight = new Set<string>();
|
||||||
|
/** Scope to poll: "global", "project", or "all" (both scopes). */
|
||||||
|
private scope: "global" | "project" | "all";
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private store: TaskStore,
|
private store: TaskStore,
|
||||||
@@ -85,13 +109,14 @@ export class CronRunner {
|
|||||||
);
|
);
|
||||||
this.aiPromptExecutor = options.aiPromptExecutor;
|
this.aiPromptExecutor = options.aiPromptExecutor;
|
||||||
this.onScheduleRunProcessed = options.onScheduleRunProcessed;
|
this.onScheduleRunProcessed = options.onScheduleRunProcessed;
|
||||||
|
this.scope = options.scope ?? "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Start the polling loop. */
|
/** Start the polling loop. */
|
||||||
start(): void {
|
start(): void {
|
||||||
if (this.running) return;
|
if (this.running) return;
|
||||||
this.running = true;
|
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
|
// Run first tick immediately
|
||||||
void this.tick();
|
void this.tick();
|
||||||
@@ -127,9 +152,20 @@ export class CronRunner {
|
|||||||
return;
|
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;
|
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) {
|
for (const schedule of dueSchedules) {
|
||||||
// Skip if already in-flight (prevents concurrent runs of same schedule)
|
// Skip if already in-flight (prevents concurrent runs of same schedule)
|
||||||
if (this.inFlight.has(schedule.id)) {
|
if (this.inFlight.has(schedule.id)) {
|
||||||
@@ -137,6 +173,22 @@ export class CronRunner {
|
|||||||
continue;
|
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)
|
// Re-check pause on each schedule (may have changed mid-loop)
|
||||||
const currentSettings = await this.store.getSettings();
|
const currentSettings = await this.store.getSettings();
|
||||||
if (currentSettings.globalPause || currentSettings.enginePaused) {
|
if (currentSettings.globalPause || currentSettings.enginePaused) {
|
||||||
|
|||||||
@@ -165,6 +165,7 @@ export class ProjectEngine {
|
|||||||
this.cronRunner = new CronRunner(store, this.automationStore, {
|
this.cronRunner = new CronRunner(store, this.automationStore, {
|
||||||
aiPromptExecutor,
|
aiPromptExecutor,
|
||||||
onScheduleRunProcessed: this.buildInsightRunHandler(cwd),
|
onScheduleRunProcessed: this.buildInsightRunHandler(cwd),
|
||||||
|
scope: "project", // Project-scoped execution — global schedules run separately
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync insight extraction automation on startup
|
// Sync insight extraction automation on startup
|
||||||
|
|||||||
@@ -1,12 +1,23 @@
|
|||||||
/**
|
/**
|
||||||
* RoutineScheduler — polls for due routines and triggers their execution via RoutineRunner.
|
* 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:
|
* Handles:
|
||||||
* - Polling interval with configurable interval
|
* - Polling interval with configurable interval
|
||||||
* - Re-entrance guard (prevents overlapping polls)
|
* - Re-entrance guard (prevents overlapping polls)
|
||||||
* - Pause awareness (globalPause / enginePaused)
|
* - Pause awareness (globalPause / enginePaused)
|
||||||
* - Catch-up execution before normal due execution
|
* - Catch-up execution before normal due execution
|
||||||
* - Per-routine failure isolation
|
* - 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 { CronExpressionParser } from "cron-parser";
|
||||||
@@ -28,6 +39,13 @@ export interface RoutineSchedulerOptions {
|
|||||||
routineRunner: RoutineRunner;
|
routineRunner: RoutineRunner;
|
||||||
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
|
/** Polling interval in milliseconds. Default: 60000 (60s). Minimum: 10000 (10s). */
|
||||||
pollIntervalMs?: number;
|
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 routineStore: RoutineStore;
|
||||||
private routineRunner: RoutineRunner;
|
private routineRunner: RoutineRunner;
|
||||||
private pollIntervalMs: number;
|
private pollIntervalMs: number;
|
||||||
|
/** Scope to poll: "global", "project", or "all". */
|
||||||
|
private scope: "global" | "project" | "all";
|
||||||
|
|
||||||
private running: boolean = false;
|
private running: boolean = false;
|
||||||
private ticking: boolean = false;
|
private ticking: boolean = false;
|
||||||
@@ -48,6 +68,7 @@ export class RoutineScheduler {
|
|||||||
this.routineStore = options.routineStore;
|
this.routineStore = options.routineStore;
|
||||||
this.routineRunner = options.routineRunner;
|
this.routineRunner = options.routineRunner;
|
||||||
this.pollIntervalMs = Math.max(10000, options.pollIntervalMs ?? 60000);
|
this.pollIntervalMs = Math.max(10000, options.pollIntervalMs ?? 60000);
|
||||||
|
this.scope = options.scope ?? "project";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -60,7 +81,7 @@ export class RoutineScheduler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.running = true;
|
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
|
// Run first tick immediately
|
||||||
void this.tick();
|
void this.tick();
|
||||||
@@ -119,16 +140,42 @@ export class RoutineScheduler {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get due routines
|
// Get due routines based on configured scope
|
||||||
const dueRoutines = await this.getDueRoutines();
|
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) {
|
if (dueRoutines.length === 0) {
|
||||||
return;
|
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
|
// Process each routine
|
||||||
for (const routine of dueRoutines) {
|
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)
|
// Re-check pause state (may have changed mid-loop)
|
||||||
const currentSettings = await this.taskStore.getSettings();
|
const currentSettings = await this.taskStore.getSettings();
|
||||||
if (currentSettings.globalPause || currentSettings.enginePaused) {
|
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.
|
* Trigger a routine manually via the API.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -463,6 +463,7 @@ export class InProcessRuntime
|
|||||||
routineStore,
|
routineStore,
|
||||||
routineRunner: this.routineRunner,
|
routineRunner: this.routineRunner,
|
||||||
pollIntervalMs: 60000,
|
pollIntervalMs: 60000,
|
||||||
|
scope: "project", // Project-scoped execution — global routines run separately
|
||||||
});
|
});
|
||||||
this.routineScheduler.start();
|
this.routineScheduler.start();
|
||||||
runtimeLog.log("RoutineScheduler initialized and started");
|
runtimeLog.log("RoutineScheduler initialized and started");
|
||||||
|
|||||||
Reference in New Issue
Block a user