Skip to main content

kaish_types/
job.rs

1//! Job identification and status types.
2
3use std::path::PathBuf;
4use std::time::SystemTime;
5
6use serde::{Deserialize, Serialize};
7
8use crate::clock;
9
10/// Unique identifier for a background job.
11///
12/// `Ord`/`PartialOrd` order by the wrapped id — job ids are minted in
13/// increasing order (`JobManager`'s `next_id` counter), so sorting by
14/// `JobId` gives spawn order (GH #247: `JobManager::list`/`list_ids`
15/// previously iterated a `HashMap` in arbitrary order).
16#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
17#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18#[serde(transparent)]
19pub struct JobId(pub u64);
20
21impl std::fmt::Display for JobId {
22    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
23        write!(f, "{}", self.0)
24    }
25}
26
27/// Status of a background job.
28///
29/// Wire spelling (via `Serialize`/`Deserialize`) is lowercase — `"running"`,
30/// `"stopped"`, `"done"`, `"gated"`, `"killed"`, `"failed"` — matching the existing
31/// `/v/jobs/N/status` text vocabulary (`Job::status_string`), not the
32/// capitalized `Display` impl (which stays capitalized for human-facing
33/// text: the `jobs` table, `[N]+ Done ...` notifications). This is now the
34/// pinned wire shape for `jobs --json` and any embedder that deserializes
35/// `JobInfo` — see the round-trip tests below.
36#[non_exhaustive]
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
38#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39#[serde(rename_all = "lowercase")]
40pub enum JobStatus {
41    /// Job is currently running.
42    Running,
43    /// Job was stopped by a signal (e.g., Ctrl-Z / SIGTSTP).
44    Stopped,
45    /// Job completed successfully.
46    Done,
47    /// Job was terminated by `kill %N` (or an embedder's cancel) and has
48    /// unwound. Terminal, like `Failed`, but distinguishes "someone killed
49    /// it" from "it errored on its own" — before this variant existed the
50    /// killed job was deleted outright, so "I killed job 1" and "job 1 never
51    /// existed" were indistinguishable (GH #244). The job's cached result and
52    /// output stay readable until the job is reaped.
53    Killed,
54    /// Job failed with an error.
55    Failed,
56}
57
58impl std::fmt::Display for JobStatus {
59    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
60        match self {
61            JobStatus::Running => write!(f, "Running"),
62            JobStatus::Stopped => write!(f, "Stopped"),
63            JobStatus::Done => write!(f, "Done"),
64            JobStatus::Killed => write!(f, "Killed"),
65            JobStatus::Failed => write!(f, "Failed"),
66        }
67    }
68}
69
70/// Information about a job for listing.
71#[non_exhaustive]
72#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
73#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
74pub struct JobInfo {
75    /// Job ID.
76    pub id: JobId,
77    /// Command description.
78    pub command: String,
79    /// Current status.
80    pub status: JobStatus,
81    /// Path to output file (if available).
82    #[serde(default, skip_serializing_if = "Option::is_none")]
83    pub output_file: Option<PathBuf>,
84    /// OS process ID (if this is a stopped/foreground process). Only ever
85    /// set for a Ctrl-Z-stopped foreground job — an embedder (no TTY) will
86    /// never see this populated; see [`Self::pgids`] for the surface that
87    /// actually covers embedder-spawned externals.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub pid: Option<u32>,
90    /// The job's exit code, once finished. `None` while `Running`/`Stopped`.
91    /// GH #243: previously the only way to learn *how* a job failed was to
92    /// string-parse `failed:{code}` off `/v/jobs/N/status` or block on
93    /// `JobManager::wait`.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub exit_code: Option<i64>,
96    /// Wall-clock time the job started running (spawn/registration time).
97    /// Acquired via [`crate::clock::system_now`], not `SystemTime::now()`
98    /// directly, so this stays valid on `wasm32-unknown-unknown`. On the wire
99    /// this is an RFC 3339 UTC string with millisecond precision
100    /// (`"2026-08-02T14:29:00.123Z"`) — see [`crate::rfc3339`].
101    #[serde(with = "crate::rfc3339::system_time")]
102    #[cfg_attr(feature = "schema", schemars(schema_with = "crate::rfc3339::schema"))]
103    pub started_at: SystemTime,
104    /// Wall-clock time the job finished, if it has. `None` while
105    /// `Running`/`Stopped`. Same RFC 3339 wire format as [`Self::started_at`].
106    #[serde(
107        default,
108        skip_serializing_if = "Option::is_none",
109        with = "crate::rfc3339::opt_system_time"
110    )]
111    #[cfg_attr(feature = "schema", schemars(schema_with = "crate::rfc3339::opt_schema"))]
112    pub finished_at: Option<SystemTime>,
113    /// OS process groups spawned by this job's external children (so
114    /// `kill -<sig> %N` can signal them, and an embedder can see what's
115    /// actually running). Empty for a pure-builtin job. GH #243: the real
116    /// surface for "what is this job doing", since `pid` above almost never
117    /// applies to an embedder-created job.
118    #[serde(default, skip_serializing_if = "Vec::is_empty")]
119    pub pgids: Vec<u32>,
120}
121
122impl JobInfo {
123    /// Create a `JobInfo` with the required fields; `output_file`/`pid`/
124    /// `exit_code`/`finished_at` default to `None`, `pgids` to empty,
125    /// and `started_at` to now (callers that track a job's real start time —
126    /// i.e. `JobManager` — override it via [`Self::with_started_at`]). Chain
127    /// the `with_*` setters to fill in the rest.
128    ///
129    /// `#[non_exhaustive]` blocks struct-literal construction from outside this
130    /// crate — this constructor plus the setters below are the replacement.
131    pub fn new(id: JobId, command: impl Into<String>, status: JobStatus) -> Self {
132        Self {
133            id,
134            command: command.into(),
135            status,
136            output_file: None,
137            pid: None,
138            exit_code: None,
139            started_at: clock::system_now(),
140            finished_at: None,
141            pgids: Vec::new(),
142        }
143    }
144
145    /// Set the output file path.
146    pub fn with_output_file(mut self, output_file: Option<PathBuf>) -> Self {
147        self.output_file = output_file;
148        self
149    }
150
151    /// Set the OS process ID.
152    pub fn with_pid(mut self, pid: Option<u32>) -> Self {
153        self.pid = pid;
154        self
155    }
156
157    /// Set the exit code (see [`Self::exit_code`]).
158    pub fn with_exit_code(mut self, exit_code: Option<i64>) -> Self {
159        self.exit_code = exit_code;
160        self
161    }
162
163    /// Set the job's real start time (see [`Self::started_at`]).
164    pub fn with_started_at(mut self, started_at: SystemTime) -> Self {
165        self.started_at = started_at;
166        self
167    }
168
169    /// Set the job's finish time (see [`Self::finished_at`]).
170    pub fn with_finished_at(mut self, finished_at: Option<SystemTime>) -> Self {
171        self.finished_at = finished_at;
172        self
173    }
174
175    /// Set the recorded process groups (see [`Self::pgids`]).
176    pub fn with_pgids(mut self, pgids: Vec<u32>) -> Self {
177        self.pgids = pgids;
178        self
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn new_defaults_optional_fields_to_none() {
188        let before = std::time::SystemTime::now();
189        let info = JobInfo::new(JobId(1), "echo hi", JobStatus::Running);
190        assert_eq!(info.id, JobId(1));
191        assert_eq!(info.command, "echo hi");
192        assert_eq!(info.status, JobStatus::Running);
193        assert!(info.output_file.is_none());
194        assert!(info.pid.is_none());
195        assert!(info.exit_code.is_none());
196        assert!(info.finished_at.is_none());
197        assert!(info.pgids.is_empty());
198        // started_at defaults to "now" — bounded sanity check, not exact.
199        assert!(
200            info.started_at >= before,
201            "started_at should default to roughly now"
202        );
203        assert!(
204            info.started_at.duration_since(before).unwrap_or_default() < std::time::Duration::from_secs(5),
205            "started_at default drifted too far from now"
206        );
207    }
208
209    #[test]
210    fn with_setters_chain_and_override_defaults() {
211        let started = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_000);
212        let finished = std::time::SystemTime::UNIX_EPOCH + std::time::Duration::from_secs(1_010);
213        let info = JobInfo::new(JobId(2), "sleep 1", JobStatus::Done)
214            .with_output_file(Some(PathBuf::from("job-output.txt")))
215            .with_pid(Some(1234))
216            .with_exit_code(Some(0))
217            .with_started_at(started)
218            .with_finished_at(Some(finished))
219            .with_pgids(vec![4242, 4243]);
220        assert_eq!(info.output_file, Some(PathBuf::from("job-output.txt")));
221        assert_eq!(info.pid, Some(1234));
222        assert_eq!(info.exit_code, Some(0));
223        assert_eq!(info.started_at, started);
224        assert_eq!(info.finished_at, Some(finished));
225        assert_eq!(info.pgids, vec![4242, 4243]);
226    }
227
228    // ── serde: JobId ──
229
230    #[test]
231    fn job_id_serializes_transparent() {
232        assert_eq!(serde_json::to_value(JobId(42)).unwrap(), serde_json::json!(42));
233        let back: JobId = serde_json::from_value(serde_json::json!(42)).unwrap();
234        assert_eq!(back, JobId(42));
235    }
236
237    // ── serde: JobStatus wire spelling (pinned — API once kaijutsu depends on it) ──
238
239    #[test]
240    fn job_status_json_spelling_is_lowercase() {
241        // Pin the exact wire spelling. Deliberately lowercase, matching the
242        // existing `/v/jobs/N/status` vocabulary (`running`/`done:0`/`gated`/
243        // `failed:N`) rather than the capitalized `Display` impl — `Display`
244        // stays capitalized for human-facing text (the `jobs` table).
245        assert_eq!(serde_json::to_string(&JobStatus::Running).unwrap(), "\"running\"");
246        assert_eq!(serde_json::to_string(&JobStatus::Stopped).unwrap(), "\"stopped\"");
247        assert_eq!(serde_json::to_string(&JobStatus::Done).unwrap(), "\"done\"");
248        assert_eq!(serde_json::to_string(&JobStatus::Killed).unwrap(), "\"killed\"");
249        assert_eq!(serde_json::to_string(&JobStatus::Failed).unwrap(), "\"failed\"");
250    }
251
252    #[test]
253    fn job_status_round_trips_through_serde() {
254        for status in [
255            JobStatus::Running,
256            JobStatus::Stopped,
257            JobStatus::Done,
258            JobStatus::Killed,
259            JobStatus::Failed,
260        ] {
261            let json = serde_json::to_string(&status).unwrap();
262            let back: JobStatus = serde_json::from_str(&json).unwrap();
263            assert_eq!(back, status);
264        }
265    }
266
267    // ── serde: JobInfo round-trip ──
268
269    #[test]
270    fn job_info_omits_unset_optional_fields_from_the_wire() {
271        // A plain running job (the common case) must not carry dead weight:
272        // no output_file/pid/exit_code/finished_at, no pgids array.
273        let info = JobInfo::new(JobId(4), "sleep 5", JobStatus::Running);
274        let json = serde_json::to_value(&info).unwrap();
275        let obj = json.as_object().unwrap();
276        assert!(!obj.contains_key("output_file"), "{json}");
277        assert!(!obj.contains_key("pid"), "{json}");
278        assert!(!obj.contains_key("exit_code"), "{json}");
279        assert!(!obj.contains_key("finished_at"), "{json}");
280        assert!(!obj.contains_key("pgids"), "{json}");
281        // Required fields always present.
282        assert!(obj.contains_key("started_at"), "{json}");
283        assert!(obj.contains_key("status"), "{json}");
284    }
285
286    // ── serde: timestamps are RFC 3339 UTC strings (wire format pinned) ──
287
288    #[test]
289    fn job_info_timestamps_serialize_as_rfc3339_utc_strings() {
290        // 1_700_000_000s past the epoch is 2023-11-14T22:13:20Z. Exactly three
291        // fractional digits, truncated never rounded, `Z` only — fixed width
292        // keeps string order equal to time order.
293        let started = std::time::UNIX_EPOCH + std::time::Duration::new(1_700_000_000, 123_456_789);
294        let finished = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_005);
295        let info = JobInfo::new(JobId(6), "sleep 5", JobStatus::Done)
296            .with_started_at(started)
297            .with_finished_at(Some(finished));
298        let json = serde_json::to_value(&info).unwrap();
299        assert_eq!(json["started_at"], "2023-11-14T22:13:20.123Z", "{json}");
300        assert_eq!(json["finished_at"], "2023-11-14T22:13:25.000Z", "{json}");
301    }
302
303    #[test]
304    fn job_info_timestamps_parse_second_through_nanosecond_precision() {
305        let base = serde_json::json!({
306            "id": 7, "command": "x", "status": "done",
307            "started_at": "2023-11-14T22:13:20Z",
308        });
309        let back: JobInfo = serde_json::from_value(base).unwrap();
310        assert_eq!(
311            back.started_at,
312            std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_700_000_000)
313        );
314
315        let nanos = serde_json::json!({
316            "id": 7, "command": "x", "status": "done",
317            "started_at": "2023-11-14T22:13:20.123456789Z",
318        });
319        let back: JobInfo = serde_json::from_value(nanos).unwrap();
320        assert_eq!(
321            back.started_at,
322            std::time::UNIX_EPOCH + std::time::Duration::new(1_700_000_000, 123_456_789)
323        );
324    }
325
326    #[test]
327    fn job_info_timestamps_reject_junk_loud() {
328        // One spelling on the wire: `Z` only (kaish never emits an offset, so
329        // it does not accept one), `T` separator, real calendar dates, nothing
330        // before the epoch.
331        for bad in [
332            "2023-11-14 22:13:20Z",      // space separator
333            "2023-11-14T22:13:20",       // missing zone
334            "2023-11-14T22:13:20+00:00", // offset instead of Z
335            "1969-12-31T23:59:59Z",      // before the epoch
336            "2023-13-01T00:00:00Z",      // month 13
337            "2023-02-29T00:00:00Z",      // not a leap year
338            "2023-11-14T24:00:00Z",      // hour 24
339            "not-a-time",
340        ] {
341            let v = serde_json::json!({
342                "id": 8, "command": "x", "status": "done", "started_at": bad,
343            });
344            let r: Result<JobInfo, _> = serde_json::from_value(v);
345            assert!(r.is_err(), "{bad:?} must be rejected");
346            let msg = r.unwrap_err().to_string();
347            assert!(
348                msg.contains("RFC 3339"),
349                "error for {bad:?} must name the expected format: {msg}"
350            );
351        }
352    }
353
354    #[test]
355    fn job_info_leap_day_round_trips() {
356        let leap = std::time::UNIX_EPOCH + std::time::Duration::from_secs(1_709_164_800);
357        let info = JobInfo::new(JobId(9), "x", JobStatus::Done).with_started_at(leap);
358        let json = serde_json::to_value(&info).unwrap();
359        assert_eq!(json["started_at"], "2024-02-29T00:00:00.000Z", "{json}");
360        let back: JobInfo = serde_json::from_value(json).unwrap();
361        assert_eq!(back.started_at, leap);
362    }
363
364    #[test]
365    fn job_info_exit_code_present_when_job_failed() {
366        // GH #243(a): a job that exited 42 must surface the code, not just
367        // "Failed" — this is the exact bug the audit verified against
368        // `jobs --json`.
369        let info = JobInfo::new(JobId(5), "sh -c 'exit 42'", JobStatus::Failed)
370            .with_exit_code(Some(42));
371        let json = serde_json::to_value(&info).unwrap();
372        assert_eq!(json["exit_code"], 42);
373        assert_eq!(json["status"], "failed");
374    }
375}