feat: add trace viewer and replay artifacts

Adds the trace read/replay layer on the session-first foundation: `trace show` merges per-process segments into one deterministic timeline (bounded JSON for agents), and `trace export` renders a single self-contained, XSS-safe HTML viewer for humans. Opt-in `session start --screenshots` captures pre/post-action screenshots and refmap copies; command.start/end boundary events and a versioned trace.meta header make a step-by-step replay reconstructable. Redaction is hardened so raw caller arguments never leak into trace-reachable error messages. Available across CLI, batch, and FFI.
This commit is contained in:
Lahfir 2026-07-02 11:48:06 -07:00 committed by GitHub
parent e16b218653
commit e3e1872ff3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 5961 additions and 398 deletions

3
.gitignore vendored
View file

@ -82,3 +82,6 @@ todos/
.cursor/
.context/
rust_out
# trace export artifacts must never land in the repo tree
trace-*.html

View file

@ -160,7 +160,7 @@ Batch is not a second dispatcher. `src/batch/mod.rs` deserializes JSON entries i
### Additive Phase Model
- **Phase 1:** Foundation + macOS MVP (55 commands, core engine, macOS adapter)
- **Phase 1:** Foundation + macOS MVP (56 commands, core engine, macOS adapter)
- **Phase 2:** Windows + Linux adapters, 10+ new commands — core untouched
- **Phase 3:** MCP server mode via `--mcp` flag — wraps existing commands
- **Phase 4:** Daemon, sessions, enterprise quality gates
@ -369,10 +369,10 @@ for the actionability preflight (`get_live_*`), and `is_protected_process`
## Commands
55 commands spanning App/Window, Observation, Interaction, Scroll, Keyboard,
56 commands spanning App/Window, Observation, Interaction, Scroll, Keyboard,
Mouse, Notifications (macOS), Clipboard, Wait, System (including `session`), and
Batch. The full surface and per-command reference live in `skills/agent-desktop/`.
All 55 are implemented on macOS (Phase 1); Windows/Linux (Phase 2/3) target the
All 56 are implemented on macOS (Phase 1); Windows/Linux (Phase 2/3) target the
same surface. Adding a command: see the Extensibility Pattern above.
## Non-Goals

View file

@ -57,7 +57,16 @@ The `session.json` file describing one session: id, optional name, created/ended
Structured file tracing activates only when the manifest has `trace: on`. FFI adapters and bare `--session` ids without this manifest do not write trace segments.
### Trace Segment
One append-only JSONL file per OS process under `<session>/trace/<pid>-<procStartTs>.jsonl`, written lazily with atomic lines (`ts_ms`, monotonic `seq`, redacted fields). Explicit `--trace <path>` overrides to a single file.
One append-only JSONL file per OS process under `<session>/trace/<pid>-<procStartTs>.jsonl`, written lazily with atomic lines. Each new segment opens with a `trace.meta` header (`schema`, binary version, `os`, `pid`, `proc_start_ms`, `session_id`). Older traces without meta read as schema 0. Explicit `--trace <path>` overrides to a single file.
### Trace Timeline
The merged, deterministic ordering of all events from every segment in a session, produced by `trace show` and `trace export`. Merge key is `(ts_ms, writer pid, in-file position)`; the reader tolerates truncated tails, corrupt lines, and foreign files with counted warnings rather than hard errors.
### Trace Schema
Additive-only evolution contract: new event types and optional fields may appear; existing meanings never change. Readers ignore unknown content. Segments declare their schema in the leading `trace.meta` line; unknown future schemas warn and parse best-effort.
### Replay Artifacts
Opt-in capture mode (`session start --screenshots`, manifest `artifacts: full`) that stores pre/post-action PNGs under `<session>/trace/screens/` and refmap copies under `<session>/trace/refmaps/`. Event-mode traces (`artifacts: events`, the default) record JSONL only. Artifacts are unredacted and may appear in exported HTML — treat them like screenshots.
### Protected Process
A session-critical operating-system process that agent-desktop refuses to close on every surface, because terminating it would break the user's desktop session.

View file

@ -41,7 +41,7 @@
- **Native Rust CLI**: Fast, single binary, no runtime dependencies
- **C-ABI cdylib** (`libagent_desktop_ffi`): Load once from Python / Swift / Go / Ruby / Node / C instead of forking the CLI per call
- **55 commands**: Observation, interaction, keyboard, mouse, notifications, clipboard, window management, session lifecycle, plus a bundled `skills` doc loader
- **56 commands**: Observation, interaction, keyboard, mouse, notifications, clipboard, window management, session lifecycle, trace read/export, plus a bundled `skills` doc loader
- **Progressive skeleton traversal**: 7896% token reduction on dense apps via shallow overview + targeted drill-down
- **Snapshot & refs**: AI-optimized workflow using compact snapshot IDs and deterministic element references (`@e1`, `@e2`)
- **Headless-by-default interactions**: Ref actions use accessibility APIs and block silent focus, cursor, keyboard, or pasteboard side effects
@ -139,6 +139,18 @@ agent-desktop snapshot -i # re-observe after UI changes
Agent loop: snapshot → decide → act → snapshot → decide → act → ...
```
### Trace viewer (read back a session)
```bash
agent-desktop session start --screenshots # opt-in replay artifacts (PNG + refmap copies)
agent-desktop snapshot --app Finder -i # work normally under the active session
agent-desktop click @e5
agent-desktop trace show --limit 500 # bounded JSON timeline for agents
agent-desktop trace export --out run.html # single-file HTML viewer (works from file://)
```
`trace show` merges all segment files deterministically and requires no permissions. `trace export` embeds the timeline plus screenshots as base64 in one static HTML file. Without `--out`, the HTML is written to the session directory (`~/.agent-desktop/sessions/<id>/trace-<id>.html`), not the current directory; `--out` overrides the path. Treat exported HTML like a screenshot when `artifacts: full` was enabled.
### Shared sessions for multi-agent workflows
Run `session start` once per agent run to create a trace-enabled session (manifest `trace: on` by default) and set the active pointer. Subsequent commands in that run get automatic JSONL segments under `~/.agent-desktop/sessions/<id>/trace/` and share the session's latest-snapshot namespace — no `--trace` on every call.
@ -429,7 +441,7 @@ No. The core workflow reads native accessibility trees and assigns refs to inter
|-----------|----------|
| **Native Rust CLI** | Fast, single binary, no runtime dependencies |
| **C-ABI cdylib** | Load once from Python, Swift, Go, Ruby, Node, or C instead of forking |
| **55 Commands** | Observation, interaction, keyboard, mouse, notifications, clipboard, window management, session lifecycle, and bundled `skills` docs |
| **56 Commands** | Observation, interaction, keyboard, mouse, notifications, clipboard, window management, session lifecycle, trace read/export, and bundled `skills` docs |
| **Snapshot & Refs** | Compact snapshot IDs and deterministic element refs like `@e1`, `@e2` |
| **Structured JSON** | Machine-readable responses with error codes and recovery hints |

View file

@ -92,7 +92,7 @@ fn is_live_read_unsupported(err: &AdapterError) -> bool {
pub enum ScreenshotTarget {
Screen(usize),
/// Capture the frontmost window owned by this process ID.
/// Capture the largest visible window owned by this process ID.
Window(i32),
FullScreen,
}

View file

@ -53,6 +53,7 @@ pub mod skills;
pub mod snapshot;
pub mod status;
pub mod toggle;
pub mod trace;
pub mod triple_click;
pub mod type_text;
pub mod uncheck;

View file

@ -1,6 +1,7 @@
use crate::error::AppError;
use crate::session::{
GcOptions, SessionTraceMode, StartSessionOptions, end_session, gc, list_sessions, start_session,
ArtifactsMode, GcOptions, SessionTraceMode, StartSessionOptions, end_session, gc,
list_sessions, start_session,
};
use serde_json::{Value, json};
use std::time::Duration;
@ -10,6 +11,7 @@ pub enum SessionAction {
Start {
name: Option<String>,
no_trace: bool,
screenshots: bool,
force: bool,
},
End {
@ -27,6 +29,7 @@ pub fn execute(action: SessionAction) -> Result<Value, AppError> {
SessionAction::Start {
name,
no_trace,
screenshots,
force,
} => {
let manifest = start_session(StartSessionOptions {
@ -36,12 +39,18 @@ pub fn execute(action: SessionAction) -> Result<Value, AppError> {
} else {
SessionTraceMode::On
},
artifacts: if screenshots {
ArtifactsMode::Full
} else {
ArtifactsMode::Events
},
force,
})?;
Ok(json!({
"session_id": manifest.id,
"name": manifest.name,
"trace": manifest.trace,
"artifacts": manifest.artifacts,
"created_at": manifest.created_at,
}))
}
@ -62,6 +71,7 @@ pub fn execute(action: SessionAction) -> Result<Value, AppError> {
"created_at": manifest.created_at,
"ended_at": manifest.ended_at,
"trace": manifest.trace,
"artifacts": manifest.artifacts,
})
})
.collect();

View file

@ -98,7 +98,7 @@ const SKILLS: &[Skill] = &[
Skill {
canonical: "agent-desktop",
aliases: &["desktop", "agent-desktop"],
summary: "Primary guide. Snapshot/ref loop, JSON envelope, 55 commands including session lifecycle, observation, interaction, keyboard/mouse, app lifecycle, notifications, clipboard, wait.",
summary: "Primary guide. Snapshot/ref loop, JSON envelope, 56 commands including session lifecycle, observation, interaction, keyboard/mouse, app lifecycle, notifications, clipboard, wait.",
main: SKILL_DESKTOP_MAIN,
refs: skill_desktop_refs,
},

View file

@ -28,8 +28,12 @@ pub fn execute_with_report_with_context(
.map(str::to_string)
.or_else(|| read_current_session_pointer().ok().flatten());
let tracing = context.trace_enabled();
let artifacts = session_id
.as_deref()
.and_then(|id| crate::session::read_manifest(id).ok().flatten())
.map(|manifest| manifest.artifacts);
Ok(json!({
let mut body = json!({
"platform": std::env::consts::OS,
"version": env!("CARGO_PKG_VERSION"),
"permissions": permissions,
@ -37,7 +41,11 @@ pub fn execute_with_report_with_context(
"ref_count": ref_count,
"session_id": session_id,
"tracing": tracing,
}))
});
if let Some(artifacts) = artifacts {
body["artifacts"] = json!(artifacts);
}
Ok(body)
}
#[cfg(test)]

View file

@ -42,6 +42,7 @@ fn status_reports_tracing_false_when_writer_failed() {
name: None,
trace: crate::session::SessionTraceMode::On,
force: true,
..Default::default()
})
.unwrap();
let unopenable = std::env::temp_dir()
@ -61,3 +62,23 @@ fn status_reports_tracing_false_when_writer_failed() {
"a failed trace writer must not report tracing:true"
);
}
#[test]
fn status_surfaces_artifacts_mode_for_active_session() {
let _guard = crate::refs_test_support::HomeGuard::new();
let session = crate::session::start_session(crate::session::StartSessionOptions {
artifacts: crate::session::ArtifactsMode::Full,
..Default::default()
})
.unwrap();
let context = CommandContext::new(Some(session.id.clone()), None, false).unwrap();
let report = PermissionReport {
accessibility: PermissionState::Granted,
screen_recording: PermissionState::Granted,
automation: PermissionState::NotRequired,
};
let value = execute_with_report_with_context(&DeniedAdapter, &report, &context).unwrap();
assert_eq!(value["artifacts"], "full");
}

View file

@ -0,0 +1,111 @@
use crate::{
context::CommandContext,
error::AppError,
refs_store::RefStore,
session::resolve_active_session,
trace_read::{ExportOptions, ReadOptions, export_html, read_merged},
};
use serde_json::{Value, json};
use std::path::PathBuf;
const DEFAULT_SHOW_LIMIT: usize = 500;
pub const TRACE_SHOW_DEFAULT_LIMIT: usize = DEFAULT_SHOW_LIMIT;
#[derive(Debug, Clone)]
pub enum TraceAction {
Show { limit: usize, event: Option<String> },
Export { limit: usize, out: Option<PathBuf> },
}
pub fn execute(action: TraceAction, context: &CommandContext) -> Result<Value, AppError> {
match action {
TraceAction::Show { limit, event } => show(context, limit, event),
TraceAction::Export { limit, out } => export(context, limit, out),
}
}
fn resolve_trace_session(context: &CommandContext) -> Result<(String, RefStore), AppError> {
let session_id = resolve_active_session(context.session_id(), None)?.ok_or_else(|| {
AppError::invalid_input_with_suggestion(
"No active session for trace command",
"Run `session start` or pass `--session <id>`.",
)
})?;
let store = RefStore::for_session(Some(&session_id))?;
let trace_dir = store.trace_dir();
if !trace_dir.is_dir() {
return Err(AppError::invalid_input_with_suggestion(
format!("Session '{session_id}' has no trace directory"),
"Run `session start` with tracing enabled before recording commands.",
));
}
Ok((session_id, store))
}
fn empty_trace_dir_error(session_id: &str) -> AppError {
AppError::invalid_input_with_suggestion(
format!("Session '{session_id}' has an empty trace directory"),
"Run `session start` with tracing enabled before recording commands.",
)
}
fn show(context: &CommandContext, limit: usize, event: Option<String>) -> Result<Value, AppError> {
let (session_id, store) = resolve_trace_session(context)?;
let trace_dir = store.trace_dir();
let merged = read_merged(
&trace_dir,
&ReadOptions {
limit,
event_prefix: event,
},
)?;
if merged.segments.is_empty() {
return Err(empty_trace_dir_error(&session_id));
}
let mut body = json!({
"session_id": session_id,
"segments": merged.segments,
"total_events": merged.total_events,
"returned_events": merged.returned_events,
"truncated": merged.truncated,
"events": merged.events,
});
if let Some(matched_events) = merged.matched_events {
body["matched_events"] = json!(matched_events);
}
if !merged.warnings.is_empty() {
body["warnings"] = json!(merged.warnings);
}
Ok(body)
}
fn export(context: &CommandContext, limit: usize, out: Option<PathBuf>) -> Result<Value, AppError> {
let (session_id, store) = resolve_trace_session(context)?;
let (_html, stats) = export_html(
&store.trace_dir(),
&session_id,
&ExportOptions { limit, out },
)?;
let mut body = json!({
"path": stats.path,
"event_count": stats.event_count,
"screenshots_embedded": stats.screenshots_embedded,
"screenshots_skipped": stats.screenshots_skipped,
"bytes": stats.bytes,
"total_events": stats.total_events,
"returned_events": stats.returned_events,
"truncated": stats.truncated,
});
if !stats.warnings.is_empty() {
body["warnings"] = json!(stats.warnings);
}
Ok(body)
}
#[cfg(test)]
#[path = "trace_tests.rs"]
mod tests;

View file

@ -0,0 +1,350 @@
use super::*;
use crate::refs_test_support::HomeGuard;
use crate::session::{SessionTraceMode, StartSessionOptions, start_session};
use crate::trace_read::{ReadOptions, read_merged};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
fn fixture_trace_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("../../tests/fixtures/trace_show/trace")
}
fn copy_fixture_trace(dest: &Path) {
fs::create_dir_all(dest).unwrap();
for name in ["100-1000.jsonl", "200-2000.jsonl"] {
fs::copy(fixture_trace_dir().join(name), dest.join(name)).unwrap();
}
}
#[test]
fn show_merges_fixture_segments_with_expected_shape() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
copy_fixture_trace(&trace_dir);
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let body = execute(
TraceAction::Show {
limit: 0,
event: None,
},
&context,
)
.unwrap();
assert_eq!(body["session_id"], manifest.id);
assert_eq!(body["segments"].as_array().unwrap().len(), 2);
assert_eq!(body["total_events"].as_u64().unwrap(), 5);
assert_eq!(body["returned_events"], 5);
assert_eq!(body["truncated"], false);
assert_eq!(body["events"][0]["event"], "trace.meta");
assert_eq!(body["events"][1]["snapshot_id"], "snap-a");
assert_eq!(body["events"][2]["snapshot_id"], "snap-b");
assert!(
body.get("matched_events").is_none(),
"matched_events must be omitted when --event is not passed"
);
}
#[test]
fn show_without_active_session_is_invalid_args() {
let _guard = HomeGuard::new();
let context = CommandContext::new(None, None, false).unwrap();
let err = execute(
TraceAction::Show {
limit: 500,
event: None,
},
&context,
)
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn show_without_trace_directory_is_invalid_args() {
let _guard = HomeGuard::new();
let context = CommandContext::new(Some("legacy-no-trace".into()), None, false).unwrap();
let err = execute(
TraceAction::Show {
limit: 500,
event: None,
},
&context,
)
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn show_honors_limit_and_event_prefix() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
copy_fixture_trace(&trace_dir);
let merged = read_merged(
&trace_dir,
&ReadOptions {
limit: 1,
event_prefix: Some("command.".into()),
},
)
.unwrap();
assert!(merged.truncated);
assert_eq!(merged.returned_events, 1);
}
#[test]
fn show_reports_raw_total_and_matched_count_when_filtered() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
copy_fixture_trace(&trace_dir);
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let body = execute(
TraceAction::Show {
limit: 0,
event: Some("command.".into()),
},
&context,
)
.unwrap();
assert_eq!(body["total_events"].as_u64().unwrap(), 5);
assert_eq!(body["matched_events"].as_u64().unwrap(), 2);
assert_eq!(body["returned_events"], 2);
}
#[test]
fn show_matched_events_survives_limit_truncation_from_single_read() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
copy_fixture_trace(&trace_dir);
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let body = execute(
TraceAction::Show {
limit: 1,
event: Some("command.".into()),
},
&context,
)
.unwrap();
assert_eq!(body["total_events"].as_u64().unwrap(), 5);
assert_eq!(body["matched_events"].as_u64().unwrap(), 2);
assert_eq!(body["returned_events"], 1);
assert_eq!(body["truncated"], true);
}
#[test]
fn show_on_empty_trace_directory_is_invalid_args() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let err = execute(
TraceAction::Show {
limit: 500,
event: None,
},
&context,
)
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
assert!(err.to_string().contains("empty trace directory"));
}
#[test]
fn export_on_empty_trace_directory_is_invalid_args() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let err = execute(
TraceAction::Export {
limit: 0,
out: None,
},
&context,
)
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
assert!(err.to_string().contains("empty trace directory"));
}
#[test]
fn tail_limit_surfaces_unpaired_command_warning() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
fs::create_dir_all(&trace_dir).unwrap();
let path = trace_dir.join("100-1000.jsonl");
let mut file = fs::File::create(&path).unwrap();
writeln!(
file,
r#"{{"event":"command.start","command":"click","ts_ms":1,"seq":1}}"#
)
.unwrap();
writeln!(
file,
r#"{{"event":"command.end","command":"snapshot","ok":true,"duration_ms":1,"ts_ms":2,"seq":2}}"#
)
.unwrap();
writeln!(
file,
r#"{{"event":"command.start","command":"type","ts_ms":3,"seq":3}}"#
)
.unwrap();
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let body = execute(
TraceAction::Show {
limit: 2,
event: None,
},
&context,
)
.unwrap();
assert_eq!(body["truncated"], true);
let warnings = body["warnings"].as_array().expect("warnings");
assert!(
warnings
.iter()
.any(|warning| warning["kind"] == "unpaired_command")
);
}
#[test]
fn export_json_surfaces_warnings_and_truncation_without_html() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
fs::create_dir_all(&trace_dir).unwrap();
fs::write(trace_dir.join("notes.txt"), b"not a trace segment").unwrap();
let path = trace_dir.join("100-1000.jsonl");
let mut file = fs::File::create(&path).unwrap();
writeln!(
file,
r#"{{"event":"command.start","command":"click","ts_ms":1,"seq":1}}"#
)
.unwrap();
writeln!(
file,
r#"{{"event":"command.end","command":"click","ok":true,"duration_ms":1,"ts_ms":2,"seq":2}}"#
)
.unwrap();
writeln!(
file,
r#"{{"event":"command.start","command":"type","ts_ms":3,"seq":3}}"#
)
.unwrap();
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let body = execute(
TraceAction::Export {
limit: 2,
out: None,
},
&context,
)
.unwrap();
assert_eq!(body["total_events"].as_u64().unwrap(), 3);
assert_eq!(body["returned_events"], 2);
assert_eq!(body["event_count"], 2);
assert_eq!(body["truncated"], true);
assert!(body["path"].as_str().unwrap().ends_with(".html"));
assert!(body["bytes"].as_u64().unwrap() > 0);
let warnings = body["warnings"].as_array().expect("warnings");
assert!(
warnings
.iter()
.any(|warning| warning["kind"] == "foreign_file"),
"expected a foreign_file warning, got {warnings:?}"
);
}
#[test]
fn export_json_omits_warnings_when_trace_is_clean() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
fs::create_dir_all(&trace_dir).unwrap();
let path = trace_dir.join("100-1000.jsonl");
let mut file = fs::File::create(&path).unwrap();
writeln!(
file,
r#"{{"event":"snapshot.saved","snapshot_id":"s1","ts_ms":1,"seq":1}}"#
)
.unwrap();
let context = CommandContext::new(Some(manifest.id), None, false).unwrap();
let body = execute(
TraceAction::Export {
limit: 0,
out: None,
},
&context,
)
.unwrap();
assert_eq!(body["total_events"].as_u64().unwrap(), 1);
assert_eq!(body["returned_events"], 1);
assert_eq!(body["event_count"], 1);
assert_eq!(body["truncated"], false);
assert!(body.get("warnings").is_none());
}

View file

@ -10,7 +10,9 @@ use crate::{
error::{AppError, ErrorCode},
notification::{NotificationFilter, NotificationInfo},
refs_store::RefStore,
search_text, snapshot,
search_text,
snapshot::{self, emit_snapshot_saved},
trace_artifacts,
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
@ -176,8 +178,15 @@ fn wait_for_text(
.map(|expected| matches.len() == expected)
.unwrap_or_else(|| !matches.is_empty());
if matched {
let snapshot_id = RefStore::for_session(context.session_id())?
.save_new_snapshot(&result.refmap)?;
let store = RefStore::for_session(context.session_id())?;
let snapshot_id = store.save_new_snapshot(&result.refmap)?;
trace_artifacts::copy_refmap_if_full(
context,
&store,
&snapshot_id,
&result.refmap,
)?;
emit_snapshot_saved(context, &result)?;
let elapsed = start.elapsed().as_millis();
let found = matches.first();
let mut body = json!({

View file

@ -170,7 +170,7 @@ fn element_wait_actionable_retries_until_live_state_converges() {
wait_predicate::ElementPredicate::Actionable(
crate::action_request::ActionRequest::headless(crate::action::Action::Click),
),
250,
5_000,
&adapter,
&crate::context::CommandContext::default(),
)

View file

@ -4,7 +4,8 @@ use crate::{
context::CommandContext,
error::{AppError, ErrorCode},
refs_store::RefStore,
snapshot,
snapshot::{self, emit_snapshot_saved},
trace_artifacts,
};
use serde_json::{Value, json};
use std::time::{Duration, Instant};
@ -42,9 +43,16 @@ pub fn execute(
let present = query::tree_has_match(&result.tree, &query);
let matched = if input.gone { !present } else { present };
if matched {
let snapshot_id = RefStore::for_session(context.session_id())?
.save_new_snapshot(&result.refmap)?;
result.snapshot_id = Some(snapshot_id);
let store = RefStore::for_session(context.session_id())?;
let snapshot_id = store.save_new_snapshot(&result.refmap)?;
trace_artifacts::copy_refmap_if_full(
context,
&store,
&snapshot_id,
&result.refmap,
)?;
result.snapshot_id = Some(snapshot_id.clone());
emit_snapshot_saved(context, &result)?;
let elapsed = start.elapsed().as_millis();
return snapshot_cmd::format_snapshot_fields(
&result,
@ -106,8 +114,9 @@ fn persist_last_built(
let Some(result) = last_built else {
return Ok(None);
};
let snapshot_id =
RefStore::for_session(context.session_id())?.save_new_snapshot(&result.refmap)?;
let store = RefStore::for_session(context.session_id())?;
let snapshot_id = store.save_new_snapshot(&result.refmap)?;
trace_artifacts::copy_refmap_if_full(context, &store, &snapshot_id, &result.refmap)?;
Ok(Some(snapshot_id))
}

View file

@ -23,7 +23,7 @@ pub(crate) fn element(
) -> Result<Value, AppError> {
timeout_err(
format!(
"Element {ref_id} did not satisfy predicate '{}' within {timeout_ms}ms; last_observed={last_observed}",
"Element {ref_id} did not satisfy predicate '{}' within {timeout_ms}ms",
predicate.name()
),
json!({
@ -48,7 +48,10 @@ pub(crate) fn window(
last_error: Option<Value>,
) -> Result<Value, AppError> {
timeout_err(
format!("Window with title '{title}' not found within {timeout_ms}ms"),
format!(
"Window title ({} chars) not found within {timeout_ms}ms",
title.chars().count()
),
with_last_error(
json!({
"predicate": "window",
@ -67,7 +70,10 @@ pub(crate) fn text(
last_error: Option<Value>,
) -> Result<Value, AppError> {
timeout_err(
format!("Text '{text}' did not match within {timeout_ms}ms"),
format!(
"Text ({} chars) did not match within {timeout_ms}ms",
text.chars().count()
),
with_last_error(
json!({
"predicate": "text",
@ -121,7 +127,8 @@ pub(crate) fn selector(
}
timeout_err(
format!(
"Selector '{selector}' did not {} within {timeout_ms}ms",
"Selector ({} chars) did not {} within {timeout_ms}ms",
selector.chars().count(),
if gone { "disappear" } else { "appear" }
),
details,

View file

@ -2,13 +2,16 @@ use crate::{
action::Action, action_request::ActionRequest, error::AppError,
interaction_policy::InteractionPolicy, session, trace::TraceConfig,
};
use serde_json::Value;
use serde_json::{Value, json};
use std::cell::Cell;
use std::path::PathBuf;
use std::time::Instant;
#[derive(Debug, Clone, Default)]
pub struct CommandContext {
session_id: Option<String>,
trace: TraceConfig,
artifacts_full: bool,
headed: bool,
wait_selector: Option<WaitSelector>,
}
@ -20,6 +23,62 @@ pub struct WaitSelector {
pub timeout_ms: u64,
}
/// Emits `command.start` on construction and `command.end` on `complete`.
/// The `Drop` guard emits a fallback `command.end` for a scope abandoned
/// without `complete` (normal early return, or a panic under the unwind
/// test profile). It cannot fire on a genuine panic in the release binary:
/// `[profile.release]` uses `panic = "abort"`, which terminates without
/// unwinding `Drop`. A panicked release command therefore leaves an
/// unpaired `command.start`, which the trace reader tolerates as an
/// `unpaired_command` warning rather than a lost record.
pub struct CommandScope<'a> {
context: &'a CommandContext,
command: &'static str,
started: Instant,
finished: Cell<bool>,
}
impl CommandScope<'_> {
pub fn complete(self, result: &Result<Value, AppError>) {
self.finished.set(true);
match result {
Ok(_) => self.emit_end(true, None, None),
Err(err) => {
let message = err.to_string();
self.emit_end(false, Some(err.code()), Some(message.as_str()));
}
}
}
fn emit_end(&self, ok: bool, code: Option<&str>, message: Option<&str>) {
let mut fields = json!({
"command": self.command,
"ok": ok,
"duration_ms": self.started.elapsed().as_millis(),
});
if let Some(code) = code {
fields["code"] = json!(code);
}
if let Some(message) = message {
fields["message"] = json!(message);
}
let _ = self.context.trace("command.end", fields);
}
}
impl Drop for CommandScope<'_> {
fn drop(&mut self) {
if self.finished.get() {
return;
}
self.emit_end(
false,
Some("INTERNAL"),
Some("command scope dropped without completion"),
);
}
}
impl CommandContext {
pub fn new(
session_id: Option<String>,
@ -29,10 +88,12 @@ 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())?;
let (segment_dir, artifacts_full) =
session_trace_state(session_id.as_deref(), trace_path.is_some())?;
Ok(Self {
session_id,
trace: TraceConfig::build(trace_path, segment_dir, trace_strict)?,
artifacts_full,
headed: false,
wait_selector: None,
})
@ -52,6 +113,16 @@ impl CommandContext {
self.wait_selector.as_ref()
}
pub fn command_scope(&self, command: &'static str) -> CommandScope<'_> {
let _ = self.trace("command.start", json!({ "command": command }));
CommandScope {
context: self,
command,
started: Instant::now(),
finished: Cell::new(false),
}
}
pub fn request(&self, action: Action, base: InteractionPolicy) -> ActionRequest {
ActionRequest {
action,
@ -81,15 +152,21 @@ impl CommandContext {
if let Some(id) = session_id.as_deref() {
validate_session_id(id)?;
}
let trace = if self.trace.pending_file_path().is_some() || session_id == self.session_id {
self.trace.clone()
let reuses_parent_trace = session_id == self.session_id
|| (self.trace.pending_file_path().is_some() && self.trace.has_sink());
let (trace, artifacts_full) = if reuses_parent_trace {
(self.trace.clone(), self.artifacts_full)
} else {
let segment_dir = session_segment_dir(session_id.as_deref(), false)?;
self.trace.clone_with_session_segment(segment_dir)?
let (segment_dir, artifacts_full) = session_trace_state(session_id.as_deref(), false)?;
(
self.trace.clone_with_session_segment(segment_dir)?,
artifacts_full,
)
};
Ok(Self {
session_id,
trace,
artifacts_full,
headed: self.headed,
wait_selector: None,
})
@ -111,22 +188,30 @@ impl CommandContext {
pub fn trace_enabled(&self) -> bool {
self.trace.has_sink()
}
pub fn artifacts_full(&self) -> bool {
self.artifacts_full
}
}
fn session_segment_dir(
fn session_trace_state(
session_id: Option<&str>,
explicit_trace: bool,
) -> Result<Option<PathBuf>, AppError> {
) -> Result<(Option<PathBuf>, bool), AppError> {
if explicit_trace {
return Ok(None);
return Ok((None, false));
}
let Some(session_id) = session_id else {
return Ok(None);
return Ok((None, false));
};
if !session::trace_enabled_for_session(session_id)? {
return Ok(None);
}
Ok(Some(session::trace_dir(session_id)?))
let manifest = session::read_manifest(session_id)?;
let trace_dir = if manifest.as_ref().is_some_and(|m| m.trace_enabled()) {
Some(session::trace_dir(session_id)?)
} else {
None
};
let artifacts_full = manifest.as_ref().is_some_and(|m| m.artifacts_full());
Ok((trace_dir, artifacts_full))
}
pub fn validate_session_id(id: &str) -> Result<(), AppError> {
@ -147,3 +232,7 @@ pub fn validate_session_id(id: &str) -> Result<(), AppError> {
#[cfg(test)]
#[path = "context_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "context_scope_tests.rs"]
mod scope_tests;

View file

@ -0,0 +1,151 @@
use super::*;
use crate::error::AppError;
use crate::session::{SessionTraceMode, StartSessionOptions, start_session};
use serde_json::json;
#[test]
fn command_scope_emits_start_and_success_end() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-scope-ok-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
let scope = context.command_scope("snapshot");
scope.complete(&Ok(json!({ "ok": true })));
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains(r#""event":"command.start""#));
assert!(body.contains(r#""event":"command.end""#));
assert!(body.contains(r#""ok":true"#));
let end_line = body
.lines()
.find(|line| line.contains(r#""event":"command.end""#))
.expect("command.end line");
let end_event: serde_json::Value = serde_json::from_str(end_line).unwrap();
assert!(end_event["duration_ms"].as_u64().is_some());
let _ = std::fs::remove_file(path);
}
#[test]
fn command_scope_emits_error_end_with_code_and_message() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-scope-err-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
let scope = context.command_scope("wait");
let err = AppError::invalid_input("bad args");
scope.complete(&Err(err));
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains(r#""ok":false"#));
assert!(body.contains(r#""code":"INVALID_ARGS""#));
let _ = std::fs::remove_file(path);
}
#[test]
fn command_scope_drop_emits_internal_end_once() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-scope-drop-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
{
let _scope = context.command_scope("click");
}
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body.matches(r#""event":"command.end""#).count(), 1);
assert!(body.contains(r#""code":"INTERNAL""#));
let _ = std::fs::remove_file(path);
}
#[test]
fn command_scope_is_noop_without_trace_sink() {
let context = CommandContext::default();
let scope = context.command_scope("status");
scope.complete(&Ok(json!({})));
}
#[test]
fn artifacts_full_follows_manifest_mode() {
let _guard = crate::refs_test_support::HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
artifacts: crate::session::ArtifactsMode::Full,
..Default::default()
})
.unwrap();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
assert!(context.artifacts_full());
}
#[test]
fn batch_item_with_failed_parent_trace_writes_to_its_own_session_segment() {
let _guard = crate::refs_test_support::HomeGuard::new();
let session = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let unopenable = std::env::temp_dir()
.join("agent-desktop-batch-failed-parent-nodir")
.join("trace.jsonl");
let parent = CommandContext::new(None, Some(unopenable), false).unwrap();
assert!(
!parent.trace_enabled(),
"parent explicit --trace to an unopenable path must have a failed (sinkless) writer"
);
let child = parent.for_batch_item(Some(session.id.clone())).unwrap();
child
.trace("batch.item.event", json!({ "ok": true }))
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&session.id))
.unwrap()
.trace_dir();
let wrote = std::fs::read_dir(&trace_dir)
.map(|entries| {
entries.flatten().any(|entry| {
std::fs::read_to_string(entry.path())
.map(|c| c.contains("batch.item.event"))
.unwrap_or(false)
})
})
.unwrap_or(false);
assert!(
wrote,
"a batch item targeting a trace-enabled session must write to that session's segment, not inherit the parent's dead --trace writer"
);
}
#[test]
fn wait_text_timeout_message_omits_raw_text_from_trace_segment() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-scope-wait-text-redact-{}.jsonl",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
let marker = "zzq93f_super_secret_marker_do_not_leak";
let err = crate::commands::wait_timeout::text(marker, 50, None, None).unwrap_err();
let scope = context.command_scope("wait");
scope.complete(&Err(err));
let body = std::fs::read_to_string(&path).unwrap();
assert!(body.contains(r#""event":"command.end""#));
assert!(!body.contains(marker));
let _ = std::fs::remove_file(path);
}

View file

@ -31,7 +31,11 @@ fn trace_writes_jsonl_without_stdout_dependency() {
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
let event_line = body
.lines()
.find(|line| line.contains(r#""event":"ref.resolve.ok""#))
.expect("event line");
let event: serde_json::Value = serde_json::from_str(event_line).unwrap();
assert_eq!(event["event"], "ref.resolve.ok");
assert_eq!(event["ref"], "@e1");
assert!(event["ts_ms"].as_u64().is_some());
@ -56,7 +60,11 @@ fn trace_injects_session_id_as_top_level_unredacted_field() {
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
let event_line = body
.lines()
.find(|line| line.contains(r#""event":"ref.resolve.ok""#))
.expect("event line");
let event: serde_json::Value = serde_json::from_str(event_line).unwrap();
assert_eq!(event["session_id"], "my-session");
assert_eq!(event["event"], "ref.resolve.ok");
assert!(event["ts_ms"].as_u64().is_some());
@ -162,7 +170,11 @@ fn trace_redacts_sensitive_text_and_value_fields() {
.unwrap();
let body = std::fs::read_to_string(&path).unwrap();
let event: serde_json::Value = serde_json::from_str(body.trim()).unwrap();
let event_line = body
.lines()
.find(|line| line.contains(r#""event":"event""#))
.expect("event line");
let event: serde_json::Value = serde_json::from_str(event_line).unwrap();
assert_eq!(event["text"]["redacted"], true);
assert_eq!(event["value"]["redacted"], true);
assert_eq!(event["message"], "diagnostic error");
@ -225,6 +237,7 @@ fn trace_on_session_writes_segment_without_explicit_trace_flag() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
@ -246,6 +259,7 @@ fn no_trace_session_still_namespaces_snapshots() {
name: None,
trace: SessionTraceMode::Off,
force: false,
..Default::default()
})
.unwrap();
assert!(!trace_enabled_for_session(&manifest.id).unwrap());
@ -261,6 +275,7 @@ fn explicit_trace_overrides_session_sink() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let path = std::env::temp_dir().join(format!(
@ -291,12 +306,14 @@ fn batch_item_session_override_uses_its_own_segment_dir() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let child_session = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: true,
..Default::default()
})
.unwrap();
let parent = CommandContext::new(Some(parent_session.id.clone()), None, false).unwrap();
@ -329,12 +346,14 @@ fn strict_parent_allows_no_trace_batch_override() {
name: None,
trace: SessionTraceMode::On,
force: true,
..Default::default()
})
.unwrap();
let untraced = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: true,
..Default::default()
})
.unwrap();
let parent = CommandContext::new(Some(traced.id.clone()), None, true).unwrap();

View file

@ -32,6 +32,9 @@ pub mod session;
pub mod snapshot;
pub mod snapshot_ref;
pub(crate) mod trace;
pub(crate) mod trace_artifacts;
pub mod trace_read;
pub mod trace_sanitize;
mod window_lookup;
pub use action::{
@ -57,4 +60,4 @@ pub use permission_report::PermissionReport;
pub use permission_state::PermissionState;
pub use refs::{RefEntry, RefMap};
pub use refs_store::RefStore;
pub use trace::sanitize_trace_value;
pub use trace_sanitize::sanitize_trace_value;

View file

@ -7,6 +7,7 @@ use crate::{
error::{AdapterError, AppError},
refs::RefEntry,
resolved_element::ResolvedElement,
trace_artifacts,
};
use serde_json::json;
@ -27,12 +28,26 @@ pub(crate) fn execute_resolved(
request: ActionRequest,
) -> Result<ActionResult, AppError> {
check_actionability_with_trace(&target, &request)?;
let pre = trace_artifacts::capture_action_screenshot(
target.context,
target.adapter,
target.entry.pid,
"pre",
);
target.context.trace_lazy(
"action.dispatch.start",
|| json!({ "ref": target.ref_id, "action": request.action.name() }),
)?;
let action_name = request.action.name();
let result = target.adapter.execute_action(target.handle, request)?;
let dispatch_result = target.adapter.execute_action(target.handle, request);
let post = trace_artifacts::capture_action_screenshot(
target.context,
target.adapter,
target.entry.pid,
"post",
);
let _ = trace_artifacts::emit_action_artifacts(target.context, target.ref_id, &pre, &post);
let result = dispatch_result?;
let _ = target.context.trace_lazy(
"action.dispatch.ok",
|| json!({ "ref": target.ref_id, "action": action_name, "result": &result }),

View file

@ -247,6 +247,33 @@ fn write_tmp_then_rename(tmp: &Path, path: &Path, bytes: &[u8]) -> Result<(), Ap
Ok(())
}
pub(crate) fn is_symlink(path: &Path) -> bool {
std::fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
}
pub(crate) fn open_nofollow(path: &Path) -> std::io::Result<std::fs::File> {
#[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(
std::io::ErrorKind::PermissionDenied,
"path must not be a symlink",
));
}
std::fs::File::open(path)
}
}
pub(crate) fn home_dir() -> Option<PathBuf> {
let home = HOME_OVERRIDE
.with(|cell| cell.borrow().clone())

View file

@ -159,7 +159,7 @@ impl RefStore {
snapshot_id: &str,
) -> Result<Option<RefMap>, AppError> {
let path = Self::snapshot_path_for_base(base_dir, snapshot_id);
let mut file = match open_refstore_file(&path) {
let mut file = match crate::refs::open_nofollow(&path) {
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
@ -205,7 +205,7 @@ impl RefStore {
}
fn read_latest_snapshot_id(&self) -> Result<Option<String>, AppError> {
let mut file = match open_refstore_file(&self.latest_path()) {
let mut file = match crate::refs::open_nofollow(&self.latest_path()) {
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Err(err.into()),
@ -312,27 +312,6 @@ impl RefStore {
}
}
fn open_refstore_file(path: &Path) -> std::io::Result<std::fs::File> {
#[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,
"refmap path must not be a symlink",
));
}
std::fs::File::open(path)
}
}
/// Pruning logic is a sibling `#[path]` module rather than a separate crate
/// module so it can access `base_dir`/`snapshots_dir` directly. Exposing them
/// as `pub(crate)` would widen the visibility surface to every module in the

