feat(automation): add CronScheduler channel adapter

Implements CronScheduler as a ChannelAdapter that fires InboundMessages
on cron schedules and routes agent responses to configured output
channels (e.g. Telegram). Includes 9 tests.
This commit is contained in:
William Valentin
2026-02-05 22:22:13 -08:00
parent c4d30fd0d3
commit b9e008ea23
3 changed files with 238 additions and 0 deletions
+103
View File
@@ -0,0 +1,103 @@
import { Cron } from 'croner';
import type { CronJobConfig } from '../config/schema.js';
import type { ChannelAdapter, ChannelStatus, InboundMessage, OutboundMessage } from '../channels/types.js';
/** Minimal interface for the parts of ChannelRegistry we need. */
interface ChannelLookup {
get(name: string): { send(peerId: string, message: OutboundMessage): Promise<void> } | undefined;
}
export class CronScheduler implements ChannelAdapter {
readonly name = 'cron';
private _status: ChannelStatus = 'disconnected';
private messageHandler?: (msg: InboundMessage) => void;
private cronInstances: Map<string, Cron> = new Map();
private jobs: Map<string, CronJobConfig> = new Map();
constructor(
private readonly jobConfigs: CronJobConfig[],
private readonly channelLookup: ChannelLookup,
) {
for (const job of jobConfigs) {
this.jobs.set(job.name, job);
}
}
get status(): ChannelStatus {
return this._status;
}
async connect(): Promise<void> {
this._status = 'connected';
for (const job of this.jobConfigs) {
if (!job.enabled) continue;
const cronInstance = new Cron(job.schedule, {
timezone: job.timezone,
paused: false,
}, () => {
this.triggerJob(job.name);
});
this.cronInstances.set(job.name, cronInstance);
}
const enabledCount = this.jobConfigs.filter(j => j.enabled).length;
if (enabledCount > 0) {
console.log(`CronScheduler: ${enabledCount} job(s) scheduled`);
}
}
async disconnect(): Promise<void> {
for (const [, cron] of this.cronInstances) {
cron.stop();
}
this.cronInstances.clear();
this._status = 'disconnected';
}
async send(peerId: string, message: OutboundMessage): Promise<void> {
// peerId is the cron job name — look up its output config
const job = this.jobs.get(peerId);
if (!job) {
console.warn(`No cron job found for '${peerId}'`);
return;
}
const outputAdapter = this.channelLookup.get(job.output.channel);
if (!outputAdapter) {
console.warn(`Output channel '${job.output.channel}' not found for cron job '${peerId}'`);
return;
}
await outputAdapter.send(job.output.peer, message);
}
onMessage(handler: (msg: InboundMessage) => void): void {
this.messageHandler = handler;
}
/** Manually trigger a job (also called by cron on schedule). */
triggerJob(jobName: string): void {
const job = this.jobs.get(jobName);
if (!job) return;
const msg: InboundMessage = {
id: `cron-${jobName}-${Date.now()}`,
channel: 'cron',
senderId: jobName,
senderName: `cron:${jobName}`,
text: job.message,
timestamp: Date.now(),
metadata: { cronJob: jobName, scheduled: true },
};
this.messageHandler?.(msg);
}
/** Get list of all job names (enabled and disabled). */
getJobNames(): string[] {
return Array.from(this.jobs.keys());
}
}