feat(automation): add daily briefing preset and cron backup scheduling

This commit is contained in:
William Valentin
2026-02-16 13:42:18 -08:00
parent 52231b7a93
commit ce621d1b72
13 changed files with 350 additions and 45 deletions
+1
View File
@@ -3,3 +3,4 @@ export { WebhookHandler } from './webhooks.js';
export { GmailWatcher } from './gmail.js';
export { HeartbeatMonitor, parseInterval } from './heartbeat.js';
export type { HeartbeatResult, HeartbeatDeps, CheckResult } from './heartbeat.js';
export { buildPresetCronJobs } from './presets.js';
+76
View File
@@ -0,0 +1,76 @@
import { describe, expect, it, vi } from 'vitest';
import { configSchema } from '../config/schema.js';
import { buildPresetCronJobs } from './presets.js';
describe('buildPresetCronJobs', () => {
it('creates a daily briefing preset cron job when enabled with output', () => {
const config = configSchema.parse({
telegram: { bot_token: 'token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
automation: {
daily_briefing: {
enabled: true,
schedule: '0 7 * * *',
timezone: 'America/New_York',
output: { channel: 'telegram', peer: '1' },
model_tier: 'fast',
prompt: 'Daily briefing prompt',
},
},
});
const jobs = buildPresetCronJobs(config);
expect(jobs).toHaveLength(1);
expect(jobs[0]).toMatchObject({
name: 'daily-briefing',
schedule: '0 7 * * *',
timezone: 'America/New_York',
output: { channel: 'telegram', peer: '1' },
model_tier: 'fast',
message: 'Daily briefing prompt',
});
});
it('skips daily briefing job when output is missing', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = configSchema.parse({
telegram: { bot_token: 'token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
automation: {
daily_briefing: {
enabled: true,
},
},
});
const jobs = buildPresetCronJobs(config);
expect(jobs).toHaveLength(0);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('output is missing'));
warnSpy.mockRestore();
});
it('skips preset when daily briefing name conflicts with user cron job', () => {
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const config = configSchema.parse({
telegram: { bot_token: 'token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-sonnet' } },
automation: {
cron: [{
name: 'daily-briefing',
schedule: '0 9 * * *',
message: 'manual job',
output: { channel: 'telegram', peer: '1' },
}],
daily_briefing: {
enabled: true,
output: { channel: 'telegram', peer: '1' },
},
},
});
const jobs = buildPresetCronJobs(config);
expect(jobs).toHaveLength(0);
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('conflicts with automation.cron'));
warnSpy.mockRestore();
});
});
+31
View File
@@ -0,0 +1,31 @@
import type { Config, CronJobConfig } from '../config/schema.js';
/**
* Builds config-derived cron jobs that are not manually listed under automation.cron.
* This keeps opinionated automation features opt-in while reusing the existing CronScheduler.
*/
export function buildPresetCronJobs(config: Config): CronJobConfig[] {
const jobs: CronJobConfig[] = [];
const existingNames = new Set(config.automation.cron.map((job) => job.name));
const briefing = config.automation.daily_briefing;
if (briefing.enabled) {
if (!briefing.output) {
console.warn('automation.daily_briefing.enabled=true but output is missing; skipping daily briefing job');
} else if (existingNames.has(briefing.name)) {
console.warn(`automation.daily_briefing name '${briefing.name}' conflicts with automation.cron; skipping preset job`);
} else {
jobs.push({
name: briefing.name,
schedule: briefing.schedule,
message: briefing.prompt,
output: briefing.output,
enabled: true,
timezone: briefing.timezone,
model_tier: briefing.model_tier,
});
}
}
return jobs;
}
+34
View File
@@ -205,7 +205,9 @@ describe('configSchema — backup', () => {
it('defaults backup settings', () => {
const result = configSchema.parse(minimalConfig);
expect(result.backup.enabled).toBe(false);
expect(result.backup.schedule).toBeUndefined();
expect(result.backup.interval).toBe('24h');
expect(result.backup.run_on_start).toBe(false);
expect(result.backup.include_vectors).toBe(true);
expect(result.backup.minio.enabled).toBe(false);
expect(result.backup.minio.prefix).toBe('flynn');
@@ -217,7 +219,9 @@ describe('configSchema — backup', () => {
...minimalConfig,
backup: {
enabled: true,
schedule: '0 2 * * *',
interval: '12h',
run_on_start: true,
local_dir: '/tmp/flynn-backups',
include_vectors: false,
minio: {
@@ -233,7 +237,9 @@ describe('configSchema — backup', () => {
});
expect(result.backup.enabled).toBe(true);
expect(result.backup.schedule).toBe('0 2 * * *');
expect(result.backup.interval).toBe('12h');
expect(result.backup.run_on_start).toBe(true);
expect(result.backup.local_dir).toBe('/tmp/flynn-backups');
expect(result.backup.include_vectors).toBe(false);
expect(result.backup.minio.enabled).toBe(true);
@@ -845,6 +851,9 @@ describe('configSchema automation', () => {
expect(result.automation).toBeDefined();
expect(result.automation.delivery_mode).toBe('shared_session');
expect(result.automation.cron).toEqual([]);
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');
});
it('accepts isolated automation delivery mode', () => {
@@ -919,6 +928,31 @@ describe('configSchema automation', () => {
expect(result.automation.cron[0].enabled).toBe(false);
expect(result.automation.cron[0].timezone).toBe('America/New_York');
});
it('accepts daily briefing automation config', () => {
const result = configSchema.parse({
...baseConfig,
automation: {
daily_briefing: {
enabled: true,
name: 'weekday-briefing',
schedule: '0 7 * * 1-5',
timezone: 'America/New_York',
output: { channel: 'telegram', peer: '123' },
prompt: 'Custom briefing prompt',
model_tier: 'fast',
},
},
});
expect(result.automation.daily_briefing.enabled).toBe(true);
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.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');
});
});
describe('configSchema — intents', () => {
+34
View File
@@ -341,6 +341,36 @@ const gtasksSchema = z.object({
token_file: z.string().default('~/.config/flynn/gtasks-token.json'),
}).optional();
const dailyBriefingSchema = z.object({
enabled: z.boolean().default(false),
name: z.string().min(1).default('daily-briefing'),
schedule: z.string().min(1).default('0 8 * * *'),
timezone: z.string().optional(),
output: z.object({
channel: z.string().min(1),
peer: z.string().min(1),
}).optional(),
prompt: z.string().min(1).default(
[
'Create my daily briefing.',
'',
'Use available tools to gather:',
'- Today\'s calendar events (calendar.today or calendar.list)',
'- Unread or recent important email (gmail.search/gmail.list)',
'- Top pending tasks (tasks.list/tasks.lists)',
'',
'Output format:',
'1) Schedule',
'2) Priorities',
'3) Risks/Follow-ups',
'4) Suggested first actions',
'',
'Keep it concise and actionable.',
].join('\n'),
),
model_tier: modelTierEnum.optional(),
}).default({});
const automationDeliveryModeSchema = z.enum(['shared_session', 'isolated_job']);
const automationSchema = z.object({
@@ -353,6 +383,7 @@ const automationSchema = z.object({
gdocs: gdocsSchema,
gdrive: gdriveSchema,
gtasks: gtasksSchema,
daily_briefing: dailyBriefingSchema,
heartbeat: heartbeatSchema,
}).default({});
@@ -683,7 +714,9 @@ const sessionsSchema = z.object({
const backupSchema = z.object({
enabled: z.boolean().default(false),
schedule: z.string().optional(),
interval: z.string().default('24h'),
run_on_start: z.boolean().default(false),
local_dir: z.string().default('~/.local/share/flynn/backups'),
include_vectors: z.boolean().default(true),
minio: z.object({
@@ -814,6 +847,7 @@ export type GcalConfig = z.infer<typeof gcalSchema>;
export type GdocsConfig = z.infer<typeof gdocsSchema>;
export type GdriveConfig = z.infer<typeof gdriveSchema>;
export type GtasksConfig = z.infer<typeof gtasksSchema>;
export type DailyBriefingConfig = z.infer<typeof dailyBriefingSchema>;
export type AutomationDeliveryMode = z.infer<typeof automationDeliveryModeSchema>;
export type PairingCodeConfig = z.infer<typeof pairingSchema>;
export type LogLevel = z.infer<typeof logLevelSchema>;
+36
View File
@@ -142,4 +142,40 @@ describe('registerChannels', () => {
expect(names).toContain('zalo');
expect(gateway.setZaloHandler).toHaveBeenCalledTimes(1);
});
it('registers cron scheduler when daily briefing preset is enabled', () => {
const config = configSchema.parse({
telegram: { bot_token: 'test-token', allowed_chat_ids: [1] },
models: { default: { provider: 'anthropic', model: 'claude-3' } },
automation: {
daily_briefing: {
enabled: true,
schedule: '0 8 * * *',
output: { channel: 'telegram', peer: '1' },
},
},
});
const channelRegistry = new ChannelRegistry();
const gateway = {
setWebhookHandler: vi.fn(),
setGmailHandler: vi.fn(),
setTeamsHandler: vi.fn(),
setGoogleChatHandler: vi.fn(),
setBlueBubblesHandler: vi.fn(),
setLineHandler: vi.fn(),
setFeishuHandler: vi.fn(),
setZaloHandler: vi.fn(),
};
registerChannels({
config,
channelRegistry,
hookEngine: new HookEngine(config.hooks),
gateway: gateway as unknown as Parameters<typeof registerChannels>[0]['gateway'],
});
const names = channelRegistry.list().map((adapter) => adapter.name);
expect(names).toContain('cron');
});
});
+6 -4
View File
@@ -1,7 +1,7 @@
import type { Config } from '../config/index.js';
import type { HookEngine } from '../hooks/index.js';
import { ChannelRegistry, TelegramAdapter, WebChatAdapter, DiscordAdapter, SlackAdapter, WhatsAppAdapter, MatrixAdapter, SignalAdapter, MattermostAdapter, TeamsAdapter, GoogleChatAdapter, BlueBubblesAdapter, LineAdapter, FeishuAdapter, ZaloAdapter, PairingManager } from '../channels/index.js';
import { CronScheduler, WebhookHandler, GmailWatcher } from '../automation/index.js';
import { CronScheduler, WebhookHandler, GmailWatcher, buildPresetCronJobs } from '../automation/index.js';
import type { GatewayServer } from '../gateway/index.js';
export interface ChannelsDeps {
@@ -202,10 +202,12 @@ export function registerChannels(deps: ChannelsDeps): ChannelsResult {
// Register cron scheduler adapter (if any cron jobs configured)
let cronScheduler: CronScheduler | undefined;
if (config.automation.cron.length > 0) {
cronScheduler = new CronScheduler(config.automation.cron, channelRegistry, config.automation.delivery_mode);
const presetCronJobs = buildPresetCronJobs(config);
const cronJobs = [...config.automation.cron, ...presetCronJobs];
if (cronJobs.length > 0) {
cronScheduler = new CronScheduler(cronJobs, channelRegistry, config.automation.delivery_mode);
channelRegistry.register(cronScheduler);
console.log(`Registered ${config.automation.cron.length} cron job(s)`);
console.log(`Registered ${cronJobs.length} cron job(s)`);
}
// Register webhook handler adapter (if any webhooks configured)
+41 -6
View File
@@ -2,6 +2,7 @@
import { resolve } from 'path';
import { homedir } from 'os';
import { mkdirSync } from 'fs';
import { Cron } from 'croner';
// ── Config & Types ──
import type { Config } from '../config/index.js';
@@ -106,7 +107,8 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
if (config.backup.enabled) {
const backupIntervalMs = parseDuration(config.backup.interval);
if (!backupIntervalMs) {
const backupSchedule = config.backup.schedule?.trim();
if (!backupSchedule && !backupIntervalMs) {
console.warn(`Backup enabled but interval is invalid: ${config.backup.interval}`);
} else {
let backupRunning = false;
@@ -129,11 +131,44 @@ export async function startDaemon(config: Config, options?: StartDaemonOptions):
}
};
const backupInterval = setInterval(() => {
void runScheduledBackup();
}, backupIntervalMs);
lifecycle.onShutdown(async () => { clearInterval(backupInterval); });
console.log(`Backup scheduler enabled (${config.backup.interval})`);
let backupCron: Cron | undefined;
let backupInterval: ReturnType<typeof setInterval> | undefined;
if (backupSchedule) {
try {
backupCron = new Cron(backupSchedule, { paused: false }, () => {
void runScheduledBackup();
});
console.log(`Backup scheduler enabled (cron: ${backupSchedule})`);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
console.warn(`Backup cron schedule is invalid (${backupSchedule}): ${message}`);
}
}
if (!backupCron && backupIntervalMs) {
backupInterval = setInterval(() => {
void runScheduledBackup();
}, backupIntervalMs);
console.log(`Backup scheduler enabled (interval: ${config.backup.interval})`);
}
if (!backupCron && !backupInterval) {
console.warn('Backup scheduler disabled: no valid backup.schedule or backup.interval');
} else {
if (config.backup.run_on_start) {
void runScheduledBackup();
}
lifecycle.onShutdown(async () => {
if (backupCron) {
backupCron.stop();
}
if (backupInterval) {
clearInterval(backupInterval);
}
});
}
}
}
+7 -1
View File
@@ -60,6 +60,7 @@ describe('discoverServices', () => {
expect.objectContaining({ name: 'feishu', status: 'not_configured' }),
expect.objectContaining({ name: 'zalo', status: 'not_configured' }),
expect.objectContaining({ name: 'cron', status: 'not_configured' }),
expect.objectContaining({ name: 'daily_briefing', status: 'not_configured' }),
expect.objectContaining({ name: 'mcp', status: 'not_configured' }),
expect.objectContaining({ name: 'web_search', status: 'configured' }),
expect.objectContaining({ name: 'audio_transcription', status: 'not_configured' }),
@@ -99,13 +100,18 @@ describe('discoverServices', () => {
cfg.automation.cron = [
{ name: 'job', schedule: '0 0 * * *', message: 'hi', output: { channel: 'webchat', peer: 'x' }, enabled: true },
] as CronJobConfig[];
(cfg.automation as Record<string, unknown>).daily_briefing = {
enabled: true,
output: { channel: 'webchat', peer: 'x' },
};
cfg.mcp.servers = [{ name: 'srv', command: 'x', args: [] }];
const reg = new ChannelRegistry();
const services = discoverServices(cfg, reg);
expect(services.find(s => s.name === 'cron')?.status).toBe('configured');
expect(services.find(s => s.name === 'cron')?.itemCount).toBe(1);
expect(services.find(s => s.name === 'cron')?.itemCount).toBe(2);
expect(services.find(s => s.name === 'daily_briefing')?.status).toBe('configured');
expect(services.find(s => s.name === 'mcp')?.metadata).toEqual({ serverCount: 1 });
});
+4 -1
View File
@@ -122,9 +122,11 @@ export function discoverServices(
});
const automation = config.automation;
const dailyBriefingEnabled = Boolean(automation.daily_briefing?.enabled && automation.daily_briefing.output);
const totalCronJobs = automation.cron.length + (dailyBriefingEnabled ? 1 : 0);
const automationConfigs: Array<{ enabled: boolean; name: string; description: string; itemCount?: number }> = [
{ enabled: automation.cron.length > 0, name: 'cron', description: 'Cron scheduler', itemCount: automation.cron.length },
{ enabled: totalCronJobs > 0, name: 'cron', description: 'Cron scheduler', itemCount: totalCronJobs },
{ enabled: automation.webhooks.length > 0, name: 'webhooks', description: 'Webhook handler', itemCount: automation.webhooks.length },
{ enabled: automation.gmail?.enabled ?? false, name: 'gmail', description: 'Gmail watcher' },
{ enabled: automation.heartbeat?.enabled ?? false, name: 'heartbeat', description: 'Heartbeat monitor' },
@@ -132,6 +134,7 @@ export function discoverServices(
{ enabled: automation.gdocs?.enabled ?? false, name: 'gdocs', description: 'Google Docs' },
{ enabled: automation.gdrive?.enabled ?? false, name: 'gdrive', description: 'Google Drive' },
{ enabled: automation.gtasks?.enabled ?? false, name: 'gtasks', description: 'Google Tasks' },
{ enabled: automation.daily_briefing?.enabled ?? false, name: 'daily_briefing', description: 'Daily briefing automation' },
];
for (const auto of automationConfigs) {