WP-02: encrypted settings, SMTP mail, job runner and cron scheduler
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

AES-256-GCM secret storage keyed by MASTER_KEY, SMTP settings with test mail
(lettre), persisted job runs with log and status, JobRunner with per-kind
concurrency guard, 6-field cron schedules with defaults, scheduler loop.
Settings and Jobs pages in the UI. FAKE_HOST mode for dev machines.

Tests: 30 application, 8 infrastructure, 23 API, 16 Vitest, 6 Playwright.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:16:56 +02:00
parent 6113af0a19
commit fa9ac9a6bc
31 changed files with 1632 additions and 58 deletions

View File

@ -3,7 +3,8 @@ use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use domain::jobs::{JobKind, JobRun};
use chrono::Utc;
use domain::jobs::{JobKind, JobRun, JobStatus};
use domain::ports::JobRunRepository;
use domain::DomainError;
use uuid::Uuid;
@ -24,6 +25,24 @@ pub struct JobRunner {
handlers: HashMap<JobKind, Arc<dyn JobHandler>>,
}
struct RepoLog {
runs: Arc<dyn JobRunRepository>,
id: Uuid,
}
#[async_trait]
impl JobLog for RepoLog {
async fn line(&self, text: &str) {
if let Err(e) = self.runs.append_log(self.id, text).await {
tracing_line(&format!("failed to append job log: {e}"));
}
}
}
fn tracing_line(msg: &str) {
eprintln!("{msg}");
}
impl JobRunner {
pub fn new(runs: Arc<dyn JobRunRepository>) -> Self {
Self {
@ -43,31 +62,88 @@ impl JobRunner {
k
}
async fn begin(
&self,
kind: JobKind,
params: Option<String>,
triggered_by: &str,
) -> Result<(JobRun, Arc<dyn JobHandler>), DomainError> {
let handler = self
.handlers
.get(&kind)
.cloned()
.ok_or(DomainError::NotFound)?;
if self.runs.find_running(kind).await?.is_some() {
return Err(DomainError::Conflict(format!(
"{} is already running",
kind.as_str()
)));
}
let run = JobRun {
id: Uuid::new_v4(),
kind,
params,
status: JobStatus::Running,
started_at: Utc::now(),
finished_at: None,
log: String::new(),
triggered_by: triggered_by.into(),
};
self.runs.insert(&run).await?;
Ok((run, handler))
}
async fn execute(runs: Arc<dyn JobRunRepository>, handler: Arc<dyn JobHandler>, run: &JobRun) {
let log = RepoLog {
runs: runs.clone(),
id: run.id,
};
let status = match handler.run(run.params.clone(), &log).await {
Ok(()) => JobStatus::Success,
Err(e) => {
log.line(&format!("ERROR: {e}")).await;
JobStatus::Failed
}
};
if let Err(e) = runs.finish(run.id, status).await {
tracing_line(&format!("failed to finish job: {e}"));
}
}
/// Start a job in the background. Fails with `Conflict` if the kind is already running.
pub async fn start(
&self,
_kind: JobKind,
_params: Option<String>,
_triggered_by: &str,
kind: JobKind,
params: Option<String>,
triggered_by: &str,
) -> Result<JobRun, DomainError> {
todo!()
let (run, handler) = self.begin(kind, params, triggered_by).await?;
let (runs, run_clone) = (self.runs.clone(), run.clone());
tokio::spawn(async move { Self::execute(runs, handler, &run_clone).await });
Ok(run)
}
/// Run a job and wait for it to finish (used by tests and the scheduler).
pub async fn run_and_wait(
&self,
_kind: JobKind,
_params: Option<String>,
_triggered_by: &str,
kind: JobKind,
params: Option<String>,
triggered_by: &str,
) -> Result<JobRun, DomainError> {
todo!()
let (run, handler) = self.begin(kind, params, triggered_by).await?;
Self::execute(self.runs.clone(), handler, &run).await;
self.get(run.id).await
}
pub async fn get(&self, _id: Uuid) -> Result<JobRun, DomainError> {
todo!()
pub async fn get(&self, id: Uuid) -> Result<JobRun, DomainError> {
self.runs.get(id).await?.ok_or(DomainError::NotFound)
}
pub async fn list(&self, _limit: u32) -> Result<Vec<JobRun>, DomainError> {
todo!()
pub async fn list(&self, limit: u32) -> Result<Vec<JobRun>, DomainError> {
self.runs.list(limit).await
}
pub async fn last_finished(&self, kind: JobKind) -> Result<Option<JobRun>, DomainError> {
self.runs.last_finished(kind).await
}
}