Skip to main content

Job Extension

Add background tasks with the jobs() method, implement the Job trait's execute(), set a 6-field cron schedule, and run them via systemprompt infra jobs.

Extensions add background tasks via the jobs() method.

Jobs Method

fn jobs(&self) -> Vec<Arc<dyn Job>> {
    vec![
        Arc::new(CleanupJob),
        Arc::new(SyncJob),
    ]
}

Job Trait

use systemprompt::database::DbPool;
use systemprompt::traits::{Job, JobContext, JobResult, ProviderError};

#[derive(Debug, Clone, Copy, Default)]
pub struct CleanupJob;

#[async_trait::async_trait]
impl Job for CleanupJob {
    fn name(&self) -> &'static str {
        "cleanup"
    }

    fn description(&self) -> &'static str {
        "Clean up expired records"
    }

    fn schedule(&self) -> &'static str {
        "0 0 * * * *"  // Every hour
    }

    async fn execute(&self, ctx: &JobContext) -> Result<JobResult, ProviderError> {
        let db = ctx.db_pool::<DbPool>()
            .ok_or_else(|| ProviderError::Internal("Database not available".into()))?;
        let pool = db.pool()
            .ok_or_else(|| ProviderError::Internal("PgPool not available".into()))?;

        let deleted = sqlx::query!(
            "DELETE FROM temp_records WHERE expires_at < NOW()"
        )
        .execute(pool.as_ref())
        .await
        .map_err(|e| ProviderError::Internal(e.to_string()))?
        .rows_affected();

        Ok(JobResult::success().with_message(format!("Deleted {} records", deleted)))
    }
}

Startup Jobs

Jobs can run once, serially, before the cron loop begins. This is an operations decision, not a trait method: list the job under bootstrap_jobs in services/scheduler/config.yaml:

scheduler:
  bootstrap_jobs:
    - governance_bootstrap
    - publish_pipeline

A job listed only in bootstrap_jobs runs at startup and is never cron-scheduled. A job that also has a jobs: entry with enabled: true runs at startup and then follows its cron schedule.

Cron Schedule Format

6-field cron expression: second minute hour day-of-month month day-of-week

┌───────────── second (0-59)
│ ┌───────────── minute (0-59)
│ │ ┌───────────── hour (0-23)
│ │ │ ┌───────────── day of month (1-31)
│ │ │ │ ┌───────────── month (1-12)
│ │ │ │ │ ┌───────────── day of week (0-6, Sun=0)
│ │ │ │ │ │
* * * * * *

Examples:

  • 0 0 * * * * - Every hour at minute 0
  • 0 */15 * * * * - Every 15 minutes
  • 0 0 0 * * * - Daily at midnight
  • 0 30 2 * * * - Daily at 2:30 AM
  • 0 0 0 * * 1 - Every Monday at midnight

JobContext

async fn execute(&self, ctx: &JobContext) -> Result<JobResult, ProviderError> {
    // Get the type-erased database pool
    let db = ctx.db_pool::<DbPool>();

    // The actor authorizing this run (from `owner:` in scheduler config)
    let actor = ctx.actor();

    // Per-run string parameters
    let params = ctx.parameters();

    // Type-erased app context and paths
    // ctx.app_context::<T>() / ctx.app_paths::<T>()
}

JobResult

// Success
Ok(JobResult::success())
Ok(JobResult::success().with_message("Processed 100 items"))
Ok(JobResult::success().with_stats(100, 0).with_duration(duration_ms))

// Failure
Ok(JobResult::failure("Database connection failed"))

Configuration Override

Override job schedules in services/scheduler/config.yaml. Every job declares an explicit owner:, a real user name that becomes JobContext.actor for each run:

scheduler:
  enabled: true
  jobs:
    - name: cleanup
      extension: my-extension
      owner: admin
      schedule: "0 */30 * * * *"  # Override to every 30 minutes
      enabled: true

CLI Commands

# Run job manually
systemprompt infra jobs run cleanup

# List all jobs
systemprompt infra jobs list

# Show detailed information about a job
systemprompt infra jobs show cleanup

# View job execution history
systemprompt infra jobs history

Typed Extension

use systemprompt::extension::prelude::JobExtensionTyped;

impl JobExtensionTyped for MyExtension {
    fn jobs(&self) -> Vec<Arc<dyn Job>> {
        vec![Arc::new(CleanupJob)]
    }
}