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