Skip to main content

ignition_core/client/
eam.rs

1//! EAM capability constants + models (07-02, BKUP-02) — the
2//! Enterprise Administration Module's wire, live-proven on 8.3.3
3//! during 07-RESEARCH (trimmed openapi extract in the phase dir).
4//!
5//! TWO seams, one family (the 05-04 tag split precedent):
6//!
7//! 1. **RUNTIME reads** under `/data/eam/api/v1` — task HISTORY (the
8//!    `{items, metadata}` list envelope) and FORCE dispatch (Task 3).
9//!    Every runtime endpoint 403s with "This operation can only be
10//!    performed when EAM is configured as a controller" on a stock
11//!    gateway — message-classified into
12//!    [`crate::error::CoreError::EamNotController`] at the classify
13//!    seam (path-scoped; never a misleading `auth_rejected`).
14//! 2. **TASK DEFINITIONS as config resources** under
15//!    `/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks`
16//!    — the standard config-resource family (the tag-provider
17//!    pattern: array-body POST for create, list/find reads; find
18//!    answers the definition + a `scheduledTaskState` healthcheck).
19//!
20//! Two-layer naming (the LOCKED convention): client models stay
21//! wire-faithful camelCase (history items carry the gateway's own
22//! `taskName`/`taskStart`/… keys; epoch-ms times as JSON numbers,
23//! passthrough); unit-explicit keys live at the ACTIONS layer where
24//! useful. Definition records are passthrough shapes (the
25//! TagProviderRecord pattern — `config.profile.type` /
26//! `scheduleMode` / settings ride as raw JSON).
27//!
28//! WRITE SURFACE (10-02, capture-locked per 10-LIVE-CAPTURES.md —
29//! both rigs, 8.3.3 + 8.3.6):
30//!
31//! - **Runtime lifecycle verbs** (`suspend`/`resume`/`cancel`) —
32//!   POST, empty body, **204 on success** (suspend/resume also PERSIST
33//!   `config.profile.isSuspended` into the definition; cancel of a
34//!   task with nothing pending is a silent 204). Failures are NOT
35//!   4xx: an unknown name (or an untriggered task, or an OnDemand
36//!   task) answers **500 Jetty HTML** — suspend's message is
37//!   INDISTINGUISHABLE across those causes; resume's names the task
38//!   (`No NamedResourceHandler found for task '<name>'`); cancel
39//!   never fails observed. There is no 404 on this seam.
40//! - **`scheduled/{true|false}` read** — the pending-execution
41//!   envelope (`{items, metadata}`), 13 wire keys per item; the
42//!   `taskState`/`type` vocabularies are capture-locked STRING consts
43//!   (below), never enums.
44//! - **Full-record modify (PUT)** — the SAME resource path as create,
45//!   single-element ARRAY body carrying the ORIGINAL `signature`
46//!   (echo semantics: sent keys land verbatim; the 200
47//!   `{success, changes[{name,type,collection,newSignature}], problem}`
48//!   body's `newSignature` is authoritative for the NEXT mutation —
49//!   signatures are per-write, never cacheable). Rename via PUT is
50//!   NOT supported (changed name + original signature ⇒ 404 empty).
51//! - **Signature-keyed DELETE** — `/{name}/{signature}` with
52//!   `?collection=core` (the COLLECTION, never the type token —
53//!   `collection=eam-tasks` 404s) and `confirm=true` only when the
54//!   caller explicitly opts in (a lone-resource delete SUCCEEDS
55//!   without it; the confirm-demand shape is UNOBSERVED — §3d — so
56//!   it is never hard-coded).
57//!
58//! **FINDING for 10-03/10-04 (recorded here, NOT classified):** a
59//! signature mismatch on PUT/DELETE answers **HTTP 500** with a JSON
60//! `{success:false, changes:[], problem{message, stacktrace}}` body
61//! whose message contains the stable substring `signature mismatch`
62//! (drift-proof across 8.3.3/8.3.6; the surrounding prose and stack
63//! frames drift, and 8.3.3 LEAKS the live signature). The existing
64//! taxonomy has no honest slug for this (500 → `Internal`, exit 1);
65//! adding one is a Three-Place decision for the actions/CLI plans —
66//! the client layer carries the captured `problem` verbatim in the
67//! [`ModifyOutcome`]/[`DeleteOutcome`] models so the caller can
68//! detect it without a new classification site.
69
70use std::collections::BTreeMap;
71
72use serde::{Deserialize, Serialize};
73
74use crate::client::projects::encode_segment;
75
76/// The EAM runtime base (module-scoped prefix — classify()'s
77/// controller-403 arm keys on this). Task 3's force method is its
78/// first production caller (the gate comes off then).
79pub(crate) const EAM_BASE: &str = "/data/eam/api/v1";
80
81/// GET path — task run history (the standard list envelope).
82pub(crate) const EAM_HISTORY_PATH: &str = "/data/eam/api/v1/eam-tasks/history";
83
84/// The history list's DEFAULT limit — EAM history grows unboundedly
85/// and the server default is unlimited (the logs family's Pitfall-9
86/// discipline: an explicit limit ALWAYS rides the wire).
87pub(crate) const EAM_HISTORY_DEFAULT_LIMIT: i64 = 200;
88
89/// POST path — force-dispatch a task now (Task 3; owner = the task
90/// healthcheck's `scheduledTaskState.details.owner`, live-captured
91/// fallback `"eam"`). 204 is the live-proven success shape.
92pub(crate) fn eam_force_path(owner: &str, name: &str) -> String {
93    format!("{EAM_BASE}/eam-tasks/force/{owner}/{name}")
94}
95
96/// The task-definition config-resource id (the 05-04 tag-provider
97/// pattern rides again: array-body POST/PUT, standard list/find).
98pub(crate) const EAM_TASKS_RESOURCE: &str = "com.inductiveautomation.eam/eam-tasks";
99
100/// GET path — the task-definition resource list.
101pub(crate) fn eam_tasks_list_path() -> String {
102    format!("/data/api/v1/resources/list/{EAM_TASKS_RESOURCE}")
103}
104
105/// GET path — one definition's full record (`/find/{enc}`) incl. the
106/// `scheduledTaskState` healthcheck (`currentState`/`nextScheduled`/
107/// `owner`) and the `signature` mutations need.
108pub(crate) fn eam_task_find_path(name: &str) -> String {
109    format!(
110        "/data/api/v1/resources/find/{EAM_TASKS_RESOURCE}/{}",
111        encode_segment(name)
112    )
113}
114
115/// POST path — create task definitions (the body is a JSON ARRAY of
116/// definition records; the tag-provider create shape).
117pub(crate) fn eam_tasks_create_path() -> String {
118    format!("/data/api/v1/resources/{EAM_TASKS_RESOURCE}")
119}
120
121/// PUT path — full-record modify of task definitions. The SAME
122/// resource path as create (10-LIVE-CAPTURES §6a: the array-body PUT
123/// with the ORIGINAL signature is the modify wire shape).
124pub(crate) fn eam_tasks_modify_path() -> String {
125    eam_tasks_create_path()
126}
127
128/// DELETE path — delete-by-signature, the byte-twin of
129/// `tag_provider_delete_path` (`/{name}/{signature}`; both segments
130/// percent-encoded through the ONE locked encoder — the signature
131/// comes from find, the live-proven chain). Query params ride the
132/// impl (`collection=core` always; `confirm=true` only on explicit
133/// opt-in — 10-LIVE-CAPTURES §3b/§3c/Decision 3).
134pub(crate) fn eam_task_delete_path(name: &str, signature: &str) -> String {
135    format!(
136        "/data/api/v1/resources/{EAM_TASKS_RESOURCE}/{}/{}",
137        encode_segment(name),
138        encode_segment(signature)
139    )
140}
141
142/// POST path — suspend a task's scheduler trigger (lifecycle).
143/// `{name}` rides RAW — gateway identifiers are URL-safe like the
144/// force path (10-LIVE-CAPTURES §1: no encoding observed).
145pub(crate) fn eam_task_suspend_path(name: &str) -> String {
146    format!("{EAM_BASE}/eam-tasks/suspend/{name}")
147}
148
149/// POST path — resume a suspended task (lifecycle; the inverse of
150/// [`eam_task_suspend_path`], same raw-name rule).
151pub(crate) fn eam_task_resume_path(name: &str) -> String {
152    format!("{EAM_BASE}/eam-tasks/resume/{name}")
153}
154
155/// POST path — cancel a task's PENDING execution (lifecycle). 204 is
156/// the answer whether or not anything was pending (10-LIVE-CAPTURES
157/// §7: cancel of an unknown name is also a silent 204).
158pub(crate) fn eam_task_cancel_path(name: &str) -> String {
159    format!("{EAM_BASE}/eam-tasks/cancel/{name}")
160}
161
162/// GET path — the pending-execution read. The LITERAL captured
163/// segments: the path takes the WORD `true`/`false` (10-LIVE-CAPTURES
164/// §2: "segment takes the literal word, no encoding surprises").
165pub(crate) fn eam_tasks_scheduled_path(running: bool) -> String {
166    format!("{EAM_BASE}/eam-tasks/scheduled/{running}")
167}
168
169/// The CAPTURED `taskState` vocabulary on the scheduled seam —
170/// exactly the values both rigs ever answered (10-LIVE-CAPTURES §2 +
171/// Decision 2). A `taskState` OUTSIDE this set is honest UNKNOWN:
172/// `Running`/`Pending` rows were never capturable (no connected-agent
173/// rig) and MUST still parse — the field stays a plain [`String`]
174/// (the 09-05 String-vocabulary decision; NEVER an enum). `pub` like
175/// the BUNDLE_*_STATES precedent (the actions/CLI tiers consult it).
176pub const EAM_TASK_STATES: &[&str] = &[
177    // 8.3.6 rig A + 8.3.3 rig B, scheduled/false (10-LIVE-CAPTURES §2).
178    "Scheduled",
179    // Captured via the find healthcheck `currentState` after a 204
180    // suspend — a suspended task vanishes FROM scheduled/false
181    // (10-LIVE-CAPTURES §1c + Decision 1).
182    "Suspended",
183];
184
185/// The CAPTURED `currentState` vocabulary on the find healthcheck
186/// (`scheduledTaskState.currentState`) — the lifecycle companion the
187/// suspend/resume verbs persist (10-LIVE-CAPTURES §1 + Decision 1).
188/// String consts, never an enum; unobserved values passthrough.
189pub const EAM_CURRENT_STATES: &[&str] = &[
190    // Fresh OnDemand task, pre-trigger (10-LIVE-CAPTURES §1a).
191    "Stopped",
192    // Scheduled task with a broken/no-op schedule (stats NPE ride-along).
193    "Errored",
194    // Post-204-suspend read-back (10-LIVE-CAPTURES §1c).
195    "Suspended",
196];
197
198/// One history item — wire-faithful camelCase (the live-captured
199/// shape; `taskName` carries `" (forced)"` on forced runs, `level`
200/// e.g. `Failed`, times epoch-ms as the gateway serialized them).
201#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
202pub struct EamHistoryItem {
203    /// `taskId` — the run's id, a UUID STRING on 8.3.3 controllers
204    /// (wire-faithful; the live capture serializes
205    /// `"a2f4dab1-9a8f-4feb-9306-29e261f60453"`).
206    #[serde(rename = "taskId", default)]
207    pub task_id: String,
208    /// `taskName` — the definition name (+ `" (forced)"` on forced
209    /// runs).
210    #[serde(rename = "taskName", default)]
211    pub task_name: String,
212    /// `taskStart` — epoch-ms.
213    #[serde(rename = "taskStart", default)]
214    pub task_start: i64,
215    /// `taskEnd` — epoch-ms (null while a run is in flight).
216    #[serde(rename = "taskEnd", default)]
217    pub task_end: Option<i64>,
218    /// `target` — the agent the run dispatched to (e.g.
219    /// `_controller`).
220    #[serde(rename = "target", default)]
221    pub target: Option<String>,
222    /// `level` — the outcome class (`Failed`, …) — DATA, never
223    /// parsed into an error.
224    #[serde(rename = "level", default)]
225    pub level: Option<String>,
226    /// `detail` — the gateway's own outcome text (GNET
227    /// not-connected / trial-expired honesty rides VERBATIM here).
228    #[serde(rename = "detail", default)]
229    pub detail: Option<String>,
230    /// `taskType` — the profile type token (`eam_backup`, …).
231    #[serde(rename = "taskType", default)]
232    pub task_type: Option<String>,
233}
234
235/// One task-definition record — passthrough (the TagProviderRecord
236/// pattern): `config.profile.{type,scheduleMode}` + settings ride as
237/// raw JSON; find answers additionally carry the
238/// `scheduledTaskState` healthcheck and the mutation `signature`.
239#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
240pub struct EamTaskRecord {
241    /// Resource name (the task definition's name).
242    #[serde(default)]
243    pub name: String,
244    /// Definition config — `profile.type` / `profile.scheduleMode` /
245    /// `profile.settings` raw passthrough.
246    #[serde(default)]
247    pub config: serde_json::Value,
248    /// The record's mutation signature (find records carry it).
249    #[serde(default)]
250    pub signature: Option<String>,
251    /// The `scheduledTaskState` healthcheck (find answers carry it:
252    /// `currentState` / `nextScheduled` / `owner` under `details`).
253    #[serde(rename = "scheduledTaskState", default)]
254    pub scheduled_task_state: Option<serde_json::Value>,
255    /// `collection`, `type`, `enabled`, … resource keys round-trip.
256    #[serde(flatten)]
257    pub extra: BTreeMap<String, serde_json::Value>,
258}
259
260/// One row of the `scheduled/{running}` pending-execution read —
261/// wire-faithful, ALL 13 captured keys (10-LIVE-CAPTURES §2 +
262/// Decision 2: the extract documented only 10; the live wire carries
263/// `isForced`/`isRunning`/`progress` too). Unknown keys are ignored
264/// by serde (read-only model — no round-trip needed).
265#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
266pub struct EamScheduledTask {
267    /// The task definition's name.
268    #[serde(default)]
269    pub name: String,
270    /// The task's owner (the force-verb's owner segment source).
271    #[serde(default)]
272    pub owner: String,
273    /// `type` — a HUMAN LABEL (`"Collect Backup"`), NOT the
274    /// `profile.type` token (`eam_backup`): the runtime seam and the
275    /// config seam use DIFFERENT vocabularies — passthrough both,
276    /// never normalize (the history-`taskType` precedent, confirmed
277    /// on this seam; 10-LIVE-CAPTURES §2).
278    #[serde(rename = "type", default)]
279    pub task_type: Option<String>,
280    /// `execStart` — epoch-ms while running; `null` on the captured
281    /// scheduled rows (10-LIVE-CAPTURES §2).
282    #[serde(rename = "execStart", default)]
283    pub exec_start: Option<i64>,
284    /// The gateway's own row message (empty string captured).
285    #[serde(default)]
286    pub message: String,
287    /// Whether the execution repeats (the scheduled cron task: `true`).
288    #[serde(default)]
289    pub repeats: bool,
290    /// `canPause` — gateway-owned capability flag (the captured
291    /// `Scheduled` cell: `true`).
292    #[serde(rename = "canPause", default)]
293    pub can_pause: bool,
294    /// `canResume` — the captured `Scheduled` cell: `false`.
295    #[serde(rename = "canResume", default)]
296    pub can_resume: bool,
297    /// `canCancel` — the captured `Scheduled` cell: `true`.
298    #[serde(rename = "canCancel", default)]
299    pub can_cancel: bool,
300    /// `taskState` — CAPTURE-LOCKED String vocabulary
301    /// ([`EAM_TASK_STATES`]; observed `"Scheduled"`/`"Suspended"`,
302    /// `Running`/`Pending` UNOBSERVED — passthrough, NEVER an enum;
303    /// 10-LIVE-CAPTURES Decision 2).
304    #[serde(rename = "taskState", default)]
305    pub task_state: String,
306    /// `isForced` — beyond-the-extract live key (false on the
307    /// captured rows; the force dispatch never produced a row).
308    #[serde(rename = "isForced", default)]
309    pub is_forced: bool,
310    /// `isRunning` — beyond-the-extract live key (false captured).
311    #[serde(rename = "isRunning", default)]
312    pub is_running: bool,
313    /// `progress` — JSON float (`0.0` captured on both rigs).
314    #[serde(default)]
315    pub progress: f64,
316}
317
318/// One element of a mutation's `changes[]` array — the invariant
319/// `{name, type, collection, newSignature}` shape captured across
320/// create/modify/delete/module-settings (10-LIVE-CAPTURES §9 +
321/// Decision 8). `newSignature` is the POST-write signature
322/// (server-derived, opaque, rig-varying — never cacheable across
323/// writes; for DELETE it is the deleted resource's final signature).
324#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
325pub struct ResourceChange {
326    /// The mutated resource's name.
327    #[serde(default)]
328    pub name: String,
329    /// `type` — the resource type token (e.g.
330    /// `com.inductiveautomation.eam/eam-tasks`).
331    #[serde(rename = "type", default)]
332    pub resource_type: String,
333    /// The config-module collection (`core` — the delete query param
334    /// must carry this VALUE, never the type token).
335    #[serde(default)]
336    pub collection: String,
337    /// `newSignature` — the post-write signature
338    /// (authoritative-for-next-mutation; `None` only if the gateway
339    /// ever omits the key — lenient).
340    #[serde(rename = "newSignature", default)]
341    pub new_signature: Option<String>,
342}
343
344/// The `problem{message, stacktrace}` shape — present + non-null ONLY
345/// on the semantic-refusal 500s (the signature-mismatch family;
346/// 10-LIVE-CAPTURES §10 + Decision 9). Message prose and stack frames
347/// DRIFT between 8.3.3/8.3.6; the `signature mismatch` substring is
348/// stable (classify on it, never on frames — and only at a layer
349/// that owns the slug decision).
350#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
351pub struct MutationProblem {
352    /// The gateway's message (may LEAK the live signature on 8.3.3 —
353    /// surface sanitized, not raw).
354    #[serde(default)]
355    pub message: String,
356    /// The Java stack frames (drifting server internals; carried
357    /// honestly, never parsed).
358    #[serde(default)]
359    pub stacktrace: Vec<String>,
360}
361
362/// The captured mutation-success envelope
363/// `{success, changes[], problem}` — the 200 body of a full-record
364/// PUT modify (10-LIVE-CAPTURES §6a/§9). `problem` is `null` on every
365/// captured success; a `success:false` + `problem` answer is the
366/// signature-mismatch refusal (HTTP 500 — see the module-doc
367/// FINDING).
368#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
369pub struct ModifyOutcome {
370    /// `success` — the body-level verdict (false on semantic refusals).
371    #[serde(default)]
372    pub success: bool,
373    /// Per-array-item change records.
374    #[serde(default)]
375    pub changes: Vec<ResourceChange>,
376    /// The semantic-refusal problem (None on successes).
377    #[serde(default)]
378    pub problem: Option<MutationProblem>,
379}
380
381/// The DELETE mutation envelope — [`ModifyOutcome`] plus the 4th key
382/// `references` (`[]` on every captured success, `null` on the 500
383/// problem shape; 10-LIVE-CAPTURES §3b/§9). The element shape of a
384/// NON-empty `references` (the confirm-demand affected-resources
385/// list) is UNOBSERVED (§3d) — raw passthrough, never guessed.
386#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
387pub struct DeleteOutcome {
388    /// `success` — the body-level verdict.
389    #[serde(default)]
390    pub success: bool,
391    /// Per-array-item change records.
392    #[serde(default)]
393    pub changes: Vec<ResourceChange>,
394    /// The semantic-refusal problem (None on successes).
395    #[serde(default)]
396    pub problem: Option<MutationProblem>,
397    /// Affected/dependent resources — `[]` captured on success,
398    /// `null` on the failure shape; element shape UNOBSERVED.
399    #[serde(default)]
400    pub references: Option<Vec<serde_json::Value>>,
401}
402
403#[cfg(test)]
404mod tests {
405    use super::{EamHistoryItem, EamTaskRecord};
406
407    /// History items parse under the live-captured wire keys — the
408    /// forced-suffix taskName and the Failed level ride VERBATIM
409    /// (research Pitfall 3: execution outcomes are DATA). `taskId`
410    /// is the captured UUID STRING (8.3.3 wire-faithful — the
411    /// 07-UAT gap-1 shape, raw capture in .planning/debug).
412    #[test]
413    fn history_item_parses_the_live_shape() {
414        let item: EamHistoryItem = serde_json::from_value(serde_json::json!({
415            "taskId": "a2f4dab1-9a8f-4feb-9306-29e261f60453",
416            "taskName": "nightly-backup (forced)",
417            "taskStart": 1787930000000_i64,
418            "taskEnd": 1787930009000_i64,
419            "target": "_controller",
420            "level": "Failed",
421            "detail": "Gateway network for agent '_controller' is currently not connected",
422            "taskType": "eam_backup"
423        }))
424        .expect("live-captured shape parses");
425        assert_eq!(item.task_id, "a2f4dab1-9a8f-4feb-9306-29e261f60453");
426        assert_eq!(item.task_name, "nightly-backup (forced)");
427        assert_eq!(item.level.as_deref(), Some("Failed"));
428        assert!(
429            item.detail
430                .as_deref()
431                .is_some_and(|d| d.contains("not connected"))
432        );
433
434        // A running task: no taskEnd, no detail.
435        let running: EamHistoryItem = serde_json::from_value(serde_json::json!({
436            "taskId": "b3c5ebc2-0b90-40fc-8417-3af372071546",
437            "taskName": "nightly-backup",
438            "taskStart": 1787930000000_i64
439        }))
440        .expect("sparse shape parses (tolerant defaults)");
441        assert_eq!(running.task_end, None);
442        assert_eq!(running.detail, None);
443    }
444
445    /// Definition records parse with config passthrough + the find
446    /// shape's scheduledTaskState/extra keys round-tripping (the
447    /// list shape carries neither state nor signature).
448    #[test]
449    fn task_record_parses_list_and_find_shapes() {
450        let listed: EamTaskRecord = serde_json::from_value(serde_json::json!({
451            "name": "nightly-backup",
452            "config": {
453                "profile": {
454                    "type": "eam_backup",
455                    "scheduleMode": "OnDemand",
456                    "settings": {"targetGateways": [], "targetGroups": [], "concurrentBackups": 0, "forceBackups": false}
457                }
458            },
459            "collection": "eam-tasks",
460            "type": "com.inductiveautomation.eam"
461        }))
462        .expect("list shape parses");
463        assert_eq!(
464            listed.config["profile"]["type"],
465            serde_json::json!("eam_backup")
466        );
467        assert_eq!(listed.signature, None, "list records carry no signature");
468        assert_eq!(
469            listed.extra.get("collection"),
470            Some(&serde_json::json!("eam-tasks")),
471            "resource keys round-trip"
472        );
473
474        let found: EamTaskRecord = serde_json::from_value(serde_json::json!({
475            "name": "nightly-backup",
476            "config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}},
477            "signature": "abc123",
478            "scheduledTaskState": {
479                "currentState": "IDLE",
480                "details": {"owner": "eam", "nextScheduled": None::<String>}
481            }
482        }))
483        .expect("find shape parses");
484        assert_eq!(found.signature.as_deref(), Some("abc123"));
485        let state = found.scheduled_task_state.expect("state present");
486        assert_eq!(state["currentState"], serde_json::json!("IDLE"));
487        assert_eq!(state["details"]["owner"], serde_json::json!("eam"));
488    }
489
490    /// The force path embeds owner + name raw (both are gateway
491    /// identifiers — `[A-Za-z0-9._-]`, URL-safe like logger names).
492    #[test]
493    fn force_path_is_the_module_scoped_shape() {
494        assert_eq!(
495            super::eam_force_path("eam", "nightly-backup"),
496            "/data/eam/api/v1/eam-tasks/force/eam/nightly-backup"
497        );
498    }
499
500    /// The lifecycle verb paths ride the runtime seam with the name
501    /// RAW (the force-path rule; 10-LIVE-CAPTURES §1/§7 — no encoding
502    /// observed on either rig).
503    #[test]
504    fn lifecycle_paths_are_the_captured_shapes() {
505        assert_eq!(
506            super::eam_task_suspend_path("nightly-backup"),
507            "/data/eam/api/v1/eam-tasks/suspend/nightly-backup"
508        );
509        assert_eq!(
510            super::eam_task_resume_path("nightly-backup"),
511            "/data/eam/api/v1/eam-tasks/resume/nightly-backup"
512        );
513        assert_eq!(
514            super::eam_task_cancel_path("nightly-backup"),
515            "/data/eam/api/v1/eam-tasks/cancel/nightly-backup"
516        );
517    }
518
519    /// The scheduled read takes the LITERAL word segments
520    /// (`true`/`false` — 10-LIVE-CAPTURES §2).
521    #[test]
522    fn scheduled_path_takes_the_literal_bool_word() {
523        assert_eq!(
524            super::eam_tasks_scheduled_path(true),
525            "/data/eam/api/v1/eam-tasks/scheduled/true"
526        );
527        assert_eq!(
528            super::eam_tasks_scheduled_path(false),
529            "/data/eam/api/v1/eam-tasks/scheduled/false"
530        );
531    }
532
533    /// Modify rides the SAME resource path as create (the array-body
534    /// PUT, §6a); delete is the byte-twin `/{name}/{signature}` shape
535    /// with the locked per-segment encoder (hyphens over-encode —
536    /// safe; the tag-provider delete-path precedent).
537    #[test]
538    fn mutation_paths_are_the_config_resource_shapes() {
539        assert_eq!(
540            super::eam_tasks_modify_path(),
541            "/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks"
542        );
543        assert_eq!(
544            super::eam_tasks_create_path(),
545            super::eam_tasks_modify_path(),
546            "modify and create share ONE resource path"
547        );
548        assert_eq!(
549            super::eam_task_delete_path("nightly-backup", "sig-abc123"),
550            "/data/api/v1/resources/com.inductiveautomation.eam/eam-tasks/nightly%2Dbackup/sig%2Dabc123"
551        );
552    }
553
554    /// The captured vocabularies are STRING consts (never enums) —
555    /// pinned verbatim per rig provenance (10-LIVE-CAPTURES
556    /// Decisions 1-2).
557    #[test]
558    fn captured_vocabularies_are_the_string_const_sets() {
559        assert_eq!(super::EAM_TASK_STATES, &["Scheduled", "Suspended"]);
560        assert_eq!(
561            super::EAM_CURRENT_STATES,
562            &["Stopped", "Errored", "Suspended"]
563        );
564    }
565
566    /// The scheduled row parses the VERBATIM captured body
567    /// (8.3.6 09:59:39Z, scheduled/false — 10-LIVE-CAPTURES §2),
568    /// 13 keys wire-faithful, and an UNOBSERVED taskState
569    /// (`"Running"`) still parses — the String-vocabulary discipline.
570    #[test]
571    fn scheduled_task_parses_the_verbatim_captured_row() {
572        let captured: super::EamScheduledTask = serde_json::from_value(serde_json::json!({
573            "name": "ign-p10-scratch-sched",
574            "owner": "eam",
575            "type": "Collect Backup",
576            "execStart": null,
577            "message": "",
578            "repeats": true,
579            "canPause": true,
580            "canResume": false,
581            "canCancel": true,
582            "taskState": "Scheduled",
583            "isForced": false,
584            "isRunning": false,
585            "progress": 0.0
586        }))
587        .expect("the captured row parses");
588        assert_eq!(captured.name, "ign-p10-scratch-sched");
589        assert_eq!(captured.owner, "eam");
590        assert_eq!(
591            captured.task_type.as_deref(),
592            Some("Collect Backup"),
593            "the human label rides verbatim — never conflated with profile.type"
594        );
595        assert_eq!(captured.exec_start, None, "execStart null while scheduled");
596        assert_eq!(captured.message, "");
597        assert!(captured.repeats);
598        assert!(captured.can_pause && !captured.can_resume && captured.can_cancel);
599        assert_eq!(captured.task_state, "Scheduled");
600        assert!(!captured.is_forced && !captured.is_running);
601        assert_eq!(captured.progress, 0.0);
602
603        // An UNOBSERVED state parses honestly (passthrough; never an
604        // enum that would reject it — 10-LIVE-CAPTURES Decision 2).
605        let running: super::EamScheduledTask = serde_json::from_value(serde_json::json!({
606            "name": "t", "owner": "eam", "type": "Collect Backup",
607            "execStart": 1788947896010_i64, "message": "executing",
608            "repeats": false, "canPause": true, "canResume": true,
609            "canCancel": true, "taskState": "Running",
610            "isForced": true, "isRunning": true, "progress": 0.5
611        }))
612        .expect("unobserved Running/Pending rows MUST parse (spec-shaped cells)");
613        assert_eq!(running.task_state, "Running");
614        assert_eq!(running.progress, 0.5);
615    }
616
617    /// The modify 200 body parses the VERBATIM captured shape
618    /// (8.3.6 09:58:12Z full-record modify — 10-LIVE-CAPTURES §6a/§9):
619    /// success + changes[{name,type,collection,newSignature}] + null
620    /// problem.
621    #[test]
622    fn modify_outcome_parses_the_verbatim_captured_body() {
623        let outcome: super::ModifyOutcome = serde_json::from_value(serde_json::json!({
624            "success": true,
625            "changes": [
626                {
627                    "name": "ign-p10-scratch-sched",
628                    "type": "com.inductiveautomation.eam/eam-tasks",
629                    "collection": "core",
630                    "newSignature": "0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0"
631                }
632            ],
633            "problem": null
634        }))
635        .expect("the captured modify body parses");
636        assert!(outcome.success);
637        assert_eq!(outcome.changes.len(), 1);
638        let change = &outcome.changes[0];
639        assert_eq!(change.name, "ign-p10-scratch-sched");
640        assert_eq!(
641            change.resource_type,
642            "com.inductiveautomation.eam/eam-tasks"
643        );
644        assert_eq!(change.collection, "core");
645        assert_eq!(
646            change.new_signature.as_deref(),
647            Some("0d0dfea2919abb1f02fc86baea73d99696626524169a9ac36526044f89ac16e0")
648        );
649        assert_eq!(outcome.problem, None, "problem null on every success");
650    }
651
652    /// The delete 200 body parses with the 4th key `references`
653    /// (`[]` on success — 10-LIVE-CAPTURES §3b/§9), and the
654    /// signature-mismatch 500 problem shape parses honestly
655    /// (`success:false` + drifting message + stack frames, §3a/§10).
656    #[test]
657    fn delete_outcome_parses_success_and_mismatch_shapes() {
658        let success: super::DeleteOutcome = serde_json::from_value(serde_json::json!({
659            "success": true,
660            "changes": [
661                {
662                    "name": "ign-p10-scratch-sched",
663                    "type": "com.inductiveautomation.eam/eam-tasks",
664                    "collection": "core",
665                    "newSignature": "ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"
666                }
667            ],
668            "problem": null,
669            "references": []
670        }))
671        .expect("the captured delete body parses");
672        assert!(success.success);
673        assert_eq!(
674            success.references,
675            Some(Vec::new()),
676            "references [] on success, honestly empty"
677        );
678
679        // The 8.3.6 signature-mismatch 500 body (§3a verbatim): the
680        // message carries the STABLE `signature mismatch` substring
681        // inside DRIFTING prose (8.3.3's variant leaks the live
682        // signature and differs in frames — never parsed).
683        let mismatch: super::DeleteOutcome = serde_json::from_value(serde_json::json!({
684            "success": false,
685            "changes": [],
686            "problem": {
687                "message": "DELETE illegal: signature mismatch for 'ResourceId{resourcePath=com.inductiveautomation.eam/eam-tasks/ign-p10-scratch-sched, collectionName=core}'",
688                "stacktrace": [
689                    "com.inductiveautomation.ignition.common.resourcecollection.PushException: DELETE illegal: signature mismatch for …",
690                    "\tat com.inductiveautomation.ignition.gateway.resourcecollection.ChangeOperationValidationHandler$AtomicPushValidationHandler.throwIfInvalid(ChangeOperationValidationHandler.java:61)"
691                ]
692            },
693            "references": null
694        }))
695        .expect("the mismatch body parses");
696        assert!(!mismatch.success);
697        assert_eq!(mismatch.changes, Vec::<super::ResourceChange>::new());
698        let problem = mismatch.problem.expect("the problem rides");
699        assert!(
700            problem.message.contains("signature mismatch"),
701            "the stable substring survives drift"
702        );
703        assert_eq!(problem.stacktrace.len(), 2);
704        assert_eq!(
705            mismatch.references, None,
706            "references null on the failure shape"
707        );
708    }
709}