Skip to main content

kanade_shared/
manifest.rs

1use serde::{Deserialize, Serialize};
2
3use crate::ipc::jobs::JobCategory;
4use crate::wire::{RunAs, Shell, Staleness};
5
6/// YAML job manifest (= registered "what to run", v0.18.0+).
7///
8/// Owns only script-intrinsic fields. **Who** (`target`), **how to
9/// phase fanout** (`rollout`), and **when to stagger start**
10/// (`jitter`) all moved to the Schedule / exec request side — same
11/// script can now be fired against different targets / rollouts
12/// without copying the script body.
13///
14/// #492: these types are READ fleet-wide (agents decode them from
15/// BUCKET_JOBS / BUCKET_SCHEDULES and inside live Commands), so they
16/// must tolerate unknown fields — `deny_unknown_fields` here made a
17/// gradually-upgrading fleet's OLD agents reject the whole object
18/// the moment a newer backend added any field. Operator typo
19/// protection (the old reason for the attribute) lives at the WRITE
20/// boundaries instead: `kanade job/schedule create` and the backend
21/// POST extractor parse via [`crate::strict`], which rejects unknown
22/// keys with their full paths. The wire rule: new fields always get
23/// `#[serde(default)]` (+ `skip_serializing_if` while old readers
24/// may still be strict).
25#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
26pub struct Manifest {
27    pub id: String,
28    pub version: String,
29    #[serde(default)]
30    pub description: Option<String>,
31    pub execute: Execute,
32    #[serde(default)]
33    pub require_approval: bool,
34    /// Opt-in marker that this job produces a JSON inventory fact
35    /// payload on stdout. When present, the backend's results
36    /// projector parses `ExecResult.stdout` as JSON and upserts an
37    /// `inventory_facts` row keyed by `(pc_id, manifest.id)`. The
38    /// `display` sub-config drives the SPA's Inventory page render.
39    #[serde(default)]
40    pub inventory: Option<InventoryHint>,
41    /// Issue #246: opt-in marker that this job emits per-line
42    /// observability events on stdout (one JSON `ObsEvent` per
43    /// newline). When present, the agent — after the script exits
44    /// successfully — parses each non-empty stdout line as an
45    /// `ObsEvent`, publishes it on `obs.<pc_id>` via the
46    /// `obs_outbox`, and (intentionally) **omits the stdout from
47    /// the `ExecResult`** so the timeline data doesn't double up
48    /// in `execution_results.stdout` (which would multiply rows
49    /// by ~50/day/PC of noise).
50    ///
51    /// Distinct from `inventory:` (single JSON object → projector
52    /// upsert) — events are append-only timeline points consumed
53    /// by the dedicated `obs_events` table.
54    #[serde(default)]
55    pub emit: Option<EmitConfig>,
56    /// #290: opt-in marker that this job is an operator-defined
57    /// **health check** whose result feeds the Client App's Health
58    /// tab over KLP (`StateSnapshot.checks`). The script prints a
59    /// free-form JSON object on stdout (like any inventory job); the
60    /// agent reads the [`CheckHint::status_field`] value dynamically
61    /// into a [`crate::ipc::state::Check`] named `check.name`.
62    /// Cadence / windows / conditions come from
63    /// the job's Schedule (exactly like inventory) — there is
64    /// deliberately no interval here. **Composes with `inventory:`**:
65    /// the script's stdout is one JSON object, so a check can also
66    /// carry an `inventory:` block to project the rest of that object
67    /// (incl. `explode` sub-tables) for SPA fleet-querying. Only
68    /// `emit:` (NDJSON stdout) is incompatible.
69    #[serde(default)]
70    pub check: Option<CheckHint>,
71    /// v0.26: Layer 2 staleness policy (SPEC.md §2.6.2). Controls
72    /// what the agent does at fire time when it can't verify the
73    /// `script_current` / `script_status` KV values are fresh —
74    /// especially relevant for `runs_on: agent` schedules where
75    /// the agent may fire from cache while offline. Defaults to
76    /// `Staleness::Cached` (silently use cached values), which
77    /// matches every pre-v0.26 Manifest.
78    #[serde(default)]
79    pub staleness: Staleness,
80    /// #291: opt-in marker that this job is offered to **end users**
81    /// in the Client App's job tabs over KLP (`jobs.list` →
82    /// `jobs.execute`). Parallel to [`inventory`] / [`check`] /
83    /// [`emit`]: the block's mere presence is the opt-in, and it
84    /// groups the end-user presentation fields (name / category /
85    /// icon) that only make sense for a user-facing job. `None`
86    /// (the default) ⇒ an operator-only job — inventory, checks,
87    /// scheduled maintenance — that never surfaces in the catalog.
88    ///
89    /// The agent re-reads this at every `jobs.list` / `jobs.execute`
90    /// (SPEC §2.1), so removing the block takes a job out of a
91    /// running client on its next action.
92    ///
93    /// [`inventory`]: Manifest::inventory
94    /// [`check`]: Manifest::check
95    /// [`emit`]: Manifest::emit
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub client: Option<ClientHint>,
98    /// Free-form operator taxonomy for the Jobs catalog. Purely a
99    /// SPA-side organisational aid — agents / scheduler / projector
100    /// never read it — so it carries no runtime semantics and any
101    /// string is allowed (`security`, `weekly`, `windows`, …). Jobs
102    /// cross-cut (a `check-bitlocker` is at once a health-check, a
103    /// security control, and Windows-specific), which is why this is
104    /// a multi-valued list rather than the single closed-enum
105    /// [`ClientHint::category`] (whose values are the end-user Client
106    /// App's tabs, a different concern). The operator Jobs page groups
107    /// rows by id-prefix for free; tags add the orthogonal filter axis
108    /// prefixes can't express.
109    ///
110    /// Empty by default (the overwhelming majority of jobs), and a
111    /// new field, so it follows the #492 wire rule: `serde(default)`
112    /// plus `skip_serializing_if` keep gradually-upgrading old readers
113    /// from tripping over its absence / presence.
114    #[serde(default, skip_serializing_if = "Vec::is_empty")]
115    pub tags: Vec<String>,
116    /// GitOps provenance (#678) — see [`JobOrigin`]. Stamped by
117    /// `kanade job create` when the source YAML lives inside a Git work
118    /// tree, so the SPA can render the job read-only and point edits
119    /// back at the repo instead of letting a ClickOps edit silently
120    /// diverge from Git (SPEC design principle #3: 設定駆動 YAML + Git).
121    /// `None` for SPA-born jobs and for manifests applied from outside
122    /// any Git repo. Purely informational: agents / scheduler /
123    /// projector never read it, and it survives `script_file:` inlining
124    /// (it's orthogonal to the exactly-one-of script-source rule). New
125    /// field ⇒ #492 wire rule (`default` + `skip_serializing_if`).
126    #[serde(default, skip_serializing_if = "Option::is_none")]
127    pub origin: Option<JobOrigin>,
128}
129
130/// GitOps provenance for a [`Manifest`] (#678). Populated by
131/// `kanade job create` from the Git context of the source YAML; the
132/// SPA reads it to render Git-managed jobs read-only and link the
133/// operator back at the repo. Never consulted by the runtime.
134#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
135pub struct JobOrigin {
136    /// Repo-relative path of the manifest YAML — the primary edit
137    /// target the SPA surfaces (e.g. `configs/jobs/foo.yaml`). Forward
138    /// slashes regardless of the authoring OS.
139    pub path: String,
140    /// `origin` remote URL, when the repo has one. Lets the SPA turn
141    /// `path` into a clickable link; `None` for remote-less repos.
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub repo: Option<String>,
144    /// Repo-relative path of the `script_file:` this manifest inlined,
145    /// when it used one — a secondary pointer shown beneath `path`.
146    #[serde(default, skip_serializing_if = "Option::is_none")]
147    pub script_file: Option<String>,
148}
149
150/// "Who + how + when-to-stagger" — the fanout-plan side of an exec.
151/// Used both as the POST `/api/exec/{job_id}` body and as the embedded
152/// `target` / `rollout` / `jitter` slot on [`Schedule`]. Centralising
153/// here keeps the validation + serialisation logic in one place.
154#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
155pub struct FanoutPlan {
156    #[serde(default)]
157    pub target: Target,
158    /// Optional wave rollout — when present, the backend publishes
159    /// each wave's group subject on its own delay schedule instead
160    /// of fanning out the `target` block in one go. `target` then
161    /// only labels the deploy for the audit log.
162    #[serde(default, skip_serializing_if = "Option::is_none")]
163    pub rollout: Option<Rollout>,
164    /// Optional humantime jitter; agent uses it to randomise
165    /// execution start. Lives here (not on the script) so different
166    /// schedules / ad-hoc fires of the same job can pick different
167    /// stagger windows.
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub jitter: Option<String>,
170    /// Absolute time the scheduler stamps on each emitted Command
171    /// when this exec was driven by a [`Schedule`] with
172    /// `starting_deadline`. Agents receiving a Command after this
173    /// instant publish a synthetic skipped-result instead of
174    /// running the script. `None` (default) = no deadline / catch
175    /// up whenever delivered. Operators don't usually set this
176    /// directly — the scheduler computes it from `tick_at +
177    /// starting_deadline`.
178    #[serde(default, skip_serializing_if = "Option::is_none")]
179    pub deadline_at: Option<chrono::DateTime<chrono::Utc>>,
180}
181
182/// Manifest sub-section: how the SPA should render the inventory
183/// facts this job produces. Each field name (`field`) is a top-level
184/// key in the stdout JSON, e.g. `hostname`, `ram_gb`.
185///
186/// Two render modes:
187///   * `display` — vertical "field / value" per PC, used by the
188///     `/inventory?pc=<id>` detail view. ALL columns the operator
189///     wants visible on the detail page.
190///   * `summary` — horizontal table across the fleet (row = PC,
191///     column = field) on `/inventory`. Optional; when omitted the
192///     SPA falls back to `display`, but operators usually want a
193///     trimmer "hostname / OS / CPU / RAM" set for the fleet view.
194#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
195pub struct InventoryHint {
196    /// Detail-view columns, in order.
197    pub display: Vec<DisplayField>,
198    /// Optional fleet-list columns (row = PC). Defaults to `display`
199    /// when omitted, but operators usually pick a 3-5 column subset.
200    #[serde(default, skip_serializing_if = "Option::is_none")]
201    pub summary: Option<Vec<DisplayField>>,
202    /// v0.31 / #40: payload arrays that should be exploded into
203    /// per-element rows of a derived SQLite table. Lets operators
204    /// answer cross-PC questions ("which PCs still have Chrome <
205    /// 120?", "C: >90% full") with normal SQL filters + indexes
206    /// instead of grepping JSON. The projector creates the derived
207    /// table on register and replaces this PC's rows on each result
208    /// (DELETE WHERE pc_id=? AND job_id=? + bulk INSERT). See
209    /// [`ExplodeSpec`] for the per-spec schema.
210    #[serde(default, skip_serializing_if = "Option::is_none")]
211    pub explode: Option<Vec<ExplodeSpec>>,
212    /// v0.35 / #93: top-level scalar fields whose changes the
213    /// projector logs to `inventory_history` (one event per
214    /// changed field per scan). Pairs with `explode[].track_history`
215    /// — that covers array elements; this covers single-valued
216    /// fields like `ram_bytes` / `os_version` / `cpu_model` /
217    /// `os_build` that operators want to track for "did the RAM
218    /// get upgraded?" / "when did Win 11 land on this PC?" /
219    /// "BIOS / firmware bumped?" questions. Field name = `field_path`
220    /// in the history row, `identity_json` is NULL, `before_json`
221    /// / `after_json` each carry `{"value": <prior or new value>}`.
222    /// First-ever observation of a scalar (no prior facts row)
223    /// emits `added`; subsequent value changes emit `changed`. No
224    /// `removed` events — a scalar disappearing from the payload
225    /// is rare and the operator can still see the last value via
226    /// the `before_json` of the most recent change.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub history_scalars: Option<Vec<String>>,
229}
230
231/// Manifest sub-section (#290): marks a job as an operator-defined
232/// **health check**. Parallel to [`InventoryHint`] / `EmitConfig`.
233/// The stdout contract is a free-form JSON object (same as any
234/// inventory job) from which the agent reads `status_field` /
235/// `detail_field` to build the KLP [`crate::ipc::state::Check`] shown
236/// on the Client App's Health tab.
237///
238/// There is deliberately **no timing field** — when / how often /
239/// in which window a check runs is driven by the job's Schedule,
240/// exactly like inventory jobs, so operators get the full `when:` /
241/// rollout / `runs_on` expressiveness for free.
242///
243/// A check's stdout is a **free-form inventory object** (arbitrary
244/// key/value pairs + arrays) — same as any inventory job — that also
245/// carries a status field. `check:` adds only the health semantics on
246/// top: which field is the ok/warn/fail/unknown status, an optional
247/// one-line summary field, and a remediation job. Everything else
248/// (rich per-PC detail, `explode` sub-tables like a software list) is
249/// driven by a co-present [`InventoryHint`] and rendered with the
250/// SAME display logic the SPA Inventory page uses — on the Client App
251/// too. This keeps checks maximally expressive without a bespoke
252/// payload type.
253#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
254pub struct CheckHint {
255    /// Stable check id → [`Check.name`](crate::ipc::state::Check),
256    /// the SPA/Client React key + analytics label. Unique within the
257    /// fleet's check set. Machine-friendly slug (`disk_space`,
258    /// `defender_rtp`); for the human-facing row title see [`label`].
259    ///
260    /// [`label`]: CheckHint::label
261    pub name: String,
262    /// Optional human-facing display title →
263    /// [`Check.label`](crate::ipc::state::Check). The Client App's
264    /// Health tab and the operator SPA's Compliance page render this
265    /// instead of the [`name`](CheckHint::name) slug when set
266    /// (`"ウイルス対策のリアルタイム保護"` reads better than
267    /// `defender_rtp`). Falls back to the slug when absent, so it's
268    /// purely additive. Author it in the check's language — there's no
269    /// per-locale variant; checks are operator-defined per fleet.
270    #[serde(default, skip_serializing_if = "Option::is_none")]
271    pub label: Option<String>,
272    /// Top-level stdout field whose string value
273    /// (`ok`/`warn`/`fail`/`unknown`) becomes the Health-tab light
274    /// ([`CheckStatus`](crate::ipc::state::CheckStatus)). Defaults to
275    /// `"status"`; a missing / unparseable value → `unknown`.
276    #[serde(default = "default_status_field")]
277    pub status_field: String,
278    /// Top-level stdout field used as the Health-tab row's one-line
279    /// summary. Defaults to `"detail"`; absent in the payload → no
280    /// detail line (the rich breakdown lives in the inventory view).
281    #[serde(default = "default_detail_field")]
282    pub detail_field: String,
283    /// Optional remediation job id →
284    /// [`Check.troubleshoot`](crate::ipc::state::Check). The Client
285    /// App shows a "修復する" button when present; that job must be
286    /// `user_invokable`.
287    #[serde(default, skip_serializing_if = "Option::is_none")]
288    pub troubleshoot: Option<String>,
289    /// #290 PR-E: when `true` (default), the backend also projects this
290    /// check's `status` / `detail` into the `check_status` table so the
291    /// operator SPA gets a fleet-wide compliance view for free — no
292    /// `inventory:` block needed. Set `fleet: false` for a client-only
293    /// check the operator doesn't want surfaced across the fleet.
294    #[serde(default = "default_fleet")]
295    pub fleet: bool,
296}
297
298fn default_status_field() -> String {
299    "status".to_string()
300}
301
302fn default_detail_field() -> String {
303    "detail".to_string()
304}
305
306fn default_fleet() -> bool {
307    true
308}
309
310/// Manifest sub-section (#291): marks a job as **user-invokable**
311/// from the Client App and carries how it presents to the end user.
312/// Parallel to [`InventoryHint`] / [`CheckHint`] / `EmitConfig` —
313/// the block's presence is the opt-in (no separate boolean), and its
314/// required fields (`name`, `category`) are enforced by serde at
315/// parse time, so a half-filled catalog entry fails
316/// `kanade job create` instead of rendering a nameless / tab-less row.
317///
318/// The agent maps this 1:1 into the KLP
319/// [`UserInvokableJob`](crate::ipc::jobs::UserInvokableJob) wire shape
320/// that `jobs.list` returns; the Client App renders one row per job in
321/// the tab named by `category`.
322#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
323pub struct ClientHint {
324    /// End-user-facing title for the job row. The operator-internal
325    /// `Manifest::id` slug is rarely what an end user should read, so
326    /// this is required (and validated non-empty by
327    /// [`Manifest::validate`]). Maps to `UserInvokableJob::display_name`.
328    pub name: String,
329    /// Optional one-line subtitle under `name` in the Client App.
330    /// Distinct from the operator-facing top-level
331    /// [`Manifest::description`] — this one is written for the end
332    /// user. Maps to `UserInvokableJob::display_description`.
333    #[serde(default, skip_serializing_if = "Option::is_none")]
334    pub description: Option<String>,
335    /// Which Client App tab the job lives in (`software_update` →
336    /// アップデート, `troubleshoot` → 困ったとき, `catalog` → software
337    /// catalog). Required — without it the agent can't place the job
338    /// in a tab.
339    pub category: JobCategory,
340    /// Optional icon hint for the job row — a lucide-react icon name
341    /// or a `data:` URL. `None` ⇒ the Client App falls back to the
342    /// category's default icon. Surfaced verbatim in
343    /// `jobs.list[].icon`.
344    #[serde(default, skip_serializing_if = "Option::is_none")]
345    pub icon: Option<String>,
346}
347
348/// Issue #246 — `emit:` manifest block for jobs whose stdout is
349/// NDJSON observability events (one `ObsEvent` per line). Parallel
350/// to `inventory:` but for the append-only timeline pipeline; see
351/// `Manifest::emit` for the full contract.
352#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
353pub struct EmitConfig {
354    /// What kind of payload the agent should expect on stdout. Only
355    /// `events` is defined today (parses each non-empty line as
356    /// `ObsEvent` and publishes on `obs.<pc_id>`); future variants
357    /// (e.g. metrics streams, structured trace events) plug in here.
358    #[serde(rename = "type")]
359    pub kind: EmitKind,
360    /// Operator hint for where the script keeps its own state — the
361    /// watermark file the PowerShell / sh body reads + writes
362    /// between runs so it only emits NEW events since the last
363    /// poll. The agent doesn't read this; it's documentation that
364    /// the SPA (and `kanade job edit`) can surface to operators
365    /// reviewing the manifest. Optional; the script is allowed to
366    /// keep state anywhere (registry, env, etc.) — the field's
367    /// presence makes the convention discoverable.
368    #[serde(default, skip_serializing_if = "Option::is_none")]
369    pub watermark_path: Option<String>,
370}
371
372/// `emit.type` enum. Lowercase serde so manifests read
373/// `type: events` rather than `Events`.
374#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
375#[serde(rename_all = "lowercase")]
376pub enum EmitKind {
377    /// Per-line `ObsEvent` JSON. Agent parses + publishes on
378    /// `obs.<pc_id>`, drops the stdout from the resulting
379    /// `ExecResult`.
380    Events,
381}
382
383/// v0.31 / #40: declarative "flatten this JSON array into a real
384/// SQLite table" spec on an inventory manifest. The projector
385/// creates the table on first registration (CREATE TABLE IF NOT
386/// EXISTS + indexes) and writes a row per element of
387/// `payload[field]` on every result, scoped by (pc_id, job_id) so
388/// each PC's rows replace cleanly without a per-PC schema.
389#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
390pub struct ExplodeSpec {
391    /// JSON array key under the payload to explode. E.g. `"apps"`
392    /// for `payload: { apps: [{...}, {...}] }`.
393    pub field: String,
394    /// Derived SQLite table name. Operators choose this — pick
395    /// something namespaced + stable (`inventory_sw_apps`, not
396    /// `apps`) so multiple inventory manifests don't collide on a
397    /// generic name.
398    pub table: String,
399    /// Element-level fields that uniquely identify a row inside one
400    /// PC's payload. The full PK is `(pc_id, job_id) + these
401    /// columns`. Required — operators must think about uniqueness
402    /// (e.g. `["name", "source"]` for installed apps because the
403    /// same name appears in multiple uninstall hives).
404    ///
405    /// v0.31 / #41: same tuple drives history identity. When
406    /// `track_history` is on, the projector serialises these
407    /// fields' values into `inventory_history.identity_json` for
408    /// every change event, so queries like "every PC that ever
409    /// installed Chrome (any source)" filter on identity_json
410    /// content without a per-manifest schema.
411    pub primary_key: Vec<String>,
412    /// Per-element fields that become columns in the derived table.
413    pub columns: Vec<ExplodeColumn>,
414    /// v0.31 / #41: when true (default false), the projector
415    /// diffs each PC's incoming payload against the prior rows
416    /// for the same (pc_id, job_id) BEFORE the DELETE-then-INSERT
417    /// replace, and writes added / removed / changed events into
418    /// `inventory_history`. Lets operators answer time-dimension
419    /// questions ("when did Chrome 120 first appear on PC X?",
420    /// "what's the Win 11 23H2 rollout curve") without storing
421    /// per-scan snapshots. Off by default so operators opt in
422    /// per-spec — history has a real storage cost on long-lived
423    /// deployments (mitigated by the 90-day default retention
424    /// sweeper, see `cleanup` module).
425    #[serde(default)]
426    pub track_history: bool,
427}
428
429/// One column in an [`ExplodeSpec`]'s derived table.
430#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
431pub struct ExplodeColumn {
432    /// JSON key under each array element. Becomes the column name
433    /// in the derived SQLite table — we don't rename.
434    pub field: String,
435    /// SQLite affinity: `"text"` (default), `"integer"`, `"real"`.
436    /// Storage maps directly via `sqlx::query.bind(...)`; type
437    /// mismatches at INSERT-time fail loudly rather than silently
438    /// dropping the row.
439    #[serde(default, skip_serializing_if = "Option::is_none")]
440    #[serde(rename = "type")]
441    pub kind: Option<String>,
442    /// When true, the projector creates a `CREATE INDEX` on this
443    /// column at table-creation time. Boost for the common-filter
444    /// columns (`name`, `version`) — operators mark them
445    /// explicitly, the projector won't guess.
446    #[serde(default)]
447    pub index: bool,
448}
449
450#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
451pub struct DisplayField {
452    /// Top-level key in the stdout JSON.
453    pub field: String,
454    /// Human-readable column header.
455    pub label: String,
456    /// Optional render hint — `"number"`, `"bytes"`, `"timestamp"`,
457    /// or `"table"` (#39). Defaults to plain text rendering on the
458    /// SPA side. `"table"` expects the field's value to be a JSON
459    /// array of objects and renders a nested sub-table on the
460    /// per-PC detail page using `columns` as the schema; the fleet
461    /// summary view falls back to showing the row count for
462    /// `"table"` cells so the wide list stays compact.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    #[serde(rename = "type")]
465    pub kind: Option<String>,
466    /// v0.30 / #39: when `kind == "table"`, the SPA renders the
467    /// field's value (an array of objects like
468    /// `disks: [{ device_id, size_bytes, ... }]`) as a nested
469    /// sub-table using these columns. Each column is itself a
470    /// `DisplayField`, so the nested cells reuse the same render
471    /// hints (`bytes`, `number`, `timestamp`) — no parallel format
472    /// pipeline. Ignored for any other `kind`.
473    #[serde(default, skip_serializing_if = "Option::is_none")]
474    pub columns: Option<Vec<DisplayField>>,
475}
476
477#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
478pub struct Rollout {
479    #[serde(default)]
480    pub strategy: RolloutStrategy,
481    pub waves: Vec<Wave>,
482}
483
484#[derive(
485    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
486)]
487#[serde(rename_all = "lowercase")]
488pub enum RolloutStrategy {
489    #[default]
490    Wave,
491}
492
493#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
494pub struct Wave {
495    pub group: String,
496    /// humantime delay measured from the deploy's publish time. wave[0]
497    /// typically has "0s"; subsequent waves use minutes / hours.
498    pub delay: String,
499}
500
501#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default)]
502pub struct Target {
503    #[serde(default)]
504    pub groups: Vec<String>,
505    #[serde(default)]
506    pub pcs: Vec<String>,
507    #[serde(default)]
508    pub all: bool,
509}
510
511impl Target {
512    /// At least one of all / groups / pcs is set.
513    pub fn is_specified(&self) -> bool {
514        self.all || !self.groups.is_empty() || !self.pcs.is_empty()
515    }
516}
517
518#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
519pub struct Execute {
520    pub shell: ExecuteShell,
521    /// Inline script body. Mutually exclusive with [`script_file`]
522    /// and [`script_object`]; exactly one of the three must be set
523    /// (enforced by [`Execute::validate_script_source`] at the
524    /// write-side parse boundaries — `kanade job create` and
525    /// `POST /api/jobs`).
526    ///
527    /// Empty string is treated as **unset** so operators can swap
528    /// to a `script_file:` / `script_object:` alternative just by
529    /// commenting out the body, without having to also drop the
530    /// `script:` key entirely.
531    ///
532    /// [`script_file`]: Self::script_file
533    /// [`script_object`]: Self::script_object
534    #[serde(default, skip_serializing_if = "Option::is_none")]
535    pub script: Option<String>,
536    /// Repo-local file path resolved by the operator-side CLI at
537    /// `kanade job create` time. The CLI reads the file, slots its
538    /// contents into `script`, and clears this field before
539    /// POSTing — so the backend / agents never see `script_file`
540    /// in stored manifests. SPEC §2.4.1.
541    ///
542    /// Resolver lands in a follow-up PR
543    /// (yukimemi/kanade#210); today this field passes parse-time
544    /// validation but the operator-side CLI bails with "not yet
545    /// implemented" until the resolver ships, so manifests that
546    /// reach the backend with `script_file` set are treated as a
547    /// schema-bug.
548    #[serde(default, skip_serializing_if = "Option::is_none")]
549    pub script_file: Option<String>,
550    /// Object Store reference (`<name>/<version>`) into the
551    /// `scripts` bucket (`OBJECT_SCRIPTS`). Agents fetch the body
552    /// at Execute time via `/api/script-objects/{name}/{version}`
553    /// and cache it locally. SPEC §2.4.1.
554    ///
555    /// Fully wired (#210/#211): the backend resolves the digest at
556    /// exec submission (`api::exec::resolve_script_source`), the agent
557    /// fetches + sha-verifies + caches the body (`script_cache`), and
558    /// `kanade script` CRUDs the store. Unlike `script_file:` (inlined
559    /// CLI-side, git-managed), this keeps the body in versioned,
560    /// digest-pinned object storage — the ops-managed counterpart.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub script_object: Option<String>,
563    /// humantime duration string (e.g. "30s", "10m"). Script-intrinsic
564    /// — represents how long this script reasonably takes to run.
565    pub timeout: String,
566    /// Token + session combination the agent uses to launch the
567    /// script (v0.21). Default = [`RunAs::System`] (Session 0,
568    /// LocalSystem privileges, no GUI) — matches pre-v0.21 behavior.
569    #[serde(default)]
570    pub run_as: RunAs,
571    /// Working directory for the spawned child (v0.21.1). When
572    /// unset, the child inherits the agent's cwd — on Windows that
573    /// means `%SystemRoot%\System32` for the prod service, which is
574    /// almost never what operators actually want. Use an absolute
575    /// path; relative paths are passed through to the OS verbatim.
576    /// `%PROGRAMDATA%` works for `run_as: system`; for `run_as: user`
577    /// you'd want `%USERPROFILE%` (but expansion happens in the
578    /// shell, so write `$env:USERPROFILE` for PowerShell, or set
579    /// it via teravars before `kanade job create`).
580    #[serde(default, skip_serializing_if = "Option::is_none")]
581    pub cwd: Option<String>,
582}
583
584impl Execute {
585    /// Treat an empty `script:` body as "intentionally unset". Operators
586    /// commenting out a block-scalar tend to leave the key behind, and
587    /// failing the validator on `script: ""` would surprise them.
588    fn has_inline_script(&self) -> bool {
589        matches!(&self.script, Some(s) if !s.is_empty())
590    }
591
592    /// Enforce that exactly one of `script` / `script_file` /
593    /// `script_object` is set. Called at the write-side parse
594    /// boundaries (CLI `kanade job create` + backend
595    /// `POST /api/jobs`) so ambiguous YAML is rejected before it
596    /// reaches the JOBS KV. Read paths (projector, agent
597    /// scheduler, list endpoints) skip this check — they only ever
598    /// see what the write path already validated.
599    pub fn validate_script_source(&self) -> Result<(), String> {
600        let inline = self.has_inline_script();
601        let file = self.script_file.is_some();
602        let obj = self.script_object.is_some();
603        let set = [inline, file, obj].into_iter().filter(|b| *b).count();
604        match set {
605            1 => Ok(()),
606            0 => Err("execute: one of `script`, `script_file`, `script_object` must be set".into()),
607            _ => Err(format!(
608                "execute: only one of `script` / `script_file` / `script_object` may be set \
609                 (got script={inline}, script_file={file}, script_object={obj})"
610            )),
611        }
612    }
613}
614
615impl Manifest {
616    /// Cross-field semantic checks that don't fit into pure serde
617    /// derive. Currently delegates to
618    /// [`Execute::validate_script_source`] — see that method's
619    /// docs for the rationale on which call sites should run this.
620    pub fn validate(&self) -> Result<(), String> {
621        self.execute.validate_script_source()?;
622        // Stdout-format compatibility. `inventory:` and `check:` both
623        // consume the SAME single JSON object — they COMPOSE: a check
624        // can extract `status`/`detail` for the Health tab while the
625        // projector explodes the rest into SPA sub-tables. `emit:` is
626        // different — its stdout is NDJSON and the agent omits it from
627        // the result entirely — so it can't be paired with either.
628        if self.emit.is_some() && (self.inventory.is_some() || self.check.is_some()) {
629            return Err(
630                "`emit:` is incompatible with `inventory:` / `check:` — emit's stdout is NDJSON \
631                 timeline events (and omitted from the result), while inventory/check read a \
632                 single JSON object from stdout"
633                    .to_string(),
634            );
635        }
636        // A check's `name` is the Health-tab row id (React key); the
637        // field names tell the agent where to read status/detail.
638        // An empty value is an invisible runtime bug, and the serde
639        // defaults don't guard an operator who writes `status_field:
640        // ""` explicitly — reject all three here.
641        if let Some(check) = &self.check {
642            for (label, value) in [
643                ("check.name", &check.name),
644                ("check.status_field", &check.status_field),
645                ("check.detail_field", &check.detail_field),
646            ] {
647                if value.trim().is_empty() {
648                    return Err(format!("{label} must not be empty"));
649                }
650            }
651            // A present-but-blank `troubleshoot` is a broken
652            // remediation job id (the "修復する" button would target
653            // an empty manifest id) — reject it too.
654            if let Some(troubleshoot) = &check.troubleshoot {
655                if troubleshoot.trim().is_empty() {
656                    return Err("check.troubleshoot must not be empty when set".to_string());
657                }
658            }
659            // A present-but-blank `label` would render an empty row
660            // title on the Health tab / Compliance page — reject it so
661            // the slug fallback only ever kicks in when label is absent.
662            if let Some(label) = &check.label {
663                if label.trim().is_empty() {
664                    return Err("check.label must not be empty when set".to_string());
665                }
666            }
667        }
668        // #291: a `client:` job is rendered in the Client App's
669        // catalog (`jobs.list` → `jobs.execute`). serde already makes
670        // `name` + `category` required at parse time; the only gap is
671        // a present-but-blank `name`, which would render an empty row
672        // title — reject it like the other display-id fields.
673        if let Some(client) = &self.client {
674            if client.name.trim().is_empty() {
675                return Err("client.name must not be empty".to_string());
676            }
677            // Optional display fields, when present, must be
678            // meaningful: a blank `description` renders an empty
679            // subtitle and a blank `icon` is a dangling lucide name.
680            // Same present-but-blank guard the `check:` block applies
681            // to its optional `troubleshoot` id.
682            for (label, value) in [
683                ("client.description", &client.description),
684                ("client.icon", &client.icon),
685            ] {
686                if let Some(v) = value {
687                    if v.trim().is_empty() {
688                        return Err(format!("{label} must not be empty when set"));
689                    }
690                }
691            }
692        }
693        // A blank / whitespace-only tag is an invisible operator typo
694        // that would render an empty filter chip on the Jobs page —
695        // reject it like the other present-but-blank display fields.
696        for tag in &self.tags {
697            if tag.trim().is_empty() {
698                return Err("tags must not contain empty entries".to_string());
699            }
700        }
701        Ok(())
702    }
703}
704
705#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
706#[serde(rename_all = "lowercase")]
707pub enum ExecuteShell {
708    Powershell,
709    Cmd,
710}
711
712impl From<ExecuteShell> for Shell {
713    fn from(s: ExecuteShell) -> Self {
714        match s {
715            ExecuteShell::Powershell => Shell::Powershell,
716            ExecuteShell::Cmd => Shell::Cmd,
717        }
718    }
719}
720
721#[cfg(test)]
722mod tests {
723    use super::*;
724
725    /// The example check-job + schedule YAMLs shipped under `configs/`
726    /// must stay valid as the schema evolves (#290 PR-C). `include_str!`
727    /// pins them at compile time so a breaking edit fails `cargo test`
728    /// rather than only `kanade job create` at deploy time.
729    #[test]
730    fn example_check_job_yamls_parse_and_validate() {
731        let jobs = [
732            (
733                "check-bitlocker",
734                include_str!("../../../configs/jobs/check-bitlocker.yaml"),
735            ),
736            (
737                "check-av-signature",
738                include_str!("../../../configs/jobs/check-av-signature.yaml"),
739            ),
740            (
741                "check-cert-expiry",
742                include_str!("../../../configs/jobs/check-cert-expiry.yaml"),
743            ),
744            (
745                "check-disk-space",
746                include_str!("../../../configs/jobs/check-disk-space.yaml"),
747            ),
748            (
749                "check-pending-reboot",
750                include_str!("../../../configs/jobs/check-pending-reboot.yaml"),
751            ),
752            (
753                "check-defender-rtp",
754                include_str!("../../../configs/jobs/check-defender-rtp.yaml"),
755            ),
756            (
757                "check-firewall",
758                include_str!("../../../configs/jobs/check-firewall.yaml"),
759            ),
760        ];
761        for (name, yaml) in jobs {
762            let m: Manifest =
763                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} parse: {e}"));
764            m.validate()
765                .unwrap_or_else(|e| panic!("{name} validate: {e}"));
766            let check = m
767                .check
768                .as_ref()
769                .unwrap_or_else(|| panic!("{name} must carry a check: hint"));
770            assert!(!check.name.trim().is_empty(), "{name} check.name empty");
771            // These examples all read admin-only WMI / registry / netsh
772            // state, so they run_as system. NOTE: that's a property of
773            // these particular checks, NOT of the `check:` contract — a
774            // check probing user-session state could run_as user.
775            assert_eq!(
776                m.execute.run_as,
777                RunAs::System,
778                "{name} should run_as system"
779            );
780        }
781    }
782
783    /// The example user-invokable job YAMLs (#291) shipped under
784    /// `configs/jobs/` must stay valid as the `client:` schema
785    /// evolves. `include_str!` pins them at compile time so a breaking
786    /// edit fails `cargo test`, not `kanade job create` at deploy.
787    #[test]
788    fn example_client_job_yamls_parse_and_validate() {
789        let jobs = [
790            (
791                "fix-teams-cache",
792                JobCategory::Troubleshoot,
793                include_str!("../../../configs/jobs/fix-teams-cache.yaml"),
794            ),
795            (
796                "chrome-update",
797                JobCategory::SoftwareUpdate,
798                include_str!("../../../configs/jobs/chrome-update.yaml"),
799            ),
800            (
801                "install-slack",
802                JobCategory::Catalog,
803                include_str!("../../../configs/jobs/install-slack.yaml"),
804            ),
805            (
806                "fix-defender-rtp",
807                JobCategory::Troubleshoot,
808                include_str!("../../../configs/jobs/fix-defender-rtp.yaml"),
809            ),
810        ];
811        for (id, category, yaml) in jobs {
812            let m: Manifest =
813                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
814            m.validate()
815                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
816            assert_eq!(m.id, id, "{id} id mismatch");
817            let client = m
818                .client
819                .as_ref()
820                .unwrap_or_else(|| panic!("{id} must carry a client: block"));
821            assert!(!client.name.trim().is_empty(), "{id} client.name empty");
822            assert_eq!(client.category, category, "{id} category");
823        }
824    }
825
826    /// The `emit: { type: events }` collector jobs under
827    /// `configs/jobs/` feed the obs_events timeline. `include_str!`
828    /// pins them at compile time so a breaking edit (e.g. an `emit:`
829    /// paired with `check:`/`inventory:`, a bad watermark field, or a
830    /// YAML typo in the PowerShell block) fails `cargo test` rather
831    /// than `kanade job create` at deploy. Every one must carry an
832    /// `emit.type=events` block and NO check/inventory (validate()
833    /// rejects the pairing).
834    #[test]
835    fn example_event_collector_job_yamls_parse_and_validate() {
836        let jobs = [
837            (
838                "collect-winlog-events",
839                include_str!("../../../configs/jobs/collect-winlog-events.yaml"),
840            ),
841            (
842                "collect-winlog-logons-all",
843                include_str!("../../../configs/jobs/collect-winlog-logons-all.yaml"),
844            ),
845            (
846                "collect-wlan-events",
847                include_str!("../../../configs/jobs/collect-wlan-events.yaml"),
848            ),
849        ];
850        for (id, yaml) in jobs {
851            // Strict parse so an unknown-key typo in these fixtures fails
852            // here (not silently at deploy) — the runtime Manifest is
853            // unknown-key-tolerant, so the lenient serde_yaml::from_str
854            // wouldn't catch fixture drift (CodeRabbit #689).
855            let m: Manifest =
856                crate::strict::from_yaml_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
857            m.validate()
858                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
859            assert_eq!(m.id, id, "{id} id mismatch");
860            let emit = m
861                .emit
862                .as_ref()
863                .unwrap_or_else(|| panic!("{id} must carry an emit: block"));
864            assert_eq!(emit.kind, EmitKind::Events, "{id} emit.type");
865            assert!(
866                m.check.is_none() && m.inventory.is_none(),
867                "{id}: emit jobs must not pair with check/inventory"
868            );
869        }
870    }
871
872    /// The `inventory:` snapshot jobs under `configs/jobs/` project
873    /// facts into `inventory_facts` + exploded tables. `include_str!`
874    /// pins them at compile time so a breaking edit (bad explode
875    /// schema, a YAML typo in the PowerShell block, an `inventory:`
876    /// accidentally paired with `emit:`) fails `cargo test` rather
877    /// than the projector at deploy. Each must carry an `inventory:`
878    /// block and NO emit (validate() rejects the pairing).
879    #[test]
880    fn example_inventory_job_yamls_parse_and_validate() {
881        let jobs = [
882            (
883                "inventory-hw",
884                include_str!("../../../configs/jobs/inventory-hw.yaml"),
885            ),
886            (
887                "inventory-sw",
888                include_str!("../../../configs/jobs/inventory-sw.yaml"),
889            ),
890            (
891                "inventory-driver",
892                include_str!("../../../configs/jobs/inventory-driver.yaml"),
893            ),
894        ];
895        for (id, yaml) in jobs {
896            let m: Manifest =
897                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{id} parse: {e}"));
898            m.validate()
899                .unwrap_or_else(|e| panic!("{id} validate: {e}"));
900            assert_eq!(m.id, id, "{id} id mismatch");
901            assert!(m.inventory.is_some(), "{id} must carry an inventory: block");
902            assert!(m.emit.is_none(), "{id}: inventory jobs must not set emit:");
903        }
904    }
905
906    #[test]
907    fn example_check_schedule_yamls_parse_and_validate() {
908        let schedules = [
909            (
910                "check-bitlocker",
911                include_str!("../../../configs/schedules/check-bitlocker.yaml"),
912            ),
913            (
914                "check-av-signature",
915                include_str!("../../../configs/schedules/check-av-signature.yaml"),
916            ),
917            (
918                "check-cert-expiry",
919                include_str!("../../../configs/schedules/check-cert-expiry.yaml"),
920            ),
921            (
922                "check-disk-space",
923                include_str!("../../../configs/schedules/check-disk-space.yaml"),
924            ),
925            (
926                "check-pending-reboot",
927                include_str!("../../../configs/schedules/check-pending-reboot.yaml"),
928            ),
929            (
930                "check-defender-rtp",
931                include_str!("../../../configs/schedules/check-defender-rtp.yaml"),
932            ),
933            (
934                "check-firewall",
935                include_str!("../../../configs/schedules/check-firewall.yaml"),
936            ),
937        ];
938        for (name, yaml) in schedules {
939            let s: Schedule =
940                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
941            s.validate()
942                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
943            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
944        }
945    }
946
947    /// Inventory schedule wrappers (`per_pc` cadence) must stay valid
948    /// alongside the schedule schema. `include_str!` pins them so a
949    /// breaking edit fails `cargo test`, not `kanade schedule create`.
950    #[test]
951    fn example_inventory_schedule_yamls_parse_and_validate() {
952        let schedules = [
953            (
954                "inventory-hw",
955                include_str!("../../../configs/schedules/inventory-hw.yaml"),
956            ),
957            (
958                "inventory-sw",
959                include_str!("../../../configs/schedules/inventory-sw.yaml"),
960            ),
961            (
962                "inventory-driver",
963                include_str!("../../../configs/schedules/inventory-driver.yaml"),
964            ),
965        ];
966        for (name, yaml) in schedules {
967            let s: Schedule =
968                serde_yaml::from_str(yaml).unwrap_or_else(|e| panic!("{name} schedule parse: {e}"));
969            s.validate()
970                .unwrap_or_else(|e| panic!("{name} schedule validate: {e}"));
971            assert_eq!(s.job_id, name, "{name} schedule must reference its job");
972        }
973    }
974
975    #[test]
976    fn target_is_specified_requires_at_least_one_field() {
977        let empty = Target::default();
978        assert!(!empty.is_specified());
979
980        let with_all = Target {
981            all: true,
982            ..Target::default()
983        };
984        assert!(with_all.is_specified());
985
986        let with_groups = Target {
987            groups: vec!["canary".into()],
988            ..Target::default()
989        };
990        assert!(with_groups.is_specified());
991
992        let with_pcs = Target {
993            pcs: vec!["pc-01".into()],
994            ..Target::default()
995        };
996        assert!(with_pcs.is_specified());
997    }
998
999    #[test]
1000    fn manifest_deserialises_minimal_yaml() {
1001        // Matches jobs/echo-test.yaml. v0.18: no target/rollout/jitter
1002        // — those live on the schedule / exec request now.
1003        let yaml = r#"
1004id: echo-test
1005version: 0.0.1
1006execute:
1007  shell: powershell
1008  script: "echo 'kanade'"
1009  timeout: 30s
1010"#;
1011        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1012        assert_eq!(m.id, "echo-test");
1013        assert_eq!(m.version, "0.0.1");
1014        assert!(matches!(m.execute.shell, ExecuteShell::Powershell));
1015        assert_eq!(
1016            m.execute.script.as_deref().map(str::trim),
1017            Some("echo 'kanade'")
1018        );
1019        assert!(m.execute.script_file.is_none());
1020        assert!(m.execute.script_object.is_none());
1021        assert_eq!(m.execute.timeout, "30s");
1022        assert!(!m.require_approval);
1023        m.validate()
1024            .expect("inline-script manifest passes validation");
1025    }
1026
1027    #[test]
1028    fn manifest_parses_check_job_and_validates() {
1029        // An operator-defined health check (#290): a `check:` hint +
1030        // a PowerShell script that prints {status, detail}.
1031        let yaml = r#"
1032id: check-bitlocker
1033version: 0.1.0
1034execute:
1035  shell: powershell
1036  run_as: system
1037  timeout: 15s
1038  script: |
1039    [pscustomobject]@{ status = 'ok'; detail = 'all volumes protected' } | ConvertTo-Json -Compress
1040check:
1041  name: bitlocker
1042  troubleshoot: fix-bitlocker
1043"#;
1044        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1045        let check = m.check.as_ref().expect("check hint present");
1046        assert_eq!(check.name, "bitlocker");
1047        assert_eq!(check.troubleshoot.as_deref(), Some("fix-bitlocker"));
1048        // Field names default to the conventional "status" / "detail".
1049        assert_eq!(check.status_field, "status");
1050        assert_eq!(check.detail_field, "detail");
1051        assert!(m.inventory.is_none() && m.emit.is_none());
1052        m.validate().expect("check-only manifest passes validation");
1053    }
1054
1055    #[test]
1056    fn manifest_check_defaults_and_custom_fields() {
1057        // Minimal: only `name`; status/detail fields default.
1058        let m: Manifest = serde_yaml::from_str(
1059            r#"
1060id: check-disk
1061version: 0.1.0
1062execute:
1063  shell: powershell
1064  script: "[pscustomobject]@{ status = 'ok' } | ConvertTo-Json -Compress"
1065  timeout: 10s
1066check:
1067  name: disk_free
1068"#,
1069        )
1070        .expect("parse");
1071        let c = m.check.as_ref().unwrap();
1072        assert_eq!(c.name, "disk_free");
1073        assert_eq!(c.status_field, "status");
1074        assert_eq!(c.detail_field, "detail");
1075        assert!(c.troubleshoot.is_none());
1076        m.validate().expect("validates");
1077
1078        // The operator can point status/detail at any field of their
1079        // free-form inventory object.
1080        let m2: Manifest = serde_yaml::from_str(
1081            r#"
1082id: check-custom
1083version: 0.1.0
1084execute:
1085  shell: powershell
1086  script: "echo x"
1087  timeout: 10s
1088check:
1089  name: patch_level
1090  status_field: compliance
1091  detail_field: summary
1092"#,
1093        )
1094        .expect("parse");
1095        let c2 = m2.check.as_ref().unwrap();
1096        assert_eq!(c2.status_field, "compliance");
1097        assert_eq!(c2.detail_field, "summary");
1098    }
1099
1100    #[test]
1101    fn manifest_allows_check_composed_with_inventory() {
1102        // `check:` + `inventory:` COMPOSE on the same stdout object:
1103        // status/detail → Health tab, the rest → SPA projection +
1104        // explode sub-tables. Must pass validation.
1105        let yaml = r#"
1106id: check-bitlocker-detailed
1107version: 0.1.0
1108execute:
1109  shell: powershell
1110  script: "echo x"
1111  timeout: 10s
1112check:
1113  name: bitlocker
1114inventory:
1115  display:
1116    - { field: status, label: Status }
1117"#;
1118        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1119        assert!(m.check.is_some() && m.inventory.is_some());
1120        m.validate().expect("check + inventory compose");
1121    }
1122
1123    #[test]
1124    fn manifest_rejects_check_combined_with_emit() {
1125        // `emit:` stdout is NDJSON (and omitted from the result), so
1126        // it can't pair with `check:` (which needs a single JSON
1127        // object on stdout).
1128        let yaml = r#"
1129id: bad-mix
1130version: 0.1.0
1131execute:
1132  shell: powershell
1133  script: "echo x"
1134  timeout: 10s
1135check:
1136  name: bitlocker
1137emit:
1138  type: events
1139"#;
1140        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1141        let err = m.validate().expect_err("emit + check must fail");
1142        assert!(err.contains("incompatible"), "err: {err}");
1143    }
1144
1145    #[test]
1146    fn manifest_rejects_emit_combined_with_inventory() {
1147        // The other half of the emit-incompatibility condition.
1148        let yaml = r#"
1149id: bad-mix-2
1150version: 0.1.0
1151execute:
1152  shell: powershell
1153  script: "echo x"
1154  timeout: 10s
1155emit:
1156  type: events
1157inventory:
1158  display:
1159    - { field: status, label: Status }
1160"#;
1161        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1162        let err = m.validate().expect_err("emit + inventory must fail");
1163        assert!(err.contains("incompatible"), "err: {err}");
1164    }
1165
1166    #[test]
1167    fn manifest_rejects_empty_check_field_names() {
1168        // Empty name / status_field / detail_field are invisible
1169        // runtime bugs (empty React key, agent reads the wrong field)
1170        // — reject them even though serde supplies non-empty defaults.
1171        let base = |inner: &str| {
1172            format!(
1173                "id: c\nversion: 0.1.0\nexecute:\n  shell: powershell\n  script: \"echo x\"\n  timeout: 10s\ncheck:\n{inner}"
1174            )
1175        };
1176        for inner in [
1177            "  name: \"\"\n",
1178            "  name: ok\n  status_field: \"\"\n",
1179            "  name: ok\n  detail_field: \"   \"\n",
1180            // present-but-blank troubleshoot → broken remediation id.
1181            "  name: ok\n  troubleshoot: \"  \"\n",
1182        ] {
1183            let m: Manifest = serde_yaml::from_str(&base(inner)).expect("parse");
1184            let err = m.validate().expect_err("empty field must fail");
1185            assert!(err.contains("must not be empty"), "err: {err}");
1186        }
1187    }
1188
1189    #[test]
1190    fn manifest_client_absent_by_default() {
1191        // A plain operator job (the overwhelming majority) carries no
1192        // `client:` block, so it never surfaces in the end-user
1193        // catalog.
1194        let yaml = r#"
1195id: echo-test
1196version: 0.0.1
1197execute:
1198  shell: powershell
1199  script: "echo 'kanade'"
1200  timeout: 30s
1201"#;
1202        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1203        assert!(m.client.is_none());
1204        m.validate().expect("operator-only job validates");
1205    }
1206
1207    #[test]
1208    fn manifest_client_parses_and_validates() {
1209        // The Client App "困ったとき" remediation job shape: a
1210        // user-invokable troubleshoot job with the end-user fields the
1211        // KLP `jobs.list` wire needs, grouped under `client:`.
1212        let yaml = r#"
1213id: fix-teams-cache
1214version: 1.0.0
1215execute:
1216  shell: powershell
1217  script: "echo clearing"
1218  timeout: 60s
1219client:
1220  name: "Teams のキャッシュをクリア"
1221  description: "Teams が重いときに試してください"
1222  category: troubleshoot
1223  icon: brush-cleaning
1224"#;
1225        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1226        let c = m.client.as_ref().expect("client block present");
1227        assert_eq!(c.name, "Teams のキャッシュをクリア");
1228        assert_eq!(
1229            c.description.as_deref(),
1230            Some("Teams が重いときに試してください")
1231        );
1232        assert_eq!(c.category, JobCategory::Troubleshoot);
1233        assert_eq!(c.icon.as_deref(), Some("brush-cleaning"));
1234        m.validate().expect("user-invokable job validates");
1235    }
1236
1237    #[test]
1238    fn manifest_client_minimal_only_name_and_category() {
1239        // description + icon are optional; name + category are the
1240        // serde-required minimum.
1241        let yaml = r#"
1242id: install-slack
1243version: 1.0.0
1244execute:
1245  shell: powershell
1246  script: "echo install"
1247  timeout: 600s
1248client:
1249  name: Slack
1250  category: catalog
1251"#;
1252        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1253        let c = m.client.as_ref().expect("client present");
1254        assert_eq!(c.category, JobCategory::Catalog);
1255        assert!(c.description.is_none() && c.icon.is_none());
1256        m.validate().expect("minimal client validates");
1257    }
1258
1259    #[test]
1260    fn manifest_client_rejects_blank_name() {
1261        // serde guarantees `name`/`category` are present; the one gap
1262        // is a present-but-blank name → empty catalog row title.
1263        let yaml = r#"
1264id: j
1265version: 1.0.0
1266execute:
1267  shell: powershell
1268  script: "echo x"
1269  timeout: 30s
1270client:
1271  name: "   "
1272  category: catalog
1273"#;
1274        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1275        let err = m.validate().expect_err("blank name must fail");
1276        assert!(err.contains("client.name"), "err: {err}");
1277    }
1278
1279    #[test]
1280    fn manifest_client_rejects_blank_optional_fields() {
1281        // description / icon are optional, but a present-but-blank
1282        // value is a bug (empty subtitle / dangling icon name) — reject
1283        // it, mirroring the check: block's troubleshoot guard.
1284        for (field, line) in [
1285            ("client.description", "  description: \"  \"\n"),
1286            ("client.icon", "  icon: \"\"\n"),
1287        ] {
1288            let yaml = format!(
1289                "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}"
1290            );
1291            let m: Manifest = serde_yaml::from_str(&yaml).expect("parse");
1292            let err = m.validate().expect_err("blank optional field must fail");
1293            assert!(err.contains(field), "expected {field} in err: {err}");
1294        }
1295    }
1296
1297    #[test]
1298    fn manifest_client_requires_category_at_parse() {
1299        // A `client:` block missing `category` is a hard parse error
1300        // (serde required field) — no manual validate() needed.
1301        let yaml = r#"
1302id: j
1303version: 1.0.0
1304execute:
1305  shell: powershell
1306  script: "echo x"
1307  timeout: 30s
1308client:
1309  name: "A job"
1310"#;
1311        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
1312        assert!(
1313            r.is_err(),
1314            "missing category must be a parse error, got {r:?}"
1315        );
1316    }
1317
1318    #[test]
1319    fn manifest_client_rejects_unknown_field() {
1320        // #492: the strict create boundary catches a fat-fingered
1321        // `displayname:` (with its path) instead of silently
1322        // dropping it; the tolerant read path accepts it.
1323        let yaml = r#"
1324id: j
1325version: 1.0.0
1326execute:
1327  shell: powershell
1328  script: "echo x"
1329  timeout: 30s
1330client:
1331  name: "A job"
1332  category: catalog
1333  displayname: oops
1334"#;
1335        let r = crate::strict::from_yaml_str::<Manifest>(yaml);
1336        let err = r.expect_err("unknown client field must be rejected at the write boundary");
1337        // serde_ignored renders the Option layer as `?`:
1338        // `client.?.displayname`. Assert on the leaf key.
1339        assert!(err.contains("displayname"), "{err}");
1340        // The READ path tolerates the same payload (gradual-upgrade
1341        // contract: an old agent must accept a newer writer's field).
1342        let m: Manifest = serde_yaml::from_str(yaml).expect("tolerant read");
1343        assert_eq!(m.client.as_ref().map(|c| c.name.as_str()), Some("A job"));
1344    }
1345
1346    #[test]
1347    fn manifest_tags_default_empty() {
1348        // The overwhelming majority of jobs carry no tags; the field
1349        // must default to an empty Vec (not fail to parse) and skip
1350        // serialisation so old readers never see the key.
1351        let yaml = r#"
1352id: echo-test
1353version: 0.0.1
1354execute:
1355  shell: powershell
1356  script: "echo 'kanade'"
1357  timeout: 30s
1358"#;
1359        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1360        assert!(m.tags.is_empty());
1361        m.validate().expect("tag-less job validates");
1362        // skip_serializing_if = empty ⇒ the key is absent from JSON.
1363        let json = serde_json::to_string(&m).expect("serialize");
1364        assert!(
1365            !json.contains("tags"),
1366            "empty tags must not serialise: {json}"
1367        );
1368    }
1369
1370    #[test]
1371    fn manifest_parses_and_validates_tags() {
1372        let yaml = r#"
1373id: check-bitlocker
1374version: 0.1.0
1375execute:
1376  shell: powershell
1377  script: "echo x"
1378  timeout: 30s
1379tags: [security, windows, health-check]
1380"#;
1381        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1382        assert_eq!(m.tags, vec!["security", "windows", "health-check"]);
1383        m.validate().expect("tagged job validates");
1384        // Round-trips through JSON (the wire format the SPA reads).
1385        let json = serde_json::to_string(&m).expect("serialize");
1386        assert!(json.contains("\"tags\""), "non-empty tags must serialise");
1387    }
1388
1389    #[test]
1390    fn manifest_rejects_blank_tag() {
1391        // A whitespace-only tag renders an empty filter chip — reject
1392        // it at the write boundary like the other blank display fields.
1393        let yaml = r#"
1394id: j
1395version: 0.1.0
1396execute:
1397  shell: powershell
1398  script: "echo x"
1399  timeout: 30s
1400tags: [ok, "   "]
1401"#;
1402        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1403        let err = m.validate().expect_err("blank tag must fail");
1404        assert!(err.contains("tags must not contain empty"), "err: {err}");
1405    }
1406
1407    fn execute_with(
1408        script: Option<&str>,
1409        script_file: Option<&str>,
1410        script_object: Option<&str>,
1411    ) -> Execute {
1412        Execute {
1413            shell: ExecuteShell::Powershell,
1414            script: script.map(str::to_owned),
1415            script_file: script_file.map(str::to_owned),
1416            script_object: script_object.map(str::to_owned),
1417            timeout: "30s".into(),
1418            run_as: RunAs::default(),
1419            cwd: None,
1420        }
1421    }
1422
1423    #[test]
1424    fn validate_accepts_inline_script() {
1425        let e = execute_with(Some("echo hi"), None, None);
1426        assert!(e.validate_script_source().is_ok());
1427    }
1428
1429    #[test]
1430    fn validate_accepts_script_file_alone() {
1431        let e = execute_with(None, Some("scripts/cleanup.ps1"), None);
1432        assert!(e.validate_script_source().is_ok());
1433    }
1434
1435    #[test]
1436    fn validate_accepts_script_object_alone() {
1437        let e = execute_with(None, None, Some("cleanup/1.0.0"));
1438        assert!(e.validate_script_source().is_ok());
1439    }
1440
1441    #[test]
1442    fn validate_treats_empty_inline_script_as_unset() {
1443        // `script: ""` + `script_object` set is the natural shape
1444        // when an operator comments out the YAML block-scalar body
1445        // but leaves the key. Should pass.
1446        let e = execute_with(Some(""), None, Some("cleanup/1.0.0"));
1447        assert!(e.validate_script_source().is_ok());
1448    }
1449
1450    #[test]
1451    fn validate_rejects_zero_sources() {
1452        let e = execute_with(None, None, None);
1453        let err = e.validate_script_source().unwrap_err();
1454        assert!(err.contains("must be set"), "got: {err}");
1455    }
1456
1457    #[test]
1458    fn validate_rejects_empty_inline_only() {
1459        let e = execute_with(Some(""), None, None);
1460        let err = e.validate_script_source().unwrap_err();
1461        assert!(err.contains("must be set"), "got: {err}");
1462    }
1463
1464    #[test]
1465    fn validate_rejects_inline_plus_file() {
1466        let e = execute_with(Some("echo hi"), Some("scripts/cleanup.ps1"), None);
1467        let err = e.validate_script_source().unwrap_err();
1468        assert!(err.contains("only one of"), "got: {err}");
1469    }
1470
1471    #[test]
1472    fn validate_rejects_inline_plus_object() {
1473        let e = execute_with(Some("echo hi"), None, Some("cleanup/1.0.0"));
1474        let err = e.validate_script_source().unwrap_err();
1475        assert!(err.contains("only one of"), "got: {err}");
1476    }
1477
1478    #[test]
1479    fn validate_rejects_file_plus_object() {
1480        let e = execute_with(None, Some("scripts/cleanup.ps1"), Some("cleanup/1.0.0"));
1481        let err = e.validate_script_source().unwrap_err();
1482        assert!(err.contains("only one of"), "got: {err}");
1483    }
1484
1485    #[test]
1486    fn validate_rejects_all_three() {
1487        let e = execute_with(
1488            Some("echo hi"),
1489            Some("scripts/cleanup.ps1"),
1490            Some("cleanup/1.0.0"),
1491        );
1492        let err = e.validate_script_source().unwrap_err();
1493        assert!(err.contains("only one of"), "got: {err}");
1494    }
1495
1496    #[test]
1497    fn manifest_deserialises_script_object_yaml() {
1498        // SPEC §2.4.1 example shape with the Object Store
1499        // reference picked over inline.
1500        let yaml = r#"
1501id: cleanup-disk-temp
1502version: 1.0.1
1503execute:
1504  shell: powershell
1505  script_object: cleanup-disk-temp/1.0.1
1506  timeout: 600s
1507"#;
1508        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
1509        assert_eq!(
1510            m.execute.script_object.as_deref(),
1511            Some("cleanup-disk-temp/1.0.1")
1512        );
1513        assert!(m.execute.script.is_none());
1514        m.validate()
1515            .expect("script_object-only manifest passes validation");
1516    }
1517
1518    #[test]
1519    fn manifest_rejects_typo_in_script_field_name() {
1520        // #492: the strict create boundary catches `script_objectt`
1521        // and similar fat-fingers (with the full path) instead of
1522        // letting them silently fall through to "all three unset".
1523        let yaml = r#"
1524id: typo
1525version: 1.0.0
1526execute:
1527  shell: powershell
1528  script_objectt: oops
1529  timeout: 30s
1530"#;
1531        let err = crate::strict::from_yaml_str::<Manifest>(yaml)
1532            .expect_err("typo'd execute field must be rejected at the write boundary");
1533        assert!(err.contains("execute.script_objectt"), "{err}");
1534    }
1535
1536    #[test]
1537    fn schedule_carries_target_and_rollout() {
1538        let yaml = r#"
1539id: hourly-cleanup-canary
1540when:
1541  per_pc: { every: 1h }
1542job_id: cleanup
1543enabled: true
1544target:
1545  groups: [canary, wave1]
1546jitter: 30s
1547rollout:
1548  strategy: wave
1549  waves:
1550    - { group: canary, delay: 0s }
1551    - { group: wave1,  delay: 5s }
1552"#;
1553        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1554        assert_eq!(s.id, "hourly-cleanup-canary");
1555        assert_eq!(s.job_id, "cleanup");
1556        assert_eq!(s.plan.target.groups, vec!["canary", "wave1"]);
1557        assert_eq!(s.plan.jitter.as_deref(), Some("30s"));
1558        let rollout = s.plan.rollout.expect("rollout present");
1559        assert_eq!(rollout.waves.len(), 2);
1560        assert_eq!(rollout.waves[0].group, "canary");
1561        assert_eq!(rollout.waves[1].delay, "5s");
1562        assert_eq!(rollout.strategy, RolloutStrategy::Wave);
1563    }
1564
1565    #[test]
1566    fn schedule_minimal_target_all() {
1567        let yaml = r#"
1568id: kitting
1569when:
1570  per_pc: once
1571enabled: true
1572job_id: scheduled-echo
1573target: { all: true }
1574"#;
1575        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1576        assert_eq!(s.id, "kitting");
1577        assert_eq!(s.when, When::PerPc(PerPolicy::Once(OnceLiteral::Once)));
1578        assert!(s.enabled);
1579        assert_eq!(s.job_id, "scheduled-echo");
1580        assert!(s.plan.target.all);
1581        assert!(s.plan.rollout.is_none());
1582        assert!(s.plan.jitter.is_none());
1583        assert!(s.active.is_empty());
1584    }
1585
1586    #[test]
1587    fn schedule_enabled_defaults_to_true() {
1588        let yaml = r#"
1589id: x
1590when:
1591  per_pc: once
1592job_id: y
1593target: { all: true }
1594"#;
1595        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1596        assert!(s.enabled);
1597    }
1598
1599    #[test]
1600    fn schedule_tags_default_empty_and_skip_serialise() {
1601        let yaml = r#"
1602id: x
1603when:
1604  per_pc: once
1605job_id: y
1606target: { all: true }
1607"#;
1608        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1609        assert!(s.tags.is_empty());
1610        s.validate().expect("tag-less schedule validates");
1611        let json = serde_json::to_string(&s).expect("serialize");
1612        assert!(
1613            !json.contains("tags"),
1614            "empty tags must not serialise: {json}"
1615        );
1616    }
1617
1618    #[test]
1619    fn schedule_parses_and_validates_tags() {
1620        let yaml = r#"
1621id: weekly-cleanup
1622when:
1623  per_pc: { every: 1h }
1624job_id: cleanup
1625target: { all: true }
1626tags: [weekly, maintenance]
1627"#;
1628        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1629        assert_eq!(s.tags, vec!["weekly", "maintenance"]);
1630        s.validate().expect("tagged schedule validates");
1631    }
1632
1633    #[test]
1634    fn schedule_rejects_blank_tag() {
1635        let yaml = r#"
1636id: x
1637when:
1638  per_pc: once
1639job_id: y
1640target: { all: true }
1641tags: [ok, "  "]
1642"#;
1643        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
1644        let err = s.validate().expect_err("blank tag must fail");
1645        assert!(err.contains("tags must not contain empty"), "err: {err}");
1646    }
1647
1648    // ---- `when` parsing (#418 Phase 1) ----
1649
1650    fn schedule_yaml_with(when_block: &str) -> String {
1651        format!(
1652            r#"
1653id: x
1654when:
1655{when_block}
1656job_id: y
1657target: {{ all: true }}
1658"#
1659        )
1660    }
1661
1662    #[test]
1663    fn when_per_pc_every_parses_unquoted_humantime() {
1664        // `6h` is digit-led but non-numeric → YAML string, same as
1665        // the old `cooldown: 6h` convention. No quotes needed.
1666        let s: Schedule =
1667            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { every: 6h }")).expect("parse");
1668        assert_eq!(
1669            s.when,
1670            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() }))
1671        );
1672    }
1673
1674    #[test]
1675    fn when_per_target_every_parses() {
1676        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with("  per_target: { every: 24h }"))
1677            .expect("parse");
1678        assert_eq!(
1679            s.when,
1680            When::PerTarget(PerPolicy::Every(EverySpec {
1681                every: "24h".into()
1682            }))
1683        );
1684    }
1685
1686    #[test]
1687    fn when_per_target_once_parses() {
1688        // Falls out of the shared PerPolicy shape and decide_fire
1689        // already implements it ("any one pc succeeds → skip the
1690        // target forever"), so it is allowed, not rejected.
1691        let s: Schedule =
1692            serde_yaml::from_str(&schedule_yaml_with("  per_target: once")).expect("parse");
1693        assert_eq!(s.when, When::PerTarget(PerPolicy::Once(OnceLiteral::Once)));
1694    }
1695
1696    #[test]
1697    fn when_calendar_time_parses() {
1698        let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(
1699            "  calendar:\n    at: \"09:00\"\n    days: [mon-fri]",
1700        ))
1701        .expect("parse");
1702        match &s.when {
1703            When::Calendar(c) => {
1704                assert_eq!(c.at, "09:00");
1705                assert_eq!(c.days, vec!["mon-fri"]);
1706            }
1707            other => panic!("expected calendar, got {other:?}"),
1708        }
1709    }
1710
1711    #[test]
1712    fn when_calendar_days_default_empty() {
1713        let s: Schedule =
1714            serde_yaml::from_str(&schedule_yaml_with("  calendar:\n    at: \"09:00\""))
1715                .expect("parse");
1716        match &s.when {
1717            When::Calendar(c) => assert!(c.days.is_empty(), "days defaults to empty (= daily)"),
1718            other => panic!("expected calendar, got {other:?}"),
1719        }
1720    }
1721
1722    #[test]
1723    fn when_calendar_datetime_parses_all_separators() {
1724        // one-shot: date+time in hyphen / ISO-T / slash forms
1725        for at in ["2026-06-10 09:00", "2026-06-10T09:00", "2026/06/10 09:00"] {
1726            let block = format!("  calendar:\n    at: \"{at}\"");
1727            let s: Schedule = serde_yaml::from_str(&schedule_yaml_with(&block))
1728                .unwrap_or_else(|e| panic!("parse '{at}': {e}"));
1729            match &s.when {
1730                When::Calendar(c) => {
1731                    use chrono::Datelike;
1732                    let p = c.parse_at().expect("parse_at");
1733                    let d = p.date.expect("datetime at carries a date");
1734                    assert_eq!((d.year(), d.month(), d.day()), (2026, 6, 10), "for '{at}'");
1735                }
1736                other => panic!("expected calendar, got {other:?}"),
1737            }
1738        }
1739    }
1740
1741    #[test]
1742    fn when_rejects_bad_once_keyword() {
1743        // `onec` must be a parse error, not a silently-absorbed
1744        // string (OnceLiteral is a single-variant enum for exactly
1745        // this reason).
1746        let r: Result<Schedule, _> = serde_yaml::from_str(&schedule_yaml_with("  per_pc: onec"));
1747        assert!(r.is_err(), "expected parse error, got {r:?}");
1748    }
1749
1750    #[test]
1751    fn when_rejects_unknown_key_in_every() {
1752        // `{ evry: 6h }` still fails on the tolerant read path: the
1753        // required `every` key is missing, so no PerPolicy variant
1754        // matches (#492 removed deny_unknown_fields, but required
1755        // keys keep the untagged disambiguation honest).
1756        let r: Result<Schedule, _> =
1757            serde_yaml::from_str(&schedule_yaml_with("  per_pc: { evry: 6h }"));
1758        assert!(r.is_err(), "expected parse error, got {r:?}");
1759    }
1760
1761    #[test]
1762    fn when_rejects_unknown_variant() {
1763        let r: Result<Schedule, _> =
1764            serde_yaml::from_str(&schedule_yaml_with("  per_galaxy: once"));
1765        assert!(r.is_err(), "expected parse error, got {r:?}");
1766    }
1767
1768    #[test]
1769    fn when_rejects_old_top_level_cron_field() {
1770        // Pre-#418 shape: top-level `cron:` + no `when:`. Must fail
1771        // loudly (missing `when`), which is what turns stale KV
1772        // blobs into warn-skips after the upgrade.
1773        let yaml = r#"
1774id: x
1775cron: "* * * * * *"
1776job_id: y
1777target: { all: true }
1778"#;
1779        let r: Result<Schedule, _> = serde_yaml::from_str(yaml);
1780        assert!(r.is_err(), "expected parse error, got {r:?}");
1781    }
1782
1783    #[test]
1784    fn when_rejects_retired_cron_escape_hatch() {
1785        // #418 Phase 2 retired `when: { cron: "..." }`. A raw cron
1786        // is now an unknown variant → parse error (operators use the
1787        // calendar form instead).
1788        let r: Result<Schedule, _> =
1789            serde_yaml::from_str(&schedule_yaml_with("  cron: \"0 0 9 * * mon-fri\""));
1790        assert!(
1791            r.is_err(),
1792            "expected parse error for retired cron, got {r:?}"
1793        );
1794    }
1795
1796    #[test]
1797    fn when_round_trips_json_and_yaml() {
1798        // Round-trip through the full Schedule: that is the wire
1799        // unit for both stores (JSON catalog KV + YAML mirror), and
1800        // it exercises the singleton_map field attribute that keeps
1801        // serde_yaml on the map shape instead of `!per_pc` tags.
1802        for when in [
1803            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1804            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1805            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1806            When::PerTarget(PerPolicy::Every(EverySpec {
1807                every: "24h".into(),
1808            })),
1809            calendar("09:00", &["mon-fri"]),
1810            calendar("2026-06-10 09:00", &[]),
1811            When::On(vec![OnTrigger::Startup]),
1812            When::On(vec![OnTrigger::Startup, OnTrigger::Logon]),
1813            When::On(vec![OnTrigger::Lock, OnTrigger::Unlock]),
1814            When::On(vec![OnTrigger::NetworkChange]),
1815        ] {
1816            // Event triggers are agent-only; the rest validate on backend.
1817            let runs_on = if matches!(when, When::On(_)) {
1818                RunsOn::Agent
1819            } else {
1820                RunsOn::Backend
1821            };
1822            let s = schedule_with(when.clone(), runs_on);
1823
1824            let json = serde_json::to_string(&s).expect("json serialise");
1825            let back: Schedule = serde_json::from_str(&json).expect("json deserialise");
1826            assert_eq!(back.when, when, "json round-trip for {when}");
1827
1828            let yaml = serde_yaml::to_string(&s).expect("yaml serialise");
1829            assert!(
1830                !yaml.contains('!'),
1831                "yaml must use the map shape, not tags: {yaml}"
1832            );
1833            let back: Schedule = serde_yaml::from_str(&yaml).expect("yaml deserialise");
1834            assert_eq!(back.when, when, "yaml round-trip for {when}");
1835        }
1836    }
1837
1838    #[test]
1839    fn when_once_serialises_as_bare_keyword() {
1840        // The wire shape operators see in the YAML mirror must stay
1841        // the ergonomic `per_pc: once`, not a one-variant map.
1842        let json = serde_json::to_value(When::PerPc(PerPolicy::Once(OnceLiteral::Once)))
1843            .expect("serialise");
1844        assert_eq!(json, serde_json::json!({ "per_pc": "once" }));
1845    }
1846
1847    #[test]
1848    fn when_displays_operator_summary() {
1849        for (when, expected) in [
1850            (
1851                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1852                "per_pc once",
1853            ),
1854            (
1855                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1856                "per_pc every 6h",
1857            ),
1858            (
1859                When::PerTarget(PerPolicy::Every(EverySpec {
1860                    every: "24h".into(),
1861                })),
1862                "per_target every 24h",
1863            ),
1864            (calendar("09:00", &["mon-fri"]), "at 09:00 [mon-fri]"),
1865            (calendar("2026-06-10 09:00", &[]), "at 2026-06-10 09:00"),
1866            (When::On(vec![OnTrigger::Startup]), "on [startup]"),
1867            (
1868                When::On(vec![OnTrigger::Startup, OnTrigger::Logon]),
1869                "on [startup,logon]",
1870            ),
1871            (
1872                When::On(vec![OnTrigger::Lock, OnTrigger::Unlock]),
1873                "on [lock,unlock]",
1874            ),
1875            (
1876                When::On(vec![OnTrigger::NetworkChange]),
1877                "on [network_change]",
1878            ),
1879        ] {
1880            assert_eq!(when.to_string(), expected);
1881        }
1882    }
1883
1884    // ---- lowering (#418: when → engine vocabulary) ----
1885
1886    fn schedule_with(when: When, runs_on: RunsOn) -> Schedule {
1887        Schedule {
1888            id: "x".into(),
1889            when,
1890            job_id: "y".into(),
1891            plan: FanoutPlan::default(),
1892            active: Active::default(),
1893            constraints: Constraints::default(),
1894            on_failure: OnFailure::default(),
1895            tz: ScheduleTz::default(),
1896            starting_deadline: None,
1897            runs_on,
1898            enabled: true,
1899            tags: Vec::new(),
1900        }
1901    }
1902
1903    fn calendar(at: &str, days: &[&str]) -> When {
1904        When::Calendar(CalendarSpec {
1905            at: at.into(),
1906            days: days.iter().map(|d| (*d).to_string()).collect(),
1907        })
1908    }
1909
1910    #[test]
1911    fn next_calendar_fire_returns_next_utc_occurrence() {
1912        use chrono::TimeZone;
1913        // Daily 09:00, evaluated in UTC. From 08:00 the same day, the
1914        // next strict occurrence is 09:00 that day.
1915        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
1916        s.tz = ScheduleTz::Utc;
1917        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 8, 0, 0).unwrap();
1918        let next = s.next_calendar_fire(now).expect("calendar has a next fire");
1919        assert_eq!(
1920            next,
1921            chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap()
1922        );
1923    }
1924
1925    #[test]
1926    fn next_calendar_fire_is_strictly_after_now() {
1927        use chrono::TimeZone;
1928        // Standing exactly on a fire instant must preview the *next*
1929        // one (inclusive = false), not the one firing right now.
1930        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
1931        s.tz = ScheduleTz::Utc;
1932        let on_fire = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap();
1933        let next = s
1934            .next_calendar_fire(on_fire)
1935            .expect("calendar has a next fire");
1936        assert_eq!(
1937            next,
1938            chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap()
1939        );
1940    }
1941
1942    #[test]
1943    fn next_calendar_fire_none_for_reconcile_shapes() {
1944        // `per_pc` / `per_target` lower to the every-minute poll cron —
1945        // no discrete upcoming event to preview, so `None`.
1946        let now = chrono::Utc::now();
1947        for when in [
1948            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
1949            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
1950            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
1951            When::PerTarget(PerPolicy::Every(EverySpec {
1952                every: "24h".into(),
1953            })),
1954        ] {
1955            let s = schedule_with(when, RunsOn::Backend);
1956            assert!(
1957                s.next_calendar_fire(now).is_none(),
1958                "reconcile shapes have no calendar fire",
1959            );
1960        }
1961    }
1962
1963    // ---- preview_fires (#418 dry-run / preview) ----
1964
1965    fn cal_utc(at: &str, days: &[&str]) -> Schedule {
1966        let mut s = schedule_with(calendar(at, days), RunsOn::Backend);
1967        s.tz = ScheduleTz::Utc; // host-independent assertions
1968        s
1969    }
1970
1971    #[test]
1972    fn preview_lists_next_calendar_occurrences() {
1973        use chrono::TimeZone;
1974        // Weekday 09:00, from Wed 2026-06-10 00:00 UTC: the next five
1975        // fires skip the weekend (Sat 13 / Sun 14).
1976        let s = cal_utc("09:00", &["mon-fri"]);
1977        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
1978        let got = s.preview_fires(now, 5);
1979        let want: Vec<_> = [
1980            (2026, 6, 10), // Wed
1981            (2026, 6, 11), // Thu
1982            (2026, 6, 12), // Fri
1983            (2026, 6, 15), // Mon (skips Sat 13 / Sun 14)
1984            (2026, 6, 16), // Tue
1985        ]
1986        .iter()
1987        .map(|(y, m, d)| chrono::Utc.with_ymd_and_hms(*y, *m, *d, 9, 0, 0).unwrap())
1988        .collect();
1989        assert_eq!(got, want);
1990    }
1991
1992    #[test]
1993    fn preview_handles_nth_and_last_weekday() {
1994        use chrono::TimeZone;
1995        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
1996        // 2nd Tuesday (Patch Tuesday): Jun 9, Jul 14 2026.
1997        let nth = cal_utc("09:00", &["tue#2"]).preview_fires(now, 2);
1998        assert_eq!(
1999            nth,
2000            vec![
2001                chrono::Utc.with_ymd_and_hms(2026, 6, 9, 9, 0, 0).unwrap(),
2002                chrono::Utc.with_ymd_and_hms(2026, 7, 14, 9, 0, 0).unwrap(),
2003            ]
2004        );
2005        // Last Friday of the month: Jun 26, Jul 31 2026.
2006        let last = cal_utc("22:00", &["friL"]).preview_fires(now, 2);
2007        assert_eq!(
2008            last,
2009            vec![
2010                chrono::Utc.with_ymd_and_hms(2026, 6, 26, 22, 0, 0).unwrap(),
2011                chrono::Utc.with_ymd_and_hms(2026, 7, 31, 22, 0, 0).unwrap(),
2012            ]
2013        );
2014    }
2015
2016    #[test]
2017    fn preview_is_empty_for_reconcile_and_zero_count() {
2018        let now = chrono::Utc::now();
2019        // reconcile shapes have no discrete fire times
2020        let recon = schedule_with(
2021            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2022            RunsOn::Backend,
2023        );
2024        assert!(recon.preview_fires(now, 5).is_empty());
2025        // count == 0 yields nothing even for a calendar
2026        assert!(cal_utc("09:00", &[]).preview_fires(now, 0).is_empty());
2027    }
2028
2029    #[test]
2030    fn preview_skips_outside_active_window() {
2031        use chrono::TimeZone;
2032        // Daily 09:00, active only [2026-06-15, 2026-06-17). Occurrences
2033        // before `from` are skipped; `until` is exclusive, so 06-17's
2034        // fire is out — leaving exactly the 15th and 16th.
2035        let mut s = cal_utc("09:00", &[]);
2036        s.active = Active {
2037            from: Some("2026-06-15".into()),
2038            until: Some("2026-06-17".into()),
2039        };
2040        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
2041        let got = s.preview_fires(now, 5);
2042        assert_eq!(
2043            got,
2044            vec![
2045                chrono::Utc.with_ymd_and_hms(2026, 6, 15, 9, 0, 0).unwrap(),
2046                chrono::Utc.with_ymd_and_hms(2026, 6, 16, 9, 0, 0).unwrap(),
2047            ]
2048        );
2049    }
2050
2051    #[test]
2052    fn preview_empty_when_calendar_time_outside_window() {
2053        use chrono::TimeZone;
2054        // Fires at 09:00 but the maintenance window is overnight — it can
2055        // never run, so the preview is empty (matches
2056        // `calendar_outside_window`), and the scan still terminates.
2057        let mut s = cal_utc("09:00", &[]);
2058        s.constraints = Constraints {
2059            window: Some("22:00-05:00".into()),
2060            ..Constraints::default()
2061        };
2062        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
2063        assert!(s.preview_fires(now, 5).is_empty());
2064        // Every candidate tick is rejected, so this also exercises the
2065        // SCAN_CAP bound: a large `count` must still terminate (and
2066        // return empty) rather than spin (claude #578 review).
2067        assert!(s.preview_fires(now, 50).is_empty());
2068    }
2069
2070    #[test]
2071    fn preview_past_one_shot_is_empty() {
2072        use chrono::TimeZone;
2073        // A dated one-shot whose instant has passed never fires again.
2074        let s = cal_utc("2026-06-10 09:00", &[]);
2075        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 0, 0, 0).unwrap();
2076        assert!(s.preview_fires(now, 5).is_empty());
2077        // …but from before it, the single future fire shows up.
2078        let before = chrono::Utc.with_ymd_and_hms(2026, 6, 1, 0, 0, 0).unwrap();
2079        assert_eq!(
2080            s.preview_fires(before, 5),
2081            vec![chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap()]
2082        );
2083    }
2084
2085    #[test]
2086    fn lowering_matches_the_418_table() {
2087        let cases = [
2088            (
2089                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2090                (POLL_CRON, ExecMode::OncePerPc, None),
2091            ),
2092            (
2093                When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2094                (POLL_CRON, ExecMode::OncePerPc, Some("6h")),
2095            ),
2096            (
2097                When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
2098                (POLL_CRON, ExecMode::OncePerTarget, None),
2099            ),
2100            (
2101                When::PerTarget(PerPolicy::Every(EverySpec {
2102                    every: "24h".into(),
2103                })),
2104                (POLL_CRON, ExecMode::OncePerTarget, Some("24h")),
2105            ),
2106            // calendar repeating → 6-field cron
2107            (
2108                calendar("09:00", &["mon-fri"]),
2109                ("0 0 9 * * mon-fri", ExecMode::EveryTick, None),
2110            ),
2111            // calendar daily (no days) → DOW *
2112            (
2113                calendar("18:30", &[]),
2114                ("0 30 18 * * *", ExecMode::EveryTick, None),
2115            ),
2116            // calendar one-shot → 7-field year cron
2117            (
2118                calendar("2026-06-10 09:00", &[]),
2119                ("0 0 9 10 6 * 2026", ExecMode::EveryTick, None),
2120            ),
2121        ];
2122        for (when, (cron, mode, cooldown)) in cases {
2123            let l = schedule_with(when.clone(), RunsOn::Backend).lowered();
2124            assert_eq!(l.cron, cron, "cron for {when}");
2125            assert_eq!(l.mode, mode, "mode for {when}");
2126            assert_eq!(l.cooldown.as_deref(), cooldown, "cooldown for {when}");
2127        }
2128    }
2129
2130    #[test]
2131    fn lowered_carries_schedule_tz() {
2132        for (tz, want) in [
2133            (ScheduleTz::Local, ScheduleTz::Local),
2134            (ScheduleTz::Utc, ScheduleTz::Utc),
2135        ] {
2136            let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
2137            s.tz = tz;
2138            assert_eq!(s.lowered().tz, want, "calendar carries tz");
2139            // reconcile shapes carry tz too (for the active-window check)
2140            let mut s = schedule_with(
2141                When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2142                RunsOn::Backend,
2143            );
2144            s.tz = tz;
2145            assert_eq!(s.lowered().tz, want, "reconcile carries tz");
2146        }
2147    }
2148
2149    #[test]
2150    fn poll_cron_is_accepted_by_the_engine_parser() {
2151        // POLL_CRON is system-generated — if the engine's parser
2152        // ever rejected it every reconcile schedule would die at
2153        // register time. Validate it with the same croner config
2154        // (Seconds::Required, dom_and_dow, year optional).
2155        croner::parser::CronParser::builder()
2156            .seconds(croner::parser::Seconds::Required)
2157            .dom_and_dow(true)
2158            .build()
2159            .parse(POLL_CRON)
2160            .expect("POLL_CRON must parse");
2161    }
2162
2163    // ---- Schedule::validate() (#418 decision F) ----
2164
2165    #[test]
2166    fn validate_accepts_reconcile_shapes() {
2167        for when in [
2168            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2169            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2170            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
2171            When::PerTarget(PerPolicy::Every(EverySpec {
2172                every: "24h".into(),
2173            })),
2174        ] {
2175            schedule_with(when.clone(), RunsOn::Backend)
2176                .validate()
2177                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
2178        }
2179    }
2180
2181    #[test]
2182    fn validate_accepts_per_pc_on_agent() {
2183        schedule_with(
2184            When::PerPc(PerPolicy::Every(EverySpec { every: "1h".into() })),
2185            RunsOn::Agent,
2186        )
2187        .validate()
2188        .expect("per_pc + agent is the offline-inventory shape");
2189    }
2190
2191    // ---- #418 event triggers (when: { on }) ----
2192
2193    #[test]
2194    fn validate_accepts_event_on_agent() {
2195        for triggers in [
2196            vec![OnTrigger::Startup],
2197            vec![OnTrigger::Logon],
2198            vec![OnTrigger::Lock],
2199            vec![OnTrigger::Unlock],
2200            vec![OnTrigger::NetworkChange],
2201            vec![
2202                OnTrigger::Startup,
2203                OnTrigger::Logon,
2204                OnTrigger::Lock,
2205                OnTrigger::Unlock,
2206                OnTrigger::NetworkChange,
2207            ],
2208        ] {
2209            schedule_with(When::On(triggers), RunsOn::Agent)
2210                .validate()
2211                .expect("when.on is valid on runs_on: agent");
2212        }
2213    }
2214
2215    #[test]
2216    fn validate_rejects_event_on_backend() {
2217        let err = schedule_with(When::On(vec![OnTrigger::Startup]), RunsOn::Backend)
2218            .validate()
2219            .unwrap_err();
2220        assert!(err.contains("when.on"), "got: {err}");
2221        assert!(err.contains("runs_on: agent"), "got: {err}");
2222    }
2223
2224    #[test]
2225    fn validate_rejects_empty_event_list() {
2226        let err = schedule_with(When::On(vec![]), RunsOn::Agent)
2227            .validate()
2228            .unwrap_err();
2229        assert!(err.contains("when.on"), "got: {err}");
2230        assert!(err.contains("at least one"), "got: {err}");
2231    }
2232
2233    #[test]
2234    fn event_schedule_lowers_to_event_mode_and_is_event() {
2235        let s = schedule_with(When::On(vec![OnTrigger::Startup]), RunsOn::Agent);
2236        assert!(s.is_event());
2237        assert_eq!(s.lowered().mode, ExecMode::Event);
2238        assert_eq!(s.event_triggers(), &[OnTrigger::Startup]);
2239        // non-event schedules report no triggers.
2240        let cal = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
2241        assert!(!cal.is_event());
2242        assert!(cal.event_triggers().is_empty());
2243    }
2244
2245    // ---- #418 constraints.require (env gates) ----
2246
2247    fn require_schedule(req: Require, runs_on: RunsOn) -> Schedule {
2248        let mut s = schedule_with(
2249            When::PerPc(PerPolicy::Every(EverySpec { every: "1m".into() })),
2250            runs_on,
2251        );
2252        s.constraints.require = Some(req);
2253        s
2254    }
2255
2256    #[test]
2257    fn require_met_combinations() {
2258        use std::time::Duration;
2259        let idle = |m: u64| Some(Duration::from_secs(m * 60));
2260        // Builder for the sensed state: (ac, idle, cpu, network).
2261        let env = |ac, idle, cpu, net| EnvState {
2262            ac_online: ac,
2263            idle,
2264            cpu_pct: cpu,
2265            network_up: net,
2266        };
2267        // Empty require — always met regardless of sensed state.
2268        assert!(require_met(
2269            &Require::default(),
2270            &env(false, None, None, false)
2271        ));
2272        // ac_power: only on AC.
2273        let ac = Require {
2274            ac_power: true,
2275            ..Default::default()
2276        };
2277        assert!(!require_met(&ac, &env(false, None, None, true)));
2278        assert!(require_met(&ac, &env(true, None, None, false)));
2279        // idle: needs >= the configured min; None idle never satisfies.
2280        let idle10 = Require {
2281            idle: Some("10m".into()),
2282            ..Default::default()
2283        };
2284        assert!(!require_met(&idle10, &env(true, None, None, true)));
2285        assert!(!require_met(&idle10, &env(true, idle(5), None, true)));
2286        assert!(require_met(&idle10, &env(true, idle(15), None, true)));
2287        assert!(require_met(&idle10, &env(true, idle(10), None, true))); // boundary inclusive
2288        // cpu_below: needs CPU strictly < threshold; None cpu never satisfies.
2289        let cpu20 = Require {
2290            cpu_below: Some(20.0),
2291            ..Default::default()
2292        };
2293        assert!(!require_met(&cpu20, &env(true, None, None, true))); // no sample → fail-closed
2294        assert!(!require_met(&cpu20, &env(true, None, Some(20.0), true))); // == threshold
2295        assert!(!require_met(&cpu20, &env(true, None, Some(55.0), true))); // busy
2296        assert!(require_met(&cpu20, &env(true, None, Some(5.0), true))); // quiet
2297        // network: only when online.
2298        let net = Require {
2299            network: true,
2300            ..Default::default()
2301        };
2302        assert!(!require_met(&net, &env(true, None, None, false))); // offline
2303        assert!(require_met(&net, &env(true, None, None, true))); // online
2304        // all four: AND.
2305        let all = Require {
2306            ac_power: true,
2307            idle: Some("10m".into()),
2308            cpu_below: Some(20.0),
2309            network: true,
2310        };
2311        assert!(!require_met(&all, &env(false, idle(20), Some(5.0), true))); // on battery
2312        assert!(!require_met(&all, &env(true, idle(1), Some(5.0), true))); // not idle enough
2313        assert!(!require_met(&all, &env(true, idle(20), Some(50.0), true))); // busy
2314        assert!(!require_met(&all, &env(true, idle(20), Some(5.0), false))); // offline
2315        assert!(require_met(&all, &env(true, idle(20), Some(5.0), true)));
2316        // An unparseable idle is treated as no-requirement by require_met
2317        // (validate rejects it at create time, so this only guards a
2318        // hand-edited blob): ac still gates.
2319        let bad = Require {
2320            ac_power: true,
2321            idle: Some("garbage".into()),
2322            ..Default::default()
2323        };
2324        assert!(require_met(&bad, &env(true, None, None, true)));
2325        assert!(!require_met(&bad, &env(false, None, None, true)));
2326    }
2327
2328    #[test]
2329    fn validate_accepts_and_rejects_cpu_below() {
2330        // In-range accepted.
2331        require_schedule(
2332            Require {
2333                cpu_below: Some(20.0),
2334                ..Default::default()
2335            },
2336            RunsOn::Agent,
2337        )
2338        .validate()
2339        .expect("cpu_below 20 is valid");
2340        // Upper boundary: 100.0 is accepted (fires unless CPU is exactly
2341        // 100%). Pins the inclusive upper bound against a future c < 100.0.
2342        require_schedule(
2343            Require {
2344                cpu_below: Some(100.0),
2345                ..Default::default()
2346            },
2347            RunsOn::Agent,
2348        )
2349        .validate()
2350        .expect("cpu_below 100 is valid");
2351        // Out of range rejected (0 and >100).
2352        for bad in [0.0, -5.0, 100.1] {
2353            let err = require_schedule(
2354                Require {
2355                    cpu_below: Some(bad),
2356                    ..Default::default()
2357                },
2358                RunsOn::Agent,
2359            )
2360            .validate()
2361            .unwrap_err();
2362            assert!(
2363                err.contains("constraints.require.cpu_below"),
2364                "cpu_below {bad}: {err}"
2365            );
2366        }
2367    }
2368
2369    #[test]
2370    fn validate_accepts_require_on_agent() {
2371        require_schedule(
2372            Require {
2373                ac_power: true,
2374                idle: Some("10m".into()),
2375                cpu_below: Some(20.0),
2376                network: true,
2377            },
2378            RunsOn::Agent,
2379        )
2380        .validate()
2381        .expect("constraints.require is valid on runs_on: agent");
2382    }
2383
2384    #[test]
2385    fn validate_rejects_require_on_backend() {
2386        let err = require_schedule(
2387            Require {
2388                ac_power: true,
2389                ..Default::default()
2390            },
2391            RunsOn::Backend,
2392        )
2393        .validate()
2394        .unwrap_err();
2395        assert!(err.contains("constraints.require"), "got: {err}");
2396        assert!(err.contains("runs_on: agent"), "got: {err}");
2397
2398        // An idle-only require (ac_power: false) is also non-empty
2399        // (is_empty folds the fields) and must reject on backend too —
2400        // guards against a regression in Require::is_empty.
2401        let err = require_schedule(
2402            Require {
2403                idle: Some("10m".into()),
2404                ..Default::default()
2405            },
2406            RunsOn::Backend,
2407        )
2408        .validate()
2409        .unwrap_err();
2410        assert!(
2411            err.contains("constraints.require"),
2412            "idle-only on backend: {err}"
2413        );
2414    }
2415
2416    #[test]
2417    fn validate_rejects_bad_require_idle() {
2418        let err = require_schedule(
2419            Require {
2420                idle: Some("not-a-duration".into()),
2421                ..Default::default()
2422            },
2423            RunsOn::Agent,
2424        )
2425        .validate()
2426        .unwrap_err();
2427        assert!(err.contains("constraints.require.idle"), "got: {err}");
2428    }
2429
2430    #[test]
2431    fn require_round_trips_and_skips_empty() {
2432        // ac_power: false is skipped; an all-default require nested in
2433        // constraints is omitted (is_empty folds it in).
2434        let yaml = "id: s\nwhen: { per_pc: { every: 1m } }\njob_id: j\nruns_on: agent\n\
2435                    constraints: { require: { ac_power: true, idle: 10m, cpu_below: 20, \
2436                    network: true } }\n";
2437        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2438        let req = s.constraints.require.as_ref().expect("require present");
2439        assert!(req.ac_power);
2440        assert_eq!(req.idle.as_deref(), Some("10m"));
2441        assert_eq!(req.cpu_below, Some(20.0));
2442        assert!(req.network);
2443        // Re-serialize: idle + cpu_below + network present, ac_power true.
2444        let back = serde_json::to_string(&s.constraints).unwrap();
2445        assert!(back.contains("\"idle\":\"10m\""), "got: {back}");
2446        assert!(back.contains("\"cpu_below\":20"), "got: {back}");
2447        assert!(back.contains("\"network\":true"), "got: {back}");
2448        // An empty require is omitted entirely by is_empty.
2449        let mut empty = s.clone();
2450        empty.constraints.require = Some(Require::default());
2451        assert!(empty.constraints.is_empty());
2452    }
2453
2454    #[test]
2455    fn validate_rejects_per_target_on_agent() {
2456        let err = schedule_with(
2457            When::PerTarget(PerPolicy::Every(EverySpec {
2458                every: "24h".into(),
2459            })),
2460            RunsOn::Agent,
2461        )
2462        .validate()
2463        .unwrap_err();
2464        assert!(err.contains("per_target"), "got: {err}");
2465        assert!(err.contains("runs_on: agent"), "got: {err}");
2466
2467        // per_target: once is also backend-only.
2468        let err = schedule_with(
2469            When::PerTarget(PerPolicy::Once(OnceLiteral::Once)),
2470            RunsOn::Agent,
2471        )
2472        .validate()
2473        .unwrap_err();
2474        assert!(err.contains("per_target"), "got (once): {err}");
2475        assert!(err.contains("runs_on: agent"), "got (once): {err}");
2476    }
2477
2478    #[test]
2479    fn validate_rejects_bad_every_duration() {
2480        let err = schedule_with(
2481            When::PerPc(PerPolicy::Every(EverySpec { every: "6x".into() })),
2482            RunsOn::Backend,
2483        )
2484        .validate()
2485        .unwrap_err();
2486        assert!(err.contains("when.every"), "got: {err}");
2487    }
2488
2489    #[test]
2490    fn validate_rejects_bad_jitter_and_starting_deadline() {
2491        let mut s = schedule_with(
2492            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2493            RunsOn::Backend,
2494        );
2495        s.plan.jitter = Some("5x".into());
2496        let err = s.validate().unwrap_err();
2497        assert!(err.contains("jitter"), "got: {err}");
2498
2499        let mut s = schedule_with(
2500            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2501            RunsOn::Backend,
2502        );
2503        s.starting_deadline = Some("soon".into());
2504        let err = s.validate().unwrap_err();
2505        assert!(err.contains("starting_deadline"), "got: {err}");
2506    }
2507
2508    #[test]
2509    fn validate_accepts_calendar_shapes() {
2510        for when in [
2511            calendar("09:00", &["mon-fri"]),   // weekday morning
2512            calendar("00:00", &["sun"]),       // weekly
2513            calendar("18:30", &[]),            // daily
2514            calendar("2026-06-10 09:00", &[]), // one-shot
2515            calendar("2026/12/25 00:00", &[]), // one-shot, slash form
2516        ] {
2517            schedule_with(when.clone(), RunsOn::Backend)
2518                .validate()
2519                .unwrap_or_else(|e| panic!("{when} should validate: {e}"));
2520        }
2521    }
2522
2523    #[test]
2524    fn validate_rejects_bad_at() {
2525        for bad in ["25:00", "09:60", "9", "noon", "2026-13-01 09:00"] {
2526            let err = schedule_with(calendar(bad, &[]), RunsOn::Backend)
2527                .validate()
2528                .unwrap_err();
2529            assert!(err.contains("when.at"), "for '{bad}', got: {err}");
2530        }
2531    }
2532
2533    #[test]
2534    fn validate_rejects_datetime_at_with_days() {
2535        // A dated `at` is a one-shot — pairing it with days is a
2536        // contradiction (the date already pins the day).
2537        let err = schedule_with(calendar("2026-06-10 09:00", &["mon"]), RunsOn::Backend)
2538            .validate()
2539            .unwrap_err();
2540        assert!(
2541            err.contains("one-shot") && err.contains("days"),
2542            "got: {err}"
2543        );
2544    }
2545
2546    #[test]
2547    fn validate_rejects_bad_day_name() {
2548        // A garbage DOW token is caught by the days pre-flight and
2549        // reported against `when.days`, not the confusing
2550        // "when.at lowered to invalid cron" (claude #432 review).
2551        let err = schedule_with(calendar("09:00", &["funday"]), RunsOn::Backend)
2552            .validate()
2553            .unwrap_err();
2554        assert!(err.contains("when.days"), "got: {err}");
2555        assert!(err.contains("funday"), "names the bad token: {err}");
2556        // a degenerate range like `mon-` reports the whole token, not
2557        // a cryptic empty part (claude #432 follow-up)
2558        let err = schedule_with(calendar("09:00", &["mon-"]), RunsOn::Backend)
2559            .validate()
2560            .unwrap_err();
2561        assert!(err.contains("'mon-'"), "names the whole token: {err}");
2562        // valid names / ranges / numeric / * all pass
2563        for ok in [
2564            calendar("09:00", &["mon-fri"]),
2565            calendar("09:00", &["mon", "wed", "sun"]),
2566            calendar("09:00", &["1-5"]),
2567        ] {
2568            schedule_with(ok.clone(), RunsOn::Backend)
2569                .validate()
2570                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
2571        }
2572    }
2573
2574    #[test]
2575    fn validate_accepts_nth_weekday() {
2576        // #418: nth-weekday (Patch Tuesday). validate() also lowers to
2577        // a cron and parses it with croner, so passing here proves the
2578        // whole chain — token → DOW field → engine-acceptable cron.
2579        for ok in [
2580            calendar("09:00", &["tue#2"]),          // 2nd Tuesday
2581            calendar("09:00", &["fri#1"]),          // 1st Friday
2582            calendar("03:00", &["sun#5"]),          // 5th Sunday
2583            calendar("09:00", &["tue#2", "thu#2"]), // a list of nths
2584            calendar("09:00", &["2#2"]),            // numeric DOW + ordinal
2585            // Case-insensitive both sides: validate lowercases, croner
2586            // upper-cases the whole pattern before aliasing (claude #547).
2587            calendar("09:00", &["TUE#2"]),
2588        ] {
2589            schedule_with(ok.clone(), RunsOn::Backend)
2590                .validate()
2591                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
2592        }
2593    }
2594
2595    #[test]
2596    fn validate_rejects_bad_nth_weekday() {
2597        // ordinal out of 1..5, a range with #, and a bad day before #.
2598        for bad in ["tue#0", "tue#6", "tue#x", "mon-fri#2", "funday#2"] {
2599            let err = schedule_with(calendar("09:00", &[bad]), RunsOn::Backend)
2600                .validate()
2601                .unwrap_err();
2602            assert!(err.contains("when.days"), "for '{bad}', got: {err}");
2603        }
2604    }
2605
2606    #[test]
2607    fn validate_accepts_last_weekday() {
2608        // #418: last-weekday (`friL` = last Friday). Like the nth case,
2609        // validate() lowers to a cron and round-trips it through croner,
2610        // so passing proves token → DOW field → engine-acceptable cron
2611        // with the verified last-<dow>-of-month semantics.
2612        for ok in [
2613            calendar("09:00", &["friL"]),         // last Friday
2614            calendar("03:00", &["sunL"]),         // last Sunday
2615            calendar("22:00", &["5L"]),           // numeric DOW + last
2616            calendar("00:00", &["0L"]),           // numeric Sunday (0…
2617            calendar("00:00", &["7L"]),           // …and its 7 alias)
2618            calendar("09:00", &["monL", "friL"]), // a list of last-weekdays
2619            // Case-insensitive both the weekday and the `L` suffix:
2620            // validate lowercases the day, croner upper-cases the whole
2621            // pattern before aliasing (claude #547).
2622            calendar("09:00", &["FRIL"]),
2623            calendar("09:00", &["fril"]),
2624        ] {
2625            schedule_with(ok.clone(), RunsOn::Backend)
2626                .validate()
2627                .unwrap_or_else(|e| panic!("{ok} should validate: {e}"));
2628        }
2629    }
2630
2631    #[test]
2632    fn validate_rejects_bad_last_weekday() {
2633        // bare `L` (no weekday — a footgun croner reads as Saturday), a
2634        // range with L, a bad day before L, and an internal space that
2635        // would otherwise leak a malformed cron downstream (gemini #560).
2636        for bad in ["L", "l", "mon-friL", "fundayL", "8L", "*L", "fri L"] {
2637            let err = schedule_with(calendar("09:00", &[bad]), RunsOn::Backend)
2638                .validate()
2639                .unwrap_err();
2640            assert!(err.contains("when.days"), "for '{bad}', got: {err}");
2641        }
2642    }
2643
2644    #[test]
2645    fn calendar_oneshot_instant_detects_past() {
2646        use chrono::TimeZone;
2647        // a dated `at` resolves to an absolute instant…
2648        let c = CalendarSpec {
2649            at: "2024-01-01 09:00".into(),
2650            days: vec![],
2651        };
2652        let t = c
2653            .oneshot_instant(ScheduleTz::Utc)
2654            .expect("one-shot instant");
2655        assert_eq!(
2656            t,
2657            chrono::Utc.with_ymd_and_hms(2024, 1, 1, 9, 0, 0).unwrap()
2658        );
2659        assert!(t < chrono::Utc::now(), "2024 is in the past");
2660        // …while a repeating (time-only) calendar has no instant
2661        let rep = CalendarSpec {
2662            at: "09:00".into(),
2663            days: vec!["mon-fri".into()],
2664        };
2665        assert!(rep.oneshot_instant(ScheduleTz::Utc).is_none());
2666    }
2667
2668    fn schedule_with_active(from: Option<&str>, until: Option<&str>) -> Schedule {
2669        let mut s = schedule_with(
2670            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2671            RunsOn::Backend,
2672        );
2673        s.active = Active {
2674            from: from.map(str::to_owned),
2675            until: until.map(str::to_owned),
2676        };
2677        s
2678    }
2679
2680    #[test]
2681    fn validate_accepts_active_window() {
2682        schedule_with_active(Some("2026-07-01"), Some("2026-08-01T12:00:00+09:00"))
2683            .validate()
2684            .expect("date + rfc3339 bounds should validate");
2685    }
2686
2687    #[test]
2688    fn validate_rejects_unparseable_active_bound() {
2689        let err = schedule_with_active(Some("July 1st"), None)
2690            .validate()
2691            .unwrap_err();
2692        assert!(err.contains("active"), "got: {err}");
2693    }
2694
2695    #[test]
2696    fn validate_rejects_from_not_before_until() {
2697        let err = schedule_with_active(Some("2026-08-01"), Some("2026-07-01"))
2698            .validate()
2699            .unwrap_err();
2700        assert!(err.contains("strictly before"), "got: {err}");
2701
2702        let err = schedule_with_active(Some("2026-07-01"), Some("2026-07-01"))
2703            .validate()
2704            .unwrap_err();
2705        assert!(err.contains("strictly before"), "got: {err}");
2706    }
2707
2708    // ---- Active window semantics ----
2709
2710    #[test]
2711    fn active_window_is_half_open() {
2712        use chrono::TimeZone;
2713        let active = Active {
2714            from: Some("2026-07-01".into()),
2715            until: Some("2026-08-01".into()),
2716        };
2717        // UTC tz so the date bounds are UTC midnight.
2718        let at = |y, m, d, h| chrono::Utc.with_ymd_and_hms(y, m, d, h, 0, 0).unwrap();
2719        let c = |t| active.contains(t, ScheduleTz::Utc);
2720        assert!(!c(at(2026, 6, 30, 23)), "before from");
2721        assert!(c(at(2026, 7, 1, 0)), "at from (inclusive)");
2722        assert!(c(at(2026, 7, 15, 12)), "inside");
2723        assert!(!c(at(2026, 8, 1, 0)), "at until (exclusive)");
2724        assert!(!c(at(2026, 8, 2, 0)), "after until");
2725    }
2726
2727    #[test]
2728    fn active_empty_window_is_always_active() {
2729        assert!(Active::default().contains(chrono::Utc::now(), ScheduleTz::Local));
2730    }
2731
2732    #[test]
2733    fn active_rfc3339_bound_honours_offset_regardless_of_tz() {
2734        use chrono::TimeZone;
2735        let active = Active {
2736            from: Some("2026-07-01T09:00:00+09:00".into()),
2737            until: None,
2738        };
2739        // RFC3339 carries its own offset → tz arg is ignored.
2740        // 09:00 JST = 00:00 UTC.
2741        for tz in [ScheduleTz::Utc, ScheduleTz::Local] {
2742            assert!(
2743                !active.contains(
2744                    chrono::Utc
2745                        .with_ymd_and_hms(2026, 6, 30, 23, 59, 0)
2746                        .unwrap(),
2747                    tz
2748                )
2749            );
2750            assert!(active.contains(
2751                chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap(),
2752                tz
2753            ));
2754        }
2755    }
2756
2757    #[test]
2758    fn active_date_bound_respects_tz() {
2759        // A bare `YYYY-MM-DD` bound is midnight *in the schedule's
2760        // tz* (#418 Phase 2). The UTC interpretation is exact and
2761        // host-independent; assert that precisely.
2762        use chrono::TimeZone;
2763        let utc = Active::parse_bound("2026-07-01", ScheduleTz::Utc).expect("utc");
2764        assert_eq!(
2765            utc,
2766            chrono::Utc.with_ymd_and_hms(2026, 7, 1, 0, 0, 0).unwrap()
2767        );
2768
2769        // The local interpretation must equal what chrono::Local
2770        // computes for the same wall-clock midnight — proves the tz
2771        // path is wired to the host zone (the magnitude vs UTC is
2772        // host-dependent, so we compare against Local directly rather
2773        // than hard-coding the JST offset, keeping CI green on UTC
2774        // runners).
2775        let local = Active::parse_bound("2026-07-01", ScheduleTz::Local).expect("local");
2776        let want = chrono::Local
2777            .with_ymd_and_hms(2026, 7, 1, 0, 0, 0)
2778            .single()
2779            .expect("local midnight is unambiguous")
2780            .with_timezone(&chrono::Utc);
2781        assert_eq!(local, want, "date bound resolved in host-local tz");
2782    }
2783
2784    #[test]
2785    fn active_empty_is_skipped_when_serialising() {
2786        let s = schedule_with(
2787            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2788            RunsOn::Backend,
2789        );
2790        let json = serde_json::to_value(&s).expect("serialise");
2791        assert!(
2792            json.get("active").is_none(),
2793            "empty active must not appear on the wire: {json}"
2794        );
2795    }
2796
2797    // ---- constraints.window (#418 Phase 3) ----
2798
2799    fn with_window(win: &str) -> Schedule {
2800        let mut s = schedule_with(
2801            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2802            RunsOn::Backend,
2803        );
2804        s.constraints.window = Some(win.into());
2805        s
2806    }
2807
2808    #[test]
2809    fn constraints_window_parses_and_round_trips() {
2810        let yaml = r#"
2811id: x
2812when:
2813  per_pc: { every: 6h }
2814job_id: y
2815target: { all: true }
2816constraints:
2817  window: "22:00-05:00"
2818"#;
2819        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
2820        assert_eq!(s.constraints.window.as_deref(), Some("22:00-05:00"));
2821        let back: Schedule =
2822            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
2823        assert_eq!(back.constraints.window.as_deref(), Some("22:00-05:00"));
2824    }
2825
2826    #[test]
2827    fn constraints_empty_is_skipped_when_serialising() {
2828        let s = schedule_with(
2829            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
2830            RunsOn::Backend,
2831        );
2832        let json = serde_json::to_value(&s).expect("serialise");
2833        assert!(
2834            json.get("constraints").is_none(),
2835            "empty constraints must not appear on the wire: {json}"
2836        );
2837    }
2838
2839    #[test]
2840    fn window_no_constraint_always_allows() {
2841        let c = Constraints::default();
2842        assert!(c.allows(chrono::Utc::now(), ScheduleTz::Local));
2843    }
2844
2845    #[test]
2846    fn window_same_day_is_half_open() {
2847        use chrono::TimeZone;
2848        let s = with_window("09:00-17:00");
2849        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
2850        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
2851        assert!(!a(at(8, 59)), "before start");
2852        assert!(a(at(9, 0)), "at start (inclusive)");
2853        assert!(a(at(16, 59)), "inside");
2854        assert!(!a(at(17, 0)), "at end (exclusive)");
2855        assert!(!a(at(23, 0)), "after end");
2856    }
2857
2858    #[test]
2859    fn window_crossing_midnight() {
2860        use chrono::TimeZone;
2861        let s = with_window("22:00-05:00");
2862        let at = |h, m| chrono::Utc.with_ymd_and_hms(2026, 6, 9, h, m, 0).unwrap();
2863        let a = |t| s.constraints.allows(t, ScheduleTz::Utc);
2864        assert!(a(at(22, 0)), "at start tonight");
2865        assert!(a(at(23, 30)), "late tonight");
2866        assert!(a(at(3, 0)), "early tomorrow");
2867        assert!(!a(at(5, 0)), "at end (exclusive)");
2868        assert!(!a(at(12, 0)), "midday outside");
2869        assert!(!a(at(21, 59)), "just before start");
2870    }
2871
2872    #[test]
2873    fn window_respects_tz() {
2874        // The same instant is inside the window under one tz and may
2875        // be outside under another. Compare UTC vs Local via the
2876        // host's own offset (kept CI-green on UTC runners like the
2877        // active tz test does).
2878        use chrono::TimeZone;
2879        let s = with_window("09:00-17:00");
2880        let noon_utc = chrono::Utc.with_ymd_and_hms(2026, 6, 9, 12, 0, 0).unwrap();
2881        // Under UTC, 12:00 is inside 09:00-17:00.
2882        assert!(s.constraints.allows(noon_utc, ScheduleTz::Utc));
2883        // Under Local, the verdict tracks the host wall-clock time;
2884        // assert it matches a direct wall_time membership check.
2885        let local_t = noon_utc.with_timezone(&chrono::Local).time();
2886        let in_local = local_t >= chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap()
2887            && local_t < chrono::NaiveTime::from_hms_opt(17, 0, 0).unwrap();
2888        assert_eq!(s.constraints.allows(noon_utc, ScheduleTz::Local), in_local);
2889    }
2890
2891    #[test]
2892    fn validate_accepts_good_window() {
2893        for w in ["09:00-17:00", "22:00-05:00", "00:00-23:59"] {
2894            with_window(w)
2895                .validate()
2896                .unwrap_or_else(|e| panic!("'{w}' should validate: {e}"));
2897        }
2898    }
2899
2900    #[test]
2901    fn validate_rejects_bad_window() {
2902        for bad in ["9-5", "22:00", "22:00-22:00", "25:00-05:00", "09:00_17:00"] {
2903            let err = with_window(bad).validate().unwrap_err();
2904            assert!(
2905                err.contains("constraints.window"),
2906                "for '{bad}', got: {err}"
2907            );
2908        }
2909    }
2910
2911    // ---- constraints.skip_dates (#418 holiday exclusion) ----
2912
2913    fn with_skip_dates(dates: &[&str]) -> Schedule {
2914        let mut s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
2915        s.tz = ScheduleTz::Utc; // host-independent date assertions
2916        s.constraints.skip_dates = dates.iter().map(|d| (*d).to_string()).collect();
2917        s
2918    }
2919
2920    #[test]
2921    fn allows_blocks_listed_skip_date() {
2922        use chrono::TimeZone;
2923        let s = with_skip_dates(&["2026-06-10", "2026-12-25"]);
2924        // Any time on a listed date is blocked (whole day).
2925        let on = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 9, 0, 0).unwrap();
2926        assert!(!s.constraints.allows(on, ScheduleTz::Utc));
2927        let on_midnight = chrono::Utc.with_ymd_and_hms(2026, 12, 25, 0, 0, 0).unwrap();
2928        assert!(!s.constraints.allows(on_midnight, ScheduleTz::Utc));
2929        // A date not in the list fires normally.
2930        let off = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 9, 0, 0).unwrap();
2931        assert!(s.constraints.allows(off, ScheduleTz::Utc));
2932    }
2933
2934    #[test]
2935    fn allows_corrupt_skip_date_fails_closed() {
2936        use chrono::TimeZone;
2937        // A garbled entry (only reachable via hand-edited KV) blocks
2938        // rather than silently re-enabling fires — same posture as a
2939        // corrupt window.
2940        let s = with_skip_dates(&["not-a-date"]);
2941        let any = chrono::Utc.with_ymd_and_hms(2026, 6, 11, 9, 0, 0).unwrap();
2942        assert!(!s.constraints.allows(any, ScheduleTz::Utc));
2943    }
2944
2945    #[test]
2946    fn validate_accepts_good_skip_dates() {
2947        with_skip_dates(&["2026-01-01", "2026-12-25", "2027-05-03"])
2948            .validate()
2949            .expect("well-formed skip dates should validate");
2950    }
2951
2952    #[test]
2953    fn validate_rejects_bad_skip_date() {
2954        for bad in ["2026-13-01", "01-01-2026", "nope", "2026/01/01"] {
2955            let err = with_skip_dates(&[bad]).validate().unwrap_err();
2956            assert!(
2957                err.contains("constraints.skip_dates"),
2958                "for '{bad}', got: {err}"
2959            );
2960        }
2961    }
2962
2963    #[test]
2964    fn preview_skips_holidays() {
2965        use chrono::TimeZone;
2966        // Daily 09:00 with two of the next five days marked as holidays
2967        // — preview drops exactly those, since it gates on `allows`.
2968        let mut s = cal_utc("09:00", &[]);
2969        s.constraints.skip_dates = vec!["2026-06-11".into(), "2026-06-13".into()];
2970        let now = chrono::Utc.with_ymd_and_hms(2026, 6, 10, 0, 0, 0).unwrap();
2971        let got = s.preview_fires(now, 4);
2972        let want: Vec<_> = [
2973            (2026, 6, 10),
2974            (2026, 6, 12), // skips 06-11
2975            (2026, 6, 14), // skips 06-13
2976            (2026, 6, 15),
2977        ]
2978        .iter()
2979        .map(|(y, m, d)| chrono::Utc.with_ymd_and_hms(*y, *m, *d, 9, 0, 0).unwrap())
2980        .collect();
2981        assert_eq!(got, want);
2982    }
2983
2984    // ---- constraints.max_concurrent (#418) ----
2985
2986    fn with_max_concurrent(max: u32, runs_on: RunsOn) -> Schedule {
2987        let mut s = schedule_with(
2988            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
2989            runs_on,
2990        );
2991        s.constraints.max_concurrent = Some(max);
2992        s
2993    }
2994
2995    #[test]
2996    fn validate_accepts_backend_max_concurrent() {
2997        with_max_concurrent(5, RunsOn::Backend)
2998            .validate()
2999            .expect("backend max_concurrent should validate");
3000    }
3001
3002    #[test]
3003    fn validate_rejects_max_concurrent_on_agent() {
3004        // Decision E: a central running-instance cap needs a central
3005        // counter, which agents don't have.
3006        let err = with_max_concurrent(5, RunsOn::Agent)
3007            .validate()
3008            .unwrap_err();
3009        assert!(err.contains("constraints.max_concurrent"), "got: {err}");
3010        assert!(err.contains("runs_on: agent"), "got: {err}");
3011    }
3012
3013    #[test]
3014    fn validate_rejects_zero_max_concurrent() {
3015        let err = with_max_concurrent(0, RunsOn::Backend)
3016            .validate()
3017            .unwrap_err();
3018        assert!(err.contains("max_concurrent must be >= 1"), "got: {err}");
3019    }
3020
3021    #[test]
3022    fn max_concurrent_round_trips_and_skips_when_absent() {
3023        let s = with_max_concurrent(3, RunsOn::Backend);
3024        let json = serde_json::to_value(&s.constraints).expect("ser");
3025        assert_eq!(json.get("max_concurrent").and_then(|v| v.as_u64()), Some(3));
3026        // A schedule with no constraints omits the whole block.
3027        let bare = schedule_with(
3028            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3029            RunsOn::Backend,
3030        );
3031        assert!(bare.constraints.is_empty());
3032    }
3033
3034    #[test]
3035    fn window_fail_closed_on_corrupt_blob() {
3036        // A malformed window (only reachable via a hand-edited KV
3037        // blob — validate() rejects it at create) must BLOCK, not
3038        // silently allow fires during a change-freeze (gemini #452).
3039        let s = with_window("22:00_05:00");
3040        assert!(
3041            !s.constraints.allows(chrono::Utc::now(), ScheduleTz::Utc),
3042            "corrupt window fails closed"
3043        );
3044        // …and the scheduler can surface why it's stuck.
3045        assert!(
3046            s.bad_window().is_some(),
3047            "bad_window reports the parse error"
3048        );
3049        assert!(with_window("22:00-05:00").bad_window().is_none());
3050    }
3051
3052    #[test]
3053    fn calendar_outside_window_is_flagged() {
3054        // at 09:00 can never fall in 22:00-05:00 → never fires.
3055        let mut s = schedule_with(calendar("09:00", &["mon-fri"]), RunsOn::Backend);
3056        s.constraints.window = Some("22:00-05:00".into());
3057        assert!(s.calendar_outside_window(), "09:00 is not in 22:00-05:00");
3058
3059        // at 23:00 IS inside the overnight window → fine.
3060        let mut s = schedule_with(calendar("23:00", &[]), RunsOn::Backend);
3061        s.constraints.window = Some("22:00-05:00".into());
3062        assert!(!s.calendar_outside_window(), "23:00 is in 22:00-05:00");
3063
3064        // reconcile shapes are never flagged (they poll every minute).
3065        let mut s = schedule_with(
3066            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3067            RunsOn::Backend,
3068        );
3069        s.constraints.window = Some("22:00-05:00".into());
3070        assert!(!s.calendar_outside_window(), "reconcile is unaffected");
3071
3072        // no window → never flagged.
3073        let s = schedule_with(calendar("09:00", &[]), RunsOn::Backend);
3074        assert!(!s.calendar_outside_window());
3075    }
3076
3077    // ---- on_failure.retry (#418 Phase 4) ----
3078
3079    fn with_retry(max: u32, backoff: &str) -> Schedule {
3080        let mut s = schedule_with(
3081            When::PerPc(PerPolicy::Every(EverySpec { every: "6h".into() })),
3082            RunsOn::Backend,
3083        );
3084        s.on_failure.retry = Some(Retry {
3085            max,
3086            backoff: backoff.into(),
3087        });
3088        s
3089    }
3090
3091    #[test]
3092    fn on_failure_parses_and_round_trips() {
3093        let yaml = r#"
3094id: x
3095when:
3096  per_pc: { every: 6h }
3097job_id: y
3098target: { all: true }
3099on_failure:
3100  retry: { max: 3, backoff: 10m }
3101"#;
3102        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3103        let r = s.on_failure.retry.as_ref().expect("retry present");
3104        assert_eq!(r.max, 3);
3105        assert_eq!(r.backoff, "10m");
3106        let back: Schedule =
3107            serde_json::from_str(&serde_json::to_string(&s).expect("ser")).expect("de");
3108        assert_eq!(back.on_failure, s.on_failure);
3109    }
3110
3111    #[test]
3112    fn on_failure_empty_is_skipped_when_serialising() {
3113        let s = schedule_with(
3114            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3115            RunsOn::Backend,
3116        );
3117        let json = serde_json::to_value(&s).expect("serialise");
3118        assert!(
3119            json.get("on_failure").is_none(),
3120            "empty on_failure must not appear on the wire: {json}"
3121        );
3122    }
3123
3124    #[test]
3125    fn validate_accepts_good_retry() {
3126        for (max, backoff) in [(1, "30s"), (3, "10m"), (10, "1h")] {
3127            with_retry(max, backoff)
3128                .validate()
3129                .unwrap_or_else(|e| panic!("retry {{max:{max}, backoff:{backoff}}}: {e}"));
3130        }
3131    }
3132
3133    #[test]
3134    fn validate_rejects_bad_backoff() {
3135        let err = with_retry(3, "soon").validate().unwrap_err();
3136        assert!(err.contains("on_failure.retry.backoff"), "got: {err}");
3137    }
3138
3139    #[test]
3140    fn validate_rejects_sub_second_backoff() {
3141        // "500ms" parses as humantime but lowers to 0s on the wire —
3142        // reject it so the operator doesn't get a silent no-wait
3143        // (coderabbit #466).
3144        for bad in ["500ms", "0s", "999ms"] {
3145            let err = with_retry(3, bad).validate().unwrap_err();
3146            assert!(
3147                err.contains("on_failure.retry.backoff must be >= 1s"),
3148                "for '{bad}', got: {err}"
3149            );
3150        }
3151    }
3152
3153    #[test]
3154    fn validate_rejects_out_of_range_max() {
3155        for bad in [0u32, 11, 1000] {
3156            let err = with_retry(bad, "10m").validate().unwrap_err();
3157            assert!(
3158                err.contains("on_failure.retry.max"),
3159                "for max={bad}, got: {err}"
3160            );
3161        }
3162    }
3163
3164    #[test]
3165    fn lowered_retry_reduces_backoff_to_seconds() {
3166        let s = with_retry(3, "10m");
3167        let spec = s.on_failure.lowered_retry().expect("a retry policy");
3168        assert_eq!(spec.max, 3);
3169        assert_eq!(spec.backoff_secs, 600);
3170    }
3171
3172    #[test]
3173    fn lowered_retry_is_none_without_policy() {
3174        let s = schedule_with(
3175            When::PerPc(PerPolicy::Once(OnceLiteral::Once)),
3176            RunsOn::Backend,
3177        );
3178        assert!(s.on_failure.lowered_retry().is_none());
3179    }
3180
3181    // ---- global change-freeze (#418 Phase 5) ----
3182
3183    #[test]
3184    fn freeze_empty_window_is_always_active() {
3185        // The big-red-button shape: no bounds = frozen until cleared.
3186        let f = Freeze::default();
3187        assert!(f.is_active(chrono::Utc::now()));
3188    }
3189
3190    #[test]
3191    fn freeze_window_is_half_open() {
3192        use chrono::TimeZone;
3193        let f = Freeze {
3194            from: Some("2026-12-20T00:00:00+00:00".into()),
3195            until: Some("2027-01-05T00:00:00+00:00".into()),
3196            reason: Some("year-end".into()),
3197            tz: ScheduleTz::Utc,
3198        };
3199        let at = |y, mo, d| chrono::Utc.with_ymd_and_hms(y, mo, d, 0, 0, 0).unwrap();
3200        assert!(!f.is_active(at(2026, 12, 19)), "before from = not frozen");
3201        assert!(f.is_active(at(2026, 12, 20)), "from is inclusive");
3202        assert!(f.is_active(at(2026, 12, 31)), "inside window");
3203        assert!(!f.is_active(at(2027, 1, 5)), "until is exclusive");
3204        assert!(!f.is_active(at(2027, 1, 6)), "after until = not frozen");
3205    }
3206
3207    #[test]
3208    fn freeze_fails_closed_on_corrupt_bound() {
3209        // A freeze is a safety switch: an unparseable bound (only
3210        // reachable via a hand-edited KV blob) must read as FROZEN, not
3211        // "fire normally" (coderabbit #472) — the opposite of `active`,
3212        // which fail-opens.
3213        let f = Freeze {
3214            from: Some("not-a-date".into()),
3215            until: None,
3216            reason: None,
3217            tz: ScheduleTz::Utc,
3218        };
3219        assert!(f.is_active(chrono::Utc::now()), "corrupt bound → frozen");
3220    }
3221
3222    #[test]
3223    fn freeze_validate_accepts_good_bounds() {
3224        Freeze {
3225            from: Some("2026-12-20".into()),
3226            until: Some("2027-01-05T12:00:00+09:00".into()),
3227            reason: None,
3228            tz: ScheduleTz::Local,
3229        }
3230        .validate()
3231        .expect("date + rfc3339 bounds should validate");
3232        // Empty (indefinite) freeze is valid.
3233        Freeze::default().validate().expect("empty freeze is valid");
3234    }
3235
3236    #[test]
3237    fn freeze_validate_rejects_bad_bound_and_inverted_window() {
3238        let err = Freeze {
3239            from: Some("never".into()),
3240            ..Default::default()
3241        }
3242        .validate()
3243        .unwrap_err();
3244        assert!(err.contains("freeze:"), "got: {err}");
3245
3246        let inverted = Freeze {
3247            from: Some("2027-01-05".into()),
3248            until: Some("2026-12-20".into()),
3249            ..Default::default()
3250        }
3251        .validate()
3252        .unwrap_err();
3253        assert!(inverted.contains("freeze.from"), "got: {inverted}");
3254    }
3255
3256    #[test]
3257    fn freeze_round_trips_and_skips_empty_fields() {
3258        let f = Freeze {
3259            from: None,
3260            until: Some("2027-01-05".into()),
3261            reason: Some("INC-1234".into()),
3262            tz: ScheduleTz::Utc,
3263        };
3264        let json = serde_json::to_value(&f).expect("serialise");
3265        assert!(json.get("from").is_none(), "empty from omitted: {json}");
3266        let back: Freeze = serde_json::from_value(json).expect("round-trip");
3267        assert_eq!(back, f);
3268    }
3269
3270    #[test]
3271    fn shipped_schedule_configs_parse_and_validate() {
3272        // Every YAML under configs/schedules/ must parse with the
3273        // current Schedule serde AND pass validate() — keeps the
3274        // shipped examples from drifting out of sync with the model
3275        // (#418 removed back-compat, so drift = broken at create).
3276        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../configs/schedules");
3277        let mut seen = 0;
3278        for entry in std::fs::read_dir(&dir).expect("read configs/schedules") {
3279            let path = entry.expect("dir entry").path();
3280            if path.extension().and_then(|e| e.to_str()) != Some("yaml") {
3281                continue;
3282            }
3283            let body = std::fs::read_to_string(&path).expect("read yaml");
3284            let s: Schedule = serde_yaml::from_str(&body)
3285                .unwrap_or_else(|e| panic!("{} failed to parse: {e}", path.display()));
3286            s.validate()
3287                .unwrap_or_else(|e| panic!("{} failed validate(): {e}", path.display()));
3288            seen += 1;
3289        }
3290        assert!(seen > 0, "no schedule YAMLs found in {}", dir.display());
3291    }
3292
3293    // ---- pre-existing enum wire formats (unchanged by #418) ----
3294
3295    #[test]
3296    fn exec_mode_serialises_snake_case() {
3297        for (mode, expected) in [
3298            (ExecMode::EveryTick, "every_tick"),
3299            (ExecMode::OncePerPc, "once_per_pc"),
3300            (ExecMode::OncePerTarget, "once_per_target"),
3301        ] {
3302            let s = serde_json::to_value(mode).expect("serialise");
3303            assert_eq!(s, serde_json::Value::String(expected.into()));
3304            let back: ExecMode = serde_json::from_value(serde_json::Value::String(expected.into()))
3305                .expect("deserialise");
3306            assert_eq!(back, mode, "round-trip for {expected}");
3307        }
3308    }
3309
3310    #[test]
3311    fn schedule_runs_on_defaults_to_backend() {
3312        let yaml = r#"
3313id: x
3314when:
3315  per_pc: once
3316job_id: y
3317target: { all: true }
3318"#;
3319        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3320        assert_eq!(s.runs_on, RunsOn::Backend);
3321    }
3322
3323    #[test]
3324    fn schedule_runs_on_agent_parses() {
3325        let yaml = r#"
3326id: offline-inv
3327when:
3328  per_pc: { every: 1h }
3329job_id: inventory-hw
3330target: { all: true }
3331runs_on: agent
3332"#;
3333        let s: Schedule = serde_yaml::from_str(yaml).expect("parse");
3334        assert_eq!(s.runs_on, RunsOn::Agent);
3335        assert_eq!(s.lowered().mode, ExecMode::OncePerPc);
3336    }
3337
3338    #[test]
3339    fn runs_on_serialises_snake_case() {
3340        for (mode, expected) in [(RunsOn::Backend, "backend"), (RunsOn::Agent, "agent")] {
3341            let s = serde_json::to_value(mode).expect("serialise");
3342            assert_eq!(s, serde_json::Value::String(expected.into()));
3343            let back: RunsOn = serde_json::from_value(serde_json::Value::String(expected.into()))
3344                .expect("deserialise");
3345            assert_eq!(back, mode);
3346        }
3347    }
3348
3349    #[test]
3350    fn execute_shell_into_wire_shell() {
3351        assert_eq!(Shell::from(ExecuteShell::Powershell), Shell::Powershell);
3352        assert_eq!(Shell::from(ExecuteShell::Cmd), Shell::Cmd);
3353    }
3354
3355    #[test]
3356    fn manifest_staleness_defaults_to_cached() {
3357        let yaml = r#"
3358id: x
3359version: 1.0.0
3360execute:
3361  shell: powershell
3362  script: "echo"
3363  timeout: 1s
3364"#;
3365        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3366        assert_eq!(m.staleness, Staleness::Cached);
3367    }
3368
3369    #[test]
3370    fn manifest_strict_staleness_parses() {
3371        let yaml = r#"
3372id: urgent-patch
3373version: 2.5.1
3374execute:
3375  shell: powershell
3376  script: Install-Hotfix
3377  timeout: 5m
3378staleness:
3379  mode: strict
3380  max_cache_age: 0s
3381"#;
3382        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3383        match m.staleness {
3384            Staleness::Strict { max_cache_age } => assert_eq!(max_cache_age, "0s"),
3385            other => panic!("expected strict, got {other:?}"),
3386        }
3387    }
3388
3389    #[test]
3390    fn manifest_unchecked_staleness_parses() {
3391        let yaml = r#"
3392id: legacy
3393version: 0.1.0
3394execute:
3395  shell: cmd
3396  script: "echo"
3397  timeout: 1s
3398staleness:
3399  mode: unchecked
3400"#;
3401        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3402        assert_eq!(m.staleness, Staleness::Unchecked);
3403    }
3404
3405    #[test]
3406    fn missing_required_field_errors() {
3407        // `id` missing.
3408        let yaml = r#"
3409version: 1.0.0
3410target: { all: true }
3411execute:
3412  shell: powershell
3413  script: "echo"
3414  timeout: 1s
3415"#;
3416        let r: Result<Manifest, _> = serde_yaml::from_str(yaml);
3417        assert!(r.is_err(), "expected error, got {:?}", r);
3418    }
3419
3420    #[test]
3421    fn display_field_table_kind_round_trips_with_nested_columns() {
3422        // #39: `type: table` + `columns:` on a DisplayField gets
3423        // round-tripped through serde so the SPA receives the
3424        // nested schema verbatim. Nested columns themselves are
3425        // DisplayFields so they can carry `type: bytes` /
3426        // `type: number` for cell formatting.
3427        let yaml = r#"
3428id: inv-hw
3429version: 1.0.0
3430execute:
3431  shell: powershell
3432  script: "echo"
3433  timeout: 60s
3434inventory:
3435  display:
3436    - field: hostname
3437      label: Hostname
3438    - field: disks
3439      label: Disks
3440      type: table
3441      columns:
3442        - field: device_id
3443          label: Drive
3444        - field: size_bytes
3445          label: Size
3446          type: bytes
3447        - field: free_bytes
3448          label: Free
3449          type: bytes
3450        - field: file_system
3451          label: FS
3452"#;
3453        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3454        let inv = m.inventory.as_ref().expect("inventory hint");
3455        let disks = inv
3456            .display
3457            .iter()
3458            .find(|d| d.field == "disks")
3459            .expect("disks display row");
3460        assert_eq!(disks.kind.as_deref(), Some("table"));
3461        let cols = disks.columns.as_ref().expect("table needs columns");
3462        assert_eq!(cols.len(), 4);
3463        assert_eq!(cols[1].field, "size_bytes");
3464        assert_eq!(cols[1].kind.as_deref(), Some("bytes"));
3465    }
3466
3467    #[test]
3468    fn display_field_scalar_kind_keeps_columns_none() {
3469        // Defensive: when type is a scalar (`bytes` / `number` /
3470        // `timestamp`) the `columns` field stays None — the SPA
3471        // uses its presence as the "render nested table" signal,
3472        // so it must not leak in via serde defaults.
3473        let yaml = r#"
3474id: x
3475version: 1.0.0
3476execute:
3477  shell: powershell
3478  script: "echo"
3479  timeout: 5s
3480inventory:
3481  display:
3482    - { field: ram_bytes, label: RAM, type: bytes }
3483"#;
3484        let m: Manifest = serde_yaml::from_str(yaml).expect("parse");
3485        let inv = m.inventory.as_ref().unwrap();
3486        assert!(inv.display[0].columns.is_none());
3487    }
3488
3489    // ---- checked-in JSON Schema freshness (docs/schemas/) ----
3490
3491    /// The JSON Schemas under `docs/schemas/` must match what
3492    /// `schema_for!` produces today — a Cargo.lock-style freshness guard
3493    /// so a `Schedule` / `Manifest` field change can't silently drift
3494    /// the operator-facing schema. The SPA editor, the backend
3495    /// `/api/schemas/*` endpoints, and these files all read the same
3496    /// derived shape; this test fails CI if the checked-in copy lags.
3497    /// Regenerate with:
3498    ///   `UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current`
3499    #[test]
3500    fn schema_files_are_current() {
3501        assert_schema_file("schedule.schema.json", &schemars::schema_for!(Schedule));
3502        assert_schema_file("job.schema.json", &schemars::schema_for!(Manifest));
3503    }
3504
3505    fn assert_schema_file(name: &str, schema: &schemars::Schema) {
3506        let generated = serde_json::to_string_pretty(schema).expect("serialize schema") + "\n";
3507        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
3508            .join("../../docs/schemas")
3509            .join(name);
3510        if std::env::var_os("UPDATE_SCHEMAS").is_some() {
3511            std::fs::create_dir_all(path.parent().unwrap()).expect("mkdir docs/schemas");
3512            std::fs::write(&path, &generated).unwrap_or_else(|e| panic!("write {path:?}: {e}"));
3513            return;
3514        }
3515        // Normalize CRLF→LF before comparing: `.gitattributes` already
3516        // pins these files to `eol=lf`, but a stray CRLF working-tree
3517        // copy (autocrlf, a tool rewrite) shouldn't turn a *content*-
3518        // freshness check into a confusing line-ending failure — that's
3519        // .gitattributes' job, not this test's (gemini #588).
3520        let on_disk = std::fs::read_to_string(&path)
3521            .unwrap_or_else(|e| {
3522                panic!(
3523                    "read {path:?}: {e}\n\
3524                     generate it with: UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current"
3525                )
3526            })
3527            .replace("\r\n", "\n");
3528        assert_eq!(
3529            on_disk, generated,
3530            "{name} is stale — a Schedule/Manifest schema change isn't reflected in docs/schemas/. \
3531             Refresh with: UPDATE_SCHEMAS=1 cargo test -p kanade-shared schema_files_are_current"
3532        );
3533    }
3534}
3535
3536/// Periodic schedule (spec §2.4.3). v0.18.0 carries the fanout plan
3537/// (target + optional rollout + optional jitter) inline; the
3538/// referenced job (`job_id` → [`BUCKET_JOBS`]) supplies only the
3539/// script body. Two schedules of the same job can target different
3540/// groups on different cadences without copying the manifest.
3541///
3542/// #418 Phase 1: the cadence is the single [`When`] field. The old
3543/// `cron` × `mode` × `cooldown` × `auto_disable_when_done` quartet
3544/// is gone (no back-compat — pre-Phase-1 KV blobs fail to parse and
3545/// are warn-skipped; re-`schedule create` to upgrade them). The
3546/// engine underneath is unchanged: [`Schedule::lowered`] maps `when`
3547/// onto the same (cron, ExecMode, cooldown) trio the scheduler and
3548/// `decide_fire` always ran on.
3549#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone)]
3550pub struct Schedule {
3551    pub id: String,
3552    /// When to fire — a reconcile cadence (`per_pc` / `per_target`)
3553    /// or a calendar time trigger (`at` / `days`). See [`When`].
3554    ///
3555    /// `singleton_map`: serde_yaml 0.9 renders externally-tagged
3556    /// enums as `!per_pc` YAML tags by default; this keeps the
3557    /// operator-facing map shape (`when: { per_pc: once }`). JSON
3558    /// output is identical either way, and the schemars schema
3559    /// (external tagging = oneOf of single-key objects) already
3560    /// matches the singleton-map wire shape.
3561    #[serde(with = "serde_yaml::with::singleton_map")]
3562    #[schemars(with = "When")]
3563    pub when: When,
3564    /// Key into [`crate::kv::BUCKET_JOBS`]. Must equal a registered
3565    /// Manifest's `id`.
3566    pub job_id: String,
3567    /// Who + how-to-phase + when-to-stagger. The Manifest doesn't
3568    /// carry these any more — same job + different fanout = different
3569    /// schedule.
3570    #[serde(flatten)]
3571    pub plan: FanoutPlan,
3572    /// Optional validity window. Outside `[from, until)` the
3573    /// schedule is dormant — still registered, still visible, but
3574    /// every tick is skipped (deleted ≠ dormant: a campaign that
3575    /// ended stays inspectable and can be re-armed by editing the
3576    /// window). Checked at tick time on both the backend scheduler
3577    /// and the agent's local scheduler.
3578    #[serde(default, skip_serializing_if = "Active::is_empty")]
3579    pub active: Active,
3580    /// #418 operational constraints gating *when within an active
3581    /// period* a fire may happen: a maintenance `window`, a fleet
3582    /// `max_concurrent` cap, and `skip_dates` (holiday exclusion). The
3583    /// wall-clock ones are evaluated in the schedule's `tz`; future
3584    /// `require` (env gates) lands in the same namespace. Checked at
3585    /// tick time on both schedulers (and surfaced by `preview`).
3586    #[serde(default, skip_serializing_if = "Constraints::is_empty")]
3587    pub constraints: Constraints,
3588    /// #418 Phase 4: what to do after a fire's script comes back
3589    /// failed. Currently just `retry` (fixed-backoff in-process
3590    /// re-run); future `notify` / `disable` join the same namespace.
3591    /// Applied fire-side in `handle_command` (the retry policy is
3592    /// lowered onto every Command this schedule produces), so it
3593    /// covers both `runs_on` locations.
3594    #[serde(default, skip_serializing_if = "OnFailure::is_empty")]
3595    pub on_failure: OnFailure,
3596    /// #418 Phase 2: the timezone this schedule's wall-clock fields
3597    /// are evaluated in — both the calendar `at` firing time AND the
3598    /// `active.{from,until}` window bounds. `local` (default) = the
3599    /// running host's TZ (the agent's for `runs_on: agent`, the
3600    /// backend server's otherwise); `utc` for TZ-independent
3601    /// schedules. Reconcile shapes (`per_pc`/`per_target`) ignore it
3602    /// for firing (poll cron runs every minute regardless) but still
3603    /// honor it for the `active` window.
3604    #[serde(default)]
3605    pub tz: ScheduleTz,
3606    /// v0.22: optional humantime window after a cron tick during
3607    /// which the Command is still considered "live". The scheduler
3608    /// computes `tick_at + starting_deadline` and stamps it onto
3609    /// each Command as `deadline_at`; agents skip Commands they
3610    /// receive after that absolute time. `None` (default) = no
3611    /// deadline, meaning a Command queued in the broker / stream
3612    /// during agent downtime runs whenever the agent reconnects —
3613    /// good for kitting / inventory / cleanup. Set this for
3614    /// time-of-day notifications, lunch reminders, etc., where
3615    /// "fire 3 hours late" would be wrong.
3616    #[serde(default, skip_serializing_if = "Option::is_none")]
3617    pub starting_deadline: Option<String>,
3618    /// v0.23: where does the cron tick happen? `Backend` (default,
3619    /// historical) = backend's scheduler fires Commands via NATS;
3620    /// agents passively receive. `Agent` = each targeted agent runs
3621    /// its own internal cron and fires locally, so the schedule
3622    /// keeps ticking even when the broker is unreachable (laptop on
3623    /// the train, broker maintenance window, full WAN outage). The
3624    /// two locations are mutually exclusive — when `Agent`, the
3625    /// backend scheduler stays out and just keeps the definition in
3626    /// KV for agents to read.
3627    #[serde(default)]
3628    pub runs_on: RunsOn,
3629    #[serde(default = "default_true")]
3630    pub enabled: bool,
3631    /// Free-form operator taxonomy for the Schedules page — the
3632    /// schedule-side mirror of `Manifest.tags` (added in #640; a plain
3633    /// code ref rather than an intra-doc link, since that field isn't
3634    /// on this branch until #640 merges). Purely a SPA-side
3635    /// organisational aid (search / filter chips alongside the
3636    /// id-prefix grouping); the scheduler never reads it, so any
3637    /// string is allowed and it carries no firing semantics. A
3638    /// schedule's own tags are independent of its job's: the same job
3639    /// may back a `weekly` maintenance schedule and a `canary` rollout
3640    /// schedule. Empty by default and `skip_serializing_if`-elided per
3641    /// the #492 gradual-upgrade wire rule.
3642    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3643    pub tags: Vec<String>,
3644}
3645
3646/// v0.23 — where the cron tick fires from.
3647#[derive(
3648    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
3649)]
3650#[serde(rename_all = "snake_case")]
3651pub enum RunsOn {
3652    /// Backend's central scheduler ticks and publishes Commands to
3653    /// NATS. Historical default, what every pre-v0.23 schedule
3654    /// uses. Agent offline ⇒ Command queued in STREAM_EXEC; agent
3655    /// reconnects ⇒ catch-up via [`command_replay`](crate)
3656    /// (see kanade-agent's command_replay module).
3657    #[default]
3658    Backend,
3659    /// Each targeted agent runs the cron tick locally. Survives
3660    /// broker / WAN outages. Best for laptops / mobile devices that
3661    /// roam off the corporate network. Agent must be online for the
3662    /// initial schedule + job-catalog pull, but once cached the
3663    /// agent fires the script standalone.
3664    Agent,
3665}
3666
3667/// Per-pc/per-target dedup semantics for a [`Schedule`] (v0.19).
3668#[derive(
3669    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
3670)]
3671#[serde(rename_all = "snake_case")]
3672pub enum ExecMode {
3673    /// Fire on every cron tick at the whole target. Historical
3674    /// (pre-v0.19) behavior; no dedup.
3675    #[default]
3676    EveryTick,
3677    /// Fire at each pc until that pc succeeds; then skip it until
3678    /// the optional cooldown elapses (or forever if no cooldown).
3679    /// Use for kitting / first-boot / per-pc compliance checks.
3680    OncePerPc,
3681    /// Fire at the whole target until **any** pc succeeds; then
3682    /// skip the whole target until the optional cooldown elapses
3683    /// (or forever if no cooldown). Use for "one delegate is
3684    /// enough" tasks like license check-in.
3685    OncePerTarget,
3686    /// #418 OS-native event trigger (`when: { on: [...] }`). There is
3687    /// no cron — the agent fires it from an OS event source (boot /
3688    /// session-change), not a tick — so the scheduler skips
3689    /// `tokio-cron` registration for it. Each event occurrence fires
3690    /// once, gated by the standard freeze / active / window /
3691    /// skip_dates checks.
3692    Event,
3693}
3694
3695/// #418 Phase 1 — the single "when does this fire" axis.
3696///
3697/// Replaces the old `cron` + `mode` + `cooldown` trio whose
3698/// interactions were implicit (cron doubled as both a real
3699/// time-of-day trigger and a reconcile poll period; contradictory
3700/// combinations silently no-opped). Two shapes:
3701///
3702/// * **reconcile** (`per_pc` / `per_target`) — desired-state: "each
3703///   pc (or one delegate) should have run this within `every`".
3704///   The poll period is system-generated ([`POLL_CRON`], every
3705///   minute) and no longer the operator's concern.
3706/// * **calendar** (`{ at, days }`) — a wall-clock time trigger
3707///   (#418 Phase 2, replacing the old raw-cron escape hatch). Fires
3708///   the whole target at the given time, no dedup. `at: "09:00"` +
3709///   `days` repeats; `at: "2026-06-10 09:00"` (a date+time) fires
3710///   exactly once. Evaluated in the schedule's top-level `tz`.
3711#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
3712#[serde(rename_all = "snake_case")]
3713pub enum When {
3714    /// Fire at each targeted pc: `once` (kitting — succeed once,
3715    /// skip forever, forever catching brand-new / re-imaged pcs)
3716    /// or `{ every: <humantime> }` (patrol — re-arm per pc after
3717    /// the interval).
3718    PerPc(PerPolicy),
3719    /// Fire until **any** one pc of the target succeeds, then skip
3720    /// the whole target (`once`) or re-arm after `every`. Needs
3721    /// fleet-wide completion data, so it is backend-only —
3722    /// `runs_on: agent` + `per_target` is rejected by
3723    /// [`Schedule::validate`].
3724    PerTarget(PerPolicy),
3725    /// Calendar time trigger: `{ at: "09:00", days: [mon-fri] }`
3726    /// (repeating) or `{ at: "2026-06-10 09:00" }` (one-shot). Fires
3727    /// the whole target at that wall-clock time in the schedule's
3728    /// `tz` — no dedup, no cooldown.
3729    Calendar(CalendarSpec),
3730    /// #418 OS-native event trigger: `when: { on: [startup, logon] }`.
3731    /// Fires when the agent observes the listed OS event(s) rather than
3732    /// on a clock — there is no cron. `runs_on: agent` only (the agent
3733    /// owns the event source); [`Schedule::validate`] rejects it on
3734    /// `backend` and rejects an empty list. Each event occurrence fires
3735    /// once, gated by the same freeze / active / `constraints.window` /
3736    /// `skip_dates` checks as the cron path. `startup` fires once per OS
3737    /// boot (deduped via the host boot time); a `starting_deadline`, if
3738    /// set, limits it to "agent came up within that long after boot".
3739    On(Vec<OnTrigger>),
3740}
3741
3742/// An OS event the agent can fire a schedule on (#418 `when: { on }`).
3743#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Hash)]
3744#[serde(rename_all = "snake_case")]
3745pub enum OnTrigger {
3746    /// Once per OS boot (the agent's first run for that boot). Catches
3747    /// freshly-imaged / reinstalled hosts at their next startup.
3748    Startup,
3749    /// On an interactive-session user logon — console, RDP, or
3750    /// auto-logon (Windows `WTS_SESSION_LOGON`). Does not fire for
3751    /// service / network / batch logons (no interactive session).
3752    Logon,
3753    /// When the workstation is locked (Win+L / idle lock; Windows
3754    /// `WTS_SESSION_LOCK`). Use for step-away compliance / cleanup.
3755    Lock,
3756    /// When the workstation is unlocked — the user returns to a locked
3757    /// session (Windows `WTS_SESSION_UNLOCK`). Use to re-check
3758    /// compliance / refresh state when work resumes.
3759    Unlock,
3760    /// When the host's network changes — IP address table change on
3761    /// connect / disconnect / DHCP renew / VPN / Wi-Fi roam (Windows
3762    /// `NotifyAddrChange`). Debounced agent-side (a burst of changes
3763    /// from one transition fires once after the network settles), so
3764    /// use it for "re-check connectivity / re-register on network move"
3765    /// rather than expecting one fire per raw adapter event.
3766    ///
3767    /// IPv4 only: `NotifyAddrChange` watches the IPv4 address table, so a
3768    /// transition that touches only IPv6 addresses won't fire. In practice
3769    /// dual-stack networks change both tables together, but a pure-IPv6
3770    /// move (e.g. an IPv6-only Wi-Fi roam) is not detected.
3771    NetworkChange,
3772}
3773
3774/// Calendar time trigger (#418 Phase 2). `at` is either a time of
3775/// day (`"HH:MM"`, repeating — combine with `days`) or a full
3776/// date+time (`"YYYY-MM-DD HH:MM"`, a one-shot that fires once and
3777/// never again). Evaluated in the schedule's top-level `tz`.
3778#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
3779pub struct CalendarSpec {
3780    /// `"HH:MM"` (24h) for a repeating trigger, or
3781    /// `"YYYY-MM-DD HH:MM"` (hyphen / slash / `T` separators all
3782    /// accepted) for a one-shot. Parsed lazily —
3783    /// [`Schedule::validate`] rejects garbage at create time.
3784    pub at: String,
3785    /// Day-of-week filter for a time-of-day `at`: `["mon-fri"]`,
3786    /// `["mon","wed","fri"]`, … (passed verbatim to the cron DOW
3787    /// field, so ranges and names both work). An **nth-weekday**
3788    /// `["tue#2"]` fires only on the 2nd Tuesday of each month
3789    /// ("Patch Tuesday"); the ordinal is `1..5`. A **last-weekday**
3790    /// `["friL"]` fires only on the last Friday of each month (handy
3791    /// for monthly maintenance). Empty = every day. Must be empty
3792    /// when `at` carries a date (the date already pins the day).
3793    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3794    pub days: Vec<String>,
3795}
3796
3797/// Parsed `CalendarSpec.at`: the wall-clock minute/hour, plus the
3798/// date for a one-shot (`None` = repeating time-of-day).
3799struct ParsedAt {
3800    minute: u32,
3801    hour: u32,
3802    date: Option<chrono::NaiveDate>,
3803}
3804
3805impl CalendarSpec {
3806    /// Parse `at`: a date+time (`YYYY-MM-DD HH:MM`, hyphen / slash /
3807    /// `T` separators) is a one-shot; a bare `HH:MM` is repeating.
3808    fn parse_at(&self) -> Result<ParsedAt, String> {
3809        use chrono::Timelike;
3810        let s = self.at.trim();
3811        for fmt in ["%Y-%m-%d %H:%M", "%Y-%m-%dT%H:%M", "%Y/%m/%d %H:%M"] {
3812            if let Ok(dt) = chrono::NaiveDateTime::parse_from_str(s, fmt) {
3813                return Ok(ParsedAt {
3814                    minute: dt.minute(),
3815                    hour: dt.hour(),
3816                    date: Some(dt.date()),
3817                });
3818            }
3819        }
3820        if let Ok(t) = chrono::NaiveTime::parse_from_str(s, "%H:%M") {
3821            return Ok(ParsedAt {
3822                minute: t.minute(),
3823                hour: t.hour(),
3824                date: None,
3825            });
3826        }
3827        Err(format!(
3828            "when.at: unparseable '{}' (want HH:MM or YYYY-MM-DD HH:MM)",
3829            self.at
3830        ))
3831    }
3832
3833    /// Pre-flight check on the `days` tokens so a bad day name gives
3834    /// a `when.days:`-scoped error instead of croner's confusing
3835    /// "when.at lowered to invalid cron" (claude #432 review). Each
3836    /// token is a day name (`mon`..`sun`), a numeric DOW (`0`..`7`),
3837    /// `*`, a `-` range of those, an **nth-weekday** like `tue#2`
3838    /// (2nd Tuesday of the month — "Patch Tuesday"), or a
3839    /// **last-weekday** like `friL` (last Friday of the month).
3840    fn validate_days(&self) -> Result<(), String> {
3841        const NAMES: [&str; 7] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"];
3842        let is_day = |p: &str| NAMES.contains(&p) || p.parse::<u8>().is_ok_and(|n| n <= 7);
3843        for tok in &self.days {
3844            // Report the whole token on a malformed range like `mon-`
3845            // (which would otherwise split to a cryptic empty part —
3846            // claude #432 follow-up).
3847            let invalid = |reason: &str| {
3848                Err(format!(
3849                    "when.days: invalid day token '{tok}' ({reason}; \
3850                     want mon..sun, 0-7, a range like mon-fri, an nth-weekday \
3851                     like tue#2, a last-weekday like friL, or *)"
3852                ))
3853            };
3854            // #418: nth-weekday suffix (`tue#2` = 2nd Tuesday). Croner
3855            // accepts `<dow>#<n>` (n = 1..5) in the DOW field, and
3856            // `to_cron` passes the token through verbatim, so the
3857            // engine fires only on that occurrence. It's a single
3858            // weekday + ordinal — not combinable with a range.
3859            if let Some((day_part, nth_part)) = tok.split_once('#') {
3860                // Normalize once and use `d` consistently (gemini #547);
3861                // the outer `invalid` already echoes the raw `tok`.
3862                let d = day_part.trim().to_ascii_lowercase();
3863                if d.contains('-') || !is_day(&d) {
3864                    return invalid("the part before # must be a single weekday");
3865                }
3866                match nth_part.trim().parse::<u8>() {
3867                    Ok(n) if (1..=5).contains(&n) => {}
3868                    _ => return invalid("the # ordinal must be 1..5 (e.g. tue#2 = 2nd Tuesday)"),
3869                }
3870                continue;
3871            }
3872            // #418: last-weekday suffix (`friL` = last Friday of the
3873            // month — the monthly-maintenance sibling of Patch Tuesday).
3874            // Croner accepts `<dow>L` in the DOW field with verified
3875            // last-<dow>-of-month semantics, and `to_cron` passes it
3876            // through verbatim. A single weekday + `L` — bare `L` and
3877            // ranges are rejected (croner would read bare `L` as
3878            // Saturday, which is a confusing footgun).
3879            if let Some(day_part) = tok.strip_suffix(['L', 'l']) {
3880                // No `.trim()`: a cron DOW token can't carry internal
3881                // whitespace, so `"fri L"` must be *rejected* here (its
3882                // strip leaves `"fri "`, and `is_day` catches the space)
3883                // rather than trimmed into a clean `"fri"` that then
3884                // produces a malformed `fri L` cron downstream and a
3885                // confusing croner error (gemini #560).
3886                let d = day_part.to_ascii_lowercase();
3887                if d.is_empty() {
3888                    return invalid("`L` (last-weekday) needs a weekday before it, e.g. friL");
3889                }
3890                if d.contains('-') || !is_day(&d) {
3891                    return invalid(
3892                        "the part before L must be a single weekday (e.g. friL = last Friday)",
3893                    );
3894                }
3895                continue;
3896            }
3897            for part in tok.split('-') {
3898                let p = part.trim().to_ascii_lowercase();
3899                if p.is_empty() {
3900                    return invalid("empty range bound");
3901                }
3902                if p != "*" && !is_day(&p) {
3903                    return invalid(&format!("'{part}' is not a day"));
3904                }
3905            }
3906        }
3907        Ok(())
3908    }
3909
3910    /// For a one-shot (`at` carries a date), the absolute instant it
3911    /// fires in `tz`. `None` for a repeating calendar. Used to warn
3912    /// about a one-shot whose date is already in the past (it would
3913    /// never fire).
3914    pub fn oneshot_instant(&self, tz: ScheduleTz) -> Option<chrono::DateTime<chrono::Utc>> {
3915        let p = self.parse_at().ok()?;
3916        let date = p.date?;
3917        let naive = date.and_hms_opt(p.hour, p.minute, 0)?;
3918        tz.naive_to_utc(naive)
3919    }
3920
3921    /// The wall-clock time-of-day this calendar fires at (`None` if
3922    /// `at` is unparseable — validate() guards that). Used to detect
3923    /// a calendar whose fire time can never fall inside its
3924    /// `constraints.window` (claude #452 review).
3925    pub fn fire_time(&self) -> Option<chrono::NaiveTime> {
3926        let p = self.parse_at().ok()?;
3927        chrono::NaiveTime::from_hms_opt(p.hour, p.minute, 0)
3928    }
3929
3930    /// Lower to the cron string the scheduler engine runs. Repeating
3931    /// → 6-field `0 {min} {hour} * * {dow}`; one-shot → 7-field
3932    /// `0 {min} {hour} {day} {month} * {year}` (a past year never
3933    /// fires — that's what makes it one-shot).
3934    fn to_cron(&self) -> Result<String, String> {
3935        use chrono::Datelike;
3936        let ParsedAt { minute, hour, date } = self.parse_at()?;
3937        match date {
3938            Some(d) => {
3939                if !self.days.is_empty() {
3940                    return Err(
3941                        "when.at with a date is a one-shot and cannot be combined with days".into(),
3942                    );
3943                }
3944                Ok(format!(
3945                    "0 {minute} {hour} {} {} * {}",
3946                    d.day(),
3947                    d.month(),
3948                    d.year()
3949                ))
3950            }
3951            None => {
3952                let dow = if self.days.is_empty() {
3953                    "*".to_string()
3954                } else {
3955                    self.validate_days()?;
3956                    self.days.join(",")
3957                };
3958                Ok(format!("0 {minute} {hour} * * {dow}"))
3959            }
3960        }
3961    }
3962}
3963
3964/// The timezone a schedule's wall-clock fields (`when.at`,
3965/// `active.{from,until}`) are evaluated in (#418 Phase 2).
3966#[derive(
3967    Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq, Default,
3968)]
3969#[serde(rename_all = "snake_case")]
3970pub enum ScheduleTz {
3971    /// The running host's local timezone — the agent's for
3972    /// `runs_on: agent`, the backend server's otherwise. Default.
3973    #[default]
3974    Local,
3975    /// UTC — for timezone-independent schedules.
3976    Utc,
3977}
3978
3979impl ScheduleTz {
3980    /// Interpret a naive (zoneless) datetime as being in this tz and
3981    /// convert to UTC. On a DST *fold* (the local time occurs twice
3982    /// when clocks go back) we pick `.earliest()` rather than
3983    /// rejecting it; `None` is reserved for a true DST *gap* (a local
3984    /// time that never exists). `Utc` is fixed-offset so neither ever
3985    /// happens; `Local` is whatever timezone the running host is set
3986    /// to and *can* hit a gap/fold on any DST-observing host — not
3987    /// just the JST we run today (gemini + claude #432 review).
3988    fn naive_to_utc(self, naive: chrono::NaiveDateTime) -> Option<chrono::DateTime<chrono::Utc>> {
3989        use chrono::TimeZone;
3990        match self {
3991            ScheduleTz::Utc => Some(chrono::DateTime::from_naive_utc_and_offset(
3992                naive,
3993                chrono::Utc,
3994            )),
3995            ScheduleTz::Local => chrono::Local
3996                .from_local_datetime(&naive)
3997                .earliest()
3998                .map(|dt| dt.with_timezone(&chrono::Utc)),
3999        }
4000    }
4001
4002    /// The wall-clock time-of-day `now` reads as in this tz — used by
4003    /// [`Constraints::allows`] to test a maintenance window
4004    /// (#418 Phase 3). `Utc` is the naive UTC time; `Local` is the
4005    /// running host's local time.
4006    fn wall_time(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveTime {
4007        match self {
4008            ScheduleTz::Utc => now.time(),
4009            ScheduleTz::Local => now.with_timezone(&chrono::Local).time(),
4010        }
4011    }
4012
4013    /// The wall-clock *date* `now` reads as in this tz — used by
4014    /// [`Constraints::allows`] to test `skip_dates` (#418 holiday
4015    /// exclusion). Same tz semantics as [`Self::wall_time`].
4016    fn wall_date(self, now: chrono::DateTime<chrono::Utc>) -> chrono::NaiveDate {
4017        match self {
4018            ScheduleTz::Utc => now.date_naive(),
4019            ScheduleTz::Local => now.with_timezone(&chrono::Local).date_naive(),
4020        }
4021    }
4022
4023    /// Stable lowercase wire/display label (`local` / `utc`) — matches
4024    /// the serde `snake_case` representation. Used for the preview
4025    /// response's `tz` field so the JSON shape isn't coupled to the
4026    /// `Debug` repr (claude #578 review).
4027    pub fn as_str(self) -> &'static str {
4028        match self {
4029            ScheduleTz::Local => "local",
4030            ScheduleTz::Utc => "utc",
4031        }
4032    }
4033}
4034
4035impl std::fmt::Display for ScheduleTz {
4036    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4037        f.write_str(self.as_str())
4038    }
4039}
4040
4041/// `once` vs `{ every: <humantime> }` — shared by `per_pc` /
4042/// `per_target`. Untagged so the YAML stays the bare keyword or a
4043/// one-key map, nothing more ceremonial.
4044#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
4045#[serde(untagged)]
4046pub enum PerPolicy {
4047    /// The bare string `once`: succeed once, then skip permanently
4048    /// (cooldown = infinity).
4049    Once(OnceLiteral),
4050    /// Re-arm after the humantime interval, e.g. `{ every: 6h }`.
4051    Every(EverySpec),
4052}
4053
4054/// Single-variant enum so serde accepts exactly the string `once`
4055/// (a free-form `String` would swallow typos like `onec`).
4056#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Copy, PartialEq, Eq)]
4057#[serde(rename_all = "snake_case")]
4058pub enum OnceLiteral {
4059    Once,
4060}
4061
4062/// `{ every: <humantime> }`. Standalone struct (not an inline
4063/// struct variant). `{ evry: 6h }` still fails to parse (the
4064/// required `every` key is missing), and the create boundaries
4065/// reject the unknown `evry` via [`crate::strict`] with its path —
4066/// while agents reading a future writer's extra fields tolerate
4067/// them (#492).
4068#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
4069pub struct EverySpec {
4070    /// Humantime interval (`10m`, `6h`, `1d`...). Parsed lazily —
4071    /// [`Schedule::validate`] rejects garbage at create time.
4072    pub every: String,
4073}
4074
4075impl PerPolicy {
4076    /// The cooldown this policy lowers to: `once` = `None`
4077    /// (permanent skip), `every` = the interval.
4078    fn cooldown(&self) -> Option<String> {
4079        match self {
4080            PerPolicy::Once(_) => None,
4081            PerPolicy::Every(EverySpec { every }) => Some(every.clone()),
4082        }
4083    }
4084}
4085
4086impl std::fmt::Display for When {
4087    /// Operator-facing one-liner (`per_pc once` / `per_pc every 6h`
4088    /// / `at 09:00 [mon-fri]` / `at 2026-06-10 09:00`) for log
4089    /// lines, audit payloads and the API's `ScheduleSummary`.
4090    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
4091        let policy = |p: &PerPolicy| match p {
4092            PerPolicy::Once(_) => "once".to_string(),
4093            PerPolicy::Every(EverySpec { every }) => format!("every {every}"),
4094        };
4095        match self {
4096            When::PerPc(p) => write!(f, "per_pc {}", policy(p)),
4097            When::PerTarget(p) => write!(f, "per_target {}", policy(p)),
4098            When::Calendar(c) if c.days.is_empty() => write!(f, "at {}", c.at),
4099            When::Calendar(c) => write!(f, "at {} [{}]", c.at, c.days.join(",")),
4100            When::On(triggers) => {
4101                let names: Vec<&str> = triggers.iter().map(|t| t.as_str()).collect();
4102                write!(f, "on [{}]", names.join(","))
4103            }
4104        }
4105    }
4106}
4107
4108impl OnTrigger {
4109    /// Lowercase wire/display label (matches the serde `snake_case`).
4110    pub fn as_str(self) -> &'static str {
4111        match self {
4112            OnTrigger::Startup => "startup",
4113            OnTrigger::Logon => "logon",
4114            OnTrigger::Lock => "lock",
4115            OnTrigger::Unlock => "unlock",
4116            OnTrigger::NetworkChange => "network_change",
4117        }
4118    }
4119}
4120
4121/// Optional validity window for a [`Schedule`] (#418 decision G).
4122/// Half-open `[from, until)`; either bound may be omitted. Bounds
4123/// are `YYYY-MM-DD` (= that day's 00:00 in the schedule's `tz`) or
4124/// full RFC3339 (offset is honored as-is, `tz` ignored). Kept as
4125/// strings so the JSON Schema the SPA editor consumes stays two
4126/// plain string fields, mirroring `jitter` / `starting_deadline`.
4127///
4128/// #418 Phase 2: bounds are evaluated in the schedule's top-level
4129/// `tz` (was UTC-only in Phase 1) so `tz: local` makes both the
4130/// calendar `at` AND the `active` window local — one consistent
4131/// timezone per schedule.
4132#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
4133pub struct Active {
4134    /// Dormant before this instant.
4135    #[serde(default, skip_serializing_if = "Option::is_none")]
4136    pub from: Option<String>,
4137    /// Dormant from this instant on (exclusive).
4138    #[serde(default, skip_serializing_if = "Option::is_none")]
4139    pub until: Option<String>,
4140}
4141
4142impl Active {
4143    /// `skip_serializing_if` helper — an empty window means "always
4144    /// active" and is omitted from the wire format entirely.
4145    pub fn is_empty(&self) -> bool {
4146        self.from.is_none() && self.until.is_none()
4147    }
4148
4149    /// Parse one bound: RFC3339 first (offset honored, `tz`
4150    /// ignored), then bare `YYYY-MM-DD` (00:00 in `tz`).
4151    pub fn parse_bound(s: &str, tz: ScheduleTz) -> Result<chrono::DateTime<chrono::Utc>, String> {
4152        if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(s) {
4153            return Ok(dt.with_timezone(&chrono::Utc));
4154        }
4155        if let Ok(d) = chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d") {
4156            let midnight = d.and_hms_opt(0, 0, 0).expect("00:00:00 is always valid");
4157            return tz.naive_to_utc(midnight).ok_or_else(|| {
4158                format!("active: bound '{s}' falls in a DST gap for the schedule's tz")
4159            });
4160        }
4161        Err(format!(
4162            "active: unparseable bound '{s}' (want YYYY-MM-DD or RFC3339)"
4163        ))
4164    }
4165
4166    /// Is `now` inside the window? Unparseable bounds are treated
4167    /// as absent here (fail-open) — [`Schedule::validate`] is the
4168    /// place that rejects them loudly; this runs on every tick and
4169    /// must never panic on a stale KV blob.
4170    pub fn contains(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
4171        let bound = |s: &Option<String>| s.as_deref().and_then(|s| Self::parse_bound(s, tz).ok());
4172        if bound(&self.from).is_some_and(|from| now < from) {
4173            return false;
4174        }
4175        if bound(&self.until).is_some_and(|until| now >= until) {
4176            return false;
4177        }
4178        true
4179    }
4180}
4181
4182/// Host-environment gate (#418 `constraints.require`). Fire only when
4183/// the target host is in the required state. Sensed **in-process by the
4184/// agent** (Win32), so it is `runs_on: agent` only — the backend cannot
4185/// read a target host's power/idle state ([`Schedule::validate`]
4186/// rejects it on `runs_on: backend`, symmetric with `when: { on }`).
4187///
4188/// Evaluated at fire time as a skip-this-tick gate (NOT in
4189/// [`Constraints::allows`], which stays pure for `preview`): a reconcile
4190/// cadence re-checks every minute (so it effectively defers until the
4191/// state is met — the intended pairing); a `calendar` fire that lands
4192/// while the state is unmet is simply missed, same as `window`. It is
4193/// therefore a *runtime* gate and does not appear in `preview`.
4194// No `Eq`: `cpu_below: Option<f64>` is only `PartialEq` (f64 is not Eq).
4195#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq)]
4196pub struct Require {
4197    /// Fire only while on **AC power** (skip on battery). Reads
4198    /// `GetSystemPowerStatus`; an unknown/unreadable status is treated
4199    /// as not-on-AC (fail-closed — a restrictive gate must not fire
4200    /// when it can't confirm the condition). `false` (default) = no
4201    /// power requirement.
4202    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
4203    pub ac_power: bool,
4204    /// Fire only when the active console session has had **no keyboard /
4205    /// mouse input for at least this long** (humantime, e.g. `"10m"`) —
4206    /// "don't run while the user is actively working". Input-based
4207    /// (simpler than Task Scheduler's CPU/disk-aware idle). A
4208    /// headless / disconnected console (no interactive user) trivially
4209    /// satisfies it. `None` (default) = no idle requirement. Parsed
4210    /// lazily; [`Schedule::validate`] rejects garbage at create time.
4211    #[serde(default, skip_serializing_if = "Option::is_none")]
4212    pub idle: Option<String>,
4213    /// Fire only when the **whole-machine CPU usage is below this
4214    /// percent** (0–100; e.g. `20.0` = "system CPU < 20%") — "don't run
4215    /// while the box is busy". Reuses the agent's `host_perf` system CPU%
4216    /// sample (`sysinfo` mean over cores), so the reading is up to one
4217    /// `host_perf` cadence old (default 60s) — fine as a "generally
4218    /// busy?" proxy, and more accurate than a fresh one-shot read (CPU%
4219    /// needs two samples). An unavailable sample (host_perf not warmed
4220    /// up yet, or stale) is treated as "not below" (fail-closed — a
4221    /// restrictive gate must not fire when it can't confirm). `None`
4222    /// (default) = no CPU requirement. [`Schedule::validate`] rejects an
4223    /// out-of-range value at create time.
4224    #[serde(default, skip_serializing_if = "Option::is_none")]
4225    pub cpu_below: Option<f64>,
4226    /// Fire only when the host has **internet connectivity** (Windows
4227    /// `GetNetworkConnectivityHint` reports InternetAccess) — "don't run
4228    /// until online" for jobs that download / phone home. A captive
4229    /// portal (ConstrainedInternetAccess), LAN-only (LocalAccess), or
4230    /// unknown/unreadable state is treated as offline (fail-closed) — a
4231    /// portal would just fail a download, so we hold the run. For VPN /
4232    /// SASE / app-specific conditions, use a custom script gate (separate
4233    /// slice). `false` (default) = no network requirement.
4234    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
4235    pub network: bool,
4236}
4237
4238impl Require {
4239    /// `skip_serializing_if` helper for an embedded empty `require`.
4240    pub fn is_empty(&self) -> bool {
4241        !self.ac_power && self.idle.is_none() && self.cpu_below.is_none() && !self.network
4242    }
4243
4244    /// Parsed minimum-idle duration (`None` = no idle requirement, or an
4245    /// unparseable value — `validate` rejects the latter at create time).
4246    pub fn min_idle(&self) -> Option<std::time::Duration> {
4247        self.idle
4248            .as_deref()
4249            .and_then(|s| humantime::parse_duration(s.trim()).ok())
4250    }
4251
4252    /// First unparseable field for create-time rejection (mirrors
4253    /// [`Constraints::bad_skip_date`]).
4254    pub fn bad_idle(&self) -> Option<String> {
4255        self.idle.as_deref().and_then(|s| {
4256            humantime::parse_duration(s.trim())
4257                .err()
4258                .map(|e| format!("constraints.require.idle: invalid duration '{s}': {e}"))
4259        })
4260    }
4261}
4262
4263/// Host-environment state sensed by the agent, fed to [`require_met`].
4264/// A named struct (not positional args) so the growing set of sensed
4265/// signals — several of them `bool` — can't be transposed at a call
4266/// site. The Win32 sensing lives in `kanade-agent::env_gate`.
4267#[derive(Debug, Clone, Copy, Default)]
4268pub struct EnvState {
4269    /// Is the host on AC power (`false` if on battery or unreadable).
4270    pub ac_online: bool,
4271    /// How long the console has been idle (`None` = couldn't determine).
4272    pub idle: Option<std::time::Duration>,
4273    /// Whole-machine CPU usage 0–100 (`None` = no sample yet).
4274    pub cpu_pct: Option<f64>,
4275    /// Does the host have internet connectivity (`false` if offline /
4276    /// LAN-only / unreadable).
4277    pub network_up: bool,
4278}
4279
4280/// Pure env-gate decision (#418 `constraints.require`). The Win32
4281/// sensing lives in the agent (`kanade-agent::env_gate`); this is the
4282/// testable core, fed the already-sensed [`EnvState`]. Deliberately a
4283/// free fn (not folded into [`Constraints::allows`]) so `allows` stays
4284/// pure and `preview` never evaluates a runtime gate. Each set
4285/// requirement is a restrictive AND: any unmet (or unknown) gate skips.
4286pub fn require_met(req: &Require, env: &EnvState) -> bool {
4287    if req.ac_power && !env.ac_online {
4288        return false;
4289    }
4290    if let Some(min) = req.min_idle() {
4291        match env.idle {
4292            Some(d) if d >= min => {}
4293            _ => return false,
4294        }
4295    }
4296    if let Some(max) = req.cpu_below {
4297        match env.cpu_pct {
4298            Some(p) if p < max => {}
4299            _ => return false,
4300        }
4301    }
4302    if req.network && !env.network_up {
4303        return false;
4304    }
4305    true
4306}
4307
4308/// [`Active`] decides *over what date range* a schedule is live,
4309/// `Constraints` decides *when, within an active period,* a fire is
4310/// allowed: `window` (a maintenance time-of-day window),
4311/// `max_concurrent` (a fleet-wide running-instance cap), `skip_dates`
4312/// (holiday exclusion) and `require` (host-environment gates, agent-only
4313/// — see [`Require`]).
4314// No `Eq`: contains `require: Option<Require>` which holds an f64.
4315#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq)]
4316pub struct Constraints {
4317    /// `"HH:MM-HH:MM"` wall-clock window (evaluated in the schedule's
4318    /// `tz`). Fires outside it are skipped — mainly for reconcile
4319    /// cadences ("patrol every 6h, but only fire overnight") and
4320    /// daytime change-freezes. `start > end` crosses midnight
4321    /// (`"22:00-05:00"` = 22:00 through 05:00 next morning). Parsed
4322    /// lazily; [`Schedule::validate`] rejects garbage at create time.
4323    #[serde(default, skip_serializing_if = "Option::is_none")]
4324    pub window: Option<String>,
4325    /// Fleet-wide cap on how many instances of this schedule's job may
4326    /// run **at the same time** (#418 "同時実行ハード上限"). The
4327    /// backend scheduler counts the job's still-in-flight runs
4328    /// (`execution_results.finished_at IS NULL`) each tick and only
4329    /// dispatches to as many remaining pcs as there are free slots —
4330    /// a rolling window that refills as runs complete. Useful for
4331    /// disk/CPU/network-heavy jobs you don't want hammering the whole
4332    /// fleet at once.
4333    ///
4334    /// **Backend-only** (it needs a central counter): combining it
4335    /// with `runs_on: agent` is rejected by [`Schedule::validate`]
4336    /// (#418 decision E — "中央上限には中央が要る"). Most meaningful
4337    /// for `per_pc` reconcile cadences, where the poll re-ticks and
4338    /// refills slots. `None` (default) = no cap.
4339    #[serde(default, skip_serializing_if = "Option::is_none")]
4340    pub max_concurrent: Option<u32>,
4341    /// Calendar dates the schedule must **not** fire on — holidays,
4342    /// blackout days, one-off freeze dates (#418 "祝日除外"). Each is
4343    /// `YYYY-MM-DD`, evaluated as a wall-clock date in the schedule's
4344    /// `tz`. Applies to every `when` shape (a reconcile cadence skips
4345    /// the whole day; a calendar fire landing on the date is
4346    /// suppressed) and is honored by both the live scheduler and
4347    /// `preview`, since both gate on [`Constraints::allows`]. Empty
4348    /// (default) = no skips. Operator-supplied: there is no built-in
4349    /// holiday calendar — list the dates you care about. Parsed lazily;
4350    /// [`Schedule::validate`] rejects a malformed date at create time.
4351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
4352    pub skip_dates: Vec<String>,
4353    /// Host-environment gate (#418): fire only when the target host is
4354    /// in the required state (on AC power, idle). Agent-sensed at fire
4355    /// time, `runs_on: agent` only. See [`Require`]. `None` (default) =
4356    /// no environment requirement.
4357    #[serde(default, skip_serializing_if = "Option::is_none")]
4358    pub require: Option<Require>,
4359}
4360
4361impl Constraints {
4362    /// `skip_serializing_if` helper — empty constraints are omitted
4363    /// from the wire format entirely.
4364    pub fn is_empty(&self) -> bool {
4365        self.window.is_none()
4366            && self.max_concurrent.is_none()
4367            && self.skip_dates.is_empty()
4368            && self.require.as_ref().is_none_or(Require::is_empty)
4369    }
4370
4371    /// The first unparseable `skip_dates` entry, if any — the
4372    /// scheduler logs it at register time so a fail-closed
4373    /// (never-firing) schedule from a hand-edited KV blob is
4374    /// diagnosable, mirroring [`Schedule::bad_window`].
4375    pub fn bad_skip_date(&self) -> Option<String> {
4376        self.skip_dates.iter().find_map(|s| {
4377            chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d")
4378                .err()
4379                .map(|e| format!("constraints.skip_dates: invalid date '{s}': {e}"))
4380        })
4381    }
4382
4383    /// Parse `"HH:MM-HH:MM"` into `(start, end)`. Equal bounds are an
4384    /// error (a zero-width or all-day window is ambiguous — write no
4385    /// window for "always").
4386    pub fn parse_window(s: &str) -> Result<(chrono::NaiveTime, chrono::NaiveTime), String> {
4387        let (a, b) = s
4388            .split_once('-')
4389            .ok_or_else(|| format!("constraints.window: '{s}' must be 'HH:MM-HH:MM'"))?;
4390        let parse = |part: &str| {
4391            chrono::NaiveTime::parse_from_str(part.trim(), "%H:%M")
4392                .map_err(|e| format!("constraints.window: invalid time '{}': {e}", part.trim()))
4393        };
4394        let (start, end) = (parse(a)?, parse(b)?);
4395        if start == end {
4396            return Err(format!(
4397                "constraints.window: start and end are equal ('{s}'); omit window for 'always'"
4398            ));
4399        }
4400        Ok((start, end))
4401    }
4402
4403    /// Is a fire allowed at `now` (evaluated in `tz`)? No window =
4404    /// always allowed. Half-open `[start, end)`; `start > end`
4405    /// crosses midnight.
4406    ///
4407    /// **Fail-closed** on an unparseable window (returns `false`,
4408    /// gemini #452 review): a window is a *restrictive* constraint
4409    /// (change-freeze / overnight-only), so a corrupt one must NOT
4410    /// silently allow fires during the restricted hours. Bad windows
4411    /// are rejected at create time by [`Schedule::validate`]; this
4412    /// only bites a hand-edited KV blob, where blocking is the safe
4413    /// direction. The scheduler warns at register time
4414    /// ([`Schedule::bad_window`]) so a stuck schedule is diagnosable.
4415    /// The tick path never panics regardless.
4416    pub fn allows(&self, now: chrono::DateTime<chrono::Utc>, tz: ScheduleTz) -> bool {
4417        // #418 holiday / blackout dates: never fire on a listed wall
4418        // date (in `tz`). Checked before the window since a skipped day
4419        // overrides any within-window allowance. Fail-closed on a
4420        // corrupt entry (same posture as `window`): a skip date is a
4421        // *restrictive* constraint, so a garbled one must not silently
4422        // re-enable fires — it blocks until fixed (`validate` rejects it
4423        // at create time; `bad_skip_date` lets the scheduler warn).
4424        if !self.skip_dates.is_empty() {
4425            let today = tz.wall_date(now);
4426            let blocked = self.skip_dates.iter().any(|s| {
4427                match chrono::NaiveDate::parse_from_str(s.trim(), "%Y-%m-%d") {
4428                    Ok(d) => d == today,
4429                    Err(_) => true, // corrupt entry → fail-closed (block)
4430                }
4431            });
4432            if blocked {
4433                return false;
4434            }
4435        }
4436        match self.window.as_deref() {
4437            // No window → always allowed.
4438            None => true,
4439            // Window set: membership, or fail-closed if unparseable
4440            // (`window_contains` returns None for a corrupt window).
4441            Some(_) => self.window_contains(tz.wall_time(now)).unwrap_or(false),
4442        }
4443    }
4444
4445    /// Membership of a wall-clock time-of-day in the window. `None`
4446    /// when there is no window or it's unparseable (callers decide
4447    /// the failure direction). `start > end` crosses midnight.
4448    fn window_contains(&self, t: chrono::NaiveTime) -> Option<bool> {
4449        let (start, end) = Self::parse_window(self.window.as_deref()?).ok()?;
4450        Some(if start <= end {
4451            start <= t && t < end
4452        } else {
4453            t >= start || t < end
4454        })
4455    }
4456}
4457
4458/// What to do when a fire's script fails (#418 Phase 4 — the "高"
4459/// retry/backoff gap). Where [`Constraints`] gates *whether* a fire
4460/// happens, `OnFailure` decides what happens *after* one ran and
4461/// came back bad. Only `retry` so far; future `notify` / `disable`
4462/// would join the same namespace.
4463#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
4464pub struct OnFailure {
4465    /// Re-run the script in-process when it exits non-zero (or times
4466    /// out), up to a cap, with a fixed backoff between attempts.
4467    /// `None` (default) = no retry: a failed run is published as-is
4468    /// and (for reconcile cadences) simply re-fires on the next poll
4469    /// tick. See [`Retry`].
4470    #[serde(default, skip_serializing_if = "Option::is_none")]
4471    pub retry: Option<Retry>,
4472}
4473
4474impl OnFailure {
4475    /// `skip_serializing_if` helper — an empty policy is omitted from
4476    /// the wire format entirely.
4477    pub fn is_empty(&self) -> bool {
4478        self.retry.is_none()
4479    }
4480
4481    /// Lower the operator-facing `retry` (humantime backoff) onto the
4482    /// engine vocabulary the agent's executor runs on (backoff in
4483    /// whole seconds). Single seam shared by the backend command
4484    /// builder and the agent's local scheduler so the two stamp the
4485    /// same [`crate::wire::RetrySpec`] onto every Command. Returns
4486    /// `None` when there is no retry policy or the backoff is
4487    /// unparseable (validate() rejects the latter at create time;
4488    /// this stays fail-safe = "no retry" for a hand-edited KV blob
4489    /// rather than panicking on the fire path).
4490    pub fn lowered_retry(&self) -> Option<crate::wire::RetrySpec> {
4491        let r = self.retry.as_ref()?;
4492        let backoff_secs = humantime::parse_duration(&r.backoff).ok()?.as_secs();
4493        Some(crate::wire::RetrySpec {
4494            max: r.max,
4495            backoff_secs,
4496        })
4497    }
4498}
4499
4500/// Fixed-backoff retry policy (#418 Phase 4). `max` is the number of
4501/// *additional* attempts after the first run (so `max: 3` = up to 4
4502/// total executions); `backoff` is the humantime delay slept between
4503/// attempts. The retry happens fire-side (inside `kanade fire` /
4504/// `handle_command`) on every OS for the PoC — the Windows-native
4505/// "restart on failure" Task Scheduler path is deferred to the
4506/// native-delegation phase (#418 decision H).
4507#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, PartialEq, Eq)]
4508pub struct Retry {
4509    /// Max additional attempts after the first failure. Bounded
4510    /// `1..=10` by [`Schedule::validate`] — a typo'd `max: 1000`
4511    /// with a short backoff would otherwise pin a flapping script in
4512    /// a tight loop for the whole window.
4513    pub max: u32,
4514    /// Humantime delay slept between attempts (`"10m"`, `"30s"`).
4515    pub backoff: String,
4516}
4517
4518/// Fleet-wide change-freeze (#418 Phase 5 — the "メンテナンス窓 /
4519/// 変更凍結" gap's global half). Where [`Constraints::window`] is a
4520/// *per-schedule* time-of-day gate, a `Freeze` is a *single, fleet-
4521/// global* "stop all automated change" switch the operator flips
4522/// during an incident or a year-end change-freeze. It lives in its
4523/// own KV singleton ([`crate::kv::KEY_FREEZE`]); when present and
4524/// active, both the backend scheduler and every agent's local
4525/// scheduler skip *every* fire.
4526///
4527/// Shapes:
4528/// * `{}` (no bounds) — frozen indefinitely until the operator
4529///   clears it (incident "big red button").
4530/// * `{ from, until }` — frozen only within `[from, until)`,
4531///   evaluated in `tz` (planned change-freeze; auto-thaws).
4532///
4533/// The KV key being *absent* means "not frozen" — so clearing the
4534/// freeze is a KV delete, and `is_active` only ever runs on a freeze
4535/// the operator actually set.
4536#[derive(Serialize, Deserialize, schemars::JsonSchema, Debug, Clone, Default, PartialEq, Eq)]
4537pub struct Freeze {
4538    /// Frozen from this instant (RFC3339 or bare `YYYY-MM-DD` in
4539    /// `tz`). `None` ⇒ frozen from the beginning of time.
4540    #[serde(default, skip_serializing_if = "Option::is_none")]
4541    pub from: Option<String>,
4542    /// Thawed from this instant on, exclusive. `None` ⇒ frozen with
4543    /// no scheduled end (manual clear required).
4544    #[serde(default, skip_serializing_if = "Option::is_none")]
4545    pub until: Option<String>,
4546    /// Operator-supplied note surfaced on the freeze-skip log and the
4547    /// SPA banner ("year-end change freeze", "INC-1234"). Advisory.
4548    #[serde(default, skip_serializing_if = "Option::is_none")]
4549    pub reason: Option<String>,
4550    /// Timezone the bare-date bounds are evaluated in (RFC3339 bounds
4551    /// carry their own offset). Defaults to host-local like a
4552    /// schedule's `tz`.
4553    #[serde(default)]
4554    pub tz: ScheduleTz,
4555}
4556
4557impl Freeze {
4558    /// Is the fleet frozen at `now`? An empty window (`from`/`until`
4559    /// both absent) is frozen unconditionally; otherwise membership of
4560    /// `[from, until)` in `tz`. Half-open like [`Active::contains`],
4561    /// but **fails CLOSED** on an unparseable bound — a freeze is a
4562    /// safety switch, so a corrupt window (only reachable via a
4563    /// hand-edited KV blob; `validate` rejects it at set time) must
4564    /// mean "frozen", not "fire normally" (coderabbit #472). This is
4565    /// the one deliberate divergence from `active`'s fail-OPEN
4566    /// behaviour, where an unparseable bound dormant-skips a schedule.
4567    pub fn is_active(&self, now: chrono::DateTime<chrono::Utc>) -> bool {
4568        // Parse a bound; an unparseable one short-circuits the whole
4569        // check to `true` (frozen) via the closure's `None` sentinel
4570        // handled below.
4571        let bound = |s: &Option<String>| -> Result<Option<chrono::DateTime<chrono::Utc>>, ()> {
4572            match s.as_deref() {
4573                None => Ok(None),
4574                Some(raw) => Active::parse_bound(raw, self.tz).map(Some).map_err(|_| ()),
4575            }
4576        };
4577        let (from, until) = match (bound(&self.from), bound(&self.until)) {
4578            (Ok(f), Ok(u)) => (f, u),
4579            // Any corrupt bound → fail closed (frozen).
4580            _ => return true,
4581        };
4582        if from.is_some_and(|f| now < f) {
4583            return false;
4584        }
4585        if until.is_some_and(|u| now >= u) {
4586            return false;
4587        }
4588        true
4589    }
4590
4591    /// Reject unparseable bounds / `from >= until` at set time (the
4592    /// API + CLI counterpart to [`Schedule::validate`]).
4593    pub fn validate(&self) -> Result<(), String> {
4594        let from = self
4595            .from
4596            .as_deref()
4597            .map(|s| Active::parse_bound(s, self.tz))
4598            .transpose()
4599            .map_err(|e| e.replace("active:", "freeze:"))?;
4600        let until = self
4601            .until
4602            .as_deref()
4603            .map(|s| Active::parse_bound(s, self.tz))
4604            .transpose()
4605            .map_err(|e| e.replace("active:", "freeze:"))?;
4606        if let (Some(f), Some(u)) = (from, until) {
4607            if f >= u {
4608                return Err(format!(
4609                    "freeze.from ({}) must be strictly before freeze.until ({})",
4610                    self.from.as_deref().unwrap_or_default(),
4611                    self.until.as_deref().unwrap_or_default(),
4612                ));
4613            }
4614        }
4615        Ok(())
4616    }
4617}
4618
4619/// The system-generated poll cadence every reconcile-shaped `when`
4620/// lowers to. Operators never write this: the real inter-run
4621/// spacing is the `every` cooldown; this only bounds "how soon do
4622/// we notice somebody is due" (#418 decision B took the poll
4623/// period away from the operator).
4624pub const POLL_CRON: &str = "0 * * * * *";
4625
4626/// What a [`When`] lowers to — the exact (cron, mode, cooldown)
4627/// trio the pre-#418 engine ran on. Keeping the engine vocabulary
4628/// unchanged is what lets Phase 1 swap the operator surface without
4629/// touching the tick / dedup machinery.
4630pub struct Lowered {
4631    /// Cron handed to `tokio-cron-scheduler` — [`POLL_CRON`] for
4632    /// reconcile shapes, a 6/7-field cron for calendar shapes.
4633    pub cron: String,
4634    /// Dedup semantics for `decide_fire`.
4635    pub mode: ExecMode,
4636    /// Humantime re-arm interval (`None` = succeed once, skip
4637    /// forever).
4638    pub cooldown: Option<String>,
4639    /// Timezone to evaluate `cron` in (#418 Phase 2). The scheduler
4640    /// passes this to `Job::new_async_tz`. Reconcile shapes carry
4641    /// the schedule's tz too even though POLL_CRON is tz-agnostic,
4642    /// so the same value drives the `active`-window check.
4643    pub tz: ScheduleTz,
4644}
4645
4646impl Schedule {
4647    /// The error message if this schedule's `constraints.window` is
4648    /// set but unparseable, else `None`. The scheduler logs this at
4649    /// register time so a fail-closed (never-firing) schedule from a
4650    /// hand-edited KV blob is diagnosable (gemini #452 review).
4651    pub fn bad_window(&self) -> Option<String> {
4652        let w = self.constraints.window.as_deref()?;
4653        Constraints::parse_window(w).err()
4654    }
4655
4656    /// True when this is a `calendar` schedule whose fire time can
4657    /// never fall inside its `constraints.window` — the cron fires,
4658    /// the window check rejects it, and (firing only at that
4659    /// time-of-day) it effectively never runs. An easy misconfig to
4660    /// set up by accident; the scheduler warns at register time
4661    /// (claude #452 review). Reconcile shapes poll every minute, so
4662    /// they always catch the window opening and aren't affected.
4663    pub fn calendar_outside_window(&self) -> bool {
4664        let When::Calendar(c) = &self.when else {
4665            return false;
4666        };
4667        let Some(t) = c.fire_time() else {
4668            return false;
4669        };
4670        matches!(self.constraints.window_contains(t), Some(false))
4671    }
4672
4673    /// Up to `count` future instants this schedule will fire, as
4674    /// absolute UTC, strictly after `now` — the dry-run / preview
4675    /// surface (#418 "ドライラン / プレビュー"). Only **calendar**
4676    /// schedules have discrete fire times; reconcile shapes
4677    /// (`per_pc`/`per_target`) poll every minute gated by cooldown, so
4678    /// they return an empty vec and the caller describes the cadence
4679    /// instead. Occurrences outside the `active.{from,until}` window or
4680    /// the `constraints.window` are **skipped**, so the list reflects
4681    /// when the schedule will ACTUALLY run, not the raw cron ticks.
4682    /// Evaluated in the schedule's `tz`, exactly like the scheduler's
4683    /// `Job::new_async_tz`, and with the same croner config the
4684    /// scheduler / [`Schedule::validate`] use, so a preview can never
4685    /// disagree with a real fire. A schedule that can never fire (a
4686    /// calendar time wholly outside its window, a past one-shot,
4687    /// `enabled: false` is *not* considered here — callers gate on
4688    /// `enabled` separately) yields an empty vec.
4689    pub fn preview_fires(
4690        &self,
4691        now: chrono::DateTime<chrono::Utc>,
4692        count: usize,
4693    ) -> Vec<chrono::DateTime<chrono::Utc>> {
4694        use croner::parser::{CronParser, Seconds};
4695        if !matches!(self.when, When::Calendar(_)) {
4696            return Vec::new();
4697        }
4698        // Same lowering + croner config as `next_calendar_fire` and the
4699        // live scheduler, so a preview can never disagree with a real
4700        // fire. `preview_fires` adds the N-occurrence walk and the
4701        // active / window filtering on top of that single seam.
4702        let lowered = self.lowered();
4703        let Ok(cron) = CronParser::builder()
4704            .seconds(Seconds::Required)
4705            .dom_and_dow(true)
4706            .build()
4707            .parse(&lowered.cron)
4708        else {
4709            return Vec::new();
4710        };
4711        let accept = |utc: chrono::DateTime<chrono::Utc>| {
4712            self.active.contains(utc, self.tz) && self.constraints.allows(utc, self.tz)
4713        };
4714        match self.tz {
4715            ScheduleTz::Utc => Self::next_occurrences(&cron, now, count, accept),
4716            ScheduleTz::Local => {
4717                Self::next_occurrences(&cron, now.with_timezone(&chrono::Local), count, accept)
4718            }
4719        }
4720    }
4721
4722    /// Walk croner forward from `after` collecting up to `count`
4723    /// accepted occurrences (converted to UTC). Generic over the tz the
4724    /// cron is evaluated in so `preview_fires` can run it in either
4725    /// `Utc` or `Local` without duplicating the loop.
4726    fn next_occurrences<Tz>(
4727        cron: &croner::Cron,
4728        after: chrono::DateTime<Tz>,
4729        count: usize,
4730        accept: impl Fn(chrono::DateTime<chrono::Utc>) -> bool,
4731    ) -> Vec<chrono::DateTime<chrono::Utc>>
4732    where
4733        Tz: chrono::TimeZone,
4734    {
4735        // Bound the scan so an `active`/window dead-end (every future
4736        // tick rejected) can't spin forever: ~4096 raw ticks covers
4737        // >10y of a daily calendar while staying instant for croner.
4738        const SCAN_CAP: usize = 4096;
4739        let mut out = Vec::with_capacity(count.min(SCAN_CAP));
4740        let mut cursor = after;
4741        let mut scanned = 0usize;
4742        while out.len() < count && scanned < SCAN_CAP {
4743            scanned += 1;
4744            let Ok(next) = cron.find_next_occurrence(&cursor, false) else {
4745                break;
4746            };
4747            let utc = next.with_timezone(&chrono::Utc);
4748            if accept(utc) {
4749                out.push(utc);
4750            }
4751            // `find_next_occurrence(.., inclusive = false)` already
4752            // advances strictly past `cursor`, so handing it `next`
4753            // verbatim gets the following occurrence — no manual +1s
4754            // nudge (and `DateTime<Tz>` is `Copy`, so no clone).
4755            cursor = next;
4756        }
4757        out
4758    }
4759
4760    /// Lower the operator-facing `when` onto the engine vocabulary.
4761    /// Single seam shared by the backend scheduler and the agent's
4762    /// local scheduler so the two can never drift.
4763    pub fn lowered(&self) -> Lowered {
4764        let tz = self.tz;
4765        match &self.when {
4766            When::PerPc(p) => Lowered {
4767                cron: POLL_CRON.into(),
4768                mode: ExecMode::OncePerPc,
4769                cooldown: p.cooldown(),
4770                tz,
4771            },
4772            When::PerTarget(p) => Lowered {
4773                cron: POLL_CRON.into(),
4774                mode: ExecMode::OncePerTarget,
4775                cooldown: p.cooldown(),
4776                tz,
4777            },
4778            // `to_cron` only fails on a malformed `at` (rejected by
4779            // validate() at create time). For a hand-edited KV blob
4780            // that slipped past, emit a deliberately-invalid cron so
4781            // register()'s Job::new_async_tz fails → warn+skip,
4782            // rather than firing at the wrong time.
4783            When::Calendar(c) => Lowered {
4784                cron: c
4785                    .to_cron()
4786                    .unwrap_or_else(|_| "# invalid calendar at".into()),
4787                mode: ExecMode::EveryTick,
4788                cooldown: None,
4789                tz,
4790            },
4791            // Event triggers have no cron — the agent fires them from an
4792            // OS event source. The `# event-trigger` cron is never
4793            // registered (the scheduler branches on `is_event()` first),
4794            // but keep it deliberately-invalid as a belt-and-suspenders
4795            // so a stray registration would fail rather than misfire.
4796            When::On(_) => Lowered {
4797                cron: "# event-trigger (no cron)".into(),
4798                mode: ExecMode::Event,
4799                cooldown: None,
4800                tz,
4801            },
4802        }
4803    }
4804
4805    /// True when this schedule fires from an OS event (`when: { on }`)
4806    /// rather than a clock — the agent skips `tokio-cron` registration
4807    /// for these and drives them from boot / session-change instead.
4808    pub fn is_event(&self) -> bool {
4809        matches!(self.when, When::On(_))
4810    }
4811
4812    /// The OS event triggers this schedule listens for, or `&[]` when it
4813    /// is not an event schedule.
4814    pub fn event_triggers(&self) -> &[OnTrigger] {
4815        match &self.when {
4816            When::On(t) => t,
4817            _ => &[],
4818        }
4819    }
4820
4821    /// The next absolute (UTC) time this schedule fires, or `None` when
4822    /// it has no discrete upcoming fire to preview.
4823    ///
4824    /// Used by the KLP `maintenance.list` preview ("what's about to
4825    /// happen on my PC", SPEC §2.1). Returns `None` for:
4826    ///
4827    /// - reconcile shapes (`per_pc` / `per_target`) — they lower to the
4828    ///   every-minute [`POLL_CRON`] and re-converge state continuously,
4829    ///   so "next fire" is always ~60s away and means nothing to a user
4830    ///   previewing upcoming maintenance;
4831    /// - a calendar schedule whose lowered cron won't parse (a
4832    ///   hand-edited KV blob that slipped past [`Schedule::validate`]);
4833    /// - a cron with no future occurrence.
4834    ///
4835    /// The wall-clock fire is evaluated in the schedule's own `tz`
4836    /// (matching the live tick's `Job::new_async_tz`) then normalised
4837    /// to UTC for the wire. `inclusive = false`: strictly the *next*
4838    /// fire after `now`, never one matching the current instant.
4839    pub fn next_calendar_fire(
4840        &self,
4841        now: chrono::DateTime<chrono::Utc>,
4842    ) -> Option<chrono::DateTime<chrono::Utc>> {
4843        if !matches!(self.when, When::Calendar(_)) {
4844            return None;
4845        }
4846        let lowered = self.lowered();
4847        // Same parser configuration tokio-cron-scheduler 0.15 uses
4848        // internally, so this can never compute a fire the live
4849        // scheduler wouldn't (seconds required, DOM-and-DOW honored).
4850        let cron = croner::parser::CronParser::builder()
4851            .seconds(croner::parser::Seconds::Required)
4852            .dom_and_dow(true)
4853            .build()
4854            .parse(&lowered.cron)
4855            .ok()?;
4856        match lowered.tz {
4857            ScheduleTz::Utc => cron.find_next_occurrence(&now, false).ok(),
4858            ScheduleTz::Local => {
4859                let now_local = now.with_timezone(&chrono::Local);
4860                cron.find_next_occurrence(&now_local, false)
4861                    .ok()
4862                    .map(|t| t.with_timezone(&chrono::Utc))
4863            }
4864        }
4865    }
4866
4867    /// Cross-field semantic checks that don't fit pure serde derive
4868    /// — the [`Manifest::validate`] counterpart (#418 decision F;
4869    /// pre-Phase-1 a broken schedule was accepted at create time
4870    /// and silently warn-skipped at tick time). Run at every create
4871    /// site: `kanade schedule create` (client-side) and
4872    /// `POST /api/schedules`. The job_id-exists check lives in the
4873    /// API handler instead — it needs the JOBS KV.
4874    pub fn validate(&self) -> Result<(), String> {
4875        if matches!(self.runs_on, RunsOn::Agent) && matches!(self.when, When::PerTarget(_)) {
4876            return Err(
4877                "when.per_target needs fleet-wide completion data and is backend-only; \
4878                 it cannot be combined with runs_on: agent (each agent self-schedules, \
4879                 so per-target dedup would be deduping across a target of 1)"
4880                    .into(),
4881            );
4882        }
4883        // #418 event triggers: the agent owns the OS event source
4884        // (boot / session-change), so `when: { on }` is agent-only and
4885        // needs at least one trigger.
4886        if let When::On(triggers) = &self.when {
4887            if !matches!(self.runs_on, RunsOn::Agent) {
4888                return Err(
4889                    "when.on (OS event trigger) is fired by the agent's own event \
4890                     source, so it requires runs_on: agent"
4891                        .into(),
4892                );
4893            }
4894            if triggers.is_empty() {
4895                return Err(
4896                    "when.on must list at least one trigger (e.g. [startup, logon])".into(),
4897                );
4898            }
4899        }
4900        if let Some(cd) = self.lowered().cooldown.as_deref() {
4901            humantime::parse_duration(cd)
4902                .map_err(|e| format!("when.every: invalid duration '{cd}': {e}"))?;
4903        }
4904        if let When::Calendar(c) = &self.when {
4905            // Lower the calendar form to its cron (catches a bad `at`
4906            // and the date+days conflict), then validate that cron
4907            // with the same parser configuration tokio-cron-scheduler
4908            // 0.15 uses internally (croner, seconds required,
4909            // DOM-and-DOW both honored, year optional) — create-time
4910            // validation can never accept what register() rejects.
4911            let cron = c.to_cron()?;
4912            croner::parser::CronParser::builder()
4913                .seconds(croner::parser::Seconds::Required)
4914                .dom_and_dow(true)
4915                .build()
4916                .parse(&cron)
4917                .map_err(|e| format!("when.at lowered to invalid cron '{cron}': {e}"))?;
4918        }
4919        // The other humantime strings on the schedule (claude #419
4920        // review): runtime degrades gracefully on both (bad jitter →
4921        // silent no-op, bad starting_deadline → warn + skipped tick),
4922        // but "rejected at create time" should cover every field the
4923        // operator can typo, not just `when`.
4924        if let Some(j) = &self.plan.jitter {
4925            humantime::parse_duration(j)
4926                .map_err(|e| format!("jitter: invalid duration '{j}': {e}"))?;
4927        }
4928        if let Some(sd) = &self.starting_deadline {
4929            humantime::parse_duration(sd)
4930                .map_err(|e| format!("starting_deadline: invalid duration '{sd}': {e}"))?;
4931        }
4932        let from = self
4933            .active
4934            .from
4935            .as_deref()
4936            .map(|s| Active::parse_bound(s, self.tz))
4937            .transpose()?;
4938        let until = self
4939            .active
4940            .until
4941            .as_deref()
4942            .map(|s| Active::parse_bound(s, self.tz))
4943            .transpose()?;
4944        if let (Some(f), Some(u)) = (from, until) {
4945            if f >= u {
4946                return Err(format!(
4947                    "active.from ({}) must be strictly before active.until ({})",
4948                    self.active.from.as_deref().unwrap_or_default(),
4949                    self.active.until.as_deref().unwrap_or_default(),
4950                ));
4951            }
4952        }
4953        // #418 Phase 3: a bad maintenance window is rejected at create
4954        // time (parse_window also catches equal bounds).
4955        if let Some(w) = self.constraints.window.as_deref() {
4956            Constraints::parse_window(w)?;
4957        }
4958        // #418 holiday exclusion: reject a malformed skip date at create
4959        // time so the fail-closed `allows` path only ever bites a
4960        // hand-edited KV blob, not a fresh `kanade schedule create`.
4961        if let Some(err) = self.constraints.bad_skip_date() {
4962            return Err(err);
4963        }
4964        // #418: constraints.max_concurrent is a central running-instance
4965        // cap, so it needs the backend's counter — reject it on
4966        // runs_on: agent (decision E), and reject a meaningless 0.
4967        if let Some(mc) = self.constraints.max_concurrent {
4968            // Check the structural incompatibility (agent has no central
4969            // counter) before the value range, so a `max_concurrent: 0`
4970            // + `runs_on: agent` combo reports the more fundamental
4971            // problem first (claude #542).
4972            if matches!(self.runs_on, RunsOn::Agent) {
4973                return Err(
4974                    "constraints.max_concurrent needs a central counter and is backend-only; \
4975                     it cannot be combined with runs_on: agent (each agent self-schedules, \
4976                     so there is no fleet-wide count to cap against)"
4977                        .into(),
4978                );
4979            }
4980            if mc == 0 {
4981                return Err(
4982                    "constraints.max_concurrent must be >= 1 (0 would never fire; \
4983                     omit it for no cap)"
4984                        .into(),
4985                );
4986            }
4987        }
4988        // #418: constraints.require (host-state env gates: ac_power /
4989        // idle / cpu_below / network) is sensed in-process by the agent,
4990        // so it needs runs_on: agent — the backend can't read a target
4991        // host's power / idle / cpu / connectivity state. Symmetric with
4992        // `when: { on }` (also agent-only); inverse of max_concurrent
4993        // (backend-only).
4994        if let Some(req) = &self.constraints.require {
4995            if !req.is_empty() && matches!(self.runs_on, RunsOn::Backend) {
4996                return Err(
4997                    "constraints.require (host-state env gates: ac_power / idle / cpu_below / \
4998                     network) is sensed in-process by the agent and needs runs_on: agent; the \
4999                     backend cannot read a target host's power / idle / cpu / connectivity state"
5000                        .into(),
5001                );
5002            }
5003            // Reject a malformed idle duration at create time so the
5004            // fail-closed runtime path only ever bites a hand-edited
5005            // KV blob (mirror skip_dates / on_failure.retry).
5006            if let Some(err) = req.bad_idle() {
5007                return Err(err);
5008            }
5009            // cpu_below is a percent — reject out-of-range so a typo
5010            // can't make a schedule that never (>=100 is always-busy?
5011            // no — <0 never matches) or trivially fires.
5012            if let Some(c) = req.cpu_below
5013                && !(c > 0.0 && c <= 100.0)
5014            {
5015                return Err(format!(
5016                    "constraints.require.cpu_below must be in (0, 100] percent (got {c}); \
5017                     omit it for no CPU requirement"
5018                ));
5019            }
5020        }
5021        // #418 Phase 4: a bad on_failure.retry is rejected at create
5022        // time — backoff must be valid humantime, and max is bounded
5023        // so a typo can't pin a flapping script in a tight loop.
5024        if let Some(r) = &self.on_failure.retry {
5025            let backoff = humantime::parse_duration(&r.backoff).map_err(|e| {
5026                format!(
5027                    "on_failure.retry.backoff: invalid duration '{}': {e}",
5028                    r.backoff
5029                )
5030            })?;
5031            // The wire form lowers backoff to whole seconds, so a
5032            // sub-second value would silently become a 0s no-wait
5033            // (coderabbit #466). Reject it rather than honour a backoff
5034            // the operator can't actually get.
5035            if backoff.as_secs() < 1 {
5036                return Err(format!(
5037                    "on_failure.retry.backoff must be >= 1s (got '{}'); sub-second backoffs \
5038                     round to 0 on the wire",
5039                    r.backoff
5040                ));
5041            }
5042            if !(1..=10).contains(&r.max) {
5043                return Err(format!(
5044                    "on_failure.retry.max must be 1..=10 (got {}); it counts additional \
5045                     attempts after the first run",
5046                    r.max
5047                ));
5048            }
5049        }
5050        // A blank / whitespace-only tag renders an empty filter chip on
5051        // the Schedules page — reject it at create time, mirroring the
5052        // Manifest::validate tag guard.
5053        for tag in &self.tags {
5054            if tag.trim().is_empty() {
5055                return Err("tags must not contain empty entries".to_string());
5056            }
5057        }
5058        Ok(())
5059    }
5060}
5061
5062fn default_true() -> bool {
5063    true
5064}