You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
ai_synth/docs/superpowers/plans/2026-03-27-scheduled-genera...

13 KiB

Scheduled Synthesis Generation — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Per-theme scheduled generation + email delivery. Background scheduler checks every 60s for due schedules, generates synthesis, and emails it to up to 3 addresses.

Architecture: New theme_schedules table (1:1 with themes). Schedule CRUD via REST API. Internal tokio::spawn scheduler in main.rs (same pattern as session cleanup). Scheduler calls run_generation_inner directly, then send_synthesis_email for each recipient.

Tech Stack: Rust (Axum, sqlx, tokio), SolidJS

Spec: docs/superpowers/specs/2026-03-27-scheduled-generation-design.md


Task 1: Migration + model + DB

Files:

  • Create: backend/migrations/20260327000030_create_theme_schedules.sql

  • Create: backend/src/models/schedule.rs

  • Create: backend/src/db/schedules.rs

  • Modify: backend/src/models/mod.rs, backend/src/db/mod.rs

  • Modify: CLAUDE.md

  • Step 1: Create migration

CREATE TABLE theme_schedules (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    theme_id UUID NOT NULL UNIQUE REFERENCES themes(id) ON DELETE CASCADE,
    user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    enabled BOOLEAN NOT NULL DEFAULT true,
    days JSONB NOT NULL DEFAULT '[]',
    time_utc TEXT NOT NULL DEFAULT '08:00',
    emails JSONB NOT NULL DEFAULT '[]',
    last_run_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_theme_schedules_enabled ON theme_schedules(enabled) WHERE enabled = true;
  • Step 2: Create models/schedule.rs

Structs:

  • ThemeSchedule — DB row struct (sqlx::FromRow + Serialize)
  • UpsertScheduleRequest — { enabled, days, time_utc, emails } with validation
  • ScheduleResponse — API response with parsed JSON arrays

Validation for UpsertScheduleRequest:

  • emails max 3, each validated with basic email regex

  • days each must be one of mon,tue,wed,thu,fri,sat,sun

  • time_utc must match HH:MM (00-23:00-59)

  • Step 3: Create db/schedules.rs

Functions:

  • get_for_theme(pool, user_id, theme_id) -> Option<ThemeSchedule>

  • upsert(pool, user_id, theme_id, &UpsertScheduleRequest) -> ThemeSchedule

  • delete(pool, user_id, theme_id) -> bool

  • find_due_schedules(pool) -> Vec<ThemeSchedule> — query for enabled schedules where:

    enabled = true
    AND days @> to_jsonb($1::text)   -- today's day code
    AND time_utc <= $2               -- current HH:MM
    AND (last_run_at IS NULL OR last_run_at::date < CURRENT_DATE)
    
  • mark_run(pool, schedule_id) — update last_run_at = now()

  • Step 4: Register modules + update CLAUDE.md

models/mod.rs: add pub mod schedule; db/mod.rs: add pub mod schedules; CLAUDE.md: change to ## Database (30 migrations)

  • Step 5: Build and commit
cd backend && cargo build
git add -A && git commit -m "feat: add theme_schedules table, model, and DB queries"

Task 2: Schedule CRUD handler + routes

Files:

  • Create: backend/src/handlers/schedules.rs

  • Modify: backend/src/handlers/mod.rs

  • Modify: backend/src/router.rs

  • Step 1: Create handler

Three handlers following existing patterns (read handlers/themes.rs for style):

// GET /api/v1/themes/:id/schedule
pub async fn get_schedule(auth_user, state, Path(theme_id)) -> schedule or null

// PUT /api/v1/themes/:id/schedule
pub async fn upsert_schedule(auth_user, state, Path(theme_id), Json(body)) -> schedule
// Verify theme belongs to user before upserting

// DELETE /api/v1/themes/:id/schedule
pub async fn delete_schedule(auth_user, state, Path(theme_id)) -> 204
  • Step 2: Register + routes

handlers/mod.rs: add pub mod schedules;

In router.rs, add after theme routes:

.route("/themes/{id}/schedule", get(handlers::schedules::get_schedule))
.route("/themes/{id}/schedule", put(handlers::schedules::upsert_schedule))
.route("/themes/{id}/schedule", delete(handlers::schedules::delete_schedule))
  • Step 3: Build, test, commit
cd backend && cargo build && cargo test --lib
git add -A && git commit -m "feat: add schedule CRUD endpoints"

Task 3: Background scheduler service

Files:

  • Create: backend/src/services/scheduler.rs

  • Modify: backend/src/services/mod.rs

  • Modify: backend/src/main.rs

  • Step 1: Create services/scheduler.rs

//! Background scheduler for automated synthesis generation.
//!
//! Checks every 60 seconds for due theme schedules and triggers
//! generation + email delivery.

use crate::app_state::AppState;
use crate::db;
use crate::services::{email, synthesis};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
use tokio::sync::watch;
use uuid::Uuid;

/// Check for due schedules and run them.
pub async fn run_scheduled_jobs(state: &AppState) {
    let due = match db::schedules::find_due_schedules(&state.pool).await {
        Ok(schedules) => schedules,
        Err(e) => {
            tracing::warn!(error = %e, "Failed to query due schedules");
            return;
        }
    };

    if due.is_empty() {
        return;
    }

    tracing::info!(count = due.len(), "Found due scheduled jobs");

    for schedule in due {
        // Skip if user has a manual generation in progress
        if state.job_store.has_active_job(schedule.user_id).is_some() {
            tracing::info!(user_id = %schedule.user_id, theme_id = %schedule.theme_id, "Skipping scheduled job — manual generation in progress");
            continue;
        }

        tracing::info!(
            schedule_id = %schedule.id,
            theme_id = %schedule.theme_id,
            user_id = %schedule.user_id,
            "Running scheduled generation"
        );

        // Run generation with a dummy progress channel
        let (tx, _rx) = watch::channel(synthesis::ProgressEvent::Progress {
            step: "init".into(),
            message: "Scheduled generation...".into(),
            percent: 0,
        });

        let job_id = Uuid::new_v4();
        let cancelled = AtomicBool::new(false);

        let result = synthesis::run_generation_inner(
            job_id,
            state,
            schedule.user_id,
            schedule.theme_id,
            &tx,
            None,  // no provider override
            &cancelled,
        )
        .await;

        match result {
            Ok(synthesis_id) => {
                tracing::info!(synthesis_id = %synthesis_id, "Scheduled generation completed");

                // Send email to all recipients
                let emails: Vec<String> = serde_json::from_value(schedule.emails.clone()).unwrap_or_default();
                if !emails.is_empty() {
                    // Load synthesis for email
                    if let Ok(Some(synth)) = db::syntheses::get_by_id(&state.pool, synthesis_id).await {
                        let sections: Vec<crate::models::synthesis::NewsSection> =
                            serde_json::from_value(synth.sections).unwrap_or_default();
                        let week = &synth.week;
                        let date = synth.created_at.format("%d %B %Y").to_string();

                        for email_addr in &emails {
                            match email::send_synthesis_email(
                                &state.http_client,
                                &state.config.resend_api_key,
                                &state.config.email_from,
                                email_addr,
                                week,
                                &date,
                                &sections,
                            )
                            .await
                            {
                                Ok(()) => tracing::info!(to = email_addr, "Scheduled email sent"),
                                Err(e) => tracing::warn!(to = email_addr, error = %e, "Failed to send scheduled email"),
                            }
                        }
                    }
                }

                // Mark as run
                db::schedules::mark_run(&state.pool, schedule.id).await.ok();
            }
            Err(e) => {
                tracing::error!(
                    schedule_id = %schedule.id,
                    error = %e,
                    "Scheduled generation failed"
                );
                // Don't mark as run — will retry next interval
            }
        }
    }
}
  • Step 2: Register module

services/mod.rs: add pub mod scheduler;

  • Step 3: Spawn in main.rs

In main.rs, after the session cleanup spawn and before let app = router::build_router(...), add:

// Scheduled synthesis generation (check every 60 seconds)
{
    let scheduler_state = state.clone();
    tokio::spawn(async move {
        let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
        loop {
            interval.tick().await;
            crate::services::scheduler::run_scheduled_jobs(&scheduler_state).await;
        }
    });
}
  • Step 4: Build, test, commit
cd backend && cargo build && cargo test --lib
git add -A && git commit -m "feat: add background scheduler for automated synthesis generation"

Task 4: Frontend — schedule API + types + i18n

Files:

  • Create: frontend/src/api/schedules.ts

  • Modify: frontend/src/types.ts

  • Modify: frontend/src/i18n/fr.ts

  • Step 1: Create API client

Read existing API modules for the pattern, then create:

export interface ScheduleResponse {
  id: string;
  theme_id: string;
  enabled: boolean;
  days: string[];
  time_utc: string;
  emails: string[];
  last_run_at: string | null;
}

export interface UpsertScheduleRequest {
  enabled: boolean;
  days: string[];
  time_utc: string;
  emails: string[];
}

export const schedulesApi = {
  get: (themeId: string) => ...,
  upsert: (themeId: string, data: UpsertScheduleRequest) => ...,
  remove: (themeId: string) => ...,
};
  • Step 2: Add i18n labels
'schedule.title': 'Planification',
'schedule.enabled': 'Planification activee',
'schedule.days': 'Jours',
'schedule.time': 'Heure (UTC)',
'schedule.emails': 'Destinataires',
'schedule.addEmail': 'Ajouter une adresse',
'schedule.maxEmails': '3 adresses maximum',
'schedule.saved': 'Planification enregistree',
'schedule.deleted': 'Planification supprimee',
'schedule.dayMon': 'L',
'schedule.dayTue': 'M',
'schedule.dayWed': 'M',
'schedule.dayThu': 'J',
'schedule.dayFri': 'V',
'schedule.daySat': 'S',
'schedule.daySun': 'D',
  • Step 3: TypeScript check + commit
cd frontend && npx tsc --noEmit
git add -A && git commit -m "feat: add schedule API client and i18n labels"

Task 5: Frontend — Schedule component in ThemeManager

Files:

  • Create: frontend/src/components/settings/SettingsSchedule.tsx

  • Modify: frontend/src/pages/ThemeManager.tsx

  • Step 1: Create SettingsSchedule component

Props: { themeId: string }

The component:

  1. Loads the schedule on mount via schedulesApi.get(themeId)
  2. Shows enable toggle, day buttons, time input, email list
  3. Auto-saves on any change via schedulesApi.upsert(themeId, data)

Day buttons: 7 buttons in a row, each toggles a day code in the days array. Use the day letter labels from i18n.

Email list: up to 3 <input type="email"> with add/remove. Validate on blur.

  • Step 2: Add to ThemeManager

Import SettingsSchedule and render it inside the selected theme view, after the sources section:

<Show when={selectedThemeId()}>
  {/* ... existing content settings + sources ... */}
  <SettingsSchedule themeId={selectedThemeId()!} />
</Show>
  • Step 3: TypeScript check + commit
cd frontend && npx tsc --noEmit
git add -A && git commit -m "feat: add schedule UI to theme management page"

Task 6: Integration tests + E2E

Files:

  • Create: backend/tests/api_schedules_test.rs

  • Modify: e2e/tests/themes.spec.ts

  • Step 1: Create integration tests

// Tests:
// - get_schedule_returns_null_when_none
// - upsert_schedule_creates_schedule
// - upsert_schedule_updates_existing
// - upsert_schedule_invalid_emails_returns_422
// - upsert_schedule_invalid_days_returns_422
// - upsert_schedule_too_many_emails_returns_422
// - delete_schedule_returns_204
// - schedule_requires_auth
// - schedule_requires_theme_ownership

Each test creates a user + theme, then operates on the schedule endpoint.

  • Step 2: Add E2E test

In e2e/tests/themes.spec.ts, add after the existing CRUD test:

// Create schedule via API
const schedResp = await page.evaluate(async (tid: string) => {
    const resp = await fetch(`/api/v1/themes/${tid}/schedule`, {
        method: 'PUT',
        headers: { 'Content-Type': 'application/json', 'X-Requested-With': 'XMLHttpRequest' },
        credentials: 'same-origin',
        body: JSON.stringify({
            enabled: true,
            days: ['mon', 'fri'],
            time_utc: '09:00',
            emails: ['test@example.com'],
        }),
    });
    return { status: resp.status, data: await resp.json() };
}, themeId);
expect(schedResp.status).toBe(200);
expect(schedResp.data.days).toEqual(['mon', 'fri']);
  • Step 3: Build + commit
cd backend && cargo build --tests
git add -A && git commit -m "test: add schedule CRUD integration tests and E2E"