mirror of
https://github.com/lahfir/agent-desktop.git
synced 2026-07-26 17:12:15 +00:00
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:
parent
e16b218653
commit
e3e1872ff3
83 changed files with 5961 additions and 398 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -82,3 +82,6 @@ todos/
|
|||
.cursor/
|
||||
.context/
|
||||
rust_out
|
||||
|
||||
# trace export artifacts must never land in the repo tree
|
||||
trace-*.html
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
11
CONCEPTS.md
11
CONCEPTS.md
|
|
@ -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.
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -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**: 78–96% 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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
111
crates/core/src/commands/trace.rs
Normal file
111
crates/core/src/commands/trace.rs
Normal 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;
|
||||
350
crates/core/src/commands/trace_tests.rs
Normal file
350
crates/core/src/commands/trace_tests.rs
Normal 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());
|
||||
}
|
||||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
151
crates/core/src/context_scope_tests.rs
Normal file
151
crates/core/src/context_scope_tests.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 }),
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
116
crates/core/src/session/session_gc_tests.rs
Normal file
116
crates/core/src/session/session_gc_tests.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
311
crates/core/src/trace_artifacts.rs
Normal file
311
crates/core/src/trace_artifacts.rs
Normal 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;
|
||||
240
crates/core/src/trace_artifacts_more_tests.rs
Normal file
240
crates/core/src/trace_artifacts_more_tests.rs
Normal 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"
|
||||
);
|
||||
}
|
||||
305
crates/core/src/trace_artifacts_tests.rs
Normal file
305
crates/core/src/trace_artifacts_tests.rs
Normal 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"),
|
||||
}
|
||||
}
|
||||
267
crates/core/src/trace_read/html.rs
Normal file
267
crates/core/src/trace_read/html.rs
Normal 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;
|
||||
97
crates/core/src/trace_read/html_export_stats_tests.rs
Normal file
97
crates/core/src/trace_read/html_export_stats_tests.rs
Normal 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
|
||||
);
|
||||
}
|
||||
145
crates/core/src/trace_read/html_screenshot_tests.rs
Normal file
145
crates/core/src/trace_read/html_screenshot_tests.rs
Normal 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));
|
||||
}
|
||||
356
crates/core/src/trace_read/html_tests.rs
Normal file
356
crates/core/src/trace_read/html_tests.rs
Normal 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,"));
|
||||
}
|
||||
140
crates/core/src/trace_read/merge.rs
Normal file
140
crates/core/src/trace_read/merge.rs
Normal 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
|
||||
}
|
||||
133
crates/core/src/trace_read/merge_tests.rs
Normal file
133
crates/core/src/trace_read/merge_tests.rs
Normal 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);
|
||||
}
|
||||
200
crates/core/src/trace_read/mod.rs
Normal file
200
crates/core/src/trace_read/mod.rs
Normal 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;
|
||||
346
crates/core/src/trace_read/mod_tests.rs
Normal file
346
crates/core/src/trace_read/mod_tests.rs
Normal 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"]);
|
||||
}
|
||||
142
crates/core/src/trace_read/segment.rs
Normal file
142
crates/core/src/trace_read/segment.rs
Normal 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))
|
||||
}
|
||||
202
crates/core/src/trace_read/segment_tests.rs
Normal file
202
crates/core/src/trace_read/segment_tests.rs
Normal 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"]);
|
||||
}
|
||||
237
crates/core/src/trace_read/viewer.css
Normal file
237
crates/core/src/trace_read/viewer.css
Normal 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; }
|
||||
}
|
||||
47
crates/core/src/trace_read/viewer.html
Normal file
47
crates/core/src/trace_read/viewer.html
Normal 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">↑↓ 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>
|
||||
347
crates/core/src/trace_read/viewer.js
Normal file
347
crates/core/src/trace_read/viewer.js
Normal 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();
|
||||
})();
|
||||
88
crates/core/src/trace_sanitize.rs
Normal file
88
crates/core/src/trace_sanitize.rs
Normal 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;
|
||||
58
crates/core/src/trace_sanitize_tests.rs
Normal file
58
crates/core/src/trace_sanitize_tests.rs
Normal 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);
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
69
crates/ffi/codegen_templates/trace_export.rs.in
Normal file
69
crates/ffi/codegen_templates/trace_export.rs.in
Normal 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) }
|
||||
})
|
||||
}
|
||||
69
crates/ffi/codegen_templates/trace_show.rs.in
Normal file
69
crates/ffi/codegen_templates/trace_show.rs.in
Normal 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) }
|
||||
})
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ const COMMAND_SPECIFIC_TESTS: &[&str] = &[
|
|||
"session",
|
||||
"snapshot",
|
||||
"status",
|
||||
"trace",
|
||||
"wait",
|
||||
];
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
40
src/cli_args/trace.rs
Normal 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,
|
||||
}
|
||||
|
|
@ -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(())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
23
src/dispatch/session.rs
Normal 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
23
src/dispatch/trace.rs
Normal 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,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
5
tests/fixtures/trace_show/trace/100-1000.jsonl
vendored
Normal file
5
tests/fixtures/trace_show/trace/100-1000.jsonl
vendored
Normal 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
|
||||
1
tests/fixtures/trace_show/trace/200-2000.jsonl
vendored
Normal file
1
tests/fixtures/trace_show/trace/200-2000.jsonl
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
{"event":"snapshot.saved","snapshot_id":"snap-b","ref_count":1,"ts_ms":15,"seq":1}
|
||||
Loading…
Reference in a new issue