Skip to content
This repository was archived by the owner on Jul 19, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions crates/soft-trace-viewer/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,5 +20,7 @@ serde_json = { workspace = true }
tiny_http = { workspace = true }

[dev-dependencies]
tempfile = "3"
ureq = { workspace = true }
tempfile = "3"
ureq = { workspace = true }
# End-to-end search test produces a real trace via soft-agent's runner.
soft-agent = { path = "../soft-agent" }
77 changes: 76 additions & 1 deletion crates/soft-trace-viewer/src/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@
display: flex; align-items: baseline; gap: 12px; }
header h1 { font-size: 14px; margin: 0; font-weight: 600; }
header .meta { color: var(--muted); font-size: 12px; }
header input.search { margin-left: auto; min-width: 240px; padding: 4px 8px;
font-size: 12px; background: var(--bg); color: var(--fg);
border: 1px solid var(--border); border-radius: 3px;
font-family: monospace; }
header input.search:focus { outline: none; border-color: var(--accent); }
header .search-count { color: var(--muted); font-size: 11px; min-width: 60px; }
main { display: grid; grid-template-columns: 280px 1fr; height: calc(100vh - 38px); }
#sidebar { border-right: 1px solid var(--border); overflow-y: auto; padding: 8px 0;
background: var(--panel); }
Expand All @@ -28,6 +34,11 @@
#sidebar .trace:hover { color: var(--fg); background: rgba(88, 166, 255, 0.05); }
#sidebar .trace.active { color: var(--accent); border-left-color: var(--accent);
background: rgba(88, 166, 255, 0.08); }
#sidebar .trace.dimmed { opacity: 0.25; }
#sidebar .trace .snippet { display: block; font-size: 10px; color: var(--muted);
margin-top: 2px; word-break: break-all; }
#sidebar .trace .snippet mark { background: rgba(88, 166, 255, 0.25);
color: var(--fg); padding: 0; }
#content { overflow-y: auto; padding: 16px 24px; }
.placeholder { color: var(--muted); font-style: italic; }
.node { margin: 4px 0; }
Expand Down Expand Up @@ -56,12 +67,19 @@
<header>
<h1>soft trace viewer</h1>
<span class="meta">click a run id to load its trace</span>
<input class="search" id="search" type="search"
placeholder="search across traces (e.g. depot, gate.verdict, deny)"
autocomplete="off" spellcheck="false">
<span class="search-count" id="search-count"></span>
</header>
<main>
<div id="sidebar"><div class="placeholder" style="padding: 0 16px;">loading…</div></div>
<div id="content"><div class="placeholder">Select a trace from the sidebar.</div></div>
</main>
<script>
// Track sidebar entries so search can dim non-matching ones.
let sidebarEntries = []; // [{ id, el }]

async function loadTraces() {
const sb = document.getElementById('sidebar');
try {
Expand All @@ -72,20 +90,77 @@ <h1>soft trace viewer</h1>
return;
}
sb.innerHTML = '';
sidebarEntries = [];
data.traces.forEach(id => {
const el = document.createElement('div');
el.className = 'trace';
el.textContent = id.slice(0, 16) + (id.length > 16 ? '…' : '');
const head = document.createElement('span');
head.textContent = id.slice(0, 16) + (id.length > 16 ? '…' : '');
el.appendChild(head);
el.title = id;
el.onclick = () => selectTrace(id, el);
sb.appendChild(el);
sidebarEntries.push({ id, el });
});
} catch (e) {
sb.innerHTML = '<div class="placeholder" style="padding: 0 16px; color: var(--err);">'
+ 'failed to load: ' + e + '</div>';
}
}

// --- search ---

let searchTimer = null;

document.getElementById('search').addEventListener('input', e => {
clearTimeout(searchTimer);
const q = e.target.value;
searchTimer = setTimeout(() => runSearch(q), 200);
});

async function runSearch(q) {
const countEl = document.getElementById('search-count');
// Clear any prior dim/snippet state.
sidebarEntries.forEach(({ el }) => {
el.classList.remove('dimmed');
const oldSnippet = el.querySelector('.snippet');
if (oldSnippet) oldSnippet.remove();
});
if (!q.trim()) {
countEl.textContent = '';
return;
}
countEl.textContent = 'searching…';
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(q));
const data = await res.json();
const matchedIds = new Set(data.results.map(r => r.run_id));
const snippets = new Map(data.results.map(r => [r.run_id, r.snippet]));
sidebarEntries.forEach(({ id, el }) => {
if (matchedIds.has(id)) {
const s = document.createElement('span');
s.className = 'snippet';
s.innerHTML = highlightSnippet(snippets.get(id) || '', q);
el.appendChild(s);
} else {
el.classList.add('dimmed');
}
});
countEl.textContent = data.results.length + ' / ' + data.scanned;
} catch (e) {
countEl.textContent = 'err';
}
}

