Skip to main content

kanade_shared/ipc/
maintenance.rs

1//! `maintenance.*` method types — scheduled-job preview + reboot
2//! defer.
3//!
4//! Per SPEC §2.1: the Client App shows the user "what's about to
5//! happen on my PC" (next N days of scheduled jobs targeting this
6//! PC, derived from `BUCKET_SCHEDULES` ∩ this agent's groups +
7//! pc_id) and lets them push back on imminent restarts via
8//! `maintenance.defer` (15m / 30m / 1h, per SPEC §2.1).
9
10use serde::{Deserialize, Serialize};
11
12// ---------- maintenance.list ----------
13
14/// `maintenance.list` params — preview window in days.
15#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
16pub struct MaintenanceListParams {
17    /// How far into the future to preview. Clamped agent-side to
18    /// [1, 30]. Defaults to 7 days.
19    #[serde(default = "default_window_days")]
20    pub window_days: u32,
21}
22
23impl Default for MaintenanceListParams {
24    fn default() -> Self {
25        Self {
26            window_days: default_window_days(),
27        }
28    }
29}
30
31fn default_window_days() -> u32 {
32    7
33}
34
35#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
36pub struct MaintenanceListResult {
37    pub items: Vec<MaintenanceItem>,
38}
39
40/// One upcoming scheduled job that will fire against this PC.
41#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
42pub struct MaintenanceItem {
43    /// Schedule id from `BUCKET_SCHEDULES`.
44    pub schedule_id: String,
45    /// Manifest id the schedule fires (matches everywhere else —
46    /// `Schedule.job_id`, `Manifest.id`).
47    pub manifest_id: String,
48    /// Manifest's `display_name` (or `Manifest.id` if no display
49    /// name is set) so the Client App doesn't need a second lookup.
50    pub display_name: String,
51    /// Next absolute time this schedule will fire at this PC,
52    /// computed from the schedule's cron expression. UTC.
53    pub next_fire_at: chrono::DateTime<chrono::Utc>,
54    /// `true` if this is the schedule's *deferrable* run — currently
55    /// only true for reboot manifests with `category:
56    /// software_update`. SPA enables the "延期申請" button when
57    /// true.
58    #[serde(default)]
59    pub deferrable: bool,
60}
61
62// ---------- maintenance.defer ----------
63
64/// `maintenance.defer` params — push back a scheduled reboot. The
65/// agent records the deferral and skips the next fire of the named
66/// schedule for the chosen window (SPEC §2.1: 15m / 30m / 1h).
67#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
68pub struct MaintenanceDeferParams {
69    pub schedule_id: String,
70    pub duration: DeferDuration,
71}
72
73/// Allowed defer windows per SPEC §2.1 (`15分 / 30分 / 1時間`). The
74/// fixed set avoids operators having to think about long-tail "user
75/// deferred 3 days" scenarios — anything bigger goes through the
76/// helpdesk.
77///
78/// Wire form mirrors SPEC §2.1's `15m / 30m / 1h` humantime
79/// shorthand verbatim so JSON payloads read like operator-spoken
80/// shorthand. The `#[non_exhaustive]` annotation leaves room for
81/// future SPEC bumps to add windows (e.g. `2h`) without forcing a
82/// wire-protocol version change — downstream Rust consumers see a
83/// compile-time nudge to add a wildcard arm.
84#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
85#[non_exhaustive]
86pub enum DeferDuration {
87    /// 15 minutes.
88    #[serde(rename = "15m")]
89    M15,
90    /// 30 minutes.
91    #[serde(rename = "30m")]
92    M30,
93    /// 1 hour.
94    #[serde(rename = "1h")]
95    H1,
96}
97
98impl DeferDuration {
99    /// Wall-clock duration this enum variant represents.
100    pub fn as_duration(self) -> chrono::Duration {
101        match self {
102            Self::M15 => chrono::Duration::minutes(15),
103            Self::M30 => chrono::Duration::minutes(30),
104            Self::H1 => chrono::Duration::hours(1),
105        }
106    }
107}
108
109#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
110pub struct MaintenanceDeferResult {
111    /// Absolute time the schedule will fire (next), after applying
112    /// the defer. Lets the SPA show "Deferred to 14:30" without
113    /// re-querying `maintenance.list`.
114    pub new_fire_at: chrono::DateTime<chrono::Utc>,
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120    use chrono::TimeZone;
121
122    #[test]
123    fn params_default_window_is_seven_days() {
124        let p = MaintenanceListParams::default();
125        assert_eq!(p.window_days, 7);
126        let p: MaintenanceListParams = serde_json::from_str("{}").unwrap();
127        assert_eq!(p.window_days, 7);
128    }
129
130    #[test]
131    fn item_with_deferrable_round_trips() {
132        let t = chrono::Utc
133            .with_ymd_and_hms(2026, 5, 25, 14, 30, 0)
134            .unwrap();
135        let i = MaintenanceItem {
136            schedule_id: "weekly-reboot".into(),
137            manifest_id: "reboot".into(),
138            display_name: "再起動".into(),
139            next_fire_at: t,
140            deferrable: true,
141        };
142        let json = serde_json::to_string(&i).unwrap();
143        let back: MaintenanceItem = serde_json::from_str(&json).unwrap();
144        assert_eq!(back.schedule_id, i.schedule_id);
145        assert_eq!(back.display_name, "再起動");
146        assert_eq!(back.next_fire_at, t);
147        assert!(back.deferrable);
148    }
149
150    #[test]
151    fn defer_duration_wire_matches_spec_2_1_humantime() {
152        // SPEC §2.1 writes the windows as `15分 / 30分 / 1時間`,
153        // and the documented operator shorthand is humantime
154        // (`15m / 30m / 1h`). Pin the wire so a future enum rename
155        // can't silently drift the Client App's "延期" button
156        // payloads.
157        for (variant, expected) in [
158            (DeferDuration::M15, "\"15m\""),
159            (DeferDuration::M30, "\"30m\""),
160            (DeferDuration::H1, "\"1h\""),
161        ] {
162            let s = serde_json::to_string(&variant).unwrap();
163            assert_eq!(s, expected, "encode {variant:?}");
164            let back: DeferDuration = serde_json::from_str(expected).unwrap();
165            assert_eq!(back, variant, "round-trip {expected}");
166        }
167    }
168
169    #[test]
170    fn defer_duration_as_duration_matches_spec_table() {
171        assert_eq!(
172            DeferDuration::M15.as_duration(),
173            chrono::Duration::minutes(15)
174        );
175        assert_eq!(
176            DeferDuration::M30.as_duration(),
177            chrono::Duration::minutes(30)
178        );
179        assert_eq!(DeferDuration::H1.as_duration(), chrono::Duration::hours(1));
180    }
181
182    #[test]
183    fn defer_result_round_trips() {
184        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 24, 15, 0, 0).unwrap();
185        let r = MaintenanceDeferResult { new_fire_at: t };
186        let json = serde_json::to_string(&r).unwrap();
187        let back: MaintenanceDeferResult = serde_json::from_str(&json).unwrap();
188        assert_eq!(back.new_fire_at, t);
189    }
190
191    #[test]
192    fn item_deferrable_defaults_to_false() {
193        let wire = r#"{
194            "schedule_id":"x","manifest_id":"y","display_name":"z",
195            "next_fire_at":"2026-05-24T00:00:00Z"
196        }"#;
197        let i: MaintenanceItem = serde_json::from_str(wire).unwrap();
198        assert!(!i.deferrable);
199    }
200}