use std::time::Instant;
use crate::backend::process::ProcessRunner;
use crate::backend::{AutoAxBackend, AxBackendAdapter};
use crate::cli::{AxWatchPollArgs, AxWatchStartArgs, AxWatchStopArgs, OutputFormat};
use crate::commands::{emit_json_success, reject_tsv_for_list_only};
use crate::error::CliError;
use crate::model::{
AxWatchPollRequest, AxWatchPollResult, AxWatchStartCommandResult, AxWatchStartRequest,
AxWatchStartResult, AxWatchStopCommandResult, AxWatchStopRequest, AxWatchStopResult,
};
use crate::retry::run_with_retry;
use crate::run::{
ActionPolicy, action_policy_result, build_action_meta_with_attempts, next_action_id,
};
const REDACTED_SESSION_ID: &str = "redacted";
pub fn run_start(
format: OutputFormat,
args: &AxWatchStartArgs,
policy: ActionPolicy,
runner: &dyn ProcessRunner,
) -> Result<(), CliError> {
let request = AxWatchStartRequest {
session_id: args.session_id.clone(),
events: args.events.clone(),
max_buffer: args.max_buffer,
watch_id: args.watch_id.clone(),
};
let action_id = next_action_id("ax.watch.start");
let started = Instant::now();
let mut attempts_used = 0u8;
let mut detail = AxWatchStartResult {
watch_id: request
.watch_id
.clone()
.unwrap_or_else(|| "axw-dry-run".to_string()),
session_id: request.session_id.clone(),
events: request.events.clone(),
max_buffer: request.max_buffer,
started: false,
};
if !policy.dry_run {
let backend = AutoAxBackend::default();
let retry = policy.retry_policy();
let (backend_result, attempts) = run_with_retry(retry, || {
backend.watch_start(runner, &request, policy.timeout_ms)
})?;
attempts_used = attempts;
detail = backend_result;
}
let result = AxWatchStartCommandResult {
detail,
policy: action_policy_result(policy),
meta: build_action_meta_with_attempts(action_id, started, policy, attempts_used),
};
print_start(format, result)
}
pub fn run_poll(
format: OutputFormat,
args: &AxWatchPollArgs,
policy: ActionPolicy,
runner: &dyn ProcessRunner,
) -> Result<(), CliError> {
let request = AxWatchPollRequest {
watch_id: args.watch_id.clone(),
limit: args.limit,
drain: args.drain,
};
let backend = AutoAxBackend::default();
let result: AxWatchPollResult = backend.watch_poll(runner, &request, policy.timeout_ms)?;
match format {
OutputFormat::Json => {
emit_json_success("ax.watch.poll", result)?;
}
OutputFormat::Text => {
println!(
"ax.watch.poll\twatch_id={}\tevents={}\tdropped={}\trunning={}",
result.watch_id,
result.events.len(),
result.dropped,
result.running,
);
for event in result.events {
println!(
"ax.watch.event\twatch_id={}\tevent={}\tat_ms={}\trole={}\ttitle={}",
event.watch_id,
event.event,
event.at_ms,
event.role.unwrap_or_default(),
event.title.unwrap_or_default()
);
}
}
OutputFormat::Tsv => {
return reject_tsv_for_list_only();
}
}
Ok(())
}
pub fn run_stop(
format: OutputFormat,
args: &AxWatchStopArgs,
policy: ActionPolicy,
runner: &dyn ProcessRunner,
) -> Result<(), CliError> {
let request = AxWatchStopRequest {
watch_id: args.watch_id.clone(),
};
let action_id = next_action_id("ax.watch.stop");
let started = Instant::now();
let mut attempts_used = 0u8;
let mut detail = AxWatchStopResult {
watch_id: request.watch_id.clone(),
stopped: false,
drained: 0,
};
if !policy.dry_run {
let backend = AutoAxBackend::default();
let retry = policy.retry_policy();
let (backend_result, attempts) = run_with_retry(retry, || {
backend.watch_stop(runner, &request, policy.timeout_ms)
})?;
attempts_used = attempts;
detail = backend_result;
}
let result = AxWatchStopCommandResult {
detail,
policy: action_policy_result(policy),
meta: build_action_meta_with_attempts(action_id, started, policy, attempts_used),
};
print_stop(format, result)
}
fn print_start(format: OutputFormat, result: AxWatchStartCommandResult) -> Result<(), CliError> {
match format {
OutputFormat::Json => {
emit_json_success("ax.watch.start", result)?;
}
OutputFormat::Text => {
println!(
"ax.watch.start\twatch_id={}\tsession_id={}\tstarted={}\tevents={}\tmax_buffer={}\taction_id={}\telapsed_ms={}",
result.detail.watch_id,
REDACTED_SESSION_ID,
result.detail.started,
result.detail.events.join(","),
result.detail.max_buffer,
result.meta.action_id,
result.meta.elapsed_ms,
);
}
OutputFormat::Tsv => {
return reject_tsv_for_list_only();
}
}
Ok(())
}
fn print_stop(format: OutputFormat, result: AxWatchStopCommandResult) -> Result<(), CliError> {
match format {
OutputFormat::Json => {
emit_json_success("ax.watch.stop", result)?;
}
OutputFormat::Text => {
println!(
"ax.watch.stop\twatch_id={}\tstopped={}\tdrained={}\taction_id={}\telapsed_ms={}",
result.detail.watch_id,
result.detail.stopped,
result.detail.drained,
result.meta.action_id,
result.meta.elapsed_ms,
);
}
OutputFormat::Tsv => {
return reject_tsv_for_list_only();
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use nils_test_support::{EnvGuard, GlobalStateLock};
use super::{run_poll, run_start, run_stop};
use crate::backend::process::RealProcessRunner;
use crate::cli::{AxWatchPollArgs, AxWatchStartArgs, AxWatchStopArgs, OutputFormat};
use crate::run::ActionPolicy;
fn policy(dry_run: bool) -> ActionPolicy {
ActionPolicy {
dry_run,
retries: 0,
retry_delay_ms: 150,
timeout_ms: 1000,
}
}
fn sample_start_args() -> AxWatchStartArgs {
AxWatchStartArgs {
session_id: "axs-unit".to_string(),
watch_id: Some("axw-unit".to_string()),
events: vec![
"AXFocusedUIElementChanged".to_string(),
"AXTitleChanged".to_string(),
],
max_buffer: 64,
}
}
fn sample_poll_args() -> AxWatchPollArgs {
AxWatchPollArgs {
watch_id: "axw-unit".to_string(),
limit: 10,
drain: true,
}
}
fn sample_stop_args() -> AxWatchStopArgs {
AxWatchStopArgs {
watch_id: "axw-unit".to_string(),
}
}
#[test]
fn run_start_and_stop_dry_run_support_text_and_json() {
let lock = GlobalStateLock::new();
let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
let runner = RealProcessRunner;
run_start(
OutputFormat::Text,
&sample_start_args(),
policy(true),
&runner,
)
.expect("start text dry-run should succeed");
run_start(
OutputFormat::Json,
&sample_start_args(),
policy(true),
&runner,
)
.expect("start json dry-run should succeed");
run_stop(
OutputFormat::Text,
&sample_stop_args(),
policy(true),
&runner,
)
.expect("stop text dry-run should succeed");
run_stop(
OutputFormat::Json,
&sample_stop_args(),
policy(true),
&runner,
)
.expect("stop json dry-run should succeed");
}
#[test]
fn run_start_and_stop_reject_tsv_in_dry_run() {
let lock = GlobalStateLock::new();
let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
let runner = RealProcessRunner;
let start_err = run_start(
OutputFormat::Tsv,
&sample_start_args(),
policy(true),
&runner,
)
.expect_err("start tsv should be rejected");
assert!(start_err.to_string().contains("windows list"));
let stop_err = run_stop(
OutputFormat::Tsv,
&sample_stop_args(),
policy(true),
&runner,
)
.expect_err("stop tsv should be rejected");
assert!(stop_err.to_string().contains("windows list"));
}
#[test]
fn run_poll_covers_event_text_and_tsv_rejection() {
let lock = GlobalStateLock::new();
let _mode = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_TEST_MODE", "1");
let _backend = EnvGuard::set(&lock, "AGENTS_MACOS_AGENT_AX_BACKEND", "hammerspoon");
let _poll_override = EnvGuard::set(
&lock,
"AGENTS_MACOS_AGENT_AX_WATCH_POLL_JSON",
r#"{"watch_id":"axw-unit","events":[{"watch_id":"axw-unit","event":"AXTitleChanged","at_ms":1700000002222,"role":"AXButton","title":"Save","identifier":"save-btn","pid":2001}],"dropped":0,"running":true}"#,
);
let runner = RealProcessRunner;
run_poll(
OutputFormat::Text,
&sample_poll_args(),
policy(false),
&runner,
)
.expect("poll text should succeed");
let err = run_poll(
OutputFormat::Tsv,
&sample_poll_args(),
policy(false),
&runner,
)
.expect_err("poll tsv should be rejected");
assert!(err.to_string().contains("windows list"));
}
}