function highlightSnippet(snippet, q) {
// Case-insensitive highlight with HTML escape.
const escaped = escapeHtml(snippet);
const ql = escapeHtml(q);
if (!ql) return escaped;
const re = new RegExp('(' + ql.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
return escaped.replace(re, '<mark>$1</mark>');
}

async function selectTrace(id, el) {
document.querySelectorAll('#sidebar .trace.active').forEach(n => n.classList.remove('active'));
el.classList.add('active');
Expand Down
129 changes: 125 additions & 4 deletions crates/soft-trace-viewer/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
//! Tiny HTTP viewer for lex-store traces.
//!
//! Serves three things:
//! - `GET /` → embedded `index.html` (vanilla JS, no deps)
//! - `GET /api/traces` → JSON array of run IDs in the store
//! - `GET /api/trace/<id>`→ the full `TraceTree` as JSON
//! Serves four things:
//! - `GET /` → embedded `index.html` (vanilla JS, no deps)
//! - `GET /api/traces` → JSON array of run IDs in the store
//! - `GET /api/trace/<id>` → the full `TraceTree` as JSON
//! - `GET /api/search?q=...` → list of trace ids whose contents
//! contain the (case-insensitive) substring `q`, plus a one-line
//! snippet from the first matching node.
//!
//! Designed to be a one-process companion to `soft-run` containers
//! sharing a `/traces` volume. No auth — assume the bind address is
Expand Down Expand Up @@ -119,10 +122,128 @@ fn handle(req: Request, store: &Store) {
Err(e) => respond_text(req, &format!("load_trace {id}: {e}"), 404),
}
}
(Method::Get, path) if path.starts_with("/api/search") => {
handle_search(req, store, path);
}
_ => respond_text(req, "not found", 404),
}
}

/// Naive case-insensitive substring search over each trace's
/// serialized JSON. For Phase 1 trace volumes (~1 KB per trace,
/// ~tens of traces) this is fast enough; if the store grows past a
/// few thousand traces this would benefit from `lex-search` (lex 0.3
/// #224) or a precomputed inverted index.
fn handle_search(req: Request, store: &Store, raw_path: &str) {
let q = match raw_path
.split_once('?')
.and_then(|(_, qs)| query_param(qs, "q"))
{
Some(q) if !q.trim().is_empty() => q.to_lowercase(),
_ => {
respond_json(req, &json!({"results": [], "query": ""}), 200);
return;
}
};

let ids = match store.list_traces() {
Ok(ids) => ids,
Err(e) => {
respond_text(req, &format!("list_traces: {e}"), 500);
return;
}
};

let mut results: Vec<serde_json::Value> = Vec::new();
for id in &ids {
let tree = match store.load_trace(id) {
Ok(t) => t,
Err(_) => continue,
};
let body = match serde_json::to_string(&tree) {
Ok(s) => s,
Err(_) => continue,
};
let lower = body.to_lowercase();
if let Some(pos) = lower.find(&q) {
// Build a ~80-char snippet centered on the match.
let snippet_start = pos.saturating_sub(30);
let snippet_end = (pos + q.len() + 50).min(body.len());
// Snap to UTF-8 boundaries.
let snippet_start = body
.char_indices()
.map(|(i, _)| i)
.take_while(|&i| i <= snippet_start)
.last()
.unwrap_or(0);
let snippet_end = body
.char_indices()
.map(|(i, _)| i)
.find(|&i| i >= snippet_end)
.unwrap_or(body.len());
let snippet = &body[snippet_start..snippet_end];
results.push(json!({
"run_id": id,
"snippet": snippet,
"match_offset": pos,
}));
}
}

respond_json(
req,
&json!({
"query": q,
"results": results,
"scanned": ids.len(),
}),
200,
);
}

/// Minimal `application/x-www-form-urlencoded` query-string parser.
/// Returns the first value for `key`, decoded for the simple
/// `+` → space and `%XX` → byte cases. Sufficient for our search
/// box; not a full URL parser.
fn query_param(qs: &str, key: &str) -> Option<String> {
for pair in qs.split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
if k == key {
return Some(percent_decode(v));
}
}
None
}

fn percent_decode(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let bytes = s.as_bytes();
let mut i = 0;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hex = std::str::from_utf8(&bytes[i + 1..i + 3]).unwrap_or("");
if let Ok(byte) = u8::from_str_radix(hex, 16) {
out.push(byte as char);
i += 3;
} else {
out.push(bytes[i] as char);
i += 1;
}
}
other => {
out.push(other as char);
i += 1;
}
}
}
out
}

fn respond_html(req: Request, body: &str, status: u16) {
let header = Header::from_bytes(&b"Content-Type"[..], &b"text/html; charset=utf-8"[..])
.expect("static content-type header is well-formed");
Expand Down
Loading
Loading