Skip to main content

macos_agent/
run.rs

1use std::sync::atomic::{AtomicU64, Ordering};
2use std::time::Instant;
3
4use crate::backend;
5use crate::backend::process::RealProcessRunner;
6use crate::cli::{
7    AppsCommand, AxActionCommand, AxAttrCommand, AxCommand, AxSessionCommand, AxWatchCommand, Cli,
8    CommandGroup, DebugCommand, InputCommand, InputSourceCommand, ObserveCommand, OutputFormat,
9    PreflightArgs, ProfileCommand, ScenarioCommand, WaitCommand, WindowCommand, WindowsCommand,
10};
11use crate::commands;
12use crate::error::CliError;
13use crate::model::{ActionMeta, ActionPolicyResult};
14use crate::preflight;
15use crate::retry::RetryPolicy;
16use crate::test_mode;
17
18static ACTION_COUNTER: AtomicU64 = AtomicU64::new(1);
19
20#[derive(Debug, Clone, Copy)]
21pub struct ActionPolicy {
22    pub dry_run: bool,
23    pub retries: u8,
24    pub retry_delay_ms: u64,
25    pub timeout_ms: u64,
26}
27
28impl ActionPolicy {
29    pub fn retry_policy(self) -> RetryPolicy {
30        RetryPolicy {
31            retries: self.retries,
32            retry_delay_ms: self.retry_delay_ms,
33        }
34    }
35}
36
37pub fn command_label(cli: &Cli) -> &'static str {
38    command_group_label(&cli.command)
39}
40
41pub fn command_group_label(command: &CommandGroup) -> &'static str {
42    match command {
43        CommandGroup::Preflight(_) => "preflight",
44        CommandGroup::Windows { .. } => "windows.list",
45        CommandGroup::Apps { .. } => "apps.list",
46        CommandGroup::Window { command } => match command {
47            WindowCommand::Activate(_) => "window.activate",
48        },
49        CommandGroup::Input { command } => match command {
50            InputCommand::Click(_) => "input.click",
51            InputCommand::Type(_) => "input.type",
52            InputCommand::Hotkey(_) => "input.hotkey",
53        },
54        CommandGroup::InputSource { command } => match command {
55            InputSourceCommand::Current(_) => "input-source.current",
56            InputSourceCommand::Switch(_) => "input-source.switch",
57        },
58        CommandGroup::Ax { command } => match command {
59            AxCommand::List(_) => "ax.list",
60            AxCommand::Click(_) => "ax.click",
61            AxCommand::Type(_) => "ax.type",
62            AxCommand::Attr { command } => match command {
63                AxAttrCommand::Get(_) => "ax.attr.get",
64                AxAttrCommand::Set(_) => "ax.attr.set",
65            },
66            AxCommand::Action { command } => match command {
67                AxActionCommand::Perform(_) => "ax.action.perform",
68            },
69            AxCommand::Session { command } => match command {
70                AxSessionCommand::Start(_) => "ax.session.start",
71                AxSessionCommand::List(_) => "ax.session.list",
72                AxSessionCommand::Stop(_) => "ax.session.stop",
73            },
74            AxCommand::Watch { command } => match command {
75                AxWatchCommand::Start(_) => "ax.watch.start",
76                AxWatchCommand::Poll(_) => "ax.watch.poll",
77                AxWatchCommand::Stop(_) => "ax.watch.stop",
78            },
79        },
80        CommandGroup::Observe { command } => match command {
81            ObserveCommand::Screenshot(_) => "observe.screenshot",
82        },
83        CommandGroup::Debug { command } => match command {
84            DebugCommand::Bundle(_) => "debug.bundle",
85        },
86        CommandGroup::Wait { command } => match command {
87            WaitCommand::Sleep(_) => "wait.sleep",
88            WaitCommand::AppActive(_) => "wait.app-active",
89            WaitCommand::WindowPresent(_) => "wait.window-present",
90            WaitCommand::AxPresent(_) => "wait.ax-present",
91            WaitCommand::AxUnique(_) => "wait.ax-unique",
92        },
93        CommandGroup::Scenario { command } => match command {
94            ScenarioCommand::Run(_) => "scenario.run",
95        },
96        CommandGroup::Profile { command } => match command {
97            ProfileCommand::Validate(_) => "profile.validate",
98            ProfileCommand::Init(_) => "profile.init",
99        },
100        CommandGroup::Completion(_) => "completion",
101    }
102}
103
104pub fn run(cli: Cli) -> Result<(), CliError> {
105    ensure_supported_platform()?;
106    validate_format_support(&cli)?;
107
108    let policy = ActionPolicy {
109        dry_run: cli.dry_run,
110        retries: cli.retries,
111        retry_delay_ms: cli.retry_delay_ms,
112        timeout_ms: cli.timeout_ms,
113    };
114    let runner = RealProcessRunner;
115
116    match cli.command {
117        CommandGroup::Preflight(args) => run_preflight(cli.format, args),
118        CommandGroup::Windows {
119            command: WindowsCommand::List(args),
120        } => commands::list::run_windows_list(cli.format, &args),
121        CommandGroup::Apps {
122            command: AppsCommand::List(args),
123        } => commands::list::run_apps_list(cli.format, &args),
124        CommandGroup::Observe {
125            command: ObserveCommand::Screenshot(args),
126        } => commands::observe::run_screenshot(cli.format, &args),
127        CommandGroup::Debug {
128            command: DebugCommand::Bundle(args),
129        } => commands::list::run_debug_bundle(cli.format, &args, policy, &runner),
130        CommandGroup::Wait {
131            command: WaitCommand::Sleep(args),
132        } => commands::wait::run_sleep(cli.format, &args),
133        CommandGroup::Wait {
134            command: WaitCommand::AppActive(args),
135        } => commands::wait::run_app_active(cli.format, &args),
136        CommandGroup::Wait {
137            command: WaitCommand::WindowPresent(args),
138        } => commands::wait::run_window_present(cli.format, &args),
139        CommandGroup::Wait {
140            command: WaitCommand::AxPresent(args),
141        } => commands::wait::run_ax_present(cli.format, &args, policy, &runner),
142        CommandGroup::Wait {
143            command: WaitCommand::AxUnique(args),
144        } => commands::wait::run_ax_unique(cli.format, &args, policy, &runner),
145        CommandGroup::Scenario {
146            command: ScenarioCommand::Run(args),
147        } => commands::scenario::run(cli.format, &args),
148        CommandGroup::Profile {
149            command: ProfileCommand::Validate(args),
150        } => commands::profile::run_validate(cli.format, &args),
151        CommandGroup::Profile {
152            command: ProfileCommand::Init(args),
153        } => commands::profile::run_init(cli.format, &args),
154        CommandGroup::Completion(_) => Ok(()),
155        CommandGroup::Window {
156            command: WindowCommand::Activate(args),
157        } => commands::window_activate::run(cli.format, &args, policy, &runner),
158        CommandGroup::Input {
159            command: InputCommand::Click(args),
160        } => commands::input_click::run(cli.format, &args, policy, &runner),
161        CommandGroup::Input {
162            command: InputCommand::Type(args),
163        } => commands::input_type::run(cli.format, &args, policy, &runner),
164        CommandGroup::Input {
165            command: InputCommand::Hotkey(args),
166        } => commands::input_hotkey::run(cli.format, &args, policy, &runner),
167        CommandGroup::InputSource { command } => match command {
168            InputSourceCommand::Current(args) => {
169                commands::input_source::run_current(cli.format, &args, policy, &runner)
170            }
171            InputSourceCommand::Switch(args) => {
172                commands::input_source::run_switch(cli.format, &args, policy, &runner)
173            }
174        },
175        CommandGroup::Ax { command } => match command {
176            AxCommand::List(args) => commands::ax_list::run(cli.format, &args, policy, &runner),
177            AxCommand::Click(args) => commands::ax_click::run(cli.format, &args, policy, &runner),
178            AxCommand::Type(args) => commands::ax_type::run(cli.format, &args, policy, &runner),
179            AxCommand::Attr { command } => match command {
180                AxAttrCommand::Get(args) => {
181                    commands::ax_attr::run_get(cli.format, &args, policy, &runner)
182                }
183                AxAttrCommand::Set(args) => {
184                    commands::ax_attr::run_set(cli.format, &args, policy, &runner)
185                }
186            },
187            AxCommand::Action { command } => match command {
188                AxActionCommand::Perform(args) => {
189                    commands::ax_action::run_perform(cli.format, &args, policy, &runner)
190                }
191            },
192            AxCommand::Session { command } => match command {
193                AxSessionCommand::Start(args) => {
194                    commands::ax_session::run_start(cli.format, &args, policy, &runner)
195                }
196                AxSessionCommand::List(args) => {
197                    commands::ax_session::run_list(cli.format, &args, policy, &runner)
198                }
199                AxSessionCommand::Stop(args) => {
200                    commands::ax_session::run_stop(cli.format, &args, policy, &runner)
201                }
202            },
203            AxCommand::Watch { command } => match command {
204                AxWatchCommand::Start(args) => {
205                    commands::ax_watch::run_start(cli.format, &args, policy, &runner)
206                }
207                AxWatchCommand::Poll(args) => {
208                    commands::ax_watch::run_poll(cli.format, &args, policy, &runner)
209                }
210                AxWatchCommand::Stop(args) => {
211                    commands::ax_watch::run_stop(cli.format, &args, policy, &runner)
212                }
213            },
214        },
215    }
216}
217
218fn ensure_supported_platform() -> Result<(), CliError> {
219    if cfg!(target_os = "macos") || test_mode::enabled() {
220        Ok(())
221    } else {
222        Err(CliError::unsupported_platform())
223    }
224}
225
226fn validate_format_support(cli: &Cli) -> Result<(), CliError> {
227    if cli.format != OutputFormat::Tsv {
228        return Ok(());
229    }
230
231    let tsv_allowed = matches!(
232        &cli.command,
233        CommandGroup::Windows {
234            command: WindowsCommand::List(_),
235        } | CommandGroup::Apps {
236            command: AppsCommand::List(_),
237        }
238    );
239
240    if tsv_allowed {
241        Ok(())
242    } else {
243        Err(CliError::usage(
244            "--format tsv is only supported for `windows list` and `apps list`",
245        ))
246    }
247}
248
249fn run_preflight(format: OutputFormat, args: PreflightArgs) -> Result<(), CliError> {
250    let snapshot = preflight::collect_system_snapshot();
251    let probes = if args.include_probes {
252        preflight::run_live_probes()
253    } else {
254        Vec::new()
255    };
256    let mut report = preflight::build_report_with_probes(snapshot, args.strict, probes);
257    let backend_capability = backend::preflight_capability_check();
258    report.checks.push(preflight::CheckReport {
259        id: "ax_backend_capabilities",
260        label: "AX backend capabilities",
261        status: preflight::CheckStatus::Ok,
262        blocking: false,
263        message: backend_capability.message,
264        hint: backend_capability.hint,
265    });
266
267    match format {
268        OutputFormat::Text => println!("{}", preflight::render_text(&report)),
269        OutputFormat::Json => println!("{}", preflight::render_json(&report)),
270        OutputFormat::Tsv => {
271            return Err(CliError::usage(
272                "--format tsv is only supported for `windows list` and `apps list`",
273            ));
274        }
275    }
276
277    Ok(())
278}
279
280pub fn next_action_id(command: &str) -> String {
281    let sequence = ACTION_COUNTER.fetch_add(1, Ordering::SeqCst);
282    format!("{command}-{}-{sequence}", test_mode::timestamp_token())
283}
284
285pub fn build_action_meta(action_id: String, started: Instant, policy: ActionPolicy) -> ActionMeta {
286    build_action_meta_with_attempts(
287        action_id,
288        started,
289        policy,
290        if policy.dry_run { 0 } else { 1 },
291    )
292}
293
294pub fn build_action_meta_with_attempts(
295    action_id: String,
296    started: Instant,
297    policy: ActionPolicy,
298    attempts_used: u8,
299) -> ActionMeta {
300    ActionMeta {
301        action_id,
302        elapsed_ms: started.elapsed().as_millis() as u64,
303        dry_run: policy.dry_run,
304        retries: policy.retries,
305        attempts_used,
306        timeout_ms: policy.timeout_ms,
307    }
308}
309
310pub fn action_policy_result(policy: ActionPolicy) -> ActionPolicyResult {
311    ActionPolicyResult {
312        dry_run: policy.dry_run,
313        retries: policy.retries,
314        retry_delay_ms: policy.retry_delay_ms,
315        timeout_ms: policy.timeout_ms,
316    }
317}
318
319#[cfg(test)]
320mod tests {
321    use clap::Parser;
322    use nils_test_support::{EnvGuard, GlobalStateLock};
323    use pretty_assertions::assert_eq;
324
325    use crate::cli::{Cli, OutputFormat};
326
327    use super::{ensure_supported_platform, validate_format_support};
328
329    #[test]
330    fn rejects_tsv_for_non_list_commands() {
331        let cli = Cli::try_parse_from(["macos-agent", "--format", "tsv", "preflight"])
332            .expect("args should parse");
333        let err = validate_format_support(&cli).expect_err("tsv should fail for preflight");
334        assert_eq!(err.exit_code(), 2);
335        assert!(err.to_string().contains("windows list"));
336    }
337
338    #[test]
339    fn allows_tsv_for_windows_list() {
340        let cli = Cli::try_parse_from(["macos-agent", "--format", "tsv", "windows", "list"])
341            .expect("args should parse");
342        assert_eq!(cli.format, OutputFormat::Tsv);
343        validate_format_support(&cli).expect("tsv should be accepted for windows list");
344    }
345
346    #[test]
347    fn platform_gate_maps_non_macos_to_usage_error_unless_test_mode() {
348        let lock = GlobalStateLock::new();
349        let _mode = EnvGuard::remove(&lock, "AGENTS_MACOS_AGENT_TEST_MODE");
350        let result = ensure_supported_platform();
351        #[cfg(target_os = "macos")]
352        assert!(result.is_ok());
353
354        #[cfg(not(target_os = "macos"))]
355        {
356            let err = result.expect_err("non-macos should be rejected by default");
357            assert_eq!(err.exit_code(), 2);
358            assert!(err.to_string().contains("macOS"));
359        }
360    }
361}