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}