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 /// Snapshot of the last KLP-driven run of this job FOR THIS
126 /// USER. `None` until they've executed it at least once.
127 /// Backend keeps the cross-user / cross-PC history separately
128 /// (operator-only `executions` table).
129 #[serde(default, skip_serializing_if = "Option::is_none")]
130 pub last_run: Option<JobRun>,
131}
132
133/// Confirmation-dialog config for a [`UserInvokableJob`], projected 1:1
134/// from the manifest's `client.confirm` block. Kept self-contained in the
135/// wire layer (not a re-export of the manifest's `ConfirmHint`) so the KLP
136/// contract doesn't depend on the manifest module.
137#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
138pub struct JobConfirm {
139 /// Whether the Client App shows the confirmation dialog before running.
140 /// `false` fires the job immediately with no prompt.
141 pub enabled: bool,
142 /// Custom dialog message. `None` ⇒ the client's built-in
143 /// 「「{name}」を実行しますか?」.
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub message: Option<String>,
146}
147
148/// Compact summary of a past run — what the Client App shows next
149/// to the job's "Run again" button.
150#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
151pub struct JobRun {
152 pub run_id: String,
153 pub status: RunStatus,
154 pub started_at: chrono::DateTime<chrono::Utc>,
155 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub finished_at: Option<chrono::DateTime<chrono::Utc>>,
157 #[serde(default, skip_serializing_if = "Option::is_none")]
158 pub exit_code: Option<i32>,
159}
160
161// ---------- jobs.list ----------
162
163/// `jobs.list` params — optional category filter (when the Client
164/// App is showing a single tab and doesn't want the full set).
165#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
166pub struct JobsListParams {
167 /// `None` ⇒ return every user-invokable job. `Some(key)` ⇒ filter
168 /// to that category key. The agent always strips
169 /// `user_invokable: false` manifests regardless of filter.
170 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub category: Option<String>,
172}
173
174#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
175pub struct JobsListResult {
176 pub items: Vec<UserInvokableJob>,
177}
178
179// ---------- jobs.execute ----------
180
181/// `jobs.execute` params — the manifest id to run. Agent looks up
182/// the manifest from KV at fire time, so a change to
183/// `user_invokable` takes effect on the next execute attempt (SPEC
184/// §2.1: "Agent 側で manifest を必ず再 lookup し、`user_invokable:
185/// false` への変更が即時反映される").
186#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
187pub struct JobsExecuteParams {
188 /// Manifest id from `jobs.list[].id`.
189 pub id: String,
190}
191
192#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
193pub struct JobsExecuteResult {
194 /// Agent-minted UUID for this specific run. Carried back to the
195 /// caller so they can correlate the `jobs.progress` pushes that
196 /// follow + later `jobs.kill` calls.
197 pub run_id: String,
198}
199
200// ---------- jobs.subscribe ----------
201
202#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
203pub struct JobsSubscribeParams {}
204
205#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
206pub struct JobsSubscribeResult {
207 pub subscription: String,
208}
209
210#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
211pub struct JobsUnsubscribeParams {
212 pub subscription: String,
213}
214
215// ---------- jobs.progress (push) ----------
216
217/// Push payload for `jobs.progress`. Sent on:
218/// - first move from Queued → Running
219/// - each stdout / stderr chunk (split to fit the 1 MiB framing
220/// cap — SPEC §2.12.2)
221/// - terminal state transition (Completed / Failed / Killed) with
222/// `exit_code` populated
223///
224/// The reference shape is SPEC §2.12.11's `JobProgress` struct.
225#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
226pub struct JobProgress {
227 /// The `run_id` minted by `jobs.execute`.
228 pub run_id: String,
229 pub status: RunStatus,
230 /// Newly-produced stdout, UTF-8 decoded (tolerant — see
231 /// `kanade-agent::process::capture_tolerant`). `None` when this
232 /// push is a pure status transition; `Some("")` would never be
233 /// emitted (the agent omits the field instead).
234 #[serde(default, skip_serializing_if = "Option::is_none")]
235 pub stdout_chunk: Option<String>,
236 /// Newly-produced stderr. Same conventions as `stdout_chunk`.
237 #[serde(default, skip_serializing_if = "Option::is_none")]
238 pub stderr_chunk: Option<String>,
239 /// Populated on the terminal push only. Agents stamp the actual
240 /// process exit code from the child. Synthetic non-process
241 /// outcomes (timeout, remote kill) are surfaced as `Some(-1)`
242 /// with the `status` field carrying the distinguishing
243 /// information (`Failed` / `Killed`), not via a reserved
244 /// exit-code number.
245 ///
246 /// Note: the sibling `ExecResult` wire (manifest exec →
247 /// backend, NOT this KLP flow) DOES partition synthetic skip
248 /// codes (124 / 125 / 126 / 127) for the agent's pre-exec
249 /// staleness gates. See the doc on
250 /// `kanade-agent::commands::publish_staleness_skipped` for
251 /// the table. JobProgress's exit_code does not share that
252 /// partition.
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub exit_code: Option<i32>,
255}
256
257// ---------- jobs.kill ----------
258
259/// `jobs.kill` params — `run_id` from this connection's earlier
260/// `jobs.execute` call. SPEC §2.12.4 forbids cross-connection kill
261/// (agent returns `Unauthorized`); a user wanting to stop another
262/// user's job goes through the operator SPA, not the Client App.
263#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
264pub struct JobsKillParams {
265 pub run_id: String,
266}
267
268#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
269pub struct JobsKillResult {
270 /// Wall-clock the agent dispatched the kill signal. The
271 /// terminal `jobs.progress` push (status = `Killed`) follows
272 /// asynchronously once the child process actually exits.
273 pub requested_at: chrono::DateTime<chrono::Utc>,
274}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279 use chrono::TimeZone;
280
281 #[test]
282 fn user_invokable_job_carries_free_form_category() {
283 // #792: category is a free-form string + optional tab metadata.
284 let wire = r#"{
285 "id":"wifi-tweak","display_name":"Wi-Fi 省電力を切る",
286 "category":"settings","category_label":"設定",
287 "category_icon":"settings","category_order":15,"version":"1.0.0"
288 }"#;
289 let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
290 assert_eq!(j.category, "settings");
291 assert_eq!(j.category_label.as_deref(), Some("設定"));
292 assert_eq!(j.category_icon.as_deref(), Some("settings"));
293 assert_eq!(j.category_order, Some(15));
294 }
295
296 #[test]
297 fn run_status_serialises_snake_case() {
298 for (variant, expected) in [
299 (RunStatus::Queued, "\"queued\""),
300 (RunStatus::Running, "\"running\""),
301 (RunStatus::Completed, "\"completed\""),
302 (RunStatus::Failed, "\"failed\""),
303 (RunStatus::Killed, "\"killed\""),
304 ] {
305 let s = serde_json::to_string(&variant).unwrap();
306 assert_eq!(s, expected, "encode {variant:?}");
307 let back: RunStatus = serde_json::from_str(expected).unwrap();
308 assert_eq!(back, variant, "round-trip {expected}");
309 }
310 }
311
312 #[test]
313 fn user_invokable_job_minimum_shape_decodes() {
314 // Backend that hasn't fully populated `display_description`
315 // / `icon` / `last_run` must still produce decodable rows.
316 let wire = r#"{
317 "id":"chrome-update","display_name":"Chrome を更新",
318 "category":"software_update","version":"1.2.0"
319 }"#;
320 let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
321 assert_eq!(j.id, "chrome-update");
322 assert!(j.display_description.is_none());
323 assert!(j.icon.is_none());
324 assert!(j.last_run.is_none());
325 // #865: an older agent omits timeout_secs ⇒ None (client falls
326 // back to its fixed watchdog).
327 assert!(j.timeout_secs.is_none());
328 }
329
330 #[test]
331 fn user_invokable_job_timeout_secs_round_trips() {
332 // #865: present on the wire ⇒ carried through; absent ⇒ omitted
333 // from the encoded form (not `null`), per the #492 wire rule.
334 let wire = r#"{
335 "id":"office-repair","display_name":"Office 修復",
336 "category":"troubleshoot","version":"1.0.0","timeout_secs":3600
337 }"#;
338 let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
339 assert_eq!(j.timeout_secs, Some(3600));
340
341 let v = serde_json::to_value(UserInvokableJob {
342 id: "x".into(),
343 display_name: "X".into(),
344 display_description: None,
345 icon: None,
346 category: "catalog".into(),
347 category_label: None,
348 category_icon: None,
349 category_order: None,
350 version: "1.0.0".into(),
351 timeout_secs: None,
352 confirm: None,
353 last_run: None,
354 })
355 .unwrap();
356 assert!(v.get("timeout_secs").is_none(), "wire: {v:?}");
357 // confirm is None ⇒ omitted (not `null`), so an older client that
358 // predates the field keeps its historical always-confirm default.
359 assert!(v.get("confirm").is_none(), "wire: {v:?}");
360 }
361
362 #[test]
363 fn user_invokable_job_confirm_round_trips() {
364 // A job carrying a confirm config surfaces it on the wire; the
365 // client reads `enabled` / `message` to suppress or reword the modal.
366 let wire = r#"{
367 "id":"wifi-tweak","display_name":"Wi-Fi 省電力を切る",
368 "category":"settings","version":"1.0.0",
369 "confirm":{"enabled":false}
370 }"#;
371 let j: UserInvokableJob = serde_json::from_str(wire).unwrap();
372 let c = j.confirm.expect("confirm present");
373 assert!(!c.enabled);
374 assert!(c.message.is_none());
375
376 // Round-trip a message-carrying confirm; message omitted when None.
377 let v = serde_json::to_value(JobConfirm {
378 enabled: true,
379 message: Some("よろしいですか?".into()),
380 })
381 .unwrap();
382 assert_eq!(v["enabled"], true);
383 assert_eq!(v["message"], "よろしいですか?");
384 let v = serde_json::to_value(JobConfirm {
385 enabled: true,
386 message: None,
387 })
388 .unwrap();
389 assert!(v.get("message").is_none(), "wire: {v:?}");
390 }
391
392 #[test]
393 fn job_progress_status_transition_omits_chunks() {
394 // Status-only push (Queued → Running) has neither stdout
395 // nor stderr; both fields must be absent from the wire, not
396 // null. Strict JS clients reject `null` strings.
397 let p = JobProgress {
398 run_id: "run-1".into(),
399 status: RunStatus::Running,
400 stdout_chunk: None,
401 stderr_chunk: None,
402 exit_code: None,
403 };
404 let v = serde_json::to_value(&p).unwrap();
405 assert!(v.get("stdout_chunk").is_none(), "wire: {v:?}");
406 assert!(v.get("stderr_chunk").is_none(), "wire: {v:?}");
407 assert!(v.get("exit_code").is_none(), "wire: {v:?}");
408 }
409
410 #[test]
411 fn job_progress_terminal_push_carries_exit_code() {
412 let p = JobProgress {
413 run_id: "run-1".into(),
414 status: RunStatus::Completed,
415 stdout_chunk: None,
416 stderr_chunk: None,
417 exit_code: Some(0),
418 };
419 let v = serde_json::to_value(&p).unwrap();
420 assert_eq!(v["status"], "completed");
421 assert_eq!(v["exit_code"], 0);
422 }
423
424 #[test]
425 fn jobs_list_filter_optional() {
426 // No filter ⇒ all categories. Wire form has no `category`
427 // key, not `category: null`.
428 let p = JobsListParams::default();
429 let v = serde_json::to_value(&p).unwrap();
430 assert!(v.get("category").is_none(), "wire: {v:?}");
431 }
432
433 #[test]
434 fn jobs_execute_result_round_trips() {
435 let r = JobsExecuteResult {
436 run_id: "run-uuid-1".into(),
437 };
438 let json = serde_json::to_string(&r).unwrap();
439 let back: JobsExecuteResult = serde_json::from_str(&json).unwrap();
440 assert_eq!(back.run_id, "run-uuid-1");
441 }
442
443 #[test]
444 fn job_run_serialises_with_optional_finish() {
445 // In-flight run: started_at present, finished_at + exit_code
446 // absent. Critical because the Client App's "last run" chip
447 // uses `finished_at.is_some()` as the "row is terminal" flag.
448 let r = JobRun {
449 run_id: "run-1".into(),
450 status: RunStatus::Running,
451 started_at: chrono::Utc.with_ymd_and_hms(2026, 5, 24, 0, 0, 0).unwrap(),
452 finished_at: None,
453 exit_code: None,
454 };
455 let v = serde_json::to_value(&r).unwrap();
456 assert!(v.get("finished_at").is_none(), "wire: {v:?}");
457 assert!(v.get("exit_code").is_none(), "wire: {v:?}");
458 }
459
460 #[test]
461 fn unknown_enum_variants_decode_to_unknown() {
462 // #492: a newer peer's new variant must not make this build
463 // fail to decode the whole containing message — serde(other)
464 // catches it (non_exhaustive alone never protected the wire).
465 let s: RunStatus = serde_json::from_str("\"skipped\"").unwrap();
466 assert_eq!(s, RunStatus::Unknown);
467 // Known variants are untouched.
468 let r: RunStatus = serde_json::from_str("\"running\"").unwrap();
469 assert_eq!(r, RunStatus::Running);
470 }
471
472 #[test]
473 fn unknown_variant_round_trips() {
474 // PR #558 review (gemini): Unknown must SERIALIZE cleanly too
475 // — a node that decoded a newer peer's variant and re-emits
476 // the containing message (e.g. an agent forwarding a
477 // jobs.list entry) must not hit a runtime serialization
478 // error. It serialises as "unknown" and decodes back to
479 // Unknown on every #492-aware peer.
480 let s = serde_json::to_string(&RunStatus::Unknown).unwrap();
481 assert_eq!(s, "\"unknown\"");
482 let back: RunStatus = serde_json::from_str(&s).unwrap();
483 assert_eq!(back, RunStatus::Unknown);
484 }
485
486 #[test]
487 fn unknown_decodes_across_the_other_wire_enums() {
488 // PR #558 review (claude): cover the remaining #492 enums.
489 use crate::ipc::error::ErrorKind;
490 use crate::ipc::maintenance::DeferDuration;
491 use crate::ipc::notifications::NotificationPriority;
492
493 let k: ErrorKind = serde_json::from_str("\"FutureErrorKind\"").unwrap();
494 assert_eq!(k, ErrorKind::Unknown);
495 assert_eq!(k.code(), -32099);
496 let known: ErrorKind = serde_json::from_str("\"Unauthorized\"").unwrap();
497 assert_eq!(known, ErrorKind::Unauthorized);
498
499 let p: NotificationPriority = serde_json::from_str("\"critical\"").unwrap();
500 assert_eq!(p, NotificationPriority::Unknown);
501
502 let d: DeferDuration = serde_json::from_str("\"2h\"").unwrap();
503 assert_eq!(d, DeferDuration::Unknown);
504 assert_eq!(d.as_duration(), chrono::Duration::minutes(15));
505 }
506}