Skip to main content

sioul/spam/
jobs.rs

1// SPDX-License-Identifier: GPL-3.0-or-later
2// Copyright © 2026 Aurélien Pierre
3
4//! Long jobs run apart (docs/mcp.md, "The spam filter's tools"): the MCP's
5//! `spam_fetch` and `spam_train` start a detached `sioul spam fetch|train
6//! --job <id>` and answer at once with its id. The job writes where it
7//! stands, then its result, into `$XDG_STATE_HOME/sioul/spam/jobs/<id>.json`,
8//! which `spam_job` and `sioul spam job` read.
9//!
10//! - Its process holds `<id>.lock` while it runs: a job whose file still
11//!   says it runs, but whose lock nobody holds, ended without a word
12//!   (killed, the computer stopped), and is said so.
13//! - `<id>.stop`, made by `spam_job` with `stop` (or `sioul spam job <id>
14//!   --stop`), stops it at its next step; what was downloaded stays.
15//! - What it printed goes to `<id>.log` (its errors; never a message).
16//! - The folder and its files are yours alone (0700, 0600); a job's files go
17//!   a week after it ended. A training's result names its worst errors by
18//!   their senders and subjects, masked: kept no longer than that.
19//! - One job of each kind at a time.
20
21use crate::Session;
22use serde::{Deserialize, Serialize};
23use serde_json::Value;
24use sioul_learn::{Cancel, Progress, Stage};
25use std::path::PathBuf;
26use std::time::{Duration, Instant};
27
28/// The jobs that run apart.
29pub(crate) const KINDS: [&str; 2] = ["fetch", "train"];
30/// Days a job's files are kept after it ended.
31const KEPT_DAYS: i64 = 7;
32/// Seconds a job may stay "starting" before its process is looked for.
33const GRACE: i64 = 60;
34
35/// Where a job stands.
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(rename_all = "lowercase")]
38pub(crate) enum State {
39    /// Started; its process has not taken it yet.
40    Starting,
41    Running,
42    /// Ended with its result.
43    Done,
44    /// Ended on an error, said.
45    Failed,
46    /// Stopped when asked.
47    Stopped,
48    /// Its process is gone without a word: killed, or the computer stopped.
49    Died,
50}
51
52impl State {
53    pub(crate) fn as_str(self) -> &'static str {
54        match self {
55            State::Starting => "starting",
56            State::Running => "running",
57            State::Done => "done",
58            State::Failed => "failed",
59            State::Stopped => "stopped",
60            State::Died => "died",
61        }
62    }
63
64    fn ended(self) -> bool {
65        !matches!(self, State::Starting | State::Running)
66    }
67}
68
69/// What a job is on now: a stage of `sioul_learn`, how far, and a line saying it.
70#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
71pub(crate) struct Step {
72    pub stage: String,
73    pub done: u64,
74    pub total: u64,
75    /// An account and a folder while downloading; never a message.
76    pub detail: String,
77    /// The same, said in the person's language.
78    pub line: String,
79}
80
81/// One job, as its file keeps it.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub(crate) struct Job {
84    pub id: String,
85    /// "fetch" or "train".
86    pub kind: String,
87    /// What it was asked, as the command line takes it.
88    pub args: Vec<String>,
89    pub state: State,
90    /// Its process, once it runs.
91    #[serde(default)]
92    pub pid: Option<u32>,
93    /// Unix seconds.
94    pub created: i64,
95    #[serde(default)]
96    pub started: Option<i64>,
97    #[serde(default)]
98    pub ended: Option<i64>,
99    pub updated: i64,
100    #[serde(default)]
101    pub progress: Option<Step>,
102    /// What it said at the end, in lines (the command's, in the person's language), and as data.
103    #[serde(default)]
104    pub lines: Vec<String>,
105    #[serde(default)]
106    pub result: Option<Value>,
107    #[serde(default)]
108    pub error: Option<String>,
109    /// Asked to stop: it stops at its next step.
110    #[serde(default)]
111    pub stop_asked: bool,
112}
113
114fn now() -> i64 {
115    jiff::Timestamp::now().as_second()
116}
117
118/// The jobs' folder: `$XDG_STATE_HOME/sioul/spam/jobs`.
119pub(crate) fn folder() -> PathBuf {
120    sioul_core::config::state_dir().join("spam").join("jobs")
121}
122
123fn file(id: &str, extension: &str) -> PathBuf {
124    folder().join(format!("{id}.{extension}"))
125}
126
127/// Whether `id` can name a job: letters, digits and dashes, as `start`
128/// makes them; anything else (a path, "..") names none.
129pub(crate) fn valid(id: &str) -> bool {
130    (1..=64).contains(&id.len()) && !id.starts_with('-') && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
131}
132
133/// The folder, made yours alone.
134fn private_folder() -> Result<(), String> {
135    let folder = folder();
136    let mut builder = std::fs::DirBuilder::new();
137    builder.recursive(true);
138    #[cfg(unix)]
139    std::os::unix::fs::DirBuilderExt::mode(&mut builder, 0o700);
140    builder.create(&folder).map_err(|e| format!("{}: {e}", folder.display()))
141}
142
143/// A file of the folder opened to write, yours alone.
144fn private_file(path: &std::path::Path, truncate: bool) -> Result<std::fs::File, String> {
145    let mut options = std::fs::OpenOptions::new();
146    options.create(true).write(true).truncate(truncate);
147    #[cfg(unix)]
148    std::os::unix::fs::OpenOptionsExt::mode(&mut options, 0o600);
149    options.open(path).map_err(|e| format!("{}: {e}", path.display()))
150}
151
152/// A job's file written whole: beside, then moved, so that a reader never reads half of it.
153fn write(job: &Job) -> Result<(), String> {
154    use std::io::Write;
155    let path = file(&job.id, "json");
156    let beside = file(&job.id, "json.new");
157    let text = serde_json::to_string_pretty(job).map_err(|e| e.to_string())?;
158    private_file(&beside, true)?.write_all(text.as_bytes()).map_err(|e| format!("{}: {e}", beside.display()))?;
159    std::fs::rename(&beside, &path).map_err(|e| format!("{}: {e}", path.display()))
160}
161
162/// A job's file as written.
163fn read(id: &str) -> Result<Job, String> {
164    let path = file(id, "json");
165    let text = std::fs::read_to_string(&path).map_err(|_| format!("No job “{id}”."))?;
166    serde_json::from_str(&text).map_err(|e| format!("{}: {e}", path.display()))
167}
168
169/// Whether a process holds the job's lock: it runs.
170fn held(id: &str) -> bool {
171    let Ok(lock) = std::fs::OpenOptions::new().read(true).open(file(id, "lock")) else { return false };
172    match lock.try_lock() {
173        Ok(()) => {
174            let _ = lock.unlock();
175            false
176        }
177        Err(_) => true,
178    }
179}
180
181/// The last lines its process printed, on one line each (an error's words).
182fn log_tail(id: &str) -> String {
183    let text = std::fs::read_to_string(file(id, "log")).unwrap_or_default();
184    let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect();
185    lines[lines.len().saturating_sub(3)..].iter().map(|l| crate::one_line(l)).collect::<Vec<_>>().join(" / ")
186}
187
188/// A job as it stands: one whose file says it runs, but whose process
189/// holds its lock no more (after the grace a starting one has), ended
190/// without a word: said "died", with the last words it printed.
191pub(crate) fn look(id: &str) -> Result<Job, String> {
192    if !valid(id) {
193        return Err(format!("No job “{}”.", crate::one_line(&id.chars().take(64).collect::<String>())));
194    }
195    let mut job = read(id)?;
196    if !job.state.ended() && !held(id) && !(job.state == State::Starting && now() - job.created < GRACE) {
197        job.state = State::Died;
198        let tail = log_tail(id);
199        job.error = Some(if tail.is_empty() { "Its process ended without a word: killed, or the computer stopped.".to_string() } else { tail });
200    }
201    job.stop_asked |= file(id, "stop").exists();
202    Ok(job)
203}
204
205/// Every job kept, the newest first.
206pub(crate) fn list() -> Vec<Job> {
207    let mut jobs: Vec<Job> = std::fs::read_dir(folder())
208        .into_iter()
209        .flatten()
210        .filter_map(Result::ok)
211        .filter_map(|e| e.file_name().to_str()?.strip_suffix(".json").map(str::to_string))
212        .filter(|id| valid(id))
213        .filter_map(|id| look(&id).ok())
214        .collect();
215    jobs.sort_by(|a, b| b.created.cmp(&a.created).then(b.id.cmp(&a.id)));
216    jobs
217}
218
219/// The job of this kind that runs, if one does.
220pub(crate) fn running(kind: &str) -> Option<Job> {
221    list().into_iter().find(|j| j.kind == kind && !j.state.ended())
222}
223
224/// The files of jobs ended more than a week ago, gone.
225fn prune() {
226    let before = now() - KEPT_DAYS * 86_400;
227    for job in list().into_iter().filter(|j| j.state.ended() && j.ended.unwrap_or(j.updated) < before) {
228        for extension in ["json", "lock", "log", "stop", "json.new"] {
229            let _ = std::fs::remove_file(file(&job.id, extension));
230        }
231    }
232}
233
234/// A new job's id: when, what, and four letters: "20261007-153012-train-kxqa".
235fn new_id(kind: &str) -> String {
236    use std::hash::{BuildHasher, Hasher};
237    let mut n = std::collections::hash_map::RandomState::new().build_hasher().finish();
238    let letters: String = (0..4)
239        .map(|_| {
240            let letter = char::from(b'a' + (n % 26) as u8);
241            n /= 26;
242            letter
243        })
244        .collect();
245    format!("{}-{kind}-{letters}", jiff::Zoned::now().strftime("%Y%m%d-%H%M%S"))
246}
247
248/// Starts a job: its file made ("starting", `create`), then `sioul spam
249/// <kind> --job <id> <args>` started apart, in a group of its own (on
250/// Windows, without a console), so that it goes on when the program that
251/// started it ends. Answers at once; the job's own process says the rest in its file.
252pub(crate) fn start(s: &Session, kind: &str, args: Vec<String>) -> Result<Job, String> {
253    let job = create(s, kind, args)?;
254    spawn(s, job)
255}
256
257/// A new job's file, "starting": one of each kind at a time; those ended a week ago go.
258pub(crate) fn create(s: &Session, kind: &str, args: Vec<String>) -> Result<Job, String> {
259    if !KINDS.contains(&kind) {
260        return Err(format!("No such job: “{kind}”."));
261    }
262    private_folder()?;
263    prune();
264    if let Some(job) = running(kind) {
265        return Err(s.say("spam-job-busy", &[("kind", kind.to_string()), ("id", job.id)]));
266    }
267    let created = now();
268    let job = Job {
269        id: new_id(kind),
270        kind: kind.to_string(),
271        args,
272        state: State::Starting,
273        pid: None,
274        created,
275        started: None,
276        ended: None,
277        updated: created,
278        progress: None,
279        lines: Vec::new(),
280        result: None,
281        error: None,
282        stop_asked: false,
283    };
284    write(&job)?;
285    Ok(job)
286}
287
288/// The job's process, started apart; the job said failed when it cannot start.
289pub(crate) fn spawn(s: &Session, job: Job) -> Result<Job, String> {
290    let started = (|| -> Result<std::process::Child, String> {
291        let program = std::env::current_exe().map_err(|e| e.to_string())?;
292        let mut command = std::process::Command::new(program);
293        command.arg("--config").arg(&s.config_path).arg("--language").arg(s.tr.language()).args(["spam", job.kind.as_str(), "--job", job.id.as_str()]).args(&job.args);
294        let log = private_file(&file(&job.id, "log"), true)?;
295        command.stdin(std::process::Stdio::null()).stdout(std::process::Stdio::null()).stderr(log);
296        detach(&mut command);
297        command.spawn().map_err(|e| e.to_string())
298    })();
299    match started {
300        Ok(mut child) => {
301            let pid = child.id();
302            // Its end waited for here, so that it leaves no zombie while this program runs.
303            let _ = std::thread::Builder::new().name("spam-job".into()).spawn(move || {
304                let _ = child.wait();
305            });
306            // Its own process writes its file from now on: this answer only says its id and process.
307            Ok(Job { pid: Some(pid), ..job })
308        }
309        Err(e) => {
310            let ended = now();
311            let failed = Job { state: State::Failed, ended: Some(ended), updated: ended, error: Some(e.clone()), ..job };
312            let _ = write(&failed);
313            Err(e)
314        }
315    }
316}
317
318/// A process of its own, which the end of the one that started it does not end.
319fn detach(command: &mut std::process::Command) {
320    #[cfg(unix)]
321    std::os::unix::process::CommandExt::process_group(command, 0);
322    #[cfg(windows)]
323    // DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP.
324    std::os::windows::process::CommandExt::creation_flags(command, 0x0000_0008 | 0x0000_0200);
325}
326
327/// Asks a running job to stop at its next step.
328pub(crate) fn stop(id: &str) -> Result<Job, String> {
329    let job = look(id)?;
330    if !job.state.ended() {
331        private_file(&file(id, "stop"), false)?;
332    }
333    look(id)
334}
335
336/// A job, from inside its own process: it holds the lock while it runs,
337/// says where it stands at most once a second (and at each new step), and
338/// stops when asked.
339pub(crate) struct Running {
340    job: Job,
341    /// Held while the process lives: the lock says it runs.
342    _lock: std::fs::File,
343    said: Option<Instant>,
344    cancel: Cancel,
345}
346
347impl Running {
348    /// Takes the job `start` made: its lock, its state "running", its process.
349    pub(crate) fn open(id: &str) -> Result<Running, String> {
350        if !valid(id) {
351            return Err(format!("No job “{}”.", crate::one_line(id)));
352        }
353        let mut job = read(id)?;
354        let lock = private_file(&file(id, "lock"), false)?;
355        lock.lock().map_err(|e| e.to_string())?;
356        if job.state.ended() {
357            return Err(format!("The job {id} has ended already."));
358        }
359        let started = now();
360        (job.state, job.pid, job.started, job.updated) = (State::Running, Some(std::process::id()), Some(started), started);
361        write(&job)?;
362        let cancel = Cancel::new();
363        // Asked to stop, it stops at its next step: fastText too, which says nothing while it learns.
364        let (watched, stop) = (cancel.clone(), file(id, "stop"));
365        let _ = std::thread::Builder::new().name("spam-job-stop".into()).spawn(move || {
366            while !watched.cancelled() {
367                if stop.exists() {
368                    watched.cancel();
369                }
370                std::thread::sleep(Duration::from_millis(500));
371            }
372        });
373        Ok(Running { job, _lock: lock, said: None, cancel })
374    }
375
376    /// What stops it.
377    pub(crate) fn cancel(&self) -> Cancel {
378        self.cancel.clone()
379    }
380
381    /// Where it stands, written at most once a second, and at each new step or folder.
382    pub(crate) fn see(&mut self, s: &Session, p: &Progress) {
383        let stage = p.stage.as_str().to_string();
384        let new = self.job.progress.as_ref().is_none_or(|old| old.stage != stage || old.detail != p.detail);
385        if !new && self.said.is_some_and(|at| at.elapsed() < Duration::from_secs(1)) {
386            return;
387        }
388        self.said = Some(Instant::now());
389        self.job.progress = Some(Step { stage, done: p.done, total: p.total, detail: crate::one_line(&p.detail), line: step_line(s, p) });
390        self.job.updated = now();
391        self.job.stop_asked |= self.cancel.cancelled();
392        let _ = write(&self.job);
393    }
394
395    /// The end: its result (lines and data), or what went wrong; "stopped" when asked to stop.
396    pub(crate) fn finish(mut self, outcome: Result<(Vec<String>, Value), String>) -> Result<(), String> {
397        let ended = now();
398        (self.job.ended, self.job.updated) = (Some(ended), ended);
399        match outcome {
400            Ok((lines, data)) => (self.job.state, self.job.lines, self.job.result) = (State::Done, lines, Some(data)),
401            Err(e) => (self.job.state, self.job.error) = (if self.cancel.cancelled() { State::Stopped } else { State::Failed }, Some(e)),
402        }
403        self.job.stop_asked |= self.cancel.cancelled();
404        // The watcher of the stop file ends with it.
405        self.cancel.cancel();
406        let _ = std::fs::remove_file(file(&self.job.id, "stop"));
407        write(&self.job)
408    }
409}
410
411/// A step said in the person's language: "home · INBOX: 1200 / 3000", "Learning your mail's words…".
412pub(crate) fn step_line(s: &Session, p: &Progress) -> String {
413    let counted = if p.total > 0 { format!(" {} / {}", p.done, p.total) } else { String::new() };
414    match p.stage {
415        Stage::Corpus if p.detail.is_empty() => s.say("spam-fetch-estimate", &[("n", p.total.to_string())]),
416        Stage::Corpus => format!("{}:{counted}", crate::one_line(&p.detail)),
417        stage => format!("{}{counted}", s.tr.text(&format!("spam-stage-{}", stage.as_str()), None)),
418    }
419}
420
421#[cfg(test)]
422mod tests {
423    use super::*;
424
425    /// Ids that could name a file elsewhere name no job.
426    #[test]
427    fn ids_name_nothing_else() {
428        assert!(valid("20261007-153012-train-kxqa"));
429        for id in ["", "..", "../x", "a/b", "-x", "x.json", "a b", &"x".repeat(65)] {
430            assert!(!valid(id), "{id}");
431        }
432        let id = new_id("fetch");
433        assert!(valid(&id) && id.contains("-fetch-"), "{id}");
434    }
435}