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/// Category key for a user-invokable job (the manifest's
21/// `client.category`). #792: this is now a **free-form string**, not a
22/// closed enum — an operator names a tab from the manifest alone and the
23/// Client App renders one tab per distinct key it sees (label / icon /
24/// order supplied by `client.category_label` / `_icon` / `_order`, with
25/// built-in defaults for the well-known keys below).
26///
27/// The agent's maintenance / auto-reboot logic special-cases the
28/// `software_update` key, so it's named here as a constant rather than a
29/// bare literal; the other well-known keys only carry Client App display
30/// defaults, so they live there.
31pub const CATEGORY_SOFTWARE_UPDATE: &str = "software_update";
32
33/// Run-state machine for one `jobs.execute` invocation.
34///
35/// State transitions:
36/// `Queued` → `Running` → `Completed` | `Failed` | `Killed`.
37/// `Queued` ⇒ accepted but not started yet (waiting on the
38/// concurrent-run cap or staleness check); the very first
39/// `jobs.progress` push usually moves straight to `Running`.
40/// `#[non_exhaustive]` so a future SPEC can add states like
41/// `Skipped` (staleness gate) without a wire bump.
42#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
43#[serde(rename_all = "snake_case")]
44#[non_exhaustive]
45pub enum RunStatus {
46    /// Accepted, not yet spawned.
47    Queued,
48    /// `tokio::process::Command::spawn()` returned, script is
49    /// running.
50    Running,
51    /// Exited with code 0 (or whatever the manifest declares as
52    /// success).
53    Completed,
54    /// Exited non-zero, or a Layer 2 skipped-result was published.
55    Failed,
56    /// User-initiated kill via `jobs.kill`. Distinct from `Failed`
57    /// so the SPA can show "stopped by you" instead of "errored".
58    Killed,
59    /// #492: serde-level forward-compat catch-all. `#[non_exhaustive]`
60    /// only affects Rust match exhaustiveness — serde still hard-fails
61    /// on an unknown variant STRING, so a newer peer's new variant
62    /// used to make older readers reject the whole containing message.
63    /// Unknown decodes any unrecognised value; UIs render it neutrally.
64    #[serde(other)]
65    Unknown,
66}
67
68/// One entry in `jobs.list` — the SPEC §2.12.11 reference shape,
69/// extended with the `description` field used by the manifest's
70/// existing `display_description`.
71#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
72pub struct UserInvokableJob {
73    /// Manifest id (matches everywhere else — `Command.id`,
74    /// `ExecResult.manifest_id`).
75    pub id: String,
76    /// `display_name` from the manifest.
77    pub display_name: String,
78    /// `display_description` from the manifest. Renders as the row's
79    /// subtitle in the Client App.
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub display_description: Option<String>,
82    /// Optional icon hint (lucide-react name or a `data:` URL).
83    /// `None` means the SPA falls back to the category's default
84    /// icon.
85    #[serde(default, skip_serializing_if = "Option::is_none")]
86    pub icon: Option<String>,
87    /// Free-form category key (#792). The Client App groups jobs into one
88    /// tab per distinct key.
89    pub category: String,
90    /// Operator-supplied display name for the category's tab (from
91    /// `client.category_label`). `None` ⇒ the Client App uses a built-in
92    /// default for a well-known key, else the key itself.
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub category_label: Option<String>,
95    /// Operator-supplied tab icon (lucide name or `data:` URL) for the
96    /// category (from `client.category_icon`). `None` ⇒ Client App default.
97    #[serde(default, skip_serializing_if = "Option::is_none")]
98    pub category_icon: Option<String>,
99    /// Operator-supplied sort order for the tab (from
100    /// `client.category_order`); lower sorts first. `None` ⇒ default.
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub category_order: Option<i64>,
103    /// Pinned version string from the manifest. Same field as
104    /// `Manifest.version`.
105    pub version: String,
106    /// `Manifest.execute.timeout` lowered to whole seconds (#865). The
107    /// agent kills the run at this deadline, so the Client App's
108    /// stuck-run watchdog uses `timeout_secs + grace` instead of a fixed
109    /// 15 min — a near-silent job (e.g. Office repair) is no longer
110    /// falsely marked failed while it's still running. `None` from an
111    /// older agent that predates this field ⇒ the client falls back to
112    /// the fixed watchdog (#492 wire rule: `serde(default)` +
113    /// `skip_serializing_if`).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub timeout_secs: Option<u64>,
116    /// Confirmation-dialog config projected from the manifest's
117    /// `client.confirm` block. `None` ⇒ the manifest specified nothing (or
118    /// an older agent predates this field), so the Client App keeps its
119    /// historical behaviour: show the modal with the built-in message. When
120    /// present, `enabled: false` suppresses the dialog and `message`
121    /// overrides its text. (#492 wire rule: `serde(default)` +
122    /// `skip_serializing_if`.)
123    #[serde(default, skip_serializing_if = "Option::is_none")]
124    pub confirm: Option<JobConfirm>,
125    /// The `client.unlock` scope that revealed this row, when it is an
126    /// unlock-gated job. `None` ⇒ an ordinary job every user sees.
127    ///
128    /// A display marker: the agent already applied the gate on the way out
129    /// (a gated row only appears while the caller holds a matching grant), so
130    /// the client uses this solely to badge the row as helpdesk-only. The
131    /// gate is listing-only — `jobs.execute` does not re-check it — so a row
132    /// the user can see is a row they can run. #492 wire rule.
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub unlock: Option<String>,
135    /// Snapshot of the last KLP-driven run of this job FOR THIS
136    /// USER. `None` until they've executed it at least once.
137    /// Backend keeps the cross-user / cross-PC history separately
138    /// (operator-only `executions` table).
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub last_run: Option<JobRun>,
141}
142
143/// Confirmation-dialog config for a [`UserInvokableJob`], projected 1:1
144/// from the manifest's `client.confirm` block. Kept self-contained in the
145/// wire layer (not a re-export of the manifest's `ConfirmHint`) so the KLP
146/// contract doesn't depend on the manifest module.
147#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
148pub struct JobConfirm {
149    /// Whether the Client App shows the confirmation dialog before running.
150    /// `false` fires the job immediately with no prompt.
151    pub enabled: bool,
152    /// Custom dialog message. `None` ⇒ the client's built-in
153    /// 「「{name}」を実行しますか?」.
154    #[serde(default, skip_serializing_if = "Option::is_none")]
155    pub message: Option<String>,
156}
157
158/// Compact summary of a past run — what the Client App shows next
159/// to the job's "Run again" button.
160#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
161pub struct JobRun {
162    pub run_id: String,
163    pub status: RunStatus,
164    pub started_at: chrono::DateTime<chrono::Utc>,
165    #[serde(default, skip_serializing_if = "Option::is_none")]
166    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub exit_code: Option<i32>,
169}
170
171// ---------- jobs.list ----------
172
173/// `jobs.list` params — optional category filter (when the Client
174/// App is showing a single tab and doesn't want the full set).
175#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
176pub struct JobsListParams {
177    /// `None` ⇒ return every user-invokable job. `Some(key)` ⇒ filter
178    /// to that category key. The agent always strips
179    /// `user_invokable: false` manifests regardless of filter.
180    #[serde(default, skip_serializing_if = "Option::is_none")]
181    pub category: Option<String>,
182}
183
184#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
185pub struct JobsListResult {
186    pub items: Vec<UserInvokableJob>,
187}
188
189// ---------- jobs.execute ----------
190
191/// `jobs.execute` params — the manifest id to run. Agent looks up
192/// the manifest from KV at fire time, so a change to
193/// `user_invokable` takes effect on the next execute attempt (SPEC
194/// §2.1: "Agent 側で manifest を必ず再 lookup し、`user_invokable:
195/// false` への変更が即時反映される").
196#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
197pub struct JobsExecuteParams {
198    /// Manifest id from `jobs.list[].id`.
199    pub id: String,
200}
201
202#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
203pub struct JobsExecuteResult {
204    /// Agent-minted UUID for this specific run. Carried back to the
205    /// caller so they can correlate the `jobs.progress` pushes that
206    /// follow + later `jobs.kill` calls.
207    pub run_id: String,
208}
209
210// ---------- jobs.subscribe ----------
211
212#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
213pub struct JobsSubscribeParams {}
214
215#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
216pub struct JobsSubscribeResult {
217    pub subscription: String,
218}
219
220#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
221pub struct JobsUnsubscribeParams {
222    pub subscription: String,
223}
224
225// ---------- jobs.progress (push) ----------
226
227/// Push payload for `jobs.progress`. Sent on:
228/// - first move from Queued → Running
229/// - each stdout / stderr chunk (split to fit the 1 MiB framing
230///   cap — SPEC §2.12.2)
231/// - terminal state transition (Completed / Failed / Killed) with
232///   `exit_code` populated
233///
234/// The reference shape is SPEC §2.12.11's `JobProgress` struct.
235#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
236pub struct JobProgress {
237    /// The `run_id` minted by `jobs.execute`.
238    pub run_id: String,
239    pub status: RunStatus,
240    /// Newly-produced stdout, UTF-8 decoded (tolerant — see
241    /// `kanade-agent::process::capture_tolerant`). `None` when this
242    /// push is a pure status transition; `Some("")` would never be
243    /// emitted (the agent omits the field instead).
244    #[serde(default, skip_serializing_if = "Option::is_none")]
245    pub stdout_chunk: Option<String>,
246    /// Newly-produced stderr. Same conventions as `stdout_chunk`.
247    #[serde(default, skip_serializing_if = "Option::is_none")]
248    pub stderr_chunk: Option<String>,
249    /// Populated on the terminal push only. Agents stamp the actual
250    /// process exit code from the child. Synthetic non-process
251    /// outcomes (timeout, remote kill) are surfaced as `Some(-1)`
252    /// with the `status` field carrying the distinguishing
253    /// information (`Failed` / `Killed`), not via a reserved
254    /// exit-code number.
255    ///
256    /// Note: the sibling `ExecResult` wire (manifest exec →
257    /// backend, NOT this KLP flow) DOES partition synthetic skip
258    /// codes (124 / 125 / 126 / 127) for the agent's pre-exec
259    /// staleness gates. See the doc on
260    /// `kanade-agent::commands::publish_staleness_skipped` for
261    /// the table. JobProgress's exit_code does not share that
262    /// partition.
263    #[serde(default, skip_serializing_if = "Option::is_none")]
264    pub exit_code: Option<i32>,
265}
266
267// ---------- jobs.kill ----------
268
269/// `jobs.kill` params — `run_id` from this connection's earlier
270/// `jobs.execute` call. SPEC §2.12.4 forbids cross-connection kill
271/// (agent returns `Unauthorized`); a user wanting to stop another
272/// user's job goes through the operator SPA, not the Client App.
273#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
274pub struct JobsKillParams {
275    pub run_id: String,
276}
277
278#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
279pub struct JobsKillResult {
280    /// Wall-clock the agent dispatched the kill signal. The
281    /// terminal `jobs.progress` push (status = `Killed`) follows
282    /// asynchronously once the child process actually exits.
283    pub requested_at: chrono::DateTime<chrono::Utc>,
284}
285
286#[cfg(test)]
287mod tests {
288    use super::*;
289    use chrono::TimeZone;
290
291    #[test]
292    fn user_invokable_job_carries_free_form_category() {
293        // #792: category is a free-form string + optional tab metadata.
294        let wire = r#"{
295            "id":"wifi-tweak","display_name":"Wi-Fi 省電力を切る",
296            "category":"settings","category_label":"設定",
297            "category_icon":"settings","category_order":15,"version":"1.0.0"
298        }"#;
299        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
300        assert_eq!(j.category, "settings");
301        assert_eq!(j.category_label.as_deref(), Some("設定"));
302        assert_eq!(j.category_icon.as_deref(), Some("settings"));
303        assert_eq!(j.category_order, Some(15));
304    }
305
306    #[test]
307    fn run_status_serialises_snake_case() {
308        for (variant, expected) in [
309            (RunStatus::Queued, "\"queued\""),
310            (RunStatus::Running, "\"running\""),
311            (RunStatus::Completed, "\"completed\""),
312            (RunStatus::Failed, "\"failed\""),
313            (RunStatus::Killed, "\"killed\""),
314        ] {
315            let s = serde_json::to_string(&variant).unwrap();
316            assert_eq!(s, expected, "encode {variant:?}");
317            let back: RunStatus = serde_json::from_str(expected).unwrap();
318            assert_eq!(back, variant, "round-trip {expected}");
319        }
320    }
321
322    #[test]
323    fn user_invokable_job_minimum_shape_decodes() {
324        // Backend that hasn't fully populated `display_description`
325        // / `icon` / `last_run` must still produce decodable rows.
326        let wire = r#"{
327            "id":"chrome-update","display_name":"Chrome を更新",
328            "category":"software_update","version":"1.2.0"
329        }"#;
330        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
331        assert_eq!(j.id, "chrome-update");
332        assert!(j.display_description.is_none());
333        assert!(j.icon.is_none());
334        assert!(j.last_run.is_none());
335        // #865: an older agent omits timeout_secs ⇒ None (client falls
336        // back to its fixed watchdog).
337        assert!(j.timeout_secs.is_none());
338    }
339
340    #[test]
341    fn user_invokable_job_timeout_secs_round_trips() {
342        // #865: present on the wire ⇒ carried through; absent ⇒ omitted
343        // from the encoded form (not `null`), per the #492 wire rule.
344        let wire = r#"{
345            "id":"office-repair","display_name":"Office 修復",
346            "category":"troubleshoot","version":"1.0.0","timeout_secs":3600
347        }"#;
348        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
349        assert_eq!(j.timeout_secs, Some(3600));
350
351        let v = serde_json::to_value(UserInvokableJob {
352            id: "x".into(),
353            display_name: "X".into(),
354            display_description: None,
355            icon: None,
356            category: "catalog".into(),
357            category_label: None,
358            category_icon: None,
359            category_order: None,
360            version: "1.0.0".into(),
361            timeout_secs: None,
362            confirm: None,
363            unlock: None,
364            last_run: None,
365        })
366        .unwrap();
367        assert!(v.get("timeout_secs").is_none(), "wire: {v:?}");
368        // confirm is None ⇒ omitted (not `null`), so an older client that
369        // predates the field keeps its historical always-confirm default.
370        assert!(v.get("confirm").is_none(), "wire: {v:?}");
371        // Likewise `unlock`: an ungated job must look exactly as it did
372        // before the field existed, so an older client renders no badge.
373        assert!(v.get("unlock").is_none(), "wire: {v:?}");
374    }
375
376    #[test]
377    fn user_invokable_job_unlock_badge_round_trips() {
378        // A gated row carries its scope so the client can badge it. The
379        // agent only ever emits this row to a caller who already holds the
380        // grant — the field is decoration, not the gate.
381        let wire = r#"{
382            "id":"profile-rebuild","display_name":"プロファイル再作成",
383            "category":"troubleshoot","version":"1.0.0","unlock":"support"
384        }"#;
385        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
386        assert_eq!(j.unlock.as_deref(), Some("support"));
387    }
388
389    #[test]
390    fn user_invokable_job_confirm_round_trips() {
391        // A job carrying a confirm config surfaces it on the wire; the
392        // client reads `enabled` / `message` to suppress or reword the modal.
393        let wire = r#"{
394            "id":"wifi-tweak","display_name":"Wi-Fi 省電力を切る",
395            "category":"settings","version":"1.0.0",
396            "confirm":{"enabled":false}
397        }"#;
398        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
399        let c = j.confirm.expect("confirm present");
400        assert!(!c.enabled);
401        assert!(c.message.is_none());
402
403        // Round-trip a message-carrying confirm; message omitted when None.
404        let v = serde_json::to_value(JobConfirm {
405            enabled: true,
406            message: Some("よろしいですか?".into()),
407        })
408        .unwrap();
409        assert_eq!(v["enabled"], true);
410        assert_eq!(v["message"], "よろしいですか?");
411        let v = serde_json::to_value(JobConfirm {
412            enabled: true,
413            message: None,
414        })
415        .unwrap();
416        assert!(v.get("message").is_none(), "wire: {v:?}");
417    }
418
419    #[test]
420    fn job_progress_status_transition_omits_chunks() {
421        // Status-only push (Queued → Running) has neither stdout
422        // nor stderr; both fields must be absent from the wire, not
423        // null. Strict JS clients reject `null` strings.
424        let p = JobProgress {
425            run_id: "run-1".into(),
426            status: RunStatus::Running,
427            stdout_chunk: None,
428            stderr_chunk: None,
429            exit_code: None,
430        };
431        let v = serde_json::to_value(&p).unwrap();
432        assert!(v.get("stdout_chunk").is_none(), "wire: {v:?}");
433        assert!(v.get("stderr_chunk").is_none(), "wire: {v:?}");
434        assert!(v.get("exit_code").is_none(), "wire: {v:?}");
435    }
436
437    #[test]
438    fn job_progress_terminal_push_carries_exit_code() {
439        let p = JobProgress {
440            run_id: "run-1".into(),
441            status: RunStatus::Completed,
442            stdout_chunk: None,
443            stderr_chunk: None,
444            exit_code: Some(0),
445        };
446        let v = serde_json::to_value(&p).unwrap();
447        assert_eq!(v["status"], "completed");
448        assert_eq!(v["exit_code"], 0);
449    }
450
451    #[test]
452    fn jobs_list_filter_optional() {
453        // No filter ⇒ all categories. Wire form has no `category`
454        // key, not `category: null`.
455        let p = JobsListParams::default();
456        let v = serde_json::to_value(&p).unwrap();
457        assert!(v.get("category").is_none(), "wire: {v:?}");
458    }
459
460    #[test]
461    fn jobs_execute_result_round_trips() {
462        let r = JobsExecuteResult {
463            run_id: "run-uuid-1".into(),
464        };
465        let json = serde_json::to_string(&r).unwrap();
466        let back: JobsExecuteResult = serde_json::from_str(&json).unwrap();
467        assert_eq!(back.run_id, "run-uuid-1");
468    }
469
470    #[test]
471    fn job_run_serialises_with_optional_finish() {
472        // In-flight run: started_at present, finished_at + exit_code
473        // absent. Critical because the Client App's "last run" chip
474        // uses `finished_at.is_some()` as the "row is terminal" flag.
475        let r = JobRun {
476            run_id: "run-1".into(),
477            status: RunStatus::Running,
478            started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap(),
479            finished_at: None,
480            exit_code: None,
481        };
482        let v = serde_json::to_value(&r).unwrap();
483        assert!(v.get("finished_at").is_none(), "wire: {v:?}");
484        assert!(v.get("exit_code").is_none(), "wire: {v:?}");
485    }
486
487    #[test]
488    fn unknown_enum_variants_decode_to_unknown() {
489        // #492: a newer peer's new variant must not make this build
490        // fail to decode the whole containing message — serde(other)
491        // catches it (non_exhaustive alone never protected the wire).
492        let s: RunStatus = serde_json::from_str("\"skipped\"").unwrap();
493        assert_eq!(s, RunStatus::Unknown);
494        // Known variants are untouched.
495        let r: RunStatus = serde_json::from_str("\"running\"").unwrap();
496        assert_eq!(r, RunStatus::Running);
497    }
498
499    #[test]
500    fn unknown_variant_round_trips() {
501        // PR #558 review (gemini): Unknown must SERIALIZE cleanly too
502        // — a node that decoded a newer peer's variant and re-emits
503        // the containing message (e.g. an agent forwarding a
504        // jobs.list entry) must not hit a runtime serialization
505        // error. It serialises as "unknown" and decodes back to
506        // Unknown on every #492-aware peer.
507        let s = serde_json::to_string(&RunStatus::Unknown).unwrap();
508        assert_eq!(s, "\"unknown\"");
509        let back: RunStatus = serde_json::from_str(&s).unwrap();
510        assert_eq!(back, RunStatus::Unknown);
511    }
512
513    #[test]
514    fn unknown_decodes_across_the_other_wire_enums() {
515        // PR #558 review (claude): cover the remaining #492 enums.
516        use crate::ipc::error::ErrorKind;
517        use crate::ipc::maintenance::DeferDuration;
518        use crate::ipc::notifications::NotificationPriority;
519
520        let k: ErrorKind = serde_json::from_str("\"FutureErrorKind\"").unwrap();
521        assert_eq!(k, ErrorKind::Unknown);
522        assert_eq!(k.code(), -32099);
523        let known: ErrorKind = serde_json::from_str("\"Unauthorized\"").unwrap();
524        assert_eq!(known, ErrorKind::Unauthorized);
525
526        let p: NotificationPriority = serde_json::from_str("\"critical\"").unwrap();
527        assert_eq!(p, NotificationPriority::Unknown);
528
529        let d: DeferDuration = serde_json::from_str("\"2h\"").unwrap();
530        assert_eq!(d, DeferDuration::Unknown);
531        assert_eq!(d.as_duration(), chrono::Duration::minutes(15));
532    }
533}