From a05229c6f3faede605c09c2313b034afbf6ec40e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 19:38:59 +0000 Subject: [PATCH 01/11] Add Checks system for YAML workflow execution Implements a Checks tab in ShowWorkspace that reads workflow definitions from .treq/workflows/*.yaml and allows manual job execution with per-step pass/fail results. - core/checks.rs: business logic for listing and running workflows, with parallel job execution via std::thread and DB persistence of results - commands/checks.rs: Tauri command wrappers (list_workflows, run_workflow_job, run_workflow) - local_db.rs: workflow_runs table for persisting job results - NAPI dispatch: three new arms so JS integration tests use real Rust code - ChecksTab.tsx: React component with per-job and per-workflow Run buttons, step-level pass/fail icons (data-testid for test queries) - ShowWorkspace.tsx: new Checks tab trigger and content panel - api.ts / api-types.ts: TypeScript types and invoke wrappers - Rust unit tests (9), Rust integration tests (7), JS integration tests (6) --- Cargo.lock | 26 ++ crates/treq-napi/src/dispatch.rs | 36 +++ src-tauri/Cargo.toml | 1 + src-tauri/src/commands/checks.rs | 37 +++ src-tauri/src/commands/mod.rs | 2 + src-tauri/src/core/checks.rs | 360 +++++++++++++++++++++ src-tauri/src/core/mod.rs | 2 + src-tauri/src/lib.rs | 3 + src-tauri/src/local_db.rs | 71 ++++ src-tauri/tests/checks_test.rs | 77 +++++ src-tauri/tests/e2e_test_helpers.rs | 36 +++ src/components/ChecksTab.tsx | 184 +++++++++++ src/components/ShowWorkspace.tsx | 67 ++++ src/lib/api-types.ts | 6 + src/lib/api.ts | 22 ++ test/integration/workspace/checks.test.tsx | 162 ++++++++++ 16 files changed, 1092 insertions(+) create mode 100644 src-tauri/src/commands/checks.rs create mode 100644 src-tauri/src/core/checks.rs create mode 100644 src-tauri/tests/checks_test.rs create mode 100644 src/components/ChecksTab.tsx create mode 100644 test/integration/workspace/checks.test.tsx diff --git a/Cargo.lock b/Cargo.lock index c2350eeb..41df4b35 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5646,6 +5646,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -5891,6 +5897,19 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap 2.14.0", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + [[package]] name = "serial2" version = "0.2.36" @@ -7173,6 +7192,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "serde_yaml", "sha2 0.11.0", "tauri", "tauri-build", @@ -7321,6 +7341,12 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + [[package]] name = "url" version = "2.5.8" diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index 90fb2b7f..b1dd2304 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -930,6 +930,42 @@ pub fn dispatch(command: &str, args: Value) -> Result { serde_json::to_value(result).map_err(|e| e.to_string()) } + "list_workflows" => { + let repo_path = get_str(&args, "repoPath")?; + let result = treq_lib::core::list_workflows_sync(&repo_path)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "run_workflow_job" => { + let repo_path = get_str(&args, "repoPath")?; + let filename = get_str(&args, "filename")?; + let job_id = get_str(&args, "jobId")?; + let workspace_id = get_i64(&args, "workspaceId")?; + let workspace_path = get_str(&args, "workspacePath")?; + let result = treq_lib::core::run_workflow_job_sync( + &repo_path, + &filename, + &job_id, + workspace_id, + &workspace_path, + )?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "run_workflow" => { + let repo_path = get_str(&args, "repoPath")?; + let filename = get_str(&args, "filename")?; + let workspace_id = get_i64(&args, "workspaceId")?; + let workspace_path = get_str(&args, "workspacePath")?; + let result = treq_lib::core::run_workflow_sync( + &repo_path, + &filename, + workspace_id, + &workspace_path, + )?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + // ── Tauri-runtime-only: silent no-ops ───────────────────────────── "pty_create_session" | "pty_session_exists" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e14712f3..dc9d63f6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -26,6 +26,7 @@ tauri-plugin-deep-link = "2.4.7" tauri-plugin-cli = "2.4.1" serde = { version = "1", features = ["derive"] } serde_json = "1" +serde_yaml = "0.9" rusqlite = { version = "0.39", features = ["bundled"] } portable-pty = "0.9" chrono = { version = "0.4", features = ["serde"] } diff --git a/src-tauri/src/commands/checks.rs b/src-tauri/src/commands/checks.rs new file mode 100644 index 00000000..7b905f76 --- /dev/null +++ b/src-tauri/src/commands/checks.rs @@ -0,0 +1,37 @@ +use crate::core::{JobResult, WorkflowInfo}; + +#[tauri::command] +pub async fn list_workflows(repo_path: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || crate::core::list_workflows_sync(&repo_path)) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn run_workflow_job( + repo_path: String, + filename: String, + job_id: String, + workspace_id: i64, + workspace_path: String, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::core::run_workflow_job_sync(&repo_path, &filename, &job_id, workspace_id, &workspace_path) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn run_workflow( + repo_path: String, + filename: String, + workspace_id: i64, + workspace_path: String, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + crate::core::run_workflow_sync(&repo_path, &filename, workspace_id, &workspace_path) + }) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index a8feb6f3..c3b4ceaf 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,5 +1,6 @@ // Command modules pub mod binary; +pub mod checks; pub mod commits; pub mod file_view; pub mod file_watcher; @@ -13,6 +14,7 @@ pub mod workspace; // Re-export all commands for convenient access pub use binary::*; +pub use checks::*; pub use commits::*; pub use file_view::*; pub use file_watcher::*; diff --git a/src-tauri/src/core/checks.rs b/src-tauri/src/core/checks.rs new file mode 100644 index 00000000..d1d39da6 --- /dev/null +++ b/src-tauri/src/core/checks.rs @@ -0,0 +1,360 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::Path; +use std::process::Command; + +// ── Internal YAML structs ──────────────────────────────────────────────────── + +#[derive(Deserialize)] +struct WorkflowFile { + name: String, + jobs: HashMap, +} + +#[derive(Deserialize)] +struct JobDef { + name: Option, + steps: Vec, +} + +#[derive(Deserialize)] +struct StepDef { + name: String, + run: String, + #[serde(rename = "working-directory")] + working_directory: Option, + env: Option>, +} + +// ── Public API types ───────────────────────────────────────────────────────── + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkflowInfo { + pub filename: String, + pub name: String, + pub jobs: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JobInfo { + pub id: String, + pub name: String, + pub steps: Vec, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StepInfo { + pub name: String, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct StepResult { + pub name: String, + pub success: bool, +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct JobResult { + pub job_id: String, + pub steps: Vec, + pub success: bool, +} + +// ── Public functions ───────────────────────────────────────────────────────── + +pub fn list_workflows_sync(repo_path: &str) -> Result, String> { + let workflows_dir = Path::new(repo_path).join(".treq").join("workflows"); + if !workflows_dir.exists() { + return Ok(vec![]); + } + + let mut entries: Vec<_> = std::fs::read_dir(&workflows_dir) + .map_err(|e| format!("Failed to read workflows dir: {}", e))? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name = name.to_string_lossy(); + name.ends_with(".yaml") || name.ends_with(".yml") + }) + .collect(); + + entries.sort_by_key(|e| e.file_name()); + + let mut workflows = Vec::new(); + for entry in entries { + let filename = entry.file_name().to_string_lossy().to_string(); + let content = std::fs::read_to_string(entry.path()) + .map_err(|e| format!("Failed to read {}: {}", filename, e))?; + let wf: WorkflowFile = serde_yaml::from_str(&content) + .map_err(|e| format!("Failed to parse {}: {}", filename, e))?; + + let mut jobs: Vec = wf + .jobs + .into_iter() + .map(|(id, def)| JobInfo { + id: id.clone(), + name: def.name.unwrap_or_else(|| id), + steps: def.steps.into_iter().map(|s| StepInfo { name: s.name }).collect(), + }) + .collect(); + jobs.sort_by(|a, b| a.id.cmp(&b.id)); + + workflows.push(WorkflowInfo { filename, name: wf.name, jobs }); + } + + Ok(workflows) +} + +pub fn run_workflow_job_sync( + repo_path: &str, + filename: &str, + job_id: &str, + workspace_id: i64, + workspace_path: &str, +) -> Result { + let workflows_dir = Path::new(repo_path).join(".treq").join("workflows"); + let file_path = workflows_dir.join(filename); + let content = std::fs::read_to_string(&file_path) + .map_err(|e| format!("Failed to read {}: {}", filename, e))?; + let wf: WorkflowFile = serde_yaml::from_str(&content) + .map_err(|e| format!("Failed to parse {}: {}", filename, e))?; + + let job_def = wf + .jobs + .into_iter() + .find(|(id, _)| id == job_id) + .map(|(_, def)| def) + .ok_or_else(|| format!("Job '{}' not found in '{}'", job_id, filename))?; + + let base_dir = if Path::new(workspace_path).is_dir() { + workspace_path.to_string() + } else { + repo_path.to_string() + }; + + let extended_path = crate::binary_paths::get_extended_path(); + let mut step_results = Vec::new(); + + for step in &job_def.steps { + let cwd = if let Some(wd) = &step.working_directory { + Path::new(&base_dir).join(wd).to_string_lossy().to_string() + } else { + base_dir.clone() + }; + + let mut cmd = Command::new("sh"); + cmd.args(["-c", &step.run]) + .current_dir(&cwd) + .env("PATH", &extended_path); + + if let Some(env_vars) = &step.env { + for (k, v) in env_vars { + cmd.env(k, v); + } + } + + let output = cmd.output().map_err(|e| format!("Failed to run step '{}': {}", step.name, e))?; + let success = output.status.success(); + + step_results.push(StepResult { name: step.name.clone(), success }); + + if !success { + break; + } + } + + let overall_success = step_results.iter().all(|s| s.success); + let result = JobResult { + job_id: job_id.to_string(), + steps: step_results, + success: overall_success, + }; + + store_workflow_run(repo_path, workspace_id, filename, &result)?; + + Ok(result) +} + +pub fn run_workflow_sync( + repo_path: &str, + filename: &str, + workspace_id: i64, + workspace_path: &str, +) -> Result, String> { + let workflows = list_workflows_sync(repo_path)?; + let wf = workflows + .into_iter() + .find(|w| w.filename == filename) + .ok_or_else(|| format!("Workflow '{}' not found", filename))?; + + let job_ids: Vec = wf.jobs.into_iter().map(|j| j.id).collect(); + + let repo_path = repo_path.to_string(); + let filename = filename.to_string(); + let workspace_path = workspace_path.to_string(); + + let handles: Vec<_> = job_ids + .into_iter() + .map(|job_id| { + let repo_path = repo_path.clone(); + let filename = filename.clone(); + let workspace_path = workspace_path.clone(); + std::thread::spawn(move || { + run_workflow_job_sync(&repo_path, &filename, &job_id, workspace_id, &workspace_path) + }) + }) + .collect(); + + let mut results = Vec::new(); + for handle in handles { + match handle.join() { + Ok(Ok(result)) => results.push(result), + Ok(Err(e)) => return Err(e), + Err(_) => return Err("Job thread panicked".to_string()), + } + } + + Ok(results) +} + +pub fn store_workflow_run( + repo_path: &str, + workspace_id: i64, + filename: &str, + result: &JobResult, +) -> Result<(), String> { + let steps_json = serde_json::to_string(&result.steps) + .map_err(|e| format!("Failed to serialize steps: {}", e))?; + crate::local_db::add_workflow_run( + repo_path, + workspace_id, + filename, + &result.job_id, + result.success, + &steps_json, + )?; + Ok(()) +} + +// ── Unit tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn make_workflow(jobs_yaml: &str) -> String { + format!( + "name: Test Workflow\non:\n workflow_dispatch: {{}}\njobs:\n{}", + jobs_yaml + ) + } + + fn write_workflow(dir: &TempDir, filename: &str, content: &str) -> String { + let workflows_dir = dir.path().join(".treq").join("workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + fs::write(workflows_dir.join(filename), content).unwrap(); + dir.path().to_string_lossy().to_string() + } + + #[test] + fn test_list_workflows_empty_when_no_dir() { + let dir = TempDir::new().unwrap(); + let result = list_workflows_sync(&dir.path().to_string_lossy()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_list_workflows_parses_yaml() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n name: Greet Job\n steps:\n - name: Say hi\n run: echo hi\n", + ); + let repo = write_workflow(&dir, "ci.yaml", &content); + let result = list_workflows_sync(&repo).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "Test Workflow"); + } + + #[test] + fn test_list_workflows_skips_non_yaml() { + let dir = TempDir::new().unwrap(); + let workflows_dir = dir.path().join(".treq").join("workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + fs::write(workflows_dir.join("readme.txt"), "not yaml").unwrap(); + let result = list_workflows_sync(&dir.path().to_string_lossy()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_list_workflows_sorted_by_filename() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " j:\n steps:\n - name: s\n run: echo x\n", + ); + let repo = write_workflow(&dir, "b.yaml", &content); + write_workflow(&dir, "a.yaml", &content); + let result = list_workflows_sync(&repo).unwrap(); + assert_eq!(result.len(), 2); + assert!(result[0].filename < result[1].filename); + } + + #[test] + fn test_run_job_success() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n steps:\n - name: Say hi\n run: echo hi\n", + ); + let repo = write_workflow(&dir, "ci.yaml", &content); + let _ = crate::local_db::init_local_db(&repo); + let result = run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); + assert!(result.success); + assert!(!result.steps.is_empty()); + } + + #[test] + fn test_run_job_stops_at_first_failure() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " check:\n steps:\n - name: Fail\n run: exit 1\n - name: Skip\n run: echo skip\n", + ); + let repo = write_workflow(&dir, "ci.yaml", &content); + let _ = crate::local_db::init_local_db(&repo); + let result = run_workflow_job_sync(&repo, "ci.yaml", "check", 0, &repo).unwrap(); + assert!(!result.success); + assert_eq!(result.steps.len(), 1); + } + + #[test] + fn test_run_job_unknown_job_returns_error() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n steps:\n - name: hi\n run: echo hi\n", + ); + let repo = write_workflow(&dir, "ci.yaml", &content); + let err = run_workflow_job_sync(&repo, "ci.yaml", "nonexistent", 0, &repo).unwrap_err(); + assert!(err.contains("nonexistent")); + } + + #[test] + fn test_run_job_env_vars() { + let dir = TempDir::new().unwrap(); + let content = "name: Env Test\non:\n workflow_dispatch: {}\njobs:\n check:\n steps:\n - name: Check env\n run: test \"$MY_VAR\" = \"hello\"\n env:\n MY_VAR: hello\n"; + let repo = write_workflow(&dir, "env.yaml", content); + let _ = crate::local_db::init_local_db(&repo); + let result = run_workflow_job_sync(&repo, "env.yaml", "check", 0, &repo).unwrap(); + assert!(result.success); + } + + #[test] + fn test_run_workflow_runs_all_jobs() { + let dir = TempDir::new().unwrap(); + let content = "name: Multi\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: s1\n run: echo a\n job2:\n steps:\n - name: s2\n run: echo b\n"; + let repo = write_workflow(&dir, "multi.yaml", content); + let _ = crate::local_db::init_local_db(&repo); + let results = run_workflow_sync(&repo, "multi.yaml", 0, &repo).unwrap(); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.success)); + } +} diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index a0f62307..a57270d8 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -1,10 +1,12 @@ pub mod app; pub mod changes; +pub mod checks; pub mod commits; pub mod repo; pub mod workspaces; pub use app::*; pub use changes::*; +pub use checks::*; pub use commits::*; pub use repo::*; use std::path::{Path, PathBuf}; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ca6acddb..af163d74 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -523,6 +523,9 @@ pub fn run() { commands::gh_set_pr_draft, commands::gh_create_pr, commands::gh_list_pr_review_threads, + commands::list_workflows, + commands::run_workflow_job, + commands::run_workflow, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/local_db.rs b/src-tauri/src/local_db.rs index baf44659..14c4b5ba 100644 --- a/src-tauri/src/local_db.rs +++ b/src-tauri/src/local_db.rs @@ -346,6 +346,20 @@ pub fn init_local_db(repo_path: &str) -> Result { ) .map_err(|e| format!("Failed to create instance_registry heartbeat index: {}", e))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS workflow_runs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id INTEGER, + filename TEXT NOT NULL, + job_id TEXT NOT NULL, + success INTEGER NOT NULL DEFAULT 0, + steps_json TEXT NOT NULL, + ran_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("Failed to create workflow_runs table: {}", e))?; + // Migration: rename pending_reviews columns from old schema to new schema. let has_old_columns: Result = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('pending_reviews') WHERE name IN ('comments_json', 'overall_comment', 'viewed_files_json')", @@ -2353,3 +2367,60 @@ mod tests { } } } + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct WorkflowRunRecord { + pub id: i64, + pub filename: String, + pub job_id: String, + pub success: bool, + pub steps_json: String, + pub ran_at: String, +} + +pub fn add_workflow_run( + repo_path: &str, + workspace_id: i64, + filename: &str, + job_id: &str, + success: bool, + steps_json: &str, +) -> Result { + let conn = get_connection(repo_path)?; + let ran_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT INTO workflow_runs (workspace_id, filename, job_id, success, steps_json, ran_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6)", + params![workspace_id, filename, job_id, success as i64, steps_json, ran_at], + ) + .map_err(|e| format!("Failed to insert workflow_run: {}", e))?; + Ok(conn.last_insert_rowid()) +} + +pub fn get_latest_workflow_run( + repo_path: &str, + workspace_id: i64, + filename: &str, + job_id: &str, +) -> Result, String> { + let conn = get_connection(repo_path)?; + conn.query_row( + "SELECT id, filename, job_id, success, steps_json, ran_at + FROM workflow_runs + WHERE workspace_id = ?1 AND filename = ?2 AND job_id = ?3 + ORDER BY id DESC LIMIT 1", + params![workspace_id, filename, job_id], + |row| { + Ok(WorkflowRunRecord { + id: row.get(0)?, + filename: row.get(1)?, + job_id: row.get(2)?, + success: row.get::<_, i64>(3)? != 0, + steps_json: row.get(4)?, + ran_at: row.get(5)?, + }) + }, + ) + .optional() + .map_err(|e| format!("Failed to query workflow_run: {}", e)) +} diff --git a/src-tauri/tests/checks_test.rs b/src-tauri/tests/checks_test.rs new file mode 100644 index 00000000..183be35e --- /dev/null +++ b/src-tauri/tests/checks_test.rs @@ -0,0 +1,77 @@ +mod e2e_test_helpers; + +use e2e_test_helpers::{TestRepo, FAILING_WORKFLOW, PASSING_WORKFLOW}; +use treq_lib::core; + +#[test] +fn test_list_workflows_empty_for_fresh_repo() { + let repo = TestRepo::new().expect("Failed to create test repo"); + let result = core::list_workflows_sync(&repo.repo_path).expect("Failed to list workflows"); + assert!(result.is_empty()); +} + +#[test] +fn test_list_workflows_sees_yaml_file() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", PASSING_WORKFLOW) + .expect("Failed to write workflow"); + let result = core::list_workflows_sync(&repo.repo_path).expect("Failed to list workflows"); + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "Passing CI"); +} + +#[test] +fn test_list_workflows_multiple_files_sorted() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("b.yaml", PASSING_WORKFLOW) + .expect("Failed to write b.yaml"); + repo.write_workflow("a.yaml", FAILING_WORKFLOW) + .expect("Failed to write a.yaml"); + let result = core::list_workflows_sync(&repo.repo_path).expect("Failed to list workflows"); + assert_eq!(result.len(), 2); + assert!(result[0].filename < result[1].filename); +} + +#[test] +fn test_run_workflow_job_success() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", PASSING_WORKFLOW) + .expect("Failed to write workflow"); + let result = core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "greet", 0, &repo.repo_path) + .expect("Failed to run job"); + assert!(result.success); + assert!(!result.steps.is_empty()); +} + +#[test] +fn test_run_workflow_job_stops_at_first_failure() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", FAILING_WORKFLOW) + .expect("Failed to write workflow"); + let result = core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "check", 0, &repo.repo_path) + .expect("Failed to run job"); + assert!(!result.success); + assert_eq!(result.steps.len(), 1); +} + +#[test] +fn test_run_workflow_job_unknown_job_error() { + let repo = TestRepo::new().expect("Failed to create test repo"); + repo.write_workflow("ci.yaml", PASSING_WORKFLOW) + .expect("Failed to write workflow"); + let err = core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "nonexistent", 0, &repo.repo_path) + .unwrap_err(); + assert!(err.contains("nonexistent")); +} + +#[test] +fn test_run_workflow_runs_all_jobs() { + let repo = TestRepo::new().expect("Failed to create test repo"); + let content = "name: Multi Job\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: step1\n run: echo a\n job2:\n steps:\n - name: step2\n run: echo b\n"; + repo.write_workflow("multi.yaml", content) + .expect("Failed to write workflow"); + let results = core::run_workflow_sync(&repo.repo_path, "multi.yaml", 0, &repo.repo_path) + .expect("Failed to run workflow"); + assert_eq!(results.len(), 2); + assert!(results.iter().all(|r| r.success)); +} diff --git a/src-tauri/tests/e2e_test_helpers.rs b/src-tauri/tests/e2e_test_helpers.rs index dea5613c..f8b43401 100644 --- a/src-tauri/tests/e2e_test_helpers.rs +++ b/src-tauri/tests/e2e_test_helpers.rs @@ -5,6 +5,37 @@ use std::sync::atomic::{AtomicU64, Ordering}; use std::time::{SystemTime, UNIX_EPOCH}; use tempfile::TempDir; +#[allow(dead_code)] +pub const PASSING_WORKFLOW: &str = " +name: Passing CI +on: + workflow_dispatch: {} +jobs: + greet: + name: Greet Job + steps: + - name: Say hello + run: echo hello + - name: Say world + run: echo world +"; + +#[allow(dead_code)] +pub const FAILING_WORKFLOW: &str = " +name: Failing CI +on: + workflow_dispatch: {} +jobs: + check: + name: Check Job + steps: + - name: Fail here + run: exit 1 + - name: Never runs + run: echo skipped +"; + + fn random_default_branch_name() -> String { static COUNTER: AtomicU64 = AtomicU64::new(0); let seq = COUNTER.fetch_add(1, Ordering::Relaxed); @@ -255,6 +286,11 @@ impl TestRepo { Ok(file_path) } + /// Write a YAML workflow file to `.treq/workflows/{filename}` in the repo. + pub fn write_workflow(&self, filename: &str, content: &str) -> Result { + self.create_file(&format!(".treq/workflows/{}", filename), content) + } + /// Write or append file content at an absolute path. fn write_file_at_path(file_path: PathBuf, content: &str, append: bool) -> Result<(), String> { if let Some(parent) = file_path.parent() { diff --git a/src/components/ChecksTab.tsx b/src/components/ChecksTab.tsx new file mode 100644 index 00000000..b6cb3684 --- /dev/null +++ b/src/components/ChecksTab.tsx @@ -0,0 +1,184 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + CheckCircle2, + CircleDot, + Loader2, + Play, + XCircle, +} from "lucide-react"; +import { Button } from "./ui/button"; +import { listWorkflows, runWorkflow, runWorkflowJob } from "../lib/api"; +import type { JobResult, WorkflowInfo } from "../lib/api-types"; + +interface Props { + repoPath: string; + workspaceId: number; + workspacePath: string; +} + +export function ChecksTab({ repoPath, workspaceId, workspacePath }: Props) { + const [runningJobs, setRunningJobs] = useState>(new Set()); + const [runningWorkflows, setRunningWorkflows] = useState>( + new Set(), + ); + const [jobResults, setJobResults] = useState>({}); + + const { data: workflows = [], isLoading } = useQuery({ + queryKey: ["workflows", repoPath], + queryFn: () => listWorkflows(repoPath), + }); + + const jobKey = (filename: string, jobId: string) => `${filename}:${jobId}`; + + async function handleRunJob(wf: WorkflowInfo, jobId: string) { + const key = jobKey(wf.filename, jobId); + setRunningJobs((prev) => new Set(prev).add(key)); + try { + const result = await runWorkflowJob( + repoPath, + wf.filename, + jobId, + workspaceId, + workspacePath, + ); + setJobResults((prev) => ({ ...prev, [key]: result })); + } finally { + setRunningJobs((prev) => { + const next = new Set(prev); + next.delete(key); + return next; + }); + } + } + + async function handleRunWorkflow(wf: WorkflowInfo) { + setRunningWorkflows((prev) => new Set(prev).add(wf.filename)); + try { + const results = await runWorkflow( + repoPath, + wf.filename, + workspaceId, + workspacePath, + ); + const updates: Record = {}; + for (const result of results) { + updates[jobKey(wf.filename, result.job_id)] = result; + } + setJobResults((prev) => ({ ...prev, ...updates })); + } finally { + setRunningWorkflows((prev) => { + const next = new Set(prev); + next.delete(wf.filename); + return next; + }); + } + } + + if (isLoading) { + return ( +
+ + Loading workflows… +
+ ); + } + + if (workflows.length === 0) { + return ( +
+ No workflows found. Add YAML files to{" "} + .treq/workflows/ to get started. +
+ ); + } + + return ( +
+ {workflows.map((wf) => ( +
+
+
+
{wf.name}
+
+ {wf.filename} +
+
+ +
+ +
+ {wf.jobs.map((job) => { + const key = jobKey(wf.filename, job.id); + const isRunning = runningJobs.has(key); + const result = jobResults[key]; + + return ( +
+
+ {job.name} + +
+ +
+ {job.steps.map((step, idx) => { + const stepResult = result?.steps[idx]; + return ( +
+ {stepResult === undefined ? ( + + ) : stepResult.success ? ( + + ) : ( + + )} + {step.name} +
+ ); + })} +
+
+ ); + })} +
+
+ ))} +
+ ); +} diff --git a/src/components/ShowWorkspace.tsx b/src/components/ShowWorkspace.tsx index 43661d92..c2b01083 100644 --- a/src/components/ShowWorkspace.tsx +++ b/src/components/ShowWorkspace.tsx @@ -5,6 +5,59 @@ import { useQuery, useQueryClient, } from "@tanstack/react-query"; +import { + DirectoryEntry, + dryRunHomeRepoRebase, + type HomeRebaseDryRunResult, + type JjLogResult, + type SingleRebaseResult, + Workspace, + type WorkspaceBookmarkConflict, + checkAndRebaseWorkspaces, + createSession, + discardWorkspaceChanges, + getWorkspaceReadme, + getWorkspaceStatus, + listCommits, + lsWorkspace, + pullWorkspaceFromRemote, + pushWorkspaceToRemote, + rebaseHomeRepoBranch, + resolveBookmarkConflict, + updateWorkspace, +} from "../lib/api"; +import { getStatusBgColor } from "../lib/git-status-colors"; +import { type ParsedFileChange } from "../lib/git-utils"; +import { cn, getFullWorkspacePath, resolveReadmeImageSrc } from "../lib/utils"; + +import { + ChangesDiffViewer, + type ChangesDiffViewerHandle, +} from "./ChangesDiffViewer"; +import { FileBrowser } from "./FileBrowser"; +import { LinearCommitHistory } from "./LinearCommitHistory"; +import { CommitDiffViewer } from "./CommitDiffViewer"; +import { WorkspaceBookmarkConflictModal } from "./WorkspaceBookmarkConflictModal"; +import { WorkspaceStackPanel } from "./WorkspaceStackPanel"; +import { ChecksTab } from "./ChecksTab"; +import { Tabs, TabsList, TabsTrigger } from "./ui/tabs"; +import { Button } from "./ui/button"; +import { Kbd, KbdGroup } from "./ui/kbd"; +import { useToast } from "./ui/toast"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "./ui/tooltip"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; import { AlertTriangle, ArrowRight, @@ -26,6 +79,7 @@ import { Search, Trash2, Upload, + Workflow, } from "lucide-react"; import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { @@ -1001,6 +1055,13 @@ export const ShowWorkspace = memo( )} + + + Checks +
@@ -1283,6 +1344,12 @@ export const ShowWorkspace = memo( onViewTentativeChanges={handleViewTentativeChanges} onDeleteTentativeChanges={handleDeleteTentativeChanges} /> + ) : activeTab === "checks" ? ( + ) : ( => + invoke("list_workflows", { repoPath }); + +export const runWorkflowJob = ( + repoPath: string, + filename: string, + jobId: string, + workspaceId: number, + workspacePath: string, +): Promise => + invoke("run_workflow_job", { repoPath, filename, jobId, workspaceId, workspacePath }); + +export const runWorkflow = ( + repoPath: string, + filename: string, + workspaceId: number, + workspacePath: string, +): Promise => + invoke("run_workflow", { repoPath, filename, workspaceId, workspacePath }); diff --git a/test/integration/workspace/checks.test.tsx b/test/integration/workspace/checks.test.tsx new file mode 100644 index 00000000..1ce5ee1d --- /dev/null +++ b/test/integration/workspace/checks.test.tsx @@ -0,0 +1,162 @@ +import * as React from "react"; +import { beforeEach, describe, expect, it } from "vitest"; +import { + createTestRepo, + findSidebarBranchElement, + openRepo, + writeRepoFile, +} from "../../utils"; +import { createWorkspace, getWorkspaces } from "../../../src/lib/api"; +import { render, screen, waitFor } from "../../test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import userEvent from "@testing-library/user-event"; + +const PASSING_WORKFLOW = ` +name: Passing CI +on: + workflow_dispatch: {} +jobs: + greet: + name: Greet Job + steps: + - name: Say hello + run: echo hello + - name: Say world + run: echo world +`; + +const FAILING_WORKFLOW = ` +name: Failing CI +on: + workflow_dispatch: {} +jobs: + check: + name: Check Job + steps: + - name: Fail here + run: exit 1 + - name: Never runs + run: echo skipped +`; + +async function openChecksTab( + user: ReturnType, + branchName: string, +) { + render(); + await user.click(await findSidebarBranchElement(branchName)); + const checksTab = await screen.findByRole("tab", { name: /^Checks/ }); + await user.click(checksTab); + await screen.findByRole("tab", { name: /^Checks/, selected: true }); +} + +describe("Checks tab", () => { + it("shows empty state when no workflows exist", async () => { + const user = userEvent.setup(); + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + const workspaceId = await createWorkspace(repoPath, "checks-empty"); + const workspaces = await getWorkspaces(repoPath); + expect(workspaces.find((w) => w.id === workspaceId)).toBeDefined(); + + await openChecksTab(user, "checks-empty"); + + await screen.findByText(/No workflows found/i); + }); + + it("lists workflow after adding YAML file", async () => { + const user = userEvent.setup(); + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "checks-list"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", PASSING_WORKFLOW); + + await openChecksTab(user, "checks-list"); + + await screen.findByText("Passing CI"); + }); + + it("shows step names within a job", async () => { + const user = userEvent.setup(); + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "checks-steps"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", PASSING_WORKFLOW); + + await openChecksTab(user, "checks-steps"); + + await screen.findByText("Say hello"); + await screen.findByText("Say world"); + }); + + it("shows green checkmarks after a passing job run", async () => { + const user = userEvent.setup(); + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "checks-pass"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", PASSING_WORKFLOW); + + await openChecksTab(user, "checks-pass"); + await screen.findByText("Greet Job"); + + const runBtn = await screen.findByRole("button", { + name: /Run Greet Job/i, + }); + await user.click(runBtn); + + await waitFor(() => { + const icons = document.querySelectorAll('[data-testid="step-result-pass"]'); + expect(icons.length).toBeGreaterThan(0); + }); + }); + + it("shows red X and stops after first failing step", async () => { + const user = userEvent.setup(); + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "checks-fail"); + await writeRepoFile( + repoPath, + ".treq/workflows/ci.yaml", + FAILING_WORKFLOW, + ); + + await openChecksTab(user, "checks-fail"); + await screen.findByText("Check Job"); + + const runBtn = await screen.findByRole("button", { + name: /Run Check Job/i, + }); + await user.click(runBtn); + + await waitFor(() => { + const failIcons = document.querySelectorAll( + '[data-testid="step-result-fail"]', + ); + expect(failIcons.length).toBe(1); + }); + + expect( + document.querySelectorAll('[data-testid="step-result-pass"]').length, + ).toBe(0); + }); + + it("sorts multiple workflow files alphabetically", async () => { + const user = userEvent.setup(); + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "checks-sorted"); + await writeRepoFile(repoPath, ".treq/workflows/z-last.yaml", PASSING_WORKFLOW); + await writeRepoFile( + repoPath, + ".treq/workflows/a-first.yaml", + FAILING_WORKFLOW, + ); + + await openChecksTab(user, "checks-sorted"); + + const workflowNames = await screen.findAllByText(/CI$/); + expect(workflowNames[0].textContent).toBe("Failing CI"); + expect(workflowNames[1].textContent).toBe("Passing CI"); + }); +}); From 94315cdd4e3def76453c0ea8549463963a2fed0c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 19:38:59 +0000 Subject: [PATCH 02/11] Harden checks system: security, concurrency, and trust guard - Add explicit repository trust requirement before running any workflow job; untrusted repos see a UI banner with a "Trust Repository" button - Validate workflow filenames (reject path separators and '..') and working-directory values (reject absolute paths and parent traversal) to prevent path escape from .treq/workflows/ - Canonicalize file paths and confirm containment within the workflows directory before reading or executing any workflow file - Skip invalid YAML files when listing workflows instead of failing the entire listing call - Replace unbounded thread spawning in run_workflow with a bounded executor (MAX_CONCURRENT_JOBS = 4) using a channel semaphore pattern - Add per-step timeout (STEP_TIMEOUT_SECS = 60) with polling loop; kills the child process and returns a timeout error on expiry - Switch step execution from cmd.output() to spawn() + Stdio::null() for safe stdin handling in the NAPI/test context while enabling timeout - Add repo_trust table to local.db; add is_repo_trusted / trust_repo functions in local_db, Tauri commands, and NAPI dispatch - Update JS integration tests to pre-trust repos before running jobs - Update Rust integration tests to call trust_repo before execution tests - Add new unit tests: trust guard, path traversal rejection, invalid YAML skip, filename and working-directory validation --- crates/treq-napi/src/dispatch.rs | 12 + src-tauri/src/commands/checks.rs | 24 +- src-tauri/src/core/checks.rs | 260 +++++++++++++++++---- src-tauri/src/lib.rs | 2 + src-tauri/src/local_db.rs | 34 +++ src-tauri/tests/checks_test.rs | 4 + src/components/ChecksTab.tsx | 55 ++++- src/lib/api.ts | 6 + test/integration/workspace/checks.test.tsx | 4 +- 9 files changed, 353 insertions(+), 48 deletions(-) diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index b1dd2304..e8696002 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -966,6 +966,18 @@ pub fn dispatch(command: &str, args: Value) -> Result { serde_json::to_value(result).map_err(|e| e.to_string()) } + "is_repo_trusted" => { + let repo_path = get_str(&args, "repoPath")?; + let trusted = treq_lib::local_db::is_repo_trusted(&repo_path); + Ok(serde_json::Value::Bool(trusted)) + } + + "trust_repo" => { + let repo_path = get_str(&args, "repoPath")?; + treq_lib::local_db::trust_repo(&repo_path)?; + Ok(serde_json::Value::Null) + } + // ── Tauri-runtime-only: silent no-ops ───────────────────────────── "pty_create_session" | "pty_session_exists" diff --git a/src-tauri/src/commands/checks.rs b/src-tauri/src/commands/checks.rs index 7b905f76..7f32f3a3 100644 --- a/src-tauri/src/commands/checks.rs +++ b/src-tauri/src/commands/checks.rs @@ -16,7 +16,13 @@ pub async fn run_workflow_job( workspace_path: String, ) -> Result { tauri::async_runtime::spawn_blocking(move || { - crate::core::run_workflow_job_sync(&repo_path, &filename, &job_id, workspace_id, &workspace_path) + crate::core::run_workflow_job_sync( + &repo_path, + &filename, + &job_id, + workspace_id, + &workspace_path, + ) }) .await .map_err(|e| e.to_string())? @@ -35,3 +41,19 @@ pub async fn run_workflow( .await .map_err(|e| e.to_string())? } + +#[tauri::command] +pub async fn is_repo_trusted(repo_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + Ok(crate::local_db::is_repo_trusted(&repo_path)) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn trust_repo(repo_path: String) -> Result<(), String> { + tauri::async_runtime::spawn_blocking(move || crate::local_db::trust_repo(&repo_path)) + .await + .map_err(|e| e.to_string())? +} diff --git a/src-tauri/src/core/checks.rs b/src-tauri/src/core/checks.rs index d1d39da6..4039fce1 100644 --- a/src-tauri/src/core/checks.rs +++ b/src-tauri/src/core/checks.rs @@ -1,7 +1,11 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +const STEP_TIMEOUT_SECS: u64 = 60; +const MAX_CONCURRENT_JOBS: usize = 4; // ── Internal YAML structs ──────────────────────────────────────────────────── @@ -60,6 +64,40 @@ pub struct JobResult { pub success: bool, } +// ── Validation helpers ─────────────────────────────────────────────────────── + +fn validate_filename(filename: &str) -> Result<(), String> { + if filename.contains('/') || filename.contains('\\') || filename.contains("..") { + return Err(format!( + "Invalid workflow filename '{}': must not contain path separators or '..'", + filename + )); + } + if !filename.ends_with(".yaml") && !filename.ends_with(".yml") { + return Err(format!( + "Invalid workflow filename '{}': must have .yaml or .yml extension", + filename + )); + } + Ok(()) +} + +fn validate_working_directory(wd: &str) -> Result<(), String> { + if Path::new(wd).is_absolute() { + return Err(format!( + "working-directory must be a relative path, got: '{}'", + wd + )); + } + if wd.split('/').any(|c| c == "..") || wd.split('\\').any(|c| c == "..") { + return Err(format!( + "working-directory must not traverse parent directories: '{}'", + wd + )); + } + Ok(()) +} + // ── Public functions ───────────────────────────────────────────────────────── pub fn list_workflows_sync(repo_path: &str) -> Result, String> { @@ -80,13 +118,32 @@ pub fn list_workflows_sync(repo_path: &str) -> Result, String> entries.sort_by_key(|e| e.file_name()); + let canonical_dir = workflows_dir + .canonicalize() + .map_err(|e| format!("Failed to access workflows directory: {}", e))?; + let mut workflows = Vec::new(); for entry in entries { let filename = entry.file_name().to_string_lossy().to_string(); - let content = std::fs::read_to_string(entry.path()) - .map_err(|e| format!("Failed to read {}: {}", filename, e))?; - let wf: WorkflowFile = serde_yaml::from_str(&content) - .map_err(|e| format!("Failed to parse {}: {}", filename, e))?; + + // Verify each file stays within the workflows directory. + let canonical_file = match entry.path().canonicalize() { + Ok(p) => p, + Err(_) => continue, + }; + if !canonical_file.starts_with(&canonical_dir) { + continue; + } + + let content = match std::fs::read_to_string(entry.path()) { + Ok(c) => c, + Err(_) => continue, + }; + + let wf: WorkflowFile = match serde_yaml::from_str(&content) { + Ok(w) => w, + Err(_) => continue, // skip invalid YAML files + }; let mut jobs: Vec = wf .jobs @@ -112,12 +169,35 @@ pub fn run_workflow_job_sync( workspace_id: i64, workspace_path: &str, ) -> Result { + if !crate::local_db::is_repo_trusted(repo_path) { + return Err( + "repository_not_trusted: Trust this repository before running checks".to_string(), + ); + } + + validate_filename(filename)?; + let workflows_dir = Path::new(repo_path).join(".treq").join("workflows"); let file_path = workflows_dir.join(filename); + + // Verify file stays inside the workflows directory after path normalization. + let canonical_dir = workflows_dir + .canonicalize() + .map_err(|e| format!("Failed to access workflows directory: {}", e))?; + let canonical_file = file_path + .canonicalize() + .map_err(|_| format!("Workflow file not found: '{}'", filename))?; + if !canonical_file.starts_with(&canonical_dir) { + return Err(format!( + "Workflow file '{}' is outside the workflows directory", + filename + )); + } + let content = std::fs::read_to_string(&file_path) - .map_err(|e| format!("Failed to read {}: {}", filename, e))?; + .map_err(|e| format!("Failed to read '{}': {}", filename, e))?; let wf: WorkflowFile = serde_yaml::from_str(&content) - .map_err(|e| format!("Failed to parse {}: {}", filename, e))?; + .map_err(|e| format!("Failed to parse '{}': {}", filename, e))?; let job_def = wf .jobs @@ -137,6 +217,7 @@ pub fn run_workflow_job_sync( for step in &job_def.steps { let cwd = if let Some(wd) = &step.working_directory { + validate_working_directory(wd)?; Path::new(&base_dir).join(wd).to_string_lossy().to_string() } else { base_dir.clone() @@ -145,7 +226,10 @@ pub fn run_workflow_job_sync( let mut cmd = Command::new("sh"); cmd.args(["-c", &step.run]) .current_dir(&cwd) - .env("PATH", &extended_path); + .env("PATH", &extended_path) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()); if let Some(env_vars) = &step.env { for (k, v) in env_vars { @@ -153,9 +237,31 @@ pub fn run_workflow_job_sync( } } - let output = cmd.output().map_err(|e| format!("Failed to run step '{}': {}", step.name, e))?; - let success = output.status.success(); + let mut child = cmd + .spawn() + .map_err(|e| format!("Failed to start step '{}': {}", step.name, e))?; + + let timeout = Duration::from_secs(STEP_TIMEOUT_SECS); + let start = Instant::now(); + let exit_status = loop { + match child.try_wait() { + Ok(Some(status)) => break status, + Ok(None) => { + if start.elapsed() >= timeout { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!( + "Step '{}' timed out after {} seconds", + step.name, STEP_TIMEOUT_SECS + )); + } + std::thread::sleep(Duration::from_millis(100)); + } + Err(e) => return Err(format!("Failed waiting for step '{}': {}", step.name, e)), + } + }; + let success = exit_status.success(); step_results.push(StepResult { name: step.name.clone(), success }); if !success { @@ -188,29 +294,47 @@ pub fn run_workflow_sync( .ok_or_else(|| format!("Workflow '{}' not found", filename))?; let job_ids: Vec = wf.jobs.into_iter().map(|j| j.id).collect(); + let total = job_ids.len(); + + let rp = repo_path.to_string(); + let fn_ = filename.to_string(); + let wp = workspace_path.to_string(); + + let (result_tx, result_rx) = std::sync::mpsc::channel::>(); + let mut job_iter = job_ids.into_iter(); + let mut in_flight = 0usize; + let mut results = Vec::with_capacity(total); + + loop { + // Refill the slot pool up to MAX_CONCURRENT_JOBS. + while in_flight < MAX_CONCURRENT_JOBS { + match job_iter.next() { + Some(job_id) => { + let rp = rp.clone(); + let fn_ = fn_.clone(); + let wp = wp.clone(); + let tx = result_tx.clone(); + std::thread::spawn(move || { + let r = run_workflow_job_sync(&rp, &fn_, &job_id, workspace_id, &wp); + tx.send(r).ok(); + }); + in_flight += 1; + } + None => break, + } + } - let repo_path = repo_path.to_string(); - let filename = filename.to_string(); - let workspace_path = workspace_path.to_string(); - - let handles: Vec<_> = job_ids - .into_iter() - .map(|job_id| { - let repo_path = repo_path.clone(); - let filename = filename.clone(); - let workspace_path = workspace_path.clone(); - std::thread::spawn(move || { - run_workflow_job_sync(&repo_path, &filename, &job_id, workspace_id, &workspace_path) - }) - }) - .collect(); + if in_flight == 0 { + break; + } - let mut results = Vec::new(); - for handle in handles { - match handle.join() { - Ok(Ok(result)) => results.push(result), + match result_rx.recv() { + Ok(Ok(r)) => { + results.push(r); + in_flight -= 1; + } Ok(Err(e)) => return Err(e), - Err(_) => return Err("Job thread panicked".to_string()), + Err(_) => return Err("Job thread disconnected unexpectedly".to_string()), } } @@ -258,6 +382,13 @@ mod tests { dir.path().to_string_lossy().to_string() } + fn setup_trusted_repo(dir: &TempDir, filename: &str, content: &str) -> String { + let repo = write_workflow(dir, filename, content); + crate::local_db::init_local_db(&repo).unwrap(); + crate::local_db::trust_repo(&repo).unwrap(); + repo + } + #[test] fn test_list_workflows_empty_when_no_dir() { let dir = TempDir::new().unwrap(); @@ -287,6 +418,22 @@ mod tests { assert!(result.is_empty()); } + #[test] + fn test_list_workflows_skips_invalid_yaml() { + let dir = TempDir::new().unwrap(); + let workflows_dir = dir.path().join(".treq").join("workflows"); + fs::create_dir_all(&workflows_dir).unwrap(); + fs::write(workflows_dir.join("broken.yaml"), "this: is: not: valid: yaml: :::").unwrap(); + // valid workflow alongside the broken one + let content = make_workflow( + " j:\n steps:\n - name: s\n run: echo x\n", + ); + fs::write(workflows_dir.join("valid.yaml"), &content).unwrap(); + let result = list_workflows_sync(&dir.path().to_string_lossy()).unwrap(); + assert_eq!(result.len(), 1); + assert_eq!(result[0].filename, "valid.yaml"); + } + #[test] fn test_list_workflows_sorted_by_filename() { let dir = TempDir::new().unwrap(); @@ -301,13 +448,35 @@ mod tests { } #[test] - fn test_run_job_success() { + fn test_run_job_requires_trust() { let dir = TempDir::new().unwrap(); let content = make_workflow( " greet:\n steps:\n - name: Say hi\n run: echo hi\n", ); let repo = write_workflow(&dir, "ci.yaml", &content); - let _ = crate::local_db::init_local_db(&repo); + crate::local_db::init_local_db(&repo).unwrap(); + // NOT trusting the repo + let err = run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap_err(); + assert!(err.contains("repository_not_trusted")); + } + + #[test] + fn test_run_job_rejects_path_traversal_filename() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + crate::local_db::init_local_db(&repo).unwrap(); + crate::local_db::trust_repo(&repo).unwrap(); + let err = run_workflow_job_sync(&repo, "../secret.yaml", "job", 0, &repo).unwrap_err(); + assert!(err.contains("Invalid workflow filename")); + } + + #[test] + fn test_run_job_success() { + let dir = TempDir::new().unwrap(); + let content = make_workflow( + " greet:\n steps:\n - name: Say hi\n run: echo hi\n", + ); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); let result = run_workflow_job_sync(&repo, "ci.yaml", "greet", 0, &repo).unwrap(); assert!(result.success); assert!(!result.steps.is_empty()); @@ -319,8 +488,7 @@ mod tests { let content = make_workflow( " check:\n steps:\n - name: Fail\n run: exit 1\n - name: Skip\n run: echo skip\n", ); - let repo = write_workflow(&dir, "ci.yaml", &content); - let _ = crate::local_db::init_local_db(&repo); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); let result = run_workflow_job_sync(&repo, "ci.yaml", "check", 0, &repo).unwrap(); assert!(!result.success); assert_eq!(result.steps.len(), 1); @@ -332,7 +500,7 @@ mod tests { let content = make_workflow( " greet:\n steps:\n - name: hi\n run: echo hi\n", ); - let repo = write_workflow(&dir, "ci.yaml", &content); + let repo = setup_trusted_repo(&dir, "ci.yaml", &content); let err = run_workflow_job_sync(&repo, "ci.yaml", "nonexistent", 0, &repo).unwrap_err(); assert!(err.contains("nonexistent")); } @@ -341,8 +509,7 @@ mod tests { fn test_run_job_env_vars() { let dir = TempDir::new().unwrap(); let content = "name: Env Test\non:\n workflow_dispatch: {}\njobs:\n check:\n steps:\n - name: Check env\n run: test \"$MY_VAR\" = \"hello\"\n env:\n MY_VAR: hello\n"; - let repo = write_workflow(&dir, "env.yaml", content); - let _ = crate::local_db::init_local_db(&repo); + let repo = setup_trusted_repo(&dir, "env.yaml", content); let result = run_workflow_job_sync(&repo, "env.yaml", "check", 0, &repo).unwrap(); assert!(result.success); } @@ -351,10 +518,25 @@ mod tests { fn test_run_workflow_runs_all_jobs() { let dir = TempDir::new().unwrap(); let content = "name: Multi\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: s1\n run: echo a\n job2:\n steps:\n - name: s2\n run: echo b\n"; - let repo = write_workflow(&dir, "multi.yaml", content); - let _ = crate::local_db::init_local_db(&repo); + let repo = setup_trusted_repo(&dir, "multi.yaml", content); let results = run_workflow_sync(&repo, "multi.yaml", 0, &repo).unwrap(); assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.success)); } + + #[test] + fn test_validate_filename_rejects_path_separators() { + assert!(validate_filename("sub/dir/ci.yaml").is_err()); + assert!(validate_filename("../escape.yaml").is_err()); + assert!(validate_filename("ci.yaml").is_ok()); + assert!(validate_filename("ci.yml").is_ok()); + } + + #[test] + fn test_validate_working_directory_rejects_absolute_and_traversal() { + assert!(validate_working_directory("/absolute/path").is_err()); + assert!(validate_working_directory("../../etc").is_err()); + assert!(validate_working_directory("sub/dir").is_ok()); + assert!(validate_working_directory("frontend").is_ok()); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index af163d74..e0b5714c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -526,6 +526,8 @@ pub fn run() { commands::list_workflows, commands::run_workflow_job, commands::run_workflow, + commands::is_repo_trusted, + commands::trust_repo, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/local_db.rs b/src-tauri/src/local_db.rs index 14c4b5ba..a4829b2b 100644 --- a/src-tauri/src/local_db.rs +++ b/src-tauri/src/local_db.rs @@ -360,6 +360,15 @@ pub fn init_local_db(repo_path: &str) -> Result { ) .map_err(|e| format!("Failed to create workflow_runs table: {}", e))?; + conn.execute( + "CREATE TABLE IF NOT EXISTS repo_trust ( + id INTEGER PRIMARY KEY, + trusted_at TEXT NOT NULL + )", + [], + ) + .map_err(|e| format!("Failed to create repo_trust table: {}", e))?; + // Migration: rename pending_reviews columns from old schema to new schema. let has_old_columns: Result = conn.query_row( "SELECT COUNT(*) FROM pragma_table_info('pending_reviews') WHERE name IN ('comments_json', 'overall_comment', 'viewed_files_json')", @@ -2424,3 +2433,28 @@ pub fn get_latest_workflow_run( .optional() .map_err(|e| format!("Failed to query workflow_run: {}", e)) } + +pub fn is_repo_trusted(repo_path: &str) -> bool { + get_connection(repo_path) + .and_then(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM repo_trust", + [], + |row| row.get::<_, i64>(0), + ) + .map_err(|e| format!("Failed to query repo_trust: {}", e)) + }) + .map(|count| count > 0) + .unwrap_or(false) +} + +pub fn trust_repo(repo_path: &str) -> Result<(), String> { + let conn = get_connection(repo_path)?; + let trusted_at = Utc::now().to_rfc3339(); + conn.execute( + "INSERT OR IGNORE INTO repo_trust (id, trusted_at) VALUES (1, ?1)", + params![trusted_at], + ) + .map_err(|e| format!("Failed to trust repo: {}", e))?; + Ok(()) +} diff --git a/src-tauri/tests/checks_test.rs b/src-tauri/tests/checks_test.rs index 183be35e..106feab6 100644 --- a/src-tauri/tests/checks_test.rs +++ b/src-tauri/tests/checks_test.rs @@ -37,6 +37,7 @@ fn test_run_workflow_job_success() { let repo = TestRepo::new().expect("Failed to create test repo"); repo.write_workflow("ci.yaml", PASSING_WORKFLOW) .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); let result = core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "greet", 0, &repo.repo_path) .expect("Failed to run job"); assert!(result.success); @@ -48,6 +49,7 @@ fn test_run_workflow_job_stops_at_first_failure() { let repo = TestRepo::new().expect("Failed to create test repo"); repo.write_workflow("ci.yaml", FAILING_WORKFLOW) .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); let result = core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "check", 0, &repo.repo_path) .expect("Failed to run job"); assert!(!result.success); @@ -59,6 +61,7 @@ fn test_run_workflow_job_unknown_job_error() { let repo = TestRepo::new().expect("Failed to create test repo"); repo.write_workflow("ci.yaml", PASSING_WORKFLOW) .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); let err = core::run_workflow_job_sync(&repo.repo_path, "ci.yaml", "nonexistent", 0, &repo.repo_path) .unwrap_err(); assert!(err.contains("nonexistent")); @@ -70,6 +73,7 @@ fn test_run_workflow_runs_all_jobs() { let content = "name: Multi Job\non:\n workflow_dispatch: {}\njobs:\n job1:\n steps:\n - name: step1\n run: echo a\n job2:\n steps:\n - name: step2\n run: echo b\n"; repo.write_workflow("multi.yaml", content) .expect("Failed to write workflow"); + treq_lib::local_db::trust_repo(&repo.repo_path).expect("Failed to trust repo"); let results = core::run_workflow_sync(&repo.repo_path, "multi.yaml", 0, &repo.repo_path) .expect("Failed to run workflow"); assert_eq!(results.len(), 2); diff --git a/src/components/ChecksTab.tsx b/src/components/ChecksTab.tsx index b6cb3684..4a89a3fd 100644 --- a/src/components/ChecksTab.tsx +++ b/src/components/ChecksTab.tsx @@ -1,14 +1,21 @@ import { useState } from "react"; -import { useQuery } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import { CheckCircle2, CircleDot, Loader2, Play, + ShieldCheck, XCircle, } from "lucide-react"; import { Button } from "./ui/button"; -import { listWorkflows, runWorkflow, runWorkflowJob } from "../lib/api"; +import { + isRepoTrusted, + listWorkflows, + runWorkflow, + runWorkflowJob, + trustRepo, +} from "../lib/api"; import type { JobResult, WorkflowInfo } from "../lib/api-types"; interface Props { @@ -18,19 +25,30 @@ interface Props { } export function ChecksTab({ repoPath, workspaceId, workspacePath }: Props) { + const queryClient = useQueryClient(); const [runningJobs, setRunningJobs] = useState>(new Set()); const [runningWorkflows, setRunningWorkflows] = useState>( new Set(), ); const [jobResults, setJobResults] = useState>({}); - const { data: workflows = [], isLoading } = useQuery({ + const { data: isTrusted, isLoading: trustLoading } = useQuery({ + queryKey: ["repo-trusted", repoPath], + queryFn: () => isRepoTrusted(repoPath), + }); + + const { data: workflows = [], isLoading: workflowsLoading } = useQuery({ queryKey: ["workflows", repoPath], queryFn: () => listWorkflows(repoPath), }); const jobKey = (filename: string, jobId: string) => `${filename}:${jobId}`; + async function handleTrustRepo() { + await trustRepo(repoPath); + queryClient.invalidateQueries({ queryKey: ["repo-trusted", repoPath] }); + } + async function handleRunJob(wf: WorkflowInfo, jobId: string) { const key = jobKey(wf.filename, jobId); setRunningJobs((prev) => new Set(prev).add(key)); @@ -75,11 +93,11 @@ export function ChecksTab({ repoPath, workspaceId, workspacePath }: Props) { } } - if (isLoading) { + if (trustLoading || workflowsLoading) { return (
- Loading workflows… + Loading…
); } @@ -95,6 +113,25 @@ export function ChecksTab({ repoPath, workspaceId, workspacePath }: Props) { return (
+ {!isTrusted && ( +
+
+ + + Trust this repository to enable running workflow checks. + +
+ +
+ )} + {workflows.map((wf) => (
handleRunWorkflow(wf)} > {runningWorkflows.has(wf.filename) ? ( @@ -135,7 +172,11 @@ export function ChecksTab({ repoPath, workspaceId, workspacePath }: Props) { + workflow={wf} + repoPath={repoPath} + workspaceId={workspaceId} + isTrusted={!!isTrusted} + isRunningWorkflow={runningWorkflows.has(wf.filename)} + runningJobs={runningJobs} + jobResults={jobResults} + jobKey={jobKey} + onRunWorkflow={handleRunWorkflow} + onRunJob={handleRunJob} + onOpenLogs={setLogTarget} + /> + ))} +
+ ); +} + +interface CardProps { + workflow: WorkflowInfo; + repoPath: string; + workspaceId: number; + isTrusted: boolean; + isRunningWorkflow: boolean; + runningJobs: Set; + jobResults: Record; + jobKey: (filename: string, jobId: string) => string; + onRunWorkflow: (wf: WorkflowInfo) => void; + onRunJob: (wf: WorkflowInfo, jobId: string) => void; + onOpenLogs: (target: LogTarget) => void; +} + +function WorkflowCard({ + workflow: wf, + repoPath, + workspaceId, + isTrusted, + isRunningWorkflow, + runningJobs, + jobResults, + jobKey, + onRunWorkflow, + onRunJob, + onOpenLogs, +}: CardProps) { + const { data: runs = [] } = useQuery({ + queryKey: ["workflow-runs", repoPath, workspaceId, wf.filename], + queryFn: () => listWorkflowRuns(repoPath, workspaceId, wf.filename), + }); + + const [latestRun]: (RunSummary | undefined)[] = runs; + + return ( +
+
+
+
{wf.name}
+
+ {wf.filename}
+
+ +
-
- {wf.jobs.map((job) => { - const key = jobKey(wf.filename, job.id); - const isRunning = runningJobs.has(key); - const result = jobResults[key]; - - return ( -
-
- {job.name} +
+ {wf.jobs.map((job) => { + const key = jobKey(wf.filename, job.id); + const isRunning = runningJobs.has(key); + const result = jobResults[key]; + const runJob = latestRun?.jobs.find((j) => j.job_id === job.id); + + return ( +
+
+ {job.name} +
+ {latestRun && runJob?.has_logs && ( + )} + +
+
+ +
+ {job.steps.map((step, idx) => { + const stepResult = result?.steps[idx] ?? runJob?.steps[idx]; + const canOpenStepLogs = !!(latestRun && runJob?.has_logs); + return ( + -
- -
- {job.steps.map((step, idx) => { - const stepResult = result?.steps[idx]; - return ( -
- {stepResult === undefined ? ( - - ) : stepResult.success ? ( - - ) : ( - - )} - {step.name} -
- ); - })} -
+ {step.name} + + ); + })} +
+
+ ); + })} +
+ + {runs.length > 0 && ( +
+
+ Run history +
+
+ {runs.map((run) => ( +
+
+ + #{run.id} + + {formatRunTime(run.started_at)} + +
+
+ {run.jobs + .filter((j) => j.has_logs) + .map((j) => ( + + ))}
- ); - })} +
+ ))}
- ))} + )}
); } diff --git a/src/components/LogsBrowser.tsx b/src/components/LogsBrowser.tsx new file mode 100644 index 00000000..8d3df339 --- /dev/null +++ b/src/components/LogsBrowser.tsx @@ -0,0 +1,174 @@ +import { useMemo, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ArrowLeft, Download, Loader2 } from "lucide-react"; +import { Button } from "./ui/button"; +import { exportRunLogs, getRunLogs } from "../lib/api"; +import type { LogLine } from "../lib/api-types"; +import { cn } from "../lib/utils"; + +interface Props { + repoPath: string; + runId: number; + jobId: string; + /** Scopes the initial view to a single step when opened from a step row. */ + initialStepIndex?: number; + onBack: () => void; +} + +const LEVEL_FILTERS = ["all", "info", "warning", "error"] as const; +type LevelFilter = (typeof LEVEL_FILTERS)[number]; + +/** Info stays uncolored so warnings and errors are what draw the eye. */ +function levelClass(level: string): string { + if (level === "error") return "text-red-600 dark:text-red-400"; + if (level === "warning") return "text-amber-600 dark:text-amber-400"; + return "text-foreground"; +} + +function formatTimestamp(ts: string): string { + const parsed = new Date(ts); + if (Number.isNaN(parsed.getTime())) return ts; + return parsed.toISOString().slice(11, 23); +} + +export function LogsBrowser({ + repoPath, + runId, + jobId, + initialStepIndex, + onBack, +}: Props) { + const [level, setLevel] = useState("all"); + const [search, setSearch] = useState(""); + const [stepIndex, setStepIndex] = useState( + initialStepIndex, + ); + const [exportedTo, setExportedTo] = useState(null); + + const { data: lines = [], isLoading } = useQuery({ + queryKey: ["run-logs", repoPath, runId, jobId, level, search, stepIndex], + queryFn: () => + getRunLogs(repoPath, runId, jobId, { + level: level === "all" ? undefined : level, + search: search || undefined, + stepIndex, + }), + }); + + // Step names for the step filter, in first-seen order. + const steps = useMemo(() => { + const seen = new Map(); + for (const line of lines) { + if (!seen.has(line.step_index)) seen.set(line.step_index, line.step_name); + } + return [...seen.entries()].sort((a, b) => a[0] - b[0]); + }, [lines]); + + async function handleExport() { + const dest = `${repoPath}/.treq/runs/${runId}/${jobId}.log`; + const written = await exportRunLogs(repoPath, runId, jobId, dest); + setExportedTo(written); + } + + return ( +
+
+
+ +
+
{jobId}
+
Run #{runId}
+
+
+ +
+ +
+
+ {LEVEL_FILTERS.map((option) => ( + + ))} +
+ + {steps.length > 1 && ( + + )} + + setSearch(e.target.value)} + /> +
+ + {exportedTo && ( +
+ Exported to {exportedTo} +
+ )} + +
+ {isLoading ? ( +
+ + Loading logs… +
+ ) : lines.length === 0 ? ( +
+ No log lines match the current filters. +
+ ) : ( + lines.map((line: LogLine, idx: number) => ( +
+ + {formatTimestamp(line.ts)} + + + {line.message} + +
+ )) + )} +
+
+ ); +} diff --git a/src/lib/api-types.ts b/src/lib/api-types.ts index 2354c13f..0eaad30a 100644 --- a/src/lib/api-types.ts +++ b/src/lib/api-types.ts @@ -523,3 +523,26 @@ export interface JobResult { steps: StepResult[]; success: boolean; } + +export interface RunJobSummary { + job_id: string; + status: string; + steps: StepResult[]; + has_logs: boolean; +} +export interface RunSummary { + id: number; + filename: string; + status: string; + started_at: string; + completed_at: string | null; + jobs: RunJobSummary[]; +} +export interface LogLine { + ts: string; + step_index: number; + step_name: string; + stream: string; + level: string; + message: string; +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 6a03c4d4..c0b6e4fe 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -20,10 +20,12 @@ import type { JjRebaseResult, JjRevisionDiff, JobResult, + LogLine, MergeStrategy, PullWorkspaceResult, RepoBranch, RenameWorkspaceResult, + RunSummary, SingleRebaseResult, Workspace, WorkflowInfo, @@ -602,3 +604,47 @@ export const isRepoTrusted = (repoPath: string): Promise => export const trustRepo = (repoPath: string): Promise => invoke("trust_repo", { repoPath }); + +export const listWorkflowRuns = ( + repoPath: string, + workspaceId: number, + filename: string, + limit?: number, +): Promise => + invoke("list_workflow_runs", { + repoPath, + workspaceId, + filename, + limit: limit ?? null, + }); + +export const getRunLogs = ( + repoPath: string, + runId: number, + jobId: string, + options?: { + level?: string; + search?: string; + stepIndex?: number; + limit?: number; + offset?: number; + }, +): Promise => + invoke("get_run_logs", { + repoPath, + runId, + jobId, + level: options?.level ?? null, + search: options?.search ?? null, + stepIndex: options?.stepIndex ?? null, + limit: options?.limit ?? null, + offset: options?.offset ?? null, + }); + +export const exportRunLogs = ( + repoPath: string, + runId: number, + jobId: string, + destPath: string, +): Promise => + invoke("export_run_logs", { repoPath, runId, jobId, destPath }); diff --git a/test/integration/workspace/checks.test.tsx b/test/integration/workspace/checks.test.tsx index d7b3075b..b7bf95bd 100644 --- a/test/integration/workspace/checks.test.tsx +++ b/test/integration/workspace/checks.test.tsx @@ -11,7 +11,7 @@ import { getWorkspaces, trustRepo, } from "../../../src/lib/api"; -import { render, screen, waitFor } from "../../test-utils"; +import { render, screen, waitFor, within } from "../../test-utils"; import { Dashboard } from "../../../src/components/Dashboard"; import userEvent from "@testing-library/user-event"; @@ -165,3 +165,112 @@ describe("Checks tab", () => { expect(workflowNames[1].textContent).toBe("Passing CI"); }); }); + +const LOGGING_WORKFLOW = ` +name: Logging CI +on: + workflow_dispatch: {} +jobs: + build: + name: Build Job + steps: + - name: Emit output + run: "echo hello-from-logs; echo 'warning: careful'; echo 'error: broke' 1>&2" +`; + +describe("Checks logs browser", () => { + beforeEach(() => { + user = userEvent.setup(); + }); + + async function runBuildJob(repoPath: string, branch: string) { + await createWorkspace(repoPath, branch); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", LOGGING_WORKFLOW); + await trustRepo(repoPath); + await openChecksTab(branch); + await user.click( + await screen.findByRole("button", { name: /Run Build Job/i }), + ); + await screen.findByTestId("run-history-item"); + } + + it("records a run in the history after running a job", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await runBuildJob(repoPath, "logs-history"); + + const items = await screen.findAllByTestId("run-history-item"); + expect(items).toHaveLength(1); + }); + + it("adds a second history item when the job is re-run", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await runBuildJob(repoPath, "logs-rerun"); + + await user.click( + await screen.findByRole("button", { name: /Run Build Job/i }), + ); + await waitFor(async () => { + const items = await screen.findAllByTestId("run-history-item"); + expect(items).toHaveLength(2); + }); + }); + + it("opens the logs browser and shows captured output with levels", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await runBuildJob(repoPath, "logs-view"); + + await user.click( + (await screen.findAllByRole("button", { name: /^Logs/ }))[0], + ); + + await screen.findByTestId("logs-output"); + await screen.findByText("hello-from-logs"); + await waitFor(() => { + const errorLines = document.querySelectorAll( + '[data-testid="log-line"][data-level="error"]', + ); + expect(errorLines.length).toBeGreaterThan(0); + }); + }); + + it("filters log lines by level", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await runBuildJob(repoPath, "logs-filter"); + + await user.click( + (await screen.findAllByRole("button", { name: /^Logs/ }))[0], + ); + await screen.findByText("hello-from-logs"); + + await user.click(await screen.findByRole("button", { name: "error" })); + + await waitFor(() => { + const lines = document.querySelectorAll('[data-testid="log-line"]'); + expect(lines.length).toBe(1); + }); + expect(screen.queryByText("hello-from-logs")).toBeNull(); + }); + + it("exports logs to a file", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await runBuildJob(repoPath, "logs-export"); + + await user.click( + (await screen.findAllByRole("button", { name: /^Logs/ }))[0], + ); + await screen.findByText("hello-from-logs"); + + const browser = await screen.findByTestId("logs-browser"); + await user.click( + await within(browser).findByRole("button", { name: /Export/i }), + ); + + const note = await screen.findByText(/Exported to/i); + expect(note.textContent).toContain(".log"); + }); +}); From 01c03fa8ba63b1eadf842436cca483f296f88960 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 19:39:00 +0000 Subject: [PATCH 05/11] feat: add repo-wide Logs tab with SQL explorer, multi-select level filter Level filter - LogQuery.level becomes levels: Vec, applied as a SQL IN clause; an empty or absent list means no filtering - New LogLevelFilter multi-select built on DropdownMenuCheckboxItem, shared by the run logs browser and the new Logs tab; the menu stays open on select so several levels can be ticked in one pass Repo-wide data source - A DuckDB `logs` view spans .treq/runs/**/*.jsonl, recovering run_id and job_id from each file's path so lines can be grouped across runs - get_repo_logs browses every run's lines with level and search filters SQL explorer - run_logs_sql executes ad-hoc queries against the `logs` view and returns columns plus stringified rows - Only single read-only statements are accepted (SELECT, WITH, DESCRIBE, SHOW, EXPLAIN, SUMMARIZE); writes, multiple statements, and ATTACH/COPY/INSTALL style keywords are rejected before reaching DuckDB - Results are capped by wrapping the user's query in an outer LIMIT UI - LogsTab is offered on the home repo only, with Browse and SQL Explorer views - Browse renders timestamp, run id, job id and message per line, reusing the logs browser's level colouring - LogsSqlExplorer provides a query editor, starter templates, a result grid with NULL rendering, and inline error reporting Tests - 35 Rust unit tests covering multi-level filtering, the cross-run view and SQL validation - 7 new JS integration tests for the Logs tab, level filter and explorer - New logs-tab-sql-explorer screenshot spec; existing specs updated for the multi-select --- crates/treq-napi/src/dispatch.rs | 31 +- .../specs/checks-logs-browser.spec.tsx | 10 +- .../specs/logs-tab-sql-explorer.spec.tsx | 114 +++++ src-tauri/src/commands/checks.rs | 41 +- src-tauri/src/core/checks_logs.rs | 396 +++++++++++++++++- src-tauri/src/lib.rs | 2 + src/components/LogLevelFilter.tsx | 66 +++ src/components/LogsBrowser.tsx | 23 +- src/components/LogsSqlExplorer.tsx | 162 +++++++ src/components/LogsTab.tsx | 126 ++++++ src/components/ShowWorkspace.tsx | 13 + src/lib/api-types.ts | 16 + src/lib/api.ts | 30 +- test/integration/workspace/checks.test.tsx | 30 +- test/integration/workspace/logs-tab.test.tsx | 169 ++++++++ 15 files changed, 1181 insertions(+), 48 deletions(-) create mode 100644 scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx create mode 100644 src/components/LogLevelFilter.tsx create mode 100644 src/components/LogsSqlExplorer.tsx create mode 100644 src/components/LogsTab.tsx create mode 100644 test/integration/workspace/logs-tab.test.tsx diff --git a/crates/treq-napi/src/dispatch.rs b/crates/treq-napi/src/dispatch.rs index 266e00e3..b5f92cc7 100644 --- a/crates/treq-napi/src/dispatch.rs +++ b/crates/treq-napi/src/dispatch.rs @@ -997,7 +997,7 @@ pub fn dispatch(command: &str, args: Value) -> Result { let run_id = get_i64(&args, "runId")?; let job_id = get_str(&args, "jobId")?; let query = treq_lib::core::checks_logs::LogQuery { - level: opt_str(&args, "level"), + levels: opt_str_vec(&args, "levels"), search: opt_str(&args, "search"), step_index: opt_i64(&args, "stepIndex"), limit: opt_i64(&args, "limit"), @@ -1007,6 +1007,27 @@ pub fn dispatch(command: &str, args: Value) -> Result { serde_json::to_value(result).map_err(|e| e.to_string()) } + "get_repo_logs" => { + let repo_path = get_str(&args, "repoPath")?; + let query = treq_lib::core::checks_logs::LogQuery { + levels: opt_str_vec(&args, "levels"), + search: opt_str(&args, "search"), + step_index: None, + limit: opt_i64(&args, "limit"), + offset: opt_i64(&args, "offset"), + }; + let result = treq_lib::core::checks_logs::query_repo_logs(&repo_path, &query)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + + "run_logs_sql" => { + let repo_path = get_str(&args, "repoPath")?; + let sql = get_str(&args, "sql")?; + let max_rows = opt_i64(&args, "maxRows").unwrap_or(500); + let result = treq_lib::core::checks_logs::run_logs_sql(&repo_path, &sql, max_rows)?; + serde_json::to_value(result).map_err(|e| e.to_string()) + } + "export_run_logs" => { let repo_path = get_str(&args, "repoPath")?; let run_id = get_i64(&args, "runId")?; @@ -1201,6 +1222,14 @@ fn opt_str(args: &Value, key: &str) -> Option { args.get(key).and_then(|v| v.as_str()).map(String::from) } +fn opt_str_vec(args: &Value, key: &str) -> Option> { + args.get(key).and_then(|v| v.as_array()).map(|arr| { + arr.iter() + .filter_map(|v| v.as_str().map(String::from)) + .collect() + }) +} + fn opt_str_to_value(s: Option) -> Value { match s { Some(v) => Value::String(v), diff --git a/scripts/screenshot/specs/checks-logs-browser.spec.tsx b/scripts/screenshot/specs/checks-logs-browser.spec.tsx index 7c36b039..bdd8e8e1 100644 --- a/scripts/screenshot/specs/checks-logs-browser.spec.tsx +++ b/scripts/screenshot/specs/checks-logs-browser.spec.tsx @@ -77,12 +77,14 @@ it("captures the checks run history and the logs browser", async () => { "Every log line is rendered in a monospace font with a timestamp in a left-hand column.", 'The line "warning: unused variable x" is amber/yellow and "error: assertion failed" is red.', 'Plain lines such as "Compiling treq v0.1.3" and "running 2 tests" are rendered in the default text colour, not amber or red.', - 'A filter row shows "all", "info", "warning" and "error" buttons plus a step dropdown and a search box.', + 'A filter row shows a level multi-select button reading "All levels" plus a step dropdown and a search box.', ], }); - // Filter down to error lines only. - await user.click(within(browser).getByRole("button", { name: "error" })); + // Filter down to error lines only via the level multi-select. + await user.click(within(browser).getByTestId("log-level-filter")); + await user.click(await screen.findByRole("menuitemcheckbox", { name: /error/i })); + await user.keyboard("{Escape}"); await waitFor(() => { const lines = document.querySelectorAll('[data-testid="log-line"]'); if (lines.length !== 1) throw new Error(`expected 1 line, got ${lines.length}`); @@ -92,7 +94,7 @@ it("captures the checks run history and the logs browser", async () => { name: "checks-logs-03-error-filter", expectations: [ 'Exactly one log line is shown — the red "error: assertion failed" line.', - 'The "error" filter button is visually selected/highlighted compared to the other filter buttons.', + 'The level filter button reads "error" rather than "All levels".', 'The info and warning lines from the previous screenshot are gone.', ], }); diff --git a/scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx b/scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx new file mode 100644 index 00000000..9c0e5605 --- /dev/null +++ b/scripts/screenshot/specs/logs-tab-sql-explorer.spec.tsx @@ -0,0 +1,114 @@ +import * as React from "react"; +import { it } from "vitest"; +import userEvent from "@testing-library/user-event"; +import { createTestRepo, openRepo, writeRepoFile } from "../../../test/utils"; +import { render, screen, waitFor, within } from "../../../test/test-utils"; +import { Dashboard } from "../../../src/components/Dashboard"; +import { createWorkspace, trustRepo } from "../../../src/lib/api"; +import { captureDocument } from "../capture"; + +const LOGGING_WORKFLOW = ` +name: Pull request checks +on: + workflow_dispatch: {} +jobs: + build: + name: Build Job + steps: + - name: Compile + run: "echo 'Compiling treq v0.1.3'; echo 'warning: unused variable x'" + - name: Test + run: "echo 'running 2 tests'; echo 'error: assertion failed' 1>&2" +`; + +it("captures the home repo Logs tab, level multi-select and SQL explorer", async () => { + const { repoPath } = createTestRepo(false); + openRepo(repoPath); + await createWorkspace(repoPath, "feat/logs"); + await writeRepoFile(repoPath, ".treq/workflows/ci.yaml", LOGGING_WORKFLOW); + await trustRepo(repoPath); + + const user = userEvent.setup(); + render(); + + // Seed log rows by running the check inside the workspace. + const sidebar = document.querySelector( + `.${CSS.escape("group/sidebar")}`, + ) as HTMLElement; + await waitFor(() => { + if (within(sidebar).queryAllByText("feat/logs").length === 0) { + throw new Error("workspace not in sidebar yet"); + } + }); + await user.click(within(sidebar).getAllByText("feat/logs")[0]); + await user.click(await screen.findByRole("tab", { name: /^Checks/ })); + await user.click(await screen.findByRole("button", { name: /Run Build Job/i })); + await screen.findByTestId("run-history-item"); + + // Back to the home repo, where the Logs tab lives. + await user.click(within(sidebar).getAllByText(/^branch-/)[0]); + await user.click(await screen.findByRole("tab", { name: /^Logs/ })); + await screen.findByRole("tab", { name: /^Logs/, selected: true }); + await screen.findByText("Compiling treq v0.1.3"); + + await captureDocument(document, { + name: "logs-tab-01-browse", + expectations: [ + 'A "Checks logs" data-source header is visible with the path ".treq/runs/**/*.jsonl" underneath it.', + 'The header has "Browse" and "SQL Explorer" toggle buttons, with "Browse" currently selected.', + 'Log lines are listed in a monospace font, each with a timestamp, a "#" column, a blue "build" job-id column, and the message.', + 'The warning line is amber and the error line is red; plain lines are the default colour.', + 'A level filter button reading "All levels" sits above the log lines next to a search box.', + ], + }); + + // Open the multi-select and tick two levels. + // Radix positions its popper from measured element boxes, which jsdom always + // reports as 0x0, so the menu rasterises at the top-left corner here. Its + // placement is a harness artifact and is not something these shots can check. + await user.click(await screen.findByTestId("log-level-filter")); + await screen.findByRole("menuitemcheckbox", { name: /warning/i }); + + await captureDocument(document, { + name: "logs-tab-02-level-multiselect", + expectations: [ + 'An open dropdown lists "info", "warning" and "error" as checkbox items.', + "None of the checkboxes are ticked yet, matching the \"All levels\" button label.", + "Ignore where the dropdown sits on the page: jsdom reports zero-sized boxes so Radix cannot anchor it to the trigger in this harness.", + ], + }); + + await user.click(await screen.findByRole("menuitemcheckbox", { name: /warning/i })); + await user.click(await screen.findByRole("menuitemcheckbox", { name: /error/i })); + await waitFor(() => { + const lines = document.querySelectorAll('[data-testid="repo-log-line"]'); + if (lines.length !== 2) throw new Error(`expected 2 lines, got ${lines.length}`); + }); + await user.keyboard("{Escape}"); + + await captureDocument(document, { + name: "logs-tab-03-two-levels-selected", + expectations: [ + 'The level filter button now reads "2 levels" instead of "All levels".', + "Exactly two log lines remain: the amber warning line and the red error line.", + 'The plain info lines ("Compiling treq v0.1.3", "running 2 tests") are gone.', + ], + }); + + // Switch to the SQL explorer and run an aggregate query. + await user.click(await screen.findByRole("button", { name: /SQL Explorer/i })); + const explorer = await screen.findByTestId("logs-sql-explorer"); + await user.click(await screen.findByRole("button", { name: /Errors by job/i })); + await user.click(within(explorer).getByRole("button", { name: /Run query/i })); + await screen.findByTestId("sql-results"); + + await captureDocument(document, { + name: "logs-tab-04-sql-explorer", + expectations: [ + "A SQL editor shows a multi-line GROUP BY query against the logs view, in a monospace font.", + 'A row of template buttons ("Recent lines", "Errors by job", "Lines per run") sits above the editor.', + 'A result grid is rendered below with column headers "job_id" and "errors", and a row containing "build".', + 'A row count line such as "1 row" appears above the grid.', + ], + }); +}, 90000); diff --git a/src-tauri/src/commands/checks.rs b/src-tauri/src/commands/checks.rs index d4f06014..14b520b6 100644 --- a/src-tauri/src/commands/checks.rs +++ b/src-tauri/src/commands/checks.rs @@ -1,4 +1,4 @@ -use crate::core::checks_logs::{LogLine, LogQuery}; +use crate::core::checks_logs::{LogLine, LogQuery, RepoLogLine, SqlResult}; use crate::core::{JobResult, RunSummary, WorkflowInfo}; #[tauri::command] @@ -81,7 +81,7 @@ pub async fn get_run_logs( repo_path: String, run_id: i64, job_id: String, - level: Option, + levels: Option>, search: Option, step_index: Option, limit: Option, @@ -89,7 +89,7 @@ pub async fn get_run_logs( ) -> Result, String> { tauri::async_runtime::spawn_blocking(move || { let query = LogQuery { - level, + levels, search, step_index, limit, @@ -101,6 +101,41 @@ pub async fn get_run_logs( .map_err(|e| e.to_string())? } +#[tauri::command] +pub async fn get_repo_logs( + repo_path: String, + levels: Option>, + search: Option, + limit: Option, + offset: Option, +) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || { + let query = LogQuery { + levels, + search, + step_index: None, + limit, + offset, + }; + crate::core::checks_logs::query_repo_logs(&repo_path, &query) + }) + .await + .map_err(|e| e.to_string())? +} + +#[tauri::command] +pub async fn run_logs_sql( + repo_path: String, + sql: String, + max_rows: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + crate::core::checks_logs::run_logs_sql(&repo_path, &sql, max_rows.unwrap_or(500)) + }) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn export_run_logs( repo_path: String, diff --git a/src-tauri/src/core/checks_logs.rs b/src-tauri/src/core/checks_logs.rs index 6e9d657e..fbf90aae 100644 --- a/src-tauri/src/core/checks_logs.rs +++ b/src-tauri/src/core/checks_logs.rs @@ -24,15 +24,40 @@ pub struct LogLine { } /// Filters accepted by the logs browser. +/// +/// `levels` is a set: an empty or absent list means "no level filter", matching +/// the multi-select showing nothing ticked. #[derive(Debug, Deserialize, Default, Clone)] pub struct LogQuery { - pub level: Option, + pub levels: Option>, pub search: Option, pub step_index: Option, pub limit: Option, pub offset: Option, } +/// A run/job-tagged log line, for browsing across the whole repo. +#[derive(Debug, Serialize, Deserialize, Clone, PartialEq)] +pub struct RepoLogLine { + pub run_id: i64, + pub job_id: String, + pub ts: String, + pub step_index: i64, + pub step_name: String, + pub stream: String, + pub level: String, + pub message: String, +} + +/// Result of an ad-hoc SQL query in the explorer. +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct SqlResult { + pub columns: Vec, + /// Every cell rendered as text so any column type survives the boundary. + pub rows: Vec>>, + pub row_count: usize, +} + const DEFAULT_LIMIT: i64 = 2000; // ── Paths ──────────────────────────────────────────────────────────────────── @@ -166,6 +191,35 @@ fn sql_quote(value: &str) -> String { value.replace('\'', "''") } +/// Shared WHERE builder for the single-job and cross-run readers. +fn build_where_clause(query: &LogQuery) -> String { + let mut conditions: Vec = Vec::new(); + + if let Some(levels) = query.levels.as_ref().filter(|l| !l.is_empty()) { + let list = levels + .iter() + .map(|l| format!("'{}'", sql_quote(l))) + .collect::>() + .join(", "); + conditions.push(format!("level IN ({})", list)); + } + if let Some(search) = query.search.as_ref().filter(|s| !s.is_empty()) { + conditions.push(format!( + "lower(message) LIKE '%{}%'", + sql_quote(&search.to_lowercase()) + )); + } + if let Some(step_index) = query.step_index { + conditions.push(format!("step_index = {}", step_index)); + } + + if conditions.is_empty() { + String::new() + } else { + format!("WHERE {}", conditions.join(" AND ")) + } +} + /// Read a job's log file, applying the browser's filters. /// /// Returns an empty vec when the file is missing or empty — a job that produced @@ -181,24 +235,7 @@ pub fn query_logs(absolute_log_path: &str, query: &LogQuery) -> Result = Vec::new(); - if let Some(level) = query.level.as_ref().filter(|l| !l.is_empty()) { - conditions.push(format!("level = '{}'", sql_quote(level))); - } - if let Some(search) = query.search.as_ref().filter(|s| !s.is_empty()) { - conditions.push(format!( - "lower(message) LIKE '%{}%'", - sql_quote(&search.to_lowercase()) - )); - } - if let Some(step_index) = query.step_index { - conditions.push(format!("step_index = {}", step_index)); - } - let where_clause = if conditions.is_empty() { - String::new() - } else { - format!("WHERE {}", conditions.join(" AND ")) - }; + let where_clause = build_where_clause(query); let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 100_000); let offset = query.offset.unwrap_or(0).max(0); @@ -241,6 +278,219 @@ pub fn query_logs(absolute_log_path: &str, query: &LogQuery) -> Result String { + Path::new(repo_path) + .join(".treq") + .join("runs") + .join("*") + .join("*.jsonl") + .to_string_lossy() + .to_string() +} + +/// True when at least one log file exists, so callers can skip DuckDB entirely. +fn has_any_logs(repo_path: &str) -> bool { + let runs_dir = Path::new(repo_path).join(".treq").join("runs"); + let Ok(entries) = std::fs::read_dir(&runs_dir) else { + return false; + }; + entries.filter_map(|e| e.ok()).any(|run_dir| { + std::fs::read_dir(run_dir.path()) + .map(|mut files| { + files.any(|f| { + f.ok() + .map(|f| f.file_name().to_string_lossy().ends_with(".jsonl")) + .unwrap_or(false) + }) + }) + .unwrap_or(false) + }) +} + +/// SQL defining the `logs` view: every run's JSONL, with `run_id` and `job_id` +/// recovered from the file path so cross-run queries can group by them. +fn logs_view_sql(repo_path: &str) -> String { + format!( + "CREATE OR REPLACE VIEW logs AS + SELECT + CAST(regexp_extract(filename, 'runs/([0-9]+)/', 1) AS BIGINT) AS run_id, + regexp_extract(filename, '/([^/]+)\\.jsonl$', 1) AS job_id, + CAST(ts AS VARCHAR) AS ts, + CAST(step_index AS BIGINT) AS step_index, + CAST(step_name AS VARCHAR) AS step_name, + CAST(stream AS VARCHAR) AS stream, + CAST(level AS VARCHAR) AS level, + CAST(message AS VARCHAR) AS message + FROM read_json_auto('{}', format='newline_delimited', filename=true)", + sql_quote(&runs_glob(repo_path)) + ) +} + +/// Open an in-memory DuckDB with the `logs` view registered. +fn connect_with_logs_view(repo_path: &str) -> Result { + let conn = duckdb::Connection::open_in_memory() + .map_err(|e| format!("Failed to open DuckDB connection: {}", e))?; + conn.execute_batch(&logs_view_sql(repo_path)) + .map_err(|e| format!("Failed to register logs view: {}", e))?; + Ok(conn) +} + +/// Browse log lines across every run in the repo. +pub fn query_repo_logs(repo_path: &str, query: &LogQuery) -> Result, String> { + if !has_any_logs(repo_path) { + return Ok(vec![]); + } + let conn = connect_with_logs_view(repo_path)?; + + let limit = query.limit.unwrap_or(DEFAULT_LIMIT).clamp(1, 100_000); + let offset = query.offset.unwrap_or(0).max(0); + let sql = format!( + "SELECT run_id, job_id, ts, step_index, step_name, stream, level, message + FROM logs {} ORDER BY run_id DESC, ts, step_index LIMIT {} OFFSET {}", + build_where_clause(query), + limit, + offset + ); + + let mut stmt = conn + .prepare(&sql) + .map_err(|e| format!("Failed to prepare repo log query: {}", e))?; + let rows = stmt + .query_map([], |row| { + Ok(RepoLogLine { + run_id: row.get(0)?, + job_id: row.get(1)?, + ts: row.get(2)?, + step_index: row.get(3)?, + step_name: row.get(4)?, + stream: row.get(5)?, + level: row.get(6)?, + message: row.get(7)?, + }) + }) + .map_err(|e| format!("Failed to query repo logs: {}", e))?; + + rows.collect::>>() + .map_err(|e| format!("Failed to read repo log rows: {}", e)) +} + +// ── SQL explorer ───────────────────────────────────────────────────────────── + +/// Statement kinds the explorer will run. Everything else is rejected so a +/// stray COPY/ATTACH/INSTALL can't write files or pull in extensions. +const ALLOWED_SQL_PREFIXES: [&str; 6] = + ["select", "with", "describe", "show", "explain", "summarize"]; + +/// Reject anything that isn't a single read-only statement. +fn validate_sql(sql: &str) -> Result<(), String> { + let trimmed = sql.trim().trim_end_matches(';').trim(); + if trimmed.is_empty() { + return Err("Query is empty".to_string()); + } + // One statement only: a second one could smuggle in a write. + if trimmed.contains(';') { + return Err("Only a single statement can be run at a time".to_string()); + } + + let lower = trimmed.to_lowercase(); + if !ALLOWED_SQL_PREFIXES + .iter() + .any(|p| lower.starts_with(p) && lower[p.len()..].starts_with(char::is_whitespace)) + { + return Err( + "Only read-only queries are allowed (SELECT, WITH, DESCRIBE, SHOW, EXPLAIN, SUMMARIZE)" + .to_string(), + ); + } + + // These can still appear mid-statement, e.g. inside a CTE body. + let blocked = [ + "attach ", "copy ", "install ", "load ", "export ", "import ", "create ", "insert ", + "update ", "delete ", "drop ", "alter ", + ]; + if let Some(word) = blocked.iter().find(|w| lower.contains(*w)) { + return Err(format!( + "Statement contains a disallowed keyword: {}", + word.trim() + )); + } + Ok(()) +} + +/// Run an ad-hoc read-only query against the `logs` view. +pub fn run_logs_sql(repo_path: &str, sql: &str, max_rows: i64) -> Result { + validate_sql(sql)?; + + if !has_any_logs(repo_path) { + return Err( + "No check logs recorded yet — run a workflow check to populate the logs table." + .to_string(), + ); + } + + let conn = connect_with_logs_view(repo_path)?; + let capped = format!( + "SELECT * FROM ({}) AS explorer_query LIMIT {}", + sql.trim().trim_end_matches(';'), + max_rows.clamp(1, 10_000) + ); + + let mut stmt = conn + .prepare(&capped) + .map_err(|e| format!("Query error: {}", e))?; + let mut rows = stmt.query([]).map_err(|e| format!("Query error: {}", e))?; + + let mut columns: Vec = Vec::new(); + let mut out_rows: Vec>> = Vec::new(); + + while let Some(row) = rows.next().map_err(|e| format!("Query error: {}", e))? { + if columns.is_empty() { + columns = row + .as_ref() + .column_names() + .into_iter() + .map(String::from) + .collect(); + } + let mut cells = Vec::with_capacity(columns.len()); + for idx in 0..columns.len() { + // Everything is stringified; the grid renders text regardless of type. + let value: Option = row + .get::<_, Option>(idx) + .or_else(|_| { + row.get::<_, Option>(idx) + .map(|v| v.map(|n| n.to_string())) + }) + .or_else(|_| { + row.get::<_, Option>(idx) + .map(|v| v.map(|n| n.to_string())) + }) + .or_else(|_| { + row.get::<_, Option>(idx) + .map(|v| v.map(|b| b.to_string())) + }) + .unwrap_or(None); + cells.push(value); + } + out_rows.push(cells); + } + + // A zero-row result still needs its header, which the loop above never saw. + if columns.is_empty() { + columns = stmt.column_names().into_iter().map(String::from).collect(); + } + + let row_count = out_rows.len(); + Ok(SqlResult { + columns, + rows: out_rows, + row_count, + }) +} + /// Render a job's logs as a plain text file suitable for sharing. pub fn export_logs(absolute_log_path: &str, dest_path: &str) -> Result { let lines = query_logs( @@ -347,7 +597,7 @@ mod tests { let result = query_logs( &path, &LogQuery { - level: Some("error".to_string()), + levels: Some(vec!["error".to_string()]), ..Default::default() }, ) @@ -387,6 +637,112 @@ mod tests { assert_eq!(by_step.len(), 1); } + #[test] + fn test_query_logs_filters_by_multiple_levels() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "warning", "careful"), + sample_line(2, "error", "boom"), + ], + ); + let result = query_logs( + &path, + &LogQuery { + levels: Some(vec!["warning".to_string(), "error".to_string()]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result.len(), 2); + assert!(result.iter().all(|l| l.level != "info")); + } + + #[test] + fn test_empty_levels_list_does_not_filter() { + let dir = TempDir::new().unwrap(); + let path = write_log( + &dir, + &[ + sample_line(0, "info", "hello"), + sample_line(1, "error", "boom"), + ], + ); + let result = query_logs( + &path, + &LogQuery { + levels: Some(vec![]), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(result.len(), 2); + } + + #[test] + fn test_query_repo_logs_spans_runs_with_ids() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + for run_id in [1i64, 2i64] { + let writer = LogWriter::create(&repo, run_id, "build").unwrap(); + writer + .write_line(&sample_line(0, "info", &format!("run {}", run_id))) + .unwrap(); + writer.flush().unwrap(); + } + + let result = query_repo_logs(&repo, &LogQuery::default()).unwrap(); + assert_eq!(result.len(), 2); + // Newest run first, and run/job identity is recovered from the path. + assert_eq!(result[0].run_id, 2); + assert_eq!(result[0].job_id, "build"); + } + + #[test] + fn test_query_repo_logs_empty_without_runs() { + let dir = TempDir::new().unwrap(); + let result = query_repo_logs(&dir.path().to_string_lossy(), &LogQuery::default()).unwrap(); + assert!(result.is_empty()); + } + + #[test] + fn test_validate_sql_rejects_writes_and_multiple_statements() { + assert!(validate_sql("SELECT * FROM logs").is_ok()); + assert!(validate_sql("WITH x AS (SELECT 1) SELECT * FROM x").is_ok()); + assert!(validate_sql("DROP TABLE logs").is_err()); + assert!(validate_sql("SELECT 1; DROP TABLE logs").is_err()); + assert!(validate_sql("COPY logs TO '/tmp/out.csv'").is_err()); + assert!(validate_sql("").is_err()); + } + + #[test] + fn test_run_logs_sql_returns_columns_and_rows() { + let dir = TempDir::new().unwrap(); + let repo = dir.path().to_string_lossy().to_string(); + let writer = LogWriter::create(&repo, 1, "build").unwrap(); + writer.write_line(&sample_line(0, "error", "boom")).unwrap(); + writer.write_line(&sample_line(1, "info", "fine")).unwrap(); + writer.flush().unwrap(); + + let result = run_logs_sql( + &repo, + "SELECT level, count(*) AS n FROM logs GROUP BY level ORDER BY level", + 100, + ) + .unwrap(); + assert_eq!(result.columns, vec!["level", "n"]); + assert_eq!(result.row_count, 2); + } + + #[test] + fn test_run_logs_sql_rejects_disallowed_statement() { + let dir = TempDir::new().unwrap(); + let err = run_logs_sql(&dir.path().to_string_lossy(), "DELETE FROM logs", 100).unwrap_err(); + assert!(err.contains("read-only")); + } + #[test] fn test_export_logs_writes_plain_text() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 8c45badc..87b3c99b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -531,6 +531,8 @@ pub fn run() { commands::list_workflow_runs, commands::get_run_logs, commands::export_run_logs, + commands::get_repo_logs, + commands::run_logs_sql, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src/components/LogLevelFilter.tsx b/src/components/LogLevelFilter.tsx new file mode 100644 index 00000000..d3161f52 --- /dev/null +++ b/src/components/LogLevelFilter.tsx @@ -0,0 +1,66 @@ +import { ChevronDown } from "lucide-react"; +import { Button } from "./ui/button"; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from "./ui/dropdown-menu"; + +export const LOG_LEVELS = ["info", "warning", "error"] as const; + +interface Props { + /** Selected levels; empty means no filter (all levels shown). */ + value: string[]; + onChange: (levels: string[]) => void; +} + +/** Multi-select for log levels. Nothing ticked means "All levels". */ +export function LogLevelFilter({ value, onChange }: Props) { + function toggle(level: string) { + onChange( + value.includes(level) + ? value.filter((l) => l !== level) + : [...value, level], + ); + } + + const label = + value.length === 0 + ? "All levels" + : value.length === 1 + ? value[0] + : `${value.length} levels`; + + return ( + + + + + + {LOG_LEVELS.map((level) => ( + event.preventDefault()} + onCheckedChange={() => toggle(level)} + className="capitalize" + > + {level} + + ))} + + + ); +} diff --git a/src/components/LogsBrowser.tsx b/src/components/LogsBrowser.tsx index 8d3df339..ea9a01fb 100644 --- a/src/components/LogsBrowser.tsx +++ b/src/components/LogsBrowser.tsx @@ -5,6 +5,7 @@ import { Button } from "./ui/button"; import { exportRunLogs, getRunLogs } from "../lib/api"; import type { LogLine } from "../lib/api-types"; import { cn } from "../lib/utils"; +import { LogLevelFilter } from "./LogLevelFilter"; interface Props { repoPath: string; @@ -15,8 +16,7 @@ interface Props { onBack: () => void; } -const LEVEL_FILTERS = ["all", "info", "warning", "error"] as const; -type LevelFilter = (typeof LEVEL_FILTERS)[number]; +export { levelClass, formatTimestamp }; /** Info stays uncolored so warnings and errors are what draw the eye. */ function levelClass(level: string): string { @@ -38,7 +38,7 @@ export function LogsBrowser({ initialStepIndex, onBack, }: Props) { - const [level, setLevel] = useState("all"); + const [levels, setLevels] = useState([]); const [search, setSearch] = useState(""); const [stepIndex, setStepIndex] = useState( initialStepIndex, @@ -46,10 +46,10 @@ export function LogsBrowser({ const [exportedTo, setExportedTo] = useState(null); const { data: lines = [], isLoading } = useQuery({ - queryKey: ["run-logs", repoPath, runId, jobId, level, search, stepIndex], + queryKey: ["run-logs", repoPath, runId, jobId, levels, search, stepIndex], queryFn: () => getRunLogs(repoPath, runId, jobId, { - level: level === "all" ? undefined : level, + levels: levels.length > 0 ? levels : undefined, search: search || undefined, stepIndex, }), @@ -90,18 +90,7 @@ export function LogsBrowser({
-
- {LEVEL_FILTERS.map((option) => ( - - ))} -
+ {steps.length > 1 && (