feat(automation): add timezone-safe daily briefing dedupe

This commit is contained in:
William Valentin
2026-02-16 14:01:14 -08:00
parent 71af3b5a42
commit 56854f04bd
10 changed files with 118 additions and 4 deletions
+35
View File
@@ -14,6 +14,7 @@ function makeCronJob(overrides?: Partial<CronJobConfig>): CronJobConfig {
message: 'Hello from cron',
output: { channel: 'telegram', peer: '123' },
enabled: true,
once_per_local_day: false,
...overrides,
};
}
@@ -176,4 +177,38 @@ describe('CronScheduler', () => {
const names = scheduler.getJobNames();
expect(names).toEqual(['job-a', 'job-b']);
});
it('dedupes once_per_local_day job within same local day', () => {
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
scheduler.triggerJob('test-job');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T18:00:00.000Z').getTime()); // still same local day
scheduler.triggerJob('test-job');
nowSpy.mockRestore();
expect(messages).toHaveLength(1);
});
it('allows once_per_local_day job on next local day', () => {
const jobs = [makeCronJob({ once_per_local_day: true, timezone: 'America/New_York' })];
scheduler = new CronScheduler(jobs, asCronChannelRegistry(mockChannelRegistry));
const messages: InboundMessage[] = [];
scheduler.onMessage((msg: InboundMessage) => messages.push(msg));
const nowSpy = vi.spyOn(Date, 'now');
nowSpy.mockReturnValueOnce(new Date('2026-02-16T13:00:00.000Z').getTime()); // 08:00 local
scheduler.triggerJob('test-job');
nowSpy.mockReturnValueOnce(new Date('2026-02-17T13:00:00.000Z').getTime()); // next local day
scheduler.triggerJob('test-job');
nowSpy.mockRestore();
expect(messages).toHaveLength(2);
});
});
+37
View File
@@ -17,6 +17,7 @@ export class CronScheduler implements ChannelAdapter {
private messageHandler?: (msg: InboundMessage) => void;
private cronInstances: Map<string, Cron> = new Map();
private jobs: Map<string, CronJobConfig> = new Map();
private lastTriggeredLocalDateByJob: Map<string, string> = new Map();
constructor(
private readonly jobConfigs: CronJobConfig[],
@@ -89,6 +90,14 @@ export class CronScheduler implements ChannelAdapter {
triggerJob(jobName: string): void {
const job = this.jobs.get(jobName);
if (!job) {return;}
if (job.once_per_local_day) {
const dayKey = this.localDayKey(Date.now(), job.timezone);
const lastDayKey = this.lastTriggeredLocalDateByJob.get(jobName);
if (lastDayKey === dayKey) {
return;
}
this.lastTriggeredLocalDateByJob.set(jobName, dayKey);
}
const runId = `run-${randomUUID()}`;
const senderId = this.deliveryMode === 'isolated_job' ? `${jobName}:${runId}` : jobName;
@@ -174,9 +183,37 @@ export class CronScheduler implements ChannelAdapter {
}
this.jobs.delete(name);
this.lastTriggeredLocalDateByJob.delete(name);
auditLogger?.cronRemove(name);
return true;
}
private localDayKey(timestamp: number, timezone?: string): string {
const date = new Date(timestamp);
const format = (timeZoneValue?: string): string => {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone: timeZoneValue,
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).formatToParts(date);
const year = parts.find((part) => part.type === 'year')?.value ?? '0000';
const month = parts.find((part) => part.type === 'month')?.value ?? '00';
const day = parts.find((part) => part.type === 'day')?.value ?? '00';
return `${year}-${month}-${day}`;
};
if (!timezone) {
return format();
}
try {
return format(timezone);
} catch {
console.warn(`CronScheduler: invalid timezone '${timezone}' for job local-day dedupe; falling back to system timezone`);
return format();
}
}
}
+20
View File
@@ -12,6 +12,7 @@ describe('buildPresetCronJobs', () => {
enabled: true,
schedule: '0 7 * * *',
timezone: 'America/New_York',
dedupe_per_local_day: true,
output: { channel: 'telegram', peer: '1' },
model_tier: 'fast',
prompt: 'Daily briefing prompt',
@@ -28,9 +29,28 @@ describe('buildPresetCronJobs', () => {
output: { channel: 'telegram', peer: '1' },
model_tier: 'fast',
message: 'Daily briefing prompt',
once_per_local_day: true,
});
});
it('can disable daily briefing per-local-day dedupe', () => {
const config = configSchema.parse({
telegram: { bot_token: 'token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
automation: {
daily_briefing: {
enabled: true,
dedupe_per_local_day: false,
output: { channel: 'telegram', peer: '1' },
},
},
});
const jobs = buildPresetCronJobs(config);
expect(jobs).toHaveLength(1);
expect(jobs[0].once_per_local_day).toBe(false);
});
it('skips daily briefing job when output is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = configSchema.parse({
+1
View File
@@ -23,6 +23,7 @@ export function buildPresetCronJobs(config: Config): CronJobConfig[] {
enabled: true,
timezone: briefing.timezone,
model_tier: briefing.model_tier,
once_per_local_day: briefing.dedupe_per_local_day,
});
}
}
+5
View File
@@ -863,6 +863,7 @@ describe('configSchema automation', () => {
expect(result.automation.daily_briefing.enabled).toBe(false);
expect(result.automation.daily_briefing.schedule).toBe('0 8 * * *');
expect(result.automation.daily_briefing.name).toBe('daily-briefing');
expect(result.automation.daily_briefing.dedupe_per_local_day).toBe(true);
});
it('accepts isolated automation delivery mode', () => {
@@ -931,11 +932,13 @@ describe('configSchema automation', () => {
output: { channel: 'telegram', peer: '123' },
enabled: false,
timezone: 'America/New_York',
once_per_local_day: true,
}],
},
});
expect(result.automation.cron[0].enabled).toBe(false);
expect(result.automation.cron[0].timezone).toBe('America/New_York');
expect(result.automation.cron[0].once_per_local_day).toBe(true);
});
it('accepts daily briefing automation config', () => {
@@ -947,6 +950,7 @@ describe('configSchema automation', () => {
name: 'weekday-briefing',
schedule: '0 7 * * 1-5',
timezone: 'America/New_York',
dedupe_per_local_day: false,
output: { channel: 'telegram', peer: '123' },
prompt: 'Custom briefing prompt',
model_tier: 'fast',
@@ -958,6 +962,7 @@ describe('configSchema automation', () => {
expect(result.automation.daily_briefing.name).toBe('weekday-briefing');
expect(result.automation.daily_briefing.schedule).toBe('0 7 * * 1-5');
expect(result.automation.daily_briefing.timezone).toBe('America/New_York');
expect(result.automation.daily_briefing.dedupe_per_local_day).toBe(false);
expect(result.automation.daily_briefing.output).toEqual({ channel: 'telegram', peer: '123' });
expect(result.automation.daily_briefing.prompt).toBe('Custom briefing prompt');
expect(result.automation.daily_briefing.model_tier).toBe('fast');
+2
View File
@@ -251,6 +251,7 @@ const cronJobSchema = z.object({
enabled: z.boolean().default(true),
timezone: z.string().optional(),
model_tier: modelTierEnum.optional(),
once_per_local_day: z.boolean().default(false),
});
const webhookSchema = z.object({
@@ -350,6 +351,7 @@ const dailyBriefingSchema = z.object({
name: z.string().min(1).default('daily-briefing'),
schedule: z.string().min(1).default('0 8 * * *'),
timezone: z.string().optional(),
dedupe_per_local_day: z.boolean().default(true),
output: z.object({
channel: z.string().min(1),
peer: z.string().min(1),
+7 -1
View File
@@ -24,7 +24,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
const lines = jobNames.map((name) => {
const job = scheduler.getJob(name);
if (!job) {return `- ${name}`;}
return `- **${name}** — schedule: \`${job.schedule}\`, enabled: ${job.enabled}, output: ${job.output.channel}/${job.output.peer}\n message: "${job.message.length > 80 ? job.message.slice(0, 80) + '...' : job.message}"`;
return `- **${name}** — schedule: \`${job.schedule}\`, enabled: ${job.enabled}, output: ${job.output.channel}/${job.output.peer}, once_per_local_day: ${job.once_per_local_day}\n message: "${job.message.length > 80 ? job.message.slice(0, 80) + '...' : job.message}"`;
});
return {
success: true,
@@ -113,6 +113,10 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
type: 'string',
description: 'IANA timezone (e.g. "America/Los_Angeles"). Defaults to system timezone.',
},
once_per_local_day: {
type: 'boolean',
description: 'If true, suppress duplicate triggers within the same local day for this job (uses job timezone).',
},
},
required: ['name', 'schedule', 'message'],
},
@@ -124,6 +128,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
output_channel?: string;
output_peer?: string;
timezone?: string;
once_per_local_day?: boolean;
};
try {
@@ -149,6 +154,7 @@ export function createCronTools(scheduler: CronScheduler): Tool[] {
},
enabled: true,
timezone: args.timezone,
once_per_local_day: args.once_per_local_day ?? false,
});
if (!created) {