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}
37
38/// Run-state machine for one `jobs.execute` invocation.
39///
40/// State transitions:
41/// `Queued` → `Running` → `Completed` | `Failed` | `Killed`.
42/// `Queued` ⇒ accepted but not started yet (waiting on the
43/// concurrent-run cap or staleness check); the very first
44/// `jobs.progress` push usually moves straight to `Running`.
45/// `#[non_exhaustive]` so a future SPEC can add states like
46/// `Skipped` (staleness gate) without a wire bump.
47#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
48#[serde(rename_all = "snake_case")]
49#[non_exhaustive]
50pub enum RunStatus {
51    /// Accepted, not yet spawned.
52    Queued,
53    /// `tokio::process::Command::spawn()` returned, script is
54    /// running.
55    Running,
56    /// Exited with code 0 (or whatever the manifest declares as
57    /// success).
58    Completed,
59    /// Exited non-zero, or a Layer 2 skipped-result was published.
60    Failed,
61    /// User-initiated kill via `jobs.kill`. Distinct from `Failed`
62    /// so the SPA can show "stopped by you" instead of "errored".
63    Killed,
64}
65
66/// One entry in `jobs.list` — the SPEC §2.12.11 reference shape,
67/// extended with the `description` field used by the manifest's
68/// existing `display_description`.
69#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
70pub struct UserInvokableJob {
71    /// Manifest id (matches everywhere else — `Command.id`,
72    /// `ExecResult.manifest_id`).
73    pub id: String,
74    /// `display_name` from the manifest.
75    pub display_name: String,
76    /// `display_description` from the manifest. Renders as the row's
77    /// subtitle in the Client App.
78    #[serde(default, skip_serializing_if = "Option::is_none")]
79    pub display_description: Option<String>,
80    /// Optional icon hint (lucide-react name or a `data:` URL).
81    /// `None` means the SPA falls back to the category's default
82    /// icon.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub icon: Option<String>,
85    pub category: JobCategory,
86    /// Pinned version string from the manifest. Same field as
87    /// `Manifest.version`.
88    pub version: String,
89    /// Snapshot of the last KLP-driven run of this job FOR THIS
90    /// USER. `None` until they've executed it at least once.
91    /// Backend keeps the cross-user / cross-PC history separately
92    /// (operator-only `executions` table).
93    #[serde(default, skip_serializing_if = "Option::is_none")]
94    pub last_run: Option<JobRun>,
95}
96
97/// Compact summary of a past run — what the Client App shows next
98/// to the job's "Run again" button.
99#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
100pub struct JobRun {
101    pub run_id: String,
102    pub status: RunStatus,
103    pub started_at: chrono::DateTime<chrono::Utc>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub exit_code: Option<i32>,
108}
109
110// ---------- jobs.list ----------
111
112/// `jobs.list` params — optional category filter (when the Client
113/// App is showing a single tab and doesn't want the full set).
114#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
115pub struct JobsListParams {
116    /// `None` ⇒ return every user-invokable job. `Some(c)` ⇒ filter
117    /// to that category. The agent always strips
118    /// `user_invokable: false` manifests regardless of filter.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub category: Option<JobCategory>,
121}
122
123#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
124pub struct JobsListResult {
125    pub items: Vec<UserInvokableJob>,
126}
127
128// ---------- jobs.execute ----------
129
130/// `jobs.execute` params — the manifest id to run. Agent looks up
131/// the manifest from KV at fire time, so a change to
132/// `user_invokable` takes effect on the next execute attempt (SPEC
133/// §2.1: "Agent 側で manifest を必ず再 lookup し、`user_invokable:
134/// false` への変更が即時反映される").
135#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
136pub struct JobsExecuteParams {
137    /// Manifest id from `jobs.list[].id`.
138    pub id: String,
139}
140
141#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
142pub struct JobsExecuteResult {
143    /// Agent-minted UUID for this specific run. Carried back to the
144    /// caller so they can correlate the `jobs.progress` pushes that
145    /// follow + later `jobs.kill` calls.
146    pub run_id: String,
147}
148
149// ---------- jobs.subscribe ----------
150
151#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
152pub struct JobsSubscribeParams {}
153
154#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
155pub struct JobsSubscribeResult {
156    pub subscription: String,
157}
158
159#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
160pub struct JobsUnsubscribeParams {
161    pub subscription: String,
162}
163
164// ---------- jobs.progress (push) ----------
165
166/// Push payload for `jobs.progress`. Sent on:
167/// - first move from Queued → Running
168/// - each stdout / stderr chunk (split to fit the 1 MiB framing
169///   cap — SPEC §2.12.2)
170/// - terminal state transition (Completed / Failed / Killed) with
171///   `exit_code` populated
172///
173/// The reference shape is SPEC §2.12.11's `JobProgress` struct.
174#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
175pub struct JobProgress {
176    /// The `run_id` minted by `jobs.execute`.
177    pub run_id: String,
178    pub status: RunStatus,
179    /// Newly-produced stdout, UTF-8 decoded (tolerant — see
180    /// `kanade-agent::process::capture_tolerant`). `None` when this
181    /// push is a pure status transition; `Some("")` would never be
182    /// emitted (the agent omits the field instead).
183    #[serde(default, skip_serializing_if = "Option::is_none")]
184    pub stdout_chunk: Option<String>,
185    /// Newly-produced stderr. Same conventions as `stdout_chunk`.
186    #[serde(default, skip_serializing_if = "Option::is_none")]
187    pub stderr_chunk: Option<String>,
188    /// Populated on the terminal push only. Agents stamp the actual
189    /// process exit code from the child. Synthetic non-process
190    /// outcomes (timeout, remote kill) are surfaced as `Some(-1)`
191    /// with the `status` field carrying the distinguishing
192    /// information (`Failed` / `Killed`), not via a reserved
193    /// exit-code number.
194    ///
195    /// Note: the sibling `ExecResult` wire (manifest exec →
196    /// backend, NOT this KLP flow) DOES partition synthetic skip
197    /// codes (124 / 125 / 126 / 127) for the agent's pre-exec
198    /// staleness gates. See the doc on
199    /// `kanade-agent::commands::publish_staleness_skipped` for
200    /// the table. JobProgress's exit_code does not share that
201    /// partition.
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub exit_code: Option<i32>,
204}
205
206// ---------- jobs.kill ----------
207
208/// `jobs.kill` params — `run_id` from this connection's earlier
209/// `jobs.execute` call. SPEC §2.12.4 forbids cross-connection kill
210/// (agent returns `Unauthorized`); a user wanting to stop another
211/// user's job goes through the operator SPA, not the Client App.
212#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
213pub struct JobsKillParams {
214    pub run_id: String,
215}
216
217#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
218pub struct JobsKillResult {
219    /// Wall-clock the agent dispatched the kill signal. The
220    /// terminal `jobs.progress` push (status = `Killed`) follows
221    /// asynchronously once the child process actually exits.
222    pub requested_at: chrono::DateTime<chrono::Utc>,
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use chrono::TimeZone;
229
230    #[test]
231    fn job_category_serialises_snake_case() {
232        for (variant, expected) in [
233            (JobCategory::SoftwareUpdate, "\"software_update\""),
234            (JobCategory::Troubleshoot, "\"troubleshoot\""),
235            (JobCategory::Catalog, "\"catalog\""),
236        ] {
237            let s = serde_json::to_string(&variant).unwrap();
238            assert_eq!(s, expected, "encode {variant:?}");
239            let back: JobCategory = serde_json::from_str(expected).unwrap();
240            assert_eq!(back, variant, "round-trip {expected}");
241        }
242    }
243
244    #[test]
245    fn run_status_serialises_snake_case() {
246        for (variant, expected) in [
247            (RunStatus::Queued, "\"queued\""),
248            (RunStatus::Running, "\"running\""),
249            (RunStatus::Completed, "\"completed\""),
250            (RunStatus::Failed, "\"failed\""),
251            (RunStatus::Killed, "\"killed\""),
252        ] {
253            let s = serde_json::to_string(&variant).unwrap();
254            assert_eq!(s, expected, "encode {variant:?}");
255            let back: RunStatus = serde_json::from_str(expected).unwrap();
256            assert_eq!(back, variant, "round-trip {expected}");
257        }
258    }
259
260    #[test]
261    fn user_invokable_job_minimum_shape_decodes() {
262        // Backend that hasn't fully populated `display_description`
263        // / `icon` / `last_run` must still produce decodable rows.
264        let wire = r#"{
265            "id":"chrome-update","display_name":"Chrome を更新",
266            "category":"software_update","version":"1.2.0"
267        }"#;
268        let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
269        assert_eq!(j.id, "chrome-update");
270        assert!(j.display_description.is_none());
271        assert!(j.icon.is_none());
272        assert!(j.last_run.is_none());
273    }
274
275    #[test]
276    fn job_progress_status_transition_omits_chunks() {
277        // Status-only push (Queued → Running) has neither stdout
278        // nor stderr; both fields must be absent from the wire, not
279        // null. Strict JS clients reject `null` strings.
280        let p = JobProgress {
281            run_id: "run-1".into(),
282            status: RunStatus::Running,
283            stdout_chunk: None,
284            stderr_chunk: None,
285            exit_code: None,
286        };
287        let v = serde_json::to_value(&p).unwrap();
288        assert!(v.get("stdout_chunk").is_none(), "wire: {v:?}");
289        assert!(v.get("stderr_chunk").is_none(), "wire: {v:?}");
290        assert!(v.get("exit_code").is_none(), "wire: {v:?}");
291    }
292
293    #[test]
294    fn job_progress_terminal_push_carries_exit_code() {
295        let p = JobProgress {
296            run_id: "run-1".into(),
297            status: RunStatus::Completed,
298            stdout_chunk: None,
299            stderr_chunk: None,
300            exit_code: Some(0),
301        };
302        let v = serde_json::to_value(&p).unwrap();
303        assert_eq!(v["status"], "completed");
304        assert_eq!(v["exit_code"], 0);
305    }
306
307    #[test]
308    fn jobs_list_filter_optional() {
309        // No filter ⇒ all categories. Wire form has no `category`
310        // key, not `category: null`.
311        let p = JobsListParams::default();
312        let v = serde_json::to_value(&p).unwrap();
313        assert!(v.get("category").is_none(), "wire: {v:?}");
314    }
315
316    #[test]
317    fn jobs_execute_result_round_trips() {
318        let r = JobsExecuteResult {
319            run_id: "run-uuid-1".into(),
320        };
321        let json = serde_json::to_string(&r).unwrap();
322        let back: JobsExecuteResult = serde_json::from_str(&json).unwrap();
323        assert_eq!(back.run_id, "run-uuid-1");
324    }
325
326    #[test]
327    fn job_run_serialises_with_optional_finish() {
328        // In-flight run: started_at present, finished_at + exit_code
329        // absent. Critical because the Client App's "last run" chip
330        // uses `finished_at.is_some()` as the "row is terminal" flag.
331        let r = JobRun {
332            run_id: "run-1".into(),
333            status: RunStatus::Running,
334            started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap(),
335            finished_at: None,
336            exit_code: None,
337        };
338        let v = serde_json::to_value(&r).unwrap();
339        assert!(v.get("finished_at").is_none(), "wire: {v:?}");
340        assert!(v.get("exit_code").is_none(), "wire: {v:?}");
341    }
342}