View file

@ -10,6 +10,16 @@ pub struct SessionManifest {
pub ended_at: Option<u64>,
#[serde(default)]
pub trace: SessionTraceMode,
#[serde(default)]
pub artifacts: ArtifactsMode,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ArtifactsMode {
Full,
#[default]
Events,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
@ -24,4 +34,8 @@ impl SessionManifest {
pub fn trace_enabled(&self) -> bool {
matches!(self.trace, SessionTraceMode::On) && self.ended_at.is_none()
}
pub fn artifacts_full(&self) -> bool {
matches!(self.artifacts, ArtifactsMode::Full) && self.ended_at.is_none()
}
}

View file

@ -2,7 +2,7 @@ mod gc;
mod manifest;
pub use gc::{GcOptions, GcReport, gc, is_live, pointer_references_live_session};
pub use manifest::{SessionManifest, SessionTraceMode};
pub use manifest::{ArtifactsMode, SessionManifest, SessionTraceMode};
use crate::{
context::validate_session_id,
@ -24,9 +24,21 @@ static SESSION_COUNTER: AtomicU64 = AtomicU64::new(0);
pub struct StartSessionOptions {
pub name: Option<String>,
pub trace: SessionTraceMode,
pub artifacts: ArtifactsMode,
pub force: bool,
}
impl Default for StartSessionOptions {
fn default() -> Self {
Self {
name: None,
trace: SessionTraceMode::On,
artifacts: ArtifactsMode::Events,
force: false,
}
}
}
pub fn agent_desktop_dir() -> Result<PathBuf, AppError> {
let home = home_dir().ok_or_else(|| AppError::Internal("HOME directory not found".into()))?;
Ok(home.join(".agent-desktop"))
@ -68,10 +80,10 @@ pub fn resolve_active_session(
pub fn read_current_session_pointer() -> Result<Option<String>, AppError> {
let path = current_session_path()?;
let mut file = match open_session_file(&path) {
let mut file = match crate::refs::open_nofollow(&path) {
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) if is_symlinked(&path) => {
Err(err) if crate::refs::is_symlink(&path) => {
tracing::warn!(
"ignoring symlinked session pointer {}: {err}",
path.display()
@ -104,7 +116,7 @@ pub fn clear_current_session_pointer() -> Result<(), AppError> {
pub fn read_manifest(session_id: &str) -> Result<Option<SessionManifest>, AppError> {
let path = manifest_path(session_id)?;
let mut file = match open_session_file(&path) {
let mut file = match crate::refs::open_nofollow(&path) {
Ok(file) => file,
Err(err) if err.kind() == ErrorKind::NotFound => return Ok(None),
Err(err) => return Ok(ignore_unreadable_manifest(&path, &err)),
@ -119,12 +131,6 @@ pub fn read_manifest(session_id: &str) -> Result<Option<SessionManifest>, AppErr
}
}
fn is_symlinked(path: &Path) -> bool {
std::fs::symlink_metadata(path)
.map(|meta| meta.file_type().is_symlink())
.unwrap_or(false)
}
fn ignore_unreadable_manifest<E: std::fmt::Display>(
path: &Path,
err: &E,
@ -203,6 +209,14 @@ pub fn list_sessions() -> Result<Vec<SessionManifest>, AppError> {
}
pub fn start_session(options: StartSessionOptions) -> Result<SessionManifest, AppError> {
if matches!(options.trace, SessionTraceMode::Off)
&& matches!(options.artifacts, ArtifactsMode::Full)
{
return Err(AppError::invalid_input_with_suggestion(
"Artifacts mode full requires tracing",
"Remove --no-trace or omit --screenshots.",
));
}
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",
@ -222,6 +236,7 @@ pub fn start_session(options: StartSessionOptions) -> Result<SessionManifest, Ap
created_at: now_millis(),
ended_at: None,
trace: options.trace,
artifacts: options.artifacts,
};
write_manifest(&manifest)?;
write_current_session_pointer(&id)?;
@ -288,27 +303,10 @@ pub(super) fn now_millis() -> u64 {
.unwrap_or(0)
}
fn open_session_file(path: &Path) -> std::io::Result<std::fs::File> {
#[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;
#[cfg(test)]
#[path = "session_gc_tests.rs"]
mod gc_tests;

View file

@ -0,0 +1,116 @@
use super::*;
use crate::refs_test_support::HomeGuard;
use crate::session::SessionTraceMode;
use std::fs;
use std::time::Duration;
#[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,
..Default::default()
})
.unwrap();
let ended = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: true,
..Default::default()
})
.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::gc::remove_session_dir(&dir).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn gc_respects_older_than_threshold() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: false,
..Default::default()
})
.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());
}
#[test]
fn gc_leaves_recently_created_unended_session() {
let _guard = HomeGuard::new();
let started = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: true,
..Default::default()
})
.unwrap();
clear_current_session_pointer().unwrap();
let report = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::from_secs(0)),
})
.unwrap();
assert!(!report.removed.contains(&started.id));
assert!(session_dir(&started.id).unwrap().is_dir());
}
#[cfg(unix)]
#[test]
fn unreadable_manifest_is_skipped_not_fatal_for_list_and_gc() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let _guard = HomeGuard::new();
let good = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: true,
..Default::default()
})
.unwrap();
let bad_dir = session_dir("unreadablesess").unwrap();
fs::create_dir_all(&bad_dir).unwrap();
let manifest = bad_dir.join("session.json");
fs::write(&manifest, b"{}").unwrap();
fs::set_permissions(&manifest, fs::Permissions::from_mode(0o000)).unwrap();
let listed: Vec<String> = list_sessions().unwrap().into_iter().map(|m| m.id).collect();
assert!(listed.contains(&good.id));
assert!(!listed.iter().any(|id| id == "unreadablesess"));
assert!(read_manifest("unreadablesess").unwrap().is_none());
fs::set_permissions(&manifest, fs::Permissions::from_mode(0o600)).unwrap();
}

View file

@ -1,9 +1,8 @@
use super::*;
use crate::refs_lock::RefStoreLock;
use crate::refs_test_support::HomeGuard;
use crate::session::SessionTraceMode;
use crate::session::{ArtifactsMode, SessionTraceMode};
use std::fs;
use std::time::Duration;
#[test]
fn resolve_prefers_explicit_over_env_and_pointer() {
@ -49,6 +48,7 @@ fn manifest_round_trips_with_optional_fields() {
created_at: 1,
ended_at: None,
trace: SessionTraceMode::On,
artifacts: ArtifactsMode::Events,
};
write_manifest(&manifest).unwrap();
let loaded = read_manifest("run-1").unwrap().expect("manifest");
@ -68,6 +68,7 @@ fn start_creates_tree_manifest_and_pointer() {
name: Some("demo".into()),
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
assert!(session_dir(&manifest.id).unwrap().join("trace").is_dir());
@ -84,6 +85,7 @@ fn start_refuses_live_pointer_without_force() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let _lock = RefStoreLock::acquire(
@ -97,6 +99,7 @@ fn start_refuses_live_pointer_without_force() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
@ -109,6 +112,7 @@ fn end_seals_manifest_and_clears_pointer() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let ended = end_session(None).unwrap();
@ -116,45 +120,6 @@ fn end_seals_manifest_and_clears_pointer() {
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::gc::remove_session_dir(&dir).unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn list_reports_manifest_fields_only() {
let _guard = HomeGuard::new();
@ -162,6 +127,7 @@ fn list_reports_manifest_fields_only() {
name: Some("listed".into()),
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let listed = list_sessions().unwrap();
@ -176,30 +142,12 @@ fn trace_enabled_requires_manifest_on() {
name: None,
trace: SessionTraceMode::Off,
force: false,
..Default::default()
})
.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());
}
#[test]
fn new_session_id_includes_process_id() {
let id = new_session_id();
@ -214,6 +162,7 @@ fn corrupt_manifest_is_ignored_not_fatal() {
name: None,
trace: SessionTraceMode::Off,
force: true,
..Default::default()
})
.unwrap();
let bad_dir = session_dir("corruptsess").unwrap();
@ -226,54 +175,6 @@ fn corrupt_manifest_is_ignored_not_fatal() {
assert!(!listed.iter().any(|id| id == "corruptsess"));
}
#[cfg(unix)]
#[test]
fn unreadable_manifest_is_skipped_not_fatal_for_list_and_gc() {
use std::os::unix::fs::PermissionsExt;
if unsafe { libc::geteuid() } == 0 {
return;
}
let _guard = HomeGuard::new();
let good = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: true,
})
.unwrap();
let bad_dir = session_dir("unreadablesess").unwrap();
fs::create_dir_all(&bad_dir).unwrap();
let manifest = bad_dir.join("session.json");
fs::write(&manifest, b"{}").unwrap();
fs::set_permissions(&manifest, fs::Permissions::from_mode(0o000)).unwrap();
let listed: Vec<String> = list_sessions().unwrap().into_iter().map(|m| m.id).collect();
assert!(listed.contains(&good.id));
assert!(!listed.iter().any(|id| id == "unreadablesess"));
assert!(read_manifest("unreadablesess").unwrap().is_none());
fs::set_permissions(&manifest, fs::Permissions::from_mode(0o600)).unwrap();
}
#[test]
fn gc_leaves_recently_created_unended_session() {
let _guard = HomeGuard::new();
let started = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::Off,
force: true,
})
.unwrap();
clear_current_session_pointer().unwrap();
let report = gc(GcOptions {
ended_only: false,
older_than: Some(Duration::from_secs(0)),
})
.unwrap();
assert!(!report.removed.contains(&started.id));
assert!(session_dir(&started.id).unwrap().is_dir());
}
#[test]
fn start_with_force_overrides_live_pointer() {
let _guard = HomeGuard::new();
@ -281,6 +182,7 @@ fn start_with_force_overrides_live_pointer() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
let _lock = RefStoreLock::acquire(
@ -294,6 +196,7 @@ fn start_with_force_overrides_live_pointer() {
name: None,
trace: SessionTraceMode::On,
force: true,
..Default::default()
})
.unwrap();
assert_ne!(first.id, second.id);
@ -310,6 +213,7 @@ fn trace_enabled_false_once_session_ended() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
assert!(trace_enabled_for_session(&manifest.id).unwrap());
@ -317,6 +221,69 @@ fn trace_enabled_false_once_session_ended() {
assert!(!trace_enabled_for_session(&manifest.id).unwrap());
}
#[test]
fn start_with_screenshots_records_full_artifacts_mode() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
artifacts: ArtifactsMode::Full,
..Default::default()
})
.unwrap();
assert_eq!(manifest.artifacts, ArtifactsMode::Full);
let loaded = read_manifest(&manifest.id).unwrap().expect("manifest");
assert_eq!(loaded.artifacts, ArtifactsMode::Full);
}
#[test]
fn start_without_screenshots_records_events_artifacts_mode() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
..Default::default()
})
.unwrap();
assert_eq!(manifest.artifacts, ArtifactsMode::Events);
}
#[test]
fn legacy_manifest_without_artifacts_defaults_to_events() {
let _guard = HomeGuard::new();
let dir = session_dir("legacy").unwrap();
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join("session.json"),
r#"{"id":"legacy","created_at":1,"trace":"on"}"#,
)
.unwrap();
let manifest = read_manifest("legacy").unwrap().expect("manifest");
assert_eq!(manifest.artifacts, ArtifactsMode::Events);
}
#[test]
fn no_trace_with_screenshots_is_invalid_args() {
let _guard = HomeGuard::new();
let err = start_session(StartSessionOptions {
trace: SessionTraceMode::Off,
artifacts: ArtifactsMode::Full,
..Default::default()
})
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn ended_session_reports_artifacts_full_false() {
let _guard = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
artifacts: ArtifactsMode::Full,
..Default::default()
})
.unwrap();
assert!(manifest.artifacts_full());
end_session(Some(&manifest.id)).unwrap();
let ended = read_manifest(&manifest.id).unwrap().expect("manifest");
assert!(!ended.artifacts_full());
}
#[cfg(unix)]
#[test]
fn symlinked_manifest_is_ignored_not_fatal() {
@ -325,6 +292,7 @@ fn symlinked_manifest_is_ignored_not_fatal() {
name: None,
trace: SessionTraceMode::Off,
force: true,
..Default::default()
})
.unwrap();
let dir = session_dir("symsess").unwrap();

View file

