# Remove dead SQLite dual-path code; keep migration-only readers ## Summary PostgreSQL cutover left hundreds of production dual-path branches (`backendMode ? PG : SQLite/store.db`) whose SQLite arms only hit throwing `Database`/`ArchiveDatabase`/`CentralDatabase` stubs. This change mechanically collapses those unreachable arms so production authority is AsyncDataLayer/PostgreSQL only, while preserving the six authorized read-only migration/recovery `DatabaseSync` seams. ## Dual-path mass removed | Metric | Before | After | |---|---|---| | `if (…backendMode)` (non-test) | ~328 | ~70 | | `store.db` / `this.db` refs in core (non-test) | ~570+ | ~375 (mostly pure legacy MissionStore/eval/insight SQLite classes + thin getters) | | Net diff | — | **~6.7k lines removed** across 41 files | Remaining `backendMode` checks are intentional (incomplete-PG sync safe-defaults, settings-sync disabled-on-PG, symbol-lock PG-only gates, “requires PostgreSQL” config versioning throws), not live SQLite authority. ## Subsystems cleaned - **Core TaskStore / task-store/***: collapsed if/else and early-return dual-path across reads, moves, lifecycle, mutations, workflow, archive, branch/PR, artifacts, comments, audit, project ops, etc. `initImpl` is PostgreSQL-only (SQLite startup tail deleted). - **Satellite stores**: automation, agent, routine, plugin, secrets, approval-request, central-core dual-path arms collapsed. - **Plugins**: reports async methods, compound-engineering pipeline + session stores, CLI Printing Press store — SQLite fallbacks removed; PG required. - **Engine**: no functional dual-path change beyond whitespace (settings-sync / peer-exchange PG-disabled behavior kept). ## Six migration-only readers retained (allowlist unchanged) 1. `packages/core/src/postgres/sqlite-migrator.ts` 2. `packages/core/src/project-identity.ts` 3. `packages/core/src/sqlite-validation.ts` 4. `packages/core/src/postgres/startup-factory.ts` 5. `packages/cli/src/commands/db.ts` 6. `scripts/lib/start-local-project.mjs` Plus low-level `sqlite-adapter` and migrator/startup-import tests. Inventory ratchet still requires exactly these six `new DatabaseSync(` production sites, all `readOnly: true`. ## Not treated as SQLite - `.fusion/project.json`, `task.json`, `agent-log.jsonl` file storage - AsyncDataLayer / Drizzle PG paths - Incomplete-PG sync safe-default stubs (still return empty/false/null under backend without consulting SQLite) ## Verification - `sqlite-production-reader-inventory.test.ts` — 15/15 pass - `incomplete-pg-ports.pg.test.ts` — 6/6 pass - Targeted PG tests (create-task, move, handoff, runtime-persistence, agent, mission, insight, central-core) — green - `tsc --noEmit` for `@fusion/core`, `@fusion/engine`, `@fusion/dashboard` — green - `scripts/check-no-getdatabase.mjs` — clean <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **Improvements** * Improved end-to-end consistency by making PostgreSQL/async persistence the standard across core task/workflow, automation, agents, plugins, routines, secrets, approvals, central operations, and session storage. * Unified scheduling, settings, configuration revision writes, run/workflow selection, queues/leases/transitions, and audit/lifecycle updates around consistent async transaction behavior. * **Bug Fixes** * Fixed edge cases for archived/deleted reads, unarchive/recovery flows, not-found handling, and task/artifact/document/log/comment operations, including more reliable emissions and hydration across search/list and lifecycle operations. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
616 lines
22 KiB
TypeScript
616 lines
22 KiB
TypeScript
/**
|
|
* RoutineStore: SQLite-backed store for Routine CRUD, run tracking, and due queries.
|
|
*
|
|
* Follows the AutomationStore pattern with:
|
|
* - Lazy DB initialization
|
|
* - Per-routine mutation locking via promise chains
|
|
* - Typed EventEmitter lifecycle events
|
|
*/
|
|
|
|
import { EventEmitter } from "node:events";
|
|
import { randomUUID } from "node:crypto";
|
|
import { CronExpressionParser } from "cron-parser";
|
|
import { Database, fromJson } from "./db.js";
|
|
import {
|
|
isCronTrigger,
|
|
type Routine,
|
|
type RoutineTrigger,
|
|
type RoutineCreateInput,
|
|
type RoutineUpdateInput,
|
|
type RoutineExecutionResult,
|
|
type RoutineTriggerType,
|
|
type RoutineCronTrigger,
|
|
type RoutineWebhookTrigger,
|
|
type RoutineApiTrigger,
|
|
type RoutineManualTrigger,
|
|
MAX_ROUTINE_RUN_HISTORY,
|
|
} from "./routine.js";
|
|
import { assertProjectRootDir } from "./project-root-guard.js";
|
|
import type { AsyncDataLayer } from "./postgres/data-layer.js";
|
|
import { appendConfigurationRevision, createConfigurationRevision, getConfigurationRevision, rollbackConfiguration } from "./async-configuration-revision-store.js";
|
|
import type { ConfigChangedBy, ConfigurationRevision } from "./types.js";
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:30:
|
|
* Async Drizzle helpers for backend-mode (PostgreSQL) RoutineStore operations.
|
|
* These helpers target the project.routines table via Drizzle and are the async
|
|
* equivalent of the sync this.db.prepare() call sites below.
|
|
*/
|
|
import {
|
|
upsertRoutine as upsertRoutineAsync,
|
|
getRoutine as getRoutineAsync,
|
|
listRoutines as listRoutinesAsync,
|
|
deleteRoutine as deleteRoutineAsync,
|
|
getDueRoutines as getDueRoutinesAsync,
|
|
} from "./async-routine-store.js";
|
|
|
|
const CRON_TIMEZONE = "UTC";
|
|
|
|
export interface RoutineStoreEvents {
|
|
"routine:created": [routine: Routine];
|
|
"routine:updated": [routine: Routine];
|
|
"routine:deleted": [routine: Routine];
|
|
"routine:run": [data: { routine: Routine; result: RoutineExecutionResult }];
|
|
}
|
|
|
|
/** Database row shape for the routines table. */
|
|
interface RoutineRow {
|
|
id: string;
|
|
agentId: string;
|
|
name: string;
|
|
description: string | null;
|
|
triggerType: string;
|
|
triggerConfig: string | null;
|
|
command: string | null;
|
|
steps: string | null;
|
|
timeoutMs: number | null;
|
|
catchUpPolicy: string;
|
|
executionPolicy: string;
|
|
enabled: number;
|
|
lastRunAt: string | null;
|
|
lastRunResult: string | null;
|
|
nextRunAt: string | null;
|
|
runCount: number;
|
|
runHistory: string;
|
|
catchUpLimit: number;
|
|
scope: string;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface RoutineStoreOptions {
|
|
/**
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:30:
|
|
* When an AsyncDataLayer is injected, RoutineStore operates in "backend mode":
|
|
* all data access delegates to PostgreSQL via Drizzle and no SQLite Database
|
|
* is constructed. When absent, the legacy SQLite path is byte-identical to
|
|
* pre-migration. This mirrors the TaskStore/AgentStore dual-path pattern.
|
|
*/
|
|
asyncLayer?: AsyncDataLayer;
|
|
}
|
|
|
|
export class RoutineStore extends EventEmitter<RoutineStoreEvents> {
|
|
/** SQLite database instance (lazy init). */
|
|
private _db: Database | null = null;
|
|
|
|
/** Per-routine promise chain for serializing writes. */
|
|
private routineLocks: Map<string, Promise<void>> = new Map();
|
|
|
|
/**
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:30:
|
|
* When set, RoutineStore operates in backend mode (PostgreSQL via Drizzle).
|
|
* All data access delegates to async helpers. No SQLite Database is
|
|
* constructed. This mirrors the TaskStore/AgentStore dual-path pattern.
|
|
*/
|
|
public readonly asyncLayer: AsyncDataLayer | null = null;
|
|
|
|
/** True when AsyncDataLayer was injected. Gates all SQLite construction. */
|
|
public get backendMode(): boolean {
|
|
return this.asyncLayer !== null;
|
|
}
|
|
|
|
constructor(private rootDir: string, options?: RoutineStoreOptions) {
|
|
super();
|
|
assertProjectRootDir(rootDir, "RoutineStore");
|
|
this.asyncLayer = options?.asyncLayer ?? null;
|
|
}
|
|
|
|
private requireVersionedConfigurationBackend(): void {
|
|
/* FNXC:ConfigVersioning 2026-07-18-19:10: legacy SQLite routine writes have no durable atomic revision transaction, so reject before mutation rather than create non-rollbackable configuration. */
|
|
if (!this.backendMode) throw new Error("Routine configuration changes require the PostgreSQL revision store");
|
|
}
|
|
|
|
// ── Database Access ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Get the SQLite database, initializing it on first access.
|
|
*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:30:
|
|
* Throws in backend mode (asyncLayer injected) — callers must branch on
|
|
* backendMode and use the async helpers instead.
|
|
*/
|
|
private get db(): Database {
|
|
throw new Error("SQLite Database is not available in backend mode (asyncLayer injected)");
|
|
}
|
|
|
|
/** Initialize the store (no-op in backend mode, DB is lazily initialized otherwise). */
|
|
async init(): Promise<void> {
|
|
// FNXC:SqliteFinalRemoval 2026-06-26-10:30: No-op in backend mode.
|
|
return;
|
|
}
|
|
|
|
// ── Row Conversion ─────────────────────────────────────────────────
|
|
|
|
private rowToRoutine(row: RoutineRow): Routine {
|
|
const triggerConfig = fromJson<{
|
|
cronExpression?: string;
|
|
timezone?: string;
|
|
webhookPath?: string;
|
|
secret?: string;
|
|
endpoint?: string;
|
|
}>(row.triggerConfig);
|
|
|
|
let trigger: RoutineTrigger;
|
|
switch (row.triggerType as RoutineTriggerType) {
|
|
case "cron":
|
|
trigger = {
|
|
type: "cron",
|
|
cronExpression: triggerConfig?.cronExpression ?? "0 * * * *",
|
|
timezone: triggerConfig?.timezone,
|
|
} as RoutineCronTrigger;
|
|
break;
|
|
case "webhook":
|
|
trigger = {
|
|
type: "webhook",
|
|
webhookPath: triggerConfig?.webhookPath ?? "",
|
|
secret: triggerConfig?.secret,
|
|
} as RoutineWebhookTrigger;
|
|
break;
|
|
case "api":
|
|
trigger = {
|
|
type: "api",
|
|
endpoint: triggerConfig?.endpoint ?? "",
|
|
} as RoutineApiTrigger;
|
|
break;
|
|
case "manual":
|
|
default:
|
|
trigger = { type: "manual" } as RoutineManualTrigger;
|
|
break;
|
|
}
|
|
|
|
return {
|
|
id: row.id,
|
|
agentId: row.agentId || "",
|
|
name: row.name,
|
|
description: row.description || undefined,
|
|
trigger,
|
|
command: row.command || undefined,
|
|
steps: fromJson<Routine["steps"]>(row.steps),
|
|
timeoutMs: row.timeoutMs ?? undefined,
|
|
catchUpPolicy: (row.catchUpPolicy as Routine["catchUpPolicy"]) || "run_one",
|
|
executionPolicy: (row.executionPolicy as Routine["executionPolicy"]) || "queue",
|
|
enabled: row.enabled === 1,
|
|
lastRunAt: row.lastRunAt || undefined,
|
|
lastRunResult: fromJson<RoutineExecutionResult>(row.lastRunResult),
|
|
nextRunAt: row.nextRunAt || undefined,
|
|
runCount: row.runCount || 0,
|
|
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,
|
|
};
|
|
}
|
|
|
|
private async upsertRoutine(routine: Routine): Promise<void> {
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:35:
|
|
* Backend-mode: delegate to the async Drizzle upsertRoutine helper.
|
|
*/
|
|
await upsertRoutineAsync(this.asyncLayer!.db, routine);
|
|
return;
|
|
}
|
|
|
|
// ── Locking ───────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Serialize all mutations to a given routine by chaining promises.
|
|
* Concurrent callers for the same ID will queue behind each other.
|
|
*/
|
|
private withRoutineLock<T>(id: string, fn: () => Promise<T>): Promise<T> {
|
|
const prev = this.routineLocks.get(id) ?? Promise.resolve();
|
|
let resolve!: () => void;
|
|
const next = new Promise<void>((r) => { resolve = r; });
|
|
this.routineLocks.set(id, next);
|
|
|
|
return prev.then(async () => {
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
if (this.routineLocks.get(id) === next) {
|
|
this.routineLocks.delete(id);
|
|
}
|
|
resolve!();
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Cron Utilities ────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compute the next run time from a cron expression.
|
|
* @param cronExpression - A valid cron expression (5 fields).
|
|
* @param fromDate - The date to compute from. Defaults to now.
|
|
* @returns ISO-8601 timestamp of the next run.
|
|
*/
|
|
computeNextRun(cronExpression: string, fromDate?: Date): string {
|
|
const interval = CronExpressionParser.parse(cronExpression, {
|
|
currentDate: fromDate ?? new Date(),
|
|
tz: CRON_TIMEZONE,
|
|
});
|
|
const next = interval.next();
|
|
return new Date(next.getTime()).toISOString();
|
|
}
|
|
|
|
/**
|
|
* Validate a cron expression. Returns true if valid.
|
|
*/
|
|
static isValidCron(cronExpression: string): boolean {
|
|
try {
|
|
CronExpressionParser.parse(cronExpression);
|
|
return true;
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// ── CRUD ──────────────────────────────────────────────────────────
|
|
|
|
/*
|
|
* FNXC:ConfigVersioning 2026-07-18-12:15:
|
|
* Preserve the legacy SQLite CRUD seam while installations migrate. The
|
|
* PostgreSQL branch below journals mutations atomically; compatibility
|
|
* callers must not lose their pre-existing ability to manage routines.
|
|
*/
|
|
|
|
/**
|
|
* Create a new routine.
|
|
*/
|
|
async createRoutine(input: RoutineCreateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> {
|
|
this.requireVersionedConfigurationBackend();
|
|
if (!input.name?.trim()) {
|
|
throw new Error("Name is required and cannot be empty");
|
|
}
|
|
|
|
// Validate cron expression if cron trigger
|
|
if (isCronTrigger(input.trigger)) {
|
|
if (!RoutineStore.isValidCron(input.trigger.cronExpression)) {
|
|
throw new Error(`Invalid cron expression: "${input.trigger.cronExpression}"`);
|
|
}
|
|
}
|
|
|
|
const id = randomUUID();
|
|
const now = new Date().toISOString();
|
|
const enabled = input.enabled !== undefined ? input.enabled : true;
|
|
|
|
const routine: Routine = {
|
|
id,
|
|
agentId: input.agentId,
|
|
name: input.name.trim(),
|
|
description: input.description?.trim() || undefined,
|
|
trigger: input.trigger,
|
|
command: input.command?.trim() || undefined,
|
|
steps: input.steps && input.steps.length > 0 ? input.steps : undefined,
|
|
timeoutMs: input.timeoutMs,
|
|
catchUpPolicy: input.catchUpPolicy ?? "run_one",
|
|
executionPolicy: input.executionPolicy ?? "queue",
|
|
enabled,
|
|
runCount: 0,
|
|
runHistory: [],
|
|
scope: input.scope ?? "project",
|
|
createdAt: now,
|
|
updatedAt: now,
|
|
};
|
|
|
|
// Compute nextRunAt for enabled cron routines
|
|
if (enabled && isCronTrigger(routine.trigger)) {
|
|
routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression);
|
|
}
|
|
|
|
/*
|
|
FNXC:ConfigVersioning 2026-07-18-00:30:
|
|
FN-8282 requires routine creation and its immutable snapshot to commit in
|
|
one PostgreSQL transaction, so a failed revision insert cannot expose a
|
|
routine with no restore point.
|
|
*/
|
|
await this.asyncLayer!.transactionImmediate(async (tx) => {
|
|
await upsertRoutineAsync(tx, routine);
|
|
const revision = createConfigurationRevision({
|
|
projectId: this.asyncLayer!.projectId ?? "",
|
|
ownerScope: "project",
|
|
configKind: "routine",
|
|
configTarget: { routineId: routine.id },
|
|
before: null,
|
|
after: routine,
|
|
changedBy,
|
|
});
|
|
if (revision) await appendConfigurationRevision(tx, revision);
|
|
});
|
|
|
|
this.emit("routine:created", routine);
|
|
return routine;
|
|
}
|
|
|
|
/**
|
|
* Get a routine by ID.
|
|
*/
|
|
async getRoutine(id: string): Promise<Routine> {
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:40:
|
|
* Backend-mode: delegate to the async Drizzle getRoutine helper.
|
|
*/
|
|
return getRoutineAsync(this.asyncLayer!.db, id);
|
|
}
|
|
|
|
/**
|
|
* List all routines.
|
|
*/
|
|
async listRoutines(): Promise<Routine[]> {
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:40:
|
|
* Backend-mode: delegate to the async Drizzle listRoutines helper.
|
|
*/
|
|
return listRoutinesAsync(this.asyncLayer!.db);
|
|
}
|
|
|
|
/**
|
|
* Update an existing routine.
|
|
*/
|
|
async updateRoutine(id: string, updates: RoutineUpdateInput, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> {
|
|
this.requireVersionedConfigurationBackend();
|
|
return this.withRoutineLock(id, async () => {
|
|
const routine = await this.getRoutine(id);
|
|
const before = structuredClone(routine);
|
|
|
|
if (updates.name !== undefined) {
|
|
if (!updates.name.trim()) throw new Error("Name cannot be empty");
|
|
routine.name = updates.name.trim();
|
|
}
|
|
if (updates.description !== undefined) {
|
|
routine.description = updates.description?.trim() || undefined;
|
|
}
|
|
if (updates.trigger !== undefined) {
|
|
// Validate cron if switching to cron
|
|
if (isCronTrigger(updates.trigger)) {
|
|
if (!RoutineStore.isValidCron(updates.trigger.cronExpression)) {
|
|
throw new Error(`Invalid cron expression: "${updates.trigger.cronExpression}"`);
|
|
}
|
|
}
|
|
routine.trigger = updates.trigger;
|
|
}
|
|
if (updates.command !== undefined) {
|
|
routine.command = updates.command?.trim() || undefined;
|
|
}
|
|
if (updates.steps !== undefined) {
|
|
routine.steps = updates.steps.length > 0 ? updates.steps : undefined;
|
|
}
|
|
if (updates.timeoutMs !== undefined) {
|
|
routine.timeoutMs = updates.timeoutMs;
|
|
}
|
|
if (updates.catchUpPolicy !== undefined) {
|
|
routine.catchUpPolicy = updates.catchUpPolicy;
|
|
}
|
|
if (updates.executionPolicy !== undefined) {
|
|
routine.executionPolicy = updates.executionPolicy;
|
|
}
|
|
if (updates.enabled !== undefined) {
|
|
routine.enabled = updates.enabled;
|
|
}
|
|
|
|
// Recompute nextRunAt if enabled and cron trigger
|
|
if (routine.enabled && isCronTrigger(routine.trigger)) {
|
|
routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression);
|
|
} else if (!routine.enabled || !isCronTrigger(routine.trigger)) {
|
|
routine.nextRunAt = undefined;
|
|
}
|
|
|
|
routine.updatedAt = new Date().toISOString();
|
|
await this.asyncLayer!.transactionImmediate(async (tx) => {
|
|
await upsertRoutineAsync(tx, routine);
|
|
const revision = createConfigurationRevision({
|
|
projectId: this.asyncLayer!.projectId ?? "",
|
|
ownerScope: "project",
|
|
configKind: "routine",
|
|
configTarget: { routineId: routine.id },
|
|
before,
|
|
after: routine,
|
|
changedBy,
|
|
});
|
|
if (revision) await appendConfigurationRevision(tx, revision);
|
|
});
|
|
|
|
this.emit("routine:updated", routine);
|
|
return routine;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Restore a routine snapshot by stable id, including deletion when the
|
|
* selected revision predates creation. The replacement and forward revision
|
|
* share one backend transaction.
|
|
*/
|
|
async rollbackConfiguration(revisionId: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<ConfigurationRevision> {
|
|
if (!this.backendMode) throw new Error("Configuration rollback requires the PostgreSQL revision store");
|
|
const layer = this.asyncLayer!;
|
|
return layer.transactionImmediate((tx) => rollbackConfiguration(tx, layer.projectId ?? "", revisionId, changedBy, {
|
|
readCurrent: async () => {
|
|
const revision = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId);
|
|
if (!revision || revision.configKind !== "routine") throw new Error(`Routine configuration revision ${revisionId} was not found`);
|
|
try { return await getRoutineAsync(tx, String(revision.configTarget.routineId)); } catch (error) { if ((error as { code?: string }).code === "ENOENT") return null; throw error; }
|
|
},
|
|
replace: async (snapshot) => {
|
|
const target = await getConfigurationRevision(tx, layer.projectId ?? "", revisionId);
|
|
const id = String(target?.configTarget.routineId ?? "");
|
|
if (snapshot === null) await deleteRoutineAsync(tx, id);
|
|
else await upsertRoutineAsync(tx, snapshot as Routine);
|
|
},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Delete a routine.
|
|
*/
|
|
async deleteRoutine(id: string, changedBy: ConfigChangedBy = { kind: "human", id: "local-user" }): Promise<Routine> {
|
|
this.requireVersionedConfigurationBackend();
|
|
return this.withRoutineLock(id, async () => {
|
|
const routine = await this.getRoutine(id);
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:40:
|
|
* Backend-mode: delegate to the async Drizzle deleteRoutine helper.
|
|
*/
|
|
await this.asyncLayer!.transactionImmediate(async (tx) => {
|
|
await deleteRoutineAsync(tx, id);
|
|
const revision = createConfigurationRevision({
|
|
projectId: this.asyncLayer!.projectId ?? "",
|
|
ownerScope: "project",
|
|
configKind: "routine",
|
|
configTarget: { routineId: routine.id },
|
|
before: routine,
|
|
after: null,
|
|
changedBy,
|
|
});
|
|
if (revision) await appendConfigurationRevision(tx, revision);
|
|
});
|
|
|
|
this.emit("routine:deleted", routine);
|
|
return routine;
|
|
});
|
|
}
|
|
|
|
// ── Run Tracking ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Record a run result for a routine. Updates lastRunAt, lastRunResult,
|
|
* nextRunAt, runCount, and appends to runHistory.
|
|
*/
|
|
async recordRun(id: string, result: RoutineExecutionResult): Promise<Routine> {
|
|
return this.withRoutineLock(id, async () => {
|
|
const routine = await this.getRoutine(id);
|
|
|
|
routine.lastRunAt = result.startedAt;
|
|
routine.lastRunResult = result;
|
|
routine.runCount += 1;
|
|
|
|
// Prepend to history (most recent first), cap at MAX_ROUTINE_RUN_HISTORY
|
|
routine.runHistory.unshift(result);
|
|
if (routine.runHistory.length > MAX_ROUTINE_RUN_HISTORY) {
|
|
routine.runHistory = routine.runHistory.slice(0, MAX_ROUTINE_RUN_HISTORY);
|
|
}
|
|
|
|
// Recompute next run if enabled and cron trigger
|
|
if (routine.enabled && isCronTrigger(routine.trigger)) {
|
|
routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression);
|
|
}
|
|
|
|
routine.updatedAt = new Date().toISOString();
|
|
await this.upsertRoutine(routine);
|
|
this.emit("routine:run", { routine, result });
|
|
return routine;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Mark a routine execution as started (pre-run bookkeeping).
|
|
*/
|
|
async startRoutineExecution(
|
|
id: string,
|
|
meta: { triggeredAt: string; catchUpFrom?: string; invocationSource: string },
|
|
): Promise<void> {
|
|
await this.withRoutineLock(id, async () => {
|
|
const routine = await this.getRoutine(id);
|
|
routine.lastRunAt = meta.triggeredAt;
|
|
routine.updatedAt = new Date().toISOString();
|
|
await this.upsertRoutine(routine);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Record the completion (success or failure) of a routine execution.
|
|
*/
|
|
async completeRoutineExecution(
|
|
id: string,
|
|
meta: { completedAt: string; success: boolean; resultJson?: Record<string, unknown>; error?: string; output?: string; triggerType?: RoutineTriggerType; stepResults?: RoutineExecutionResult["stepResults"] },
|
|
): Promise<void> {
|
|
await this.withRoutineLock(id, async () => {
|
|
const routine = await this.getRoutine(id);
|
|
const result: RoutineExecutionResult = {
|
|
routineId: id,
|
|
success: meta.success,
|
|
output: meta.output ?? (meta.success ? JSON.stringify(meta.resultJson ?? {}) : ""),
|
|
error: meta.error,
|
|
startedAt: routine.lastRunAt ?? meta.completedAt,
|
|
completedAt: meta.completedAt,
|
|
triggerType: meta.triggerType,
|
|
stepResults: meta.stepResults,
|
|
};
|
|
|
|
routine.lastRunAt = result.startedAt;
|
|
routine.lastRunResult = result;
|
|
routine.runCount += 1;
|
|
|
|
routine.runHistory.unshift(result);
|
|
if (routine.runHistory.length > MAX_ROUTINE_RUN_HISTORY) {
|
|
routine.runHistory = routine.runHistory.slice(0, MAX_ROUTINE_RUN_HISTORY);
|
|
}
|
|
|
|
if (routine.enabled && isCronTrigger(routine.trigger)) {
|
|
routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression);
|
|
}
|
|
|
|
routine.updatedAt = new Date().toISOString();
|
|
await this.upsertRoutine(routine);
|
|
this.emit("routine:run", { routine, result });
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Cancel a routine execution (no result recorded, just reset state).
|
|
*/
|
|
async cancelRoutineExecution(id: string): Promise<void> {
|
|
await this.withRoutineLock(id, async () => {
|
|
const routine = await this.getRoutine(id);
|
|
if (routine.enabled && isCronTrigger(routine.trigger)) {
|
|
routine.nextRunAt = this.computeNextRun(routine.trigger.cronExpression);
|
|
}
|
|
routine.updatedAt = new Date().toISOString();
|
|
await this.upsertRoutine(routine);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Get all routines that are due to run (nextRunAt <= now and enabled).
|
|
* Filters by scope: "global" or "project".
|
|
*/
|
|
async getDueRoutines(scope: "global" | "project"): Promise<Routine[]> {
|
|
const now = new Date().toISOString();
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:45:
|
|
* Backend-mode: delegate to the async Drizzle getDueRoutines helper.
|
|
*/
|
|
return getDueRoutinesAsync(this.asyncLayer!.db, now, scope);
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
/*
|
|
* FNXC:SqliteFinalRemoval 2026-06-26-10:45:
|
|
* Backend-mode: delegate to the async Drizzle getDueRoutines helper (no scope).
|
|
*/
|
|
return getDueRoutinesAsync(this.asyncLayer!.db, now);
|
|
}
|
|
}
|