Skip to main content

kanade_shared/ipc/
jobs.rs

1//! `jobs.*` method types — user-invokable job catalog + execute +
2//! progress + kill.
3//!
4//! The Client App's three job-driven tabs (SPEC §2.1):
5//! - "アップデート" lists `category: software_update` manifests
6//! - "困ったとき" lists `category: troubleshoot` manifests
7//! - Software catalog lists `category: catalog` manifests
8//!
9//! All three flow through the same `jobs.list` / `jobs.execute` /
10//! `jobs.progress` pipeline — only the filter differs. Manifests
11//! with `user_invokable: false` are invisible from KLP (the agent
12//! filters before answering `jobs.list`, and rejects
13//! `jobs.execute` with `Unauthorized` if a client tries to call
14//! one directly).
15
16use serde::{Deserialize, Serialize};
17
18// ---------- shared types ----------
19
20/// Job category from the manifest's `category:` field. Drives which
21/// Client App tab the job appears in. `#[non_exhaustive]` leaves
22/// room for SPEC additions (new tabs) without a wire bump.
23#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
24#[serde(rename_all = "snake_case")]
25#[non_exhaustive]
26pub enum JobCategory {
27    /// Chrome / Edge / Office / runtime updaters. Appears in the
28    /// "アップデート" tab.
29    SoftwareUpdate,
30    /// Teams cache clear, Office repair, network reset, … Appears
31    /// in the "困ったとき" tab.
32    Troubleshoot,
33    /// Self-service install catalog. Appears in the software
34    /// catalog tab.
35    Catalog,
36    /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
37    /// only affects Rust match exhaustiveness — serde still hard-fails
38    /// on an unknown variant STRING, so a newer peer's new variant
39    /// used to make older readers reject the whole containing message.
40    /// Unknown decodes any unrecognised value; UIs render it neutrally.
41    #[serde(other)]
42    Unknown,
43}
44
45/// Run-state machine for one `jobs.execute` invocation.
46///
47/// State transitions:
48/// `Queued` → `Running` → `Completed` | `Failed` | `Killed`.
49/// `Queued` ⇒ accepted but not started yet (waiting on the
50/// concurrent-run cap or staleness check); the very first
51/// `jobs.progress` push usually moves straight to `Running`.
52/// `#[non_exhaustive]` so a future SPEC can add states like
53/// `Skipped` (staleness gate) without a wire bump.
54#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
55#[serde(rename_all = "snake_case")]
56#[non_exhaustive]
57pub enum RunStatus {
58    /// Accepted, not yet spawned.
59    Queued,
60    /// `tokio::process::Command::spawn()` returned, script is
61    /// running.
62    Running,
63    /// Exited with code 0 (or whatever the manifest declares as
64    /// success).
65    Completed,
66    /// Exited non-zero, or a Layer 2 skipped-result was published.
67    Failed,
68    /// User-initiated kill via `jobs.kill`. Distinct from `Failed`
69    /// so the SPA can show "stopped by you" instead of "errored".
70    Killed,
71    /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
72    /// only affects Rust match exhaustiveness — serde still hard-fails
73    /// on an unknown variant STRING, so a newer peer's new variant
74    /// used to make older readers reject the whole containing message.
75    /// Unknown decodes any unrecognised value; UIs render it neutrally.
76    #[serde(other)]
77    Unknown,
78}
79
80/// One entry in `jobs.list` — the SPEC §2.12.11 reference shape,
81/// extended with the `description` field used by the manifest's
82/// existing `display_description`.
83#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
84pub struct UserInvokableJob {
85    /// Manifest id (matches everywhere else — `Command.id`,
86    /// `ExecResult.manifest_id`).
87    pub id: String,
88    /// `display_name` from the manifest.
89    pub display_name: String,
90    /// `display_description` from the manifest. Renders as the row's
91    /// subtitle in the Client App.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub display_description: Option<String>,
94    /// Optional icon hint (lucide-react name or a `data:` URL).
95    /// `None` means the SPA falls back to the category's default
96    /// icon.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub icon: Option<String>,
99    pub category: JobCategory,
100    /// Pinned version string from the manifest. Same field as
101    /// `Manifest.version`.
102    pub version: String,
103    /// Snapshot of the last KLP-driven run of this job FOR THIS
104    /// USER. `None` until they've executed it at least once.
105    /// Backend keeps the cross-user / cross-PC history separately
106    /// (operator-only `executions` table).
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub last_run: Option<JobRun>,
109}
110
111/// Compact summary of a past run — what the Client App shows next
112/// to the job's "Run again" button.
113#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
114pub struct JobRun {
115    pub run_id: String,
116    pub status: RunStatus,
117    pub started_at: chrono::DateTime<chrono::Utc>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub exit_code: Option<i32>,
122}
123
124// ---------- jobs.list ----------
125
126/// `jobs.list` params — optional category filter (when the Client
127/// App is showing a single tab and doesn't want the full set).
128#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
129pub struct JobsListParams {
130    /// `None` ⇒ return every user-invokable job. `Some(c)` ⇒ filter
131    /// to that category. The agent always strips
132    /// `user_invokable: false` manifests regardless of filter.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub category: Option<JobCategory>,
135}
136
137#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
138pub struct JobsListResult {
139    pub items: Vec<UserInvokableJob>,
140}
141
142// ---------- jobs.execute ----------
143
144/// `jobs.execute` params — the manifest id to run. Agent looks up
145/// the manifest from KV at fire time, so a change to
146/// `user_invokable` takes effect on the next execute attempt (SPEC
147/// §2.1: "Agent 側で manifest を必ず再 lookup し、`user_invokable:
148/// false` への変更が即時反映される").
149#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
150pub struct JobsExecuteParams {
151    /// Manifest id from `jobs.list[].id`.
152    pub id: String,
153}
154
155#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
156pub struct JobsExecuteResult {
157    /// Agent-minted UUID for this specific run. Carried back to the
158    /// caller so they can correlate the `jobs.progress` pushes that
159    /// follow + later `jobs.kill` calls.
160    pub run_id: String,
161}
162
163// ---------- jobs.subscribe ----------
164
165#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
166pub struct JobsSubscribeParams {}
167
168#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
169pub struct JobsSubscribeResult {
170    pub subscription: String,
171}
172
173#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
174pub struct JobsUnsubscribeParams {
175    pub subscription: String,
176}
177
178// ---------- jobs.progress (push) ----------
179
180/// Push payload for `jobs.progress`. Sent on:
181/// - first move from Queued → Running
182/// - each stdout / stderr chunk (split to fit the 1 MiB framing
183///   cap — SPEC §2.12.2)
184/// - terminal state transition (Completed / Failed / Killed) with
185///   `exit_code` populated
186///
187/// The reference shape is SPEC §2.12.11's `JobProgress` struct.
188#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
189pub struct JobProgress {
190    /// The `run_id` minted by `jobs.execute`.
191    pub run_id: String,
192    pub status: RunStatus,
193    /// Newly-produced stdout, UTF-8 decoded (tolerant — see
194    /// `kanade-agent::process::capture_tolerant`). `None` when this
195    /// push is a pure status transition; `Some("")` would never be
196    /// emitted (the agent omits the field instead).
197    #[serde(default, skip_serializing_if = "Option::is_none")]
198    pub stdout_chunk: Option<String>,
199    /// Newly-produced stderr. Same conventions as `stdout_chunk`.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub stderr_chunk: Option<String>,
202    /// Populated on the terminal push only. Agents stamp the actual
203    /// process exit code from the child. Synthetic non-process
204    /// outcomes (timeout, remote kill) are surfaced as `Some(-1)`
205    /// with the `status` field carrying the distinguishing
206    /// information (`Failed` / `Killed`), not via a reserved
207    /// exit-code number.
208    ///
209    /// Note: the sibling `ExecResult` wire (manifest exec →
210    /// backend, NOT this KLP flow) DOES partition synthetic skip
211    /// codes (124 / 125 / 126 / 127) for the agent's pre-exec
212    /// staleness gates. See the doc on
213    /// `kanade-agent::commands::publish_staleness_skipped` for
214    /// the table. JobProgress's exit_code does not share that
215    /// partition.
216    #[serde(default, skip_serializing_if = "Option::is_none")]
217    pub exit_code: Option<i32>,
218}
219
220// ---------- jobs.kill ----------
221
222/// `jobs.kill` params — `run_id` from this connection's earlier
223/// `jobs.execute` call. SPEC §2.12.4 forbids cross-connection kill
224/// (agent returns `Unauthorized`); a user wanting to stop another
225/// user's job goes through the operator SPA, not the Client App.
226#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
227pub struct JobsKillParams {
228    pub run_id: String,
229}
230
231#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
232pub struct JobsKillResult {
233    /// Wall-clock the agent dispatched the kill signal. The
234    /// terminal `jobs.progress` push (status = `Killed`) follows
235    /// asynchronously once the child process actually exits.
236    pub requested_at: chrono::DateTime<chrono::Utc>,
237}
238
239#[cfg(test)]
240mod tests {
241    use super::*;
242    use chrono::TimeZone;
243
244    #[test]
245    fn job_category_serialises_snake_case() {
246        for (variant, expected) in [
247            (JobCategory::SoftwareUpdate, "\"software_update\""),
248            (JobCategory::Troubleshoot, "\"troubleshoot\""),
249            (JobCategory::Catalog, "\"catalog\""),
250        ] {
251            let s = serde_json::to_string(&variant).unwrap();
252            assert_eq!(s, expected, "encode {variant:?}");
253            let back: JobCategory = serde_json::from_str(expected).unwrap();
254            assert_eq!(back, variant, "round-trip {expected}");
255        }
256    }
257
258    #[test]
259    fn run_status_serialises_snake_case() {
260        for (variant, expected) in [
261            (RunStatus::Queued, "\"queued\""),
262            (RunStatus::Running, "\"running\""),
263            (RunStatus::Completed, "\"completed\""),
264            (RunStatus::Failed, "\"failed\""),
265            (RunStatus::Killed, "\"killed\""),
266        ] {
267            let s = serde_json::to_string(&variant).unwrap();
268            assert_eq!(s, expected, "encode {variant:?}");
269            let back: RunStatus = serde_json::from_str(expected).unwrap();
270            assert_eq!(back, variant, "round-trip {expected}");
271        }
272    }
273
274    #[test]
275    fn user_invokable_job_minimum_shape_decodes() {
276        // Backend that hasn't fully populated `display_description`
277        // / `icon` / `last_run` must still produce decodable rows.
278        let wire = r#"{
279            "id":"chrome-update","display_name":"Chrome を更新",
280            "category":"software_update","version":"1.2.0"
281        }"#;
282        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
283        assert_eq!(j.id, "chrome-update");
284        assert!(j.display_description.is_none());
285        assert!(j.icon.is_none());
286        assert!(j.last_run.is_none());
287    }
288
289    #[test]
290    fn job_progress_status_transition_omits_chunks() {
291        // Status-only push (Queued → Running) has neither stdout
292        // nor stderr; both fields must be absent from the wire, not
293        // null. Strict JS clients reject `null` strings.
294        let p = JobProgress {
295            run_id: "run-1".into(),
296            status: RunStatus::Running,
297            stdout_chunk: None,
298            stderr_chunk: None,
299            exit_code: None,
300        };
301        let v = serde_json::to_value(&p).unwrap();
302        assert!(v.get("stdout_chunk").is_none(), "wire: {v:?}");
303        assert!(v.get("stderr_chunk").is_none(), "wire: {v:?}");
304        assert!(v.get("exit_code").is_none(), "wire: {v:?}");
305    }
306
307    #[test]
308    fn job_progress_terminal_push_carries_exit_code() {
309        let p = JobProgress {
310            run_id: "run-1".into(),
311            status: RunStatus::Completed,
312            stdout_chunk: None,
313            stderr_chunk: None,
314            exit_code: Some(0),
315        };
316        let v = serde_json::to_value(&p).unwrap();
317        assert_eq!(v["status"], "completed");
318        assert_eq!(v["exit_code"], 0);
319    }
320
321    #[test]
322    fn jobs_list_filter_optional() {
323        // No filter ⇒ all categories. Wire form has no `category`
324        // key, not `category: null`.
325        let p = JobsListParams::default();
326        let v = serde_json::to_value(&p).unwrap();
327        assert!(v.get("category").is_none(), "wire: {v:?}");
328    }
329
330    #[test]
331    fn jobs_execute_result_round_trips() {
332        let r = JobsExecuteResult {
333            run_id: "run-uuid-1".into(),
334        };
335        let json = serde_json::to_string(&r).unwrap();
336        let back: JobsExecuteResult = serde_json::from_str(&json).unwrap();
337        assert_eq!(back.run_id, "run-uuid-1");
338    }
339
340    #[test]
341    fn job_run_serialises_with_optional_finish() {
342        // In-flight run: started_at present, finished_at + exit_code
343        // absent. Critical because the Client App's "last run" chip
344        // uses `finished_at.is_some()` as the "row is terminal" flag.
345        let r = JobRun {
346            run_id: "run-1".into(),
347            status: RunStatus::Running,
348            started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap(),
349            finished_at: None,
350            exit_code: None,
351        };
352        let v = serde_json::to_value(&r).unwrap();
353        assert!(v.get("finished_at").is_none(), "wire: {v:?}");
354        assert!(v.get("exit_code").is_none(), "wire: {v:?}");
355    }
356
357    #[test]
358    fn unknown_enum_variants_decode_to_unknown() {
359        // #492: a newer peer's new variant must not make this build
360        // fail to decode the whole containing message — serde(other)
361        // catches it (non_exhaustive alone never protected the wire).
362        let s: RunStatus = serde_json::from_str("\"skipped\"").unwrap();
363        assert_eq!(s, RunStatus::Unknown);
364        let c: JobCategory = serde_json::from_str("\"future_tab\"").unwrap();
365        assert_eq!(c, JobCategory::Unknown);
366        // Known variants are untouched.
367        let r: RunStatus = serde_json::from_str("\"running\"").unwrap();
368        assert_eq!(r, RunStatus::Running);
369    }
370
371    #[test]
372    fn unknown_variant_round_trips() {
373        // PR #558 review (gemini): Unknown must SERIALIZE cleanly too
374        // — a node that decoded a newer peer's variant and re-emits
375        // the containing message (e.g. an agent forwarding a
376        // jobs.list entry) must not hit a runtime serialization
377        // error. It serialises as "unknown" and decodes back to
378        // Unknown on every #492-aware peer.
379        let s = serde_json::to_string(&RunStatus::Unknown).unwrap();
380        assert_eq!(s, "\"unknown\"");
381        let back: RunStatus = serde_json::from_str(&s).unwrap();
382        assert_eq!(back, RunStatus::Unknown);
383    }
384
385    #[test]
386    fn unknown_decodes_across_the_other_wire_enums() {
387        // PR #558 review (claude): cover the remaining #492 enums.
388        use crate::ipc::error::ErrorKind;
389        use crate::ipc::maintenance::DeferDuration;
390        use crate::ipc::notifications::NotificationPriority;
391
392        let k: ErrorKind = serde_json::from_str("\"FutureErrorKind\"").unwrap();
393        assert_eq!(k, ErrorKind::Unknown);
394        assert_eq!(k.code(), -32099);
395        let known: ErrorKind = serde_json::from_str("\"Unauthorized\"").unwrap();
396        assert_eq!(known, ErrorKind::Unauthorized);
397
398        let p: NotificationPriority = serde_json::from_str("\"critical\"").unwrap();
399        assert_eq!(p, NotificationPriority::Unknown);
400
401        let d: DeferDuration = serde_json::from_str("\"2h\"").unwrap();
402        assert_eq!(d, DeferDuration::Unknown);
403        assert_eq!(d.as_duration(), chrono::Duration::minutes(15));
404    }
405}