@ -6,7 +6,9 @@ use crate::{
ref_alloc::{self, RefAllocConfig},
refs::RefMap,
refs_store::RefStore,
trace_artifacts,
};
use serde_json::json;
#[derive(Clone)]
pub struct SnapshotResult {
@ -130,14 +132,28 @@ pub fn run_with_context(
let mut result = build(adapter, opts, app_name, window_id)?;
let store = RefStore::for_session(context.session_id())?;
let snapshot_id = store.save_new_snapshot(&result.refmap)?;
trace_artifacts::copy_refmap_if_full(context, &store, &snapshot_id, &result.refmap)?;
result.snapshot_id = Some(snapshot_id);
context.trace_lazy(
"snapshot.saved",
|| serde_json::json!({ "snapshot_id": result.snapshot_id, "ref_count": result.refmap.len() }),
)?;
emit_snapshot_saved(context, &result)?;
Ok(result)
}
pub(crate) fn emit_snapshot_saved(
context: &CommandContext,
result: &SnapshotResult,
) -> Result<(), AppError> {
context.trace_lazy("snapshot.saved", || {
let mut fields = json!({
"snapshot_id": result.snapshot_id,
"ref_count": result.refmap.len(),
});
if !result.window.app.is_empty() {
fields["app"] = json!(result.window.app);
}
fields
})
}
pub fn append_surface_refs(
adapter: &dyn PlatformAdapter,
pid: i32,
@ -191,8 +207,10 @@ pub fn append_surface_refs_with_context(
let tree = ref_alloc::allocate_refs(raw_tree, &mut refmap, &config);
if let Some(id) = store.latest_snapshot_id() {
store.save_existing_snapshot(&id, &refmap)?;
trace_artifacts::copy_refmap_if_full(context, &store, &id, &refmap)?;
} else {
store.save_new_snapshot(&refmap)?;
let id = store.save_new_snapshot(&refmap)?;
trace_artifacts::copy_refmap_if_full(context, &store, &id, &refmap)?;
}
Ok(Some(tree))
}

View file

@ -72,9 +72,12 @@ pub fn run_from_ref_with_context(
let saved_snapshot_id = if let Some(id) = active_snapshot_id {
store.save_existing_snapshot(&id, &refmap)?;
crate::trace_artifacts::copy_refmap_if_full(context, &store, &id, &refmap)?;
Some(id)
} else {
Some(store.save_new_snapshot(&refmap)?)
let id = store.save_new_snapshot(&refmap)?;
crate::trace_artifacts::copy_refmap_if_full(context, &store, &id, &refmap)?;
Some(id)
};
context.trace_lazy("snapshot.root.saved", || {
serde_json::json!({

View file

@ -1,8 +1,9 @@
use crate::error::AppError;
use crate::trace_sanitize::sanitize_trace_value;
use serde_json::{Map, Value, json};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, Mutex, OnceLock};
const MAX_TRACE_FILE_BYTES: u64 = 64 * 1024 * 1024;
@ -25,10 +26,11 @@ enum WriterState {
Failed,
}
#[derive(Debug, Clone, Default)]
#[derive(Debug, Default)]
struct TraceState {
pending: TracePending,
writer: Arc<Mutex<WriterState>>,
meta_written: AtomicBool,
}
#[derive(Debug, Clone, Default)]
@ -71,6 +73,7 @@ impl TraceConfig {
state: Arc::new(TraceState {
pending,
writer: Arc::new(Mutex::new(writer)),
meta_written: AtomicBool::new(false),
}),
})
}
@ -94,6 +97,7 @@ impl TraceConfig {
Some(writer) => writer,
None => return Ok(()),
};
self.ensure_meta_if_needed(&writer, session_id)?;
match writer
.lock()
.map_err(|_| AppError::Internal("trace writer lock poisoned".into()))
@ -164,7 +168,7 @@ impl TraceConfig {
&self,
session_segment_dir: Option<PathBuf>,
) -> Result<Self, AppError> {
if self.pending_file_path().is_some() {
if self.pending_file_path().is_some() && self.has_sink() {
return Ok(self.clone());
}
match session_segment_dir {
@ -175,6 +179,26 @@ impl TraceConfig {
}),
}
}
fn ensure_meta_if_needed(
&self,
writer: &Arc<Mutex<std::fs::File>>,
session_id: Option<&str>,
) -> Result<(), AppError> {
if self.state.meta_written.load(Ordering::Relaxed) {
return Ok(());
}
let mut file = writer
.lock()
.map_err(|_| AppError::Internal("trace writer lock poisoned".into()))?;
if file.metadata()?.len() > 0 {
self.state.meta_written.store(true, Ordering::Relaxed);
return Ok(());
}
write_meta_header(&mut file, session_id)?;
self.state.meta_written.store(true, Ordering::Relaxed);
Ok(())
}
}
fn process_segment_suffix() -> &'static str {
@ -197,7 +221,32 @@ fn open_segment_trace_file(dir: &Path) -> Result<std::fs::File, AppError> {
open_trace_file(&segment_path_for_dir(dir))
}
fn ensure_trace_dir(dir: &Path) -> Result<(), AppError> {
pub(crate) fn process_start_ms() -> u64 {
static START_MS: OnceLock<u64> = OnceLock::new();
*START_MS.get_or_init(|| {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
})
}
fn write_meta_header(file: &mut std::fs::File, session_id: Option<&str>) -> Result<(), AppError> {
write_event(
file,
"trace.meta",
session_id,
json!({
"schema": 1,
"version": env!("CARGO_PKG_VERSION"),
"os": std::env::consts::OS,
"pid": std::process::id(),
"proc_start_ms": process_start_ms(),
}),
)
}
pub(crate) fn ensure_trace_dir(dir: &Path) -> Result<(), AppError> {
if let Ok(meta) = std::fs::symlink_metadata(dir) {
if meta.file_type().is_symlink() {
return Err(AppError::invalid_input_with_suggestion(
@ -302,88 +351,6 @@ fn reject_loose_trace_permissions(_file: &std::fs::File) -> Result<(), AppError>
Ok(())
}
/// Recursively redacts fields whose keys match `SENSITIVE_KEYS`. Non-sensitive
/// fields and non-object values are left unchanged. Array elements are
/// recursively scanned. Used by both the file-trace writer and the FFI log
/// callback layer so that sensitive values never reach a consumer.
pub fn sanitize_trace_value(value: Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(key, value)| {
if is_sensitive_trace_key(&key) {
(key, redacted_value(value))
} else {
(key, sanitize_trace_value(value))
}
})
.collect(),
),
Value::Array(items) => Value::Array(items.into_iter().map(sanitize_trace_value).collect()),
other => other,
}
}
fn is_sensitive_trace_key(key: &str) -> bool {
const SENSITIVE_KEYS: &[&str] = &[
"text",
"value",
"expected",
"name",
"username",
"description",
"label",
"query",
"secret",
"token",
"password",
"title",
"url",
"help",
"placeholder",
];
trace_key_tokens(key)
.iter()
.any(|part| SENSITIVE_KEYS.contains(&part.as_str()))
}
fn trace_key_tokens(key: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut previous_was_lower_or_digit = false;
for ch in key.chars() {
if !ch.is_ascii_alphanumeric() {
push_trace_key_token(&mut tokens, &mut current);
previous_was_lower_or_digit = false;
continue;
}
if ch.is_ascii_uppercase() && previous_was_lower_or_digit {
push_trace_key_token(&mut tokens, &mut current);
}
current.push(ch.to_ascii_lowercase());
previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
push_trace_key_token(&mut tokens, &mut current);
tokens
}
fn push_trace_key_token(tokens: &mut Vec<String>, current: &mut String) {
if !current.is_empty() {
tokens.push(std::mem::take(current));
}
}
fn redacted_value(value: Value) -> Value {
match value {
Value::Null => Value::Null,
_ => json!({ "redacted": true }),
}
}
#[cfg(test)]
#[path = "trace_tests.rs"]
mod tests;

View file

@ -0,0 +1,311 @@
use crate::{
adapter::{PlatformAdapter, ScreenshotTarget},
context::CommandContext,
error::AppError,
refs::{RefMap, is_symlink, open_nofollow, write_private_file},
refs_store::RefStore,
trace::{ensure_trace_dir, process_start_ms},
};
use serde_json::json;
use std::io::Read;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
const SCREENSHOT_BYTE_BUDGET: u64 = 128 * 1024 * 1024;
const SCREENSHOT_COUNT_BUDGET: u32 = 200;
const REFMAP_BYTE_BUDGET: u64 = 64 * 1024 * 1024;
// ponytail: per-process budget ceiling — a long multi-invocation session can exceed the intended per-session disk cap; session-scoped accounting (persisted counter under the refstore lock) is deferred to the Phase 4 daemon.
static CAPTURE_SEQ: AtomicU32 = AtomicU32::new(0);
static SCREENSHOT_BYTES_USED: AtomicU64 = AtomicU64::new(0);
static SCREENSHOT_COUNT_USED: AtomicU32 = AtomicU32::new(0);
static REFMAP_BYTES_USED: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
#[derive(Clone, Copy)]
struct LocalBudget {
screenshot_bytes: u64,
screenshot_count: u32,
refmap_bytes: u64,
screenshot_bytes_used: u64,
screenshot_count_used: u32,
refmap_bytes_used: u64,
}
#[cfg(test)]
thread_local! {
static LOCAL_BUDGET: std::cell::RefCell<Option<LocalBudget>> = const { std::cell::RefCell::new(None) };
}
#[cfg(test)]
pub(crate) fn set_test_budgets(screenshot_bytes: u64, screenshot_count: u32, refmap_bytes: u64) {
LOCAL_BUDGET.with(|cell| {
*cell.borrow_mut() = Some(LocalBudget {
screenshot_bytes,
screenshot_count,
refmap_bytes,
screenshot_bytes_used: 0,
screenshot_count_used: 0,
refmap_bytes_used: 0,
});
});
}
#[cfg(test)]
pub(crate) fn clear_test_budgets() {
LOCAL_BUDGET.with(|cell| {
*cell.borrow_mut() = None;
});
}
#[cfg(test)]
fn reserve_screenshot_local(byte_len: u64) -> Option<Result<(), &'static str>> {
let mut local = LOCAL_BUDGET.with(|cell| *cell.borrow())?;
if local.screenshot_count_used >= local.screenshot_count {
return Some(Err("count_budget"));
}
if local.screenshot_bytes_used.saturating_add(byte_len) > local.screenshot_bytes {
return Some(Err("budget"));
}
local.screenshot_count_used += 1;
local.screenshot_bytes_used = local.screenshot_bytes_used.saturating_add(byte_len);
LOCAL_BUDGET.with(|cell| *cell.borrow_mut() = Some(local));
Some(Ok(()))
}
#[cfg(test)]
fn reserve_refmap_local(byte_len: u64) -> Option<Result<(), &'static str>> {
let mut local = LOCAL_BUDGET.with(|cell| *cell.borrow())?;
if local.refmap_bytes_used.saturating_add(byte_len) > local.refmap_bytes {
return Some(Err("budget"));
}
local.refmap_bytes_used = local.refmap_bytes_used.saturating_add(byte_len);
LOCAL_BUDGET.with(|cell| *cell.borrow_mut() = Some(local));
Some(Ok(()))
}
fn reserve_atomic_bytes(used: &AtomicU64, limit: u64, byte_len: u64) -> Result<(), &'static str> {
used.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
let next = cur.saturating_add(byte_len);
if next > limit { None } else { Some(next) }
})
.map(|_| ())
.map_err(|_| "budget")
}
fn reserve_atomic_count(used: &AtomicU32, limit: u32) -> Result<(), &'static str> {
used.fetch_update(Ordering::Relaxed, Ordering::Relaxed, |cur| {
let next = cur.saturating_add(1);
if next > limit { None } else { Some(next) }
})
.map(|_| ())
.map_err(|_| "count_budget")
}
fn reserve_screenshot(byte_len: u64) -> Result<(), &'static str> {
#[cfg(test)]
if let Some(result) = reserve_screenshot_local(byte_len) {
return result;
}
reserve_atomic_count(&SCREENSHOT_COUNT_USED, SCREENSHOT_COUNT_BUDGET)?;
if let Err(reason) =
reserve_atomic_bytes(&SCREENSHOT_BYTES_USED, SCREENSHOT_BYTE_BUDGET, byte_len)
{
SCREENSHOT_COUNT_USED.fetch_sub(1, Ordering::Relaxed);
return Err(reason);
}
Ok(())
}
fn reserve_refmap(byte_len: u64) -> Result<(), &'static str> {
#[cfg(test)]
if let Some(result) = reserve_refmap_local(byte_len) {
return result;
}
reserve_atomic_bytes(&REFMAP_BYTES_USED, REFMAP_BYTE_BUDGET, byte_len)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum ArtifactOutcome {
Captured(String),
Skipped(String),
}
fn artifacts_enabled(context: &CommandContext) -> bool {
context.trace_enabled() && context.artifacts_full()
}
fn session_trace_dir(context: &CommandContext) -> Option<PathBuf> {
let session_id = context.session_id()?;
let store = RefStore::for_session(Some(session_id)).ok()?;
Some(store.trace_dir())
}
fn screens_dir(trace_dir: &Path) -> PathBuf {
trace_dir.join("screens")
}
fn refmaps_dir(trace_dir: &Path) -> PathBuf {
trace_dir.join("refmaps")
}
fn relative_to_trace(trace_dir: &Path, path: &Path) -> String {
path.strip_prefix(trace_dir)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| path.to_string_lossy().into_owned())
}
pub(crate) fn capture_action_screenshot(
context: &CommandContext,
adapter: &dyn PlatformAdapter,
pid: i32,
phase: &str,
) -> ArtifactOutcome {
if !artifacts_enabled(context) {
return ArtifactOutcome::Skipped("disabled".into());
}
let Some(trace_dir) = session_trace_dir(context) else {
return ArtifactOutcome::Skipped("no_session".into());
};
let screens = screens_dir(&trace_dir);
if let Err(err) = ensure_trace_dir(&screens) {
return ArtifactOutcome::Skipped(format!("dir: {err}"));
}
let buf = match adapter.screenshot(ScreenshotTarget::Window(pid)) {
Ok(buf) => buf,
Err(err) => {
return ArtifactOutcome::Skipped(format!("adapter: {}", err.code.as_str()));
}
};
let byte_len = buf.data.len() as u64;
if let Err(reason) = reserve_screenshot(byte_len) {
return ArtifactOutcome::Skipped(reason.into());
}
let seq = CAPTURE_SEQ.fetch_add(1, Ordering::Relaxed);
let filename = format!("{}-{}-{}-{}.png", pid, process_start_ms(), seq, phase);
let path = screens.join(&filename);
if write_private_file(&path, &buf.data).is_err() {
return ArtifactOutcome::Skipped("write_failed".into());
}
ArtifactOutcome::Captured(relative_to_trace(&trace_dir, &path))
}
pub(crate) fn copy_refmap_if_full(
context: &CommandContext,
store: &RefStore,
snapshot_id: &str,
refmap: &RefMap,
) -> Result<(), AppError> {
if !artifacts_enabled(context) {
return Ok(());
}
let trace_dir = store.trace_dir();
let refmaps = refmaps_dir(&trace_dir);
if let Err(err) = ensure_trace_dir(&refmaps) {
tracing::warn!("refmap artifact dir unavailable: {err}");
return Ok(());
}
let dest = refmaps.join(format!("{snapshot_id}.json"));
if dest.is_file() {
return Ok(());
}
let json = match refmap.serialize_with_size_check() {
Ok(json) => json,
Err(err) => {
tracing::warn!("refmap artifact serialize failed: {err}");
return Ok(());
}
};
let byte_len = json.len() as u64;
if reserve_refmap(byte_len).is_err() {
let _ = context.trace_lazy(
"action.artifacts.refmap_skipped",
|| json!({ "snapshot_id": snapshot_id }),
);
return Ok(());
}
let _ = write_private_file(&dest, json.as_bytes());
Ok(())
}
pub(crate) fn emit_action_artifacts(
context: &CommandContext,
ref_id: &str,
pre: &ArtifactOutcome,
post: &ArtifactOutcome,
) -> Result<(), AppError> {
if !artifacts_enabled(context) {
return Ok(());
}
let same_skip = match (pre, post) {
(ArtifactOutcome::Skipped(a), ArtifactOutcome::Skipped(b)) if a == b && a != "disabled" => {
Some(a.as_str())
}
_ => None,
};
if let Some(reason) = same_skip {
return context.trace(
"action.artifacts",
json!({ "ref": ref_id, "skipped": reason }),
);
}
let mut fields = json!({ "ref": ref_id });
match pre {
ArtifactOutcome::Captured(path) => fields["screenshot_pre"] = json!(path),
ArtifactOutcome::Skipped(reason) if reason != "disabled" => {
fields["skipped_pre"] = json!(reason);
}
_ => {}
}
match post {
ArtifactOutcome::Captured(path) => fields["screenshot_post"] = json!(path),
ArtifactOutcome::Skipped(reason) if reason != "disabled" => {
fields["skipped_post"] = json!(reason);
}
_ => {}
}
context.trace("action.artifacts", fields)
}
pub(crate) fn resolve_screenshot_path(trace_dir: &Path, relative: &str) -> Option<PathBuf> {
if relative.is_empty() || relative.starts_with('/') || relative.contains('\\') {
return None;
}
let path = PathBuf::from(relative);
for component in path.components() {
if matches!(
component,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
) {
return None;
}
}
let joined = trace_dir.join(&path);
let canonical = joined.canonicalize().ok()?;
let trace_canonical = trace_dir.canonicalize().ok()?;
if !canonical.starts_with(&trace_canonical) {
return None;
}
if is_symlink(&joined) {
return None;
}
Some(joined)
}
pub(crate) fn read_screenshot_for_embed(trace_dir: &Path, relative: &str) -> Option<Vec<u8>> {
let path = resolve_screenshot_path(trace_dir, relative)?;
let mut file = open_nofollow(&path).ok()?;
let mut bytes = Vec::new();
file.read_to_end(&mut bytes).ok()?;
Some(bytes)
}
#[cfg(test)]
#[path = "trace_artifacts_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "trace_artifacts_more_tests.rs"]
mod more_tests;

View file

@ -0,0 +1,240 @@
use super::tests::{artifacts_session, entry, png_adapter, run_ref_action, setup_artifacts_test};
use super::*;
use crate::context::CommandContext;
use crate::refs::RefMap;
use crate::refs_store::RefStore;
use crate::trace_artifacts::clear_test_budgets;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, AtomicU64, Ordering};
#[test]
fn artifacts_full_captures_pre_and_post_pngs() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
run_ref_action(&context, &png_adapter(), 42).unwrap();
let trace_dir = RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
let screens: Vec<_> = std::fs::read_dir(trace_dir.join("screens"))
.unwrap()
.map(|e| e.unwrap().path())
.collect();
assert_eq!(screens.len(), 2);
for path in &screens {
let bytes = std::fs::read(path).unwrap();
assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(
std::fs::metadata(path).unwrap().permissions().mode() & 0o777,
0o600
);
}
}
let segments: Vec<_> = std::fs::read_dir(&trace_dir)
.unwrap()
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.extension().is_some_and(|ext| ext == "jsonl"))
.collect();
let body = std::fs::read_to_string(segments[0].clone()).unwrap();
assert!(body.contains("action.artifacts"));
assert!(body.contains("screens/"));
}
#[test]
fn byte_budget_exhaustion_skips_with_budget_reason() {
let (_home, _lock) = setup_artifacts_test();
set_test_budgets(10, 200, 64 * 1024 * 1024);
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
run_ref_action(&context, &png_adapter(), 1).unwrap();
let trace_dir = RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
assert_eq!(
std::fs::read_dir(trace_dir.join("screens"))
.map(|dir| dir.count())
.unwrap_or(0),
0
);
let body = std::fs::read_dir(&trace_dir)
.unwrap()
.find_map(|e| {
let p = e.ok()?.path();
p.extension()
.is_some_and(|ext| ext == "jsonl")
.then_some(std::fs::read_to_string(p).ok())
})
.flatten()
.unwrap();
assert!(body.contains("\"skipped\":\"budget\"") || body.contains("skipped_pre"));
clear_test_budgets();
}
#[test]
fn concurrent_capture_produces_distinct_filenames() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let session_home = crate::refs::home_dir().unwrap();
let context = Arc::new(CommandContext::new(Some(manifest.id.clone()), None, false).unwrap());
let adapter = Arc::new(png_adapter());
let handles: Vec<_> = (0..4)
.map(|_| {
let context = context.clone();
let adapter = adapter.clone();
let session_home = session_home.clone();
std::thread::spawn(move || {
crate::refs::set_home_override(Some(session_home));
run_ref_action(&context, adapter.as_ref(), 7)
})
})
.collect();
for handle in handles {
handle.join().unwrap().unwrap();
}
let names: Vec<_> = std::fs::read_dir(
RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir()
.join("screens"),
)
.unwrap()
.map(|e| e.unwrap().file_name())
.collect();
assert_eq!(
names.len(),
names.iter().collect::<std::collections::HashSet<_>>().len()
);
}
#[test]
fn refmap_copy_is_idempotent_and_byte_equal() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let store = RefStore::for_session(Some(&manifest.id)).unwrap();
let mut refmap = RefMap::new();
refmap.allocate(entry(1));
let snapshot_id = store.save_new_snapshot(&refmap).unwrap();
copy_refmap_if_full(&context, &store, &snapshot_id, &refmap).unwrap();
copy_refmap_if_full(&context, &store, &snapshot_id, &refmap).unwrap();
let copied = store
.trace_dir()
.join("refmaps")
.join(format!("{snapshot_id}.json"));
let source = store
.base_dir()
.join("snapshots")
.join(&snapshot_id)
.join("refmap.json");
assert_eq!(
std::fs::read(copied).unwrap(),
std::fs::read(source).unwrap()
);
}
#[test]
fn refmap_budget_skip_then_prune_leaves_prior_copy() {
let (_home, _lock) = setup_artifacts_test();
set_test_budgets(128 * 1024 * 1024, 200, 8192);
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let store = RefStore::for_session(Some(&manifest.id)).unwrap();
let mut refmap = RefMap::new();
refmap.allocate(entry(1));
let first = store.save_new_snapshot(&refmap).unwrap();
copy_refmap_if_full(&context, &store, &first, &refmap).unwrap();
let copied = store
.trace_dir()
.join("refmaps")
.join(format!("{first}.json"));
assert!(copied.is_file());
for i in 0..600 {
let mut map = RefMap::new();
map.allocate(entry(i));
let id = store.save_new_snapshot(&map).unwrap();
copy_refmap_if_full(&context, &store, &id, &map).unwrap();
}
assert!(copied.is_file());
assert!(
!store
.base_dir()
.join("snapshots")
.join(&first)
.join("refmap.json")
.is_file()
);
clear_test_budgets();
}
#[test]
fn reserve_atomic_bytes_never_overshoots_under_concurrency() {
let used = Arc::new(AtomicU64::new(0));
let byte_len: u64 = 7;
let limit: u64 = byte_len * 10;
let mut handles = Vec::with_capacity(100);
for _ in 0..100 {
let used = used.clone();
handles.push(std::thread::spawn(move || {
reserve_atomic_bytes(&used, limit, byte_len).is_ok()
}));
}
let successes = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|ok| *ok)
.count() as u64;
assert!(used.load(Ordering::Relaxed) <= limit);
assert!(successes * byte_len <= limit);
assert_eq!(successes, 10);
}
#[test]
fn reserve_atomic_count_never_overshoots_under_concurrency() {
let used = Arc::new(AtomicU32::new(0));
let limit: u32 = 10;
let mut handles = Vec::with_capacity(100);
for _ in 0..100 {
let used = used.clone();
handles.push(std::thread::spawn(move || {
reserve_atomic_count(&used, limit).is_ok()
}));
}
let successes = handles
.into_iter()
.map(|handle| handle.join().unwrap())
.filter(|ok| *ok)
.count() as u32;
assert!(used.load(Ordering::Relaxed) <= limit);
assert_eq!(successes, limit);
}
#[test]
fn copy_refmap_internal_failure_is_best_effort() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let store = RefStore::for_session(Some(&manifest.id)).unwrap();
let mut refmap = RefMap::new();
refmap.allocate(entry(1));
let snapshot_id = store.save_new_snapshot(&refmap).unwrap();
let trace_dir = store.trace_dir();
std::fs::create_dir_all(&trace_dir).unwrap();
let refmaps_path = trace_dir.join("refmaps");
std::fs::write(&refmaps_path, b"blocking file, not a directory").unwrap();
let result = copy_refmap_if_full(&context, &store, &snapshot_id, &refmap);
assert!(
result.is_ok(),
"artifact capture must never fail the primary command: {result:?}"
);
assert!(
refmaps_path.is_file(),
"blocking file should be left untouched by the best-effort skip"
);
}

View file

@ -0,0 +1,305 @@
use super::*;
use crate::action::Action;
use crate::action_request::ActionRequest;
use crate::action_result::ActionResult;
use crate::adapter::{ImageBuffer, ImageFormat, NativeHandle, PlatformAdapter, ScreenshotTarget};
use crate::context::CommandContext;
use crate::error::AdapterError;
use crate::ref_action::{ResolvedRefAction, execute_resolved};
use crate::refs_store::RefStore;
use crate::refs_test_support::HomeGuard;
use crate::session::{ArtifactsMode, SessionTraceMode, StartSessionOptions, start_session};
use crate::trace_artifacts::clear_test_budgets;
use crate::{capability, refs::RefEntry};
use std::sync::Mutex;
use std::sync::atomic::{AtomicU32, Ordering};
const MINI_PNG: &[u8] = &[
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52,
0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x06, 0x00, 0x00, 0x00, 0x1f, 0x15, 0xc4,
0x89, 0x00, 0x00, 0x00, 0x0a, 0x49, 0x44, 0x41, 0x54, 0x78, 0x9c, 0x63, 0x00, 0x01, 0x00, 0x00,
0x05, 0x00, 0x01, 0x0d, 0x0a, 0x2d, 0xb4, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae,
0x42, 0x60, 0x82,
];
pub(super) fn entry(pid: i32) -> RefEntry {
RefEntry {
pid,
role: "button".into(),
name: Some("Run".into()),
value: None,
description: None,
states: vec![],
bounds: None,
bounds_hash: None,
available_actions: vec![capability::CLICK.into()],
source_app: None,
source_window_id: None,
source_window_title: None,
source_surface: crate::adapter::SnapshotSurface::Window,
root_ref: None,
path_is_absolute: false,
path: smallvec::SmallVec::new(),
}
}
pub(super) fn artifacts_session() -> crate::session::SessionManifest {
start_session(StartSessionOptions {
trace: SessionTraceMode::On,
artifacts: ArtifactsMode::Full,
..Default::default()
})
.unwrap()
}
pub(super) struct PngAdapter {
target: Mutex<Option<ScreenshotTarget>>,
}
impl PlatformAdapter for PngAdapter {
fn resolve_element_strict(&self, _entry: &RefEntry) -> Result<NativeHandle, AdapterError> {
Ok(NativeHandle::null())
}
fn execute_action(
&self,
_handle: &NativeHandle,
_request: ActionRequest,
) -> Result<ActionResult, AdapterError> {
Ok(ActionResult::new("ok"))
}
fn screenshot(&self, target: ScreenshotTarget) -> Result<ImageBuffer, AdapterError> {
*self.target.lock().unwrap() = Some(target);
Ok(ImageBuffer {
data: MINI_PNG.to_vec(),
format: ImageFormat::Png,
width: 1,
height: 1,
})
}
}
pub(super) fn png_adapter() -> PngAdapter {
PngAdapter {
target: Mutex::new(None),
}
}
struct FailingScreenshotAdapter;
impl PlatformAdapter for FailingScreenshotAdapter {
fn resolve_element_strict(&self, _entry: &RefEntry) -> Result<NativeHandle, AdapterError> {
Ok(NativeHandle::null())
}
fn execute_action(
&self,
_handle: &NativeHandle,
_request: ActionRequest,
) -> Result<ActionResult, AdapterError> {
Ok(ActionResult::new("ok"))
}
fn screenshot(&self, _target: ScreenshotTarget) -> Result<ImageBuffer, AdapterError> {
Err(AdapterError::not_supported("screenshot"))
}
}
struct FailingActionAdapter {
screenshot_calls: AtomicU32,
}
impl PlatformAdapter for FailingActionAdapter {
fn resolve_element_strict(&self, _entry: &RefEntry) -> Result<NativeHandle, AdapterError> {
Ok(NativeHandle::null())
}
fn execute_action(
&self,
_handle: &NativeHandle,
_request: ActionRequest,
) -> Result<ActionResult, AdapterError> {
Err(AdapterError::internal("boom"))
}
fn screenshot(&self, _target: ScreenshotTarget) -> Result<ImageBuffer, AdapterError> {
self.screenshot_calls.fetch_add(1, Ordering::SeqCst);
Ok(ImageBuffer {
data: MINI_PNG.to_vec(),
format: ImageFormat::Png,
width: 1,
height: 1,
})
}
}
struct DefaultScreenshotAdapter;
impl PlatformAdapter for DefaultScreenshotAdapter {
fn resolve_element_strict(&self, _entry: &RefEntry) -> Result<NativeHandle, AdapterError> {
Ok(NativeHandle::null())
}
fn execute_action(
&self,
_handle: &NativeHandle,
_request: ActionRequest,
) -> Result<ActionResult, AdapterError> {
Ok(ActionResult::new("ok"))
}
}
pub(super) fn run_ref_action(
context: &CommandContext,
adapter: &dyn PlatformAdapter,
pid: i32,
) -> Result<ActionResult, crate::error::AppError> {
let entry = entry(pid);
execute_resolved(
ResolvedRefAction {
adapter,
entry: &entry,
handle: &NativeHandle::null(),
ref_id: "@e1",
context,
},
ActionRequest::headless(Action::Click),
)
}
static ARTIFACT_TEST_LOCK: Mutex<()> = Mutex::new(());
pub(super) fn setup_artifacts_test() -> (HomeGuard, std::sync::MutexGuard<'static, ()>) {
clear_test_budgets();
(
HomeGuard::new(),
ARTIFACT_TEST_LOCK.lock().unwrap_or_else(|p| p.into_inner()),
)
}
#[test]
fn events_mode_produces_no_artifact_files() {
let (_home, _lock) = setup_artifacts_test();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
run_ref_action(&context, &png_adapter(), 1).unwrap();
let trace_dir = RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
assert!(!trace_dir.join("screens").exists());
}
#[test]
fn trace_off_with_artifacts_full_captures_nothing() {
let (_home, _lock) = setup_artifacts_test();
let path = std::env::temp_dir().join(format!(
"agent-desktop-artifacts-off-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let context = CommandContext::new(None, Some(path.clone()), true).unwrap();
run_ref_action(&context, &png_adapter(), 1).unwrap();
assert!(!path.parent().unwrap().join("screens").exists());
let _ = std::fs::remove_file(path);
}
#[test]
fn adapter_screenshot_error_still_succeeds_with_skip_reason() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
run_ref_action(&context, &FailingScreenshotAdapter, 1).unwrap();
let trace_dir = RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
let segment = std::fs::read_dir(&trace_dir)
.unwrap()
.find_map(|e| {
let p = e.ok()?.path();
p.extension().is_some_and(|ext| ext == "jsonl").then_some(p)
})
.unwrap();
let body = std::fs::read_to_string(segment).unwrap();
assert!(body.contains("adapter:"));
clear_test_budgets();
}
#[test]
fn count_budget_exhaustion_skips_with_count_budget_reason() {
let (_home, _lock) = setup_artifacts_test();
set_test_budgets(128 * 1024 * 1024, 1, 64 * 1024 * 1024);
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let adapter = png_adapter();
run_ref_action(&context, &adapter, 1).unwrap();
run_ref_action(&context, &adapter, 1).unwrap();
let trace_dir = RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
let count = std::fs::read_dir(trace_dir.join("screens"))
.map(|d| d.count())
.unwrap_or(0);
assert_eq!(count, 1);
clear_test_budgets();
}
#[cfg(unix)]
#[test]
fn symlinked_screens_dir_refuses_capture() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let trace_dir = RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
std::fs::create_dir_all(&trace_dir).unwrap();
let outside = trace_dir.join("outside-screens");
std::fs::create_dir_all(&outside).unwrap();
std::os::unix::fs::symlink(&outside, trace_dir.join("screens")).unwrap();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
run_ref_action(&context, &png_adapter(), 1).unwrap();
assert_eq!(
std::fs::read_dir(outside).map(|d| d.count()).unwrap_or(0),
0
);
}
#[test]
fn default_adapter_screenshot_skips_cleanly() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
run_ref_action(&context, &DefaultScreenshotAdapter, 1).unwrap();
}
#[test]
fn failing_action_still_captures_post_screenshot() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let adapter = FailingActionAdapter {
screenshot_calls: AtomicU32::new(0),
};
let err = run_ref_action(&context, &adapter, 1).unwrap_err();
assert_eq!(err.code(), "INTERNAL");
assert_eq!(adapter.screenshot_calls.load(Ordering::SeqCst), 2);
}
#[test]
fn capture_targets_window_for_pid() {
let (_home, _lock) = setup_artifacts_test();
let manifest = artifacts_session();
let context = CommandContext::new(Some(manifest.id.clone()), None, false).unwrap();
let adapter = png_adapter();
run_ref_action(&context, &adapter, 99).unwrap();
match adapter.target.lock().unwrap().take() {
Some(ScreenshotTarget::Window(pid)) => assert_eq!(pid, 99),
_ => panic!("expected Window screenshot target"),
}
}

