Skip to main content

ignition_core/actions/
eam.rs

1//! EAM task actions (07-02, BKUP-02) — the read-heavy surface with
2//! guarded writes, now carrying the full write LIFECYCLE (10-03).
3//! Reads: run history (the runtime seam — the controller state gate
4//! classifies honestly) and task definitions (the config-resource
5//! seam — available on stock gateways). Writes: create (the guard
6//! ladder), suspend/resume/cancel (find-first lifecycle verbs),
7//! modify (full-record RMW) and delete (signature-keyed) — every
8//! verb preceded by the blast-radius preview composer
9//! ([`build_blast_radius`]) when the caller needs the pre-flight.
10//!
11//! Two-layer naming (LOCKED): the CLIENT models are wire-faithful;
12//! HERE the agent-stable summary re-exposes under unit-explicit keys
13//! (`name`/`task_type`/`schedule_mode`/`current_state`), and history
14//! items pass through VERBATIM (the gateway's own camelCase keys —
15//! execution outcomes are DATA: a `Failed` level with GNET
16//! not-connected detail is an exit-0 read, never hidden, research
17//! Pitfall 3).
18
19use 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/// The planner-locked create ladder's verdict (07-02 Task 3) — a
31/// PURE function over `(task_type, schedule_mode)` so main.rs
32/// (pre-resolution, zero network) and the TUI (Confirm gating) and
33/// the action (authoritative re-check) all classify IDENTICALLY.
34///
35/// | verdict | meaning |
36/// |---|---|
37/// | `Unguarded` | `eam_backup` + OnDemand — fires only when forced, never mutates targets autonomously |
38/// | `NeedsYes` | mutating types (restart/send*/licenses) OR any non-OnDemand schedule (arms autonomous actions) |
39/// | `Refused` | `eam_restoreBackup`/`eam_installModules`/`eam_remoteUpgrade` — fleet-destructive, EXT-03 (v2) scope |
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum TaskCreateVerdict {
42    /// No `--yes` needed (OnDemand backup).
43    Unguarded,
44    /// `require_confirmation` must fire.
45    NeedsYes,
46    /// Outright refusal (`EamTaskTypeRefused`).
47    Refused,
48}
49
50/// The openapi taxonomy's REFUSED set — fleet-destructive types the
51/// CLI refuses outright (push backups/modules/upgrades to every
52/// agent target).
53const REFUSED_TYPES: [&str; 3] = [
54    "eam_restoreBackup",
55    "eam_installModules",
56    "eam_remoteUpgrade",
57];
58
59/// The taxonomy's mutating-but-allowed set — they act on target
60/// agents when dispatched, so their DEFINITIONS need `--yes`.
61const 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
71/// THE guard ladder (pure): refused types first (highest rung);
72/// then any non-OnDemand schedule (arms autonomous actions); then
73/// the mutating type set; `eam_backup` + OnDemand lands unguarded.
74/// An UNKNOWN type classifies `NeedsYes` — fail-safe (a `--yes`
75/// costs nothing; an unrecognized fleet verb firing unguarded could
76/// cost plenty; the server's own validation remains the backstop).
77pub 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/// `ign eam task new` output model — all keys always.
91#[derive(Debug, Serialize)]
92pub struct EamTaskCreateResult {
93    /// The created definition's name.
94    pub name: String,
95    /// The `profile.type` token.
96    pub task_type: String,
97    /// The `profile.scheduleMode` token.
98    pub schedule_mode: String,
99    /// The composed definition body that rode the array POST
100    /// (verbatim — the agent's read-back of what was created).
101    pub definition: Value,
102}
103
104/// `ign eam task force` output model — all keys always.
105#[derive(Debug, Serialize)]
106pub struct EamTaskForceResult {
107    /// The dispatched task's name.
108    pub task: String,
109    /// The owner the force POST targeted (from the healthcheck's
110    /// `scheduledTaskState.details.owner`, fallback `"eam"`).
111    pub owner: String,
112    /// Always `true` on this shape — the 2xx IS dispatch acceptance
113    /// (execution outcomes land in history as data).
114    pub dispatched: bool,
115    /// The newest matching history entry after dispatch (null when
116    /// none is visible yet) — its `level`/`detail` honestly surface
117    /// GNET-not-connected / trial-expired outcomes.
118    pub history: Option<EamHistoryItem>,
119    /// The composed blast-radius preview (10-03, EAMW-04: force
120    /// composes with the preview surface) — the same pre-flight the
121    /// CLI's confirmation prompt renders: targets, pending
122    /// executions, the factual controller impact. Additive field;
123    /// the serde shape stays agent-stable (all keys always).
124    pub preview: BlastRadiusPreview,
125}
126
127/// One `--setting K=V` parsed with scalar auto-typing (the 05-04
128/// tags-write `--value` precedent): a value that parses cleanly as
129/// bool (`true`/`false`) or integer serializes as a JSON bool /
130/// number; anything else stays a string. Arrays/objects are OUT of
131/// scope for K=V (the `--definition` path owns them).
132pub 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
150/// The scalar auto-typing rule: `true`/`false` → JSON bool; a clean
151/// i64 parse → JSON number; anything else → the string verbatim.
152fn 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
165/// Deep-merge `overlay` onto `base` (objects merge recursively —
166/// base keys win only when the overlay carries nothing at that
167/// path; arrays and scalars REPLACE, never merge — the documented
168/// settings-merge semantics for `--definition`).
169fn 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
187/// Compose the `eam task new` definition body (pure — the
188/// unit-testable core of [`eam_task_create`]). The live 8.3.3
189/// controller requires the profile/settings SPLIT (captured in
190/// `.planning/debug/eam-working-definition.json`; the pre-split body
191/// 422'd with "Settings cannot be null"):
192///
193/// - `config.profile` = `{type, scheduleMode}` ONLY (`isSuspended` is
194///   server-owned — never sent on create);
195/// - `config.settings` = `{targetGateways, targetGroups}` + every
196///   `--setting K=V` (auto-typed scalars) with the `--definition`
197///   overlay deep-merged over the composed SETTINGS object (objects
198///   merge, arrays/scalars replace).
199///
200/// Zero `--target` values default to the controller itself —
201/// `targetGateways: ["_controller"]`, the live-captured zero-config
202/// default on a controller-mode gateway; explicit targets replace it
203/// wholesale. `targetGroups` is always `[]` (no `--group` flag
204/// exists).
205fn 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
248/// `ign eam task new` — compose the definition, run the ladder's
249/// authoritative re-check, POST the array body.
250///
251/// Composition (the live 8.3.3 `config.settings` shape — see
252/// [`compose_task_definition`]): `{name, config: {profile: {type,
253/// scheduleMode}, settings: {targetGateways, targetGroups, ...--setting
254/// K=V}}}`; a `--definition` file's top-level object deep-merges over
255/// the composed SETTINGS (the typed/array path — mutually exclusive
256/// with `--setting` at clap). The refusal ladder runs AGAIN here
257/// (main.rs already guarded by verdict; the re-check keeps the pure fn
258/// authoritative in core — the double-check is cheap).
259pub 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    // The ladder is authoritative HERE (the CLI's pre-resolution
269    // guard is the fast path; this is the correctness path).
270    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
293/// `ign eam task force` — the preview IS the pre-flight (EAMW-04):
294/// [`build_blast_radius`] runs the find (owner resolution via the
295/// healthcheck's `scheduledTaskState.details.owner`, live-captured
296/// fallback `"eam"`) AND the pending reads the confirmation prompt
297/// renders → force POST (2xx = dispatched) → history re-read (the
298/// newest matching entry, `level`/`detail` as data). Correctness
299/// over latency (the 05-04 precondition precedent): the extra
300/// scheduled reads are the confirmation surface's data source.
301pub 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// ---- 10-03 Task 1: the runtime lifecycle (suspend/resume/cancel) ----
331//
332// Capture-locked per 10-LIVE-CAPTURES.md (both rigs, 8.3.3 + 8.3.6):
333// the verbs 204 on success and PERSIST `config.profile.isSuspended`
334// (Decision 1); failures are 500s with indistinguishable Jetty HTML
335// (§7) — so find-before-write is the ONLY honest name validation,
336// and each action carries an authoritative re-check of verb
337// applicability BEFORE the write fires (the task_create double-check
338// pattern: main.rs/TUI pre-resolve on the same pure fns).
339
340/// `ign eam task suspend|resume|cancel` output model — all keys
341/// always (the agent-stable shape; nulls are honest absence).
342#[derive(Debug, Serialize)]
343pub struct EamLifecycleResult {
344    /// The lifecycle target's name.
345    pub task: String,
346    /// What ran: `"suspended"`, `"resumed"`, or `"cancelled"`.
347    pub action: String,
348    /// The find healthcheck's `currentState` BEFORE the write (the
349    /// captured vocabulary rides [`crate::client::eam::EAM_CURRENT_STATES`]).
350    pub previous_state: Option<String>,
351    /// The post-write find read-back of `config.profile.isSuspended`
352    /// — the capture-locked persistence proof (suspend ⇒ `true`,
353    /// resume ⇒ `false`, 10-LIVE-CAPTURES Decision 1). `null` for
354    /// cancel (no definition flag rides that verb).
355    pub config_suspended: Option<bool>,
356    /// cancel only: the POST-write pending read filtered to the task
357    /// — `null` when nothing was pending (the honest no-op) or the
358    /// pending row is gone (the cancel landed). Suspend/resume leave
359    /// it `null` (they target the scheduler trigger, not a row).
360    pub pending: Option<EamScheduledTask>,
361    /// Whether the lifecycle POST actually rode the wire. Carries the
362    /// cancel no-op honesty (`fired: false` ⇔ the no-op result's
363    /// "`cancelled: false`") — a write we declined to fire is
364    /// reported, never disguised as a success.
365    pub fired: bool,
366    /// Why nothing fired: `"no pending execution"` on the cancel
367    /// no-op, or the gateway's own `canCancel=false` report on an
368    /// uncancellable row. Always `null` on a fired write.
369    pub reason: Option<String>,
370}
371
372/// The lifecycle name precheck (pure, ZERO network — the
373/// guard-ladder three-place rule: main.rs pre-resolution, the TUI's
374/// Confirm gating, and the action's authoritative re-check all call
375/// THIS fn). Name-empty/whitespace refusals ONLY — every other
376/// applicability question needs the find (the action's Tier-3 job),
377/// and inventing state rules the captures don't support is exactly
378/// the wire-dishonesty pitfall.
379pub 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
388/// The suspend re-check (pure): the capture-locked refusal branch.
389/// A suspended task's trigger is parked (`nextScheduled: "N/A"`,
390/// `currentState: "Suspended"` — 10-LIVE-CAPTURES §1c), so the
391/// gateway's own answer to the POST is the INDISTINGUISHABLE 500
392/// "Task could not be suspended" (§1a/§7 — same answer an
393/// unknown-name or OnDemand suspend gets). When find PROVES
394/// `isSuspended: true`, refuse pre-write exit 2 naming the task —
395/// a distinct, actionable message instead of the ambiguous page.
396/// Anything else (`false`, absent, unparseable) fires — no invented
397/// state machine.
398pub 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/// The cancel decision (pure) over the task's pending row (from the
417/// scheduled reads, filtered to the name). Every branch mirrors a
418/// captured fact — nothing here is an invented state machine:
419///
420/// - [`CancelDecision::Fire`] — a pending row with `canCancel: true`
421///   (the captured `Scheduled` cell, §2): the gateway says the
422///   cancel CAN fire.
423/// - [`CancelDecision::NoPending`] — no row: the gateway's own
424///   answer to cancel-with-nothing-pending is a SILENT 204 (§7) —
425///   mirrored as an honest no-op result WITHOUT the wire round trip
426///   (`fired: false`, reason `"no pending execution"`).
427/// - [`CancelDecision::NotPermitted`] — a row whose `canCancel` is
428///   `false` (an unobserved cell — Running rows never materialized
429///   on the capture rigs): the gateway's own capability flag says
430///   no; the flag is reported verbatim, the POST is not fired.
431#[derive(Debug, Clone, Copy, PartialEq, Eq)]
432pub enum CancelDecision {
433    /// Fire the cancel POST (a pending, cancellable execution exists).
434    Fire,
435    /// Honest no-op — nothing pending (`fired: false`).
436    NoPending,
437    /// Honest no-op — the row's `canCancel` is `false` (`fired: false`).
438    NotPermitted,
439}
440
441/// The pure decision read (see [`CancelDecision`]).
442pub 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
450/// The find healthcheck's `currentState` (pure projection).
451fn 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
460/// The definition flag `config.profile.isSuspended` (pure projection).
461fn 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
469/// The task's FIRST pending row (both literal segments — see
470/// [`pending_rows_for`] for the both-segments discipline). The
471/// cancel action's decision input.
472async 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
479/// `ign eam task suspend` — find (the unknown-name 500 is
480/// indistinguishable on this seam, §7 — the config-resource find's
481/// `not_found` is the honest refusal) → the authoritative
482/// already-suspended re-check → the POST → the find read-back that
483/// reports the capture-locked `isSuspended` persistence (Decision 1).
484/// A suspended task refuses exit 2 BEFORE the wire; an OnDemand or
485/// untriggered task FIRES and reports the gateway's verbatim 500
486/// (wire honesty over cleverness — no invented rules).
487pub 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    // Decision 1: the runtime verbs PERSIST the flag into the
497    // definition — the read-back is the proof the result reports.
498    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
510/// `ign eam task resume` — the suspend inverse with NO refusal
511/// re-check: the capture answers resume of a never-suspended task
512/// with a silent 204 (§1b) — the gateway accepts the verb regardless,
513/// so we fire and report (wire honesty over cleverness). The
514/// read-back reports the persisted flag honestly whatever it is.
515pub 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
535/// `ign eam task cancel` — find (unknown names answer a SILENT 204
536/// on this seam, §7 — cancel can never be a name-validation tool;
537/// the config-resource find's `not_found` is) → the pending reads →
538/// the pure [`cancel_decision`] → fire or honest no-op → the
539/// post-write pending read (`pending` reports what REMAINS).
540pub 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// ---- 10-03 Task 2: config mutations (modify + delete) ----
586//
587// Both are capture-locked: the PUT is a FULL-RECORD echo-modify
588// carrying the ORIGINAL signature (§6a — omitting config.settings is
589// the 422 trap, §6b/Decision 6), and the DELETE is signature-keyed
590// with the gateway-side confirm policy (§3 + Decision 3: NO confirm
591// by default; the unobserved confirm-demand shape retries ONCE with
592// confirm=true — never hard-coded).
593
594/// A targeted modify request (the keys the caller wants changed —
595/// everything else rides the found record untouched). Builder-ish:
596/// start from [`TaskChange::default`], set what applies.
597///
598/// **Deliberately ABSENT, per wire honesty:**
599///
600/// - **rename** — capture §5/Decision 5: a PUT with a changed `name`
601///   and the original signature answers **404 empty** (the modify
602///   route resolves the resource BY the body's name and finds
603///   nothing — no rename, no create, no error body). A rename verb
604///   must compose create-new + delete-old; that composite is the
605///   CLI tier's (10-04) documented workflow, NEVER this PUT.
606/// - **suspend flag** — capture §6a proved only a full-record echo
607///   (whose `isSuspended` happened to be `false`) landing; a
608///   PUT-DRIVEN `isSuspended` mutation is unproven. The
609///   capture-locked path for the flag is the suspend/resume VERBS
610///   (Decision 1) — so `TaskChange` carries no flag field at all
611///   rather than faking one.
612#[derive(Debug, Clone, Default)]
613pub struct TaskChange {
614    /// Flip the record's top-level `enabled` key.
615    pub enabled: Option<bool>,
616    /// Rewrite the record's top-level `description` key.
617    pub description: Option<String>,
618    /// Rewrite `config.profile.scheduleMode`. NOTE: scheduleDetails
619    /// rides ONLY via the found record's clone — schedule-mode
620    /// changes that also need a new cron/delay string are out of
621    /// this change's scope (the capture vocabulary: §12 — the wire
622    /// accepts the mode change; an unsupported pairing lands as
623    /// gateway-side state, visible in the read-back as data).
624    pub schedule_mode: Option<String>,
625    /// Deep-merge over the found `config.settings` (objects merge
626    /// recursively, arrays/scalars replace — the documented settings
627    /// semantics). The settings OBJECT always rides (the 422 trap).
628    pub settings_overlay: Option<Value>,
629}
630
631/// The `ign eam task modify` output model — all keys always.
632#[derive(Debug, Serialize)]
633pub struct EamModifyResult {
634    /// The post-write name (rename is NOT supported — always the
635    /// name as found).
636    pub task: String,
637    /// The dotted key paths the change touched: `"enabled"`,
638    /// `"description"`, `"config.profile.scheduleMode"`,
639    /// `"config.settings"`.
640    pub changed: Vec<String>,
641    /// The verbatim PUT body (the single record — the client wraps
642    /// it in the one-element array envelope): the agent's read-back
643    /// of exactly what was sent, signature + collection included.
644    pub definition: Value,
645    /// The captured 200 outcome
646    /// ([`ModifyOutcome`] — `changes[].newSignature` is
647    /// authoritative for the NEXT mutation, §6a; its `problem` rides
648    /// verbatim on the refusal shapes). `null` when a 2xx carried no
649    /// body (the lenient client Option).
650    pub put_outcome: Option<ModifyOutcome>,
651    /// The post-PUT find, serialized — the echo semantics make the
652    /// read-back meaningful (§6a: `newSignature` == read-back
653    /// signature, mutated keys landed, unknown round-trip keys
654    /// preserved). `null` when the read-back itself failed (the PUT
655    /// already succeeded — a read blip must not mask a landed write).
656    pub readback: Value,
657}
658
659/// The `ign eam task delete` output model — all keys always.
660#[derive(Debug, Serialize)]
661pub struct EamDeleteResult {
662    /// The deleted (or refused) task's name.
663    pub task: String,
664    /// Whether the gateway answered the captured success shape
665    /// (`success: true`, §3b).
666    pub deleted: bool,
667    /// The captured `changes[]` verbatim (the deleted resource's
668    /// final signature rides `changes[].newSignature`, §9).
669    pub changes: Vec<ResourceChange>,
670    /// Agent/resource names the delete touched: the `changes[]`
671    /// names plus any string-shaped `references` entries (the
672    /// affected-resources element shape is UNOBSERVED — §3d — so
673    /// the extraction is lenient: JSON strings ride; objects
674    /// contribute a `name` key when present).
675    pub affected: Vec<String>,
676}
677
678/// Apply the targeted mutations to the FULL-record clone (pure —
679/// the unit-testable core of [`eam_task_modify`]). Returns the
680/// dotted key paths touched. ONLY the targeted keys change:
681/// `config.settings`, `signature`, `collection`, and every
682/// unknown round-trip key ride the clone byte-for-byte (the
683/// never-compose-from-scratch invariant — compose_task_definition
684/// composes CREATE bodies and must never feed a modify).
685fn 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
717/// A mutable slot helper: ensures `parent` is an object and hands
718/// back the (created-if-absent) entry for `key`.
719fn 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
730/// Serialize a record round-trip (the full-record clone — every key
731/// the find answered, unknown ones included, rides the Value).
732/// The model hoists the runtime healthcheck under a top-level
733/// `scheduledTaskState` key; when the find answer carried the state
734/// under `healthchecks` (the captured §0 envelope shape) that field
735/// is `None` and would serialize a null PLACEHOLDER — dropped here,
736/// so the clone is the find body + nothing (the PUT must never
737/// carry a key the wire never answered).
738fn 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
752/// The stale-signature diagnostic (the client/eam.rs FINDING +
753/// capture Decision 4, classified HERE — the client stays
754/// classification-free): a signature mismatch answers HTTP 500 with
755/// a JSON `problem` whose stable `signature mismatch` substring
756/// never reaches this layer (the classifier's Internal fallback
757/// drops non-HTML bodies). Classify on EVIDENCE instead: a
758/// post-failure find whose signature differs from the one we sent
759/// PROVES a concurrent write (the capture also proves mismatches
760/// leave the resource untouched — §3a/§4 read-backs) — the
761/// client-fixable conflict rides exit 2 (re-run to apply against
762/// the current signature). No evidence → the original error
763/// propagates verbatim (no invented claims). No new slugs — the
764/// existing taxonomy exclusively.
765async 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
787/// `ign eam task modify` — the FULL-RECORD read-modify-write
788/// (never compose-from-scratch: `config.settings: null` is the 422
789/// trap, §6b/Decision 6 — the create-composer composes CREATE
790/// bodies, not this): find (not_found honesty + the signature
791/// source) → clone EVERY key find answered → apply ONLY the
792/// targeted [`TaskChange`] keys → PUT the single-element array
793/// carrying the ORIGINAL signature + collection → the post-PUT find
794/// read-back (the echo semantics make it meaningful, §6a). An
795/// all-`None` change refuses exit 2 pre-network (a no-op PUT would
796/// still rotate the server-side signature).
797pub 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
857/// The lenient affected-names extraction (pure): `changes[]` names
858/// are capture-proven; `references` element shape is UNOBSERVED
859/// (§3d) — JSON strings ride, objects contribute a `name` key when
860/// present, everything else is skipped honestly. Deduped, wire
861/// order preserved.
862fn 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
888/// `ign eam task delete` — find first (the `not_found` honesty AND
889/// the signature source — never ask the caller for it) → DELETE
890/// with `?collection=core` and NO confirm (capture §3b/Decision 3:
891/// a lone-resource delete succeeds without it; hard-coding
892/// `confirm=true` would bypass a genuine multi-resource warning we
893/// cannot yet see) → on the body-level `success: false` (the
894/// UNOBSERVED confirm-demand shape, §3d) retry ONCE with
895/// `confirm=true` — the CLI's `--yes` gate already ran before this
896/// correctness-path action. Signature-mismatch 500s classify on
897/// evidence ([`reclassify_stale_signature`]).
898pub 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            // The confirm-demand shape (success:false + the
918            // affected-resources evidence in references/changes) —
919            // the one sanctioned retry, Decision 3 as written.
920            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// ---- 10-03 Task 3: the blast-radius preview ----
942//
943// The read-only pre-flight that feeds every guard prompt: ONE
944// composer (find + both scheduled segments) behind the pure
945// projection all three caller tiers (CLI refusal message, TUI
946// Confirm body, action re-checks) render identically. History is
947// deliberately EXCLUDED: 10-RESEARCH lists it as optional, the
948// radius answers who/what/targets/pending — the guards consult
949// nothing history carries, and the read would double the pre-flight
950// traffic for data nobody renders pre-write.
951
952/// The blast-radius preview — the composed pre-write facts (all keys
953/// always). Unknown-task names refuse `not_found` at the composer:
954/// the preview IS the pre-flight, so a bad name never reaches any
955/// confirm prompt.
956#[derive(Debug, Serialize)]
957pub struct BlastRadiusPreview {
958    /// The target task's name.
959    pub task: String,
960    /// `config.profile.type` (the token, e.g. `eam_backup`) — the
961    /// CONFIG seam's vocabulary (the scheduled rows' `type` is a
962    /// DIFFERENT, human-label vocabulary — never conflated).
963    pub task_type: Option<String>,
964    /// `config.profile.scheduleMode`.
965    pub schedule_mode: Option<String>,
966    /// The find healthcheck's `currentState`.
967    pub state: Option<String>,
968    /// The healthcheck's `details.owner` (the force verb's owner
969    /// segment source; `"eam"` fallback lives at the caller).
970    pub owner: Option<String>,
971    /// `config.profile.isSuspended` — the definition flag the
972    /// lifecycle verbs persist (Decision 1).
973    pub config_suspended: Option<bool>,
974    /// `config.settings.targetGateways` — the AGENTS the write
975    /// touches; empty when the record names none (the controller
976    /// itself is then the effective target, per the create
977    /// composer's zero-config default — reported as empty here
978    /// because the found record, not the composer, owns the list).
979    pub target_gateways: Vec<String>,
980    /// The task's pending executions (from BOTH literal scheduled
981    /// segments, filtered to the name; each row carries the
982    /// gateway-owned canPause/canResume/canCancel + taskState).
983    pub pending_executions: Vec<EamScheduledTask>,
984    /// One factual sentence: what the verb does to the task and how
985    /// many agents it touches. No dramatization — the CLI's
986    /// require_confirmation string and any future TUI body render
987    /// THIS text ([`render_preview_line`]).
988    pub controller_impact: String,
989    /// The verb the preview was composed for (`suspend`/`resume`/
990    /// `cancel`/`force`/`modify`/`delete`).
991    pub verb: String,
992}
993
994/// The agent-count fragment (`1 agent` / `2 agents` / `0 agents`).
995fn agents_fragment(count: usize) -> String {
996    format!("{count} agent{}", if count == 1 { "" } else { "s" })
997}
998
999/// The per-verb factual impact sentence (pure). Names the task, the
1000/// profile type when known, and the agent count; the pending count
1001/// only where the verb targets executions. Capture-honest: nothing
1002/// here predicts outcomes (execution results are history DATA).
1003fn 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
1035/// The pure projection: find record + filtered pending rows + verb
1036/// → the preview (the async composer's testable core).
1037fn compose_blast_radius(
1038    record: &EamTaskRecord,
1039    pending_executions: Vec<EamScheduledTask>,
1040    verb: &str,
1041) -> BlastRadiusPreview {
1042    // Filter to THIS task here (the pure fn owns the rule, so every
1043    // caller — and every test — provably drops other tasks' rows).
1044    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
1102/// The task's pending rows from BOTH literal scheduled segments
1103/// (`false` then `true` — 10-LIVE-CAPTURES §2), filtered to the
1104/// task name, tolerating the quiet empty-list body (§8). BOTH
1105/// segments always read: a Running row lives only in the `true`
1106/// segment (uncapturable on the 10-01 rigs — Decision 2) and a
1107/// short-circuit would report "nothing pending" while an execution
1108/// is in flight — the one lie this composer must never tell.
1109async 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
1125/// Compose the blast-radius preview (the read-only pre-flight):
1126/// find first — an unknown name refuses `not_found` HERE, before
1127/// any confirm prompt or write (capture Decision 7: find-before-
1128/// write is the only honest name validation on the lifecycle seam)
1129/// — then both scheduled segments. History deliberately excluded
1130/// (module doc). One composer, every guarded verb.
1131pub 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
1141/// The single-line render the CLI's `require_confirmation` operation
1142/// string embeds (ONE fn so the CLI refusal and any future TUI body
1143/// agree): `"{verb} {task}: {controller_impact} targets: [a, b]
1144/// pending: {n}"`.
1145pub 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/// `ign eam history` output model — all keys always.
1157#[derive(Debug, Serialize)]
1158pub struct EamHistoryResult {
1159    /// The run items, wire-faithful passthrough (newest first as the
1160    /// gateway orders them).
1161    pub items: Vec<EamHistoryItem>,
1162    /// How many items came back (the explicit limit's page).
1163    pub count: usize,
1164}
1165
1166/// One task-definition summary row (the agent-stable shape).
1167#[derive(Debug, Serialize)]
1168pub struct EamTaskSummary {
1169    /// Definition name.
1170    pub name: String,
1171    /// `config.profile.type` (`eam_backup`, …) — null when the
1172    /// record's config carries no profile type.
1173    pub task_type: Option<String>,
1174    /// `config.profile.scheduleMode` (`OnDemand`, …) — null when
1175    /// absent.
1176    pub schedule_mode: Option<String>,
1177    /// `scheduledTaskState.currentState` — null when the list shape
1178    /// carries no state (find answers do).
1179    pub current_state: Option<String>,
1180}
1181
1182/// `ign eam tasks` output model — all keys always.
1183#[derive(Debug, Serialize)]
1184pub struct EamTasksResult {
1185    /// The definition summary rows.
1186    pub tasks: Vec<EamTaskSummary>,
1187}
1188
1189/// `ign eam tasks <NAME>` output model — all keys always.
1190#[derive(Debug, Serialize)]
1191pub struct EamTaskDetailResult {
1192    /// Definition name.
1193    pub name: String,
1194    /// The full definition record (config + resource keys,
1195    /// passthrough as JSON).
1196    pub definition: serde_json::Value,
1197    /// The `scheduledTaskState` healthcheck (null when absent —
1198    /// `currentState`/`nextScheduled`/`owner` under `details`).
1199    pub state: serde_json::Value,
1200}
1201
1202/// `ign eam history` — the runtime read (controller gate honestly
1203/// classified at the wire seam).
1204pub 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
1216/// `ign eam tasks` — the definitions read (config-resource seam).
1217pub 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
1224/// `ign eam tasks <NAME>` — one definition's full record + state;
1225/// unknown names ride the config-resource `not_found` path.
1226pub 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
1241/// The summary projection from one record (the agent-stable keys).
1242fn 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    /// The ladder EXHAUSTIVELY over the openapi taxonomy's 11 types
1274    /// × the schedule modes — the planner-locked breadth pinned as a
1275    /// pure function.
1276    #[test]
1277    fn guard_ladder_is_exhaustive_over_the_taxonomy() {
1278        // eam_backup + OnDemand = the ONLY unguarded cell.
1279        assert_eq!(
1280            task_create_guard("eam_backup", "OnDemand"),
1281            TaskCreateVerdict::Unguarded
1282        );
1283
1284        // The refused trio — the ladder's top rung fires regardless
1285        // of schedule.
1286        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        // The mutating seven — --yes under OnDemand.
1304        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        // ANY non-OnDemand schedule arms autonomous actions — even
1321        // eam_backup (the openapi schedule tokens + unknown modes).
1322        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        // Unknown types classify fail-safe (guarded, never silently
1331        // unguarded — the server's validation is the backstop).
1332        assert_eq!(
1333            task_create_guard("eam_unknownFutureType", "OnDemand"),
1334            TaskCreateVerdict::NeedsYes
1335        );
1336    }
1337
1338    /// The K=V scalar auto-typing rule: bool/int ride typed,
1339    /// everything else stays a string; malformed input refuses
1340    /// `invalid_input`.
1341    #[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        // Negative + big ints ride typed.
1356        assert_eq!(
1357            parse_setting("n=-7").unwrap(),
1358            ("n".to_string(), serde_json::json!(-7))
1359        );
1360        // Strings stay strings — including numeric-looking text
1361        // with units and values that aren't clean ints.
1362        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    /// The auto-typing helper's direct pins.
1381    #[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    /// The --definition merge semantics: objects merge recursively,
1391    /// arrays and scalars REPLACE.
1392    #[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    /// The composition pins (07-05 gap 3 — the live `config.settings`
1425    /// shape): profile carries type/scheduleMode ONLY; settings owns
1426    /// targetGateways/targetGroups + the K=V scalars; a bare create
1427    /// (no --target) defaults to the controller itself.
1428    #[test]
1429    fn composition_splits_profile_and_settings_the_live_shape() {
1430        // Bare create: targetGateways defaults to ["_controller"]
1431        // (the live-captured zero-config default on a
1432        // controller-mode gateway).
1433        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        // Explicit --target values replace the default wholesale.
1448        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        // The --definition overlay deep-merges over the composed
1483        // SETTINGS object (arrays/scalars replace, objects merge).
1484        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    /// The summary projection carries the profile type/scheduleMode
1510    /// and degrades to nulls when the list shape carries neither the
1511    /// state nor a profile.
1512    #[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    // ---- 10-03 Task 1: the runtime lifecycle ----
1537
1538    /// The lifecycle result model serializes ALL keys always (the
1539    /// agent-stable shape — nulls are honest absence, never omitted
1540    /// keys), for both a fired write and the cancel no-op.
1541    #[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    /// The pure name precheck refuses empty/whitespace names exit 2
1589    /// (and ONLY those — every other question needs the find).
1590    #[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    /// The suspend re-check (pure) refuses ONLY the capture-proven
1611    /// already-suspended case (exit 2 naming the task); false,
1612    /// absent, and unparseable flags all fire.
1613    #[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    /// The cancel decision (pure) over the pending row — every
1642    /// branch mirrors a captured fact (the §2 can* truth cell, §7's
1643    /// silent-204 nothing-pending answer).
1644    #[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    // ---- 10-03 Task 2: modify + delete ----
1684
1685    /// The FULL-record fixture — the captured find shape (§0's key
1686    /// inventory: type/name/description/enabled/version/collection/
1687    /// collections/signature/config{profile,settings}/data/
1688    /// attributes/metrics/healthchecks) with the §6a baseline values.
1689    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    /// THE never-compose-from-scratch invariant: the modify PUT body
1721    /// preserves the fixture's config.settings byte-equal on a
1722    /// settings-free change; ONLY targeted keys move; signature/
1723    /// collection/unknown round-trip keys ride the clone untouched.
1724    #[test]
1725    fn modify_put_body_preserves_the_fixture_record_except_targeted_keys() {
1726        // A description-only change: EVERYTHING else byte-equal.
1727        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        // Targeted enabled + settings overlay: ONLY those keys move
1755        // (the overlay deep-merges; the rest of settings stays).
1756        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        // A schedule-mode change touches ONLY the profile's mode key.
1773        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    /// The modify result model serializes ALL keys always.
1795    #[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    /// The delete result's affected-names extraction: `changes[]`
1815    /// names are capture-proven; the `references` element shape is
1816    /// UNOBSERVED (§3d) — strings ride, name-keyed objects
1817    /// contribute, the rest is skipped honestly; dedup, wire order.
1818    #[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        // The confirm-demand shape is UNOBSERVED — the extraction is
1841        // lenient over spec-shaped reference elements (marked as
1842        // such; nothing here is capture-proven beyond `[]`/null).
1843        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    // ---- 10-03 Task 3: the blast-radius preview ----
1864
1865    /// The captured scheduled row, name-adjustable (the §2 verbatim
1866    /// shape) — the preview's pending-input fixture.
1867    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    /// The composer over fixture find + scheduled bodies: the task
1887    /// targets 2 agents with 1 pending execution; rows for OTHER
1888    /// tasks are filtered; the agent-stable keys ride.
1889    #[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        // All keys always — serialization never drops a key.
1935        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    /// The empty case: a bare record (no settings, no healthcheck)
1956    /// and zero pending rows compose an honest preview — empty
1957    /// lists, nulls, and an impact that still names the task.
1958    #[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    /// Per-verb impact sentences DIFFER where the verbs differ —
1979    /// one composer, six honest sentences (plus the fallback).
1980    #[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        // The pending-aware cancel names the execution count; the
2010        // empty-pending cancel says so.
2011        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        // Unknown verbs get the factual fallback (never a panic).
2020        let odd = compose_blast_radius(&record, Vec::new(), "teleport");
2021        assert!(odd.controller_impact.contains("teleport"));
2022    }
2023
2024    /// THE render pin: `"{verb} {task}: {impact} targets: [a, b]
2025    /// pending: {n}"` — the ONE line both the CLI refusal and the
2026    /// future TUI body embed.
2027    #[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        // Empty targets render as empty brackets (never "[ ]" or a
2060        // dropped key — the format is agent-stable).
2061        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}