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    /// #492: serde-level forward-compat catch-all — a newer client's
97    /// new defer option decodes here instead of failing the whole
98    /// request on an older agent. Treated as the shortest defer.
99    #[serde(other)]
100    Unknown,
101}
102
103impl DeferDuration {
104    /// Wall-clock duration this enum variant represents.
105    pub fn as_duration(self) -> chrono::Duration {
106        match self {
107            Self::M15 => chrono::Duration::minutes(15),
108            // Unknown = a defer option this build doesn't know;
109            // shortest defer is the conservative interpretation.
110            // warn so a fleet-upgrade window's "defer was shorter
111            // than expected" is diagnosable (PR #558 review, claude).
112            Self::Unknown => {
113                tracing::warn!("unknown DeferDuration variant from a newer peer; treating as 15m");
114                chrono::Duration::minutes(15)
115            }
116            Self::M30 => chrono::Duration::minutes(30),
117            Self::H1 => chrono::Duration::hours(1),
118        }
119    }
120}
121
122#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
123pub struct MaintenanceDeferResult {
124    /// Absolute time the schedule will fire (next), after applying
125    /// the defer. Lets the SPA show "Deferred to 14:30" without
126    /// re-querying `maintenance.list`.
127    pub new_fire_at: chrono::DateTime<chrono::Utc>,
128}
129
130#[cfg(test)]
131mod tests {
132    use super::*;
133    use chrono::TimeZone;
134
135    #[test]
136    fn params_default_window_is_seven_days() {
137        let p = MaintenanceListParams::default();
138        assert_eq!(p.window_days, 7);
139        let p: MaintenanceListParams = serde_json::from_str("{}").unwrap();
140        assert_eq!(p.window_days, 7);
141    }
142
143    #[test]
144    fn item_with_deferrable_round_trips() {
145        let t = chrono::Utc
146            .with_ymd_and_hms(2026, 5, 25, 14, 30, 0)
147            .unwrap();
148        let i = MaintenanceItem {
149            schedule_id: "weekly-reboot".into(),
150            manifest_id: "reboot".into(),
151            display_name: "再起動".into(),
152            next_fire_at: t,
153            deferrable: true,
154        };
155        let json = serde_json::to_string(&i).unwrap();
156        let back: MaintenanceItem = serde_json::from_str(&json).unwrap();
157        assert_eq!(back.schedule_id, i.schedule_id);
158        assert_eq!(back.display_name, "再起動");
159        assert_eq!(back.next_fire_at, t);
160        assert!(back.deferrable);
161    }
162
163    #[test]
164    fn defer_duration_wire_matches_spec_2_1_humantime() {
165        // SPEC §2.1 writes the windows as `15分 / 30分 / 1時間`,
166        // and the documented operator shorthand is humantime
167        // (`15m / 30m / 1h`). Pin the wire so a future enum rename
168        // can't silently drift the Client App's "延期" button
169        // payloads.
170        for (variant, expected) in [
171            (DeferDuration::M15, "\"15m\""),
172            (DeferDuration::M30, "\"30m\""),
173            (DeferDuration::H1, "\"1h\""),
174        ] {
175            let s = serde_json::to_string(&variant).unwrap();
176            assert_eq!(s, expected, "encode {variant:?}");
177            let back: DeferDuration = serde_json::from_str(expected).unwrap();
178            assert_eq!(back, variant, "round-trip {expected}");
179        }
180    }
181
182    #[test]
183    fn defer_duration_as_duration_matches_spec_table() {
184        assert_eq!(
185            DeferDuration::M15.as_duration(),
186            chrono::Duration::minutes(15)
187        );
188        assert_eq!(
189            DeferDuration::M30.as_duration(),
190            chrono::Duration::minutes(30)
191        );
192        assert_eq!(DeferDuration::H1.as_duration(), chrono::Duration::hours(1));
193    }
194
195    #[test]
196    fn defer_result_round_trips() {
197        let t = chrono::Utc.with_ymd_and_hms(2026, 5, 24, 15, 0, 0).unwrap();
198        let r = MaintenanceDeferResult { new_fire_at: t };
199        let json = serde_json::to_string(&r).unwrap();
200        let back: MaintenanceDeferResult = serde_json::from_str(&json).unwrap();
201        assert_eq!(back.new_fire_at, t);
202    }
203
204    #[test]
205    fn item_deferrable_defaults_to_false() {
206        let wire = r#"{
207            "schedule_id":"x","manifest_id":"y","display_name":"z",
208            "next_fire_at":"2026-05-24T00:00:00Z"
209        }"#;
210        let i: MaintenanceItem = serde_json::from_str(wire).unwrap();
211        assert!(!i.deferrable);
212    }
213}