Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::wire::{RunAs, Shell, Staleness};
4
5/// YAML job manifest (= registered "what to run", v0.18.0+).
6///
7/// Owns only script-intrinsic fields. **Who** (`target`), **how to
8/// phase fanout** (`rollout`), and **when to stagger start**
9/// (`jitter`) all moved to the Schedule / exec request side — same
10/// script can now be fired against different targets / rollouts
11/// without copying the script body.
12///
13/// `deny_unknown_fields` makes operators copy-pasting an older yaml
14/// that still has `target:` / `rollout:` see a clear parse error at
15/// `kanade job create` time instead of mysteriously losing it.
16#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
17#[serde(deny_unknown_fields)]
18pub struct Manifest {
19    pub id: String,
20    pub version: String,
21    #[serde(default)]
22    pub description: Option<String>,
23    pub execute: Execute,
24    #[serde(default)]
25    pub require_approval: bool,
26    /// Opt-in marker that this job produces a JSON inventory fact
27    /// payload on stdout. When present, the backend's results
28    /// projector parses `ExecResult.stdout` as JSON and upserts an
29    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
30    /// `display` sub-config drives the SPA's Inventory page render.
31    #[serde(default)]
32    pub inventory: Option<InventoryHint>,
33    /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
34    /// what the agent does at fire time when it can't verify the
35    /// `script_current` / `script_status` KV values are fresh —
36    /// especially relevant for `runs_on: agent` schedules where
37    /// the agent may fire from cache while offline. Defaults to
38    /// `Staleness::Cached` (silently use cached values), which
39    /// matches every pre-v0.26 Manifest.
40    #[serde(default)]
41    pub staleness: Staleness,
42}
43
44/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
45/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
46/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
47/// here keeps the validation + serialisation logic in one place.
48#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
49pub struct FanoutPlan {
50    #[serde(default)]
51    pub target: Target,
52    /// Optional wave rollout — when present, the backend publishes
53    /// each wave's group subject on its own delay schedule instead
54    /// of fanning out the `target` block in one go. `target` then
55    /// only labels the deploy for the audit log.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub rollout: Option<Rollout>,
58    /// Optional humantime jitter; agent uses it to randomise
59    /// execution start. Lives here (not on the script) so different
60    /// schedules / ad-hoc fires of the same job can pick different
61    /// stagger windows.
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub jitter: Option<String>,
64    /// Absolute time the scheduler stamps on each emitted Command
65    /// when this exec was driven by a [`Schedule`] with
66    /// `starting_deadline`. Agents receiving a Command after this
67    /// instant publish a synthetic skipped-result instead of
68    /// running the script. `None` (default) = no deadline / catch
69    /// up whenever delivered. Operators don't usually set this
70    /// directly — the scheduler computes it from `tick_at +
71    /// starting_deadline`.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
74}
75
76/// Manifest sub-section: how the SPA should render the inventory
77/// facts this job produces. Each field name (`field`) is a top-level
78/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
79///
80/// Two render modes:
81///   * `display` — vertical "field / value" per PC, used by the
82///     `/inventory?pc=<id>` detail view. ALL columns the operator
83///     wants visible on the detail page.
84///   * `summary` — horizontal table across the fleet (row = PC,
85///     column = field) on `/inventory`. Optional; when omitted the
86///     SPA falls back to `display`, but operators usually want a
87///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
88#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
89pub struct InventoryHint {
90    /// Detail-view columns, in order.
91    pub display: Vec<DisplayField>,
92    /// Optional fleet-list columns (row = PC). Defaults to `display`
93    /// when omitted, but operators usually pick a 3-5 column subset.
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub summary: Option<Vec<DisplayField>>,
96    /// v0.31 / #40: payload arrays that should be exploded into
97    /// per-element rows of a derived SQLite table. Lets operators
98    /// answer cross-PC questions ("which PCs still have Chrome <
99    /// 120?", "C: >90% full") with normal SQL filters + indexes
100    /// instead of grepping JSON. The projector creates the derived
101    /// table on register and replaces this PC's rows on each result
102    /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
103    /// [`ExplodeSpec`] for the per-spec schema.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub explode: Option<Vec<ExplodeSpec>>,
106    /// v0.35 / #93: top-level scalar fields whose changes the
107    /// projector logs to `inventory_history` (one event per
108    /// changed field per scan). Pairs with `explode[].track_history`
109    /// — that covers array elements; this covers single-valued
110    /// fields like `ram_bytes` / `os_version` / `cpu_model` /
111    /// `os_build` that operators want to track for "did the RAM
112    /// get upgraded?" / "when did Win 11 land on this PC?" /
113    /// "BIOS / firmware bumped?" questions. Field name = `field_path`
114    /// in the history row, `identity_json` is NULL, `before_json`
115    /// / `after_json` each carry `{"value": <prior or new value>}`.
116    /// First-ever observation of a scalar (no prior facts row)
117    /// emits `added`; subsequent value changes emit `changed`. No
118    /// `removed` events — a scalar disappearing from the payload
119    /// is rare and the operator can still see the last value via
120    /// the `before_json` of the most recent change.
121    #[serde(default, skip_serializing_if = "Option::is_none")]
122    pub history_scalars: Option<Vec<String>>,
123}
124
125/// v0.31 / #40: declarative "flatten this JSON array into a real
126/// SQLite table" spec on an inventory manifest. The projector
127/// creates the table on first registration (CREATE TABLE IF NOT
128/// EXISTS + indexes) and writes a row per element of
129/// `payload[field]` on every result, scoped by (pc_id, job_id) so
130/// each PC's rows replace cleanly without a per-PC schema.
131#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
132pub struct ExplodeSpec {
133    /// JSON array key under the payload to explode. E.g. `"apps"`
134    /// for `payload: { apps: [{...}, {...}] }`.
135    pub field: String,
136    /// Derived SQLite table name. Operators choose this — pick
137    /// something namespaced + stable (`inventory_sw_apps`, not
138    /// `apps`) so multiple inventory manifests don't collide on a
139    /// generic name.
140    pub table: String,
141    /// Element-level fields that uniquely identify a row inside one
142    /// PC's payload. The full PK is `(pc_id, job_id) + these
143    /// columns`. Required — operators must think about uniqueness
144    /// (e.g. `["name", "source"]` for installed apps because the
145    /// same name appears in multiple uninstall hives).
146    ///
147    /// v0.31 / #41: same tuple drives history identity. When
148    /// `track_history` is on, the projector serialises these
149    /// fields' values into `inventory_history.identity_json` for
150    /// every change event, so queries like "every PC that ever
151    /// installed Chrome (any source)" filter on identity_json
152    /// content without a per-manifest schema.
153    pub primary_key: Vec<String>,
154    /// Per-element fields that become columns in the derived table.
155    pub columns: Vec<ExplodeColumn>,
156    /// v0.31 / #41: when true (default false), the projector
157    /// diffs each PC's incoming payload against the prior rows
158    /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
159    /// replace, and writes added / removed / changed events into
160    /// `inventory_history`. Lets operators answer time-dimension
161    /// questions ("when did Chrome 120 first appear on PC X?",
162    /// "what's the Win 11 23H2 rollout curve") without storing
163    /// per-scan snapshots. Off by default so operators opt in
164    /// per-spec — history has a real storage cost on long-lived
165    /// deployments (mitigated by the 90-day default retention
166    /// sweeper, see `cleanup` module).
167    #[serde(default)]
168    pub track_history: bool,
169}
170
171/// One column in an [`ExplodeSpec`]'s derived table.
172#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
173pub struct ExplodeColumn {
174    /// JSON key under each array element. Becomes the column name
175    /// in the derived SQLite table — we don't rename.
176    pub field: String,
177    /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
178    /// Storage maps directly via `sqlx::query.bind(...)`; type
179    /// mismatches at INSERT-time fail loudly rather than silently
180    /// dropping the row.
181    #[serde(default, skip_serializing_if = "Option::is_none")]
182    #[serde(rename = "type")]
183    pub kind: Option<String>,
184    /// When true, the projector creates a `CREATE INDEX` on this
185    /// column at table-creation time. Boost for the common-filter
186    /// columns (`name`, `version`) — operators mark them
187    /// explicitly, the projector won't guess.
188    #[serde(default)]
189    pub index: bool,
190}
191
192#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
193pub struct DisplayField {
194    /// Top-level key in the stdout JSON.
195    pub field: String,
196    /// Human-readable column header.
197    pub label: String,
198    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
199    /// or `"table"` (#39). Defaults to plain text rendering on the
200    /// SPA side. `"table"` expects the field's value to be a JSON
201    /// array of objects and renders a nested sub-table on the
202    /// per-PC detail page using `columns` as the schema; the fleet
203    /// summary view falls back to showing the row count for
204    /// `"table"` cells so the wide list stays compact.
205    #[serde(default, skip_serializing_if = "Option::is_none")]
206    #[serde(rename = "type")]
207    pub kind: Option<String>,
208    /// v0.30 / #39: when `kind == "table"`, the SPA renders the
209    /// field's value (an array of objects like
210    /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
211    /// sub-table using these columns. Each column is itself a
212    /// `DisplayField`, so the nested cells reuse the same render
213    /// hints (`bytes`, `number`, `timestamp`) — no parallel format
214    /// pipeline. Ignored for any other `kind`.
215    #[serde(default, skip_serializing_if = "Option::is_none")]
216    pub columns: Option<Vec<DisplayField>>,
217}
218
219#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
220pub struct Rollout {
221    #[serde(default)]
222    pub strategy: RolloutStrategy,
223    pub waves: Vec<Wave>,
224}
225
226#[derive(
227    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
228)]
229#[serde(rename_all = "lowercase")]
230pub enum RolloutStrategy {
231    #[default]
232    Wave,
233}
234
235#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
236pub struct Wave {
237    pub group: String,
238    /// humantime delay measured from the deploy's publish time. wave[0]
239    /// typically has "0s"; subsequent waves use minutes / hours.
240    pub delay: String,
241}
242
243#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
244pub struct Target {
245    #[serde(default)]
246    pub groups: Vec<String>,
247    #[serde(default)]
248    pub pcs: Vec<String>,
249    #[serde(default)]
250    pub all: bool,
251}
252
253impl Target {
254    /// At least one of all / groups / pcs is set.
255    pub fn is_specified(&self) -> bool {
256        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
257    }
258}
259
260#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
261pub struct Execute {
262    pub shell: ExecuteShell,
263    pub script: String,
264    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
265    /// — represents how long this script reasonably takes to run.
266    pub timeout: String,
267    /// Token + session combination the agent uses to launch the
268    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
269    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
270    #[serde(default)]
271    pub run_as: RunAs,
272    /// Working directory for the spawned child (v0.21.1). When
273    /// unset, the child inherits the agent's cwd — on Windows that
274    /// means `%SystemRoot%\System32` for the prod service, which is
275    /// almost never what operators actually want. Use an absolute
276    /// path; relative paths are passed through to the OS verbatim.
277    /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
278    /// you'd want `%USERPROFILE%` (but expansion happens in the
279    /// shell, so write `$env:USERPROFILE` for PowerShell, or set
280    /// it via teravars before `kanade job create`).
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub cwd: Option<String>,
283}
284
285#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
286#[serde(rename_all = "lowercase")]
287pub enum ExecuteShell {
288    Powershell,
289    Cmd,
290}
291
292impl From<ExecuteShell> for Shell {
293    fn from(s: ExecuteShell) -> Self {
294        match s {
295            ExecuteShell::Powershell => Shell::Powershell,
296            ExecuteShell::Cmd => Shell::Cmd,
297        }
298    }
299}
300
301#[cfg(test)]
302mod tests {
303    use super::*;
304
305    #[test]
306    fn target_is_specified_requires_at_least_one_field() {
307        let empty = Target::default();
308        assert!(!empty.is_specified());
309
310        let with_all = Target {
311            all: true,
312            ..Target::default()
313        };
314        assert!(with_all.is_specified());
315
316        let with_groups = Target {
317            groups: vec!["canary".into()],
318            ..Target::default()
319        };
320        assert!(with_groups.is_specified());
321
322        let with_pcs = Target {
323            pcs: vec!["minipc".into()],
324            ..Target::default()
325        };
326        assert!(with_pcs.is_specified());
327    }
328
329    #[test]
330    fn manifest_deserialises_minimal_yaml() {
331        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
332        // — those live on the schedule / exec request now.
333        let yaml = r#"
334id: echo-test
335version: 0.0.1
336execute:
337  shell: powershell
338  script: "echo 'kanade'"
339  timeout: 30s
340"#;
341        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
342        assert_eq!(m.id, "echo-test");
343        assert_eq!(m.version, "0.0.1");
344        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
345        assert_eq!(m.execute.script.trim(), "echo 'kanade'");
346        assert_eq!(m.execute.timeout, "30s");
347        assert!(!m.require_approval);
348    }
349
350    #[test]
351    fn schedule_carries_target_and_rollout() {
352        let yaml = r#"
353id: hourly-cleanup-canary
354cron: "0 0 * * * *"
355job_id: cleanup
356enabled: true
357target:
358  groups: [canary, wave1]
359jitter: 30s
360rollout:
361  strategy: wave
362  waves:
363    - { group: canary, delay: 0s }
364    - { group: wave1,  delay: 5s }
365"#;
366        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
367        assert_eq!(s.id, "hourly-cleanup-canary");
368        assert_eq!(s.job_id, "cleanup");
369        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
370        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
371        let rollout = s.plan.rollout.expect("rollout present");
372        assert_eq!(rollout.waves.len(), 2);
373        assert_eq!(rollout.waves[0].group, "canary");
374        assert_eq!(rollout.waves[1].delay, "5s");
375        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
376    }
377
378    #[test]
379    fn schedule_minimal_target_all() {
380        let yaml = r#"
381id: every-10s
382cron: "*/10 * * * * *"
383enabled: true
384job_id: scheduled-echo
385target: { all: true }
386"#;
387        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
388        assert_eq!(s.id, "every-10s");
389        assert_eq!(s.cron, "*/10 * * * * *");
390        assert!(s.enabled);
391        assert_eq!(s.job_id, "scheduled-echo");
392        assert!(s.plan.target.all);
393        assert!(s.plan.rollout.is_none());
394        assert!(s.plan.jitter.is_none());
395    }
396
397    #[test]
398    fn schedule_enabled_defaults_to_true() {
399        let yaml = r#"
400id: x
401cron: "* * * * * *"
402job_id: y
403target: { all: true }
404"#;
405        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
406        assert!(s.enabled);
407    }
408
409    #[test]
410    fn schedule_mode_defaults_to_every_tick() {
411        let yaml = r#"
412id: x
413cron: "* * * * * *"
414job_id: y
415target: { all: true }
416"#;
417        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
418        assert_eq!(s.mode, ExecMode::EveryTick);
419        assert!(s.cooldown.is_none());
420        assert!(!s.auto_disable_when_done);
421    }
422
423    #[test]
424    fn schedule_mode_serialises_snake_case() {
425        for (mode, expected) in [
426            (ExecMode::EveryTick, "every_tick"),
427            (ExecMode::OncePerPc, "once_per_pc"),
428            (ExecMode::OncePerTarget, "once_per_target"),
429        ] {
430            let s = serde_json::to_value(mode).expect("serialise");
431            assert_eq!(s, serde_json::Value::String(expected.into()));
432            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
433                .expect("deserialise");
434            assert_eq!(back, mode, "round-trip for {expected}");
435        }
436    }
437
438    #[test]
439    fn schedule_kitting_yaml_parses() {
440        let yaml = r#"
441id: kitting-setup
442cron: "*/30 * * * * *"
443job_id: install-baseline
444target: { all: true }
445mode: once_per_pc
446"#;
447        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
448        assert_eq!(s.mode, ExecMode::OncePerPc);
449        assert!(s.cooldown.is_none());
450        assert!(!s.auto_disable_when_done);
451    }
452
453    #[test]
454    fn schedule_batch_campaign_yaml_parses() {
455        let yaml = r#"
456id: q3-patch-batch
457cron: "*/5 * * * * *"
458job_id: install-patch
459target:
460  pcs: [pc-001, pc-002, pc-003]
461mode: once_per_pc
462auto_disable_when_done: true
463"#;
464        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
465        assert_eq!(s.mode, ExecMode::OncePerPc);
466        assert!(s.cooldown.is_none());
467        assert!(s.auto_disable_when_done);
468        assert_eq!(s.plan.target.pcs.len(), 3);
469    }
470
471    #[test]
472    fn schedule_throttled_yaml_parses() {
473        let yaml = r#"
474id: daily-compliance
475cron: "*/5 * * * * *"
476job_id: check-av-status
477target: { all: true }
478mode: once_per_pc
479cooldown: 1d
480"#;
481        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
482        assert_eq!(s.mode, ExecMode::OncePerPc);
483        assert_eq!(s.cooldown.as_deref(), Some("1d"));
484    }
485
486    #[test]
487    fn schedule_runs_on_defaults_to_backend() {
488        let yaml = r#"
489id: x
490cron: "* * * * * *"
491job_id: y
492target: { all: true }
493"#;
494        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
495        assert_eq!(s.runs_on, RunsOn::Backend);
496    }
497
498    #[test]
499    fn schedule_runs_on_agent_parses() {
500        let yaml = r#"
501id: offline-inv
502cron: "0 0 * * * *"
503job_id: inventory-hw
504target: { all: true }
505runs_on: agent
506mode: once_per_pc
507"#;
508        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
509        assert_eq!(s.runs_on, RunsOn::Agent);
510        assert_eq!(s.mode, ExecMode::OncePerPc);
511    }
512
513    #[test]
514    fn runs_on_serialises_snake_case() {
515        for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
516            let s = serde_json::to_value(mode).expect("serialise");
517            assert_eq!(s, serde_json::Value::String(expected.into()));
518            let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
519                .expect("deserialise");
520            assert_eq!(back, mode);
521        }
522    }
523
524    #[test]
525    fn schedule_once_per_target_yaml_parses() {
526        let yaml = r#"
527id: license-checkin
528cron: "*/10 * * * * *"
529job_id: hit-license-server
530target: { all: true }
531mode: once_per_target
532cooldown: 24h
533"#;
534        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
535        assert_eq!(s.mode, ExecMode::OncePerTarget);
536        assert_eq!(s.cooldown.as_deref(), Some("24h"));
537    }
538
539    #[test]
540    fn execute_shell_into_wire_shell() {
541        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
542        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
543    }
544
545    #[test]
546    fn manifest_staleness_defaults_to_cached() {
547        let yaml = r#"
548id: x
549version: 1.0.0
550execute:
551  shell: powershell
552  script: "echo"
553  timeout: 1s
554"#;
555        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
556        assert_eq!(m.staleness, Staleness::Cached);
557    }
558
559    #[test]
560    fn manifest_strict_staleness_parses() {
561        let yaml = r#"
562id: urgent-patch
563version: 2.5.1
564execute:
565  shell: powershell
566  script: Install-Hotfix
567  timeout: 5m
568staleness:
569  mode: strict
570  max_cache_age: 0s
571"#;
572        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
573        match m.staleness {
574            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
575            other => panic!("expected strict, got {other:?}"),
576        }
577    }
578
579    #[test]
580    fn manifest_unchecked_staleness_parses() {
581        let yaml = r#"
582id: legacy
583version: 0.1.0
584execute:
585  shell: cmd
586  script: "echo"
587  timeout: 1s
588staleness:
589  mode: unchecked
590"#;
591        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
592        assert_eq!(m.staleness, Staleness::Unchecked);
593    }
594
595    #[test]
596    fn missing_required_field_errors() {
597        // `id` missing.
598        let yaml = r#"
599version: 1.0.0
600target: { all: true }
601execute:
602  shell: powershell
603  script: "echo"
604  timeout: 1s
605"#;
606        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
607        assert!(r.is_err(), "expected error, got {:?}", r);
608    }
609
610    #[test]
611    fn display_field_table_kind_round_trips_with_nested_columns() {
612        // #39: `type: table` + `columns:` on a DisplayField gets
613        // round-tripped through serde so the SPA receives the
614        // nested schema verbatim. Nested columns themselves are
615        // DisplayFields so they can carry `type: bytes` /
616        // `type: number` for cell formatting.
617        let yaml = r#"
618id: inv-hw
619version: 1.0.0
620execute:
621  shell: powershell
622  script: "echo"
623  timeout: 60s
624inventory:
625  display:
626    - field: hostname
627      label: Hostname
628    - field: disks
629      label: Disks
630      type: table
631      columns:
632        - field: device_id
633          label: Drive
634        - field: size_bytes
635          label: Size
636          type: bytes
637        - field: free_bytes
638          label: Free
639          type: bytes
640        - field: file_system
641          label: FS
642"#;
643        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
644        let inv = m.inventory.as_ref().expect("inventory hint");
645        let disks = inv
646            .display
647            .iter()
648            .find(|d| d.field == "disks")
649            .expect("disks display row");
650        assert_eq!(disks.kind.as_deref(), Some("table"));
651        let cols = disks.columns.as_ref().expect("table needs columns");
652        assert_eq!(cols.len(), 4);
653        assert_eq!(cols[1].field, "size_bytes");
654        assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
655    }
656
657    #[test]
658    fn display_field_scalar_kind_keeps_columns_none() {
659        // Defensive: when type is a scalar (`bytes` / `number` /
660        // `timestamp`) the `columns` field stays None — the SPA
661        // uses its presence as the "render nested table" signal,
662        // so it must not leak in via serde defaults.
663        let yaml = r#"
664id: x
665version: 1.0.0
666execute:
667  shell: powershell
668  script: "echo"
669  timeout: 5s
670inventory:
671  display:
672    - { field: ram_bytes, label: RAM, type: bytes }
673"#;
674        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
675        let inv = m.inventory.as_ref().unwrap();
676        assert!(inv.display[0].columns.is_none());
677    }
678}
679
680/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
681/// (target + optional rollout + optional jitter) inline; the
682/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
683/// script body. Two schedules of the same job can target different
684/// groups on different cadences without copying the manifest.
685#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
686pub struct Schedule {
687    pub id: String,
688    /// 6-field cron expression (`sec min hour day month day-of-week`),
689    /// matching `tokio-cron-scheduler` syntax.
690    pub cron: String,
691    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
692    /// Manifest's `id`.
693    pub job_id: String,
694    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
695    /// carry these any more — same job + different fanout = different
696    /// schedule.
697    #[serde(flatten)]
698    pub plan: FanoutPlan,
699    /// Per-pc/per-target dedup semantics (v0.19). Default
700    /// `EveryTick` keeps the historical "fire every cron tick at the
701    /// whole target" behavior.
702    #[serde(default)]
703    pub mode: ExecMode,
704    /// Humantime cooldown for `OncePerPc` / `OncePerTarget`. Once a
705    /// pc/target has succeeded, the scheduler waits this long before
706    /// considering it eligible again. Omit for "succeed once, then
707    /// permanently skip" — i.e. cooldown = infinity.
708    #[serde(default, skip_serializing_if = "Option::is_none")]
709    pub cooldown: Option<String>,
710    /// When true AND the schedule's lifecycle is permanently
711    /// terminated (`cooldown = None` + dedup says nothing more to
712    /// do), the scheduler flips `enabled = false` and emits an
713    /// audit event. No-op when `cooldown` is set (re-arming
714    /// schedules never finish).
715    #[serde(default)]
716    pub auto_disable_when_done: bool,
717    /// v0.22: optional humantime window after a cron tick during
718    /// which the Command is still considered "live". The scheduler
719    /// computes `tick_at + starting_deadline` and stamps it onto
720    /// each Command as `deadline_at`; agents skip Commands they
721    /// receive after that absolute time. `None` (default) = no
722    /// deadline, meaning a Command queued in the broker / stream
723    /// during agent downtime runs whenever the agent reconnects —
724    /// good for kitting / inventory / cleanup. Set this for
725    /// time-of-day notifications, lunch reminders, etc., where
726    /// "fire 3 hours late" would be wrong.
727    #[serde(default, skip_serializing_if = "Option::is_none")]
728    pub starting_deadline: Option<String>,
729    /// v0.23: where does the cron tick happen? `Backend` (default,
730    /// historical) = backend's scheduler fires Commands via NATS;
731    /// agents passively receive. `Agent` = each targeted agent runs
732    /// its own internal cron and fires locally, so the schedule
733    /// keeps ticking even when the broker is unreachable (laptop on
734    /// the train, broker maintenance window, full WAN outage). The
735    /// two locations are mutually exclusive — when `Agent`, the
736    /// backend scheduler stays out and just keeps the definition in
737    /// KV for agents to read.
738    #[serde(default)]
739    pub runs_on: RunsOn,
740    #[serde(default = "default_true")]
741    pub enabled: bool,
742}
743
744/// v0.23 — where the cron tick fires from.
745#[derive(
746    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
747)]
748#[serde(rename_all = "snake_case")]
749pub enum RunsOn {
750    /// Backend's central scheduler ticks and publishes Commands to
751    /// NATS. Historical default, what every pre-v0.23 schedule
752    /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
753    /// reconnects ⇒ catch-up via [`command_replay`](crate)
754    /// (see kanade-agent's command_replay module).
755    #[default]
756    Backend,
757    /// Each targeted agent runs the cron tick locally. Survives
758    /// broker / WAN outages. Best for laptops / mobile devices that
759    /// roam off the corporate network. Agent must be online for the
760    /// initial schedule + job-catalog pull, but once cached the
761    /// agent fires the script standalone.
762    Agent,
763}
764
765/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
766#[derive(
767    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
768)]
769#[serde(rename_all = "snake_case")]
770pub enum ExecMode {
771    /// Fire on every cron tick at the whole target. Historical
772    /// (pre-v0.19) behavior; no dedup.
773    #[default]
774    EveryTick,
775    /// Fire at each pc until that pc succeeds; then skip it until
776    /// the optional cooldown elapses (or forever if no cooldown).
777    /// Use for kitting / first-boot / per-pc compliance checks.
778    OncePerPc,
779    /// Fire at the whole target until **any** pc succeeds; then
780    /// skip the whole target until the optional cooldown elapses
781    /// (or forever if no cooldown). Use for "one delegate is
782    /// enough" tasks like license check-in.
783    OncePerTarget,
784}
785
786fn default_true() -> bool {
787    true
788}