| 1 |
|
- |
//! Trace ingestion, redaction, and recording commands
|
|
1
|
+ |
//! Local agent-trace discovery, summary, and redaction.
|
|
2
|
+ |
//!
|
|
3
|
+ |
//! This is the Rust port of `packages/openagents-cli/src/trace-store.ts` and
|
|
4
|
+ |
//! `trace-command.ts`. It replaces a module that returned two hardcoded sessions
|
|
5
|
+ |
//! from `scan_foreign_sessions` and a `redact_trace` that swapped the *prefix* of a
|
|
6
|
+ |
//! secret — turning `sk-liveSECRET` into `[REDACTED_KEY]liveSECRET` — while writing
|
|
7
|
+ |
//! no file and reporting success. A redaction command that leaves the key in place
|
|
8
|
+ |
//! is worse than no command, because whoever ran it has been told the trace is safe.
|
|
9
|
+ |
//!
|
|
10
|
+ |
//! Nothing here invents a value. Discovery reports only files it actually stat'ed,
|
|
11
|
+ |
//! `summarize` reports only fields the document carries, and redaction reports
|
|
12
|
+ |
//! counts per category without ever echoing the matched text.
|
| 2 |
13
|
|
|
|
14
|
+ |
use regex::{Captures, Regex};
|
| 3 |
15
|
|
use serde::{Deserialize, Serialize};
|
|
16
|
+ |
use std::collections::{BTreeMap, BTreeSet, VecDeque};
|
|
17
|
+ |
use std::fs;
|
|
18
|
+ |
use std::path::{Path, PathBuf};
|
| 4 |
19
|
|
|
| 5 |
|
- |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
| 6 |
|
- |
pub struct SessionTrace {
|
| 7 |
|
- |
pub session_id: String,
|
| 8 |
|
- |
pub agent_name: String,
|
| 9 |
|
- |
pub step_count: usize,
|
| 10 |
|
- |
pub created_at: u64,
|
|
20
|
+ |
/// Which store a file was found in. It is a property of the directory the file was
|
|
21
|
+ |
/// found under, never inferred from the file's name or contents.
|
|
22
|
+ |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
23
|
+ |
#[serde(rename_all = "snake_case")]
|
|
24
|
+ |
pub enum TraceSourceKind {
|
|
25
|
+ |
OpenagentsExport,
|
|
26
|
+ |
ClaudeSession,
|
|
27
|
+ |
CodexSession,
|
|
28
|
+ |
TracePath,
|
|
29
|
+ |
}
|
|
30
|
+ |
|
|
31
|
+ |
impl TraceSourceKind {
|
|
32
|
+ |
pub fn as_str(self) -> &'static str {
|
|
33
|
+ |
match self {
|
|
34
|
+ |
Self::OpenagentsExport => "openagents_export",
|
|
35
|
+ |
Self::ClaudeSession => "claude_session",
|
|
36
|
+ |
Self::CodexSession => "codex_session",
|
|
37
|
+ |
Self::TracePath => "trace_path",
|
|
38
|
+ |
}
|
|
39
|
+ |
}
|
| 11 |
40
|
|
}
|
| 12 |
41
|
|
|
| 13 |
|
- |
pub struct TraceStore {
|
| 14 |
|
- |
pub traces: Vec<SessionTrace>,
|
|
42
|
+ |
#[derive(Debug, Clone)]
|
|
43
|
+ |
pub struct TraceStoreSpec {
|
|
44
|
+ |
pub root: PathBuf,
|
|
45
|
+ |
pub kind: TraceSourceKind,
|
|
46
|
+ |
pub extensions: Vec<&'static str>,
|
| 15 |
47
|
|
}
|
| 16 |
48
|
|
|
| 17 |
|
- |
impl TraceStore {
|
| 18 |
|
- |
pub fn new() -> Self {
|
| 19 |
|
- |
Self { traces: Vec::new() }
|
|
49
|
+ |
/// One discovered file. Its only identifier is its absolute path: discovery derives
|
|
50
|
+ |
/// no session id, and `trace show` resolves by path, so inventing an id here would
|
|
51
|
+ |
/// add a lookup that has nothing behind it.
|
|
52
|
+ |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
53
|
+ |
pub struct TraceCandidate {
|
|
54
|
+ |
pub path: PathBuf,
|
|
55
|
+ |
pub kind: TraceSourceKind,
|
|
56
|
+ |
pub bytes: u64,
|
|
57
|
+ |
pub modified_at: String,
|
|
58
|
+ |
}
|
|
59
|
+ |
|
|
60
|
+ |
/// What one store yielded, including what was refused and why.
|
|
61
|
+ |
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
62
|
+ |
pub struct TraceStoreScan {
|
|
63
|
+ |
pub root: PathBuf,
|
|
64
|
+ |
pub kind: TraceSourceKind,
|
|
65
|
+ |
pub present: bool,
|
|
66
|
+ |
pub matched: usize,
|
|
67
|
+ |
pub listed: usize,
|
|
68
|
+ |
pub skipped_symlinks: usize,
|
|
69
|
+ |
pub truncated: bool,
|
|
70
|
+ |
}
|
|
71
|
+ |
|
|
72
|
+ |
#[derive(Debug, Clone, Copy)]
|
|
73
|
+ |
pub struct DiscoveryBounds {
|
|
74
|
+ |
pub max_depth: usize,
|
|
75
|
+ |
pub max_files_per_store: usize,
|
|
76
|
+ |
pub max_scan_entries: usize,
|
|
77
|
+ |
}
|
|
78
|
+ |
|
|
79
|
+ |
impl Default for DiscoveryBounds {
|
|
80
|
+ |
fn default() -> Self {
|
|
81
|
+ |
Self {
|
|
82
|
+ |
max_depth: 4,
|
|
83
|
+ |
max_files_per_store: 20,
|
|
84
|
+ |
max_scan_entries: 5000,
|
|
85
|
+ |
}
|
| 20 |
86
|
|
}
|
|
87
|
+ |
}
|
| 21 |
88
|
|
|
| 22 |
|
- |
pub fn scan_foreign_sessions() -> Vec<SessionTrace> {
|
| 23 |
|
- |
vec![
|
| 24 |
|
- |
SessionTrace {
|
| 25 |
|
- |
session_id: "claude_sess_01".to_string(),
|
| 26 |
|
- |
agent_name: "claude-code".to_string(),
|
| 27 |
|
- |
step_count: 42,
|
| 28 |
|
- |
created_at: 1724600000,
|
| 29 |
|
- |
},
|
| 30 |
|
- |
SessionTrace {
|
| 31 |
|
- |
session_id: "codex_sess_01".to_string(),
|
| 32 |
|
- |
agent_name: "codex-cli".to_string(),
|
| 33 |
|
- |
step_count: 18,
|
| 34 |
|
- |
created_at: 1724600100,
|
|
89
|
+ |
/// The three stores a machine carries by default, in listing order.
|
|
90
|
+ |
pub fn default_trace_stores(home: &Path) -> Vec<TraceStoreSpec> {
|
|
91
|
+ |
vec![
|
|
92
|
+ |
TraceStoreSpec {
|
|
93
|
+ |
root: home.join(".openagents").join("exports"),
|
|
94
|
+ |
kind: TraceSourceKind::OpenagentsExport,
|
|
95
|
+ |
extensions: vec![".json"],
|
|
96
|
+ |
},
|
|
97
|
+ |
TraceStoreSpec {
|
|
98
|
+ |
root: home.join(".claude").join("projects"),
|
|
99
|
+ |
kind: TraceSourceKind::ClaudeSession,
|
|
100
|
+ |
extensions: vec![".jsonl"],
|
|
101
|
+ |
},
|
|
102
|
+ |
TraceStoreSpec {
|
|
103
|
+ |
root: home.join(".codex").join("sessions"),
|
|
104
|
+ |
kind: TraceSourceKind::CodexSession,
|
|
105
|
+ |
extensions: vec![".jsonl"],
|
|
106
|
+ |
},
|
|
107
|
+ |
]
|
|
108
|
+ |
}
|
|
109
|
+ |
|
|
110
|
+ |
/// A store the caller named, through `--path` or `OPENAGENTS_TRACE_PATHS`.
|
|
111
|
+ |
pub fn path_trace_store(root: PathBuf) -> TraceStoreSpec {
|
|
112
|
+ |
TraceStoreSpec {
|
|
113
|
+ |
root,
|
|
114
|
+ |
kind: TraceSourceKind::TracePath,
|
|
115
|
+ |
extensions: vec![".json", ".jsonl"],
|
|
116
|
+ |
}
|
|
117
|
+ |
}
|
|
118
|
+ |
|
|
119
|
+ |
/// Extra stores from `OPENAGENTS_TRACE_PATHS`, a colon-separated list.
|
|
120
|
+ |
pub fn extra_path_stores() -> Vec<TraceStoreSpec> {
|
|
121
|
+ |
std::env::var("OPENAGENTS_TRACE_PATHS")
|
|
122
|
+ |
.unwrap_or_default()
|
|
123
|
+ |
.split(':')
|
|
124
|
+ |
.map(str::trim)
|
|
125
|
+ |
.filter(|entry| !entry.is_empty())
|
|
126
|
+ |
.map(|entry| path_trace_store(PathBuf::from(entry)))
|
|
127
|
+ |
.collect()
|
|
128
|
+ |
}
|
|
129
|
+ |
|
|
130
|
+ |
fn matches_extension(name: &str, extensions: &[&str]) -> bool {
|
|
131
|
+ |
extensions.iter().any(|ext| name.ends_with(ext))
|
|
132
|
+ |
}
|
|
133
|
+ |
|
|
134
|
+ |
/// Walk one store breadth-first within `bounds`, newest file first.
|
|
135
|
+ |
///
|
|
136
|
+ |
/// Symlinks are never followed and always counted: a symlinked root is refused
|
|
137
|
+ |
/// outright, and a symlinked entry is skipped, so a planted link can neither escape
|
|
138
|
+ |
/// the store nor spin the walk in a loop.
|
|
139
|
+ |
pub fn scan_store(spec: &TraceStoreSpec, bounds: DiscoveryBounds) -> (TraceStoreScan, Vec<TraceCandidate>) {
|
|
140
|
+ |
let root_meta = fs::symlink_metadata(&spec.root).ok();
|
|
141
|
+ |
let root_is_symlink = root_meta.as_ref().is_some_and(|m| m.file_type().is_symlink());
|
|
142
|
+ |
let usable = root_meta.as_ref().is_some_and(|m| m.is_dir()) && !root_is_symlink;
|
|
143
|
+ |
|
|
144
|
+ |
if !usable {
|
|
145
|
+ |
return (
|
|
146
|
+ |
TraceStoreScan {
|
|
147
|
+ |
root: spec.root.clone(),
|
|
148
|
+ |
kind: spec.kind,
|
|
149
|
+ |
present: false,
|
|
150
|
+ |
matched: 0,
|
|
151
|
+ |
listed: 0,
|
|
152
|
+ |
skipped_symlinks: usize::from(root_is_symlink),
|
|
153
|
+ |
truncated: false,
|
| 35 |
154
|
|
},
|
| 36 |
|
- |
]
|
|
155
|
+ |
Vec::new(),
|
|
156
|
+ |
);
|
|
157
|
+ |
}
|
|
158
|
+ |
|
|
159
|
+ |
let mut found: Vec<(PathBuf, u64, std::time::SystemTime)> = Vec::new();
|
|
160
|
+ |
let mut skipped_symlinks = 0usize;
|
|
161
|
+ |
let mut visited = 0usize;
|
|
162
|
+ |
let mut truncated = false;
|
|
163
|
+ |
let mut queue: VecDeque<(PathBuf, usize)> = VecDeque::from([(spec.root.clone(), 0usize)]);
|
|
164
|
+ |
|
|
165
|
+ |
'walk: while let Some((directory, depth)) = queue.pop_front() {
|
|
166
|
+ |
let entries = match fs::read_dir(&directory) {
|
|
167
|
+ |
Ok(entries) => entries,
|
|
168
|
+ |
// An unreadable directory is skipped, not reported as a file.
|
|
169
|
+ |
Err(_) => continue,
|
|
170
|
+ |
};
|
|
171
|
+ |
for entry in entries.flatten() {
|
|
172
|
+ |
visited += 1;
|
|
173
|
+ |
if visited >= bounds.max_scan_entries {
|
|
174
|
+ |
truncated = true;
|
|
175
|
+ |
break 'walk;
|
|
176
|
+ |
}
|
|
177
|
+ |
let path = entry.path();
|
|
178
|
+ |
let Ok(meta) = fs::symlink_metadata(&path) else {
|
|
179
|
+ |
continue;
|
|
180
|
+ |
};
|
|
181
|
+ |
if meta.file_type().is_symlink() {
|
|
182
|
+ |
skipped_symlinks += 1;
|
|
183
|
+ |
continue;
|
|
184
|
+ |
}
|
|
185
|
+ |
if meta.is_dir() {
|
|
186
|
+ |
if depth < bounds.max_depth {
|
|
187
|
+ |
queue.push_back((path, depth + 1));
|
|
188
|
+ |
}
|
|
189
|
+ |
continue;
|
|
190
|
+ |
}
|
|
191
|
+ |
let name = entry.file_name().to_string_lossy().into_owned();
|
|
192
|
+ |
if meta.is_file() && matches_extension(&name, &spec.extensions) {
|
|
193
|
+ |
let mtime = meta.modified().unwrap_or(std::time::UNIX_EPOCH);
|
|
194
|
+ |
found.push((path, meta.len(), mtime));
|
|
195
|
+ |
}
|
|
196
|
+ |
}
|
|
197
|
+ |
}
|
|
198
|
+ |
|
|
199
|
+ |
let matched = found.len();
|
|
200
|
+ |
found.sort_by(|a, b| b.2.cmp(&a.2));
|
|
201
|
+ |
let candidates: Vec<TraceCandidate> = found
|
|
202
|
+ |
.into_iter()
|
|
203
|
+ |
.take(bounds.max_files_per_store)
|
|
204
|
+ |
.map(|(path, bytes, mtime)| TraceCandidate {
|
|
205
|
+ |
path,
|
|
206
|
+ |
kind: spec.kind,
|
|
207
|
+ |
bytes,
|
|
208
|
+ |
modified_at: iso8601_utc(mtime),
|
|
209
|
+ |
})
|
|
210
|
+ |
.collect();
|
|
211
|
+ |
|
|
212
|
+ |
(
|
|
213
|
+ |
TraceStoreScan {
|
|
214
|
+ |
root: spec.root.clone(),
|
|
215
|
+ |
kind: spec.kind,
|
|
216
|
+ |
present: true,
|
|
217
|
+ |
matched,
|
|
218
|
+ |
listed: candidates.len(),
|
|
219
|
+ |
skipped_symlinks,
|
|
220
|
+ |
truncated,
|
|
221
|
+ |
},
|
|
222
|
+ |
candidates,
|
|
223
|
+ |
)
|
|
224
|
+ |
}
|
|
225
|
+ |
|
|
226
|
+ |
/// Scan every store and merge the candidates, newest first across all of them.
|
|
227
|
+ |
pub fn discover(specs: &[TraceStoreSpec], bounds: DiscoveryBounds) -> (Vec<TraceStoreScan>, Vec<TraceCandidate>) {
|
|
228
|
+ |
let mut scans = Vec::with_capacity(specs.len());
|
|
229
|
+ |
let mut candidates = Vec::new();
|
|
230
|
+ |
for spec in specs {
|
|
231
|
+ |
let (scan, mut found) = scan_store(spec, bounds);
|
|
232
|
+ |
scans.push(scan);
|
|
233
|
+ |
candidates.append(&mut found);
|
|
234
|
+ |
}
|
|
235
|
+ |
candidates.sort_by(|a, b| b.modified_at.cmp(&a.modified_at));
|
|
236
|
+ |
(scans, candidates)
|
|
237
|
+ |
}
|
|
238
|
+ |
|
|
239
|
+ |
/// Format a `SystemTime` as `2026-08-26T05:44:44.859Z`, matching the ISO strings the
|
|
240
|
+ |
/// TypeScript CLI reports so the two listings can be compared line for line.
|
|
241
|
+ |
fn iso8601_utc(time: std::time::SystemTime) -> String {
|
|
242
|
+ |
let duration = time
|
|
243
|
+ |
.duration_since(std::time::UNIX_EPOCH)
|
|
244
|
+ |
.unwrap_or_default();
|
|
245
|
+ |
let total_secs = duration.as_secs() as i64;
|
|
246
|
+ |
let millis = duration.subsec_millis();
|
|
247
|
+ |
|
|
248
|
+ |
let days = total_secs.div_euclid(86_400);
|
|
249
|
+ |
let secs_of_day = total_secs.rem_euclid(86_400);
|
|
250
|
+ |
let (year, month, day) = civil_from_days(days);
|
|
251
|
+ |
format!(
|
|
252
|
+ |
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}.{:03}Z",
|
|
253
|
+ |
year,
|
|
254
|
+ |
month,
|
|
255
|
+ |
day,
|
|
256
|
+ |
secs_of_day / 3600,
|
|
257
|
+ |
(secs_of_day % 3600) / 60,
|
|
258
|
+ |
secs_of_day % 60,
|
|
259
|
+ |
millis
|
|
260
|
+ |
)
|
|
261
|
+ |
}
|
|
262
|
+ |
|
|
263
|
+ |
/// Howard Hinnant's `civil_from_days`: days since the Unix epoch to a civil date.
|
|
264
|
+ |
fn civil_from_days(days: i64) -> (i64, u32, u32) {
|
|
265
|
+ |
let z = days + 719_468;
|
|
266
|
+ |
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
|
267
|
+ |
let doe = (z - era * 146_097) as u64;
|
|
268
|
+ |
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
|
269
|
+ |
let y = yoe as i64 + era * 400;
|
|
270
|
+ |
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
|
271
|
+ |
let mp = (5 * doy + 2) / 153;
|
|
272
|
+ |
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
|
273
|
+ |
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
|
274
|
+ |
(if m <= 2 { y + 1 } else { y }, m, d)
|
|
275
|
+ |
}
|
|
276
|
+ |
|
|
277
|
+ |
// ---------------------------------------------------------------------------
|
|
278
|
+ |
// Summary
|
|
279
|
+ |
// ---------------------------------------------------------------------------
|
|
280
|
+ |
|
|
281
|
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
282
|
+ |
pub struct TraceSummary {
|
|
283
|
+ |
pub path: PathBuf,
|
|
284
|
+ |
/// `atif`, `jsonl`, or `unknown`. Detected from the content, then the extension.
|
|
285
|
+ |
pub format: String,
|
|
286
|
+ |
pub bytes: u64,
|
|
287
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
288
|
+ |
pub lines: Option<usize>,
|
|
289
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
290
|
+ |
pub schema_version: Option<String>,
|
|
291
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
292
|
+ |
pub session_id: Option<String>,
|
|
293
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
294
|
+ |
pub agent_name: Option<String>,
|
|
295
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
296
|
+ |
pub agent_model: Option<String>,
|
|
297
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
298
|
+ |
pub steps: Option<usize>,
|
|
299
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
300
|
+ |
pub steps_by_source: Option<BTreeMap<String, usize>>,
|
|
301
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
302
|
+ |
pub models: Option<Vec<String>>,
|
|
303
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
304
|
+ |
pub tool_calls: Option<usize>,
|
|
305
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
306
|
+ |
pub total_prompt_tokens: Option<u64>,
|
|
307
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
308
|
+ |
pub total_completion_tokens: Option<u64>,
|
|
309
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
310
|
+ |
pub first_timestamp: Option<String>,
|
|
311
|
+ |
#[serde(skip_serializing_if = "Option::is_none")]
|
|
312
|
+ |
pub last_timestamp: Option<String>,
|
|
313
|
+ |
}
|
|
314
|
+ |
|
|
315
|
+ |
fn str_field(value: &serde_json::Value, key: &str) -> Option<String> {
|
|
316
|
+ |
value.get(key).and_then(|v| v.as_str()).map(String::from)
|
|
317
|
+ |
}
|
|
318
|
+ |
|
|
319
|
+ |
/// Read a trace file and report only what it actually carries.
|
|
320
|
+ |
///
|
|
321
|
+ |
/// A file that is not an ATIF document is reported as what it is — a line-delimited
|
|
322
|
+ |
/// log, or an unknown format — rather than being given invented step counts.
|
|
323
|
+ |
pub fn summarize_trace_file(path: &Path) -> std::io::Result<TraceSummary> {
|
|
324
|
+ |
let text = fs::read_to_string(path)?;
|
|
325
|
+ |
let bytes = text.len() as u64;
|
|
326
|
+ |
|
|
327
|
+ |
let document = serde_json::from_str::<serde_json::Value>(&text)
|
|
328
|
+ |
.ok()
|
|
329
|
+ |
.filter(|v| v.is_object());
|
|
330
|
+ |
let steps = document
|
|
331
|
+ |
.as_ref()
|
|
332
|
+ |
.and_then(|d| d.get("steps"))
|
|
333
|
+ |
.and_then(|v| v.as_array());
|
|
334
|
+ |
|
|
335
|
+ |
let base = TraceSummary {
|
|
336
|
+ |
path: path.to_path_buf(),
|
|
337
|
+ |
format: "unknown".to_string(),
|
|
338
|
+ |
bytes,
|
|
339
|
+ |
lines: None,
|
|
340
|
+ |
schema_version: None,
|
|
341
|
+ |
session_id: None,
|
|
342
|
+ |
agent_name: None,
|
|
343
|
+ |
agent_model: None,
|
|
344
|
+ |
steps: None,
|
|
345
|
+ |
steps_by_source: None,
|
|
346
|
+ |
models: None,
|
|
347
|
+ |
tool_calls: None,
|
|
348
|
+ |
total_prompt_tokens: None,
|
|
349
|
+ |
total_completion_tokens: None,
|
|
350
|
+ |
first_timestamp: None,
|
|
351
|
+ |
last_timestamp: None,
|
|
352
|
+ |
};
|
|
353
|
+ |
|
|
354
|
+ |
let (Some(document), Some(steps)) = (document.as_ref(), steps) else {
|
|
355
|
+ |
if path.to_string_lossy().ends_with(".jsonl") {
|
|
356
|
+ |
return Ok(TraceSummary {
|
|
357
|
+ |
format: "jsonl".to_string(),
|
|
358
|
+ |
lines: Some(text.lines().filter(|l| !l.trim().is_empty()).count()),
|
|
359
|
+ |
..base
|
|
360
|
+ |
});
|
|
361
|
+ |
}
|
|
362
|
+ |
return Ok(base);
|
|
363
|
+ |
};
|
|
364
|
+ |
|
|
365
|
+ |
let mut steps_by_source: BTreeMap<String, usize> = BTreeMap::new();
|
|
366
|
+ |
let mut models: BTreeSet<String> = BTreeSet::new();
|
|
367
|
+ |
let mut tool_calls = 0usize;
|
|
368
|
+ |
let mut prompt_tokens = 0u64;
|
|
369
|
+ |
let mut completion_tokens = 0u64;
|
|
370
|
+ |
let mut saw_tokens = false;
|
|
371
|
+ |
|
|
372
|
+ |
for step in steps.iter().filter(|s| s.is_object()) {
|
|
373
|
+ |
let source = str_field(step, "source").unwrap_or_else(|| "unknown".to_string());
|
|
374
|
+ |
*steps_by_source.entry(source).or_insert(0) += 1;
|
|
375
|
+ |
if let Some(model) = str_field(step, "model_name") {
|
|
376
|
+ |
models.insert(model);
|
|
377
|
+ |
}
|
|
378
|
+ |
if let Some(calls) = step.get("tool_calls").and_then(|v| v.as_array()) {
|
|
379
|
+ |
tool_calls += calls.len();
|
|
380
|
+ |
}
|
|
381
|
+ |
if let Some(metrics) = step.get("metrics") {
|
|
382
|
+ |
if let Some(n) = metrics.get("prompt_tokens").and_then(|v| v.as_u64()) {
|
|
383
|
+ |
prompt_tokens += n;
|
|
384
|
+ |
saw_tokens = true;
|
|
385
|
+ |
}
|
|
386
|
+ |
if let Some(n) = metrics.get("completion_tokens").and_then(|v| v.as_u64()) {
|
|
387
|
+ |
completion_tokens += n;
|
|
388
|
+ |
saw_tokens = true;
|
|
389
|
+ |
}
|
|
390
|
+ |
}
|
|
391
|
+ |
}
|
|
392
|
+ |
|
|
393
|
+ |
let final_metrics = document.get("final_metrics");
|
|
394
|
+ |
let final_prompt = final_metrics
|
|
395
|
+ |
.and_then(|m| m.get("total_prompt_tokens"))
|
|
396
|
+ |
.and_then(|v| v.as_u64());
|
|
397
|
+ |
let final_completion = final_metrics
|
|
398
|
+ |
.and_then(|m| m.get("total_completion_tokens"))
|
|
399
|
+ |
.and_then(|v| v.as_u64());
|
|
400
|
+ |
|
|
401
|
+ |
// The document's own totals win; the per-step sums are a fallback, and when
|
|
402
|
+ |
// neither exists the fields are omitted rather than reported as zero.
|
|
403
|
+ |
let (total_prompt_tokens, total_completion_tokens) =
|
|
404
|
+ |
if final_prompt.is_some() || final_completion.is_some() {
|
|
405
|
+ |
(final_prompt, final_completion)
|
|
406
|
+ |
} else if saw_tokens {
|
|
407
|
+ |
(Some(prompt_tokens), Some(completion_tokens))
|
|
408
|
+ |
} else {
|
|
409
|
+ |
(None, None)
|
|
410
|
+ |
};
|
|
411
|
+ |
|
|
412
|
+ |
let agent = document.get("agent");
|
|
413
|
+ |
Ok(TraceSummary {
|
|
414
|
+ |
format: "atif".to_string(),
|
|
415
|
+ |
schema_version: str_field(document, "schema_version"),
|
|
416
|
+ |
session_id: str_field(document, "session_id"),
|
|
417
|
+ |
agent_name: agent.and_then(|a| str_field(a, "name")),
|
|
418
|
+ |
agent_model: agent.and_then(|a| str_field(a, "model_name")),
|
|
419
|
+ |
steps: Some(steps.len()),
|
|
420
|
+ |
steps_by_source: Some(steps_by_source),
|
|
421
|
+ |
models: Some(models.into_iter().collect()),
|
|
422
|
+ |
tool_calls: Some(tool_calls),
|
|
423
|
+ |
total_prompt_tokens,
|
|
424
|
+ |
total_completion_tokens,
|
|
425
|
+ |
first_timestamp: steps.first().and_then(|s| str_field(s, "timestamp")),
|
|
426
|
+ |
last_timestamp: steps.last().and_then(|s| str_field(s, "timestamp")),
|
|
427
|
+ |
..base
|
|
428
|
+ |
})
|
|
429
|
+ |
}
|
|
430
|
+ |
|
|
431
|
+ |
// ---------------------------------------------------------------------------
|
|
432
|
+ |
// Redaction
|
|
433
|
+ |
// ---------------------------------------------------------------------------
|
|
434
|
+ |
|
|
435
|
+ |
/// Where a redacted copy is written. `.jsonl` is checked first.
|
|
436
|
+ |
pub fn redacted_path_for(path: &Path) -> PathBuf {
|
|
437
|
+ |
let text = path.to_string_lossy();
|
|
438
|
+ |
if let Some(stem) = text.strip_suffix(".jsonl") {
|
|
439
|
+ |
PathBuf::from(format!("{}.redacted.jsonl", stem))
|
|
440
|
+ |
} else if let Some(stem) = text.strip_suffix(".json") {
|
|
441
|
+ |
PathBuf::from(format!("{}.redacted.json", stem))
|
|
442
|
+ |
} else {
|
|
443
|
+ |
PathBuf::from(format!("{}.redacted.json", text))
|
|
444
|
+ |
}
|
|
445
|
+ |
}
|
|
446
|
+ |
|
|
447
|
+ |
/// True when the path is itself a redacted copy.
|
|
448
|
+ |
pub fn is_redacted_copy(path: &Path) -> bool {
|
|
449
|
+ |
let text = path.to_string_lossy();
|
|
450
|
+ |
text.ends_with(".redacted.json") || text.ends_with(".redacted.jsonl")
|
|
451
|
+ |
}
|
|
452
|
+ |
|
|
453
|
+ |
/// What redaction removed, counted by category. Never the matched text.
|
|
454
|
+ |
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
|
455
|
+ |
pub struct Redaction {
|
|
456
|
+ |
pub text: String,
|
|
457
|
+ |
pub counts: BTreeMap<String, usize>,
|
|
458
|
+ |
pub total: usize,
|
|
459
|
+ |
}
|
|
460
|
+ |
|
|
461
|
+ |
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
462
|
+ |
pub struct RedactionResult {
|
|
463
|
+ |
pub input: PathBuf,
|
|
464
|
+ |
pub output: PathBuf,
|
|
465
|
+ |
pub counts: BTreeMap<String, usize>,
|
|
466
|
+ |
pub total: usize,
|
|
467
|
+ |
/// `None` when the input was not JSON to begin with, so re-parsing proves nothing.
|
|
468
|
+ |
pub valid_json: Option<bool>,
|
|
469
|
+ |
}
|
|
470
|
+ |
|
|
471
|
+ |
/// The number of consecutive BIP-39 words a run must reach before it is treated as a
|
|
472
|
+ |
/// seed phrase. Below this, the rule declines and the prose is left alone.
|
|
473
|
+ |
const MIN_SEED_WORDS: usize = 12;
|
|
474
|
+ |
|
|
475
|
+ |
struct RedactionRule {
|
|
476
|
+ |
category: &'static str,
|
|
477
|
+ |
pattern: Regex,
|
|
478
|
+ |
replacement: &'static str,
|
|
479
|
+ |
/// Rules whose decision needs more than the pattern. Returning the match
|
|
480
|
+ |
/// unchanged means the rule declined, and a decline is not counted.
|
|
481
|
+ |
resolve: Option<fn(&str) -> String>,
|
|
482
|
+ |
/// The bare `NAME=value` rule, which must not re-match the marker the quoted
|
|
483
|
+ |
/// rules just wrote. Stands in for the TypeScript `(?!\[REDACTED)` lookahead.
|
|
484
|
+ |
declines_redacted_capture: bool,
|
|
485
|
+ |
}
|
|
486
|
+ |
|
|
487
|
+ |
/// Every rule, in the order they run.
|
|
488
|
+ |
///
|
|
489
|
+ |
/// Order is load-bearing: the specific shapes run before the broad ones so a bearer
|
|
490
|
+ |
/// token is counted as `bearer_token` rather than swallowed by `env_value`, and each
|
|
491
|
+ |
/// rule sees the previous rule's substitutions.
|
|
492
|
+ |
fn redaction_rules(home: &str) -> Vec<RedactionRule> {
|
|
493
|
+ |
let mut rules = vec![
|
|
494
|
+ |
RedactionRule {
|
|
495
|
+ |
category: "seed_phrase",
|
|
496
|
+ |
pattern: Regex::new(r"\b(?:[a-z]{3,8} ){11}[a-z]{3,8}(?:(?: [a-z]{3,8}){3})*\b").unwrap(),
|
|
497
|
+ |
replacement: "[REDACTED:seed_phrase]",
|
|
498
|
+ |
resolve: Some(resolve_seed_phrase),
|
|
499
|
+ |
declines_redacted_capture: false,
|
|
500
|
+ |
},
|
|
501
|
+ |
RedactionRule {
|
|
502
|
+ |
category: "private_key",
|
|
503
|
+ |
pattern: Regex::new(
|
|
504
|
+ |
r"\b(?:nsec1[02-9ac-hj-np-z]{50,}|(?:xprv|yprv|zprv|tprv|uprv|vprv)[1-9A-HJ-NP-Za-km-z]{50,})\b",
|
|
505
|
+ |
)
|
|
506
|
+ |
.unwrap(),
|
|
507
|
+ |
replacement: "[REDACTED:private_key]",
|
|
508
|
+ |
resolve: None,
|
|
509
|
+ |
declines_redacted_capture: false,
|
|
510
|
+ |
},
|
|
511
|
+ |
RedactionRule {
|
|
512
|
+ |
category: "bearer_token",
|
|
513
|
+ |
pattern: Regex::new(r"\b[Bb]earer\s+[A-Za-z0-9._~+/=-]{8,}").unwrap(),
|
|
514
|
+ |
replacement: "Bearer [REDACTED:bearer_token]",
|
|
515
|
+ |
resolve: None,
|
|
516
|
+ |
declines_redacted_capture: false,
|
|
517
|
+ |
},
|
|
518
|
+ |
RedactionRule {
|
|
519
|
+ |
category: "api_key",
|
|
520
|
+ |
pattern: Regex::new(
|
|
521
|
+ |
r"\b(?:sk-[A-Za-z0-9_-]{16,}|ghp_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|gho_[A-Za-z0-9]{20,}|glpat-[A-Za-z0-9_-]{16,}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|AIza[A-Za-z0-9_-]{30,})\b",
|
|
522
|
+ |
)
|
|
523
|
+ |
.unwrap(),
|
|
524
|
+ |
replacement: "[REDACTED:api_key]",
|
|
525
|
+ |
resolve: None,
|
|
526
|
+ |
declines_redacted_capture: false,
|
|
527
|
+ |
},
|
|
528
|
+ |
// OpenAgents' own token family. `trace-command.ts` does not carry these —
|
|
529
|
+ |
// its `api_key` rule stops at the third-party prefixes — so `oa_pat_…`
|
|
530
|
+ |
// survived a redaction that claimed to have run. The patterns are the
|
|
531
|
+ |
// authoritative ones from `packages/atif/src/redaction.ts`. Redacting more
|
|
532
|
+ |
// than the TypeScript rules is always safe here; redacting less is the
|
|
533
|
+ |
// failure mode this whole module exists to close.
|
|
534
|
+ |
RedactionRule {
|
|
535
|
+ |
category: "oa_agent_token",
|
|
536
|
+ |
pattern: Regex::new(r"\boa_agent_[A-Za-z0-9_-]{6,}\b").unwrap(),
|
|
537
|
+ |
replacement: "[REDACTED:oa_agent_token]",
|
|
538
|
+ |
resolve: None,
|
|
539
|
+ |
declines_redacted_capture: false,
|
|
540
|
+ |
},
|
|
541
|
+ |
RedactionRule {
|
|
542
|
+ |
category: "oa_token",
|
|
543
|
+ |
pattern: Regex::new(
|
|
544
|
+ |
r"\b(?:oa_(?:live|test|sk|key|secret|tok|token|pat)?_?[A-Za-z0-9]{12,}|oa-x-[A-Za-z0-9_-]{4,}|smct_[A-Za-z0-9_-]{8,})\b",
|
|
545
|
+ |
)
|
|
546
|
+ |
.unwrap(),
|
|
547
|
+ |
replacement: "[REDACTED:oa_token]",
|
|
548
|
+ |
resolve: None,
|
|
549
|
+ |
declines_redacted_capture: false,
|
|
550
|
+ |
},
|
|
551
|
+ |
RedactionRule {
|
|
552
|
+ |
category: "jwt",
|
|
553
|
+ |
pattern: Regex::new(r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b").unwrap(),
|
|
554
|
+ |
replacement: "[REDACTED:jwt]",
|
|
555
|
+ |
resolve: None,
|
|
556
|
+ |
declines_redacted_capture: false,
|
|
557
|
+ |
},
|
|
558
|
+ |
RedactionRule {
|
|
559
|
+ |
category: "secret_field",
|
|
560
|
+ |
pattern: Regex::new(
|
|
561
|
+ |
r#"(?i)("[\w.-]*(?:token|secret|password|passwd|api[_-]?key|credential|private[_-]?key)[\w.-]*"\s*:\s*)"(?:[^"\\]|\\.)*""#,
|
|
562
|
+ |
)
|
|
563
|
+ |
.unwrap(),
|
|
564
|
+ |
replacement: "$1\"[REDACTED:secret_field]\"",
|
|
565
|
+ |
resolve: None,
|
|
566
|
+ |
declines_redacted_capture: false,
|
|
567
|
+ |
},
|
|
568
|
+ |
// The quoted form. The TypeScript rule uses a backreference to pair the
|
|
569
|
+ |
// quote character; `regex` has none, so the two quote styles are two rules
|
|
570
|
+ |
// that cannot cross-match — which is exactly what the backreference enforced.
|
|
571
|
+ |
RedactionRule {
|
|
572
|
+ |
category: "env_value",
|
|
573
|
+ |
pattern: Regex::new("\\b([A-Z][A-Z0-9_]{2,})=(\"[^\"\n]{4,}?\")").unwrap(),
|
|
574
|
+ |
replacement: "$1=[REDACTED:env_value]",
|
|
575
|
+ |
resolve: None,
|
|
576
|
+ |
declines_redacted_capture: false,
|
|
577
|
+ |
},
|
|
578
|
+ |
RedactionRule {
|
|
579
|
+ |
category: "env_value",
|
|
580
|
+ |
pattern: Regex::new(r"\b([A-Z][A-Z0-9_]{2,})=('[^'\n]{4,}?')").unwrap(),
|
|
581
|
+ |
replacement: "$1=[REDACTED:env_value]",
|
|
582
|
+ |
resolve: None,
|
|
583
|
+ |
declines_redacted_capture: false,
|
|
584
|
+ |
},
|
|
585
|
+ |
// The bare form. `already_redacted` below stands in for the TypeScript
|
|
586
|
+ |
// `(?!\[REDACTED)` lookahead, so this rule cannot re-match the marker the
|
|
587
|
+ |
// two rules above just wrote.
|
|
588
|
+ |
RedactionRule {
|
|
589
|
+ |
category: "env_value",
|
|
590
|
+ |
pattern: Regex::new("\\b([A-Z][A-Z0-9_]{2,})=([^\\s\"'`\\\\,}]{4,})").unwrap(),
|
|
591
|
+ |
replacement: "$1=[REDACTED:env_value]",
|
|
592
|
+ |
resolve: None,
|
|
593
|
+ |
declines_redacted_capture: true,
|
|
594
|
+ |
},
|
|
595
|
+ |
];
|
|
596
|
+ |
|
|
597
|
+ |
if !home.is_empty() {
|
|
598
|
+ |
rules.push(RedactionRule {
|
|
599
|
+ |
category: "home_path",
|
|
600
|
+ |
pattern: Regex::new(®ex::escape(home)).unwrap(),
|
|
601
|
+ |
replacement: "~",
|
|
602
|
+ |
resolve: None,
|
|
603
|
+ |
declines_redacted_capture: false,
|
|
604
|
+ |
});
|
|
605
|
+ |
}
|
|
606
|
+ |
rules.push(RedactionRule {
|
|
607
|
+ |
category: "home_path",
|
|
608
|
+ |
pattern: Regex::new(r"(?:/Users|/home)/[A-Za-z0-9._-]+").unwrap(),
|
|
609
|
+ |
replacement: "~",
|
|
610
|
+ |
resolve: None,
|
|
611
|
+ |
declines_redacted_capture: false,
|
|
612
|
+ |
});
|
|
613
|
+ |
rules
|
|
614
|
+ |
}
|
|
615
|
+ |
|
|
616
|
+ |
/// Decide whether a run of lowercase words is really a seed phrase.
|
|
617
|
+ |
///
|
|
618
|
+ |
/// A 12-word run of English prose matches the shape, so the shape alone is not
|
|
619
|
+ |
/// enough. The rule finds the longest run of consecutive words that are all in the
|
|
620
|
+ |
/// BIP-39 English list and declines unless that run reaches [`MIN_SEED_WORDS`],
|
|
621
|
+ |
/// which keeps surrounding prose intact.
|
|
622
|
+ |
fn resolve_seed_phrase(matched: &str) -> String {
|
|
623
|
+ |
let words: Vec<&str> = matched.split(' ').collect();
|
|
624
|
+ |
let wordlist = bip39::Language::English.word_list();
|
|
625
|
+ |
|
|
626
|
+ |
let mut best_start = 0usize;
|
|
627
|
+ |
let mut best_length = 0usize;
|
|
628
|
+ |
let mut run_start = 0usize;
|
|
629
|
+ |
let mut run_length = 0usize;
|
|
630
|
+ |
for (index, word) in words.iter().enumerate() {
|
|
631
|
+ |
if wordlist.contains(word) {
|
|
632
|
+ |
if run_length == 0 {
|
|
633
|
+ |
run_start = index;
|
|
634
|
+ |
}
|
|
635
|
+ |
run_length += 1;
|
|
636
|
+ |
if run_length > best_length {
|
|
637
|
+ |
best_length = run_length;
|
|
638
|
+ |
best_start = run_start;
|
|
639
|
+ |
}
|
|
640
|
+ |
} else {
|
|
641
|
+ |
run_length = 0;
|
|
642
|
+ |
}
|
|
643
|
+ |
}
|
|
644
|
+ |
|
|
645
|
+ |
if best_length < MIN_SEED_WORDS {
|
|
646
|
+ |
return matched.to_string();
|
| 37 |
647
|
|
}
|
| 38 |
648
|
|
|
| 39 |
|
- |
pub fn redact_trace(input: &str) -> String {
|
| 40 |
|
- |
input.replace("sk-", "[REDACTED_KEY]")
|
| 41 |
|
- |
.replace("oa_pat_", "[REDACTED_PAT]")
|
|
649
|
+ |
[
|
|
650
|
+ |
words[..best_start].join(" "),
|
|
651
|
+ |
"[REDACTED:seed_phrase]".to_string(),
|
|
652
|
+ |
words[best_start + best_length..].join(" "),
|
|
653
|
+ |
]
|
|
654
|
+ |
.into_iter()
|
|
655
|
+ |
.filter(|part| !part.is_empty())
|
|
656
|
+ |
.collect::<Vec<_>>()
|
|
657
|
+ |
.join(" ")
|
|
658
|
+ |
}
|
|
659
|
+ |
|
|
660
|
+ |
/// True when a bare env value is already a redaction marker.
|
|
661
|
+ |
fn already_redacted(value: &str) -> bool {
|
|
662
|
+ |
value.starts_with("[REDACTED")
|
|
663
|
+ |
}
|
|
664
|
+ |
|
|
665
|
+ |
/// Apply every rule in order and report what each removed.
|
|
666
|
+ |
///
|
|
667
|
+ |
/// The returned text is what gets written; the counts are what gets printed. The
|
|
668
|
+ |
/// matched text appears in neither.
|
|
669
|
+ |
pub fn redact_text(input: &str, home: &str) -> Redaction {
|
|
670
|
+ |
let mut output = input.to_string();
|
|
671
|
+ |
let mut counts: BTreeMap<String, usize> = BTreeMap::new();
|
|
672
|
+ |
let mut total = 0usize;
|
|
673
|
+ |
|
|
674
|
+ |
for rule in redaction_rules(home) {
|
|
675
|
+ |
let mut matched = 0usize;
|
|
676
|
+ |
let replaced = rule.pattern.replace_all(&output, |caps: &Captures| {
|
|
677
|
+ |
let whole = caps.get(0).map(|m| m.as_str()).unwrap_or_default();
|
|
678
|
+ |
|
|
679
|
+ |
if let Some(resolve) = rule.resolve {
|
|
680
|
+ |
let resolved = resolve(whole);
|
|
681
|
+ |
if resolved == whole {
|
|
682
|
+ |
return whole.to_string();
|
|
683
|
+ |
}
|
|
684
|
+ |
matched += 1;
|
|
685
|
+ |
return resolved;
|
|
686
|
+ |
}
|
|
687
|
+ |
|
|
688
|
+ |
// Stand-in for the `(?!\[REDACTED)` lookahead.
|
|
689
|
+ |
if rule.declines_redacted_capture
|
|
690
|
+ |
&& caps.get(2).is_some_and(|m| already_redacted(m.as_str()))
|
|
691
|
+ |
{
|
|
692
|
+ |
return whole.to_string();
|
|
693
|
+ |
}
|
|
694
|
+ |
|
|
695
|
+ |
matched += 1;
|
|
696
|
+ |
expand_single_digit(rule.replacement, caps)
|
|
697
|
+ |
});
|
|
698
|
+ |
output = replaced.into_owned();
|
|
699
|
+ |
|
|
700
|
+ |
if matched > 0 {
|
|
701
|
+ |
*counts.entry(rule.category.to_string()).or_insert(0) += matched;
|
|
702
|
+ |
total += matched;
|
|
703
|
+ |
}
|
|
704
|
+ |
}
|
|
705
|
+ |
|
|
706
|
+ |
Redaction {
|
|
707
|
+ |
text: output,
|
|
708
|
+ |
counts,
|
|
709
|
+ |
total,
|
| 42 |
710
|
|
}
|
| 43 |
711
|
|
}
|
|
712
|
+ |
|
|
713
|
+ |
/// Expand `$1`..`$9` in a replacement, exactly as the TypeScript does: a single
|
|
714
|
+ |
/// digit, and a missing capture becomes the empty string.
|
|
715
|
+ |
fn expand_single_digit(replacement: &str, caps: &Captures) -> String {
|
|
716
|
+ |
let mut out = String::with_capacity(replacement.len());
|
|
717
|
+ |
let mut chars = replacement.chars().peekable();
|
|
718
|
+ |
while let Some(ch) = chars.next() {
|
|
719
|
+ |
if ch == '$' {
|
|
720
|
+ |
if let Some(digit) = chars.peek().and_then(|c| c.to_digit(10)) {
|
|
721
|
+ |
chars.next();
|
|
722
|
+ |
if let Some(capture) = caps.get(digit as usize) {
|
|
723
|
+ |
out.push_str(capture.as_str());
|
|
724
|
+ |
}
|
|
725
|
+ |
continue;
|
|
726
|
+ |
}
|
|
727
|
+ |
}
|
|
728
|
+ |
out.push(ch);
|
|
729
|
+ |
}
|
|
730
|
+ |
out
|
|
731
|
+ |
}
|
|
732
|
+ |
|
|
733
|
+ |
/// Redact a trace file and write the redacted copy beside it.
|
|
734
|
+ |
///
|
|
735
|
+ |
/// The write is the point: the command this replaces printed a size and dropped the
|
|
736
|
+ |
/// result, so a caller who ran it before sharing a trace had been told the trace was
|
|
737
|
+ |
/// safe while the original still held the key.
|
|
738
|
+ |
pub fn redact_trace_file(path: &Path, home: &str) -> std::io::Result<RedactionResult> {
|
|
739
|
+ |
let text = fs::read_to_string(path)?;
|
|
740
|
+ |
let parsed_before = serde_json::from_str::<serde_json::Value>(&text).is_ok();
|
|
741
|
+ |
|
|
742
|
+ |
let redaction = redact_text(&text, home);
|
|
743
|
+ |
let output = redacted_path_for(path);
|
|
744
|
+ |
fs::write(&output, &redaction.text)?;
|
|
745
|
+ |
|
|
746
|
+ |
let valid_json = if parsed_before {
|
|
747
|
+ |
Some(serde_json::from_str::<serde_json::Value>(&redaction.text).is_ok())
|
|
748
|
+ |
} else {
|
|
749
|
+ |
None
|
|
750
|
+ |
};
|
|
751
|
+ |
|
|
752
|
+ |
Ok(RedactionResult {
|
|
753
|
+ |
input: path.to_path_buf(),
|
|
754
|
+ |
output,
|
|
755
|
+ |
counts: redaction.counts,
|
|
756
|
+ |
total: redaction.total,
|
|
757
|
+ |
valid_json,
|
|
758
|
+ |
})
|
|
759
|
+ |
}
|
|
760
|
+ |
|
|
761
|
+ |
/// Resolve a `trace show` / `trace redact` argument to a real file.
|
|
762
|
+ |
///
|
|
763
|
+ |
/// A path, or a bare file name inside `~/.openagents/exports`. An argument that
|
|
764
|
+ |
/// resolves to nothing is refused: there is no session-id lookup behind this, and
|
|
765
|
+ |
/// pretending otherwise is how the old `trace show` accepted any id at all.
|
|
766
|
+ |
pub fn resolve_trace_argument(value: &str, home: &Path) -> Result<PathBuf, String> {
|
|
767
|
+ |
let candidate = PathBuf::from(value);
|
|
768
|
+ |
let direct = if candidate.is_absolute() {
|
|
769
|
+ |
candidate
|
|
770
|
+ |
} else {
|
|
771
|
+ |
std::env::current_dir()
|
|
772
|
+ |
.unwrap_or_else(|_| PathBuf::from("."))
|
|
773
|
+ |
.join(candidate)
|
|
774
|
+ |
};
|
|
775
|
+ |
if direct.exists() {
|
|
776
|
+ |
return Ok(direct);
|
|
777
|
+ |
}
|
|
778
|
+ |
|
|
779
|
+ |
if !value.contains('/') {
|
|
780
|
+ |
let in_exports = home.join(".openagents").join("exports").join(value);
|
|
781
|
+ |
if in_exports.exists() {
|
|
782
|
+ |
return Ok(in_exports);
|
|
783
|
+ |
}
|
|
784
|
+ |
}
|
|
785
|
+ |
|
|
786
|
+ |
Err(format!(
|
|
787
|
+ |
"No trace file exists at {}, and ~/.openagents/exports has no file by that name. \
|
|
788
|
+ |
Run `oa trace list` to see what is discoverable.",
|
|
789
|
+ |
value
|
|
790
|
+ |
))
|
|
791
|
+ |
}
|