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
JavaScript
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
};