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