From 56854f04bd4775217d0d184a0534ebb71fa4b777 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Mon, 16 Feb 2026 14:01:14 -0800 Subject: [PATCH] feat(automation): add timezone-safe daily briefing dedupe --- README.md | 3 +++ config/default.yaml | 2 ++ docs/plans/state.json | 9 ++++++--- src/automation/cron.test.ts | 35 ++++++++++++++++++++++++++++++++ src/automation/cron.ts | 37 ++++++++++++++++++++++++++++++++++ src/automation/presets.test.ts | 20 ++++++++++++++++++ src/automation/presets.ts | 1 + src/config/schema.test.ts | 5 +++++ src/config/schema.ts | 2 ++ src/tools/builtin/cron.ts | 8 +++++++- 10 files changed, 118 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index eea5b87..f18d1df 100644 --- a/README.md +++ b/README.md @@ -532,6 +532,7 @@ automation: peer: "123456789" # Chat ID to send to timezone: Europe/London # Optional timezone enabled: true + once_per_local_day: false # Optional dedupe: max one trigger/day in job timezone - name: hourly-check schedule: "0 * * * *" # Every hour @@ -549,6 +550,7 @@ automation: name: daily-briefing schedule: "0 8 * * *" timezone: America/New_York + dedupe_per_local_day: true output: channel: telegram peer: "123456789" @@ -571,6 +573,7 @@ automation: | `timezone` | no | IANA timezone (defaults to system timezone) | | `enabled` | no | Whether the job is active (default: `true`) | | `model_tier` | no | Model tier for this job: `fast`, `default`, `complex`, or `local` | +| `once_per_local_day` | no | If true, suppress duplicate triggers within the same local day (job timezone) | | `automation.daily_briefing.*` | no | Built-in daily briefing preset; generates an extra cron job when `enabled: true` and `output` is set | ## Backup Scheduling diff --git a/config/default.yaml b/config/default.yaml index 2c0ede4..54a09ce 100644 --- a/config/default.yaml +++ b/config/default.yaml @@ -244,6 +244,7 @@ hooks: # output: # channel: telegram # peer: "123456789" +# once_per_local_day: false # # # Optional built-in morning briefing job (auto-registered as a cron job) # daily_briefing: @@ -251,6 +252,7 @@ hooks: # name: daily-briefing # schedule: "0 8 * * *" # timezone: America/New_York +# dedupe_per_local_day: true # output: # channel: telegram # peer: "123456789" diff --git a/docs/plans/state.json b/docs/plans/state.json index 981598c..c6f474c 100644 --- a/docs/plans/state.json +++ b/docs/plans/state.json @@ -7,13 +7,15 @@ "status": "completed", "date": "2026-02-16", "updated": "2026-02-16", - "summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback. Added `BackupScheduler` with `backup.notify` channel alerts, configurable `backup.failure_threshold`, and recovery notifications (`backup.notify_recovery`) so backup failures/recoveries proactively notify operators. Extended heartbeat monitoring with `process_memory`, `backup`, and `provider_errors` checks (with thresholds) so high RSS usage, backup failure streaks, and model-provider error spikes proactively trigger health alerts.", + "summary": "Added first-class automation presets and scheduling upgrades: `automation.daily_briefing` now auto-registers an opinionated cron job for morning briefings, and backup scheduling now supports cron expressions via `backup.schedule` plus optional `backup.run_on_start` while preserving interval fallback. Added `BackupScheduler` with `backup.notify` channel alerts, configurable `backup.failure_threshold`, and recovery notifications (`backup.notify_recovery`) so backup failures/recoveries proactively notify operators. Extended heartbeat monitoring with `process_memory`, `backup`, and `provider_errors` checks (with thresholds) so high RSS usage, backup failure streaks, and model-provider error spikes proactively trigger health alerts. Added timezone-safe daily briefing dedupe via `automation.daily_briefing.dedupe_per_local_day` and cron-level `once_per_local_day` so morning briefings do not send twice on the same local day.", "files_modified": [ "src/config/schema.ts", "src/config/schema.test.ts", "src/automation/index.ts", "src/automation/presets.ts", "src/automation/presets.test.ts", + "src/automation/cron.ts", + "src/automation/cron.test.ts", "src/automation/heartbeat.ts", "src/automation/heartbeat.test.ts", "src/backup/index.ts", @@ -27,10 +29,11 @@ "src/daemon/services.ts", "src/gateway/handlers/services.ts", "src/gateway/handlers/services.test.ts", + "src/tools/builtin/cron.ts", "config/default.yaml", "README.md" ], - "test_status": "pnpm test:run src/automation/presets.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck passing" + "test_status": "pnpm test:run src/automation/presets.test.ts src/automation/cron.test.ts src/automation/heartbeat.test.ts src/backup/scheduler.test.ts src/backup/status.test.ts src/config/schema.test.ts src/daemon/channels.test.ts src/gateway/handlers/services.test.ts + pnpm typecheck passing" }, "backup-session-summary-audit-trail": { "status": "completed", @@ -3316,7 +3319,7 @@ } }, "overall_progress": { - "total_test_count": 1832, + "total_test_count": 1844, "all_tests_passing": true, "p0_completion": "3/3 (100%)", "p1_completion": "4/4 (100%)", diff --git a/src/automation/cron.test.ts b/src/automation/cron.test.ts index 08a4785..929380f 100644 --- a/src/automation/cron.test.ts +++ b/src/automation/cron.test.ts @@ -14,6 +14,7 @@ function makeCronJob(overrides?: Partial): 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); + }); }); diff --git a/src/automation/cron.ts b/src/automation/cron.ts index a25f49e..b92221f 100644 --- a/src/automation/cron.ts +++ b/src/automation/cron.ts @@ -17,6 +17,7 @@ export class CronScheduler implements ChannelAdapter { private messageHandler?: (msg: InboundMessage) => void; private cronInstances: Map = new Map(); private jobs: Map = new Map(); + private lastTriggeredLocalDateByJob: Map = 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(); + } + } } diff --git a/src/automation/presets.test.ts b/src/automation/presets.test.ts index 36aba2b..2e43253 100644 --- a/src/automation/presets.test.ts +++ b/src/automation/presets.test.ts @@ -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({ diff --git a/src/automation/presets.ts b/src/automation/presets.ts index 36e9a7e..4f05557 100644 --- a/src/automation/presets.ts +++ b/src/automation/presets.ts @@ -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, }); } } diff --git a/src/config/schema.test.ts b/src/config/schema.test.ts index cc08337..10bb3d2 100644 --- a/src/config/schema.test.ts +++ b/src/config/schema.test.ts @@ -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'); diff --git a/src/config/schema.ts b/src/config/schema.ts index 748eb29..fbd2014 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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), diff --git a/src/tools/builtin/cron.ts b/src/tools/builtin/cron.ts index b5c760e..c5b04ca 100644 --- a/src/tools/builtin/cron.ts +++ b/src/tools/builtin/cron.ts @@ -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) {