View file

@ -0,0 +1,267 @@
use super::{ReadOptions, read_merged};
use crate::error::AppError;
use crate::trace_artifacts::read_screenshot_for_embed;
use base64::Engine;
use serde_json::{Value, json};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicU64, Ordering};
const VIEWER_HTML: &str = include_str!("viewer.html");
const VIEWER_CSS: &str = include_str!("viewer.css");
const VIEWER_JS: &str = include_str!("viewer.js");
pub const TRACE_EXPORT_DEFAULT_LIMIT: usize = 5000;
const MAX_EMBED_SCREENSHOT_BYTES: u64 = 100 * 1024 * 1024;
const MAX_JSON_BYTES: u64 = 200 * 1024 * 1024;
static EXPORT_TMP_COUNTER: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
static TEST_MAX_JSON_BYTES: AtomicU64 = AtomicU64::new(0);
#[cfg(test)]
pub(crate) fn set_test_max_json_bytes(limit: u64) {
TEST_MAX_JSON_BYTES.store(limit, Ordering::Relaxed);
}
#[cfg(test)]
pub(crate) fn clear_test_max_json_bytes() {
TEST_MAX_JSON_BYTES.store(0, Ordering::Relaxed);
}
fn max_json_bytes() -> u64 {
#[cfg(test)]
{
let limit = TEST_MAX_JSON_BYTES.load(Ordering::Relaxed);
if limit > 0 {
return limit;
}
}
MAX_JSON_BYTES
}
#[derive(Debug, Clone)]
pub struct ExportOptions {
pub limit: usize,
pub out: Option<PathBuf>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct ExportStats {
pub path: String,
pub event_count: usize,
pub screenshots_embedded: usize,
pub screenshots_skipped: usize,
pub bytes: usize,
pub warnings: Vec<Value>,
pub truncated: bool,
pub total_events: usize,
pub returned_events: usize,
}
pub fn export_html(
trace_dir: &Path,
session_id: &str,
options: &ExportOptions,
) -> Result<(String, ExportStats), AppError> {
let merged = read_merged(
trace_dir,
&ReadOptions {
limit: options.limit,
event_prefix: None,
},
)?;
if merged.segments.is_empty() {
return Err(empty_trace_dir_error(session_id));
}
let (screenshots, embedded, skipped) = embed_screenshots(trace_dir, &merged.events)?;
let island = json!({
"session_id": session_id,
"total_events": merged.total_events,
"returned_events": merged.returned_events,
"truncated": merged.truncated,
"warnings": merged.warnings,
"screenshots": screenshots,
"screenshots_embedded": embedded,
"screenshots_skipped": skipped,
"events": merged.events,
});
let island_json = serde_json::to_string(&island)?;
if island_json.len() as u64 > max_json_bytes() {
return Err(AppError::invalid_input_with_suggestion(
"Trace export exceeds the maximum embedded JSON size",
"Re-run with a smaller --limit to embed fewer events.",
));
}
let escaped = escape_for_json_island(&island_json);
let html = VIEWER_HTML
.replace("{{CSS}}", VIEWER_CSS)
.replace("{{JS}}", VIEWER_JS)
.replace("{{DATA}}", &escaped);
if html.len() as u64 > max_json_bytes() + (512 * 1024) {
return Err(AppError::invalid_input_with_suggestion(
"Trace export exceeds the maximum output size",
"Re-run with a smaller --limit to embed fewer events.",
));
}
let path = options.out.clone().unwrap_or_else(|| {
trace_dir
.parent()
.unwrap_or(trace_dir)
.join(format!("trace-{session_id}.html"))
});
write_export_file(&path, html.as_bytes())?;
let bytes = html.len();
let warnings: Vec<Value> = merged
.warnings
.iter()
.map(serde_json::to_value)
.collect::<Result<_, _>>()?;
Ok((
html,
ExportStats {
path: path.to_string_lossy().into_owned(),
event_count: merged.returned_events,
screenshots_embedded: embedded,
screenshots_skipped: skipped,
bytes,
warnings,
truncated: merged.truncated,
total_events: merged.total_events,
returned_events: merged.returned_events,
},
))
}
fn empty_trace_dir_error(session_id: &str) -> AppError {
AppError::invalid_input_with_suggestion(
format!("Session '{session_id}' has an empty trace directory"),
"Run `session start` with tracing enabled before recording commands.",
)
}
fn embed_screenshots(
trace_dir: &Path,
events: &[Value],
) -> Result<(Value, usize, usize), AppError> {
let mut paths: Vec<String> = Vec::new();
let mut seen: HashSet<String> = HashSet::new();
for event in events {
if event.get("event").and_then(Value::as_str) != Some("action.artifacts") {
continue;
}
for key in ["screenshot_pre", "screenshot_post"] {
let Some(path) = event.get(key).and_then(Value::as_str) else {
continue;
};
if seen.contains(path) {
continue;
}
seen.insert(path.to_string());
paths.push(path.to_string());
}
}
let mut map = serde_json::Map::new();
let mut embedded = 0usize;
let mut skipped = 0usize;
let mut used_bytes = 0u64;
for rel in paths {
let Some(bytes) = read_screenshot_for_embed(trace_dir, &rel) else {
skipped += 1;
continue;
};
let next = used_bytes.saturating_add(bytes.len() as u64);
if next > MAX_EMBED_SCREENSHOT_BYTES {
skipped += 1;
continue;
}
used_bytes = next;
let encoded = base64::engine::general_purpose::STANDARD.encode(bytes);
map.insert(rel, json!(format!("data:image/png;base64,{encoded}")));
embedded += 1;
}
Ok((Value::Object(map), embedded, skipped))
}
fn escape_for_json_island(json: &str) -> String {
let mut out = String::with_capacity(json.len());
for ch in json.chars() {
match ch {
'<' => out.push_str("\\u003c"),
'>' => out.push_str("\\u003e"),
'&' => out.push_str("\\u0026"),
'\u{2028}' => out.push_str("\\u2028"),
'\u{2029}' => out.push_str("\\u2029"),
other => out.push(other),
}
}
out
}
fn write_export_file(path: &Path, bytes: &[u8]) -> Result<(), AppError> {
if path.is_symlink() {
return Err(AppError::invalid_input_with_suggestion(
"Refusing to write trace export through a symlink",
"Choose a different --out path.",
));
}
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
let unique = EXPORT_TMP_COUNTER.fetch_add(1, Ordering::Relaxed);
let tmp = path.with_extension(format!("{}.{unique}.tmp", std::process::id()));
let result = write_export_tmp_then_rename(&tmp, path, bytes);
if result.is_err() {
let _ = std::fs::remove_file(&tmp);
}
result
}
fn write_export_tmp_then_rename(tmp: &Path, path: &Path, bytes: &[u8]) -> Result<(), AppError> {
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let mut file = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW)
.open(tmp)?;
file.write_all(bytes)?;
file.flush()?;
}
#[cfg(not(unix))]
std::fs::write(tmp, bytes)?;
std::fs::rename(tmp, path)?;
Ok(())
}
#[cfg(test)]
#[path = "html_tests.rs"]
mod tests;
#[cfg(test)]
#[path = "html_screenshot_tests.rs"]
mod screenshot_tests;
#[cfg(test)]
#[path = "html_export_stats_tests.rs"]
mod export_stats_tests;

View file

@ -0,0 +1,97 @@
use crate::refs_test_support::HomeGuard;
use crate::session::{SessionTraceMode, StartSessionOptions, start_session};
use crate::trace_read::{ExportOptions, export_html};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static HTML_EXPORT_STATS_TEST_LOCK: Mutex<()> = Mutex::new(());
fn write_segment(trace_dir: &Path, name: &str, lines: &[&str]) {
fs::create_dir_all(trace_dir).unwrap();
let mut file = fs::File::create(trace_dir.join(name)).unwrap();
for line in lines {
writeln!(file, "{line}").unwrap();
}
}
fn setup_trace_session() -> (
HomeGuard,
std::sync::MutexGuard<'static, ()>,
String,
PathBuf,
) {
let lock = HTML_EXPORT_STATS_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
super::clear_test_max_json_bytes();
let home = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
(home, lock, manifest.id, trace_dir)
}
#[cfg(unix)]
#[test]
fn export_file_is_owner_only() {
use std::os::unix::fs::PermissionsExt;
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"command.start","command":"snapshot","ts_ms":1,"seq":1}"#],
);
let out = crate::refs::home_dir()
.unwrap()
.join("trace-export-perm.html");
let (_html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: Some(out),
},
)
.unwrap();
let mode = fs::metadata(&stats.path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600);
}
#[test]
fn export_stats_reports_warnings_and_truncation() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[
r#"{"event":"a","ts_ms":1,"seq":1}"#,
r#"{"event":"b","ts_ms":2,"seq":2}"#,
r#"{"event":"c","ts_ms":3,"seq":3}"#,
],
);
fs::write(trace_dir.join("notes.txt"), b"not a segment").unwrap();
let (_html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 2,
out: None,
},
)
.unwrap();
assert_eq!(stats.total_events, 3);
assert_eq!(stats.returned_events, 2);
assert!(stats.truncated);
assert!(
stats.warnings.iter().any(|w| w["kind"] == "foreign_file"),
"expected foreign_file warning, got {:?}",
stats.warnings
);
}

View file

@ -0,0 +1,145 @@
use crate::refs_test_support::HomeGuard;
use crate::session::{SessionTraceMode, StartSessionOptions, start_session};
use crate::trace_read::{ExportOptions, export_html};
use serde_json::Value;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static HTML_SCREENSHOT_TEST_LOCK: Mutex<()> = Mutex::new(());
fn write_segment(trace_dir: &Path, name: &str, lines: &[&str]) {
fs::create_dir_all(trace_dir).unwrap();
let mut file = fs::File::create(trace_dir.join(name)).unwrap();
for line in lines {
writeln!(file, "{line}").unwrap();
}
}
fn setup_trace_session() -> (
HomeGuard,
std::sync::MutexGuard<'static, ()>,
String,
PathBuf,
) {
let lock = HTML_SCREENSHOT_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner());
super::clear_test_max_json_bytes();
let home = HomeGuard::new();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
(home, lock, manifest.id, trace_dir)
}
fn parse_trace_data_island(html: &str) -> Value {
let marker = "<script id=\"trace-data\" type=\"application/json\">";
let start = html.find(marker).unwrap() + marker.len();
let end = start + html[start..].find("</script>").unwrap();
serde_json::from_str(&html[start..end]).unwrap()
}
#[test]
fn traversal_screenshot_path_is_not_embedded() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
let marker = "SENTINEL-TRAVERSAL-4f2c9a";
let sentinel = trace_dir.parent().unwrap().join("secret.png");
fs::write(&sentinel, marker).unwrap();
assert_eq!(fs::read_to_string(&sentinel).unwrap(), marker);
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"action.artifacts","screenshot_pre":"../secret.png","ts_ms":1,"seq":1}"#],
);
let (html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.screenshots_skipped, 1);
assert_eq!(stats.screenshots_embedded, 0);
let island = parse_trace_data_island(&html);
assert!(island["screenshots"].as_object().unwrap().is_empty());
assert!(!html.contains(marker));
}
#[test]
fn absolute_screenshot_path_is_not_embedded() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
let marker = "SENTINEL-ABSOLUTE-8b1d3e";
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos();
let sentinel = std::env::temp_dir().join(format!("agent-desktop-abs-secret-{nanos}.png"));
fs::write(&sentinel, marker).unwrap();
assert_eq!(fs::read_to_string(&sentinel).unwrap(), marker);
write_segment(
&trace_dir,
"100-1000.jsonl",
&[&format!(
r#"{{"event":"action.artifacts","screenshot_pre":"{}","ts_ms":1,"seq":1}}"#,
sentinel.display()
)],
);
let (html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.screenshots_skipped, 1);
assert_eq!(stats.screenshots_embedded, 0);
let island = parse_trace_data_island(&html);
assert!(island["screenshots"].as_object().unwrap().is_empty());
assert!(!html.contains(marker));
let _ = fs::remove_file(&sentinel);
}
#[cfg(unix)]
#[test]
fn symlinked_screenshot_path_is_not_embedded() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
let marker = "SENTINEL-SYMLINK-71ea9c";
let target = trace_dir.parent().unwrap().join("symlink-target.png");
fs::write(&target, marker).unwrap();
assert_eq!(fs::read_to_string(&target).unwrap(), marker);
fs::create_dir_all(trace_dir.join("screens")).unwrap();
std::os::unix::fs::symlink(&target, trace_dir.join("screens/link.png")).unwrap();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"action.artifacts","screenshot_pre":"screens/link.png","ts_ms":1,"seq":1}"#],
);
let (html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.screenshots_skipped, 1);
assert_eq!(stats.screenshots_embedded, 0);
let island = parse_trace_data_island(&html);
assert!(island["screenshots"].as_object().unwrap().is_empty());
assert!(!html.contains(marker));
}

View file

