1use std::collections::BTreeSet;
20
21use serde::Serialize;
22use serde_json::{Map, Value};
23
24use crate::client::GatewayApi;
25use crate::client::eam::{
26 DeleteOutcome, EamHistoryItem, EamScheduledTask, EamTaskRecord, ModifyOutcome, ResourceChange,
27};
28use crate::error::CoreError;
29
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TaskCreateVerdict {
42 Unguarded,
44 NeedsYes,
46 Refused,
48}
49
50const REFUSED_TYPES: [&str; 3] = [
54 "eam_restoreBackup",
55 "eam_installModules",
56 "eam_remoteUpgrade",
57];
58
59const MUTATING_TYPES: [&str; 7] = [
62 "eam_restart",
63 "eam_sendProject",
64 "eam_sendResource",
65 "eam_sendTags",
66 "eam_activateLicense",
67 "eam_updateLicense",
68 "eam_unactivateLicense",
69];
70
71pub fn task_create_guard(task_type: &str, schedule_mode: &str) -> TaskCreateVerdict {
78 if REFUSED_TYPES.contains(&task_type) {
79 return TaskCreateVerdict::Refused;
80 }
81 if !schedule_mode.eq("OnDemand") {
82 return TaskCreateVerdict::NeedsYes;
83 }
84 if MUTATING_TYPES.contains(&task_type) || task_type != "eam_backup" {
85 return TaskCreateVerdict::NeedsYes;
86 }
87 TaskCreateVerdict::Unguarded
88}
89
90#[derive(Debug, Serialize)]
92pub struct EamTaskCreateResult {
93 pub name: String,
95 pub task_type: String,
97 pub schedule_mode: String,
99 pub definition: Value,
102}
103
104#[derive(Debug, Serialize)]
106pub struct EamTaskForceResult {
107 pub task: String,
109 pub owner: String,
112 pub dispatched: bool,
115 pub history: Option<EamHistoryItem>,
119 pub preview: BlastRadiusPreview,
125}
126
127pub fn parse_setting(raw: &str) -> Result<(String, Value), CoreError> {
133 let Some((key, value)) = raw.split_once('=') else {
134 return Err(CoreError::InvalidInput {
135 reason: format!(
136 "--setting expects K=V (got {raw:?}) — a value that parses as \
137 bool/int rides typed, anything else stays a string; arrays and \
138 objects need --definition <PATH>"
139 ),
140 });
141 };
142 if key.is_empty() || value.is_empty() {
143 return Err(CoreError::InvalidInput {
144 reason: format!("--setting expects non-empty K and V (got {raw:?})"),
145 });
146 }
147 Ok((key.to_string(), auto_type(value)))
148}
149
150fn auto_type(value: &str) -> Value {
153 if value == "true" {
154 return Value::Bool(true);
155 }
156 if value == "false" {
157 return Value::Bool(false);
158 }
159 if let Ok(int) = value.parse::<i64>() {
160 return Value::Number(int.into());
161 }
162 Value::String(value.to_string())
163}
164
165fn deep_merge(base: &mut Value, overlay: &Value) {
170 match (base, overlay) {
171 (Value::Object(base_map), Value::Object(overlay_map)) => {
172 for (key, overlay_value) in overlay_map {
173 match base_map.get_mut(key) {
174 Some(base_value @ Value::Object(_)) if overlay_value.is_object() => {
175 deep_merge(base_value, overlay_value);
176 }
177 _ => {
178 base_map.insert(key.clone(), overlay_value.clone());
179 }
180 }
181 }
182 }
183 (base, overlay) => *base = overlay.clone(),
184 }
185}
186
187fn compose_task_definition(
206 name: &str,
207 task_type: &str,
208 targets: &[String],
209 settings: &[String],
210 definition: Option<&Value>,
211 schedule_mode: &str,
212) -> Result<Value, CoreError> {
213 let mut profile = Map::new();
214 profile.insert("type".to_string(), Value::String(task_type.to_string()));
215 profile.insert(
216 "scheduleMode".to_string(),
217 Value::String(schedule_mode.to_string()),
218 );
219
220 let mut composed_settings = Map::new();
221 composed_settings.insert(
222 "targetGateways".to_string(),
223 if targets.is_empty() {
224 Value::Array(vec![Value::String("_controller".to_string())])
225 } else {
226 Value::Array(targets.iter().map(|t| Value::String(t.clone())).collect())
227 },
228 );
229 composed_settings.insert("targetGroups".to_string(), Value::Array(vec![]));
230 for raw in settings {
231 let (key, value) = parse_setting(raw)?;
232 composed_settings.insert(key, value);
233 }
234 let mut settings_value = Value::Object(composed_settings);
235 if let Some(overlay) = definition {
236 deep_merge(&mut settings_value, overlay);
237 }
238
239 Ok(serde_json::json!({
240 "name": name,
241 "config": {
242 "profile": Value::Object(profile),
243 "settings": settings_value,
244 },
245 }))
246}
247
248pub async fn eam_task_create(
260 api: &dyn GatewayApi,
261 name: &str,
262 task_type: &str,
263 targets: &[String],
264 settings: &[String],
265 definition: Option<&Value>,
266 schedule_mode: &str,
267) -> Result<EamTaskCreateResult, CoreError> {
268 if let TaskCreateVerdict::Refused = task_create_guard(task_type, schedule_mode) {
271 return Err(CoreError::EamTaskTypeRefused {
272 task_type: task_type.to_string(),
273 });
274 }
275
276 let composed = compose_task_definition(
277 name,
278 task_type,
279 targets,
280 settings,
281 definition,
282 schedule_mode,
283 )?;
284 api.eam_task_create(&composed).await?;
285 Ok(EamTaskCreateResult {
286 name: name.to_string(),
287 task_type: task_type.to_string(),
288 schedule_mode: schedule_mode.to_string(),
289 definition: composed,
290 })
291}
292
293pub async fn eam_task_force(
302 api: &dyn GatewayApi,
303 name: &str,
304) -> Result<EamTaskForceResult, CoreError> {
305 let preview = build_blast_radius(api, "force", name).await?;
306 let owner = preview.owner.clone().unwrap_or_else(|| "eam".to_string());
307
308 api.eam_task_force(&owner, name).await?;
309
310 let history = api
311 .eam_task_history(Some(20), Some(name))
312 .await
313 .ok()
314 .and_then(|page| {
315 page.items.into_iter().find(|item| {
316 let forced = format!("{name} (forced)");
317 item.task_name == name || item.task_name == forced
318 })
319 });
320
321 Ok(EamTaskForceResult {
322 task: name.to_string(),
323 owner,
324 dispatched: true,
325 history,
326 preview,
327 })
328}
329
330#[derive(Debug, Serialize)]
343pub struct EamLifecycleResult {
344 pub task: String,
346 pub action: String,
348 pub previous_state: Option<String>,
351 pub config_suspended: Option<bool>,
356 pub pending: Option<EamScheduledTask>,
361 pub fired: bool,
366 pub reason: Option<String>,
370}
371
372pub fn lifecycle_precheck(action: &str, task_name: &str) -> Result<(), CoreError> {
380 if task_name.trim().is_empty() {
381 return Err(CoreError::InvalidInput {
382 reason: format!("eam task {action}: the task name must not be empty/whitespace"),
383 });
384 }
385 Ok(())
386}
387
388pub fn suspend_recheck(record: &EamTaskRecord) -> Result<(), CoreError> {
399 let suspended = record
400 .config
401 .get("profile")
402 .and_then(|profile| profile.get("isSuspended"))
403 .and_then(Value::as_bool);
404 if suspended == Some(true) {
405 return Err(CoreError::InvalidInput {
406 reason: format!(
407 "task {:?} is already suspended (config.profile.isSuspended=true) — \
408 nothing to suspend; `eam task resume` it first",
409 record.name
410 ),
411 });
412 }
413 Ok(())
414}
415
416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum CancelDecision {
433 Fire,
435 NoPending,
437 NotPermitted,
439}
440
441pub fn cancel_decision(pending: Option<&EamScheduledTask>) -> CancelDecision {
443 match pending {
444 None => CancelDecision::NoPending,
445 Some(row) if row.can_cancel => CancelDecision::Fire,
446 Some(_) => CancelDecision::NotPermitted,
447 }
448}
449
450fn current_state_of(record: &EamTaskRecord) -> Option<String> {
452 record
453 .scheduled_task_state
454 .as_ref()
455 .and_then(|state| state.get("currentState"))
456 .and_then(Value::as_str)
457 .map(str::to_string)
458}
459
460fn is_suspended_of(record: &EamTaskRecord) -> Option<bool> {
462 record
463 .config
464 .get("profile")
465 .and_then(|profile| profile.get("isSuspended"))
466 .and_then(Value::as_bool)
467}
468
469async fn pending_row_for(
473 api: &dyn GatewayApi,
474 name: &str,
475) -> Result<Option<EamScheduledTask>, CoreError> {
476 Ok(pending_rows_for(api, name).await?.into_iter().next())
477}
478
479pub async fn eam_task_suspend(
488 api: &dyn GatewayApi,
489 name: &str,
490) -> Result<EamLifecycleResult, CoreError> {
491 lifecycle_precheck("suspend", name)?;
492 let record = api.eam_task_find(name).await?;
493 let previous_state = current_state_of(&record);
494 suspend_recheck(&record)?;
495 api.eam_task_suspend(name).await?;
496 let readback = api.eam_task_find(name).await?;
499 Ok(EamLifecycleResult {
500 task: name.to_string(),
501 action: "suspended".to_string(),
502 previous_state,
503 config_suspended: is_suspended_of(&readback),
504 pending: None,
505 fired: true,
506 reason: None,
507 })
508}
509
510pub async fn eam_task_resume(
516 api: &dyn GatewayApi,
517 name: &str,
518) -> Result<EamLifecycleResult, CoreError> {
519 lifecycle_precheck("resume", name)?;
520 let record = api.eam_task_find(name).await?;
521 let previous_state = current_state_of(&record);
522 api.eam_task_resume(name).await?;
523 let readback = api.eam_task_find(name).await?;
524 Ok(EamLifecycleResult {
525 task: name.to_string(),
526 action: "resumed".to_string(),
527 previous_state,
528 config_suspended: is_suspended_of(&readback),
529 pending: None,
530 fired: true,
531 reason: None,
532 })
533}
534
535pub async fn eam_task_cancel(
541 api: &dyn GatewayApi,
542 name: &str,
543) -> Result<EamLifecycleResult, CoreError> {
544 lifecycle_precheck("cancel", name)?;
545 let record = api.eam_task_find(name).await?;
546 let previous_state = current_state_of(&record);
547 let pending = pending_row_for(api, name).await?;
548 match cancel_decision(pending.as_ref()) {
549 CancelDecision::Fire => {
550 api.eam_task_cancel(name).await?;
551 let after = pending_row_for(api, name).await?;
552 Ok(EamLifecycleResult {
553 task: name.to_string(),
554 action: "cancelled".to_string(),
555 previous_state,
556 config_suspended: None,
557 pending: after,
558 fired: true,
559 reason: None,
560 })
561 }
562 CancelDecision::NoPending => Ok(EamLifecycleResult {
563 task: name.to_string(),
564 action: "cancelled".to_string(),
565 previous_state,
566 config_suspended: None,
567 pending: None,
568 fired: false,
569 reason: Some("no pending execution".to_string()),
570 }),
571 CancelDecision::NotPermitted => Ok(EamLifecycleResult {
572 task: name.to_string(),
573 action: "cancelled".to_string(),
574 previous_state,
575 config_suspended: None,
576 pending,
577 fired: false,
578 reason: Some(
579 "the gateway reports canCancel=false for the pending execution".to_string(),
580 ),
581 }),
582 }
583}
584
585#[derive(Debug, Clone, Default)]
613pub struct TaskChange {
614 pub enabled: Option<bool>,
616 pub description: Option<String>,
618 pub schedule_mode: Option<String>,
625 pub settings_overlay: Option<Value>,
629}
630
631#[derive(Debug, Serialize)]
633pub struct EamModifyResult {
634 pub task: String,
637 pub changed: Vec<String>,
641 pub definition: Value,
645 pub put_outcome: Option<ModifyOutcome>,
651 pub readback: Value,
657}
658
659#[derive(Debug, Serialize)]
661pub struct EamDeleteResult {
662 pub task: String,
664 pub deleted: bool,
667 pub changes: Vec<ResourceChange>,
670 pub affected: Vec<String>,
676}
677
678fn apply_task_change(body: &mut Value, change: &TaskChange) -> Vec<String> {
686 let mut changed = Vec::new();
687 let TaskChange {
688 enabled,
689 description,
690 schedule_mode,
691 settings_overlay,
692 } = change;
693 if let Some(enabled) = enabled {
694 *slot(body, "enabled") = Value::Bool(*enabled);
695 changed.push("enabled".to_string());
696 }
697 if let Some(description) = description {
698 *slot(body, "description") = Value::String(description.clone());
699 changed.push("description".to_string());
700 }
701 if let Some(schedule_mode) = schedule_mode {
702 let profile = slot(slot(body, "config"), "profile");
703 if !profile.is_object() {
704 *profile = Value::Object(Map::new());
705 }
706 *slot(profile, "scheduleMode") = Value::String(schedule_mode.clone());
707 changed.push("config.profile.scheduleMode".to_string());
708 }
709 if let Some(overlay) = settings_overlay {
710 let settings = slot(slot(body, "config"), "settings");
711 deep_merge(settings, overlay);
712 changed.push("config.settings".to_string());
713 }
714 changed
715}
716
717fn slot<'a>(parent: &'a mut Value, key: &str) -> &'a mut Value {
720 if !parent.is_object() {
721 *parent = Value::Object(Map::new());
722 }
723 parent
724 .as_object_mut()
725 .expect("just ensured an object")
726 .entry(key.to_string())
727 .or_insert(Value::Null)
728}
729
730fn record_to_value(record: &EamTaskRecord) -> Result<Value, CoreError> {
739 let mut value = serde_json::to_value(record).map_err(|err| {
740 CoreError::Internal(format!(
741 "task record failed to serialize for the clone: {err}"
742 ))
743 })?;
744 if value.get("scheduledTaskState") == Some(&Value::Null)
745 && let Some(map) = value.as_object_mut()
746 {
747 map.remove("scheduledTaskState");
748 }
749 Ok(value)
750}
751
752async fn reclassify_stale_signature(
766 api: &dyn GatewayApi,
767 name: &str,
768 sent_signature: &str,
769 err: CoreError,
770) -> CoreError {
771 let stale = matches!(
772 api.eam_task_find(name).await,
773 Ok(fresh) if fresh.signature.as_deref().is_some_and(|sig| sig != sent_signature)
774 );
775 if stale {
776 return CoreError::InvalidInput {
777 reason: format!(
778 "definition {name:?} changed concurrently (signature mismatch on write) — \
779 the gateway answers mismatches with a 500 and leaves the resource untouched; \
780 re-run to apply against the current signature"
781 ),
782 };
783 }
784 err
785}
786
787pub async fn eam_task_modify(
798 api: &dyn GatewayApi,
799 name: &str,
800 change: TaskChange,
801) -> Result<EamModifyResult, CoreError> {
802 lifecycle_precheck("modify", name)?;
803 let TaskChange {
804 enabled,
805 description,
806 schedule_mode,
807 settings_overlay,
808 } = &change;
809 if enabled.is_none()
810 && description.is_none()
811 && schedule_mode.is_none()
812 && settings_overlay.is_none()
813 {
814 return Err(CoreError::InvalidInput {
815 reason: format!(
816 "eam task modify {name:?}: no targeted keys — a modify must change \
817 something (enabled / description / schedule-mode / settings overlay)"
818 ),
819 });
820 }
821
822 let record = api.eam_task_find(name).await?;
823 let signature = record
824 .signature
825 .clone()
826 .ok_or_else(|| CoreError::InvalidInput {
827 reason: format!(
828 "the found record for {name:?} carries no mutation signature — modify \
829 requires it (list-shape records don't carry one; re-find)"
830 ),
831 })?;
832 let mut body = record_to_value(&record)?;
833 let changed = apply_task_change(&mut body, &change);
834 let definition = body.clone();
835
836 let put_outcome = match api.eam_task_modify(&body).await {
837 Ok(outcome) => outcome,
838 Err(err) => {
839 return Err(reclassify_stale_signature(api, name, &signature, err).await);
840 }
841 };
842
843 let readback = match api.eam_task_find(name).await {
844 Ok(fresh) => record_to_value(&fresh)?,
845 Err(_) => Value::Null,
846 };
847
848 Ok(EamModifyResult {
849 task: name.to_string(),
850 changed,
851 definition,
852 put_outcome,
853 readback,
854 })
855}
856
857fn affected_resources(outcome: &DeleteOutcome) -> Vec<String> {
863 let mut names: Vec<String> = outcome
864 .changes
865 .iter()
866 .map(|change| change.name.clone())
867 .collect();
868 if let Some(references) = &outcome.references {
869 for reference in references {
870 match reference {
871 Value::String(name) => names.push(name.clone()),
872 Value::Object(map) => {
873 if let Some(Value::String(name)) = map.get("name") {
874 names.push(name.clone());
875 }
876 }
877 _ => {}
878 }
879 }
880 }
881 let mut seen = BTreeSet::new();
882 names
883 .into_iter()
884 .filter(|name| seen.insert(name.clone()))
885 .collect()
886}
887
888pub async fn eam_task_delete(
899 api: &dyn GatewayApi,
900 name: &str,
901) -> Result<EamDeleteResult, CoreError> {
902 lifecycle_precheck("delete", name)?;
903 let record = api.eam_task_find(name).await?;
904 let signature = record
905 .signature
906 .clone()
907 .ok_or_else(|| CoreError::InvalidInput {
908 reason: format!(
909 "the found record for {name:?} carries no mutation signature — delete \
910 is signature-keyed (list-shape records don't carry one; re-find)"
911 ),
912 })?;
913
914 let outcome = match api.eam_task_delete(name, &signature, false).await {
915 Ok(outcome) if outcome.success => outcome,
916 Ok(demand) => {
917 let _ = demand;
921 match api.eam_task_delete(name, &signature, true).await {
922 Ok(retry) => retry,
923 Err(err) => {
924 return Err(reclassify_stale_signature(api, name, &signature, err).await);
925 }
926 }
927 }
928 Err(err) => {
929 return Err(reclassify_stale_signature(api, name, &signature, err).await);
930 }
931 };
932
933 Ok(EamDeleteResult {
934 task: name.to_string(),
935 deleted: outcome.success,
936 changes: outcome.changes.clone(),
937 affected: affected_resources(&outcome),
938 })
939}
940
941#[derive(Debug, Serialize)]
957pub struct BlastRadiusPreview {
958 pub task: String,
960 pub task_type: Option<String>,
964 pub schedule_mode: Option<String>,
966 pub state: Option<String>,
968 pub owner: Option<String>,
971 pub config_suspended: Option<bool>,
974 pub target_gateways: Vec<String>,
980 pub pending_executions: Vec<EamScheduledTask>,
984 pub controller_impact: String,
989 pub verb: String,
992}
993
994fn agents_fragment(count: usize) -> String {
996 format!("{count} agent{}", if count == 1 { "" } else { "s" })
997}
998
999fn controller_impact(
1004 verb: &str,
1005 task: &str,
1006 task_type: Option<&str>,
1007 agents: usize,
1008 pending: usize,
1009) -> String {
1010 let type_note = task_type.map(|t| format!(" ({t})")).unwrap_or_default();
1011 let agents = agents_fragment(agents);
1012 match verb {
1013 "suspend" => format!(
1014 "suspends task {task}{type_note} — future scheduled dispatches to {agents} stop until resumed"
1015 ),
1016 "resume" => format!(
1017 "resumes task {task}{type_note} — scheduled dispatches to {agents} can fire again"
1018 ),
1019 "cancel" if pending > 0 => format!(
1020 "cancels the pending execution of task {task}{type_note} — {pending} queued dispatch{} to {agents}",
1021 if pending == 1 { "" } else { "es" }
1022 ),
1023 "cancel" => format!("task {task}{type_note} has no pending execution to cancel"),
1024 "force" => format!("dispatches task {task}{type_note} now to {agents}"),
1025 "modify" => format!(
1026 "rewrites the definition of task {task}{type_note} — dispatch behavior to {agents} follows the new body"
1027 ),
1028 "delete" => {
1029 format!("deletes task {task}{type_note} permanently — dispatches to {agents} stop")
1030 }
1031 other => format!("examines task {task}{type_note} for {other} — targets {agents}"),
1032 }
1033}
1034
1035fn compose_blast_radius(
1038 record: &EamTaskRecord,
1039 pending_executions: Vec<EamScheduledTask>,
1040 verb: &str,
1041) -> BlastRadiusPreview {
1042 let pending_executions: Vec<EamScheduledTask> = pending_executions
1045 .into_iter()
1046 .filter(|row| row.name == record.name)
1047 .collect();
1048 let profile = record.config.get("profile");
1049 let task_type = profile
1050 .and_then(|p| p.get("type"))
1051 .and_then(Value::as_str)
1052 .map(str::to_string);
1053 let schedule_mode = profile
1054 .and_then(|p| p.get("scheduleMode"))
1055 .and_then(Value::as_str)
1056 .map(str::to_string);
1057 let config_suspended = profile
1058 .and_then(|p| p.get("isSuspended"))
1059 .and_then(Value::as_bool);
1060 let state = current_state_of(record);
1061 let owner = record
1062 .scheduled_task_state
1063 .as_ref()
1064 .and_then(|s| s.get("details"))
1065 .and_then(|d| d.get("owner"))
1066 .and_then(Value::as_str)
1067 .map(str::to_string);
1068 let target_gateways: Vec<String> = record
1069 .config
1070 .get("settings")
1071 .and_then(|settings| settings.get("targetGateways"))
1072 .and_then(Value::as_array)
1073 .map(|gateways| {
1074 gateways
1075 .iter()
1076 .filter_map(Value::as_str)
1077 .map(str::to_string)
1078 .collect()
1079 })
1080 .unwrap_or_default();
1081 let controller_impact = controller_impact(
1082 verb,
1083 &record.name,
1084 task_type.as_deref(),
1085 target_gateways.len(),
1086 pending_executions.len(),
1087 );
1088 BlastRadiusPreview {
1089 task: record.name.clone(),
1090 task_type,
1091 schedule_mode,
1092 state,
1093 owner,
1094 config_suspended,
1095 target_gateways,
1096 pending_executions,
1097 controller_impact,
1098 verb: verb.to_string(),
1099 }
1100}
1101
1102async fn pending_rows_for(
1110 api: &dyn GatewayApi,
1111 name: &str,
1112) -> Result<Vec<EamScheduledTask>, CoreError> {
1113 let mut rows = Vec::new();
1114 for running in [false, true] {
1115 rows.extend(
1116 api.eam_tasks_scheduled(running)
1117 .await?
1118 .into_iter()
1119 .filter(|row| row.name == name),
1120 );
1121 }
1122 Ok(rows)
1123}
1124
1125pub async fn build_blast_radius(
1132 api: &dyn GatewayApi,
1133 verb: &str,
1134 task_name: &str,
1135) -> Result<BlastRadiusPreview, CoreError> {
1136 let record = api.eam_task_find(task_name).await?;
1137 let pending_executions = pending_rows_for(api, task_name).await?;
1138 Ok(compose_blast_radius(&record, pending_executions, verb))
1139}
1140
1141pub fn render_preview_line(preview: &BlastRadiusPreview) -> String {
1146 format!(
1147 "{verb} {task}: {impact} targets: [{targets}] pending: {pending}",
1148 verb = preview.verb,
1149 task = preview.task,
1150 impact = preview.controller_impact,
1151 targets = preview.target_gateways.join(", "),
1152 pending = preview.pending_executions.len(),
1153 )
1154}
1155
1156#[derive(Debug, Serialize)]
1158pub struct EamHistoryResult {
1159 pub items: Vec<EamHistoryItem>,
1162 pub count: usize,
1164}
1165
1166#[derive(Debug, Serialize)]
1168pub struct EamTaskSummary {
1169 pub name: String,
1171 pub task_type: Option<String>,
1174 pub schedule_mode: Option<String>,
1177 pub current_state: Option<String>,
1180}
1181
1182#[derive(Debug, Serialize)]
1184pub struct EamTasksResult {
1185 pub tasks: Vec<EamTaskSummary>,
1187}
1188
1189#[derive(Debug, Serialize)]
1191pub struct EamTaskDetailResult {
1192 pub name: String,
1194 pub definition: serde_json::Value,
1197 pub state: serde_json::Value,
1200}
1201
1202pub async fn eam_history(
1205 api: &dyn GatewayApi,
1206 limit: Option<u32>,
1207 search: Option<&str>,
1208) -> Result<EamHistoryResult, CoreError> {
1209 let page = api.eam_task_history(limit, search).await?;
1210 Ok(EamHistoryResult {
1211 count: page.items.len(),
1212 items: page.items,
1213 })
1214}
1215
1216pub async fn eam_tasks(api: &dyn GatewayApi) -> Result<EamTasksResult, CoreError> {
1218 let page = api.eam_task_definitions().await?;
1219 Ok(EamTasksResult {
1220 tasks: page.items.iter().map(summary_from).collect(),
1221 })
1222}
1223
1224pub async fn eam_task_detail(
1227 api: &dyn GatewayApi,
1228 name: &str,
1229) -> Result<EamTaskDetailResult, CoreError> {
1230 let record = api.eam_task_find(name).await?;
1231 Ok(EamTaskDetailResult {
1232 name: record.name.clone(),
1233 definition: serde_json::to_value(&record).unwrap_or(serde_json::Value::Null),
1234 state: record
1235 .scheduled_task_state
1236 .clone()
1237 .unwrap_or(serde_json::Value::Null),
1238 })
1239}
1240
1241fn summary_from(record: &EamTaskRecord) -> EamTaskSummary {
1243 let profile = record.config.get("profile");
1244 EamTaskSummary {
1245 name: record.name.clone(),
1246 task_type: profile
1247 .and_then(|p| p.get("type"))
1248 .and_then(serde_json::Value::as_str)
1249 .map(str::to_string),
1250 schedule_mode: profile
1251 .and_then(|p| p.get("scheduleMode"))
1252 .and_then(serde_json::Value::as_str)
1253 .map(str::to_string),
1254 current_state: record
1255 .scheduled_task_state
1256 .as_ref()
1257 .and_then(|state| state.get("currentState"))
1258 .and_then(serde_json::Value::as_str)
1259 .map(str::to_string),
1260 }
1261}
1262
1263#[cfg(test)]
1264mod tests {
1265 use super::{
1266 CancelDecision, EamLifecycleResult, EamTaskRecord, TaskChange, TaskCreateVerdict,
1267 affected_resources, apply_task_change, auto_type, cancel_decision, compose_blast_radius,
1268 compose_task_definition, deep_merge, lifecycle_precheck, parse_setting,
1269 render_preview_line, summary_from, suspend_recheck, task_create_guard,
1270 };
1271 use crate::client::eam::{DeleteOutcome, EamScheduledTask};
1272
1273 #[test]
1277 fn guard_ladder_is_exhaustive_over_the_taxonomy() {
1278 assert_eq!(
1280 task_create_guard("eam_backup", "OnDemand"),
1281 TaskCreateVerdict::Unguarded
1282 );
1283
1284 for refused in [
1287 "eam_restoreBackup",
1288 "eam_installModules",
1289 "eam_remoteUpgrade",
1290 ] {
1291 assert_eq!(
1292 task_create_guard(refused, "OnDemand"),
1293 TaskCreateVerdict::Refused,
1294 "{refused} refuses even OnDemand"
1295 );
1296 assert_eq!(
1297 task_create_guard(refused, "Scheduled"),
1298 TaskCreateVerdict::Refused,
1299 "{refused} refuses under any schedule"
1300 );
1301 }
1302
1303 for mutating in [
1305 "eam_restart",
1306 "eam_sendProject",
1307 "eam_sendResource",
1308 "eam_sendTags",
1309 "eam_activateLicense",
1310 "eam_updateLicense",
1311 "eam_unactivateLicense",
1312 ] {
1313 assert_eq!(
1314 task_create_guard(mutating, "OnDemand"),
1315 TaskCreateVerdict::NeedsYes,
1316 "{mutating} needs --yes"
1317 );
1318 }
1319
1320 for mode in ["Immediate", "Scheduled", "AtTime", "AtDelay", "weird-mode"] {
1323 assert_eq!(
1324 task_create_guard("eam_backup", mode),
1325 TaskCreateVerdict::NeedsYes,
1326 "scheduleMode {mode} arms the task"
1327 );
1328 }
1329
1330 assert_eq!(
1333 task_create_guard("eam_unknownFutureType", "OnDemand"),
1334 TaskCreateVerdict::NeedsYes
1335 );
1336 }
1337
1338 #[test]
1342 fn setting_parsing_auto_types_scalars() {
1343 assert_eq!(
1344 parse_setting("concurrentBackups=2").unwrap(),
1345 ("concurrentBackups".to_string(), serde_json::json!(2))
1346 );
1347 assert_eq!(
1348 parse_setting("forceBackups=true").unwrap(),
1349 ("forceBackups".to_string(), serde_json::json!(true))
1350 );
1351 assert_eq!(
1352 parse_setting("forceBackups=false").unwrap(),
1353 ("forceBackups".to_string(), serde_json::json!(false))
1354 );
1355 assert_eq!(
1357 parse_setting("n=-7").unwrap(),
1358 ("n".to_string(), serde_json::json!(-7))
1359 );
1360 assert_eq!(
1363 parse_setting("note=hello world").unwrap(),
1364 ("note".to_string(), serde_json::json!("hello world"))
1365 );
1366 assert_eq!(
1367 parse_setting("v=1.5").unwrap(),
1368 ("v".to_string(), serde_json::json!("1.5")),
1369 "floats are NOT auto-typed (the tags-write rule: bool/int only)"
1370 );
1371
1372 let err = parse_setting("noequalsign").expect_err("refuses");
1373 assert_eq!(err.exit_code(), 2);
1374 assert_eq!(err.code(), "invalid_input");
1375 assert!(err.to_string().contains("--definition"));
1376 assert!(parse_setting("=v").is_err(), "empty key refuses");
1377 assert!(parse_setting("k=").is_err(), "empty value refuses");
1378 }
1379
1380 #[test]
1382 fn auto_type_covers_bool_int_string() {
1383 assert_eq!(auto_type("true"), serde_json::json!(true));
1384 assert_eq!(auto_type("false"), serde_json::json!(false));
1385 assert_eq!(auto_type("42"), serde_json::json!(42));
1386 assert_eq!(auto_type("text"), serde_json::json!("text"));
1387 assert_eq!(auto_type("True"), serde_json::json!("True"), "case matters");
1388 }
1389
1390 #[test]
1393 fn deep_merge_merges_objects_replaces_arrays() {
1394 let mut base = serde_json::json!({
1395 "type": "eam_backup",
1396 "scheduleMode": "OnDemand",
1397 "targetGateways": ["gw-a"],
1398 "settingsNested": {"a": 1, "b": {"x": 1}}
1399 });
1400 deep_merge(
1401 &mut base,
1402 &serde_json::json!({
1403 "targetGateways": ["gw-b", "gw-c"],
1404 "targetGroups": [],
1405 "concurrentBackups": 2,
1406 "forceBackups": true,
1407 "settingsNested": {"b": {"y": 2}}
1408 }),
1409 );
1410 assert_eq!(
1411 base,
1412 serde_json::json!({
1413 "type": "eam_backup",
1414 "scheduleMode": "OnDemand",
1415 "targetGateways": ["gw-b", "gw-c"],
1416 "targetGroups": [],
1417 "concurrentBackups": 2,
1418 "forceBackups": true,
1419 "settingsNested": {"a": 1, "b": {"x": 1, "y": 2}}
1420 })
1421 );
1422 }
1423
1424 #[test]
1429 fn composition_splits_profile_and_settings_the_live_shape() {
1430 let bare =
1434 compose_task_definition("uat-backup-demo", "eam_backup", &[], &[], None, "OnDemand")
1435 .expect("bare composition");
1436 assert_eq!(bare["name"], serde_json::json!("uat-backup-demo"));
1437 assert_eq!(
1438 bare["config"]["profile"],
1439 serde_json::json!({"type": "eam_backup", "scheduleMode": "OnDemand"}),
1440 "profile carries type + scheduleMode ONLY (isSuspended is server-owned)"
1441 );
1442 assert_eq!(
1443 bare["config"]["settings"],
1444 serde_json::json!({"targetGateways": ["_controller"], "targetGroups": []})
1445 );
1446
1447 let targeted = compose_task_definition(
1449 "nightly-backup",
1450 "eam_backup",
1451 &["gw-a".to_string()],
1452 &[
1453 "concurrentBackups=2".to_string(),
1454 "forceBackups=true".to_string(),
1455 ],
1456 None,
1457 "OnDemand",
1458 )
1459 .expect("targeted composition");
1460 assert_eq!(
1461 targeted["config"]["settings"]["targetGateways"],
1462 serde_json::json!(["gw-a"])
1463 );
1464 assert_eq!(
1465 targeted["config"]["settings"]["concurrentBackups"],
1466 serde_json::json!(2),
1467 "K=V lands in config.SETTINGS"
1468 );
1469 assert!(
1470 targeted["config"]["profile"]
1471 .get("concurrentBackups")
1472 .is_none(),
1473 "profile carries NO settings keys"
1474 );
1475 assert!(
1476 targeted["config"]["profile"]
1477 .get("targetGateways")
1478 .is_none(),
1479 "targetGateways lives in settings, not profile"
1480 );
1481
1482 let overlayed = compose_task_definition(
1485 "t3",
1486 "eam_backup",
1487 &["gw-a".to_string()],
1488 &[],
1489 Some(&serde_json::json!({
1490 "targetGateways": ["gw-b", "gw-c"],
1491 "concurrentBackups": 5
1492 })),
1493 "OnDemand",
1494 )
1495 .expect("overlay composition");
1496 assert_eq!(
1497 overlayed["config"]["settings"]["targetGateways"],
1498 serde_json::json!(["gw-b", "gw-c"]),
1499 "the overlay's array REPLACES the composed default"
1500 );
1501 assert_eq!(overlayed["config"]["settings"]["concurrentBackups"], 5);
1502 assert_eq!(
1503 overlayed["config"]["settings"]["targetGroups"],
1504 serde_json::json!([]),
1505 "composed keys the overlay omits survive the merge"
1506 );
1507 }
1508
1509 #[test]
1513 fn summary_projects_the_agent_stable_keys() {
1514 let record: EamTaskRecord = serde_json::from_value(serde_json::json!({
1515 "name": "nightly-backup",
1516 "config": {"profile": {"type": "eam_backup", "scheduleMode": "OnDemand"}},
1517 "scheduledTaskState": {"currentState": "IDLE", "details": {"owner": "eam"}}
1518 }))
1519 .expect("record parses");
1520 let summary = summary_from(&record);
1521 assert_eq!(summary.name, "nightly-backup");
1522 assert_eq!(summary.task_type.as_deref(), Some("eam_backup"));
1523 assert_eq!(summary.schedule_mode.as_deref(), Some("OnDemand"));
1524 assert_eq!(summary.current_state.as_deref(), Some("IDLE"));
1525
1526 let bare: EamTaskRecord = serde_json::from_value(serde_json::json!({
1527 "name": "bare"
1528 }))
1529 .expect("bare record parses");
1530 let summary = summary_from(&bare);
1531 assert_eq!(summary.task_type, None);
1532 assert_eq!(summary.schedule_mode, None);
1533 assert_eq!(summary.current_state, None);
1534 }
1535
1536 #[test]
1542 fn lifecycle_result_serializes_all_keys_always() {
1543 let fired = EamLifecycleResult {
1544 task: "nightly-backup".to_string(),
1545 action: "suspended".to_string(),
1546 previous_state: Some("Scheduled".to_string()),
1547 config_suspended: Some(true),
1548 pending: None,
1549 fired: true,
1550 reason: None,
1551 };
1552 let json = serde_json::to_value(&fired).expect("serializes");
1553 let map = json.as_object().expect("object shape");
1554 for key in [
1555 "task",
1556 "action",
1557 "previous_state",
1558 "config_suspended",
1559 "pending",
1560 "fired",
1561 "reason",
1562 ] {
1563 assert!(map.contains_key(key), "key {key} always rides");
1564 }
1565 assert_eq!(json["pending"], serde_json::Value::Null);
1566 assert_eq!(json["reason"], serde_json::Value::Null);
1567 assert_eq!(json["config_suspended"], serde_json::json!(true));
1568
1569 let noop = EamLifecycleResult {
1570 task: "t".to_string(),
1571 action: "cancelled".to_string(),
1572 previous_state: None,
1573 config_suspended: None,
1574 pending: None,
1575 fired: false,
1576 reason: Some("no pending execution".to_string()),
1577 };
1578 let json = serde_json::to_value(&noop).expect("serializes");
1579 assert_eq!(json["fired"], serde_json::json!(false));
1580 assert_eq!(json["reason"], serde_json::json!("no pending execution"));
1581 assert_eq!(
1582 json["previous_state"],
1583 serde_json::Value::Null,
1584 "no state on a find that carried none — null, not omitted"
1585 );
1586 }
1587
1588 #[test]
1591 fn lifecycle_precheck_refuses_empty_and_whitespace_names() {
1592 for name in ["", " ", "\t\n"] {
1593 for action in ["suspend", "resume", "cancel"] {
1594 let err =
1595 lifecycle_precheck(action, name).expect_err("empty/whitespace names refuse");
1596 assert_eq!(err.exit_code(), 2, "usage class");
1597 assert_eq!(err.code(), "invalid_input");
1598 let message = err.to_string();
1599 assert!(
1600 message.contains(action),
1601 "the refusal names the verb: {message}"
1602 );
1603 }
1604 }
1605 lifecycle_precheck("suspend", "nightly-backup").expect("real names pass");
1606 lifecycle_precheck("cancel", " x ")
1607 .expect("trimmed-nonempty passes (the gateway owns identifier rules)");
1608 }
1609
1610 #[test]
1614 fn suspend_recheck_refuses_only_already_suspended() {
1615 let record_of = |is_suspended: serde_json::Value| -> EamTaskRecord {
1616 serde_json::from_value(serde_json::json!({
1617 "name": "nightly-backup",
1618 "config": {"profile": {"isSuspended": is_suspended}}
1619 }))
1620 .expect("record parses")
1621 };
1622
1623 let err = suspend_recheck(&record_of(serde_json::json!(true)))
1624 .expect_err("already-suspended refuses pre-write");
1625 assert_eq!(err.exit_code(), 2);
1626 assert_eq!(err.code(), "invalid_input");
1627 let message = err.to_string();
1628 assert!(
1629 message.contains("nightly-backup") && message.contains("already suspended"),
1630 "the refusal names the task + state: {message}"
1631 );
1632
1633 suspend_recheck(&record_of(serde_json::json!(false)))
1634 .expect("false fires (the normal case)");
1635 suspend_recheck(&record_of(serde_json::Value::Null))
1636 .expect("absent flag fires (no invented rules)");
1637 suspend_recheck(&record_of(serde_json::json!("weird")))
1638 .expect("unparseable flag fires (the gateway's 500 is the honest answer)");
1639 }
1640
1641 #[test]
1645 fn cancel_decision_branches_mirror_the_captures() {
1646 assert_eq!(
1647 cancel_decision(None),
1648 CancelDecision::NoPending,
1649 "nothing pending → the honest no-op, no doomed POST"
1650 );
1651
1652 let row_of = |can_cancel: bool| -> EamScheduledTask {
1653 serde_json::from_value(serde_json::json!({
1654 "name": "nightly-backup",
1655 "owner": "eam",
1656 "type": "Collect Backup",
1657 "execStart": null,
1658 "message": "",
1659 "repeats": true,
1660 "canPause": true,
1661 "canResume": false,
1662 "canCancel": can_cancel,
1663 "taskState": "Scheduled",
1664 "isForced": false,
1665 "isRunning": false,
1666 "progress": 0.0
1667 }))
1668 .expect("the captured row shape parses")
1669 };
1670
1671 assert_eq!(
1672 cancel_decision(Some(&row_of(true))),
1673 CancelDecision::Fire,
1674 "the captured Scheduled cell (canCancel: true) fires"
1675 );
1676 assert_eq!(
1677 cancel_decision(Some(&row_of(false))),
1678 CancelDecision::NotPermitted,
1679 "the gateway's own canCancel=false is reported, not overridden"
1680 );
1681 }
1682
1683 fn full_record_fixture() -> serde_json::Value {
1690 serde_json::json!({
1691 "type": "com.inductiveautomation.eam/eam-tasks",
1692 "name": "ign-p10-scratch-sched",
1693 "description": "scratch",
1694 "enabled": true,
1695 "version": 1,
1696 "collection": "core",
1697 "collections": ["core"],
1698 "signature": "e5ac8bee3a6ba85e40923c0e02d29507600c57519eb8e4d78bd8c258197fe9c6",
1699 "config": {
1700 "profile": {
1701 "type": "eam_backup",
1702 "isSuspended": false,
1703 "scheduleMode": "Scheduled",
1704 "scheduleDetails": "0/30 * * * * ?"
1705 },
1706 "settings": {
1707 "targetGateways": ["_controller"],
1708 "targetGroups": [],
1709 "concurrentBackups": 0,
1710 "forceBackups": false
1711 }
1712 },
1713 "data": ["config.json"],
1714 "attributes": {"uuid": "c1aa2b52-46ad-46ea-962b-9d2498f35db1", "enabled": true},
1715 "metrics": {},
1716 "healthchecks": {"scheduledTaskState": {"currentState": "Scheduled"}}
1717 })
1718 }
1719
1720 #[test]
1725 fn modify_put_body_preserves_the_fixture_record_except_targeted_keys() {
1726 let mut body = full_record_fixture();
1728 let changed = apply_task_change(
1729 &mut body,
1730 &TaskChange {
1731 description: Some("rewritten note".to_string()),
1732 ..Default::default()
1733 },
1734 );
1735 assert_eq!(changed, vec!["description"]);
1736 let fixture = full_record_fixture();
1737 assert_eq!(
1738 body["config"]["settings"], fixture["config"]["settings"],
1739 "config.settings rides VERBATIM — omitting/reshaping it is the 422 trap"
1740 );
1741 assert_eq!(
1742 body["signature"], fixture["signature"],
1743 "the ORIGINAL signature"
1744 );
1745 assert_eq!(body["collection"], fixture["collection"]);
1746 assert_eq!(body["config"]["profile"], fixture["config"]["profile"]);
1747 assert_eq!(
1748 body["data"], fixture["data"],
1749 "unknown round-trip keys survive"
1750 );
1751 assert_eq!(body["attributes"], fixture["attributes"]);
1752 assert_eq!(body["description"], serde_json::json!("rewritten note"));
1753
1754 let mut body = full_record_fixture();
1757 let changed = apply_task_change(
1758 &mut body,
1759 &TaskChange {
1760 enabled: Some(false),
1761 settings_overlay: Some(serde_json::json!({"concurrentBackups": 4})),
1762 ..Default::default()
1763 },
1764 );
1765 assert_eq!(changed, vec!["enabled", "config.settings"]);
1766 assert_eq!(body["enabled"], serde_json::json!(false));
1767 let mut expected_settings = fixture["config"]["settings"].clone();
1768 expected_settings["concurrentBackups"] = serde_json::json!(4);
1769 assert_eq!(body["config"]["settings"], expected_settings);
1770 assert_eq!(body["signature"], fixture["signature"]);
1771
1772 let mut body = full_record_fixture();
1774 let changed = apply_task_change(
1775 &mut body,
1776 &TaskChange {
1777 schedule_mode: Some("OnDemand".to_string()),
1778 ..Default::default()
1779 },
1780 );
1781 assert_eq!(changed, vec!["config.profile.scheduleMode"]);
1782 assert_eq!(
1783 body["config"]["profile"]["scheduleMode"],
1784 serde_json::json!("OnDemand")
1785 );
1786 assert_eq!(
1787 body["config"]["profile"]["scheduleDetails"],
1788 fixture["config"]["profile"]["scheduleDetails"],
1789 "scheduleDetails rides the clone (this change's scope is the mode key)"
1790 );
1791 assert_eq!(body["config"]["settings"], fixture["config"]["settings"]);
1792 }
1793
1794 #[test]
1796 fn modify_result_serializes_all_keys_always() {
1797 let result = super::EamModifyResult {
1798 task: "t".to_string(),
1799 changed: vec!["enabled".to_string()],
1800 definition: serde_json::json!({"name": "t"}),
1801 put_outcome: None,
1802 readback: serde_json::Value::Null,
1803 };
1804 let json = serde_json::to_value(&result).expect("serializes");
1805 for key in ["task", "changed", "definition", "put_outcome", "readback"] {
1806 assert!(
1807 json.as_object().unwrap().contains_key(key),
1808 "{key} always rides"
1809 );
1810 }
1811 assert_eq!(json["put_outcome"], serde_json::Value::Null);
1812 }
1813
1814 #[test]
1819 fn delete_result_extracts_affected_names_from_changes_and_references() {
1820 let success: DeleteOutcome = serde_json::from_value(serde_json::json!({
1821 "success": true,
1822 "changes": [
1823 {
1824 "name": "ign-p10-scratch-sched",
1825 "type": "com.inductiveautomation.eam/eam-tasks",
1826 "collection": "core",
1827 "newSignature": "ec961ee921c63b18013094870ed2664331e965c4770fdf84bfe136e0b4164244"
1828 }
1829 ],
1830 "problem": null,
1831 "references": []
1832 }))
1833 .expect("the captured success body parses");
1834 assert_eq!(
1835 affected_resources(&success),
1836 vec!["ign-p10-scratch-sched".to_string()],
1837 "a lone-resource delete touches exactly the deleted resource"
1838 );
1839
1840 let demanded: DeleteOutcome = serde_json::from_value(serde_json::json!({
1844 "success": false,
1845 "changes": [
1846 {"name": "task-a", "type": "com.inductiveautomation.eam/eam-tasks", "collection": "core", "newSignature": "x"}
1847 ],
1848 "problem": null,
1849 "references": ["agent-b", {"name": "task-c"}, {"shapeless": true}, 42, "task-a"]
1850 }))
1851 .expect("the lenient shape parses");
1852 assert_eq!(
1853 affected_resources(&demanded),
1854 vec![
1855 "task-a".to_string(),
1856 "agent-b".to_string(),
1857 "task-c".to_string()
1858 ],
1859 "strings + name-keyed objects ride; shapeless elements skipped; dedup holds"
1860 );
1861 }
1862
1863 fn scheduled_row(name: &str) -> EamScheduledTask {
1868 serde_json::from_value(serde_json::json!({
1869 "name": name,
1870 "owner": "eam",
1871 "type": "Collect Backup",
1872 "execStart": null,
1873 "message": "",
1874 "repeats": true,
1875 "canPause": true,
1876 "canResume": false,
1877 "canCancel": true,
1878 "taskState": "Scheduled",
1879 "isForced": false,
1880 "isRunning": false,
1881 "progress": 0.0
1882 }))
1883 .expect("the captured row shape parses")
1884 }
1885
1886 #[test]
1890 fn preview_composes_over_fixture_find_and_scheduled() {
1891 let record: EamTaskRecord = serde_json::from_value(serde_json::json!({
1892 "name": "ign-p10-scratch-sched",
1893 "config": {
1894 "profile": {"type": "eam_backup", "isSuspended": false, "scheduleMode": "Scheduled"},
1895 "settings": {"targetGateways": ["gw-a", "gw-b"], "targetGroups": []}
1896 },
1897 "signature": "sig",
1898 "scheduledTaskState": {"currentState": "Scheduled", "details": {"owner": "eam"}}
1899 }))
1900 .expect("fixture record parses");
1901 let pending = vec![
1902 scheduled_row("ign-p10-scratch-sched"),
1903 scheduled_row("some-other-task"),
1904 ];
1905
1906 let preview = compose_blast_radius(&record, pending, "suspend");
1907 assert_eq!(preview.task, "ign-p10-scratch-sched");
1908 assert_eq!(preview.task_type.as_deref(), Some("eam_backup"));
1909 assert_eq!(preview.schedule_mode.as_deref(), Some("Scheduled"));
1910 assert_eq!(preview.state.as_deref(), Some("Scheduled"));
1911 assert_eq!(preview.owner.as_deref(), Some("eam"));
1912 assert_eq!(preview.config_suspended, Some(false));
1913 assert_eq!(
1914 preview.target_gateways,
1915 vec!["gw-a".to_string(), "gw-b".to_string()],
1916 "the AGENTS the write touches"
1917 );
1918 assert_eq!(
1919 preview.pending_executions.len(),
1920 1,
1921 "rows for other tasks are filtered out"
1922 );
1923 assert!(preview.pending_executions[0].can_cancel);
1924 assert_eq!(preview.verb, "suspend");
1925 let impact = &preview.controller_impact;
1926 assert!(
1927 impact.contains("ign-p10-scratch-sched")
1928 && impact.contains("eam_backup")
1929 && impact.contains("2 agents")
1930 && impact.contains("stop until resumed"),
1931 "the impact names task + type + agent count + consequence: {impact}"
1932 );
1933
1934 let json = serde_json::to_value(&preview).expect("serializes");
1936 for key in [
1937 "task",
1938 "task_type",
1939 "schedule_mode",
1940 "state",
1941 "owner",
1942 "config_suspended",
1943 "target_gateways",
1944 "pending_executions",
1945 "controller_impact",
1946 "verb",
1947 ] {
1948 assert!(
1949 json.as_object().unwrap().contains_key(key),
1950 "{key} always rides"
1951 );
1952 }
1953 }
1954
1955 #[test]
1959 fn preview_tolerates_empty_targets_and_pending() {
1960 let record: EamTaskRecord = serde_json::from_value(serde_json::json!({
1961 "name": "bare",
1962 "config": {}
1963 }))
1964 .expect("bare record parses");
1965 let preview = compose_blast_radius(&record, Vec::new(), "cancel");
1966 assert_eq!(preview.target_gateways, Vec::<String>::new());
1967 assert_eq!(preview.pending_executions, Vec::<EamScheduledTask>::new());
1968 assert_eq!(preview.task_type, None);
1969 assert_eq!(preview.owner, None);
1970 assert!(
1971 preview.controller_impact.contains("bare")
1972 && preview.controller_impact.contains("no pending execution"),
1973 "the cancel impact names the empty case factually: {}",
1974 preview.controller_impact
1975 );
1976 }
1977
1978 #[test]
1981 fn preview_impacts_differ_per_verb() {
1982 let record: EamTaskRecord = serde_json::from_value(serde_json::json!({
1983 "name": "nightly-backup",
1984 "config": {
1985 "profile": {"type": "eam_backup", "scheduleMode": "Scheduled"},
1986 "settings": {"targetGateways": ["gw-a"]}
1987 },
1988 "scheduledTaskState": {"currentState": "Scheduled", "details": {"owner": "eam"}}
1989 }))
1990 .expect("fixture record parses");
1991 let pending = vec![scheduled_row("nightly-backup")];
1992
1993 let mut impacts = Vec::new();
1994 for verb in ["suspend", "resume", "cancel", "force", "modify", "delete"] {
1995 let preview = compose_blast_radius(&record, pending.clone(), verb);
1996 let impact = preview.controller_impact.clone();
1997 assert!(
1998 impact.contains("nightly-backup") && impact.contains("1 agent"),
1999 "{verb}'s impact names the task + agent count: {impact}"
2000 );
2001 impacts.push(impact);
2002 }
2003 let distinct: std::collections::BTreeSet<&String> = impacts.iter().collect();
2004 assert_eq!(
2005 distinct.len(),
2006 impacts.len(),
2007 "each verb's factual sentence is distinct: {impacts:?}"
2008 );
2009 let cancel_with = compose_blast_radius(&record, pending.clone(), "cancel");
2012 assert!(cancel_with.controller_impact.contains("1 queued dispatch"));
2013 let cancel_without = compose_blast_radius(&record, Vec::new(), "cancel");
2014 assert!(
2015 cancel_without
2016 .controller_impact
2017 .contains("no pending execution")
2018 );
2019 let odd = compose_blast_radius(&record, Vec::new(), "teleport");
2021 assert!(odd.controller_impact.contains("teleport"));
2022 }
2023
2024 #[test]
2028 fn render_preview_line_contains_task_agents_verb_and_pending() {
2029 let record: EamTaskRecord = serde_json::from_value(serde_json::json!({
2030 "name": "ign-p10-scratch",
2031 "config": {
2032 "profile": {"type": "eam_backup", "scheduleMode": "OnDemand"},
2033 "settings": {"targetGateways": ["_controller"]}
2034 }
2035 }))
2036 .expect("fixture record parses");
2037 let preview = compose_blast_radius(
2038 &record,
2039 vec![
2040 scheduled_row("ign-p10-scratch"),
2041 scheduled_row("ign-p10-scratch"),
2042 ],
2043 "delete",
2044 );
2045 assert_eq!(
2046 preview.pending_executions.len(),
2047 2,
2048 "same-name rows from both segments both count"
2049 );
2050 let line = render_preview_line(&preview);
2051 assert_eq!(
2052 line,
2053 "delete ign-p10-scratch: deletes task ign-p10-scratch (eam_backup) permanently \
2054 — dispatches to 1 agent stop targets: [_controller] pending: 2"
2055 );
2056 assert!(line.contains("ign-p10-scratch"));
2057 assert!(line.contains("1 agent"));
2058
2059 let bare = compose_blast_radius(
2062 &serde_json::from_value::<EamTaskRecord>(serde_json::json!({
2063 "name": "bare", "config": {}
2064 }))
2065 .expect("bare parses"),
2066 Vec::new(),
2067 "resume",
2068 );
2069 assert_eq!(
2070 render_preview_line(&bare),
2071 "resume bare: resumes task bare — scheduled dispatches to 0 agents can fire again targets: [] pending: 0"
2072 );
2073 }
2074}