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/// #492: these types are READ fleet-wide (agents decode them from
14/// BUCKET_JOBS / BUCKET_SCHEDULES and inside live Commands), so they
15/// must tolerate unknown fields — `deny_unknown_fields` here made a
16/// gradually-upgrading fleet's OLD agents reject the whole object
17/// the moment a newer backend added any field. Operator typo
18/// protection (the old reason for the attribute) lives at the WRITE
19/// boundaries instead: `kanade job/schedule create` and the backend
20/// POST extractor parse via [`crate::strict`], which rejects unknown
21/// keys with their full paths. The wire rule: new fields always get
22/// `#[serde(default)]` (+ `skip_serializing_if` while old readers
23/// may still be strict).
24#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
25pub struct Manifest {
26    pub id: String,
27    pub version: String,
28    #[serde(default)]
29    pub description: Option<String>,
30    pub execute: Execute,
31    #[serde(default)]
32    pub require_approval: bool,
33    /// Opt-in marker that this job produces a JSON inventory fact
34    /// payload on stdout. When present, the backend's results
35    /// projector parses `ExecResult.stdout` as JSON and upserts an
36    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
37    /// `display` sub-config drives the SPA's Inventory page render.
38    #[serde(default)]
39    pub inventory: Option<InventoryHint>,
40    /// Issue #246: opt-in marker that this job emits per-line
41    /// observability events on stdout (one JSON `ObsEvent` per
42    /// newline). When present, the agent — after the script exits
43    /// successfully — parses each non-empty stdout line as an
44    /// `ObsEvent`, publishes it on `obs.<pc_id>` via the
45    /// `obs_outbox`, and (intentionally) **omits the stdout from
46    /// the `ExecResult`** so the timeline data doesn't double up
47    /// in `execution_results.stdout` (which would multiply rows
48    /// by ~50/day/PC of noise).
49    ///
50    /// Distinct from `inventory:` (single JSON object → projector
51    /// upsert) — events are append-only timeline points consumed
52    /// by the dedicated `obs_events` table.
53    #[serde(default)]
54    pub emit: Option<EmitConfig>,
55    /// #290: opt-in marker that this job is an operator-defined
56    /// **health check** whose result feeds the Client App's Health
57    /// tab over KLP (`StateSnapshot.checks`). The script prints a
58    /// free-form JSON object on stdout (like any inventory job); the
59    /// agent reads the [`CheckHint::status_field`] value dynamically
60    /// into a [`crate::ipc::state::Check`] named `check.name`.
61    /// Cadence / windows / conditions come from
62    /// the job's Schedule (exactly like inventory) — there is
63    /// deliberately no interval here. **Composes with `inventory:`**:
64    /// the script's stdout is one JSON object, so a check can also
65    /// carry an `inventory:` block to project the rest of that object
66    /// (incl. `explode` sub-tables) for SPA fleet-querying. Only
67    /// `emit:` (NDJSON stdout) is incompatible.
68    #[serde(default)]
69    pub check: Option<CheckHint>,
70    /// #219: opt-in marker that this job COLLECTS files into a bundle.
71    /// The script does the collection work and prints a single JSON
72    /// object on stdout carrying a `files` array of paths (the field
73    /// name is [`CollectHint::files_field`], default `"files"`); the
74    /// agent — after the script exits successfully — zips those files,
75    /// uploads the archive to the `OBJECT_COLLECTIONS` Object Store
76    /// bucket (key `<pc_id>/<job_id>/<timestamp>.zip`), and records the
77    /// key in [`crate::wire::ExecResult::collect_object`]. The operator
78    /// downloads bundles from the SPA Collect page.
79    ///
80    /// Like `inventory:` / `check:` this reads a JSON object from stdout,
81    /// but it consumes that stdout for its OWN contract (a `files`
82    /// list), so it is mutually exclusive with `inventory:` / `check:` /
83    /// `emit:` (enforced in [`Manifest::validate`]). It composes with
84    /// `client:` — a `collect:` + `client:` job lets an end user trigger
85    /// a collection from the Client App (the same-host agent runs it).
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub collect: Option<CollectHint>,
88    /// #720: opt-in declarative aggregation over `obs_events` that drives
89    /// the SPA **Analytics** page. Unlike the other hints this one never
90    /// touches stdout and is never delivered to the agent — it's a pure
91    /// *read spec* the backend reads from `BUCKET_JOBS` at query time and
92    /// turns into `json_extract` aggregation SQL. Each entry is one widget
93    /// (a `dashboard:` tab groups them); `scope:` selects per-PC vs
94    /// fleet-wide rollup. Because it consumes nothing at run time it
95    /// composes with every other hint (typically paired with `emit:`,
96    /// which produces the events it reads). See [`AggregateWidget`].
97    ///
98    /// New field ⇒ #492 wire rule (`default` + `skip_serializing_if`).
99    #[serde(default, skip_serializing_if = "Option::is_none")]
100    pub aggregate: Option<Vec<AggregateWidget>>,
101    /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
102    /// what the agent does at fire time when it can't verify the
103    /// `script_current` / `script_status` KV values are fresh —
104    /// especially relevant for `runs_on: agent` schedules where
105    /// the agent may fire from cache while offline. Defaults to
106    /// `Staleness::Cached` (silently use cached values), which
107    /// matches every pre-v0.26 Manifest.
108    #[serde(default)]
109    pub staleness: Staleness,
110    /// #291: opt-in marker that this job is offered to **end users**
111    /// in the Client App's job tabs over KLP (`jobs.list` →
112    /// `jobs.execute`). Parallel to [`inventory`] / [`check`] /
113    /// [`emit`]: the block's mere presence is the opt-in, and it
114    /// groups the end-user presentation fields (name / category /
115    /// icon) that only make sense for a user-facing job. `None`
116    /// (the default) ⇒ an operator-only job — inventory, checks,
117    /// scheduled maintenance — that never surfaces in the catalog.
118    ///
119    /// The agent re-reads this at every `jobs.list` / `jobs.execute`
120    /// (SPEC §2.1), so removing the block takes a job out of a
121    /// running client on its next action.
122    ///
123    /// [`inventory`]: Manifest::inventory
124    /// [`check`]: Manifest::check
125    /// [`emit`]: Manifest::emit
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub client: Option<ClientHint>,
128    /// Free-form operator taxonomy for the Jobs catalog. Purely a
129    /// SPA-side organisational aid — agents / scheduler / projector
130    /// never read it — so it carries no runtime semantics and any
131    /// string is allowed (`security`, `weekly`, `windows`, …). Jobs
132    /// cross-cut (a `check-bitlocker` is at once a health-check, a
133    /// security control, and Windows-specific), which is why this is
134    /// a multi-valued list rather than the single closed-enum
135    /// [`ClientHint::category`] (whose values are the end-user Client
136    /// App's tabs, a different concern). The operator Jobs page groups
137    /// rows by id-prefix for free; tags add the orthogonal filter axis
138    /// prefixes can't express.
139    ///
140    /// Empty by default (the overwhelming majority of jobs), and a
141    /// new field, so it follows the #492 wire rule: `serde(default)`
142    /// plus `skip_serializing_if` keep gradually-upgrading old readers
143    /// from tripping over its absence / presence.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub tags: Vec<String>,
146    /// GitOps provenance (#678) — see [`RepoOrigin`]. Stamped by
147    /// `kanade job create` when the source YAML lives inside a Git work
148    /// tree, so the SPA can render the job read-only and point edits
149    /// back at the repo instead of letting a ClickOps edit silently
150    /// diverge from Git (SPEC design principle #3: 設定駆動 YAML + Git).
151    /// `None` for SPA-born jobs and for manifests applied from outside
152    /// any Git repo. Purely informational: agents / scheduler /
153    /// projector never read it, and it survives `script_file:` inlining
154    /// (it's orthogonal to the exactly-one-of script-source rule). New
155    /// field ⇒ #492 wire rule (`default` + `skip_serializing_if`).
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub origin: Option<RepoOrigin>,
158}
159
160/// GitOps provenance for a repo-managed YAML artifact — a [`Manifest`]
161/// (#678) or a [`Schedule`] (#695). Populated by `kanade job create` /
162/// `kanade schedule create` from the Git context of the source YAML;
163/// the SPA reads it to render Git-managed entries read-only and link
164/// the operator back at the repo. Never consulted by the runtime.
165#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
166pub struct RepoOrigin {
167    /// Repo-relative path of the source YAML — the primary edit target
168    /// the SPA surfaces (e.g. `configs/jobs/foo.yaml`). Forward slashes
169    /// regardless of the authoring OS.
170    pub path: String,
171    /// `origin` remote URL, when the repo has one. Lets the SPA turn
172    /// `path` into a clickable link; `None` for remote-less repos.
173    #[serde(default, skip_serializing_if = "Option::is_none")]
174    pub repo: Option<String>,
175    /// Repo-relative path of the `script_file:` a job manifest inlined,
176    /// when it used one — a secondary pointer shown beneath `path`.
177    /// Always `None` for schedules (they carry no script).
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub script_file: Option<String>,
180}
181
182/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
183/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
184/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
185/// here keeps the validation + serialisation logic in one place.
186#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
187pub struct FanoutPlan {
188    #[serde(default)]
189    pub target: Target,
190    /// Optional wave rollout — when present, the backend publishes
191    /// each wave's group subject on its own delay schedule instead
192    /// of fanning out the `target` block in one go. `target` then
193    /// only labels the deploy for the audit log.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub rollout: Option<Rollout>,
196    /// Optional humantime jitter; agent uses it to randomise
197    /// execution start. Lives here (not on the script) so different
198    /// schedules / ad-hoc fires of the same job can pick different
199    /// stagger windows.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub jitter: Option<String>,
202    /// Absolute time the scheduler stamps on each emitted Command
203    /// when this exec was driven by a [`Schedule`] with
204    /// `starting_deadline`. Agents receiving a Command after this
205    /// instant publish a synthetic skipped-result instead of
206    /// running the script. `None` (default) = no deadline / catch
207    /// up whenever delivered. Operators don't usually set this
208    /// directly — the scheduler computes it from `tick_at +
209    /// starting_deadline`.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
212}
213
214/// Sentinel lines that fence an inventory JSON payload inside an
215/// otherwise human-readable job stdout (#793).
216///
217/// A user-invokable (`client:`) job can't put both a friendly message
218/// and a JSON inventory object on its single stdout stream — the Client
219/// App renders stdout verbatim, while the projector needs it to be JSON.
220/// The convention: print a readable message for the user, then wrap the
221/// inventory JSON object between these two marker lines. The projector
222/// parses the fenced region (see [`inventory_payload`]) and the Client
223/// App strips it from what it shows the user. A plain inventory job (no
224/// `client:`) needs no fence — its whole stdout is the JSON.
225pub const INVENTORY_BLOCK_BEGIN: &str = "#KANADE-INVENTORY-BEGIN";
226/// Closing marker — see [`INVENTORY_BLOCK_BEGIN`].
227pub const INVENTORY_BLOCK_END: &str = "#KANADE-INVENTORY-END";
228
229/// Extract the inventory JSON payload from a job's stdout: the text
230/// between [`INVENTORY_BLOCK_BEGIN`] and [`INVENTORY_BLOCK_END`] when the
231/// fence is present, else the whole stdout (back-compat — a pure-JSON
232/// inventory job has no fence). An unterminated fence (the closing marker
233/// missing, e.g. truncated output) takes everything after the opener.
234/// The result is trimmed so surrounding message text / whitespace never
235/// reaches the JSON parser.
236pub fn inventory_payload(stdout: &str) -> &str {
237    let Some(begin) = find_line_marker(stdout, INVENTORY_BLOCK_BEGIN) else {
238        return stdout.trim();
239    };
240    let after = &stdout[begin + INVENTORY_BLOCK_BEGIN.len()..];
241    let inner = match find_line_marker(after, INVENTORY_BLOCK_END) {
242        Some(end) => &after[..end],
243        None => after,
244    };
245    inner.trim()
246}
247
248/// Find `needle` only where it begins a line (start of `hay` or right
249/// after a `\n`). Anchoring to line start means a script echoing the
250/// literal sentinel mid-message (e.g. printing a command name) can't
251/// false-trigger the fence (Claude #793).
252fn find_line_marker(hay: &str, needle: &str) -> Option<usize> {
253    if hay.starts_with(needle) {
254        return Some(0);
255    }
256    hay.find(&format!("\n{needle}")).map(|p| p + 1)
257}
258
259/// Manifest sub-section: how the SPA should render the inventory
260/// facts this job produces. Each field name (`field`) is a top-level
261/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
262///
263/// Two render modes:
264///   * `display` — vertical "field / value" per PC, used by the
265///     `/inventory?pc=<id>` detail view. ALL columns the operator
266///     wants visible on the detail page.
267///   * `summary` — horizontal table across the fleet (row = PC,
268///     column = field) on `/inventory`. Optional; when omitted the
269///     SPA falls back to `display`, but operators usually want a
270///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
271#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
272pub struct InventoryHint {
273    /// Detail-view columns, in order.
274    pub display: Vec<DisplayField>,
275    /// Optional fleet-list columns (row = PC). Defaults to `display`
276    /// when omitted, but operators usually pick a 3-5 column subset.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub summary: Option<Vec<DisplayField>>,
279    /// v0.31 / #40: payload arrays that should be exploded into
280    /// per-element rows of a derived SQLite table. Lets operators
281    /// answer cross-PC questions ("which PCs still have Chrome <
282    /// 120?", "C: >90% full") with normal SQL filters + indexes
283    /// instead of grepping JSON. The projector creates the derived
284    /// table on register and replaces this PC's rows on each result
285    /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
286    /// [`ExplodeSpec`] for the per-spec schema.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub explode: Option<Vec<ExplodeSpec>>,
289    /// v0.35 / #93: top-level scalar fields whose changes the
290    /// projector logs to `inventory_history` (one event per
291    /// changed field per scan). Pairs with `explode[].track_history`
292    /// — that covers array elements; this covers single-valued
293    /// fields like `ram_bytes` / `os_version` / `cpu_model` /
294    /// `os_build` that operators want to track for "did the RAM
295    /// get upgraded?" / "when did Win 11 land on this PC?" /
296    /// "BIOS / firmware bumped?" questions. Field name = `field_path`
297    /// in the history row, `identity_json` is NULL, `before_json`
298    /// / `after_json` each carry `{"value": <prior or new value>}`.
299    /// First-ever observation of a scalar (no prior facts row)
300    /// emits `added`; subsequent value changes emit `changed`. No
301    /// `removed` events — a scalar disappearing from the payload
302    /// is rare and the operator can still see the last value via
303    /// the `before_json` of the most recent change.
304    #[serde(default, skip_serializing_if = "Option::is_none")]
305    pub history_scalars: Option<Vec<String>>,
306}
307
308/// Manifest sub-section (#290): marks a job as an operator-defined
309/// **health check**. Parallel to [`InventoryHint`] / `EmitConfig`.
310/// The stdout contract is a free-form JSON object (same as any
311/// inventory job) from which the agent reads `status_field` /
312/// `detail_field` to build the KLP [`crate::ipc::state::Check`] shown
313/// on the Client App's Health tab.
314///
315/// There is deliberately **no timing field** — when / how often /
316/// in which window a check runs is driven by the job's Schedule,
317/// exactly like inventory jobs, so operators get the full `when:` /
318/// rollout / `runs_on` expressiveness for free.
319///
320/// A check's stdout is a **free-form inventory object** (arbitrary
321/// key/value pairs + arrays) — same as any inventory job — that also
322/// carries a status field. `check:` adds only the health semantics on
323/// top: which field is the ok/warn/fail/unknown status, an optional
324/// one-line summary field, and a remediation job. Everything else
325/// (rich per-PC detail, `explode` sub-tables like a software list) is
326/// driven by a co-present [`InventoryHint`] and rendered with the
327/// SAME display logic the SPA Inventory page uses — on the Client App
328/// too. This keeps checks maximally expressive without a bespoke
329/// payload type.
330#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
331pub struct CheckHint {
332    /// Stable check id → [`Check.name`](crate::ipc::state::Check),
333    /// the SPA/Client React key + analytics label. Unique within the
334    /// fleet's check set. Machine-friendly slug (`disk_space`,
335    /// `defender_rtp`); for the human-facing row title see [`label`].
336    ///
337    /// [`label`]: CheckHint::label
338    pub name: String,
339    /// Optional human-facing display title →
340    /// [`Check.label`](crate::ipc::state::Check). The Client App's
341    /// Health tab and the operator SPA's Compliance page render this
342    /// instead of the [`name`](CheckHint::name) slug when set
343    /// (`"ウイルス対策のリアルタイム保護"` reads better than
344    /// `defender_rtp`). Falls back to the slug when absent, so it's
345    /// purely additive. Author it in the check's language — there's no
346    /// per-locale variant; checks are operator-defined per fleet.
347    #[serde(default, skip_serializing_if = "Option::is_none")]
348    pub label: Option<String>,
349    /// Top-level stdout field whose string value
350    /// (`ok`/`warn`/`fail`/`unknown`) becomes the Health-tab light
351    /// ([`CheckStatus`](crate::ipc::state::CheckStatus)). Defaults to
352    /// `"status"`; a missing / unparseable value → `unknown`.
353    #[serde(default = "default_status_field")]
354    pub status_field: String,
355    /// Top-level stdout field used as the Health-tab row's one-line
356    /// summary. Defaults to `"detail"`; absent in the payload → no
357    /// detail line (the rich breakdown lives in the inventory view).
358    #[serde(default = "default_detail_field")]
359    pub detail_field: String,
360    /// Optional remediation job id →
361    /// [`Check.troubleshoot`](crate::ipc::state::Check). The Client
362    /// App shows a "修復する" button when present; that job must be
363    /// `user_invokable`.
364    #[serde(default, skip_serializing_if = "Option::is_none")]
365    pub troubleshoot: Option<String>,
366    /// #290 PR-E: when `true` (default), the backend also projects this
367    /// check's `status` / `detail` into the `check_status` table so the
368    /// operator SPA gets a fleet-wide compliance view for free — no
369    /// `inventory:` block needed. Set `fleet: false` for a client-only
370    /// check the operator doesn't want surfaced across the fleet.
371    #[serde(default = "default_fleet")]
372    pub fleet: bool,
373    /// Optional auto-notification on a compliance transition. When set, the
374    /// backend publishes an end-user notification the moment this check
375    /// transitions *into* one of [`CheckAlert::on`] (e.g. ok → fail) — to
376    /// the failing PC's user and/or operator groups. Fired once per
377    /// transition (not on every poll). Requires `fleet: true` (the alert
378    /// rides the same projection that fills `check_status`).
379    #[serde(default, skip_serializing_if = "Option::is_none")]
380    pub alert: Option<CheckAlert>,
381}
382
383/// Auto-notification rule for a [`CheckHint`] (compliance alerting). When a
384/// check's status transitions into one of [`on`](Self::on), the backend
385/// publishes a notification to the failing PC's user
386/// ([`notify_user`](Self::notify_user)) and/or operator groups
387/// ([`notify_groups`](Self::notify_groups)). Deliberately config-driven:
388/// who gets told, how loud, and the wording all live in the manifest, not
389/// hardcoded in the backend.
390#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
391pub struct CheckAlert {
392    /// Statuses that fire the alert on *transition into* them (a check that
393    /// stays failing doesn't re-alert every poll). Defaults to `[fail]`.
394    /// `ok` is not representable — [`CheckAlertStatus`] has no `Ok` variant,
395    /// so a YAML `on: [ok]` fails to deserialize (before `validate()` is
396    /// even reached); "recovered" notifications are out of scope.
397    #[serde(default = "default_alert_on")]
398    pub on: Vec<CheckAlertStatus>,
399    /// Notify the user(s) on the failing PC (`notifications.pc.<pc_id>`).
400    #[serde(default)]
401    pub notify_user: bool,
402    /// Notify these operator groups (`notifications.group.<name>`).
403    #[serde(default, skip_serializing_if = "Vec::is_empty")]
404    pub notify_groups: Vec<String>,
405    /// Notification priority (colour/label only — toasting is the separate
406    /// `toast` flag). Defaults to `warn`.
407    #[serde(default = "default_alert_priority")]
408    pub priority: crate::ipc::notifications::NotificationPriority,
409    /// Require the recipient to click 確認 to dismiss.
410    #[serde(default)]
411    pub require_ack: bool,
412    /// Surface an OS toast (launches a closed Client App, Action Center
413    /// while locked). Recommended `true` for `notify_user` so a
414    /// non-emergency "your PC is non-compliant" nudge still reaches a user
415    /// whose app is closed.
416    #[serde(default)]
417    pub toast: bool,
418    /// Also send the alert by email, to every address mapped to the
419    /// `notify_groups` (via the `group_contacts` KV, edited on the SPA
420    /// Groups page). Opt-in: defaults to `false`, so an existing alert
421    /// never starts emailing on its own. Requires `notify_groups` to be
422    /// non-empty (there is no per-PC user email) and the backend's
423    /// `[mail]` config to be present; otherwise the email is a logged
424    /// no-op while the in-app/toast notification still fires.
425    #[serde(default)]
426    pub email: bool,
427    /// Notification title (required). May use the same `{…}` placeholders
428    /// as [`body`](Self::body).
429    pub title: String,
430    /// Notification body template. Placeholders: `{pc_id}`, `{name}` (check
431    /// slug), `{label}` (check label, falls back to slug), `{status}`,
432    /// `{detail}` (the check's one-line summary), `{last_logon}` (the PC's
433    /// last sign-in account). Absent → empty body.
434    #[serde(default, skip_serializing_if = "Option::is_none")]
435    pub body: Option<String>,
436}
437
438/// A check status that can trigger a [`CheckAlert`]. Mirrors the
439/// projected `check_status.status` values minus `ok` (alerting on `ok` is
440/// rejected at validation).
441#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
442#[serde(rename_all = "snake_case")]
443pub enum CheckAlertStatus {
444    Warn,
445    Fail,
446    Unknown,
447}
448
449impl CheckAlertStatus {
450    /// The wire string, matching the projected `check_status.status`.
451    pub fn as_str(self) -> &'static str {
452        match self {
453            Self::Warn => "warn",
454            Self::Fail => "fail",
455            Self::Unknown => "unknown",
456        }
457    }
458}
459
460fn default_alert_on() -> Vec<CheckAlertStatus> {
461    vec![CheckAlertStatus::Fail]
462}
463
464fn default_alert_priority() -> crate::ipc::notifications::NotificationPriority {
465    crate::ipc::notifications::NotificationPriority::Warn
466}
467
468fn default_status_field() -> String {
469    "status".to_string()
470}
471
472fn default_detail_field() -> String {
473    "detail".to_string()
474}
475
476fn default_fleet() -> bool {
477    true
478}
479
480fn default_files_field() -> String {
481    "files".to_string()
482}
483
484/// Fallback cap on a collect bundle's total input size when the
485/// manifest's `collect.max_size` is unset. 50 MB (decimal).
486pub const DEFAULT_COLLECT_MAX_SIZE: u64 = 50 * 1_000_000;
487
488/// Manifest sub-section (#219): marks a job as a **file collector** and
489/// carries how the collected bundle presents in the SPA. Parallel to
490/// [`InventoryHint`] / [`CheckHint`] — the block's presence is the
491/// opt-in. The script prints a single JSON object on stdout whose
492/// [`files_field`](CollectHint::files_field) key holds an array of file
493/// paths to bundle (env vars are expanded); the agent zips them and
494/// uploads to `OBJECT_COLLECTIONS`. See [`Manifest::collect`].
495#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
496pub struct CollectHint {
497    /// Operator/end-user-facing title for the collection, shown as the
498    /// bundle's heading on the SPA Collect page (and the Client App row
499    /// when paired with `client:`). Required; validated non-empty.
500    pub name: String,
501    /// Optional one-line description of what the bundle contains.
502    #[serde(default, skip_serializing_if = "Option::is_none")]
503    pub description: Option<String>,
504    /// Human-readable cap on the bundle's total input size
505    /// (`"50MB"`, `"500KB"`, `"1GiB"`). The agent refuses to build a
506    /// bundle whose listed files exceed this. `None` ⇒
507    /// [`DEFAULT_COLLECT_MAX_SIZE`]. Parsed by [`parse_size_bytes`];
508    /// [`Manifest::validate`] rejects an unparseable value at create
509    /// time.
510    ///
511    /// Note: this bounds the **uncompressed** bytes the agent reads off
512    /// disk, not the resulting zip. Text logs compress well, so the
513    /// download is usually much smaller; many tiny files add a little
514    /// per-entry zip overhead. Read it as "how much the agent reads +
515    /// packs", not "the exact download size".
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub max_size: Option<String>,
518    /// Top-level stdout JSON key holding the array of file paths to
519    /// bundle. Defaults to `"files"`.
520    #[serde(default = "default_files_field")]
521    pub files_field: String,
522}
523
524impl CollectHint {
525    /// The effective size cap in bytes — the parsed `max_size` or
526    /// [`DEFAULT_COLLECT_MAX_SIZE`] when unset. Assumes `max_size` (if
527    /// present) already passed [`Manifest::validate`]; falls back to the
528    /// default on a parse error rather than panicking on the fire path.
529    pub fn max_size_bytes(&self) -> u64 {
530        match &self.max_size {
531            Some(s) => parse_size_bytes(s).unwrap_or(DEFAULT_COLLECT_MAX_SIZE),
532            None => DEFAULT_COLLECT_MAX_SIZE,
533        }
534    }
535}
536
537/// Parse a human-readable byte size (`"50MB"`, `"500 KB"`, `"1GiB"`,
538/// `"1024"`). Decimal units (KB/MB/GB) are 1000-based; binary units
539/// (KiB/MiB/GiB) are 1024-based; a bare number (or `B`) is bytes.
540/// Case-insensitive. Shared by `collect.max_size` validation and the
541/// agent's bundle-size enforcement.
542pub fn parse_size_bytes(s: &str) -> Result<u64, String> {
543    let t = s.trim();
544    if t.is_empty() {
545        return Err("size must not be empty".to_string());
546    }
547    let split = t.find(|c: char| !c.is_ascii_digit()).unwrap_or(t.len());
548    let (num_str, unit_raw) = t.split_at(split);
549    if num_str.is_empty() {
550        return Err(format!("size '{s}': missing leading number"));
551    }
552    let num: u64 = num_str
553        .parse()
554        .map_err(|_| format!("size '{s}': bad number '{num_str}'"))?;
555    let mult: u64 = match unit_raw.trim().to_ascii_lowercase().as_str() {
556        "" | "b" => 1,
557        "kb" => 1_000,
558        "mb" => 1_000_000,
559        "gb" => 1_000_000_000,
560        "kib" => 1024,
561        "mib" => 1024 * 1024,
562        "gib" => 1024 * 1024 * 1024,
563        other => {
564            return Err(format!(
565                "size '{s}': unknown unit '{other}' (use B/KB/MB/GB/KiB/MiB/GiB)"
566            ));
567        }
568    };
569    num.checked_mul(mult)
570        .ok_or_else(|| format!("size '{s}': overflow"))
571}
572
573/// Manifest sub-section (#291): marks a job as **user-invokable**
574/// from the Client App and carries how it presents to the end user.
575/// Parallel to [`InventoryHint`] / [`CheckHint`] / `EmitConfig` —
576/// the block's presence is the opt-in (no separate boolean), and its
577/// required fields (`name`, `category`) are enforced by serde at
578/// parse time, so a half-filled catalog entry fails
579/// `kanade job create` instead of rendering a nameless / tab-less row.
580///
581/// The agent maps this 1:1 into the KLP
582/// [`UserInvokableJob`](crate::ipc::jobs::UserInvokableJob) wire shape
583/// that `jobs.list` returns; the Client App renders one row per job in
584/// the tab named by `category`.
585#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
586pub struct ClientHint {
587    /// End-user-facing title for the job row. The operator-internal
588    /// `Manifest::id` slug is rarely what an end user should read, so
589    /// this is required (and validated non-empty by
590    /// [`Manifest::validate`]). Maps to `UserInvokableJob::display_name`.
591    pub name: String,
592    /// Optional one-line subtitle under `name` in the Client App.
593    /// Distinct from the operator-facing top-level
594    /// [`Manifest::description`] — this one is written for the end
595    /// user. Maps to `UserInvokableJob::display_description`.
596    #[serde(default, skip_serializing_if = "Option::is_none")]
597    pub description: Option<String>,
598    /// Which Client App tab the job lives in — a **free-form category
599    /// key** (#792). The Client App renders one tab per distinct key.
600    /// Well-known keys (`software_update`, `troubleshoot`, `catalog`)
601    /// carry built-in tab labels/icons; any other key defines a new tab
602    /// (style it with `category_label` / `category_icon`). Required and
603    /// validated non-empty — without it the agent can't place the job.
604    /// Note: the `software_update` key also drives the agent's
605    /// maintenance / auto-reboot grouping.
606    pub category: String,
607    /// Optional display name for the category's TAB. Set it on (at least
608    /// one of) a custom category's jobs to name the tab; `None` ⇒ a
609    /// built-in default for a well-known key, else the key itself.
610    #[serde(default, skip_serializing_if = "Option::is_none")]
611    pub category_label: Option<String>,
612    /// Optional icon for the category's TAB (lucide name or `data:` URL).
613    /// `None` ⇒ Client App default for the key.
614    #[serde(default, skip_serializing_if = "Option::is_none")]
615    pub category_icon: Option<String>,
616    /// Optional sort order for the TAB; lower sorts first. `None` ⇒
617    /// default (well-known keys keep their familiar order; custom keys
618    /// sort after, then by label).
619    #[serde(default, skip_serializing_if = "Option::is_none")]
620    pub category_order: Option<i64>,
621    /// Optional icon hint for the job ROW — a lucide-react icon name
622    /// or a `data:` URL. `None` ⇒ the Client App falls back to the
623    /// category's icon. Surfaced verbatim in `jobs.list[].icon`.
624    #[serde(default, skip_serializing_if = "Option::is_none")]
625    pub icon: Option<String>,
626    /// Optional visibility scope for the end-user Client App (#816).
627    ///
628    /// `None` ⇒ visible to every PC (current behavior). When set, only
629    /// agents whose `pc_id` / group membership match the [`Target`] list
630    /// the job in `jobs.list` and may run it via KLP `jobs.execute`.
631    ///
632    /// This gates the END-USER surface ONLY. Operators are unaffected:
633    /// `POST /api/exec/{job_id}` (SPA / `kanade exec`) is a separate path
634    /// that never consults `client:`, so an operator can still run the
635    /// job on any PC regardless of `visible_to`. Reuses the schedule
636    /// `Target` shape (`all` / `groups` / `pcs`); a present-but-empty
637    /// target is rejected by [`Manifest::validate`].
638    #[serde(default, skip_serializing_if = "Option::is_none")]
639    pub visible_to: Option<Target>,
640}
641
642/// #720 — one widget on the SPA **Analytics** page: a declarative
643/// aggregation over the `obs_events` table. The backend reads these off
644/// `Manifest::aggregate` (from `BUCKET_JOBS`) at query time and builds
645/// the `json_extract` GROUP BY / time-bucket SQL from these generic
646/// primitives, so an operator can chart any emitted event without a Rust
647/// change. The reference shapes are the attendance dashboards
648/// (presence / app_sample / web_visit), but the same DSL covers logon /
649/// reboot / agent-health trends, etc.
650#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
651pub struct AggregateWidget {
652    /// Tab this widget lives under on the Analytics page. Widgets from
653    /// every job are collected and grouped by this label, so the same
654    /// string across jobs builds one multi-source dashboard. Required.
655    pub dashboard: String,
656    /// Widget heading. Required, validated non-empty.
657    pub title: String,
658    /// Optional one-line subtitle shown muted under the `title` on the
659    /// Analytics page — room for a unit, a caveat, or what the number
660    /// means ("samples × 2 min", "Security 4624 only"). Rejected if
661    /// present-but-blank.
662    #[serde(default, skip_serializing_if = "Option::is_none")]
663    pub description: Option<String>,
664    /// Optional sort weight (#743). Once the order-aware sort lands (PR2)
665    /// widgets render in `(order, dashboard, title)` order, so a lower
666    /// `order` pulls a widget — and its tab — earlier; equal/absent `order`
667    /// falls back to the alphabetical `(dashboard, title)` ordering. Treated
668    /// as `0` when unset, so a fleet with no `order` anywhere stays purely
669    /// alphabetical (today's behaviour); negatives are allowed to pin
670    /// something first. (This field only carries the value; the backend
671    /// applies it.)
672    #[serde(default, skip_serializing_if = "Option::is_none")]
673    pub order: Option<i32>,
674    /// `pc` rolls up a single selected PC; `fleet` rolls up all PCs
675    /// (and unlocks `group_by: pc_id` to rank PCs against each other).
676    /// Defaults to `pc`.
677    #[serde(default)]
678    pub scope: AggregateScope,
679    /// `obs_events.kind` this widget reads (e.g. `app_sample`,
680    /// `presence`, `unexpected_shutdown`). Required.
681    pub kind: String,
682    /// Optional `obs_events.source` filter, when one `kind` is emitted by
683    /// more than one collector.
684    #[serde(default, skip_serializing_if = "Option::is_none")]
685    pub source: Option<String>,
686    /// How to roll the matching events up. See [`AggregateAgg`].
687    pub agg: AggregateAgg,
688    /// Dotted JSON path (no `$.` prefix) to group by for `agg: count` /
689    /// `sum` — e.g. `foreground.app`. The literal `pc_id` is special:
690    /// it groups by the `pc_id` column (fleet ranking), not a payload
691    /// field. Omit for a single total. Required when `agg: sum` needs a
692    /// breakdown; for `agg: count` omitting it yields the grand total.
693    #[serde(default, skip_serializing_if = "Option::is_none")]
694    pub group_by: Option<String>,
695    /// Dotted JSON path to a boolean for `agg: ratio` (e.g. `active`):
696    /// the widget reports `true_count / total`. Required when `agg: ratio`.
697    #[serde(default, skip_serializing_if = "Option::is_none")]
698    pub bool_path: Option<String>,
699    /// Dotted JSON path to a number for `agg: sum`. Required when `agg: sum`.
700    #[serde(default, skip_serializing_if = "Option::is_none")]
701    pub value_path: Option<String>,
702    /// Optional value transform applied before grouping. Currently only
703    /// `host` (parse a URL down to its host) — used by the top-sites
704    /// widget, where SQLite can't parse a URL so the backend does it in
705    /// Rust. See [`AggregateTransform`].
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub transform: Option<AggregateTransform>,
708    /// Optional sampling cadence in minutes. When set, a `count` is also
709    /// reported as estimated time (`count × sample_minutes`) — e.g. a
710    /// 2-minute app sampler turns 11 samples into ~22 minutes. Must be ≥ 1.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    #[schemars(range(min = 1))]
713    pub sample_minutes: Option<u32>,
714    /// Grouped values to drop from the rollup (e.g. `["LockApp"]` so the
715    /// lock screen doesn't top the app ranking). Empty by default.
716    #[serde(default, skip_serializing_if = "Vec::is_empty")]
717    pub exclude: Vec<String>,
718    /// Optional time bucketing — `hour` buckets events by local
719    /// hour-of-day for a `timeline` render. See [`AggregateTimeBucket`].
720    #[serde(default, skip_serializing_if = "Option::is_none")]
721    pub time_bucket: Option<AggregateTimeBucket>,
722    /// Top-N cap for grouped renders (`bar`). Defaults to 10 when unset.
723    #[serde(default, skip_serializing_if = "Option::is_none")]
724    #[schemars(range(min = 1))]
725    pub limit: Option<u32>,
726    /// Which widget the SPA draws. See [`AggregateRender`].
727    pub render: AggregateRender,
728}
729
730/// Per-PC vs fleet-wide rollup for an [`AggregateWidget`].
731#[derive(
732    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
733)]
734#[serde(rename_all = "lowercase")]
735#[non_exhaustive]
736pub enum AggregateScope {
737    /// Roll up the single PC the operator selected. The default.
738    #[default]
739    Pc,
740    /// Roll up across every PC. Unlocks `group_by: pc_id`.
741    Fleet,
742    /// #492 forward-compat catch-all — a Manifest is read fleet-wide, so
743    /// an older reader must tolerate a future variant rather than failing
744    /// to decode the whole job. The backend skips an `Unknown` widget.
745    #[serde(other)]
746    Unknown,
747}
748
749/// The rollup function for an [`AggregateWidget`].
750#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
751#[serde(rename_all = "lowercase")]
752#[non_exhaustive]
753pub enum AggregateAgg {
754    /// Row count, optionally grouped (`group_by`) and time-estimated
755    /// (`sample_minutes`).
756    Count,
757    /// `true_count / total` over `bool_path`.
758    Ratio,
759    /// Sum of `value_path`, optionally grouped.
760    Sum,
761    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
762    #[serde(other)]
763    Unknown,
764}
765
766/// Optional pre-grouping value transform for an [`AggregateWidget`].
767#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
768#[serde(rename_all = "lowercase")]
769#[non_exhaustive]
770pub enum AggregateTransform {
771    /// Parse the grouped value as a URL and keep only its host.
772    Host,
773    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
774    #[serde(other)]
775    Unknown,
776}
777
778/// Time bucketing for an [`AggregateWidget`] (drives a `timeline`).
779#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
780#[serde(rename_all = "lowercase")]
781#[non_exhaustive]
782pub enum AggregateTimeBucket {
783    /// Bucket by local hour-of-day (0–23), summed over the window.
784    Hour,
785    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
786    #[serde(other)]
787    Unknown,
788}
789
790/// Which visual the SPA renders an [`AggregateWidget`] as.
791#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
792#[serde(rename_all = "lowercase")]
793#[non_exhaustive]
794pub enum AggregateRender {
795    /// Ranked horizontal bars (a grouped `count` / `sum`).
796    Bar,
797    /// A single ratio dial (`agg: ratio`).
798    Gauge,
799    /// 24-hour activity strip (`time_bucket: hour`).
800    Timeline,
801    /// A single headline number (an ungrouped total).
802    Stat,
803    /// #492 forward-compat catch-all (see [`AggregateScope::Unknown`]).
804    #[serde(other)]
805    Unknown,
806}
807
808/// True if `p` is a well-formed dotted JSON path of `[A-Za-z0-9_]`
809/// segments joined by single dots — the shape safe to bind into
810/// `json_extract(payload, '$.' || ?)`. The charset blocks injection; the
811/// segment check additionally rejects `"."`, `".foo"`, `"foo."`,
812/// `"foo..bar"`, which would pass the charset but produce a malformed
813/// `$.` path that errors at query time. Accepts `pc_id`, `foreground.app`,
814/// `active`, etc.
815fn is_valid_json_path(p: &str) -> bool {
816    !p.is_empty()
817        && p.split('.').all(|seg| {
818            !seg.is_empty() && seg.chars().all(|c| c.is_ascii_alphanumeric() || c == '_')
819        })
820}
821
822/// Per-widget validation for a list of [`AggregateWidget`]s — shared by
823/// the `aggregate:` job hint ([`Manifest::validate`]) and the standalone
824/// [`View`] resource (#743) so the two can't diverge. `field` names the
825/// containing key for error messages (`"aggregate"` or `"widgets"`).
826///
827/// Enforces: non-empty list; non-empty dashboard/title/kind; a
828/// blank-when-set `source`; rejection of any #492 `Unknown` enum
829/// (an operator typo at create time); safe dotted JSON paths; the value
830/// path each `agg` needs (and rejection of mis-paired ones); `pc_id`
831/// grouping only in `fleet` scope; `transform`/`limit`/`exclude` only with
832/// a `group_by`; positive `limit`/`sample_minutes`; `gauge`⇔`ratio`; and
833/// `timeline`⇔`time_bucket`.
834pub fn validate_aggregate_widgets(widgets: &[AggregateWidget], field: &str) -> Result<(), String> {
835    if widgets.is_empty() {
836        return Err(format!(
837            "`{field}:` must list at least one widget when present"
838        ));
839    }
840    for (i, w) in widgets.iter().enumerate() {
841        let at = format!("{field}[{i}]");
842        for (label, value) in [
843            ("dashboard", &w.dashboard),
844            ("title", &w.title),
845            ("kind", &w.kind),
846        ] {
847            if value.trim().is_empty() {
848                return Err(format!("{at}.{label} must not be empty"));
849            }
850        }
851        // A present-but-blank `source` is a no-op filter — reject like the
852        // other blank-when-set guards.
853        if let Some(source) = &w.source {
854            if source.trim().is_empty() {
855                return Err(format!("{at}.source must not be empty when set"));
856            }
857        }
858        // A present-but-blank `description` renders an empty muted line —
859        // reject it so the subtitle only shows when it says something.
860        if let Some(description) = &w.description {
861            if description.trim().is_empty() {
862                return Err(format!("{at}.description must not be empty when set"));
863            }
864        }
865        // Reject values that fell through to the #492 `Unknown` catch-all:
866        // at create time on the current version that's an operator typo. (A
867        // genuinely-future variant only reaches an older reader via a stored
868        // resource, which is never re-validated, so forward-compat holds.)
869        if w.scope == AggregateScope::Unknown {
870            return Err(format!("{at}.scope is not a known value (pc | fleet)"));
871        }
872        if w.agg == AggregateAgg::Unknown {
873            return Err(format!(
874                "{at}.agg is not a known value (count | ratio | sum)"
875            ));
876        }
877        if w.render == AggregateRender::Unknown {
878            return Err(format!(
879                "{at}.render is not a known value (bar | gauge | timeline | stat)"
880            ));
881        }
882        if w.transform == Some(AggregateTransform::Unknown) {
883            return Err(format!("{at}.transform is not a known value (host)"));
884        }
885        if w.time_bucket == Some(AggregateTimeBucket::Unknown) {
886            return Err(format!("{at}.time_bucket is not a known value (hour)"));
887        }
888        for (label, path) in [
889            ("group_by", &w.group_by),
890            ("bool_path", &w.bool_path),
891            ("value_path", &w.value_path),
892        ] {
893            if let Some(p) = path {
894                if !is_valid_json_path(p) {
895                    return Err(format!(
896                        "{at}.{label} '{p}' must be a dotted JSON path of [A-Za-z0-9_] segments"
897                    ));
898                }
899            }
900        }
901        // Each agg uses exactly one value path; reject a mis-paired path so
902        // a typo fails at create rather than being ignored.
903        match w.agg {
904            // count: grouped → ranking, ungrouped → grand total.
905            AggregateAgg::Count => {
906                for (label, path) in [("bool_path", &w.bool_path), ("value_path", &w.value_path)] {
907                    if path.is_some() {
908                        return Err(format!("{at}.agg=count does not use `{label}`"));
909                    }
910                }
911            }
912            AggregateAgg::Ratio => {
913                if w.bool_path.is_none() {
914                    return Err(format!("{at}.agg=ratio requires `bool_path`"));
915                }
916                if w.value_path.is_some() {
917                    return Err(format!("{at}.agg=ratio does not use `value_path`"));
918                }
919            }
920            AggregateAgg::Sum => {
921                if w.value_path.is_none() {
922                    return Err(format!("{at}.agg=sum requires `value_path`"));
923                }
924                if w.bool_path.is_some() {
925                    return Err(format!("{at}.agg=sum does not use `bool_path`"));
926                }
927            }
928            // Rejected above; arm exists only for exhaustiveness.
929            AggregateAgg::Unknown => {}
930        }
931        // Ranking PCs against each other only means something across the
932        // fleet — within one PC it's a single bar.
933        if w.group_by.as_deref() == Some("pc_id") && w.scope != AggregateScope::Fleet {
934            return Err(format!(
935                "{at}.group_by: pc_id is only valid with scope: fleet"
936            ));
937        }
938        // `transform` rewrites the grouped PAYLOAD value (URL→host); it's
939        // meaningless on a `pc_id` grouping (the pc_id column, not a payload
940        // field), so reject the combo at create time.
941        if w.transform.is_some() && w.group_by.as_deref() == Some("pc_id") {
942            return Err(format!("{at}.transform is not valid with group_by: pc_id"));
943        }
944        // limit / transform / exclude all operate on grouped values, so
945        // without a `group_by` they're silent no-ops — reject.
946        if w.group_by.is_none() {
947            if w.limit.is_some() {
948                return Err(format!("{at}.limit requires `group_by`"));
949            }
950            if w.transform.is_some() {
951                return Err(format!("{at}.transform requires `group_by`"));
952            }
953            if !w.exclude.is_empty() {
954                return Err(format!("{at}.exclude requires `group_by`"));
955            }
956        }
957        if w.limit == Some(0) {
958            return Err(format!("{at}.limit must be > 0"));
959        }
960        if w.sample_minutes == Some(0) {
961            return Err(format!("{at}.sample_minutes must be > 0"));
962        }
963        for ex in &w.exclude {
964            if ex.trim().is_empty() {
965                return Err(format!("{at}.exclude must not contain empty entries"));
966            }
967        }
968        // A gauge draws a single ratio dial — only meaningful for agg: ratio.
969        if w.render == AggregateRender::Gauge && w.agg != AggregateAgg::Ratio {
970            return Err(format!("{at}.render=gauge is only valid with agg: ratio"));
971        }
972        // A timeline needs a bucket; a bucket on any other render is a no-op
973        // that signals operator confusion — reject both.
974        match (w.render, &w.time_bucket) {
975            (AggregateRender::Timeline, None) => {
976                return Err(format!("{at}.render=timeline requires `time_bucket`"));
977            }
978            (r, Some(_)) if r != AggregateRender::Timeline => {
979                return Err(format!(
980                    "{at}.time_bucket is only valid with render: timeline"
981                ));
982            }
983            _ => {}
984        }
985    }
986    Ok(())
987}
988
989/// A standalone declarative read/aggregation for the Analytics page (#743).
990///
991/// A **view** aggregates stored fleet data (`obs_events`, …) without an
992/// `execute` or a schedule — unlike a [`Manifest`] it only declares
993/// [`AggregateWidget`]s. (The first line is concise on purpose: `schemars`
994/// uses it as the generated schema's `title`.) The backend reads views from
995/// `BUCKET_VIEWS` at
996/// query time and merges their widgets with the co-located `aggregate:`
997/// hints on jobs, so a cross-cutting dashboard (one that charts events
998/// emitted by several other jobs / the agent) has a home that doesn't need
999/// a noop job carrier. Stored JSON in `BUCKET_VIEWS`, keyed by `id`.
1000#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1001pub struct View {
1002    /// Stable identifier (the KV key). Required, validated non-empty.
1003    pub id: String,
1004    /// Optional human description shown on the Views admin page.
1005    #[serde(default, skip_serializing_if = "Option::is_none")]
1006    pub description: Option<String>,
1007    /// The widgets this view contributes to the Analytics page.
1008    pub widgets: Vec<AggregateWidget>,
1009    /// Free-form operator taxonomy (same role as [`Manifest::tags`]).
1010    #[serde(default, skip_serializing_if = "Vec::is_empty")]
1011    pub tags: Vec<String>,
1012    /// GitOps provenance (#678), stamped by `kanade view create` from the
1013    /// source YAML's Git context — same as [`Manifest::origin`].
1014    #[serde(default, skip_serializing_if = "Option::is_none")]
1015    pub origin: Option<RepoOrigin>,
1016}
1017
1018/// True if `id` is a safe resource identifier — non-empty and only
1019/// `[A-Za-z0-9._-]`. A view `id` becomes a NATS KV key *and* a URL path
1020/// segment (`/api/views/{id}`), so this blocks `/`, `..`, whitespace and
1021/// other characters that would break the KV key or let a CLI arg wander
1022/// the URL space. (#743 / #744 follow-up — a deliberately small charset
1023/// rather than the looser set NATS technically allows.)
1024pub fn is_valid_resource_id(id: &str) -> bool {
1025    !id.is_empty()
1026        && id
1027            .chars()
1028            .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-')
1029}
1030
1031impl View {
1032    pub fn validate(&self) -> Result<(), String> {
1033        if !is_valid_resource_id(self.id.trim()) {
1034            return Err(
1035                "view.id must be non-empty and only [A-Za-z0-9._-] (it's a KV key + URL segment)"
1036                    .to_string(),
1037            );
1038        }
1039        validate_aggregate_widgets(&self.widgets, "widgets")?;
1040        for tag in &self.tags {
1041            if tag.trim().is_empty() {
1042                return Err("tags must not contain empty entries".to_string());
1043            }
1044        }
1045        Ok(())
1046    }
1047}
1048
1049/// Issue #246 — `emit:` manifest block for jobs whose stdout is
1050/// NDJSON observability events (one `ObsEvent` per line). Parallel
1051/// to `inventory:` but for the append-only timeline pipeline; see
1052/// `Manifest::emit` for the full contract.
1053#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1054pub struct EmitConfig {
1055    /// What kind of payload the agent should expect on stdout. Only
1056    /// `events` is defined today (parses each non-empty line as
1057    /// `ObsEvent` and publishes on `obs.<pc_id>`); future variants
1058    /// (e.g. metrics streams, structured trace events) plug in here.
1059    #[serde(rename = "type")]
1060    pub kind: EmitKind,
1061    /// Operator hint for where the script keeps its own state — the
1062    /// watermark file the PowerShell / sh body reads + writes
1063    /// between runs so it only emits NEW events since the last
1064    /// poll. The agent doesn't read this; it's documentation that
1065    /// the SPA (and `kanade job edit`) can surface to operators
1066    /// reviewing the manifest. Optional; the script is allowed to
1067    /// keep state anywhere (registry, env, etc.) — the field's
1068    /// presence makes the convention discoverable.
1069    #[serde(default, skip_serializing_if = "Option::is_none")]
1070    pub watermark_path: Option<String>,
1071}
1072
1073/// `emit.type` enum. Lowercase serde so manifests read
1074/// `type: events` rather than `Events`.
1075#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
1076#[serde(rename_all = "lowercase")]
1077pub enum EmitKind {
1078    /// Per-line `ObsEvent` JSON. Agent parses + publishes on
1079    /// `obs.<pc_id>`, drops the stdout from the resulting
1080    /// `ExecResult`.
1081    Events,
1082}
1083
1084/// v0.31 / #40: declarative "flatten this JSON array into a real
1085/// SQLite table" spec on an inventory manifest. The projector
1086/// creates the table on first registration (CREATE TABLE IF NOT
1087/// EXISTS + indexes) and writes a row per element of
1088/// `payload[field]` on every result, scoped by (pc_id, job_id) so
1089/// each PC's rows replace cleanly without a per-PC schema.
1090#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1091pub struct ExplodeSpec {
1092    /// JSON array key under the payload to explode. E.g. `"apps"`
1093    /// for `payload: { apps: [{...}, {...}] }`.
1094    pub field: String,
1095    /// Derived SQLite table name. Operators choose this — pick
1096    /// something namespaced + stable (`inventory_sw_apps`, not
1097    /// `apps`) so multiple inventory manifests don't collide on a
1098    /// generic name.
1099    pub table: String,
1100    /// Element-level fields that uniquely identify a row inside one
1101    /// PC's payload. The full PK is `(pc_id, job_id) + these
1102    /// columns`. Required — operators must think about uniqueness
1103    /// (e.g. `["name", "source"]` for installed apps because the
1104    /// same name appears in multiple uninstall hives).
1105    ///
1106    /// v0.31 / #41: same tuple drives history identity. When
1107    /// `track_history` is on, the projector serialises these
1108    /// fields' values into `inventory_history.identity_json` for
1109    /// every change event, so queries like "every PC that ever
1110    /// installed Chrome (any source)" filter on identity_json
1111    /// content without a per-manifest schema.
1112    pub primary_key: Vec<String>,
1113    /// Per-element fields that become columns in the derived table.
1114    pub columns: Vec<ExplodeColumn>,
1115    /// v0.31 / #41: when true (default false), the projector
1116    /// diffs each PC's incoming payload against the prior rows
1117    /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
1118    /// replace, and writes added / removed / changed events into
1119    /// `inventory_history`. Lets operators answer time-dimension
1120    /// questions ("when did Chrome 120 first appear on PC X?",
1121    /// "what's the Win 11 23H2 rollout curve") without storing
1122    /// per-scan snapshots. Off by default so operators opt in
1123    /// per-spec — history has a real storage cost on long-lived
1124    /// deployments (mitigated by the 90-day default retention
1125    /// sweeper, see `cleanup` module).
1126    #[serde(default)]
1127    pub track_history: bool,
1128}
1129
1130/// One column in an [`ExplodeSpec`]'s derived table.
1131#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1132pub struct ExplodeColumn {
1133    /// JSON key under each array element. Becomes the column name
1134    /// in the derived SQLite table — we don't rename.
1135    pub field: String,
1136    /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
1137    /// Storage maps directly via `sqlx::query.bind(...)`; type
1138    /// mismatches at INSERT-time fail loudly rather than silently
1139    /// dropping the row.
1140    #[serde(default, skip_serializing_if = "Option::is_none")]
1141    #[serde(rename = "type")]
1142    pub kind: Option<String>,
1143    /// When true, the projector creates a `CREATE INDEX` on this
1144    /// column at table-creation time. Boost for the common-filter
1145    /// columns (`name`, `version`) — operators mark them
1146    /// explicitly, the projector won't guess.
1147    #[serde(default)]
1148    pub index: bool,
1149}
1150
1151#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1152pub struct DisplayField {
1153    /// Top-level key in the stdout JSON.
1154    pub field: String,
1155    /// Human-readable column header.
1156    pub label: String,
1157    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
1158    /// or `"table"` (#39). Defaults to plain text rendering on the
1159    /// SPA side. `"table"` expects the field's value to be a JSON
1160    /// array of objects and renders a nested sub-table on the
1161    /// per-PC detail page using `columns` as the schema; the fleet
1162    /// summary view falls back to showing the row count for
1163    /// `"table"` cells so the wide list stays compact.
1164    #[serde(default, skip_serializing_if = "Option::is_none")]
1165    #[serde(rename = "type")]
1166    pub kind: Option<String>,
1167    /// v0.30 / #39: when `kind == "table"`, the SPA renders the
1168    /// field's value (an array of objects like
1169    /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
1170    /// sub-table using these columns. Each column is itself a
1171    /// `DisplayField`, so the nested cells reuse the same render
1172    /// hints (`bytes`, `number`, `timestamp`) — no parallel format
1173    /// pipeline. Ignored for any other `kind`.
1174    #[serde(default, skip_serializing_if = "Option::is_none")]
1175    pub columns: Option<Vec<DisplayField>>,
1176}
1177
1178#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1179pub struct Rollout {
1180    #[serde(default)]
1181    pub strategy: RolloutStrategy,
1182    pub waves: Vec<Wave>,
1183}
1184
1185#[derive(
1186    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
1187)]
1188#[serde(rename_all = "lowercase")]
1189pub enum RolloutStrategy {
1190    #[default]
1191    Wave,
1192}
1193
1194#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1195pub struct Wave {
1196    pub group: String,
1197    /// humantime delay measured from the deploy's publish time. wave[0]
1198    /// typically has "0s"; subsequent waves use minutes / hours.
1199    pub delay: String,
1200}
1201
1202#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
1203pub struct Target {
1204    #[serde(default)]
1205    pub groups: Vec<String>,
1206    #[serde(default)]
1207    pub pcs: Vec<String>,
1208    #[serde(default)]
1209    pub all: bool,
1210}
1211
1212impl Target {
1213    /// At least one of all / groups / pcs is set.
1214    pub fn is_specified(&self) -> bool {
1215        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
1216    }
1217
1218    /// Whether a PC (its `pc_id` + group membership) falls in this target:
1219    /// `all`, or the pc is listed, or it belongs to a listed group. Used
1220    /// by the agent to scope `client.visible_to` (#816). An unspecified
1221    /// target matches nobody (callers should treat "no target" as
1222    /// "visible to all" before calling this).
1223    pub fn matches(&self, pc_id: &str, groups: &[String]) -> bool {
1224        self.all
1225            || self.pcs.iter().any(|p| p == pc_id)
1226            || self.groups.iter().any(|g| groups.contains(g))
1227    }
1228}
1229
1230#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
1231pub struct Execute {
1232    pub shell: ExecuteShell,
1233    /// Inline script body. Mutually exclusive with [`script_file`]
1234    /// and [`script_object`]; exactly one of the three must be set
1235    /// (enforced by [`Execute::validate_script_source`] at the
1236    /// write-side parse boundaries — `kanade job create` and
1237    /// `POST /api/jobs`).
1238    ///
1239    /// Empty string is treated as **unset** so operators can swap
1240    /// to a `script_file:` / `script_object:` alternative just by
1241    /// commenting out the body, without having to also drop the
1242    /// `script:` key entirely.
1243    ///
1244    /// [`script_file`]: Self::script_file
1245    /// [`script_object`]: Self::script_object
1246    #[serde(default, skip_serializing_if = "Option::is_none")]
1247    pub script: Option<String>,
1248    /// Repo-local file path resolved by the operator-side CLI at
1249    /// `kanade job create` time. The CLI reads the file, slots its
1250    /// contents into `script`, and clears this field before
1251    /// POSTing — so the backend / agents never see `script_file`
1252    /// in stored manifests. SPEC §2.4.1.
1253    ///
1254    /// Resolver lands in a follow-up PR
1255    /// (yukimemi/kanade#210); today this field passes parse-time
1256    /// validation but the operator-side CLI bails with "not yet
1257    /// implemented" until the resolver ships, so manifests that
1258    /// reach the backend with `script_file` set are treated as a
1259    /// schema-bug.
1260    #[serde(default, skip_serializing_if = "Option::is_none")]
1261    pub script_file: Option<String>,
1262    /// Object Store reference (`<name>/<version>`) into the
1263    /// `scripts` bucket (`OBJECT_SCRIPTS`). Agents fetch the body
1264    /// at Execute time via `/api/script-objects/{name}/{version}`
1265    /// and cache it locally. SPEC §2.4.1.
1266    ///
1267    /// Fully wired (#210/#211): the backend resolves the digest at
1268    /// exec submission (`api::exec::resolve_script_source`), the agent
1269    /// fetches + sha-verifies + caches the body (`script_cache`), and
1270    /// `kanade script` CRUDs the store. Unlike `script_file:` (inlined
1271    /// CLI-side, git-managed), this keeps the body in versioned,
1272    /// digest-pinned object storage — the ops-managed counterpart.
1273    #[serde(default, skip_serializing_if = "Option::is_none")]
1274    pub script_object: Option<String>,
1275    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
1276    /// — represents how long this script reasonably takes to run.
1277    pub timeout: String,
1278    /// Token + session combination the agent uses to launch the
1279    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
1280    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
1281    #[serde(default)]
1282    pub run_as: RunAs,
1283    /// Working directory for the spawned child (v0.21.1). When
1284    /// unset, the child inherits the agent's cwd — on Windows that
1285    /// means `%SystemRoot%\System32` for the prod service, which is
1286    /// almost never what operators actually want. Use an absolute
1287    /// path; relative paths are passed through to the OS verbatim.
1288    /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
1289    /// you'd want `%USERPROFILE%` (but expansion happens in the
1290    /// shell, so write `$env:USERPROFILE` for PowerShell, or set
1291    /// it via teravars before `kanade job create`).
1292    #[serde(default, skip_serializing_if = "Option::is_none")]
1293    pub cwd: Option<String>,
1294}
1295
1296impl Execute {
1297    /// Treat an empty `script:` body as "intentionally unset". Operators
1298    /// commenting out a block-scalar tend to leave the key behind, and
1299    /// failing the validator on `script: ""` would surprise them.
1300    fn has_inline_script(&self) -> bool {
1301        matches!(&self.script, Some(s) if !s.is_empty())
1302    }
1303
1304    /// Enforce that exactly one of `script` / `script_file` /
1305    /// `script_object` is set. Called at the write-side parse
1306    /// boundaries (CLI `kanade job create` + backend
1307    /// `POST /api/jobs`) so ambiguous YAML is rejected before it
1308    /// reaches the JOBS KV. Read paths (projector, agent
1309    /// scheduler, list endpoints) skip this check — they only ever
1310    /// see what the write path already validated.
1311    pub fn validate_script_source(&self) -> Result<(), String> {
1312        let inline = self.has_inline_script();
1313        let file = self.script_file.is_some();
1314        let obj = self.script_object.is_some();
1315        let set = [inline, file, obj].into_iter().filter(|b| *b).count();
1316        match set {
1317            1 => Ok(()),
1318            0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
1319            _ => Err(format!(
1320                "execute: only one of `script` / `script_file` / `script_object` may be set \
1321                 (got script={inline}, script_file={file}, script_object={obj})"
1322            )),
1323        }
1324    }
1325}
1326
1327impl Manifest {
1328    /// Cross-field semantic checks that don't fit into pure serde
1329    /// derive. Currently delegates to
1330    /// [`Execute::validate_script_source`] — see that method's
1331    /// docs for the rationale on which call sites should run this.
1332    pub fn validate(&self) -> Result<(), String> {
1333        self.execute.validate_script_source()?;
1334        // Stdout-format compatibility. `inventory:` and `check:` both
1335        // consume the SAME single JSON object — they COMPOSE: a check
1336        // can extract `status`/`detail` for the Health tab while the
1337        // projector explodes the rest into SPA sub-tables. `emit:` is
1338        // different — its stdout is NDJSON and the agent omits it from
1339        // the result entirely — so it can't be paired with either.
1340        if self.emit.is_some() && (self.inventory.is_some() || self.check.is_some()) {
1341            return Err(
1342                "`emit:` is incompatible with `inventory:` / `check:` — emit's stdout is NDJSON \
1343                 timeline events (and omitted from the result), while inventory/check read a \
1344                 single JSON object from stdout"
1345                    .to_string(),
1346            );
1347        }
1348        // `collect:` consumes stdout for its OWN contract (a JSON object
1349        // carrying a `files` array), so unlike the inventory+check pair it
1350        // can't share stdout with another stdout-reading hint. It composes
1351        // only with `client:` (which doesn't touch stdout).
1352        if self.collect.is_some()
1353            && (self.inventory.is_some() || self.check.is_some() || self.emit.is_some())
1354        {
1355            return Err(
1356                "`collect:` is incompatible with `inventory:` / `check:` / `emit:` — collect \
1357                 reads its own `files` JSON object from stdout. (It composes with `client:`.)"
1358                    .to_string(),
1359            );
1360        }
1361        // A check's `name` is the Health-tab row id (React key); the
1362        // field names tell the agent where to read status/detail.
1363        // An empty value is an invisible runtime bug, and the serde
1364        // defaults don't guard an operator who writes `status_field:
1365        // ""` explicitly — reject all three here.
1366        if let Some(check) = &self.check {
1367            for (label, value) in [
1368                ("check.name", &check.name),
1369                ("check.status_field", &check.status_field),
1370                ("check.detail_field", &check.detail_field),
1371            ] {
1372                if value.trim().is_empty() {
1373                    return Err(format!("{label} must not be empty"));
1374                }
1375            }
1376            // A present-but-blank `troubleshoot` is a broken
1377            // remediation job id (the "修復する" button would target
1378            // an empty manifest id) — reject it too.
1379            if let Some(troubleshoot) = &check.troubleshoot {
1380                if troubleshoot.trim().is_empty() {
1381                    return Err("check.troubleshoot must not be empty when set".to_string());
1382                }
1383            }
1384            // A present-but-blank `label` would render an empty row
1385            // title on the Health tab / Compliance page — reject it so
1386            // the slug fallback only ever kicks in when label is absent.
1387            if let Some(label) = &check.label {
1388                if label.trim().is_empty() {
1389                    return Err("check.label must not be empty when set".to_string());
1390                }
1391            }
1392            if let Some(alert) = &check.alert {
1393                // An alert that names no recipient is a silent no-op.
1394                if !alert.notify_user && alert.notify_groups.is_empty() {
1395                    return Err("check.alert must set notify_user and/or notify_groups".to_string());
1396                }
1397                if alert.title.trim().is_empty() {
1398                    return Err("check.alert.title must not be empty".to_string());
1399                }
1400                // `on: []` would never fire; an empty group name resolves to
1401                // a malformed `notifications.group.` subject.
1402                if alert.on.is_empty() {
1403                    return Err("check.alert.on must list at least one status".to_string());
1404                }
1405                if alert.notify_groups.iter().any(|g| g.trim().is_empty()) {
1406                    return Err("check.alert.notify_groups must not contain blanks".to_string());
1407                }
1408                // Email is addressed via group_contacts (group → email), so
1409                // there must be a group to map. notify_user has no email.
1410                if alert.email && alert.notify_groups.is_empty() {
1411                    return Err(
1412                        "check.alert.email requires notify_groups (email is addressed per group, not per user)"
1413                            .to_string(),
1414                    );
1415                }
1416                // The alert rides the `check_status` projection, which only
1417                // runs for `fleet: true`.
1418                if !check.fleet {
1419                    return Err(
1420                        "check.alert requires fleet: true (the alert rides the compliance projection)"
1421                            .to_string(),
1422                    );
1423                }
1424            }
1425        }
1426        // #291: a `client:` job is rendered in the Client App's
1427        // catalog (`jobs.list` → `jobs.execute`). serde already makes
1428        // `name` + `category` required at parse time; the only gap is
1429        // a present-but-blank `name`, which would render an empty row
1430        // title — reject it like the other display-id fields.
1431        if let Some(client) = &self.client {
1432            if client.name.trim().is_empty() {
1433                return Err("client.name must not be empty".to_string());
1434            }
1435            // #792: category is a free-form key now, so a blank one would
1436            // group the job under an empty tab — reject it like `name`.
1437            if client.category.trim().is_empty() {
1438                return Err("client.category must not be empty".to_string());
1439            }
1440            // Optional display fields, when present, must be
1441            // meaningful: a blank `description` renders an empty
1442            // subtitle and a blank `icon` is a dangling lucide name.
1443            // Same present-but-blank guard the `check:` block applies
1444            // to its optional `troubleshoot` id.
1445            for (label, value) in [
1446                ("client.description", &client.description),
1447                ("client.icon", &client.icon),
1448                ("client.category_label", &client.category_label),
1449                ("client.category_icon", &client.category_icon),
1450            ] {
1451                if let Some(v) = value {
1452                    if v.trim().is_empty() {
1453                        return Err(format!("{label} must not be empty when set"));
1454                    }
1455                }
1456            }
1457            // #816: a present-but-empty `visible_to` (no all/groups/pcs)
1458            // would hide the job from everyone in the Client App — almost
1459            // certainly a mistake. Require at least one selector; omit the
1460            // whole block to mean "visible to all".
1461            if let Some(t) = &client.visible_to {
1462                if !t.is_specified() {
1463                    return Err(
1464                        "client.visible_to must set at least one of all / groups / pcs (omit it for all PCs)"
1465                            .to_string(),
1466                    );
1467                }
1468            }
1469        }
1470        // #219: a `collect:` job's `name` heads the bundle on the SPA
1471        // Collect page (and the Client App row when paired with
1472        // `client:`), `files_field` tells the agent where to read the
1473        // path list, and `max_size` must be a parseable size so a typo
1474        // is caught at create time rather than silently capping the
1475        // bundle at the default on the fire path.
1476        if let Some(collect) = &self.collect {
1477            if collect.name.trim().is_empty() {
1478                return Err("collect.name must not be empty".to_string());
1479            }
1480            if collect.files_field.trim().is_empty() {
1481                return Err("collect.files_field must not be empty".to_string());
1482            }
1483            if let Some(description) = &collect.description {
1484                if description.trim().is_empty() {
1485                    return Err("collect.description must not be empty when set".to_string());
1486                }
1487            }
1488            if let Some(max_size) = &collect.max_size {
1489                parse_size_bytes(max_size).map_err(|e| format!("collect.max_size: {e}"))?;
1490            }
1491        }
1492        // #720/#743: `aggregate:` is a pure read-spec (it never touches
1493        // stdout and is never sent to an agent), so it composes with every
1494        // other hint. The per-widget rules are shared with the standalone
1495        // `view` resource — see [`validate_aggregate_widgets`].
1496        if let Some(widgets) = &self.aggregate {
1497            validate_aggregate_widgets(widgets, "aggregate")?;
1498        }
1499        // A blank / whitespace-only tag is an invisible operator typo
1500        // that would render an empty filter chip on the Jobs page —
1501        // reject it like the other present-but-blank display fields.
1502        for tag in &self.tags {
1503            if tag.trim().is_empty() {
1504                return Err("tags must not contain empty entries".to_string());
1505            }
1506        }
1507        Ok(())
1508    }
1509}
1510
1511#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
1512#[serde(rename_all = "lowercase")]
1513pub enum ExecuteShell {
1514    Powershell,
1515    Cmd,
1516}
1517
1518impl From<ExecuteShell> for Shell {
1519    fn from(s: ExecuteShell) -> Self {
1520        match s {
1521            ExecuteShell::Powershell => Shell::Powershell,
1522            ExecuteShell::Cmd => Shell::Cmd,
1523        }
1524    }
1525}
1526
1527#[cfg(test)]
1528mod tests {
1529    use super::*;
1530
1531    #[test]
1532    fn inventory_payload_extracts_fenced_block() {
1533        // Readable message + fenced JSON → only the JSON, trimmed.
1534        let stdout = "Wi-Fi 設定を適用しました。\n\
1535            #KANADE-INVENTORY-BEGIN\n\
1536            {\"applied\": true}\n\
1537            #KANADE-INVENTORY-END\n";
1538        assert_eq!(inventory_payload(stdout), "{\"applied\": true}");
1539    }
1540
1541    #[test]
1542    fn inventory_payload_falls_back_to_whole_stdout() {
1543        // No fence (a plain inventory job) → whole stdout, trimmed.
1544        assert_eq!(
1545            inventory_payload("  {\"ram_gb\": 16}\n"),
1546            "{\"ram_gb\": 16}"
1547        );
1548    }
1549
1550    #[test]
1551    fn inventory_payload_handles_unterminated_fence() {
1552        // Closing marker missing (e.g. truncated) → everything after the
1553        // opener, trimmed.
1554        let stdout = "msg\n#KANADE-INVENTORY-BEGIN\n{\"a\": 1}";
1555        assert_eq!(inventory_payload(stdout), "{\"a\": 1}");
1556    }
1557
1558    #[test]
1559    fn inventory_payload_ignores_mid_line_sentinel() {
1560        // The marker echoed mid-line (not at a line start) must NOT be
1561        // treated as a fence — fall back to the whole stdout.
1562        let stdout = "see #KANADE-INVENTORY-BEGIN in the docs\nnot json";
1563        assert_eq!(inventory_payload(stdout), stdout.trim());
1564    }
1565
1566    /// The example check-job + schedule YAMLs shipped under `configs/`
1567    /// must stay valid as the schema evolves (#290 PR-C). `include_str!`
1568    /// pins them at compile time so a breaking edit fails `cargo test`
1569    /// rather than only `kanade job create` at deploy time.
1570    #[test]
1571    fn example_check_job_yamls_parse_and_validate() {
1572        let jobs = [
1573            (
1574                "check-bitlocker",
1575                include_str!("../../../configs/jobs/check-bitlocker.yaml"),
1576            ),
1577            (
1578                "check-av-signature",
1579                include_str!("../../../configs/jobs/check-av-signature.yaml"),
1580            ),
1581            (
1582                "check-cert-expiry",
1583                include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
1584            ),
1585            (
1586                "check-disk-space",
1587                include_str!("../../../configs/jobs/check-disk-space.yaml"),
1588            ),
1589            (
1590                "check-pending-reboot",
1591                include_str!("../../../configs/jobs/check-pending-reboot.yaml"),
1592            ),
1593            (
1594                "check-defender-rtp",
1595                include_str!("../../../configs/jobs/check-defender-rtp.yaml"),
1596            ),
1597            (
1598                "check-firewall",
1599                include_str!("../../../configs/jobs/check-firewall.yaml"),
1600            ),
1601        ];
1602        for (name, yaml) in jobs {
1603            let m: Manifest =
1604                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
1605            m.validate()
1606                .unwrap_or_else(|e| panic!("{name} validate: {e}"));
1607            let check = m
1608                .check
1609                .as_ref()
1610                .unwrap_or_else(|| panic!("{name} must carry a check: hint"));
1611            assert!(!check.name.trim().is_empty(), "{name} check.name empty");
1612            // These examples all read admin-only WMI / registry / netsh
1613            // state, so they run_as system. NOTE: that's a property of
1614            // these particular checks, NOT of the `check:` contract — a
1615            // check probing user-session state could run_as user.
1616            assert_eq!(
1617                m.execute.run_as,
1618                RunAs::System,
1619                "{name} should run_as system"
1620            );
1621        }
1622    }
1623
1624    /// The example user-invokable job YAMLs (#291) shipped under
1625    /// `configs/jobs/` must stay valid as the `client:` schema
1626    /// evolves. `include_str!` pins them at compile time so a breaking
1627    /// edit fails `cargo test`, not `kanade job create` at deploy.
1628    #[test]
1629    fn example_client_job_yamls_parse_and_validate() {
1630        let jobs = [
1631            (
1632                "fix-teams-cache",
1633                "troubleshoot",
1634                include_str!("../../../configs/jobs/fix-teams-cache.yaml"),
1635            ),
1636            (
1637                "chrome-update",
1638                "software_update",
1639                include_str!("../../../configs/jobs/chrome-update.yaml"),
1640            ),
1641            (
1642                "install-slack",
1643                "catalog",
1644                include_str!("../../../configs/jobs/install-slack.yaml"),
1645            ),
1646            (
1647                "fix-defender-rtp",
1648                "troubleshoot",
1649                include_str!("../../../configs/jobs/fix-defender-rtp.yaml"),
1650            ),
1651            // #792 custom category ("settings") + #809 message/inventory.
1652            (
1653                "example-power-plan",
1654                "settings",
1655                include_str!("../../../configs/jobs/example-power-plan.yaml"),
1656            ),
1657            // #792: diagnostics moved to its own "support" tab.
1658            (
1659                "collect-diagnostics",
1660                "support",
1661                include_str!("../../../configs/jobs/collect-diagnostics.yaml"),
1662            ),
1663        ];
1664        for (id, category, yaml) in jobs {
1665            let m: Manifest =
1666                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
1667            m.validate()
1668                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
1669            assert_eq!(m.id, id, "{id} id mismatch");
1670            let client = m
1671                .client
1672                .as_ref()
1673                .unwrap_or_else(|| panic!("{id} must carry a client: block"));
1674            assert!(!client.name.trim().is_empty(), "{id} client.name empty");
1675            assert_eq!(client.category, category, "{id} category");
1676        }
1677    }
1678
1679    /// #219: the shipped `collect:` example must stay valid as the
1680    /// schema evolves. `include_str!` pins it at compile time so a
1681    /// breaking edit (or a YAML typo in the PowerShell block) fails
1682    /// `cargo test` rather than `kanade job create` at deploy. It carries
1683    /// both `collect:` and `client:` (end-user-triggerable), which must
1684    /// compose.
1685    #[test]
1686    fn example_collect_job_yaml_parses_and_validates() {
1687        let yaml = include_str!("../../../configs/jobs/collect-diagnostics.yaml");
1688        let m: Manifest = serde_yaml::from_str(yaml).expect("collect-diagnostics parse");
1689        m.validate().expect("collect-diagnostics validate");
1690        assert_eq!(m.id, "collect-diagnostics");
1691        let collect = m.collect.as_ref().expect("collect: block present");
1692        assert!(!collect.name.trim().is_empty());
1693        assert_eq!(collect.files_field, "files");
1694        assert_eq!(collect.max_size_bytes(), 50_000_000);
1695        // collect + client compose — the Client App can trigger it.
1696        assert!(
1697            m.client.is_some(),
1698            "collect-diagnostics also carries client:"
1699        );
1700    }
1701
1702    /// The `emit: { type: events }` collector jobs under
1703    /// `configs/jobs/` feed the obs_events timeline. `include_str!`
1704    /// pins them at compile time so a breaking edit (e.g. an `emit:`
1705    /// paired with `check:`/`inventory:`, a bad watermark field, or a
1706    /// YAML typo in the PowerShell block) fails `cargo test` rather
1707    /// than `kanade job create` at deploy. Every one must carry an
1708    /// `emit.type=events` block and NO check/inventory (validate()
1709    /// rejects the pairing).
1710    #[test]
1711    fn example_event_collector_job_yamls_parse_and_validate() {
1712        let jobs = [
1713            (
1714                "collect-winlog-events",
1715                include_str!("../../../configs/jobs/collect-winlog-events.yaml"),
1716            ),
1717            (
1718                "collect-winlog-logons-all",
1719                include_str!("../../../configs/jobs/collect-winlog-logons-all.yaml"),
1720            ),
1721            (
1722                "collect-wlan-events",
1723                include_str!("../../../configs/jobs/collect-wlan-events.yaml"),
1724            ),
1725        ];
1726        for (id, yaml) in jobs {
1727            // Strict parse so an unknown-key typo in these fixtures fails
1728            // here (not silently at deploy) — the runtime Manifest is
1729            // unknown-key-tolerant, so the lenient serde_yaml::from_str
1730            // wouldn't catch fixture drift (CodeRabbit #689).
1731            let m: Manifest =
1732                crate::strict::from_yaml_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
1733            m.validate()
1734                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
1735            assert_eq!(m.id, id, "{id} id mismatch");
1736            let emit = m
1737                .emit
1738                .as_ref()
1739                .unwrap_or_else(|| panic!("{id} must carry an emit: block"));
1740            assert_eq!(emit.kind, EmitKind::Events, "{id} emit.type");
1741            assert!(
1742                m.check.is_none() && m.inventory.is_none(),
1743                "{id}: emit jobs must not pair with check/inventory"
1744            );
1745        }
1746    }
1747
1748    /// The `inventory:` snapshot jobs under `configs/jobs/` project
1749    /// facts into `inventory_facts` + exploded tables. `include_str!`
1750    /// pins them at compile time so a breaking edit (bad explode
1751    /// schema, a YAML typo in the PowerShell block, an `inventory:`
1752    /// accidentally paired with `emit:`) fails `cargo test` rather
1753    /// than the projector at deploy. Each must carry an `inventory:`
1754    /// block and NO emit (validate() rejects the pairing).
1755    #[test]
1756    fn example_inventory_job_yamls_parse_and_validate() {
1757        let jobs = [
1758            (
1759                "inventory-hw",
1760                include_str!("../../../configs/jobs/inventory-hw.yaml"),
1761            ),
1762            (
1763                "inventory-sw",
1764                include_str!("../../../configs/jobs/inventory-sw.yaml"),
1765            ),
1766            (
1767                "inventory-driver",
1768                include_str!("../../../configs/jobs/inventory-driver.yaml"),
1769            ),
1770        ];
1771        for (id, yaml) in jobs {
1772            let m: Manifest =
1773                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
1774            m.validate()
1775                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
1776            assert_eq!(m.id, id, "{id} id mismatch");
1777            assert!(m.inventory.is_some(), "{id} must carry an inventory: block");
1778            assert!(m.emit.is_none(), "{id}: inventory jobs must not set emit:");
1779        }
1780    }
1781
1782    #[test]
1783    fn example_check_schedule_yamls_parse_and_validate() {
1784        let schedules = [
1785            (
1786                "check-bitlocker",
1787                include_str!("../../../configs/schedules/check-bitlocker.yaml"),
1788            ),
1789            (
1790                "check-av-signature",
1791                include_str!("../../../configs/schedules/check-av-signature.yaml"),
1792            ),
1793            (
1794                "check-cert-expiry",
1795                include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
1796            ),
1797            (
1798                "check-disk-space",
1799                include_str!("../../../configs/schedules/check-disk-space.yaml"),
1800            ),
1801            (
1802                "check-pending-reboot",
1803                include_str!("../../../configs/schedules/check-pending-reboot.yaml"),
1804            ),
1805            (
1806                "check-defender-rtp",
1807                include_str!("../../../configs/schedules/check-defender-rtp.yaml"),
1808            ),
1809            (
1810                "check-firewall",
1811                include_str!("../../../configs/schedules/check-firewall.yaml"),
1812            ),
1813        ];
1814        for (name, yaml) in schedules {
1815            let s: Schedule =
1816                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
1817            s.validate()
1818                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
1819            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
1820        }
1821    }
1822
1823    /// Inventory schedule wrappers (`per_pc` cadence) must stay valid
1824    /// alongside the schedule schema. `include_str!` pins them so a
1825    /// breaking edit fails `cargo test`, not `kanade schedule create`.
1826    #[test]
1827    fn example_inventory_schedule_yamls_parse_and_validate() {
1828        let schedules = [
1829            (
1830                "inventory-hw",
1831                include_str!("../../../configs/schedules/inventory-hw.yaml"),
1832            ),
1833            (
1834                "inventory-sw",
1835                include_str!("../../../configs/schedules/inventory-sw.yaml"),
1836            ),
1837            (
1838                "inventory-driver",
1839                include_str!("../../../configs/schedules/inventory-driver.yaml"),
1840            ),
1841        ];
1842        for (name, yaml) in schedules {
1843            let s: Schedule =
1844                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
1845            s.validate()
1846                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
1847            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
1848        }
1849    }
1850
1851    #[test]
1852    fn target_is_specified_requires_at_least_one_field() {
1853        let empty = Target::default();
1854        assert!(!empty.is_specified());
1855
1856        let with_all = Target {
1857            all: true,
1858            ..Target::default()
1859        };
1860        assert!(with_all.is_specified());
1861
1862        let with_groups = Target {
1863            groups: vec!["canary".into()],
1864            ..Target::default()
1865        };
1866        assert!(with_groups.is_specified());
1867
1868        let with_pcs = Target {
1869            pcs: vec!["pc-01".into()],
1870            ..Target::default()
1871        };
1872        assert!(with_pcs.is_specified());
1873    }
1874
1875    #[test]
1876    fn manifest_deserialises_minimal_yaml() {
1877        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
1878        // — those live on the schedule / exec request now.
1879        let yaml = r#"
1880id: echo-test
1881version: 0.0.1
1882execute:
1883  shell: powershell
1884  script: "echo 'kanade'"
1885  timeout: 30s
1886"#;
1887        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1888        assert_eq!(m.id, "echo-test");
1889        assert_eq!(m.version, "0.0.1");
1890        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
1891        assert_eq!(
1892            m.execute.script.as_deref().map(str::trim),
1893            Some("echo 'kanade'")
1894        );
1895        assert!(m.execute.script_file.is_none());
1896        assert!(m.execute.script_object.is_none());
1897        assert_eq!(m.execute.timeout, "30s");
1898        assert!(!m.require_approval);
1899        m.validate()
1900            .expect("inline-script manifest passes validation");
1901    }
1902
1903    #[test]
1904    fn manifest_parses_check_job_and_validates() {
1905        // An operator-defined health check (#290): a `check:` hint +
1906        // a PowerShell script that prints {status, detail}.
1907        let yaml = r#"
1908id: check-bitlocker
1909version: 0.1.0
1910execute:
1911  shell: powershell
1912  run_as: system
1913  timeout: 15s
1914  script: |
1915    [pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
1916check:
1917  name: bitlocker
1918  troubleshoot: fix-bitlocker
1919"#;
1920        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1921        let check = m.check.as_ref().expect("check hint present");
1922        assert_eq!(check.name, "bitlocker");
1923        assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
1924        // Field names default to the conventional "status" / "detail".
1925        assert_eq!(check.status_field, "status");
1926        assert_eq!(check.detail_field, "detail");
1927        assert!(m.inventory.is_none() && m.emit.is_none());
1928        m.validate().expect("check-only manifest passes validation");
1929    }
1930
1931    #[test]
1932    fn manifest_check_defaults_and_custom_fields() {
1933        // Minimal: only `name`; status/detail fields default.
1934        let m: Manifest = serde_yaml::from_str(
1935            r#"
1936id: check-disk
1937version: 0.1.0
1938execute:
1939  shell: powershell
1940  script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
1941  timeout: 10s
1942check:
1943  name: disk_free
1944"#,
1945        )
1946        .expect("parse");
1947        let c = m.check.as_ref().unwrap();
1948        assert_eq!(c.name, "disk_free");
1949        assert_eq!(c.status_field, "status");
1950        assert_eq!(c.detail_field, "detail");
1951        assert!(c.troubleshoot.is_none());
1952        m.validate().expect("validates");
1953
1954        // The operator can point status/detail at any field of their
1955        // free-form inventory object.
1956        let m2: Manifest = serde_yaml::from_str(
1957            r#"
1958id: check-custom
1959version: 0.1.0
1960execute:
1961  shell: powershell
1962  script: "echo x"
1963  timeout: 10s
1964check:
1965  name: patch_level
1966  status_field: compliance
1967  detail_field: summary
1968"#,
1969        )
1970        .expect("parse");
1971        let c2 = m2.check.as_ref().unwrap();
1972        assert_eq!(c2.status_field, "compliance");
1973        assert_eq!(c2.detail_field, "summary");
1974    }
1975
1976    #[test]
1977    fn manifest_allows_check_composed_with_inventory() {
1978        // `check:` + `inventory:` COMPOSE on the same stdout object:
1979        // status/detail → Health tab, the rest → SPA projection +
1980        // explode sub-tables. Must pass validation.
1981        let yaml = r#"
1982id: check-bitlocker-detailed
1983version: 0.1.0
1984execute:
1985  shell: powershell
1986  script: "echo x"
1987  timeout: 10s
1988check:
1989  name: bitlocker
1990inventory:
1991  display:
1992    - { field: status, label: Status }
1993"#;
1994        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1995        assert!(m.check.is_some() && m.inventory.is_some());
1996        m.validate().expect("check + inventory compose");
1997    }
1998
1999    #[test]
2000    fn manifest_parses_collect_job_and_validates() {
2001        // #219: a `collect:` hint + a script that lists files on stdout.
2002        let yaml = r#"
2003id: collect-diagnostics
2004version: 0.1.0
2005execute:
2006  shell: powershell
2007  run_as: system
2008  timeout: 120s
2009  script: |
2010    @{ files = @("$env:KANADE_COLLECT_DIR/system.csv") } | ConvertTo-Json
2011collect:
2012  name: "Full diagnostics"
2013  description: "Event logs + process"
2014  max_size: 50MB
2015"#;
2016        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2017        let c = m.collect.as_ref().expect("collect hint present");
2018        assert_eq!(c.name, "Full diagnostics");
2019        assert_eq!(c.files_field, "files"); // default
2020        assert_eq!(c.max_size_bytes(), 50_000_000);
2021        m.validate().expect("collect-only manifest validates");
2022    }
2023
2024    #[test]
2025    fn manifest_collect_max_size_defaults_when_unset() {
2026        let m: Manifest = serde_yaml::from_str(
2027            r#"
2028id: collect-min
2029version: 0.1.0
2030execute:
2031  shell: powershell
2032  script: "echo x"
2033  timeout: 10s
2034collect:
2035  name: minimal
2036"#,
2037        )
2038        .expect("parse");
2039        let c = m.collect.as_ref().unwrap();
2040        assert!(c.max_size.is_none());
2041        assert_eq!(c.max_size_bytes(), DEFAULT_COLLECT_MAX_SIZE);
2042        m.validate().expect("validates");
2043    }
2044
2045    #[test]
2046    fn manifest_allows_collect_with_client() {
2047        // collect composes with client (client doesn't touch stdout):
2048        // an end user can trigger a collection from the Client App.
2049        let yaml = r#"
2050id: collect-diag-client
2051version: 0.1.0
2052execute:
2053  shell: powershell
2054  script: "echo x"
2055  timeout: 10s
2056collect:
2057  name: diagnostics
2058client:
2059  name: "Send diagnostics"
2060  category: troubleshoot
2061"#;
2062        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2063        assert!(m.collect.is_some() && m.client.is_some());
2064        m.validate().expect("collect + client compose");
2065    }
2066
2067    #[test]
2068    fn manifest_rejects_collect_combined_with_inventory() {
2069        // collect consumes stdout for its own `files` contract → can't
2070        // share with inventory/check/emit.
2071        let yaml = r#"
2072id: bad-collect-mix
2073version: 0.1.0
2074execute:
2075  shell: powershell
2076  script: "echo x"
2077  timeout: 10s
2078collect:
2079  name: diag
2080inventory:
2081  display:
2082    - { field: status, label: Status }
2083"#;
2084        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2085        let err = m
2086            .validate()
2087            .expect_err("collect + inventory must be rejected");
2088        assert!(err.contains("collect"), "error mentions collect: {err}");
2089    }
2090
2091    #[test]
2092    fn manifest_rejects_collect_combined_with_check_or_emit() {
2093        // collect is exclusive with every stdout-consuming hint, not
2094        // just inventory — guard the check + emit branches too.
2095        for extra in ["check:\n  name: health\n", "emit:\n  type: events\n"] {
2096            let yaml = format!(
2097                "id: bad-collect-mix\nversion: 0.1.0\nexecute:\n  shell: powershell\n  \
2098                 script: \"echo x\"\n  timeout: 10s\ncollect:\n  name: diag\n{extra}"
2099            );
2100            let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
2101            let err = m
2102                .validate()
2103                .expect_err("collect + stdout-consuming hint must fail");
2104            assert!(err.contains("collect"), "error mentions collect: {err}");
2105        }
2106    }
2107
2108    #[test]
2109    fn manifest_rejects_collect_empty_name_and_bad_size() {
2110        let empty_name: Manifest = serde_yaml::from_str(
2111            r#"
2112id: c
2113version: 0.1.0
2114execute: { shell: powershell, script: "echo x", timeout: 10s }
2115collect: { name: "  " }
2116"#,
2117        )
2118        .expect("parse");
2119        assert!(
2120            empty_name.validate().is_err(),
2121            "blank collect.name rejected"
2122        );
2123
2124        let bad_size: Manifest = serde_yaml::from_str(
2125            r#"
2126id: c
2127version: 0.1.0
2128execute: { shell: powershell, script: "echo x", timeout: 10s }
2129collect: { name: diag, max_size: "50 quux" }
2130"#,
2131        )
2132        .expect("parse");
2133        let err = bad_size.validate().expect_err("bad max_size rejected");
2134        assert!(err.contains("max_size"), "error mentions max_size: {err}");
2135    }
2136
2137    #[test]
2138    fn parse_size_bytes_units() {
2139        assert_eq!(parse_size_bytes("1024").unwrap(), 1024);
2140        assert_eq!(parse_size_bytes("1B").unwrap(), 1);
2141        assert_eq!(parse_size_bytes("50MB").unwrap(), 50_000_000);
2142        assert_eq!(parse_size_bytes("500 KB").unwrap(), 500_000);
2143        assert_eq!(parse_size_bytes("1GiB").unwrap(), 1024 * 1024 * 1024);
2144        assert_eq!(parse_size_bytes("2mib").unwrap(), 2 * 1024 * 1024);
2145        assert!(parse_size_bytes("").is_err());
2146        assert!(parse_size_bytes("MB").is_err());
2147        assert!(parse_size_bytes("12 zonks").is_err());
2148    }
2149
2150    #[test]
2151    fn manifest_rejects_check_combined_with_emit() {
2152        // `emit:` stdout is NDJSON (and omitted from the result), so
2153        // it can't pair with `check:` (which needs a single JSON
2154        // object on stdout).
2155        let yaml = r#"
2156id: bad-mix
2157version: 0.1.0
2158execute:
2159  shell: powershell
2160  script: "echo x"
2161  timeout: 10s
2162check:
2163  name: bitlocker
2164emit:
2165  type: events
2166"#;
2167        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2168        let err = m.validate().expect_err("emit + check must fail");
2169        assert!(err.contains("incompatible"), "err: {err}");
2170    }
2171
2172    #[test]
2173    fn manifest_rejects_emit_combined_with_inventory() {
2174        // The other half of the emit-incompatibility condition.
2175        let yaml = r#"
2176id: bad-mix-2
2177version: 0.1.0
2178execute:
2179  shell: powershell
2180  script: "echo x"
2181  timeout: 10s
2182emit:
2183  type: events
2184inventory:
2185  display:
2186    - { field: status, label: Status }
2187"#;
2188        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2189        let err = m.validate().expect_err("emit + inventory must fail");
2190        assert!(err.contains("incompatible"), "err: {err}");
2191    }
2192
2193    #[test]
2194    fn manifest_rejects_empty_check_field_names() {
2195        // Empty name / status_field / detail_field are invisible
2196        // runtime bugs (empty React key, agent reads the wrong field)
2197        // — reject them even though serde supplies non-empty defaults.
2198        let base = |inner: &str| {
2199            format!(
2200                "id: c\nversion: 0.1.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 10s\ncheck:\n{inner}"
2201            )
2202        };
2203        for inner in [
2204            "  name: \"\"\n",
2205            "  name: ok\n  status_field: \"\"\n",
2206            "  name: ok\n  detail_field: \"   \"\n",
2207            // present-but-blank troubleshoot → broken remediation id.
2208            "  name: ok\n  troubleshoot: \"  \"\n",
2209        ] {
2210            let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
2211            let err = m.validate().expect_err("empty field must fail");
2212            assert!(err.contains("must not be empty"), "err: {err}");
2213        }
2214    }
2215
2216    #[test]
2217    fn check_alert_decodes_with_defaults_and_validates() {
2218        let yaml = r#"
2219id: c
2220version: 0.1.0
2221execute:
2222  shell: powershell
2223  script: "echo x"
2224  timeout: 10s
2225check:
2226  name: bitlocker
2227  alert:
2228    notify_user: true
2229    title: "BitLocker 未準拠"
2230"#;
2231        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2232        m.validate().expect("valid alert");
2233        let alert = m.check.unwrap().alert.unwrap();
2234        // Defaults: on = [fail], priority = warn, body = None.
2235        assert_eq!(alert.on, vec![CheckAlertStatus::Fail]);
2236        assert_eq!(
2237            alert.priority,
2238            crate::ipc::notifications::NotificationPriority::Warn
2239        );
2240        assert!(alert.body.is_none());
2241        assert!(alert.notify_user);
2242    }
2243
2244    #[test]
2245    fn check_alert_validation_rejects_bad_configs() {
2246        let base = |alert: &str| {
2247            format!(
2248                "id: c\nversion: 0.1.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 10s\ncheck:\n  name: bitlocker\n  alert:\n{alert}"
2249            )
2250        };
2251        let cases = [
2252            // No recipient.
2253            ("    title: t\n", "notify_user and/or notify_groups"),
2254            // Empty title.
2255            (
2256                "    notify_user: true\n    title: \"  \"\n",
2257                "title must not be empty",
2258            ),
2259            // Empty `on`.
2260            (
2261                "    notify_user: true\n    title: t\n    on: []\n",
2262                "on must list at least one status",
2263            ),
2264            // Blank group name.
2265            (
2266                "    notify_groups: [\"  \"]\n    title: t\n",
2267                "notify_groups must not contain blanks",
2268            ),
2269            // alert requires fleet: true.
2270            (
2271                "    notify_user: true\n    title: t\n  fleet: false\n",
2272                "requires fleet: true",
2273            ),
2274            // email opt-in without a group to address.
2275            (
2276                "    notify_user: true\n    email: true\n    title: t\n",
2277                "email requires notify_groups",
2278            ),
2279        ];
2280        for (alert, want) in cases {
2281            let m: Manifest = serde_yaml::from_str(&base(alert)).expect("parse");
2282            let err = m.validate().expect_err("bad alert must fail");
2283            assert!(err.contains(want), "for {alert:?}: got {err}");
2284        }
2285    }
2286
2287    #[test]
2288    fn manifest_client_absent_by_default() {
2289        // A plain operator job (the overwhelming majority) carries no
2290        // `client:` block, so it never surfaces in the end-user
2291        // catalog.
2292        let yaml = r#"
2293id: echo-test
2294version: 0.0.1
2295execute:
2296  shell: powershell
2297  script: "echo 'kanade'"
2298  timeout: 30s
2299"#;
2300        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2301        assert!(m.client.is_none());
2302        m.validate().expect("operator-only job validates");
2303    }
2304
2305    #[test]
2306    fn manifest_client_parses_and_validates() {
2307        // The Client App "困ったとき" remediation job shape: a
2308        // user-invokable troubleshoot job with the end-user fields the
2309        // KLP `jobs.list` wire needs, grouped under `client:`.
2310        let yaml = r#"
2311id: fix-teams-cache
2312version: 1.0.0
2313execute:
2314  shell: powershell
2315  script: "echo clearing"
2316  timeout: 60s
2317client:
2318  name: "Teams のキャッシュをクリア"
2319  description: "Teams が重いときに試してください"
2320  category: troubleshoot
2321  icon: brush-cleaning
2322"#;
2323        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2324        let c = m.client.as_ref().expect("client block present");
2325        assert_eq!(c.name, "Teams のキャッシュをクリア");
2326        assert_eq!(
2327            c.description.as_deref(),
2328            Some("Teams が重いときに試してください")
2329        );
2330        assert_eq!(c.category, "troubleshoot");
2331        assert_eq!(c.icon.as_deref(), Some("brush-cleaning"));
2332        m.validate().expect("user-invokable job validates");
2333    }
2334
2335    #[test]
2336    fn manifest_client_minimal_only_name_and_category() {
2337        // description + icon are optional; name + category are the
2338        // serde-required minimum.
2339        let yaml = r#"
2340id: install-slack
2341version: 1.0.0
2342execute:
2343  shell: powershell
2344  script: "echo install"
2345  timeout: 600s
2346client:
2347  name: Slack
2348  category: catalog
2349"#;
2350        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2351        let c = m.client.as_ref().expect("client present");
2352        assert_eq!(c.category, "catalog");
2353        assert!(c.description.is_none() && c.icon.is_none());
2354        m.validate().expect("minimal client validates");
2355    }
2356
2357    #[test]
2358    fn manifest_client_rejects_blank_name() {
2359        // serde guarantees `name`/`category` are present; the one gap
2360        // is a present-but-blank name → empty catalog row title.
2361        let yaml = r#"
2362id: j
2363version: 1.0.0
2364execute:
2365  shell: powershell
2366  script: "echo x"
2367  timeout: 30s
2368client:
2369  name: "   "
2370  category: catalog
2371"#;
2372        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2373        let err = m.validate().expect_err("blank name must fail");
2374        assert!(err.contains("client.name"), "err: {err}");
2375    }
2376
2377    #[test]
2378    fn manifest_client_rejects_blank_optional_fields() {
2379        // description / icon are optional, but a present-but-blank
2380        // value is a bug (empty subtitle / dangling icon name) — reject
2381        // it, mirroring the check: block's troubleshoot guard.
2382        for (field, line) in [
2383            ("client.description", "  description: \"  \"\n"),
2384            ("client.icon", "  icon: \"\"\n"),
2385            // #792: the new category tab-metadata fields get the same
2386            // present-but-blank guard.
2387            ("client.category_label", "  category_label: \"  \"\n"),
2388            ("client.category_icon", "  category_icon: \"\"\n"),
2389        ] {
2390            let yaml = format!(
2391                "id: j\nversion: 1.0.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 30s\nclient:\n  name: A\n  category: catalog\n{line}"
2392            );
2393            let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
2394            let err = m.validate().expect_err("blank optional field must fail");
2395            assert!(err.contains(field), "expected {field} in err: {err}");
2396        }
2397    }
2398
2399    #[test]
2400    fn manifest_client_rejects_blank_category() {
2401        // #792: category is a free-form key now; serde keeps it required,
2402        // but a present-but-blank value would group the job under an empty
2403        // tab — validate() must reject it.
2404        let yaml = r#"
2405id: j
2406version: 1.0.0
2407execute:
2408  shell: powershell
2409  script: "echo x"
2410  timeout: 30s
2411client:
2412  name: "A job"
2413  category: "   "
2414"#;
2415        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2416        let err = m.validate().expect_err("blank category must fail");
2417        assert!(err.contains("client.category"), "err: {err}");
2418    }
2419
2420    #[test]
2421    fn target_matches_pc_group_and_all() {
2422        // #816: pc match, group match, all, and the no-match case.
2423        let by_pc = Target {
2424            pcs: vec!["PC1".into()],
2425            ..Default::default()
2426        };
2427        assert!(by_pc.matches("PC1", &[]));
2428        assert!(!by_pc.matches("PC2", &["g1".into()]));
2429
2430        let by_group = Target {
2431            groups: vec!["g1".into()],
2432            ..Default::default()
2433        };
2434        assert!(by_group.matches("PC2", &["g1".into()]));
2435        assert!(!by_group.matches("PC2", &["g2".into()]));
2436
2437        let all = Target {
2438            all: true,
2439            ..Default::default()
2440        };
2441        assert!(all.matches("anyPC", &[]));
2442    }
2443
2444    #[test]
2445    fn manifest_client_rejects_empty_visible_to() {
2446        // #816: a present-but-empty visible_to (no all/groups/pcs) would
2447        // hide the job from everyone — validate() must reject it.
2448        let yaml = r#"
2449id: j
2450version: 1.0.0
2451execute:
2452  shell: powershell
2453  script: "echo x"
2454  timeout: 30s
2455client:
2456  name: "A job"
2457  category: troubleshoot
2458  visible_to: {}
2459"#;
2460        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2461        let err = m.validate().expect_err("empty visible_to must fail");
2462        assert!(err.contains("client.visible_to"), "err: {err}");
2463    }
2464
2465    #[test]
2466    fn manifest_client_accepts_visible_to_groups() {
2467        let yaml = r#"
2468id: j
2469version: 1.0.0
2470execute:
2471  shell: powershell
2472  script: "echo x"
2473  timeout: 30s
2474client:
2475  name: "A job"
2476  category: settings
2477  visible_to:
2478    groups: [wifi-affected]
2479"#;
2480        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2481        m.validate().expect("visible_to with a group validates");
2482        let vt = m.client.unwrap().visible_to.unwrap();
2483        assert_eq!(vt.groups, vec!["wifi-affected".to_string()]);
2484    }
2485
2486    #[test]
2487    fn manifest_client_requires_category_at_parse() {
2488        // A `client:` block missing `category` is a hard parse error
2489        // (serde required field) — no manual validate() needed.
2490        let yaml = r#"
2491id: j
2492version: 1.0.0
2493execute:
2494  shell: powershell
2495  script: "echo x"
2496  timeout: 30s
2497client:
2498  name: "A job"
2499"#;
2500        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
2501        assert!(
2502            r.is_err(),
2503            "missing category must be a parse error, got {r:?}"
2504        );
2505    }
2506
2507    #[test]
2508    fn manifest_client_rejects_unknown_field() {
2509        // #492: the strict create boundary catches a fat-fingered
2510        // `displayname:` (with its path) instead of silently
2511        // dropping it; the tolerant read path accepts it.
2512        let yaml = r#"
2513id: j
2514version: 1.0.0
2515execute:
2516  shell: powershell
2517  script: "echo x"
2518  timeout: 30s
2519client:
2520  name: "A job"
2521  category: catalog
2522  displayname: oops
2523"#;
2524        let r = crate::strict::from_yaml_str::<Manifest>(yaml);
2525        let err = r.expect_err("unknown client field must be rejected at the write boundary");
2526        // serde_ignored renders the Option layer as `?`:
2527        // `client.?.displayname`. Assert on the leaf key.
2528        assert!(err.contains("displayname"), "{err}");
2529        // The READ path tolerates the same payload (gradual-upgrade
2530        // contract: an old agent must accept a newer writer's field).
2531        let m: Manifest = serde_yaml::from_str(yaml).expect("tolerant read");
2532        assert_eq!(m.client.as_ref().map(|c| c.name.as_str()), Some("A job"));
2533    }
2534
2535    #[test]
2536    fn manifest_tags_default_empty() {
2537        // The overwhelming majority of jobs carry no tags; the field
2538        // must default to an empty Vec (not fail to parse) and skip
2539        // serialisation so old readers never see the key.
2540        let yaml = r#"
2541id: echo-test
2542version: 0.0.1
2543execute:
2544  shell: powershell
2545  script: "echo 'kanade'"
2546  timeout: 30s
2547"#;
2548        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2549        assert!(m.tags.is_empty());
2550        m.validate().expect("tag-less job validates");
2551        // skip_serializing_if = empty ⇒ the key is absent from JSON.
2552        let json = serde_json::to_string(&m).expect("serialize");
2553        assert!(
2554            !json.contains("tags"),
2555            "empty tags must not serialise: {json}"
2556        );
2557    }
2558
2559    #[test]
2560    fn manifest_parses_and_validates_tags() {
2561        let yaml = r#"
2562id: check-bitlocker
2563version: 0.1.0
2564execute:
2565  shell: powershell
2566  script: "echo x"
2567  timeout: 30s
2568tags: [security, windows, health-check]
2569"#;
2570        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2571        assert_eq!(m.tags, vec!["security", "windows", "health-check"]);
2572        m.validate().expect("tagged job validates");
2573        // Round-trips through JSON (the wire format the SPA reads).
2574        let json = serde_json::to_string(&m).expect("serialize");
2575        assert!(json.contains("\"tags\""), "non-empty tags must serialise");
2576    }
2577
2578    #[test]
2579    fn manifest_rejects_blank_tag() {
2580        // A whitespace-only tag renders an empty filter chip — reject
2581        // it at the write boundary like the other blank display fields.
2582        let yaml = r#"
2583id: j
2584version: 0.1.0
2585execute:
2586  shell: powershell
2587  script: "echo x"
2588  timeout: 30s
2589tags: [ok, "   "]
2590"#;
2591        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
2592        let err = m.validate().expect_err("blank tag must fail");
2593        assert!(err.contains("tags must not contain empty"), "err: {err}");
2594    }
2595
2596    // #720 — wrap an `aggregate:` YAML block (already indented as a
2597    // top-level key body) into an otherwise-minimal valid manifest.
2598    fn manifest_with_aggregate(aggregate_block: &str) -> Manifest {
2599        let yaml = format!(
2600            "id: t\nversion: 0.0.1\nexecute:\n  shell: powershell\n  script: echo hi\n  timeout: 30s\n{aggregate_block}"
2601        );
2602        serde_yaml::from_str(&yaml).expect("parse aggregate manifest")
2603    }
2604
2605    #[test]
2606    fn aggregate_accepts_full_valid_spec() {
2607        // count+group_by+exclude+sample_minutes, ratio+bool_path,
2608        // timeline+time_bucket, fleet ranking via group_by: pc_id, and a
2609        // bare total stat — alongside emit (composes with every hint).
2610        let m = manifest_with_aggregate(
2611            "emit:\n  type: events\naggregate:\n\
2612             - { dashboard: Utilization, title: Top apps, kind: app_sample, agg: count, group_by: foreground.app, sample_minutes: 2, exclude: [LockApp], render: bar }\n\
2613             - { dashboard: Utilization, title: Active ratio, kind: presence, agg: ratio, bool_path: active, sample_minutes: 5, render: gauge }\n\
2614             - { dashboard: Utilization, title: By hour, kind: presence, agg: ratio, bool_path: active, time_bucket: hour, render: timeline }\n\
2615             - { dashboard: Reliability, title: Crashes by PC, scope: fleet, kind: unexpected_shutdown, agg: count, group_by: pc_id, render: bar }\n\
2616             - { dashboard: Reliability, title: Total crashes, scope: fleet, kind: unexpected_shutdown, agg: count, render: stat }\n",
2617        );
2618        m.validate().expect("valid aggregate spec");
2619    }
2620
2621    #[test]
2622    fn aggregate_rejects_empty_list() {
2623        let m = manifest_with_aggregate("aggregate: []\n");
2624        let err = m.validate().expect_err("empty list must fail");
2625        assert!(err.contains("at least one widget"), "err: {err}");
2626    }
2627
2628    #[test]
2629    fn aggregate_rejects_ratio_without_bool_path() {
2630        let m = manifest_with_aggregate(
2631            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: ratio, render: gauge }\n",
2632        );
2633        let err = m.validate().expect_err("ratio needs bool_path");
2634        assert!(err.contains("agg=ratio requires `bool_path`"), "err: {err}");
2635    }
2636
2637    #[test]
2638    fn aggregate_rejects_sum_without_value_path() {
2639        let m = manifest_with_aggregate(
2640            "aggregate:\n- { dashboard: D, title: T, kind: io, agg: sum, render: bar }\n",
2641        );
2642        let err = m.validate().expect_err("sum needs value_path");
2643        assert!(err.contains("agg=sum requires `value_path`"), "err: {err}");
2644    }
2645
2646    #[test]
2647    fn aggregate_rejects_pc_id_group_without_fleet() {
2648        let m = manifest_with_aggregate(
2649            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: count, group_by: pc_id, render: bar }\n",
2650        );
2651        let err = m.validate().expect_err("pc_id grouping needs fleet");
2652        assert!(
2653            err.contains("pc_id is only valid with scope: fleet"),
2654            "err: {err}"
2655        );
2656    }
2657
2658    #[test]
2659    fn aggregate_rejects_transform_with_pc_id_group() {
2660        let m = manifest_with_aggregate(
2661            "aggregate:\n- { dashboard: D, title: T, scope: fleet, kind: web_visit, agg: count, group_by: pc_id, transform: host, render: bar }\n",
2662        );
2663        let err = m
2664            .validate()
2665            .expect_err("transform on pc_id grouping must fail");
2666        assert!(
2667            err.contains("transform is not valid with group_by: pc_id"),
2668            "err: {err}"
2669        );
2670    }
2671
2672    #[test]
2673    fn aggregate_rejects_timeline_without_bucket() {
2674        let m = manifest_with_aggregate(
2675            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: ratio, bool_path: active, render: timeline }\n",
2676        );
2677        let err = m.validate().expect_err("timeline needs a bucket");
2678        assert!(
2679            err.contains("render=timeline requires `time_bucket`"),
2680            "err: {err}"
2681        );
2682    }
2683
2684    #[test]
2685    fn aggregate_rejects_bucket_on_non_timeline() {
2686        let m = manifest_with_aggregate(
2687            "aggregate:\n- { dashboard: D, title: T, kind: presence, agg: ratio, bool_path: active, time_bucket: hour, render: gauge }\n",
2688        );
2689        let err = m.validate().expect_err("bucket only on timeline");
2690        assert!(
2691            err.contains("time_bucket is only valid with render: timeline"),
2692            "err: {err}"
2693        );
2694    }
2695
2696    #[test]
2697    fn aggregate_rejects_unsafe_json_path() {
2698        // A path with characters outside [A-Za-z0-9_.] could break out of
2699        // the `'$.' || ?` bind — reject at create time.
2700        let m = manifest_with_aggregate(
2701            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: \"foo'; DROP\", render: bar }\n",
2702        );
2703        let err = m.validate().expect_err("unsafe path must fail");
2704        assert!(err.contains("dotted JSON path"), "err: {err}");
2705    }
2706
2707    #[test]
2708    fn aggregate_rejects_blank_title() {
2709        let m = manifest_with_aggregate(
2710            "aggregate:\n- { dashboard: D, title: \"  \", kind: k, agg: count, render: stat }\n",
2711        );
2712        let err = m.validate().expect_err("blank title must fail");
2713        assert!(err.contains("title must not be empty"), "err: {err}");
2714    }
2715
2716    #[test]
2717    fn aggregate_rejects_blank_kind() {
2718        let m = manifest_with_aggregate(
2719            "aggregate:\n- { dashboard: D, title: T, kind: \" \", agg: count, render: stat }\n",
2720        );
2721        let err = m.validate().expect_err("blank kind must fail");
2722        assert!(err.contains("kind must not be empty"), "err: {err}");
2723    }
2724
2725    #[test]
2726    fn aggregate_rejects_blank_source_when_set() {
2727        let m = manifest_with_aggregate(
2728            "aggregate:\n- { dashboard: D, title: T, kind: k, source: \"\", agg: count, render: stat }\n",
2729        );
2730        let err = m.validate().expect_err("blank source must fail");
2731        assert!(
2732            err.contains("source must not be empty when set"),
2733            "err: {err}"
2734        );
2735    }
2736
2737    #[test]
2738    fn aggregate_accepts_description_and_rejects_blank() {
2739        let ok = manifest_with_aggregate(
2740            "aggregate:\n- { dashboard: D, title: T, description: \"samples x 2 min\", kind: k, agg: count, render: stat }\n",
2741        );
2742        ok.validate()
2743            .expect("description is a valid optional field");
2744        assert_eq!(
2745            ok.aggregate.as_ref().unwrap()[0].description.as_deref(),
2746            Some("samples x 2 min")
2747        );
2748        let bad = manifest_with_aggregate(
2749            "aggregate:\n- { dashboard: D, title: T, description: \"  \", kind: k, agg: count, render: stat }\n",
2750        );
2751        let err = bad.validate().expect_err("blank description must fail");
2752        assert!(
2753            err.contains("description must not be empty when set"),
2754            "err: {err}"
2755        );
2756    }
2757
2758    #[test]
2759    fn aggregate_rejects_count_with_value_path() {
2760        let m = manifest_with_aggregate(
2761            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, value_path: bytes, render: stat }\n",
2762        );
2763        let err = m.validate().expect_err("count must not use value_path");
2764        assert!(
2765            err.contains("agg=count does not use `value_path`"),
2766            "err: {err}"
2767        );
2768    }
2769
2770    #[test]
2771    fn aggregate_rejects_ratio_with_value_path() {
2772        let m = manifest_with_aggregate(
2773            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: ratio, bool_path: active, value_path: bytes, render: gauge }\n",
2774        );
2775        let err = m.validate().expect_err("ratio must not use value_path");
2776        assert!(
2777            err.contains("agg=ratio does not use `value_path`"),
2778            "err: {err}"
2779        );
2780    }
2781
2782    #[test]
2783    fn aggregate_rejects_gauge_without_ratio() {
2784        let m = manifest_with_aggregate(
2785            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, render: gauge }\n",
2786        );
2787        let err = m.validate().expect_err("gauge needs ratio");
2788        assert!(
2789            err.contains("render=gauge is only valid with agg: ratio"),
2790            "err: {err}"
2791        );
2792    }
2793
2794    #[test]
2795    fn aggregate_rejects_limit_without_group_by() {
2796        let m = manifest_with_aggregate(
2797            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, limit: 5, render: stat }\n",
2798        );
2799        let err = m.validate().expect_err("limit needs group_by");
2800        assert!(err.contains("limit requires `group_by`"), "err: {err}");
2801    }
2802
2803    #[test]
2804    fn aggregate_rejects_exclude_without_group_by() {
2805        let m = manifest_with_aggregate(
2806            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, exclude: [x], render: stat }\n",
2807        );
2808        let err = m.validate().expect_err("exclude needs group_by");
2809        assert!(err.contains("exclude requires `group_by`"), "err: {err}");
2810    }
2811
2812    #[test]
2813    fn aggregate_rejects_zero_limit_and_zero_sample_minutes() {
2814        let m = manifest_with_aggregate(
2815            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, limit: 0, render: bar }\n",
2816        );
2817        assert!(m.validate().unwrap_err().contains("limit must be > 0"));
2818        let m = manifest_with_aggregate(
2819            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, sample_minutes: 0, render: bar }\n",
2820        );
2821        assert!(
2822            m.validate()
2823                .unwrap_err()
2824                .contains("sample_minutes must be > 0")
2825        );
2826    }
2827
2828    #[test]
2829    fn aggregate_rejects_empty_exclude_entry() {
2830        let m = manifest_with_aggregate(
2831            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, group_by: app, exclude: [\"  \"], render: bar }\n",
2832        );
2833        let err = m.validate().expect_err("blank exclude entry must fail");
2834        assert!(
2835            err.contains("exclude must not contain empty entries"),
2836            "err: {err}"
2837        );
2838    }
2839
2840    #[test]
2841    fn aggregate_rejects_malformed_dotted_paths() {
2842        for bad in [".foo", "foo.", "foo..bar", "."] {
2843            let m = manifest_with_aggregate(&format!(
2844                "aggregate:\n- {{ dashboard: D, title: T, kind: k, agg: count, group_by: \"{bad}\", render: bar }}\n"
2845            ));
2846            let err = m.validate().expect_err("malformed path must fail");
2847            assert!(err.contains("dotted JSON path"), "path {bad}: {err}");
2848        }
2849    }
2850
2851    #[test]
2852    fn aggregate_rejects_unknown_enum_value() {
2853        // An unrecognised render string deserialises to the #492 Unknown
2854        // catch-all (so old readers don't choke); validate() rejects it as
2855        // a typo at create time.
2856        let m = manifest_with_aggregate(
2857            "aggregate:\n- { dashboard: D, title: T, kind: k, agg: count, render: heatmap }\n",
2858        );
2859        let err = m.validate().expect_err("unknown render must fail");
2860        assert!(err.contains("render is not a known value"), "err: {err}");
2861    }
2862
2863    #[test]
2864    fn aggregate_accepts_order_field() {
2865        let m = manifest_with_aggregate(
2866            "aggregate:\n- { dashboard: D, title: T, order: -5, kind: k, agg: count, render: stat }\n",
2867        );
2868        m.validate().expect("order is a valid optional field");
2869        let w = &m.aggregate.as_ref().unwrap()[0];
2870        assert_eq!(w.order, Some(-5));
2871    }
2872
2873    // ── #743 View resource ───────────────────────────────────────────
2874    fn view_from(yaml_body: &str) -> View {
2875        serde_yaml::from_str(&format!("id: v1\n{yaml_body}")).expect("parse view")
2876    }
2877
2878    #[test]
2879    fn view_accepts_valid_widgets() {
2880        let v = view_from(
2881            "widgets:\n\
2882             - { dashboard: Reliability, title: Crashes by PC, scope: fleet, kind: unexpected_shutdown, agg: count, group_by: pc_id, render: bar }\n\
2883             - { dashboard: Reliability, title: Total, scope: fleet, kind: unexpected_shutdown, agg: count, render: stat }\n",
2884        );
2885        v.validate().expect("valid view");
2886    }
2887
2888    #[test]
2889    fn view_rejects_empty_widgets() {
2890        let v = view_from("widgets: []\n");
2891        let err = v.validate().expect_err("empty widgets must fail");
2892        assert!(err.contains("at least one widget"), "err: {err}");
2893    }
2894
2895    #[test]
2896    fn view_rejects_blank_id() {
2897        let v: View = serde_yaml::from_str(
2898            "id: \"  \"\nwidgets:\n- { dashboard: D, title: T, kind: k, agg: count, render: stat }\n",
2899        )
2900        .expect("parse");
2901        let err = v.validate().expect_err("blank id must fail");
2902        assert!(err.contains("view.id must"), "err: {err}");
2903    }
2904
2905    #[test]
2906    fn view_rejects_unsafe_id() {
2907        // A `/` or `..` in the id would break the KV key and the
2908        // `/api/views/{id}` URL segment — reject at create time.
2909        for bad in ["../etc", "a/b", "has space", "x;y"] {
2910            let v: View = serde_yaml::from_str(&format!(
2911                "id: \"{bad}\"\nwidgets:\n- {{ dashboard: D, title: T, kind: k, agg: count, render: stat }}\n",
2912            ))
2913            .expect("parse");
2914            let err = v.validate().expect_err("unsafe id must fail");
2915            assert!(err.contains("[A-Za-z0-9._-]"), "id {bad}: {err}");
2916        }
2917        assert!(is_valid_resource_id("dashboards-fleet.v1_2"));
2918    }
2919
2920    #[test]
2921    fn view_reuses_shared_widget_validation() {
2922        // The same per-widget rule the job hint enforces (ratio needs
2923        // bool_path), reported under the `widgets[..]` field.
2924        let v = view_from(
2925            "widgets:\n- { dashboard: D, title: T, kind: presence, agg: ratio, render: gauge }\n",
2926        );
2927        let err = v.validate().expect_err("ratio without bool_path must fail");
2928        assert!(
2929            err.contains("widgets[0].agg=ratio requires `bool_path`"),
2930            "err: {err}"
2931        );
2932    }
2933
2934    fn execute_with(
2935        script: Option<&str>,
2936        script_file: Option<&str>,
2937        script_object: Option<&str>,
2938    ) -> Execute {
2939        Execute {
2940            shell: ExecuteShell::Powershell,
2941            script: script.map(str::to_owned),
2942            script_file: script_file.map(str::to_owned),
2943            script_object: script_object.map(str::to_owned),
2944            timeout: "30s".into(),
2945            run_as: RunAs::default(),
2946            cwd: None,
2947        }
2948    }
2949
2950    #[test]
2951    fn validate_accepts_inline_script() {
2952        let e = execute_with(Some("echo hi"), None, None);
2953        assert!(e.validate_script_source().is_ok());
2954    }
2955
2956    #[test]
2957    fn validate_accepts_script_file_alone() {
2958        let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
2959        assert!(e.validate_script_source().is_ok());
2960    }
2961
2962    #[test]
2963    fn validate_accepts_script_object_alone() {
2964        let e = execute_with(None, None, Some("cleanup/1.0.0"));
2965        assert!(e.validate_script_source().is_ok());
2966    }
2967
2968    #[test]
2969    fn validate_treats_empty_inline_script_as_unset() {
2970        // `script: ""` + `script_object` set is the natural shape
2971        // when an operator comments out the YAML block-scalar body
2972        // but leaves the key. Should pass.
2973        let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
2974        assert!(e.validate_script_source().is_ok());
2975    }
2976
2977    #[test]
2978    fn validate_rejects_zero_sources() {
2979        let e = execute_with(None, None, None);
2980        let err = e.validate_script_source().unwrap_err();
2981        assert!(err.contains("must be set"), "got: {err}");
2982    }
2983
2984    #[test]
2985    fn validate_rejects_empty_inline_only() {
2986        let e = execute_with(Some(""), None, None);
2987        let err = e.validate_script_source().unwrap_err();
2988        assert!(err.contains("must be set"), "got: {err}");
2989    }
2990
2991    #[test]
2992    fn validate_rejects_inline_plus_file() {
2993        let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
2994        let err = e.validate_script_source().unwrap_err();
2995        assert!(err.contains("only one of"), "got: {err}");
2996    }
2997
2998    #[test]
2999    fn validate_rejects_inline_plus_object() {
3000        let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
3001        let err = e.validate_script_source().unwrap_err();
3002        assert!(err.contains("only one of"), "got: {err}");
3003    }
3004
3005    #[test]
3006    fn validate_rejects_file_plus_object() {
3007        let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
3008        let err = e.validate_script_source().unwrap_err();
3009        assert!(err.contains("only one of"), "got: {err}");
3010    }
3011
3012    #[test]
3013    fn validate_rejects_all_three() {
3014        let e = execute_with(
3015            Some("echo hi"),
3016            Some("scripts/cleanup.ps1"),
3017            Some("cleanup/1.0.0"),
3018        );
3019        let err = e.validate_script_source().unwrap_err();
3020        assert!(err.contains("only one of"), "got: {err}");
3021    }
3022
3023    #[test]
3024    fn manifest_deserialises_script_object_yaml() {
3025        // SPEC §2.4.1 example shape with the Object Store
3026        // reference picked over inline.
3027        let yaml = r#"
3028id: cleanup-disk-temp
3029version: 1.0.1
3030execute:
3031  shell: powershell
3032  script_object: cleanup-disk-temp/1.0.1
3033  timeout: 600s
3034"#;
3035        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3036        assert_eq!(
3037            m.execute.script_object.as_deref(),
3038            Some("cleanup-disk-temp/1.0.1")
3039        );
3040        assert!(m.execute.script.is_none());
3041        m.validate()
3042            .expect("script_object-only manifest passes validation");
3043    }
3044
3045    #[test]
3046    fn manifest_rejects_typo_in_script_field_name() {
3047        // #492: the strict create boundary catches `script_objectt`
3048        // and similar fat-fingers (with the full path) instead of
3049        // letting them silently fall through to "all three unset".
3050        let yaml = r#"
3051id: typo
3052version: 1.0.0
3053execute:
3054  shell: powershell
3055  script_objectt: oops
3056  timeout: 30s
3057"#;
3058        let err = crate::strict::from_yaml_str::<Manifest>(yaml)
3059            .expect_err("typo'd execute field must be rejected at the write boundary");
3060        assert!(err.contains("execute.script_objectt"), "{err}");
3061    }
3062
3063    #[test]
3064    fn schedule_carries_target_and_rollout() {
3065        let yaml = r#"
3066id: hourly-cleanup-canary
3067when:
3068  per_pc: { every: 1h }
3069job_id: cleanup
3070enabled: true
3071target:
3072  groups: [canary, wave1]
3073jitter: 30s
3074rollout:
3075  strategy: wave
3076  waves:
3077    - { group: canary, delay: 0s }
3078    - { group: wave1,  delay: 5s }
3079"#;
3080        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3081        assert_eq!(s.id, "hourly-cleanup-canary");
3082        assert_eq!(s.job_id, "cleanup");
3083        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
3084        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
3085        let rollout = s.plan.rollout.expect("rollout present");
3086        assert_eq!(rollout.waves.len(), 2);
3087        assert_eq!(rollout.waves[0].group, "canary");
3088        assert_eq!(rollout.waves[1].delay, "5s");
3089        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
3090    }
3091
3092    #[test]
3093    fn schedule_minimal_target_all() {
3094        let yaml = r#"
3095id: kitting
3096when:
3097  per_pc: once
3098enabled: true
3099job_id: scheduled-echo
3100target: { all: true }
3101"#;
3102        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3103        assert_eq!(s.id, "kitting");
3104        assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
3105        assert!(s.enabled);
3106        assert_eq!(s.job_id, "scheduled-echo");
3107        assert!(s.plan.target.all);
3108        assert!(s.plan.rollout.is_none());
3109        assert!(s.plan.jitter.is_none());
3110        assert!(s.active.is_empty());
3111    }
3112
3113    #[test]
3114    fn schedule_enabled_defaults_to_true() {
3115        let yaml = r#"
3116id: x
3117when:
3118  per_pc: once
3119job_id: y
3120target: { all: true }
3121"#;
3122        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3123        assert!(s.enabled);
3124    }
3125
3126    #[test]
3127    fn schedule_tags_default_empty_and_skip_serialise() {
3128        let yaml = r#"
3129id: x
3130when:
3131  per_pc: once
3132job_id: y
3133target: { all: true }
3134"#;
3135        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3136        assert!(s.tags.is_empty());
3137        s.validate().expect("tag-less schedule validates");
3138        let json = serde_json::to_string(&s).expect("serialize");
3139        assert!(
3140            !json.contains("tags"),
3141            "empty tags must not serialise: {json}"
3142        );
3143    }
3144
3145    #[test]
3146    fn schedule_parses_and_validates_tags() {
3147        let yaml = r#"
3148id: weekly-cleanup
3149when:
3150  per_pc: { every: 1h }
3151job_id: cleanup
3152target: { all: true }
3153tags: [weekly, maintenance]
3154"#;
3155        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3156        assert_eq!(s.tags, vec!["weekly", "maintenance"]);
3157        s.validate().expect("tagged schedule validates");
3158    }
3159
3160    #[test]
3161    fn schedule_rejects_blank_tag() {
3162        let yaml = r#"
3163id: x
3164when:
3165  per_pc: once
3166job_id: y
3167target: { all: true }
3168tags: [ok, "  "]
3169"#;
3170        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3171        let err = s.validate().expect_err("blank tag must fail");
3172        assert!(err.contains("tags must not contain empty"), "err: {err}");
3173    }
3174
3175    // ---- `when` parsing (#418 Phase 1) ----
3176
3177    fn schedule_yaml_with(when_block: &str) -> String {
3178        format!(
3179            r#"
3180id: x
3181when:
3182{when_block}
3183job_id: y
3184target: {{ all: true }}
3185"#
3186        )
3187    }
3188
3189    #[test]
3190    fn when_per_pc_every_parses_unquoted_humantime() {
3191        // `6h` is digit-led but non-numeric → YAML string, same as
3192        // the old `cooldown: 6h` convention. No quotes needed.
3193        let s: Schedule =
3194            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { every: 6h }")).expect("parse");
3195        assert_eq!(
3196            s.when,
3197            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
3198        );
3199    }
3200
3201    #[test]
3202    fn when_per_target_every_parses() {
3203        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with("  per_target: { every: 24h }"))
3204            .expect("parse");
3205        assert_eq!(
3206            s.when,
3207            When::PerTarget(PerPolicy::Every(EverySpec {
3208                every: "24h".into()
3209            }))
3210        );
3211    }
3212
3213    #[test]
3214    fn when_per_target_once_parses() {
3215        // Falls out of the shared PerPolicy shape and decide_fire
3216        // already implements it ("any one pc succeeds → skip the
3217        // target forever"), so it is allowed, not rejected.
3218        let s: Schedule =
3219            serde_yaml::from_str(&schedule_yaml_with("  per_target: once")).expect("parse");
3220        assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
3221    }
3222
3223    #[test]
3224    fn when_calendar_time_parses() {
3225        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
3226            "  calendar:\n    at: \"09:00\"\n    days: [mon-fri]",
3227        ))
3228        .expect("parse");
3229        match &s.when {
3230            When::Calendar(c) => {
3231                assert_eq!(c.at, "09:00");
3232                assert_eq!(c.days, vec!["mon-fri"]);
3233            }
3234            other => panic!("expected calendar, got {other:?}"),
3235        }
3236    }
3237
3238    #[test]
3239    fn when_calendar_days_default_empty() {
3240        let s: Schedule =
3241            serde_yaml::from_str(&schedule_yaml_with("  calendar:\n    at: \"09:00\""))
3242                .expect("parse");
3243        match &s.when {
3244            When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
3245            other => panic!("expected calendar, got {other:?}"),
3246        }
3247    }
3248
3249    #[test]
3250    fn when_calendar_datetime_parses_all_separators() {
3251        // one-shot: date+time in hyphen / ISO-T / slash forms
3252        for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
3253            let block = format!("  calendar:\n    at: \"{at}\"");
3254            let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
3255                .unwrap_or_else(|e| panic!("parse '{at}': {e}"));
3256            match &s.when {
3257                When::Calendar(c) => {
3258                    use chrono::Datelike;
3259                    let p = c.parse_at().expect("parse_at");
3260                    let d = p.date.expect("datetime at carries a date");
3261                    assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
3262                }
3263                other => panic!("expected calendar, got {other:?}"),
3264            }
3265        }
3266    }
3267
3268    #[test]
3269    fn when_rejects_bad_once_keyword() {
3270        // `onec` must be a parse error, not a silently-absorbed
3271        // string (OnceLiteral is a single-variant enum for exactly
3272        // this reason).
3273        let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with("  per_pc: onec"));
3274        assert!(r.is_err(), "expected parse error, got {r:?}");
3275    }
3276
3277    #[test]
3278    fn when_rejects_unknown_key_in_every() {
3279        // `{ evry: 6h }` still fails on the tolerant read path: the
3280        // required `every` key is missing, so no PerPolicy variant
3281        // matches (#492 removed deny_unknown_fields, but required
3282        // keys keep the untagged disambiguation honest).
3283        let r: Result<Schedule, _> =
3284            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { evry: 6h }"));
3285        assert!(r.is_err(), "expected parse error, got {r:?}");
3286    }
3287
3288    #[test]
3289    fn when_rejects_unknown_variant() {
3290        let r: Result<Schedule, _> =
3291            serde_yaml::from_str(&schedule_yaml_with("  per_galaxy: once"));
3292        assert!(r.is_err(), "expected parse error, got {r:?}");
3293    }
3294
3295    #[test]
3296    fn when_rejects_old_top_level_cron_field() {
3297        // Pre-#418 shape: top-level `cron:` + no `when:`. Must fail
3298        // loudly (missing `when`), which is what turns stale KV
3299        // blobs into warn-skips after the upgrade.
3300        let yaml = r#"
3301id: x
3302cron: "* * * * * *"
3303job_id: y
3304target: { all: true }
3305"#;
3306        let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
3307        assert!(r.is_err(), "expected parse error, got {r:?}");
3308    }
3309
3310    #[test]
3311    fn when_rejects_retired_cron_escape_hatch() {
3312        // #418 Phase 2 retired `when: { cron: "..." }`. A raw cron
3313        // is now an unknown variant → parse error (operators use the
3314        // calendar form instead).
3315        let r: Result<Schedule, _> =
3316            serde_yaml::from_str(&schedule_yaml_with("  cron: \"0 0 9 * * mon-fri\""));
3317        assert!(
3318            r.is_err(),
3319            "expected parse error for retired cron, got {r:?}"
3320        );
3321    }
3322
3323    #[test]
3324    fn when_round_trips_json_and_yaml() {
3325        // Round-trip through the full Schedule: that is the wire
3326        // unit for both stores (JSON catalog KV + YAML mirror), and
3327        // it exercises the singleton_map field attribute that keeps
3328        // serde_yaml on the map shape instead of `!per_pc` tags.
3329        for when in [
3330            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3331            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3332            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3333            When::PerTarget(PerPolicy::Every(EverySpec {
3334                every: "24h".into(),
3335            })),
3336            calendar("09:00", &["mon-fri"]),
3337            calendar("2026-06-10 09:00", &[]),
3338            When::On(vec![OnTrigger::Startup]),
3339            When::On(vec![OnTrigger::Startup, OnTrigger::Logon]),
3340            When::On(vec![OnTrigger::Lock, OnTrigger::Unlock]),
3341            When::On(vec![OnTrigger::NetworkChange]),
3342        ] {
3343            // Event triggers are agent-only; the rest validate on backend.
3344            let runs_on = if matches!(when, When::On(_)) {
3345                RunsOn::Agent
3346            } else {
3347                RunsOn::Backend
3348            };
3349            let s = schedule_with(when.clone(), runs_on);
3350
3351            let json = serde_json::to_string(&s).expect("json serialise");
3352            let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
3353            assert_eq!(back.when, when, "json round-trip for {when}");
3354
3355            let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
3356            assert!(
3357                !yaml.contains('!'),
3358                "yaml must use the map shape, not tags: {yaml}"
3359            );
3360            let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
3361            assert_eq!(back.when, when, "yaml round-trip for {when}");
3362        }
3363    }
3364
3365    #[test]
3366    fn when_once_serialises_as_bare_keyword() {
3367        // The wire shape operators see in the YAML mirror must stay
3368        // the ergonomic `per_pc: once`, not a one-variant map.
3369        let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
3370            .expect("serialise");
3371        assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
3372    }
3373
3374    #[test]
3375    fn when_displays_operator_summary() {
3376        for (when, expected) in [
3377            (
3378                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3379                "per_pc once",
3380            ),
3381            (
3382                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3383                "per_pc every 6h",
3384            ),
3385            (
3386                When::PerTarget(PerPolicy::Every(EverySpec {
3387                    every: "24h".into(),
3388                })),
3389                "per_target every 24h",
3390            ),
3391            (calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
3392            (calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
3393            (When::On(vec![OnTrigger::Startup]), "on [startup]"),
3394            (
3395                When::On(vec![OnTrigger::Startup, OnTrigger::Logon]),
3396                "on [startup,logon]",
3397            ),
3398            (
3399                When::On(vec![OnTrigger::Lock, OnTrigger::Unlock]),
3400                "on [lock,unlock]",
3401            ),
3402            (
3403                When::On(vec![OnTrigger::NetworkChange]),
3404                "on [network_change]",
3405            ),
3406        ] {
3407            assert_eq!(when.to_string(), expected);
3408        }
3409    }
3410
3411    // ---- lowering (#418: when → engine vocabulary) ----
3412
3413    fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
3414        Schedule {
3415            id: "x".into(),
3416            when,
3417            job_id: "y".into(),
3418            plan: FanoutPlan::default(),
3419            active: Active::default(),
3420            constraints: Constraints::default(),
3421            on_failure: OnFailure::default(),
3422            tz: ScheduleTz::default(),
3423            starting_deadline: None,
3424            runs_on,
3425            enabled: true,
3426            tags: Vec::new(),
3427            origin: None,
3428        }
3429    }
3430
3431    fn calendar(at: &str, days: &[&str]) -> When {
3432        When::Calendar(CalendarSpec {
3433            at: at.into(),
3434            days: days.iter().map(|d| (*d).to_string()).collect(),
3435        })
3436    }
3437
3438    #[test]
3439    fn next_calendar_fire_returns_next_utc_occurrence() {
3440        use chrono::TimeZone;
3441        // Daily 09:00, evaluated in UTC. From 08:00 the same day, the
3442        // next strict occurrence is 09:00 that day.
3443        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
3444        s.tz = ScheduleTz::Utc;
3445        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 8, 0, 0).unwrap();
3446        let next = s.next_calendar_fire(now).expect("calendar has a next fire");
3447        assert_eq!(
3448            next,
3449            chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap()
3450        );
3451    }
3452
3453    #[test]
3454    fn next_calendar_fire_is_strictly_after_now() {
3455        use chrono::TimeZone;
3456        // Standing exactly on a fire instant must preview the *next*
3457        // one (inclusive = false), not the one firing right now.
3458        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
3459        s.tz = ScheduleTz::Utc;
3460        let on_fire = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap();
3461        let next = s
3462            .next_calendar_fire(on_fire)
3463            .expect("calendar has a next fire");
3464        assert_eq!(
3465            next,
3466            chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap()
3467        );
3468    }
3469
3470    #[test]
3471    fn next_calendar_fire_none_for_reconcile_shapes() {
3472        // `per_pc` / `per_target` lower to the every-minute poll cron —
3473        // no discrete upcoming event to preview, so `None`.
3474        let now = chrono::Utc::now();
3475        for when in [
3476            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3477            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3478            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3479            When::PerTarget(PerPolicy::Every(EverySpec {
3480                every: "24h".into(),
3481            })),
3482        ] {
3483            let s = schedule_with(when, RunsOn::Backend);
3484            assert!(
3485                s.next_calendar_fire(now).is_none(),
3486                "reconcile shapes have no calendar fire",
3487            );
3488        }
3489    }
3490
3491    // ---- preview_fires (#418 dry-run / preview) ----
3492
3493    fn cal_utc(at: &str, days: &[&str]) -> Schedule {
3494        let mut s = schedule_with(calendar(at, days), RunsOn::Backend);
3495        s.tz = ScheduleTz::Utc; // host-independent assertions
3496        s
3497    }
3498
3499    #[test]
3500    fn preview_lists_next_calendar_occurrences() {
3501        use chrono::TimeZone;
3502        // Weekday 09:00, from Wed 2026-06-10 00:00 UTC: the next five
3503        // fires skip the weekend (Sat 13 / Sun 14).
3504        let s = cal_utc("09:00", &["mon-fri"]);
3505        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
3506        let got = s.preview_fires(now, 5);
3507        let want: Vec<_> = [
3508            (2026, 6, 10), // Wed
3509            (2026, 6, 11), // Thu
3510            (2026, 6, 12), // Fri
3511            (2026, 6, 15), // Mon (skips Sat 13 / Sun 14)
3512            (2026, 6, 16), // Tue
3513        ]
3514        .iter()
3515        .map(|(y, m, d)| chrono::Utc.with_ymd_and_hms(*y, *m, *d, 9, 0, 0).unwrap())
3516        .collect();
3517        assert_eq!(got, want);
3518    }
3519
3520    #[test]
3521    fn preview_handles_nth_and_last_weekday() {
3522        use chrono::TimeZone;
3523        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
3524        // 2nd Tuesday (Patch Tuesday): Jun 9, Jul 14 2026.
3525        let nth = cal_utc("09:00", &["tue#2"]).preview_fires(now, 2);
3526        assert_eq!(
3527            nth,
3528            vec![
3529                chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap(),
3530                chrono::Utc.with_ymd_and_hms(2026, 7, 14, 9, 0, 0).unwrap(),
3531            ]
3532        );
3533        // Last Friday of the month: Jun 26, Jul 31 2026.
3534        let last = cal_utc("22:00", &["friL"]).preview_fires(now, 2);
3535        assert_eq!(
3536            last,
3537            vec![
3538                chrono::Utc.with_ymd_and_hms(2026, 6, 26, 22, 0, 0).unwrap(),
3539                chrono::Utc.with_ymd_and_hms(2026, 7, 31, 22, 0, 0).unwrap(),
3540            ]
3541        );
3542    }
3543
3544    #[test]
3545    fn preview_is_empty_for_reconcile_and_zero_count() {
3546        let now = chrono::Utc::now();
3547        // reconcile shapes have no discrete fire times
3548        let recon = schedule_with(
3549            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3550            RunsOn::Backend,
3551        );
3552        assert!(recon.preview_fires(now, 5).is_empty());
3553        // count == 0 yields nothing even for a calendar
3554        assert!(cal_utc("09:00", &[]).preview_fires(now, 0).is_empty());
3555    }
3556
3557    #[test]
3558    fn preview_skips_outside_active_window() {
3559        use chrono::TimeZone;
3560        // Daily 09:00, active only [2026-06-15, 2026-06-17). Occurrences
3561        // before `from` are skipped; `until` is exclusive, so 06-17's
3562        // fire is out — leaving exactly the 15th and 16th.
3563        let mut s = cal_utc("09:00", &[]);
3564        s.active = Active {
3565            from: Some("2026-06-15".into()),
3566            until: Some("2026-06-17".into()),
3567        };
3568        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
3569        let got = s.preview_fires(now, 5);
3570        assert_eq!(
3571            got,
3572            vec![
3573                chrono::Utc.with_ymd_and_hms(2026, 6, 15, 9, 0, 0).unwrap(),
3574                chrono::Utc.with_ymd_and_hms(2026, 6, 16, 9, 0, 0).unwrap(),
3575            ]
3576        );
3577    }
3578
3579    #[test]
3580    fn preview_empty_when_calendar_time_outside_window() {
3581        use chrono::TimeZone;
3582        // Fires at 09:00 but the maintenance window is overnight — it can
3583        // never run, so the preview is empty (matches
3584        // `calendar_outside_window`), and the scan still terminates.
3585        let mut s = cal_utc("09:00", &[]);
3586        s.constraints = Constraints {
3587            window: Some("22:00-05:00".into()),
3588            ..Constraints::default()
3589        };
3590        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
3591        assert!(s.preview_fires(now, 5).is_empty());
3592        // Every candidate tick is rejected, so this also exercises the
3593        // SCAN_CAP bound: a large `count` must still terminate (and
3594        // return empty) rather than spin (claude #578 review).
3595        assert!(s.preview_fires(now, 50).is_empty());
3596    }
3597
3598    #[test]
3599    fn preview_past_one_shot_is_empty() {
3600        use chrono::TimeZone;
3601        // A dated one-shot whose instant has passed never fires again.
3602        let s = cal_utc("2026-06-10 09:00", &[]);
3603        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 0, 0, 0).unwrap();
3604        assert!(s.preview_fires(now, 5).is_empty());
3605        // …but from before it, the single future fire shows up.
3606        let before = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
3607        assert_eq!(
3608            s.preview_fires(before, 5),
3609            vec![chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap()]
3610        );
3611    }
3612
3613    #[test]
3614    fn lowering_matches_the_418_table() {
3615        let cases = [
3616            (
3617                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3618                (POLL_CRON, ExecMode::OncePerPc, None),
3619            ),
3620            (
3621                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3622                (POLL_CRON, ExecMode::OncePerPc, Some("6h")),
3623            ),
3624            (
3625                When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3626                (POLL_CRON, ExecMode::OncePerTarget, None),
3627            ),
3628            (
3629                When::PerTarget(PerPolicy::Every(EverySpec {
3630                    every: "24h".into(),
3631                })),
3632                (POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
3633            ),
3634            // calendar repeating → 6-field cron
3635            (
3636                calendar("09:00", &["mon-fri"]),
3637                ("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
3638            ),
3639            // calendar daily (no days) → DOW *
3640            (
3641                calendar("18:30", &[]),
3642                ("0 30 18 * * *", ExecMode::EveryTick, None),
3643            ),
3644            // calendar one-shot → 7-field year cron
3645            (
3646                calendar("2026-06-10 09:00", &[]),
3647                ("0 0 9 10 6 * 2026", ExecMode::EveryTick, None),
3648            ),
3649        ];
3650        for (when, (cron, mode, cooldown)) in cases {
3651            let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
3652            assert_eq!(l.cron, cron, "cron for {when}");
3653            assert_eq!(l.mode, mode, "mode for {when}");
3654            assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
3655        }
3656    }
3657
3658    #[test]
3659    fn lowered_carries_schedule_tz() {
3660        for (tz, want) in [
3661            (ScheduleTz::Local, ScheduleTz::Local),
3662            (ScheduleTz::Utc, ScheduleTz::Utc),
3663        ] {
3664            let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
3665            s.tz = tz;
3666            assert_eq!(s.lowered().tz, want, "calendar carries tz");
3667            // reconcile shapes carry tz too (for the active-window check)
3668            let mut s = schedule_with(
3669                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3670                RunsOn::Backend,
3671            );
3672            s.tz = tz;
3673            assert_eq!(s.lowered().tz, want, "reconcile carries tz");
3674        }
3675    }
3676
3677    #[test]
3678    fn poll_cron_is_accepted_by_the_engine_parser() {
3679        // POLL_CRON is system-generated — if the engine's parser
3680        // ever rejected it every reconcile schedule would die at
3681        // register time. Validate it with the same croner config
3682        // (Seconds::Required, dom_and_dow, year optional).
3683        croner::parser::CronParser::builder()
3684            .seconds(croner::parser::Seconds::Required)
3685            .dom_and_dow(true)
3686            .build()
3687            .parse(POLL_CRON)
3688            .expect("POLL_CRON must parse");
3689    }
3690
3691    // ---- Schedule::validate() (#418 decision F) ----
3692
3693    #[test]
3694    fn validate_accepts_reconcile_shapes() {
3695        for when in [
3696            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3697            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3698            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3699            When::PerTarget(PerPolicy::Every(EverySpec {
3700                every: "24h".into(),
3701            })),
3702        ] {
3703            schedule_with(when.clone(), RunsOn::Backend)
3704                .validate()
3705                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
3706        }
3707    }
3708
3709    #[test]
3710    fn validate_accepts_per_pc_on_agent() {
3711        schedule_with(
3712            When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
3713            RunsOn::Agent,
3714        )
3715        .validate()
3716        .expect("per_pc + agent is the offline-inventory shape");
3717    }
3718
3719    // ---- #418 event triggers (when: { on }) ----
3720
3721    #[test]
3722    fn validate_accepts_event_on_agent() {
3723        for triggers in [
3724            vec![OnTrigger::Startup],
3725            vec![OnTrigger::Logon],
3726            vec![OnTrigger::Lock],
3727            vec![OnTrigger::Unlock],
3728            vec![OnTrigger::NetworkChange],
3729            vec![
3730                OnTrigger::Startup,
3731                OnTrigger::Logon,
3732                OnTrigger::Lock,
3733                OnTrigger::Unlock,
3734                OnTrigger::NetworkChange,
3735            ],
3736        ] {
3737            schedule_with(When::On(triggers), RunsOn::Agent)
3738                .validate()
3739                .expect("when.on is valid on runs_on: agent");
3740        }
3741    }
3742
3743    #[test]
3744    fn validate_rejects_event_on_backend() {
3745        let err = schedule_with(When::On(vec![OnTrigger::Startup]), RunsOn::Backend)
3746            .validate()
3747            .unwrap_err();
3748        assert!(err.contains("when.on"), "got: {err}");
3749        assert!(err.contains("runs_on: agent"), "got: {err}");
3750    }
3751
3752    #[test]
3753    fn validate_rejects_empty_event_list() {
3754        let err = schedule_with(When::On(vec![]), RunsOn::Agent)
3755            .validate()
3756            .unwrap_err();
3757        assert!(err.contains("when.on"), "got: {err}");
3758        assert!(err.contains("at least one"), "got: {err}");
3759    }
3760
3761    #[test]
3762    fn event_schedule_lowers_to_event_mode_and_is_event() {
3763        let s = schedule_with(When::On(vec![OnTrigger::Startup]), RunsOn::Agent);
3764        assert!(s.is_event());
3765        assert_eq!(s.lowered().mode, ExecMode::Event);
3766        assert_eq!(s.event_triggers(), &[OnTrigger::Startup]);
3767        // non-event schedules report no triggers.
3768        let cal = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
3769        assert!(!cal.is_event());
3770        assert!(cal.event_triggers().is_empty());
3771    }
3772
3773    // ---- #418 constraints.require (env gates) ----
3774
3775    fn require_schedule(req: Require, runs_on: RunsOn) -> Schedule {
3776        let mut s = schedule_with(
3777            When::PerPc(PerPolicy::Every(EverySpec { every: "1m".into() })),
3778            runs_on,
3779        );
3780        s.constraints.require = Some(req);
3781        s
3782    }
3783
3784    #[test]
3785    fn require_met_combinations() {
3786        use std::time::Duration;
3787        let idle = |m: u64| Some(Duration::from_secs(m * 60));
3788        // Builder for the sensed state: (ac, idle, cpu, network).
3789        let env = |ac, idle, cpu, net| EnvState {
3790            ac_online: ac,
3791            idle,
3792            cpu_pct: cpu,
3793            network_up: net,
3794        };
3795        // Empty require — always met regardless of sensed state.
3796        assert!(require_met(
3797            &Require::default(),
3798            &env(false, None, None, false)
3799        ));
3800        // ac_power: only on AC.
3801        let ac = Require {
3802            ac_power: true,
3803            ..Default::default()
3804        };
3805        assert!(!require_met(&ac, &env(false, None, None, true)));
3806        assert!(require_met(&ac, &env(true, None, None, false)));
3807        // idle: needs >= the configured min; None idle never satisfies.
3808        let idle10 = Require {
3809            idle: Some("10m".into()),
3810            ..Default::default()
3811        };
3812        assert!(!require_met(&idle10, &env(true, None, None, true)));
3813        assert!(!require_met(&idle10, &env(true, idle(5), None, true)));
3814        assert!(require_met(&idle10, &env(true, idle(15), None, true)));
3815        assert!(require_met(&idle10, &env(true, idle(10), None, true))); // boundary inclusive
3816        // cpu_below: needs CPU strictly < threshold; None cpu never satisfies.
3817        let cpu20 = Require {
3818            cpu_below: Some(20.0),
3819            ..Default::default()
3820        };
3821        assert!(!require_met(&cpu20, &env(true, None, None, true))); // no sample → fail-closed
3822        assert!(!require_met(&cpu20, &env(true, None, Some(20.0), true))); // == threshold
3823        assert!(!require_met(&cpu20, &env(true, None, Some(55.0), true))); // busy
3824        assert!(require_met(&cpu20, &env(true, None, Some(5.0), true))); // quiet
3825        // network: only when online.
3826        let net = Require {
3827            network: true,
3828            ..Default::default()
3829        };
3830        assert!(!require_met(&net, &env(true, None, None, false))); // offline
3831        assert!(require_met(&net, &env(true, None, None, true))); // online
3832        // all four: AND.
3833        let all = Require {
3834            ac_power: true,
3835            idle: Some("10m".into()),
3836            cpu_below: Some(20.0),
3837            network: true,
3838        };
3839        assert!(!require_met(&all, &env(false, idle(20), Some(5.0), true))); // on battery
3840        assert!(!require_met(&all, &env(true, idle(1), Some(5.0), true))); // not idle enough
3841        assert!(!require_met(&all, &env(true, idle(20), Some(50.0), true))); // busy
3842        assert!(!require_met(&all, &env(true, idle(20), Some(5.0), false))); // offline
3843        assert!(require_met(&all, &env(true, idle(20), Some(5.0), true)));
3844        // An unparseable idle is treated as no-requirement by require_met
3845        // (validate rejects it at create time, so this only guards a
3846        // hand-edited blob): ac still gates.
3847        let bad = Require {
3848            ac_power: true,
3849            idle: Some("garbage".into()),
3850            ..Default::default()
3851        };
3852        assert!(require_met(&bad, &env(true, None, None, true)));
3853        assert!(!require_met(&bad, &env(false, None, None, true)));
3854    }
3855
3856    #[test]
3857    fn validate_accepts_and_rejects_cpu_below() {
3858        // In-range accepted.
3859        require_schedule(
3860            Require {
3861                cpu_below: Some(20.0),
3862                ..Default::default()
3863            },
3864            RunsOn::Agent,
3865        )
3866        .validate()
3867        .expect("cpu_below 20 is valid");
3868        // Upper boundary: 100.0 is accepted (fires unless CPU is exactly
3869        // 100%). Pins the inclusive upper bound against a future c < 100.0.
3870        require_schedule(
3871            Require {
3872                cpu_below: Some(100.0),
3873                ..Default::default()
3874            },
3875            RunsOn::Agent,
3876        )
3877        .validate()
3878        .expect("cpu_below 100 is valid");
3879        // Out of range rejected (0 and >100).
3880        for bad in [0.0, -5.0, 100.1] {
3881            let err = require_schedule(
3882                Require {
3883                    cpu_below: Some(bad),
3884                    ..Default::default()
3885                },
3886                RunsOn::Agent,
3887            )
3888            .validate()
3889            .unwrap_err();
3890            assert!(
3891                err.contains("constraints.require.cpu_below"),
3892                "cpu_below {bad}: {err}"
3893            );
3894        }
3895    }
3896
3897    #[test]
3898    fn validate_accepts_require_on_agent() {
3899        require_schedule(
3900            Require {
3901                ac_power: true,
3902                idle: Some("10m".into()),
3903                cpu_below: Some(20.0),
3904                network: true,
3905            },
3906            RunsOn::Agent,
3907        )
3908        .validate()
3909        .expect("constraints.require is valid on runs_on: agent");
3910    }
3911
3912    #[test]
3913    fn validate_rejects_require_on_backend() {
3914        let err = require_schedule(
3915            Require {
3916                ac_power: true,
3917                ..Default::default()
3918            },
3919            RunsOn::Backend,
3920        )
3921        .validate()
3922        .unwrap_err();
3923        assert!(err.contains("constraints.require"), "got: {err}");
3924        assert!(err.contains("runs_on: agent"), "got: {err}");
3925
3926        // An idle-only require (ac_power: false) is also non-empty
3927        // (is_empty folds the fields) and must reject on backend too —
3928        // guards against a regression in Require::is_empty.
3929        let err = require_schedule(
3930            Require {
3931                idle: Some("10m".into()),
3932                ..Default::default()
3933            },
3934            RunsOn::Backend,
3935        )
3936        .validate()
3937        .unwrap_err();
3938        assert!(
3939            err.contains("constraints.require"),
3940            "idle-only on backend: {err}"
3941        );
3942    }
3943
3944    #[test]
3945    fn validate_rejects_bad_require_idle() {
3946        let err = require_schedule(
3947            Require {
3948                idle: Some("not-a-duration".into()),
3949                ..Default::default()
3950            },
3951            RunsOn::Agent,
3952        )
3953        .validate()
3954        .unwrap_err();
3955        assert!(err.contains("constraints.require.idle"), "got: {err}");
3956    }
3957
3958    #[test]
3959    fn require_round_trips_and_skips_empty() {
3960        // ac_power: false is skipped; an all-default require nested in
3961        // constraints is omitted (is_empty folds it in).
3962        let yaml = "id: s\nwhen: { per_pc: { every: 1m } }\njob_id: j\nruns_on: agent\n\
3963                    constraints: { require: { ac_power: true, idle: 10m, cpu_below: 20, \
3964                    network: true } }\n";
3965        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3966        let req = s.constraints.require.as_ref().expect("require present");
3967        assert!(req.ac_power);
3968        assert_eq!(req.idle.as_deref(), Some("10m"));
3969        assert_eq!(req.cpu_below, Some(20.0));
3970        assert!(req.network);
3971        // Re-serialize: idle + cpu_below + network present, ac_power true.
3972        let back = serde_json::to_string(&s.constraints).unwrap();
3973        assert!(back.contains("\"idle\":\"10m\""), "got: {back}");
3974        assert!(back.contains("\"cpu_below\":20"), "got: {back}");
3975        assert!(back.contains("\"network\":true"), "got: {back}");
3976        // An empty require is omitted entirely by is_empty.
3977        let mut empty = s.clone();
3978        empty.constraints.require = Some(Require::default());
3979        assert!(empty.constraints.is_empty());
3980    }
3981
3982    #[test]
3983    fn validate_rejects_per_target_on_agent() {
3984        let err = schedule_with(
3985            When::PerTarget(PerPolicy::Every(EverySpec {
3986                every: "24h".into(),
3987            })),
3988            RunsOn::Agent,
3989        )
3990        .validate()
3991        .unwrap_err();
3992        assert!(err.contains("per_target"), "got: {err}");
3993        assert!(err.contains("runs_on: agent"), "got: {err}");
3994
3995        // per_target: once is also backend-only.
3996        let err = schedule_with(
3997            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
3998            RunsOn::Agent,
3999        )
4000        .validate()
4001        .unwrap_err();
4002        assert!(err.contains("per_target"), "got (once): {err}");
4003        assert!(err.contains("runs_on: agent"), "got (once): {err}");
4004    }
4005
4006    #[test]
4007    fn validate_rejects_bad_every_duration() {
4008        let err = schedule_with(
4009            When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
4010            RunsOn::Backend,
4011        )
4012        .validate()
4013        .unwrap_err();
4014        assert!(err.contains("when.every"), "got: {err}");
4015    }
4016
4017    #[test]
4018    fn validate_rejects_bad_jitter_and_starting_deadline() {
4019        let mut s = schedule_with(
4020            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4021            RunsOn::Backend,
4022        );
4023        s.plan.jitter = Some("5x".into());
4024        let err = s.validate().unwrap_err();
4025        assert!(err.contains("jitter"), "got: {err}");
4026
4027        let mut s = schedule_with(
4028            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4029            RunsOn::Backend,
4030        );
4031        s.starting_deadline = Some("soon".into());
4032        let err = s.validate().unwrap_err();
4033        assert!(err.contains("starting_deadline"), "got: {err}");
4034    }
4035
4036    #[test]
4037    fn validate_accepts_calendar_shapes() {
4038        for when in [
4039            calendar("09:00", &["mon-fri"]),   // weekday morning
4040            calendar("00:00", &["sun"]),       // weekly
4041            calendar("18:30", &[]),            // daily
4042            calendar("2026-06-10 09:00", &[]), // one-shot
4043            calendar("2026/12/25 00:00", &[]), // one-shot, slash form
4044        ] {
4045            schedule_with(when.clone(), RunsOn::Backend)
4046                .validate()
4047                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
4048        }
4049    }
4050
4051    #[test]
4052    fn validate_rejects_bad_at() {
4053        for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
4054            let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
4055                .validate()
4056                .unwrap_err();
4057            assert!(err.contains("when.at"), "for '{bad}', got: {err}");
4058        }
4059    }
4060
4061    #[test]
4062    fn validate_rejects_datetime_at_with_days() {
4063        // A dated `at` is a one-shot — pairing it with days is a
4064        // contradiction (the date already pins the day).
4065        let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
4066            .validate()
4067            .unwrap_err();
4068        assert!(
4069            err.contains("one-shot") && err.contains("days"),
4070            "got: {err}"
4071        );
4072    }
4073
4074    #[test]
4075    fn validate_rejects_bad_day_name() {
4076        // A garbage DOW token is caught by the days pre-flight and
4077        // reported against `when.days`, not the confusing
4078        // "when.at lowered to invalid cron" (claude #432 review).
4079        let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
4080            .validate()
4081            .unwrap_err();
4082        assert!(err.contains("when.days"), "got: {err}");
4083        assert!(err.contains("funday"), "names the bad token: {err}");
4084        // a degenerate range like `mon-` reports the whole token, not
4085        // a cryptic empty part (claude #432 follow-up)
4086        let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
4087            .validate()
4088            .unwrap_err();
4089        assert!(err.contains("'mon-'"), "names the whole token: {err}");
4090        // valid names / ranges / numeric / * all pass
4091        for ok in [
4092            calendar("09:00", &["mon-fri"]),
4093            calendar("09:00", &["mon", "wed", "sun"]),
4094            calendar("09:00", &["1-5"]),
4095        ] {
4096            schedule_with(ok.clone(), RunsOn::Backend)
4097                .validate()
4098                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
4099        }
4100    }
4101
4102    #[test]
4103    fn validate_accepts_nth_weekday() {
4104        // #418: nth-weekday (Patch Tuesday). validate() also lowers to
4105        // a cron and parses it with croner, so passing here proves the
4106        // whole chain — token → DOW field → engine-acceptable cron.
4107        for ok in [
4108            calendar("09:00", &["tue#2"]),          // 2nd Tuesday
4109            calendar("09:00", &["fri#1"]),          // 1st Friday
4110            calendar("03:00", &["sun#5"]),          // 5th Sunday
4111            calendar("09:00", &["tue#2", "thu#2"]), // a list of nths
4112            calendar("09:00", &["2#2"]),            // numeric DOW + ordinal
4113            // Case-insensitive both sides: validate lowercases, croner
4114            // upper-cases the whole pattern before aliasing (claude #547).
4115            calendar("09:00", &["TUE#2"]),
4116        ] {
4117            schedule_with(ok.clone(), RunsOn::Backend)
4118                .validate()
4119                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
4120        }
4121    }
4122
4123    #[test]
4124    fn validate_rejects_bad_nth_weekday() {
4125        // ordinal out of 1..5, a range with #, and a bad day before #.
4126        for bad in ["tue#0", "tue#6", "tue#x", "mon-fri#2", "funday#2"] {
4127            let err = schedule_with(calendar("09:00", &[bad]), RunsOn::Backend)
4128                .validate()
4129                .unwrap_err();
4130            assert!(err.contains("when.days"), "for '{bad}', got: {err}");
4131        }
4132    }
4133
4134    #[test]
4135    fn validate_accepts_last_weekday() {
4136        // #418: last-weekday (`friL` = last Friday). Like the nth case,
4137        // validate() lowers to a cron and round-trips it through croner,
4138        // so passing proves token → DOW field → engine-acceptable cron
4139        // with the verified last-<dow>-of-month semantics.
4140        for ok in [
4141            calendar("09:00", &["friL"]),         // last Friday
4142            calendar("03:00", &["sunL"]),         // last Sunday
4143            calendar("22:00", &["5L"]),           // numeric DOW + last
4144            calendar("00:00", &["0L"]),           // numeric Sunday (0…
4145            calendar("00:00", &["7L"]),           // …and its 7 alias)
4146            calendar("09:00", &["monL", "friL"]), // a list of last-weekdays
4147            // Case-insensitive both the weekday and the `L` suffix:
4148            // validate lowercases the day, croner upper-cases the whole
4149            // pattern before aliasing (claude #547).
4150            calendar("09:00", &["FRIL"]),
4151            calendar("09:00", &["fril"]),
4152        ] {
4153            schedule_with(ok.clone(), RunsOn::Backend)
4154                .validate()
4155                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
4156        }
4157    }
4158
4159    #[test]
4160    fn validate_rejects_bad_last_weekday() {
4161        // bare `L` (no weekday — a footgun croner reads as Saturday), a
4162        // range with L, a bad day before L, and an internal space that
4163        // would otherwise leak a malformed cron downstream (gemini #560).
4164        for bad in ["L", "l", "mon-friL", "fundayL", "8L", "*L", "fri L"] {
4165            let err = schedule_with(calendar("09:00", &[bad]), RunsOn::Backend)
4166                .validate()
4167                .unwrap_err();
4168            assert!(err.contains("when.days"), "for '{bad}', got: {err}");
4169        }
4170    }
4171
4172    #[test]
4173    fn calendar_oneshot_instant_detects_past() {
4174        use chrono::TimeZone;
4175        // a dated `at` resolves to an absolute instant…
4176        let c = CalendarSpec {
4177            at: "2024-01-01 09:00".into(),
4178            days: vec![],
4179        };
4180        let t = c
4181            .oneshot_instant(ScheduleTz::Utc)
4182            .expect("one-shot instant");
4183        assert_eq!(
4184            t,
4185            chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
4186        );
4187        assert!(t < chrono::Utc::now(), "2024 is in the past");
4188        // …while a repeating (time-only) calendar has no instant
4189        let rep = CalendarSpec {
4190            at: "09:00".into(),
4191            days: vec!["mon-fri".into()],
4192        };
4193        assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
4194    }
4195
4196    fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
4197        let mut s = schedule_with(
4198            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4199            RunsOn::Backend,
4200        );
4201        s.active = Active {
4202            from: from.map(str::to_owned),
4203            until: until.map(str::to_owned),
4204        };
4205        s
4206    }
4207
4208    #[test]
4209    fn validate_accepts_active_window() {
4210        schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
4211            .validate()
4212            .expect("date + rfc3339 bounds should validate");
4213    }
4214
4215    #[test]
4216    fn validate_rejects_unparseable_active_bound() {
4217        let err = schedule_with_active(Some("July 1st"), None)
4218            .validate()
4219            .unwrap_err();
4220        assert!(err.contains("active"), "got: {err}");
4221    }
4222
4223    #[test]
4224    fn validate_rejects_from_not_before_until() {
4225        let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
4226            .validate()
4227            .unwrap_err();
4228        assert!(err.contains("strictly before"), "got: {err}");
4229
4230        let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
4231            .validate()
4232            .unwrap_err();
4233        assert!(err.contains("strictly before"), "got: {err}");
4234    }
4235
4236    // ---- Active window semantics ----
4237
4238    #[test]
4239    fn active_window_is_half_open() {
4240        use chrono::TimeZone;
4241        let active = Active {
4242            from: Some("2026-07-01".into()),
4243            until: Some("2026-08-01".into()),
4244        };
4245        // UTC tz so the date bounds are UTC midnight.
4246        let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
4247        let c = |t| active.contains(t, ScheduleTz::Utc);
4248        assert!(!c(at(2026, 6, 30, 23)), "before from");
4249        assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
4250        assert!(c(at(2026, 7, 15, 12)), "inside");
4251        assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
4252        assert!(!c(at(2026, 8, 2, 0)), "after until");
4253    }
4254
4255    #[test]
4256    fn active_empty_window_is_always_active() {
4257        assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
4258    }
4259
4260    #[test]
4261    fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
4262        use chrono::TimeZone;
4263        let active = Active {
4264            from: Some("2026-07-01T09:00:00+09:00".into()),
4265            until: None,
4266        };
4267        // RFC3339 carries its own offset → tz arg is ignored.
4268        // 09:00 JST = 00:00 UTC.
4269        for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
4270            assert!(
4271                !active.contains(
4272                    chrono::Utc
4273                        .with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
4274                        .unwrap(),
4275                    tz
4276                )
4277            );
4278            assert!(active.contains(
4279                chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
4280                tz
4281            ));
4282        }
4283    }
4284
4285    #[test]
4286    fn active_date_bound_respects_tz() {
4287        // A bare `YYYY-MM-DD` bound is midnight *in the schedule's
4288        // tz* (#418 Phase 2). The UTC interpretation is exact and
4289        // host-independent; assert that precisely.
4290        use chrono::TimeZone;
4291        let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
4292        assert_eq!(
4293            utc,
4294            chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
4295        );
4296
4297        // The local interpretation must equal what chrono::Local
4298        // computes for the same wall-clock midnight — proves the tz
4299        // path is wired to the host zone (the magnitude vs UTC is
4300        // host-dependent, so we compare against Local directly rather
4301        // than hard-coding the JST offset, keeping CI green on UTC
4302        // runners).
4303        let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
4304        let want = chrono::Local
4305            .with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
4306            .single()
4307            .expect("local midnight is unambiguous")
4308            .with_timezone(&chrono::Utc);
4309        assert_eq!(local, want, "date bound resolved in host-local tz");
4310    }
4311
4312    #[test]
4313    fn active_empty_is_skipped_when_serialising() {
4314        let s = schedule_with(
4315            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4316            RunsOn::Backend,
4317        );
4318        let json = serde_json::to_value(&s).expect("serialise");
4319        assert!(
4320            json.get("active").is_none(),
4321            "empty active must not appear on the wire: {json}"
4322        );
4323    }
4324
4325    // ---- constraints.window (#418 Phase 3) ----
4326
4327    fn with_window(win: &str) -> Schedule {
4328        let mut s = schedule_with(
4329            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4330            RunsOn::Backend,
4331        );
4332        s.constraints.window = Some(win.into());
4333        s
4334    }
4335
4336    #[test]
4337    fn constraints_window_parses_and_round_trips() {
4338        let yaml = r#"
4339id: x
4340when:
4341  per_pc: { every: 6h }
4342job_id: y
4343target: { all: true }
4344constraints:
4345  window: "22:00-05:00"
4346"#;
4347        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
4348        assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
4349        let back: Schedule =
4350            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
4351        assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
4352    }
4353
4354    #[test]
4355    fn constraints_empty_is_skipped_when_serialising() {
4356        let s = schedule_with(
4357            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4358            RunsOn::Backend,
4359        );
4360        let json = serde_json::to_value(&s).expect("serialise");
4361        assert!(
4362            json.get("constraints").is_none(),
4363            "empty constraints must not appear on the wire: {json}"
4364        );
4365    }
4366
4367    #[test]
4368    fn window_no_constraint_always_allows() {
4369        let c = Constraints::default();
4370        assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
4371    }
4372
4373    #[test]
4374    fn window_same_day_is_half_open() {
4375        use chrono::TimeZone;
4376        let s = with_window("09:00-17:00");
4377        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
4378        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
4379        assert!(!a(at(8, 59)), "before start");
4380        assert!(a(at(9, 0)), "at start (inclusive)");
4381        assert!(a(at(16, 59)), "inside");
4382        assert!(!a(at(17, 0)), "at end (exclusive)");
4383        assert!(!a(at(23, 0)), "after end");
4384    }
4385
4386    #[test]
4387    fn window_crossing_midnight() {
4388        use chrono::TimeZone;
4389        let s = with_window("22:00-05:00");
4390        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
4391        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
4392        assert!(a(at(22, 0)), "at start tonight");
4393        assert!(a(at(23, 30)), "late tonight");
4394        assert!(a(at(3, 0)), "early tomorrow");
4395        assert!(!a(at(5, 0)), "at end (exclusive)");
4396        assert!(!a(at(12, 0)), "midday outside");
4397        assert!(!a(at(21, 59)), "just before start");
4398    }
4399
4400    #[test]
4401    fn window_respects_tz() {
4402        // The same instant is inside the window under one tz and may
4403        // be outside under another. Compare UTC vs Local via the
4404        // host's own offset (kept CI-green on UTC runners like the
4405        // active tz test does).
4406        use chrono::TimeZone;
4407        let s = with_window("09:00-17:00");
4408        let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
4409        // Under UTC, 12:00 is inside 09:00-17:00.
4410        assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
4411        // Under Local, the verdict tracks the host wall-clock time;
4412        // assert it matches a direct wall_time membership check.
4413        let local_t = noon_utc.with_timezone(&chrono::Local).time();
4414        let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
4415            && local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
4416        assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
4417    }
4418
4419    #[test]
4420    fn validate_accepts_good_window() {
4421        for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
4422            with_window(w)
4423                .validate()
4424                .unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
4425        }
4426    }
4427
4428    #[test]
4429    fn validate_rejects_bad_window() {
4430        for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
4431            let err = with_window(bad).validate().unwrap_err();
4432            assert!(
4433                err.contains("constraints.window"),
4434                "for '{bad}', got: {err}"
4435            );
4436        }
4437    }
4438
4439    // ---- constraints.skip_dates (#418 holiday exclusion) ----
4440
4441    fn with_skip_dates(dates: &[&str]) -> Schedule {
4442        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
4443        s.tz = ScheduleTz::Utc; // host-independent date assertions
4444        s.constraints.skip_dates = dates.iter().map(|d| (*d).to_string()).collect();
4445        s
4446    }
4447
4448    #[test]
4449    fn allows_blocks_listed_skip_date() {
4450        use chrono::TimeZone;
4451        let s = with_skip_dates(&["2026-06-10", "2026-12-25"]);
4452        // Any time on a listed date is blocked (whole day).
4453        let on = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap();
4454        assert!(!s.constraints.allows(on, ScheduleTz::Utc));
4455        let on_midnight = chrono::Utc.with_ymd_and_hms(2026, 12, 25, 0, 0, 0).unwrap();
4456        assert!(!s.constraints.allows(on_midnight, ScheduleTz::Utc));
4457        // A date not in the list fires normally.
4458        let off = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 9, 0, 0).unwrap();
4459        assert!(s.constraints.allows(off, ScheduleTz::Utc));
4460    }
4461
4462    #[test]
4463    fn allows_corrupt_skip_date_fails_closed() {
4464        use chrono::TimeZone;
4465        // A garbled entry (only reachable via hand-edited KV) blocks
4466        // rather than silently re-enabling fires — same posture as a
4467        // corrupt window.
4468        let s = with_skip_dates(&["not-a-date"]);
4469        let any = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 9, 0, 0).unwrap();
4470        assert!(!s.constraints.allows(any, ScheduleTz::Utc));
4471    }
4472
4473    #[test]
4474    fn validate_accepts_good_skip_dates() {
4475        with_skip_dates(&["2026-01-01", "2026-12-25", "2027-05-03"])
4476            .validate()
4477            .expect("well-formed skip dates should validate");
4478    }
4479
4480    #[test]
4481    fn validate_rejects_bad_skip_date() {
4482        for bad in ["2026-13-01", "01-01-2026", "nope", "2026/01/01"] {
4483            let err = with_skip_dates(&[bad]).validate().unwrap_err();
4484            assert!(
4485                err.contains("constraints.skip_dates"),
4486                "for '{bad}', got: {err}"
4487            );
4488        }
4489    }
4490
4491    #[test]
4492    fn preview_skips_holidays() {
4493        use chrono::TimeZone;
4494        // Daily 09:00 with two of the next five days marked as holidays
4495        // — preview drops exactly those, since it gates on `allows`.
4496        let mut s = cal_utc("09:00", &[]);
4497        s.constraints.skip_dates = vec!["2026-06-11".into(), "2026-06-13".into()];
4498        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
4499        let got = s.preview_fires(now, 4);
4500        let want: Vec<_> = [
4501            (2026, 6, 10),
4502            (2026, 6, 12), // skips 06-11
4503            (2026, 6, 14), // skips 06-13
4504            (2026, 6, 15),
4505        ]
4506        .iter()
4507        .map(|(y, m, d)| chrono::Utc.with_ymd_and_hms(*y, *m, *d, 9, 0, 0).unwrap())
4508        .collect();
4509        assert_eq!(got, want);
4510    }
4511
4512    // ---- constraints.max_concurrent (#418) ----
4513
4514    fn with_max_concurrent(max: u32, runs_on: RunsOn) -> Schedule {
4515        let mut s = schedule_with(
4516            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4517            runs_on,
4518        );
4519        s.constraints.max_concurrent = Some(max);
4520        s
4521    }
4522
4523    #[test]
4524    fn validate_accepts_backend_max_concurrent() {
4525        with_max_concurrent(5, RunsOn::Backend)
4526            .validate()
4527            .expect("backend max_concurrent should validate");
4528    }
4529
4530    #[test]
4531    fn validate_rejects_max_concurrent_on_agent() {
4532        // Decision E: a central running-instance cap needs a central
4533        // counter, which agents don't have.
4534        let err = with_max_concurrent(5, RunsOn::Agent)
4535            .validate()
4536            .unwrap_err();
4537        assert!(err.contains("constraints.max_concurrent"), "got: {err}");
4538        assert!(err.contains("runs_on: agent"), "got: {err}");
4539    }
4540
4541    #[test]
4542    fn validate_rejects_zero_max_concurrent() {
4543        let err = with_max_concurrent(0, RunsOn::Backend)
4544            .validate()
4545            .unwrap_err();
4546        assert!(err.contains("max_concurrent must be >= 1"), "got: {err}");
4547    }
4548
4549    #[test]
4550    fn max_concurrent_round_trips_and_skips_when_absent() {
4551        let s = with_max_concurrent(3, RunsOn::Backend);
4552        let json = serde_json::to_value(&s.constraints).expect("ser");
4553        assert_eq!(json.get("max_concurrent").and_then(|v| v.as_u64()), Some(3));
4554        // A schedule with no constraints omits the whole block.
4555        let bare = schedule_with(
4556            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4557            RunsOn::Backend,
4558        );
4559        assert!(bare.constraints.is_empty());
4560    }
4561
4562    #[test]
4563    fn window_fail_closed_on_corrupt_blob() {
4564        // A malformed window (only reachable via a hand-edited KV
4565        // blob — validate() rejects it at create) must BLOCK, not
4566        // silently allow fires during a change-freeze (gemini #452).
4567        let s = with_window("22:00_05:00");
4568        assert!(
4569            !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
4570            "corrupt window fails closed"
4571        );
4572        // …and the scheduler can surface why it's stuck.
4573        assert!(
4574            s.bad_window().is_some(),
4575            "bad_window reports the parse error"
4576        );
4577        assert!(with_window("22:00-05:00").bad_window().is_none());
4578    }
4579
4580    #[test]
4581    fn calendar_outside_window_is_flagged() {
4582        // at 09:00 can never fall in 22:00-05:00 → never fires.
4583        let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
4584        s.constraints.window = Some("22:00-05:00".into());
4585        assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
4586
4587        // at 23:00 IS inside the overnight window → fine.
4588        let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
4589        s.constraints.window = Some("22:00-05:00".into());
4590        assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
4591
4592        // reconcile shapes are never flagged (they poll every minute).
4593        let mut s = schedule_with(
4594            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4595            RunsOn::Backend,
4596        );
4597        s.constraints.window = Some("22:00-05:00".into());
4598        assert!(!s.calendar_outside_window(), "reconcile is unaffected");
4599
4600        // no window → never flagged.
4601        let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
4602        assert!(!s.calendar_outside_window());
4603    }
4604
4605    // ---- on_failure.retry (#418 Phase 4) ----
4606
4607    fn with_retry(max: u32, backoff: &str) -> Schedule {
4608        let mut s = schedule_with(
4609            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
4610            RunsOn::Backend,
4611        );
4612        s.on_failure.retry = Some(Retry {
4613            max,
4614            backoff: backoff.into(),
4615        });
4616        s
4617    }
4618
4619    #[test]
4620    fn on_failure_parses_and_round_trips() {
4621        let yaml = r#"
4622id: x
4623when:
4624  per_pc: { every: 6h }
4625job_id: y
4626target: { all: true }
4627on_failure:
4628  retry: { max: 3, backoff: 10m }
4629"#;
4630        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
4631        let r = s.on_failure.retry.as_ref().expect("retry present");
4632        assert_eq!(r.max, 3);
4633        assert_eq!(r.backoff, "10m");
4634        let back: Schedule =
4635            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
4636        assert_eq!(back.on_failure, s.on_failure);
4637    }
4638
4639    #[test]
4640    fn on_failure_empty_is_skipped_when_serialising() {
4641        let s = schedule_with(
4642            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4643            RunsOn::Backend,
4644        );
4645        let json = serde_json::to_value(&s).expect("serialise");
4646        assert!(
4647            json.get("on_failure").is_none(),
4648            "empty on_failure must not appear on the wire: {json}"
4649        );
4650    }
4651
4652    #[test]
4653    fn validate_accepts_good_retry() {
4654        for (max, backoff) in [(1, "30s"), (3, "10m"), (10, "1h")] {
4655            with_retry(max, backoff)
4656                .validate()
4657                .unwrap_or_else(|e| panic!("retry {{max:{max}, backoff:{backoff}}}: {e}"));
4658        }
4659    }
4660
4661    #[test]
4662    fn validate_rejects_bad_backoff() {
4663        let err = with_retry(3, "soon").validate().unwrap_err();
4664        assert!(err.contains("on_failure.retry.backoff"), "got: {err}");
4665    }
4666
4667    #[test]
4668    fn validate_rejects_sub_second_backoff() {
4669        // "500ms" parses as humantime but lowers to 0s on the wire —
4670        // reject it so the operator doesn't get a silent no-wait
4671        // (coderabbit #466).
4672        for bad in ["500ms", "0s", "999ms"] {
4673            let err = with_retry(3, bad).validate().unwrap_err();
4674            assert!(
4675                err.contains("on_failure.retry.backoff must be >= 1s"),
4676                "for '{bad}', got: {err}"
4677            );
4678        }
4679    }
4680
4681    #[test]
4682    fn validate_rejects_out_of_range_max() {
4683        for bad in [0u32, 11, 1000] {
4684            let err = with_retry(bad, "10m").validate().unwrap_err();
4685            assert!(
4686                err.contains("on_failure.retry.max"),
4687                "for max={bad}, got: {err}"
4688            );
4689        }
4690    }
4691
4692    #[test]
4693    fn lowered_retry_reduces_backoff_to_seconds() {
4694        let s = with_retry(3, "10m");
4695        let spec = s.on_failure.lowered_retry().expect("a retry policy");
4696        assert_eq!(spec.max, 3);
4697        assert_eq!(spec.backoff_secs, 600);
4698    }
4699
4700    #[test]
4701    fn lowered_retry_is_none_without_policy() {
4702        let s = schedule_with(
4703            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
4704            RunsOn::Backend,
4705        );
4706        assert!(s.on_failure.lowered_retry().is_none());
4707    }
4708
4709    // ---- global change-freeze (#418 Phase 5) ----
4710
4711    #[test]
4712    fn freeze_empty_window_is_always_active() {
4713        // The big-red-button shape: no bounds = frozen until cleared.
4714        let f = Freeze::default();
4715        assert!(f.is_active(chrono::Utc::now()));
4716    }
4717
4718    #[test]
4719    fn freeze_window_is_half_open() {
4720        use chrono::TimeZone;
4721        let f = Freeze {
4722            from: Some("2026-12-20T00:00:00+00:00".into()),
4723            until: Some("2027-01-05T00:00:00+00:00".into()),
4724            reason: Some("year-end".into()),
4725            tz: ScheduleTz::Utc,
4726        };
4727        let at = |y, mo, d| chrono::Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap();
4728        assert!(!f.is_active(at(2026, 12, 19)), "before from = not frozen");
4729        assert!(f.is_active(at(2026, 12, 20)), "from is inclusive");
4730        assert!(f.is_active(at(2026, 12, 31)), "inside window");
4731        assert!(!f.is_active(at(2027, 1, 5)), "until is exclusive");
4732        assert!(!f.is_active(at(2027, 1, 6)), "after until = not frozen");
4733    }
4734
4735    #[test]
4736    fn freeze_fails_closed_on_corrupt_bound() {
4737        // A freeze is a safety switch: an unparseable bound (only
4738        // reachable via a hand-edited KV blob) must read as FROZEN, not
4739        // "fire normally" (coderabbit #472) — the opposite of `active`,
4740        // which fail-opens.
4741        let f = Freeze {
4742            from: Some("not-a-date".into()),
4743            until: None,
4744            reason: None,
4745            tz: ScheduleTz::Utc,
4746        };
4747        assert!(f.is_active(chrono::Utc::now()), "corrupt bound → frozen");
4748    }
4749
4750    #[test]
4751    fn freeze_validate_accepts_good_bounds() {
4752        Freeze {
4753            from: Some("2026-12-20".into()),
4754            until: Some("2027-01-05T12:00:00+09:00".into()),
4755            reason: None,
4756            tz: ScheduleTz::Local,
4757        }
4758        .validate()
4759        .expect("date + rfc3339 bounds should validate");
4760        // Empty (indefinite) freeze is valid.
4761        Freeze::default().validate().expect("empty freeze is valid");
4762    }
4763
4764    #[test]
4765    fn freeze_validate_rejects_bad_bound_and_inverted_window() {
4766        let err = Freeze {
4767            from: Some("never".into()),
4768            ..Default::default()
4769        }
4770        .validate()
4771        .unwrap_err();
4772        assert!(err.contains("freeze:"), "got: {err}");
4773
4774        let inverted = Freeze {
4775            from: Some("2027-01-05".into()),
4776            until: Some("2026-12-20".into()),
4777            ..Default::default()
4778        }
4779        .validate()
4780        .unwrap_err();
4781        assert!(inverted.contains("freeze.from"), "got: {inverted}");
4782    }
4783
4784    #[test]
4785    fn freeze_round_trips_and_skips_empty_fields() {
4786        let f = Freeze {
4787            from: None,
4788            until: Some("2027-01-05".into()),
4789            reason: Some("INC-1234".into()),
4790            tz: ScheduleTz::Utc,
4791        };
4792        let json = serde_json::to_value(&f).expect("serialise");
4793        assert!(json.get("from").is_none(), "empty from omitted: {json}");
4794        let back: Freeze = serde_json::from_value(json).expect("round-trip");
4795        assert_eq!(back, f);
4796    }
4797
4798    #[test]
4799    fn shipped_schedule_configs_parse_and_validate() {
4800        // Every YAML under configs/schedules/ must parse with the
4801        // current Schedule serde AND pass validate() — keeps the
4802        // shipped examples from drifting out of sync with the model
4803        // (#418 removed back-compat, so drift = broken at create).
4804        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
4805        let mut seen = 0;
4806        for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
4807            let path = entry.expect("dir entry").path();
4808            if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
4809                continue;
4810            }
4811            let body = std::fs::read_to_string(&path).expect("read yaml");
4812            let s: Schedule = serde_yaml::from_str(&body)
4813                .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
4814            s.validate()
4815                .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
4816            seen += 1;
4817        }
4818        assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
4819    }
4820
4821    // ---- pre-existing enum wire formats (unchanged by #418) ----
4822
4823    #[test]
4824    fn exec_mode_serialises_snake_case() {
4825        for (mode, expected) in [
4826            (ExecMode::EveryTick, "every_tick"),
4827            (ExecMode::OncePerPc, "once_per_pc"),
4828            (ExecMode::OncePerTarget, "once_per_target"),
4829        ] {
4830            let s = serde_json::to_value(mode).expect("serialise");
4831            assert_eq!(s, serde_json::Value::String(expected.into()));
4832            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
4833                .expect("deserialise");
4834            assert_eq!(back, mode, "round-trip for {expected}");
4835        }
4836    }
4837
4838    #[test]
4839    fn schedule_runs_on_defaults_to_backend() {
4840        let yaml = r#"
4841id: x
4842when:
4843  per_pc: once
4844job_id: y
4845target: { all: true }
4846"#;
4847        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
4848        assert_eq!(s.runs_on, RunsOn::Backend);
4849    }
4850
4851    #[test]
4852    fn schedule_runs_on_agent_parses() {
4853        let yaml = r#"
4854id: offline-inv
4855when:
4856  per_pc: { every: 1h }
4857job_id: inventory-hw
4858target: { all: true }
4859runs_on: agent
4860"#;
4861        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
4862        assert_eq!(s.runs_on, RunsOn::Agent);
4863        assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
4864    }
4865
4866    #[test]
4867    fn runs_on_serialises_snake_case() {
4868        for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
4869            let s = serde_json::to_value(mode).expect("serialise");
4870            assert_eq!(s, serde_json::Value::String(expected.into()));
4871            let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
4872                .expect("deserialise");
4873            assert_eq!(back, mode);
4874        }
4875    }
4876
4877    #[test]
4878    fn execute_shell_into_wire_shell() {
4879        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
4880        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
4881    }
4882
4883    #[test]
4884    fn manifest_staleness_defaults_to_cached() {
4885        let yaml = r#"
4886id: x
4887version: 1.0.0
4888execute:
4889  shell: powershell
4890  script: "echo"
4891  timeout: 1s
4892"#;
4893        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
4894        assert_eq!(m.staleness, Staleness::Cached);
4895    }
4896
4897    #[test]
4898    fn manifest_strict_staleness_parses() {
4899        let yaml = r#"
4900id: urgent-patch
4901version: 2.5.1
4902execute:
4903  shell: powershell
4904  script: Install-Hotfix
4905  timeout: 5m
4906staleness:
4907  mode: strict
4908  max_cache_age: 0s
4909"#;
4910        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
4911        match m.staleness {
4912            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
4913            other => panic!("expected strict, got {other:?}"),
4914        }
4915    }
4916
4917    #[test]
4918    fn manifest_unchecked_staleness_parses() {
4919        let yaml = r#"
4920id: legacy
4921version: 0.1.0
4922execute:
4923  shell: cmd
4924  script: "echo"
4925  timeout: 1s
4926staleness:
4927  mode: unchecked
4928"#;
4929        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
4930        assert_eq!(m.staleness, Staleness::Unchecked);
4931    }
4932
4933    #[test]
4934    fn missing_required_field_errors() {
4935        // `id` missing.
4936        let yaml = r#"
4937version: 1.0.0
4938target: { all: true }
4939execute:
4940  shell: powershell
4941  script: "echo"
4942  timeout: 1s
4943"#;
4944        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
4945        assert!(r.is_err(), "expected error, got {:?}", r);
4946    }
4947
4948    #[test]
4949    fn display_field_table_kind_round_trips_with_nested_columns() {
4950        // #39: `type: table` + `columns:` on a DisplayField gets
4951        // round-tripped through serde so the SPA receives the
4952        // nested schema verbatim. Nested columns themselves are
4953        // DisplayFields so they can carry `type: bytes` /
4954        // `type: number` for cell formatting.
4955        let yaml = r#"
4956id: inv-hw
4957version: 1.0.0
4958execute:
4959  shell: powershell
4960  script: "echo"
4961  timeout: 60s
4962inventory:
4963  display:
4964    - field: hostname
4965      label: Hostname
4966    - field: disks
4967      label: Disks
4968      type: table
4969      columns:
4970        - field: device_id
4971          label: Drive
4972        - field: size_bytes
4973          label: Size
4974          type: bytes
4975        - field: free_bytes
4976          label: Free
4977          type: bytes
4978        - field: file_system
4979          label: FS
4980"#;
4981        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
4982        let inv = m.inventory.as_ref().expect("inventory hint");
4983        let disks = inv
4984            .display
4985            .iter()
4986            .find(|d| d.field == "disks")
4987            .expect("disks display row");
4988        assert_eq!(disks.kind.as_deref(), Some("table"));
4989        let cols = disks.columns.as_ref().expect("table needs columns");
4990        assert_eq!(cols.len(), 4);
4991        assert_eq!(cols[1].field, "size_bytes");
4992        assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
4993    }
4994
4995    #[test]
4996    fn display_field_scalar_kind_keeps_columns_none() {
4997        // Defensive: when type is a scalar (`bytes` / `number` /
4998        // `timestamp`) the `columns` field stays None — the SPA
4999        // uses its presence as the "render nested table" signal,
5000        // so it must not leak in via serde defaults.
5001        let yaml = r#"
5002id: x
5003version: 1.0.0
5004execute:
5005  shell: powershell
5006  script: "echo"
5007  timeout: 5s
5008inventory:
5009  display:
5010    - { field: ram_bytes, label: RAM, type: bytes }
5011"#;
5012        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
5013        let inv = m.inventory.as_ref().unwrap();
5014        assert!(inv.display[0].columns.is_none());
5015    }
5016
5017    // ---- checked-in JSON Schema freshness (docs/schemas/) ----
5018
5019    /// The JSON Schemas under `docs/schemas/` must match what
5020    /// `schema_for!` produces today — a Cargo.lock-style freshness guard
5021    /// so a `Schedule` / `Manifest` field change can't silently drift
5022    /// the operator-facing schema. The SPA editor, the backend
5023    /// `/api/schemas/*` endpoints, and these files all read the same
5024    /// derived shape; this test fails CI if the checked-in copy lags.
5025    /// Regenerate with:
5026    ///   `UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current`
5027    #[test]
5028    fn schema_files_are_current() {
5029        assert_schema_file("schedule.schema.json", &schemars::schema_for!(Schedule));
5030        assert_schema_file("job.schema.json", &schemars::schema_for!(Manifest));
5031        assert_schema_file("view.schema.json", &schemars::schema_for!(View));
5032    }
5033
5034    fn assert_schema_file(name: &str, schema: &schemars::Schema) {
5035        let generated = serde_json::to_string_pretty(schema).expect("serialize schema") + "\n";
5036        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
5037            .join("../../docs/schemas")
5038            .join(name);
5039        if std::env::var_os("UPDATE_SCHEMAS").is_some() {
5040            std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir docs/schemas");
5041            std::fs::write(&path, &generated).unwrap_or_else(|e| panic!("write {path:?}: {e}"));
5042            return;
5043        }
5044        // Normalize CRLF→LF before comparing: `.gitattributes` already
5045        // pins these files to `eol=lf`, but a stray CRLF working-tree
5046        // copy (autocrlf, a tool rewrite) shouldn't turn a *content*-
5047        // freshness check into a confusing line-ending failure — that's
5048        // .gitattributes' job, not this test's (gemini #588).
5049        let on_disk = std::fs::read_to_string(&path)
5050            .unwrap_or_else(|e| {
5051                panic!(
5052                    "read {path:?}: {e}\n\
5053                     generate it with: UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current"
5054                )
5055            })
5056            .replace("\r\n", "\n");
5057        assert_eq!(
5058            on_disk, generated,
5059            "{name} is stale — a Schedule/Manifest schema change isn't reflected in docs/schemas/. \
5060             Refresh with: UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current"
5061        );
5062    }
5063}
5064
5065/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
5066/// (target + optional rollout + optional jitter) inline; the
5067/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
5068/// script body. Two schedules of the same job can target different
5069/// groups on different cadences without copying the manifest.
5070///
5071/// #418 Phase 1: the cadence is the single [`When`] field. The old
5072/// `cron` × `mode` × `cooldown` × `auto_disable_when_done` quartet
5073/// is gone (no back-compat — pre-Phase-1 KV blobs fail to parse and
5074/// are warn-skipped; re-`schedule create` to upgrade them). The
5075/// engine underneath is unchanged: [`Schedule::lowered`] maps `when`
5076/// onto the same (cron, ExecMode, cooldown) trio the scheduler and
5077/// `decide_fire` always ran on.
5078#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
5079pub struct Schedule {
5080    pub id: String,
5081    /// When to fire — a reconcile cadence (`per_pc` / `per_target`)
5082    /// or a calendar time trigger (`at` / `days`). See [`When`].
5083    ///
5084    /// `singleton_map`: serde_yaml 0.9 renders externally-tagged
5085    /// enums as `!per_pc` YAML tags by default; this keeps the
5086    /// operator-facing map shape (`when: { per_pc: once }`). JSON
5087    /// output is identical either way, and the schemars schema
5088    /// (external tagging = oneOf of single-key objects) already
5089    /// matches the singleton-map wire shape.
5090    #[serde(with = "serde_yaml::with::singleton_map")]
5091    #[schemars(with = "When")]
5092    pub when: When,
5093    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
5094    /// Manifest's `id`.
5095    pub job_id: String,
5096    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
5097    /// carry these any more — same job + different fanout = different
5098    /// schedule.
5099    #[serde(flatten)]
5100    pub plan: FanoutPlan,
5101    /// Optional validity window. Outside `[from, until)` the
5102    /// schedule is dormant — still registered, still visible, but
5103    /// every tick is skipped (deleted ≠ dormant: a campaign that
5104    /// ended stays inspectable and can be re-armed by editing the
5105    /// window). Checked at tick time on both the backend scheduler
5106    /// and the agent's local scheduler.
5107    #[serde(default, skip_serializing_if = "Active::is_empty")]
5108    pub active: Active,
5109    /// #418 operational constraints gating *when within an active
5110    /// period* a fire may happen: a maintenance `window`, a fleet
5111    /// `max_concurrent` cap, and `skip_dates` (holiday exclusion). The
5112    /// wall-clock ones are evaluated in the schedule's `tz`; future
5113    /// `require` (env gates) lands in the same namespace. Checked at
5114    /// tick time on both schedulers (and surfaced by `preview`).
5115    #[serde(default, skip_serializing_if = "Constraints::is_empty")]
5116    pub constraints: Constraints,
5117    /// #418 Phase 4: what to do after a fire's script comes back
5118    /// failed. Currently just `retry` (fixed-backoff in-process
5119    /// re-run); future `notify` / `disable` join the same namespace.
5120    /// Applied fire-side in `handle_command` (the retry policy is
5121    /// lowered onto every Command this schedule produces), so it
5122    /// covers both `runs_on` locations.
5123    #[serde(default, skip_serializing_if = "OnFailure::is_empty")]
5124    pub on_failure: OnFailure,
5125    /// #418 Phase 2: the timezone this schedule's wall-clock fields
5126    /// are evaluated in — both the calendar `at` firing time AND the
5127    /// `active.{from,until}` window bounds. `local` (default) = the
5128    /// running host's TZ (the agent's for `runs_on: agent`, the
5129    /// backend server's otherwise); `utc` for TZ-independent
5130    /// schedules. Reconcile shapes (`per_pc`/`per_target`) ignore it
5131    /// for firing (poll cron runs every minute regardless) but still
5132    /// honor it for the `active` window.
5133    #[serde(default)]
5134    pub tz: ScheduleTz,
5135    /// v0.22: optional humantime window after a cron tick during
5136    /// which the Command is still considered "live". The scheduler
5137    /// computes `tick_at + starting_deadline` and stamps it onto
5138    /// each Command as `deadline_at`; agents skip Commands they
5139    /// receive after that absolute time. `None` (default) = no
5140    /// deadline, meaning a Command queued in the broker / stream
5141    /// during agent downtime runs whenever the agent reconnects —
5142    /// good for kitting / inventory / cleanup. Set this for
5143    /// time-of-day notifications, lunch reminders, etc., where
5144    /// "fire 3 hours late" would be wrong.
5145    #[serde(default, skip_serializing_if = "Option::is_none")]
5146    pub starting_deadline: Option<String>,
5147    /// v0.23: where does the cron tick happen? `Backend` (default,
5148    /// historical) = backend's scheduler fires Commands via NATS;
5149    /// agents passively receive. `Agent` = each targeted agent runs
5150    /// its own internal cron and fires locally, so the schedule
5151    /// keeps ticking even when the broker is unreachable (laptop on
5152    /// the train, broker maintenance window, full WAN outage). The
5153    /// two locations are mutually exclusive — when `Agent`, the
5154    /// backend scheduler stays out and just keeps the definition in
5155    /// KV for agents to read.
5156    #[serde(default)]
5157    pub runs_on: RunsOn,
5158    #[serde(default = "default_true")]
5159    pub enabled: bool,
5160    /// Free-form operator taxonomy for the Schedules page — the
5161    /// schedule-side mirror of `Manifest.tags` (added in #640; a plain
5162    /// code ref rather than an intra-doc link, since that field isn't
5163    /// on this branch until #640 merges). Purely a SPA-side
5164    /// organisational aid (search / filter chips alongside the
5165    /// id-prefix grouping); the scheduler never reads it, so any
5166    /// string is allowed and it carries no firing semantics. A
5167    /// schedule's own tags are independent of its job's: the same job
5168    /// may back a `weekly` maintenance schedule and a `canary` rollout
5169    /// schedule. Empty by default and `skip_serializing_if`-elided per
5170    /// the #492 gradual-upgrade wire rule.
5171    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5172    pub tags: Vec<String>,
5173    /// GitOps provenance (#695) — see [`RepoOrigin`]. Stamped by
5174    /// `kanade schedule create` when the source YAML lives inside a Git
5175    /// work tree, so the SPA renders the schedule read-only and points
5176    /// edits back at the repo (SPEC design principle #3: 設定駆動 YAML +
5177    /// Git), parity with a job's [`Manifest::origin`]. `None` for
5178    /// SPA-born schedules and ones applied from outside any repo. Purely
5179    /// informational — the scheduler never reads it. New field ⇒ #492
5180    /// wire rule (`default` + `skip_serializing_if`).
5181    #[serde(default, skip_serializing_if = "Option::is_none")]
5182    pub origin: Option<RepoOrigin>,
5183}
5184
5185/// v0.23 — where the cron tick fires from.
5186#[derive(
5187    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
5188)]
5189#[serde(rename_all = "snake_case")]
5190pub enum RunsOn {
5191    /// Backend's central scheduler ticks and publishes Commands to
5192    /// NATS. Historical default, what every pre-v0.23 schedule
5193    /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
5194    /// reconnects ⇒ catch-up via [`command_replay`](crate)
5195    /// (see kanade-agent's command_replay module).
5196    #[default]
5197    Backend,
5198    /// Each targeted agent runs the cron tick locally. Survives
5199    /// broker / WAN outages. Best for laptops / mobile devices that
5200    /// roam off the corporate network. Agent must be online for the
5201    /// initial schedule + job-catalog pull, but once cached the
5202    /// agent fires the script standalone.
5203    Agent,
5204}
5205
5206/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
5207#[derive(
5208    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
5209)]
5210#[serde(rename_all = "snake_case")]
5211pub enum ExecMode {
5212    /// Fire on every cron tick at the whole target. Historical
5213    /// (pre-v0.19) behavior; no dedup.
5214    #[default]
5215    EveryTick,
5216    /// Fire at each pc until that pc succeeds; then skip it until
5217    /// the optional cooldown elapses (or forever if no cooldown).
5218    /// Use for kitting / first-boot / per-pc compliance checks.
5219    OncePerPc,
5220    /// Fire at the whole target until **any** pc succeeds; then
5221    /// skip the whole target until the optional cooldown elapses
5222    /// (or forever if no cooldown). Use for "one delegate is
5223    /// enough" tasks like license check-in.
5224    OncePerTarget,
5225    /// #418 OS-native event trigger (`when: { on: [...] }`). There is
5226    /// no cron — the agent fires it from an OS event source (boot /
5227    /// session-change), not a tick — so the scheduler skips
5228    /// `tokio-cron` registration for it. Each event occurrence fires
5229    /// once, gated by the standard freeze / active / window /
5230    /// skip_dates checks.
5231    Event,
5232}
5233
5234/// #418 Phase 1 — the single "when does this fire" axis.
5235///
5236/// Replaces the old `cron` + `mode` + `cooldown` trio whose
5237/// interactions were implicit (cron doubled as both a real
5238/// time-of-day trigger and a reconcile poll period; contradictory
5239/// combinations silently no-opped). Two shapes:
5240///
5241/// * **reconcile** (`per_pc` / `per_target`) — desired-state: "each
5242///   pc (or one delegate) should have run this within `every`".
5243///   The poll period is system-generated ([`POLL_CRON`], every
5244///   minute) and no longer the operator's concern.
5245/// * **calendar** (`{ at, days }`) — a wall-clock time trigger
5246///   (#418 Phase 2, replacing the old raw-cron escape hatch). Fires
5247///   the whole target at the given time, no dedup. `at: "09:00"` +
5248///   `days` repeats; `at: "2026-06-10 09:00"` (a date+time) fires
5249///   exactly once. Evaluated in the schedule's top-level `tz`.
5250#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5251#[serde(rename_all = "snake_case")]
5252pub enum When {
5253    /// Fire at each targeted pc: `once` (kitting — succeed once,
5254    /// skip forever, forever catching brand-new / re-imaged pcs)
5255    /// or `{ every: <humantime> }` (patrol — re-arm per pc after
5256    /// the interval).
5257    PerPc(PerPolicy),
5258    /// Fire until **any** one pc of the target succeeds, then skip
5259    /// the whole target (`once`) or re-arm after `every`. Needs
5260    /// fleet-wide completion data, so it is backend-only —
5261    /// `runs_on: agent` + `per_target` is rejected by
5262    /// [`Schedule::validate`].
5263    PerTarget(PerPolicy),
5264    /// Calendar time trigger: `{ at: "09:00", days: [mon-fri] }`
5265    /// (repeating) or `{ at: "2026-06-10 09:00" }` (one-shot). Fires
5266    /// the whole target at that wall-clock time in the schedule's
5267    /// `tz` — no dedup, no cooldown.
5268    Calendar(CalendarSpec),
5269    /// #418 OS-native event trigger: `when: { on: [startup, logon] }`.
5270    /// Fires when the agent observes the listed OS event(s) rather than
5271    /// on a clock — there is no cron. `runs_on: agent` only (the agent
5272    /// owns the event source); [`Schedule::validate`] rejects it on
5273    /// `backend` and rejects an empty list. Each event occurrence fires
5274    /// once, gated by the same freeze / active / `constraints.window` /
5275    /// `skip_dates` checks as the cron path. `startup` fires once per OS
5276    /// boot (deduped via the host boot time); a `starting_deadline`, if
5277    /// set, limits it to "agent came up within that long after boot".
5278    On(Vec<OnTrigger>),
5279}
5280
5281/// An OS event the agent can fire a schedule on (#418 `when: { on }`).
5282#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
5283#[serde(rename_all = "snake_case")]
5284pub enum OnTrigger {
5285    /// Once per OS boot (the agent's first run for that boot). Catches
5286    /// freshly-imaged / reinstalled hosts at their next startup.
5287    Startup,
5288    /// On an interactive-session user logon — console, RDP, or
5289    /// auto-logon (Windows `WTS_SESSION_LOGON`). Does not fire for
5290    /// service / network / batch logons (no interactive session).
5291    Logon,
5292    /// When the workstation is locked (Win+L / idle lock; Windows
5293    /// `WTS_SESSION_LOCK`). Use for step-away compliance / cleanup.
5294    Lock,
5295    /// When the workstation is unlocked — the user returns to a locked
5296    /// session (Windows `WTS_SESSION_UNLOCK`). Use to re-check
5297    /// compliance / refresh state when work resumes.
5298    Unlock,
5299    /// When the host's network changes — IP address table change on
5300    /// connect / disconnect / DHCP renew / VPN / Wi-Fi roam (Windows
5301    /// `NotifyAddrChange`). Debounced agent-side (a burst of changes
5302    /// from one transition fires once after the network settles), so
5303    /// use it for "re-check connectivity / re-register on network move"
5304    /// rather than expecting one fire per raw adapter event.
5305    ///
5306    /// IPv4 only: `NotifyAddrChange` watches the IPv4 address table, so a
5307    /// transition that touches only IPv6 addresses won't fire. In practice
5308    /// dual-stack networks change both tables together, but a pure-IPv6
5309    /// move (e.g. an IPv6-only Wi-Fi roam) is not detected.
5310    NetworkChange,
5311}
5312
5313/// Calendar time trigger (#418 Phase 2). `at` is either a time of
5314/// day (`"HH:MM"`, repeating — combine with `days`) or a full
5315/// date+time (`"YYYY-MM-DD HH:MM"`, a one-shot that fires once and
5316/// never again). Evaluated in the schedule's top-level `tz`.
5317#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5318pub struct CalendarSpec {
5319    /// `"HH:MM"` (24h) for a repeating trigger, or
5320    /// `"YYYY-MM-DD HH:MM"` (hyphen / slash / `T` separators all
5321    /// accepted) for a one-shot. Parsed lazily —
5322    /// [`Schedule::validate`] rejects garbage at create time.
5323    pub at: String,
5324    /// Day-of-week filter for a time-of-day `at`: `["mon-fri"]`,
5325    /// `["mon","wed","fri"]`, … (passed verbatim to the cron DOW
5326    /// field, so ranges and names both work). An **nth-weekday**
5327    /// `["tue#2"]` fires only on the 2nd Tuesday of each month
5328    /// ("Patch Tuesday"); the ordinal is `1..5`. A **last-weekday**
5329    /// `["friL"]` fires only on the last Friday of each month (handy
5330    /// for monthly maintenance). Empty = every day. Must be empty
5331    /// when `at` carries a date (the date already pins the day).
5332    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5333    pub days: Vec<String>,
5334}
5335
5336/// Parsed `CalendarSpec.at`: the wall-clock minute/hour, plus the
5337/// date for a one-shot (`None` = repeating time-of-day).
5338struct ParsedAt {
5339    minute: u32,
5340    hour: u32,
5341    date: Option<chrono::NaiveDate>,
5342}
5343
5344impl CalendarSpec {
5345    /// Parse `at`: a date+time (`YYYY-MM-DD HH:MM`, hyphen / slash /
5346    /// `T` separators) is a one-shot; a bare `HH:MM` is repeating.
5347    fn parse_at(&self) -> Result<ParsedAt, String> {
5348        use chrono::Timelike;
5349        let s = self.at.trim();
5350        for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
5351            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
5352                return Ok(ParsedAt {
5353                    minute: dt.minute(),
5354                    hour: dt.hour(),
5355                    date: Some(dt.date()),
5356                });
5357            }
5358        }
5359        if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
5360            return Ok(ParsedAt {
5361                minute: t.minute(),
5362                hour: t.hour(),
5363                date: None,
5364            });
5365        }
5366        Err(format!(
5367            "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
5368            self.at
5369        ))
5370    }
5371
5372    /// Pre-flight check on the `days` tokens so a bad day name gives
5373    /// a `when.days:`-scoped error instead of croner's confusing
5374    /// "when.at lowered to invalid cron" (claude #432 review). Each
5375    /// token is a day name (`mon`..`sun`), a numeric DOW (`0`..`7`),
5376    /// `*`, a `-` range of those, an **nth-weekday** like `tue#2`
5377    /// (2nd Tuesday of the month — "Patch Tuesday"), or a
5378    /// **last-weekday** like `friL` (last Friday of the month).
5379    fn validate_days(&self) -> Result<(), String> {
5380        const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
5381        let is_day = |p: &str| NAMES.contains(&p) || p.parse::<u8>().is_ok_and(|n| n <= 7);
5382        for tok in &self.days {
5383            // Report the whole token on a malformed range like `mon-`
5384            // (which would otherwise split to a cryptic empty part —
5385            // claude #432 follow-up).
5386            let invalid = |reason: &str| {
5387                Err(format!(
5388                    "when.days: invalid day token '{tok}' ({reason}; \
5389                     want mon..sun, 0-7, a range like mon-fri, an nth-weekday \
5390                     like tue#2, a last-weekday like friL, or *)"
5391                ))
5392            };
5393            // #418: nth-weekday suffix (`tue#2` = 2nd Tuesday). Croner
5394            // accepts `<dow>#<n>` (n = 1..5) in the DOW field, and
5395            // `to_cron` passes the token through verbatim, so the
5396            // engine fires only on that occurrence. It's a single
5397            // weekday + ordinal — not combinable with a range.
5398            if let Some((day_part, nth_part)) = tok.split_once('#') {
5399                // Normalize once and use `d` consistently (gemini #547);
5400                // the outer `invalid` already echoes the raw `tok`.
5401                let d = day_part.trim().to_ascii_lowercase();
5402                if d.contains('-') || !is_day(&d) {
5403                    return invalid("the part before # must be a single weekday");
5404                }
5405                match nth_part.trim().parse::<u8>() {
5406                    Ok(n) if (1..=5).contains(&n) => {}
5407                    _ => return invalid("the # ordinal must be 1..5 (e.g. tue#2 = 2nd Tuesday)"),
5408                }
5409                continue;
5410            }
5411            // #418: last-weekday suffix (`friL` = last Friday of the
5412            // month — the monthly-maintenance sibling of Patch Tuesday).
5413            // Croner accepts `<dow>L` in the DOW field with verified
5414            // last-<dow>-of-month semantics, and `to_cron` passes it
5415            // through verbatim. A single weekday + `L` — bare `L` and
5416            // ranges are rejected (croner would read bare `L` as
5417            // Saturday, which is a confusing footgun).
5418            if let Some(day_part) = tok.strip_suffix(['L', 'l']) {
5419                // No `.trim()`: a cron DOW token can't carry internal
5420                // whitespace, so `"fri L"` must be *rejected* here (its
5421                // strip leaves `"fri "`, and `is_day` catches the space)
5422                // rather than trimmed into a clean `"fri"` that then
5423                // produces a malformed `fri L` cron downstream and a
5424                // confusing croner error (gemini #560).
5425                let d = day_part.to_ascii_lowercase();
5426                if d.is_empty() {
5427                    return invalid("`L` (last-weekday) needs a weekday before it, e.g. friL");
5428                }
5429                if d.contains('-') || !is_day(&d) {
5430                    return invalid(
5431                        "the part before L must be a single weekday (e.g. friL = last Friday)",
5432                    );
5433                }
5434                continue;
5435            }
5436            for part in tok.split('-') {
5437                let p = part.trim().to_ascii_lowercase();
5438                if p.is_empty() {
5439                    return invalid("empty range bound");
5440                }
5441                if p != "*" && !is_day(&p) {
5442                    return invalid(&format!("'{part}' is not a day"));
5443                }
5444            }
5445        }
5446        Ok(())
5447    }
5448
5449    /// For a one-shot (`at` carries a date), the absolute instant it
5450    /// fires in `tz`. `None` for a repeating calendar. Used to warn
5451    /// about a one-shot whose date is already in the past (it would
5452    /// never fire).
5453    pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
5454        let p = self.parse_at().ok()?;
5455        let date = p.date?;
5456        let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
5457        tz.naive_to_utc(naive)
5458    }
5459
5460    /// The wall-clock time-of-day this calendar fires at (`None` if
5461    /// `at` is unparseable — validate() guards that). Used to detect
5462    /// a calendar whose fire time can never fall inside its
5463    /// `constraints.window` (claude #452 review).
5464    pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
5465        let p = self.parse_at().ok()?;
5466        chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
5467    }
5468
5469    /// Lower to the cron string the scheduler engine runs. Repeating
5470    /// → 6-field `0 {min} {hour} * * {dow}`; one-shot → 7-field
5471    /// `0 {min} {hour} {day} {month} * {year}` (a past year never
5472    /// fires — that's what makes it one-shot).
5473    fn to_cron(&self) -> Result<String, String> {
5474        use chrono::Datelike;
5475        let ParsedAt { minute, hour, date } = self.parse_at()?;
5476        match date {
5477            Some(d) => {
5478                if !self.days.is_empty() {
5479                    return Err(
5480                        "when.at with a date is a one-shot and cannot be combined with days".into(),
5481                    );
5482                }
5483                Ok(format!(
5484                    "0 {minute} {hour} {} {} * {}",
5485                    d.day(),
5486                    d.month(),
5487                    d.year()
5488                ))
5489            }
5490            None => {
5491                let dow = if self.days.is_empty() {
5492                    "*".to_string()
5493                } else {
5494                    self.validate_days()?;
5495                    self.days.join(",")
5496                };
5497                Ok(format!("0 {minute} {hour} * * {dow}"))
5498            }
5499        }
5500    }
5501}
5502
5503/// The timezone a schedule's wall-clock fields (`when.at`,
5504/// `active.{from,until}`) are evaluated in (#418 Phase 2).
5505#[derive(
5506    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
5507)]
5508#[serde(rename_all = "snake_case")]
5509pub enum ScheduleTz {
5510    /// The running host's local timezone — the agent's for
5511    /// `runs_on: agent`, the backend server's otherwise. Default.
5512    #[default]
5513    Local,
5514    /// UTC — for timezone-independent schedules.
5515    Utc,
5516}
5517
5518impl ScheduleTz {
5519    /// Interpret a naive (zoneless) datetime as being in this tz and
5520    /// convert to UTC. On a DST *fold* (the local time occurs twice
5521    /// when clocks go back) we pick `.earliest()` rather than
5522    /// rejecting it; `None` is reserved for a true DST *gap* (a local
5523    /// time that never exists). `Utc` is fixed-offset so neither ever
5524    /// happens; `Local` is whatever timezone the running host is set
5525    /// to and *can* hit a gap/fold on any DST-observing host — not
5526    /// just the JST we run today (gemini + claude #432 review).
5527    fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
5528        use chrono::TimeZone;
5529        match self {
5530            ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
5531                naive,
5532                chrono::Utc,
5533            )),
5534            ScheduleTz::Local => chrono::Local
5535                .from_local_datetime(&naive)
5536                .earliest()
5537                .map(|dt| dt.with_timezone(&chrono::Utc)),
5538        }
5539    }
5540
5541    /// The wall-clock time-of-day `now` reads as in this tz — used by
5542    /// [`Constraints::allows`] to test a maintenance window
5543    /// (#418 Phase 3). `Utc` is the naive UTC time; `Local` is the
5544    /// running host's local time.
5545    fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
5546        match self {
5547            ScheduleTz::Utc => now.time(),
5548            ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
5549        }
5550    }
5551
5552    /// The wall-clock *date* `now` reads as in this tz — used by
5553    /// [`Constraints::allows`] to test `skip_dates` (#418 holiday
5554    /// exclusion). Same tz semantics as [`Self::wall_time`].
5555    fn wall_date(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveDate {
5556        match self {
5557            ScheduleTz::Utc => now.date_naive(),
5558            ScheduleTz::Local => now.with_timezone(&chrono::Local).date_naive(),
5559        }
5560    }
5561
5562    /// Stable lowercase wire/display label (`local` / `utc`) — matches
5563    /// the serde `snake_case` representation. Used for the preview
5564    /// response's `tz` field so the JSON shape isn't coupled to the
5565    /// `Debug` repr (claude #578 review).
5566    pub fn as_str(self) -> &'static str {
5567        match self {
5568            ScheduleTz::Local => "local",
5569            ScheduleTz::Utc => "utc",
5570        }
5571    }
5572}
5573
5574impl std::fmt::Display for ScheduleTz {
5575    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5576        f.write_str(self.as_str())
5577    }
5578}
5579
5580/// `once` vs `{ every: <humantime> }` — shared by `per_pc` /
5581/// `per_target`. Untagged so the YAML stays the bare keyword or a
5582/// one-key map, nothing more ceremonial.
5583#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5584#[serde(untagged)]
5585pub enum PerPolicy {
5586    /// The bare string `once`: succeed once, then skip permanently
5587    /// (cooldown = infinity).
5588    Once(OnceLiteral),
5589    /// Re-arm after the humantime interval, e.g. `{ every: 6h }`.
5590    Every(EverySpec),
5591}
5592
5593/// Single-variant enum so serde accepts exactly the string `once`
5594/// (a free-form `String` would swallow typos like `onec`).
5595#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
5596#[serde(rename_all = "snake_case")]
5597pub enum OnceLiteral {
5598    Once,
5599}
5600
5601/// `{ every: <humantime> }`. Standalone struct (not an inline
5602/// struct variant). `{ evry: 6h }` still fails to parse (the
5603/// required `every` key is missing), and the create boundaries
5604/// reject the unknown `evry` via [`crate::strict`] with its path —
5605/// while agents reading a future writer's extra fields tolerate
5606/// them (#492).
5607#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
5608pub struct EverySpec {
5609    /// Humantime interval (`10m`, `6h`, `1d`...). Parsed lazily —
5610    /// [`Schedule::validate`] rejects garbage at create time.
5611    pub every: String,
5612}
5613
5614impl PerPolicy {
5615    /// The cooldown this policy lowers to: `once` = `None`
5616    /// (permanent skip), `every` = the interval.
5617    fn cooldown(&self) -> Option<String> {
5618        match self {
5619            PerPolicy::Once(_) => None,
5620            PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
5621        }
5622    }
5623}
5624
5625impl std::fmt::Display for When {
5626    /// Operator-facing one-liner (`per_pc once` / `per_pc every 6h`
5627    /// / `at 09:00 [mon-fri]` / `at 2026-06-10 09:00`) for log
5628    /// lines, audit payloads and the API's `ScheduleSummary`.
5629    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
5630        let policy = |p: &PerPolicy| match p {
5631            PerPolicy::Once(_) => "once".to_string(),
5632            PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
5633        };
5634        match self {
5635            When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
5636            When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
5637            When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
5638            When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
5639            When::On(triggers) => {
5640                let names: Vec<&str> = triggers.iter().map(|t| t.as_str()).collect();
5641                write!(f, "on [{}]", names.join(","))
5642            }
5643        }
5644    }
5645}
5646
5647impl OnTrigger {
5648    /// Lowercase wire/display label (matches the serde `snake_case`).
5649    pub fn as_str(self) -> &'static str {
5650        match self {
5651            OnTrigger::Startup => "startup",
5652            OnTrigger::Logon => "logon",
5653            OnTrigger::Lock => "lock",
5654            OnTrigger::Unlock => "unlock",
5655            OnTrigger::NetworkChange => "network_change",
5656        }
5657    }
5658}
5659
5660/// Optional validity window for a [`Schedule`] (#418 decision G).
5661/// Half-open `[from, until)`; either bound may be omitted. Bounds
5662/// are `YYYY-MM-DD` (= that day's 00:00 in the schedule's `tz`) or
5663/// full RFC3339 (offset is honored as-is, `tz` ignored). Kept as
5664/// strings so the JSON Schema the SPA editor consumes stays two
5665/// plain string fields, mirroring `jitter` / `starting_deadline`.
5666///
5667/// #418 Phase 2: bounds are evaluated in the schedule's top-level
5668/// `tz` (was UTC-only in Phase 1) so `tz: local` makes both the
5669/// calendar `at` AND the `active` window local — one consistent
5670/// timezone per schedule.
5671#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
5672pub struct Active {
5673    /// Dormant before this instant.
5674    #[serde(default, skip_serializing_if = "Option::is_none")]
5675    pub from: Option<String>,
5676    /// Dormant from this instant on (exclusive).
5677    #[serde(default, skip_serializing_if = "Option::is_none")]
5678    pub until: Option<String>,
5679}
5680
5681impl Active {
5682    /// `skip_serializing_if` helper — an empty window means "always
5683    /// active" and is omitted from the wire format entirely.
5684    pub fn is_empty(&self) -> bool {
5685        self.from.is_none() && self.until.is_none()
5686    }
5687
5688    /// Parse one bound: RFC3339 first (offset honored, `tz`
5689    /// ignored), then bare `YYYY-MM-DD` (00:00 in `tz`).
5690    pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
5691        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
5692            return Ok(dt.with_timezone(&chrono::Utc));
5693        }
5694        if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
5695            let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
5696            return tz.naive_to_utc(midnight).ok_or_else(|| {
5697                format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
5698            });
5699        }
5700        Err(format!(
5701            "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
5702        ))
5703    }
5704
5705    /// Is `now` inside the window? Unparseable bounds are treated
5706    /// as absent here (fail-open) — [`Schedule::validate`] is the
5707    /// place that rejects them loudly; this runs on every tick and
5708    /// must never panic on a stale KV blob.
5709    pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
5710        let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
5711        if bound(&self.from).is_some_and(|from| now < from) {
5712            return false;
5713        }
5714        if bound(&self.until).is_some_and(|until| now >= until) {
5715            return false;
5716        }
5717        true
5718    }
5719}
5720
5721/// Host-environment gate (#418 `constraints.require`). Fire only when
5722/// the target host is in the required state. Sensed **in-process by the
5723/// agent** (Win32), so it is `runs_on: agent` only — the backend cannot
5724/// read a target host's power/idle state ([`Schedule::validate`]
5725/// rejects it on `runs_on: backend`, symmetric with `when: { on }`).
5726///
5727/// Evaluated at fire time as a skip-this-tick gate (NOT in
5728/// [`Constraints::allows`], which stays pure for `preview`): a reconcile
5729/// cadence re-checks every minute (so it effectively defers until the
5730/// state is met — the intended pairing); a `calendar` fire that lands
5731/// while the state is unmet is simply missed, same as `window`. It is
5732/// therefore a *runtime* gate and does not appear in `preview`.
5733// No `Eq`: `cpu_below: Option<f64>` is only `PartialEq` (f64 is not Eq).
5734#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq)]
5735pub struct Require {
5736    /// Fire only while on **AC power** (skip on battery). Reads
5737    /// `GetSystemPowerStatus`; an unknown/unreadable status is treated
5738    /// as not-on-AC (fail-closed — a restrictive gate must not fire
5739    /// when it can't confirm the condition). `false` (default) = no
5740    /// power requirement.
5741    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
5742    pub ac_power: bool,
5743    /// Fire only when the active console session has had **no keyboard /
5744    /// mouse input for at least this long** (humantime, e.g. `"10m"`) —
5745    /// "don't run while the user is actively working". Input-based
5746    /// (simpler than Task Scheduler's CPU/disk-aware idle). A
5747    /// headless / disconnected console (no interactive user) trivially
5748    /// satisfies it. `None` (default) = no idle requirement. Parsed
5749    /// lazily; [`Schedule::validate`] rejects garbage at create time.
5750    #[serde(default, skip_serializing_if = "Option::is_none")]
5751    pub idle: Option<String>,
5752    /// Fire only when the **whole-machine CPU usage is below this
5753    /// percent** (0–100; e.g. `20.0` = "system CPU < 20%") — "don't run
5754    /// while the box is busy". Reuses the agent's `host_perf` system CPU%
5755    /// sample (`sysinfo` mean over cores), so the reading is up to one
5756    /// `host_perf` cadence old (default 60s) — fine as a "generally
5757    /// busy?" proxy, and more accurate than a fresh one-shot read (CPU%
5758    /// needs two samples). An unavailable sample (host_perf not warmed
5759    /// up yet, or stale) is treated as "not below" (fail-closed — a
5760    /// restrictive gate must not fire when it can't confirm). `None`
5761    /// (default) = no CPU requirement. [`Schedule::validate`] rejects an
5762    /// out-of-range value at create time.
5763    #[serde(default, skip_serializing_if = "Option::is_none")]
5764    pub cpu_below: Option<f64>,
5765    /// Fire only when the host has **internet connectivity** (Windows
5766    /// `GetNetworkConnectivityHint` reports InternetAccess) — "don't run
5767    /// until online" for jobs that download / phone home. A captive
5768    /// portal (ConstrainedInternetAccess), LAN-only (LocalAccess), or
5769    /// unknown/unreadable state is treated as offline (fail-closed) — a
5770    /// portal would just fail a download, so we hold the run. For VPN /
5771    /// SASE / app-specific conditions, use a custom script gate (separate
5772    /// slice). `false` (default) = no network requirement.
5773    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
5774    pub network: bool,
5775}
5776
5777impl Require {
5778    /// `skip_serializing_if` helper for an embedded empty `require`.
5779    pub fn is_empty(&self) -> bool {
5780        !self.ac_power && self.idle.is_none() && self.cpu_below.is_none() && !self.network
5781    }
5782
5783    /// Parsed minimum-idle duration (`None` = no idle requirement, or an
5784    /// unparseable value — `validate` rejects the latter at create time).
5785    pub fn min_idle(&self) -> Option<std::time::Duration> {
5786        self.idle
5787            .as_deref()
5788            .and_then(|s| humantime::parse_duration(s.trim()).ok())
5789    }
5790
5791    /// First unparseable field for create-time rejection (mirrors
5792    /// [`Constraints::bad_skip_date`]).
5793    pub fn bad_idle(&self) -> Option<String> {
5794        self.idle.as_deref().and_then(|s| {
5795            humantime::parse_duration(s.trim())
5796                .err()
5797                .map(|e| format!("constraints.require.idle: invalid duration '{s}': {e}"))
5798        })
5799    }
5800}
5801
5802/// Host-environment state sensed by the agent, fed to [`require_met`].
5803/// A named struct (not positional args) so the growing set of sensed
5804/// signals — several of them `bool` — can't be transposed at a call
5805/// site. The Win32 sensing lives in `kanade-agent::env_gate`.
5806#[derive(Debug, Clone, Copy, Default)]
5807pub struct EnvState {
5808    /// Is the host on AC power (`false` if on battery or unreadable).
5809    pub ac_online: bool,
5810    /// How long the console has been idle (`None` = couldn't determine).
5811    pub idle: Option<std::time::Duration>,
5812    /// Whole-machine CPU usage 0–100 (`None` = no sample yet).
5813    pub cpu_pct: Option<f64>,
5814    /// Does the host have internet connectivity (`false` if offline /
5815    /// LAN-only / unreadable).
5816    pub network_up: bool,
5817}
5818
5819/// Pure env-gate decision (#418 `constraints.require`). The Win32
5820/// sensing lives in the agent (`kanade-agent::env_gate`); this is the
5821/// testable core, fed the already-sensed [`EnvState`]. Deliberately a
5822/// free fn (not folded into [`Constraints::allows`]) so `allows` stays
5823/// pure and `preview` never evaluates a runtime gate. Each set
5824/// requirement is a restrictive AND: any unmet (or unknown) gate skips.
5825pub fn require_met(req: &Require, env: &EnvState) -> bool {
5826    if req.ac_power && !env.ac_online {
5827        return false;
5828    }
5829    if let Some(min) = req.min_idle() {
5830        match env.idle {
5831            Some(d) if d >= min => {}
5832            _ => return false,
5833        }
5834    }
5835    if let Some(max) = req.cpu_below {
5836        match env.cpu_pct {
5837            Some(p) if p < max => {}
5838            _ => return false,
5839        }
5840    }
5841    if req.network && !env.network_up {
5842        return false;
5843    }
5844    true
5845}
5846
5847/// [`Active`] decides *over what date range* a schedule is live,
5848/// `Constraints` decides *when, within an active period,* a fire is
5849/// allowed: `window` (a maintenance time-of-day window),
5850/// `max_concurrent` (a fleet-wide running-instance cap), `skip_dates`
5851/// (holiday exclusion) and `require` (host-environment gates, agent-only
5852/// — see [`Require`]).
5853// No `Eq`: contains `require: Option<Require>` which holds an f64.
5854#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq)]
5855pub struct Constraints {
5856    /// `"HH:MM-HH:MM"` wall-clock window (evaluated in the schedule's
5857    /// `tz`). Fires outside it are skipped — mainly for reconcile
5858    /// cadences ("patrol every 6h, but only fire overnight") and
5859    /// daytime change-freezes. `start > end` crosses midnight
5860    /// (`"22:00-05:00"` = 22:00 through 05:00 next morning). Parsed
5861    /// lazily; [`Schedule::validate`] rejects garbage at create time.
5862    #[serde(default, skip_serializing_if = "Option::is_none")]
5863    pub window: Option<String>,
5864    /// Fleet-wide cap on how many instances of this schedule's job may
5865    /// run **at the same time** (#418 "同時実行ハード上限"). The
5866    /// backend scheduler counts the job's still-in-flight runs
5867    /// (`execution_results.finished_at IS NULL`) each tick and only
5868    /// dispatches to as many remaining pcs as there are free slots —
5869    /// a rolling window that refills as runs complete. Useful for
5870    /// disk/CPU/network-heavy jobs you don't want hammering the whole
5871    /// fleet at once.
5872    ///
5873    /// **Backend-only** (it needs a central counter): combining it
5874    /// with `runs_on: agent` is rejected by [`Schedule::validate`]
5875    /// (#418 decision E — "中央上限には中央が要る"). Most meaningful
5876    /// for `per_pc` reconcile cadences, where the poll re-ticks and
5877    /// refills slots. `None` (default) = no cap.
5878    #[serde(default, skip_serializing_if = "Option::is_none")]
5879    pub max_concurrent: Option<u32>,
5880    /// Calendar dates the schedule must **not** fire on — holidays,
5881    /// blackout days, one-off freeze dates (#418 "祝日除外"). Each is
5882    /// `YYYY-MM-DD`, evaluated as a wall-clock date in the schedule's
5883    /// `tz`. Applies to every `when` shape (a reconcile cadence skips
5884    /// the whole day; a calendar fire landing on the date is
5885    /// suppressed) and is honored by both the live scheduler and
5886    /// `preview`, since both gate on [`Constraints::allows`]. Empty
5887    /// (default) = no skips. Operator-supplied: there is no built-in
5888    /// holiday calendar — list the dates you care about. Parsed lazily;
5889    /// [`Schedule::validate`] rejects a malformed date at create time.
5890    #[serde(default, skip_serializing_if = "Vec::is_empty")]
5891    pub skip_dates: Vec<String>,
5892    /// Host-environment gate (#418): fire only when the target host is
5893    /// in the required state (on AC power, idle). Agent-sensed at fire
5894    /// time, `runs_on: agent` only. See [`Require`]. `None` (default) =
5895    /// no environment requirement.
5896    #[serde(default, skip_serializing_if = "Option::is_none")]
5897    pub require: Option<Require>,
5898}
5899
5900impl Constraints {
5901    /// `skip_serializing_if` helper — empty constraints are omitted
5902    /// from the wire format entirely.
5903    pub fn is_empty(&self) -> bool {
5904        self.window.is_none()
5905            && self.max_concurrent.is_none()
5906            && self.skip_dates.is_empty()
5907            && self.require.as_ref().is_none_or(Require::is_empty)
5908    }
5909
5910    /// The first unparseable `skip_dates` entry, if any — the
5911    /// scheduler logs it at register time so a fail-closed
5912    /// (never-firing) schedule from a hand-edited KV blob is
5913    /// diagnosable, mirroring [`Schedule::bad_window`].
5914    pub fn bad_skip_date(&self) -> Option<String> {
5915        self.skip_dates.iter().find_map(|s| {
5916            chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d")
5917                .err()
5918                .map(|e| format!("constraints.skip_dates: invalid date '{s}': {e}"))
5919        })
5920    }
5921
5922    /// Parse `"HH:MM-HH:MM"` into `(start, end)`. Equal bounds are an
5923    /// error (a zero-width or all-day window is ambiguous — write no
5924    /// window for "always").
5925    pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
5926        let (a, b) = s
5927            .split_once('-')
5928            .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
5929        let parse = |part: &str| {
5930            chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
5931                .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
5932        };
5933        let (start, end) = (parse(a)?, parse(b)?);
5934        if start == end {
5935            return Err(format!(
5936                "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
5937            ));
5938        }
5939        Ok((start, end))
5940    }
5941
5942    /// Is a fire allowed at `now` (evaluated in `tz`)? No window =
5943    /// always allowed. Half-open `[start, end)`; `start > end`
5944    /// crosses midnight.
5945    ///
5946    /// **Fail-closed** on an unparseable window (returns `false`,
5947    /// gemini #452 review): a window is a *restrictive* constraint
5948    /// (change-freeze / overnight-only), so a corrupt one must NOT
5949    /// silently allow fires during the restricted hours. Bad windows
5950    /// are rejected at create time by [`Schedule::validate`]; this
5951    /// only bites a hand-edited KV blob, where blocking is the safe
5952    /// direction. The scheduler warns at register time
5953    /// ([`Schedule::bad_window`]) so a stuck schedule is diagnosable.
5954    /// The tick path never panics regardless.
5955    pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
5956        // #418 holiday / blackout dates: never fire on a listed wall
5957        // date (in `tz`). Checked before the window since a skipped day
5958        // overrides any within-window allowance. Fail-closed on a
5959        // corrupt entry (same posture as `window`): a skip date is a
5960        // *restrictive* constraint, so a garbled one must not silently
5961        // re-enable fires — it blocks until fixed (`validate` rejects it
5962        // at create time; `bad_skip_date` lets the scheduler warn).
5963        if !self.skip_dates.is_empty() {
5964            let today = tz.wall_date(now);
5965            let blocked = self.skip_dates.iter().any(|s| {
5966                match chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d") {
5967                    Ok(d) => d == today,
5968                    Err(_) => true, // corrupt entry → fail-closed (block)
5969                }
5970            });
5971            if blocked {
5972                return false;
5973            }
5974        }
5975        match self.window.as_deref() {
5976            // No window → always allowed.
5977            None => true,
5978            // Window set: membership, or fail-closed if unparseable
5979            // (`window_contains` returns None for a corrupt window).
5980            Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
5981        }
5982    }
5983
5984    /// Membership of a wall-clock time-of-day in the window. `None`
5985    /// when there is no window or it's unparseable (callers decide
5986    /// the failure direction). `start > end` crosses midnight.
5987    fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
5988        let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
5989        Some(if start <= end {
5990            start <= t && t < end
5991        } else {
5992            t >= start || t < end
5993        })
5994    }
5995}
5996
5997/// What to do when a fire's script fails (#418 Phase 4 — the "高"
5998/// retry/backoff gap). Where [`Constraints`] gates *whether* a fire
5999/// happens, `OnFailure` decides what happens *after* one ran and
6000/// came back bad. Only `retry` so far; future `notify` / `disable`
6001/// would join the same namespace.
6002#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
6003pub struct OnFailure {
6004    /// Re-run the script in-process when it exits non-zero (or times
6005    /// out), up to a cap, with a fixed backoff between attempts.
6006    /// `None` (default) = no retry: a failed run is published as-is
6007    /// and (for reconcile cadences) simply re-fires on the next poll
6008    /// tick. See [`Retry`].
6009    #[serde(default, skip_serializing_if = "Option::is_none")]
6010    pub retry: Option<Retry>,
6011}
6012
6013impl OnFailure {
6014    /// `skip_serializing_if` helper — an empty policy is omitted from
6015    /// the wire format entirely.
6016    pub fn is_empty(&self) -> bool {
6017        self.retry.is_none()
6018    }
6019
6020    /// Lower the operator-facing `retry` (humantime backoff) onto the
6021    /// engine vocabulary the agent's executor runs on (backoff in
6022    /// whole seconds). Single seam shared by the backend command
6023    /// builder and the agent's local scheduler so the two stamp the
6024    /// same [`crate::wire::RetrySpec`] onto every Command. Returns
6025    /// `None` when there is no retry policy or the backoff is
6026    /// unparseable (validate() rejects the latter at create time;
6027    /// this stays fail-safe = "no retry" for a hand-edited KV blob
6028    /// rather than panicking on the fire path).
6029    pub fn lowered_retry(&self) -> Option<crate::wire::RetrySpec> {
6030        let r = self.retry.as_ref()?;
6031        let backoff_secs = humantime::parse_duration(&r.backoff).ok()?.as_secs();
6032        Some(crate::wire::RetrySpec {
6033            max: r.max,
6034            backoff_secs,
6035        })
6036    }
6037}
6038
6039/// Fixed-backoff retry policy (#418 Phase 4). `max` is the number of
6040/// *additional* attempts after the first run (so `max: 3` = up to 4
6041/// total executions); `backoff` is the humantime delay slept between
6042/// attempts. The retry happens fire-side (inside `kanade fire` /
6043/// `handle_command`) on every OS for the PoC — the Windows-native
6044/// "restart on failure" Task Scheduler path is deferred to the
6045/// native-delegation phase (#418 decision H).
6046#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
6047pub struct Retry {
6048    /// Max additional attempts after the first failure. Bounded
6049    /// `1..=10` by [`Schedule::validate`] — a typo'd `max: 1000`
6050    /// with a short backoff would otherwise pin a flapping script in
6051    /// a tight loop for the whole window.
6052    pub max: u32,
6053    /// Humantime delay slept between attempts (`"10m"`, `"30s"`).
6054    pub backoff: String,
6055}
6056
6057/// Fleet-wide change-freeze (#418 Phase 5 — the "メンテナンス窓 /
6058/// 変更凍結" gap's global half). Where [`Constraints::window`] is a
6059/// *per-schedule* time-of-day gate, a `Freeze` is a *single, fleet-
6060/// global* "stop all automated change" switch the operator flips
6061/// during an incident or a year-end change-freeze. It lives in its
6062/// own KV singleton ([`crate::kv::KEY_FREEZE`]); when present and
6063/// active, both the backend scheduler and every agent's local
6064/// scheduler skip *every* fire.
6065///
6066/// Shapes:
6067/// * `{}` (no bounds) — frozen indefinitely until the operator
6068///   clears it (incident "big red button").
6069/// * `{ from, until }` — frozen only within `[from, until)`,
6070///   evaluated in `tz` (planned change-freeze; auto-thaws).
6071///
6072/// The KV key being *absent* means "not frozen" — so clearing the
6073/// freeze is a KV delete, and `is_active` only ever runs on a freeze
6074/// the operator actually set.
6075#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
6076pub struct Freeze {
6077    /// Frozen from this instant (RFC3339 or bare `YYYY-MM-DD` in
6078    /// `tz`). `None` ⇒ frozen from the beginning of time.
6079    #[serde(default, skip_serializing_if = "Option::is_none")]
6080    pub from: Option<String>,
6081    /// Thawed from this instant on, exclusive. `None` ⇒ frozen with
6082    /// no scheduled end (manual clear required).
6083    #[serde(default, skip_serializing_if = "Option::is_none")]
6084    pub until: Option<String>,
6085    /// Operator-supplied note surfaced on the freeze-skip log and the
6086    /// SPA banner ("year-end change freeze", "INC-1234"). Advisory.
6087    #[serde(default, skip_serializing_if = "Option::is_none")]
6088    pub reason: Option<String>,
6089    /// Timezone the bare-date bounds are evaluated in (RFC3339 bounds
6090    /// carry their own offset). Defaults to host-local like a
6091    /// schedule's `tz`.
6092    #[serde(default)]
6093    pub tz: ScheduleTz,
6094}
6095
6096impl Freeze {
6097    /// Is the fleet frozen at `now`? An empty window (`from`/`until`
6098    /// both absent) is frozen unconditionally; otherwise membership of
6099    /// `[from, until)` in `tz`. Half-open like [`Active::contains`],
6100    /// but **fails CLOSED** on an unparseable bound — a freeze is a
6101    /// safety switch, so a corrupt window (only reachable via a
6102    /// hand-edited KV blob; `validate` rejects it at set time) must
6103    /// mean "frozen", not "fire normally" (coderabbit #472). This is
6104    /// the one deliberate divergence from `active`'s fail-OPEN
6105    /// behaviour, where an unparseable bound dormant-skips a schedule.
6106    pub fn is_active(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
6107        // Parse a bound; an unparseable one short-circuits the whole
6108        // check to `true` (frozen) via the closure's `None` sentinel
6109        // handled below.
6110        let bound = |s: &Option<String>| -> Result<Option<chrono::DateTime<chrono::Utc>>, ()> {
6111            match s.as_deref() {
6112                None => Ok(None),
6113                Some(raw) => Active::parse_bound(raw, self.tz).map(Some).map_err(|_| ()),
6114            }
6115        };
6116        let (from, until) = match (bound(&self.from), bound(&self.until)) {
6117            (Ok(f), Ok(u)) => (f, u),
6118            // Any corrupt bound → fail closed (frozen).
6119            _ => return true,
6120        };
6121        if from.is_some_and(|f| now < f) {
6122            return false;
6123        }
6124        if until.is_some_and(|u| now >= u) {
6125            return false;
6126        }
6127        true
6128    }
6129
6130    /// Reject unparseable bounds / `from >= until` at set time (the
6131    /// API + CLI counterpart to [`Schedule::validate`]).
6132    pub fn validate(&self) -> Result<(), String> {
6133        let from = self
6134            .from
6135            .as_deref()
6136            .map(|s| Active::parse_bound(s, self.tz))
6137            .transpose()
6138            .map_err(|e| e.replace("active:", "freeze:"))?;
6139        let until = self
6140            .until
6141            .as_deref()
6142            .map(|s| Active::parse_bound(s, self.tz))
6143            .transpose()
6144            .map_err(|e| e.replace("active:", "freeze:"))?;
6145        if let (Some(f), Some(u)) = (from, until) {
6146            if f >= u {
6147                return Err(format!(
6148                    "freeze.from ({}) must be strictly before freeze.until ({})",
6149                    self.from.as_deref().unwrap_or_default(),
6150                    self.until.as_deref().unwrap_or_default(),
6151                ));
6152            }
6153        }
6154        Ok(())
6155    }
6156}
6157
6158/// The system-generated poll cadence every reconcile-shaped `when`
6159/// lowers to. Operators never write this: the real inter-run
6160/// spacing is the `every` cooldown; this only bounds "how soon do
6161/// we notice somebody is due" (#418 decision B took the poll
6162/// period away from the operator).
6163pub const POLL_CRON: &str = "0 * * * * *";
6164
6165/// What a [`When`] lowers to — the exact (cron, mode, cooldown)
6166/// trio the pre-#418 engine ran on. Keeping the engine vocabulary
6167/// unchanged is what lets Phase 1 swap the operator surface without
6168/// touching the tick / dedup machinery.
6169pub struct Lowered {
6170    /// Cron handed to `tokio-cron-scheduler` — [`POLL_CRON`] for
6171    /// reconcile shapes, a 6/7-field cron for calendar shapes.
6172    pub cron: String,
6173    /// Dedup semantics for `decide_fire`.
6174    pub mode: ExecMode,
6175    /// Humantime re-arm interval (`None` = succeed once, skip
6176    /// forever).
6177    pub cooldown: Option<String>,
6178    /// Timezone to evaluate `cron` in (#418 Phase 2). The scheduler
6179    /// passes this to `Job::new_async_tz`. Reconcile shapes carry
6180    /// the schedule's tz too even though POLL_CRON is tz-agnostic,
6181    /// so the same value drives the `active`-window check.
6182    pub tz: ScheduleTz,
6183}
6184
6185impl Schedule {
6186    /// The error message if this schedule's `constraints.window` is
6187    /// set but unparseable, else `None`. The scheduler logs this at
6188    /// register time so a fail-closed (never-firing) schedule from a
6189    /// hand-edited KV blob is diagnosable (gemini #452 review).
6190    pub fn bad_window(&self) -> Option<String> {
6191        let w = self.constraints.window.as_deref()?;
6192        Constraints::parse_window(w).err()
6193    }
6194
6195    /// True when this is a `calendar` schedule whose fire time can
6196    /// never fall inside its `constraints.window` — the cron fires,
6197    /// the window check rejects it, and (firing only at that
6198    /// time-of-day) it effectively never runs. An easy misconfig to
6199    /// set up by accident; the scheduler warns at register time
6200    /// (claude #452 review). Reconcile shapes poll every minute, so
6201    /// they always catch the window opening and aren't affected.
6202    pub fn calendar_outside_window(&self) -> bool {
6203        let When::Calendar(c) = &self.when else {
6204            return false;
6205        };
6206        let Some(t) = c.fire_time() else {
6207            return false;
6208        };
6209        matches!(self.constraints.window_contains(t), Some(false))
6210    }
6211
6212    /// Up to `count` future instants this schedule will fire, as
6213    /// absolute UTC, strictly after `now` — the dry-run / preview
6214    /// surface (#418 "ドライラン / プレビュー"). Only **calendar**
6215    /// schedules have discrete fire times; reconcile shapes
6216    /// (`per_pc`/`per_target`) poll every minute gated by cooldown, so
6217    /// they return an empty vec and the caller describes the cadence
6218    /// instead. Occurrences outside the `active.{from,until}` window or
6219    /// the `constraints.window` are **skipped**, so the list reflects
6220    /// when the schedule will ACTUALLY run, not the raw cron ticks.
6221    /// Evaluated in the schedule's `tz`, exactly like the scheduler's
6222    /// `Job::new_async_tz`, and with the same croner config the
6223    /// scheduler / [`Schedule::validate`] use, so a preview can never
6224    /// disagree with a real fire. A schedule that can never fire (a
6225    /// calendar time wholly outside its window, a past one-shot,
6226    /// `enabled: false` is *not* considered here — callers gate on
6227    /// `enabled` separately) yields an empty vec.
6228    pub fn preview_fires(
6229        &self,
6230        now: chrono::DateTime<chrono::Utc>,
6231        count: usize,
6232    ) -> Vec<chrono::DateTime<chrono::Utc>> {
6233        use croner::parser::{CronParser, Seconds};
6234        if !matches!(self.when, When::Calendar(_)) {
6235            return Vec::new();
6236        }
6237        // Same lowering + croner config as `next_calendar_fire` and the
6238        // live scheduler, so a preview can never disagree with a real
6239        // fire. `preview_fires` adds the N-occurrence walk and the
6240        // active / window filtering on top of that single seam.
6241        let lowered = self.lowered();
6242        let Ok(cron) = CronParser::builder()
6243            .seconds(Seconds::Required)
6244            .dom_and_dow(true)
6245            .build()
6246            .parse(&lowered.cron)
6247        else {
6248            return Vec::new();
6249        };
6250        let accept = |utc: chrono::DateTime<chrono::Utc>| {
6251            self.active.contains(utc, self.tz) && self.constraints.allows(utc, self.tz)
6252        };
6253        match self.tz {
6254            ScheduleTz::Utc => Self::next_occurrences(&cron, now, count, accept),
6255            ScheduleTz::Local => {
6256                Self::next_occurrences(&cron, now.with_timezone(&chrono::Local), count, accept)
6257            }
6258        }
6259    }
6260
6261    /// Walk croner forward from `after` collecting up to `count`
6262    /// accepted occurrences (converted to UTC). Generic over the tz the
6263    /// cron is evaluated in so `preview_fires` can run it in either
6264    /// `Utc` or `Local` without duplicating the loop.
6265    fn next_occurrences<Tz>(
6266        cron: &croner::Cron,
6267        after: chrono::DateTime<Tz>,
6268        count: usize,
6269        accept: impl Fn(chrono::DateTime<chrono::Utc>) -> bool,
6270    ) -> Vec<chrono::DateTime<chrono::Utc>>
6271    where
6272        Tz: chrono::TimeZone,
6273    {
6274        // Bound the scan so an `active`/window dead-end (every future
6275        // tick rejected) can't spin forever: ~4096 raw ticks covers
6276        // >10y of a daily calendar while staying instant for croner.
6277        const SCAN_CAP: usize = 4096;
6278        let mut out = Vec::with_capacity(count.min(SCAN_CAP));
6279        let mut cursor = after;
6280        let mut scanned = 0usize;
6281        while out.len() < count && scanned < SCAN_CAP {
6282            scanned += 1;
6283            let Ok(next) = cron.find_next_occurrence(&cursor, false) else {
6284                break;
6285            };
6286            let utc = next.with_timezone(&chrono::Utc);
6287            if accept(utc) {
6288                out.push(utc);
6289            }
6290            // `find_next_occurrence(.., inclusive = false)` already
6291            // advances strictly past `cursor`, so handing it `next`
6292            // verbatim gets the following occurrence — no manual +1s
6293            // nudge (and `DateTime<Tz>` is `Copy`, so no clone).
6294            cursor = next;
6295        }
6296        out
6297    }
6298
6299    /// Lower the operator-facing `when` onto the engine vocabulary.
6300    /// Single seam shared by the backend scheduler and the agent's
6301    /// local scheduler so the two can never drift.
6302    pub fn lowered(&self) -> Lowered {
6303        let tz = self.tz;
6304        match &self.when {
6305            When::PerPc(p) => Lowered {
6306                cron: POLL_CRON.into(),
6307                mode: ExecMode::OncePerPc,
6308                cooldown: p.cooldown(),
6309                tz,
6310            },
6311            When::PerTarget(p) => Lowered {
6312                cron: POLL_CRON.into(),
6313                mode: ExecMode::OncePerTarget,
6314                cooldown: p.cooldown(),
6315                tz,
6316            },
6317            // `to_cron` only fails on a malformed `at` (rejected by
6318            // validate() at create time). For a hand-edited KV blob
6319            // that slipped past, emit a deliberately-invalid cron so
6320            // register()'s Job::new_async_tz fails → warn+skip,
6321            // rather than firing at the wrong time.
6322            When::Calendar(c) => Lowered {
6323                cron: c
6324                    .to_cron()
6325                    .unwrap_or_else(|_| "# invalid calendar at".into()),
6326                mode: ExecMode::EveryTick,
6327                cooldown: None,
6328                tz,
6329            },
6330            // Event triggers have no cron — the agent fires them from an
6331            // OS event source. The `# event-trigger` cron is never
6332            // registered (the scheduler branches on `is_event()` first),
6333            // but keep it deliberately-invalid as a belt-and-suspenders
6334            // so a stray registration would fail rather than misfire.
6335            When::On(_) => Lowered {
6336                cron: "# event-trigger (no cron)".into(),
6337                mode: ExecMode::Event,
6338                cooldown: None,
6339                tz,
6340            },
6341        }
6342    }
6343
6344    /// True when this schedule fires from an OS event (`when: { on }`)
6345    /// rather than a clock — the agent skips `tokio-cron` registration
6346    /// for these and drives them from boot / session-change instead.
6347    pub fn is_event(&self) -> bool {
6348        matches!(self.when, When::On(_))
6349    }
6350
6351    /// The OS event triggers this schedule listens for, or `&[]` when it
6352    /// is not an event schedule.
6353    pub fn event_triggers(&self) -> &[OnTrigger] {
6354        match &self.when {
6355            When::On(t) => t,
6356            _ => &[],
6357        }
6358    }
6359
6360    /// The next absolute (UTC) time this schedule fires, or `None` when
6361    /// it has no discrete upcoming fire to preview.
6362    ///
6363    /// Used by the KLP `maintenance.list` preview ("what's about to
6364    /// happen on my PC", SPEC §2.1). Returns `None` for:
6365    ///
6366    /// - reconcile shapes (`per_pc` / `per_target`) — they lower to the
6367    ///   every-minute [`POLL_CRON`] and re-converge state continuously,
6368    ///   so "next fire" is always ~60s away and means nothing to a user
6369    ///   previewing upcoming maintenance;
6370    /// - a calendar schedule whose lowered cron won't parse (a
6371    ///   hand-edited KV blob that slipped past [`Schedule::validate`]);
6372    /// - a cron with no future occurrence.
6373    ///
6374    /// The wall-clock fire is evaluated in the schedule's own `tz`
6375    /// (matching the live tick's `Job::new_async_tz`) then normalised
6376    /// to UTC for the wire. `inclusive = false`: strictly the *next*
6377    /// fire after `now`, never one matching the current instant.
6378    pub fn next_calendar_fire(
6379        &self,
6380        now: chrono::DateTime<chrono::Utc>,
6381    ) -> Option<chrono::DateTime<chrono::Utc>> {
6382        if !matches!(self.when, When::Calendar(_)) {
6383            return None;
6384        }
6385        let lowered = self.lowered();
6386        // Same parser configuration tokio-cron-scheduler 0.15 uses
6387        // internally, so this can never compute a fire the live
6388        // scheduler wouldn't (seconds required, DOM-and-DOW honored).
6389        let cron = croner::parser::CronParser::builder()
6390            .seconds(croner::parser::Seconds::Required)
6391            .dom_and_dow(true)
6392            .build()
6393            .parse(&lowered.cron)
6394            .ok()?;
6395        match lowered.tz {
6396            ScheduleTz::Utc => cron.find_next_occurrence(&now, false).ok(),
6397            ScheduleTz::Local => {
6398                let now_local = now.with_timezone(&chrono::Local);
6399                cron.find_next_occurrence(&now_local, false)
6400                    .ok()
6401                    .map(|t| t.with_timezone(&chrono::Utc))
6402            }
6403        }
6404    }
6405
6406    /// Cross-field semantic checks that don't fit pure serde derive
6407    /// — the [`Manifest::validate`] counterpart (#418 decision F;
6408    /// pre-Phase-1 a broken schedule was accepted at create time
6409    /// and silently warn-skipped at tick time). Run at every create
6410    /// site: `kanade schedule create` (client-side) and
6411    /// `POST /api/schedules`. The job_id-exists check lives in the
6412    /// API handler instead — it needs the JOBS KV.
6413    pub fn validate(&self) -> Result<(), String> {
6414        if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
6415            return Err(
6416                "when.per_target needs fleet-wide completion data and is backend-only; \
6417                 it cannot be combined with runs_on: agent (each agent self-schedules, \
6418                 so per-target dedup would be deduping across a target of 1)"
6419                    .into(),
6420            );
6421        }
6422        // #418 event triggers: the agent owns the OS event source
6423        // (boot / session-change), so `when: { on }` is agent-only and
6424        // needs at least one trigger.
6425        if let When::On(triggers) = &self.when {
6426            if !matches!(self.runs_on, RunsOn::Agent) {
6427                return Err(
6428                    "when.on (OS event trigger) is fired by the agent's own event \
6429                     source, so it requires runs_on: agent"
6430                        .into(),
6431                );
6432            }
6433            if triggers.is_empty() {
6434                return Err(
6435                    "when.on must list at least one trigger (e.g. [startup, logon])".into(),
6436                );
6437            }
6438        }
6439        if let Some(cd) = self.lowered().cooldown.as_deref() {
6440            humantime::parse_duration(cd)
6441                .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
6442        }
6443        if let When::Calendar(c) = &self.when {
6444            // Lower the calendar form to its cron (catches a bad `at`
6445            // and the date+days conflict), then validate that cron
6446            // with the same parser configuration tokio-cron-scheduler
6447            // 0.15 uses internally (croner, seconds required,
6448            // DOM-and-DOW both honored, year optional) — create-time
6449            // validation can never accept what register() rejects.
6450            let cron = c.to_cron()?;
6451            croner::parser::CronParser::builder()
6452                .seconds(croner::parser::Seconds::Required)
6453                .dom_and_dow(true)
6454                .build()
6455                .parse(&cron)
6456                .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
6457        }
6458        // The other humantime strings on the schedule (claude #419
6459        // review): runtime degrades gracefully on both (bad jitter →
6460        // silent no-op, bad starting_deadline → warn + skipped tick),
6461        // but "rejected at create time" should cover every field the
6462        // operator can typo, not just `when`.
6463        if let Some(j) = &self.plan.jitter {
6464            humantime::parse_duration(j)
6465                .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
6466        }
6467        if let Some(sd) = &self.starting_deadline {
6468            humantime::parse_duration(sd)
6469                .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
6470        }
6471        let from = self
6472            .active
6473            .from
6474            .as_deref()
6475            .map(|s| Active::parse_bound(s, self.tz))
6476            .transpose()?;
6477        let until = self
6478            .active
6479            .until
6480            .as_deref()
6481            .map(|s| Active::parse_bound(s, self.tz))
6482            .transpose()?;
6483        if let (Some(f), Some(u)) = (from, until) {
6484            if f >= u {
6485                return Err(format!(
6486                    "active.from ({}) must be strictly before active.until ({})",
6487                    self.active.from.as_deref().unwrap_or_default(),
6488                    self.active.until.as_deref().unwrap_or_default(),
6489                ));
6490            }
6491        }
6492        // #418 Phase 3: a bad maintenance window is rejected at create
6493        // time (parse_window also catches equal bounds).
6494        if let Some(w) = self.constraints.window.as_deref() {
6495            Constraints::parse_window(w)?;
6496        }
6497        // #418 holiday exclusion: reject a malformed skip date at create
6498        // time so the fail-closed `allows` path only ever bites a
6499        // hand-edited KV blob, not a fresh `kanade schedule create`.
6500        if let Some(err) = self.constraints.bad_skip_date() {
6501            return Err(err);
6502        }
6503        // #418: constraints.max_concurrent is a central running-instance
6504        // cap, so it needs the backend's counter — reject it on
6505        // runs_on: agent (decision E), and reject a meaningless 0.
6506        if let Some(mc) = self.constraints.max_concurrent {
6507            // Check the structural incompatibility (agent has no central
6508            // counter) before the value range, so a `max_concurrent: 0`
6509            // + `runs_on: agent` combo reports the more fundamental
6510            // problem first (claude #542).
6511            if matches!(self.runs_on, RunsOn::Agent) {
6512                return Err(
6513                    "constraints.max_concurrent needs a central counter and is backend-only; \
6514                     it cannot be combined with runs_on: agent (each agent self-schedules, \
6515                     so there is no fleet-wide count to cap against)"
6516                        .into(),
6517                );
6518            }
6519            if mc == 0 {
6520                return Err(
6521                    "constraints.max_concurrent must be >= 1 (0 would never fire; \
6522                     omit it for no cap)"
6523                        .into(),
6524                );
6525            }
6526        }
6527        // #418: constraints.require (host-state env gates: ac_power /
6528        // idle / cpu_below / network) is sensed in-process by the agent,
6529        // so it needs runs_on: agent — the backend can't read a target
6530        // host's power / idle / cpu / connectivity state. Symmetric with
6531        // `when: { on }` (also agent-only); inverse of max_concurrent
6532        // (backend-only).
6533        if let Some(req) = &self.constraints.require {
6534            if !req.is_empty() && matches!(self.runs_on, RunsOn::Backend) {
6535                return Err(
6536                    "constraints.require (host-state env gates: ac_power / idle / cpu_below / \
6537                     network) is sensed in-process by the agent and needs runs_on: agent; the \
6538                     backend cannot read a target host's power / idle / cpu / connectivity state"
6539                        .into(),
6540                );
6541            }
6542            // Reject a malformed idle duration at create time so the
6543            // fail-closed runtime path only ever bites a hand-edited
6544            // KV blob (mirror skip_dates / on_failure.retry).
6545            if let Some(err) = req.bad_idle() {
6546                return Err(err);
6547            }
6548            // cpu_below is a percent — reject out-of-range so a typo
6549            // can't make a schedule that never (>=100 is always-busy?
6550            // no — <0 never matches) or trivially fires.
6551            if let Some(c) = req.cpu_below
6552                && !(c > 0.0 && c <= 100.0)
6553            {
6554                return Err(format!(
6555                    "constraints.require.cpu_below must be in (0, 100] percent (got {c}); \
6556                     omit it for no CPU requirement"
6557                ));
6558            }
6559        }
6560        // #418 Phase 4: a bad on_failure.retry is rejected at create
6561        // time — backoff must be valid humantime, and max is bounded
6562        // so a typo can't pin a flapping script in a tight loop.
6563        if let Some(r) = &self.on_failure.retry {
6564            let backoff = humantime::parse_duration(&r.backoff).map_err(|e| {
6565                format!(
6566                    "on_failure.retry.backoff: invalid duration '{}': {e}",
6567                    r.backoff
6568                )
6569            })?;
6570            // The wire form lowers backoff to whole seconds, so a
6571            // sub-second value would silently become a 0s no-wait
6572            // (coderabbit #466). Reject it rather than honour a backoff
6573            // the operator can't actually get.
6574            if backoff.as_secs() < 1 {
6575                return Err(format!(
6576                    "on_failure.retry.backoff must be >= 1s (got '{}'); sub-second backoffs \
6577                     round to 0 on the wire",
6578                    r.backoff
6579                ));
6580            }
6581            if !(1..=10).contains(&r.max) {
6582                return Err(format!(
6583                    "on_failure.retry.max must be 1..=10 (got {}); it counts additional \
6584                     attempts after the first run",
6585                    r.max
6586                ));
6587            }
6588        }
6589        // A blank / whitespace-only tag renders an empty filter chip on
6590        // the Schedules page — reject it at create time, mirroring the
6591        // Manifest::validate tag guard.
6592        for tag in &self.tags {
6593            if tag.trim().is_empty() {
6594                return Err("tags must not contain empty entries".to_string());
6595            }
6596        }
6597        Ok(())
6598    }
6599}
6600
6601fn default_true() -> bool {
6602    true
6603}