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}