@ -0,0 +1,356 @@
use super::escape_for_json_island;
use crate::refs_test_support::HomeGuard;
use crate::session::{SessionTraceMode, StartSessionOptions, start_session};
use crate::trace_read::{ExportOptions, export_html};
use serde_json::Value;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
static HTML_TEST_LOCK: Mutex<()> = Mutex::new(());
fn html_test_guard() -> std::sync::MutexGuard<'static, ()> {
HTML_TEST_LOCK
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
fn write_segment(trace_dir: &Path, name: &str, lines: &[&str]) {
fs::create_dir_all(trace_dir).unwrap();
let mut file = fs::File::create(trace_dir.join(name)).unwrap();
for line in lines {
writeln!(file, "{line}").unwrap();
}
}
fn setup_html_test() -> (HomeGuard, std::sync::MutexGuard<'static, ()>) {
let lock = html_test_guard();
super::clear_test_max_json_bytes();
(HomeGuard::new(), lock)
}
fn setup_trace_session() -> (
HomeGuard,
std::sync::MutexGuard<'static, ()>,
String,
PathBuf,
) {
let (home, lock) = setup_html_test();
let manifest = start_session(StartSessionOptions {
trace: SessionTraceMode::On,
..Default::default()
})
.unwrap();
let trace_dir = crate::refs_store::RefStore::for_session(Some(&manifest.id))
.unwrap()
.trace_dir();
(home, lock, manifest.id, trace_dir)
}
fn parse_trace_data_island(html: &str) -> Value {
let marker = "<script id=\"trace-data\" type=\"application/json\">";
let start = html.find(marker).unwrap() + marker.len();
let end = start + html[start..].find("</script>").unwrap();
serde_json::from_str(&html[start..end]).unwrap()
}
#[test]
fn export_writes_single_self_contained_file() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"command.start","command":"snapshot","ts_ms":1,"seq":1}"#],
);
let out = std::env::temp_dir().join(format!(
"trace-export-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let (_html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: Some(out.clone()),
},
)
.unwrap();
assert_eq!(stats.event_count, 1);
assert!(stats.bytes > 0);
let body = fs::read_to_string(&out).unwrap();
assert!(!body.contains("src=\"http"));
assert!(!body.contains("<link href"));
let _ = fs::remove_file(out);
}
#[test]
fn hostile_strings_are_escaped_in_json_island() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
let hostile = "<script>alert(1)</script><img src=x onerror=alert(2)>";
write_segment(
&trace_dir,
"100-1000.jsonl",
&[&format!(
r#"{{"event":"command.end","command":"snapshot","ok":false,"message":"{hostile}","ts_ms":1,"seq":1}}"#
)],
);
let (html, _) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert!(!html.contains("<script>alert"));
assert!(html.contains("\\u003cscript\\u003e") || html.contains("\\u003c"));
}
#[test]
fn json_island_round_trips() {
let raw = r#"{"event":"test","value":"<tag>&"}"#;
let escaped = escape_for_json_island(raw);
let start = escaped.find('{').unwrap();
let end = escaped.rfind('}').unwrap();
let parsed: serde_json::Value = serde_json::from_str(&escaped[start..=end]).unwrap();
assert_eq!(parsed["value"], "<tag>&");
}
#[test]
fn missing_screenshot_counts_as_skipped_not_error() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[
r#"{"event":"action.artifacts","screenshot_pre":"screens/missing.png","ts_ms":1,"seq":1}"#,
],
);
let (_html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.screenshots_skipped, 1);
}
#[test]
fn export_is_byte_deterministic() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"snapshot.saved","snapshot_id":"s1","ts_ms":1,"seq":1}"#],
);
let options = ExportOptions {
limit: 0,
out: None,
};
let (a, _) = export_html(&trace_dir, &session_id, &options).unwrap();
let (b, _) = export_html(&trace_dir, &session_id, &options).unwrap();
assert_eq!(a, b);
}
#[test]
fn default_output_path_uses_session_id() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"command.start","command":"x","ts_ms":1,"seq":1}"#],
);
let (_html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert!(stats.path.ends_with(&format!("trace-{session_id}.html")));
assert!(
std::path::Path::new(&stats.path).starts_with(trace_dir.parent().unwrap()),
"default export must land inside the session directory, not the cwd"
);
let _ = fs::remove_file(&stats.path);
}
#[test]
fn oversized_json_guard_returns_invalid_args() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
crate::trace_read::html::set_test_max_json_bytes(100);
let huge = "x".repeat(200);
write_segment(
&trace_dir,
"100-1000.jsonl",
&[&format!(
r#"{{"event":"note","payload":"{huge}","ts_ms":1,"seq":1}}"#
)],
);
let err = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap_err();
assert_eq!(err.code(), "INVALID_ARGS");
super::clear_test_max_json_bytes();
}
#[test]
fn embed_budget_skips_later_screenshots() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
fs::create_dir_all(trace_dir.join("screens")).unwrap();
let big = vec![0u8; 60 * 1024 * 1024];
fs::write(trace_dir.join("screens/a.png"), &big).unwrap();
fs::write(trace_dir.join("screens/b.png"), &big).unwrap();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[
r#"{"event":"action.artifacts","screenshot_pre":"screens/a.png","ts_ms":1,"seq":1}"#,
r#"{"event":"action.artifacts","screenshot_pre":"screens/b.png","ts_ms":2,"seq":2}"#,
],
);
let (_html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.screenshots_embedded, 1);
assert_eq!(stats.screenshots_skipped, 1);
}
#[test]
fn export_honors_limit_in_metadata() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[
r#"{"event":"a","ts_ms":1,"seq":1}"#,
r#"{"event":"b","ts_ms":2,"seq":2}"#,
r#"{"event":"c","ts_ms":3,"seq":3}"#,
],
);
let (html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 2,
out: None,
},
)
.unwrap();
assert_eq!(stats.event_count, 2);
assert!(html.contains("\"truncated\":true"));
}
#[test]
fn redacted_field_survives_in_island_json() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"secret","title":{"redacted":true},"ts_ms":1,"seq":1}"#],
);
let (html, _) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert!(html.contains("\"redacted\":true"));
}
#[test]
fn empty_timeline_renders_empty_state_marker() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
fs::write(trace_dir.join("100-1000.jsonl"), "").unwrap();
let (html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.event_count, 0);
let island = parse_trace_data_island(&html);
assert!(island["events"].as_array().unwrap().is_empty());
}
#[test]
fn unpaired_command_renders_open_incomplete_marker() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"command.start","command":"click","ts_ms":1,"seq":1}"#],
);
let (html, _) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
let island = parse_trace_data_island(&html);
let warnings = island["warnings"].as_array().unwrap();
assert!(
warnings
.iter()
.any(|warning| warning["kind"] == "unpaired_command"),
"expected an unpaired_command warning, got {warnings:?}"
);
}
#[test]
fn embedded_screenshot_uses_base64_data_uri() {
let (_home, _lock, session_id, trace_dir) = setup_trace_session();
fs::create_dir_all(trace_dir.join("screens")).unwrap();
let png = [
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44,
0x52,
];
fs::write(trace_dir.join("screens/shot.png"), png).unwrap();
write_segment(
&trace_dir,
"100-1000.jsonl",
&[r#"{"event":"action.artifacts","screenshot_pre":"screens/shot.png","ts_ms":1,"seq":1}"#],
);
let (html, stats) = export_html(
&trace_dir,
&session_id,
&ExportOptions {
limit: 0,
out: None,
},
)
.unwrap();
assert_eq!(stats.screenshots_embedded, 1);
assert!(html.contains("data:image/png;base64,"));
}

View file

@ -0,0 +1,140 @@
use super::segment::ParsedEvent;
use std::cmp::Reverse;
use std::collections::BinaryHeap;
#[derive(Debug, Clone)]
pub(crate) struct MergeItem {
pub event: ParsedEvent,
pub writer_pid: u32,
pub segment: String,
}
struct StreamState {
events: Vec<ParsedEvent>,
index: usize,
writer_pid: u32,
segment: String,
}
type HeapKey = (u64, u32, u64, usize);
fn stream_head_key(streams: &[StreamState], stream_idx: usize) -> Option<HeapKey> {
let stream = &streams[stream_idx];
let event = stream.events.get(stream.index)?;
Some((event.ts_ms, stream.writer_pid, event.position, stream_idx))
}
pub(crate) fn merge_segments(sources: Vec<(Vec<ParsedEvent>, u32, String)>) -> Vec<MergeItem> {
let mut streams: Vec<StreamState> = sources
.into_iter()
.map(|(events, writer_pid, segment)| StreamState {
events,
index: 0,
writer_pid,
segment,
})
.collect();
let mut heap: BinaryHeap<Reverse<HeapKey>> = (0..streams.len())
.filter_map(|stream_idx| stream_head_key(&streams, stream_idx).map(Reverse))
.collect();
let mut merged = Vec::new();
while let Some(Reverse((_, _, _, stream_idx))) = heap.pop() {
let cursor = streams[stream_idx].index;
merged.push(MergeItem {
event: streams[stream_idx].events[cursor].clone(),
writer_pid: streams[stream_idx].writer_pid,
segment: streams[stream_idx].segment.clone(),
});
streams[stream_idx].index += 1;
if let Some(key) = stream_head_key(&streams, stream_idx) {
heap.push(Reverse(key));
}
}
merged
}
use serde_json::{Map, Value, json};
pub(crate) fn annotate_provenance(item: &MergeItem) -> Value {
let mut value = item.event.value.clone();
if let Value::Object(ref mut map) = value {
map.entry("writer_pid".to_string())
.or_insert(json!(item.writer_pid));
map.entry("segment".to_string())
.or_insert(json!(item.segment));
}
value
}
pub(crate) fn filter_by_event_prefix(events: Vec<Value>, prefix: Option<&str>) -> Vec<Value> {
let Some(prefix) = prefix.filter(|p| !p.is_empty()) else {
return events;
};
events
.into_iter()
.filter(|event| {
event
.get("event")
.and_then(Value::as_str)
.is_some_and(|name| name.starts_with(prefix))
})
.collect()
}
pub(crate) fn apply_tail_limit(mut events: Vec<Value>, limit: usize) -> (Vec<Value>, bool) {
if limit == 0 || events.len() <= limit {
return (events, false);
}
let start = events.len() - limit;
(events.split_off(start), true)
}
pub(crate) fn detect_unpaired_commands(events: &[Value]) -> Vec<String> {
let mut open: Map<String, Value> = Map::new();
let mut warnings = Vec::new();
for event in events {
let Some(name) = event.get("event").and_then(Value::as_str) else {
continue;
};
let segment = event
.get("segment")
.and_then(Value::as_str)
.unwrap_or("")
.to_string();
let writer_pid = event.get("writer_pid").and_then(Value::as_u64).unwrap_or(0);
let command = event
.get("command")
.and_then(Value::as_str)
.unwrap_or("unknown");
let key = format!("{segment}:{writer_pid}:{command}");
if name == "command.start" {
open.insert(
key.clone(),
json!({ "command": command, "segment": segment, "writer_pid": writer_pid }),
);
} else if name == "command.end" {
if open.remove(&key).is_none() {
warnings.push(format!(
"Unpaired command.end for '{command}' in segment {segment}"
));
}
}
}
for (_, info) in open {
let command = info
.get("command")
.and_then(Value::as_str)
.unwrap_or("unknown");
warnings.push(format!(
"Unpaired command.start for '{command}' in segment {}",
info.get("segment").and_then(Value::as_str).unwrap_or("")
));
}
warnings
}

View file

@ -0,0 +1,133 @@
use super::merge::{
MergeItem, annotate_provenance, apply_tail_limit, detect_unpaired_commands,
filter_by_event_prefix, merge_segments,
};
use super::segment::ParsedEvent;
use serde_json::{Value, json};
fn event(ts: u64, position: u64, name: &str) -> ParsedEvent {
ParsedEvent {
value: json!({"event": name, "ts_ms": ts, "seq": position}),
ts_ms: ts,
position,
}
}
#[test]
fn two_segments_interleave_by_ts_ms() {
let a = vec![event(100, 1, "a"), event(300, 2, "a")];
let b = vec![event(200, 1, "b")];
let merged = merge_segments(vec![(a, 10, "10-1".into()), (b, 20, "20-1".into())]);
let names: Vec<_> = merged
.iter()
.map(|m| m.event.value["event"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["a", "b", "a"]);
}
#[test]
fn same_millisecond_tie_orders_by_pid_then_position() {
let a = vec![event(500, 1, "a")];
let b = vec![event(500, 1, "b")];
let merged = merge_segments(vec![(a, 10, "10-1".into()), (b, 20, "20-1".into())]);
assert_eq!(merged[0].writer_pid, 10);
assert_eq!(merged[1].writer_pid, 20);
}
#[test]
fn in_process_ts_regression_preserves_seq_order() {
let a = vec![event(1000, 5, "a5"), event(999, 6, "a6")];
let b = vec![event(999, 1, "b1")];
let merged = merge_segments(vec![(a, 10, "10-1".into()), (b, 20, "20-1".into())]);
let positions: Vec<_> = merged
.iter()
.map(|m| (m.writer_pid, m.event.position))
.collect();
assert_eq!(positions, vec![(20, 1), (10, 5), (10, 6)]);
}
#[test]
fn discovery_order_independence_via_merge() {
let a = vec![event(100, 1, "a")];
let b = vec![event(200, 1, "b")];
let forward = merge_segments(vec![
(a.clone(), 10, "10-1".into()),
(b.clone(), 20, "20-1".into()),
]);
let reverse = merge_segments(vec![(b, 20, "20-1".into()), (a, 10, "10-1".into())]);
let names_fwd: Vec<_> = forward
.iter()
.map(|m| m.event.value["event"].as_str())
.collect();
let names_rev: Vec<_> = reverse
.iter()
.map(|m| m.event.value["event"].as_str())
.collect();
assert_eq!(names_fwd, names_rev);
}
#[test]
fn provenance_does_not_clobber_existing_pid() {
let item = MergeItem {
event: ParsedEvent {
value: json!({"event":"ref.resolve.entry","pid":9999,"ts_ms":1,"seq":1}),
ts_ms: 1,
position: 1,
},
writer_pid: 100,
segment: "100-1".into(),
};
let annotated = annotate_provenance(&item);
assert_eq!(annotated["pid"], 9999);
assert_eq!(annotated["writer_pid"], 100);
assert_eq!(annotated["segment"], "100-1");
}
#[test]
fn unknown_fields_pass_through() {
let item = MergeItem {
event: ParsedEvent {
value: json!({"event":"future.event","ts_ms":1,"seq":1,"new_field":"value"}),
ts_ms: 1,
position: 1,
},
writer_pid: 1,
segment: "1-1".into(),
};
let annotated = annotate_provenance(&item);
assert_eq!(annotated["new_field"], "value");
}
#[test]
fn event_prefix_filter_with_tail_limit() {
let events: Vec<Value> = (0..10)
.map(|i| json!({"event": if i % 2 == 0 { "action.click" } else { "other" }, "ts_ms": i}))
.collect();
let filtered = filter_by_event_prefix(events, Some("action."));
assert_eq!(filtered.len(), 5);
let (tail, truncated) = apply_tail_limit(filtered, 2);
assert!(truncated);
assert_eq!(tail.len(), 2);
assert_eq!(tail[0]["ts_ms"], 6);
assert_eq!(tail[1]["ts_ms"], 8);
}
#[test]
fn unpaired_command_start_is_detected() {
let events = vec![
json!({"event":"command.start","command":"click","segment":"100-1","writer_pid":100,"ts_ms":1}),
json!({"event":"command.end","command":"click","segment":"100-1","writer_pid":100,"ts_ms":2}),
json!({"event":"command.start","command":"type","segment":"100-1","writer_pid":100,"ts_ms":3}),
];
let warnings = detect_unpaired_commands(&events);
assert_eq!(warnings.len(), 1);
assert!(warnings[0].contains("type"));
}
#[test]
fn limit_zero_returns_all() {
let events: Vec<Value> = (0..5).map(|i| json!({"event":"e","ts_ms":i})).collect();
let (all, truncated) = apply_tail_limit(events.clone(), 0);
assert!(!truncated);
assert_eq!(all.len(), 5);
}

View file

@ -0,0 +1,200 @@
mod html;
mod merge;
mod segment;
pub use html::{ExportOptions, ExportStats, TRACE_EXPORT_DEFAULT_LIMIT, export_html};
use crate::error::AppError;
use merge::{
annotate_provenance, apply_tail_limit, detect_unpaired_commands, filter_by_event_prefix,
merge_segments,
};
use segment::{SegmentReadStats, parse_segment_filename, read_segment_events};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::path::Path;
#[derive(Debug, Clone, Default)]
pub struct ReadOptions {
pub limit: usize,
pub event_prefix: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TraceWarningKind {
ForeignFile,
UnreadableSegment,
SymlinkedSegment,
SchemaUnknown,
UnpairedCommand,
}
#[derive(Debug, Clone, Serialize)]
pub struct TraceWarning {
pub kind: TraceWarningKind,
pub message: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct SegmentInfo {
pub segment: String,
pub pid: u32,
pub schema: u32,
pub event_count: usize,
#[serde(skip_serializing_if = "is_zero")]
pub skipped_lines: usize,
}
fn is_zero(v: &usize) -> bool {
*v == 0
}
#[derive(Debug, Clone)]
pub struct MergedTrace {
pub events: Vec<Value>,
pub segments: Vec<SegmentInfo>,
pub segments_truncated: bool,
pub warnings: Vec<TraceWarning>,
pub warnings_truncated: bool,
pub total_events: usize,
pub matched_events: Option<usize>,
pub returned_events: usize,
pub truncated: bool,
}
const METADATA_LIST_CAP: usize = 500;
pub fn read_merged(trace_dir: &Path, options: &ReadOptions) -> Result<MergedTrace, AppError> {
if !trace_dir.is_dir() {
return Err(AppError::invalid_input_with_suggestion(
"Trace directory does not exist",
"Run `session start` with tracing enabled, or pass `--session <id>`.",
));
}
let mut warnings = Vec::new();
let mut segment_infos = Vec::new();
let mut merge_sources = Vec::new();
for entry in std::fs::read_dir(trace_dir)? {
let entry = entry?;
let name = entry.file_name();
let Some(name) = name.to_str() else {
continue;
};
if name.ends_with(".jsonl.tmp") {
continue;
}
let Some(parsed_name) = parse_segment_filename(name) else {
if !name.starts_with('.') {
warnings.push(TraceWarning {
kind: TraceWarningKind::ForeignFile,
message: format!("Ignoring foreign file in trace directory: {name}"),
});
}
continue;
};
let path = entry.path();
if crate::refs::is_symlink(&path) {
warnings.push(TraceWarning {
kind: TraceWarningKind::SymlinkedSegment,
message: format!("Skipping symlinked segment: {name}"),
});
continue;
}
match read_segment_events(&path) {
Ok((events, stats)) => {
if let Some(ref msg) = stats.schema_warning {
warnings.push(TraceWarning {
kind: TraceWarningKind::SchemaUnknown,
message: msg.clone(),
});
}
segment_infos.push(segment_info_from_stats(&parsed_name, &stats));
merge_sources.push((events, parsed_name.pid, parsed_name.stem));
}
Err(err) => {
warnings.push(TraceWarning {
kind: TraceWarningKind::UnreadableSegment,
message: format!("Skipping unreadable segment {name}: {err}"),
});
}
}
}
segment_infos.sort_by(|a, b| a.segment.cmp(&b.segment));
merge_sources.sort_by(|a, b| a.2.cmp(&b.2));
let merged = merge_segments(merge_sources);
let all_events: Vec<Value> = merged.iter().map(annotate_provenance).collect();
let total_events = all_events.len();
for msg in detect_unpaired_commands(&all_events) {
warnings.push(TraceWarning {
kind: TraceWarningKind::UnpairedCommand,
message: msg,
});
}
let filtered_events = filter_by_event_prefix(all_events, options.event_prefix.as_deref());
let matched_events = if options.event_prefix.is_some() {
Some(filtered_events.len())
} else {
None
};
let (returned_events, truncated) = apply_tail_limit(filtered_events, options.limit);
let (segments, segments_truncated) = cap_list(segment_infos, METADATA_LIST_CAP);
let (warnings, warnings_truncated) = cap_list(warnings, METADATA_LIST_CAP);
Ok(MergedTrace {
returned_events: returned_events.len(),
events: returned_events,
segments,
segments_truncated,
warnings,
warnings_truncated,
total_events,
matched_events,
truncated,
})
}
fn cap_list<T>(mut items: Vec<T>, cap: usize) -> (Vec<T>, bool) {
if items.len() <= cap {
return (items, false);
}
items.truncate(cap);
(items, true)
}
fn segment_info_from_stats(
parsed: &segment::ParsedSegmentName,
stats: &SegmentReadStats,
) -> SegmentInfo {
SegmentInfo {
segment: parsed.stem.clone(),
pid: parsed.pid,
schema: stats.schema,
event_count: stats.event_count,
skipped_lines: stats.skipped_lines,
}
}
#[cfg(test)]
#[path = "segment_tests.rs"]
mod segment_tests;
#[cfg(test)]
#[path = "merge_tests.rs"]
mod merge_tests;
#[cfg(test)]
#[path = "mod_tests.rs"]
mod mod_tests;

View file

@ -0,0 +1,346 @@
use super::{METADATA_LIST_CAP, ReadOptions, TraceWarningKind, cap_list, read_merged};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("agent-desktop-{prefix}-{nanos}"))
}
fn write_segment(dir: &Path, name: &str, lines: &[&str]) {
fs::create_dir_all(dir).unwrap();
let path = dir.join(name);
let mut file = fs::File::create(&path).unwrap();
for line in lines {
writeln!(file, "{line}").unwrap();
}
}
#[test]
fn empty_trace_directory_yields_empty_timeline() {
let dir = temp_dir("trace-mod-empty");
fs::create_dir(&dir).unwrap();
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(result.events.is_empty());
assert!(result.warnings.is_empty());
}
#[test]
fn missing_trace_directory_returns_error() {
let dir = temp_dir("trace-mod-missing");
let result = read_merged(&dir, &ReadOptions::default());
assert!(result.is_err());
}
#[test]
fn foreign_file_produces_warning() {
let dir = temp_dir("trace-mod-foreign");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("notes.txt"), b"hello").unwrap();
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(
result
.warnings
.iter()
.any(|w| w.kind == TraceWarningKind::ForeignFile)
);
}
#[test]
fn tmp_file_is_silently_ignored() {
let dir = temp_dir("trace-mod-tmp");
fs::create_dir(&dir).unwrap();
fs::write(dir.join("100-1.jsonl.tmp"), b"data").unwrap();
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(
!result
.warnings
.iter()
.any(|w| w.kind == TraceWarningKind::ForeignFile)
);
}
#[test]
fn two_segments_merge_with_provenance() {
let dir = temp_dir("trace-mod-merge");
write_segment(
&dir,
"100-1000.jsonl",
&[r#"{"event":"a","ts_ms":100,"seq":1,"pid":555}"#],
);
write_segment(
&dir,
"200-2000.jsonl",
&[r#"{"event":"b","ts_ms":200,"seq":1}"#],
);
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert_eq!(result.events.len(), 2);
assert_eq!(result.events[0]["writer_pid"], 100);
assert_eq!(result.events[0]["pid"], 555);
assert_eq!(result.events[1]["writer_pid"], 200);
assert_eq!(result.segments.len(), 2);
}
#[test]
fn schema_unknown_warning_for_future_schema() {
let dir = temp_dir("trace-mod-schema");
write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"trace.meta","schema":2,"pid":100,"ts_ms":0,"seq":0}"#,
r#"{"event":"future","ts_ms":1,"seq":1,"extra":true}"#,
],
);
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(
result
.warnings
.iter()
.any(|w| w.kind == TraceWarningKind::SchemaUnknown)
);
assert_eq!(result.events[1]["extra"], true);
}
#[test]
fn tail_limit_truncates_fully_paired_commands_without_false_unpaired_warning() {
let dir = temp_dir("trace-mod-tail");
write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"command.start","command":"click","ts_ms":1,"seq":1}"#,
r#"{"event":"command.end","command":"click","ok":true,"ts_ms":2,"seq":2}"#,
r#"{"event":"command.start","command":"type","ts_ms":3,"seq":3}"#,
r#"{"event":"command.end","command":"type","ok":true,"ts_ms":4,"seq":4}"#,
],
);
let result = read_merged(
&dir,
&ReadOptions {
limit: 3,
event_prefix: None,
},
)
.unwrap();
assert!(result.truncated);
assert_eq!(result.returned_events, 3);
assert!(
!result
.warnings
.iter()
.any(|w| w.kind == TraceWarningKind::UnpairedCommand)
);
}
#[test]
fn tail_limit_boundary_split_pair_is_silent_but_genuine_orphan_still_warns() {
let dir = temp_dir("trace-mod-tail-boundary");
write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"command.start","command":"click","ts_ms":1,"seq":1}"#,
r#"{"event":"command.end","command":"click","ok":true,"ts_ms":2,"seq":2}"#,
r#"{"event":"command.start","command":"orphan","ts_ms":3,"seq":3}"#,
r#"{"event":"command.start","command":"type","ts_ms":4,"seq":4}"#,
r#"{"event":"command.end","command":"type","ok":true,"ts_ms":5,"seq":5}"#,
],
);
let result = read_merged(
&dir,
&ReadOptions {
limit: 4,
event_prefix: None,
},
)
.unwrap();
assert!(result.truncated);
assert_eq!(result.returned_events, 4);
let unpaired: Vec<&str> = result
.warnings
.iter()
.filter(|w| w.kind == TraceWarningKind::UnpairedCommand)
.map(|w| w.message.as_str())
.collect();
assert_eq!(unpaired.len(), 1);
assert!(unpaired[0].contains("orphan"));
assert!(!unpaired.iter().any(|msg| msg.contains("click")));
}
#[test]
fn total_events_stays_raw_while_matched_events_reflects_prefix_filter() {
let dir = temp_dir("trace-mod-matched");
write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"command.start","command":"click","ts_ms":1,"seq":1}"#,
r#"{"event":"command.end","command":"click","ok":true,"ts_ms":2,"seq":2}"#,
r#"{"event":"other.thing","ts_ms":3,"seq":3}"#,
],
);
let filtered = read_merged(
&dir,
&ReadOptions {
limit: 0,
event_prefix: Some("command.".into()),
},
)
.unwrap();
assert_eq!(filtered.total_events, 3);
assert_eq!(filtered.matched_events, Some(2));
assert_eq!(filtered.returned_events, 2);
let unfiltered = read_merged(&dir, &ReadOptions::default()).unwrap();
assert_eq!(unfiltered.total_events, 3);
assert_eq!(unfiltered.matched_events, None);
assert_eq!(unfiltered.returned_events, 3);
}
#[cfg(unix)]
#[test]
fn unreadable_segment_is_skipped_with_warning() {
use std::os::unix::fs::PermissionsExt;
let dir = temp_dir("trace-mod-unread");
fs::create_dir(&dir).unwrap();
let seg = dir.join("100-1000.jsonl");
fs::write(&seg, b"{\"event\":\"a\",\"ts_ms\":1,\"seq\":1}\n").unwrap();
fs::set_permissions(&seg, fs::Permissions::from_mode(0o000)).unwrap();
write_segment(
&dir,
"200-2000.jsonl",
&[r#"{"event":"b","ts_ms":2,"seq":1}"#],
);
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(
result
.warnings
.iter()
.any(|w| w.kind == TraceWarningKind::UnreadableSegment)
);
assert_eq!(result.events.len(), 1);
fs::set_permissions(&seg, fs::Permissions::from_mode(0o644)).unwrap();
}
#[cfg(unix)]
#[test]
fn symlinked_segment_is_skipped_with_warning() {
let dir = temp_dir("trace-mod-symlink");
fs::create_dir(&dir).unwrap();
let target = dir.join("100-1000.jsonl");
fs::write(&target, b"{\"event\":\"a\",\"ts_ms\":1,\"seq\":1}\n").unwrap();
let link = dir.join("200-2000.jsonl");
std::os::unix::fs::symlink(&target, &link).unwrap();
write_segment(
&dir,
"300-3000.jsonl",
&[r#"{"event":"b","ts_ms":2,"seq":1}"#],
);
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(
result
.warnings
.iter()
.any(|w| w.kind == TraceWarningKind::SymlinkedSegment)
);
assert_eq!(result.events.len(), 2);
}
#[test]
fn cap_list_below_threshold_is_unmodified() {
let (items, truncated) = cap_list(vec![1, 2, 3], 5);
assert_eq!(items, vec![1, 2, 3]);
assert!(!truncated);
}
#[test]
fn cap_list_above_threshold_truncates_and_flags() {
let (items, truncated) = cap_list(vec![1, 2, 3, 4, 5], 3);
assert_eq!(items, vec![1, 2, 3]);
assert!(truncated);
}
#[test]
fn segments_metadata_list_is_capped() {
let dir = temp_dir("trace-mod-seg-cap");
fs::create_dir(&dir).unwrap();
for pid in 0..=METADATA_LIST_CAP {
fs::File::create(dir.join(format!("{pid}-0.jsonl"))).unwrap();
}
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(result.segments_truncated);
assert_eq!(result.segments.len(), METADATA_LIST_CAP);
}
#[test]
fn warnings_list_is_capped() {
let dir = temp_dir("trace-mod-warn-cap");
fs::create_dir(&dir).unwrap();
for i in 0..=METADATA_LIST_CAP {
fs::write(dir.join(format!("junk{i}.txt")), b"").unwrap();
}
let result = read_merged(&dir, &ReadOptions::default()).unwrap();
assert!(result.warnings_truncated);
assert_eq!(result.warnings.len(), METADATA_LIST_CAP);
}
#[test]
fn read_merged_discovery_order_independent_on_genuine_tie() {
let dir_low_stem_created_first = temp_dir("trace-mod-tie-fwd");
write_segment(
&dir_low_stem_created_first,
"100-1000.jsonl",
&[r#"{"event":"from_early_stem","ts_ms":500,"seq":1}"#],
);
write_segment(
&dir_low_stem_created_first,
"100-2000.jsonl",
&[r#"{"event":"from_late_stem","ts_ms":500,"seq":1}"#],
);
let dir_high_stem_created_first = temp_dir("trace-mod-tie-rev");
write_segment(
&dir_high_stem_created_first,
"100-2000.jsonl",
&[r#"{"event":"from_late_stem","ts_ms":500,"seq":1}"#],
);
write_segment(
&dir_high_stem_created_first,
"100-1000.jsonl",
&[r#"{"event":"from_early_stem","ts_ms":500,"seq":1}"#],
);
let forward = read_merged(&dir_low_stem_created_first, &ReadOptions::default()).unwrap();
let reverse = read_merged(&dir_high_stem_created_first, &ReadOptions::default()).unwrap();
let names_forward: Vec<_> = forward
.events
.iter()
.map(|e| e["event"].as_str().unwrap().to_string())
.collect();
let names_reverse: Vec<_> = reverse
.events
.iter()
.map(|e| e["event"].as_str().unwrap().to_string())
.collect();
assert_eq!(names_forward, names_reverse);
assert_eq!(names_forward, vec!["from_early_stem", "from_late_stem"]);
}

View file

@ -0,0 +1,142 @@
use crate::error::AppError;
use serde_json::Value;
use std::io::{BufRead, BufReader};
use std::path::Path;
pub(crate) const MAX_LINE_BYTES: usize = 8 * 1024 * 1024;
pub(crate) const KNOWN_SCHEMA_MAX: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct ParsedSegmentName {
pub stem: String,
pub pid: u32,
pub proc_start_ms: u64,
}
#[derive(Debug, Clone)]
pub(crate) struct ParsedEvent {
pub value: Value,
pub ts_ms: u64,
pub position: u64,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct SegmentReadStats {
pub event_count: usize,
pub skipped_lines: usize,
pub schema: u32,
pub schema_warning: Option<String>,
pub meta_seen: bool,
}
pub(crate) fn parse_segment_filename(name: &str) -> Option<ParsedSegmentName> {
if !name.ends_with(".jsonl") {
return None;
}
let stem = name.strip_suffix(".jsonl")?;
if stem.ends_with(".tmp") {
return None;
}
let (pid_str, ts_str) = stem.rsplit_once('-')?;
if pid_str.is_empty() || ts_str.is_empty() {
return None;
}
if !pid_str.chars().all(|c| c.is_ascii_digit()) {
return None;
}
if !ts_str.chars().all(|c| c.is_ascii_digit()) {
return None;
}
let pid: u32 = pid_str.parse().ok()?;
let proc_start_ms: u64 = ts_str.parse().ok()?;
Some(ParsedSegmentName {
stem: stem.to_string(),
pid,
proc_start_ms,
})
}
pub(crate) fn read_segment_events(
path: &Path,
) -> Result<(Vec<ParsedEvent>, SegmentReadStats), AppError> {
let file = crate::refs::open_nofollow(path).map_err(AppError::from)?;
let mut reader = BufReader::new(file);
let mut raw: Vec<u8> = Vec::new();
let mut stats = SegmentReadStats::default();
let mut events = Vec::new();
let mut position: u64 = 0;
loop {
raw.clear();
let bytes_read = reader.read_until(b'\n', &mut raw)?;
if bytes_read == 0 {
break;
}
position += 1;
let has_trailing_newline = raw.last() == Some(&b'\n');
let line_bytes = if has_trailing_newline {
&raw[..raw.len() - 1]
} else {
raw.as_slice()
};
if line_bytes.is_empty() {
continue;
}
if line_bytes.len() > MAX_LINE_BYTES {
stats.skipped_lines += 1;
continue;
}
if !has_trailing_newline {
stats.skipped_lines += 1;
continue;
}
let Ok(line_body) = std::str::from_utf8(line_bytes) else {
stats.skipped_lines += 1;
continue;
};
let parsed: Value = match serde_json::from_str(line_body) {
Ok(v) => v,
Err(_) => {
stats.skipped_lines += 1;
continue;
}
};
let Some(obj) = parsed.as_object() else {
stats.skipped_lines += 1;
continue;
};
if obj.get("event").and_then(Value::as_str) == Some("trace.meta") && !stats.meta_seen {
stats.meta_seen = true;
if let Some(schema) = obj.get("schema").and_then(Value::as_u64) {
stats.schema = schema as u32;
if stats.schema > KNOWN_SCHEMA_MAX {
stats.schema_warning = Some(format!(
"Segment schema {schema} exceeds reader maximum {KNOWN_SCHEMA_MAX}"
));
}
}
}
let ts_ms = obj.get("ts_ms").and_then(Value::as_u64).unwrap_or(0);
events.push(ParsedEvent {
value: parsed,
ts_ms,
position,
});
stats.event_count += 1;
}
if !stats.meta_seen {
stats.schema = 0;
}
Ok((events, stats))
}

View file

@ -0,0 +1,202 @@
use super::segment::{MAX_LINE_BYTES, parse_segment_filename, read_segment_events};
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_dir(prefix: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!("agent-desktop-{prefix}-{nanos}"))
}
fn write_segment(dir: &Path, name: &str, lines: &[&str]) -> std::path::PathBuf {
fs::create_dir_all(dir).unwrap();
let path = dir.join(name);
let mut file = fs::File::create(&path).unwrap();
for line in lines {
writeln!(file, "{line}").unwrap();
}
path
}
#[test]
fn parse_valid_segment_filename() {
let parsed = parse_segment_filename("4242-1719900000000.jsonl").unwrap();
assert_eq!(parsed.pid, 4242);
assert_eq!(parsed.proc_start_ms, 1719900000000);
assert_eq!(parsed.stem, "4242-1719900000000");
}
#[test]
fn parse_rejects_invalid_filenames() {
assert!(parse_segment_filename("abc-1.jsonl").is_none());
assert!(parse_segment_filename("1.jsonl").is_none());
assert!(parse_segment_filename("notes.txt").is_none());
assert!(parse_segment_filename("123-9.jsonl.tmp").is_none());
}
#[test]
fn truncated_final_line_is_skipped() {
let dir = temp_dir("trace-seg-trunc");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("100-1000.jsonl");
let mut file = fs::File::create(&path).unwrap();
writeln!(file, r#"{{"event":"click","ts_ms":1000,"seq":1}}"#).unwrap();
write!(file, r#"{{"event":"click","ts_ms":1001,"seq":2,"trunc"#).unwrap();
let (_, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.skipped_lines, 1);
assert_eq!(stats.event_count, 1);
}
#[test]
fn corrupt_middle_line_is_skipped() {
let dir = temp_dir("trace-seg-trunc");
let path = write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"a","ts_ms":1,"seq":1}"#,
"not json at all",
r#"{"event":"b","ts_ms":2,"seq":2}"#,
],
);
let (events, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.skipped_lines, 1);
assert_eq!(events.len(), 2);
}
#[test]
fn non_object_json_line_is_skipped() {
let dir = temp_dir("trace-seg-trunc");
let path = write_segment(
&dir,
"100-1000.jsonl",
&[
"[1,2,3]",
r#""string""#,
r#"{"event":"ok","ts_ms":1,"seq":1}"#,
],
);
let (events, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.skipped_lines, 2);
assert_eq!(events.len(), 1);
}
#[test]
fn oversized_line_is_skipped() {
let dir = temp_dir("trace-seg-trunc");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("100-1000.jsonl");
let mut file = fs::File::create(&path).unwrap();
let big = "x".repeat(MAX_LINE_BYTES + 1);
writeln!(file, "{{\"event\":\"big\",\"data\":\"{big}\"}}").unwrap();
writeln!(file, r#"{{"event":"ok","ts_ms":1,"seq":1}}"#).unwrap();
let (events, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.skipped_lines, 1);
assert_eq!(events.len(), 1);
}
#[test]
fn trace_meta_sets_schema() {
let dir = temp_dir("trace-seg-trunc");
let path = write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"trace.meta","schema":1,"pid":100,"ts_ms":0,"seq":0}"#,
r#"{"event":"click","ts_ms":1,"seq":1}"#,
],
);
let (_, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.schema, 1);
}
#[test]
fn absent_meta_reads_as_schema_zero() {
let dir = temp_dir("trace-seg-trunc");
let path = write_segment(
&dir,
"100-1000.jsonl",
&[r#"{"event":"click","ts_ms":1,"seq":1}"#],
);
let (_, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.schema, 0);
assert!(stats.schema_warning.is_none());
}
#[test]
fn schema_two_produces_warning() {
let dir = temp_dir("trace-seg-trunc");
let path = write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"trace.meta","schema":2,"pid":100,"ts_ms":0,"seq":0}"#,
r#"{"event":"click","ts_ms":1,"seq":1}"#,
],
);
let (_, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.schema, 2);
assert!(stats.schema_warning.is_some());
}
#[test]
fn multiple_meta_lines_only_first_counts_for_schema() {
let dir = temp_dir("trace-seg-trunc");
let path = write_segment(
&dir,
"100-1000.jsonl",
&[
r#"{"event":"trace.meta","schema":1,"pid":100,"ts_ms":0,"seq":0}"#,
r#"{"event":"trace.meta","schema":2,"pid":100,"ts_ms":1,"seq":1}"#,
r#"{"event":"click","ts_ms":2,"seq":2}"#,
],
);
let (events, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.schema, 1);
assert_eq!(events.len(), 3);
}
#[cfg(unix)]
#[test]
fn symlinked_segment_is_detected() {
let dir = temp_dir("trace-seg-symlink");
fs::create_dir_all(&dir).unwrap();
let target = dir.join("100-1000.jsonl");
fs::write(&target, b"{\"event\":\"a\",\"ts_ms\":1,\"seq\":1}\n").unwrap();
let link = dir.join("200-2000.jsonl");
std::os::unix::fs::symlink(&target, &link).unwrap();
assert!(crate::refs::is_symlink(&link));
}
#[test]
fn invalid_utf8_line_is_skipped_not_fatal() {
let dir = temp_dir("trace-seg-badutf8");
fs::create_dir_all(&dir).unwrap();
let path = dir.join("100-1000.jsonl");
let mut file = fs::File::create(&path).unwrap();
writeln!(file, r#"{{"event":"a","ts_ms":1,"seq":1}}"#).unwrap();
file.write_all(b"\xFF\xFE not valid utf8 at all\n").unwrap();
writeln!(file, r#"{{"event":"b","ts_ms":2,"seq":2}}"#).unwrap();
drop(file);
let (events, stats) = read_segment_events(&path).unwrap();
assert_eq!(stats.skipped_lines, 1);
assert_eq!(events.len(), 2);
let names: Vec<_> = events
.iter()
.map(|e| e.value["event"].as_str().unwrap())
.collect();
assert_eq!(names, vec!["a", "b"]);
}

View file

@ -0,0 +1,237 @@
:root {
color-scheme: light dark;
--bg: #0b0e14;
--bg-grad: radial-gradient(1200px 600px at 78% -8%, #12243180 0%, transparent 60%);
--panel: #12161f;
--panel-2: #171c27;
--raised: #1c2230;
--text: #e6e9ef;
--muted: #8b93a3;
--faint: #5b6373;
--accent: #4fd6c8;
--accent-2: #6ea8ff;
--ok: #46c17a;
--err: #ff5d6c;
--warn: #f5c451;
--border: #262d3b;
--border-soft: #1c222e;
--sel: #1f6feb26;
--sel-line: var(--accent);
--shadow: 0 10px 30px -12px #00000099;
--mono: ui-monospace, "SF Mono", "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
--sans: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
}
@media (prefers-color-scheme: light) {
:root {
--bg: #f4f6fa;
--bg-grad: radial-gradient(1100px 560px at 80% -10%, #4a6cf714 0%, transparent 55%);
--panel: #ffffff;
--panel-2: #f7f9fc;
--raised: #eef1f7;
--text: #141a24;
--muted: #5a6473;
--faint: #8b94a4;
--accent: #0e8f83;
--accent-2: #2f6bff;
--ok: #1c8a4e;
--err: #d33449;
--warn: #b7791a;
--border: #dde3ec;
--border-soft: #e7ebf2;
--sel: #2f6bff14;
--shadow: 0 12px 34px -16px #1b2a5233;
}
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
background: var(--bg-grad), var(--bg);
color: var(--text);
font-family: var(--sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
}
.signal-bar {
height: 3px;
background: linear-gradient(90deg, var(--accent) 0%, var(--accent-2) 55%, var(--warn) 100%);
opacity: 0.9;
}
header {
padding: 0.9rem 1.25rem 0.75rem;
border-bottom: 1px solid var(--border);
background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
}
.brand { display: flex; align-items: center; gap: 0.6rem; }
.mark {
width: 12px; height: 12px; border-radius: 3px;
background: conic-gradient(from 210deg, var(--accent), var(--accent-2), var(--accent));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent);
}
h1 {
margin: 0;
font-family: var(--mono);
font-size: 0.98rem;
font-weight: 650;
letter-spacing: -0.01em;
}
.brand-sub {
margin-left: 0.4rem; padding: 0.05rem 0.4rem;
font-size: 0.7rem; letter-spacing: 0.14em; text-transform: uppercase;
color: var(--accent); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--border));
border-radius: 999px; vertical-align: middle;
}
.stats { display: flex; flex-wrap: wrap; gap: 0.4rem; margin: 0.7rem 0 0; }
.pill {
display: inline-flex; align-items: baseline; gap: 0.35rem;
padding: 0.2rem 0.55rem; border-radius: 999px;
background: var(--raised); border: 1px solid var(--border-soft);
font-size: 0.76rem; color: var(--muted);
}
.pill b { color: var(--text); font-family: var(--mono); font-weight: 600; font-variant-numeric: tabular-nums; }
.pill .k { color: var(--faint); text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.66rem; }
.toolbar { display: flex; align-items: center; gap: 1rem; margin-top: 0.75rem; flex-wrap: wrap; }
.filter-label {
position: relative; display: flex; align-items: center; flex: 1 1 260px; max-width: 460px;
}
.filter-icon {
position: absolute; left: 0.6rem; width: 13px; height: 13px; border-radius: 50%;
border: 1.6px solid var(--faint); pointer-events: none;
}
.filter-icon::after {
content: ""; position: absolute; right: -3px; bottom: -3px;
width: 6px; height: 1.6px; background: var(--faint); transform: rotate(45deg);
}
#filter {
width: 100%; padding: 0.45rem 0.6rem 0.45rem 1.75rem;
border: 1px solid var(--border); border-radius: 8px;
background: var(--bg); color: var(--text);
font-family: var(--mono); font-size: 0.82rem;
transition: border-color 0.15s, box-shadow 0.15s;
}
#filter:focus {
outline: none; border-color: var(--accent);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 22%, transparent);
}
.match-count {
position: absolute; right: 0.6rem; font-size: 0.72rem; color: var(--faint);
font-family: var(--mono); font-variant-numeric: tabular-nums;
}
.legend { display: flex; align-items: center; gap: 0.85rem; font-size: 0.74rem; color: var(--muted); }
.legend-item { display: inline-flex; align-items: center; gap: 0.32rem; }
.dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
.dot.ok { background: var(--ok); }
.dot.err { background: var(--err); }
.dot.warn { background: var(--warn); }
.kbd-hint { color: var(--faint); font-family: var(--mono); letter-spacing: 0.02em; }
.banner {
padding: 0.6rem 0.8rem; margin-top: 0.75rem; border-radius: 8px;
background: color-mix(in srgb, var(--warn) 12%, var(--panel));
border: 1px solid color-mix(in srgb, var(--warn) 40%, var(--border));
color: color-mix(in srgb, var(--warn) 88%, var(--text));
white-space: pre-wrap; font-size: 0.8rem; line-height: 1.5;
}
.hidden { display: none !important; }
#layout {
display: grid;
grid-template-columns: minmax(300px, 40%) 1fr;
height: calc(100vh - 3px - var(--header-h, 150px));
min-height: 360px;
}
#timeline { overflow: auto; border-right: 1px solid var(--border); outline: none; }
#timeline:focus-visible { box-shadow: inset 3px 0 0 var(--accent-2); }
.group { border-bottom: 1px solid var(--border-soft); }
.group-header {
position: sticky; top: 0; z-index: 1;
display: flex; align-items: center; gap: 0.5rem;
padding: 0.5rem 0.8rem 0.5rem 0.7rem; cursor: default;
background: color-mix(in srgb, var(--panel) 92%, transparent);
backdrop-filter: blur(6px);
font-family: var(--mono); font-weight: 600; font-size: 0.84rem;
border-left: 3px solid var(--border);
}
.group-header.ok { border-left-color: var(--ok); }
.group-header.err { border-left-color: var(--err); }
.group-header.open-incomplete { border-left-color: var(--warn); }
.group-cmd { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.status {
display: inline-flex; align-items: center; gap: 0.28rem;
padding: 0.08rem 0.45rem; border-radius: 999px;
font-size: 0.68rem; font-weight: 600; letter-spacing: 0.02em;
border: 1px solid transparent;
}
.status.ok { color: var(--ok); background: color-mix(in srgb, var(--ok) 14%, transparent); border-color: color-mix(in srgb, var(--ok) 34%, transparent); }
.status.err { color: var(--err); background: color-mix(in srgb, var(--err) 14%, transparent); border-color: color-mix(in srgb, var(--err) 34%, transparent); }
.status.open-incomplete { color: var(--warn); background: color-mix(in srgb, var(--warn) 14%, transparent); border-color: color-mix(in srgb, var(--warn) 34%, transparent); }
.dur {
font-family: var(--mono); font-size: 0.7rem; color: var(--muted);
font-variant-numeric: tabular-nums; font-weight: 400;
}
.event-row {
display: flex; align-items: baseline; gap: 0.6rem;
padding: 0.4rem 0.8rem 0.4rem 1.35rem; cursor: pointer;
border-top: 1px solid var(--border-soft);
animation: row-in 0.24s ease both;
}
.event-row:hover { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.event-row.selected {
background: var(--sel);
box-shadow: inset 3px 0 0 var(--sel-line);
}
.event-name {
flex: 1; font-family: var(--mono); font-size: 0.82rem; color: var(--text);
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.event-name .seg-a { color: var(--accent-2); }
.event-meta { color: var(--faint); font-size: 0.72rem; font-family: var(--mono); font-variant-numeric: tabular-nums; }
.empty {
padding: 3rem 2rem; color: var(--muted); text-align: center; font-size: 0.9rem;
}
.empty .em-mark { display: block; font-size: 1.6rem; margin-bottom: 0.5rem; opacity: 0.5; }
#detail { padding: 1.1rem 1.35rem; overflow: auto; }
#detail-title {
margin: 0 0 0.85rem; font-family: var(--mono); font-size: 0.92rem; font-weight: 650;
color: var(--text); letter-spacing: -0.01em;
border-bottom: 1px solid var(--border-soft); padding-bottom: 0.6rem;
}
#detail-body {
margin: 0; padding: 0.9rem 1rem;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
white-space: pre-wrap; word-break: break-word;
font-family: var(--mono); font-size: 0.8rem; line-height: 1.55; color: var(--text);
box-shadow: var(--shadow);
}
#shots { display: flex; gap: 1rem; margin-top: 1.1rem; flex-wrap: wrap; }
.shot-wrap { display: flex; flex-direction: column; gap: 0.4rem; }
.shot-label {
font-size: 0.7rem; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em;
font-family: var(--mono);
}
.shot-wrap img {
max-width: 260px; max-height: 180px; border: 1px solid var(--border); border-radius: 8px;
cursor: zoom-in; background: var(--raised); transition: transform 0.15s, box-shadow 0.15s;
}
.shot-wrap img:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.thumb-missing {
width: 260px; height: 150px; display: flex; align-items: center; justify-content: center;
border: 1px dashed var(--border); border-radius: 8px; color: var(--faint); font-size: 0.78rem;
font-family: var(--mono); background: repeating-linear-gradient(45deg, transparent, transparent 8px, var(--border-soft) 8px, var(--border-soft) 9px);
}
.lightbox {
position: fixed; inset: 0; z-index: 10;
display: flex; align-items: center; justify-content: center;
background: #000000d0; backdrop-filter: blur(3px);
animation: fade-in 0.15s ease both; cursor: zoom-out;
}
.lightbox img { max-width: 94vw; max-height: 90vh; border-radius: 8px; box-shadow: 0 24px 60px -12px #000; }
.lightbox-hint {
position: fixed; bottom: 1.2rem; color: #cfd4de; font-family: var(--mono); font-size: 0.74rem;
letter-spacing: 0.06em;
}
@keyframes row-in { from { opacity: 0; transform: translateY(3px); } to { opacity: 1; transform: none; } }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@media (prefers-reduced-motion: reduce) { * { animation: none !important; transition: none !important; } }
@media (max-width: 720px) {
#layout { grid-template-columns: 1fr; height: auto; }
#timeline { border-right: none; border-bottom: 1px solid var(--border); max-height: 55vh; }
}

View file

@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>agent-desktop trace</title>
<style>{{CSS}}</style>
</head>
<body>
<div class="signal-bar" aria-hidden="true"></div>
<header>
<div class="brand">
<span class="mark" aria-hidden="true"></span>
<h1>agent-desktop<span class="brand-sub">trace</span></h1>
</div>
<p id="meta" class="stats"></p>
<div id="warnings" class="banner hidden" role="status"></div>
<div class="toolbar">
<label class="filter-label">
<span class="filter-icon" aria-hidden="true"></span>
<input id="filter" type="search" placeholder="filter events by name…" autocomplete="off" spellcheck="false">
<span id="match-count" class="match-count" aria-live="polite"></span>
</label>
<span class="legend" aria-hidden="true">
<span class="legend-item"><i class="dot ok"></i>ok</span>
<span class="legend-item"><i class="dot err"></i>error</span>
<span class="legend-item"><i class="dot warn"></i>incomplete</span>
<span class="legend-item kbd-hint">&uarr;&darr; navigate</span>
</span>
</div>
</header>
<main id="layout">
<section id="timeline" aria-label="Event timeline" tabindex="0"></section>
<section id="detail" aria-label="Event detail">
<h2 id="detail-title">Detail</h2>
<pre id="detail-body"></pre>
<div id="shots"></div>
</section>
</main>
<div id="lightbox" class="lightbox hidden" role="dialog" aria-label="Screenshot" aria-modal="true">
<img id="lightbox-img" alt="Expanded screenshot">
<span class="lightbox-hint" aria-hidden="true">esc to close</span>
</div>
<script id="trace-data" type="application/json">{{DATA}}</script>
<script>{{JS}}</script>
</body>
</html>

View file

@ -0,0 +1,347 @@
(function () {
"use strict";
var BASE64_RE = /^[A-Za-z0-9+/=]+$/;
var DATA_PREFIX = "data:image/png;base64,";
var rows = [];
var selectedIndex = -1;
var payload;
function parsePayload() {
var node = document.getElementById("trace-data");
try {
return JSON.parse(node.textContent || "{}");
} catch (e) {
return { events: [], warnings: [{ kind: "parse_error", message: String(e) }] };
}
}
function redactValue(value, indent) {
indent = indent || 0;
var pad = " ".repeat(indent);
if (value && typeof value === "object" && value.redacted === true) return "⟨redacted⟩";
if (Array.isArray(value)) {
if (!value.length) return "[]";
return "[\n" + value.map(function (item) {
return " ".repeat(indent + 1) + redactValue(item, indent + 1);
}).join(",\n") + "\n" + pad + "]";
}
if (value && typeof value === "object") {
var keys = Object.keys(value);
if (!keys.length) return "{}";
return "{\n" + keys.map(function (key) {
return " ".repeat(indent + 1) + JSON.stringify(key) + ": " + redactValue(value[key], indent + 1);
}).join(",\n") + "\n" + pad + "}";
}
return JSON.stringify(value);
}
function groupEvents(events) {
var groups = [];
var stack = [];
events.forEach(function (event, index) {
var name = event.event || "";
if (name === "command.start") {
var frame = { command: event.command || "command", start: event, children: [], end: null, open: true };
groups.push({ type: "group", group: frame });
stack.push(frame);
return;
}
if (name === "command.end") {
if (stack.length) {
var top = stack.pop();
top.end = event;
top.open = false;
} else {
groups.push({ type: "event", event: event, index: index });
}
return;
}
if (stack.length) stack[stack.length - 1].children.push({ event: event, index: index });
else groups.push({ type: "event", event: event, index: index });
});
return groups;
}
function statusOf(group) {
if (group.open || !group.end) return { cls: "open-incomplete", label: "incomplete", glyph: "○" };
return group.end.ok
? { cls: "ok", label: "ok", glyph: "✓" }
: { cls: "err", label: "error", glyph: "✗" };
}
function safeDataUri(uri) {
if (typeof uri !== "string" || uri.indexOf(DATA_PREFIX) !== 0) return null;
var payloadStr = uri.slice(DATA_PREFIX.length);
return payloadStr && BASE64_RE.test(payloadStr) ? uri : null;
}
function rowSummary(e) {
var n = e.event || "";
if (n === "snapshot.saved" || n === "snapshot.root.saved") {
return [e.snapshot_id, e.ref_count != null ? e.ref_count + " refs" : null, e.app].filter(Boolean).join(" · ");
}
if (n === "action.artifacts") return "before / after";
if (n === "action.dispatch.ok") return e.action || "";
if (n === "command.end") return (e.ok ? "ok" : "error") + (e.code ? " · " + e.code : "");
if (e.ref) return String(e.ref) + (e.action ? " · " + e.action : "");
if (e.code) return String(e.code);
if (typeof e.message === "string") return e.message;
return "";
}
function eventNameNode(name) {
var el = document.createElement("div");
el.className = "event-name";
var text = name || "(unknown)";
var dot = text.indexOf(".");
if (dot > 0) {
var head = document.createElement("span");
head.className = "seg-a";
head.textContent = text.slice(0, dot);
el.appendChild(head);
el.appendChild(document.createTextNode(text.slice(dot)));
} else {
el.textContent = text;
}
return el;
}
function makeRow(event, depth, onSelect) {
var row = document.createElement("div");
row.className = "event-row";
row.style.animationDelay = Math.min(rows.length * 7, 300) + "ms";
if (depth) row.classList.add("child");
row.appendChild(eventNameNode(event.event));
var sum = rowSummary(event);
if (sum) {
var s = document.createElement("span");
s.className = "event-summary";
s.textContent = sum;
row.appendChild(s);
}
var meta = document.createElement("span");
meta.className = "event-meta";
meta.textContent = event.ts_ms != null ? String(event.ts_ms) : "—";
row.appendChild(meta);
register(row, onSelect);
return row;
}
function register(el, onSelect) {
var i = rows.length;
el.addEventListener("click", function () { select(i); });
rows.push({ el: el, run: onSelect });
}
function renderTimeline(container, p, filterText) {
container.textContent = "";
rows = [];
selectedIndex = -1;
var events = p.events || [];
var ft = filterText ? filterText.toLowerCase() : "";
var filtered = events.filter(function (e) {
return !ft || String(e.event || "").toLowerCase().indexOf(ft) !== -1;
});
setMatchCount(filtered.length, events.length, !!ft);
if (!filtered.length) {
var empty = document.createElement("div");
empty.className = "empty";
var mark = document.createElement("span");
mark.className = "em-mark";
mark.textContent = ft ? "∅" : "—";
empty.appendChild(mark);
empty.appendChild(document.createTextNode(ft ? "No events match the filter." : "No events in this trace."));
container.appendChild(empty);
return;
}
groupEvents(filtered).forEach(function (item) {
if (item.type === "event") {
container.appendChild(makeRow(item.event, false, detailFor(item.event)));
return;
}
var group = item.group;
var st = statusOf(group);
var wrap = document.createElement("div");
wrap.className = "group";
var header = document.createElement("div");
header.className = "group-header " + st.cls;
var cmd = document.createElement("span");
cmd.className = "group-cmd";
cmd.textContent = group.command;
header.appendChild(cmd);
if (group.end && group.end.duration_ms != null) {
var dur = document.createElement("span");
dur.className = "dur";
dur.textContent = group.end.duration_ms + "ms";
header.appendChild(dur);
}
var badge = document.createElement("span");
badge.className = "status " + st.cls;
badge.textContent = st.glyph + " " + st.label;
header.appendChild(badge);
register(header, commandDetail(group));
wrap.appendChild(header);
group.children.forEach(function (child) {
wrap.appendChild(makeRow(child.event, true, detailFor(child.event)));
});
container.appendChild(wrap);
});
}
function select(i) {
if (i < 0 || i >= rows.length) return;
if (selectedIndex >= 0 && rows[selectedIndex]) rows[selectedIndex].el.classList.remove("selected");
selectedIndex = i;
rows[i].el.classList.add("selected");
rows[i].el.scrollIntoView({ block: "nearest" });
rows[i].run();
}
function step(delta) {
if (!rows.length) return;
var next = selectedIndex < 0 ? 0 : selectedIndex + delta;
select(Math.max(0, Math.min(rows.length - 1, next)));
}
function detailFor(event) {
return function () {
setDetail(event.event || "Detail", redactValue(event, 0), artifactsIn([event]));
};
}
function commandDetail(group) {
return function () {
var st = statusOf(group);
var summary = { command: group.command, status: st.label };
if (group.end) {
if (group.end.duration_ms != null) summary.duration_ms = group.end.duration_ms;
if (group.end.ok != null) summary.ok = group.end.ok;
if (group.end.code) summary.code = group.end.code;
if (group.end.message != null) summary.message = group.end.message;
}
var steps = group.children.map(function (c) { return c.event.event; }).filter(Boolean);
if (steps.length) summary.steps = steps;
setDetail(group.command + " " + st.glyph + " " + st.label, redactValue(summary, 0),
artifactsIn(group.children.map(function (c) { return c.event; })));
};
}
function artifactsIn(events) {
return events.filter(function (e) { return e && e.event === "action.artifacts"; });
}
function setDetail(title, body, artifacts) {
document.getElementById("detail-title").textContent = title;
document.getElementById("detail-body").textContent = body;
var shotsEl = document.getElementById("shots");
shotsEl.textContent = "";
artifacts.forEach(function (ev) {
["screenshot_pre", "screenshot_post"].forEach(function (key) {
var wrap = document.createElement("div");
wrap.className = "shot-wrap";
var caption = document.createElement("div");
caption.className = "shot-label";
caption.textContent = key === "screenshot_pre" ? "before" : "after";
wrap.appendChild(caption);
var rel = ev[key];
var uri = rel && payload.screenshots ? safeDataUri(payload.screenshots[rel]) : null;
if (uri) {
var img = document.createElement("img");
img.alt = caption.textContent + " screenshot";
img.src = uri;
img.addEventListener("click", function () { openLightbox(uri); });
wrap.appendChild(img);
} else {
var miss = document.createElement("div");
miss.className = "thumb-missing";
miss.textContent = "screenshot unavailable";
wrap.appendChild(miss);
}
shotsEl.appendChild(wrap);
});
});
}
function openLightbox(uri) {
document.getElementById("lightbox-img").src = uri;
document.getElementById("lightbox").classList.remove("hidden");
}
function closeLightbox() {
document.getElementById("lightbox").classList.add("hidden");
document.getElementById("lightbox-img").src = "";
}
function setMatchCount(shown, total, filtering) {
document.getElementById("match-count").textContent = filtering ? shown + " / " + total : "";
}
function pill(label, value) {
var el = document.createElement("span");
el.className = "pill";
var k = document.createElement("span");
k.className = "k";
k.textContent = label;
var v = document.createElement("b");
v.textContent = value;
el.appendChild(k);
el.appendChild(v);
return el;
}
function renderMeta(p) {
var meta = document.getElementById("meta");
meta.textContent = "";
meta.appendChild(pill("session", p.session_id || "?"));
meta.appendChild(pill("events", (p.returned_events || 0) + " / " + (p.total_events || 0)));
if (p.screenshots_embedded) meta.appendChild(pill("shots", String(p.screenshots_embedded)));
if (p.truncated) meta.appendChild(pill("view", "tail"));
}
function renderWarnings(p) {
var node = document.getElementById("warnings");
var lines = [];
(p.warnings || []).forEach(function (w) {
lines.push(String(w.kind || "warning") + ": " + String(w.message || ""));
});
if (p.truncated) lines.push("Timeline truncated to the last " + (p.returned_events || 0) + " events.");
if (p.screenshots_skipped) {
lines.push("Embedded " + (p.screenshots_embedded || 0) + " screenshots; " + p.screenshots_skipped + " omitted (budget or missing).");
}
if (!lines.length) { node.className = "banner hidden"; node.textContent = ""; return; }
node.className = "banner";
node.textContent = lines.join("\n");
}
function syncHeaderHeight() {
var h = document.querySelector("header").offsetHeight + 3;
document.documentElement.style.setProperty("--header-h", h + "px");
}
payload = parsePayload();
var timeline = document.getElementById("timeline");
var filter = document.getElementById("filter");
renderMeta(payload);
renderWarnings(payload);
syncHeaderHeight();
window.addEventListener("resize", syncHeaderHeight);
function rerender() {
renderTimeline(timeline, payload, filter.value.trim());
if (rows.length) select(0);
}
filter.addEventListener("input", rerender);
document.addEventListener("keydown", function (e) {
if (e.key === "Escape") { closeLightbox(); return; }
if (document.activeElement === filter) return;
if (e.key === "ArrowDown" || e.key === "j") { e.preventDefault(); step(1); }
else if (e.key === "ArrowUp" || e.key === "k") { e.preventDefault(); step(-1); }
else if (e.key === "/") { e.preventDefault(); filter.focus(); }
});
document.getElementById("lightbox").addEventListener("click", closeLightbox);
rerender();
})();

View file

@ -0,0 +1,88 @@
use serde_json::{Value, json};
/// Recursively redacts fields whose keys match `SENSITIVE_KEYS`. Non-sensitive
/// fields and non-object values are left unchanged. Array elements are
/// recursively scanned. Used by both the file-trace writer and the FFI log
/// callback layer so that sensitive values never reach a consumer.
pub fn sanitize_trace_value(value: Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.into_iter()
.map(|(key, value)| {
if is_sensitive_trace_key(&key) {
(key, redacted_value(value))
} else {
(key, sanitize_trace_value(value))
}
})
.collect(),
),
Value::Array(items) => Value::Array(items.into_iter().map(sanitize_trace_value).collect()),
other => other,
}
}
fn is_sensitive_trace_key(key: &str) -> bool {
const SENSITIVE_KEYS: &[&str] = &[
"text",
"value",
"expected",
"name",
"username",
"description",
"label",
"query",
"selector",
"secret",
"token",
"password",
"title",
"url",
"help",
"placeholder",
];
trace_key_tokens(key)
.iter()
.any(|part| SENSITIVE_KEYS.contains(&part.as_str()))
}
fn trace_key_tokens(key: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut previous_was_lower_or_digit = false;
for ch in key.chars() {
if !ch.is_ascii_alphanumeric() {
push_trace_key_token(&mut tokens, &mut current);
previous_was_lower_or_digit = false;
continue;
}
if ch.is_ascii_uppercase() && previous_was_lower_or_digit {
push_trace_key_token(&mut tokens, &mut current);
}
current.push(ch.to_ascii_lowercase());
previous_was_lower_or_digit = ch.is_ascii_lowercase() || ch.is_ascii_digit();
}
push_trace_key_token(&mut tokens, &mut current);
tokens
}
fn push_trace_key_token(tokens: &mut Vec<String>, current: &mut String) {
if !current.is_empty() {
tokens.push(std::mem::take(current));
}
}
fn redacted_value(value: Value) -> Value {
match value {
Value::Null => Value::Null,
_ => json!({ "redacted": true }),
}
}
#[cfg(test)]
#[path = "trace_sanitize_tests.rs"]
mod tests;

