feat(automation): add timezone-safe daily briefing dedupe
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user