UNPKG

trellis

Version:

Agentic State Engine — event-sourced causal graph with branching, decision traces, and realtime sync for AI-native applications

420 lines (412 loc) 11.9 kB
import { DEFAULT_TENANT } from "./chunk-GA6RZXIK.js"; import { defineType } from "./chunk-E4T2QHKA.js"; import { PROVENANCE, init_canonical_op } from "./chunk-RUMOVKR4.js"; // src/plugins/cron/plugin.ts init_canonical_op(); // src/plugins/cron/ontology.ts import { z } from "zod"; var CronJobType = defineType( "CronJob", { name: z.string().min(1), enabled: z.boolean().default(true), intervalMs: z.number().min(500), handler: z.string().min(1), payload: z.unknown().optional(), nextRunAt: z.string().optional(), lastRunAt: z.string().optional(), leaseOwner: z.string().optional(), leaseExpiresAt: z.string().optional(), lastError: z.string().optional(), timezone: z.string().optional() }, { title: "name", label: "Cron Job" } ); var CronRunType = defineType( "CronRun", { jobId: z.string().min(1), startedAt: z.string(), finishedAt: z.string(), status: z.enum(["ok", "error", "skipped"]), error: z.string().optional(), result: z.unknown().optional() }, { title: "jobId", label: "Cron Run" } ); var cronOntology = { id: "trellis:cron", name: "Cron", description: "Graph-native scheduled jobs (ADR 0019)", version: "1.0.0", entities: [ { name: "CronJob", description: "Durable schedule definition", attributes: [ { name: "name", type: "string", required: true }, { name: "enabled", type: "boolean" }, { name: "intervalMs", type: "number", required: true }, { name: "handler", type: "string", required: true }, { name: "payload", type: "any" }, { name: "nextRunAt", type: "string" }, { name: "lastRunAt", type: "string" }, { name: "leaseOwner", type: "string" }, { name: "leaseExpiresAt", type: "string" }, { name: "lastError", type: "string" }, { name: "timezone", type: "string" } ] }, { name: "CronRun", description: "One execution of a CronJob", attributes: [ { name: "jobId", type: "string", required: true }, { name: "startedAt", type: "string", required: true }, { name: "finishedAt", type: "string", required: true }, { name: "status", type: "string", required: true }, { name: "error", type: "string" }, { name: "result", type: "any" } ] } ], relations: [] }; // src/plugins/cron/cron-expr.ts var MIN_INTERVAL_MS = 500; function assertIntervalMs(intervalMs) { const n = typeof intervalMs === "number" ? intervalMs : Number(intervalMs); if (!Number.isFinite(n) || n < MIN_INTERVAL_MS) { throw new Error(`intervalMs must be a number >= ${MIN_INTERVAL_MS}`); } return n; } function nextRunAtFromInterval(intervalMs, fromMs = Date.now()) { return new Date(fromMs + assertIntervalMs(intervalMs)).toISOString(); } function isDue(nextRunAt, nowMs) { if (!nextRunAt) return true; const t = Date.parse(nextRunAt); if (!Number.isFinite(t)) return true; return t <= nowMs; } function leaseIsLive(leaseExpiresAt, nowMs) { if (!leaseExpiresAt) return false; const t = Date.parse(leaseExpiresAt); if (!Number.isFinite(t)) return false; return t > nowMs; } // src/plugins/cron/handlers.ts function createBuiltinHandlers() { return { "builtin:ping": async (job) => ({ ping: true, jobId: job.id, at: (/* @__PURE__ */ new Date()).toISOString() }), "builtin:counter": async (job, ctx) => { const payload = job.payload ?? {}; const targetId = payload.targetId; if (!targetId) { throw new Error("builtin:counter requires payload.targetId"); } const entity = await ctx.getEntity(targetId); if (!entity) { throw new Error(`counter target not found: ${targetId}`); } const prev = Number(entity.count ?? 0); const count = Number.isFinite(prev) ? prev + 1 : 1; await ctx.updateEntity(targetId, { count }); return { targetId, count }; } }; } // src/plugins/cron/scheduler.ts var DEFAULT_TICK_MS = 1e3; var DEFAULT_LEASE_MS = 3e4; var CronScheduler = class { store; tickMs; leaseMs; ownerId; now; handlers = /* @__PURE__ */ new Map(); timer = null; ticking = false; jobCount = 0; constructor(opts) { this.store = opts.store; this.tickMs = opts.tickMs ?? DEFAULT_TICK_MS; this.leaseMs = opts.leaseMs ?? DEFAULT_LEASE_MS; this.ownerId = opts.ownerId ?? `cron:${crypto.randomUUID().slice(0, 8)}`; this.now = opts.now ?? (() => Date.now()); for (const [id, fn] of Object.entries(createBuiltinHandlers())) { this.handlers.set(id, fn); } } registerHandler(id, fn) { this.handlers.set(id, fn); } start() { if (this.timer) return; this.timer = setInterval(() => { void this.tick().catch((err) => { console.error("[trellis/cron] tick error:", err); }); }, this.tickMs); if (typeof this.timer.unref === "function") { this.timer.unref(); } } stop() { if (this.timer) { clearInterval(this.timer); this.timer = null; } } getStatus() { return { running: this.timer !== null, tickMs: this.tickMs, jobCount: this.jobCount }; } /** One scheduler pass — used by the interval and by unit tests. */ async tick() { if (this.ticking) return; this.ticking = true; try { const nowMs = this.now(); const jobs = await this.store.listJobs(); this.jobCount = jobs.length; for (const job of jobs) { await this.processJob(job, nowMs); } } finally { this.ticking = false; } } async processJob(job, nowMs) { if (!job.enabled) return; if (!isDue(job.nextRunAt, nowMs)) return; if (leaseIsLive(job.leaseExpiresAt, nowMs)) { await this.store.createRun({ jobId: job.id, startedAt: new Date(nowMs).toISOString(), finishedAt: new Date(nowMs).toISOString(), status: "skipped", result: { reason: "lease_held" } }); return; } let intervalMs; try { intervalMs = assertIntervalMs(job.intervalMs); } catch (err) { const message = err instanceof Error ? err.message : String(err); await this.failJob(job, nowMs, message); return; } const leaseExpiresAt = new Date(nowMs + this.leaseMs).toISOString(); await this.store.updateJob(job.id, { leaseOwner: this.ownerId, leaseExpiresAt }); const startedAt = new Date(nowMs).toISOString(); const handler = this.handlers.get(job.handler); if (!handler) { await this.failJob(job, nowMs, `unknown handler: ${job.handler}`, startedAt); return; } try { const result = await handler(job, this.store); const finishedMs = this.now(); const finishedAt = new Date(finishedMs).toISOString(); await this.store.createRun({ jobId: job.id, startedAt, finishedAt, status: "ok", result }); await this.store.updateJob(job.id, { lastRunAt: finishedAt, nextRunAt: nextRunAtFromInterval(intervalMs, finishedMs), lastError: void 0, leaseOwner: void 0, leaseExpiresAt: void 0 }); } catch (err) { const message = err instanceof Error ? err.message : String(err); await this.failJob(job, this.now(), message, startedAt, intervalMs); } } async failJob(job, nowMs, message, startedAt, intervalMs) { const finishedAt = new Date(nowMs).toISOString(); await this.store.createRun({ jobId: job.id, startedAt: startedAt ?? finishedAt, finishedAt, status: "error", error: message }); const patch = { lastRunAt: finishedAt, lastError: message, leaseOwner: void 0, leaseExpiresAt: void 0 }; try { const ms = intervalMs ?? assertIntervalMs(job.intervalMs); patch.nextRunAt = nextRunAtFromInterval(ms, nowMs); } catch { } await this.store.updateJob(job.id, patch); } }; // src/plugins/cron/plugin.ts var CRON_CTX = { provenance: PROVENANCE.cron }; function factsToAttrs(entity) { const obj = { id: entity.id, type: entity.type }; for (const f of entity.facts) { if (f.a !== "type") obj[f.a] = f.v; } return obj; } function toJobRecord(raw) { return { id: String(raw.id), name: String(raw.name ?? raw.id), enabled: raw.enabled !== false, intervalMs: Number(raw.intervalMs ?? 0), handler: String(raw.handler ?? ""), payload: raw.payload, nextRunAt: raw.nextRunAt != null ? String(raw.nextRunAt) : void 0, lastRunAt: raw.lastRunAt != null ? String(raw.lastRunAt) : void 0, leaseOwner: raw.leaseOwner != null ? String(raw.leaseOwner) : void 0, leaseExpiresAt: raw.leaseExpiresAt != null ? String(raw.leaseExpiresAt) : void 0, lastError: raw.lastError != null ? String(raw.lastError) : void 0, timezone: raw.timezone != null ? String(raw.timezone) : void 0 }; } function createKernelCronStore(kernel) { return { async listJobs() { return kernel.listEntities("CronJob").map((e) => toJobRecord(factsToAttrs(e))); }, async updateJob(id, attrs) { const patch = { ...attrs }; for (const key of ["leaseOwner", "leaseExpiresAt", "lastError"]) { if (key in attrs && attrs[key] === void 0) { patch[key] = ""; } } delete patch.id; await kernel.updateEntity(id, patch, CRON_CTX); }, async createRun(attrs) { const id = `cronrun:${crypto.randomUUID()}`; await kernel.createEntity(id, "CronRun", attrs, void 0, CRON_CTX); return id; }, async getEntity(id) { const e = kernel.getEntity(id); return e ? factsToAttrs(e) : null; }, async updateEntity(id, attrs) { await kernel.updateEntity(id, attrs, CRON_CTX); } }; } function createPoolCronStore(pool) { return createKernelCronStore(pool.get(DEFAULT_TENANT)); } function createCronPlugin(kernel, opts = {}) { const store = createKernelCronStore(kernel); const scheduler = new CronScheduler({ store, tickMs: opts.tickMs, leaseMs: opts.leaseMs, ownerId: opts.ownerId }); return { id: "trellis:cron", name: "Cron", version: "1.0.0", description: "Graph-native scheduled jobs (ADR 0019)", ontologies: [cronOntology], scheduler, onLoad: async (ctx) => { ctx.log("Cron plugin loaded"); if (opts.autoStart !== false) { scheduler.start(); } }, onUnload: async (ctx) => { scheduler.stop(); ctx.log("Cron plugin unloaded"); } }; } function attachCronToPool(pool, opts = {}) { if (process.env.TRELLIS_CRON === "0") { return null; } const store = createPoolCronStore(pool); const scheduler = new CronScheduler({ store, tickMs: opts.tickMs, leaseMs: opts.leaseMs, ownerId: opts.ownerId ?? "cron:db-serve" }); scheduler.start(); return scheduler; } async function ensureDemoPingJob(store) { const jobs = await store.listJobs(); if (jobs.some((j) => j.id === "cron:demo-ping")) return; } async function seedDemoPingJob(kernel) { const existing = kernel.getEntity("cron:demo-ping"); if (existing) return "cron:demo-ping"; const now = Date.now(); await kernel.createEntity( "cron:demo-ping", "CronJob", { name: "demo-ping", enabled: true, intervalMs: 5e3, handler: "builtin:ping", nextRunAt: nextRunAtFromInterval(5e3, now) }, void 0, CRON_CTX ); return "cron:demo-ping"; } export { CronJobType, CronRunType, cronOntology, MIN_INTERVAL_MS, assertIntervalMs, nextRunAtFromInterval, isDue, leaseIsLive, createBuiltinHandlers, CronScheduler, createKernelCronStore, createPoolCronStore, createCronPlugin, attachCronToPool, ensureDemoPingJob, seedDemoPingJob };