Skip to main content

systemprompt_provider_contracts/
job.rs

1//! [`Job`] contract for scheduled / on-startup background jobs registered
2//! via the `inventory` crate.
3//!
4//! Copyright (c) systemprompt.io — Business Source License 1.1.
5//! See <https://systemprompt.io> for licensing details.
6
7use std::collections::HashMap;
8use std::sync::Arc;
9
10use async_trait::async_trait;
11use systemprompt_identifiers::Actor;
12
13use crate::error::ProviderResult;
14
15#[derive(Debug, Clone)]
16pub struct JobResult {
17    pub success: bool,
18    pub message: Option<String>,
19    pub items_processed: Option<u64>,
20    pub items_failed: Option<u64>,
21    pub duration_ms: u64,
22}
23
24impl JobResult {
25    #[must_use]
26    pub const fn success() -> Self {
27        Self {
28            success: true,
29            message: None,
30            items_processed: None,
31            items_failed: None,
32            duration_ms: 0,
33        }
34    }
35
36    #[must_use]
37    pub fn with_message(mut self, message: impl Into<String>) -> Self {
38        self.message = Some(message.into());
39        self
40    }
41
42    #[must_use]
43    pub const fn with_stats(mut self, processed: u64, failed: u64) -> Self {
44        self.items_processed = Some(processed);
45        self.items_failed = Some(failed);
46        self
47    }
48
49    #[must_use]
50    pub const fn with_duration(mut self, duration_ms: u64) -> Self {
51        self.duration_ms = duration_ms;
52        self
53    }
54
55    #[must_use]
56    pub fn failure(message: impl Into<String>) -> Self {
57        Self {
58            success: false,
59            message: Some(message.into()),
60            items_processed: None,
61            items_failed: None,
62            duration_ms: 0,
63        }
64    }
65}
66
67pub struct JobContext {
68    actor: Actor,
69    db_pool: Arc<dyn std::any::Any + Send + Sync>,
70    app_context: Arc<dyn std::any::Any + Send + Sync>,
71    app_paths: Arc<dyn std::any::Any + Send + Sync>,
72    parameters: HashMap<String, String>,
73    enforce: bool,
74}
75
76impl std::fmt::Debug for JobContext {
77    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
78        f.debug_struct("JobContext")
79            .field("actor", &self.actor)
80            .field("db_pool", &"<type-erased>")
81            .field("app_context", &"<type-erased>")
82            .field("app_paths", &"<type-erased>")
83            .field("parameters", &self.parameters)
84            .field("enforce", &self.enforce)
85            .finish()
86    }
87}
88
89impl JobContext {
90    #[must_use]
91    pub fn new(
92        actor: Actor,
93        db_pool: Arc<dyn std::any::Any + Send + Sync>,
94        app_context: Arc<dyn std::any::Any + Send + Sync>,
95        app_paths: Arc<dyn std::any::Any + Send + Sync>,
96    ) -> Self {
97        Self {
98            actor,
99            db_pool,
100            app_context,
101            app_paths,
102            parameters: HashMap::new(),
103            enforce: false,
104        }
105    }
106
107    /// Enforcement consent from the job's configuration. Jobs whose actions
108    /// are destructive or outward-facing (e.g. banning IPs) must take those
109    /// actions only when this is `true`; otherwise they observe and log.
110    /// Defaults to `false` so a job never enforces without explicit opt-in.
111    #[must_use]
112    pub const fn enforce(&self) -> bool {
113        self.enforce
114    }
115
116    #[must_use]
117    pub const fn with_enforce(mut self, enforce: bool) -> Self {
118        self.enforce = enforce;
119        self
120    }
121
122    #[must_use]
123    pub const fn actor(&self) -> &Actor {
124        &self.actor
125    }
126
127    #[must_use]
128    pub fn with_parameters(mut self, parameters: HashMap<String, String>) -> Self {
129        self.parameters = parameters;
130        self
131    }
132
133    #[must_use]
134    pub fn db_pool<T: 'static>(&self) -> Option<&T> {
135        self.db_pool.as_ref().downcast_ref::<T>()
136    }
137
138    #[must_use]
139    pub fn app_context<T: 'static>(&self) -> Option<&T> {
140        self.app_context.as_ref().downcast_ref::<T>()
141    }
142
143    #[must_use]
144    pub fn app_paths<T: 'static>(&self) -> Option<&T> {
145        self.app_paths.as_ref().downcast_ref::<T>()
146    }
147
148    #[must_use]
149    pub fn db_pool_arc(&self) -> Arc<dyn std::any::Any + Send + Sync> {
150        Arc::clone(&self.db_pool)
151    }
152
153    #[must_use]
154    pub fn app_context_arc(&self) -> Arc<dyn std::any::Any + Send + Sync> {
155        Arc::clone(&self.app_context)
156    }
157
158    #[must_use]
159    pub fn app_paths_arc(&self) -> Arc<dyn std::any::Any + Send + Sync> {
160        Arc::clone(&self.app_paths)
161    }
162
163    #[must_use]
164    pub const fn parameters(&self) -> &HashMap<String, String> {
165        &self.parameters
166    }
167
168    #[must_use]
169    pub fn get_parameter(&self, key: &str) -> Option<&String> {
170        self.parameters.get(key)
171    }
172
173    /// Parse a parameter as `T`. `Ok(None)` when the key is absent; an error
174    /// when the value is present but unparseable, so a mistyped override
175    /// fails the run instead of silently falling back to the default.
176    pub fn get_parameter_parsed<T: std::str::FromStr>(
177        &self,
178        key: &str,
179    ) -> Result<Option<T>, crate::ProviderError>
180    where
181        T::Err: std::fmt::Display,
182    {
183        self.parameters
184            .get(key)
185            .map(|value| {
186                value.parse().map_err(|e| {
187                    crate::ProviderError::Configuration(format!(
188                        "invalid job parameter {key}={value}: {e}"
189                    ))
190                })
191            })
192            .transpose()
193    }
194}
195
196// Why: jobs are collected as `&'static dyn Job` via `inventory`; an async fn
197// in a bare trait is not dyn-compatible, so #[async_trait] is required.
198#[async_trait]
199pub trait Job: Send + Sync + 'static {
200    fn name(&self) -> &'static str;
201
202    fn description(&self) -> &'static str {
203        ""
204    }
205
206    fn schedule(&self) -> &'static str;
207
208    fn tags(&self) -> Vec<&'static str> {
209        vec![]
210    }
211
212    async fn execute(&self, ctx: &JobContext) -> ProviderResult<JobResult>;
213
214    fn enabled(&self) -> bool {
215        true
216    }
217
218    /// Whether this job is meant to carry its own `scheduler.jobs` cron entry.
219    ///
220    /// Return `false` for a job that exists only as an inline step of a larger
221    /// pipeline job. Scheduling such a step independently would duplicate work
222    /// the pipeline already does, so the scheduler stops warning that it has no
223    /// cron entry — a warning that is otherwise the right signal for a job that
224    /// really has fallen out of a deployed profile.
225    fn schedulable(&self) -> bool {
226        true
227    }
228}
229
230inventory::collect!(&'static dyn Job);
231
232#[macro_export]
233macro_rules! submit_job {
234    ($job:expr) => {
235        inventory::submit!($job as &'static dyn $crate::Job);
236    };
237}