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    /// Issue #246: opt-in marker that this job emits per-line
34    /// observability events on stdout (one JSON `ObsEvent` per
35    /// newline). When present, the agent — after the script exits
36    /// successfully — parses each non-empty stdout line as an
37    /// `ObsEvent`, publishes it on `obs.<pc_id>` via the
38    /// `obs_outbox`, and (intentionally) **omits the stdout from
39    /// the `ExecResult`** so the timeline data doesn't double up
40    /// in `execution_results.stdout` (which would multiply rows
41    /// by ~50/day/PC of noise).
42    ///
43    /// Distinct from `inventory:` (single JSON object → projector
44    /// upsert) — events are append-only timeline points consumed
45    /// by the dedicated `obs_events` table.
46    #[serde(default)]
47    pub emit: Option<EmitConfig>,
48    /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
49    /// what the agent does at fire time when it can't verify the
50    /// `script_current` / `script_status` KV values are fresh —
51    /// especially relevant for `runs_on: agent` schedules where
52    /// the agent may fire from cache while offline. Defaults to
53    /// `Staleness::Cached` (silently use cached values), which
54    /// matches every pre-v0.26 Manifest.
55    #[serde(default)]
56    pub staleness: Staleness,
57}
58
59/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
60/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
61/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
62/// here keeps the validation + serialisation logic in one place.
63#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
64pub struct FanoutPlan {
65    #[serde(default)]
66    pub target: Target,
67    /// Optional wave rollout — when present, the backend publishes
68    /// each wave's group subject on its own delay schedule instead
69    /// of fanning out the `target` block in one go. `target` then
70    /// only labels the deploy for the audit log.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub rollout: Option<Rollout>,
73    /// Optional humantime jitter; agent uses it to randomise
74    /// execution start. Lives here (not on the script) so different
75    /// schedules / ad-hoc fires of the same job can pick different
76    /// stagger windows.
77    #[serde(default, skip_serializing_if = "Option::is_none")]
78    pub jitter: Option<String>,
79    /// Absolute time the scheduler stamps on each emitted Command
80    /// when this exec was driven by a [`Schedule`] with
81    /// `starting_deadline`. Agents receiving a Command after this
82    /// instant publish a synthetic skipped-result instead of
83    /// running the script. `None` (default) = no deadline / catch
84    /// up whenever delivered. Operators don't usually set this
85    /// directly — the scheduler computes it from `tick_at +
86    /// starting_deadline`.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
89}
90
91/// Manifest sub-section: how the SPA should render the inventory
92/// facts this job produces. Each field name (`field`) is a top-level
93/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
94///
95/// Two render modes:
96///   * `display` — vertical "field / value" per PC, used by the
97///     `/inventory?pc=<id>` detail view. ALL columns the operator
98///     wants visible on the detail page.
99///   * `summary` — horizontal table across the fleet (row = PC,
100///     column = field) on `/inventory`. Optional; when omitted the
101///     SPA falls back to `display`, but operators usually want a
102///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
103#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
104pub struct InventoryHint {
105    /// Detail-view columns, in order.
106    pub display: Vec<DisplayField>,
107    /// Optional fleet-list columns (row = PC). Defaults to `display`
108    /// when omitted, but operators usually pick a 3-5 column subset.
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub summary: Option<Vec<DisplayField>>,
111    /// v0.31 / #40: payload arrays that should be exploded into
112    /// per-element rows of a derived SQLite table. Lets operators
113    /// answer cross-PC questions ("which PCs still have Chrome <
114    /// 120?", "C: >90% full") with normal SQL filters + indexes
115    /// instead of grepping JSON. The projector creates the derived
116    /// table on register and replaces this PC's rows on each result
117    /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
118    /// [`ExplodeSpec`] for the per-spec schema.
119    #[serde(default, skip_serializing_if = "Option::is_none")]
120    pub explode: Option<Vec<ExplodeSpec>>,
121    /// v0.35 / #93: top-level scalar fields whose changes the
122    /// projector logs to `inventory_history` (one event per
123    /// changed field per scan). Pairs with `explode[].track_history`
124    /// — that covers array elements; this covers single-valued
125    /// fields like `ram_bytes` / `os_version` / `cpu_model` /
126    /// `os_build` that operators want to track for "did the RAM
127    /// get upgraded?" / "when did Win 11 land on this PC?" /
128    /// "BIOS / firmware bumped?" questions. Field name = `field_path`
129    /// in the history row, `identity_json` is NULL, `before_json`
130    /// / `after_json` each carry `{"value": <prior or new value>}`.
131    /// First-ever observation of a scalar (no prior facts row)
132    /// emits `added`; subsequent value changes emit `changed`. No
133    /// `removed` events — a scalar disappearing from the payload
134    /// is rare and the operator can still see the last value via
135    /// the `before_json` of the most recent change.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub history_scalars: Option<Vec<String>>,
138}
139
140/// Issue #246 — `emit:` manifest block for jobs whose stdout is
141/// NDJSON observability events (one `ObsEvent` per line). Parallel
142/// to `inventory:` but for the append-only timeline pipeline; see
143/// `Manifest::emit` for the full contract.
144#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
145#[serde(deny_unknown_fields)]
146pub struct EmitConfig {
147    /// What kind of payload the agent should expect on stdout. Only
148    /// `events` is defined today (parses each non-empty line as
149    /// `ObsEvent` and publishes on `obs.<pc_id>`); future variants
150    /// (e.g. metrics streams, structured trace events) plug in here.
151    #[serde(rename = "type")]
152    pub kind: EmitKind,
153    /// Operator hint for where the script keeps its own state — the
154    /// watermark file the PowerShell / sh body reads + writes
155    /// between runs so it only emits NEW events since the last
156    /// poll. The agent doesn't read this; it's documentation that
157    /// the SPA (and `kanade job edit`) can surface to operators
158    /// reviewing the manifest. Optional; the script is allowed to
159    /// keep state anywhere (registry, env, etc.) — the field's
160    /// presence makes the convention discoverable.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub watermark_path: Option<String>,
163}
164
165/// `emit.type` enum. Lowercase serde so manifests read
166/// `type: events` rather than `Events`.
167#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
168#[serde(rename_all = "lowercase")]
169pub enum EmitKind {
170    /// Per-line `ObsEvent` JSON. Agent parses + publishes on
171    /// `obs.<pc_id>`, drops the stdout from the resulting
172    /// `ExecResult`.
173    Events,
174}
175
176/// v0.31 / #40: declarative "flatten this JSON array into a real
177/// SQLite table" spec on an inventory manifest. The projector
178/// creates the table on first registration (CREATE TABLE IF NOT
179/// EXISTS + indexes) and writes a row per element of
180/// `payload[field]` on every result, scoped by (pc_id, job_id) so
181/// each PC's rows replace cleanly without a per-PC schema.
182#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
183pub struct ExplodeSpec {
184    /// JSON array key under the payload to explode. E.g. `"apps"`
185    /// for `payload: { apps: [{...}, {...}] }`.
186    pub field: String,
187    /// Derived SQLite table name. Operators choose this — pick
188    /// something namespaced + stable (`inventory_sw_apps`, not
189    /// `apps`) so multiple inventory manifests don't collide on a
190    /// generic name.
191    pub table: String,
192    /// Element-level fields that uniquely identify a row inside one
193    /// PC's payload. The full PK is `(pc_id, job_id) + these
194    /// columns`. Required — operators must think about uniqueness
195    /// (e.g. `["name", "source"]` for installed apps because the
196    /// same name appears in multiple uninstall hives).
197    ///
198    /// v0.31 / #41: same tuple drives history identity. When
199    /// `track_history` is on, the projector serialises these
200    /// fields' values into `inventory_history.identity_json` for
201    /// every change event, so queries like "every PC that ever
202    /// installed Chrome (any source)" filter on identity_json
203    /// content without a per-manifest schema.
204    pub primary_key: Vec<String>,
205    /// Per-element fields that become columns in the derived table.
206    pub columns: Vec<ExplodeColumn>,
207    /// v0.31 / #41: when true (default false), the projector
208    /// diffs each PC's incoming payload against the prior rows
209    /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
210    /// replace, and writes added / removed / changed events into
211    /// `inventory_history`. Lets operators answer time-dimension
212    /// questions ("when did Chrome 120 first appear on PC X?",
213    /// "what's the Win 11 23H2 rollout curve") without storing
214    /// per-scan snapshots. Off by default so operators opt in
215    /// per-spec — history has a real storage cost on long-lived
216    /// deployments (mitigated by the 90-day default retention
217    /// sweeper, see `cleanup` module).
218    #[serde(default)]
219    pub track_history: bool,
220}
221
222/// One column in an [`ExplodeSpec`]'s derived table.
223#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
224pub struct ExplodeColumn {
225    /// JSON key under each array element. Becomes the column name
226    /// in the derived SQLite table — we don't rename.
227    pub field: String,
228    /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
229    /// Storage maps directly via `sqlx::query.bind(...)`; type
230    /// mismatches at INSERT-time fail loudly rather than silently
231    /// dropping the row.
232    #[serde(default, skip_serializing_if = "Option::is_none")]
233    #[serde(rename = "type")]
234    pub kind: Option<String>,
235    /// When true, the projector creates a `CREATE INDEX` on this
236    /// column at table-creation time. Boost for the common-filter
237    /// columns (`name`, `version`) — operators mark them
238    /// explicitly, the projector won't guess.
239    #[serde(default)]
240    pub index: bool,
241}
242
243#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
244pub struct DisplayField {
245    /// Top-level key in the stdout JSON.
246    pub field: String,
247    /// Human-readable column header.
248    pub label: String,
249    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
250    /// or `"table"` (#39). Defaults to plain text rendering on the
251    /// SPA side. `"table"` expects the field's value to be a JSON
252    /// array of objects and renders a nested sub-table on the
253    /// per-PC detail page using `columns` as the schema; the fleet
254    /// summary view falls back to showing the row count for
255    /// `"table"` cells so the wide list stays compact.
256    #[serde(default, skip_serializing_if = "Option::is_none")]
257    #[serde(rename = "type")]
258    pub kind: Option<String>,
259    /// v0.30 / #39: when `kind == "table"`, the SPA renders the
260    /// field's value (an array of objects like
261    /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
262    /// sub-table using these columns. Each column is itself a
263    /// `DisplayField`, so the nested cells reuse the same render
264    /// hints (`bytes`, `number`, `timestamp`) — no parallel format
265    /// pipeline. Ignored for any other `kind`.
266    #[serde(default, skip_serializing_if = "Option::is_none")]
267    pub columns: Option<Vec<DisplayField>>,
268}
269
270#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
271pub struct Rollout {
272    #[serde(default)]
273    pub strategy: RolloutStrategy,
274    pub waves: Vec<Wave>,
275}
276
277#[derive(
278    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
279)]
280#[serde(rename_all = "lowercase")]
281pub enum RolloutStrategy {
282    #[default]
283    Wave,
284}
285
286#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
287pub struct Wave {
288    pub group: String,
289    /// humantime delay measured from the deploy's publish time. wave[0]
290    /// typically has "0s"; subsequent waves use minutes / hours.
291    pub delay: String,
292}
293
294#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
295pub struct Target {
296    #[serde(default)]
297    pub groups: Vec<String>,
298    #[serde(default)]
299    pub pcs: Vec<String>,
300    #[serde(default)]
301    pub all: bool,
302}
303
304impl Target {
305    /// At least one of all / groups / pcs is set.
306    pub fn is_specified(&self) -> bool {
307        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
308    }
309}
310
311#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
312#[serde(deny_unknown_fields)]
313pub struct Execute {
314    pub shell: ExecuteShell,
315    /// Inline script body. Mutually exclusive with [`script_file`]
316    /// and [`script_object`]; exactly one of the three must be set
317    /// (enforced by [`Execute::validate_script_source`] at the
318    /// write-side parse boundaries — `kanade job create` and
319    /// `POST /api/jobs`).
320    ///
321    /// Empty string is treated as **unset** so operators can swap
322    /// to a `script_file:` / `script_object:` alternative just by
323    /// commenting out the body, without having to also drop the
324    /// `script:` key entirely.
325    ///
326    /// [`script_file`]: Self::script_file
327    /// [`script_object`]: Self::script_object
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub script: Option<String>,
330    /// Repo-local file path resolved by the operator-side CLI at
331    /// `kanade job create` time. The CLI reads the file, slots its
332    /// contents into `script`, and clears this field before
333    /// POSTing — so the backend / agents never see `script_file`
334    /// in stored manifests. SPEC §2.4.1.
335    ///
336    /// Resolver lands in a follow-up PR
337    /// (yukimemi/kanade#210); today this field passes parse-time
338    /// validation but the operator-side CLI bails with "not yet
339    /// implemented" until the resolver ships, so manifests that
340    /// reach the backend with `script_file` set are treated as a
341    /// schema-bug.
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub script_file: Option<String>,
344    /// Object Store reference (`<name>/<version>`) into the
345    /// `scripts` bucket (`OBJECT_SCRIPTS`). Agents fetch the body
346    /// at Execute time via `/api/script-objects/{name}/{version}`
347    /// and cache it locally. SPEC §2.4.1.
348    ///
349    /// Resolver lands in the same follow-up PR as `script_file`;
350    /// today this field passes parse-time validation but the
351    /// backend / agent exec paths bail with "not yet implemented"
352    /// when they see it.
353    #[serde(default, skip_serializing_if = "Option::is_none")]
354    pub script_object: Option<String>,
355    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
356    /// — represents how long this script reasonably takes to run.
357    pub timeout: String,
358    /// Token + session combination the agent uses to launch the
359    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
360    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
361    #[serde(default)]
362    pub run_as: RunAs,
363    /// Working directory for the spawned child (v0.21.1). When
364    /// unset, the child inherits the agent's cwd — on Windows that
365    /// means `%SystemRoot%\System32` for the prod service, which is
366    /// almost never what operators actually want. Use an absolute
367    /// path; relative paths are passed through to the OS verbatim.
368    /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
369    /// you'd want `%USERPROFILE%` (but expansion happens in the
370    /// shell, so write `$env:USERPROFILE` for PowerShell, or set
371    /// it via teravars before `kanade job create`).
372    #[serde(default, skip_serializing_if = "Option::is_none")]
373    pub cwd: Option<String>,
374}
375
376impl Execute {
377    /// Treat an empty `script:` body as "intentionally unset". Operators
378    /// commenting out a block-scalar tend to leave the key behind, and
379    /// failing the validator on `script: ""` would surprise them.
380    fn has_inline_script(&self) -> bool {
381        matches!(&self.script, Some(s) if !s.is_empty())
382    }
383
384    /// Enforce that exactly one of `script` / `script_file` /
385    /// `script_object` is set. Called at the write-side parse
386    /// boundaries (CLI `kanade job create` + backend
387    /// `POST /api/jobs`) so ambiguous YAML is rejected before it
388    /// reaches the JOBS KV. Read paths (projector, agent
389    /// scheduler, list endpoints) skip this check — they only ever
390    /// see what the write path already validated.
391    pub fn validate_script_source(&self) -> Result<(), String> {
392        let inline = self.has_inline_script();
393        let file = self.script_file.is_some();
394        let obj = self.script_object.is_some();
395        let set = [inline, file, obj].into_iter().filter(|b| *b).count();
396        match set {
397            1 => Ok(()),
398            0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
399            _ => Err(format!(
400                "execute: only one of `script` / `script_file` / `script_object` may be set \
401                 (got script={inline}, script_file={file}, script_object={obj})"
402            )),
403        }
404    }
405}
406
407impl Manifest {
408    /// Cross-field semantic checks that don't fit into pure serde
409    /// derive. Currently delegates to
410    /// [`Execute::validate_script_source`] — see that method's
411    /// docs for the rationale on which call sites should run this.
412    pub fn validate(&self) -> Result<(), String> {
413        self.execute.validate_script_source()?;
414        Ok(())
415    }
416}
417
418#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
419#[serde(rename_all = "lowercase")]
420pub enum ExecuteShell {
421    Powershell,
422    Cmd,
423}
424
425impl From<ExecuteShell> for Shell {
426    fn from(s: ExecuteShell) -> Self {
427        match s {
428            ExecuteShell::Powershell => Shell::Powershell,
429            ExecuteShell::Cmd => Shell::Cmd,
430        }
431    }
432}
433
434#[cfg(test)]
435mod tests {
436    use super::*;
437
438    #[test]
439    fn target_is_specified_requires_at_least_one_field() {
440        let empty = Target::default();
441        assert!(!empty.is_specified());
442
443        let with_all = Target {
444            all: true,
445            ..Target::default()
446        };
447        assert!(with_all.is_specified());
448
449        let with_groups = Target {
450            groups: vec!["canary".into()],
451            ..Target::default()
452        };
453        assert!(with_groups.is_specified());
454
455        let with_pcs = Target {
456            pcs: vec!["minipc".into()],
457            ..Target::default()
458        };
459        assert!(with_pcs.is_specified());
460    }
461
462    #[test]
463    fn manifest_deserialises_minimal_yaml() {
464        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
465        // — those live on the schedule / exec request now.
466        let yaml = r#"
467id: echo-test
468version: 0.0.1
469execute:
470  shell: powershell
471  script: "echo 'kanade'"
472  timeout: 30s
473"#;
474        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
475        assert_eq!(m.id, "echo-test");
476        assert_eq!(m.version, "0.0.1");
477        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
478        assert_eq!(
479            m.execute.script.as_deref().map(str::trim),
480            Some("echo 'kanade'")
481        );
482        assert!(m.execute.script_file.is_none());
483        assert!(m.execute.script_object.is_none());
484        assert_eq!(m.execute.timeout, "30s");
485        assert!(!m.require_approval);
486        m.validate()
487            .expect("inline-script manifest passes validation");
488    }
489
490    fn execute_with(
491        script: Option<&str>,
492        script_file: Option<&str>,
493        script_object: Option<&str>,
494    ) -> Execute {
495        Execute {
496            shell: ExecuteShell::Powershell,
497            script: script.map(str::to_owned),
498            script_file: script_file.map(str::to_owned),
499            script_object: script_object.map(str::to_owned),
500            timeout: "30s".into(),
501            run_as: RunAs::default(),
502            cwd: None,
503        }
504    }
505
506    #[test]
507    fn validate_accepts_inline_script() {
508        let e = execute_with(Some("echo hi"), None, None);
509        assert!(e.validate_script_source().is_ok());
510    }
511
512    #[test]
513    fn validate_accepts_script_file_alone() {
514        let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
515        assert!(e.validate_script_source().is_ok());
516    }
517
518    #[test]
519    fn validate_accepts_script_object_alone() {
520        let e = execute_with(None, None, Some("cleanup/1.0.0"));
521        assert!(e.validate_script_source().is_ok());
522    }
523
524    #[test]
525    fn validate_treats_empty_inline_script_as_unset() {
526        // `script: ""` + `script_object` set is the natural shape
527        // when an operator comments out the YAML block-scalar body
528        // but leaves the key. Should pass.
529        let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
530        assert!(e.validate_script_source().is_ok());
531    }
532
533    #[test]
534    fn validate_rejects_zero_sources() {
535        let e = execute_with(None, None, None);
536        let err = e.validate_script_source().unwrap_err();
537        assert!(err.contains("must be set"), "got: {err}");
538    }
539
540    #[test]
541    fn validate_rejects_empty_inline_only() {
542        let e = execute_with(Some(""), None, None);
543        let err = e.validate_script_source().unwrap_err();
544        assert!(err.contains("must be set"), "got: {err}");
545    }
546
547    #[test]
548    fn validate_rejects_inline_plus_file() {
549        let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
550        let err = e.validate_script_source().unwrap_err();
551        assert!(err.contains("only one of"), "got: {err}");
552    }
553
554    #[test]
555    fn validate_rejects_inline_plus_object() {
556        let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
557        let err = e.validate_script_source().unwrap_err();
558        assert!(err.contains("only one of"), "got: {err}");
559    }
560
561    #[test]
562    fn validate_rejects_file_plus_object() {
563        let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
564        let err = e.validate_script_source().unwrap_err();
565        assert!(err.contains("only one of"), "got: {err}");
566    }
567
568    #[test]
569    fn validate_rejects_all_three() {
570        let e = execute_with(
571            Some("echo hi"),
572            Some("scripts/cleanup.ps1"),
573            Some("cleanup/1.0.0"),
574        );
575        let err = e.validate_script_source().unwrap_err();
576        assert!(err.contains("only one of"), "got: {err}");
577    }
578
579    #[test]
580    fn manifest_deserialises_script_object_yaml() {
581        // SPEC §2.4.1 example shape with the Object Store
582        // reference picked over inline.
583        let yaml = r#"
584id: cleanup-disk-temp
585version: 1.0.1
586execute:
587  shell: powershell
588  script_object: cleanup-disk-temp/1.0.1
589  timeout: 600s
590"#;
591        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
592        assert_eq!(
593            m.execute.script_object.as_deref(),
594            Some("cleanup-disk-temp/1.0.1")
595        );
596        assert!(m.execute.script.is_none());
597        m.validate()
598            .expect("script_object-only manifest passes validation");
599    }
600
601    #[test]
602    fn manifest_rejects_typo_in_script_field_name() {
603        // `deny_unknown_fields` on Execute catches `script_objectt`
604        // and similar fat-fingers at parse time instead of letting
605        // them silently fall through to "all three unset".
606        let yaml = r#"
607id: typo
608version: 1.0.0
609execute:
610  shell: powershell
611  script_objectt: oops
612  timeout: 30s
613"#;
614        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
615        assert!(r.is_err(), "expected parse error, got {r:?}");
616    }
617
618    #[test]
619    fn schedule_carries_target_and_rollout() {
620        let yaml = r#"
621id: hourly-cleanup-canary
622cron: "0 0 * * * *"
623job_id: cleanup
624enabled: true
625target:
626  groups: [canary, wave1]
627jitter: 30s
628rollout:
629  strategy: wave
630  waves:
631    - { group: canary, delay: 0s }
632    - { group: wave1,  delay: 5s }
633"#;
634        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
635        assert_eq!(s.id, "hourly-cleanup-canary");
636        assert_eq!(s.job_id, "cleanup");
637        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
638        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
639        let rollout = s.plan.rollout.expect("rollout present");
640        assert_eq!(rollout.waves.len(), 2);
641        assert_eq!(rollout.waves[0].group, "canary");
642        assert_eq!(rollout.waves[1].delay, "5s");
643        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
644    }
645
646    #[test]
647    fn schedule_minimal_target_all() {
648        let yaml = r#"
649id: every-10s
650cron: "*/10 * * * * *"
651enabled: true
652job_id: scheduled-echo
653target: { all: true }
654"#;
655        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
656        assert_eq!(s.id, "every-10s");
657        assert_eq!(s.cron, "*/10 * * * * *");
658        assert!(s.enabled);
659        assert_eq!(s.job_id, "scheduled-echo");
660        assert!(s.plan.target.all);
661        assert!(s.plan.rollout.is_none());
662        assert!(s.plan.jitter.is_none());
663    }
664
665    #[test]
666    fn schedule_enabled_defaults_to_true() {
667        let yaml = r#"
668id: x
669cron: "* * * * * *"
670job_id: y
671target: { all: true }
672"#;
673        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
674        assert!(s.enabled);
675    }
676
677    #[test]
678    fn schedule_mode_defaults_to_every_tick() {
679        let yaml = r#"
680id: x
681cron: "* * * * * *"
682job_id: y
683target: { all: true }
684"#;
685        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
686        assert_eq!(s.mode, ExecMode::EveryTick);
687        assert!(s.cooldown.is_none());
688        assert!(!s.auto_disable_when_done);
689    }
690
691    #[test]
692    fn schedule_mode_serialises_snake_case() {
693        for (mode, expected) in [
694            (ExecMode::EveryTick, "every_tick"),
695            (ExecMode::OncePerPc, "once_per_pc"),
696            (ExecMode::OncePerTarget, "once_per_target"),
697        ] {
698            let s = serde_json::to_value(mode).expect("serialise");
699            assert_eq!(s, serde_json::Value::String(expected.into()));
700            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
701                .expect("deserialise");
702            assert_eq!(back, mode, "round-trip for {expected}");
703        }
704    }
705
706    #[test]
707    fn schedule_kitting_yaml_parses() {
708        let yaml = r#"
709id: kitting-setup
710cron: "*/30 * * * * *"
711job_id: install-baseline
712target: { all: true }
713mode: once_per_pc
714"#;
715        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
716        assert_eq!(s.mode, ExecMode::OncePerPc);
717        assert!(s.cooldown.is_none());
718        assert!(!s.auto_disable_when_done);
719    }
720
721    #[test]
722    fn schedule_batch_campaign_yaml_parses() {
723        let yaml = r#"
724id: q3-patch-batch
725cron: "*/5 * * * * *"
726job_id: install-patch
727target:
728  pcs: [pc-001, pc-002, pc-003]
729mode: once_per_pc
730auto_disable_when_done: true
731"#;
732        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
733        assert_eq!(s.mode, ExecMode::OncePerPc);
734        assert!(s.cooldown.is_none());
735        assert!(s.auto_disable_when_done);
736        assert_eq!(s.plan.target.pcs.len(), 3);
737    }
738
739    #[test]
740    fn schedule_throttled_yaml_parses() {
741        let yaml = r#"
742id: daily-compliance
743cron: "*/5 * * * * *"
744job_id: check-av-status
745target: { all: true }
746mode: once_per_pc
747cooldown: 1d
748"#;
749        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
750        assert_eq!(s.mode, ExecMode::OncePerPc);
751        assert_eq!(s.cooldown.as_deref(), Some("1d"));
752    }
753
754    #[test]
755    fn schedule_runs_on_defaults_to_backend() {
756        let yaml = r#"
757id: x
758cron: "* * * * * *"
759job_id: y
760target: { all: true }
761"#;
762        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
763        assert_eq!(s.runs_on, RunsOn::Backend);
764    }
765
766    #[test]
767    fn schedule_runs_on_agent_parses() {
768        let yaml = r#"
769id: offline-inv
770cron: "0 0 * * * *"
771job_id: inventory-hw
772target: { all: true }
773runs_on: agent
774mode: once_per_pc
775"#;
776        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
777        assert_eq!(s.runs_on, RunsOn::Agent);
778        assert_eq!(s.mode, ExecMode::OncePerPc);
779    }
780
781    #[test]
782    fn runs_on_serialises_snake_case() {
783        for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
784            let s = serde_json::to_value(mode).expect("serialise");
785            assert_eq!(s, serde_json::Value::String(expected.into()));
786            let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
787                .expect("deserialise");
788            assert_eq!(back, mode);
789        }
790    }
791
792    #[test]
793    fn schedule_once_per_target_yaml_parses() {
794        let yaml = r#"
795id: license-checkin
796cron: "*/10 * * * * *"
797job_id: hit-license-server
798target: { all: true }
799mode: once_per_target
800cooldown: 24h
801"#;
802        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
803        assert_eq!(s.mode, ExecMode::OncePerTarget);
804        assert_eq!(s.cooldown.as_deref(), Some("24h"));
805    }
806
807    #[test]
808    fn execute_shell_into_wire_shell() {
809        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
810        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
811    }
812
813    #[test]
814    fn manifest_staleness_defaults_to_cached() {
815        let yaml = r#"
816id: x
817version: 1.0.0
818execute:
819  shell: powershell
820  script: "echo"
821  timeout: 1s
822"#;
823        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
824        assert_eq!(m.staleness, Staleness::Cached);
825    }
826
827    #[test]
828    fn manifest_strict_staleness_parses() {
829        let yaml = r#"
830id: urgent-patch
831version: 2.5.1
832execute:
833  shell: powershell
834  script: Install-Hotfix
835  timeout: 5m
836staleness:
837  mode: strict
838  max_cache_age: 0s
839"#;
840        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
841        match m.staleness {
842            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
843            other => panic!("expected strict, got {other:?}"),
844        }
845    }
846
847    #[test]
848    fn manifest_unchecked_staleness_parses() {
849        let yaml = r#"
850id: legacy
851version: 0.1.0
852execute:
853  shell: cmd
854  script: "echo"
855  timeout: 1s
856staleness:
857  mode: unchecked
858"#;
859        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
860        assert_eq!(m.staleness, Staleness::Unchecked);
861    }
862
863    #[test]
864    fn missing_required_field_errors() {
865        // `id` missing.
866        let yaml = r#"
867version: 1.0.0
868target: { all: true }
869execute:
870  shell: powershell
871  script: "echo"
872  timeout: 1s
873"#;
874        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
875        assert!(r.is_err(), "expected error, got {:?}", r);
876    }
877
878    #[test]
879    fn display_field_table_kind_round_trips_with_nested_columns() {
880        // #39: `type: table` + `columns:` on a DisplayField gets
881        // round-tripped through serde so the SPA receives the
882        // nested schema verbatim. Nested columns themselves are
883        // DisplayFields so they can carry `type: bytes` /
884        // `type: number` for cell formatting.
885        let yaml = r#"
886id: inv-hw
887version: 1.0.0
888execute:
889  shell: powershell
890  script: "echo"
891  timeout: 60s
892inventory:
893  display:
894    - field: hostname
895      label: Hostname
896    - field: disks
897      label: Disks
898      type: table
899      columns:
900        - field: device_id
901          label: Drive
902        - field: size_bytes
903          label: Size
904          type: bytes
905        - field: free_bytes
906          label: Free
907          type: bytes
908        - field: file_system
909          label: FS
910"#;
911        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
912        let inv = m.inventory.as_ref().expect("inventory hint");
913        let disks = inv
914            .display
915            .iter()
916            .find(|d| d.field == "disks")
917            .expect("disks display row");
918        assert_eq!(disks.kind.as_deref(), Some("table"));
919        let cols = disks.columns.as_ref().expect("table needs columns");
920        assert_eq!(cols.len(), 4);
921        assert_eq!(cols[1].field, "size_bytes");
922        assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
923    }
924
925    #[test]
926    fn display_field_scalar_kind_keeps_columns_none() {
927        // Defensive: when type is a scalar (`bytes` / `number` /
928        // `timestamp`) the `columns` field stays None — the SPA
929        // uses its presence as the "render nested table" signal,
930        // so it must not leak in via serde defaults.
931        let yaml = r#"
932id: x
933version: 1.0.0
934execute:
935  shell: powershell
936  script: "echo"
937  timeout: 5s
938inventory:
939  display:
940    - { field: ram_bytes, label: RAM, type: bytes }
941"#;
942        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
943        let inv = m.inventory.as_ref().unwrap();
944        assert!(inv.display[0].columns.is_none());
945    }
946}
947
948/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
949/// (target + optional rollout + optional jitter) inline; the
950/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
951/// script body. Two schedules of the same job can target different
952/// groups on different cadences without copying the manifest.
953#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
954pub struct Schedule {
955    pub id: String,
956    /// 6-field cron expression (`sec min hour day month day-of-week`),
957    /// matching `tokio-cron-scheduler` syntax.
958    pub cron: String,
959    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
960    /// Manifest's `id`.
961    pub job_id: String,
962    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
963    /// carry these any more — same job + different fanout = different
964    /// schedule.
965    #[serde(flatten)]
966    pub plan: FanoutPlan,
967    /// Per-pc/per-target dedup semantics (v0.19). Default
968    /// `EveryTick` keeps the historical "fire every cron tick at the
969    /// whole target" behavior.
970    #[serde(default)]
971    pub mode: ExecMode,
972    /// Humantime cooldown for `OncePerPc` / `OncePerTarget`. Once a
973    /// pc/target has succeeded, the scheduler waits this long before
974    /// considering it eligible again. Omit for "succeed once, then
975    /// permanently skip" — i.e. cooldown = infinity.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub cooldown: Option<String>,
978    /// When true AND the schedule's lifecycle is permanently
979    /// terminated (`cooldown = None` + dedup says nothing more to
980    /// do), the scheduler flips `enabled = false` and emits an
981    /// audit event. No-op when `cooldown` is set (re-arming
982    /// schedules never finish).
983    #[serde(default)]
984    pub auto_disable_when_done: bool,
985    /// v0.22: optional humantime window after a cron tick during
986    /// which the Command is still considered "live". The scheduler
987    /// computes `tick_at + starting_deadline` and stamps it onto
988    /// each Command as `deadline_at`; agents skip Commands they
989    /// receive after that absolute time. `None` (default) = no
990    /// deadline, meaning a Command queued in the broker / stream
991    /// during agent downtime runs whenever the agent reconnects —
992    /// good for kitting / inventory / cleanup. Set this for
993    /// time-of-day notifications, lunch reminders, etc., where
994    /// "fire 3 hours late" would be wrong.
995    #[serde(default, skip_serializing_if = "Option::is_none")]
996    pub starting_deadline: Option<String>,
997    /// v0.23: where does the cron tick happen? `Backend` (default,
998    /// historical) = backend's scheduler fires Commands via NATS;
999    /// agents passively receive. `Agent` = each targeted agent runs
1000    /// its own internal cron and fires locally, so the schedule
1001    /// keeps ticking even when the broker is unreachable (laptop on
1002    /// the train, broker maintenance window, full WAN outage). The
1003    /// two locations are mutually exclusive — when `Agent`, the
1004    /// backend scheduler stays out and just keeps the definition in
1005    /// KV for agents to read.
1006    #[serde(default)]
1007    pub runs_on: RunsOn,
1008    #[serde(default = "default_true")]
1009    pub enabled: bool,
1010}
1011
1012/// v0.23 — where the cron tick fires from.
1013#[derive(
1014    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
1015)]
1016#[serde(rename_all = "snake_case")]
1017pub enum RunsOn {
1018    /// Backend's central scheduler ticks and publishes Commands to
1019    /// NATS. Historical default, what every pre-v0.23 schedule
1020    /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
1021    /// reconnects ⇒ catch-up via [`command_replay`](crate)
1022    /// (see kanade-agent's command_replay module).
1023    #[default]
1024    Backend,
1025    /// Each targeted agent runs the cron tick locally. Survives
1026    /// broker / WAN outages. Best for laptops / mobile devices that
1027    /// roam off the corporate network. Agent must be online for the
1028    /// initial schedule + job-catalog pull, but once cached the
1029    /// agent fires the script standalone.
1030    Agent,
1031}
1032
1033/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
1034#[derive(
1035    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
1036)]
1037#[serde(rename_all = "snake_case")]
1038pub enum ExecMode {
1039    /// Fire on every cron tick at the whole target. Historical
1040    /// (pre-v0.19) behavior; no dedup.
1041    #[default]
1042    EveryTick,
1043    /// Fire at each pc until that pc succeeds; then skip it until
1044    /// the optional cooldown elapses (or forever if no cooldown).
1045    /// Use for kitting / first-boot / per-pc compliance checks.
1046    OncePerPc,
1047    /// Fire at the whole target until **any** pc succeeds; then
1048    /// skip the whole target until the optional cooldown elapses
1049    /// (or forever if no cooldown). Use for "one delegate is
1050    /// enough" tasks like license check-in.
1051    OncePerTarget,
1052}
1053
1054fn default_true() -> bool {
1055    true
1056}