diff --git a/Cargo.lock b/Cargo.lock index 93104e286f..9c52857e2c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4836,6 +4836,7 @@ dependencies = [ "criterion", "crossterm", "dirs", + "filetime", "flate2", "fs2", "getrandom 0.2.17", diff --git a/Cargo.toml b/Cargo.toml index 00ce80a01c..9aa747d622 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -162,6 +162,7 @@ cc = "1" tempfile = "3" sha2 = "0.11" hex = "0.4" +filetime = "0.2" criterion = { version = "0.5", features = ["async_tokio", "html_reports"] } # test-util enables tokio::test(start_paused = true) so timer-driven unit # tests (daemon restart-grace windows) run on virtual time instead of real diff --git a/plugin/README-cursor.md b/plugin/README-cursor.md index 9efeaf0faf..789123f9b1 100644 --- a/plugin/README-cursor.md +++ b/plugin/README-cursor.md @@ -151,7 +151,8 @@ per-call review, add the snippet below to `~/.cursor/permissions.json` "tracedecay:tracedecay_todos", "tracedecay:tracedecay_type_hierarchy", "tracedecay:tracedecay_unsafe_patterns", - "tracedecay:tracedecay_unused_imports" + "tracedecay:tracedecay_unused_imports", + "tracedecay:tracedecay_workflows" ] } ``` diff --git a/plugin/skills/managing-session-context/SKILL.md b/plugin/skills/managing-session-context/SKILL.md index 2dbe1a91da..8ab1b366e5 100644 --- a/plugin/skills/managing-session-context/SKILL.md +++ b/plugin/skills/managing-session-context/SKILL.md @@ -45,6 +45,14 @@ Climb cheapest-first; stop as soon as the question is answered. `branch`|`worktree`|`commit`, `value`, optional `since`/`until`, `limit`): find sessions active on a branch or worktree, or sessions that produced a commit; feed returned session ids back into grep/replay/drill-down above. +7. **Workflow-run recovery → `tracedecay_workflows`**: recover multi-agent + workflow (`wf_*`) runs and their per-phase agents. List runs for a thread + with `session_id`, or every run on a branch/worktree/commit with + `branch`/`worktree`/`commit` (a run inherits its parent session's git + spans). Show one run's result summary + phases + agent roster with + `run_id`, then drill into a single agent with `run_id` + `agent_label`. + To read that agent's messages, scope `tracedecay_message_search` with + `workflow_run` (+ optional `workflow_agent`), or replay via rungs 3–4. After a compaction, if prior-session context seems missing, run this ladder before assuming the compacted summary is complete. diff --git a/src/cli.rs b/src/cli.rs index 630e14d5ff..0decc1b354 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -669,6 +669,21 @@ pub enum SessionsAction { #[arg(long)] dry_run: bool, }, + /// List unfinished workflow/task evidence from ingested session messages + Unfinished { + /// Maximum evidence rows + #[arg(long, default_value_t = 25)] + limit: usize, + /// Output as JSON + #[arg(long)] + json: bool, + /// Registered project id whose session store should be searched + #[arg(long)] + project_id: Option, + /// Registered project root path or alias whose session store should be searched + #[arg(long, conflicts_with = "project_id")] + project_path: Option, + }, } #[derive(Subcommand)] diff --git a/src/global_db.rs b/src/global_db.rs index 4f6ed9feae..ad3e8eb583 100644 --- a/src/global_db.rs +++ b/src/global_db.rs @@ -21,6 +21,21 @@ use crate::sessions::{ const UNIX_TIMESTAMP_MILLIS_THRESHOLD: i64 = 1_000_000_000_000; +/// Scopes a `tracedecay_message_search` to the agent transcripts of one +/// workflow run, mirroring `GitScopeFilter` as a search-only concern. The run's +/// messages are the messages of its agents (rows in `workflow_agents`); see +/// [`GlobalDb::search_session_messages_workflow_scoped`] for the EXISTS +/// pushdown. Serializes so the applied filter echoes cleanly into the payload. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct WorkflowScopeFilter { + /// The `wf_*` run whose agents' messages to keep. + pub run_id: String, + /// When set, narrows the scope to just this one agent of the run + /// (matched on `workflow_agents.agent_label`). + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_label: Option, +} + /// Total savings + call count for a project (or all projects when `project` is None). #[derive(Debug, Clone, serde::Serialize)] pub struct SavingsTotal { @@ -1023,6 +1038,9 @@ impl GlobalDb { crate::sessions::git_correlation::ensure_git_correlation_schema(&db.conn) .await .ok()?; + crate::sessions::workflow_index::ensure_workflow_index_schema(&db.conn) + .await + .ok()?; // One-off self-heal: re-derive timestamps and token-usage counters // for legacy messages ingested before extraction existed. // Marker-guarded (runs once per store) and fail-open, like the LCM @@ -3271,6 +3289,80 @@ impl GlobalDb { crate::sessions::git_correlation::session_ids_for_scope(&self.conn, filter).await } + // ── Workflow-run index ─────────────────────────────────────────── + + /// Inserts or updates one indexed workflow run (idempotent on `run_id`). + /// See [`crate::sessions::workflow_index::upsert_run`]. + pub async fn workflow_upsert_run( + &self, + run: &crate::sessions::workflow_index::WorkflowRun, + ) -> Result<(), crate::sessions::workflow_index::WorkflowIndexError> { + crate::sessions::workflow_index::upsert_run(&self.conn, run).await + } + + /// Inserts or updates one workflow agent (idempotent on + /// `(run_id, agent_label, agent_id)`). + /// See [`crate::sessions::workflow_index::upsert_agent`]. + pub async fn workflow_upsert_agent( + &self, + agent: &crate::sessions::workflow_index::WorkflowAgent, + ) -> Result<(), crate::sessions::workflow_index::WorkflowIndexError> { + crate::sessions::workflow_index::upsert_agent(&self.conn, agent).await + } + + /// Lists workflow runs spawned by one parent session, newest-first. + /// See [`crate::sessions::workflow_index::runs_for_session`]. + pub async fn workflow_runs_for_session( + &self, + parent_session_id: &str, + limit: usize, + ) -> Result< + Vec, + crate::sessions::workflow_index::WorkflowIndexError, + > { + crate::sessions::workflow_index::runs_for_session(&self.conn, parent_session_id, limit) + .await + } + + /// Fetches one workflow run by its `wf_*` id. + /// See [`crate::sessions::workflow_index::run_for_id`]. + pub async fn workflow_run_for_id( + &self, + run_id: &str, + ) -> Result< + Option, + crate::sessions::workflow_index::WorkflowIndexError, + > { + crate::sessions::workflow_index::run_for_id(&self.conn, run_id).await + } + + /// Lists the agents of one workflow run in phase order. + /// See [`crate::sessions::workflow_index::agents_for_run`]. + pub async fn workflow_agents_for_run( + &self, + run_id: &str, + limit: usize, + ) -> Result< + Vec, + crate::sessions::workflow_index::WorkflowIndexError, + > { + crate::sessions::workflow_index::agents_for_run(&self.conn, run_id, limit).await + } + + /// Lists workflow runs that ran on a git branch/worktree/commit, joined + /// through their parent session's git spans. + /// See [`crate::sessions::workflow_index::runs_for_git_scope`]. + pub async fn workflow_runs_for_git_scope( + &self, + filter: &crate::sessions::git_correlation::GitScopeFilter, + limit: usize, + ) -> Result< + Vec, + crate::sessions::workflow_index::WorkflowIndexError, + > { + crate::sessions::workflow_index::runs_for_git_scope(&self.conn, filter, limit).await + } + /// Lists per-session activity windows for the historical git-correlation /// backfill: each row carries the session's declared `started_at`/`ended_at` /// plus the min/max `session_messages.timestamp`, so the caller can derive @@ -3317,6 +3409,7 @@ impl GlobalDb { limit, filters, None, + None, ) .await } @@ -3342,6 +3435,37 @@ impl GlobalDb { limit, filters, Some(git_filter), + None, + ) + .await + } + + /// Like [`Self::search_session_messages_filtered`], additionally scoping + /// hits to the agent transcripts of one workflow run via EXISTS pushdown + /// against `workflow_agents`. A run's agents are matched either by the + /// transcript file the message came from (`workflow_agents.transcript_path + /// = session_messages.source_path`) or, as a fallback, by the agent's own + /// session id (`workflow_agents.agent_session_id = session_messages.session_id`), + /// so the scope holds whichever key the ingest recorded. When + /// `filter.agent_label` is set the scope narrows to that one agent. A call + /// against a store predating the workflow-index schema returns no hits. + pub async fn search_session_messages_workflow_scoped( + &self, + provider: Option<&str>, + project_key: Option<&str>, + query: &str, + limit: usize, + filters: SessionSearchFilters<'_>, + workflow_filter: &WorkflowScopeFilter, + ) -> Vec { + self.search_session_messages_filtered_inner( + provider, + project_key, + query, + limit, + filters, + None, + Some(workflow_filter), ) .await } @@ -3354,10 +3478,19 @@ impl GlobalDb { limit: usize, filters: SessionSearchFilters<'_>, ) -> Vec { - self.search_session_messages_filtered_inner(None, project_key, query, limit, filters, None) - .await + self.search_session_messages_filtered_inner( + None, + project_key, + query, + limit, + filters, + None, + None, + ) + .await } + #[allow(clippy::too_many_arguments)] // internal fan-in of independent scope/time/git/workflow filters async fn search_session_messages_filtered_inner( &self, provider: Option<&str>, @@ -3366,6 +3499,7 @@ impl GlobalDb { limit: usize, filters: SessionSearchFilters<'_>, git_filter: Option<&crate::sessions::git_correlation::GitScopeFilter>, + workflow_filter: Option<&WorkflowScopeFilter>, ) -> Vec { // A git-scoped search against a store written before the correlation // schema existed can never match; report empty rather than issuing a @@ -3379,6 +3513,16 @@ impl GlobalDb { return Vec::new(); } } + // Likewise a workflow-scoped search against a store predating the + // workflow-index schema can never match: short-circuit to empty rather + // than hitting `no such table: workflow_agents`. + if workflow_filter.is_some() + && !crate::sessions::workflow_index::tables_present(&self.conn) + .await + .unwrap_or(false) + { + return Vec::new(); + } let fts_query = session_fts_query(query); if fts_query.is_empty() || limit == 0 { return Vec::new(); @@ -3455,6 +3599,24 @@ impl GlobalDb { query_params.extend(predicate_values); } } + // Workflow-run scoping: reuse the shared EXISTS predicate (also used + // by future lcm/grep paths) so run/agent correlation semantics stay in + // one place. Renumber its `?1`, `?2`, … slots to follow the query's + // existing numbered placeholders, then append the bind values in order. + if let Some(filter) = workflow_filter { + let (mut predicate, predicate_values) = + crate::sessions::workflow_index::workflow_scope_exists_predicate( + filter, + "m.source_path", + "m.session_id", + ); + let base = query_params.len(); + for slot in (1..=predicate_values.len()).rev() { + predicate = predicate.replace(&format!("?{slot}"), &format!("?{}", base + slot)); + } + let _ = write!(sql, " AND {predicate}"); + query_params.extend(predicate_values); + } for term in &literal_terms { query_params.push(Value::Text(term.clone())); let _ = write!( diff --git a/src/main.rs b/src/main.rs index 1ea6c801e1..221fe5002d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -731,6 +731,9 @@ fn should_skip_startup_maintenance(command: &Commands) -> bool { | Commands::Lsp { .. } | Commands::Doctor { .. } | Commands::Analytics { .. } + | Commands::Sessions { + action: SessionsAction::Unfinished { .. }, + } | Commands::Migrate { .. } | Commands::Projects { .. } | Commands::HookPreToolUse @@ -806,6 +809,9 @@ fn should_skip_agent_install_maintenance(command: &Commands) -> bool { | Commands::Migrate { .. } | Commands::Projects { .. } | Commands::Tool { .. } + | Commands::Sessions { + action: SessionsAction::Unfinished { .. }, + } | Commands::Daemon { .. } ) } diff --git a/src/mcp/server.rs b/src/mcp/server.rs index bc3e063cfa..a186d6e3de 100644 --- a/src/mcp/server.rs +++ b/src/mcp/server.rs @@ -1372,7 +1372,7 @@ impl McpServer { return; } - self.spawn_read_refresh_task(Arc::clone(cg), self.sync_config.full_sync_escalation_files); + self.spawn_read_refresh_task(cg, self.sync_config.full_sync_escalation_files); } /// Spawns the detached D4 refresh task. The task owns cheap `Arc` clones @@ -1383,7 +1383,7 @@ impl McpServer { /// /// The caller MUST have already set `background_refresh_running` to /// `true`; this task clears it on completion. - fn spawn_read_refresh_task(&self, cg: Arc, escalation: usize) { + fn spawn_read_refresh_task(&self, cg: &Arc, escalation: usize) { let running = Arc::clone(&self.background_refresh_running); let done_at = Arc::clone(&self.last_background_refresh_done_at); let token_map = Arc::clone(&self.file_token_map); @@ -1461,7 +1461,7 @@ impl McpServer { } self.last_background_refresh_at .store(now, Ordering::Release); - self.spawn_read_refresh_task(cg, self.sync_config.full_sync_escalation_files); + self.spawn_read_refresh_task(&cg, self.sync_config.full_sync_escalation_files); } /// Returns a compact one-line notice when automation runs have staged diff --git a/src/mcp/tools/definitions.rs b/src/mcp/tools/definitions.rs index 81aff4bcd6..9593f477bf 100644 --- a/src/mcp/tools/definitions.rs +++ b/src/mcp/tools/definitions.rs @@ -309,6 +309,7 @@ pub fn get_tool_definitions() -> Vec { def_dashboard(), def_message_search(), def_sessions_for(), + def_workflows(), def_lcm_status(), def_lcm_doctor(), def_lcm_load_session(), @@ -2463,7 +2464,9 @@ fn def_message_search() -> ToolDefinition { }, "branch": git_scope_branch_schema(), "worktree": git_scope_worktree_schema(), - "commit": git_scope_commit_schema() + "commit": git_scope_commit_schema(), + "workflow_run": workflow_run_scope_schema(), + "workflow_agent": workflow_agent_scope_schema() }, "required": ["query"] }), @@ -2534,6 +2537,65 @@ fn def_sessions_for() -> ToolDefinition { ) } +fn def_workflows() -> ToolDefinition { + def( + "tracedecay_workflows", + "Workflow Runs", + "Recover Claude Code workflow runs (multi-agent `wf_*` orchestrations) and their per-phase agents from the active project. Three modes, chosen by which argument is set: (1) list runs for a parent thread via session_id, or every run on a branch/worktree/commit via branch/worktree/commit (a run inherits its parent session's git spans); (2) show one run's result summary, phases, and agent roster via run_id; (3) drill into one agent's transcript via run_id + agent_label. Read-only; runs that never ran leave no rows.", + json!({ + "type": "object", + "properties": { + "session_id": { + "type": "string", + "description": "Parent thread/session id: list the workflow runs it spawned (newest first). Mutually exclusive with run_id and the git filters." + }, + "run_id": { + "type": "string", + "description": "A `wf_*` run id: show that run's summary, phases, and agents. Combine with agent_label to drill into one agent." + }, + "agent_label": { + "type": "string", + "description": "With run_id, drill into a single agent of that run by its label (e.g. 'mine:claude-transcripts')." + }, + "branch": { + "type": "string", + "description": "List workflow runs whose parent session was active on this git branch (via the session-git correlation index)." + }, + "worktree": { + "type": "string", + "description": "List workflow runs whose parent session was active in this git worktree root path." + }, + "commit": { + "type": "string", + "description": "List workflow runs whose parent session was attributed to this commit sha (full or >=6-char hex prefix)." + }, + "limit": { + "type": "integer", + "minimum": 1, + "maximum": 100, + "description": "Maximum runs or agents to return (default: 20)." + } + } + }), + ) +} + +/// Optional `workflow_run` narrowing filter shared by `tracedecay_message_search`: +/// scopes hits to the transcripts of one workflow run's agents. +fn workflow_run_scope_schema() -> Value { + json!({ + "type": "string", + "description": "Optional workflow run id (`wf_*`) filter: only messages from sessions that spawned this workflow run (via the workflow-run index). Pair with agent_label to scope to one agent." + }) +} + +fn workflow_agent_scope_schema() -> Value { + json!({ + "type": "string", + "description": "Optional workflow agent label filter, used with workflow_run to scope to a single agent of that run." + }) +} + fn lcm_storage_scope_schema() -> Value { json!({ "type": "string", diff --git a/src/mcp/tools/handlers/mod.rs b/src/mcp/tools/handlers/mod.rs index e1e5ab179b..e938c7eea2 100644 --- a/src/mcp/tools/handlers/mod.rs +++ b/src/mcp/tools/handlers/mod.rs @@ -19,6 +19,7 @@ pub mod session; pub mod skills; mod support; pub mod workflow; +pub mod workflow_query; use std::path::Path; @@ -432,6 +433,7 @@ pub async fn handle_tool_call_with_registry_and_implicit_project( .await } "tracedecay_sessions_for" => session::handle_sessions_for(cg, args).await, + "tracedecay_workflows" => workflow_query::handle_workflows(cg, args).await, "tracedecay_lcm_status" => { session::handle_lcm_status(session::LcmHandlerContext::active(cg), args).await } @@ -1020,7 +1022,7 @@ mod tests { // host CLI capabilities they need; agents should never see a tool that // will instantly fail. The count and per-tool checks below adapt to // the host's capability set. - let expected_total = 99 + usize::from(super::super::definitions::ast_grep_available()); + let expected_total = 100 + usize::from(super::super::definitions::ast_grep_available()); assert_eq!(tools.len(), expected_total); let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_str()).collect(); @@ -1103,6 +1105,7 @@ mod tests { assert!(tool_names.contains(&"tracedecay_dashboard")); assert!(tool_names.contains(&"tracedecay_message_search")); assert!(tool_names.contains(&"tracedecay_sessions_for")); + assert!(tool_names.contains(&"tracedecay_workflows")); assert!(tool_names.contains(&"tracedecay_lcm_status")); assert!(tool_names.contains(&"tracedecay_lcm_doctor")); assert!(tool_names.contains(&"tracedecay_lcm_load_session")); diff --git a/src/mcp/tools/handlers/session.rs b/src/mcp/tools/handlers/session.rs index d515dd9f1a..81d1a11ad4 100644 --- a/src/mcp/tools/handlers/session.rs +++ b/src/mcp/tools/handlers/session.rs @@ -6,9 +6,12 @@ use std::sync::{LazyLock, Mutex}; use serde_json::{json, Map, Value}; use super::super::render::{self, truncated_json_envelope_with_handle, Md}; -use super::support::{profile_root_for_global_db, project_registry_context, safe_profile_relpath}; +use super::support::{ + argument_error, profile_root_for_global_db, project_registry_context, safe_profile_relpath, + string_arg, tool_json_with_md, +}; use crate::errors::{Result, TraceDecayError}; -use crate::global_db::{GlobalDb, ProjectRegistryContext}; +use crate::global_db::{GlobalDb, ProjectRegistryContext, WorkflowScopeFilter}; use crate::mcp::response_handles::{ observe_response_truncation, store_response_handle, RESPONSE_RETRIEVE_TOOL, }; @@ -43,22 +46,6 @@ fn tool_json(project_root: Option<&Path>, args: &Value, value: &Value) -> ToolRe tool_json_with_md(project_root, args, value, || render::generic_md(value)) } -/// Like [`tool_json`] but renders the markdown (default-format) body with a -/// caller-supplied closure instead of the generic key/value renderer. The -/// `format:"json"` path is unaffected — it always serializes `value` compactly. -fn tool_json_with_md String>( - project_root: Option<&Path>, - args: &Value, - value: &Value, - md: F, -) -> ToolResult { - let text = render::finalize(project_root, args, value, md); - ToolResult::new( - json!({ "content": [{ "type": "text", "text": text }] }), - Vec::new(), - ) -} - const MESSAGE_SEARCH_SNIPPET_CHARS: usize = 240; /// Renders `tracedecay_message_search` results as compact markdown. Each hit @@ -80,6 +67,9 @@ fn render_message_search_md(value: &Value) -> String { if let Some(summary) = git_filter_summary(value) { md.field("git filter", &summary); } + if let Some(summary) = workflow_filter_summary(value) { + md.field("workflow filter", &summary); + } let results = value.get("results").and_then(Value::as_array); match results { Some(results) if !results.is_empty() => { @@ -95,6 +85,25 @@ fn render_message_search_md(value: &Value) -> String { md.render() } +/// One-line `scoped to run wf_… agent …` summary of an applied workflow-run +/// filter, or `None` when none was applied. Reads the `workflow_run` / +/// `workflow_agent` keys echoed into the payload by the message-search handler. +fn workflow_filter_summary(value: &Value) -> Option { + if !value + .get("workflow_filter_applied") + .and_then(Value::as_bool) + .unwrap_or(false) + { + return None; + } + let run_id = value.get("workflow_run").and_then(Value::as_str)?; + let mut summary = format!("scoped to run `{run_id}`"); + if let Some(agent) = value.get("workflow_agent").and_then(Value::as_str) { + let _ = write!(summary, " agent `{agent}`"); + } + Some(summary) +} + /// One-line `branch=… worktree=… commit=…` summary of the applied git-scope /// filter, or `None` when no filter was applied. Reads the `git_filter` object /// echoed into the payload by the message-search / lcm-grep handlers. @@ -1046,25 +1055,12 @@ fn truncate_chars(value: &str, max_chars: usize) -> (String, bool) { (text, truncated) } -fn string_arg<'a>(args: &'a Value, name: &str) -> Option<&'a str> { - args.get(name) - .and_then(Value::as_str) - .map(str::trim) - .filter(|value| !value.is_empty()) -} - fn required_string_arg<'a>(args: &'a Value, name: &str) -> Result<&'a str> { string_arg(args, name).ok_or_else(|| TraceDecayError::Config { message: format!("missing required parameter: {name}"), }) } -fn argument_error(message: impl Into) -> TraceDecayError { - TraceDecayError::Config { - message: message.into(), - } -} - fn bounded_usize_arg(args: &Value, name: &str, min: usize, max: usize) -> Result> { let Some(value) = args.get(name) else { return Ok(None); @@ -1971,6 +1967,14 @@ pub(super) async fn handle_message_search( .and_then(Value::as_str) .map(str::trim) .filter(|parent_session_id| !parent_session_id.is_empty()); + // Workflow-run scoping narrows a search to the agent transcripts of one + // run via an EXISTS pushdown against `workflow_agents` (see + // `search_session_messages_workflow_scoped`), so a hit is kept only when it + // belongs to an agent of the run — not the whole parent thread. + // `workflow_agent`, when set, pins the scope to a single agent. Both are + // echoed in the payload so callers see the applied filter. + let workflow_run = string_arg(&args, "workflow_run"); + let workflow_agent = string_arg(&args, "workflow_agent"); let include_subagents = args .get("include_subagents") .and_then(Value::as_bool) @@ -2033,7 +2037,37 @@ pub(super) async fn handle_message_search( ) .await; } - let results = if git_filter_applied { + // Build the workflow-run scope filter and, separately, resolve the run's + // parent thread purely for the echoed `workflow_run_parent_session` field + // (the scope itself is authoritative via the `workflow_agents` EXISTS + // pushdown, so an unknown/orphan parent no longer needs a sentinel). + let workflow_scope = workflow_run.map(|run_id| WorkflowScopeFilter { + run_id: run_id.to_string(), + agent_label: workflow_agent.map(str::to_string), + }); + let workflow_filter_applied = workflow_scope.is_some(); + let resolved_workflow_parent: Option = match workflow_run { + Some(run_id) => match db.workflow_run_for_id(run_id).await { + Ok(Some(run)) if !run.parent_session_id.is_empty() => Some(run.parent_session_id), + _ => None, + }, + None => None, + }; + let results = if let Some(workflow_filter) = &workflow_scope { + db.search_session_messages_workflow_scoped( + requested_provider, + project_key, + query, + limit, + SessionSearchFilters { + scope, + parent_session_id, + time_range, + }, + workflow_filter, + ) + .await + } else if git_filter_applied { db.search_session_messages_git_scoped( requested_provider, project_key, @@ -2105,6 +2139,25 @@ pub(super) async fn handle_message_search( map.insert("git_filter_applied".to_string(), Value::Bool(true)); } } + if workflow_filter_applied { + if let Some(map) = payload.as_object_mut() { + map.insert( + "workflow_run".to_string(), + workflow_run.map_or(Value::Null, |run| Value::String(run.to_string())), + ); + if let Some(label) = workflow_agent { + map.insert( + "workflow_agent".to_string(), + Value::String(label.to_string()), + ); + } + map.insert( + "workflow_run_parent_session".to_string(), + resolved_workflow_parent.map_or(Value::Null, Value::String), + ); + map.insert("workflow_filter_applied".to_string(), Value::Bool(true)); + } + } Ok(tool_json_with_md( Some(&target_root), &args, diff --git a/src/mcp/tools/handlers/support.rs b/src/mcp/tools/handlers/support.rs index aa994077c2..639873cd13 100644 --- a/src/mcp/tools/handlers/support.rs +++ b/src/mcp/tools/handlers/support.rs @@ -6,11 +6,45 @@ use std::collections::HashSet; use std::path::{Component, Path, PathBuf}; -use serde_json::Value; +use serde_json::{json, Value}; +use super::super::render; +use super::super::ToolResult; use crate::errors::{Result, TraceDecayError}; use crate::global_db::{CodeProjectRecord, GlobalDb, ProjectRegistryContext}; +/// Trimmed, non-empty string argument by key, or `None` when absent, non-string, +/// or blank after trimming. +pub(super) fn string_arg<'a>(args: &'a Value, key: &str) -> Option<&'a str> { + args.get(key) + .and_then(Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) +} + +/// Builds a `Config` error from a message, for argument-validation failures. +pub(super) fn argument_error(message: impl Into) -> TraceDecayError { + TraceDecayError::Config { + message: message.into(), + } +} + +/// Wraps a JSON payload in a text `ToolResult`, rendering the default-format +/// (markdown) body with a caller-supplied closure. The `format:"json"` path is +/// unaffected — [`render::finalize`] serializes `value` compactly there. +pub(super) fn tool_json_with_md String>( + project_root: Option<&Path>, + args: &Value, + value: &Value, + md: F, +) -> ToolResult { + let text = render::finalize(project_root, args, value, md); + ToolResult::new( + json!({ "content": [{ "type": "text", "text": text }] }), + Vec::new(), + ) +} + /// Extracts the `node_id` parameter from tool arguments, accepting `id` as a /// fallback alias. LLMs occasionally shorten `node_id` to `id`; this avoids a /// confusing error when that happens. diff --git a/src/mcp/tools/handlers/workflow_query.rs b/src/mcp/tools/handlers/workflow_query.rs new file mode 100644 index 0000000000..e6a5cf51b5 --- /dev/null +++ b/src/mcp/tools/handlers/workflow_query.rs @@ -0,0 +1,520 @@ +//! Read-only `tracedecay_workflows` query surface. + +use std::fmt::Write as _; +use std::path::Path; + +use serde_json::{json, Value}; + +use crate::errors::{Result, TraceDecayError}; +use crate::global_db::GlobalDb; +use crate::sessions::git_correlation::GitScopeFilter; +use crate::sessions::workflow_index::{WorkflowIndexError, MAX_WORKFLOW_LIMIT}; +use crate::tracedecay::TraceDecay; + +use super::super::render::{self, Md}; +use super::super::ToolResult; +use super::support::{argument_error, string_arg, tool_json_with_md}; + +const DEFAULT_WORKFLOWS_LIMIT: usize = 20; + +#[allow(clippy::needless_pass_by_value)] // used with `.map_err(workflow_error)` +fn workflow_error(err: WorkflowIndexError) -> TraceDecayError { + TraceDecayError::Config { + message: err.to_string(), + } +} + +enum WorkflowMode { + Run { + run_id: String, + agent_label: Option, + }, + Session { + session_id: String, + }, + GitScope { + filter: GitScopeFilter, + }, +} + +fn parse_mode(args: &Value) -> Result { + let run_id = string_arg(args, "run_id"); + let session_id = string_arg(args, "session_id"); + let git_filter = GitScopeFilter::from_args( + string_arg(args, "branch"), + string_arg(args, "worktree"), + string_arg(args, "commit"), + ) + .map_err(|err| argument_error(err.to_string()))?; + + let selectors = [ + run_id.is_some(), + session_id.is_some(), + !git_filter.is_empty(), + ] + .into_iter() + .filter(|set| *set) + .count(); + if selectors == 0 { + return Err(argument_error( + "provide one of: run_id (show/drill), session_id (list runs for a thread), \ + or branch/worktree/commit (list runs on a git ref)", + )); + } + if selectors > 1 { + return Err(argument_error( + "run_id, session_id, and the git filters are mutually exclusive; pass only one", + )); + } + + if let Some(run_id) = run_id { + return Ok(WorkflowMode::Run { + run_id: run_id.to_string(), + agent_label: string_arg(args, "agent_label").map(str::to_string), + }); + } + if let Some(session_id) = session_id { + return Ok(WorkflowMode::Session { + session_id: session_id.to_string(), + }); + } + Ok(WorkflowMode::GitScope { filter: git_filter }) +} + +pub(super) async fn handle_workflows(cg: &TraceDecay, args: Value) -> Result { + let mode = parse_mode(&args)?; + let limit = bounded_limit(&args)?; + + let db_path = cg.store_layout().sessions_db_path.clone(); + if !db_path.is_file() { + return Ok(empty_payload(cg.project_root(), &args, &mode)); + } + let Some(db) = GlobalDb::open_read_only_at(&db_path).await else { + return Ok(tool_json_with_md( + Some(cg.project_root()), + &args, + &json!({ + "status": "unavailable", + "message": "could not open project tracedecay session database", + "runs": [], + "count": 0 + }), + || "No workflow index available.".to_string(), + )); + }; + + let payload = match &mode { + WorkflowMode::Run { + run_id, + agent_label, + } => run_payload(&db, run_id, agent_label.as_deref(), limit).await?, + WorkflowMode::Session { session_id } => { + let runs = db + .workflow_runs_for_session(session_id, limit) + .await + .map_err(workflow_error)?; + json!({ + "status": "ok", + "mode": "session", + "session_id": session_id, + "count": runs.len(), + "runs": runs, + }) + } + WorkflowMode::GitScope { filter } => { + let runs = db + .workflow_runs_for_git_scope(filter, limit) + .await + .map_err(workflow_error)?; + json!({ + "status": "ok", + "mode": "git_scope", + "git_filter": filter, + "count": runs.len(), + "runs": runs, + }) + } + }; + + Ok(tool_json_with_md( + Some(cg.project_root()), + &args, + &payload, + || render_workflows_md(&payload), + )) +} + +fn bounded_limit(args: &Value) -> Result { + match args.get("limit") { + None | Some(Value::Null) => Ok(DEFAULT_WORKFLOWS_LIMIT), + Some(value) => { + let raw = value + .as_u64() + .ok_or_else(|| argument_error("limit must be a positive integer"))?; + Ok((raw as usize).clamp(1, MAX_WORKFLOW_LIMIT)) + } + } +} + +fn run_not_found_payload(run_id: &str) -> Value { + json!({ + "status": "ok", + "mode": "run", + "run_id": run_id, + "found": false, + "runs": [], + "count": 0, + }) +} + +async fn run_payload( + db: &GlobalDb, + run_id: &str, + agent_label: Option<&str>, + limit: usize, +) -> Result { + let Some(run) = db + .workflow_run_for_id(run_id) + .await + .map_err(workflow_error)? + else { + return Ok(run_not_found_payload(run_id)); + }; + let agents = db + .workflow_agents_for_run(run_id, limit) + .await + .map_err(workflow_error)?; + match agent_label { + Some(label) => { + let agent = agents.iter().find(|a| a.agent_label == label); + Ok(json!({ + "status": "ok", + "mode": "agent", + "run_id": run_id, + "agent_label": label, + "found": agent.is_some(), + "run": run, + "agent": agent, + })) + } + None => Ok(json!({ + "status": "ok", + "mode": "run", + "run_id": run_id, + "found": true, + "run": run, + "agents": agents, + "agent_count": agents.len(), + })), + } +} + +fn empty_payload(project_root: &Path, args: &Value, mode: &WorkflowMode) -> ToolResult { + let payload = match mode { + WorkflowMode::Run { run_id, .. } => run_not_found_payload(run_id), + WorkflowMode::Session { session_id } => json!({ + "status": "ok", "mode": "session", "session_id": session_id, + "runs": [], "count": 0, + }), + WorkflowMode::GitScope { filter } => json!({ + "status": "ok", "mode": "git_scope", "git_filter": filter, + "runs": [], "count": 0, + }), + }; + tool_json_with_md(Some(project_root), args, &payload, || { + render_workflows_md(&payload) + }) +} + +fn render_workflows_md(value: &Value) -> String { + let mut md = Md::new(); + match render::field_str(value, "mode") { + "agent" => render_agent_md(&mut md, value), + "run" if value.get("found").and_then(Value::as_bool) == Some(true) => { + render_run_detail_md(&mut md, value); + } + _ => render_run_list_md(&mut md, value), + } + md.render() +} + +fn render_run_list_md(md: &mut Md, value: &Value) { + md.heading(2, "Workflow Runs"); + if let Some(session_id) = value.get("session_id").and_then(Value::as_str) { + md.field("thread", &format!("`{session_id}`")); + } + if let Some(filter) = value.get("git_filter") { + let summary = git_filter_summary(filter); + if !summary.is_empty() { + md.field("git", &summary); + } + } + md.field("count", &render::field_i64(value, "count").to_string()); + let runs = value.get("runs").and_then(Value::as_array); + match runs { + Some(runs) if !runs.is_empty() => { + md.blank(); + for run in runs { + append_run_bullet(md, run); + } + } + _ => { + md.blank() + .empty_note("No workflow runs recorded for this scope yet."); + } + } +} + +fn append_run_bullet(md: &mut Md, run: &Value) { + let run_id = render::field_str(run, "run_id"); + let name = render::field_str(run, "name"); + let status = render::field_str(run, "status"); + let mut header = format!("`{run_id}`"); + if !name.is_empty() { + let _ = write!(header, " · {name}"); + } + if !status.is_empty() { + let _ = write!(header, " · {status}"); + } + md.bullet(&header); + let mut detail = String::new(); + let agent_count = render::field_i64(run, "agent_count"); + if agent_count > 0 { + let _ = write!(detail, "{agent_count} agents"); + } + if let Some(started) = run.get("started_ts").and_then(Value::as_i64) { + if !detail.is_empty() { + detail.push_str(" · "); + } + let _ = write!( + detail, + "started {}", + crate::timeutil::humanize_unix_secs(started) + ); + } + let summary = render::field_str(run, "result_summary"); + if !summary.is_empty() { + if !detail.is_empty() { + detail.push_str(" · "); + } + detail.push_str(&crate::sessions::shared::one_line_truncated(summary, 160)); + } + if !detail.is_empty() { + md.line(&format!(" {detail}")); + } +} + +fn render_run_detail_md(md: &mut Md, value: &Value) { + let run = value.get("run").unwrap_or(value); + let run_id = render::field_str(run, "run_id"); + md.heading(2, &format!("Workflow Run `{run_id}`")); + for (label, key) in [("name", "name"), ("status", "status")] { + let field = render::field_str(run, key); + if !field.is_empty() { + md.field(label, field); + } + } + if let Some(parent) = run.get("parent_session_id").and_then(Value::as_str) { + if !parent.is_empty() { + md.field("thread", &format!("`{parent}`")); + } + } + let summary = render::field_str(run, "result_summary"); + if !summary.is_empty() { + md.blank() + .line(&crate::sessions::shared::one_line_truncated(summary, 600)); + } + // Phases (from phase_json), then agents. + if let Some(phases) = run + .get("phase_json") + .and_then(Value::as_str) + .and_then(|raw| serde_json::from_str::(raw).ok()) + .as_ref() + .and_then(Value::as_array) + { + if !phases.is_empty() { + md.blank().line("**Phases**"); + for phase in phases { + let title = render::field_str(phase, "title"); + if !title.is_empty() { + md.bullet(title); + } + } + } + } + let agents = value.get("agents").and_then(Value::as_array); + match agents { + Some(agents) if !agents.is_empty() => { + md.blank().line("**Agents**"); + for agent in agents { + append_agent_bullet(md, agent); + } + } + _ => { + md.blank().empty_note("No agents recorded for this run."); + } + } +} + +fn render_agent_md(md: &mut Md, value: &Value) { + let run_id = render::field_str(value, "run_id"); + let label = render::field_str(value, "agent_label"); + md.heading(2, &format!("Agent `{label}`")); + md.field("run", &format!("`{run_id}`")); + match value.get("agent") { + Some(agent) if !agent.is_null() => { + append_agent_bullet(md, agent); + let transcript = render::field_str(agent, "transcript_path"); + if !transcript.is_empty() { + md.blank() + .line(&format!("transcript: `{transcript}`")) + .line("Replay it with `tracedecay_message_search` (workflow_run/workflow_agent filter) or `tracedecay_lcm_load_session`."); + } + } + _ => { + md.blank() + .empty_note("No agent with that label in this run."); + } + } +} + +fn append_agent_bullet(md: &mut Md, agent: &Value) { + let label = render::field_str(agent, "agent_label"); + let phase = render::field_str(agent, "phase"); + let status = render::field_str(agent, "status"); + let mut header = format!("`{label}`"); + if !phase.is_empty() { + let _ = write!(header, " · {phase}"); + } + if !status.is_empty() { + let _ = write!(header, " · {status}"); + } + md.bullet(&header); + let mut detail = String::new(); + let model = render::field_str(agent, "model"); + if !model.is_empty() { + let _ = write!(detail, "{model}"); + } + let tokens = render::field_i64(agent, "tokens"); + if tokens > 0 { + if !detail.is_empty() { + detail.push_str(" · "); + } + let _ = write!(detail, "{tokens} tok"); + } + if !detail.is_empty() { + md.line(&format!(" {detail}")); + } +} + +fn git_filter_summary(filter: &Value) -> String { + let mut parts = Vec::new(); + for key in ["branch", "worktree", "commit"] { + if let Some(value) = filter.get(key).and_then(Value::as_str) { + parts.push(format!("{key}=`{value}`")); + } + } + parts.join(" ") +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn parse_mode_requires_exactly_one_selector() { + // None → error. + assert!(parse_mode(&json!({})).is_err()); + // Two families → error. + assert!(parse_mode(&json!({"run_id": "wf_a", "session_id": "s"})).is_err()); + assert!(parse_mode(&json!({"session_id": "s", "branch": "main"})).is_err()); + } + + #[test] + fn parse_mode_run_carries_optional_agent_label() { + match parse_mode(&json!({"run_id": "wf_a", "agent_label": "mine:claude"})).unwrap() { + WorkflowMode::Run { + run_id, + agent_label, + } => { + assert_eq!(run_id, "wf_a"); + assert_eq!(agent_label.as_deref(), Some("mine:claude")); + } + _ => panic!("expected run mode"), + } + } + + #[test] + fn parse_mode_git_scope_normalizes_filter() { + match parse_mode(&json!({"branch": "feat/x"})).unwrap() { + WorkflowMode::GitScope { filter } => { + assert_eq!(filter.branch.as_deref(), Some("feat/x")); + assert!(filter.worktree.is_none()); + } + _ => panic!("expected git scope mode"), + } + } + + #[test] + fn bounded_limit_defaults_and_clamps() { + assert_eq!(bounded_limit(&json!({})).unwrap(), DEFAULT_WORKFLOWS_LIMIT); + assert_eq!(bounded_limit(&json!({"limit": 5})).unwrap(), 5); + assert_eq!( + bounded_limit(&json!({"limit": 10_000})).unwrap(), + MAX_WORKFLOW_LIMIT + ); + assert!(bounded_limit(&json!({"limit": "nope"})).is_err()); + } + + #[test] + fn render_run_list_empty_is_summary_first() { + let payload = json!({ + "status": "ok", "mode": "session", "session_id": "s1", + "runs": [], "count": 0, + }); + let md = render_workflows_md(&payload); + assert!(md.contains("Workflow Runs")); + assert!(md.contains("No workflow runs recorded")); + // Never leaks a JSON blob into the markdown. + assert!(!md.contains("\"status\"")); + } + + #[test] + fn render_run_list_shows_name_status_and_summary() { + let payload = json!({ + "status": "ok", "mode": "session", "session_id": "s1", "count": 1, + "runs": [{ + "run_id": "wf_x", "name": "triggering-evals", "status": "completed", + "agent_count": 11, "started_ts": 1_700_000_000, + "result_summary": "36 scenarios,\n45 runs", + }], + }); + let md = render_workflows_md(&payload); + assert!(md.contains("wf_x")); + assert!(md.contains("triggering-evals")); + assert!(md.contains("11 agents")); + // Multi-line summary was flattened to one line. + assert!(md.contains("36 scenarios, 45 runs")); + assert!(!md.contains("scenarios,\n45")); + } + + #[test] + fn render_agent_drill_shows_transcript_and_replay_hint() { + let payload = json!({ + "status": "ok", "mode": "agent", "run_id": "wf_x", + "agent_label": "mine:claude", "found": true, + "agent": { + "agent_label": "mine:claude", "phase": "Mine", "status": "completed", + "model": "claude-fable-5", "tokens": 30_212, + "transcript_path": "/home/u/.claude/.../agent-a1.jsonl", + }, + }); + let md = render_workflows_md(&payload); + assert!(md.contains("Agent `mine:claude`")); + assert!(md.contains("claude-fable-5")); + assert!(md.contains("30212 tok")); + assert!(md.contains("agent-a1.jsonl")); + assert!(md.contains("message_search")); + } +} diff --git a/src/sessions/claude.rs b/src/sessions/claude.rs index e3441adccd..f65e16bde4 100644 --- a/src/sessions/claude.rs +++ b/src/sessions/claude.rs @@ -43,7 +43,7 @@ const CLAUDE_MESSAGE_LOCATION_KEYS: TranscriptLocationMetadataKeys = const MAX_SCAN_DEPTH: u8 = 6; /// `cwd` should appear on an early line; scan a few in case the first is a /// `summary`/meta line without one. -const CWD_PROBE_LINES: usize = 8; +pub(crate) const CWD_PROBE_LINES: usize = 8; /// Claude Code transcript locator + parser. pub struct ClaudeSource { @@ -181,7 +181,7 @@ fn claude_subagent_identity(path: &Path) -> Option { } /// Reads the session `cwd` from an early line of a Claude transcript. -fn transcript_cwd(path: &Path) -> Option { +pub(crate) fn transcript_cwd(path: &Path) -> Option { use std::io::BufRead; let file = std::fs::File::open(path).ok()?; let reader = std::io::BufReader::new(file); diff --git a/src/sessions/git_correlation.rs b/src/sessions/git_correlation.rs index 1d622879b7..964a9fa2b0 100644 --- a/src/sessions/git_correlation.rs +++ b/src/sessions/git_correlation.rs @@ -727,48 +727,70 @@ async fn commit_session_ids( Ok(ids) } -/// One EXISTS predicate string plus its bound values for a git-scope -/// constraint, correlated to an outer message row via `session_column` -/// (e.g. `m.session_id` or `r.session_id`). The predicate uses anonymous `?` -/// placeholders, so callers append the returned values in order. Returns -/// `None` when the filter is empty. +/// Individual EXISTS clauses for git-scope filters, each with its bound +/// values. Callers combine with ` AND ` (message search) or ` OR ` (workflow +/// runs on a git ref). /// /// Span rows may carry `provider = ''` (raw hook routes are provider-agnostic), /// so scoping matches on `session_id` alone rather than also constraining the /// provider. -pub(crate) fn git_scope_exists_predicate( +pub(crate) fn git_scope_exists_clauses( filter: &GitScopeFilter, session_column: &str, -) -> Option<(String, Vec)> { - if filter.is_empty() { - return None; - } - let mut clauses: Vec = Vec::new(); - let mut values: Vec = Vec::new(); +) -> Vec<(String, Vec)> { + let mut clauses = Vec::new(); if let Some(branch) = &filter.branch { - clauses.push(format!( - "EXISTS (SELECT 1 FROM session_git_spans g \ - WHERE g.session_id = {session_column} AND g.branch = ?)" + clauses.push(( + format!( + "EXISTS (SELECT 1 FROM session_git_spans g \ + WHERE g.session_id = {session_column} AND g.branch = ?)" + ), + vec![Value::Text(branch.clone())], )); - values.push(Value::Text(branch.clone())); } if let Some(worktree) = &filter.worktree { - clauses.push(format!( - "EXISTS (SELECT 1 FROM session_git_spans g \ - WHERE g.session_id = {session_column} AND g.worktree = ?)" + clauses.push(( + format!( + "EXISTS (SELECT 1 FROM session_git_spans g \ + WHERE g.session_id = {session_column} AND g.worktree = ?)" + ), + vec![Value::Text(worktree.clone())], )); - values.push(Value::Text(worktree.clone())); } if let Some(commit) = &filter.commit { - clauses.push(format!( - "EXISTS (SELECT 1 FROM commit_sessions c \ - WHERE c.session_id = {session_column} \ - AND (c.commit_sha = ? OR c.commit_sha LIKE ?))" + clauses.push(( + format!( + "EXISTS (SELECT 1 FROM commit_sessions c \ + WHERE c.session_id = {session_column} \ + AND (c.commit_sha = ? OR c.commit_sha LIKE ?))" + ), + vec![ + Value::Text(commit.clone()), + Value::Text(format!("{commit}%")), + ], )); - values.push(Value::Text(commit.clone())); - values.push(Value::Text(format!("{commit}%"))); } - Some((clauses.join(" AND "), values)) + clauses +} + +/// One AND-combined EXISTS predicate plus bound values for a git-scope +/// constraint, correlated to an outer row via `session_column` (e.g. +/// `m.session_id`). Returns `None` when the filter is empty. +pub(crate) fn git_scope_exists_predicate( + filter: &GitScopeFilter, + session_column: &str, +) -> Option<(String, Vec)> { + let clauses = git_scope_exists_clauses(filter, session_column); + if clauses.is_empty() { + return None; + } + let sql = clauses + .iter() + .map(|(clause, _)| clause.as_str()) + .collect::>() + .join(" AND "); + let values = clauses.into_iter().flat_map(|(_, values)| values).collect(); + Some((sql, values)) } /// True when the git-correlation tables exist in `conn`'s database. Search diff --git a/src/sessions/mod.rs b/src/sessions/mod.rs index ccea3c5fe3..67fa6dafdf 100644 --- a/src/sessions/mod.rs +++ b/src/sessions/mod.rs @@ -21,6 +21,9 @@ pub mod shared; pub mod source; pub(crate) mod transcript_backfill; pub mod vibe; +pub mod workflow_index; +pub mod workflow_ingest; +pub mod workflow_state; pub use providers::{ProviderScope, SessionProvider}; @@ -90,6 +93,11 @@ pub async fn ingest_global_sources_for_provider( // Now that messages have landed, attribute any commits that fell inside a // recorded session span. Fail-open: a git or DB hiccup never blocks ingest. attribute_commits_after_ingest(db).await; + // Index Claude Code workflow runs + their agents last, so the parent + // sessions' git spans already exist and each run inherits them. Fail-open: + // a workflow-ingest hiccup only logs at debug, never blocks session ingest. + // Runs live in their own tables, so they do not affect `stats`. + let _ = workflow_ingest::ingest_workflow_runs(db, project_root).await; stats } diff --git a/src/sessions/shared.rs b/src/sessions/shared.rs index 7cb7b2bb22..15fcaa86cb 100644 --- a/src/sessions/shared.rs +++ b/src/sessions/shared.rs @@ -4,7 +4,7 @@ //! file-backed [`crate::sessions::source`] drivers and the Hermes `SQLite` sweep //! both depend on them so they do not need to import from each other. -use std::path::Path; +use std::path::{Path, PathBuf}; use serde_json::Value; @@ -126,29 +126,57 @@ pub(crate) fn paths_equal(a: &Path, b: &Path) -> bool { } pub(crate) fn path_belongs_to_project(path: &Path, project_root: &Path) -> bool { - if paths_equal(path, project_root) { - return true; + ProjectRootMatcher::new(project_root).contains(path) +} + +/// A project root with its git worktree/common-dir resolutions computed once, +/// so repeated membership tests (e.g. one per discovered workflow run) do not +/// re-run `git_worktree_root`/`git_common_dir` on the fixed project side. A +/// single [`ProjectRootMatcher::contains`] call is exactly equivalent to +/// [`path_belongs_to_project`], which is a thin wrapper over it. +pub(crate) struct ProjectRootMatcher { + root: PathBuf, + worktree: Option, + common_dir: Option, +} + +impl ProjectRootMatcher { + /// Resolve the fixed project-side git identity once. + pub(crate) fn new(project_root: &Path) -> Self { + Self { + root: project_root.to_path_buf(), + worktree: crate::worktree::git_worktree_root(project_root), + common_dir: crate::worktree::git_common_dir(project_root), + } } - let path_worktree = crate::worktree::git_worktree_root(path); - let project_worktree = crate::worktree::git_worktree_root(project_root); - if let (Some(path_worktree), Some(project_worktree)) = - (path_worktree.as_ref(), project_worktree.as_ref()) - { - if paths_equal(path_worktree, project_worktree) { + /// True when `path` belongs to this project: it is the root, shares the + /// project's git worktree or common dir, or discovers back to the root. + /// Only the varying `path` side is git-resolved here. + pub(crate) fn contains(&self, path: &Path) -> bool { + if paths_equal(path, &self.root) { return true; } - let path_common = crate::worktree::git_common_dir(path); - let project_common = crate::worktree::git_common_dir(project_root); - return path_common + + if let (Some(path_worktree), Some(project_worktree)) = ( + crate::worktree::git_worktree_root(path).as_ref(), + self.worktree.as_ref(), + ) { + if paths_equal(path_worktree, project_worktree) { + return true; + } + return crate::worktree::git_common_dir(path) + .as_ref() + .zip(self.common_dir.as_ref()) + .is_some_and(|(path_common, project_common)| { + paths_equal(path_common, project_common) + }); + } + + crate::config::discover_project_root(path) .as_ref() - .zip(project_common.as_ref()) - .is_some_and(|(path_common, project_common)| paths_equal(path_common, project_common)); + .is_some_and(|discovered| paths_equal(discovered, &self.root)) } - - crate::config::discover_project_root(path) - .as_ref() - .is_some_and(|discovered| paths_equal(discovered, project_root)) } #[cfg(windows)] @@ -168,6 +196,20 @@ fn normalized_paths_equal(a: &Path, b: &Path) -> bool { a == b } +/// Collapse internal whitespace/newlines to single spaces and clip to at most +/// `max` characters, appending a single-character `…` when truncation occurred. +/// Shared by the workflow surfaces (run/agent summaries, result summaries, +/// unfinished-run evidence) so a multi-line blob never smears a table, bullet, +/// or stored column. +pub(crate) fn one_line_truncated(text: &str, max: usize) -> String { + let collapsed = text.split_whitespace().collect::>().join(" "); + if collapsed.chars().count() <= max { + return collapsed; + } + let truncated: String = collapsed.chars().take(max).collect(); + format!("{truncated}…") +} + /// Collapse whitespace and clip to a short preview suitable for a session title. pub(crate) fn preview_title(text: &str) -> String { const MAX_TITLE_CHARS: usize = 80; @@ -411,8 +453,15 @@ pub(crate) fn title_from_messages(messages: &[SessionMessageRecord]) -> Option subagents -> workflow runs -> workflow agents`. +//! A run's transcript files live under +//! `~/.claude/projects///subagents/workflows//`, and +//! the run's meta+result is the sibling `workflows/.json`. A run is +//! therefore *owned* by the session that spawned it (`parent_session_id`), so +//! it inherits that session's git spans: "workflows on branch X" resolves to +//! runs whose parent session has a span on X (see [`runs_for_git_scope`]). +//! +//! This module owns the **storage + query** foundation only. The ingest sweep +//! that discovers run directories and parses transcripts, and the +//! `tracedecay_workflows` query surface, build on the APIs defined here. + +use libsql::{params, Connection, Value}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; + +use crate::sessions::git_correlation::{GitScopeFilter, MAX_SESSIONS_FOR_LIMIT}; + +/// Schema version recorded in `session_schema_migrations` under +/// [`MIGRATION_NAME`]. Bump when the workflow tables change shape. +pub const WORKFLOW_INDEX_SCHEMA_VERSION: i64 = 1; + +const MIGRATION_NAME: &str = "workflow_indexing"; + +/// Hard cap on rows returned by run/agent list queries, matching the +/// git-correlation ceiling so the two surfaces page alike. +pub const MAX_WORKFLOW_LIMIT: usize = MAX_SESSIONS_FOR_LIMIT; + +/// Errors from the workflow-index store. +/// +/// Shaped like [`crate::sessions::git_correlation::GitCorrelationError`] so +/// callers and `?`-conversions read the same across both stores. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkflowIndexError { + /// Underlying database failure. + Db(String), + /// Caller-supplied argument was invalid (empty run id, …). + InvalidArgument(String), +} + +impl std::fmt::Display for WorkflowIndexError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Db(message) => write!(f, "workflow index db error: {message}"), + Self::InvalidArgument(message) => write!(f, "{message}"), + } + } +} + +impl std::error::Error for WorkflowIndexError {} + +impl From for WorkflowIndexError { + fn from(err: libsql::Error) -> Self { + Self::Db(err.to_string()) + } +} + +/// Lifecycle state of a workflow run or agent. +/// +/// Mirrors the Claude Code run JSON `status` / agent `state` vocabulary while +/// tolerating unknown strings (forward-compat): anything unrecognized folds to +/// [`WorkflowStatus::Unknown`] rather than failing ingest. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum WorkflowStatus { + /// Still executing (run dir present, no terminal result yet). + Running, + /// Reached a successful terminal result. + Completed, + /// Terminated in error / blocked / interrupted. + Failed, + /// Status not recorded or not recognized. + Unknown, +} + +impl WorkflowStatus { + pub const fn as_str(self) -> &'static str { + match self { + Self::Running => "running", + Self::Completed => "completed", + Self::Failed => "failed", + Self::Unknown => "unknown", + } + } + + /// Normalizes an on-disk status/state token. Recognizes the Claude Code + /// run vocabulary (`completed`, `running`, `failed`, `error`, `blocked`, + /// agent `done`/`in_progress`); everything else becomes `Unknown`. + pub fn from_disk(value: &str) -> Self { + let trimmed = value.trim(); + if matches_token(trimmed, &["completed", "done", "success", "succeeded"]) { + Self::Completed + } else if matches_token( + trimmed, + &["running", "in_progress", "started", "active", "pending"], + ) { + Self::Running + } else if matches_token( + trimmed, + &[ + "failed", + "error", + "errored", + "blocked", + "interrupted", + "cancelled", + "canceled", + "timeout", + "timed_out", + ], + ) { + Self::Failed + } else { + Self::Unknown + } + } +} + +/// One indexed workflow run (`wf_*` directory + its `workflows/.json`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowRun { + /// `wf_*` run id (also the transcript directory name). Primary key. + pub run_id: String, + /// The user-thread session that spawned this run; the run inherits this + /// session's git spans. May be empty when the parent could not be resolved + /// from disk (orphan run dir), in which case git-scope joins skip it. + pub parent_session_id: String, + /// Workflow name from the run meta (`workflowName` / `meta.name`). + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Serialized `phases` array from the run meta, verbatim JSON text. + #[serde(skip_serializing_if = "Option::is_none")] + pub phase_json: Option, + pub status: WorkflowStatus, + /// Run start (unix seconds). Derived from `startTime`/`timestamp`. + #[serde(skip_serializing_if = "Option::is_none")] + pub started_ts: Option, + /// Run end (unix seconds). `started_ts + durationMs` when only a duration + /// is recorded. + #[serde(skip_serializing_if = "Option::is_none")] + pub ended_ts: Option, + /// Final run result rendered to a short summary string (the run JSON + /// `summary`, or a truncated `result`), never the full result blob. + #[serde(skip_serializing_if = "Option::is_none")] + pub result_summary: Option, + /// Number of agents recorded for the run (`agentCount`), for a cheap + /// list-view count without joining `workflow_agents`. + #[serde(default, skip_serializing_if = "is_zero")] + pub agent_count: i64, +} + +/// One workflow agent: a single per-phase subagent invocation within a run. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct WorkflowAgent { + pub run_id: String, + /// Human label from the run's `workflowProgress` (`label`, e.g. + /// `mine:claude-transcripts`). Unique within a run together with + /// `agent_id`. + pub agent_label: String, + /// Claude agent id (`agentId`, e.g. `a17141dbe5a308242`) — the stem of the + /// transcript file. Empty when a progress row lacked one. + pub agent_id: String, + /// Phase title this agent ran under (`phaseTitle`). + #[serde(skip_serializing_if = "Option::is_none")] + pub phase: Option, + /// Absolute path to the agent's `agent-.jsonl` transcript, when the + /// file was found on disk. Drill-down reads replay from here. + #[serde(skip_serializing_if = "Option::is_none")] + pub transcript_path: Option, + /// The agent's own session id, when the transcript recorded one distinct + /// from the parent thread. + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_session_id: Option, + pub status: WorkflowStatus, + /// Model that ran the agent (`model`), when recorded. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Total tokens (input+output, summed from transcript `usage`), when known. + #[serde(default, skip_serializing_if = "is_zero")] + pub tokens: i64, + #[serde(skip_serializing_if = "Option::is_none")] + pub started_ts: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ended_ts: Option, +} + +#[allow(clippy::trivially_copy_pass_by_ref)] // serde skip_serializing_if signature +fn is_zero(value: &i64) -> bool { + *value == 0 +} + +fn matches_token(value: &str, tokens: &[&str]) -> bool { + tokens.iter().any(|token| value.eq_ignore_ascii_case(token)) +} + +/// Ensures the workflow-index tables exist in the session store. Version-gated +/// through the shared `session_schema_migrations` table exactly like +/// [`crate::sessions::git_correlation::ensure_git_correlation_schema`], so both +/// stores register under their own migration name in one table. +pub(crate) async fn ensure_workflow_index_schema( + conn: &Connection, +) -> Result<(), WorkflowIndexError> { + if schema_version(conn) + .await + .is_some_and(|version| version >= WORKFLOW_INDEX_SCHEMA_VERSION) + { + return Ok(()); + } + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS session_schema_migrations ( + name TEXT PRIMARY KEY, + version INTEGER NOT NULL, + applied_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE TABLE IF NOT EXISTS workflow_runs ( + run_id TEXT PRIMARY KEY, + parent_session_id TEXT NOT NULL DEFAULT '', + name TEXT, + description TEXT, + phase_json TEXT, + status TEXT NOT NULL DEFAULT 'unknown' + CHECK(status IN ('running', 'completed', 'failed', 'unknown')), + started_ts INTEGER, + ended_ts INTEGER, + result_summary TEXT, + agent_count INTEGER NOT NULL DEFAULT 0, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_runs_parent + ON workflow_runs(parent_session_id, started_ts); + CREATE TABLE IF NOT EXISTS workflow_agents ( + run_id TEXT NOT NULL, + agent_label TEXT NOT NULL, + agent_id TEXT NOT NULL DEFAULT '', + phase TEXT, + transcript_path TEXT, + agent_session_id TEXT, + status TEXT NOT NULL DEFAULT 'unknown' + CHECK(status IN ('running', 'completed', 'failed', 'unknown')), + model TEXT, + tokens INTEGER NOT NULL DEFAULT 0, + started_ts INTEGER, + ended_ts INTEGER, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + updated_at INTEGER NOT NULL DEFAULT (unixepoch()), + PRIMARY KEY(run_id, agent_label, agent_id) + ); + CREATE INDEX IF NOT EXISTS idx_workflow_agents_run + ON workflow_agents(run_id, phase); + CREATE TABLE IF NOT EXISTS workflow_index_meta ( + key TEXT PRIMARY KEY, + value INTEGER NOT NULL, + updated_at INTEGER NOT NULL DEFAULT (unixepoch()) + );", + ) + .await?; + conn.execute( + "INSERT INTO session_schema_migrations(name, version) + VALUES (?1, ?2) + ON CONFLICT(name) DO UPDATE SET + version = excluded.version, + applied_at = unixepoch()", + params![MIGRATION_NAME, WORKFLOW_INDEX_SCHEMA_VERSION], + ) + .await?; + Ok(()) +} + +async fn schema_version(conn: &Connection) -> Option { + let mut rows = conn + .query( + "SELECT version FROM session_schema_migrations WHERE name = ?1", + params![MIGRATION_NAME], + ) + .await + .ok()?; + rows.next().await.ok()??.get(0).ok() +} + +/// True when both workflow tables are present, so a query against a store that +/// predates this schema can short-circuit to empty instead of hitting a +/// `no such table` error. Mirrors +/// [`crate::sessions::git_correlation::tables_present`]. +pub async fn tables_present(conn: &Connection) -> Result { + let mut rows = conn + .query( + "SELECT COUNT(*) FROM sqlite_master + WHERE type = 'table' + AND name IN ('workflow_runs', 'workflow_agents')", + (), + ) + .await?; + let Some(row) = rows.next().await? else { + return Ok(false); + }; + Ok(row.get::(0)? == 2) +} + +/// `workflow_index_meta` key holding the newest run-file mtime (unix seconds) +/// the ingest sweep has already processed. Runs whose files are no newer than +/// this value are skipped on the next sweep. See +/// [`crate::sessions::workflow_ingest`]. +pub const INGEST_WATERMARK_KEY: &str = "ingest_watermark_mtime"; + +/// Reads the ingest watermark (max processed run-file mtime, unix seconds), or +/// `0` when unset / the schema predates this table. Never errors: a store +/// without the meta table simply reports no watermark, forcing a full sweep. +pub async fn read_ingest_watermark(conn: &Connection, key: &str) -> i64 { + let Ok(mut rows) = conn + .query( + "SELECT value FROM workflow_index_meta WHERE key = ?1", + params![key], + ) + .await + else { + return 0; + }; + match rows.next().await { + Ok(Some(row)) => row.get::(0).unwrap_or(0), + _ => 0, + } +} + +/// Advances the ingest watermark to `mtime` when it is newer than the stored +/// value (monotonic; a stale re-scan never rewinds it). Requires the schema to +/// exist; callers ensure it before writing. +pub async fn bump_ingest_watermark( + conn: &Connection, + key: &str, + mtime: i64, +) -> Result<(), WorkflowIndexError> { + conn.execute( + "INSERT INTO workflow_index_meta(key, value) + VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET + value = MAX(value, excluded.value), + updated_at = unixepoch()", + params![key, mtime], + ) + .await?; + Ok(()) +} + +fn opt_text(value: Option<&str>) -> Value { + value.map_or(Value::Null, |text| Value::Text(text.to_string())) +} + +fn opt_int(value: Option) -> Value { + value.map_or(Value::Null, Value::Integer) +} + +/// Inserts or updates one run row (idempotent on `run_id`). Re-ingesting a run +/// whose transcripts grew (e.g. a `running` run that later `completed`) +/// overwrites the mutable columns and refreshes `updated_at`. `created_at` is +/// preserved. +pub async fn upsert_run(conn: &Connection, run: &WorkflowRun) -> Result<(), WorkflowIndexError> { + if run.run_id.trim().is_empty() { + return Err(WorkflowIndexError::InvalidArgument( + "workflow run_id must not be empty".to_string(), + )); + } + conn.execute( + "INSERT INTO workflow_runs( + run_id, parent_session_id, name, description, phase_json, + status, started_ts, ended_ts, result_summary, agent_count) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10) + ON CONFLICT(run_id) DO UPDATE SET + parent_session_id = excluded.parent_session_id, + name = excluded.name, + description = excluded.description, + phase_json = excluded.phase_json, + status = excluded.status, + started_ts = excluded.started_ts, + ended_ts = excluded.ended_ts, + result_summary = excluded.result_summary, + agent_count = excluded.agent_count, + updated_at = unixepoch()", + params![ + run.run_id.clone(), + run.parent_session_id.clone(), + opt_text(run.name.as_deref()), + opt_text(run.description.as_deref()), + opt_text(run.phase_json.as_deref()), + run.status.as_str(), + opt_int(run.started_ts), + opt_int(run.ended_ts), + opt_text(run.result_summary.as_deref()), + run.agent_count, + ], + ) + .await?; + Ok(()) +} + +/// Inserts or updates one agent row (idempotent on `(run_id, agent_label, +/// agent_id)`). +pub async fn upsert_agent( + conn: &Connection, + agent: &WorkflowAgent, +) -> Result<(), WorkflowIndexError> { + if agent.run_id.trim().is_empty() { + return Err(WorkflowIndexError::InvalidArgument( + "workflow agent run_id must not be empty".to_string(), + )); + } + conn.execute( + "INSERT INTO workflow_agents( + run_id, agent_label, agent_id, phase, transcript_path, + agent_session_id, status, model, tokens, started_ts, ended_ts) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(run_id, agent_label, agent_id) DO UPDATE SET + phase = excluded.phase, + transcript_path = excluded.transcript_path, + agent_session_id = excluded.agent_session_id, + status = excluded.status, + model = excluded.model, + tokens = excluded.tokens, + started_ts = excluded.started_ts, + ended_ts = excluded.ended_ts, + updated_at = unixepoch()", + params![ + agent.run_id.clone(), + agent.agent_label.clone(), + agent.agent_id.clone(), + opt_text(agent.phase.as_deref()), + opt_text(agent.transcript_path.as_deref()), + opt_text(agent.agent_session_id.as_deref()), + agent.status.as_str(), + opt_text(agent.model.as_deref()), + agent.tokens, + opt_int(agent.started_ts), + opt_int(agent.ended_ts), + ], + ) + .await?; + Ok(()) +} + +const RUN_COLUMNS: &str = "run_id, parent_session_id, name, description, phase_json, + status, started_ts, ended_ts, result_summary, agent_count"; + +fn row_to_run(row: &libsql::Row) -> Result { + let status: String = row.get(5)?; + Ok(WorkflowRun { + run_id: row.get(0)?, + parent_session_id: row.get(1)?, + name: row.get(2)?, + description: row.get(3)?, + phase_json: row.get(4)?, + status: WorkflowStatus::from_disk(&status), + started_ts: row.get(6)?, + ended_ts: row.get(7)?, + result_summary: row.get(8)?, + agent_count: row.get::>(9)?.unwrap_or(0), + }) +} + +const AGENT_COLUMNS: &str = "run_id, agent_label, agent_id, phase, transcript_path, + agent_session_id, status, model, tokens, started_ts, ended_ts"; + +fn row_to_agent(row: &libsql::Row) -> Result { + let status: String = row.get(6)?; + Ok(WorkflowAgent { + run_id: row.get(0)?, + agent_label: row.get(1)?, + agent_id: row.get(2)?, + phase: row.get(3)?, + transcript_path: row.get(4)?, + agent_session_id: row.get(5)?, + status: WorkflowStatus::from_disk(&status), + model: row.get(7)?, + tokens: row.get::>(8)?.unwrap_or(0), + started_ts: row.get(9)?, + ended_ts: row.get(10)?, + }) +} + +fn clamp_limit(limit: usize) -> i64 { + limit.clamp(1, MAX_WORKFLOW_LIMIT) as i64 +} + +/// Lists workflow runs spawned by one parent session, newest-first. Returns an +/// empty vec (never an error) when the schema is absent. +pub async fn runs_for_session( + conn: &Connection, + parent_session_id: &str, + limit: usize, +) -> Result, WorkflowIndexError> { + if !tables_present(conn).await.unwrap_or(false) { + return Ok(Vec::new()); + } + let sql = format!( + "SELECT {RUN_COLUMNS} + FROM workflow_runs + WHERE parent_session_id = ?1 + ORDER BY COALESCE(started_ts, 0) DESC, run_id DESC + LIMIT ?2" + ); + let mut rows = conn + .query(&sql, params![parent_session_id, clamp_limit(limit)]) + .await?; + let mut runs = Vec::new(); + while let Some(row) = rows.next().await? { + runs.push(row_to_run(&row)?); + } + Ok(runs) +} + +/// Fetches one run by its `wf_*` id, or `None` when absent. +pub async fn run_for_id( + conn: &Connection, + run_id: &str, +) -> Result, WorkflowIndexError> { + if !tables_present(conn).await.unwrap_or(false) { + return Ok(None); + } + let sql = format!("SELECT {RUN_COLUMNS} FROM workflow_runs WHERE run_id = ?1"); + let mut rows = conn.query(&sql, params![run_id]).await?; + match rows.next().await? { + Some(row) => Ok(Some(row_to_run(&row)?)), + None => Ok(None), + } +} + +/// Lists the agents of one run, ordered by start time then label so a phase +/// reads top-to-bottom. +pub async fn agents_for_run( + conn: &Connection, + run_id: &str, + limit: usize, +) -> Result, WorkflowIndexError> { + if !tables_present(conn).await.unwrap_or(false) { + return Ok(Vec::new()); + } + let sql = format!( + "SELECT {AGENT_COLUMNS} + FROM workflow_agents + WHERE run_id = ?1 + ORDER BY COALESCE(started_ts, 0) ASC, agent_label ASC + LIMIT ?2" + ); + let mut rows = conn + .query(&sql, params![run_id, clamp_limit(limit)]) + .await?; + let mut agents = Vec::new(); + while let Some(row) = rows.next().await? { + agents.push(row_to_agent(&row)?); + } + Ok(agents) +} + +/// Runs that ran "on branch X / in worktree Y / for commit Z": a run inherits +/// its parent session's git spans, so this selects runs whose +/// `parent_session_id` matches a session correlated with the given git ref. +/// +/// Implemented as an `EXISTS` pushdown against the git-correlation tables +/// ([`session_git_spans`] / [`commit_sessions`]) — the same tables +/// `tracedecay_sessions_for` reads. When either the workflow schema or the +/// git-correlation schema is absent, returns empty (nothing could correlate). +pub async fn runs_for_git_scope( + conn: &Connection, + filter: &GitScopeFilter, + limit: usize, +) -> Result, WorkflowIndexError> { + if filter.is_empty() { + return Err(WorkflowIndexError::InvalidArgument( + "runs_for_git_scope requires at least one of branch/worktree/commit".to_string(), + )); + } + if !tables_present(conn).await.unwrap_or(false) { + return Ok(Vec::new()); + } + // A git-scoped run query against a store written before the correlation + // schema existed can never match; report empty rather than issuing an + // EXISTS against missing tables. + if !crate::sessions::git_correlation::tables_present(conn) + .await + .unwrap_or(false) + { + return Ok(Vec::new()); + } + + let clauses = + crate::sessions::git_correlation::git_scope_exists_clauses(filter, "r.parent_session_id"); + let mut sql = format!( + "SELECT {RUN_COLUMNS} + FROM workflow_runs AS r + WHERE r.parent_session_id <> '' + AND (" + ); + let mut params: Vec = Vec::new(); + for (idx, (clause, mut values)) in clauses.into_iter().enumerate() { + if idx > 0 { + sql.push_str(" OR "); + } + sql.push_str(&clause); + params.append(&mut values); + } + params.push(Value::Integer(clamp_limit(limit))); + let _ = write!( + sql, + ") ORDER BY COALESCE(r.started_ts, 0) DESC, r.run_id DESC LIMIT ?{}", + params.len() + ); + + let mut rows = conn.query(&sql, params).await?; + let mut runs = Vec::new(); + while let Some(row) = rows.next().await? { + runs.push(row_to_run(&row)?); + } + Ok(runs) +} + +/// EXISTS predicate scoping message search to one workflow run's agents. +/// +/// Returns `(predicate_sql, params)` where `?1`, `?2`, … bind to the values +/// in order (`run_id`, optional `agent_label`). Callers append `params` to +/// their query bind list and AND the predicate into the outer WHERE clause. +pub(crate) fn workflow_scope_exists_predicate( + filter: &crate::global_db::WorkflowScopeFilter, + message_source_path_col: &str, + message_session_id_col: &str, +) -> (String, Vec) { + let mut params = vec![Value::Text(filter.run_id.clone())]; + let mut predicate = format!( + "EXISTS (SELECT 1 FROM workflow_agents wa \ + WHERE wa.run_id = ?1 \ + AND (wa.transcript_path = {message_source_path_col} \ + OR wa.agent_session_id = {message_session_id_col})" + ); + if let Some(label) = &filter.agent_label { + params.push(Value::Text(label.clone())); + let _ = write!(predicate, " AND wa.agent_label = ?{}", params.len()); + } + predicate.push(')'); + (predicate, params) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/src/sessions/workflow_index/tests.rs b/src/sessions/workflow_index/tests.rs new file mode 100644 index 0000000000..c1a0132092 --- /dev/null +++ b/src/sessions/workflow_index/tests.rs @@ -0,0 +1,243 @@ +use super::*; +use crate::global_db::WorkflowScopeFilter; +use crate::sessions::git_correlation::{ + ensure_git_correlation_schema, record_span_observation, SpanObservation, SpanSource, +}; + +async fn mem_conn() -> Connection { + let db = libsql::Builder::new_local(":memory:") + .build() + .await + .unwrap(); + db.connect().unwrap() +} + +fn sample_run(run_id: &str, parent: &str) -> WorkflowRun { + WorkflowRun { + run_id: run_id.to_string(), + parent_session_id: parent.to_string(), + name: Some("triggering-evals".to_string()), + description: Some("mine + run + score".to_string()), + phase_json: Some(r#"[{"title":"Mine"},{"title":"Run"}]"#.to_string()), + status: WorkflowStatus::Completed, + started_ts: Some(1_700_000_000), + ended_ts: Some(1_700_000_900), + result_summary: Some("36 scenarios, 45 runs".to_string()), + agent_count: 11, + } +} + +#[test] +fn status_from_disk_folds_known_and_unknown() { + assert_eq!( + WorkflowStatus::from_disk("completed"), + WorkflowStatus::Completed + ); + assert_eq!(WorkflowStatus::from_disk("done"), WorkflowStatus::Completed); + assert_eq!( + WorkflowStatus::from_disk("in_progress"), + WorkflowStatus::Running + ); + assert_eq!(WorkflowStatus::from_disk("blocked"), WorkflowStatus::Failed); + assert_eq!( + WorkflowStatus::from_disk("timed_out"), + WorkflowStatus::Failed + ); + assert_eq!(WorkflowStatus::from_disk("banana"), WorkflowStatus::Unknown); +} + +#[tokio::test] +async fn queries_are_empty_before_schema_exists() { + let conn = mem_conn().await; + // No tables yet: readers must fail-open to empty/None. + assert!(!tables_present(&conn).await.unwrap()); + assert!(runs_for_session(&conn, "sess", 10) + .await + .unwrap() + .is_empty()); + assert!(run_for_id(&conn, "wf_x").await.unwrap().is_none()); + assert!(agents_for_run(&conn, "wf_x", 10).await.unwrap().is_empty()); +} + +#[tokio::test] +async fn upsert_is_idempotent_and_updates_mutable_columns() { + let conn = mem_conn().await; + ensure_workflow_index_schema(&conn).await.unwrap(); + assert!(tables_present(&conn).await.unwrap()); + + let mut run = sample_run("wf_alpha", "sess-1"); + run.status = WorkflowStatus::Running; + run.result_summary = None; + upsert_run(&conn, &run).await.unwrap(); + + // Re-ingest the same run once it finished: overwrite, don't duplicate. + let finished = sample_run("wf_alpha", "sess-1"); + upsert_run(&conn, &finished).await.unwrap(); + + let all = runs_for_session(&conn, "sess-1", 10).await.unwrap(); + assert_eq!(all.len(), 1); + assert_eq!(all[0], finished); + assert_eq!(all[0].status, WorkflowStatus::Completed); + assert_eq!( + all[0].result_summary.as_deref(), + Some("36 scenarios, 45 runs") + ); + + assert_eq!(run_for_id(&conn, "wf_alpha").await.unwrap(), Some(finished)); + assert!(run_for_id(&conn, "wf_missing").await.unwrap().is_none()); +} + +#[tokio::test] +async fn empty_run_id_is_rejected() { + let conn = mem_conn().await; + ensure_workflow_index_schema(&conn).await.unwrap(); + let mut run = sample_run(" ", "sess"); + run.run_id = " ".to_string(); + let err = upsert_run(&conn, &run).await.unwrap_err(); + assert!(matches!(err, WorkflowIndexError::InvalidArgument(_))); +} + +#[tokio::test] +async fn runs_for_session_orders_newest_first_and_scopes_by_parent() { + let conn = mem_conn().await; + ensure_workflow_index_schema(&conn).await.unwrap(); + + let mut old = sample_run("wf_old", "sess-1"); + old.started_ts = Some(1_000); + let mut new = sample_run("wf_new", "sess-1"); + new.started_ts = Some(2_000); + let other = sample_run("wf_other", "sess-2"); + upsert_run(&conn, &old).await.unwrap(); + upsert_run(&conn, &new).await.unwrap(); + upsert_run(&conn, &other).await.unwrap(); + + let s1 = runs_for_session(&conn, "sess-1", 10).await.unwrap(); + let ids: Vec<&str> = s1.iter().map(|r| r.run_id.as_str()).collect(); + assert_eq!(ids, vec!["wf_new", "wf_old"]); + + let s2 = runs_for_session(&conn, "sess-2", 10).await.unwrap(); + assert_eq!(s2.len(), 1); + assert_eq!(s2[0].run_id, "wf_other"); +} + +#[tokio::test] +async fn agents_upsert_and_order_within_run() { + let conn = mem_conn().await; + ensure_workflow_index_schema(&conn).await.unwrap(); + upsert_run(&conn, &sample_run("wf_a", "sess")) + .await + .unwrap(); + + let second = WorkflowAgent { + run_id: "wf_a".to_string(), + agent_label: "run:batch2".to_string(), + agent_id: "a222".to_string(), + phase: Some("Run".to_string()), + transcript_path: Some("/tmp/agent-a222.jsonl".to_string()), + agent_session_id: None, + status: WorkflowStatus::Completed, + model: Some("claude-fable-5".to_string()), + tokens: 4200, + started_ts: Some(2_000), + ended_ts: Some(2_500), + }; + let first = WorkflowAgent { + agent_label: "mine:claude".to_string(), + agent_id: "a111".to_string(), + phase: Some("Mine".to_string()), + started_ts: Some(1_000), + ..second.clone() + }; + upsert_agent(&conn, &second).await.unwrap(); + upsert_agent(&conn, &first).await.unwrap(); + // Idempotent re-ingest of the first agent. + upsert_agent(&conn, &first).await.unwrap(); + + let agents = agents_for_run(&conn, "wf_a", 10).await.unwrap(); + let labels: Vec<&str> = agents.iter().map(|a| a.agent_label.as_str()).collect(); + assert_eq!(labels, vec!["mine:claude", "run:batch2"]); + assert_eq!(agents[0].tokens, 4200); + assert_eq!(agents[1].model.as_deref(), Some("claude-fable-5")); +} + +#[test] +fn workflow_scope_exists_predicate_includes_run_and_optional_label() { + let run_only = WorkflowScopeFilter { + run_id: "wf_alpha".to_string(), + agent_label: None, + }; + let (sql, params) = workflow_scope_exists_predicate(&run_only, "m.source_path", "m.session_id"); + assert!(sql.contains("workflow_agents")); + assert!(sql.contains("wa.run_id = ?1")); + assert!(sql.contains("wa.transcript_path = m.source_path")); + assert!(sql.contains("wa.agent_session_id = m.session_id")); + assert!(!sql.contains("agent_label")); + assert_eq!(params.len(), 1); + assert!(matches!(¶ms[0], libsql::Value::Text(id) if id == "wf_alpha")); + + let narrowed = WorkflowScopeFilter { + run_id: "wf_beta".to_string(), + agent_label: Some("mine:claude".to_string()), + }; + let (sql, params) = workflow_scope_exists_predicate(&narrowed, "m.source_path", "m.session_id"); + assert!(sql.contains("workflow_agents")); + assert!(sql.contains("wa.agent_label = ?2")); + assert_eq!(params.len(), 2); + assert!(matches!(¶ms[1], libsql::Value::Text(label) if label == "mine:claude")); +} + +#[tokio::test] +async fn runs_for_git_scope_joins_through_parent_session_spans() { + let conn = mem_conn().await; + ensure_git_correlation_schema(&conn).await.unwrap(); + ensure_workflow_index_schema(&conn).await.unwrap(); + + // A run owned by sess-branch, another owned by sess-other. + upsert_run(&conn, &sample_run("wf_on_branch", "sess-branch")) + .await + .unwrap(); + upsert_run(&conn, &sample_run("wf_elsewhere", "sess-other")) + .await + .unwrap(); + // An orphan run with no resolvable parent must never leak into a + // git-scoped result. + upsert_run(&conn, &sample_run("wf_orphan", "")) + .await + .unwrap(); + + // Record a span placing sess-branch on branch `feat/x`. + record_span_observation( + &conn, + &SpanObservation { + provider: "claude".to_string(), + session_id: "sess-branch".to_string(), + thread_id: None, + branch: Some("feat/x".to_string()), + worktree: "/repo".to_string(), + ts: 1_700_000_100, + source: SpanSource::Ingest, + }, + super::super::git_correlation::DEFAULT_SPAN_MERGE_GAP_SECS, + ) + .await + .unwrap(); + + let filter = GitScopeFilter::from_args(Some("feat/x"), None, None).unwrap(); + let hits = runs_for_git_scope(&conn, &filter, 10).await.unwrap(); + let ids: Vec<&str> = hits.iter().map(|r| r.run_id.as_str()).collect(); + assert_eq!(ids, vec!["wf_on_branch"]); + + // A branch with no span yields nothing. + let none = GitScopeFilter::from_args(Some("feat/absent"), None, None).unwrap(); + assert!(runs_for_git_scope(&conn, &none, 10) + .await + .unwrap() + .is_empty()); + + // Empty filter is a caller error. + let empty = GitScopeFilter::default(); + assert!(matches!( + runs_for_git_scope(&conn, &empty, 10).await, + Err(WorkflowIndexError::InvalidArgument(_)) + )); +} diff --git a/src/sessions/workflow_ingest.rs b/src/sessions/workflow_ingest.rs new file mode 100644 index 0000000000..8582af8342 --- /dev/null +++ b/src/sessions/workflow_ingest.rs @@ -0,0 +1,673 @@ +//! Workflow-run ingest sweep. +//! +//! Scans Claude Code `wf_*` runs, keeps runs whose parent transcript belongs to +//! `project_root`, and upserts bounded run/agent summaries into `sessions.db`. + +use std::collections::HashSet; +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::accounting::parser::parse_timestamp; +use crate::global_db::GlobalDb; +use crate::sessions::shared::ProjectRootMatcher; +use crate::sessions::workflow_index::{ + bump_ingest_watermark, read_ingest_watermark, WorkflowAgent, WorkflowRun, WorkflowStatus, + INGEST_WATERMARK_KEY, +}; + +const RESULT_SUMMARY_CAP: usize = 600; + +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct WorkflowIngestStats { + pub runs_ingested: u64, + pub agents_ingested: u64, +} + +impl WorkflowIngestStats { + #[must_use] + pub fn merge(self, other: Self) -> Self { + Self { + runs_ingested: self.runs_ingested.saturating_add(other.runs_ingested), + agents_ingested: self.agents_ingested.saturating_add(other.agents_ingested), + } + } +} + +struct DiscoveredRun { + run_id: String, + parent_session_id: String, + meta_path: Option, + agents_dir: PathBuf, +} + +/// Fail-open at every level: a store that cannot be read, a project whose home +/// cannot be resolved, or an individual malformed run all degrade to "ingest +/// less", never an error. Returns the number of runs and agents upserted. +pub async fn ingest_workflow_runs(db: &GlobalDb, project_root: &Path) -> WorkflowIngestStats { + let Some(home) = crate::sessions::home_dir() else { + return WorkflowIngestStats::default(); + }; + ingest_workflow_runs_from(db, project_root, &home.join(".claude").join("projects")).await +} + +pub(crate) async fn ingest_workflow_runs_from( + db: &GlobalDb, + project_root: &Path, + projects_dir: &Path, +) -> WorkflowIngestStats { + let conn = db.dashboard_connection(); + let watermark = read_ingest_watermark(&conn, INGEST_WATERMARK_KEY).await; + + let mut stats = WorkflowIngestStats::default(); + let mut max_mtime = watermark; + + // Resolve the fixed project-side git identity once; every in-window run's + // membership test reuses it instead of re-resolving the same project root. + let project_matcher = ProjectRootMatcher::new(project_root); + + for run in discover_runs(projects_dir) { + let run_mtime = newest_mtime(&run); + if run_mtime > 0 && run_mtime <= watermark { + continue; + } + + // Scope to this project by the owning session's recorded cwd. A run + // whose parent thread began in another project is skipped without + // touching the DB — the same per-session cwd filter ClaudeSource uses. + // This filter also gates the watermark: `discover_runs` walks every + // project on the machine, but the watermark is persisted per-store, so + // only in-scope runs may advance it. Letting an out-of-project run raise + // this store's watermark could push it past a still-changing target run + // and strand that run (e.g. a Running run never re-ingested once it + // completes). + if !run_belongs_to_project(&run, &project_matcher) { + continue; + } + if run_mtime > max_mtime { + max_mtime = run_mtime; + } + + match ingest_one_run(db, &run).await { + Ok(run_stats) => stats = stats.merge(run_stats), + Err(err) => { + tracing::debug!(run_id = %run.run_id, error = %err, "skipping workflow run"); + } + } + } + + // Persist the advanced watermark so the next sweep skips everything we just + // processed. Best-effort: a write failure only means the next sweep does a + // little redundant (idempotent) work. + if max_mtime > watermark { + if let Err(err) = bump_ingest_watermark(&conn, INGEST_WATERMARK_KEY, max_mtime).await { + tracing::debug!(error = %err, "workflow ingest watermark not advanced"); + } + } + + stats +} + +/// Discover every workflow run under `projects_dir` by walking +/// `//subagents/workflows//`. +fn discover_runs(projects_dir: &Path) -> Vec { + let mut runs = Vec::new(); + let Ok(slugs) = std::fs::read_dir(projects_dir) else { + return runs; + }; + for slug in slugs.flatten() { + let slug_path = slug.path(); + if !slug_path.is_dir() { + continue; + } + let Ok(sessions) = std::fs::read_dir(&slug_path) else { + continue; + }; + for session in sessions.flatten() { + let session_path = session.path(); + if !session_path.is_dir() { + continue; + } + let Some(session_id) = session_path + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + else { + continue; + }; + let workflows_dir = session_path.join("subagents").join("workflows"); + let Ok(run_dirs) = std::fs::read_dir(&workflows_dir) else { + continue; + }; + for run in run_dirs.flatten() { + let agents_dir = run.path(); + if !agents_dir.is_dir() { + continue; + } + let Some(run_id) = agents_dir + .file_name() + .and_then(|name| name.to_str()) + .map(str::to_string) + else { + continue; + }; + let meta_path = session_path + .join("workflows") + .join(format!("{run_id}.json")); + runs.push(DiscoveredRun { + run_id, + parent_session_id: session_id.clone(), + meta_path: meta_path.is_file().then_some(meta_path), + agents_dir, + }); + } + } + } + runs +} + +/// Newest mtime (unix seconds) across a run's meta json and its agent-transcript +/// directory, for the incremental watermark. `0` when neither can be stat'd. +fn newest_mtime(run: &DiscoveredRun) -> i64 { + let mut newest = 0; + if let Some(meta) = run.meta_path.as_ref() { + newest = newest.max(file_mtime(meta)); + } + newest = newest.max(file_mtime(&run.agents_dir)); + newest +} + +fn file_mtime(path: &Path) -> i64 { + std::fs::metadata(path) + .and_then(|meta| meta.modified()) + .ok() + .and_then(|time| time.duration_since(std::time::UNIX_EPOCH).ok()) + .map_or(0, |dur| i64::try_from(dur.as_secs()).unwrap_or(0)) +} + +/// Decide whether a run's owning session began inside the project described by +/// `project_matcher`, from the `cwd` recorded in the parent transcript +/// (preferred) or any agent transcript. +fn run_belongs_to_project(run: &DiscoveredRun, project_matcher: &ProjectRootMatcher) -> bool { + let Some(cwd) = run_cwd(run) else { + // No resolvable cwd: refuse rather than mis-attribute a run to a + // project it may not belong to. ClaudeSource makes the same choice. + return false; + }; + project_matcher.contains(&cwd) +} + +/// The owning session's working directory, probed from the parent transcript +/// (`.jsonl`, two levels above `subagents/workflows/`) or, failing +/// that, an agent transcript in the run dir. +fn run_cwd(run: &DiscoveredRun) -> Option { + // Parent transcript sits at /.jsonl. agents_dir is + // //subagents/workflows/; `ancestors()` yields + // nth(0)= dir, nth(1)=workflows, nth(2)=subagents, + // nth(3)=/. The parent transcript is that session dir's + // sibling with a `.jsonl` suffix appended (not `with_extension`, which would + // mangle a session id that happens to contain a dot). + let parent_transcript = run.agents_dir.ancestors().nth(3).and_then(|session_dir| { + let name = session_dir.file_name()?.to_str()?; + Some(session_dir.with_file_name(format!("{name}.jsonl"))) + }); + if let Some(cwd) = parent_transcript + .as_deref() + .and_then(crate::sessions::claude::transcript_cwd) + { + return Some(cwd); + } + // Fall back to the first agent transcript that records a cwd. + for path in agent_transcripts(&run.agents_dir) { + if let Some(cwd) = crate::sessions::claude::transcript_cwd(&path) { + return Some(cwd); + } + } + None +} + +/// Absolute paths to the `agent-.jsonl` transcripts in a run directory, +/// excluding the sibling `.meta.json` files and `journal.jsonl`. +fn agent_transcripts(agents_dir: &Path) -> Vec { + let Ok(entries) = std::fs::read_dir(agents_dir) else { + return Vec::new(); + }; + let mut paths: Vec = entries + .flatten() + .map(|entry| entry.path()) + .filter(|path| { + let is_jsonl = path + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| ext.eq_ignore_ascii_case("jsonl")); + let named_agent = path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("agent-")); + is_jsonl && named_agent + }) + .collect(); + paths.sort(); + paths +} + +/// Parse one discovered run and upsert its run row plus every agent row. +async fn ingest_one_run( + db: &GlobalDb, + run: &DiscoveredRun, +) -> Result { + let (mut workflow_run, mut agents) = match run.meta_path.as_deref().and_then(read_run_meta) { + // Finished (or at least meta-written) run: authoritative roster from + // `workflowProgress[]`. + Some(meta) => parse_run_from_meta(&run.run_id, &run.parent_session_id, &meta), + // In-progress / orphan dir with no meta json yet: synthesize a Running + // run and derive the roster from journal.jsonl + present agent files. + None => parse_run_from_dir(&run.run_id, &run.parent_session_id, &run.agents_dir), + }; + + // Enrich each agent from its transcript (path, tokens, session id, times) + // and reconcile the run-level agent count with what we actually recorded. + for agent in &mut agents { + enrich_agent_from_transcript(agent, &run.agents_dir); + } + if workflow_run.agent_count == 0 { + workflow_run.agent_count = i64::try_from(agents.len()).unwrap_or(i64::MAX); + } + + db.workflow_upsert_run(&workflow_run).await?; + for agent in &agents { + db.workflow_upsert_agent(agent).await?; + } + Ok(WorkflowIngestStats { + runs_ingested: 1, + agents_ingested: agents.len() as u64, + }) +} + +/// Read and JSON-parse a `workflows/.json` file, or `None` when it is +/// missing or malformed (fail-open — the run is then treated as dir-only). +fn read_run_meta(path: &Path) -> Option { + let text = std::fs::read_to_string(path).ok()?; + serde_json::from_str(&text).ok() +} + +// --------------------------------------------------------------------------- +// Pure parsing (unit-tested; no disk access below this line). +// --------------------------------------------------------------------------- + +/// Build a [`WorkflowRun`] and its agent roster from a parsed run-meta JSON +/// (`workflows/.json`). +fn parse_run_from_meta( + run_id: &str, + parent_session_id: &str, + meta: &Value, +) -> (WorkflowRun, Vec) { + let run_id = meta + .get("runId") + .and_then(Value::as_str) + .filter(|id| !id.is_empty()) + .unwrap_or(run_id) + .to_string(); + + let name = string_field(meta, "workflowName"); + let description = string_field(meta, "summary").or_else(|| string_field(meta, "description")); + let phase_json = meta + .get("phases") + .filter(|phases| phases.is_array()) + .and_then(|phases| serde_json::to_string(phases).ok()); + let status = meta + .get("status") + .and_then(Value::as_str) + .map_or(WorkflowStatus::Unknown, WorkflowStatus::from_disk); + let started_ts = run_start_ts(meta); + let ended_ts = run_end_ts(meta, started_ts); + let result_summary = run_result_summary(meta); + let default_model = string_field(meta, "defaultModel"); + + let agents = parse_roster(&run_id, meta, default_model.as_deref()); + let agent_count = meta + .get("agentCount") + .and_then(Value::as_i64) + .unwrap_or_else(|| i64::try_from(agents.len()).unwrap_or(i64::MAX)); + + ( + WorkflowRun { + run_id, + parent_session_id: parent_session_id.to_string(), + name, + description, + phase_json, + status, + started_ts, + ended_ts, + result_summary, + agent_count, + }, + agents, + ) +} + +/// Synthesize a Running [`WorkflowRun`] for a dir-only (in-progress / orphan) +/// run and build its roster from `journal.jsonl` plus the agent files present. +fn parse_run_from_dir( + run_id: &str, + parent_session_id: &str, + agents_dir: &Path, +) -> (WorkflowRun, Vec) { + let journal = read_journal(agents_dir); + let agent_ids = roster_agent_ids(agents_dir, &journal); + let agents: Vec = agent_ids + .into_iter() + .map(|agent_id| WorkflowAgent { + run_id: run_id.to_string(), + // No progress row means no human label; the agent id is the stable + // fallback so drill-down still has a handle. + agent_label: agent_id.clone(), + status: journal_agent_status(&journal, &agent_id), + agent_id, + phase: None, + transcript_path: None, + agent_session_id: None, + model: None, + tokens: 0, + started_ts: None, + ended_ts: None, + }) + .collect(); + + ( + WorkflowRun { + run_id: run_id.to_string(), + parent_session_id: parent_session_id.to_string(), + name: None, + description: None, + phase_json: None, + status: WorkflowStatus::Running, + started_ts: None, + ended_ts: None, + result_summary: None, + agent_count: i64::try_from(agents.len()).unwrap_or(i64::MAX), + }, + agents, + ) +} + +/// Extract the agent roster from a run meta's `workflowProgress[]`, keeping only +/// `type == "workflow_agent"` entries (the array also holds `workflow_phase` +/// rows). `default_model` backfills an agent that recorded no `model`. +fn parse_roster(run_id: &str, meta: &Value, default_model: Option<&str>) -> Vec { + let Some(progress) = meta.get("workflowProgress").and_then(Value::as_array) else { + return Vec::new(); + }; + progress + .iter() + .filter(|entry| entry.get("type").and_then(Value::as_str) == Some("workflow_agent")) + .map(|entry| { + let agent_id = string_field(entry, "agentId").unwrap_or_default(); + let label = string_field(entry, "label") + .filter(|label| !label.is_empty()) + .unwrap_or_else(|| { + if agent_id.is_empty() { + "agent".to_string() + } else { + agent_id.clone() + } + }); + let status = entry + .get("state") + .and_then(Value::as_str) + .map_or(WorkflowStatus::Unknown, WorkflowStatus::from_disk); + WorkflowAgent { + run_id: run_id.to_string(), + agent_label: label, + agent_id, + phase: string_field(entry, "phaseTitle"), + transcript_path: None, + agent_session_id: None, + status, + model: string_field(entry, "model").or_else(|| default_model.map(str::to_string)), + tokens: 0, + started_ts: ms_field_to_secs(entry, "startedAt"), + ended_ts: ms_field_to_secs(entry, "lastProgressAt"), + } + }) + .collect() +} + +/// Run start time in unix seconds: `startTime` is a millisecond epoch; fall back +/// to the ISO-8601 `timestamp`. +fn run_start_ts(meta: &Value) -> Option { + ms_field_to_secs(meta, "startTime").or_else(|| { + meta.get("timestamp") + .and_then(Value::as_str) + .and_then(parse_timestamp) + .and_then(|secs| i64::try_from(secs).ok()) + }) +} + +/// Run end time in unix seconds: `started_ts + durationMs/1000` when a duration +/// is recorded, else unknown. +fn run_end_ts(meta: &Value, started_ts: Option) -> Option { + let started = started_ts?; + let duration_ms = meta.get("durationMs").and_then(Value::as_i64)?; + Some(started.saturating_add(duration_ms / 1000)) +} + +/// Prefer the run's dedicated `summary` string; otherwise render `result` (a +/// string or a JSON blob) to a truncated one-line slice, never the whole thing. +fn run_result_summary(meta: &Value) -> Option { + if let Some(summary) = string_field(meta, "summary") { + return Some(crate::sessions::shared::one_line_truncated( + &summary, + RESULT_SUMMARY_CAP, + )); + } + let result = meta.get("result")?; + let text = match result { + Value::Null => return None, + Value::String(text) => text.clone(), + other => serde_json::to_string(other).ok()?, + }; + let trimmed = text.trim(); + if trimmed.is_empty() { + return None; + } + Some(crate::sessions::shared::one_line_truncated( + trimmed, + RESULT_SUMMARY_CAP, + )) +} + +fn string_field(value: &Value, key: &str) -> Option { + value + .get(key) + .and_then(Value::as_str) + .filter(|text| !text.is_empty()) + .map(str::to_string) +} + +/// Read a millisecond-epoch numeric field and convert it to unix seconds. +fn ms_field_to_secs(value: &Value, key: &str) -> Option { + value.get(key).and_then(Value::as_i64).map(|ms| ms / 1000) +} + +// --------------------------------------------------------------------------- +// Agent transcript + journal parsing. +// --------------------------------------------------------------------------- + +/// Fill in an agent's transcript-derived fields from +/// `agent-.jsonl` when that file exists: absolute `transcript_path`, +/// summed `tokens`, `agent_session_id`, and start/end timestamps. A missing or +/// unreadable transcript leaves the roster-derived values untouched. +fn enrich_agent_from_transcript(agent: &mut WorkflowAgent, agents_dir: &Path) { + if agent.agent_id.is_empty() { + return; + } + let path = agents_dir.join(format!("agent-{}.jsonl", agent.agent_id)); + if !path.is_file() { + return; + } + agent.transcript_path = Some(path.to_string_lossy().to_string()); + let Ok(text) = std::fs::read_to_string(&path) else { + return; + }; + let summary = summarize_transcript(&text); + if summary.tokens > 0 { + agent.tokens = summary.tokens; + } + if agent.agent_session_id.is_none() { + agent.agent_session_id = summary.session_id; + } + if agent.started_ts.is_none() { + agent.started_ts = summary.first_ts; + } + if summary.last_ts.is_some() { + agent.ended_ts = summary.last_ts; + } +} + +/// Aggregates extracted from one agent transcript. +#[derive(Debug, Default, PartialEq, Eq)] +struct TranscriptSummary { + /// Sum of `input_tokens + output_tokens` across assistant `usage` objects. + tokens: i64, + session_id: Option, + first_ts: Option, + last_ts: Option, +} + +/// Sum tokens and read the session id / first+last timestamps from a transcript +/// body (one JSON object per line). Malformed lines are skipped. +fn summarize_transcript(body: &str) -> TranscriptSummary { + let mut summary = TranscriptSummary::default(); + for line in body.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() { + continue; + } + let Ok(value) = serde_json::from_str::(trimmed) else { + continue; + }; + if summary.session_id.is_none() { + summary.session_id = string_field(&value, "sessionId"); + } + if let Some(ts) = value + .get("timestamp") + .and_then(Value::as_str) + .and_then(parse_timestamp) + .and_then(|secs| i64::try_from(secs).ok()) + { + if summary.first_ts.is_none() { + summary.first_ts = Some(ts); + } + summary.last_ts = Some(ts); + } + summary.tokens = summary.tokens.saturating_add(line_usage_tokens(&value)); + } + summary +} + +/// Input+output tokens from a transcript line's `message.usage`, or `0` when the +/// line carries no usage (user turns, tool results, meta lines). +fn line_usage_tokens(value: &Value) -> i64 { + let usage = value + .get("message") + .and_then(|message| message.get("usage")) + .or_else(|| value.get("usage")); + let Some(usage) = usage else { + return 0; + }; + let input = usage + .get("input_tokens") + .and_then(Value::as_i64) + .unwrap_or(0); + let output = usage + .get("output_tokens") + .and_then(Value::as_i64) + .unwrap_or(0); + input.saturating_add(output) +} + +/// One `journal.jsonl` event: a `started` / `result` (terminal) marker keyed by +/// `agentId`. +struct JournalEvent { + event_type: String, + agent_id: String, +} + +/// Parse `journal.jsonl` into its events, skipping malformed lines. Absent +/// journal yields an empty list. +fn read_journal(agents_dir: &Path) -> Vec { + let path = agents_dir.join("journal.jsonl"); + let Ok(text) = std::fs::read_to_string(&path) else { + return Vec::new(); + }; + parse_journal(&text) +} + +fn parse_journal(body: &str) -> Vec { + body.lines() + .filter_map(|line| { + let value: Value = serde_json::from_str(line.trim()).ok()?; + let event_type = value.get("type").and_then(Value::as_str)?.to_string(); + let agent_id = value.get("agentId").and_then(Value::as_str)?.to_string(); + if agent_id.is_empty() { + return None; + } + Some(JournalEvent { + event_type, + agent_id, + }) + }) + .collect() +} + +/// The set of agent ids for a dir-only run: the union of journal-`started` +/// agents and `agent-.jsonl` files present, so an agent that appears in +/// either source is captured. +fn roster_agent_ids(agents_dir: &Path, journal: &[JournalEvent]) -> Vec { + let mut ids: Vec = Vec::new(); + let mut seen: HashSet = HashSet::new(); + let from_files = agent_transcripts(agents_dir) + .into_iter() + .filter_map(|path| { + path.file_stem() + .and_then(|s| s.to_str()) + .and_then(|s| s.strip_prefix("agent-")) + .filter(|id| !id.is_empty()) + .map(str::to_string) + }); + let from_journal = journal + .iter() + .map(|event| event.agent_id.clone()) + .filter(|id| !id.is_empty()); + for id in from_files.chain(from_journal) { + if seen.insert(id.clone()) { + ids.push(id); + } + } + ids +} + +/// Status of one agent in a dir-only run, inferred from its journal events: a +/// terminal `result` reads as Completed, otherwise Running. +fn journal_agent_status(journal: &[JournalEvent], agent_id: &str) -> WorkflowStatus { + let mut seen = false; + for event in journal.iter().filter(|event| event.agent_id == agent_id) { + seen = true; + match event.event_type.as_str() { + "result" | "done" | "completed" => return WorkflowStatus::Completed, + "error" | "failed" | "blocked" | "interrupted" => return WorkflowStatus::Failed, + _ => {} + } + } + if seen { + WorkflowStatus::Running + } else { + WorkflowStatus::Unknown + } +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests; diff --git a/src/sessions/workflow_ingest/tests.rs b/src/sessions/workflow_ingest/tests.rs new file mode 100644 index 0000000000..200256b42b --- /dev/null +++ b/src/sessions/workflow_ingest/tests.rs @@ -0,0 +1,475 @@ +use super::*; + +fn sample_meta() -> Value { + serde_json::json!({ + "runId": "wf_d0bf6fa4-48f", + "workflowName": "tracedecay-triggering-evals", + "summary": "Mine real transcripts into a broad eval corpus", + "status": "completed", + "startTime": 1_783_142_254_914_i64, + "durationMs": 983_890_i64, + "agentCount": 2, + "defaultModel": "claude-fable-5", + "phases": [ + {"title": "Mine", "detail": "harvest scenarios"}, + {"title": "Run", "detail": "run it", "model": "fable"} + ], + "result": {"scored": 45, "scenarios": 36}, + "workflowProgress": [ + {"type": "workflow_phase", "phaseTitle": "Mine"}, + { + "type": "workflow_agent", + "label": "mine:claude-transcripts", + "phaseTitle": "Mine", + "phaseIndex": 1, + "agentId": "a17141dbe5a308242", + "model": "claude-fable-5", + "state": "done", + "startedAt": 1_783_142_254_936_i64, + "lastProgressAt": 1_783_142_255_936_i64 + }, + { + "type": "workflow_agent", + "label": "", + "phaseTitle": "Run", + "agentId": "aa09ec4d07fccc915", + "state": "in_progress", + "startedAt": 1_783_142_260_000_i64 + } + ] + }) +} + +#[test] +fn parse_run_from_meta_maps_fields_and_folds_status() { + let (run, agents) = parse_run_from_meta("wf_fallback", "sess-parent", &sample_meta()); + + assert_eq!(run.run_id, "wf_d0bf6fa4-48f"); // runId wins over the dir name + assert_eq!(run.parent_session_id, "sess-parent"); + assert_eq!(run.name.as_deref(), Some("tracedecay-triggering-evals")); + assert_eq!(run.status, WorkflowStatus::Completed); + // startTime ms -> secs. + assert_eq!(run.started_ts, Some(1_783_142_254)); + // started + durationMs/1000. + assert_eq!(run.ended_ts, Some(1_783_142_254 + 983)); + // agentCount from meta, not roster length. + assert_eq!(run.agent_count, 2); + // `summary` present -> used verbatim (one-lined). + assert_eq!( + run.result_summary.as_deref(), + Some("Mine real transcripts into a broad eval corpus") + ); + + // phase_json round-trips as a JSON array of the phases. + let phases: Value = serde_json::from_str(run.phase_json.as_deref().unwrap()).unwrap(); + assert!(phases.is_array()); + assert_eq!(phases.as_array().unwrap().len(), 2); + assert_eq!(phases[0]["title"], "Mine"); + + // Only the two workflow_agent rows, in order; workflow_phase is dropped. + assert_eq!(agents.len(), 2); + assert_eq!(agents[0].agent_label, "mine:claude-transcripts"); + assert_eq!(agents[0].phase.as_deref(), Some("Mine")); + assert_eq!(agents[0].status, WorkflowStatus::Completed); + assert_eq!(agents[0].model.as_deref(), Some("claude-fable-5")); + assert_eq!(agents[0].started_ts, Some(1_783_142_254)); + assert_eq!(agents[0].ended_ts, Some(1_783_142_255)); + // Empty label falls back to the agent id; missing model backfills from + // defaultModel; state folds `in_progress` -> Running. + assert_eq!(agents[1].agent_label, "aa09ec4d07fccc915"); + assert_eq!(agents[1].model.as_deref(), Some("claude-fable-5")); + assert_eq!(agents[1].status, WorkflowStatus::Running); +} + +#[test] +fn result_summary_truncates_json_result_when_no_summary() { + let mut meta = sample_meta(); + meta.as_object_mut().unwrap().remove("summary"); + let long = "x ".repeat(2000); + meta.as_object_mut() + .unwrap() + .insert("result".to_string(), Value::String(long)); + let summary = run_result_summary(&meta).unwrap(); + // Single-char ellipsis convention: at most `CAP` content chars + `…`. + assert!(summary.chars().count() <= RESULT_SUMMARY_CAP + 1); + assert!(summary.ends_with('…')); + // Whitespace collapsed to single spaces. + assert!(!summary.contains(" ")); +} + +#[test] +fn result_summary_prefers_summary_over_result() { + let meta = sample_meta(); + // Even though `result` is a dict, the `summary` string wins. + assert_eq!( + run_result_summary(&meta).as_deref(), + Some("Mine real transcripts into a broad eval corpus") + ); +} + +#[test] +fn roster_extracts_only_workflow_agents() { + let meta = sample_meta(); + let roster = parse_roster("wf_x", &meta, Some("fallback-model")); + assert_eq!(roster.len(), 2); + assert!(roster.iter().all(|agent| agent.run_id == "wf_x")); + let labels: Vec<&str> = roster.iter().map(|a| a.agent_label.as_str()).collect(); + assert_eq!(labels, vec!["mine:claude-transcripts", "aa09ec4d07fccc915"]); +} + +#[test] +fn transcript_tokens_sum_input_and_output() { + let body = concat!( + r#"{"type":"user","sessionId":"agent-sess","timestamp":"2026-07-04T05:17:34.967Z","message":{"role":"user","content":"hi"}}"#, + "\n", + r#"{"type":"assistant","timestamp":"2026-07-04T05:18:00.000Z","message":{"role":"assistant","usage":{"input_tokens":100,"output_tokens":40}}}"#, + "\n", + " \n", + r#"not json"#, + "\n", + r#"{"type":"assistant","timestamp":"2026-07-04T05:25:32.232Z","message":{"role":"assistant","usage":{"input_tokens":10,"output_tokens":8,"cache_read_input_tokens":999}}}"#, + "\n", + ); + let summary = summarize_transcript(body); + // 100+40 + 10+8 (cache_* excluded). + assert_eq!(summary.tokens, 158); + assert_eq!(summary.session_id.as_deref(), Some("agent-sess")); + assert_eq!( + summary.first_ts, + parse_timestamp("2026-07-04T05:17:34.967Z").map(|s| s as i64) + ); + assert_eq!( + summary.last_ts, + parse_timestamp("2026-07-04T05:25:32.232Z").map(|s| s as i64) + ); +} + +#[test] +fn dir_only_run_is_running_with_journal_roster() { + let journal = concat!( + r#"{"type":"started","agentId":"a1"}"#, + "\n", + r#"{"type":"started","agentId":"a2"}"#, + "\n", + r#"{"type":"result","agentId":"a1"}"#, + "\n", + r#"{"type":"started","agentId":""}"#, + "\n", + ); + let events = parse_journal(journal); + // Empty agentId dropped; three valid events remain. + assert_eq!(events.len(), 3); + // a1 has a terminal result -> Completed; a2 only started -> Running. + assert_eq!( + journal_agent_status(&events, "a1"), + WorkflowStatus::Completed + ); + assert_eq!(journal_agent_status(&events, "a2"), WorkflowStatus::Running); + assert_eq!( + journal_agent_status(&events, "absent"), + WorkflowStatus::Unknown + ); +} + +#[test] +fn dir_only_run_from_disk_yields_running_and_roster() { + let dir = tempfile::tempdir().unwrap(); + let agents_dir = dir.path(); + // Two agent transcripts + a journal naming a3 that has no file yet. + std::fs::write( + agents_dir.join("agent-a1.jsonl"), + format!( + "{}\n", + r#"{"sessionId":"s","timestamp":"2026-07-04T05:00:00.000Z","message":{"usage":{"input_tokens":5,"output_tokens":5}}}"# + ), + ) + .unwrap(); + std::fs::write(agents_dir.join("agent-a1.meta.json"), "{}").unwrap(); + std::fs::write(agents_dir.join("agent-a2.jsonl"), "\n").unwrap(); + std::fs::write( + agents_dir.join("journal.jsonl"), + concat!( + r#"{"type":"started","agentId":"a1"}"#, + "\n", + r#"{"type":"started","agentId":"a3"}"#, + "\n" + ), + ) + .unwrap(); + + let (run, mut agents) = parse_run_from_dir("wf_dir", "sess", agents_dir); + assert_eq!(run.status, WorkflowStatus::Running); + assert_eq!(run.run_id, "wf_dir"); + // a1, a2 (from files) then a3 (journal-only). + let mut ids: Vec = agents.iter().map(|a| a.agent_id.clone()).collect(); + ids.sort(); + assert_eq!(ids, vec!["a1", "a2", "a3"]); + assert_eq!(run.agent_count, 3); + + // Enrichment attaches the transcript path + tokens for a1. + for agent in &mut agents { + enrich_agent_from_transcript(agent, agents_dir); + } + let a1 = agents.iter().find(|a| a.agent_id == "a1").unwrap(); + assert_eq!(a1.tokens, 10); + assert!(a1 + .transcript_path + .as_deref() + .unwrap() + .ends_with("agent-a1.jsonl")); + assert_eq!(a1.agent_session_id.as_deref(), Some("s")); + // a3 has no file: no transcript path, zero tokens. + let a3 = agents.iter().find(|a| a.agent_id == "a3").unwrap(); + assert!(a3.transcript_path.is_none()); + assert_eq!(a3.tokens, 0); +} + +/// Write a `//` fixture with a parent transcript whose +/// `cwd` is `project_cwd`, one meta-backed run, and (optionally) one +/// dir-only run. Returns the `~/.claude/projects` root. +fn write_fixture(home: &Path, session_id: &str, project_cwd: &Path) -> PathBuf { + let projects = home.join(".claude").join("projects"); + let slug = projects.join("dummy-slug"); + let session_dir = slug.join(session_id); + std::fs::create_dir_all(&session_dir).unwrap(); + + // Parent transcript records the owning session's cwd. + std::fs::write( + slug.join(format!("{session_id}.jsonl")), + format!( + "{}\n", + serde_json::json!({ + "type": "user", + "cwd": project_cwd.to_string_lossy(), + "sessionId": session_id, + "timestamp": "2026-07-04T05:00:00.000Z", + }) + ), + ) + .unwrap(); + + // Meta-backed run + one agent transcript. + let workflows = session_dir.join("workflows"); + std::fs::create_dir_all(&workflows).unwrap(); + std::fs::write( + workflows.join("wf_meta.json"), + serde_json::to_string(&sample_meta()).unwrap(), + ) + .unwrap(); + let run_dir = session_dir + .join("subagents") + .join("workflows") + .join("wf_meta"); + std::fs::create_dir_all(&run_dir).unwrap(); + std::fs::write( + run_dir.join("agent-a17141dbe5a308242.jsonl"), + format!( + "{}\n", + serde_json::json!({ + "sessionId": "agent-sess", + "timestamp": "2026-07-04T05:17:34.967Z", + "message": {"usage": {"input_tokens": 100, "output_tokens": 40}}, + }) + ), + ) + .unwrap(); + + // Dir-only (in-progress) run: no workflows/.json. + let orphan = session_dir + .join("subagents") + .join("workflows") + .join("wf_orphan"); + std::fs::create_dir_all(&orphan).unwrap(); + std::fs::write(orphan.join("agent-b1.jsonl"), "\n").unwrap(); + std::fs::write( + orphan.join("journal.jsonl"), + format!("{}\n", r#"{"type":"started","agentId":"b1"}"#), + ) + .unwrap(); + + projects +} + +#[tokio::test] +async fn sweep_ingests_runs_scoped_to_project_and_is_incremental() { + let home = tempfile::tempdir().unwrap(); + // `project_root` doubles as the recorded transcript cwd, so path-equality + // scoping admits the run without needing a real git worktree. + let project = tempfile::tempdir().unwrap(); + let project_root = project.path().canonicalize().unwrap(); + let projects = write_fixture(home.path(), "sess-1", &project_root); + + let db_file = tempfile::NamedTempFile::new().unwrap(); + let db = GlobalDb::open_at(db_file.path()).await.unwrap(); + + let stats = ingest_workflow_runs_from(&db, &project_root, &projects).await; + // Both the meta run and the dir-only run land. + assert_eq!(stats.runs_ingested, 2); + // Meta run: 2 agents; orphan run: 1 agent. + assert_eq!(stats.agents_ingested, 3); + + // The meta run is owned by sess-1 and reads as completed with its roster. + let runs = db.workflow_runs_for_session("sess-1", 10).await.unwrap(); + let ids: Vec<&str> = runs.iter().map(|r| r.run_id.as_str()).collect(); + assert!(ids.contains(&"wf_d0bf6fa4-48f")); // runId from meta, not dir name + assert!(ids.contains(&"wf_orphan")); + + let meta_run = db + .workflow_run_for_id("wf_d0bf6fa4-48f") + .await + .unwrap() + .unwrap(); + assert_eq!(meta_run.parent_session_id, "sess-1"); + assert_eq!(meta_run.status, WorkflowStatus::Completed); + let agents = db + .workflow_agents_for_run("wf_d0bf6fa4-48f", 10) + .await + .unwrap(); + assert_eq!(agents.len(), 2); + // The first agent's transcript enriched tokens (100+40) and its path. + let enriched = agents + .iter() + .find(|a| a.agent_id == "a17141dbe5a308242") + .unwrap(); + assert_eq!(enriched.tokens, 140); + assert!(enriched + .transcript_path + .as_deref() + .unwrap() + .ends_with("agent-a17141dbe5a308242.jsonl")); + + let orphan = db.workflow_run_for_id("wf_orphan").await.unwrap().unwrap(); + assert_eq!(orphan.status, WorkflowStatus::Running); + + // Re-sweep with nothing changed: the watermark short-circuits every run, + // so no rows are re-ingested. + let again = ingest_workflow_runs_from(&db, &project_root, &projects).await; + assert_eq!(again, WorkflowIngestStats::default()); +} + +#[tokio::test] +async fn sweep_skips_runs_owned_by_a_different_project() { + let home = tempfile::tempdir().unwrap(); + // The fixture's owning session began in `/somewhere/else`, not the + // project we sweep for, so its runs must not be ingested. + let other = tempfile::tempdir().unwrap(); + let projects = write_fixture(home.path(), "sess-x", other.path()); + + let target = tempfile::tempdir().unwrap(); + let target_root = target.path().canonicalize().unwrap(); + + let db_file = tempfile::NamedTempFile::new().unwrap(); + let db = GlobalDb::open_at(db_file.path()).await.unwrap(); + + let stats = ingest_workflow_runs_from(&db, &target_root, &projects).await; + assert_eq!(stats, WorkflowIngestStats::default()); + assert!(db + .workflow_runs_for_session("sess-x", 10) + .await + .unwrap() + .is_empty()); +} + +/// Force `path`'s mtime to a fixed unix-second value, so a fixture's +/// `newest_mtime` is deterministic regardless of wall-clock creation time. +/// A read-only open covers both files and directories (a write open would +/// `EISDIR` on a directory). +fn set_mtime(path: &Path, unix_secs: u64) { + // `filetime` sets a directory's mtime cross-platform; a read-only + // `File::open` + `set_times` works on Unix but fails on Windows, where + // adjusting a directory's timestamps needs backup-semantics access. + filetime::set_file_mtime( + path, + filetime::FileTime::from_unix_time(i64::try_from(unix_secs).unwrap(), 0), + ) + .unwrap(); +} + +/// Regression: a newer run belonging to a *different* project must not +/// advance this store's ingest watermark. `discover_runs` walks every +/// project slug on the machine, but the watermark is persisted per-store; if +/// an out-of-scope run could raise it, that watermark would leapfrog a +/// still-changing in-scope run and strand it (a Running run that later +/// completes would be skipped forever on subsequent sweeps). The watermark +/// after a sweep must therefore reflect only in-scope runs. +#[tokio::test] +async fn other_project_run_does_not_advance_watermark() { + // Far-future mtime (year ~2100) for the out-of-scope run. + const FUTURE: u64 = 4_102_444_800; + + let home = tempfile::tempdir().unwrap(); + + // Target project: an in-scope owning session recorded at `target_root`. + let target = tempfile::tempdir().unwrap(); + let target_root = target.path().canonicalize().unwrap(); + let projects = write_fixture(home.path(), "sess-target", &target_root); + + // A second project's session under the same `~/.claude/projects`, owned + // by a different cwd so it is out of scope for this sweep. + let other = tempfile::tempdir().unwrap(); + write_fixture(home.path(), "sess-other", other.path()); + + // Give the out-of-scope run a far-future mtime. Since `newest_mtime` + // maxes the meta file in, this run reads as the newest run on disk by a + // wide margin — exactly the poison the watermark must resist. + set_mtime( + &projects + .join("dummy-slug") + .join("sess-other") + .join("workflows") + .join("wf_meta.json"), + FUTURE, + ); + + let db_file = tempfile::NamedTempFile::new().unwrap(); + let db = GlobalDb::open_at(db_file.path()).await.unwrap(); + + // Sweep the target project only. The in-scope (target) runs are ingested; + // the out-of-scope (other) runs are not. + let stats = ingest_workflow_runs_from(&db, &target_root, &projects).await; + assert_eq!(stats.runs_ingested, 2); // target's wf_meta + wf_orphan + assert!(db + .workflow_runs_for_session("sess-other", 10) + .await + .unwrap() + .is_empty()); + + // The persisted watermark must reflect only in-scope runs, so it stays + // well below the out-of-project run's far-future mtime. On the buggy + // path (watermark advanced before the scope filter) it would equal + // FUTURE, and the next sweep would strand every target run. + let watermark = read_ingest_watermark(&db.dashboard_connection(), INGEST_WATERMARK_KEY).await; + assert!( + watermark > 0 && watermark < i64::try_from(FUTURE).unwrap(), + "out-of-project run advanced the watermark to {watermark} (>= {FUTURE})" + ); + + // Concretely, the target's still-Running dir-only run is not stranded: a + // second sweep with an appended agent re-ingests it rather than skipping + // it on a poisoned watermark. + let orphan_dir = target_root_orphan_dir(&projects, "sess-target"); + std::fs::write(orphan_dir.join("agent-b2.jsonl"), "\n").unwrap(); + // Bump the run's mtime just past the (correct) watermark so the + // incremental skip does not legitimately short-circuit it. + set_mtime( + &orphan_dir.join("agent-b2.jsonl"), + u64::try_from(watermark).unwrap() + 60, + ); + set_mtime(&orphan_dir, u64::try_from(watermark).unwrap() + 60); + + let again = ingest_workflow_runs_from(&db, &target_root, &projects).await; + assert_eq!( + again.runs_ingested, 1, + "the still-Running target run must be re-ingested, not stranded" + ); +} + +/// Path to a fixture session's dir-only (`wf_orphan`) run directory. +fn target_root_orphan_dir(projects: &Path, session_id: &str) -> PathBuf { + projects + .join("dummy-slug") + .join(session_id) + .join("subagents") + .join("workflows") + .join("wf_orphan") +} diff --git a/src/sessions/workflow_state.rs b/src/sessions/workflow_state.rs new file mode 100644 index 0000000000..5317518700 --- /dev/null +++ b/src/sessions/workflow_state.rs @@ -0,0 +1,149 @@ +//! Unfinished-workflow evidence listing. +//! +//! A lightweight, text-evidence view over ingested session messages: it scans +//! the LCM raw-message store for phrases that signal a stalled or terminated +//! run (`session limit`, `blocked`, `interrupted`, `runs:0`) and reports the +//! matching rows. This complements the structured `workflow_runs` / +//! `workflow_agents` tables (see [`crate::sessions::workflow_index`]): where +//! those record what the workflow harness wrote, this surfaces in-transcript +//! evidence that a run did not finish cleanly, including for providers/sessions +//! that never produced a `wf_*` run directory. + +use libsql::{params, Connection}; +use serde::Serialize; + +use crate::global_db::GlobalDb; + +/// Max characters of collapsed evidence text kept per unfinished-run row before +/// a single-character `…` truncation, so one row never dominates the listing. +const EVIDENCE_PREVIEW_CAP: usize = 180; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct WorkflowStateItem { + pub status: String, + pub provider: String, + pub session_id: String, + pub task_id: Option, + pub message_id: String, + pub ordinal: i64, + pub evidence: String, +} + +pub async fn list_unfinished( + db: &GlobalDb, + limit: usize, +) -> Result, String> { + let conn = db.dashboard_connection(); + query_unfinished(&conn, limit).await +} + +async fn query_unfinished( + conn: &Connection, + limit: usize, +) -> Result, String> { + let limit = limit.clamp(1, 250) as i64; + let mut rows = conn + .query( + "SELECT provider, session_id, message_id, ordinal, content, + COALESCE(snippet_text, ''), COALESCE(metadata_json, '') + FROM lcm_raw_messages + WHERE lower(content) LIKE '%session limit%' + OR lower(content) LIKE '%blocked%' + OR lower(content) LIKE '%interrupted%' + OR lower(content) LIKE '%runs:0%' + OR lower(content) LIKE '%\"runs\":0%' + ORDER BY COALESCE(timestamp, 0) DESC, store_id DESC + LIMIT ?1", + params![limit], + ) + .await + .map_err(|e| e.to_string())?; + + let mut out = Vec::new(); + while let Some(row) = rows.next().await.map_err(|e| e.to_string())? { + let content: String = row.get(4).map_err(|e| e.to_string())?; + let snippet: String = row.get(5).map_err(|e| e.to_string())?; + if let Some((status, evidence)) = classify_evidence(&content, &snippet) { + let metadata_json: String = row.get(6).map_err(|e| e.to_string())?; + out.push(WorkflowStateItem { + status, + provider: row.get(0).map_err(|e| e.to_string())?, + session_id: row.get(1).map_err(|e| e.to_string())?, + message_id: row.get(2).map_err(|e| e.to_string())?, + ordinal: row.get(3).map_err(|e| e.to_string())?, + task_id: task_id_from_metadata(&metadata_json), + evidence, + }); + } + } + Ok(out) +} + +fn classify_evidence(content: &str, snippet: &str) -> Option<(String, String)> { + let status = classify_status(content)?; + let evidence_source = if snippet.trim().is_empty() { + content + } else { + snippet + }; + Some(( + status.to_string(), + crate::sessions::shared::one_line_truncated(evidence_source, EVIDENCE_PREVIEW_CAP), + )) +} + +fn classify_status(text: &str) -> Option<&'static str> { + let lower = text.to_ascii_lowercase(); + if lower.contains("session limit") { + Some("session limit") + } else if lower.contains("runs:0") || lower.contains("\"runs\":0") { + Some("runs:0") + } else if lower.contains("blocked") { + Some("blocked") + } else if lower.contains("interrupted") { + Some("interrupted") + } else { + None + } +} + +fn task_id_from_metadata(metadata_json: &str) -> Option { + let value: serde_json::Value = serde_json::from_str(metadata_json).ok()?; + ["task_id", "taskId", "task", "id"] + .into_iter() + .find_map(|key| value.get(key)?.as_str()) + .filter(|value| !value.is_empty()) + .map(ToString::to_string) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use super::*; + + #[test] + fn classify_workflow_states_from_text() { + for (text, expected) in [ + ( + "Claude hit the session limit while running task", + "session limit", + ), + ("automation blocked on missing credentials", "blocked"), + ("task interrupted by compaction", "interrupted"), + ("worker finished with runs:0", "runs:0"), + (r#"{"runs":0,"status":"queued"}"#, "runs:0"), + ] { + let (status, evidence) = classify_evidence(text, "").expect("status"); + assert_eq!(status, expected); + assert!(!evidence.is_empty()); + } + } + + #[test] + fn extracts_task_id_from_metadata() { + assert_eq!( + task_id_from_metadata(r#"{"task_id":"task-123"}"#), + Some("task-123".to_string()) + ); + } +} diff --git a/src/sessions_cmd.rs b/src/sessions_cmd.rs index 0acfade116..5f23e9387d 100644 --- a/src/sessions_cmd.rs +++ b/src/sessions_cmd.rs @@ -139,6 +139,48 @@ pub(crate) async fn handle_sessions_action( } => { run_git_backfill(project_id, project_path, since, limit_sessions, dry_run).await?; } + SessionsAction::Unfinished { + limit, + json, + project_id, + project_path, + } => { + let project_path = resolve_cli_project_root(None, project_id, project_path).await?; + let db = tracedecay::sessions::cursor::open_project_session_db(&project_path) + .await + .ok_or_else(|| tracedecay::errors::TraceDecayError::Config { + message: format!( + "could not open project session database for {}", + project_path.display() + ), + })?; + let items = tracedecay::sessions::workflow_state::list_unfinished(&db, limit) + .await + .map_err(|message| tracedecay::errors::TraceDecayError::Config { message })?; + if json { + println!( + "{}", + serde_json::to_string_pretty(&items).map_err(|e| { + tracedecay::errors::TraceDecayError::Config { + message: e.to_string(), + } + })? + ); + } else { + for item in items { + let task_id = item.task_id.as_deref().unwrap_or("-"); + println!( + "{}\t{}\t{}\t{}\t{}\t{}", + item.status, + item.provider, + item.session_id, + task_id, + item.message_id, + item.evidence + ); + } + } + } } Ok(()) } diff --git a/tests/agent_suite/agent_test.rs b/tests/agent_suite/agent_test.rs index 2240a42f8f..909326577b 100644 --- a/tests/agent_suite/agent_test.rs +++ b/tests/agent_suite/agent_test.rs @@ -281,6 +281,47 @@ fn expected_tracedecay_bin() -> String { .replace('\\', "/") } +fn expected_tracedecay_bin_variants() -> Vec { + let raw = PathBuf::from(env!("CARGO_BIN_EXE_tracedecay")); + let canonical = std::fs::canonicalize(&raw).unwrap_or_else(|_| raw.clone()); + let mut variants = Vec::new(); + for path in [raw, canonical] { + let native = path.to_string_lossy().to_string(); + let slash = native.replace('\\', "/"); + if !variants.contains(&native) { + variants.push(native); + } + if !variants.contains(&slash) { + variants.push(slash); + } + } + variants +} + +fn contains_expected_tracedecay_bin(body: &str) -> bool { + let slash_body = body.replace('\\', "/"); + expected_tracedecay_bin_variants().iter().any(|expected| { + body.contains(expected) || slash_body.contains(&expected.replace('\\', "/")) + }) +} + +fn comparable_command_path(command: &str) -> String { + command + .strip_prefix("//?/") + .unwrap_or(command) + .replace('\\', "/") +} + +fn assert_command_eq(actual: &serde_json::Value, expected: &str) { + let actual = actual + .as_str() + .unwrap_or_else(|| panic!("command should be a string: {actual}")); + assert_eq!( + comparable_command_path(actual), + comparable_command_path(expected) + ); +} + /// Python snippet that py_compiles the generated plugin sources inside the /// same interpreter that runs a test's check script, instead of the separate /// `python3 -m py_compile` process `assert_python_compiles` spawns. On @@ -408,7 +449,7 @@ fn assert_codex_plugin_bundle( let mcp = read_json(&plugin_dir.join(".mcp.json")); let server = &mcp["mcpServers"]["tracedecay"]; assert_eq!(server["type"], "stdio"); - assert_eq!(server["command"], expected_command); + assert_command_eq(&server["command"], expected_command); assert_eq!(server["args"], expected_args); if expected_global_bundle { assert_eq!(server["env"]["TRACEDECAY_ENABLE_GLOBAL_DB"], "1"); @@ -523,7 +564,7 @@ fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str, expect let mcp = read_json(&plugin_dir.join("mcp.json")); let server = &mcp["mcpServers"]["tracedecay"]; assert_eq!(server["type"], "stdio"); - assert_eq!(server["command"], expected_command); + assert_command_eq(&server["command"], expected_command); assert_eq!( server["args"], serde_json::json!(["serve", "--path", "${workspaceFolder}"]) @@ -561,7 +602,8 @@ fn assert_cursor_plugin_bundle(plugin_dir: &Path, expected_command: &str, expect assert!( hook["command"] .as_str() - .is_some_and(|command| command.contains(expected_command)), + .is_some_and(|command| comparable_command_path(command) + .contains(&comparable_command_path(expected_command))), "plugin hook commands should use the installed tracedecay binary" ); assert!( @@ -1089,7 +1131,7 @@ fn test_hermes_local_install_writes_profile_plugin() { })); let tools_py = std::fs::read_to_string(plugin_dir.join("tools.py")).unwrap(); - assert!(tools_py.contains(&expected_tracedecay_bin())); + assert!(contains_expected_tracedecay_bin(&tools_py)); assert!(tools_py.contains("subprocess.run")); assert!(tools_py.contains("tracedecay tool")); assert!(tools_py.contains("TRACEDECAY_TIMEOUT_SECONDS = 120")); @@ -2957,9 +2999,8 @@ fn assert_local_install_writes_project_paths(agent: &str, paths: &[&str]) { && (*relative == ".agents/plugins/marketplace.json" || relative.ends_with(".codex-plugin/plugin.json")); if !is_instruction_file && !is_codex_metadata { - let expected = expected_tracedecay_bin(); assert!( - body.contains(&expected), + contains_expected_tracedecay_bin(&body), "{agent} local config {} should use the resolved absolute tracedecay executable", path.display() ); @@ -3682,7 +3723,7 @@ fn assert_command_contains_expected_bin( }) .expect("handler command should exist"); assert!( - command.contains(expected), + comparable_command_path(command).contains(&comparable_command_path(expected)), "Codex hook command must use the resolved absolute tracedecay executable, got {command}" ); } diff --git a/tests/core_cli_suite/cli_help_test.rs b/tests/core_cli_suite/cli_help_test.rs index c899bf0f07..bbf11f2cc0 100644 --- a/tests/core_cli_suite/cli_help_test.rs +++ b/tests/core_cli_suite/cli_help_test.rs @@ -70,6 +70,7 @@ fn nested_subcommands_accept_help() { &["daemon", "install-service", "--help"], &["sessions", "ingest", "--help"], &["sessions", "search", "--help"], + &["sessions", "unfinished", "--help"], &["projects", "list", "--help"], &["projects", "search", "--help"], &["projects", "context", "--help"], diff --git a/tests/core_cli_suite/cli_non_interactive_test.rs b/tests/core_cli_suite/cli_non_interactive_test.rs index 183a26df52..4910b23f49 100644 --- a/tests/core_cli_suite/cli_non_interactive_test.rs +++ b/tests/core_cli_suite/cli_non_interactive_test.rs @@ -2,7 +2,7 @@ use std::path::{Path, PathBuf}; use std::process::{Child, Command, ExitStatus, Output, Stdio}; use std::time::{Duration, Instant}; -use crate::common::{create_runtime, sample_node}; +use crate::common::{create_runtime, global_session, message_record, sample_node}; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; use tempfile::TempDir; @@ -18,8 +18,9 @@ use tracedecay::migrate::manifest::{ MigrationManifest, MigrationProtocol, }; use tracedecay::storage::{ - read_enrollment_marker, write_enrollment_marker, EnrollmentMarker, StorageMode, StoreKind, - StoreManifest, STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, + default_profile_project_id, profile_sharded_data_root, read_enrollment_marker, + write_enrollment_marker, EnrollmentMarker, StorageMode, StoreKind, StoreManifest, + SESSIONS_DB_FILENAME, STORE_MANIFEST_FILENAME, STORE_MANIFEST_SCHEMA_VERSION, }; use tracedecay::tracedecay::{TraceDecay, TraceDecayOpenOptions}; @@ -169,6 +170,59 @@ fn init_accepts_relative_current_directory() { ); } +#[test] +fn sessions_unfinished_lists_workflow_state_evidence() { + let home = TempDir::new().unwrap(); + let project = TempDir::new().unwrap(); + let project_root = canonical_temp_path(project.path()); + std::fs::write(project_root.join("lib.rs"), "pub fn indexed() {}\n").unwrap(); + init_project_in_process(home.path(), &project_root); + let project_id = default_profile_project_id(&project_root); + let sessions_db_path = profile_sharded_data_root(&profile_root(home.path()), &project_id) + .join(SESSIONS_DB_FILENAME); + + create_runtime().block_on(async { + let db = GlobalDb::open_at(&sessions_db_path) + .await + .expect("session db"); + assert!( + db.upsert_session(&global_session("claude", "session-1", "proj_cli")) + .await + ); + assert!( + db.upsert_session_message(&message_record( + "claude", + "message-1", + "session-1", + "assistant", + 1, + "Blocked: waiting on missing deploy credentials", + "message", + None, + Some("/tmp/project/transcript.jsonl"), + Some(1), + Some(r#"{"task_id":"task-7"}"#), + )) + .await + ); + }); + + let mut command = tracedecay_command(home.path(), &project_root); + command.args(["sessions", "unfinished", "--json"]); + let output = run_with_timeout(command, cli_timeout()); + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + + assert!( + output.status.success(), + "sessions unfinished should succeed\nstdout:\n{stdout}\nstderr:\n{stderr}" + ); + assert!(stdout.contains(r#""status": "blocked""#), "{stdout}"); + assert!(stdout.contains(r#""session_id": "session-1""#), "{stdout}"); + assert!(stdout.contains(r#""task_id": "task-7""#), "{stdout}"); + assert!(stdout.contains("missing deploy credentials"), "{stdout}"); +} + fn write_profile_sharded_fixture(home: &std::path::Path, project: &std::path::Path) { let project = canonical_temp_path(project); let shard_root = profile_shard_root(home); diff --git a/tests/mcp_suite/git_correlation_test.rs b/tests/mcp_suite/git_correlation_test.rs index 4c6eeb768c..8ce8621ae2 100644 --- a/tests/mcp_suite/git_correlation_test.rs +++ b/tests/mcp_suite/git_correlation_test.rs @@ -146,6 +146,9 @@ async fn sessions_for_and_scoped_search_end_to_end() { let profile_root = base.join("profile"); std::fs::create_dir_all(&profile_root).unwrap_or_else(|e| panic!("create profile root: {e}")); + let profile_root = profile_root + .canonicalize() + .unwrap_or_else(|e| panic!("canonicalize profile root: {e}")); let cg = TraceDecay::init_with_options( &project_root, TraceDecayOpenOptions { diff --git a/tests/mcp_suite/main.rs b/tests/mcp_suite/main.rs index bfd2074ee6..19f896a60a 100644 --- a/tests/mcp_suite/main.rs +++ b/tests/mcp_suite/main.rs @@ -23,3 +23,4 @@ mod multi_mcp_coordination_test; mod serve_degraded_mode_test; mod serve_harness; mod serve_template_path_test; +mod workflow_query_test; diff --git a/tests/mcp_suite/mcp_test.rs b/tests/mcp_suite/mcp_test.rs index 01ad7dcfd7..659f04d770 100644 --- a/tests/mcp_suite/mcp_test.rs +++ b/tests/mcp_suite/mcp_test.rs @@ -100,7 +100,7 @@ fn test_tool_definitions_count() { // is available. Outline stays registered and reports the ast-grep outline // requirement at runtime so plugin docs/rules can consistently reference it. // LCM comparison and profile-storage registry support add extra tools. - let expected = 99 + usize::from(tracedecay::mcp::tools::ast_grep_available()); + let expected = 100 + usize::from(tracedecay::mcp::tools::ast_grep_available()); assert_eq!(tools.len(), expected); } diff --git a/tests/mcp_suite/workflow_query_test.rs b/tests/mcp_suite/workflow_query_test.rs new file mode 100644 index 0000000000..fb08b242a0 --- /dev/null +++ b/tests/mcp_suite/workflow_query_test.rs @@ -0,0 +1,638 @@ +//! End-to-end tests for the workflow-run query surface: `tracedecay_workflows` +//! (list runs for a thread / for a git ref, show one run, drill one agent) and +//! the `workflow_run` / `workflow_agent` agent-precision filter on +//! `tracedecay_message_search`. Everything is driven through the real +//! `handle_tool_call` dispatch against a temp `~/.claude` fixture tree plus a +//! seeded `sessions.db`, mirroring `git_correlation_test.rs`. + +use std::path::Path; + +use serde_json::{json, Value}; + +use tracedecay::global_db::GlobalDb; +use tracedecay::sessions::git_correlation::{ + SpanObservation, SpanSource, DEFAULT_SPAN_MERGE_GAP_SECS, +}; +use tracedecay::sessions::workflow_ingest::ingest_workflow_runs; +use tracedecay::sessions::{SessionMessageRecord, SessionRecord}; +use tracedecay::tracedecay::TraceDecay; + +use crate::common; + +// Fixture identity, shared across the on-disk tree and the seeded DB rows. +const SLUG: &str = "-home-zack-projects-fixture"; +const SESSION_ID: &str = "11111111-2222-3333-4444-555555555555"; +const RUN_ID: &str = "wf_fixture-run-01"; +const AGENT_MINE_ID: &str = "a17141dbe5a308242"; +const AGENT_RUN_ID: &str = "aa09ec4d07fccc915"; +const AGENT_MINE_LABEL: &str = "mine:claude-transcripts"; +const AGENT_RUN_LABEL: &str = "run:eval-batch"; + +/// Absolute path of the `agent-.jsonl` transcript inside the fixture tree. +/// This is exactly what the ingest sweep records as `transcript_path`, and what +/// the seeded `session_messages.source_path` must equal for the workflow-scoped +/// search join to fire. +fn agent_transcript_path(home: &Path, agent_id: &str) -> String { + home.join(".claude") + .join("projects") + .join(SLUG) + .join(SESSION_ID) + .join("subagents") + .join("workflows") + .join(RUN_ID) + .join(format!("agent-{agent_id}.jsonl")) + .to_string_lossy() + .to_string() +} + +/// Materializes a workflow run on disk under `/.claude/projects/...`, +/// shaped exactly like a real run: a parent transcript recording `cwd` (so the +/// run attributes to `project_root`), a `workflows/.json` meta with two +/// `workflow_agent` progress rows, the two `agent-.jsonl` transcripts (each +/// with an assistant `usage`), and a `journal.jsonl`. +fn write_workflow_fixture(home: &Path, project_root: &Path) { + let cwd = project_root.to_string_lossy().to_string(); + let session_dir = home + .join(".claude") + .join("projects") + .join(SLUG) + .join(SESSION_ID); + let workflows_dir = session_dir.join("workflows"); + let agents_dir = session_dir.join("subagents").join("workflows").join(RUN_ID); + std::fs::create_dir_all(&workflows_dir).unwrap_or_else(|e| panic!("workflows dir: {e}")); + std::fs::create_dir_all(&agents_dir).unwrap_or_else(|e| panic!("agents dir: {e}")); + + // Parent transcript sits at /.jsonl (sibling of the + // dir) and carries the owning session's cwd. + let parent_transcript = session_dir.with_extension("jsonl"); + std::fs::write( + &parent_transcript, + format!( + "{}\n", + json!({ + "type": "user", + "sessionId": SESSION_ID, + "cwd": cwd, + "timestamp": "2026-07-04T05:00:00.000Z", + "message": {"role": "user", "content": "kick off the eval workflow"} + }) + ), + ) + .unwrap_or_else(|e| panic!("parent transcript: {e}")); + + // Run meta + result. + let meta = json!({ + "runId": RUN_ID, + "workflowName": "tracedecay-triggering-evals", + "summary": "Mine real transcripts into a broad eval corpus\nthen score them", + "status": "completed", + "startTime": 1_783_142_254_914_i64, + "durationMs": 983_890_i64, + "agentCount": 2, + "defaultModel": "claude-fable-5", + "phases": [ + {"title": "Mine", "detail": "harvest scenarios"}, + {"title": "Run", "detail": "run it", "model": "fable"} + ], + "result": {"scored": 45, "scenarios": 36}, + "workflowProgress": [ + {"type": "workflow_phase", "phaseTitle": "Mine"}, + { + "type": "workflow_agent", + "label": AGENT_MINE_LABEL, + "phaseTitle": "Mine", + "phaseIndex": 1, + "agentId": AGENT_MINE_ID, + "model": "claude-fable-5", + "state": "done", + "startedAt": 1_783_142_254_936_i64, + "lastProgressAt": 1_783_142_255_936_i64 + }, + { + "type": "workflow_agent", + "label": AGENT_RUN_LABEL, + "phaseTitle": "Run", + "agentId": AGENT_RUN_ID, + "state": "in_progress", + "startedAt": 1_783_142_260_000_i64 + } + ] + }); + std::fs::write( + workflows_dir.join(format!("{RUN_ID}.json")), + serde_json::to_string_pretty(&meta).unwrap_or_else(|e| panic!("meta json: {e}")), + ) + .unwrap_or_else(|e| panic!("write meta: {e}")); + + // Per-agent transcripts (cwd + an assistant usage so tokens/session id fill). + for (agent_id, in_tok, out_tok) in [ + (AGENT_MINE_ID, 100_i64, 40_i64), + (AGENT_RUN_ID, 10_i64, 8_i64), + ] { + let body = format!( + "{}\n{}\n", + json!({ + "type": "user", + "isSidechain": true, + "sessionId": format!("agent-{agent_id}"), + "cwd": cwd, + "gitBranch": "feat/evals", + "timestamp": "2026-07-04T05:17:34.967Z", + "message": {"role": "user", "content": "do the phase work"} + }), + json!({ + "type": "assistant", + "isSidechain": true, + "sessionId": format!("agent-{agent_id}"), + "timestamp": "2026-07-04T05:18:00.000Z", + "message": { + "role": "assistant", + "usage": {"input_tokens": in_tok, "output_tokens": out_tok} + } + }), + ); + std::fs::write(agents_dir.join(format!("agent-{agent_id}.jsonl")), body) + .unwrap_or_else(|e| panic!("agent transcript: {e}")); + std::fs::write( + agents_dir.join(format!("agent-{agent_id}.meta.json")), + json!({"agentType": "general", "spawnDepth": 1}).to_string(), + ) + .unwrap_or_else(|e| panic!("agent meta: {e}")); + } + + // Journal: both agents started, the mine agent finished. + std::fs::write( + agents_dir.join("journal.jsonl"), + format!( + "{}\n{}\n{}\n", + json!({"type": "started", "agentId": AGENT_MINE_ID}), + json!({"type": "started", "agentId": AGENT_RUN_ID}), + json!({"type": "result", "agentId": AGENT_MINE_ID}), + ), + ) + .unwrap_or_else(|e| panic!("journal: {e}")); +} + +fn span(session_id: &str, branch: &str, worktree: &str, ts: i64) -> SpanObservation { + SpanObservation { + provider: "claude".to_string(), + session_id: session_id.to_string(), + thread_id: None, + branch: Some(branch.to_string()), + worktree: worktree.to_string(), + ts, + source: SpanSource::Ingest, + } +} + +/// A session row for the run's parent thread, so a recorded git span attributes +/// to a session the store knows about (mirrors ClaudeSource's parent session). +fn parent_session(project_key: &str) -> SessionRecord { + SessionRecord { + provider: "claude".to_string(), + session_id: SESSION_ID.to_string(), + project_key: project_key.to_string(), + project_path: project_key.to_string(), + title: Some("workflow parent thread".to_string()), + started_at: Some(1_783_142_254), + ended_at: None, + transcript_path: Some(format!("{SESSION_ID}.jsonl")), + metadata_json: None, + parent_session_id: None, + is_subagent: false, + agent_id: None, + parent_tool_use_id: None, + } +} + +/// A subagent session row for one workflow agent, so its messages join back to a +/// session the store knows about (the message-search JOIN requires it) — the +/// shape ClaudeSource would persist for a sidechain transcript. +fn agent_session(home: &Path, project_key: &str, agent_id: &str) -> SessionRecord { + SessionRecord { + provider: "claude".to_string(), + session_id: format!("agent-{agent_id}"), + project_key: project_key.to_string(), + project_path: project_key.to_string(), + title: Some(format!("agent {agent_id}")), + started_at: Some(1_783_142_260), + ended_at: None, + transcript_path: Some(agent_transcript_path(home, agent_id)), + metadata_json: None, + parent_session_id: Some(SESSION_ID.to_string()), + is_subagent: true, + agent_id: Some(agent_id.to_string()), + parent_tool_use_id: None, + } +} + +/// A message row standing in for one line of an agent transcript: `session_id` +/// is the agent's own session id and `source_path` is the agent-``.jsonl +/// file — the two keys the workflow-scoped search join matches on. +fn agent_message( + home: &Path, + agent_id: &str, + message_id: &str, + text: &str, +) -> SessionMessageRecord { + let transcript = agent_transcript_path(home, agent_id); + SessionMessageRecord { + provider: "claude".to_string(), + message_id: message_id.to_string(), + session_id: format!("agent-{agent_id}"), + role: "assistant".to_string(), + timestamp: Some(1_783_142_260), + ordinal: 1, + text: text.to_string(), + kind: Some("message".to_string()), + model: Some("claude-fable-5".to_string()), + tool_names: None, + source_path: Some(transcript), + source_offset: Some(0), + metadata_json: None, + } +} + +fn extract_json(result: &tracedecay::mcp::ToolResult) -> Value { + let text = result.value["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("tool result should carry text content: {}", result.value)); + serde_json::from_str(text).unwrap_or_else(|e| panic!("tool result should be JSON: {e}\n{text}")) +} + +/// Renders a tool call as markdown (no `format:"json"` override) so tests can +/// assert on the summary-first markdown surface. +async fn call_md(cg: &TraceDecay, tool: &str, args: Value) -> String { + let result = tracedecay::mcp::handle_tool_call(cg, tool, args, None, None) + .await + .unwrap_or_else(|e| panic!("{tool} should succeed: {e}")); + result.value["content"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("{tool} result should carry text content: {}", result.value)) + .to_string() +} + +async fn call(cg: &TraceDecay, tool: &str, mut args: Value) -> Value { + if let Some(obj) = args.as_object_mut() { + obj.entry("format".to_string()) + .or_insert_with(|| json!("json")); + } + let result = tracedecay::mcp::handle_tool_call(cg, tool, args, None, None) + .await + .unwrap_or_else(|e| panic!("{tool} should succeed: {e}")); + extract_json(&result) +} + +fn search_session_ids(payload: &Value) -> Vec { + payload["results"] + .as_array() + .unwrap_or_else(|| panic!("search results should be an array: {payload}")) + .iter() + .map(|hit| { + hit["session"]["session_id"] + .as_str() + .unwrap_or_default() + .to_string() + }) + .collect() +} + +/// Ingests the on-disk fixture and drives the three `tracedecay_workflows` +/// modes plus the git-scope list end to end. +#[tokio::test] +async fn workflows_query_surface_end_to_end() { + let (env, project_root) = common::IsolatedEnv::acquire().await; + let home = env.home().to_path_buf(); + + let cg = TraceDecay::init(&project_root) + .await + .unwrap_or_else(|e| panic!("init project: {e}")); + let project_key = cg.project_root().to_string_lossy().to_string(); + + // The fixture's agent transcripts record cwd == the canonical project root + // so the ingest sweep attributes the run to this project. + write_workflow_fixture(&home, cg.project_root()); + + let db_path = cg.store_layout().sessions_db_path.clone(); + let db = GlobalDb::open_at(&db_path) + .await + .unwrap_or_else(|| panic!("open sessions.db")); + + // The public ingest entrypoint reads $HOME (isolated to the tempdir), so it + // sweeps our fixture tree. + let stats = ingest_workflow_runs(&db, cg.project_root()).await; + assert_eq!(stats.runs_ingested, 1, "one run ingested: {stats:?}"); + assert_eq!(stats.agents_ingested, 2, "two agents ingested: {stats:?}"); + + // (a) session mode: list runs spawned by the parent thread. + let by_session = call( + &cg, + "tracedecay_workflows", + json!({ "session_id": SESSION_ID }), + ) + .await; + assert_eq!(by_session["mode"], "session", "{by_session}"); + assert_eq!(by_session["count"], 1, "{by_session}"); + assert_eq!(by_session["runs"][0]["run_id"], RUN_ID, "{by_session}"); + assert_eq!(by_session["runs"][0]["name"], "tracedecay-triggering-evals"); + assert_eq!(by_session["runs"][0]["agent_count"], 2); + + // (b) run mode: one run shows its phases + the two-agent roster + summary. + let by_run = call(&cg, "tracedecay_workflows", json!({ "run_id": RUN_ID })).await; + assert_eq!(by_run["mode"], "run", "{by_run}"); + assert_eq!(by_run["found"], true, "{by_run}"); + assert_eq!(by_run["agent_count"], 2, "{by_run}"); + assert_eq!(by_run["run"]["parent_session_id"], SESSION_ID); + // Summary is carried (multi-line in the fixture; stored one-lined). + assert!( + by_run["run"]["result_summary"] + .as_str() + .unwrap_or_default() + .contains("Mine real transcripts"), + "{by_run}" + ); + let agent_labels: Vec = by_run["agents"] + .as_array() + .unwrap_or_else(|| panic!("agents should be an array: {by_run}")) + .iter() + .map(|a| a["agent_label"].as_str().unwrap_or_default().to_string()) + .collect(); + assert!( + agent_labels.contains(&AGENT_MINE_LABEL.to_string()), + "{by_run}" + ); + assert!( + agent_labels.contains(&AGENT_RUN_LABEL.to_string()), + "{by_run}" + ); + + // Markdown for the run detail is summary-first (phases + agents headings, + // no leaked JSON object). + let run_md = call_md(&cg, "tracedecay_workflows", json!({ "run_id": RUN_ID })).await; + assert!(run_md.contains("Workflow Run"), "{run_md}"); + assert!(run_md.contains("Phases"), "{run_md}"); + assert!(run_md.contains("Agents"), "{run_md}"); + assert!(!run_md.contains("\"result_summary\""), "{run_md}"); + + // (c) agent drill: one agent surfaces its transcript path + replay hint. The + // mine agent had a real transcript, so ingest recorded its transcript_path. + let drill = call( + &cg, + "tracedecay_workflows", + json!({ "run_id": RUN_ID, "agent_label": AGENT_MINE_LABEL }), + ) + .await; + assert_eq!(drill["mode"], "agent", "{drill}"); + assert_eq!(drill["found"], true, "{drill}"); + assert_eq!(drill["agent"]["agent_label"], AGENT_MINE_LABEL); + let transcript = drill["agent"]["transcript_path"] + .as_str() + .unwrap_or_default(); + assert!( + transcript.ends_with(&format!("agent-{AGENT_MINE_ID}.jsonl")), + "drill transcript path: {drill}" + ); + // Tokens summed from the transcript usage (100+40). + assert_eq!(drill["agent"]["tokens"], 140, "{drill}"); + + // (d) git-scope mode: after a span places the parent thread on a branch, + // the run surfaces via the parent-session span join. + let worktree = project_key.clone(); + db.git_record_span_observation( + &span(SESSION_ID, "feat/evals", &worktree, 1_783_142_254), + DEFAULT_SPAN_MERGE_GAP_SECS, + ) + .await + .unwrap_or_else(|e| panic!("record span: {e}")); + + let by_branch = call( + &cg, + "tracedecay_workflows", + json!({ "branch": "feat/evals" }), + ) + .await; + assert_eq!(by_branch["mode"], "git_scope", "{by_branch}"); + assert_eq!(by_branch["count"], 1, "{by_branch}"); + assert_eq!(by_branch["runs"][0]["run_id"], RUN_ID, "{by_branch}"); + + // A branch nothing ran on returns no runs. + let by_absent = call( + &cg, + "tracedecay_workflows", + json!({ "branch": "feat/absent" }), + ) + .await; + assert_eq!(by_absent["count"], 0, "{by_absent}"); + + drop(db); + cg.close(); +} + +/// Drives the `workflow_run` / `workflow_agent` agent-precision filter on +/// `tracedecay_message_search`. +#[tokio::test] +async fn message_search_workflow_scope_narrows_to_run_agents() { + let (env, project_root) = common::IsolatedEnv::acquire().await; + let home = env.home().to_path_buf(); + + let cg = TraceDecay::init(&project_root) + .await + .unwrap_or_else(|e| panic!("init project: {e}")); + let project_key = cg.project_root().to_string_lossy().to_string(); + + write_workflow_fixture(&home, cg.project_root()); + + let db_path = cg.store_layout().sessions_db_path.clone(); + let db = GlobalDb::open_at(&db_path) + .await + .unwrap_or_else(|| panic!("open sessions.db")); + + // Index the run + agents (sets each agent's transcript_path). + let stats = ingest_workflow_runs(&db, cg.project_root()).await; + assert_eq!(stats.runs_ingested, 1, "{stats:?}"); + + // Seed the parent thread + the two agent subagent sessions, then two agent + // messages whose source_path equals the agents' transcript files, plus an + // unrelated session whose message shares the query term but belongs to no + // workflow agent. Sessions come first: the message-search JOIN drops a + // message whose (provider, session_id) has no session row. + assert!(db.upsert_session(&parent_session(&project_key)).await); + assert!( + db.upsert_session(&agent_session(&home, &project_key, AGENT_MINE_ID)) + .await + ); + assert!( + db.upsert_session(&agent_session(&home, &project_key, AGENT_RUN_ID)) + .await + ); + assert!( + db.upsert_session(&SessionRecord { + session_id: "unrelated-thread".to_string(), + ..parent_session(&project_key) + }) + .await + ); + assert!( + db.upsert_session_message(&agent_message( + &home, + AGENT_MINE_ID, + "mine-m1", + "sifted transcripts into eval scenarios harvest", + )) + .await + ); + assert!( + db.upsert_session_message(&agent_message( + &home, + AGENT_RUN_ID, + "run-m1", + "executed the eval scenarios batch harvest", + )) + .await + ); + // Off-run noise: same term, different session, not an agent of the run. + assert!( + db.upsert_session_message(&SessionMessageRecord { + session_id: "unrelated-thread".to_string(), + message_id: "noise-m1".to_string(), + source_path: Some("/somewhere/unrelated.jsonl".to_string()), + ..agent_message( + &home, + AGENT_MINE_ID, + "noise-m1", + "harvest happening elsewhere" + ) + }) + .await + ); + + // workflow_run scopes to BOTH agents of the run, excluding the off-run noise. + let by_run = call( + &cg, + "tracedecay_message_search", + json!({ + "query": "harvest", + "provider": "claude", + "catch_up": false, + "workflow_run": RUN_ID, + }), + ) + .await; + assert_eq!(by_run["workflow_filter_applied"], true, "{by_run}"); + assert_eq!(by_run["workflow_run"], RUN_ID, "{by_run}"); + assert_eq!( + by_run["workflow_run_parent_session"], SESSION_ID, + "{by_run}" + ); + let run_sessions = search_session_ids(&by_run); + assert!( + run_sessions.contains(&format!("agent-{AGENT_MINE_ID}")), + "{by_run}" + ); + assert!( + run_sessions.contains(&format!("agent-{AGENT_RUN_ID}")), + "{by_run}" + ); + assert!( + !run_sessions.contains(&"unrelated-thread".to_string()), + "off-run message leaked: {by_run}" + ); + + // workflow_agent narrows to just the one agent. + let by_agent = call( + &cg, + "tracedecay_message_search", + json!({ + "query": "harvest", + "provider": "claude", + "catch_up": false, + "workflow_run": RUN_ID, + "workflow_agent": AGENT_MINE_LABEL, + }), + ) + .await; + assert_eq!(by_agent["workflow_agent"], AGENT_MINE_LABEL, "{by_agent}"); + let agent_sessions = search_session_ids(&by_agent); + assert_eq!( + agent_sessions, + vec![format!("agent-{AGENT_MINE_ID}")], + "{by_agent}" + ); + + // Markdown surface names the scoped run + agent, no leaked JSON object. + let md = call_md( + &cg, + "tracedecay_message_search", + json!({ + "query": "harvest", + "provider": "claude", + "catch_up": false, + "workflow_run": RUN_ID, + "workflow_agent": AGENT_MINE_LABEL, + }), + ) + .await; + assert!(md.contains("workflow filter"), "{md}"); + assert!(md.contains(RUN_ID), "{md}"); + assert!(md.contains(AGENT_MINE_LABEL), "{md}"); + assert!(!md.contains("\"workflow_run\""), "{md}"); + + drop(db); + cg.close(); +} + +/// A workflow-scoped search against a store that predates the workflow-index +/// schema returns empty rather than erroring on a missing table. +#[tokio::test] +async fn message_search_workflow_scope_empty_without_workflow_tables() { + let (_env, project_root) = common::IsolatedEnv::acquire().await; + + let cg = TraceDecay::init(&project_root) + .await + .unwrap_or_else(|e| panic!("init project: {e}")); + let project_key = cg.project_root().to_string_lossy().to_string(); + + let db_path = cg.store_layout().sessions_db_path.clone(); + let db = GlobalDb::open_at(&db_path) + .await + .unwrap_or_else(|| panic!("open sessions.db")); + + // Seed a plain message but NEVER create the workflow-index tables. + assert!(db.upsert_session(&parent_session(&project_key)).await); + assert!( + db.upsert_session_message(&SessionMessageRecord { + session_id: SESSION_ID.to_string(), + message_id: "m1".to_string(), + source_path: Some(format!("{SESSION_ID}.jsonl")), + ..agent_message(project_root.as_path(), AGENT_MINE_ID, "m1", "harvest text") + }) + .await + ); + + // Sanity: the same query matches without the workflow filter. + let unscoped = call( + &cg, + "tracedecay_message_search", + json!({ "query": "harvest", "provider": "claude", "catch_up": false }), + ) + .await; + assert!(unscoped["count"].as_i64().unwrap_or(0) >= 1, "{unscoped}"); + + // With the workflow filter, a store lacking workflow_agents yields nothing. + let scoped = call( + &cg, + "tracedecay_message_search", + json!({ + "query": "harvest", + "provider": "claude", + "catch_up": false, + "workflow_run": RUN_ID, + }), + ) + .await; + assert_eq!(scoped["workflow_filter_applied"], true, "{scoped}"); + assert_eq!(scoped["count"], 0, "{scoped}"); + + drop(db); + cg.close(); +}