View file

@ -0,0 +1,58 @@
use super::sanitize_trace_value;
use serde_json::json;
#[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_redacts_selector_keyed_values_including_in_nested_details() {
let value = sanitize_trace_value(json!({
"selector": "button:Submit password",
"details": { "selector": "text:my secret" }
}));
assert_eq!(value["selector"]["redacted"], true);
assert_eq!(value["details"]["selector"]["redacted"], true);
}
#[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);
}

View file

@ -25,51 +25,6 @@ fn trace_open_rejects_symlink_paths() {
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!(
@ -159,14 +114,16 @@ fn write_event_emits_single_atomic_jsonl_line_with_seq() {
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);
assert_eq!(lines.len(), 3);
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());
let third: serde_json::Value = serde_json::from_str(lines[2]).unwrap();
assert_eq!(first["event"], "trace.meta");
assert_eq!(second["event"], "first");
assert_eq!(third["event"], "second");
assert!(second["seq"].as_u64().is_some());
assert!(second["seq"].as_u64().unwrap() > first["seq"].as_u64().unwrap());
assert!(third["seq"].as_u64().is_some());
assert!(third["seq"].as_u64().unwrap() > second["seq"].as_u64().unwrap());
let _ = fs::remove_file(path);
}
@ -191,7 +148,7 @@ fn truncated_final_line_leaves_prior_lines_parseable() {
parsed += 1;
}
}
assert_eq!(parsed, 1);
assert_eq!(parsed, 2);
let _ = fs::remove_file(path);
}
@ -274,6 +231,50 @@ fn segment_open_rejects_symlinked_trace_dir() {
let _ = fs::remove_dir_all(&real);
}
#[test]
fn trace_meta_is_first_line_of_new_segment() {
let trace_dir = std::env::temp_dir().join(format!(
"agent-desktop-meta-dir-{}",
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
let config = TraceConfig::build(None, Some(trace_dir.clone()), false).unwrap();
config
.emit(
"snapshot.saved",
Some("sess-1"),
json!({ "snapshot_id": "s1" }),
)
.unwrap();
let segment = segment_path_for_dir(&trace_dir);
let body = fs::read_to_string(segment).unwrap();
let first: serde_json::Value = serde_json::from_str(body.lines().next().unwrap()).unwrap();
assert_eq!(first["event"], "trace.meta");
assert_eq!(first["schema"], 1);
assert_eq!(first["session_id"], "sess-1");
assert!(first["pid"].as_u64().is_some());
let _ = fs::remove_dir_all(trace_dir);
}
#[test]
fn explicit_trace_file_opens_with_meta_header() {
let path = std::env::temp_dir().join(format!(
"agent-desktop-meta-file-{}",
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("event", None, json!({})).unwrap();
let first: serde_json::Value =
serde_json::from_str(fs::read_to_string(&path).unwrap().lines().next().unwrap()).unwrap();
assert_eq!(first["event"], "trace.meta");
let _ = fs::remove_file(path);
}
#[test]
fn failed_writer_reports_no_sink() {
let missing = std::env::temp_dir()

View file

@ -54,6 +54,14 @@ fn command_templates() -> BTreeMap<&'static str, &'static str> {
);
m.insert("snapshot", include_str!("codegen_templates/snapshot.rs.in"));
m.insert("status", include_str!("codegen_templates/status.rs.in"));
m.insert(
"trace_export",
include_str!("codegen_templates/trace_export.rs.in"),
);
m.insert(
"trace_show",
include_str!("codegen_templates/trace_show.rs.in"),
);
m.insert("version", include_str!("codegen_templates/version.rs.in"));
m.insert("wait", include_str!("codegen_templates/wait.rs.in"));
m

View file

@ -122,6 +122,8 @@ pub unsafe extern "C" fn ad_execute_by_ref(
}
};
let scope = context.command_scope("execute_by_ref");
let result = agent_desktop_core::commands::execute_by_ref::execute(
&ref_str,
snapshot_str.as_deref(),
@ -130,6 +132,7 @@ pub unsafe extern "C" fn ad_execute_by_ref(
adapter_ref.inner.as_ref(),
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("execute_by_ref", result, out) }
})

View file

@ -93,11 +93,14 @@ pub unsafe extern "C" fn ad_snapshot(
snapshot_id: None,
};
let scope = context.command_scope("snapshot");
let result = agent_desktop_core::commands::snapshot::execute(
args,
adapter_ref.inner.as_ref(),
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("snapshot", result, out) }
})

View file

@ -43,8 +43,11 @@ pub unsafe extern "C" fn ad_status(
let report = adapter.inner.permission_report();
let scope = ctx.command_scope("status");
let result: Result<serde_json::Value, AppError> =
execute_with_report_with_context(&*adapter.inner, &report, &ctx);
scope.complete(&result);
unsafe { write_command_envelope("status", result, out) }
})

