diff --git a/docs/AGENT_GUIDE.md b/docs/AGENT_GUIDE.md index c6502aef..f0904182 100644 --- a/docs/AGENT_GUIDE.md +++ b/docs/AGENT_GUIDE.md @@ -164,6 +164,29 @@ to its subject with `vogt_work_item` (a ref like `WI-42`) or `vogt_project` (a slug) so the run's findings file as observations against that subject with the same freshness and trust every other kind of evidence carries. +### Search session history — live and archived + +Vogt exposes the engine's session history as three reads, so you can find what +any session has printed without leaving the tool surface (they need only a +running engine; no capability beyond the ordinary session token): + +- `session_search_output` — full-text search over session output. It covers + **running** sessions too, not just the archive: each hit carries `live`, so a + match in a session that is still going is distinguishable from one in a + finished run. Pass `include_live=false` for archive-only. +- `session_log_tail` — the tail of one session's output log, readable + (`strip_ansi` defaults on). Works for a live session as well as an archived + one. +- `session_history_list` — the archived-session listing, newest first. + +```console +$ uv run vogt session search --q "connection refused" +$ uv run vogt session log --id +``` + +Each returns an `engine` field that is set (with the reason) when the engine +could not be asked; an outage reads as an empty result, never as "no history". + ### Name the branch so Vogt can see it Vogt recognises which work item a git branch belongs to by its **name**, using diff --git a/docs/ENGINE.md b/docs/ENGINE.md index 0fe76553..db6092f6 100644 --- a/docs/ENGINE.md +++ b/docs/ENGINE.md @@ -1109,13 +1109,21 @@ exactly those. - `GET /api/history/sessions?limit=&offset=` -> `SessionMetadata[]`; `limit` defaults to 50. -- `GET /api/history/search?q=&limit=` -> `SearchResult[]` — full-text over - archived output, ranked; `limit` defaults to 20. +- `GET /api/history/search?q=&limit=&include_live=` -> `SearchResult[]` — + full-text over archived output, ranked; `limit` defaults to 20. Each result + carries `live` (default false). `include_live` defaults to **true**: on top + of the archived FTS hits, each running session's scrollback is scanned + on-demand (the last `history_live_scan_bytes`, ANSI-stripped, same + AND-of-terms match) and matches are appended with `live: true`, so output + that has not been archived yet is still found (#491). The combined list is + held to `limit`. Pass `include_live=false` for archive-only results. - `GET /api/history/:id` -> `SessionMetadata` -- `GET /api/history/:id/log?tail_bytes=` -> `SessionLogPreview` +- `GET /api/history/:id/log?tail_bytes=&strip_ansi=` -> `SessionLogPreview` `{session_id, text, bytes, total_bytes, truncated}` — the *tail*, 64 KiB by default. `truncated` is how a client knows it is not looking at the whole - run. + run. `strip_ansi` (default false) removes the escape sequences a terminal + consumes without printing, so `text` is readable plain text; the byte + counters still describe the raw tail window that was read. - `GET /api/history/:id/download` -> the whole log, streamed, as an attachment named for the session. - `DELETE /api/history/:id` -> `{"ok": true}`, `404` if it was already gone @@ -1326,6 +1334,8 @@ Every setting, with the TOML key for a `--config` file and its default | `assistant_profiles` | `ENGINE_ASSISTANT_PROFILES_JSON` | `[]` | additional named providers, a JSON array of profile objects (below) | | `assistant_default_profile` | `ENGINE_ASSISTANT_DEFAULT_PROFILE` | the implicit `default` | which profile a request that names none runs on | | `assistant_log_retention_days` | `ENGINE_ASSISTANT_LOG_RETENTION_DAYS` | `30` | horizon of the durable interaction log, enforced by a daily sweep | +| `history_retention_days` | `ENGINE_HISTORY_RETENTION_DAYS` | `30` | horizon for archived session history (FTS index + raw logs), enforced by a daily sweep; `0` keeps forever | +| `history_live_scan_bytes` | `ENGINE_HISTORY_LIVE_SCAN_BYTES` | `262144` | trailing scrollback bytes scanned per live session when a history search sets `include_live` | | `assistant_stt_base_urls` | `ENGINE_ASSISTANT_STT_BASE_URLS` (comma-separated) | empty (server STT off) | ordered list of OpenAI-compatible `/audio/transcriptions` bases | | `assistant_stt_model` | `ENGINE_ASSISTANT_STT_MODEL` | `whisper-1` | transcription model | | `assistant_stt_api_key` | `ENGINE_ASSISTANT_STT_API_KEY` | unset | key for whichever STT entry needs one; a local server needs none | diff --git a/engine/server/src/agent_tasks.rs b/engine/server/src/agent_tasks.rs index 7e70c24d..af5e5686 100644 --- a/engine/server/src/agent_tasks.rs +++ b/engine/server/src/agent_tasks.rs @@ -5192,6 +5192,8 @@ mod tests { assistant_profiles: vec![], assistant_default_profile: None, assistant_log_retention_days: 30, + history_retention_days: 30, + history_live_scan_bytes: 256 * 1024, assistant_stt_base_urls: vec![], assistant_stt_api_key: None, assistant_stt_model: "whisper-1".into(), diff --git a/engine/server/src/app.rs b/engine/server/src/app.rs index 55a1cfce..3aef162b 100644 --- a/engine/server/src/app.rs +++ b/engine/server/src/app.rs @@ -228,6 +228,10 @@ pub async fn router(cfg: Config) -> (Router, Arc) { // whatever the last caller passed — the failure mode r18 named in the // session log. spawn_assistant_log_retention_sweeper(Arc::clone(&state)); + // Background task: enforce the session-history retention horizon on the same + // daily schedule (#491), so `history.db` and the raw `session-logs/` cannot + // grow without bound. A no-op when history is disabled or the horizon is 0. + spawn_history_retention_sweeper(Arc::clone(&state)); // Public: /healthz, /api/config, /api/push/public-key. None reveal secrets. let public = Router::new() @@ -442,6 +446,33 @@ fn spawn_assistant_log_retention_sweeper(state: Arc) { }); } +/// Enforce the session-history retention horizon on a daily schedule (#491), +/// mirroring the assistant-log sweeper: one sweep at startup and then every 24 +/// hours. A no-op when history is disabled, and when the horizon is `0` — the +/// documented "keep forever" — so the sweep never deletes everything by +/// treating 0 days as "older than now". +fn spawn_history_retention_sweeper(state: Arc) { + let Some(history) = state.history.clone() else { + return; + }; + let retention_days = state.config.history_retention_days; + if retention_days == 0 { + return; + } + tokio::spawn(async move { + loop { + match history.cleanup_old_sessions(retention_days).await { + Ok(removed) if removed > 0 => { + tracing::info!(removed, retention_days, "session history retention sweep") + } + Ok(_) => {} + Err(e) => tracing::warn!("session history retention sweep failed: {e}"), + } + tokio::time::sleep(std::time::Duration::from_secs(24 * 60 * 60)).await; + } + }); +} + /// The engine's ordinary error body, at 404, for a path under `/api` that no /// route claimed. Deliberately the same shape every other failure here uses — /// a client that already parses `{"error": ...}` needs nothing new to read it. diff --git a/engine/server/src/assistant.rs b/engine/server/src/assistant.rs index 519905a0..997fff29 100644 --- a/engine/server/src/assistant.rs +++ b/engine/server/src/assistant.rs @@ -1909,6 +1909,8 @@ mod tests { assistant_profiles: vec![], assistant_default_profile: None, assistant_log_retention_days: 30, + history_retention_days: 30, + history_live_scan_bytes: 256 * 1024, assistant_stt_base_urls: vec![], assistant_stt_api_key: None, assistant_stt_model: "whisper-1".into(), diff --git a/engine/server/src/auth.rs b/engine/server/src/auth.rs index 4adeb8ad..09cb52d2 100644 --- a/engine/server/src/auth.rs +++ b/engine/server/src/auth.rs @@ -510,6 +510,8 @@ mod tests { assistant_profiles: vec![], assistant_default_profile: None, assistant_log_retention_days: 30, + history_retention_days: 30, + history_live_scan_bytes: 256 * 1024, assistant_stt_base_urls: vec![], assistant_stt_api_key: None, assistant_stt_model: "whisper-1".into(), diff --git a/engine/server/src/config.rs b/engine/server/src/config.rs index b7170a8a..3e5fceec 100644 --- a/engine/server/src/config.rs +++ b/engine/server/src/config.rs @@ -272,6 +272,16 @@ pub struct Config { /// days. Enforced on a schedule so the horizon is a configured maximum /// rather than whatever the last caller passed. Defaults to 30. pub assistant_log_retention_days: u32, + /// Retention horizon for archived session history (the FTS index and the + /// raw scrollback logs), in days. Enforced by a daily sweep, mirroring the + /// assistant-log horizon, so `history.db` and `session-logs/` cannot grow + /// without bound. Defaults to 30; `0` disables the sweep (keep forever). + pub history_retention_days: u32, + /// How many trailing bytes of each *live* session's scrollback a history + /// search scans when `include_live` is set. Bounds the per-search cost of + /// live coverage (no DB writes; the scan is read-only). Defaults to 256 + /// KiB. + pub history_live_scan_bytes: u64, /// Server-side speech (FR-T12), configured **independently of the chat /// profile**: the whole point of the requirement is that a deployment /// whose chat runs through OpenRouter (which does not front audio @@ -387,6 +397,8 @@ struct FileConfig { assistant_profiles: Option>, assistant_default_profile: Option, assistant_log_retention_days: Option, + history_retention_days: Option, + history_live_scan_bytes: Option, assistant_stt_base_urls: Option>, assistant_stt_api_key: Option, assistant_stt_model: Option, @@ -685,6 +697,12 @@ pub fn load( assistant_log_retention_days: parse_u32_env("ENGINE_ASSISTANT_LOG_RETENTION_DAYS")? .or(from_file.assistant_log_retention_days) .unwrap_or(30), + history_retention_days: parse_u32_env("ENGINE_HISTORY_RETENTION_DAYS")? + .or(from_file.history_retention_days) + .unwrap_or(30), + history_live_scan_bytes: parse_u64_env("ENGINE_HISTORY_LIVE_SCAN_BYTES")? + .or(from_file.history_live_scan_bytes) + .unwrap_or(256 * 1024), assistant_stt_base_urls, assistant_stt_api_key, assistant_stt_model: from_file diff --git a/engine/server/src/history.rs b/engine/server/src/history.rs index d0d31d87..0e11dae2 100644 --- a/engine/server/src/history.rs +++ b/engine/server/src/history.rs @@ -49,6 +49,11 @@ pub struct SearchResult { pub created_at: String, pub match_snippet: String, pub rank: f64, + /// True when the hit came from a *live* session's scrollback (an on-demand + /// bounded scan), rather than the archived FTS index. Archived rows default + /// this to false, including when an older engine omits the field. + #[serde(default)] + pub live: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -299,6 +304,7 @@ impl SessionHistory { created_at: row.get("created_at"), match_snippet: row.get("match_snippet"), rank: row.get("rank"), + live: false, }) .collect(); @@ -467,7 +473,18 @@ impl SessionHistory { } /// Read the tail of an archived raw session log for replay-oriented views. - pub async fn read_log_preview(&self, id: Uuid, tail_bytes: u64) -> Result { + /// + /// With `strip_ansi`, the escape sequences a terminal consumes without + /// printing are removed from `text` (the same stripper the archive path + /// uses), so an agent reading over MCP gets plain text rather than a wall + /// of `\x1b[` noise. The byte counters still describe the raw tail window + /// that was read from disk — `text` is the rendered view of it. + pub async fn read_log_preview( + &self, + id: Uuid, + tail_bytes: u64, + strip_ansi: bool, + ) -> Result { let path = self.log_path(id); let mut file = tokio::fs::File::open(&path) .await @@ -496,9 +513,15 @@ impl SessionHistory { .map_err(|e| ApiError::Internal(format!("failed to read session log: {e}")))?; } + let text = if strip_ansi { + String::from_utf8_lossy(&crate::activity::strip_ansi(&buf)).into_owned() + } else { + String::from_utf8_lossy(&buf).into_owned() + }; + Ok(SessionLogPreview { session_id: id.to_string(), - text: String::from_utf8_lossy(&buf).into_owned(), + text, bytes, total_bytes, truncated: total_bytes > bytes, @@ -584,6 +607,88 @@ fn user_query_to_fts(query: &str) -> Option { } } +/// Lowercased alphanumeric/underscore tokens (max 16), the same tokenisation +/// the FTS query builder uses, for substring-matching live-session scrollback. +/// Empty when the query carries no usable term. +pub fn query_tokens(query: &str) -> Vec { + query + .split(|c: char| !c.is_alphanumeric() && c != '_') + .filter_map(|part| { + let trimmed = part.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_lowercase()) + } + }) + .take(16) + .collect() +} + +/// Build a live-search hit from a live session's raw scrollback tail, when +/// every query token appears (case-insensitively) in the ANSI-stripped text. +/// Mirrors the archived path's AND-of-terms semantics and its plain-text +/// snippet contract — terminal output is untrusted, so the snippet is never +/// marked up; the PWA owns highlighting at its text sink. +pub fn live_match( + session_id: &str, + session_name: &str, + created_at: &str, + raw_tail: &[u8], + tokens: &[String], +) -> Option { + if tokens.is_empty() { + return None; + } + let stripped = crate::activity::strip_ansi(raw_tail); + let text = String::from_utf8_lossy(&stripped); + let haystack = text.to_lowercase(); + if !tokens.iter().all(|t| haystack.contains(t.as_str())) { + return None; + } + Some(SearchResult { + session_id: session_id.to_string(), + session_name: session_name.to_string(), + created_at: created_at.to_string(), + match_snippet: live_snippet(&text, &tokens[0]), + // Archived hits carry FTS `rank` (ascending = best); live hits have no + // FTS score. 0.0 keeps them ahead of nothing in particular; the `live` + // flag, not the rank, is what a consumer keys on. + rank: 0.0, + live: true, + }) +} + +/// A readable one-line snippet for a live hit: the first scrollback line that +/// contains the leading token (case-insensitively), trimmed and length-capped; +/// failing that, the first non-empty line. Line-oriented rather than a byte +/// window, so it stays on character boundaries and reads cleanly. +fn live_snippet(text: &str, first_token: &str) -> String { + const MAX_CHARS: usize = 160; + for line in text.lines() { + let trimmed = line.trim(); + if !trimmed.is_empty() && trimmed.to_lowercase().contains(first_token) { + return truncate_chars(trimmed, MAX_CHARS); + } + } + text.lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(|line| truncate_chars(line, MAX_CHARS)) + .unwrap_or_default() +} + +/// Truncate to at most `max` characters (not bytes), appending an ellipsis when +/// anything was dropped. +fn truncate_chars(text: &str, max: usize) -> String { + if text.chars().count() <= max { + return text.to_string(); + } + let mut out: String = text.chars().take(max).collect(); + out.push_str("..."); + out +} + fn remove_log_file(path: PathBuf) -> Result<()> { match std::fs::remove_file(&path) { Ok(()) => Ok(()), @@ -617,3 +722,66 @@ fn summarize_regular_files(path: &Path) -> Result<(usize, u64)> { ))), } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_tokens_lowercases_splits_and_keeps_underscores() { + assert_eq!( + query_tokens("Hello, World_1!"), + vec!["hello".to_string(), "world_1".to_string()] + ); + } + + #[test] + fn query_tokens_are_empty_for_a_query_with_no_usable_term() { + assert!(query_tokens(" ").is_empty()); + assert!(query_tokens("!!! ---").is_empty()); + } + + #[test] + fn query_tokens_cap_at_sixteen() { + let many = (0..30) + .map(|i| format!("w{i}")) + .collect::>() + .join(" "); + assert_eq!(query_tokens(&many).len(), 16); + } + + #[test] + fn live_match_requires_every_token_and_strips_ansi() { + // Two tokens, both present across the ANSI-coloured tail. + let tail = b"\x1b[2K\x1b[1Ghello there\n\x1b[31mworld needle\x1b[0m\n"; + let tokens = query_tokens("needle world"); + let hit = live_match("sid", "sname", "2026-01-01T00:00:00Z", tail, &tokens) + .expect("all tokens present -> a hit"); + assert!(hit.live); + assert_eq!(hit.session_id, "sid"); + assert_eq!(hit.session_name, "sname"); + assert_eq!(hit.rank, 0.0); + // Snippet is the matching line, ANSI removed, plain text (untrusted + // output preserved as data — never marked up). + assert_eq!(hit.match_snippet, "world needle"); + assert!(!hit.match_snippet.contains('\u{1b}')); + } + + #[test] + fn live_match_returns_none_when_a_token_is_absent() { + let tail = b"only has the word alpha\n"; + let tokens = query_tokens("alpha beta"); + assert!(live_match("s", "n", "t", tail, &tokens).is_none()); + } + + #[test] + fn live_match_returns_none_for_empty_tokens() { + assert!(live_match("s", "n", "t", b"anything", &[]).is_none()); + } + + #[test] + fn truncate_chars_appends_ellipsis_only_when_dropping() { + assert_eq!(truncate_chars("short", 10), "short"); + assert_eq!(truncate_chars("abcdef", 3), "abc..."); + } +} diff --git a/engine/server/src/history_api.rs b/engine/server/src/history_api.rs index 9366bcd9..ca5cb90f 100644 --- a/engine/server/src/history_api.rs +++ b/engine/server/src/history_api.rs @@ -33,16 +33,31 @@ pub struct SearchQuery { q: String, #[serde(default = "default_search_limit")] limit: usize, + /// Supplement the archived FTS index with a bounded scan of each live + /// session's scrollback, so output that has not been archived yet is still + /// found (#491). On by default; pass `false` for archive-only results. + #[serde(default = "default_include_live")] + include_live: bool, } fn default_search_limit() -> usize { 20 } +fn default_include_live() -> bool { + true +} + #[derive(Debug, Deserialize)] pub struct LogQuery { #[serde(default = "default_tail_bytes")] tail_bytes: u64, + /// Strip the escape sequences a terminal consumes without printing, so the + /// `text` is readable plain text rather than raw control bytes. Off by + /// default to preserve the raw stream for callers that render it + /// themselves (the PWA replay resolves it client-side). + #[serde(default)] + strip_ansi: bool, } fn default_tail_bytes() -> u64 { @@ -75,14 +90,46 @@ pub async fn list_sessions( Ok(Json(sessions)) } -/// Search session output via full-text search +/// Search session output via full-text search over the archive, optionally +/// supplemented by a bounded scan of live sessions' scrollback (#491). +/// +/// The live scan costs zero DB writes: for each live session it reads the last +/// `history_live_scan_bytes` of scrollback, strips ANSI, and applies the same +/// AND-of-terms match as the FTS query. Live hits are appended after the +/// archived ones and the combined list is held to `limit`, so a search never +/// returns more than asked regardless of how many sessions are running. pub async fn search_sessions( State(state): State>, Query(q): Query, ) -> Result>> { let history = state.history.as_ref().ok_or(ApiError::NotFound)?; - let results = history.search(&q.q, q.limit).await?; + let limit = q.limit.min(100); + let mut results = history.search(&q.q, limit).await?; + + if q.include_live && results.len() < limit { + let tokens = crate::history::query_tokens(&q.q); + if !tokens.is_empty() { + let scan_bytes = state.config.history_live_scan_bytes as usize; + for session in state.sessions.live_sessions() { + if results.len() >= limit { + break; + } + let summary = session.summary(); + let tail = session.tail(scan_bytes); + if let Some(hit) = crate::history::live_match( + &summary.id.to_string(), + &summary.name, + &summary.created_at, + tail.as_ref(), + &tokens, + ) { + results.push(hit); + } + } + } + } + Ok(Json(results)) } @@ -105,7 +152,9 @@ pub async fn get_session_log( ) -> Result> { let history = state.history.as_ref().ok_or(ApiError::NotFound)?; - let preview = history.read_log_preview(id, q.tail_bytes).await?; + let preview = history + .read_log_preview(id, q.tail_bytes, q.strip_ansi) + .await?; Ok(Json(preview)) } diff --git a/engine/server/tests/assistant_speech.rs b/engine/server/tests/assistant_speech.rs index 890958b5..77ba231c 100644 --- a/engine/server/tests/assistant_speech.rs +++ b/engine/server/tests/assistant_speech.rs @@ -134,6 +134,8 @@ fn test_config() -> Config { assistant_profiles: vec![], assistant_default_profile: None, assistant_log_retention_days: 30, + history_retention_days: 30, + history_live_scan_bytes: 256 * 1024, assistant_stt_base_urls: vec![], assistant_stt_api_key: None, assistant_stt_model: "whisper-1".into(), diff --git a/engine/server/tests/integration.rs b/engine/server/tests/integration.rs index 4facc3b2..90529158 100644 --- a/engine/server/tests/integration.rs +++ b/engine/server/tests/integration.rs @@ -54,6 +54,8 @@ fn test_config() -> Config { assistant_profiles: vec![], assistant_default_profile: None, assistant_log_retention_days: 30, + history_retention_days: 30, + history_live_scan_bytes: 256 * 1024, assistant_stt_base_urls: vec![], assistant_stt_api_key: None, assistant_stt_model: "whisper-1".into(), @@ -2490,6 +2492,182 @@ async fn archived_history_cleanup_removes_old_sessions_and_logs() { assert_eq!(after.status(), StatusCode::NOT_FOUND); } +/// #491: a *live* session's output is searchable before it exits, via the +/// on-demand bounded scan (`include_live`, on by default), and the hit is +/// marked `live: true`. `include_live=false` restores archive-only behaviour, +/// so the same still-running session is not found there. +#[tokio::test] +async fn live_session_output_is_searchable_before_exit() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = test_config(); + cfg.default_cwd = tmp.path().to_path_buf(); + cfg.workspace_root = tmp.path().canonicalize().unwrap(); + + let (base, _h) = boot_with_config(cfg).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + // Prints a unique needle then stays alive — never archived during the test. + let id: String = client + .post(format!("{base}/api/sessions")) + .json(&json!({ + "name": "live-search-me", + "command": ["/bin/sh", "-lc", "printf 'live-needle-zzz here\\n'; sleep 30"], + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // The needle reaches scrollback shortly after spawn; the live scan then + // finds it. Same deadline discipline as the archive-search test. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let hits: Vec = client + .get(format!("{base}/api/history/search?q=live-needle-zzz")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + if let Some(hit) = hits.iter().find(|hit| hit["session_id"] == id) { + assert_eq!( + hit["live"], true, + "a live-session hit must be flagged: {hit:?}" + ); + assert!( + hit["match_snippet"] + .as_str() + .unwrap_or_default() + .contains("live-needle-zzz"), + "live snippet must preserve the matched output as data: {hit:?}" + ); + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "live search should find a running session's output; got {hits:?}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Archive-only search excludes the still-running session. + let archive_only: Vec = client + .get(format!( + "{base}/api/history/search?q=live-needle-zzz&include_live=false" + )) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + assert!( + !archive_only.iter().any(|hit| hit["session_id"] == id), + "include_live=false must not return a live (unarchived) session: {archive_only:?}" + ); +} + +/// #491: the log-preview endpoint strips ANSI on request, so an agent reading +/// over MCP gets plain text; the default preserves the raw escape stream for +/// callers that render it themselves. +#[tokio::test] +async fn history_log_preview_strips_ansi_when_requested() { + let tmp = tempfile::tempdir().unwrap(); + let mut cfg = test_config(); + cfg.default_cwd = tmp.path().to_path_buf(); + cfg.workspace_root = tmp.path().canonicalize().unwrap(); + + let (base, _h) = boot_with_config(cfg).await; + let client = reqwest::Client::builder() + .default_headers(auth()) + .build() + .unwrap(); + + let id: String = client + .post(format!("{base}/api/sessions")) + .json(&json!({ + "name": "ansi-me", + "command": [ + "/bin/sh", + "-lc", + "printf '\\033[31mred-needle\\033[0m plain-tail\\n'; exit 0", + ], + })) + .send() + .await + .unwrap() + .json::() + .await + .unwrap()["id"] + .as_str() + .unwrap() + .to_string(); + + // Wait for the finalized row so the raw log is fully flushed. + let deadline = tokio::time::Instant::now() + Duration::from_secs(5); + loop { + let session = client + .get(format!("{base}/api/history/{id}")) + .send() + .await + .unwrap(); + if session.status() == StatusCode::OK { + let row: Value = session.json().await.unwrap(); + if !row["exit_code"].is_null() { + break; + } + } + assert!( + tokio::time::Instant::now() < deadline, + "session was not archived in time" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + } + + // Default: raw stream, escapes preserved. + let raw: Value = client + .get(format!("{base}/api/history/{id}/log")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let raw_text = raw["text"].as_str().unwrap_or_default(); + assert!( + raw_text.contains('\u{1b}'), + "default preview must keep the raw escape stream: {raw:?}" + ); + + // strip_ansi=true: readable plain text, no escape bytes. + let stripped: Value = client + .get(format!("{base}/api/history/{id}/log?strip_ansi=true")) + .send() + .await + .unwrap() + .json() + .await + .unwrap(); + let text = stripped["text"].as_str().unwrap_or_default(); + assert!( + text.contains("red-needle") && text.contains("plain-tail"), + "stripped preview must keep the visible text: {stripped:?}" + ); + assert!( + !text.contains('\u{1b}'), + "stripped preview must contain no escape bytes: {stripped:?}" + ); +} + #[tokio::test] async fn task_prompt_artifact_cleanup_prunes_old_runs_and_orphans() { let tmp = tempfile::tempdir().unwrap(); diff --git a/engine/server/tests/vogt_core.rs b/engine/server/tests/vogt_core.rs index d4ab8497..9bd505d0 100644 --- a/engine/server/tests/vogt_core.rs +++ b/engine/server/tests/vogt_core.rs @@ -181,6 +181,8 @@ fn base_config() -> Config { assistant_profiles: vec![], assistant_default_profile: None, assistant_log_retention_days: 30, + history_retention_days: 30, + history_live_scan_bytes: 256 * 1024, assistant_stt_base_urls: vec![], assistant_stt_api_key: None, assistant_stt_model: "whisper-1".into(), diff --git a/src/vogt/adapters/engine/__init__.py b/src/vogt/adapters/engine/__init__.py index f1c4d3cb..896d9ad3 100644 --- a/src/vogt/adapters/engine/__init__.py +++ b/src/vogt/adapters/engine/__init__.py @@ -1,17 +1,20 @@ """Talking to the session engine (FR-E1, FR-E8). The engine is the other half of the merged product — the Rust process that -owns PTYs, scrollback and activity state. Vogt asks it for six things and +owns PTYs, scrollback and activity state. Vogt asks it for nine things and nothing else: start a session, list sessions, describe one, stop one, read -the archive of one that has ended, and list the scheduled agent tasks. That -list is the whole coupling, and keeping it that short is what makes the -two-process shape (NFR-D11) worth having rather than merely tolerable. +the archive of one that has ended, list the scheduled agent tasks, and — the +history-search trio (#491) — list archived sessions, search session output +(live sessions included), and read the tail of one session's log. That list +is the whole coupling, and keeping it that short is what makes the two-process +shape (NFR-D11) worth having rather than merely tolerable. -The last two arrived with FR-E6 and FR-E7 and are both *reads*: what a -session left behind, and what a bound task's run found, come back to Vogt by -being collected, never by being pushed into the observed store from outside. -That is `SCHEMA.md` §1's rule — nothing writes `observed.sqlite3` except -collectors — held rather than bent. +The archive read arrived with FR-E6, the task-run read with FR-E7, and the +history-search trio with #491; all are *reads*: what a session left behind, +what a bound task's run found, and what any session has printed come back to +Vogt by being asked for on demand, never by being pushed into the observed +store from outside. That is `SCHEMA.md` §1's rule — nothing writes +`observed.sqlite3` except collectors — held rather than bent. The adapter is optional in exactly the way the forge adapter is. No engine configured means the `session.*` operations report that, and every other @@ -22,7 +25,10 @@ EngineAgentTask, EngineArchivedSession, EngineClient, + EngineHistoryMatch, + EngineHistorySession, EngineSession, + EngineSessionLog, EngineTaskFinding, EngineTaskRun, EngineUnavailable, @@ -32,7 +38,10 @@ "EngineAgentTask", "EngineArchivedSession", "EngineClient", + "EngineHistoryMatch", + "EngineHistorySession", "EngineSession", + "EngineSessionLog", "EngineTaskFinding", "EngineTaskRun", "EngineUnavailable", diff --git a/src/vogt/adapters/engine/client.py b/src/vogt/adapters/engine/client.py index 6936d033..77b19697 100644 --- a/src/vogt/adapters/engine/client.py +++ b/src/vogt/adapters/engine/client.py @@ -114,6 +114,98 @@ def from_payload(cls, payload: dict[str, Any]) -> EngineArchivedSession: ) +@dataclass(frozen=True) +class EngineHistorySession: + """One row of the engine's session-history listing (`SessionMetadata`). + + The fuller shape the history list returns — name and scrollback size on + top of the `EngineArchivedSession` facts — so a caller browsing history + can label a session without a second round trip. A live session that the + history list includes has a NULL `ended_at`/`exit_code`, same as the GUI. + """ + + id: str + name: str + created_at: str + ended_at: str | None = None + exit_code: int | None = None + cwd: str | None = None + command: str | None = None + scrollback_bytes: int = 0 + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> EngineHistorySession: + raw_bytes = payload.get("scrollback_bytes") + return cls( + id=str(payload.get("id", "")), + name=str(payload.get("name", "")), + created_at=str(payload.get("created_at", "")), + ended_at=_optional_str(payload.get("ended_at")), + exit_code=_optional_int(payload.get("exit_code")), + cwd=_optional_str(payload.get("cwd")), + command=_optional_str(payload.get("command")), + scrollback_bytes=raw_bytes if isinstance(raw_bytes, int) else 0, + ) + + +@dataclass(frozen=True) +class EngineHistoryMatch: + """One hit from a session-output search (`SearchResult`). + + `live` distinguishes a match in a *running* session's scrollback (found by + the engine's on-demand scan) from one in the archived FTS index. The + snippet is plain text — terminal output is untrusted, so the engine never + marks it up and neither does anything downstream. + """ + + session_id: str + session_name: str + created_at: str + match_snippet: str + rank: float = 0.0 + live: bool = False + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> EngineHistoryMatch: + raw_rank = payload.get("rank") + return cls( + session_id=str(payload.get("session_id", "")), + session_name=str(payload.get("session_name", "")), + created_at=str(payload.get("created_at", "")), + match_snippet=str(payload.get("match_snippet", "")), + rank=float(raw_rank) if isinstance(raw_rank, (int, float)) else 0.0, + live=bool(payload.get("live", False)), + ) + + +@dataclass(frozen=True) +class EngineSessionLog: + """The tail of a session's raw output log (`SessionLogPreview`). + + `text` is the rendered tail (ANSI-stripped when the caller asked for it); + `bytes`/`total_bytes`/`truncated` describe the raw window that was read, so + a caller knows whether it is looking at the whole run. + """ + + session_id: str + text: str + bytes: int = 0 + total_bytes: int = 0 + truncated: bool = False + + @classmethod + def from_payload(cls, payload: dict[str, Any]) -> EngineSessionLog: + raw_bytes = payload.get("bytes") + raw_total = payload.get("total_bytes") + return cls( + session_id=str(payload.get("session_id", "")), + text=str(payload.get("text", "")), + bytes=raw_bytes if isinstance(raw_bytes, int) else 0, + total_bytes=raw_total if isinstance(raw_total, int) else 0, + truncated=bool(payload.get("truncated", False)), + ) + + @dataclass(frozen=True) class EngineTaskFinding: """Something a bound agent-task run reported about itself (FR-E7). @@ -253,7 +345,7 @@ def from_config( token = resolved.read_text(encoding="utf-8").strip() or None return cls(base_url=url.strip().rstrip("/"), token=token, transport=transport) - # -- the six things Vogt asks of the engine ---------------------------- + # -- the nine things Vogt asks of the engine --------------------------- def create_session( self, @@ -359,6 +451,77 @@ def list_agent_tasks(self) -> list[EngineAgentTask]: EngineAgentTask.from_payload(row) for row in rows if isinstance(row, dict) ] + def history_sessions( + self, *, limit: int = 50, offset: int = 0 + ) -> list[EngineHistorySession]: + """The engine's archived-session listing, newest-first, paginated. + + Reads whole rows (name, size, outcome) so a caller can browse history + without a detail fetch per row. The engine may include live sessions + in this list; those carry a NULL `ended_at`. + """ + query = urllib.parse.urlencode({"limit": limit, "offset": offset}) + payload = self._call(f"/api/history/sessions?{query}") + rows = payload if isinstance(payload, list) else [] + return [ + EngineHistorySession.from_payload(row) + for row in rows + if isinstance(row, dict) + ] + + def search_history( + self, query: str, *, limit: int = 20, include_live: bool = True + ) -> list[EngineHistoryMatch]: + """Full-text search over session output, live sessions included. + + `include_live` (default true) supplements the archived FTS index with + a bounded scan of each running session's scrollback, so output that + has not been archived yet is still found; those hits carry `live: + true`. Snippets are plain text. + """ + params = urllib.parse.urlencode( + { + "q": query, + "limit": limit, + "include_live": "true" if include_live else "false", + } + ) + payload = self._call(f"/api/history/search?{params}") + rows = payload if isinstance(payload, list) else [] + return [ + EngineHistoryMatch.from_payload(row) + for row in rows + if isinstance(row, dict) + ] + + def history_log( + self, + session_id: str, + *, + tail_bytes: int = 64 * 1024, + strip_ansi: bool = True, + ) -> EngineSessionLog | None: + """The tail of a session's raw output log, or `None` when there is none. + + Works for a live session too — the engine reads the on-disk log by id + with no archive row required (this is how a running session can be + replayed). `strip_ansi` defaults true so a caller gets readable text; + pass false for the raw escape stream. + """ + params = urllib.parse.urlencode( + { + "tail_bytes": tail_bytes, + "strip_ansi": "true" if strip_ansi else "false", + } + ) + payload = self._call( + f"/api/history/{urllib.parse.quote(session_id)}/log?{params}", + allow_missing=True, + ) + if not isinstance(payload, dict): + return None + return EngineSessionLog.from_payload(payload) + # -- transport --------------------------------------------------------- def _call( @@ -421,7 +584,10 @@ def _fetch( "EngineAgentTask", "EngineArchivedSession", "EngineClient", + "EngineHistoryMatch", + "EngineHistorySession", "EngineSession", + "EngineSessionLog", "EngineTaskFinding", "EngineTaskRun", "EngineUnavailable", diff --git a/src/vogt/application/models.py b/src/vogt/application/models.py index c6ae0ad7..833dfed3 100644 --- a/src/vogt/application/models.py +++ b/src/vogt/application/models.py @@ -2643,3 +2643,119 @@ class SessionListResult(Result): "on the engine being up (FR-E9)." ), ) + + +# -- session history (#491) ------------------------------------------------ +# +# Three read ops that surface the engine's session history — list, search, +# read one log's tail — to MCP/CLI/REST. All history lives engine-side; these +# are pass-throughs that degrade the FR-E9 way: an unreachable engine sets the +# `engine` field and returns an empty view, never an error that reads as "no +# history". History is a machine surface, so the params carry no `reason`. + +_HISTORY_ENGINE_FIELD_DESC = ( + "What the engine said, when it could not be asked. Empty history is " + "returned rather than an error, so an outage never reads as 'no history' " + "(FR-E9)." +) + + +class HistoryListParams(Params): + limit: int = Field(default=50, ge=1, le=200) + offset: int = Field(default=0, ge=0) + + +class HistorySessionRow(Result): + """One row of the engine's archived-session listing. + + A live session the engine chooses to include carries a null `ended_at` + and `exit_code`, exactly as it appears in the GUI history list. + """ + + id: str + name: str + created_at: str + ended_at: str | None = None + exit_code: int | None = None + cwd: str | None = None + command: str | None = None + scrollback_bytes: int = 0 + + +class HistoryListResult(Result): + sessions: list[HistorySessionRow] = [] + engine: str | None = Field(default=None, description=_HISTORY_ENGINE_FIELD_DESC) + + +class SearchOutputParams(Params): + q: str = Field( + description=( + "Search terms. Plain words, ANDed together — not FTS query syntax." + ) + ) + limit: int = Field(default=20, ge=1, le=100) + include_live: bool = Field( + default=True, + description=( + "Scan running sessions' output too, not just the archive, so " + "output that has not been archived yet is still found. Live hits " + "are flagged `live: true` (#491)." + ), + ) + + +class HistoryOutputMatch(Result): + """One hit from a session-output search. + + `live` distinguishes a match in a running session's scrollback from one in + the archived index. The snippet is plain text — terminal output is + untrusted and is never marked up. + """ + + session_id: str + session_name: str + created_at: str + match_snippet: str + rank: float = 0.0 + live: bool = Field( + default=False, + description="True when the match is in a running session's live output.", + ) + + +class SearchOutputResult(Result): + matches: list[HistoryOutputMatch] = [] + engine: str | None = Field(default=None, description=_HISTORY_ENGINE_FIELD_DESC) + + +class LogTailParams(Params): + id: str = Field(description="Session id whose output log to read.") + tail_bytes: int = Field( + default=64 * 1024, + ge=1, + le=256 * 1024, + description="Trailing bytes of the log to read; capped at 256 KiB.", + ) + strip_ansi: bool = Field( + default=True, + description=( + "Remove terminal escape codes so the text is readable. Pass false " + "for the raw escape stream." + ), + ) + + +class LogTailResult(Result): + """The tail of one session's output log. + + Works for a live session too — the engine reads the on-disk log by id, no + archive row required. `session_id` is null when the engine has no log for + that id (or could not be asked, with `engine` then set). + """ + + session_id: str | None = None + text: str = "" + bytes: int = 0 + total_bytes: int = 0 + truncated: bool = False + engine: str | None = Field(default=None, description=_HISTORY_ENGINE_FIELD_DESC) diff --git a/src/vogt/application/services/__init__.py b/src/vogt/application/services/__init__.py index f74f2718..dc9b2eab 100644 --- a/src/vogt/application/services/__init__.py +++ b/src/vogt/application/services/__init__.py @@ -84,7 +84,10 @@ ) from vogt.application.services.retention import prune from vogt.application.services.sessions import ( + history_list, list_sessions, + log_tail, + search_output, start_session, stop_session, ) @@ -143,6 +146,7 @@ "export_instance", "get_project", "get_work", + "history_list", "import_forge_repo", "import_instance", "import_project", @@ -170,6 +174,7 @@ "list_work", "list_workflows", "list_write_backs", + "log_tail", "migrate_instance", "observations", "onboard", @@ -186,6 +191,7 @@ "revoke_suppression", "revoke_token", "scaffold_project", + "search_output", "serve", "serve_mcp_stdio", "set_write_back", diff --git a/src/vogt/application/services/sessions.py b/src/vogt/application/services/sessions.py index c2c24cea..cf908fbd 100644 --- a/src/vogt/application/services/sessions.py +++ b/src/vogt/application/services/sessions.py @@ -32,7 +32,15 @@ from vogt.application import writes from vogt.application.context import AppContext from vogt.application.models import ( + HistoryListParams, + HistoryListResult, + HistoryOutputMatch, + HistorySessionRow, ListSessionsParams, + LogTailParams, + LogTailResult, + SearchOutputParams, + SearchOutputResult, SessionListResult, SessionResult, SessionSummary, @@ -272,6 +280,93 @@ def list_sessions(ctx: AppContext, params: ListSessionsParams) -> SessionListRes ) +# -- session history (#491) ------------------------------------------------ +# +# Thin read pass-throughs to the engine's history surface. All three degrade +# the FR-E9 way `list_sessions` does: no engine, or an unreachable one, sets +# the `engine` field and returns an empty view — never an error that reads as +# "no history". History lives entirely engine-side, so there is no declared +# store to consult. + +_NO_ENGINE = "no session engine is configured (VOGT_ENGINE_URL is unset)" + + +def history_list(ctx: AppContext, params: HistoryListParams) -> HistoryListResult: + """The engine's archived-session listing, newest-first, paginated.""" + if ctx.engine is None: + return HistoryListResult(engine=_NO_ENGINE) + try: + rows = ctx.engine.history_sessions(limit=params.limit, offset=params.offset) + except EngineUnavailable as exc: + return HistoryListResult(engine=str(exc)) + return HistoryListResult( + sessions=[ + HistorySessionRow( + id=row.id, + name=row.name, + created_at=row.created_at, + ended_at=row.ended_at, + exit_code=row.exit_code, + cwd=row.cwd, + command=row.command, + scrollback_bytes=row.scrollback_bytes, + ) + for row in rows + ] + ) + + +def search_output(ctx: AppContext, params: SearchOutputParams) -> SearchOutputResult: + """Full-text search over session output, live sessions included (#491).""" + if ctx.engine is None: + return SearchOutputResult(engine=_NO_ENGINE) + try: + hits = ctx.engine.search_history( + params.q, limit=params.limit, include_live=params.include_live + ) + except EngineUnavailable as exc: + return SearchOutputResult(engine=str(exc)) + return SearchOutputResult( + matches=[ + HistoryOutputMatch( + session_id=hit.session_id, + session_name=hit.session_name, + created_at=hit.created_at, + match_snippet=hit.match_snippet, + rank=hit.rank, + live=hit.live, + ) + for hit in hits + ] + ) + + +def log_tail(ctx: AppContext, params: LogTailParams) -> LogTailResult: + """The tail of one session's output log, readable (ANSI-stripped) by default. + + A missing log — the id is unknown, or history is off — is an empty result + (`session_id` null, `engine` null), not an error: "there is no output to + show" is an ordinary answer. + """ + if ctx.engine is None: + return LogTailResult(engine=_NO_ENGINE) + try: + log = ctx.engine.history_log( + params.id, tail_bytes=params.tail_bytes, strip_ansi=params.strip_ansi + ) + except EngineUnavailable as exc: + return LogTailResult(engine=str(exc)) + if log is None: + return LogTailResult() + return LogTailResult( + session_id=log.session_id, + text=log.text, + bytes=log.bytes, + total_bytes=log.total_bytes, + truncated=log.truncated, + ) + + # -- resolution ------------------------------------------------------------ @@ -547,4 +642,11 @@ def _summarize( ) -__all__ = ["list_sessions", "start_session", "stop_session"] +__all__ = [ + "history_list", + "list_sessions", + "log_tail", + "search_output", + "start_session", + "stop_session", +] diff --git a/src/vogt/registry/operations.py b/src/vogt/registry/operations.py index 8384d711..769dfc9f 100644 --- a/src/vogt/registry/operations.py +++ b/src/vogt/registry/operations.py @@ -74,6 +74,8 @@ ForgeReposResult, GetProjectParams, GetWorkParams, + HistoryListParams, + HistoryListResult, ImportParams, ImportProjectParams, ImportProjectResult, @@ -102,6 +104,8 @@ ListSuppressionsParams, ListTokensParams, ListWorkParams, + LogTailParams, + LogTailResult, McpStdioParams, McpStdioResult, MigrateParams, @@ -130,6 +134,8 @@ RevokeTokenParams, ScaffoldProjectParams, ScaffoldProjectResult, + SearchOutputParams, + SearchOutputResult, ServeParams, ServeResult, SessionListResult, @@ -847,6 +853,46 @@ def build_operations() -> list[Operation[Any, Any]]: route=HttpRoute("POST", "/sessions/stop"), cli=CliBinding(("session", "stop")), ), + # -- session history (#491) --------------------------------------- + # + # Three reads that surface the engine's session history to agents + # (MCP), scripts (REST) and operators (CLI) at once — the GUI already + # reaches the engine directly. Static GET paths with the id as a query + # field, the house convention (there are no `{param}` routes); the id + # in `session.log_tail` rides on `LogTailParams.id`. + Operation( + name="session.history_list", + summary="List archived sessions (history), newest first.", + scope="read", + mutating=False, + params_model=HistoryListParams, + result_model=HistoryListResult, + handler=services.history_list, + route=HttpRoute("GET", "/sessions/history"), + cli=CliBinding(("session", "history")), + ), + Operation( + name="session.search_output", + summary="Search session output (live sessions included).", + scope="read", + mutating=False, + params_model=SearchOutputParams, + result_model=SearchOutputResult, + handler=services.search_output, + route=HttpRoute("GET", "/sessions/history/search"), + cli=CliBinding(("session", "search")), + ), + Operation( + name="session.log_tail", + summary="Read the tail of a session's output log, readable.", + scope="read", + mutating=False, + params_model=LogTailParams, + result_model=LogTailResult, + handler=services.log_tail, + route=HttpRoute("GET", "/sessions/log"), + cli=CliBinding(("session", "log")), + ), Operation( name="token.issue", summary="Issue a scoped token bound to an actor. Shown once.", diff --git a/tests/test_parity.py b/tests/test_parity.py index 7500b05d..a4365707 100644 --- a/tests/test_parity.py +++ b/tests/test_parity.py @@ -399,6 +399,14 @@ {"project": "parity-project", "reason": WHY}, ), ("session.list", {}), + # History reads (#491), driven against the same stand-in engine: parity + # proves the three surfaces agree on the engine's canned answers. + ("session.history_list", {}), + ("session.search_output", {"q": "needle"}), + ( + "session.log_tail", + {"id": "01000000-0000-0000-0000-000000000001"}, + ), ( "session.stop", lambda seen: { @@ -746,6 +754,7 @@ def transport( method: str = "GET", ) -> tuple[int, bytes]: spec = json.loads(body.decode("utf-8")) if body else {} + path = url.split("?", 1)[0] if method == "POST" and url.endswith("/api/sessions"): return 200, json.dumps( { @@ -760,6 +769,46 @@ def transport( return 200, b'{"ok":true}' if method == "GET" and url.endswith("/api/sessions"): return 200, b"[]" + # Session history (#491). Canned, deterministic rows so the three + # surfaces have the same engine answer to agree on. + if method == "GET" and path.endswith("/api/history/sessions"): + return 200, json.dumps( + [ + { + "id": "01000000-0000-0000-0000-000000000001", + "name": "archived-parity", + "created_at": "2026-01-01T00:00:00Z", + "ended_at": "2026-01-01T00:01:00Z", + "exit_code": 0, + "cwd": "/tmp", + "command": "echo hi", + "scrollback_bytes": 42, + } + ] + ).encode() + if method == "GET" and path.endswith("/api/history/search"): + return 200, json.dumps( + [ + { + "session_id": "01000000-0000-0000-0000-000000000001", + "session_name": "archived-parity", + "created_at": "2026-01-01T00:00:00Z", + "match_snippet": "a needle in the output", + "rank": -1.5, + "live": False, + } + ] + ).encode() + if method == "GET" and path.endswith("/log"): + return 200, json.dumps( + { + "session_id": "01000000-0000-0000-0000-000000000001", + "text": "readable tail", + "bytes": 13, + "total_bytes": 13, + "truncated": False, + } + ).encode() return 404, b"" return EngineClient(base_url="http://127.0.0.1:8910", transport=transport) diff --git a/tests/test_sessions.py b/tests/test_sessions.py index ae036b6f..a2b6bdf5 100644 --- a/tests/test_sessions.py +++ b/tests/test_sessions.py @@ -21,22 +21,28 @@ from vogt.application.models import ( CreateWorkParams, GetWorkParams, + HistoryListParams, ListAuditParams, ListEventsParams, ListSessionsParams, + LogTailParams, RegisterProjectParams, RelateWorkParams, + SearchOutputParams, StartSessionParams, StopSessionParams, ) from vogt.application.services import ( create_work, get_work, + history_list, list_audit, list_events, list_sessions, + log_tail, register_project, relate_work, + search_output, start_session, stop_session, ) @@ -57,6 +63,9 @@ def __init__(self) -> None: self.killed: list[str] = [] self.alive: dict[str, str] = {} self.counter = 0 + #: When set, the history log endpoint 404s — the engine has no log for + #: that id (history off, or the id is unknown). + self.log_missing = False def __call__( self, @@ -93,6 +102,57 @@ def __call__( for key, state in self.alive.items() ] ).encode() + # Session history (#491). Canned rows; tests assert both the mapping + # and, via `self.sent`, that the query params were forwarded. + path = url.split("?", 1)[0] + if method == "GET" and path.endswith("/api/history/sessions"): + return 200, json.dumps( + [ + { + "id": "hist-1", + "name": "old-session", + "created_at": "2026-01-01T00:00:00Z", + "ended_at": "2026-01-01T00:05:00Z", + "exit_code": 0, + "cwd": ROOT, + "command": "make test", + "scrollback_bytes": 128, + } + ] + ).encode() + if method == "GET" and path.endswith("/api/history/search"): + return 200, json.dumps( + [ + { + "session_id": "hist-1", + "session_name": "old-session", + "created_at": "2026-01-01T00:00:00Z", + "match_snippet": "found the needle here", + "rank": -2.0, + "live": False, + }, + { + "session_id": "live-9", + "session_name": "running-now", + "created_at": "2026-01-02T00:00:00Z", + "match_snippet": "a needle in a live run", + "rank": 0.0, + "live": True, + }, + ] + ).encode() + if method == "GET" and path.endswith("/log"): + if self.log_missing: + return 404, b"" + return 200, json.dumps( + { + "session_id": "hist-1", + "text": "plain readable tail", + "bytes": 19, + "total_bytes": 40, + "truncated": True, + } + ).encode() return 404, b"" @property @@ -685,3 +745,103 @@ def test_naming_both_a_work_item_and_a_project_is_still_refused( start_session( wired, StartSessionParams(work_item="WI-1", project="vogt", reason=WHY) ) + + +# -- session history reads (#491) ------------------------------------------ +# +# Thin pass-throughs to the engine, degrading the FR-E9 way `list_sessions` +# does. The stand-in returns canned rows; `engine.sent` lets a test assert the +# query params were forwarded, since that forwarding is the whole behaviour. + + +def _history_urls(engine: StandInEngine) -> list[str]: + return [row["url"] for row in engine.sent if "/api/history" in row["url"]] + + +def test_history_list_forwards_pagination_and_maps_rows( + wired: AppContext, engine: StandInEngine +) -> None: + result = history_list(wired, HistoryListParams(limit=10, offset=5)) + assert result.engine is None + assert [row.id for row in result.sessions] == ["hist-1"] + row = result.sessions[0] + assert row.name == "old-session" + assert row.exit_code == 0 + assert row.scrollback_bytes == 128 + url = _history_urls(engine)[-1] + assert "limit=10" in url and "offset=5" in url + + +def test_search_output_maps_hits_and_flags_live( + wired: AppContext, engine: StandInEngine +) -> None: + result = search_output(wired, SearchOutputParams(q="needle", limit=25)) + assert result.engine is None + assert [m.live for m in result.matches] == [False, True] + assert result.matches[0].match_snippet == "found the needle here" + assert result.matches[1].session_id == "live-9" + url = _history_urls(engine)[-1] + assert "q=needle" in url + assert "include_live=true" in url + assert "limit=25" in url + + +def test_search_output_can_ask_for_archive_only( + wired: AppContext, engine: StandInEngine +) -> None: + search_output(wired, SearchOutputParams(q="needle", include_live=False)) + assert "include_live=false" in _history_urls(engine)[-1] + + +def test_log_tail_maps_the_preview_and_forwards_flags( + wired: AppContext, engine: StandInEngine +) -> None: + result = log_tail( + wired, LogTailParams(id="hist-1", tail_bytes=4096, strip_ansi=True) + ) + assert result.engine is None + assert result.session_id == "hist-1" + assert result.text == "plain readable tail" + assert result.bytes == 19 + assert result.total_bytes == 40 + assert result.truncated is True + url = _history_urls(engine)[-1] + assert "tail_bytes=4096" in url + assert "strip_ansi=true" in url + + +def test_log_tail_missing_log_is_empty_not_an_error( + wired: AppContext, engine: StandInEngine +) -> None: + engine.log_missing = True + result = log_tail(wired, LogTailParams(id="gone")) + assert result.engine is None + assert result.session_id is None + assert result.text == "" + + +def test_history_reads_report_no_engine_configured(instance: AppContext) -> None: + ctx = dataclasses.replace(instance, engine=None) + assert "VOGT_ENGINE_URL" in (history_list(ctx, HistoryListParams()).engine or "") + assert "VOGT_ENGINE_URL" in ( + search_output(ctx, SearchOutputParams(q="x")).engine or "" + ) + assert "VOGT_ENGINE_URL" in (log_tail(ctx, LogTailParams(id="x")).engine or "") + + +def test_history_reads_report_a_dead_engine_not_an_error( + instance: AppContext, +) -> None: + dead = dataclasses.replace( + instance, + engine=EngineClient(base_url="http://127.0.0.1:8910", transport=DeadEngine()), + ) + hist = history_list(dead, HistoryListParams()) + assert hist.sessions == [] + assert hist.engine is not None and "not answering" in hist.engine + search = search_output(dead, SearchOutputParams(q="x")) + assert search.matches == [] + assert search.engine is not None and "not answering" in search.engine + tail = log_tail(dead, LogTailParams(id="x")) + assert tail.session_id is None + assert tail.engine is not None and "not answering" in tail.engine diff --git a/web/src/History.tsx b/web/src/History.tsx index 5bf776df..bbcca040 100644 --- a/web/src/History.tsx +++ b/web/src/History.tsx @@ -793,7 +793,7 @@ const History: Component = (props) => {
Metadata filters apply to loaded pages. Output search runs server-wide across the full archive. 0}> - {" "}Live sessions are listed here, but their output is not yet in the search index. + {" "}Running sessions are searched too — a match still in progress is badged Live.
@@ -836,6 +836,9 @@ const History: Component = (props) => { >
{result.session_name} + + Live + {formatDate(result.created_at)}
diff --git a/web/src/__tests__/historyNavigation.test.tsx b/web/src/__tests__/historyNavigation.test.tsx index 20ee03c1..6cb767e1 100644 --- a/web/src/__tests__/historyNavigation.test.tsx +++ b/web/src/__tests__/historyNavigation.test.tsx @@ -39,6 +39,15 @@ const betaResult = { rank: -1, }; +const liveResult = { + ...alphaResult, + session_id: "session-live", + session_name: "live shell", + match_snippet: "live says needle", + rank: 0, + live: true, +}; + function historyFixture() { return fakeVogt({}, { "GET /api/history/sessions": { body: [alpha, beta] }, @@ -75,6 +84,36 @@ describe("History result navigation", () => { .toBe("needle"); }); + it("badges a live-session hit and asks the engine to include live output (#491)", async () => { + const vogt = fakeVogt({}, { + "GET /api/history/sessions": { body: [alpha] }, + "GET /api/history/search": { body: [alphaResult, liveResult] }, + "GET /api/history/session-alpha": { body: alpha }, + "GET /api/history/session-alpha/log": { + body: { session_id: alpha.id, text: "alpha says needle", bytes: 17, total_bytes: 17, truncated: false }, + }, + }); + const view = mountAt("/history", historyResultUrl("needle", alphaResult), () => ); + + await waitFor(() => + expect(view.container.querySelectorAll(".history-search-result").length).toBe(2), + ); + + const liveButton = [...view.container.querySelectorAll(".history-search-result")] + .find((button) => button.textContent?.includes("live shell")); + expect(liveButton?.querySelector(".history-liveness-badge.live")?.textContent).toBe("Live"); + + // The archived hit carries no Live badge. + const alphaButton = [...view.container.querySelectorAll(".history-search-result")] + .find((button) => button.textContent?.includes("alpha archive")); + expect(alphaButton?.querySelector(".history-liveness-badge")).toBeNull(); + + // The search asked the engine to include live output. + expect( + vogt.engineCalls.find((call) => call.path === "/api/history/search")?.query.get("include_live"), + ).toBe("true"); + }); + it("gives each distinct result its own URL and updates the detail", async () => { historyFixture(); const view = mountAt("/history", historyResultUrl("needle", alphaResult), () => ); diff --git a/web/src/__tests__/historyTruth.test.tsx b/web/src/__tests__/historyTruth.test.tsx index 0923ae85..0f364b33 100644 --- a/web/src/__tests__/historyTruth.test.tsx +++ b/web/src/__tests__/historyTruth.test.tsx @@ -281,7 +281,7 @@ describe("History read truth", () => { expect(screen.getByText(/Tail of the live session, rendered readable/)).toBeVisible(); // Archive-only actions are hidden while the session is still live. expect(screen.queryByRole("button", { name: "Export" })).not.toBeInTheDocument(); - expect(screen.getByText(/their output is not yet in the search index/)).toBeVisible(); + expect(screen.getByText(/Running sessions are searched too/)).toBeVisible(); // The selection (the live shell) persists across filter changes and keeps // showing in the detail heading, so assert against the list *rows*, which diff --git a/web/src/api.ts b/web/src/api.ts index d131df01..9c456a5f 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -446,6 +446,9 @@ export interface HistorySearchResult { created_at: string; match_snippet: string; rank: number; + // True when the hit came from a running session's live output rather than + // the archived index (#491). Older engines omit it; treated as false. + live?: boolean; } export interface HistoryLogPreview { @@ -855,10 +858,15 @@ export const api = { signal, "list", ), - searchHistory: (query: string, limit = 20, signal?: AbortSignal) => + searchHistory: ( + query: string, + limit = 20, + includeLive = true, + signal?: AbortSignal, + ) => req( "GET", - `/api/history/search?q=${encodeURIComponent(query)}&limit=${limit}`, + `/api/history/search?q=${encodeURIComponent(query)}&limit=${limit}&include_live=${includeLive}`, undefined, signal, "long", diff --git a/web/src/styles.css b/web/src/styles.css index 9ad16574..9971d24b 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -5047,6 +5047,10 @@ textarea { .history-result-date { font-size: 11px; color: var(--fg-muted); + /* Keep the date hard right so the optional Live badge groups with the name + on the left rather than floating to the middle under space-between. */ + margin-left: auto; + white-space: nowrap; } .history-result-snippet {