diff --git a/crates/core/src/commands/mod.rs b/crates/core/src/commands/mod.rs index bff3080..8a40323 100644 --- a/crates/core/src/commands/mod.rs +++ b/crates/core/src/commands/mod.rs @@ -47,6 +47,7 @@ pub mod screenshot; pub mod scroll; pub mod scroll_to; pub mod select; +pub mod session; pub mod set_value; pub mod skills; pub mod snapshot; diff --git a/crates/core/src/commands/session.rs b/crates/core/src/commands/session.rs new file mode 100644 index 0000000..6f4ddbe --- /dev/null +++ b/crates/core/src/commands/session.rs @@ -0,0 +1,81 @@ +use crate::error::AppError; +use crate::session::{ + GcOptions, SessionTraceMode, StartSessionOptions, end_session, gc, list_sessions, start_session, +}; +use serde_json::{Value, json}; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub enum SessionAction { + Start { + name: Option, + no_trace: bool, + force: bool, + }, + End { + id: Option, + }, + List, + Gc { + older_than_secs: Option, + ended_only: bool, + }, +} + +pub fn execute(action: SessionAction) -> Result { + match action { + SessionAction::Start { + name, + no_trace, + force, + } => { + let manifest = start_session(StartSessionOptions { + name, + trace: if no_trace { + SessionTraceMode::Off + } else { + SessionTraceMode::On + }, + force, + })?; + Ok(json!({ + "session_id": manifest.id, + "name": manifest.name, + "trace": manifest.trace, + "created_at": manifest.created_at, + })) + } + SessionAction::End { id } => { + let manifest = end_session(id.as_deref())?; + Ok(json!({ + "session_id": manifest.id, + "ended_at": manifest.ended_at, + })) + } + SessionAction::List => { + let sessions: Vec = list_sessions()? + .into_iter() + .map(|manifest| { + json!({ + "session_id": manifest.id, + "name": manifest.name, + "created_at": manifest.created_at, + "ended_at": manifest.ended_at, + "trace": manifest.trace, + }) + }) + .collect(); + Ok(json!({ "sessions": sessions })) + } + SessionAction::Gc { + older_than_secs, + ended_only, + } => { + let report = gc(GcOptions { + ended_only, + older_than: older_than_secs.map(Duration::from_secs), + })?; + Ok(json!({ "removed": report.removed })) + } + } +} diff --git a/crates/core/src/commands/status.rs b/crates/core/src/commands/status.rs index 2436d7e..3f348ed 100644 --- a/crates/core/src/commands/status.rs +++ b/crates/core/src/commands/status.rs @@ -5,6 +5,7 @@ use crate::{ context::CommandContext, error::AppError, refs_store::RefStore, + session::{read_current_session_pointer, trace_enabled_for_session}, }; use serde_json::{Value, json}; @@ -22,13 +23,23 @@ pub fn execute_with_report_with_context( .and_then(|s| s.load_latest().ok()) .map(|m| m.len()); let snapshot_id = store.and_then(|s| s.latest_snapshot_id()); + let session_id = context + .session_id() + .map(str::to_string) + .or_else(|| read_current_session_pointer().ok().flatten()); + let tracing = context.trace_enabled() + || session_id + .as_deref() + .is_some_and(|id| trace_enabled_for_session(id).unwrap_or(false)); Ok(json!({ "platform": std::env::consts::OS, "version": env!("CARGO_PKG_VERSION"), "permissions": permissions, "snapshot_id": snapshot_id, - "ref_count": ref_count + "ref_count": ref_count, + "session_id": session_id, + "tracing": tracing, })) } diff --git a/crates/core/src/context.rs b/crates/core/src/context.rs index bcaa373..5992fe3 100644 --- a/crates/core/src/context.rs +++ b/crates/core/src/context.rs @@ -1,17 +1,10 @@ use crate::{ action::Action, action_request::ActionRequest, error::AppError, - interaction_policy::InteractionPolicy, trace::TraceConfig, + interaction_policy::InteractionPolicy, refs_store::RefStore, session, trace::TraceConfig, }; use serde_json::Value; use std::path::PathBuf; -#[derive(Debug, Clone)] -pub struct WaitSelector { - pub query_raw: String, - pub gone: bool, - pub timeout_ms: u64, -} - #[derive(Debug, Clone, Default)] pub struct CommandContext { session_id: Option, @@ -20,6 +13,13 @@ pub struct CommandContext { wait_selector: Option, } +#[derive(Debug, Clone)] +pub struct WaitSelector { + pub query_raw: String, + pub gone: bool, + pub timeout_ms: u64, +} + impl CommandContext { pub fn new( session_id: Option, @@ -29,18 +29,15 @@ impl CommandContext { if let Some(id) = session_id.as_deref() { validate_session_id(id)?; } + let segment_dir = session_segment_dir(session_id.as_deref(), trace_path.is_some())?; Ok(Self { session_id, - trace: TraceConfig::new(trace_path, trace_strict)?, + trace: TraceConfig::build(trace_path, segment_dir, trace_strict)?, headed: false, wait_selector: None, }) } - /// Selects headed interaction: ref actions may move the cursor and steal - /// focus, unlocking the physical click/scroll/keypress fallbacks in the - /// action chain. Off by default — the tool is headless-first (Playwright - /// style: headless is the default, headed is opt-in via `--headed`). pub fn with_headed(mut self, headed: bool) -> Self { self.headed = headed; self @@ -55,11 +52,6 @@ impl CommandContext { self.wait_selector.as_ref() } - /// Builds the action request for a ref command. Headless (default) uses the - /// command's own `base` policy — its minimum viable policy with no cursor - /// movement (most commands are pure-AX `headless`; `type` is `focus_fallback` - /// because typing requires focus). `--headed` upgrades any base to `headed`, - /// unlocking the cursor/OS-input fallbacks instead of failing closed. pub fn request(&self, action: Action, base: InteractionPolicy) -> ActionRequest { ActionRequest { action, @@ -67,18 +59,11 @@ impl CommandContext { } } - /// Builds the action request using the action's canonical CLI base policy - /// from `Action::base_interaction_policy()`, ensuring a single source of - /// truth for the per-action minimum. `--headed` upgrades the base to - /// `headed` exactly as `request()` does. pub fn request_base(&self, action: Action) -> ActionRequest { let base = action.base_interaction_policy(); self.request(action, base) } - /// Policy for raw physical-input commands (hover, drag, mouse-*). They - /// have no semantic action chain, so headless denies both focus stealing - /// and cursor movement unless `--headed` opts in. pub fn physical_input_policy(&self) -> InteractionPolicy { self.policy_with_base(InteractionPolicy::headless()) } @@ -96,9 +81,15 @@ impl CommandContext { if let Some(id) = session_id.as_deref() { validate_session_id(id)?; } + let trace = if self.trace.pending_file_path().is_some() { + self.trace.clone() + } else { + let segment_dir = session_segment_dir(session_id.as_deref(), false)?; + self.trace.clone_with_session_segment(segment_dir)? + }; Ok(Self { session_id, - trace: self.trace.clone(), + trace, headed: self.headed, wait_selector: None, }) @@ -116,6 +107,26 @@ impl CommandContext { pub fn session_id(&self) -> Option<&str> { self.session_id.as_deref() } + + pub fn trace_enabled(&self) -> bool { + self.trace.has_sink() + } +} + +fn session_segment_dir( + session_id: Option<&str>, + explicit_trace: bool, +) -> Result, AppError> { + if explicit_trace { + return Ok(None); + } + let Some(session_id) = session_id else { + return Ok(None); + }; + if !session::trace_enabled_for_session(session_id)? { + return Ok(None); + } + Ok(Some(RefStore::for_session(Some(session_id))?.trace_dir())) } pub fn validate_session_id(id: &str) -> Result<(), AppError> { @@ -134,235 +145,5 @@ pub fn validate_session_id(id: &str) -> Result<(), AppError> { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn accepts_filesystem_safe_session_ids() { - assert!(validate_session_id("agent-1_A").is_ok()); - } - - #[test] - fn rejects_path_like_session_ids() { - assert!(validate_session_id("../agent").is_err()); - assert!(validate_session_id("agent/a").is_err()); - } - - #[test] - fn trace_writes_jsonl_without_stdout_dependency() { - let path = std::env::temp_dir().join(format!( - "agent-desktop-trace-{}.jsonl", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let context = CommandContext::new(None, Some(path.clone()), false).unwrap(); - - context - .trace("ref.resolve.ok", serde_json::json!({ "ref": "@e1" })) - .unwrap(); - - let body = std::fs::read_to_string(&path).unwrap(); - let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); - assert_eq!(event["event"], "ref.resolve.ok"); - assert_eq!(event["ref"], "@e1"); - assert!(event["ts_ms"].as_u64().is_some()); - assert!( - event.get("session_id").is_none(), - "session_id must be absent when not set" - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn trace_injects_session_id_as_top_level_unredacted_field() { - let path = std::env::temp_dir().join(format!( - "agent-desktop-session-trace-{}.jsonl", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let context = - CommandContext::new(Some("my-session".into()), Some(path.clone()), false).unwrap(); - - context - .trace("ref.resolve.ok", serde_json::json!({ "ref": "@e1" })) - .unwrap(); - - let body = std::fs::read_to_string(&path).unwrap(); - let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); - assert_eq!( - event["session_id"], "my-session", - "session_id must be a top-level string" - ); - assert_eq!(event["event"], "ref.resolve.ok"); - assert!(event["ts_ms"].as_u64().is_some()); - assert!( - !body.contains("redacted"), - "session_id must not be redacted" - ); - let _ = std::fs::remove_file(path); - } - - #[test] - fn trace_write_failure_is_best_effort_unless_strict() { - let missing = std::env::temp_dir() - .join("agent-desktop-missing-dir") - .join("trace.jsonl"); - - let best_effort = CommandContext::new(None, Some(missing.clone()), false).unwrap(); - assert!(best_effort.trace("event", serde_json::json!({})).is_ok()); - - assert!(CommandContext::new(None, Some(missing), true).is_err()); - } - - #[test] - fn trace_lazy_does_not_build_fields_when_trace_is_disabled() { - let context = CommandContext::default(); - let built = std::cell::Cell::new(false); - - context - .trace_lazy("event", || { - built.set(true); - serde_json::json!({}) - }) - .unwrap(); - - assert!(!built.get()); - } - - #[cfg(unix)] - #[test] - fn trace_file_is_private_on_create() { - use std::os::unix::fs::PermissionsExt; - - let path = std::env::temp_dir().join(format!( - "agent-desktop-private-trace-{}.jsonl", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let context = CommandContext::new(None, Some(path.clone()), true).unwrap(); - context.trace("event", serde_json::json!({})).unwrap(); - - let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; - assert_eq!(mode, 0o600); - let _ = std::fs::remove_file(path); - } - - #[cfg(unix)] - #[test] - fn trace_rejects_loose_existing_file_permissions() { - use std::os::unix::fs::PermissionsExt; - - let path = std::env::temp_dir().join(format!( - "agent-desktop-loose-trace-{}.jsonl", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - std::fs::write(&path, "").unwrap(); - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); - - let err = CommandContext::new(None, Some(path.clone()), false).unwrap_err(); - - assert_eq!(err.code(), "INVALID_ARGS"); - let _ = std::fs::remove_file(path); - } - - #[test] - fn trace_redacts_sensitive_text_and_value_fields() { - let path = std::env::temp_dir().join(format!( - "agent-desktop-redacted-trace-{}.jsonl", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let context = CommandContext::new(None, Some(path.clone()), true).unwrap(); - context - .trace( - "event", - serde_json::json!({ - "text": "secret", - "value": "hidden", - "name": "private label", - "description": "private desc", - "message": "diagnostic error", - "post_state": { "value": "deep secret" }, - "target_label": "button secret", - "nested": { "expected": "token" }, - "title": "private window title", - "url": "https://internal.example/doc", - "help": "private tooltip", - "placeholder": "private placeholder" - }), - ) - .unwrap(); - - let body = std::fs::read_to_string(&path).unwrap(); - let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); - assert_eq!(event["text"]["redacted"], true); - assert_eq!(event["value"]["redacted"], true); - assert_eq!(event["name"]["redacted"], true); - assert_eq!(event["description"]["redacted"], true); - assert_eq!(event["message"], "diagnostic error"); - assert_eq!(event["post_state"]["value"]["redacted"], true); - assert_eq!(event["target_label"]["redacted"], true); - assert_eq!(event["nested"]["expected"]["redacted"], true); - assert_eq!(event["title"]["redacted"], true); - assert_eq!(event["url"]["redacted"], true); - assert_eq!(event["help"]["redacted"], true); - assert_eq!(event["placeholder"]["redacted"], true); - assert!(!body.contains("secret")); - assert!(!body.contains("hidden")); - assert!(!body.contains("private label")); - assert!(!body.contains("private desc")); - assert!(!body.contains("token")); - assert!(!body.contains("private window title")); - assert!(!body.contains("internal.example")); - assert!(!body.contains("private tooltip")); - assert!(!body.contains("private placeholder")); - let _ = std::fs::remove_file(path); - } - - #[test] - fn trace_strict_requires_trace_path() { - let err = CommandContext::new(None, None, true).unwrap_err(); - assert_eq!(err.code(), "INVALID_ARGS"); - } - - #[test] - fn batch_item_clears_wait_selector() { - let parent = CommandContext::default().with_wait_selector(Some(WaitSelector { - query_raw: "button:OK".into(), - gone: false, - timeout_ms: 5_000, - })); - let child = parent.for_batch_item(None).unwrap(); - assert!(child.wait_selector().is_none()); - } - - #[test] - fn batch_item_inherits_or_overrides_session_without_trace_loss() { - let path = std::env::temp_dir().join("agent-desktop-context-test.jsonl"); - let _ = std::fs::remove_file(&path); - let parent = CommandContext::new(Some("parent".into()), Some(path.clone()), false).unwrap(); - - let inherited = parent.for_batch_item(None).unwrap(); - let overridden = parent.for_batch_item(Some("child".into())).unwrap(); - - assert_eq!(inherited.session_id(), Some("parent")); - assert_eq!(overridden.session_id(), Some("child")); - overridden - .trace("batch.child", serde_json::json!({ "ok": true })) - .unwrap(); - let body = std::fs::read_to_string(&path).unwrap(); - assert!(body.contains("batch.child")); - let _ = std::fs::remove_file(path); - } -} +#[path = "context_tests.rs"] +mod tests; diff --git a/crates/core/src/context_tests.rs b/crates/core/src/context_tests.rs new file mode 100644 index 0000000..36b539a --- /dev/null +++ b/crates/core/src/context_tests.rs @@ -0,0 +1,324 @@ +use super::*; +use crate::session::{ + SessionTraceMode, StartSessionOptions, start_session, trace_enabled_for_session, +}; +use serde_json::json; + +#[test] +fn accepts_filesystem_safe_session_ids() { + assert!(validate_session_id("agent-1_A").is_ok()); +} + +#[test] +fn rejects_path_like_session_ids() { + assert!(validate_session_id("../agent").is_err()); + assert!(validate_session_id("agent/a").is_err()); +} + +#[test] +fn trace_writes_jsonl_without_stdout_dependency() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let context = CommandContext::new(None, Some(path.clone()), false).unwrap(); + + context + .trace("ref.resolve.ok", json!({ "ref": "@e1" })) + .unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); + assert_eq!(event["event"], "ref.resolve.ok"); + assert_eq!(event["ref"], "@e1"); + assert!(event["ts_ms"].as_u64().is_some()); + assert!(event.get("session_id").is_none()); + let _ = std::fs::remove_file(path); +} + +#[test] +fn trace_injects_session_id_as_top_level_unredacted_field() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-session-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let context = + CommandContext::new(Some("my-session".into()), Some(path.clone()), false).unwrap(); + + context + .trace("ref.resolve.ok", json!({ "ref": "@e1" })) + .unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); + assert_eq!(event["session_id"], "my-session"); + assert_eq!(event["event"], "ref.resolve.ok"); + assert!(event["ts_ms"].as_u64().is_some()); + assert!(!body.contains("redacted")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn trace_write_failure_is_best_effort_unless_strict() { + let missing = std::env::temp_dir() + .join("agent-desktop-missing-dir") + .join("trace.jsonl"); + + let best_effort = CommandContext::new(None, Some(missing.clone()), false).unwrap(); + assert!(best_effort.trace("event", json!({})).is_ok()); + assert!(CommandContext::new(None, Some(missing), false).is_ok()); +} + +#[test] +fn trace_lazy_does_not_build_fields_when_trace_is_disabled() { + let context = CommandContext::default(); + let built = std::cell::Cell::new(false); + + context + .trace_lazy("event", || { + built.set(true); + json!({}) + }) + .unwrap(); + + assert!(!built.get()); +} + +#[cfg(unix)] +#[test] +fn trace_file_is_private_on_create() { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir().join(format!( + "agent-desktop-private-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let context = CommandContext::new(None, Some(path.clone()), true).unwrap(); + context.trace("event", json!({})).unwrap(); + + let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = std::fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn trace_rejects_loose_existing_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir().join(format!( + "agent-desktop-loose-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + std::fs::write(&path, "").unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); + + let context = CommandContext::new(None, Some(path.clone()), false).unwrap(); + let err = context.trace("event", json!({})).unwrap_err(); + + assert_eq!(err.code(), "INVALID_ARGS"); + let _ = std::fs::remove_file(path); +} + +#[test] +fn trace_redacts_sensitive_text_and_value_fields() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-redacted-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let context = CommandContext::new(None, Some(path.clone()), true).unwrap(); + context + .trace( + "event", + json!({ + "text": "secret", + "value": "hidden", + "name": "private label", + "description": "private desc", + "message": "diagnostic error", + "post_state": { "value": "deep secret" }, + "target_label": "button secret", + "nested": { "expected": "token" }, + "title": "private window title", + "url": "https://internal.example/doc", + "help": "private tooltip", + "placeholder": "private placeholder" + }), + ) + .unwrap(); + + let body = std::fs::read_to_string(&path).unwrap(); + let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); + assert_eq!(event["text"]["redacted"], true); + assert_eq!(event["value"]["redacted"], true); + assert_eq!(event["message"], "diagnostic error"); + assert!(!body.contains("secret")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn trace_strict_requires_trace_path_or_trace_session() { + let err = CommandContext::new(None, None, true).unwrap_err(); + assert_eq!(err.code(), "INVALID_ARGS"); +} + +#[test] +fn batch_item_clears_wait_selector() { + let parent = CommandContext::default().with_wait_selector(Some(WaitSelector { + query_raw: "button:OK".into(), + gone: false, + timeout_ms: 5_000, + })); + let child = parent.for_batch_item(None).unwrap(); + assert!(child.wait_selector().is_none()); +} + +#[test] +fn batch_item_inherits_or_overrides_session_without_trace_loss() { + let path = std::env::temp_dir().join("agent-desktop-context-test.jsonl"); + let _ = std::fs::remove_file(&path); + let parent = CommandContext::new(Some("parent".into()), Some(path.clone()), false).unwrap(); + + let inherited = parent.for_batch_item(None).unwrap(); + let overridden = parent.for_batch_item(Some("child".into())).unwrap(); + + assert_eq!(inherited.session_id(), Some("parent")); + assert_eq!(overridden.session_id(), Some("child")); + overridden + .trace("batch.child", json!({ "ok": true })) + .unwrap(); + let body = std::fs::read_to_string(&path).unwrap(); + assert!(body.contains("batch.child")); + let _ = std::fs::remove_file(path); +} + +#[test] +fn bare_session_without_manifest_does_not_trace() { + let _guard = crate::refs_test_support::HomeGuard::new(); + let context = CommandContext::new(Some("legacy-session".into()), None, false).unwrap(); + assert!(!context.trace_enabled()); + context.trace("event", json!({})).unwrap(); + let trace_dir = crate::refs_store::RefStore::for_session(Some("legacy-session")) + .unwrap() + .trace_dir(); + assert!(!trace_dir.exists()); +} + +#[test] +fn trace_on_session_writes_segment_without_explicit_trace_flag() { + let _guard = crate::refs_test_support::HomeGuard::new(); + let manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap(); + assert!(context.trace_enabled()); + context + .trace("session.event", json!({ "ok": true })) + .unwrap(); + let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id)) + .unwrap() + .trace_dir(); + let entries: Vec<_> = std::fs::read_dir(trace_dir).unwrap().flatten().collect(); + assert_eq!(entries.len(), 1); +} + +#[test] +fn no_trace_session_still_namespaces_snapshots() { + let _guard = crate::refs_test_support::HomeGuard::new(); + let manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::Off, + force: false, + }) + .unwrap(); + assert!(!trace_enabled_for_session(&manifest.id).unwrap()); + let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap(); + assert!(!context.trace_enabled()); + assert_eq!(context.session_id(), Some(manifest.id.as_str())); +} + +#[test] +fn explicit_trace_overrides_session_sink() { + let _guard = crate::refs_test_support::HomeGuard::new(); + let manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let path = std::env::temp_dir().join(format!( + "agent-desktop-override-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let context = + CommandContext::new(Some(manifest.id.clone()), Some(path.clone()), false).unwrap(); + context.trace("override.event", json!({})).unwrap(); + assert!(path.exists()); + let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id)) + .unwrap() + .trace_dir(); + let segment_count = std::fs::read_dir(trace_dir) + .map(|entries| entries.flatten().count()) + .unwrap_or(0); + assert_eq!(segment_count, 0); + let _ = std::fs::remove_file(path); +} + +#[test] +fn batch_item_session_override_uses_its_own_segment_dir() { + let _guard = crate::refs_test_support::HomeGuard::new(); + let parent_session = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let child_session = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: true, + }) + .unwrap(); + let parent = CommandContext::new(Some(parent_session.id.clone()), None, false).unwrap(); + let child = parent + .for_batch_item(Some(child_session.id.clone())) + .unwrap(); + child.trace("child.event", json!({})).unwrap(); + let parent_trace = crate::refs_store::RefStore::for_session(Some(&parent_session.id)) + .unwrap() + .trace_dir(); + let parent_segments = std::fs::read_dir(&parent_trace) + .map(|entries| { + entries + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl")) + .count() + }) + .unwrap_or(0); + assert_eq!(parent_segments, 0); + let child_trace = crate::refs_store::RefStore::for_session(Some(&child_session.id)) + .unwrap() + .trace_dir(); + assert!(child_trace.is_dir()); +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 3ef05bd..56ebad8 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -28,6 +28,7 @@ mod refs_test_support; pub(crate) mod resolved_element; pub mod roles; pub(crate) mod search_text; +pub mod session; pub mod snapshot; pub mod snapshot_ref; pub(crate) mod trace; diff --git a/crates/core/src/refs_lock.rs b/crates/core/src/refs_lock.rs index 2e50cef..6be516f 100644 --- a/crates/core/src/refs_lock.rs +++ b/crates/core/src/refs_lock.rs @@ -186,6 +186,13 @@ fn process_is_alive(_pid: u32) -> Option { None } +pub(crate) fn lock_holder_is_live(lock_path: &Path) -> bool { + match LockSnapshot::read(lock_path) { + Ok(Some(snapshot)) => !snapshot.is_stale(), + Ok(None) | Err(()) => false, + } +} + impl Drop for RefStoreLock { fn drop(&mut self) { let should_remove = LockSnapshot::read(&self.path) diff --git a/crates/core/src/refs_store.rs b/crates/core/src/refs_store.rs index f2e8320..68fae5c 100644 --- a/crates/core/src/refs_store.rs +++ b/crates/core/src/refs_store.rs @@ -226,6 +226,14 @@ impl RefStore { .join("refmap.json") } + pub(crate) fn base_dir(&self) -> &Path { + &self.base_dir + } + + pub(crate) fn trace_dir(&self) -> PathBuf { + self.base_dir.join("trace") + } + fn snapshots_dir(&self) -> PathBuf { self.base_dir.join("snapshots") } diff --git a/crates/core/src/refs_store_tests.rs b/crates/core/src/refs_store_tests.rs index 7888920..f108a96 100644 --- a/crates/core/src/refs_store_tests.rs +++ b/crates/core/src/refs_store_tests.rs @@ -367,3 +367,40 @@ fn duplicate_snapshot_id_across_sessions_is_rejected_on_load() { assert_eq!(err.code(), "INVALID_ARGS"); assert!(err.to_string().contains("more than one session")); } + +#[test] +fn trace_dir_points_under_session_base() { + let _guard = HomeGuard::new(); + let store = RefStore::for_session(Some("run-42")).unwrap(); + assert_eq!(store.trace_dir(), store.base_dir().join("trace")); +} + +#[test] +fn trace_dir_accessors_create_no_directories() { + let _guard = HomeGuard::new(); + let store = RefStore::for_session(Some("run-42")).unwrap(); + let _ = store.trace_dir(); + assert!( + !store.trace_dir().exists(), + "trace_dir accessor must not create directories" + ); +} + +#[test] +fn prune_never_removes_trace_segments() { + let _guard = HomeGuard::new(); + let store = RefStore::for_session(Some("trace-retention")).unwrap(); + let trace_dir = store.trace_dir(); + std::fs::create_dir_all(&trace_dir).unwrap(); + let segment = trace_dir.join("1234-5678.jsonl"); + std::fs::write(&segment, b"{}\n").unwrap(); + for index in 0..=MAX_SAVED_SNAPSHOTS { + let snapshot_id = format!("snap-{index:04}"); + store + .save_snapshot(&snapshot_id, &map_with(&snapshot_id)) + .unwrap(); + store.set_latest(&snapshot_id).unwrap(); + } + assert!(segment.is_file()); + assert!(trace_dir.is_dir()); +} diff --git a/crates/core/src/session/manifest.rs b/crates/core/src/session/manifest.rs new file mode 100644 index 0000000..06f61de --- /dev/null +++ b/crates/core/src/session/manifest.rs @@ -0,0 +1,25 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SessionManifest { + pub id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + pub created_at: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub ended_at: Option, + pub trace: SessionTraceMode, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SessionTraceMode { + On, + Off, +} + +impl SessionManifest { + pub fn trace_enabled(&self) -> bool { + matches!(self.trace, SessionTraceMode::On) && self.ended_at.is_none() + } +} diff --git a/crates/core/src/session/mod.rs b/crates/core/src/session/mod.rs new file mode 100644 index 0000000..ed31439 --- /dev/null +++ b/crates/core/src/session/mod.rs @@ -0,0 +1,381 @@ +mod manifest; + +pub use manifest::{SessionManifest, SessionTraceMode}; + +use crate::{ + context::validate_session_id, + error::AppError, + refs::{home_dir, write_private_file}, + refs_lock::lock_holder_is_live, + refs_store::RefStore, +}; +use serde_json; +use std::io::{ErrorKind, Read}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const CURRENT_SESSION_FILE: &str = "current_session"; +const SESSION_MANIFEST_FILE: &str = "session.json"; +const TRACE_LIVENESS_WINDOW: Duration = Duration::from_secs(300); +static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0); + +pub struct StartSessionOptions { + pub name: Option, + pub trace: SessionTraceMode, + pub force: bool, +} + +pub struct GcOptions { + pub ended_only: bool, + pub older_than: Option, +} + +#[derive(Debug)] +pub struct GcReport { + pub removed: Vec, +} + +pub fn agent_desktop_dir() -> Result { + let home = home_dir().ok_or_else(|| AppError::Internal("HOME directory not found".into()))?; + Ok(home.join(".agent-desktop")) +} + +pub fn session_dir(session_id: &str) -> Result { + validate_session_id(session_id)?; + Ok(agent_desktop_dir()?.join("sessions").join(session_id)) +} + +pub fn trace_dir(session_id: &str) -> Result { + Ok(RefStore::for_session(Some(session_id))?.trace_dir()) +} + +pub fn current_session_path() -> Result { + Ok(agent_desktop_dir()?.join(CURRENT_SESSION_FILE)) +} + +pub fn resolve_active_session( + explicit: Option<&str>, + env: Option<&str>, +) -> Result, AppError> { + if let Some(id) = explicit { + validate_session_id(id)?; + return Ok(Some(id.to_string())); + } + if let Some(id) = env { + if id.is_empty() { + return Err(AppError::invalid_input_with_suggestion( + "AGENT_DESKTOP_SESSION must not be empty", + "Unset the variable or set it to a valid session id.", + )); + } + validate_session_id(id)?; + return Ok(Some(id.to_string())); + } + read_current_session_pointer() +} + +pub fn read_current_session_pointer() -> Result, AppError> { + let path = current_session_path()?; + let mut file = match open_session_file(&path) { + Ok(file) => file, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let mut id = String::new(); + file.read_to_string(&mut id)?; + let id = id.trim().to_string(); + if id.is_empty() { + return Ok(None); + } + validate_session_id(&id)?; + Ok(Some(id)) +} + +pub fn write_current_session_pointer(session_id: &str) -> Result<(), AppError> { + validate_session_id(session_id)?; + write_private_file(¤t_session_path()?, session_id.as_bytes()) +} + +pub fn clear_current_session_pointer() -> Result<(), AppError> { + match std::fs::remove_file(current_session_path()?) { + Ok(()) => Ok(()), + Err(err) if err.kind() == ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } +} + +pub fn read_manifest(session_id: &str) -> Result, AppError> { + let path = manifest_path(session_id)?; + let mut file = match open_session_file(&path) { + Ok(file) => file, + Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None), + Err(err) => return Err(err.into()), + }; + let mut json = String::new(); + file.read_to_string(&mut json)?; + Ok(Some(serde_json::from_str(&json)?)) +} + +pub fn write_manifest(manifest: &SessionManifest) -> Result<(), AppError> { + validate_session_id(&manifest.id)?; + let json = serde_json::to_string_pretty(manifest)?; + write_private_file(&manifest_path(&manifest.id)?, json.as_bytes()) +} + +pub fn trace_enabled_for_session(session_id: &str) -> Result { + Ok(read_manifest(session_id)?.is_some_and(|manifest| manifest.trace_enabled())) +} + +pub fn new_session_id() -> String { + let n = SESSION_COUNTER.fetch_add(1, Ordering::Relaxed); + let millis = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("run-{millis}-{n}") +} + +pub fn validate_session_name(name: &str) -> Result { + if name.is_empty() { + return Err(AppError::invalid_input_with_suggestion( + "Session name must not be empty", + "Omit --name or provide a short descriptive label.", + )); + } + if name.len() > 128 { + return Err(AppError::invalid_input_with_suggestion( + "Session name must be at most 128 characters", + "Use a shorter session name.", + )); + } + if name.chars().any(char::is_control) { + return Err(AppError::invalid_input_with_suggestion( + "Session name must not contain control characters", + "Use printable ASCII or Unicode text for --name.", + )); + } + Ok(name.to_string()) +} + +pub fn pointer_references_live_session() -> Result { + let Some(id) = read_current_session_pointer()? else { + return Ok(false); + }; + is_live(&id) +} + +pub fn is_live(session_id: &str) -> Result { + validate_session_id(session_id)?; + let store = RefStore::for_session(Some(session_id))?; + if lock_holder_is_live(&store.base_dir().join("refstore.lock")) { + return Ok(true); + } + if trace_dir_recently_written(&store.trace_dir()) { + return Ok(true); + } + Ok(false) +} + +pub fn list_sessions() -> Result, AppError> { + let sessions_root = agent_desktop_dir()?.join("sessions"); + let Ok(entries) = std::fs::read_dir(sessions_root) else { + return Ok(Vec::new()); + }; + let mut manifests = Vec::new(); + for entry in entries.flatten() { + let Ok(file_type) = entry.file_type() else { + continue; + }; + if !file_type.is_dir() { + continue; + } + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + if validate_session_id(name).is_err() { + continue; + } + if let Some(manifest) = read_manifest(name)? { + manifests.push(manifest); + } + } + manifests.sort_by_key(|manifest| manifest.created_at); + Ok(manifests) +} + +pub fn start_session(options: StartSessionOptions) -> Result { + if !options.force && pointer_references_live_session()? { + return Err(AppError::invalid_input_with_suggestion( + "Refusing to clobber the current session pointer while it references a live session", + "Run `session end` first, set AGENT_DESKTOP_SESSION for concurrent work, or pass --force.", + )); + } + let id = new_session_id(); + validate_session_id(&id)?; + let name = options + .name + .map(|name| validate_session_name(&name)) + .transpose()?; + let dir = session_dir(&id)?; + create_session_tree(&dir)?; + let manifest = SessionManifest { + id: id.clone(), + name, + created_at: now_millis(), + ended_at: None, + trace: options.trace, + }; + write_manifest(&manifest)?; + write_current_session_pointer(&id)?; + Ok(manifest) +} + +pub fn end_session(session_id: Option<&str>) -> Result { + let id = match session_id { + Some(id) => { + validate_session_id(id)?; + id.to_string() + } + None => read_current_session_pointer()?.ok_or_else(|| { + AppError::invalid_input_with_suggestion( + "No active session to end", + "Pass a session id or run `session start` first.", + ) + })?, + }; + let mut manifest = read_manifest(&id)?.ok_or_else(|| { + AppError::invalid_input_with_suggestion( + format!("Session '{id}' has no manifest"), + "Use `session list` to see known sessions.", + ) + })?; + if manifest.ended_at.is_none() { + manifest.ended_at = Some(now_millis()); + write_manifest(&manifest)?; + } + if read_current_session_pointer()?.as_deref() == Some(id.as_str()) { + clear_current_session_pointer()?; + } + Ok(manifest) +} + +pub fn gc(options: GcOptions) -> Result { + let pointer = read_current_session_pointer()?; + let mut removed = Vec::new(); + for manifest in list_sessions()? { + if pointer.as_deref() == Some(manifest.id.as_str()) { + continue; + } + if is_live(&manifest.id)? { + continue; + } + if options.ended_only && manifest.ended_at.is_none() { + continue; + } + if let Some(older_than) = options.older_than { + let age_reference = manifest.ended_at.unwrap_or(manifest.created_at); + let age_ms = now_millis().saturating_sub(age_reference); + if Duration::from_millis(age_ms) < older_than { + continue; + } + } else if manifest.ended_at.is_none() { + continue; + } + let dir = session_dir(&manifest.id)?; + if remove_session_dir(&dir)? { + removed.push(manifest.id); + } + } + Ok(GcReport { removed }) +} + +fn create_session_tree(dir: &Path) -> Result<(), AppError> { + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir.join("trace"))?; + } + #[cfg(not(unix))] + { + std::fs::create_dir_all(dir.join("trace"))?; + } + Ok(()) +} + +fn manifest_path(session_id: &str) -> Result { + Ok(session_dir(session_id)?.join(SESSION_MANIFEST_FILE)) +} + +fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0) +} + +fn trace_dir_recently_written(trace_dir: &Path) -> bool { + let Ok(entries) = std::fs::read_dir(trace_dir) else { + return false; + }; + let cutoff = SystemTime::now() + .checked_sub(TRACE_LIVENESS_WINDOW) + .unwrap_or(UNIX_EPOCH); + entries.flatten().any(|entry| { + entry + .metadata() + .ok() + .and_then(|metadata| metadata.modified().ok()) + .is_some_and(|modified| modified >= cutoff) + }) +} + +pub(crate) fn remove_session_dir(dir: &Path) -> Result { + if !dir.is_dir() { + return Ok(false); + } + if std::fs::symlink_metadata(dir) + .map(|meta| meta.file_type().is_symlink()) + .unwrap_or(true) + { + return Err(AppError::invalid_input_with_suggestion( + "Refusing to remove a symlinked session directory", + "Remove the symlink manually before running session gc.", + )); + } + std::fs::remove_dir_all(dir)?; + Ok(true) +} + +fn open_session_file(path: &Path) -> std::io::Result { + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW) + .open(path) + } + #[cfg(not(unix))] + { + if std::fs::symlink_metadata(path)?.file_type().is_symlink() { + return Err(std::io::Error::new( + ErrorKind::PermissionDenied, + "session path must not be a symlink", + )); + } + std::fs::File::open(path) + } +} + +#[cfg(test)] +#[path = "session_tests.rs"] +mod tests; diff --git a/crates/core/src/session/session_tests.rs b/crates/core/src/session/session_tests.rs new file mode 100644 index 0000000..552ade5 --- /dev/null +++ b/crates/core/src/session/session_tests.rs @@ -0,0 +1,201 @@ +use super::*; +use crate::refs_lock::RefStoreLock; +use crate::refs_test_support::HomeGuard; +use crate::session::SessionTraceMode; +use std::fs; +use std::time::Duration; + +#[test] +fn resolve_prefers_explicit_over_env_and_pointer() { + let _guard = HomeGuard::new(); + write_current_session_pointer("pointer").unwrap(); + unsafe { std::env::set_var("AGENT_DESKTOP_SESSION", "env-session") }; + let resolved = resolve_active_session(Some("explicit"), None).unwrap(); + assert_eq!(resolved.as_deref(), Some("explicit")); + unsafe { std::env::remove_var("AGENT_DESKTOP_SESSION") }; +} + +#[test] +fn resolve_prefers_env_over_pointer() { + let _guard = HomeGuard::new(); + write_current_session_pointer("pointer").unwrap(); + unsafe { std::env::set_var("AGENT_DESKTOP_SESSION", "env-session") }; + let resolved = resolve_active_session(None, Some("env-session")).unwrap(); + assert_eq!(resolved.as_deref(), Some("env-session")); + unsafe { std::env::remove_var("AGENT_DESKTOP_SESSION") }; +} + +#[test] +fn resolve_falls_back_to_pointer() { + let _guard = HomeGuard::new(); + write_current_session_pointer("pointer").unwrap(); + let resolved = resolve_active_session(None, None).unwrap(); + assert_eq!(resolved.as_deref(), Some("pointer")); +} + +#[test] +fn resolve_none_without_pointer() { + let _guard = HomeGuard::new(); + let resolved = resolve_active_session(None, None).unwrap(); + assert!(resolved.is_none()); +} + +#[test] +fn manifest_round_trips_with_optional_fields() { + let _guard = HomeGuard::new(); + let manifest = SessionManifest { + id: "run-1".into(), + name: Some("demo".into()), + created_at: 1, + ended_at: None, + trace: SessionTraceMode::On, + }; + write_manifest(&manifest).unwrap(); + let loaded = read_manifest("run-1").unwrap().expect("manifest"); + assert_eq!(loaded, manifest); +} + +#[test] +fn validate_session_name_rejects_control_chars() { + let err = validate_session_name("bad\u{1}name").unwrap_err(); + assert_eq!(err.code(), "INVALID_ARGS"); +} + +#[test] +fn start_creates_tree_manifest_and_pointer() { + let _guard = HomeGuard::new(); + let manifest = start_session(StartSessionOptions { + name: Some("demo".into()), + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + assert!(session_dir(&manifest.id).unwrap().join("trace").is_dir()); + assert_eq!( + read_current_session_pointer().unwrap().as_deref(), + Some(manifest.id.as_str()) + ); +} + +#[test] +fn start_refuses_live_pointer_without_force() { + let _guard = HomeGuard::new(); + let first = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let _lock = RefStoreLock::acquire( + &crate::refs_store::RefStore::for_session(Some(&first.id)) + .unwrap() + .base_dir() + .join("refstore.lock"), + ) + .unwrap(); + let err = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap_err(); + assert_eq!(err.code(), "INVALID_ARGS"); +} + +#[test] +fn end_seals_manifest_and_clears_pointer() { + let _guard = HomeGuard::new(); + let _manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let ended = end_session(None).unwrap(); + assert!(ended.ended_at.is_some()); + assert!(read_current_session_pointer().unwrap().is_none()); +} + +#[test] +fn gc_removes_ended_sessions_but_not_pointer_or_live() { + let _guard = HomeGuard::new(); + let live = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let ended = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: true, + }) + .unwrap(); + end_session(Some(&ended.id)).unwrap(); + let report = gc(GcOptions { + ended_only: false, + older_than: None, + }) + .unwrap(); + assert!(report.removed.contains(&ended.id)); + assert!(!report.removed.contains(&live.id)); + assert!(session_dir(&live.id).unwrap().is_dir()); + assert!(!session_dir(&ended.id).unwrap().exists()); +} + +#[test] +#[cfg(unix)] +fn remove_session_dir_rejects_symlink() { + let _guard = HomeGuard::new(); + let dir = session_dir("symlink-session").unwrap(); + let target = dir.with_extension("target"); + fs::create_dir_all(&target).unwrap(); + std::os::unix::fs::symlink(&target, &dir).unwrap(); + let err = super::remove_session_dir(&dir).unwrap_err(); + assert_eq!(err.code(), "INVALID_ARGS"); +} + +#[test] +fn list_reports_manifest_fields_only() { + let _guard = HomeGuard::new(); + let manifest = start_session(StartSessionOptions { + name: Some("listed".into()), + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + let listed = list_sessions().unwrap(); + assert!(listed.iter().any(|entry| entry.id == manifest.id)); +} + +#[test] +fn trace_enabled_requires_manifest_on() { + let _guard = HomeGuard::new(); + assert!(!trace_enabled_for_session("missing").unwrap()); + let manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::Off, + force: false, + }) + .unwrap(); + assert!(!trace_enabled_for_session(&manifest.id).unwrap()); +} + +#[test] +fn gc_respects_older_than_threshold() { + let _guard = HomeGuard::new(); + let manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::Off, + force: false, + }) + .unwrap(); + end_session(Some(&manifest.id)).unwrap(); + clear_current_session_pointer().unwrap(); + let report = gc(GcOptions { + ended_only: false, + older_than: Some(Duration::from_secs(3600)), + }) + .unwrap(); + assert!(report.removed.is_empty()); +} diff --git a/crates/core/src/trace.rs b/crates/core/src/trace.rs index 171d9b8..2bda85d 100644 --- a/crates/core/src/trace.rs +++ b/crates/core/src/trace.rs @@ -1,37 +1,68 @@ use crate::error::AppError; use serde_json::{Map, Value, json}; +use std::io::Write; use std::path::{Path, PathBuf}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; const MAX_TRACE_FILE_BYTES: u64 = 64 * 1024 * 1024; +static EVENT_SEQ: AtomicU64 = AtomicU64::new(0); + +#[derive(Debug, Clone)] +enum TracePending { + None, + File(PathBuf), + SegmentDir(PathBuf), +} + +#[derive(Debug, Clone)] +struct TraceState { + pending: TracePending, + opened: Arc>>>>, +} + #[derive(Debug, Clone, Default)] pub struct TraceConfig { strict: bool, - writer: Option>>, + state: Arc, +} + +impl Default for TraceState { + fn default() -> Self { + Self { + pending: TracePending::None, + opened: Arc::new(Mutex::new(None)), + } + } } impl TraceConfig { - pub fn new(path: Option, strict: bool) -> Result { - if strict && path.is_none() { + pub fn build( + explicit_path: Option, + session_segment_dir: Option, + strict: bool, + ) -> Result { + if strict && explicit_path.is_none() && session_segment_dir.is_none() { return Err(AppError::invalid_input_with_suggestion( - "--trace-strict requires --trace", - "Provide --trace or remove --trace-strict.", + "--trace-strict requires --trace or an active trace-enabled session", + "Provide --trace , start a session with tracing, or remove --trace-strict.", )); } - let writer = match path.as_deref() { - Some(path) => match open_trace_file(path) { - Ok(file) => Some(Arc::new(Mutex::new(file))), - Err(err) if err.code() == "INVALID_ARGS" => return Err(err), - Err(err) if strict => return Err(err), - Err(err) => { - tracing::warn!("trace open failed: {err}"); - None - } + let pending = match explicit_path { + Some(path) => TracePending::File(path), + None => match session_segment_dir { + Some(dir) => TracePending::SegmentDir(dir), + None => TracePending::None, }, - None => None, }; - Ok(Self { strict, writer }) + Ok(Self { + strict, + state: Arc::new(TraceState { + pending, + opened: Arc::new(Mutex::new(None)), + }), + }) } pub fn emit( @@ -49,8 +80,9 @@ impl TraceConfig { session_id: Option<&str>, fields: impl FnOnce() -> Value, ) -> Result<(), AppError> { - let Some(writer) = self.writer.as_ref() else { - return Ok(()); + let writer = match self.ensure_writer()? { + Some(writer) => writer, + None => return Ok(()), }; match writer .lock() @@ -65,6 +97,97 @@ impl TraceConfig { } } } + + fn ensure_writer(&self) -> Result>>, AppError> { + let mut opened = self + .state + .opened + .lock() + .map_err(|_| AppError::Internal("trace writer lock poisoned".into()))?; + if opened.is_none() { + let file = match &self.state.pending { + TracePending::None => return Ok(None), + TracePending::File(path) => match open_trace_file(path) { + Ok(file) => file, + Err(err) if self.strict => return Err(err), + Err(err) if err.code() == "INVALID_ARGS" => return Err(err), + Err(err) => { + tracing::warn!("trace open failed: {err}"); + return Ok(None); + } + }, + TracePending::SegmentDir(dir) => match open_segment_trace_file(dir) { + Ok(file) => file, + Err(err) if self.strict => return Err(err), + Err(err) if err.code() == "INVALID_ARGS" => return Err(err), + Err(err) => { + tracing::warn!("trace open failed: {err}"); + return Ok(None); + } + }, + }; + *opened = Some(Arc::new(Mutex::new(file))); + } + Ok(opened.clone()) + } + + pub(crate) fn has_sink(&self) -> bool { + !matches!(self.state.pending, TracePending::None) + } + + pub(crate) fn pending_file_path(&self) -> Option<&Path> { + match &self.state.pending { + TracePending::File(path) => Some(path), + _ => None, + } + } + + pub(crate) fn clone_with_session_segment( + &self, + session_segment_dir: Option, + ) -> Result { + if self.pending_file_path().is_some() { + return Ok(self.clone()); + } + Self::build(None, session_segment_dir, self.strict) + } +} + +fn process_segment_suffix() -> &'static str { + static SUFFIX: OnceLock = OnceLock::new(); + SUFFIX.get_or_init(|| { + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_millis()) + .unwrap_or(0); + format!("{}-{ts}", std::process::id()) + }) +} + +pub(crate) fn segment_path_for_dir(dir: &Path) -> PathBuf { + dir.join(format!("{}.jsonl", process_segment_suffix())) +} + +fn open_segment_trace_file(dir: &Path) -> Result { + ensure_trace_dir(dir)?; + open_trace_file(&segment_path_for_dir(dir)) +} + +fn ensure_trace_dir(dir: &Path) -> Result<(), AppError> { + if dir.is_dir() { + return Ok(()); + } + #[cfg(unix)] + { + use std::os::unix::fs::DirBuilderExt; + std::fs::DirBuilder::new() + .recursive(true) + .mode(0o700) + .create(dir)?; + } + #[cfg(not(unix))] + std::fs::create_dir_all(dir)?; + Ok(()) } fn open_trace_file(path: &Path) -> Result { @@ -99,6 +222,10 @@ fn write_event( .as_millis() ), ); + body.insert( + "seq".to_string(), + json!(EVENT_SEQ.fetch_add(1, Ordering::Relaxed)), + ); if let Value::Object(fields) = sanitize_trace_value(fields) { for (key, value) in fields { body.insert(key, value); @@ -107,9 +234,10 @@ fn write_event( if let Some(sid) = session_id { body.insert("session_id".to_string(), json!(sid)); } - serde_json::to_writer(&mut *file, &Value::Object(body))?; - use std::io::Write; - file.write_all(b"\n").map_err(AppError::from) + let mut line = Vec::new(); + serde_json::to_writer(&mut line, &Value::Object(body))?; + line.push(b'\n'); + file.write_all(&line).map_err(AppError::from) } fn reject_oversized_trace(file: &std::fs::File) -> Result<(), AppError> { @@ -225,98 +353,5 @@ fn redacted_value(value: Value) -> Value { } #[cfg(test)] -mod tests { - use super::*; - - #[cfg(unix)] - #[test] - fn trace_open_rejects_symlink_paths() { - let base = std::env::temp_dir().join(format!( - "agent-desktop-trace-symlink-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let target = base.with_extension("target"); - let link = base.with_extension("link"); - std::fs::write(&target, b"existing").unwrap(); - std::os::unix::fs::symlink(&target, &link).unwrap(); - - let result = open_trace_file(&link); - - assert!(result.is_err()); - let _ = std::fs::remove_file(&link); - let _ = std::fs::remove_file(&target); - } - - #[test] - fn trace_redacts_sensitive_fields_but_preserves_messages() { - let value = sanitize_trace_value(json!({ - "text": "secret", - "message": "Target is not actionable: supported_action failed", - "details": { "name": "Private Button" }, - "title": "Window" - })); - - assert_eq!(value["text"]["redacted"], true); - assert_eq!(value["details"]["name"]["redacted"], true); - assert_eq!(value["title"]["redacted"], true); - assert_eq!( - value["message"], - "Target is not actionable: supported_action failed" - ); - } - - #[test] - fn trace_redaction_covers_nested_shapes_and_substring_keys() { - let value = sanitize_trace_value(json!({ - "action": { - "typed_text": ["secret", "another"], - "api_token": {"kind": "bearer"}, - "typedText": "secret", - "apiToken": "secret", - "targetLabel": "secret", - "userName": "secret", - "filename": "report.txt", - "password": null, - "counter": 3 - } - })); - - assert_eq!(value["action"]["typed_text"]["redacted"], true); - assert_eq!(value["action"]["api_token"]["redacted"], true); - assert_eq!(value["action"]["typedText"]["redacted"], true); - assert_eq!(value["action"]["apiToken"]["redacted"], true); - assert_eq!(value["action"]["targetLabel"]["redacted"], true); - assert_eq!(value["action"]["userName"]["redacted"], true); - assert_eq!(value["action"]["filename"], "report.txt"); - assert!(value["action"]["password"].is_null()); - assert_eq!(value["action"]["counter"], 3); - } - - #[test] - fn trace_write_rejects_files_at_size_cap() { - let path = std::env::temp_dir().join(format!( - "agent-desktop-trace-cap-{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap() - .as_nanos() - )); - let file = std::fs::File::create(&path).unwrap(); - file.set_len(MAX_TRACE_FILE_BYTES).unwrap(); - drop(file); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap(); - } - let mut file = open_trace_file(&path).unwrap(); - - let err = write_event(&mut file, "event", None, json!({})).unwrap_err(); - - assert_eq!(err.code(), "INVALID_ARGS"); - let _ = std::fs::remove_file(path); - } -} +#[path = "trace_tests.rs"] +mod tests; diff --git a/crates/core/src/trace_tests.rs b/crates/core/src/trace_tests.rs new file mode 100644 index 0000000..bea7b26 --- /dev/null +++ b/crates/core/src/trace_tests.rs @@ -0,0 +1,253 @@ +use super::*; +use crate::refs_test_support::HomeGuard; +use serde_json::json; +use std::fs; + +#[cfg(unix)] +#[test] +fn trace_open_rejects_symlink_paths() { + let base = std::env::temp_dir().join(format!( + "agent-desktop-trace-symlink-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let target = base.with_extension("target"); + let link = base.with_extension("link"); + fs::write(&target, b"existing").unwrap(); + std::os::unix::fs::symlink(&target, &link).unwrap(); + + let result = open_trace_file(&link); + + assert!(result.is_err()); + let _ = fs::remove_file(&link); + let _ = fs::remove_file(&target); +} + +#[test] +fn trace_redacts_sensitive_fields_but_preserves_messages() { + let value = sanitize_trace_value(json!({ + "text": "secret", + "message": "Target is not actionable: supported_action failed", + "details": { "name": "Private Button" }, + "title": "Window" + })); + + assert_eq!(value["text"]["redacted"], true); + assert_eq!(value["details"]["name"]["redacted"], true); + assert_eq!(value["title"]["redacted"], true); + assert_eq!( + value["message"], + "Target is not actionable: supported_action failed" + ); +} + +#[test] +fn trace_redaction_covers_nested_shapes_and_substring_keys() { + let value = sanitize_trace_value(json!({ + "action": { + "typed_text": ["secret", "another"], + "api_token": {"kind": "bearer"}, + "typedText": "secret", + "apiToken": "secret", + "targetLabel": "secret", + "userName": "secret", + "filename": "report.txt", + "password": null, + "counter": 3 + } + })); + + assert_eq!(value["action"]["typed_text"]["redacted"], true); + assert_eq!(value["action"]["api_token"]["redacted"], true); + assert_eq!(value["action"]["typedText"]["redacted"], true); + assert_eq!(value["action"]["apiToken"]["redacted"], true); + assert_eq!(value["action"]["targetLabel"]["redacted"], true); + assert_eq!(value["action"]["userName"]["redacted"], true); + assert_eq!(value["action"]["filename"], "report.txt"); + assert!(value["action"]["password"].is_null()); + assert_eq!(value["action"]["counter"], 3); +} + +#[test] +fn trace_write_rejects_files_at_size_cap() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-trace-cap-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let file = fs::File::create(&path).unwrap(); + file.set_len(MAX_TRACE_FILE_BYTES).unwrap(); + drop(file); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + fs::set_permissions(&path, fs::Permissions::from_mode(0o600)).unwrap(); + } + let mut file = open_trace_file(&path).unwrap(); + + let err = write_event(&mut file, "event", None, json!({})).unwrap_err(); + + assert_eq!(err.code(), "INVALID_ARGS"); + let _ = fs::remove_file(path); +} + +#[test] +fn segment_configs_in_same_process_share_filename() { + let dir_a = std::env::temp_dir().join(format!( + "agent-desktop-seg-a-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let dir_b = dir_a.with_extension("b"); + let path_a = segment_path_for_dir(&dir_a); + let path_b = segment_path_for_dir(&dir_b); + assert_eq!(path_a.file_name(), path_b.file_name()); +} + +#[test] +fn lazy_open_writes_no_file_until_first_event() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-lazy-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let _ = fs::remove_file(&path); + let config = TraceConfig::build(Some(path.clone()), None, false).unwrap(); + assert!(!path.exists()); + config.emit("event", None, json!({})).unwrap(); + assert!(path.exists()); + let _ = fs::remove_file(path); +} + +#[test] +fn segment_lazy_open_creates_trace_dir_on_first_event() { + let _guard = HomeGuard::new(); + let trace_dir = crate::refs_store::RefStore::for_session(Some("run-42")) + .unwrap() + .trace_dir(); + assert!(!trace_dir.exists()); + let config = TraceConfig::build(None, Some(trace_dir.clone()), false).unwrap(); + config.emit("event", Some("run-42"), json!({})).unwrap(); + assert!(trace_dir.is_dir()); + let segment = segment_path_for_dir(&trace_dir); + assert!(segment.is_file()); + assert!(segment.metadata().unwrap().len() > 0); +} + +#[test] +fn write_event_emits_single_atomic_jsonl_line_with_seq() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-atomic-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let config = TraceConfig::build(Some(path.clone()), None, false).unwrap(); + config.emit("first", None, json!({})).unwrap(); + config.emit("second", None, json!({})).unwrap(); + let body = fs::read_to_string(&path).unwrap(); + let lines: Vec<&str> = body.lines().collect(); + assert_eq!(lines.len(), 2); + let first: serde_json::Value = serde_json::from_str(lines[0]).unwrap(); + let second: serde_json::Value = serde_json::from_str(lines[1]).unwrap(); + assert_eq!(first["event"], "first"); + assert_eq!(second["event"], "second"); + assert!(first["seq"].as_u64().is_some()); + assert!(second["seq"].as_u64().is_some()); + assert!(second["seq"].as_u64().unwrap() > first["seq"].as_u64().unwrap()); + let _ = fs::remove_file(path); +} + +#[test] +fn truncated_final_line_leaves_prior_lines_parseable() { + let path = std::env::temp_dir().join(format!( + "agent-desktop-trunc-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let config = TraceConfig::build(Some(path.clone()), None, false).unwrap(); + config.emit("ok", None, json!({})).unwrap(); + let mut bytes = fs::read(&path).unwrap(); + bytes.extend_from_slice(b"{\"event\":\"partial"); + fs::write(&path, bytes).unwrap(); + let body = fs::read_to_string(&path).unwrap(); + let mut parsed = 0usize; + for line in body.lines() { + if serde_json::from_str::(line).is_ok() { + parsed += 1; + } + } + assert_eq!(parsed, 1); + let _ = fs::remove_file(path); +} + +#[test] +fn strict_missing_trace_path_fails_on_first_emit() { + let missing = std::env::temp_dir() + .join("agent-desktop-missing-dir") + .join("trace.jsonl"); + let config = TraceConfig::build(Some(missing), None, true).unwrap(); + assert!(config.emit("event", None, json!({})).is_err()); +} + +#[test] +fn best_effort_missing_trace_path_succeeds_silently() { + let missing = std::env::temp_dir() + .join("agent-desktop-missing-dir-best-effort") + .join("trace.jsonl"); + let config = TraceConfig::build(Some(missing), None, false).unwrap(); + assert!(config.emit("event", None, json!({})).is_ok()); +} + +#[cfg(unix)] +#[test] +fn trace_file_is_private_on_create() { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir().join(format!( + "agent-desktop-private-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + let config = TraceConfig::build(Some(path.clone()), None, true).unwrap(); + config.emit("event", None, json!({})).unwrap(); + + let mode = fs::metadata(&path).unwrap().permissions().mode() & 0o777; + assert_eq!(mode, 0o600); + let _ = fs::remove_file(path); +} + +#[cfg(unix)] +#[test] +fn trace_rejects_loose_existing_file_permissions() { + use std::os::unix::fs::PermissionsExt; + + let path = std::env::temp_dir().join(format!( + "agent-desktop-loose-trace-{}.jsonl", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::write(&path, "").unwrap(); + fs::set_permissions(&path, fs::Permissions::from_mode(0o644)).unwrap(); + + let config = TraceConfig::build(Some(path.clone()), None, false).unwrap(); + let err = config.emit("event", None, json!({})).unwrap_err(); + + assert_eq!(err.code(), "INVALID_ARGS"); + let _ = fs::remove_file(path); +} diff --git a/crates/ffi/tests/c_abi_session_trace.rs b/crates/ffi/tests/c_abi_session_trace.rs new file mode 100644 index 0000000..4eacc2f --- /dev/null +++ b/crates/ffi/tests/c_abi_session_trace.rs @@ -0,0 +1,99 @@ +mod common; + +use agent_desktop_core::session::{ + SessionTraceMode, StartSessionOptions, start_session, trace_dir, +}; +use common::{ad_adapter_create_with_session, ad_adapter_destroy, ad_check_permissions}; +use std::ffi::CString; +use std::fs; +use std::sync::Mutex; + +static HOME_LOCK: Mutex<()> = Mutex::new(()); + +struct TestHome { + _lock: std::sync::MutexGuard<'static, ()>, + dir: std::path::PathBuf, + prev: Option, +} + +impl TestHome { + fn new() -> Self { + let lock = HOME_LOCK.lock().unwrap(); + let dir = std::env::temp_dir().join(format!( + "agent-desktop-ffi-session-trace-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )); + fs::create_dir_all(&dir).unwrap(); + let prev = std::env::var_os("HOME"); + unsafe { std::env::set_var("HOME", &dir) }; + Self { + _lock: lock, + dir, + prev, + } + } +} + +impl Drop for TestHome { + fn drop(&mut self) { + match self.prev.as_ref() { + Some(prev) => unsafe { std::env::set_var("HOME", prev) }, + None => unsafe { std::env::remove_var("HOME") }, + } + let _ = fs::remove_dir_all(&self.dir); + } +} + +fn trace_dir_for(session_id: &str) -> std::path::PathBuf { + trace_dir(session_id).unwrap() +} + +#[test] +fn ffi_trace_on_session_writes_segment() { + let _home = TestHome::new(); + let manifest = start_session(StartSessionOptions { + name: None, + trace: SessionTraceMode::On, + force: false, + }) + .unwrap(); + unsafe { + let session = CString::new(manifest.id.as_str()).unwrap(); + let ptr = ad_adapter_create_with_session(session.as_ptr()); + assert!(!ptr.is_null()); + let ctx = (*ptr) + .command_context() + .expect("command_context must succeed"); + ctx.trace("ffi.event", serde_json::json!({ "ok": true })) + .unwrap(); + ad_adapter_destroy(ptr); + } + let trace_dir = trace_dir_for(&manifest.id); + let body: String = fs::read_dir(trace_dir) + .unwrap() + .flatten() + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl")) + .map(|entry| fs::read_to_string(entry.path()).unwrap()) + .collect(); + assert!(body.contains("ffi.event")); +} + +#[test] +fn ffi_plain_session_writes_no_trace_files() { + let _home = TestHome::new(); + unsafe { + let session = CString::new("plain-session").unwrap(); + let ptr = ad_adapter_create_with_session(session.as_ptr()); + assert!(!ptr.is_null()); + let ctx = (*ptr) + .command_context() + .expect("command_context must succeed"); + ctx.trace("ffi.event", serde_json::json!({})).unwrap(); + ad_check_permissions(ptr); + ad_adapter_destroy(ptr); + } + assert!(!trace_dir_for("plain-session").exists()); +} diff --git a/src/batch/mod.rs b/src/batch/mod.rs index 681eea6..b46aafc 100644 --- a/src/batch/mod.rs +++ b/src/batch/mod.rs @@ -13,6 +13,7 @@ use serde_json::{Map, Value, json}; use crate::{ cli::Commands, cli_args::{ + session::{SessionAction, SessionArgs, SessionEndArgs, SessionGcArgs, SessionStartArgs}, skills::{SkillsAction, SkillsArgs, SkillsGetArgs}, system::BatchArgs, }, @@ -102,6 +103,7 @@ pub(crate) fn parse_command(item: BatchCommand) -> Result { "permissions" => decode(command, item.args).map(Commands::Permissions), "version" => no_args(command, item.args).map(|()| Commands::Version), "skills" => parse_skills(item.args).map(Commands::Skills), + "session" => parse_session(item.args).map(Commands::Session), "batch" => Err(AppError::invalid_input_with_suggestion( "Batch commands cannot be nested", "Flatten nested batches into one top-level batch array", @@ -185,5 +187,43 @@ fn parse_skills(args: Value) -> Result { Ok(SkillsArgs { action }) } +#[derive(Deserialize)] +struct BatchSessionArgs { + #[serde(default)] + action: Option, + name: Option, + #[serde(default)] + no_trace: bool, + #[serde(default)] + force: bool, + id: Option, + older_than: Option, + #[serde(default)] + ended: bool, +} + +fn parse_session(args: Value) -> Result { + let args: BatchSessionArgs = decode("session", args)?; + let action = match args.action.as_deref() { + None | Some("list") => SessionAction::List, + Some("start") => SessionAction::Start(SessionStartArgs { + name: args.name, + no_trace: args.no_trace, + force: args.force, + }), + Some("end") => SessionAction::End(SessionEndArgs { id: args.id }), + Some("gc") => SessionAction::Gc(SessionGcArgs { + older_than: args.older_than, + ended: args.ended, + }), + Some(other) => { + return Err(AppError::invalid_input(format!( + "Unknown session action '{other}'" + ))); + } + }; + Ok(SessionArgs { action }) +} + #[cfg(test)] mod tests; diff --git a/src/cli/contract_tests.rs b/src/cli/contract_tests.rs index a706f7e..ba57ee1 100644 --- a/src/cli/contract_tests.rs +++ b/src/cli/contract_tests.rs @@ -28,6 +28,7 @@ const COMMAND_SPECIFIC_TESTS: &[&str] = &[ "list-apps", "right-click", "skills", + "session", "snapshot", "status", "wait", diff --git a/src/cli/help_after.txt b/src/cli/help_after.txt index ee3c282..ed3ea1f 100644 --- a/src/cli/help_after.txt +++ b/src/cli/help_after.txt @@ -73,10 +73,14 @@ WAIT wait --notification Block until a new notification arrives (--text filters it) SYSTEM - status Adapter health, platform, and permission state + status Adapter health, platform, permission, session, and tracing state permissions Check nested permission states: {state,...} version Show version, target architecture, and OS skills Bundled skill docs for AI agents (list, get, path) + session start Create a trace-enabled session and set the active pointer + session end Seal the session manifest and clear the active pointer + session list List session manifests + session gc Remove ended or provably-stale sessions BATCH batch Run commands from a JSON array (--stop-on-error) @@ -88,13 +92,16 @@ REF IDs --snapshot to pin a command to a known snapshot; explicit snapshot IDs do not require --session. When --snapshot is omitted, ref commands use the active session's latest saved snapshot. Run snapshot again - after UI changes. Use --session as a shared latest-snapshot namespace - for concurrent agents. + after UI changes. Use `session start` once per run for automatic JSONL tracing + under ~/.agent-desktop/sessions//trace/, or pass --session for + snapshot namespace only (no trace without a session manifest). Set + AGENT_DESKTOP_SESSION for concurrent independent sessions. Explicit --session + overrides the env var and current-session pointer. Ref actions use strict resolution: stale targets return STALE_REF; duplicate plausible targets return AMBIGUOUS_TARGET instead of choosing arbitrarily. Ref actions run actionability checks before dispatch. Use --trace to - write JSONL diagnostics outside stdout; --trace-strict fails on trace setup - and pre-action writes. Post-action success traces are best-effort. + override the session trace sink; --trace-strict fails on trace setup and + pre-action writes. Post-action success traces are best-effort. KEY COMBOS Single keys: return, escape, tab, space, delete, up, down, left, right diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 37af621..72770e9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -11,6 +11,7 @@ use crate::cli_args::{ DismissAllNotificationsCliArgs, DismissNotificationCliArgs, ListNotificationsCliArgs, NotificationActionCliArgs, }, + session::SessionArgs, skills::SkillsArgs, system::{ AppRefArgs, BatchArgs, ClipboardSetArgs, CloseAppArgs, FocusWindowArgs, LaunchArgs, @@ -210,6 +211,8 @@ pub(crate) enum Commands { Batch(BatchArgs), #[command(about = "Bundled skill docs for AI agents (list, get, path)")] Skills(SkillsArgs), + #[command(about = "Manage trace-enabled agent sessions (start, end, list, gc)")] + Session(SessionArgs), } impl Commands { @@ -269,6 +272,7 @@ impl Commands { Self::Version => "version", Self::Batch(_) => "batch", Self::Skills(_) => "skills", + Self::Session(_) => "session", } } } diff --git a/src/cli_args/mod.rs b/src/cli_args/mod.rs index 5b8b971..9b1e26c 100644 --- a/src/cli_args/mod.rs +++ b/src/cli_args/mod.rs @@ -3,6 +3,7 @@ use serde::Deserialize; pub(crate) mod actions; pub(crate) mod notifications; +pub(crate) mod session; pub(crate) mod skills; pub(crate) mod system; diff --git a/src/cli_args/session.rs b/src/cli_args/session.rs new file mode 100644 index 0000000..6c19a9a --- /dev/null +++ b/src/cli_args/session.rs @@ -0,0 +1,49 @@ +use clap::{Args, Subcommand}; + +#[derive(Args, Debug)] +pub(crate) struct SessionArgs { + #[command(subcommand)] + pub action: SessionAction, +} + +#[derive(Subcommand, Debug)] +pub(crate) enum SessionAction { + #[command(about = "Create a session directory, manifest, and current-session pointer")] + Start(SessionStartArgs), + #[command(about = "Seal the session manifest and clear the current-session pointer")] + End(SessionEndArgs), + #[command(about = "List session manifests")] + List, + #[command(about = "Remove ended or provably-stale sessions")] + Gc(SessionGcArgs), +} + +#[derive(Args, Debug)] +pub(crate) struct SessionStartArgs { + #[arg(long, help = "Optional human-readable session label")] + pub name: Option, + #[arg(long, help = "Create the session without automatic tracing")] + pub no_trace: bool, + #[arg( + long, + help = "Override the current-session pointer even when it references a live session" + )] + pub force: bool, +} + +#[derive(Args, Debug)] +pub(crate) struct SessionEndArgs { + #[arg(help = "Session id to end; defaults to the current-session pointer")] + pub id: Option, +} + +#[derive(Args, Debug)] +pub(crate) struct SessionGcArgs { + #[arg( + long, + help = "Only remove sessions whose ended_at/created_at age exceeds this many seconds" + )] + pub older_than: Option, + #[arg(long, help = "Only consider sessions that already have ended_at set")] + pub ended: bool, +} diff --git a/src/command_policy/mod.rs b/src/command_policy/mod.rs index 30fce01..70a7671 100644 --- a/src/command_policy/mod.rs +++ b/src/command_policy/mod.rs @@ -17,7 +17,7 @@ pub(crate) enum PermissionNeed { pub(crate) fn policy_for(cmd: &Commands) -> PermissionNeed { use PermissionNeed::{Accessibility, AccessibilityAndScreenRecording, None, ScreenRecording}; match cmd { - Commands::Version | Commands::Skills(_) => None, + Commands::Version | Commands::Skills(_) | Commands::Session(_) => None, Commands::Status | Commands::Permissions(_) => None, Commands::ListWindows(_) | Commands::ListApps(_) => None, Commands::ClipboardGet | Commands::ClipboardSet(_) | Commands::ClipboardClear => None, @@ -192,7 +192,8 @@ fn validate_args(cmd: &Commands) -> Result<(), AppError> { | Commands::Permissions(_) | Commands::Version | Commands::Batch(_) - | Commands::Skills(_) => {} + | Commands::Skills(_) + | Commands::Session(_) => {} } Ok(()) } diff --git a/src/command_policy/tests.rs b/src/command_policy/tests.rs index e9fb4d0..69118fa 100644 --- a/src/command_policy/tests.rs +++ b/src/command_policy/tests.rs @@ -74,6 +74,7 @@ fn command_name_is_covered(name: &str) -> bool { | "version" | "batch" | "skills" + | "session" ) } diff --git a/src/dispatch/mod.rs b/src/dispatch/mod.rs index db94cba..01f8d52 100644 --- a/src/dispatch/mod.rs +++ b/src/dispatch/mod.rs @@ -9,8 +9,9 @@ use agent_desktop_core::{ double_click, drag, expand, find, focus, focus_window, get, helpers, hover, is_check, key_down, key_up, launch, list_apps, list_surfaces, list_windows, maximize, minimize, mouse_click, mouse_down, mouse_move, mouse_up, move_window, permissions, press, - resize_window, restore, right_click, screenshot, scroll, scroll_to, select, set_value, - skills, snapshot, status, toggle, triple_click, type_text, uncheck, version, wait, + resize_window, restore, right_click, screenshot, scroll, scroll_to, select, session, + set_value, skills, snapshot, status, toggle, triple_click, type_text, uncheck, version, + wait, }, context::CommandContext, error::AppError, @@ -18,6 +19,7 @@ use agent_desktop_core::{ use serde_json::Value; use crate::cli::Commands; +use crate::cli_args::session::SessionAction; use crate::cli_args::skills::SkillsAction; use parse::{ parse_direction, parse_get_property, parse_is_property, parse_mouse_button, parse_xy, @@ -364,6 +366,20 @@ pub(crate) fn dispatch( }), }, + Commands::Session(a) => match a.action { + SessionAction::Start(s) => session::execute(session::SessionAction::Start { + name: s.name, + no_trace: s.no_trace, + force: s.force, + }), + SessionAction::End(e) => session::execute(session::SessionAction::End { id: e.id }), + SessionAction::List => session::execute(session::SessionAction::List), + SessionAction::Gc(g) => session::execute(session::SessionAction::Gc { + older_than_secs: g.older_than, + ended_only: g.ended, + }), + }, + Commands::Batch(a) => crate::batch::execute(a, adapter, permission_report, context), } } diff --git a/src/main.rs b/src/main.rs index 21d9030..a126036 100644 --- a/src/main.rs +++ b/src/main.rs @@ -9,6 +9,7 @@ use agent_desktop_core::{ context::{CommandContext, WaitSelector}, error::AppError, output::{ENVELOPE_VERSION, ErrorPayload, Response}, + session::resolve_active_session, }; use clap::{CommandFactory, Parser}; use cli::{Cli, Commands}; @@ -57,7 +58,17 @@ fn main() { init_tracing(cli.verbose); let wait_selector = build_wait_selector(&cli); - let context = match CommandContext::new(cli.session, cli.trace, cli.trace_strict) { + let session_id = match resolve_active_session( + cli.session.as_deref(), + std::env::var("AGENT_DESKTOP_SESSION").ok().as_deref(), + ) { + Ok(session_id) => session_id, + Err(err) => { + finish("unknown", Err(err)); + return; + } + }; + let context = match CommandContext::new(session_id, cli.trace, cli.trace_strict) { Ok(context) => context .with_headed(cli.headed) .with_wait_selector(wait_selector.clone()),