View file

@ -0,0 +1,69 @@
/// Exports the merged trace timeline for the adapter's active session as a
/// single self-contained HTML file matching `agent-desktop trace export`.
///
/// `limit` controls tail semantics: `0` embeds all events; the default `5000`
/// matches the CLI. Pass `-1` to use the CLI default explicitly.
///
/// `out_path` may be null; when set it must be a NUL-terminated UTF-8 path
/// within `AD_MAX_STRING_BYTES + 1` bytes.
///
/// On success `*out` is a heap-allocated JSON envelope freed with
/// `ad_free_string`. On command-level failure `*out` still holds an error
/// envelope that must be freed.
///
/// # Safety
///
/// `adapter` must be a non-null pointer from `ad_adapter_create` or
/// `ad_adapter_create_with_session`. `out` must be non-null. `out_path`
/// may be null or a NUL-terminated UTF-8 string within `AD_MAX_STRING_BYTES + 1`
/// bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_trace_export(
adapter: *const AdAdapter,
limit: i32,
out_path: *const c_char,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
trap_panic(|| {
guard_non_null!(adapter, c"adapter is null");
let path = match optional_adapter_string(out_path, "out_path") {
Ok(value) => value,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
let effective_limit = if limit < 0 {
agent_desktop_core::trace_read::TRACE_EXPORT_DEFAULT_LIMIT
} else {
limit as usize
};
let adapter_ref = unsafe { &*adapter };
let context = match adapter_ref.command_context() {
Ok(ctx) => ctx,
Err(e) => {
let ae = app_error_to_adapter(e);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let scope = context.command_scope("trace");
let result = agent_desktop_core::commands::trace::execute(
agent_desktop_core::commands::trace::TraceAction::Export {
limit: effective_limit,
out: path.map(std::path::PathBuf::from),
},
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("trace", result, out) }
})
}

View file

@ -0,0 +1,69 @@
/// Returns the merged trace timeline for the adapter's active session as a
/// JSON envelope matching `agent-desktop trace show`.
///
/// `limit` controls tail semantics: `0` embeds all events; the default `500`
/// matches the CLI. Pass `-1` to use the CLI default explicitly.
///
/// `event_prefix` may be null; when set, only events whose name starts with the
/// prefix are returned before the tail limit is applied.
///
/// On success `*out` is a heap-allocated JSON envelope freed with
/// `ad_free_string`. On command-level failure `*out` still holds an error
/// envelope that must be freed.
///
/// # Safety
///
/// `adapter` must be a non-null pointer from `ad_adapter_create` or
/// `ad_adapter_create_with_session`. `out` must be non-null. `event_prefix`
/// may be null or a NUL-terminated UTF-8 string within `AD_MAX_STRING_BYTES + 1`
/// bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_trace_show(
adapter: *const AdAdapter,
limit: i32,
event_prefix: *const c_char,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
trap_panic(|| {
guard_non_null!(adapter, c"adapter is null");
let event = match optional_adapter_string(event_prefix, "event_prefix") {
Ok(value) => value,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
let effective_limit = if limit < 0 {
agent_desktop_core::commands::trace::TRACE_SHOW_DEFAULT_LIMIT
} else {
limit as usize
};
let adapter_ref = unsafe { &*adapter };
let context = match adapter_ref.command_context() {
Ok(ctx) => ctx,
Err(e) => {
let ae = app_error_to_adapter(e);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let scope = context.command_scope("trace");
let result = agent_desktop_core::commands::trace::execute(
agent_desktop_core::commands::trace::TraceAction::Show {
limit: effective_limit,
event,
},
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("trace", result, out) }
})
}

View file

@ -14,7 +14,17 @@ pub unsafe extern "C" fn ad_version(out: *mut *mut c_char) -> AdResult {
trap_panic(|| unsafe {
guard_non_null!(out, c"out is null");
*out = ptr::null_mut();
let context = match agent_desktop_core::context::CommandContext::new(None, None, false) {
Ok(ctx) => ctx,
Err(app_err) => {
let ae = app_error_to_adapter(app_err);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let scope = context.command_scope("version");
let result = agent_desktop_core::commands::version::execute();
scope.complete(&result);
write_command_envelope("version", result, out)
})
}

View file

@ -90,11 +90,14 @@ pub unsafe extern "C" fn ad_wait(
}
};
let scope = ctx.command_scope("wait");
let result = agent_desktop_core::commands::wait::execute(
wait_args,
adapter_ref.inner.as_ref(),
&ctx,
);
scope.complete(&result);
unsafe { write_command_envelope("wait", result, out) }
})

View file

@ -1074,6 +1074,58 @@ AdResult ad_snapshot(const struct AdAdapter *adapter,
*/
AdResult ad_status(const struct AdAdapter *adapter, char **out);
/**
* Exports the merged trace timeline for the adapter's active session as a
* single self-contained HTML file matching `agent-desktop trace export`.
*
* `limit` controls tail semantics: `0` embeds all events; the default `5000`
* matches the CLI. Pass `-1` to use the CLI default explicitly.
*
* `out_path` may be null; when set it must be a NUL-terminated UTF-8 path
* within `AD_MAX_STRING_BYTES + 1` bytes.
*
* On success `*out` is a heap-allocated JSON envelope freed with
* `ad_free_string`. On command-level failure `*out` still holds an error
* envelope that must be freed.
*
* # Safety
*
* `adapter` must be a non-null pointer from `ad_adapter_create` or
* `ad_adapter_create_with_session`. `out` must be non-null. `out_path`
* may be null or a NUL-terminated UTF-8 string within `AD_MAX_STRING_BYTES + 1`
* bytes.
*/
AdResult ad_trace_export(const struct AdAdapter *adapter,
int32_t limit,
const char *out_path,
char **out);
/**
* Returns the merged trace timeline for the adapter's active session as a
* JSON envelope matching `agent-desktop trace show`.
*
* `limit` controls tail semantics: `0` embeds all events; the default `500`
* matches the CLI. Pass `-1` to use the CLI default explicitly.
*
* `event_prefix` may be null; when set, only events whose name starts with the
* prefix are returned before the tail limit is applied.
*
* On success `*out` is a heap-allocated JSON envelope freed with
* `ad_free_string`. On command-level failure `*out` still holds an error
* envelope that must be freed.
*
* # Safety
*
* `adapter` must be a non-null pointer from `ad_adapter_create` or
* `ad_adapter_create_with_session`. `out` must be non-null. `event_prefix`
* may be null or a NUL-terminated UTF-8 string within `AD_MAX_STRING_BYTES + 1`
* bytes.
*/
AdResult ad_trace_show(const struct AdAdapter *adapter,
int32_t limit,
const char *event_prefix,
char **out);
/**
* Returns the `agent-desktop` version envelope as an owned JSON C string.
*

View file

@ -1,6 +1,6 @@
//! @generated — produced by crates/ffi/build.rs codegen.
//! Edit the templates under crates/ffi/codegen_templates/, not this file.
//! Commands in alphabetical order: execute_by_ref, snapshot, status, version, wait.
//! Commands in alphabetical order: execute_by_ref, snapshot, status, trace_export, trace_show, version, wait.
use crate::AdAdapter;
use crate::actions::conversion::action_from_c;
@ -147,6 +147,8 @@ pub unsafe extern "C" fn ad_execute_by_ref(
}
};
let scope = context.command_scope("execute_by_ref");
let result = agent_desktop_core::commands::execute_by_ref::execute(
&ref_str,
snapshot_str.as_deref(),
@ -155,6 +157,7 @@ pub unsafe extern "C" fn ad_execute_by_ref(
adapter_ref.inner.as_ref(),
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("execute_by_ref", result, out) }
})
@ -254,11 +257,14 @@ pub unsafe extern "C" fn ad_snapshot(
snapshot_id: None,
};
let scope = context.command_scope("snapshot");
let result = agent_desktop_core::commands::snapshot::execute(
args,
adapter_ref.inner.as_ref(),
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("snapshot", result, out) }
})
@ -308,13 +314,154 @@ pub unsafe extern "C" fn ad_status(
let report = adapter.inner.permission_report();
let scope = ctx.command_scope("status");
let result: Result<serde_json::Value, AppError> =
execute_with_report_with_context(&*adapter.inner, &report, &ctx);
scope.complete(&result);
unsafe { write_command_envelope("status", result, out) }
})
}
/// Exports the merged trace timeline for the adapter's active session as a
/// single self-contained HTML file matching `agent-desktop trace export`.
///
/// `limit` controls tail semantics: `0` embeds all events; the default `5000`
/// matches the CLI. Pass `-1` to use the CLI default explicitly.
///
/// `out_path` may be null; when set it must be a NUL-terminated UTF-8 path
/// within `AD_MAX_STRING_BYTES + 1` bytes.
///
/// On success `*out` is a heap-allocated JSON envelope freed with
/// `ad_free_string`. On command-level failure `*out` still holds an error
/// envelope that must be freed.
///
/// # Safety
///
/// `adapter` must be a non-null pointer from `ad_adapter_create` or
/// `ad_adapter_create_with_session`. `out` must be non-null. `out_path`
/// may be null or a NUL-terminated UTF-8 string within `AD_MAX_STRING_BYTES + 1`
/// bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_trace_export(
adapter: *const AdAdapter,
limit: i32,
out_path: *const c_char,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
trap_panic(|| {
guard_non_null!(adapter, c"adapter is null");
let path = match optional_adapter_string(out_path, "out_path") {
Ok(value) => value,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
let effective_limit = if limit < 0 {
agent_desktop_core::trace_read::TRACE_EXPORT_DEFAULT_LIMIT
} else {
limit as usize
};
let adapter_ref = unsafe { &*adapter };
let context = match adapter_ref.command_context() {
Ok(ctx) => ctx,
Err(e) => {
let ae = app_error_to_adapter(e);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let scope = context.command_scope("trace");
let result = agent_desktop_core::commands::trace::execute(
agent_desktop_core::commands::trace::TraceAction::Export {
limit: effective_limit,
out: path.map(std::path::PathBuf::from),
},
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("trace", result, out) }
})
}
/// Returns the merged trace timeline for the adapter's active session as a
/// JSON envelope matching `agent-desktop trace show`.
///
/// `limit` controls tail semantics: `0` embeds all events; the default `500`
/// matches the CLI. Pass `-1` to use the CLI default explicitly.
///
/// `event_prefix` may be null; when set, only events whose name starts with the
/// prefix are returned before the tail limit is applied.
///
/// On success `*out` is a heap-allocated JSON envelope freed with
/// `ad_free_string`. On command-level failure `*out` still holds an error
/// envelope that must be freed.
///
/// # Safety
///
/// `adapter` must be a non-null pointer from `ad_adapter_create` or
/// `ad_adapter_create_with_session`. `out` must be non-null. `event_prefix`
/// may be null or a NUL-terminated UTF-8 string within `AD_MAX_STRING_BYTES + 1`
/// bytes.
#[unsafe(no_mangle)]
pub unsafe extern "C" fn ad_trace_show(
adapter: *const AdAdapter,
limit: i32,
event_prefix: *const c_char,
out: *mut *mut c_char,
) -> AdResult {
guard_non_null!(out, c"out is null");
unsafe { *out = ptr::null_mut() };
trap_panic(|| {
guard_non_null!(adapter, c"adapter is null");
let event = match optional_adapter_string(event_prefix, "event_prefix") {
Ok(value) => value,
Err(e) => {
set_last_error(&e);
return AdResult::ErrInvalidArgs;
}
};
let effective_limit = if limit < 0 {
agent_desktop_core::commands::trace::TRACE_SHOW_DEFAULT_LIMIT
} else {
limit as usize
};
let adapter_ref = unsafe { &*adapter };
let context = match adapter_ref.command_context() {
Ok(ctx) => ctx,
Err(e) => {
let ae = app_error_to_adapter(e);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let scope = context.command_scope("trace");
let result = agent_desktop_core::commands::trace::execute(
agent_desktop_core::commands::trace::TraceAction::Show {
limit: effective_limit,
event,
},
&context,
);
scope.complete(&result);
unsafe { write_command_envelope("trace", result, out) }
})
}
/// Returns the `agent-desktop` version envelope as an owned JSON C string.
///
/// The returned string has the same `{version, ok, command, data}` shape
@ -330,7 +477,17 @@ pub unsafe extern "C" fn ad_version(out: *mut *mut c_char) -> AdResult {
trap_panic(|| unsafe {
guard_non_null!(out, c"out is null");
*out = ptr::null_mut();
let context = match agent_desktop_core::context::CommandContext::new(None, None, false) {
Ok(ctx) => ctx,
Err(app_err) => {
let ae = app_error_to_adapter(app_err);
set_last_error(&ae);
return crate::error::last_error_code();
}
};
let scope = context.command_scope("version");
let result = agent_desktop_core::commands::version::execute();
scope.complete(&result);
write_command_envelope("version", result, out)
})
}
@ -426,11 +583,14 @@ pub unsafe extern "C" fn ad_wait(
}
};
let scope = ctx.command_scope("wait");
let result = agent_desktop_core::commands::wait::execute(
wait_args,
adapter_ref.inner.as_ref(),
&ctx,
);
scope.complete(&result);
unsafe { write_command_envelope("wait", result, out) }
})

View file

@ -3,7 +3,10 @@ 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 common::{
AdResult, CStr, ad_adapter_create_with_session, ad_adapter_destroy, ad_check_permissions,
ad_free_string, ad_status,
};
use std::ffi::CString;
use std::fs;
use std::sync::Mutex;
@ -58,6 +61,7 @@ fn ffi_trace_on_session_writes_segment() {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.unwrap();
for call in 0..2 {
@ -105,3 +109,95 @@ fn ffi_plain_session_writes_no_trace_files() {
}
assert!(!trace_dir_for("plain-session").exists());
}
/// R8 claims `command.start`/`command.end` boundary events fire over FFI via
/// the generated entrypoints. The two tests above only prove the trace
/// plumbing itself works by hand-crafting a `CommandContext::trace` call —
/// they never go through a real `ad_*` entrypoint's codegen-injected
/// `context.command_scope(...)` / `scope.complete(...)` pair.
///
/// This test drives the real `ad_status` entrypoint end-to-end (chosen
/// because it needs no accessibility permission and no main-thread affinity,
/// so it runs headless in CI — see `crates/ffi/src/commands/generated.rs`)
/// under a trace-enabled session, then reads the on-disk trace segment and
/// asserts both boundary events were actually written by the generated code,
/// in the right order.
#[test]
fn ffi_real_ad_status_entrypoint_emits_command_start_and_end_trace_events() {
let _home = TestHome::new();
let manifest = start_session(StartSessionOptions {
name: None,
trace: SessionTraceMode::On,
force: false,
..Default::default()
})
.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 mut out: *mut std::os::raw::c_char = std::ptr::null_mut();
let rc = ad_status(ptr, &mut out);
assert_eq!(
rc,
AdResult::Ok,
"the real ad_status entrypoint must succeed under a trace-enabled session"
);
assert!(
!out.is_null(),
"ad_status must produce an envelope on success"
);
let body = CStr::from_ptr(out).to_string_lossy().into_owned();
assert!(
body.contains("\"command\":\"status\""),
"envelope command must be 'status', got: {body}"
);
ad_free_string(out);
ad_adapter_destroy(ptr);
}
let trace_dir = trace_dir_for(&manifest.id);
let segments: Vec<_> = fs::read_dir(trace_dir)
.unwrap()
.flatten()
.filter(|entry| entry.path().extension().is_some_and(|ext| ext == "jsonl"))
.collect();
assert_eq!(
segments.len(),
1,
"a single FFI call must write exactly one trace segment"
);
let trace_body = fs::read_to_string(segments[0].path()).unwrap();
let events: Vec<serde_json::Value> = trace_body
.lines()
.map(|line| serde_json::from_str(line).unwrap())
.collect();
let start_idx = events.iter().position(|event| {
event["event"].as_str() == Some("command.start")
&& event["command"].as_str() == Some("status")
});
let end_idx = events.iter().position(|event| {
event["event"].as_str() == Some("command.end")
&& event["command"].as_str() == Some("status")
&& event["ok"].as_bool() == Some(true)
});
assert!(
start_idx.is_some(),
"trace segment must contain a command.start event fired by ad_status's real \
command_scope(\"status\") call, got: {trace_body}"
);
assert!(
end_idx.is_some(),
"trace segment must contain a command.end event fired by ad_status's real \
scope.complete(...) call, got: {trace_body}"
);
assert!(
start_idx < end_idx,
"command.start must be recorded before command.end for the same invocation"
);
}

View file

@ -15,7 +15,15 @@ use agent_desktop_core::interaction_policy::InteractionPolicy;
/// Known Family-B commands — the exhaustive set of command-backed JSON
/// wrappers that must appear in `src/commands/generated.rs`.
const EXPECTED_COMMANDS: &[&str] = &["execute_by_ref", "snapshot", "status", "version", "wait"];
const EXPECTED_COMMANDS: &[&str] = &[
"execute_by_ref",
"snapshot",
"status",
"trace_export",
"trace_show",
"version",
"wait",
];
#[test]
fn generated_file_contains_all_expected_wrappers() {

View file

@ -239,7 +239,10 @@ mod imp {
}
Err(AdapterError::new(
ErrorCode::InvalidArgs,
format!("'{value}' is not a number; this control holds a numeric value"),
format!(
"The requested value ({} chars) is not a number; this control holds a numeric value",
value.chars().count()
),
)
.with_suggestion("Pass a numeric value, e.g. set-value @e1 50"))
}

View file

@ -37,7 +37,10 @@ pub(crate) fn select_value(el: &AXElement, value: &str) -> Result<(), AdapterErr
if !select_child_by_name(el, value) {
return Err(AdapterError::new(
ErrorCode::ElementNotFound,
format!("No child matching '{value}' found in list"),
format!(
"No child matching the requested value ({} chars) found in list",
value.chars().count()
),
)
.with_suggestion("Use 'find --role' to discover available items."));
}
@ -76,7 +79,10 @@ fn wait_for_value(el: &AXElement, value: &str) -> Result<(), AdapterError> {
if std::time::Instant::now() >= deadline {
return Err(AdapterError::new(
ErrorCode::ActionFailed,
format!("Selection did not change to '{value}'"),
format!(
"Selection did not change to the requested value ({} chars)",
value.chars().count()
),
)
.with_suggestion("Refresh the snapshot and inspect available values."));
}
@ -88,7 +94,10 @@ fn wait_for_value(el: &AXElement, value: &str) -> Result<(), AdapterError> {
fn option_not_found(value: &str) -> AdapterError {
AdapterError::new(
ErrorCode::ElementNotFound,
format!("No menu item matching '{value}' found"),
format!(
"No menu item matching the requested value ({} chars) found",
value.chars().count()
),
)
.with_suggestion("Use 'click' to open the menu, then 'snapshot' to see available options.")
}

View file

@ -161,7 +161,10 @@ safe for agents to use with machine-readable command output:
- create private trace files
- reject symlink trace paths on Unix
- redact sensitive text/value/name/message fields
- redact sensitive fields by key name (`text`/`value`/`name`/…); note that
`message` is NOT key-redacted, so raw caller arguments must never be
interpolated into it — see
`docs/solutions/conventions/keep-raw-arguments-out-of-trace-reachable-error-messages.md`
- let `--trace-strict` fail on setup and pre-action trace writes
- keep post-action success trace writes best-effort after the desktop mutation
has already happened

View file

@ -0,0 +1,77 @@
---
title: Keep raw caller arguments out of trace-reachable error messages
date: 2026-07-01
category: conventions
module: crates/core, crates/macos
problem_type: convention
component: tooling
severity: high
applies_when:
- Building an AdapterError or AppError message that can surface through command.end or actionability.check.error
- Adding or changing any error message in an action path (select, set-value, wait, click)
- Adding a new trace event field that carries user- or app-derived text
related_components:
- trace_sanitize
- trace
- context
tags:
- tracing
- redaction
- privacy
- error-messages
- sanitization
---
# Keep raw caller arguments out of trace-reachable error messages
## Context
Trace redaction (`sanitize_trace_value` in `crates/core/src/trace_sanitize.rs`) is a field-**name** allowlist: it redacts values whose key matches `SENSITIVE_KEYS` (`text`, `value`, `name`, `title`, `selector`, …) and otherwise recurses into nested objects and arrays, passing non-matching leaf values through verbatim. It does not scan free-text string values for embedded secrets.
The `message` field on trace events is deliberately not in that allowlist — it is meant to be diagnostic free text. The trap: several error builders interpolated the caller's raw argument (a `wait --text` selector, a `select --value`, a `set-value` payload) directly into that message, e.g. `format!("Text '{text}' did not match…")`. Those errors flow into `command.end` and `actionability.check.error` events, which are written to per-session JSONL segments and embedded into `trace export` HTML — in default `events` mode, with no `--screenshots` opt-in. So the raw user value leaks despite redaction being "on".
## Guidance
Never interpolate a raw user- or app-supplied value into an error message that can reach a trace sink. Keep the message diagnostic without the content: report a shape (a character count, a role, a bounded enum), not the value.
Where raw values are genuinely useful for the immediate CLI caller, put them in the error's `details` object — but `details` is not automatically exempt from tracing. Two sites clone `details` verbatim into a trace event: `crates/core/src/ref_action.rs` puts `err.details` on `actionability.check.error`, and `crates/core/src/commands/helpers.rs` puts `err.details` on `ref.resolve.error`. What actually protects those values is `write_event` in `crates/core/src/trace.rs`, which runs the whole field set — `details` included, at any nesting depth — through `sanitize_trace_value` before the line is written.
The real invariant is about the *key*, not the field: `sanitize_trace_value` recurses into objects and arrays and redacts any value whose key matches `SENSITIVE_KEYS`, wherever it sits in the tree. So a raw value is safe inside `details` (or any other object) only if it lives under a `SENSITIVE_KEYS`-matching key, or if it has already been reduced to a shape — a char count, a role, a bounded enum — rather than the content itself. What's still dangerous, in `details` or anywhere else, is raw content interpolated into a free-text string *value* under a non-sensitive key. That's the `message` trap from above: once the content is baked into the string, there is no key left for key-based redaction to match against.
Bounded, non-sensitive vocabularies (fixed key-combo names, a closed set of predicate names, role strings) are fine to interpolate — they carry no user content. The rule targets open-ended caller input.
## Why This Matters
A field-name allowlist can only redact fields it knows the name of. Any code path that folds attacker- or user-influenceable text into a differently-named, non-listed field silently defeats redaction — and the failure is invisible in review because redaction still appears to be applied. Escaping (the XSS defense in `trace export`) is orthogonal: it stops the text from *executing*, not from being *read*. An exported trace attached to an issue would carry the leaked value in plain sight.
## When to Apply
- Any `format!` that builds an `AdapterError`/`AppError` message in an action path and embeds a `&str`/`Value` derived from a command argument.
- Any new trace event field: if it can carry user/app text, either add its key to `SENSITIVE_KEYS` or guarantee it only ever holds a shape, not content.
## Examples
Before (leaks the raw value into the trace):
```rust
return Err(AdapterError::new(
ErrorCode::ActionFailed,
format!("Selection did not change to '{value}'"),
));
```
After (mirrors the existing `text_chars` count idiom — diagnostic, no content):
```rust
return Err(AdapterError::new(
ErrorCode::ActionFailed,
format!("Selection did not change to the requested value ({} chars)", value.chars().count()),
));
```
Fixed sites (commit `b35eea9`): `crates/core/src/commands/wait_timeout.rs` (window/text/selector builders), `crates/macos/src/actions/extras.rs` (`select_value` list arm, `wait_for_value`, `option_not_found`), `crates/macos/src/actions/ax_helpers.rs` (`number_cf_from_str`). Regression guard: `wait_text_timeout_message_omits_raw_text_from_trace_segment` asserts a unique marker passed as `wait --text` never appears in the written segment.
## Related
- `docs/solutions/best-practices/playwright-grade-desktop-reliability-2026-06-02.md` — the broad tracing/reliability contract; its redaction guidance is refined by this convention (key-name redaction does not cover free-text `message` content).
- `crates/core/src/trace_sanitize.rs` — the `SENSITIVE_KEYS` allowlist this convention works around.

View file

@ -9,7 +9,7 @@ description: >
Use when an AI agent needs to observe, interact with, or automate desktop applications
(click buttons, fill forms, navigate menus, read UI state, toggle checkboxes, scroll,
drag, type text, take screenshots, manage windows, use clipboard, manage notifications).
Covers 55 commands across observation, interaction, keyboard/mouse, app lifecycle,
Covers 56 commands across observation, interaction, keyboard/mouse, app lifecycle,
notifications (macOS), clipboard, wait, session lifecycle, and a `skills` command
bundled docs straight from the binary.
Triggers on: "click button", "fill form", "open app", "read UI", "automate desktop",
@ -95,7 +95,7 @@ Use **progressive skeleton traversal** as the default approach. It reduces token
- **Strict resolution:** stale refs return `STALE_REF`; duplicate plausible targets return `AMBIGUOUS_TARGET` instead of choosing arbitrarily.
- **Actionability:** ref actions check live visibility, stability, enabled state, supported action, policy, and editability before dispatch.
- **Headless vs headed:** ref actions are headless by default (AX-only, no cursor) and fail closed when only a physical gesture would work. `type` uses a focus-fallback base policy because typing needs focus but never moves the cursor. Pass the global `--headed` flag to permit cursor movement and focus stealing so physical fallbacks can complete; the AX path is still tried first, so `--headed` never regresses headless-capable elements. Raw cursor commands (`hover`, `drag`, `mouse-*`) are physical and require `--headed`; keyboard commands (`press`, `key-down`, `key-up`) are explicit low-level input.
- **Sessions and tracing:** run `session start` once per agent run to create a manifest with `trace: on` (default). Subsequent commands in that run record JSONL automatically to per-process segments under `~/.agent-desktop/sessions/<id>/trace/<pid>-<procTs>.jsonl` — no `--trace` on every call. A session owns both its trace and its latest-snapshot namespace; activating it (via pointer, env, or flag) relocates implicit "latest" to that session. Explicit `--snapshot <id>` still resolves cross-session. **`--session <id>` alone** (no manifest from `session start`) selects only the snapshot namespace — existing callers see no surprise trace files. **`--trace <path>`** still overrides to one atomic file for CI or one-offs. Activation precedence: `--session` > `AGENT_DESKTOP_SESSION` > `~/.agent-desktop/current_session` (written only by `session start`). Concurrent independent agents set `AGENT_DESKTOP_SESSION` per process; the pointer is a single-active-session convenience. Multi-agent shared sessions: each agent acts on the `snapshot_id` from its own `snapshot` call — implicit latest is not a cross-agent guarantee. Run `status` to see `session_id` and `tracing`. Trace lines include `ts_ms`, monotonic per-process `seq`, and redacted sensitive fields (`text`, `value`, `expected`, `name`, `username`, `description`, `label`, `query`, `secret`, `token`, `password`, `title`, `url`, `help`, `placeholder``{ "redacted": true }`). `--trace-strict` fails on trace setup and pre-action writes; post-action success traces are best-effort.
- **Sessions and tracing:** run `session start` once per agent run to create a manifest with `trace: on` (default). Use `session start --screenshots` when you need replay artifacts (`artifacts: full`): pre/post-action PNGs and refmap copies under the session trace directory (sensitive — treat exports like screenshots). Subsequent commands record JSONL automatically to per-process segments under `~/.agent-desktop/sessions/<id>/trace/<pid>-<procTs>.jsonl` — no `--trace` on every call. Read traces back with `trace show` (bounded JSON for agents) or `trace export` (single-file HTML for humans). A session owns both its trace and its latest-snapshot namespace; activating it (via pointer, env, or flag) relocates implicit "latest" to that session. Explicit `--snapshot <id>` still resolves cross-session. **`--session <id>` alone** (no manifest from `session start`) selects only the snapshot namespace — existing callers see no surprise trace files. **`--trace <path>`** still overrides to one atomic file for CI or one-offs. Activation precedence: `--session` > `AGENT_DESKTOP_SESSION` > `~/.agent-desktop/current_session` (written only by `session start`). Concurrent independent agents set `AGENT_DESKTOP_SESSION` per process; the pointer is a single-active-session convenience. Multi-agent shared sessions: each agent acts on the `snapshot_id` from its own `snapshot` call — implicit latest is not a cross-agent guarantee. Run `status` to see `session_id` and `tracing`. Trace lines include `ts_ms`, monotonic per-process `seq`, and redacted sensitive fields (`text`, `value`, `expected`, `name`, `username`, `description`, `label`, `query`, `secret`, `token`, `password`, `title`, `url`, `help`, `placeholder``{ "redacted": true }`). `--trace-strict` fails on trace setup and pre-action writes; post-action success traces are best-effort.
## JSON Output Contract
@ -130,7 +130,7 @@ Exit codes: `0` success, `1` structured error, `2` argument error.
`TIMEOUT` errors carry a `details` object whose `kind` field selects the schema. `kind: "wait_timeout"` includes `predicate`, `timeout_ms`, and `last_observed` or `last_error`, plus `ref`/`title`/`text_chars` depending on the wait mode. `kind: "chain_deadline"` includes `value_before`, `value_at_timeout`, `target`, and `mutated` (increment waits) or `wanted_expanded`/`observed_expanded` (disclosure waits). `mutated: true` — or an unknown `observed_expanded` state — means re-read the element before retrying; `mutated: false` means the state did not change and retrying directly is safe.
## Command Quick Reference (55 commands)
## Command Quick Reference (56 commands)
### Observation
```
@ -228,11 +228,13 @@ agent-desktop wait --notification --app "App" # Wait for new notification
### System
```
agent-desktop session start [--name LABEL] [--no-trace] [--force] # Create trace-enabled session + pointer
agent-desktop session start [--name LABEL] [--screenshots] [--no-trace] [--force] # Trace-enabled session; --screenshots enables replay artifacts (sensitive)
agent-desktop session end [id] # Seal manifest, clear pointer
agent-desktop session list # List session manifests
agent-desktop session gc [--older-than SECS] [--ended] # Reclaim ended/stale sessions
agent-desktop status # Health, session_id, tracing, permissions
agent-desktop trace show [--limit N] [--event PREFIX] # Merge trace segments (default tail 500; 0 = all)
agent-desktop trace export [--out path.html] [--limit N] # Self-contained HTML viewer (default tail 5000)
agent-desktop status # Health, session_id, tracing, artifacts, permissions
agent-desktop permissions # Check permission
agent-desktop permissions --request # Trigger permission dialog
agent-desktop version # Version info (always JSON envelope)

View file

@ -325,6 +325,59 @@ Removes ended sessions that are not live and not pointer-referenced. Never reaps
Trace-on requires a manifest with `trace: on` from `session start`. Bare `--session` or FFI `ad_adapter_create_with_session` without that manifest selects the snapshot namespace only.
## Trace read and export
Both commands require an active trace-enabled session (`session start` or `--session <id>` with a manifest). They are permissionless — no accessibility or screen-recording grant is needed to read or export traces from disk.
### trace show
```bash
agent-desktop trace show [--limit N] [--event PREFIX]
```
Merges every segment under `<session>/trace/` into one deterministic timeline. Default `--limit 500` returns the **tail**; `--limit 0` returns all events. `--event action.` filters by event-name prefix before the tail slice.
Response `data` includes `session_id`, per-segment stats (`segments[]` with `segment`, `pid`, `schema`, `event_count`, `skipped_lines`), `total_events`, `returned_events`, `truncated`, optional `warnings[]` (`kind`, `message`), and the merged `events[]` (each annotated with `writer_pid` and `segment`).
Reader tolerance: truncated final lines, corrupt JSON, foreign files, symlinked segments, and unpaired `command.start`/`command.end` pairs degrade to counted warnings — never hard errors.
`warnings[].kind` is one of:
| `kind` | Meaning |
|--------|---------|
| `foreign_file` | A file under `trace/` doesn't match the `<pid>-<procTs>.jsonl` segment name pattern (and isn't dotfile-hidden); ignored entirely |
| `unreadable_segment` | The segment file could not be opened or read; the whole segment is skipped |
| `symlinked_segment` | The segment path is a symlink; skipped before any read is attempted |
| `schema_unknown` | The segment's `trace.meta` declares a schema newer than this reader supports; still read best-effort |
| `unpaired_command` | A `command.start` has no matching `command.end` (or vice versa) within the returned event window |
### trace export
```bash
agent-desktop trace export [--out path.html] [--limit N]
```
Builds one self-contained HTML file with embedded JSON and base64 PNG screenshots. Default `--limit 5000` (ten times `trace show`'s default). Works from `file://` with no network fetches.
Without `--out`, the file is written into the **session directory** as `trace-<session_id>.html` (`~/.agent-desktop/sessions/<id>/trace-<id>.html`) — not the current working directory. `--out` overrides the path, including writing outside the session directory.
Response `data` reports `path`, `event_count`, `screenshots_embedded`, `screenshots_skipped`, and `bytes`. Export refuses symlinked `--out` paths and returns `INVALID_ARGS` when the embedded JSON exceeds 200MiB (use a smaller `--limit`).
### Replay artifacts (`--screenshots`)
```bash
agent-desktop session start --screenshots # manifest artifacts: full
```
Requires tracing (`trace: on`; `--no-trace --screenshots` is rejected). Ref actions capture pre/post PNGs under `trace/screens/`; snapshot saves copy refmaps to `trace/refmaps/`. Skips are recorded in `action.artifacts` events with machine-readable reasons. Artifacts are **unredacted** and may appear in exported HTML — opt in only when that sensitivity is acceptable.
A skip reason lands in `skipped` when the pre- and post-action screenshot outcomes share one reason, otherwise it splits across `skipped_pre`/`skipped_post`. Reasons include (non-exhaustive):
| Token | Meaning |
|-------|---------|
| `no_session` | No active session could be resolved for this action |
| `count_budget` | Per-process screenshot count budget (200) exceeded |
| `budget` | Per-process screenshot byte budget (128MiB) exceeded |
| `write_failed` | Writing the PNG to disk failed |
| `dir: <error>` | Creating `trace/screens/` failed |
| `adapter: <ERROR_CODE>` | The platform screenshot call failed with the given error code |
Refmap copies under `trace/refmaps/` are best-effort — a skipped or failed copy never fails the primary command and leaves any prior copy intact.
## System Health
### status
@ -333,6 +386,8 @@ agent-desktop status
```
Returns adapter health, platform info, permission report, latest snapshot metadata (`snapshot_id`, `ref_count`) when available, plus **`session_id`** (resolved active session, if any) and **`tracing`** (whether structured trace output is configured for this process — explicit `--trace`, or a trace-enabled session manifest).
When `session_id` resolves to a session with a readable manifest, the response also includes **`artifacts`**: `full` (`session start --screenshots` — screenshots and refmaps captured) or `events` (default — JSONL events only, no binary artifacts). Omitted when there is no active session.
### permissions
```bash
agent-desktop permissions

View file

@ -16,6 +16,7 @@ use crate::{
session::{SessionAction, SessionArgs, SessionEndArgs, SessionGcArgs, SessionStartArgs},
skills::{SkillsAction, SkillsArgs, SkillsGetArgs},
system::BatchArgs,
trace::{TraceAction, TraceArgs, TraceExportArgs, TraceShowArgs},
},
};
@ -106,6 +107,7 @@ pub(crate) fn parse_command(item: BatchCommand) -> Result<Commands, AppError> {
"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),
"trace" => parse_trace(item.args).map(Commands::Trace),
"batch" => Err(AppError::invalid_input_with_suggestion(
"Batch commands cannot be nested",
"Flatten nested batches into one top-level batch array",
@ -190,35 +192,67 @@ fn parse_skills(args: Value) -> Result<SkillsArgs, AppError> {
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BatchSessionArgs {
struct BatchSessionActionArgs {
#[serde(default)]
action: Option<String>,
#[serde(flatten)]
rest: Map<String, Value>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BatchSessionStartArgs {
name: Option<String>,
#[serde(default)]
no_trace: bool,
#[serde(default)]
screenshots: bool,
#[serde(default)]
force: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BatchSessionEndArgs {
id: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BatchSessionGcArgs {
older_than: Option<u64>,
#[serde(default)]
ended: bool,
}
fn parse_session(args: Value) -> Result<SessionArgs, AppError> {
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,
}),
let discriminant: BatchSessionActionArgs = decode("session", args)?;
let rest = Value::Object(discriminant.rest);
let action = match discriminant.action.as_deref() {
None | Some("list") => {
no_args("session", rest)?;
SessionAction::List
}
Some("start") => {
let args: BatchSessionStartArgs = decode("session", rest)?;
SessionAction::Start(SessionStartArgs {
name: args.name,
no_trace: args.no_trace,
screenshots: args.screenshots,
force: args.force,
})
}
Some("end") => {
let args: BatchSessionEndArgs = decode("session", rest)?;
SessionAction::End(SessionEndArgs { id: args.id })
}
Some("gc") => {
let args: BatchSessionGcArgs = decode("session", rest)?;
SessionAction::Gc(SessionGcArgs {
older_than: args.older_than,
ended: args.ended,
})
}
Some(other) => {
return Err(AppError::invalid_input(format!(
"Unknown session action '{other}'"
@ -228,5 +262,39 @@ fn parse_session(args: Value) -> Result<SessionArgs, AppError> {
Ok(SessionArgs { action })
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct BatchTraceArgs {
action: String,
#[serde(default)]
limit: Option<usize>,
event: Option<String>,
out: Option<std::path::PathBuf>,
}
fn parse_trace(args: Value) -> Result<TraceArgs, AppError> {
let args: BatchTraceArgs = decode("trace", args)?;
let action = match args.action.as_str() {
"show" => TraceAction::Show(TraceShowArgs {
limit: args
.limit
.unwrap_or(agent_desktop_core::commands::trace::TRACE_SHOW_DEFAULT_LIMIT),
event: args.event,
}),
"export" => TraceAction::Export(TraceExportArgs {
limit: args
.limit
.unwrap_or(agent_desktop_core::trace_read::TRACE_EXPORT_DEFAULT_LIMIT),
out: args.out,
}),
other => {
return Err(AppError::invalid_input(format!(
"Unknown trace action '{other}'"
)));
}
};
Ok(TraceArgs { action })
}
#[cfg(test)]
mod tests;

View file

@ -146,6 +146,126 @@ fn nested_batch_rejection_has_suggestion() {
assert!(err.suggestion().is_some());
}
#[test]
fn trace_batch_show_parses() {
let command = parse_command(item(
"trace",
serde_json::json!({ "action": "show", "limit": 0, "event": "action." }),
))
.expect("trace show parses");
match command {
Commands::Trace(args) => match args.action {
crate::cli_args::trace::TraceAction::Show(show) => {
assert_eq!(show.limit, 0);
assert_eq!(show.event.as_deref(), Some("action."));
}
other => panic!("unexpected trace action: {other:?}"),
},
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn trace_batch_export_parses() {
let command = parse_command(item(
"trace",
serde_json::json!({ "action": "export", "limit": 100 }),
))
.expect("trace export parses");
match command {
Commands::Trace(args) => match args.action {
crate::cli_args::trace::TraceAction::Export(export) => {
assert_eq!(export.limit, 100);
assert!(export.out.is_none());
}
other => panic!("unexpected trace action: {other:?}"),
},
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn trace_batch_rejects_unknown_field() {
let err = parse_command(item(
"trace",
serde_json::json!({ "action": "show", "limitt": 1 }),
))
.expect_err("unknown trace field rejected");
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn session_batch_accepts_screenshots_flag() {
let command = parse_command(item(
"session",
serde_json::json!({ "action": "start", "screenshots": true }),
))
.expect("session start with screenshots parses");
match command {
Commands::Session(args) => match args.action {
crate::cli_args::session::SessionAction::Start(start) => {
assert!(start.screenshots);
}
other => panic!("unexpected action: {other:?}"),
},
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn session_batch_end_rejects_start_only_field() {
let err = parse_command(item(
"session",
serde_json::json!({ "action": "end", "screenshots": true }),
))
.expect_err("'screenshots' is not valid for the 'end' action and must be rejected");
assert_eq!(err.code(), "INVALID_ARGS");
assert!(err.to_string().contains("screenshots"));
}
#[test]
fn session_batch_gc_parses_older_than() {
let command = parse_command(item(
"session",
serde_json::json!({ "action": "gc", "older_than": 5, "ended": true }),
))
.expect("session gc parses");
match command {
Commands::Session(args) => match args.action {
crate::cli_args::session::SessionAction::Gc(gc) => {
assert_eq!(gc.older_than, Some(5));
assert!(gc.ended);
}
other => panic!("unexpected action: {other:?}"),
},
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn session_batch_end_parses_id() {
let command = parse_command(item(
"session",
serde_json::json!({ "action": "end", "id": "s-1" }),
))
.expect("session end parses");
match command {
Commands::Session(args) => match args.action {
crate::cli_args::session::SessionAction::End(end) => {
assert_eq!(end.id.as_deref(), Some("s-1"));
}
other => panic!("unexpected action: {other:?}"),
},
other => panic!("unexpected command: {other:?}"),
}
}
#[test]
fn every_cli_subcommand_is_known_to_batch_parser() {
for subcommand in crate::cli::Cli::command().get_subcommands() {

View file

@ -31,6 +31,7 @@ const COMMAND_SPECIFIC_TESTS: &[&str] = &[
"session",
"snapshot",
"status",
"trace",
"wait",
];

View file

@ -78,10 +78,15 @@ SYSTEM
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 start --screenshots Opt in to screenshot/refmap replay artifacts (requires tracing)
session end Seal the session manifest and clear the active pointer
session list List session manifests
session gc Remove ended or provably-stale sessions
TRACE
trace show Merge session trace segments into a bounded JSON timeline (--limit defaults to 500; 0 = all)
trace export Export a self-contained HTML trace viewer (--limit defaults to 5000; 0 = all)
BATCH
batch <json> Run commands from a JSON array (--stop-on-error)
batch items may set "session": "id" to override the inherited --session

View file

@ -18,6 +18,7 @@ use crate::cli_args::{
ListAppsArgs, ListWindowsArgs, MoveWindowCliArgs, PermissionsArgs, ResizeWindowCliArgs,
WaitArgs,
},
trace::TraceArgs,
};
const BEFORE_HELP: &str = include_str!("help_before.txt");
@ -213,6 +214,8 @@ pub(crate) enum Commands {
Skills(SkillsArgs),
#[command(about = "Manage trace-enabled agent sessions (start, end, list, gc)")]
Session(SessionArgs),
#[command(about = "Read merged session trace timelines")]
Trace(TraceArgs),
}
impl Commands {
@ -273,6 +276,7 @@ impl Commands {
Self::Batch(_) => "batch",
Self::Skills(_) => "skills",
Self::Session(_) => "session",
Self::Trace(_) => "trace",
}
}
}

View file

@ -6,6 +6,7 @@ pub(crate) mod notifications;
pub(crate) mod session;
pub(crate) mod skills;
pub(crate) mod system;
pub(crate) mod trace;
fn default_max_depth() -> u8 {
10

View file

@ -24,6 +24,11 @@ pub(crate) struct SessionStartArgs {
pub name: Option<String>,
#[arg(long, help = "Create the session without automatic tracing")]
pub no_trace: bool,
#[arg(
long,
help = "Capture pre/post-action screenshots and refmap copies (requires tracing; sensitive)"
)]
pub screenshots: bool,
#[arg(
long,
help = "Override the current-session pointer even when it references a live session"

40
src/cli_args/trace.rs Normal file
View file

@ -0,0 +1,40 @@
use clap::{Args, Subcommand};
use std::path::PathBuf;
#[derive(Args, Debug)]
pub(crate) struct TraceArgs {
#[command(subcommand)]
pub action: TraceAction,
}
#[derive(Subcommand, Debug)]
pub(crate) enum TraceAction {
#[command(about = "Merge session trace segments into a bounded JSON timeline")]
Show(TraceShowArgs),
#[command(about = "Export a self-contained HTML trace viewer")]
Export(TraceExportArgs),
}
#[derive(Args, Debug)]
pub(crate) struct TraceShowArgs {
#[arg(
long,
default_value_t = agent_desktop_core::commands::trace::TRACE_SHOW_DEFAULT_LIMIT,
help = "Return the last N merged events (0 for all; default 500)"
)]
pub limit: usize,
#[arg(long, help = "Filter events by name prefix before applying --limit")]
pub event: Option<String>,
}
#[derive(Args, Debug)]
pub(crate) struct TraceExportArgs {
#[arg(long, help = "Output HTML path (default trace-<session>.html)")]
pub out: Option<PathBuf>,
#[arg(
long,
default_value_t = agent_desktop_core::trace_read::TRACE_EXPORT_DEFAULT_LIMIT,
help = "Embed the last N merged events (0 for all; default 5000)"
)]
pub limit: usize,
}

View file

@ -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(_) | Commands::Session(_) => None,
Commands::Version | Commands::Skills(_) | Commands::Session(_) | Commands::Trace(_) => None,
Commands::Status | Commands::Permissions(_) => None,
Commands::ListWindows(_) | Commands::ListApps(_) => None,
Commands::ClipboardGet | Commands::ClipboardSet(_) | Commands::ClipboardClear => None,
@ -193,7 +193,8 @@ fn validate_args(cmd: &Commands) -> Result<(), AppError> {
| Commands::Version
| Commands::Batch(_)
| Commands::Skills(_)
| Commands::Session(_) => {}
| Commands::Session(_)
| Commands::Trace(_) => {}
}
Ok(())
}

View file

@ -75,6 +75,7 @@ fn command_name_is_covered(name: &str) -> bool {
| "batch"
| "skills"
| "session"
| "trace"
)
}
@ -175,3 +176,25 @@ fn invalid_snapshot_root_is_rejected_before_permission_preflight() {
assert_eq!(err.code(), "INVALID_ARGS");
}
#[test]
fn trace_show_passes_preflight_with_permissions_denied() {
let report = PermissionReport {
accessibility: PermissionState::Denied {
suggestion: "denied".into(),
},
screen_recording: PermissionState::Denied {
suggestion: "denied".into(),
},
automation: PermissionState::Denied {
suggestion: "denied".into(),
},
};
let command = Commands::Trace(crate::cli_args::trace::TraceArgs {
action: crate::cli_args::trace::TraceAction::Show(crate::cli_args::trace::TraceShowArgs {
limit: 500,
event: None,
}),
});
assert!(preflight(&command, &report).is_ok());
}

View file

@ -1,5 +1,7 @@
mod notifications;
mod parse;
mod session;
mod trace;
use agent_desktop_core::{
PermissionReport,
@ -9,9 +11,8 @@ 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, session,
set_value, skills, snapshot, status, toggle, triple_click, type_text, uncheck, version,
wait,
resize_window, restore, right_click, screenshot, scroll, scroll_to, select, set_value,
skills, snapshot, status, toggle, triple_click, type_text, uncheck, version, wait,
},
context::CommandContext,
error::AppError,
@ -19,7 +20,6 @@ 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,
@ -33,6 +33,18 @@ pub(crate) fn dispatch(
context: &CommandContext,
) -> Result<Value, AppError> {
tracing::debug!("dispatch: {}", cmd.name());
let scope = context.command_scope(cmd.name());
let result = dispatch_inner(cmd, adapter, permission_report, context);
scope.complete(&result);
result
}
fn dispatch_inner(
cmd: Commands,
adapter: &dyn PlatformAdapter,
permission_report: &PermissionReport,
context: &CommandContext,
) -> Result<Value, AppError> {
match cmd {
Commands::Snapshot(a) => snapshot::execute(
snapshot::SnapshotArgs {
@ -366,21 +378,9 @@ 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.or_else(|| context.session_id().map(str::to_string)),
}),
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::Session(a) => session::dispatch(a, context),
Commands::Trace(a) => trace::dispatch(a, context),
Commands::Batch(a) => crate::batch::execute(a, adapter, permission_report, context),
}

23
src/dispatch/session.rs Normal file
View file

@ -0,0 +1,23 @@
use agent_desktop_core::{commands::session, context::CommandContext, error::AppError};
use serde_json::Value;
use crate::cli_args::session::{SessionAction, SessionArgs};
pub(crate) fn dispatch(args: SessionArgs, context: &CommandContext) -> Result<Value, AppError> {
match args.action {
SessionAction::Start(s) => session::execute(session::SessionAction::Start {
name: s.name,
no_trace: s.no_trace,
screenshots: s.screenshots,
force: s.force,
}),
SessionAction::End(e) => session::execute(session::SessionAction::End {
id: e.id.or_else(|| context.session_id().map(str::to_string)),
}),
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,
}),
}
}

23
src/dispatch/trace.rs Normal file
View file

@ -0,0 +1,23 @@
use agent_desktop_core::{commands::trace, context::CommandContext, error::AppError};
use serde_json::Value;
use crate::cli_args::trace::{TraceAction, TraceArgs};
pub(crate) fn dispatch(args: TraceArgs, context: &CommandContext) -> Result<Value, AppError> {
match args.action {
TraceAction::Show(show) => trace::execute(
trace::TraceAction::Show {
limit: show.limit,
event: show.event,
},
context,
),
TraceAction::Export(export) => trace::execute(
trace::TraceAction::Export {
limit: export.limit,
out: export.out,
},
context,
),
}
}

View file

@ -319,6 +319,37 @@ assert "trace recorded resolver events" "$has_events" "bytes=$bytes resolver_eve
assert "typed secret NOT in trace" "$([ "$leaked" = "0" ] && echo 1 || echo 0)" "secret_present=$([ "$leaked" = 1 ] && echo YES || echo no) redaction_markers=$([ "$redacted" = 1 ] && echo yes || echo no)"
rm -f "$trf"
note "trace show/export with replay artifacts"
trace_start="$("$bin" session start --screenshots --force --name e2e-trace 2>/dev/null)"
trace_sess="$(echo "$trace_start" | field "['data']['session_id']")"
"$bin" --session "$trace_sess" snapshot --app "$app" -i >/dev/null 2>&1; sleep 0.3
"$bin" --session "$trace_sess" click "$(resolve button primary-button)" >/dev/null 2>&1; sleep 0.3
"$bin" --session "$trace_sess" type "$(resolve textfield text-input)" "trace-e2e" >/dev/null 2>&1; sleep 0.5
trace_show="$("$bin" --session "$trace_sess" trace show --limit 0 2>/dev/null)"
trace_parse="$(echo "$trace_show" | python3 -c "import json,sys
d=json.load(sys.stdin)
ev=[e.get('event') for e in d.get('data',{}).get('events',[])]
print(int('command.start' in ev and 'command.end' in ev and 'snapshot.saved' in ev))" 2>/dev/null)"
trace_dir="${HOME}/.agent-desktop/sessions/${trace_sess}/trace"
png_magic=0
if [ -d "$trace_dir/screens" ]; then
first_png="$(find "$trace_dir/screens" -name '*.png' -print -quit 2>/dev/null)"
if [ -n "$first_png" ] && [ "$(head -c 8 "$first_png" | xxd -p)" = "89504e470d0a1a0a" ]; then
png_magic=1
fi
fi
assert "trace show lists command and snapshot events" "$trace_parse" "events parsed from envelope"
assert "artifact PNGs exist with PNG magic" "$png_magic" "trace_dir=$trace_dir/screens"
trace_html="$(mktemp -t agentdesk-e2e-trace-export.XXXXXX.html)"
"$bin" --session "$trace_sess" trace export --out "$trace_html" --limit 0 >/dev/null 2>&1
html_ok=0
if [ -f "$trace_html" ] && ! grep -q 'src="http' "$trace_html" && grep -q 'application/json' "$trace_html"; then
html_ok=1
fi
assert "trace export is self-contained HTML" "$html_ok" "path=$trace_html"
rm -f "$trace_html"
"$bin" session end "$trace_sess" >/dev/null 2>&1
note "surface: open sheet, list-surfaces sees it, act inside"
sheet_b="$(read_value sheet-status)"
"$bin" click "$(resolve button open-sheet)" >/dev/null 2>&1; sleep 0.6

View file

@ -0,0 +1,5 @@
{"event":"trace.meta","schema":1,"version":"0.0.0-test","os":"test","pid":100,"proc_start_ms":1000,"ts_ms":0,"seq":0}
{"event":"snapshot.saved","snapshot_id":"snap-a","ref_count":2,"app":"Finder","ts_ms":10,"seq":1}
{"event":"command.start","command":"click","ts_ms":20,"seq":2}
{"event":"command.end","command":"click","ok":true,"duration_ms":5,"ts_ms":25,"seq":3}
{"event":"partial

View file

@ -0,0 +1 @@
{"event":"snapshot.saved","snapshot_id":"snap-b","ref_count":1,"ts_ms":15,"seq":1}