Scheduling & Triggers
Scheduled runs with WorkflowManager, cron, webhook and event triggers, and durable timers.
Overview
Three pieces cover "run this later":
WorkflowManagerruns workflows now or at a given time, keeps a record of every run in aRunStore, and cancels, retries and replays runs.- The trigger system activates workflows from cron schedules, HTTP requests or internal events. The
TriggerManagercoordinates all trigger types; you decide inonTriggerFirehow a fired trigger starts a run. TimerManagerfires persisted timers from aTimerStore— in one process, or claimed across several workers with the Redis and Postgres stores.
WorkflowManager
import { createWorkflowManager, PostgresRunStore } from '@cogitator-ai/workflows';
const workflowManager = createWorkflowManager({
cogitator,
runStore: new PostgresRunStore({ client: pool }), // defaults to InMemoryRunStore
maxConcurrency: 4, // scheduled runs at a time
defaultTimeout: 10 * 60_000, // runs fail after 10 minutes unless they set their own timeout
onRunStateChange: (run) => console.log(run.id, run.status),
});
workflowManager.start(); // required for scheduled runs
const result = await workflowManager.execute(
reportWorkflow,
{ day: '2026-10-01' },
{ tags: ['daily'] }
);
const runId = await workflowManager.schedule(reportWorkflow, {
at: Date.now() + 60_000,
input: { day: '2026-10-02' },
priority: 10,
tags: ['daily'],
});Every run is a WorkflowRun record with status (pending, scheduled, running, paused, completed, failed, cancelled, ...), state, input, error, timestamps, tags, priority and the currentNodes, completedNodes and failedNodes — recorded both for runs started with execute() and for runs the manager starts from its schedule.
const run = await workflowManager.getStatus(runId);
const failed = await workflowManager.listRuns({ status: 'failed', tags: ['daily'], limit: 20 });
const stats = await workflowManager.getStats('daily-report'); // byStatus, avgDuration, successRate
await workflowManager.cancel(runId, 'No longer needed');
const retryId = await workflowManager.retry(failedRunId); // new run with the same input, tagged 'retry'
await workflowManager.cleanup(Date.now() - 30 * 24 * 60 * 60 * 1000);schedule()withcron(andtimezone) queues one run at the next occurrence. For a recurring schedule useregisterCronJob(below) or fireschedule()/execute()from a cron trigger.- Scheduled runs wait in an in-process priority queue (highest
priorityfirst) that the manager checks every second; theRunStorekeeps their records. Callstop()/dispose()on shutdown. retry(),resume()and scheduled runs need the workflow registered in this manager —execute(),schedule()andregisterCronJob()register it, or callregisterWorkflow(workflow)after a restart.execute()accepts the executor options (defaultRetry,approvalStore,timerStore, the run callbacks, ...) pluspriority,tags,metadata,tracingandmetrics. A run'sworkflowId(and soctx.workflowId) defaults to its run id.- Runs the manager starts from its schedule get the same infrastructure as
execute()runs: checkpoints when the manager has acheckpointStore, the manager'stracerandmetrics, and thetimeoutof theirScheduleOptions(elsedefaultTimeout). WithmaxRetries, a scheduled run that fails is retried automatically up to that many times; each retry is a new run whosemetadata.retryAttemptcounts the attempts.
Recurring runs
const jobId = workflowManager.registerCronJob(reportWorkflow, '0 9 * * *', {
timezone: 'Europe/London',
jobOptions: { input: { day: 'today' }, tags: ['daily'], timeout: 10 * 60_000, maxRetries: 2 },
});
workflowManager.setCronJobEnabled(jobId, false); // pause the job; true picks the next occurrence again
console.log(workflowManager.getCronJobs()); // { id, workflowName, expression, timezone, nextRun, enabled, ... }
workflowManager.unregisterCronJob(jobId); // runs already queued are keptregisterCronJob(workflow, expression, { id?, timezone?, jobOptions? }) returns the job id. While the manager is started, every occurrence queues a run with jobOptions (any ScheduleOptions except at, cron and timezone) and triggerId: 'cron:<jobId>'. Cron jobs live in the manager's memory: register them again after a restart.
Pause and resume
await workflowManager.pause(runId); // status 'paused'
await workflowManager.resume(runId); // resolves once the run is 'running' againpause() needs a manager with a checkpointStore (it throws otherwise — use cancel() to stop a run for good) and a run in status running. It aborts the run — nodes in flight get the abort signal — and the run stays paused; cancel() likewise keeps cancelled. resume(runId, options?) continues a paused run from its last checkpoint (from the start if it was paused before the first one), so nodes completed before that checkpoint do not run again. It resolves as soon as the run is running again; follow the rest with onRunStateChange or getStatus. options default to the ones the run was started with in this process, and resume() throws while the nodes that were running at the pause have not finished yet.
Trigger Manager
The DefaultTriggerManager is the central coordinator for all trigger types. onTriggerFire receives the trigger and a TriggerContext (triggerType, timestamp, payload, headers, metadata) and returns a run id:
import {
createTriggerManager,
cronTrigger,
webhookTrigger,
eventTrigger,
} from '@cogitator-ai/workflows';
const manager = createTriggerManager({
onTriggerFire: async (trigger, context) =>
workflowManager.schedule(reportWorkflow, {
input: context.payload as Partial<ReportState>,
triggerId: trigger.id,
}),
});
manager.start();register, fire and handleWebhook throw until start() is called. Triggers live in a TriggerStore (InMemoryTriggerStore by default; pass store to keep them elsewhere) and events go through a TriggerEventEmitter (eventEmitter option). registerWorkflow(workflow, [{ type, config }, ...]) registers several triggers for one workflow at once.
Registering Triggers
const cronId = await manager.register({
workflowName: 'daily-report',
type: 'cron',
config: cronTrigger('0 9 * * *', { timezone: 'America/New_York' }),
enabled: true,
});
const webhookId = await manager.register({
workflowName: 'pr-review',
type: 'webhook',
config: webhookTrigger('/github/pr', 'POST', {
auth: { type: 'hmac', secret: process.env.GITHUB_SECRET!, algorithm: 'sha256' },
}),
enabled: true,
});
const eventId = await manager.register({
workflowName: 'order-processing',
type: 'event',
config: eventTrigger('order.created', {
filter: (payload) => (payload as { amount: number }).amount > 100,
}),
enabled: true,
});Managing Triggers
await manager.enable(triggerId);
await manager.disable(triggerId);
await manager.unregister(triggerId);
const trigger = await manager.get(triggerId);
const allTriggers = await manager.list('daily-report');
const enabled = await manager.listEnabled();Manual Fire
await manager.fire(triggerId, {
payload: { forced: true, reason: 'manual test' },
});Stats
const stats = await manager.getStats();
console.log(`Total triggers: ${stats.total}`);
console.log(`Enabled: ${stats.enabled}`);
console.log(`Total fired: ${stats.totalFired}`);
console.log(`By type:`, stats.byType);Subscribing to Events
const unsubscribe = manager.onTrigger((trigger, context) => {
console.log(`Trigger fired: ${trigger.workflowName} (${trigger.type})`);
console.log(`Payload:`, context.payload);
});Cron Triggers
Schedule workflows with cron expressions. Supports standard 5-field cron syntax (and a leading seconds field), presets such as @daily, @hourly, @weekdays or @every_15_minutes (CRON_PRESETS), timezones and concurrency limits. Registering an invalid expression throws.
Basic Cron
import { cronTrigger } from '@cogitator-ai/workflows';
const config = cronTrigger('0 9 * * *');
const everyMinute = cronTrigger('* * * * *');
const hourly = cronTrigger('0 * * * *');
const weekdays9am = cronTrigger('0 9 * * 1-5');
const firstOfMonth = cronTrigger('0 0 1 * *');Cron Options
const config = cronTrigger('0 9 * * *', {
timezone: 'Europe/London',
enabled: true,
runImmediately: false,
maxConcurrent: 1,
catchUp: true,
input: { reportType: 'daily' },
condition: (ctx) => {
const hour = new Date(ctx.timestamp).getHours();
return hour >= 6 && hour <= 22;
},
});| Option | Type | Description |
|---|---|---|
timezone | string | IANA timezone (e.g., 'America/New_York') |
enabled | boolean | Whether the trigger is active (cronTrigger sets true) |
runImmediately | boolean | Fire once immediately on registration |
maxConcurrent | number | Skip an occurrence while this many onTriggerFire calls are still running |
catchUp | boolean | Fire every occurrence a late timer missed (sleep, a blocked event loop), and allow catchUp(triggerId, since) replays |
input | object | function | Static or per-fire input, delivered as context.payload |
condition | (ctx) => boolean | Additional gate before firing |
Cron Utilities
The package includes standalone cron parsing utilities:
import {
parseCronExpression,
getNextCronOccurrence,
getNextCronOccurrences,
describeCronExpression,
isValidCronExpression,
CRON_PRESETS,
} from '@cogitator-ai/workflows';
const isValid = isValidCronExpression('0 9 * * *');
const next = getNextCronOccurrence('0 9 * * *', {
timezone: 'America/New_York',
});
const nextFive = getNextCronOccurrences('*/15 * * * *', 5);
const description = describeCronExpression('0 9 * * 1-5');
const parsed = parseCronExpression(CRON_PRESETS['@weekdays']);validateCronExpression returns { valid, error?, normalized? }; getPreviousCronOccurrence, cronMatchesDate, msUntilNextCronOccurrence, createCronIterator, getSupportedTimezones and isValidTimezone round out the set.
Direct CronTriggerExecutor
For lower-level control without the full TriggerManager (the manager uses one internally):
import { createCronTrigger } from '@cogitator-ai/workflows';
const cronExecutor = createCronTrigger({
onFire: async (trigger, context) => {
console.log(`Cron fired for ${trigger.workflowName}`);
return 'run_123';
},
});
const triggerId = cronExecutor.register('nightly-cleanup', {
expression: '0 2 * * *',
timezone: 'UTC',
enabled: true,
catchUp: true,
});
const state = cronExecutor.getState(triggerId);
console.log(`Next run: ${new Date(state!.nextRun!).toISOString()}`);
const manual = await cronExecutor.fire(triggerId); // { triggered, runId?, skipped?, reason?, nextRun? }Missed Runs
With catchUp: true, a timer that fires so late that later occurrences have passed too (the machine slept, the event loop was blocked) fires each missed occurrence (at most 100) with its own timestamp instead of skipping them; without it only the due occurrence fires.
Occurrences missed while the process was down are not replayed automatically. Keep the time of the last run yourself and call catchUp on a trigger registered with catchUp: true; it fires every occurrence between since and now (at most 100) with its historical timestamp:
const results = await cronExecutor.catchUp(triggerId, lastRunAt);Webhook Triggers
Activate workflows from HTTP requests with built-in authentication, rate limiting, payload validation, and deduplication.
Basic Webhook
import { webhookTrigger } from '@cogitator-ai/workflows';
const config = webhookTrigger('/api/deploy', 'POST');Authentication
Four auth methods are supported (plus type: 'none'); every comparison is constant-time:
const bearerAuth = webhookTrigger('/api/trigger', 'POST', {
auth: { type: 'bearer', secret: 'my-secret-token' },
});
const hmacAuth = webhookTrigger('/github/webhook', 'POST', {
auth: {
type: 'hmac',
secret: process.env.WEBHOOK_SECRET!,
algorithm: 'sha256',
headerName: 'x-hub-signature-256',
},
});
const apiKeyAuth = webhookTrigger('/api/trigger', 'POST', {
auth: { type: 'api-key', secret: 'key_abc123', headerName: 'x-api-key' },
});
const basicAuth = webhookTrigger('/api/trigger', 'POST', {
auth: { type: 'basic', secret: 'user:password' },
});bearerexpectsAuthorization: Bearer <secret>,basiccompares the decodedAuthorization: Basiccredentials withsecret.hmacreads the signature fromheaderName(defaultx-signature), accepts asha256=/sha512=prefix, and signsrawBodywhen you pass it — pass the raw request body, since a re-serialized JSON body rarely matches the sender's bytes.api-keycompares theheaderNameheader (defaultx-api-key).
A failed check answers 401.
Rate Limiting
const config = webhookTrigger('/api/trigger', 'POST', {
rateLimit: {
requests: 100,
window: 60000,
burstLimit: 10,
},
});Rate limiting is a token bucket per client, keyed by the request's ip (requests without one share a bucket). When exceeded, the webhook returns HTTP 429 with Retry-After, X-RateLimit-Remaining and X-RateLimit-Reset headers. TokenBucket, RateLimiter and SlidingWindowRateLimiter are exported for your own endpoints.
Payload Handling
const config = webhookTrigger('/api/process', 'POST', {
validatePayload: (body) => {
const data = body as { type: string; id: string };
return !!data.type && !!data.id;
},
transformPayload: (body) => {
const data = body as { type: string; id: string; extra: unknown };
return { eventType: data.type, entityId: data.id };
},
deduplicationKey: (body) => (body as { id: string }).id,
deduplicationWindow: 60000,
});A duplicate within the window answers 200 with { deduplicated: true }, a payload that fails validatePayload answers 400, and an accepted request answers 202 with { accepted: true, runId, triggerId }.
Handling Requests
Route incoming HTTP requests to the webhook executor via the TriggerManager:
const result = await manager.handleWebhook({
method: 'POST',
path: '/github/webhook',
headers: request.headers,
body: request.body,
rawBody: request.rawBody,
ip: request.ip,
});
if (result?.triggered) {
response.status(202).json({ runId: result.runId });
} else {
response.status(result?.response.status ?? 404).json(result?.response.body);
}handleWebhook returns null when no enabled webhook trigger matches the method and path.
Event Triggers
React to internal application events. Useful for chaining workflows or integrating with your domain events. source matches the source field of the event payload, filter drops events, and transform shapes the payload passed to onTriggerFire.
import { eventTrigger } from '@cogitator-ai/workflows';
const config = eventTrigger('order.created', {
source: 'checkout-service',
filter: (payload) => (payload as { priority: string }).priority === 'high',
transform: (payload) => ({
orderId: (payload as { id: string }).id,
priority: 'high',
}),
});Emitting Events
manager.emitEvent('order.created', {
id: 'order_789',
priority: 'high',
source: 'checkout-service',
amount: 250,
});Durable Timers
Timer nodes (delayNode, cronWaitNode, ... with timerWorkflowNode, see Node Types) wait inside the running workflow. A TimerManager is for timers that must fire even if the process that scheduled them is gone: it polls a TimerStore for overdue timers and hands each one to a handler.
import { createTimerManager, PostgresTimerStore, Duration } from '@cogitator-ai/workflows';
const timers = createTimerManager(new PostgresTimerStore({ client: pool, claimTtl: 60_000 }), {
pollInterval: 1000,
maxRetries: 5, // consecutive handler errors before the timer is dead-lettered
onDeadLetter: (entry, error) => console.error(entry.id, error.message),
onClaimLost: (entry) => console.warn('another worker may run', entry.id),
});
timers.registerHandler('invoice-reminders', async (entry) => {
await workflowManager.execute(reminderWorkflow, entry.metadata as Partial<ReminderState>);
});
await timers.start();
await timers.schedule({
workflowId: 'invoice-reminders',
runId: 'inv_42',
nodeId: 'reminder',
type: 'fixed',
firesAt: Date.now() + Duration.days(3),
metadata: { invoiceId: 'inv_42' },
});- Handlers are looked up by the timer's
workflowId;setDefaultHandlercatches the rest. A manager without a matching handler releases the timer so a manager that has one runs it right away. - With
InMemoryTimerStore/FileTimerStoreone manager owns the store. WithRedisTimerStore/PostgresTimerStoreseveral managers can poll one store:getOverdue()claims the timers it returns forclaimTtlms (default 60 s), the manager renews the claim everyclaimTtl / 3while the handler runs, and a timer whose worker died becomes available again when its claim runs out. - A failed handler is retried on later polls until
maxRetriesconsecutive errors (default5;0retries forever), then the timer is marked fired andonDeadLetteris called. cancel,cancelRun,cancelWorkflow,getPendingTimers,getStatsandprocessNow()manage timers; fired timers older thancleanupMaxAge(7 days) are cleaned up hourly.createRecurringScheduler(timers).scheduleRecurring(id, { workflowId, runId, nodeId, interval })schedules an interval timer; callscheduleNext(id, runId)from its handler to queue the next one.Duration.minutes(5),parseDuration('2h')andformatDuration(ms)convert durations.
Lifecycle
const manager = createTriggerManager({ onTriggerFire: handler });
manager.start();
// ... register triggers, handle webhooks, emit events ...
manager.dispose();Always call .dispose() to clean up timers, intervals, and rate limiter resources.