//! `bsk record start|stop` — capture user actions in the Agent Window. use std::fs; use std::path::PathBuf; use std::time::Duration; use anyhow::Context; use bsk_protocol::Method; use bsk_protocol::tools::{ RecordAwaitParams, RecordAwaitResult, RecordStartParams, RecordStartResult, RecordStopParams, RecordStopResult, Trace, }; use clap::{Args, Subcommand}; use crate::cli::TOOL_IPC_TIMEOUT; use crate::cli::business_rpc; use crate::cli::ensure_daemon::ensure_daemon; use crate::cli::error::{CliError, Format}; use crate::cli::record_state; use crate::cli::session::{start_session, stop_session}; /// Max wait for the user to click 结束 in the browser (24 hours). const RECORD_AWAIT_TIMEOUT_MS: u32 = 86_400_000; /// `bsk record …` subcommand tree. #[derive(Debug, Clone, Args)] pub struct RecordCmd { #[command(subcommand)] pub sub: RecordSub, } #[derive(Debug, Clone, Subcommand)] pub enum RecordSub { /// Open the Agent Window, record user actions, and block until finished in the browser. Start(RecordStartArgs), /// Stop recording from the terminal (fallback), write trace JSON, and close the window. /// Works even while `record start` is blocked in `record_await` (daemon forwards /// `tool.record_stop` without the per-session busy gate). Stop(RecordStopArgs), } #[derive(Debug, Clone, Args)] pub struct RecordStartArgs { /// Target browser instance id or label. Required when multiple browsers /// are connected; omit when only one is online. #[arg(long)] pub browser: Option, #[arg(long = "tab-id")] pub tab_id: Option, /// Navigate to this http(s) URL before recording. When omitted, defaults /// to `https://example.com/`. #[arg(long)] pub url: Option, /// Optional goal text stored on the exported trace for LLM context. #[arg(long)] pub purpose: Option, /// Output path for the trace JSON (default `./trace.json`). #[arg(long, default_value = "trace.json")] pub output: PathBuf, } #[derive(Debug, Clone, Args)] pub struct RecordStopArgs { /// Output path for the trace JSON (default `./trace.json`). #[arg(long, default_value = "trace.json")] pub output: PathBuf, } pub fn dispatch(cmd: RecordCmd, format: Format) -> Result<(), CliError> { match cmd.sub { RecordSub::Start(args) => dispatch_start(args, format), RecordSub::Stop(args) => dispatch_stop(args, format), } } fn dispatch_start(args: RecordStartArgs, format: Format) -> Result<(), CliError> { if record_state::read().is_ok() { return Err(CliError::Local(anyhow::anyhow!( "a recording is already in progress; run `bsk record stop` first" ))); } let info = ensure_daemon().context("ensure daemon is running")?; let session = start_session(info.sock_path.clone(), args.browser)?; let start_params = RecordStartParams { session_id: session.session_id.clone(), tab_id: args.tab_id, url: args.url, purpose: args.purpose.clone(), }; let start_result = business_rpc::call::( info.sock_path.clone(), "record-start", Method::ToolRecordStart, Some(start_params), TOOL_IPC_TIMEOUT, ); let start_result = match start_result { Ok(result) => result, Err(err) => { let _ = stop_session(info.sock_path, &session.session_id); return Err(err); } }; if let Err(err) = record_state::write(&session.session_id) { let _ = stop_session(info.sock_path.clone(), &session.session_id); return Err(CliError::Local(err)); } if format == Format::Human { println!( "recording on tab={} — click 结束 in the browser when done", start_result.tab_id ); } let await_params = RecordAwaitParams { session_id: session.session_id.clone(), timeout_ms: Some(RECORD_AWAIT_TIMEOUT_MS), }; let await_result = business_rpc::call::( info.sock_path.clone(), "record-await", Method::ToolRecordAwait, Some(await_params), record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS), ); // Keep write/render inside a Result so `?` cannot skip session teardown. let run_result: Result<(), CliError> = match await_result { Ok(result) => (|| { write_trace_file(&args.output, &result.trace)?; render_finish(&result.trace, &args.output, format) })(), Err(err) => Err(err), }; let session_stop_result = stop_session(info.sock_path, &session.session_id); record_state::clear(); run_result?; session_stop_result?; Ok(()) } fn dispatch_stop(args: RecordStopArgs, format: Format) -> Result<(), CliError> { let state = record_state::read().map_err(CliError::Local)?; let info = ensure_daemon().context("ensure daemon is running")?; let session_id = state.session_id.clone(); let run_result: Result<(), CliError> = (|| { let params = RecordStopParams { session_id: session_id.clone(), }; let result = business_rpc::call::( info.sock_path.clone(), "record-stop", Method::ToolRecordStop, Some(params), TOOL_IPC_TIMEOUT, )?; write_trace_file(&args.output, &result.trace)?; render_stop(&result, &args.output, format) })(); let session_stop_result = stop_session(info.sock_path, &session_id); record_state::clear(); run_result?; session_stop_result?; Ok(()) } fn record_await_ipc_timeout(timeout_ms: u32) -> Duration { Duration::from_millis(u64::from(timeout_ms)) .checked_add(Duration::from_secs(15)) .unwrap_or(Duration::from_secs(u64::from(timeout_ms / 1_000) + 15)) } fn write_trace_file(output: &PathBuf, trace: &Trace) -> Result<(), CliError> { let json = serde_json::to_string_pretty(trace) .context("serialize trace JSON") .map_err(CliError::Local)?; if let Some(parent) = output.parent() { if !parent.as_os_str().is_empty() { fs::create_dir_all(parent) .with_context(|| format!("create output directory {}", parent.display())) .map_err(CliError::Local)?; } } fs::write(output, format!("{json}\n")) .with_context(|| format!("write trace to {}", output.display())) .map_err(CliError::Local)?; Ok(()) } fn render_finish(trace: &Trace, output: &PathBuf, format: Format) -> Result<(), CliError> { match format { Format::Json => { println!( "{}", serde_json::to_string(&serde_json::json!({ "output": output, "trace": trace, "window_closed": true, })) .map_err(|e| CliError::Local(anyhow::anyhow!(e)))? ); } Format::Human => { println!("saved {} steps to {}", trace.steps.len(), output.display()); } } Ok(()) } fn render_stop( result: &RecordStopResult, output: &PathBuf, format: Format, ) -> Result<(), CliError> { render_finish(&result.trace, output, format) } #[cfg(test)] mod tests { use super::*; #[test] fn default_output_is_trace_json() { let args = RecordStopArgs { output: PathBuf::from("trace.json"), }; assert_eq!(args.output, PathBuf::from("trace.json")); } #[test] fn start_args_default_output_is_trace_json() { let args = RecordStartArgs { browser: None, tab_id: None, url: None, purpose: None, output: PathBuf::from("trace.json"), }; assert_eq!(args.output, PathBuf::from("trace.json")); } #[test] fn record_await_ipc_timeout_covers_long_wait() { let got = record_await_ipc_timeout(RECORD_AWAIT_TIMEOUT_MS); assert!(got >= Duration::from_secs(86_400)); } }