From 634edbb28720ef40abcccc418998bb7c006b1d40 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:07:40 -0600 Subject: [PATCH 01/61] feat(core): extract worktree primitives into ralph-core --- crates/ralph-core/src/lib.rs | 1 + crates/ralph-core/src/worktree.rs | 183 ++++++++++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 crates/ralph-core/src/worktree.rs diff --git a/crates/ralph-core/src/lib.rs b/crates/ralph-core/src/lib.rs index 76afa42..44b74a3 100644 --- a/crates/ralph-core/src/lib.rs +++ b/crates/ralph-core/src/lib.rs @@ -14,6 +14,7 @@ pub mod prompt; pub mod providers; pub mod state; pub mod verification; +pub mod worktree; #[allow(dead_code)] pub mod plugin; diff --git a/crates/ralph-core/src/worktree.rs b/crates/ralph-core/src/worktree.rs new file mode 100644 index 0000000..41c8814 --- /dev/null +++ b/crates/ralph-core/src/worktree.rs @@ -0,0 +1,183 @@ +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use tokio::process::Command; + +#[derive(Debug, Error)] +pub enum WorktreeError { + #[error("git error: {0}")] + Git(String), + #[error("io error: {0}")] + Io(#[from] std::io::Error), + #[error("worktree not found: {0}")] + NotFound(String), +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct WorktreeInfo { + pub path: String, + pub branch: String, + pub head: String, + pub is_bare: bool, + pub is_locked: bool, + pub is_prunable: bool, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct WorktreeDiff { + pub files_changed: u32, + pub insertions: u32, + pub deletions: u32, +} + +pub async fn create(work_dir: &Path, worktree_path: &str, branch: &str) -> Result { + let create_branch = ["worktree", "add", worktree_path, "-b", branch]; + if run_git(work_dir, &create_branch).await.is_err() { + let existing_branch = ["worktree", "add", worktree_path, branch]; + run_git(work_dir, &existing_branch).await?; + } + inspect(work_dir, Path::new(worktree_path)).await +} + +pub async fn list(work_dir: &Path) -> Result, WorktreeError> { + let output = run_git(work_dir, &["worktree", "list", "--porcelain"]).await?; + Ok(parse_worktree_list(&output)) +} + +pub async fn remove(work_dir: &Path, worktree_path: &Path, branch: Option<&str>) -> Result<(), WorktreeError> { + let target = worktree_path.to_string_lossy().into_owned(); + run_git(work_dir, &["worktree", "remove", &target, "--force"]).await?; + if let Some(branch_name) = branch { + let _ = run_git(work_dir, &["branch", "-d", branch_name]).await; + } + Ok(()) +} + +pub async fn inspect(work_dir: &Path, worktree_path: &Path) -> Result { + let target = normalize_path(work_dir, worktree_path); + list(work_dir) + .await? + .into_iter() + .find(|worktree| worktree.path == target) + .ok_or(WorktreeError::NotFound(target)) +} + +pub async fn diff(work_dir: &Path, from_ref: &str, to_ref: &str) -> Result { + let range = format!("{from_ref}..{to_ref}"); + let output = run_git(work_dir, &["diff", "--numstat", &range]).await?; + Ok(parse_diff_numstat(&output)) +} + +pub fn parse_worktree_list(raw: &str) -> Vec { + let mut worktrees = Vec::new(); + let mut current = WorktreeInfo::default(); + for line in raw.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + if !current.path.is_empty() { + worktrees.push(current); + } + current = WorktreeInfo { path: path.to_string(), ..WorktreeInfo::default() }; + continue; + } + if let Some(head) = line.strip_prefix("HEAD ") { + current.head = head.to_string(); + } else if let Some(branch) = line.strip_prefix("branch ") { + current.branch = branch.trim_start_matches("refs/heads/").to_string(); + } else if line == "bare" { + current.is_bare = true; + } else if line == "locked" { + current.is_locked = true; + } else if line == "prunable" { + current.is_prunable = true; + } + } + if !current.path.is_empty() { + worktrees.push(current); + } + worktrees +} + +pub fn parse_diff_numstat(raw: &str) -> WorktreeDiff { + raw.lines().fold(WorktreeDiff::default(), |mut diff, line| { + let mut parts = line.splitn(3, '\t'); + let insertions = parts + .next() + .unwrap_or_default() + .parse::() + .unwrap_or_default(); + let deletions = parts + .next() + .unwrap_or_default() + .parse::() + .unwrap_or_default(); + if parts.next().is_some() { + diff.files_changed += 1; + diff.insertions += insertions; + diff.deletions += deletions; + } + diff + }) +} + +async fn run_git(work_dir: &Path, args: &[&str]) -> Result { + let output = Command::new("git") + .args(args) + .current_dir(work_dir) + .output() + .await?; + if output.status.success() { + return Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()); + } + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(WorktreeError::Git(if stderr.is_empty() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + stderr + })) +} + +fn normalize_path(work_dir: &Path, worktree_path: &Path) -> String { + let path = if worktree_path.is_absolute() { + PathBuf::from(worktree_path) + } else { + work_dir.join(worktree_path) + }; + path.to_string_lossy().into_owned() +} + +#[cfg(test)] +mod tests { + use super::{parse_diff_numstat, parse_worktree_list, WorktreeDiff, WorktreeInfo}; + + #[test] + fn parses_worktree_porcelain() { + let raw = "worktree /repo\nHEAD abc\nbranch refs/heads/main\n\nworktree /repo/w1\nHEAD def\nbranch refs/heads/feature\nlocked\nprunable\n"; + assert_eq!( + parse_worktree_list(raw), + vec![ + WorktreeInfo { + path: "/repo".into(), + branch: "main".into(), + head: "abc".into(), + ..WorktreeInfo::default() + }, + WorktreeInfo { + path: "/repo/w1".into(), + branch: "feature".into(), + head: "def".into(), + is_locked: true, + is_prunable: true, + ..WorktreeInfo::default() + }, + ] + ); + } + + #[test] + fn parses_numstat_safely() { + let raw = "10\t2\ta.rs\n-\t-\tb.bin\n"; + assert_eq!(parse_diff_numstat(raw), WorktreeDiff { files_changed: 2, insertions: 10, deletions: 2 }); + } +} From 587eb40a501402e546ea7bc2041446ec6928584d Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:15:36 -0600 Subject: [PATCH 02/61] refactor(core): extract story scheduler --- crates/ralph-core/src/lib.rs | 1 + crates/ralph-core/src/loop_engine/mod.rs | 3 +- crates/ralph-core/src/scheduler/mod.rs | 25 +++++ .../ralph-core/tests/scheduler_ready_sets.rs | 98 +++++++++++++++++++ 4 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 crates/ralph-core/src/scheduler/mod.rs create mode 100644 crates/ralph-core/tests/scheduler_ready_sets.rs diff --git a/crates/ralph-core/src/lib.rs b/crates/ralph-core/src/lib.rs index 44b74a3..5b76ca8 100644 --- a/crates/ralph-core/src/lib.rs +++ b/crates/ralph-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod ports; pub mod prd; pub mod prompt; pub mod providers; +pub mod scheduler; pub mod state; pub mod verification; pub mod worktree; diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index 86b30ba..3f0ed83 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -15,6 +15,7 @@ use crate::health; use crate::logger; use crate::prompt::PromptBuilder; use crate::providers::Provider; +use crate::scheduler; use crate::state; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; @@ -65,7 +66,7 @@ pub async fn run( break; } - let next_story = match prd.next_actionable_story() { + let next_story = match scheduler::ready_stories(&prd).into_iter().next() { Some(story) => story.clone(), None => { logger::log_success("No more actionable stories. Done."); diff --git a/crates/ralph-core/src/scheduler/mod.rs b/crates/ralph-core/src/scheduler/mod.rs new file mode 100644 index 0000000..22a2c12 --- /dev/null +++ b/crates/ralph-core/src/scheduler/mod.rs @@ -0,0 +1,25 @@ +use crate::prd::{Prd, UserStory}; +use std::collections::HashSet; + +pub fn ready_stories(prd: &Prd) -> Vec<&UserStory> { + let passed_ids: HashSet<&str> = prd + .stories + .iter() + .filter(|story| story.passes) + .map(|story| story.id.as_str()) + .collect(); + + prd.stories + .iter() + .filter(|story| is_ready(story, &passed_ids)) + .collect() +} + +fn is_ready(story: &UserStory, passed_ids: &HashSet<&str>) -> bool { + !story.passes + && !story.blocked + && story + .depends_on + .iter() + .all(|dependency| passed_ids.contains(dependency.as_str())) +} diff --git a/crates/ralph-core/tests/scheduler_ready_sets.rs b/crates/ralph-core/tests/scheduler_ready_sets.rs new file mode 100644 index 0000000..5464e17 --- /dev/null +++ b/crates/ralph-core/tests/scheduler_ready_sets.rs @@ -0,0 +1,98 @@ +use ralph_core::prd::{ + Complexity, Prd, Priority, ScopeSpec, UserStory, VerificationSpec, +}; +use ralph_core::scheduler; + +fn story(id: &str) -> UserStory { + UserStory { + id: id.to_string(), + title: format!("Story {id}"), + description: None, + acceptance_criteria: vec!["works".to_string()], + scope: ScopeSpec::default(), + verification: VerificationSpec::default(), + commit_message: None, + priority: Priority::Medium, + estimated_complexity: Complexity::Medium, + estimated_minutes: 0, + depends_on: Vec::new(), + passes: false, + blocked: false, + attempts: 0, + notes: None, + } +} + +fn prd(stories: Vec) -> Prd { + Prd { + project_name: "loopforge".to_string(), + feature: String::new(), + working_directory: String::new(), + branch_name: None, + stories, + generated_at: None, + } +} + +#[test] +fn ready_set_excludes_blocked_and_unsatisfied_dependencies() { + let mut completed = story("S-001"); + completed.passes = true; + + let ready = story("S-002"); + + let mut blocked = story("S-003"); + blocked.blocked = true; + + let mut waiting = story("S-004"); + waiting.depends_on = vec!["S-003".to_string()]; + + let prd = prd(vec![completed, ready, blocked, waiting]); + let ready_ids: Vec<&str> = scheduler::ready_stories(&prd) + .into_iter() + .map(|story| story.id.as_str()) + .collect(); + + assert_eq!(ready_ids, vec!["S-002"]); +} + +#[test] +fn ready_set_returns_all_satisfied_stories_in_story_order() { + let mut foundation = story("S-001"); + foundation.passes = true; + + let mut first_ready = story("S-002"); + first_ready.depends_on = vec!["S-001".to_string()]; + + let second_ready = story("S-003"); + + let mut waiting = story("S-004"); + waiting.depends_on = vec!["S-002".to_string()]; + + let prd = prd(vec![foundation, first_ready, second_ready, waiting]); + let ready_ids: Vec<&str> = scheduler::ready_stories(&prd) + .into_iter() + .map(|story| story.id.as_str()) + .collect(); + + assert_eq!(ready_ids, vec!["S-002", "S-003"]); +} + +#[test] +fn ready_set_unlocks_story_only_when_all_dependencies_pass() { + let mut first = story("S-001"); + first.passes = true; + + let second = story("S-002"); + + let mut gated = story("S-003"); + gated.depends_on = vec!["S-001".to_string(), "S-002".to_string()]; + + let prd = prd(vec![first, second, gated]); + let ready_ids: Vec<&str> = scheduler::ready_stories(&prd) + .into_iter() + .map(|story| story.id.as_str()) + .collect(); + + assert_eq!(ready_ids, vec!["S-002"]); +} From 360e0c66a799c9bd6838a26dc6f82aa57664d928 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:19:52 -0600 Subject: [PATCH 03/61] test(core): add worktree regression coverage --- crates/ralph-core/tests/worktree.rs | 118 ++++++++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 crates/ralph-core/tests/worktree.rs diff --git a/crates/ralph-core/tests/worktree.rs b/crates/ralph-core/tests/worktree.rs new file mode 100644 index 0000000..a7e9590 --- /dev/null +++ b/crates/ralph-core/tests/worktree.rs @@ -0,0 +1,118 @@ +use ralph_core::worktree::{self, WorktreeError}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +fn git(repo_dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo_dir) + .output() + .expect("git command should run"); + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + panic!( + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr).trim() + ); +} + +fn init_repo() -> (tempfile::TempDir, PathBuf, String, String) { + let temp_dir = tempfile::tempdir().expect("tempdir should exist"); + let repo_dir = temp_dir.path().join("repo"); + std::fs::create_dir(&repo_dir).expect("repo dir should exist"); + git(&repo_dir, &["init"]); + git(&repo_dir, &["config", "user.name", "LoopForge Test"]); + git(&repo_dir, &["config", "user.email", "loopforge@example.com"]); + let tracked_contents = "tracked root contents\n".to_string(); + let untracked_contents = "untracked sentinel\n".to_string(); + std::fs::write(repo_dir.join("README.md"), &tracked_contents).expect("tracked file should write"); + git(&repo_dir, &["add", "README.md"]); + git(&repo_dir, &["commit", "-m", "init"]); + std::fs::write(repo_dir.join("notes.txt"), &untracked_contents).expect("untracked file should write"); + let canonical_repo_dir = repo_dir.canonicalize().expect("repo dir should canonicalize"); + (temp_dir, canonical_repo_dir, tracked_contents, untracked_contents) +} + +fn branch_exists(repo_dir: &Path, branch: &str) -> bool { + !git(repo_dir, &["branch", "--list", branch]).is_empty() +} + +#[tokio::test] +async fn worktree_setup_and_teardown_are_reentrant() { + let (_temp_dir, repo_dir, tracked_contents, untracked_contents) = init_repo(); + let repo_head = git(&repo_dir, &["rev-parse", "HEAD"]); + let worktree_path = ".loopforge/worktrees/regression"; + let worktree_dir = repo_dir.join(worktree_path); + let worktree_branch = "loopforge/regression"; + let expected_path = worktree_dir.to_string_lossy().into_owned(); + + let first = worktree::create(&repo_dir, worktree_path, worktree_branch) + .await + .expect("first setup should succeed"); + assert_eq!(first.path, expected_path); + assert_eq!(first.branch, worktree_branch); + assert!(worktree_dir.exists(), "worktree directory must exist after setup"); + assert_eq!( + std::fs::read_to_string(worktree_dir.join("README.md")).unwrap(), + tracked_contents + ); + assert_eq!(git(&repo_dir, &["rev-parse", "HEAD"]), repo_head); + assert_eq!(std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), tracked_contents); + assert_eq!(std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), untracked_contents); + let first_list = worktree::list(&repo_dir).await.expect("worktree list should load"); + assert_eq!( + first_list + .iter() + .filter(|entry| entry.path == expected_path) + .count(), + 1 + ); + + worktree::remove(&repo_dir, Path::new(worktree_path), None) + .await + .expect("first teardown should succeed"); + assert!(!worktree_dir.exists(), "worktree directory must be removed"); + assert!(branch_exists(&repo_dir, worktree_branch), "branch should remain after partial teardown"); + let first_teardown = worktree::list(&repo_dir) + .await + .expect("list should succeed after first teardown"); + assert!(first_teardown.iter().all(|entry| entry.path != expected_path)); + let inspect_error = worktree::inspect(&repo_dir, Path::new(worktree_path)) + .await + .expect_err("removed worktree should not be inspectable"); + assert!(matches!(inspect_error, WorktreeError::NotFound(path) if path == expected_path)); + + let second = worktree::create(&repo_dir, worktree_path, worktree_branch) + .await + .expect("second setup should reuse branch cleanly"); + assert_eq!(second.path, expected_path); + assert_eq!(second.branch, worktree_branch); + let second_list = worktree::list(&repo_dir).await.expect("second list should load"); + assert_eq!( + second_list + .iter() + .filter(|entry| entry.path == expected_path) + .count(), + 1 + ); + assert_eq!(git(&repo_dir, &["rev-parse", "HEAD"]), repo_head); + assert_eq!(std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), tracked_contents); + assert_eq!(std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), untracked_contents); + + worktree::remove(&repo_dir, Path::new(worktree_path), Some(worktree_branch)) + .await + .expect("final teardown should succeed"); + assert!(!worktree_dir.exists(), "worktree directory must stay removed"); + assert!( + !branch_exists(&repo_dir, worktree_branch), + "branch should be deleted during final teardown" + ); + let final_list = worktree::list(&repo_dir).await.expect("final list should load"); + assert_eq!(final_list.len(), 1); + assert_eq!(final_list[0].path, repo_dir.to_string_lossy()); + assert_eq!(git(&repo_dir, &["rev-parse", "HEAD"]), repo_head); + assert_eq!(std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), tracked_contents); + assert_eq!(std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), untracked_contents); +} From 43f33b29e34e98f46eedf74c4c3997469a50c1a6 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:23:00 -0600 Subject: [PATCH 04/61] test(core): cover scheduler merge behavior --- crates/ralph-core/tests/scheduler_merge.rs | 113 +++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 crates/ralph-core/tests/scheduler_merge.rs diff --git a/crates/ralph-core/tests/scheduler_merge.rs b/crates/ralph-core/tests/scheduler_merge.rs new file mode 100644 index 0000000..a0c2cdf --- /dev/null +++ b/crates/ralph-core/tests/scheduler_merge.rs @@ -0,0 +1,113 @@ +use ralph_core::prd::{ + Complexity, Prd, Priority, ScopeSpec, UserStory, VerificationSpec, +}; +use ralph_core::scheduler; + +fn story(id: &str) -> UserStory { + UserStory { + id: id.to_string(), + title: format!("Story {id}"), + description: None, + acceptance_criteria: vec!["works".to_string()], + scope: ScopeSpec::default(), + verification: VerificationSpec::default(), + commit_message: None, + priority: Priority::Medium, + estimated_complexity: Complexity::Medium, + estimated_minutes: 0, + depends_on: Vec::new(), + passes: false, + blocked: false, + attempts: 0, + notes: None, + } +} + +fn prd(stories: Vec) -> Prd { + Prd { + project_name: "loopforge".to_string(), + feature: String::new(), + working_directory: String::new(), + branch_name: None, + stories, + generated_at: None, + } +} + +fn scheduled_ids(prd: &Prd) -> Vec { + scheduler::ready_stories(prd) + .into_iter() + .map(|story| story.id.clone()) + .collect() +} + +fn merge_schedule(existing: &[String], incoming: &[String]) -> Vec { + let mut merged = existing.to_vec(); + + for story_id in incoming { + if !merged.contains(story_id) { + merged.push(story_id.clone()); + } + } + + merged +} + +#[test] +fn merge_schedule_keeps_existing_queue_when_new_ready_work_arrives() { + let mut foundation = story("S-001"); + foundation.passes = true; + + let mut queued = story("S-002"); + queued.depends_on = vec!["S-001".to_string()]; + + let initial_schedule = scheduled_ids(&prd(vec![foundation.clone(), queued.clone()])); + + let independent = story("S-003"); + let mut unlocked = story("S-004"); + unlocked.depends_on = vec!["S-002".to_string()]; + + let incoming_schedule = scheduled_ids(&prd(vec![foundation, queued, independent, unlocked])); + let merged = merge_schedule(&initial_schedule, &incoming_schedule); + + assert_eq!(initial_schedule, vec!["S-002"]); + assert_eq!(incoming_schedule, vec!["S-002", "S-003"]); + assert_eq!(merged, vec!["S-002", "S-003"]); +} + +#[test] +fn merge_schedule_deduplicates_overlapping_ready_sets_in_story_order() { + let mut foundation = story("S-001"); + foundation.passes = true; + + let mut queued_first = story("S-002"); + queued_first.depends_on = vec!["S-001".to_string()]; + + let queued_second = story("S-003"); + let initial_schedule = scheduled_ids(&prd(vec![ + foundation.clone(), + queued_first.clone(), + queued_second.clone(), + ])); + + queued_first.passes = true; + + let mut overlapping = story("S-004"); + overlapping.depends_on = vec!["S-001".to_string()]; + + let mut newly_unlocked = story("S-005"); + newly_unlocked.depends_on = vec!["S-002".to_string()]; + + let incoming_schedule = scheduled_ids(&prd(vec![ + foundation, + queued_first, + queued_second, + overlapping, + newly_unlocked, + ])); + let merged = merge_schedule(&initial_schedule, &incoming_schedule); + + assert_eq!(initial_schedule, vec!["S-002", "S-003"]); + assert_eq!(incoming_schedule, vec!["S-003", "S-004", "S-005"]); + assert_eq!(merged, vec!["S-002", "S-003", "S-004", "S-005"]); +} From 2c0b15233e3a35789a1faf220e78c50c5842d311 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:29:07 -0600 Subject: [PATCH 05/61] feat(core): add worktree domain primitives --- crates/ralph-core/src/lib.rs | 1 + crates/ralph-core/src/loop_engine/mod.rs | 99 +++---------------- crates/ralph-core/src/loop_engine/worktree.rs | 93 +++++++++++++++++ crates/ralph-core/tests/worktree_state.rs | 67 +++++++++++++ 4 files changed, 177 insertions(+), 83 deletions(-) create mode 100644 crates/ralph-core/src/loop_engine/worktree.rs create mode 100644 crates/ralph-core/tests/worktree_state.rs diff --git a/crates/ralph-core/src/lib.rs b/crates/ralph-core/src/lib.rs index 5b76ca8..c4602be 100644 --- a/crates/ralph-core/src/lib.rs +++ b/crates/ralph-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod scheduler; pub mod state; pub mod verification; pub mod worktree; +pub use loop_engine::worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; #[allow(dead_code)] pub mod plugin; diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index 3f0ed83..d105368 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -3,6 +3,9 @@ mod iteration; mod outcome; mod prd_lifecycle; mod rate_limiter; +pub mod worktree; + +pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; use crate::config::RalphConfig; use crate::detection::failure_memory::FailureMemory; @@ -17,8 +20,8 @@ use crate::prompt::PromptBuilder; use crate::providers::Provider; use crate::scheduler; use crate::state; -use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; pub async fn run( config: &RalphConfig, @@ -30,24 +33,20 @@ pub async fn run( let initial_commit = git::get_head_hash(&config.paths.work_dir).await.unwrap_or_default(); let mut failure_memory = FailureMemory::load(&config.paths.failure_memory_file); let mut consecutive_zero_progress: u32 = 0; - while iter_count < config.tuning.max_iterations { if shutdown_flag.load(Ordering::SeqCst) { logger::log_warning("Shutdown requested, exiting loop"); break; } - state::wait_while_paused(&config.paths.pause_file).await; if state::check_and_clear_done(&config.paths.done_file) { break; } - if !health::check_all_services_parallel(config).await { logger::log_error("Services unavailable, retrying in 30s", Some(&config.paths.error_log)); tokio::time::sleep(std::time::Duration::from_secs(30)).await; continue; } - let mut prd = match prd_lifecycle::load_or_restore(config) { Some(prd) => prd, None => { @@ -55,17 +54,10 @@ pub async fn run( break; } }; - - let pending = prd.pending_count(); - if pending == 0 { - logger::log_success(&format!( - "All stories completed! {} passed, {} blocked.", - prd.passed_count(), - prd.blocked_count() - )); + if prd.pending_count() == 0 { + logger::log_success(&format!("All stories completed! {} passed, {} blocked.", prd.passed_count(), prd.blocked_count())); break; } - let next_story = match scheduler::ready_stories(&prd).into_iter().next() { Some(story) => story.clone(), None => { @@ -73,21 +65,17 @@ pub async fn run( break; } }; - iter_count += 1; let iter_start = std::time::Instant::now(); logger::log_iteration_header(iter_count); log_progress(&prd, &next_story.title, config, iter_count); - if failure_memory.is_in_gutter(&next_story.id, config.tuning.gutter_threshold) { handle_gutter_skip(config, &mut prd, &next_story.id, iter_count, event_sink)?; continue; } - prd.save(&config.paths.prd_backup)?; let progress_before = progress::get_progress_file_size(&config.paths.progress_file); let head_before = git::get_head_hash(&config.paths.work_dir).await.unwrap_or_default(); - let built = build_prompt(config, iter_count, &failure_memory, &next_story, event_sink); if let Err(validation_errors) = built.validate(&next_story.id) { tracing::error!(story_id = %next_story.id, errors = ?validation_errors, "prompt validation failed"); @@ -97,41 +85,19 @@ pub async fn run( }); continue; } - logger::log_info(&format!("Starting {} agent ({})...", provider.name(), provider.model())); - - let outcome = iteration::run_with_verification( - config, provider, &next_story, built.text, built.hash, - &shutdown_flag, event_sink, &mut failure_memory, iter_count, - ) - .await; - + let outcome = iteration::run_with_verification(config, provider, &next_story, built.text, built.hash, &shutdown_flag, event_sink, &mut failure_memory, iter_count).await; let final_passed = outcome.story_passed || check_story_passed_in_prd(config, &next_story.id); - - let progress_report = progress::analyze_iteration_progress( - &config.paths.work_dir, &head_before, final_passed, - &config.paths.progress_file, progress_before, - ) - .await; - - outcome::handle( - config, &outcome.agent_result, &mut prd, &next_story.id, - final_passed, &progress_report, &mut failure_memory, - &mut consecutive_zero_progress, &mut iter_count, - &shutdown_flag, event_sink, - ) - .await?; - + let progress_report = progress::analyze_iteration_progress(&config.paths.work_dir, &head_before, final_passed, &config.paths.progress_file, progress_before).await; + outcome::handle(config, &outcome.agent_result, &mut prd, &next_story.id, final_passed, &progress_report, &mut failure_memory, &mut consecutive_zero_progress, &mut iter_count, &shutdown_flag, event_sink).await?; failure_memory.save(&config.paths.failure_memory_file)?; log_iteration_complete(config, iter_count, &initial_commit, &next_story.id, final_passed, &iter_start).await; - logger::log_info(&format!("Cooldown {}s before next iteration...", config.tuning.cooldown_secs)); if helpers::interruptible_sleep(config.tuning.cooldown_secs, &shutdown_flag, event_sink, HeartbeatContext::CooldownWait).await { logger::log_warning("Shutdown requested during cooldown"); break; } } - helpers::log_session_summary(iter_count, config, &initial_commit).await; state::clear_state(&config.paths.state_file); Ok(()) @@ -147,18 +113,9 @@ fn log_progress(prd: &crate::prd::Prd, story_title: &str, config: &RalphConfig, logger::log_activity(&format!("=== Iteration {iteration} started ==="), &config.paths.activity_log); } -fn handle_gutter_skip( - config: &RalphConfig, - prd: &mut crate::prd::Prd, - story_id: &str, - iteration: u32, - event_sink: &dyn LoopEventSink, -) -> Result<(), LoopError> { +fn handle_gutter_skip(config: &RalphConfig, prd: &mut crate::prd::Prd, story_id: &str, iteration: u32, event_sink: &dyn LoopEventSink) -> Result<(), LoopError> { logger::log_warning(&format!("GUTTER: {story_id} has failed {}+ times, skipping", config.tuning.gutter_threshold)); - event_sink.emit(LoopEvent::StorySkipped { - story_id: story_id.to_string(), - reason: format!("gutter threshold ({} failures)", config.tuning.gutter_threshold), - }); + event_sink.emit(LoopEvent::StorySkipped { story_id: story_id.to_string(), reason: format!("gutter threshold ({} failures)", config.tuning.gutter_threshold) }); if let Err(err) = guardrails::add_guardrail(&config.paths.guardrails_file, story_id, "Exceeded gutter threshold", iteration) { tracing::warn!(error = %err, "failed to write guardrail"); } @@ -168,17 +125,8 @@ fn handle_gutter_skip( Ok(()) } -fn build_prompt( - config: &RalphConfig, - iteration: u32, - failure_memory: &FailureMemory, - story: &crate::prd::UserStory, - event_sink: &dyn LoopEventSink, -) -> crate::prompt::BuiltPrompt { - let builder = PromptBuilder::new( - &config.paths.prompt_file, &config.paths.guardrails_file, &config.paths.ralph_dir, - &config.paths.work_dir, iteration, failure_memory, - ); +fn build_prompt(config: &RalphConfig, iteration: u32, failure_memory: &FailureMemory, story: &crate::prd::UserStory, event_sink: &dyn LoopEventSink) -> crate::prompt::BuiltPrompt { + let builder = PromptBuilder::new(&config.paths.prompt_file, &config.paths.guardrails_file, &config.paths.ralph_dir, &config.paths.work_dir, iteration, failure_memory); let built = builder.build(story, None); event_sink.emit(LoopEvent::PromptBuilt { story_id: story.id.clone(), @@ -191,27 +139,12 @@ fn build_prompt( } fn check_story_passed_in_prd(config: &RalphConfig, story_id: &str) -> bool { - prd_lifecycle::load_or_restore(config) - .and_then(|prd| prd.stories.iter().find(|st| st.id == story_id).map(|st| st.passes)) - .unwrap_or(false) + prd_lifecycle::load_or_restore(config).and_then(|prd| prd.stories.iter().find(|st| st.id == story_id).map(|st| st.passes)).unwrap_or(false) } -async fn log_iteration_complete( - config: &RalphConfig, - iteration: u32, - initial_commit: &str, - story_id: &str, - passed: bool, - start: &std::time::Instant, -) { +async fn log_iteration_complete(config: &RalphConfig, iteration: u32, initial_commit: &str, story_id: &str, passed: bool, start: &std::time::Instant) { let elapsed = start.elapsed().as_secs(); let total_commits = git::count_commits_since(&config.paths.work_dir, initial_commit).await.unwrap_or(0); - logger::log_activity( - &format!( - "Iteration {iteration} completed in {} | Commits: {total_commits} | Story: {story_id} | Passed: {passed}", - logger::format_duration(elapsed), - ), - &config.paths.activity_log, - ); + logger::log_activity(&format!("Iteration {iteration} completed in {} | Commits: {total_commits} | Story: {story_id} | Passed: {passed}", logger::format_duration(elapsed)), &config.paths.activity_log); tracing::info!(duration = %logger::format_duration(elapsed), commits = total_commits, "Iteration complete"); } diff --git a/crates/ralph-core/src/loop_engine/worktree.rs b/crates/ralph-core/src/loop_engine/worktree.rs new file mode 100644 index 0000000..8bc7229 --- /dev/null +++ b/crates/ralph-core/src/loop_engine/worktree.rs @@ -0,0 +1,93 @@ +use serde::{Deserialize, Serialize}; + +pub const PRIMARY_WORKTREE_ID: &str = "primary"; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct LoopExecutionState { + pub iteration: u32, + pub current_story_id: Option, + pub work_dir: String, + pub primary_worktree_id: String, + pub active_worktree_id: Option, + pub worktrees: Vec, +} + +impl Default for LoopExecutionState { + fn default() -> Self { + Self { + iteration: 0, + current_story_id: None, + work_dir: String::new(), + primary_worktree_id: PRIMARY_WORKTREE_ID.to_string(), + active_worktree_id: None, + worktrees: Vec::new(), + } + } +} + +impl LoopExecutionState { + pub fn single(work_dir: impl Into) -> Self { + let work_dir = work_dir.into(); + Self { + work_dir: work_dir.clone(), + primary_worktree_id: PRIMARY_WORKTREE_ID.to_string(), + active_worktree_id: Some(PRIMARY_WORKTREE_ID.to_string()), + worktrees: vec![WorktreeExecutionState::primary(work_dir)], + ..Self::default() + } + } + + pub fn resolved_worktrees(&self) -> Vec { + if self.worktrees.is_empty() { + return vec![self.primary_worktree()]; + } + self.worktrees.clone() + } + + pub fn primary_worktree(&self) -> WorktreeExecutionState { + self.worktrees + .iter() + .find(|worktree| worktree.id == self.primary_worktree_id) + .cloned() + .or_else(|| self.worktrees.first().cloned()) + .unwrap_or_else(|| WorktreeExecutionState::primary(self.work_dir.clone())) + } + + pub fn active_worktree(&self) -> WorktreeExecutionState { + let active_id = self + .active_worktree_id + .as_deref() + .unwrap_or(&self.primary_worktree_id); + self.worktree(active_id) + .unwrap_or_else(|| self.primary_worktree()) + } + + pub fn worktree(&self, worktree_id: &str) -> Option { + self.resolved_worktrees() + .into_iter() + .find(|worktree| worktree.id == worktree_id) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(default, rename_all = "camelCase")] +pub struct WorktreeExecutionState { + pub id: String, + pub work_dir: String, + pub branch: Option, + pub story_id: Option, + pub iteration: u32, + pub last_prompt_hash: Option, + pub head_commit: Option, +} + +impl WorktreeExecutionState { + pub fn primary(work_dir: impl Into) -> Self { + Self { + id: PRIMARY_WORKTREE_ID.to_string(), + work_dir: work_dir.into(), + ..Self::default() + } + } +} diff --git a/crates/ralph-core/tests/worktree_state.rs b/crates/ralph-core/tests/worktree_state.rs new file mode 100644 index 0000000..aa704c4 --- /dev/null +++ b/crates/ralph-core/tests/worktree_state.rs @@ -0,0 +1,67 @@ +use ralph_core::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; +use serde_json::json; + +#[test] +fn single_worktree_state_matches_current_flow() { + let state = LoopExecutionState::single("/repo"); + + assert_eq!(state.primary_worktree_id, PRIMARY_WORKTREE_ID); + assert_eq!( + state.active_worktree_id.as_deref(), + Some(PRIMARY_WORKTREE_ID) + ); + assert_eq!( + state.resolved_worktrees(), + vec![WorktreeExecutionState::primary("/repo")] + ); +} + +#[test] +fn legacy_state_without_worktree_fields_deserializes_to_primary_worktree() { + let state: LoopExecutionState = serde_json::from_value(json!({ + "iteration": 4, + "currentStoryId": "S-013", + "workDir": "/repo" + })) + .unwrap(); + + assert_eq!(state.primary_worktree_id, PRIMARY_WORKTREE_ID); + assert_eq!(state.active_worktree().id, PRIMARY_WORKTREE_ID); + assert_eq!(state.active_worktree().work_dir, "/repo"); + assert_eq!(state.resolved_worktrees().len(), 1); +} + +#[test] +fn multi_worktree_state_preserves_distinct_identifiers() { + let state: LoopExecutionState = serde_json::from_value(json!({ + "iteration": 9, + "workDir": "/repo", + "primaryWorktreeId": "main", + "activeWorktreeId": "story-s013", + "worktrees": [ + {"id": "main", "workDir": "/repo", "branch": "main", "iteration": 9}, + { + "id": "story-s013", + "workDir": "/repo/.loopforge/worktrees/story-s013", + "branch": "loopforge/story-s013", + "storyId": "S-013", + "iteration": 2, + "lastPromptHash": "abc123", + "headCommit": "deadbeef" + } + ] + })) + .unwrap(); + + let worktrees = state.resolved_worktrees(); + + assert_eq!(worktrees.len(), 2); + assert_eq!(state.active_worktree().id, "story-s013"); + assert_eq!(worktrees[0].id, "main"); + assert_eq!(worktrees[1].id, "story-s013"); + assert_eq!(worktrees[0].work_dir, "/repo"); + assert_eq!( + worktrees[1].work_dir, + "/repo/.loopforge/worktrees/story-s013" + ); +} From 00291040c4c62dec1416d7ca5e73b2ed6681ae63 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:32:20 -0600 Subject: [PATCH 06/61] feat(core): add parallel worktree scheduler --- crates/ralph-core/src/lib.rs | 1 + crates/ralph-core/src/loop_engine/mod.rs | 5 +- .../ralph-core/src/loop_engine/scheduler.rs | 196 ++++++++++++++++ crates/ralph-core/tests/worktree_scheduler.rs | 210 ++++++++++++++++++ 4 files changed, 410 insertions(+), 2 deletions(-) create mode 100644 crates/ralph-core/src/loop_engine/scheduler.rs create mode 100644 crates/ralph-core/tests/worktree_scheduler.rs diff --git a/crates/ralph-core/src/lib.rs b/crates/ralph-core/src/lib.rs index c4602be..84fc81f 100644 --- a/crates/ralph-core/src/lib.rs +++ b/crates/ralph-core/src/lib.rs @@ -16,6 +16,7 @@ pub mod scheduler; pub mod state; pub mod verification; pub mod worktree; +pub use loop_engine::scheduler::{CompletionScheduler, MergeAction, WorktreeCompletion}; pub use loop_engine::worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; #[allow(dead_code)] diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index d105368..390b044 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -3,6 +3,7 @@ mod iteration; mod outcome; mod prd_lifecycle; mod rate_limiter; +pub mod scheduler; pub mod worktree; pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; @@ -18,7 +19,7 @@ use crate::health; use crate::logger; use crate::prompt::PromptBuilder; use crate::providers::Provider; -use crate::scheduler; +use crate::scheduler as story_scheduler; use crate::state; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; @@ -58,7 +59,7 @@ pub async fn run( logger::log_success(&format!("All stories completed! {} passed, {} blocked.", prd.passed_count(), prd.blocked_count())); break; } - let next_story = match scheduler::ready_stories(&prd).into_iter().next() { + let next_story = match story_scheduler::ready_stories(&prd).into_iter().next() { Some(story) => story.clone(), None => { logger::log_success("No more actionable stories. Done."); diff --git a/crates/ralph-core/src/loop_engine/scheduler.rs b/crates/ralph-core/src/loop_engine/scheduler.rs new file mode 100644 index 0000000..c47cb1f --- /dev/null +++ b/crates/ralph-core/src/loop_engine/scheduler.rs @@ -0,0 +1,196 @@ +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeCompletion { + pub worktree_id: String, + pub story_id: String, + pub passed: bool, + #[serde(default)] + pub blocked: bool, + #[serde(default)] + pub head_commit: Option, + #[serde(default)] + pub guardrail_append: Option, + #[serde(default)] + pub sequence: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum MergeAction { + UpdateStoryStatus { + story_id: String, + passed: bool, + blocked: bool, + }, + AppendGuardrail { + story_id: String, + content: String, + }, + UpdateSessionHead { + worktree_id: String, + head_commit: String, + }, +} + +#[derive(Debug, Default)] +pub struct CompletionScheduler { + pending: Vec, +} + +impl CompletionScheduler { + pub fn new() -> Self { + Self { + pending: Vec::new(), + } + } + + pub fn submit(&mut self, completion: WorktreeCompletion) { + self.pending.push(completion); + } + + pub fn submit_batch(&mut self, completions: Vec) { + self.pending.extend(completions); + } + + pub fn drain_ordered(&mut self) -> Vec { + let mut batch = std::mem::take(&mut self.pending); + batch.sort_by(deterministic_order); + batch.into_iter().flat_map(expand_actions).collect() + } + + pub fn pending_count(&self) -> usize { + self.pending.len() + } +} + +pub fn schedule(completions: Vec) -> Vec { + let mut scheduler = CompletionScheduler::new(); + scheduler.submit_batch(completions); + scheduler.drain_ordered() +} + +fn deterministic_order(left: &WorktreeCompletion, right: &WorktreeCompletion) -> Ordering { + left.story_id + .cmp(&right.story_id) + .then_with(|| left.sequence.cmp(&right.sequence)) + .then_with(|| left.worktree_id.cmp(&right.worktree_id)) +} + +fn expand_actions(completion: WorktreeCompletion) -> Vec { + let mut actions = Vec::with_capacity(3); + actions.push(MergeAction::UpdateStoryStatus { + story_id: completion.story_id.clone(), + passed: completion.passed, + blocked: completion.blocked, + }); + if let Some(content) = completion.guardrail_append { + actions.push(MergeAction::AppendGuardrail { + story_id: completion.story_id.clone(), + content, + }); + } + if let Some(head_commit) = completion.head_commit { + actions.push(MergeAction::UpdateSessionHead { + worktree_id: completion.worktree_id, + head_commit, + }); + } + actions +} + +#[cfg(test)] +mod tests { + use super::*; + + fn completion( + story_id: &str, + worktree_id: &str, + passed: bool, + sequence: u64, + ) -> WorktreeCompletion { + WorktreeCompletion { + worktree_id: worktree_id.to_string(), + story_id: story_id.to_string(), + passed, + blocked: false, + head_commit: None, + guardrail_append: None, + sequence, + } + } + + #[test] + fn empty_scheduler_produces_no_actions() { + let actions = schedule(vec![]); + assert!(actions.is_empty()); + } + + #[test] + fn single_completion_expands_to_status_action() { + let actions = schedule(vec![completion("S-001", "wt-a", true, 0)]); + assert_eq!(actions.len(), 1); + assert_eq!( + actions[0], + MergeAction::UpdateStoryStatus { + story_id: "S-001".into(), + passed: true, + blocked: false, + } + ); + } + + #[test] + fn completion_with_guardrail_expands_to_two_actions() { + let mut comp = completion("S-002", "wt-a", false, 0); + comp.guardrail_append = Some("circuit breaker hit".into()); + let actions = schedule(vec![comp]); + assert_eq!(actions.len(), 2); + assert!(matches!(&actions[0], MergeAction::UpdateStoryStatus { .. })); + assert!(matches!(&actions[1], MergeAction::AppendGuardrail { .. })); + } + + #[test] + fn completion_with_head_commit_expands_to_session_update() { + let mut comp = completion("S-003", "wt-b", true, 0); + comp.head_commit = Some("abc123".into()); + let actions = schedule(vec![comp]); + assert_eq!(actions.len(), 2); + assert_eq!( + actions[1], + MergeAction::UpdateSessionHead { + worktree_id: "wt-b".into(), + head_commit: "abc123".into(), + } + ); + } + + #[test] + fn reverse_arrival_produces_same_order() { + let forward = schedule(vec![ + completion("S-001", "wt-a", true, 0), + completion("S-002", "wt-b", false, 1), + completion("S-003", "wt-c", true, 2), + ]); + let reverse = schedule(vec![ + completion("S-003", "wt-c", true, 2), + completion("S-002", "wt-b", false, 1), + completion("S-001", "wt-a", true, 0), + ]); + assert_eq!(forward, reverse); + } + + #[test] + fn scheduler_state_drains_on_each_call() { + let mut sched = CompletionScheduler::new(); + sched.submit(completion("S-001", "wt-a", true, 0)); + assert_eq!(sched.pending_count(), 1); + let first = sched.drain_ordered(); + assert_eq!(first.len(), 1); + assert_eq!(sched.pending_count(), 0); + let second = sched.drain_ordered(); + assert!(second.is_empty()); + } +} diff --git a/crates/ralph-core/tests/worktree_scheduler.rs b/crates/ralph-core/tests/worktree_scheduler.rs new file mode 100644 index 0000000..158c63b --- /dev/null +++ b/crates/ralph-core/tests/worktree_scheduler.rs @@ -0,0 +1,210 @@ +use ralph_core::loop_engine::scheduler::{self, CompletionScheduler, MergeAction, WorktreeCompletion}; + +fn completion( + story_id: &str, + worktree_id: &str, + passed: bool, + sequence: u64, +) -> WorktreeCompletion { + WorktreeCompletion { + worktree_id: worktree_id.to_string(), + story_id: story_id.to_string(), + passed, + blocked: false, + head_commit: None, + guardrail_append: None, + sequence, + } +} + +fn story_ids(actions: &[MergeAction]) -> Vec { + actions + .iter() + .filter_map(|action| match action { + MergeAction::UpdateStoryStatus { story_id, .. } => Some(story_id.clone()), + _ => None, + }) + .collect() +} + +#[test] +fn conflicting_completions_produce_stable_ordering() { + let completions = vec![ + completion("S-003", "wt-c", true, 2), + completion("S-001", "wt-a", true, 0), + completion("S-002", "wt-b", false, 1), + ]; + let first_run = scheduler::schedule(completions.clone()); + let second_run = scheduler::schedule(completions.clone()); + let third_run = scheduler::schedule(completions); + + assert_eq!(first_run, second_run); + assert_eq!(second_run, third_run); + assert_eq!(story_ids(&first_run), vec!["S-001", "S-002", "S-003"]); +} + +#[test] +fn same_story_from_different_worktrees_ordered_by_sequence_then_id() { + let completions = vec![ + completion("S-001", "wt-b", false, 2), + completion("S-001", "wt-a", true, 1), + ]; + let actions = scheduler::schedule(completions); + let worktree_order: Vec<&str> = actions + .iter() + .filter_map(|action| match action { + MergeAction::UpdateStoryStatus { story_id, .. } if story_id == "S-001" => Some("status"), + _ => None, + }) + .collect(); + assert_eq!(worktree_order.len(), 2); + + assert_eq!( + actions[0], + MergeAction::UpdateStoryStatus { + story_id: "S-001".into(), + passed: true, + blocked: false, + } + ); + assert_eq!( + actions[1], + MergeAction::UpdateStoryStatus { + story_id: "S-001".into(), + passed: false, + blocked: false, + } + ); +} + +#[test] +fn mixed_actions_interleave_correctly_per_story() { + let mut comp_a = completion("S-002", "wt-a", true, 0); + comp_a.head_commit = Some("aaa111".into()); + + let mut comp_b = completion("S-001", "wt-b", false, 1); + comp_b.guardrail_append = Some("retry limit".into()); + comp_b.head_commit = Some("bbb222".into()); + + let actions = scheduler::schedule(vec![comp_a, comp_b]); + + assert_eq!(actions.len(), 5); + assert_eq!( + actions[0], + MergeAction::UpdateStoryStatus { + story_id: "S-001".into(), + passed: false, + blocked: false, + } + ); + assert_eq!( + actions[1], + MergeAction::AppendGuardrail { + story_id: "S-001".into(), + content: "retry limit".into(), + } + ); + assert_eq!( + actions[2], + MergeAction::UpdateSessionHead { + worktree_id: "wt-b".into(), + head_commit: "bbb222".into(), + } + ); + assert_eq!( + actions[3], + MergeAction::UpdateStoryStatus { + story_id: "S-002".into(), + passed: true, + blocked: false, + } + ); + assert_eq!( + actions[4], + MergeAction::UpdateSessionHead { + worktree_id: "wt-a".into(), + head_commit: "aaa111".into(), + } + ); +} + +#[test] +fn scheduler_api_does_not_require_tauri_or_shell_types() { + let mut sched = CompletionScheduler::new(); + sched.submit(completion("S-001", "wt-a", true, 0)); + sched.submit(completion("S-002", "wt-b", false, 1)); + assert_eq!(sched.pending_count(), 2); + + let actions = sched.drain_ordered(); + assert_eq!(actions.len(), 2); + assert_eq!(sched.pending_count(), 0); +} + +#[test] +fn batch_submit_matches_individual_submits() { + let completions = vec![ + completion("S-003", "wt-c", true, 2), + completion("S-001", "wt-a", true, 0), + completion("S-002", "wt-b", false, 1), + ]; + + let batch_result = scheduler::schedule(completions.clone()); + + let mut sched = CompletionScheduler::new(); + for comp in completions { + sched.submit(comp); + } + let individual_result = sched.drain_ordered(); + + assert_eq!(batch_result, individual_result); +} + +#[test] +fn blocked_completion_propagates_blocked_flag() { + let mut comp = completion("S-005", "wt-a", false, 0); + comp.blocked = true; + let actions = scheduler::schedule(vec![comp]); + assert_eq!( + actions[0], + MergeAction::UpdateStoryStatus { + story_id: "S-005".into(), + passed: false, + blocked: true, + } + ); +} + +#[test] +fn large_concurrent_set_stays_deterministic() { + let mut completions: Vec = (0..50) + .map(|idx| completion(&format!("S-{idx:03}"), &format!("wt-{idx}"), idx % 3 == 0, idx)) + .collect(); + let forward = scheduler::schedule(completions.clone()); + completions.reverse(); + let reversed = scheduler::schedule(completions); + assert_eq!(forward, reversed); +} + +#[test] +fn serialization_roundtrip_preserves_completion() { + let mut comp = completion("S-010", "wt-x", true, 5); + comp.head_commit = Some("deadbeef".into()); + comp.guardrail_append = Some("limit reached".into()); + + let json = serde_json::to_string(&comp).expect("should serialize"); + let restored: WorktreeCompletion = + serde_json::from_str(&json).expect("should deserialize"); + assert_eq!(comp, restored); +} + +#[test] +fn serialization_roundtrip_preserves_merge_action() { + let action = MergeAction::AppendGuardrail { + story_id: "S-001".into(), + content: "circuit breaker".into(), + }; + let json = serde_json::to_string(&action).expect("should serialize"); + let restored: MergeAction = + serde_json::from_str(&json).expect("should deserialize"); + assert_eq!(action, restored); +} From d2f22cb5d8ecbde4740b0d18dac1db43a4dd0fd9 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 18:37:18 -0600 Subject: [PATCH 07/61] feat(core): provision per-story worktrees --- crates/ralph-core/src/loop_engine/mod.rs | 3 + crates/ralph-core/src/scheduler/mod.rs | 2 + .../src/scheduler/worktree_runner.rs | 99 ++++++++++ crates/ralph-core/tests/worktree_runner.rs | 175 ++++++++++++++++++ 4 files changed, 279 insertions(+) create mode 100644 crates/ralph-core/src/scheduler/worktree_runner.rs create mode 100644 crates/ralph-core/tests/worktree_runner.rs diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index 390b044..f5a20af 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -7,6 +7,9 @@ pub mod scheduler; pub mod worktree; pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; +pub use crate::scheduler::worktree_runner::{ + ProvisionedWorktree, provision as provision_worktree, run_in_worktree, +}; use crate::config::RalphConfig; use crate::detection::failure_memory::FailureMemory; diff --git a/crates/ralph-core/src/scheduler/mod.rs b/crates/ralph-core/src/scheduler/mod.rs index 22a2c12..ff948a4 100644 --- a/crates/ralph-core/src/scheduler/mod.rs +++ b/crates/ralph-core/src/scheduler/mod.rs @@ -1,3 +1,5 @@ +pub mod worktree_runner; + use crate::prd::{Prd, UserStory}; use std::collections::HashSet; diff --git a/crates/ralph-core/src/scheduler/worktree_runner.rs b/crates/ralph-core/src/scheduler/worktree_runner.rs new file mode 100644 index 0000000..65b6c22 --- /dev/null +++ b/crates/ralph-core/src/scheduler/worktree_runner.rs @@ -0,0 +1,99 @@ +use crate::prd::UserStory; +use crate::worktree::{self, WorktreeError, WorktreeInfo}; +use std::future::Future; +use std::path::{Path, PathBuf}; + +const WORKTREE_BASE: &str = ".loopforge/worktrees"; + +#[derive(Debug, Clone)] +pub struct ProvisionedWorktree { + pub story_id: String, + pub worktree_path: PathBuf, + pub branch: String, + pub info: WorktreeInfo, +} + +pub fn worktree_path_for(work_dir: &Path, story_id: &str) -> PathBuf { + work_dir.join(WORKTREE_BASE).join(sanitize(story_id)) +} + +pub fn branch_for(story_id: &str) -> String { + format!("loopforge/{}", sanitize(story_id)) +} + +fn sanitize(story_id: &str) -> String { + story_id + .to_lowercase() + .replace(|c: char| !c.is_alphanumeric() && c != '-', "-") +} + +pub async fn provision( + work_dir: &Path, + story_id: &str, +) -> Result { + let rel_path = format!("{WORKTREE_BASE}/{}", sanitize(story_id)); + let branch = branch_for(story_id); + let info = worktree::create(work_dir, &rel_path, &branch).await?; + Ok(ProvisionedWorktree { + story_id: story_id.to_string(), + worktree_path: PathBuf::from(&info.path), + branch, + info, + }) +} + +pub async fn run_in_worktree( + work_dir: &Path, + story: &UserStory, + worker: F, +) -> Result +where + F: FnOnce(ProvisionedWorktree) -> Fut, + Fut: Future, +{ + let provisioned = provision(work_dir, &story.id).await?; + Ok(worker(provisioned).await) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + #[test] + fn different_stories_get_different_paths() { + let work_dir = Path::new("/repo"); + let path_a = worktree_path_for(work_dir, "S-001"); + let path_b = worktree_path_for(work_dir, "S-002"); + assert_ne!(path_a, path_b); + } + + #[test] + fn same_story_gets_same_path() { + let work_dir = Path::new("/repo"); + let first = worktree_path_for(work_dir, "S-001"); + let second = worktree_path_for(work_dir, "S-001"); + assert_eq!(first, second); + } + + #[test] + fn path_contains_sanitized_story_id() { + let work_dir = Path::new("/repo"); + let path = worktree_path_for(work_dir, "S-001"); + assert!(path.to_string_lossy().contains("s-001")); + assert!(path.to_string_lossy().contains(WORKTREE_BASE)); + } + + #[test] + fn branch_contains_story_id() { + let branch = branch_for("S-015"); + assert_eq!(branch, "loopforge/s-015"); + } + + #[test] + fn sanitize_strips_special_chars() { + assert_eq!(sanitize("S-001"), "s-001"); + assert_eq!(sanitize("S_002"), "s-002"); + assert_eq!(sanitize("My Story!"), "my-story-"); + } +} diff --git a/crates/ralph-core/tests/worktree_runner.rs b/crates/ralph-core/tests/worktree_runner.rs new file mode 100644 index 0000000..b0de182 --- /dev/null +++ b/crates/ralph-core/tests/worktree_runner.rs @@ -0,0 +1,175 @@ +use ralph_core::prd::UserStory; +use ralph_core::scheduler::worktree_runner::{ + branch_for, provision, run_in_worktree, worktree_path_for, +}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::{Arc, Mutex}; + +fn git(repo_dir: &Path, args: &[&str]) -> String { + let output = Command::new("git") + .args(args) + .current_dir(repo_dir) + .output() + .expect("git command should run"); + if output.status.success() { + return String::from_utf8_lossy(&output.stdout).trim().to_string(); + } + panic!( + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr).trim() + ); +} + +fn init_repo() -> (tempfile::TempDir, PathBuf) { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let repo_dir = temp_dir.path().join("repo"); + std::fs::create_dir(&repo_dir).expect("repo dir"); + git(&repo_dir, &["init"]); + git(&repo_dir, &["config", "user.name", "Test"]); + git(&repo_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(repo_dir.join("README.md"), "init\n").expect("write"); + git(&repo_dir, &["add", "README.md"]); + git(&repo_dir, &["commit", "-m", "init"]); + let canonical = repo_dir.canonicalize().expect("canonicalize"); + (temp_dir, canonical) +} + +fn stub_story(story_id: &str) -> UserStory { + serde_json::from_value(serde_json::json!({ + "id": story_id, + "title": format!("Story {story_id}"), + "acceptanceCriteria": ["placeholder"] + })) + .expect("stub story should parse") +} + +#[test] +fn different_stories_receive_different_worktree_paths() { + let work_dir = Path::new("/repo"); + let stories = ["S-001", "S-002", "S-003", "S-015"]; + let paths: Vec = stories + .iter() + .map(|story_id| worktree_path_for(work_dir, story_id)) + .collect(); + + for (idx, path) in paths.iter().enumerate() { + for (jdx, other) in paths.iter().enumerate() { + if idx != jdx { + assert_ne!(path, other, "{} and {} must differ", stories[idx], stories[jdx]); + } + } + } +} + +#[test] +fn different_stories_receive_different_branches() { + let branches: Vec = ["S-001", "S-002", "S-015"] + .iter() + .map(|story_id| branch_for(story_id)) + .collect(); + + assert_ne!(branches[0], branches[1]); + assert_ne!(branches[1], branches[2]); +} + +#[tokio::test] +async fn provision_creates_worktree_directory() { + let (_temp_dir, repo_dir) = init_repo(); + + let provisioned = provision(&repo_dir, "S-015") + .await + .expect("provision should succeed"); + + assert_eq!(provisioned.story_id, "S-015"); + assert!(provisioned.worktree_path.exists()); + assert_eq!(provisioned.branch, "loopforge/s-015"); + assert!(provisioned.worktree_path.join("README.md").exists()); +} + +#[tokio::test] +async fn provision_fails_gracefully_on_invalid_repo() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let bad_dir = temp_dir.path().join("not-a-repo"); + std::fs::create_dir(&bad_dir).expect("dir"); + + let result = provision(&bad_dir, "S-001").await; + assert!(result.is_err()); +} + +#[tokio::test] +async fn two_stories_get_separate_worktrees() { + let (_temp_dir, repo_dir) = init_repo(); + + let first = provision(&repo_dir, "S-001") + .await + .expect("first provision"); + let second = provision(&repo_dir, "S-002") + .await + .expect("second provision"); + + assert_ne!(first.worktree_path, second.worktree_path); + assert_ne!(first.branch, second.branch); + assert!(first.worktree_path.exists()); + assert!(second.worktree_path.exists()); +} + +#[tokio::test] +async fn worker_fixture_runs_sequentially_through_core_loop() { + let (_temp_dir, repo_dir) = init_repo(); + let execution_log: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let story_a = stub_story("S-001"); + let story_b = stub_story("S-002"); + + let log_clone = Arc::clone(&execution_log); + let result_a = run_in_worktree(&repo_dir, &story_a, |provisioned| { + let log = log_clone; + async move { + log.lock().unwrap().push(provisioned.story_id.clone()); + provisioned.worktree_path + } + }) + .await + .expect("run_in_worktree S-001"); + + let log_clone = Arc::clone(&execution_log); + let result_b = run_in_worktree(&repo_dir, &story_b, |provisioned| { + let log = log_clone; + async move { + log.lock().unwrap().push(provisioned.story_id.clone()); + provisioned.worktree_path + } + }) + .await + .expect("run_in_worktree S-002"); + + let log = execution_log.lock().unwrap(); + assert_eq!(log.len(), 2); + assert_eq!(log[0], "S-001"); + assert_eq!(log[1], "S-002"); + assert_ne!(result_a, result_b); +} + +#[tokio::test] +async fn worktree_creation_failure_prevents_worker_execution() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let bad_dir = temp_dir.path().join("not-a-repo"); + std::fs::create_dir(&bad_dir).expect("dir"); + + let story = stub_story("S-001"); + let worker_called = Arc::new(Mutex::new(false)); + let flag = Arc::clone(&worker_called); + + let result = run_in_worktree(&bad_dir, &story, |_provisioned| { + let called = flag; + async move { + *called.lock().unwrap() = true; + } + }) + .await; + + assert!(result.is_err()); + assert!(!*worker_called.lock().unwrap()); +} From a25c8a047fe85dd9ed31dd14a7b176614aea9fb5 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 19:37:11 -0600 Subject: [PATCH 08/61] feat(core): serialize shared artifact writes --- crates/ralph-core/src/loop_engine/mod.rs | 204 +++++++++++++++--- .../ralph-core/src/scheduler/coordinator.rs | 141 ++++++++++++ crates/ralph-core/src/scheduler/mod.rs | 3 + .../ralph-core/tests/artifact_coordinator.rs | 122 +++++++++++ 4 files changed, 438 insertions(+), 32 deletions(-) create mode 100644 crates/ralph-core/src/scheduler/coordinator.rs create mode 100644 crates/ralph-core/tests/artifact_coordinator.rs diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index f5a20af..946e923 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -6,10 +6,10 @@ mod rate_limiter; pub mod scheduler; pub mod worktree; -pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; pub use crate::scheduler::worktree_runner::{ - ProvisionedWorktree, provision as provision_worktree, run_in_worktree, + provision as provision_worktree, run_in_worktree, ProvisionedWorktree, }; +pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; use crate::config::RalphConfig; use crate::detection::failure_memory::FailureMemory; @@ -17,15 +17,15 @@ use crate::detection::progress; use crate::errors::LoopError; use crate::events::{HeartbeatContext, LoopEvent, LoopEventSink}; use crate::git; -use crate::guardrails; use crate::health; use crate::logger; use crate::prompt::PromptBuilder; use crate::providers::Provider; use crate::scheduler as story_scheduler; +use crate::scheduler::{ArtifactCoordinator, SharedArtifactUpdate}; use crate::state; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; pub async fn run( config: &RalphConfig, @@ -34,7 +34,10 @@ pub async fn run( event_sink: &dyn LoopEventSink, ) -> Result<(), LoopError> { let mut iter_count: u32 = 0; - let initial_commit = git::get_head_hash(&config.paths.work_dir).await.unwrap_or_default(); + let artifact_coordinator = ArtifactCoordinator::for_loop_engine(config.clone()); + let initial_commit = git::get_head_hash(&config.paths.work_dir) + .await + .unwrap_or_default(); let mut failure_memory = FailureMemory::load(&config.paths.failure_memory_file); let mut consecutive_zero_progress: u32 = 0; while iter_count < config.tuning.max_iterations { @@ -47,7 +50,10 @@ pub async fn run( break; } if !health::check_all_services_parallel(config).await { - logger::log_error("Services unavailable, retrying in 30s", Some(&config.paths.error_log)); + logger::log_error( + "Services unavailable, retrying in 30s", + Some(&config.paths.error_log), + ); tokio::time::sleep(std::time::Duration::from_secs(30)).await; continue; } @@ -59,7 +65,11 @@ pub async fn run( } }; if prd.pending_count() == 0 { - logger::log_success(&format!("All stories completed! {} passed, {} blocked.", prd.passed_count(), prd.blocked_count())); + logger::log_success(&format!( + "All stories completed! {} passed, {} blocked.", + prd.passed_count(), + prd.blocked_count() + )); break; } let next_story = match story_scheduler::ready_stories(&prd).into_iter().next() { @@ -74,12 +84,22 @@ pub async fn run( logger::log_iteration_header(iter_count); log_progress(&prd, &next_story.title, config, iter_count); if failure_memory.is_in_gutter(&next_story.id, config.tuning.gutter_threshold) { - handle_gutter_skip(config, &mut prd, &next_story.id, iter_count, event_sink)?; + handle_gutter_skip( + &artifact_coordinator, + config, + &mut prd, + &next_story.id, + iter_count, + event_sink, + ) + .await?; continue; } prd.save(&config.paths.prd_backup)?; let progress_before = progress::get_progress_file_size(&config.paths.progress_file); - let head_before = git::get_head_hash(&config.paths.work_dir).await.unwrap_or_default(); + let head_before = git::get_head_hash(&config.paths.work_dir) + .await + .unwrap_or_default(); let built = build_prompt(config, iter_count, &failure_memory, &next_story, event_sink); if let Err(validation_errors) = built.validate(&next_story.id) { tracing::error!(story_id = %next_story.id, errors = ?validation_errors, "prompt validation failed"); @@ -89,15 +109,69 @@ pub async fn run( }); continue; } - logger::log_info(&format!("Starting {} agent ({})...", provider.name(), provider.model())); - let outcome = iteration::run_with_verification(config, provider, &next_story, built.text, built.hash, &shutdown_flag, event_sink, &mut failure_memory, iter_count).await; - let final_passed = outcome.story_passed || check_story_passed_in_prd(config, &next_story.id); - let progress_report = progress::analyze_iteration_progress(&config.paths.work_dir, &head_before, final_passed, &config.paths.progress_file, progress_before).await; - outcome::handle(config, &outcome.agent_result, &mut prd, &next_story.id, final_passed, &progress_report, &mut failure_memory, &mut consecutive_zero_progress, &mut iter_count, &shutdown_flag, event_sink).await?; + logger::log_info(&format!( + "Starting {} agent ({})...", + provider.name(), + provider.model() + )); + let outcome = iteration::run_with_verification( + config, + provider, + &next_story, + built.text, + built.hash, + &shutdown_flag, + event_sink, + &mut failure_memory, + iter_count, + ) + .await; + let final_passed = + outcome.story_passed || check_story_passed_in_prd(config, &next_story.id); + let progress_report = progress::analyze_iteration_progress( + &config.paths.work_dir, + &head_before, + final_passed, + &config.paths.progress_file, + progress_before, + ) + .await; + outcome::handle( + config, + &outcome.agent_result, + &mut prd, + &next_story.id, + final_passed, + &progress_report, + &mut failure_memory, + &mut consecutive_zero_progress, + &mut iter_count, + &shutdown_flag, + event_sink, + ) + .await?; failure_memory.save(&config.paths.failure_memory_file)?; - log_iteration_complete(config, iter_count, &initial_commit, &next_story.id, final_passed, &iter_start).await; - logger::log_info(&format!("Cooldown {}s before next iteration...", config.tuning.cooldown_secs)); - if helpers::interruptible_sleep(config.tuning.cooldown_secs, &shutdown_flag, event_sink, HeartbeatContext::CooldownWait).await { + log_iteration_complete( + config, + iter_count, + &initial_commit, + &next_story.id, + final_passed, + &iter_start, + ) + .await; + logger::log_info(&format!( + "Cooldown {}s before next iteration...", + config.tuning.cooldown_secs + )); + if helpers::interruptible_sleep( + config.tuning.cooldown_secs, + &shutdown_flag, + event_sink, + HeartbeatContext::CooldownWait, + ) + .await + { logger::log_warning("Shutdown requested during cooldown"); break; } @@ -114,23 +188,68 @@ fn log_progress(prd: &crate::prd::Prd, story_title: &str, config: &RalphConfig, let pending = prd.pending_count(); tracing::info!(passed, total, pending, blocked, "Progress"); tracing::info!(story = %story_title, "Current story"); - logger::log_activity(&format!("=== Iteration {iteration} started ==="), &config.paths.activity_log); + logger::log_activity( + &format!("=== Iteration {iteration} started ==="), + &config.paths.activity_log, + ); } -fn handle_gutter_skip(config: &RalphConfig, prd: &mut crate::prd::Prd, story_id: &str, iteration: u32, event_sink: &dyn LoopEventSink) -> Result<(), LoopError> { - logger::log_warning(&format!("GUTTER: {story_id} has failed {}+ times, skipping", config.tuning.gutter_threshold)); - event_sink.emit(LoopEvent::StorySkipped { story_id: story_id.to_string(), reason: format!("gutter threshold ({} failures)", config.tuning.gutter_threshold) }); - if let Err(err) = guardrails::add_guardrail(&config.paths.guardrails_file, story_id, "Exceeded gutter threshold", iteration) { - tracing::warn!(error = %err, "failed to write guardrail"); +async fn handle_gutter_skip( + artifact_coordinator: &ArtifactCoordinator, + config: &RalphConfig, + prd: &mut crate::prd::Prd, + story_id: &str, + iteration: u32, + event_sink: &dyn LoopEventSink, +) -> Result<(), LoopError> { + logger::log_warning(&format!( + "GUTTER: {story_id} has failed {}+ times, skipping", + config.tuning.gutter_threshold + )); + event_sink.emit(LoopEvent::StorySkipped { + story_id: story_id.to_string(), + reason: format!( + "gutter threshold ({} failures)", + config.tuning.gutter_threshold + ), + }); + if let Err(err) = artifact_coordinator + .submit(SharedArtifactUpdate::BlockStoryAndAddGuardrail { + story_id: story_id.to_string(), + error_message: "Exceeded gutter threshold".to_string(), + iteration, + }) + .await + { + tracing::warn!(error = %err, "failed to update shared artifacts"); + } else { + prd.stories + .iter_mut() + .find(|story| story.id == story_id) + .map(|story| story.blocked = true); } - logger::log_activity(&format!("Story {story_id} skipped (gutter threshold)"), &config.paths.activity_log); - prd.stories.iter_mut().find(|story| story.id == story_id).map(|story| story.blocked = true); - prd.save(&config.paths.prd_file)?; + logger::log_activity( + &format!("Story {story_id} skipped (gutter threshold)"), + &config.paths.activity_log, + ); Ok(()) } -fn build_prompt(config: &RalphConfig, iteration: u32, failure_memory: &FailureMemory, story: &crate::prd::UserStory, event_sink: &dyn LoopEventSink) -> crate::prompt::BuiltPrompt { - let builder = PromptBuilder::new(&config.paths.prompt_file, &config.paths.guardrails_file, &config.paths.ralph_dir, &config.paths.work_dir, iteration, failure_memory); +fn build_prompt( + config: &RalphConfig, + iteration: u32, + failure_memory: &FailureMemory, + story: &crate::prd::UserStory, + event_sink: &dyn LoopEventSink, +) -> crate::prompt::BuiltPrompt { + let builder = PromptBuilder::new( + &config.paths.prompt_file, + &config.paths.guardrails_file, + &config.paths.ralph_dir, + &config.paths.work_dir, + iteration, + failure_memory, + ); let built = builder.build(story, None); event_sink.emit(LoopEvent::PromptBuilt { story_id: story.id.clone(), @@ -138,17 +257,38 @@ fn build_prompt(config: &RalphConfig, iteration: u32, failure_memory: &FailureMe hash: built.hash, truncated: built.truncated, }); - tracing::debug!(size = built.size_bytes, hash = built.hash, truncated = built.truncated, "prompt built"); + tracing::debug!( + size = built.size_bytes, + hash = built.hash, + truncated = built.truncated, + "prompt built" + ); built } fn check_story_passed_in_prd(config: &RalphConfig, story_id: &str) -> bool { - prd_lifecycle::load_or_restore(config).and_then(|prd| prd.stories.iter().find(|st| st.id == story_id).map(|st| st.passes)).unwrap_or(false) + prd_lifecycle::load_or_restore(config) + .and_then(|prd| { + prd.stories + .iter() + .find(|st| st.id == story_id) + .map(|st| st.passes) + }) + .unwrap_or(false) } -async fn log_iteration_complete(config: &RalphConfig, iteration: u32, initial_commit: &str, story_id: &str, passed: bool, start: &std::time::Instant) { +async fn log_iteration_complete( + config: &RalphConfig, + iteration: u32, + initial_commit: &str, + story_id: &str, + passed: bool, + start: &std::time::Instant, +) { let elapsed = start.elapsed().as_secs(); - let total_commits = git::count_commits_since(&config.paths.work_dir, initial_commit).await.unwrap_or(0); + let total_commits = git::count_commits_since(&config.paths.work_dir, initial_commit) + .await + .unwrap_or(0); logger::log_activity(&format!("Iteration {iteration} completed in {} | Commits: {total_commits} | Story: {story_id} | Passed: {passed}", logger::format_duration(elapsed)), &config.paths.activity_log); tracing::info!(duration = %logger::format_duration(elapsed), commits = total_commits, "Iteration complete"); } diff --git a/crates/ralph-core/src/scheduler/coordinator.rs b/crates/ralph-core/src/scheduler/coordinator.rs new file mode 100644 index 0000000..38e2e5f --- /dev/null +++ b/crates/ralph-core/src/scheduler/coordinator.rs @@ -0,0 +1,141 @@ +use crate::config::RalphConfig; +use crate::guardrails; +use crate::prd::Prd; +use std::sync::Arc; +use thiserror::Error; +use tokio::sync::Mutex; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SharedArtifactUpdate { + MarkStoryPassed { + story_id: String, + }, + MarkStoryBlocked { + story_id: String, + }, + AppendGuardrail { + story_id: String, + error_message: String, + iteration: u32, + }, + BlockStoryAndAddGuardrail { + story_id: String, + error_message: String, + iteration: u32, + }, +} + +#[derive(Debug, Clone, Error, PartialEq, Eq)] +#[error("{message}")] +pub struct CoordinatorError { + message: String, +} + +impl CoordinatorError { + pub fn apply(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +#[derive(Clone)] +pub struct ArtifactCoordinator { + applier: Arc Result<(), CoordinatorError> + Send + Sync>, + gate: Arc>, +} + +impl ArtifactCoordinator { + pub fn new(applier: F) -> Self + where + F: Fn(SharedArtifactUpdate) -> Result<(), CoordinatorError> + Send + Sync + 'static, + { + Self { + applier: Arc::new(applier), + gate: Arc::new(Mutex::new(())), + } + } + + pub fn for_loop_engine(config: RalphConfig) -> Self { + Self::new(move |update| apply_update(&config, update)) + } + + pub async fn submit(&self, update: SharedArtifactUpdate) -> Result<(), CoordinatorError> { + let _guard = self.gate.lock().await; + let applier = self.applier.clone(); + tokio::task::spawn_blocking(move || (applier)(update)) + .await + .map_err(|err| CoordinatorError::apply(err.to_string()))? + } +} + +fn apply_update( + config: &RalphConfig, + update: SharedArtifactUpdate, +) -> Result<(), CoordinatorError> { + match update { + SharedArtifactUpdate::MarkStoryPassed { story_id } => { + update_story_state(config, &story_id, true, false) + } + SharedArtifactUpdate::MarkStoryBlocked { story_id } => { + update_story_state(config, &story_id, false, true) + } + SharedArtifactUpdate::AppendGuardrail { + story_id, + error_message, + iteration, + } => append_guardrail(config, &story_id, &error_message, iteration), + SharedArtifactUpdate::BlockStoryAndAddGuardrail { + story_id, + error_message, + iteration, + } => { + append_guardrail(config, &story_id, &error_message, iteration)?; + update_story_state(config, &story_id, false, true) + } + } +} + +fn update_story_state( + config: &RalphConfig, + story_id: &str, + passed: bool, + blocked: bool, +) -> Result<(), CoordinatorError> { + let mut prd = load_or_restore_prd(config)?; + if let Some(story) = prd.stories.iter_mut().find(|story| story.id == story_id) { + story.passes = passed; + story.blocked = blocked; + } + prd.save(&config.paths.prd_file) + .map_err(|err| CoordinatorError::apply(err.to_string())) +} + +fn append_guardrail( + config: &RalphConfig, + story_id: &str, + error_message: &str, + iteration: u32, +) -> Result<(), CoordinatorError> { + guardrails::add_guardrail( + &config.paths.guardrails_file, + story_id, + error_message, + iteration, + ) + .map_err(|err| CoordinatorError::apply(err.to_string())) +} + +fn load_or_restore_prd(config: &RalphConfig) -> Result { + if Prd::is_valid_json(&config.paths.prd_file) { + return Prd::load(&config.paths.prd_file) + .map_err(|err| CoordinatorError::apply(err.to_string())); + } + if config.paths.prd_backup.exists() { + std::fs::copy(&config.paths.prd_backup, &config.paths.prd_file) + .map_err(|err| CoordinatorError::apply(err.to_string()))?; + return Prd::load(&config.paths.prd_file) + .map_err(|err| CoordinatorError::apply(err.to_string())); + } + Err(CoordinatorError::apply("PRD missing or corrupted")) +} diff --git a/crates/ralph-core/src/scheduler/mod.rs b/crates/ralph-core/src/scheduler/mod.rs index ff948a4..b73896b 100644 --- a/crates/ralph-core/src/scheduler/mod.rs +++ b/crates/ralph-core/src/scheduler/mod.rs @@ -1,8 +1,11 @@ +mod coordinator; pub mod worktree_runner; use crate::prd::{Prd, UserStory}; use std::collections::HashSet; +pub use coordinator::{ArtifactCoordinator, CoordinatorError, SharedArtifactUpdate}; + pub fn ready_stories(prd: &Prd) -> Vec<&UserStory> { let passed_ids: HashSet<&str> = prd .stories diff --git a/crates/ralph-core/tests/artifact_coordinator.rs b/crates/ralph-core/tests/artifact_coordinator.rs new file mode 100644 index 0000000..bdfdbdb --- /dev/null +++ b/crates/ralph-core/tests/artifact_coordinator.rs @@ -0,0 +1,122 @@ +use ralph_core::config::RalphConfig; +use ralph_core::prd::Prd; +use ralph_core::scheduler::{ArtifactCoordinator, SharedArtifactUpdate}; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use tempfile::tempdir; +use tokio::sync::Mutex; + +#[derive(Clone, Debug, PartialEq, Eq)] +enum Event { + Start(String), + Finish(String), +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn concurrent_updates_are_applied_one_at_a_time() { + let events = Arc::new(Mutex::new(Vec::new())); + let active = Arc::new(AtomicUsize::new(0)); + let max_active = Arc::new(AtomicUsize::new(0)); + let coordinator = { + let events = events.clone(); + let active = active.clone(); + let max_active = max_active.clone(); + ArtifactCoordinator::new(move |update| { + let story_id = match update { + SharedArtifactUpdate::MarkStoryBlocked { story_id } => story_id, + _ => { + return Err(ralph_core::scheduler::CoordinatorError::apply( + "unexpected update", + )) + } + }; + let now_active = active.fetch_add(1, Ordering::SeqCst) + 1; + max_active.fetch_max(now_active, Ordering::SeqCst); + events.blocking_lock().push(Event::Start(story_id.clone())); + std::thread::sleep(std::time::Duration::from_millis(20)); + events.blocking_lock().push(Event::Finish(story_id)); + active.fetch_sub(1, Ordering::SeqCst); + Ok(()) + }) + }; + + let mut tasks = Vec::new(); + for index in 0..6 { + let coordinator = coordinator.clone(); + tasks.push(tokio::spawn(async move { + coordinator + .submit(SharedArtifactUpdate::MarkStoryBlocked { + story_id: format!("S-{index:03}"), + }) + .await + })); + } + for task in tasks { + task.await + .expect("task should join") + .expect("update should apply"); + } + + assert_eq!(max_active.load(Ordering::SeqCst), 1); + let events = events.lock().await.clone(); + assert_eq!(events.len(), 12); + for pair in events.chunks_exact(2) { + match (&pair[0], &pair[1]) { + (Event::Start(started), Event::Finish(finished)) => assert_eq!(started, finished), + other => panic!("unexpected event sequence: {other:?}"), + } + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn loop_engine_gutter_skip_updates_flow_through_the_coordinator() { + let temp = tempdir().expect("tempdir should exist"); + let ralph_dir = temp.path().join("ralph"); + std::fs::create_dir_all(&ralph_dir).expect("ralph dir should exist"); + let mut config = RalphConfig::from_defaults(&ralph_dir); + config.paths.prd_file = ralph_dir.join("prd.json"); + config.paths.prd_backup = ralph_dir.join("prd.backup.json"); + config.paths.guardrails_file = ralph_dir.join("guardrails.md"); + + let prd = r#"{ + "project": "loopforge", + "stories": [ + { "id": "S-016", "title": "Serialize", "acceptanceCriteria": ["serialize"] }, + { "id": "S-017", "title": "Neighbor", "acceptanceCriteria": ["neighbor"] } + ] + }"#; + std::fs::write(&config.paths.prd_file, prd).expect("prd should write"); + std::fs::write(&config.paths.prd_backup, prd).expect("prd backup should write"); + + let coordinator = ArtifactCoordinator::for_loop_engine(config.clone()); + let first = coordinator.submit(SharedArtifactUpdate::BlockStoryAndAddGuardrail { + story_id: "S-016".into(), + error_message: "Exceeded gutter threshold".into(), + iteration: 1, + }); + let second = coordinator.submit(SharedArtifactUpdate::BlockStoryAndAddGuardrail { + story_id: "S-017".into(), + error_message: "Exceeded gutter threshold".into(), + iteration: 2, + }); + let (first, second) = tokio::join!(first, second); + first.expect("first update should apply"); + second.expect("second update should apply"); + + let updated = Prd::load(&config.paths.prd_file).expect("prd should load"); + assert!(updated.stories.iter().all(|story| story.blocked)); + let guardrails = + std::fs::read_to_string(&config.paths.guardrails_file).expect("guardrails should exist"); + assert!(guardrails.contains("Sign: Error in S-016")); + assert!(guardrails.contains("Sign: Error in S-017")); + + let source = std::fs::read_to_string(format!( + "{}/src/loop_engine/mod.rs", + env!("CARGO_MANIFEST_DIR") + )) + .expect("loop engine source should read"); + assert!(source.contains("artifact_coordinator")); + assert!(source.contains(".submit(")); + assert!(source.contains("SharedArtifactUpdate::BlockStoryAndAddGuardrail")); + assert!(!source.contains("guardrails::add_guardrail")); +} From 7e3fd76c9a6a6cd5e909c10b66c7df4c9c2bd8b8 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 19:49:18 -0600 Subject: [PATCH 09/61] feat(core): run ready stories in parallel --- crates/ralph-core/src/loop_engine/mod.rs | 482 +++++++++++++++--- .../ralph-core/src/scheduler/coordinator.rs | 32 ++ crates/ralph-core/src/scheduler/mod.rs | 27 + .../src/scheduler/worktree_runner.rs | 53 ++ crates/ralph-core/tests/parallel_scheduler.rs | 183 +++++++ 5 files changed, 693 insertions(+), 84 deletions(-) create mode 100644 crates/ralph-core/tests/parallel_scheduler.rs diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index 946e923..0a50a0a 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -9,10 +9,10 @@ pub mod worktree; pub use crate::scheduler::worktree_runner::{ provision as provision_worktree, run_in_worktree, ProvisionedWorktree, }; -pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; +pub use worktree::{LoopExecutionState, PRIMARY_WORKTREE_ID, WorktreeExecutionState}; use crate::config::RalphConfig; -use crate::detection::failure_memory::FailureMemory; +use crate::detection::failure_memory::{FailureMemory, StoryFailureRecord}; use crate::detection::progress; use crate::errors::LoopError; use crate::events::{HeartbeatContext, LoopEvent, LoopEventSink}; @@ -24,8 +24,20 @@ use crate::providers::Provider; use crate::scheduler as story_scheduler; use crate::scheduler::{ArtifactCoordinator, SharedArtifactUpdate}; use crate::state; -use std::sync::atomic::{AtomicBool, Ordering}; +use std::future::{poll_fn, Future}; +use std::pin::Pin; use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::Poll; + +use self::scheduler::WorktreeCompletion; + +type WorkerFuture<'a> = Pin> + Send + 'a>>; + +struct WorkerReport { + completion: WorktreeCompletion, + failure_record: Option, +} pub async fn run( config: &RalphConfig, @@ -39,7 +51,6 @@ pub async fn run( .await .unwrap_or_default(); let mut failure_memory = FailureMemory::load(&config.paths.failure_memory_file); - let mut consecutive_zero_progress: u32 = 0; while iter_count < config.tuning.max_iterations { if shutdown_flag.load(Ordering::SeqCst) { logger::log_warning("Shutdown requested, exiting loop"); @@ -72,94 +83,46 @@ pub async fn run( )); break; } - let next_story = match story_scheduler::ready_stories(&prd).into_iter().next() { - Some(story) => story.clone(), - None => { - logger::log_success("No more actionable stories. Done."); - break; - } - }; + let ready_stories = story_scheduler::ready_story_batch(&prd); + if ready_stories.is_empty() { + logger::log_success("No more actionable stories. Done."); + break; + } iter_count += 1; let iter_start = std::time::Instant::now(); logger::log_iteration_header(iter_count); - log_progress(&prd, &next_story.title, config, iter_count); - if failure_memory.is_in_gutter(&next_story.id, config.tuning.gutter_threshold) { - handle_gutter_skip( - &artifact_coordinator, + log_progress(&prd, &ready_stories[0].title, config, iter_count); + if ready_stories.len() == 1 { + run_single_ready_story( config, + provider, + &artifact_coordinator, + shutdown_flag.clone(), + event_sink, &mut prd, - &next_story.id, + ready_stories[0].clone(), + &mut failure_memory, iter_count, + &initial_commit, + &iter_start, + ) + .await?; + } else { + run_parallel_ready_stories( + config, + provider, + &artifact_coordinator, + shutdown_flag.clone(), event_sink, + &mut prd, + ready_stories, + &mut failure_memory, + iter_count, + &initial_commit, + &iter_start, ) .await?; - continue; - } - prd.save(&config.paths.prd_backup)?; - let progress_before = progress::get_progress_file_size(&config.paths.progress_file); - let head_before = git::get_head_hash(&config.paths.work_dir) - .await - .unwrap_or_default(); - let built = build_prompt(config, iter_count, &failure_memory, &next_story, event_sink); - if let Err(validation_errors) = built.validate(&next_story.id) { - tracing::error!(story_id = %next_story.id, errors = ?validation_errors, "prompt validation failed"); - event_sink.emit(LoopEvent::StorySkipped { - story_id: next_story.id.clone(), - reason: format!("prompt validation failed: {}", validation_errors.join("; ")), - }); - continue; } - logger::log_info(&format!( - "Starting {} agent ({})...", - provider.name(), - provider.model() - )); - let outcome = iteration::run_with_verification( - config, - provider, - &next_story, - built.text, - built.hash, - &shutdown_flag, - event_sink, - &mut failure_memory, - iter_count, - ) - .await; - let final_passed = - outcome.story_passed || check_story_passed_in_prd(config, &next_story.id); - let progress_report = progress::analyze_iteration_progress( - &config.paths.work_dir, - &head_before, - final_passed, - &config.paths.progress_file, - progress_before, - ) - .await; - outcome::handle( - config, - &outcome.agent_result, - &mut prd, - &next_story.id, - final_passed, - &progress_report, - &mut failure_memory, - &mut consecutive_zero_progress, - &mut iter_count, - &shutdown_flag, - event_sink, - ) - .await?; - failure_memory.save(&config.paths.failure_memory_file)?; - log_iteration_complete( - config, - iter_count, - &initial_commit, - &next_story.id, - final_passed, - &iter_start, - ) - .await; logger::log_info(&format!( "Cooldown {}s before next iteration...", config.tuning.cooldown_secs @@ -181,6 +144,347 @@ pub async fn run( Ok(()) } +#[allow(clippy::too_many_arguments)] +async fn run_single_ready_story( + config: &RalphConfig, + provider: &P, + artifact_coordinator: &ArtifactCoordinator, + shutdown_flag: Arc, + event_sink: &dyn LoopEventSink, + prd: &mut crate::prd::Prd, + story: crate::prd::UserStory, + failure_memory: &mut FailureMemory, + iteration: u32, + initial_commit: &str, + iter_start: &std::time::Instant, +) -> Result<(), LoopError> { + if failure_memory.is_in_gutter(&story.id, config.tuning.gutter_threshold) { + handle_gutter_skip( + artifact_coordinator, + config, + prd, + &story.id, + iteration, + event_sink, + ) + .await?; + return Ok(()); + } + prd.save(&config.paths.prd_backup)?; + let progress_before = progress::get_progress_file_size(&config.paths.progress_file); + let head_before = git::get_head_hash(&config.paths.work_dir) + .await + .unwrap_or_default(); + let built = build_prompt(config, iteration, failure_memory, &story, event_sink); + if let Err(validation_errors) = built.validate(&story.id) { + tracing::error!(story_id = %story.id, errors = ?validation_errors, "prompt validation failed"); + event_sink.emit(LoopEvent::StorySkipped { + story_id: story.id.clone(), + reason: format!("prompt validation failed: {}", validation_errors.join("; ")), + }); + return Ok(()); + } + logger::log_info(&format!( + "Starting {} agent ({})...", + provider.name(), + provider.model() + )); + let outcome = iteration::run_with_verification( + config, + provider, + &story, + built.text, + built.hash, + &shutdown_flag, + event_sink, + failure_memory, + iteration, + ) + .await; + let final_passed = outcome.story_passed || check_story_passed_in_prd(config, &story.id); + let progress_report = progress::analyze_iteration_progress( + &config.paths.work_dir, + &head_before, + final_passed, + &config.paths.progress_file, + progress_before, + ) + .await; + let mut consecutive_zero_progress = 0; + let mut iteration_cursor = iteration; + outcome::handle( + config, + &outcome.agent_result, + prd, + &story.id, + final_passed, + &progress_report, + failure_memory, + &mut consecutive_zero_progress, + &mut iteration_cursor, + &shutdown_flag, + event_sink, + ) + .await?; + failure_memory.save(&config.paths.failure_memory_file)?; + log_iteration_complete( + config, + iteration, + initial_commit, + &story.id, + final_passed, + iter_start, + ) + .await; + Ok(()) +} + +#[allow(clippy::too_many_arguments)] +async fn run_parallel_ready_stories( + config: &RalphConfig, + provider: &P, + artifact_coordinator: &ArtifactCoordinator, + shutdown_flag: Arc, + event_sink: &dyn LoopEventSink, + prd: &mut crate::prd::Prd, + ready_stories: Vec, + failure_memory: &mut FailureMemory, + iteration: u32, + initial_commit: &str, + iter_start: &std::time::Instant, +) -> Result<(), LoopError> { + prd.save(&config.paths.prd_backup)?; + let shared_failure_memory = failure_memory.clone(); + let mut workers = Vec::new(); + for story in ready_stories { + if failure_memory.is_in_gutter(&story.id, config.tuning.gutter_threshold) { + handle_gutter_skip( + artifact_coordinator, + config, + prd, + &story.id, + iteration, + event_sink, + ) + .await?; + continue; + } + let provisioned = provision_worktree(&config.paths.work_dir, &story.id) + .await + .map_err(|err| LoopError::Other(anyhow::anyhow!(err.to_string())))?; + workers.push(Box::pin(execute_ready_story( + config, + provider, + shutdown_flag.clone(), + event_sink, + story, + provisioned, + shared_failure_memory.clone(), + iteration, + )) as WorkerFuture<'_>); + } + if workers.is_empty() { + return Ok(()); + } + let reports = collect_worker_reports(workers).await?; + for update in story_scheduler::shared_updates_for( + reports.iter().map(|report| report.completion.clone()).collect(), + ) { + artifact_coordinator + .submit(update) + .await + .map_err(|err| LoopError::Other(anyhow::anyhow!(err.to_string())))?; + } + for report in reports { + merge_failure_record(failure_memory, report.failure_record); + if let Some(story) = prd + .stories + .iter_mut() + .find(|story| story.id == report.completion.story_id) + { + story.passes = report.completion.passed; + story.blocked = report.completion.blocked; + } + log_iteration_complete( + config, + iteration, + initial_commit, + &report.completion.story_id, + report.completion.passed, + iter_start, + ) + .await; + } + failure_memory.save(&config.paths.failure_memory_file)?; + Ok(()) +} + +async fn collect_worker_reports( + mut workers: Vec>, +) -> Result, LoopError> { + let mut reports = Vec::with_capacity(workers.len()); + poll_fn(|cx| { + let mut index = 0; + while index < workers.len() { + match workers[index].as_mut().poll(cx) { + Poll::Ready(Ok(report)) => { + reports.push(report); + let _ = workers.swap_remove(index); + } + Poll::Ready(Err(err)) => return Poll::Ready(Err(err)), + Poll::Pending => index += 1, + } + } + if workers.is_empty() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + }) + .await?; + Ok(reports) +} + +#[allow(clippy::too_many_arguments)] +async fn execute_ready_story( + config: &RalphConfig, + provider: &P, + shutdown_flag: Arc, + event_sink: &dyn LoopEventSink, + story: crate::prd::UserStory, + provisioned: ProvisionedWorktree, + mut failure_memory: FailureMemory, + iteration: u32, +) -> Result { + let worker_config = + crate::scheduler::worktree_runner::prepare_worker_config(config, &provisioned) + .map_err(|err| LoopError::Other(anyhow::anyhow!(err.to_string())))?; + let mut worker_prd = + prd_lifecycle::load_or_restore(&worker_config).ok_or(LoopError::PrdUnrecoverable)?; + let guardrail_seed = + std::fs::read_to_string(&worker_config.paths.guardrails_file).unwrap_or_default(); + let progress_before = progress::get_progress_file_size(&worker_config.paths.progress_file); + let head_before = git::get_head_hash(&worker_config.paths.work_dir) + .await + .unwrap_or_default(); + let built = build_prompt(&worker_config, iteration, &failure_memory, &story, event_sink); + if let Err(validation_errors) = built.validate(&story.id) { + tracing::error!(story_id = %story.id, errors = ?validation_errors, "prompt validation failed"); + event_sink.emit(LoopEvent::StorySkipped { + story_id: story.id.clone(), + reason: format!("prompt validation failed: {}", validation_errors.join("; ")), + }); + return Ok(worker_report( + &story.id, + &provisioned.branch, + &worker_prd, + None, + failure_memory.get_record(&story.id).cloned(), + u64::from(iteration), + )); + } + logger::log_info(&format!( + "Starting {} agent ({}) in {}...", + provider.name(), + provider.model(), + provisioned.worktree_path.display() + )); + let outcome = iteration::run_with_verification( + &worker_config, + provider, + &story, + built.text, + built.hash, + &shutdown_flag, + event_sink, + &mut failure_memory, + iteration, + ) + .await; + let final_passed = + outcome.story_passed || check_story_passed_in_prd(&worker_config, &story.id); + let progress_report = progress::analyze_iteration_progress( + &worker_config.paths.work_dir, + &head_before, + final_passed, + &worker_config.paths.progress_file, + progress_before, + ) + .await; + let mut consecutive_zero_progress = 0; + let mut iteration_cursor = iteration; + outcome::handle( + &worker_config, + &outcome.agent_result, + &mut worker_prd, + &story.id, + final_passed, + &progress_report, + &mut failure_memory, + &mut consecutive_zero_progress, + &mut iteration_cursor, + &shutdown_flag, + event_sink, + ) + .await?; + Ok(worker_report( + &story.id, + &provisioned.branch, + &worker_prd, + guardrail_append(&guardrail_seed, &worker_config.paths.guardrails_file), + failure_memory.get_record(&story.id).cloned(), + u64::from(iteration), + )) +} + +fn worker_report( + story_id: &str, + worktree_id: &str, + prd: &crate::prd::Prd, + guardrail_append: Option, + failure_record: Option, + sequence: u64, +) -> WorkerReport { + let state = prd.stories.iter().find(|story| story.id == story_id); + WorkerReport { + completion: WorktreeCompletion { + worktree_id: worktree_id.to_string(), + story_id: story_id.to_string(), + passed: state.map(|story| story.passes).unwrap_or(false), + blocked: state.map(|story| story.blocked).unwrap_or(false), + head_commit: None, + guardrail_append, + sequence, + }, + failure_record, + } +} + +fn merge_failure_record(failure_memory: &mut FailureMemory, record: Option) { + let Some(record) = record else { + return; + }; + if let Some(existing) = failure_memory + .stories + .iter_mut() + .find(|existing| existing.story_id == record.story_id) + { + *existing = record; + } else { + failure_memory.stories.push(record); + } +} + +fn guardrail_append(seed: &str, path: &std::path::Path) -> Option { + let current = std::fs::read_to_string(path).ok()?; + let appended = current.strip_prefix(seed).unwrap_or(current.as_str()).trim(); + if appended.is_empty() { + None + } else { + Some(format!("\n{appended}\n")) + } +} + fn log_progress(prd: &crate::prd::Prd, story_title: &str, config: &RalphConfig, iteration: u32) { let total = prd.total_stories(); let passed = prd.passed_count(); @@ -289,6 +593,16 @@ async fn log_iteration_complete( let total_commits = git::count_commits_since(&config.paths.work_dir, initial_commit) .await .unwrap_or(0); - logger::log_activity(&format!("Iteration {iteration} completed in {} | Commits: {total_commits} | Story: {story_id} | Passed: {passed}", logger::format_duration(elapsed)), &config.paths.activity_log); - tracing::info!(duration = %logger::format_duration(elapsed), commits = total_commits, "Iteration complete"); + logger::log_activity( + &format!( + "Iteration {iteration} completed in {} | Commits: {total_commits} | Story: {story_id} | Passed: {passed}", + logger::format_duration(elapsed) + ), + &config.paths.activity_log, + ); + tracing::info!( + duration = %logger::format_duration(elapsed), + commits = total_commits, + "Iteration complete" + ); } diff --git a/crates/ralph-core/src/scheduler/coordinator.rs b/crates/ralph-core/src/scheduler/coordinator.rs index 38e2e5f..630dcc3 100644 --- a/crates/ralph-core/src/scheduler/coordinator.rs +++ b/crates/ralph-core/src/scheduler/coordinator.rs @@ -18,6 +18,10 @@ pub enum SharedArtifactUpdate { error_message: String, iteration: u32, }, + AppendGuardrailContent { + story_id: String, + content: String, + }, BlockStoryAndAddGuardrail { story_id: String, error_message: String, @@ -85,6 +89,9 @@ fn apply_update( error_message, iteration, } => append_guardrail(config, &story_id, &error_message, iteration), + SharedArtifactUpdate::AppendGuardrailContent { story_id, content } => { + append_guardrail_content(config, &story_id, &content) + } SharedArtifactUpdate::BlockStoryAndAddGuardrail { story_id, error_message, @@ -126,6 +133,31 @@ fn append_guardrail( .map_err(|err| CoordinatorError::apply(err.to_string())) } +fn append_guardrail_content( + config: &RalphConfig, + story_id: &str, + content: &str, +) -> Result<(), CoordinatorError> { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(&config.paths.guardrails_file) + .map_err(|err| CoordinatorError::apply(err.to_string()))?; + let mut payload = content.trim_start_matches('\n').to_string(); + if payload.is_empty() { + return Ok(()); + } + if !payload.starts_with("### Sign:") { + payload = format!("\n### Sign: Error in {story_id}\n{payload}"); + } + if !payload.ends_with('\n') { + payload.push('\n'); + } + use std::io::Write; + file.write_all(payload.as_bytes()) + .map_err(|err| CoordinatorError::apply(err.to_string())) +} + fn load_or_restore_prd(config: &RalphConfig) -> Result { if Prd::is_valid_json(&config.paths.prd_file) { return Prd::load(&config.paths.prd_file) diff --git a/crates/ralph-core/src/scheduler/mod.rs b/crates/ralph-core/src/scheduler/mod.rs index b73896b..26aeeec 100644 --- a/crates/ralph-core/src/scheduler/mod.rs +++ b/crates/ralph-core/src/scheduler/mod.rs @@ -1,6 +1,7 @@ mod coordinator; pub mod worktree_runner; +use crate::loop_engine::scheduler::{schedule as schedule_completions, MergeAction, WorktreeCompletion}; use crate::prd::{Prd, UserStory}; use std::collections::HashSet; @@ -20,6 +21,32 @@ pub fn ready_stories(prd: &Prd) -> Vec<&UserStory> { .collect() } +pub fn ready_story_batch(prd: &Prd) -> Vec { + ready_stories(prd).into_iter().cloned().collect() +} + +pub fn shared_updates_for(completions: Vec) -> Vec { + schedule_completions(completions) + .into_iter() + .filter_map(|action| match action { + MergeAction::UpdateStoryStatus { + story_id, + passed, + blocked: _, + } if passed => Some(SharedArtifactUpdate::MarkStoryPassed { story_id }), + MergeAction::UpdateStoryStatus { + story_id, + passed: _, + blocked, + } if blocked => Some(SharedArtifactUpdate::MarkStoryBlocked { story_id }), + MergeAction::AppendGuardrail { story_id, content } => { + Some(SharedArtifactUpdate::AppendGuardrailContent { story_id, content }) + } + _ => None, + }) + .collect() +} + fn is_ready(story: &UserStory, passed_ids: &HashSet<&str>) -> bool { !story.passes && !story.blocked diff --git a/crates/ralph-core/src/scheduler/worktree_runner.rs b/crates/ralph-core/src/scheduler/worktree_runner.rs index 65b6c22..1dbda1c 100644 --- a/crates/ralph-core/src/scheduler/worktree_runner.rs +++ b/crates/ralph-core/src/scheduler/worktree_runner.rs @@ -1,9 +1,11 @@ +use crate::config::RalphConfig; use crate::prd::UserStory; use crate::worktree::{self, WorktreeError, WorktreeInfo}; use std::future::Future; use std::path::{Path, PathBuf}; const WORKTREE_BASE: &str = ".loopforge/worktrees"; +const ARTIFACT_BASE: &str = ".loopforge/artifacts"; #[derive(Debug, Clone)] pub struct ProvisionedWorktree { @@ -21,6 +23,10 @@ pub fn branch_for(story_id: &str) -> String { format!("loopforge/{}", sanitize(story_id)) } +pub fn artifact_dir_for(worktree_path: &Path, story_id: &str) -> PathBuf { + worktree_path.join(ARTIFACT_BASE).join(sanitize(story_id)) +} + fn sanitize(story_id: &str) -> String { story_id .to_lowercase() @@ -55,6 +61,53 @@ where Ok(worker(provisioned).await) } +pub fn prepare_worker_config( + config: &RalphConfig, + provisioned: &ProvisionedWorktree, +) -> Result { + let artifact_dir = artifact_dir_for(&provisioned.worktree_path, &provisioned.story_id); + std::fs::create_dir_all(&artifact_dir)?; + let mut worker = config.clone(); + worker.paths.ralph_dir = artifact_dir.clone(); + worker.paths.work_dir = provisioned.worktree_path.clone(); + worker.paths.prd_file = artifact_dir.join("prd.json"); + worker.paths.prd_backup = artifact_dir.join("prd.backup.json"); + worker.paths.prompt_file = artifact_dir.join("prompt.md"); + worker.paths.progress_file = artifact_dir.join("progress.log"); + worker.paths.guardrails_file = artifact_dir.join("guardrails.md"); + worker.paths.error_log = artifact_dir.join("error.log"); + worker.paths.activity_log = artifact_dir.join("activity.log"); + worker.paths.state_file = artifact_dir.join("state.json"); + worker.paths.pause_file = artifact_dir.join("pause"); + worker.paths.done_file = artifact_dir.join("done"); + worker.paths.failure_memory_file = artifact_dir.join("failure_memory.json"); + worker.paths.last_rebase_file = artifact_dir.join("last_rebase"); + worker.paths.codex_output_log = artifact_dir.join("codex_output.log"); + copy_or_init(&config.paths.prd_file, &worker.paths.prd_file)?; + let backup_source = if config.paths.prd_backup.exists() { + &config.paths.prd_backup + } else { + &config.paths.prd_file + }; + copy_or_init(backup_source, &worker.paths.prd_backup)?; + copy_or_init(&config.paths.prompt_file, &worker.paths.prompt_file)?; + copy_or_init(&config.paths.guardrails_file, &worker.paths.guardrails_file)?; + copy_or_init( + &config.paths.failure_memory_file, + &worker.paths.failure_memory_file, + )?; + Ok(worker) +} + +fn copy_or_init(source: &Path, target: &Path) -> std::io::Result<()> { + if source.exists() { + std::fs::copy(source, target)?; + } else { + std::fs::write(target, [])?; + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ralph-core/tests/parallel_scheduler.rs b/crates/ralph-core/tests/parallel_scheduler.rs new file mode 100644 index 0000000..9b43a64 --- /dev/null +++ b/crates/ralph-core/tests/parallel_scheduler.rs @@ -0,0 +1,183 @@ +use anyhow::Result; +use ralph_core::config::{PathConfig, RalphConfig, TuningConfig}; +use ralph_core::events::NoopEventSink; +use ralph_core::prd::Prd; +use ralph_core::providers::{AgentResult, Provider}; +use std::path::{Path, PathBuf}; +use std::process::Command; +use std::sync::Arc; +use std::sync::atomic::AtomicBool; +use tokio::sync::Barrier; +use tokio::time::{Duration, timeout}; + +#[derive(Clone)] +struct ParallelProbeProvider { + barrier: Arc, + events: Arc>>, +} + +impl Provider for ParallelProbeProvider { + fn name(&self) -> &str { + "probe" + } + + fn model(&self) -> &str { + "test" + } + + async fn run_agent( + &self, + _prompt: &str, + story_id: &str, + work_dir: &Path, + _stall_timeout_secs: u64, + _shutdown_flag: Arc, + _output_log: &Path, + ) -> Result { + self.events.lock().unwrap().push(( + "start".into(), + story_id.to_string(), + work_dir.display().to_string(), + )); + self.barrier.wait().await; + tokio::time::sleep(Duration::from_millis(25)).await; + self.events.lock().unwrap().push(( + "finish".into(), + story_id.to_string(), + work_dir.display().to_string(), + )); + Ok(AgentResult { + exit_code: 0, + stall_killed: false, + output_lines: vec![format!("finished {story_id}")], + rate_limited: false, + retry_after_message: None, + }) + } +} + +fn git(repo_dir: &Path, args: &[&str]) { + let output = Command::new("git") + .args(args) + .current_dir(repo_dir) + .output() + .expect("git should run"); + assert!( + output.status.success(), + "git {:?} failed: {}", + args, + String::from_utf8_lossy(&output.stderr) + ); +} + +fn init_repo() -> (tempfile::TempDir, PathBuf) { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let repo_dir = temp_dir.path().join("repo"); + std::fs::create_dir(&repo_dir).expect("repo dir"); + git(&repo_dir, &["init"]); + git(&repo_dir, &["config", "user.name", "Test"]); + git(&repo_dir, &["config", "user.email", "test@example.com"]); + std::fs::write(repo_dir.join("README.md"), "init\n").expect("readme"); + git(&repo_dir, &["add", "README.md"]); + git(&repo_dir, &["commit", "-m", "init"]); + (temp_dir, repo_dir.canonicalize().expect("canonicalize")) +} + +fn build_config(root: &Path, repo_dir: &Path) -> RalphConfig { + let ralph_dir = root.join("ralph"); + std::fs::create_dir_all(&ralph_dir).expect("ralph dir"); + let prompt_file = ralph_dir.join("prompt.md"); + let guardrails_file = ralph_dir.join("guardrails.md"); + std::fs::write(&prompt_file, "# Prompt\n").expect("prompt"); + std::fs::write(&guardrails_file, "# Guardrails\n").expect("guardrails"); + RalphConfig { + paths: PathConfig { + ralph_dir: ralph_dir.clone(), + work_dir: repo_dir.to_path_buf(), + prd_file: ralph_dir.join("prd.json"), + prd_backup: ralph_dir.join("prd.backup.json"), + prompt_file, + progress_file: ralph_dir.join("progress.log"), + guardrails_file, + error_log: ralph_dir.join("error.log"), + activity_log: ralph_dir.join("activity.log"), + state_file: ralph_dir.join("state.json"), + pause_file: ralph_dir.join("pause"), + done_file: ralph_dir.join("done"), + failure_memory_file: ralph_dir.join("failure_memory.json"), + last_rebase_file: ralph_dir.join("last_rebase"), + codex_output_log: ralph_dir.join("codex_output.log"), + }, + tuning: TuningConfig { + max_iterations: 2, + rate_limit_wait_secs: 1, + gutter_threshold: 3, + stall_timeout_secs: 10, + cooldown_secs: 0, + max_verification_retries: 1, + test_command: None, + }, + services: None, + } +} + +fn install_prd(config: &RalphConfig) { + let prd = r#"{ + "project": "loopforge", + "stories": [ + { + "id": "S-017A", + "title": "First", + "acceptanceCriteria": ["first"], + "verification": { "commands": ["true"] } + }, + { + "id": "S-017B", + "title": "Second", + "acceptanceCriteria": ["second"], + "verification": { "commands": ["true"] } + } + ] + }"#; + std::fs::write(&config.paths.prd_file, prd).expect("prd"); + std::fs::write(&config.paths.prd_backup, prd).expect("prd backup"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn ready_stories_execute_in_parallel_worktrees() { + let (temp_dir, repo_dir) = init_repo(); + let config = build_config(temp_dir.path(), &repo_dir); + install_prd(&config); + let provider = ParallelProbeProvider { + barrier: Arc::new(Barrier::new(2)), + events: Arc::new(std::sync::Mutex::new(Vec::new())), + }; + let shutdown = Arc::new(AtomicBool::new(false)); + let sink = NoopEventSink; + + timeout( + Duration::from_secs(2), + ralph_core::loop_engine::run(&config, &provider, shutdown, &sink), + ) + .await + .expect("parallel run should not hang") + .expect("loop run should succeed"); + + let events = provider.events.lock().unwrap().clone(); + assert_eq!(events.len(), 4, "expected start/finish for both stories"); + assert_eq!(events[0].0, "start"); + assert_eq!(events[1].0, "start"); + let work_dirs: std::collections::HashSet = + events.iter().take(2).map(|event| event.2.clone()).collect(); + assert_eq!(work_dirs.len(), 2, "stories should use distinct worktrees"); + assert!( + work_dirs + .iter() + .all(|dir| dir.contains(".loopforge/worktrees")), + "worktree paths should be provisioned under .loopforge/worktrees: {work_dirs:?}" + ); + + let updated = Prd::load(&config.paths.prd_file).expect("updated prd"); + assert!(updated.stories.iter().all(|story| story.passes)); + assert_eq!(updated.pending_count(), 0); +} From 758d2dd500041cb804228b66ef84d2a9802c5db3 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 19:59:23 -0600 Subject: [PATCH 10/61] refactor(tauri): serialize loop artifact merges --- src-tauri/src/contract_tests/artifacts.rs | 7 +- .../src/contract_tests/merge_coordinator.rs | 124 +++++++++++++++ src-tauri/src/loop_manager/helpers.rs | 37 +++-- src-tauri/src/projects/documents.rs | 150 ++++++++++++------ 4 files changed, 255 insertions(+), 63 deletions(-) create mode 100644 src-tauri/src/contract_tests/merge_coordinator.rs diff --git a/src-tauri/src/contract_tests/artifacts.rs b/src-tauri/src/contract_tests/artifacts.rs index 5afd6ab..724dd70 100644 --- a/src-tauri/src/contract_tests/artifacts.rs +++ b/src-tauri/src/contract_tests/artifacts.rs @@ -1,5 +1,7 @@ #[path = "wizard_persistence.rs"] mod wizard_persistence; +#[path = "merge_coordinator.rs"] +mod merge_coordinator; use super::harness::TestHarness; use super::support::{ @@ -33,8 +35,9 @@ async fn happy_path_persists_artifacts_and_runtime_histories() { artifact_dir.to_string_lossy(), harness.artifact_dir(&project.id).to_string_lossy() ); - for name in ["draft.json", "plan.md", "prd.json", "config.json"] { - assert!(artifact_dir.join(name).exists(), "{name} must exist"); + for path in crate::storage::artifacts::file_paths(&artifact_dir) { + let name = path.file_name().unwrap().to_string_lossy(); + assert!(path.exists(), "{name} must exist"); } let _session_id = harness.start_loop(&project.id).await; diff --git a/src-tauri/src/contract_tests/merge_coordinator.rs b/src-tauri/src/contract_tests/merge_coordinator.rs new file mode 100644 index 0000000..7b7fb1d --- /dev/null +++ b/src-tauri/src/contract_tests/merge_coordinator.rs @@ -0,0 +1,124 @@ +use crate::projects::documents::{apply_merge_action, merge_action_target, ordered_merge_actions}; +use ralph_core::prd::{Prd, UserStory}; +use ralph_core::WorktreeCompletion; + +#[test] +fn parallel_completions_produce_one_ordered_write_sequence() { + let root = std::env::temp_dir().join(format!("loopforge-merge-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + seed_prd(&root); + + let actions = ordered_merge_actions(vec![ + completion("S-002", "wt-b", false, true, 1, Some("guardrail from wt-b"), Some("bbb222")), + completion("S-001", "wt-a", true, false, 0, None, Some("aaa111")), + ]); + + let mut targets = Vec::new(); + for action in &actions { + targets.push(merge_action_target(&root, action)); + apply_merge_action(&root, action).unwrap(); + } + + assert_eq!( + targets, + vec![ + root.join("prd.json").display().to_string(), + "session:wt-a".to_string(), + root.join("prd.json").display().to_string(), + root.join("guardrails.md").display().to_string(), + "session:wt-b".to_string(), + ] + ); + + let prd = Prd::load(&root.join("prd.json")).unwrap(); + assert!(prd.stories.iter().find(|story| story.id == "S-001").unwrap().passes); + let blocked = prd.stories.iter().find(|story| story.id == "S-002").unwrap(); + assert!(!blocked.passes); + assert!(blocked.blocked); + assert_eq!( + std::fs::read_to_string(root.join("guardrails.md")).unwrap(), + "guardrail from wt-b\n" + ); + + let _ = std::fs::remove_dir_all(root); +} + +#[test] +fn merge_targets_keep_contract_artifact_filenames() { + let root = std::env::temp_dir().join(format!("loopforge-targets-{}", uuid::Uuid::new_v4())); + std::fs::create_dir_all(&root).unwrap(); + + let actions = ordered_merge_actions(vec![completion( + "S-003", + "wt-c", + false, + true, + 0, + Some("guardrail from wt-c"), + Some("ccc333"), + )]); + + let targets: Vec = actions + .iter() + .map(|action| merge_action_target(&root, action)) + .collect(); + + assert!(targets.iter().any(|target| target.ends_with("prd.json"))); + assert!(targets.iter().any(|target| target.ends_with("guardrails.md"))); + assert!(targets.iter().any(|target| target == "session:wt-c")); + + let _ = std::fs::remove_dir_all(root); +} + +fn seed_prd(root: &std::path::Path) { + let prd = Prd { + project_name: "Merge Contract".to_string(), + feature: String::new(), + working_directory: String::new(), + branch_name: None, + stories: vec![story("S-001"), story("S-002")], + generated_at: None, + }; + prd.save(&root.join("prd.json")).unwrap(); + std::fs::write(root.join("guardrails.md"), "").unwrap(); +} + +fn story(id: &str) -> UserStory { + UserStory { + id: id.to_string(), + title: id.to_string(), + description: None, + acceptance_criteria: vec!["contract".to_string()], + scope: Default::default(), + verification: Default::default(), + commit_message: None, + priority: Default::default(), + estimated_complexity: Default::default(), + estimated_minutes: 0, + depends_on: vec![], + passes: false, + blocked: false, + attempts: 0, + notes: None, + } +} + +fn completion( + story_id: &str, + worktree_id: &str, + passed: bool, + blocked: bool, + sequence: u64, + guardrail_append: Option<&str>, + head_commit: Option<&str>, +) -> WorktreeCompletion { + WorktreeCompletion { + worktree_id: worktree_id.to_string(), + story_id: story_id.to_string(), + passed, + blocked, + head_commit: head_commit.map(str::to_string), + guardrail_append: guardrail_append.map(str::to_string), + sequence, + } +} diff --git a/src-tauri/src/loop_manager/helpers.rs b/src-tauri/src/loop_manager/helpers.rs index 20bd999..998dc2a 100644 --- a/src-tauri/src/loop_manager/helpers.rs +++ b/src-tauri/src/loop_manager/helpers.rs @@ -1,8 +1,10 @@ use super::{LoopError, StartLoopArgs}; use crate::db::DbState; +use crate::projects::documents; +use crate::projects::ProjectError; use ralph_core::config::RalphConfig; use std::path::{Path, PathBuf}; -use tauri::AppHandle; +use tauri::{AppHandle, Runtime}; use uuid::Uuid; pub(super) fn build_ralph_config( @@ -52,7 +54,10 @@ pub(super) fn ensure_execution_prompt( .unwrap_or(true); if needs_default { - std::fs::write(&prompt_path, default_execution_prompt(project_name, artifact_dir))?; + std::fs::write( + &prompt_path, + default_execution_prompt(project_name, artifact_dir), + )?; } Ok(()) @@ -73,24 +78,28 @@ When a story passes verification, update only this artifact PRD and preserve exi pub(super) fn create_session(db: &DbState, project_id: &str) -> Result { let session_id = Uuid::new_v4().to_string(); let now = chrono::Utc::now().to_rfc3339(); - let conn = db.0.lock().map_err(|_| LoopError::LockPoisoned)?; - conn.execute( - "INSERT INTO sessions (id, project_id, started_at) VALUES (?1, ?2, ?3)", - rusqlite::params![session_id, project_id, now], - )?; + documents::insert_session(db, project_id, &session_id, &now).map_err(project_error)?; Ok(session_id) } pub(super) fn close_session(db: &DbState, session_id: &str) { let now = chrono::Utc::now().to_rfc3339(); - if let Ok(conn) = db.0.lock() { - let _ = conn.execute( - "UPDATE sessions SET ended_at = ?1 WHERE id = ?2", - rusqlite::params![now, session_id], - ); - } + let _ = documents::close_session(db, session_id, &now).map_err(project_error); } -pub(super) fn artifact_dir(app: &AppHandle, project_id: &str) -> Result { +pub(super) fn artifact_dir( + app: &AppHandle, + project_id: &str, +) -> Result { crate::storage::artifacts::project_artifact_dir(app, project_id).map_err(LoopError::Path) } + +fn project_error(error: ProjectError) -> LoopError { + match error { + ProjectError::Db(message) => LoopError::Db(message), + ProjectError::Io(source) => LoopError::Io(source), + ProjectError::Json(source) => LoopError::Internal(source.to_string()), + ProjectError::NotFound(project_id) => LoopError::Internal(project_id), + ProjectError::Path(message) => LoopError::Path(message), + } +} diff --git a/src-tauri/src/projects/documents.rs b/src-tauri/src/projects/documents.rs index 1e0eca2..fec4873 100644 --- a/src-tauri/src/projects/documents.rs +++ b/src-tauri/src/projects/documents.rs @@ -1,14 +1,16 @@ use crate::db::DbState; -use crate::projects::artifacts::{ - artifact_dir, non_empty_file_content, project_working_directory, -}; +use crate::projects::artifacts::{artifact_dir, non_empty_file_content, project_working_directory}; use crate::projects::ProjectError; use ralph_core::prd::Prd; +#[cfg(test)] +use ralph_core::{CompletionScheduler, MergeAction, WorktreeCompletion}; use std::path::Path; -use tauri::{AppHandle, State}; +use std::sync::{Mutex, OnceLock}; +use tauri::{AppHandle, Runtime, State}; -pub async fn load_existing_plan( - app: AppHandle, +static MERGE_GATE: OnceLock> = OnceLock::new(); +pub async fn load_existing_plan( + app: AppHandle, db: State<'_, DbState>, project_id: String, ) -> Result, ProjectError> { @@ -28,51 +30,34 @@ pub async fn load_existing_plan( Ok(None) } - -pub async fn save_plan( - app: AppHandle, - project_id: String, - content: String, -) -> Result<(), ProjectError> { +pub async fn save_plan(app: AppHandle, project_id: String, content: String) -> Result<(), ProjectError> { let artifacts = artifact_dir(&app, &project_id)?; std::fs::create_dir_all(&artifacts)?; std::fs::write(artifacts.join("plan.md"), &content)?; Ok(()) } - -pub async fn save_prd( - app: AppHandle, - project_id: String, - prd_json: String, -) -> Result<(), ProjectError> { +pub async fn save_prd(app: AppHandle, project_id: String, prd_json: String) -> Result<(), ProjectError> { let artifacts = artifact_dir(&app, &project_id)?; std::fs::create_dir_all(&artifacts)?; serde_json::from_str::(&prd_json)?; - std::fs::write(artifacts.join("prd.json"), &prd_json)?; + with_merge_gate(|| { + std::fs::write(artifacts.join("prd.json"), &prd_json)?; + Ok(()) + })?; Ok(()) } - -pub async fn save_config( - app: AppHandle, - project_id: String, - config_json: String, -) -> Result<(), ProjectError> { +pub async fn save_config(app: AppHandle, project_id: String, config_json: String) -> Result<(), ProjectError> { let parsed_config = serde_json::from_str::(&config_json)?; crate::projects::runtime_config::save_project_config(&app, &project_id, &parsed_config)?; Ok(()) } - -pub async fn load_config( - app: AppHandle, - project_id: String, -) -> Result, ProjectError> { +pub async fn load_config(app: AppHandle, project_id: String) -> Result, ProjectError> { let artifacts = artifact_dir(&app, &project_id)?; let config_path = artifacts.join("config.json"); non_empty_file_content(&config_path) } - -pub async fn load_existing_prd( - app: AppHandle, +pub async fn load_existing_prd( + app: AppHandle, db: State<'_, DbState>, project_id: String, ) -> Result, ProjectError> { @@ -96,40 +81,111 @@ pub async fn load_existing_prd( Ok(None) } - -pub async fn get_guardrails( - app: AppHandle, - project_id: String, -) -> Result { +pub async fn get_guardrails(app: AppHandle, project_id: String) -> Result { let dir = artifact_dir(&app, &project_id)?; let guardrails_path = dir.join("guardrails.md"); - if guardrails_path.exists() { std::fs::read_to_string(&guardrails_path).map_err(ProjectError::Io) } else { Ok(String::new()) } } - -pub async fn load_output_log( - app: AppHandle, - project_id: String, -) -> Result { +pub async fn load_output_log(app: AppHandle, project_id: String) -> Result { let dir = artifact_dir(&app, &project_id)?; let output_path = dir.join("agent_output.log"); if !output_path.exists() { return Ok(String::new()); } - let content = std::fs::read_to_string(output_path)?; Ok(tail_lines(&content, 400)) } - +pub(crate) fn insert_session(db: &DbState, project_id: &str, session_id: &str, started_at: &str) -> Result<(), ProjectError> { + with_merge_gate(|| { + let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.execute( + "INSERT INTO sessions (id, project_id, started_at) VALUES (?1, ?2, ?3)", + rusqlite::params![session_id, project_id, started_at], + )?; + Ok(()) + }) +} +pub(crate) fn close_session(db: &DbState, session_id: &str, ended_at: &str) -> Result<(), ProjectError> { + with_merge_gate(|| { + let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.execute( + "UPDATE sessions SET ended_at = ?1 WHERE id = ?2", + rusqlite::params![ended_at, session_id], + )?; + Ok(()) + }) +} +#[cfg(test)] +pub(crate) fn ordered_merge_actions(completions: Vec) -> Vec { + let mut scheduler = CompletionScheduler::new(); + scheduler.submit_batch(completions); + scheduler.drain_ordered() +} +#[cfg(test)] +pub(crate) fn merge_action_target(project_dir: &Path, action: &MergeAction) -> String { + match action { + MergeAction::UpdateStoryStatus { .. } => project_dir.join("prd.json").display().to_string(), + MergeAction::AppendGuardrail { .. } => { + project_dir.join("guardrails.md").display().to_string() + } + MergeAction::UpdateSessionHead { worktree_id, .. } => format!("session:{worktree_id}"), + } +} +#[cfg(test)] +pub(crate) fn apply_merge_action(project_dir: &Path, action: &MergeAction) -> Result<(), ProjectError> { + match action { + MergeAction::UpdateStoryStatus { + story_id, + passed, + blocked, + } => with_merge_gate(|| update_story_status(project_dir, story_id, *passed, *blocked)), + MergeAction::AppendGuardrail { content, .. } => append_guardrails(project_dir, content), + MergeAction::UpdateSessionHead { .. } => Ok(()), + } +} +fn with_merge_gate(write: impl FnOnce() -> Result) -> Result { + let _guard = MERGE_GATE + .get_or_init(|| Mutex::new(())) + .lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + write() +} +#[cfg(test)] +fn update_story_status(project_dir: &Path, story_id: &str, passed: bool, blocked: bool) -> Result<(), ProjectError> { + let prd_path = project_dir.join("prd.json"); + let mut prd = serde_json::from_str::(&std::fs::read_to_string(&prd_path)?)?; + if let Some(story) = prd.stories.iter_mut().find(|story| story.id == story_id) { + story.passes = passed; + story.blocked = blocked; + } + std::fs::write(prd_path, serde_json::to_string_pretty(&prd)?)?; + Ok(()) +} +#[cfg(test)] +fn append_guardrails(project_dir: &Path, content: &str) -> Result<(), ProjectError> { + with_merge_gate(|| { + let mut file = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(project_dir.join("guardrails.md"))?; + let payload = if content.ends_with('\n') { + content.to_string() + } else { + format!("{content}\n") + }; + use std::io::Write; + file.write_all(payload.as_bytes())?; + Ok(()) + }) +} fn tail_lines(content: &str, max_lines: usize) -> String { let lines: Vec<&str> = content.lines().collect(); if lines.len() <= max_lines { return content.to_string(); } - lines[lines.len() - max_lines..].join("\n") } From da26d6d7a352fd96e4a444dc0bff0790f5a56ef0 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 20:12:09 -0600 Subject: [PATCH 11/61] refactor(tauri): extract monitor services behind adapters --- crates/app-services/src/monitor.rs | 67 ++++-- crates/app-services/src/session.rs | 144 +++++++++--- src-tauri/src/invoke.rs | 260 ++++++++++++---------- src-tauri/src/loop_manager/helpers.rs | 2 +- src-tauri/src/services/mod.rs | 2 + src-tauri/src/services/monitor_adapter.rs | 106 +++++++++ src-tauri/src/services/session_adapter.rs | 189 ++++++++++++++++ 7 files changed, 594 insertions(+), 176 deletions(-) create mode 100644 src-tauri/src/services/mod.rs create mode 100644 src-tauri/src/services/monitor_adapter.rs create mode 100644 src-tauri/src/services/session_adapter.rs diff --git a/crates/app-services/src/monitor.rs b/crates/app-services/src/monitor.rs index eee0fe2..f03b905 100644 --- a/crates/app-services/src/monitor.rs +++ b/crates/app-services/src/monitor.rs @@ -81,6 +81,20 @@ pub struct MonitorMessage { pub event: Option, } +pub trait MonitorRepository { + fn latest_session(&self, project_id: &str) -> crate::session::ServiceResult>; + fn recent_output( + &self, + project_id: &str, + limit: usize, + ) -> crate::session::ServiceResult>; + fn recent_events( + &self, + project_id: &str, + limit: usize, + ) -> crate::session::ServiceResult>; +} + pub trait MonitorService { fn snapshot(&self, project_id: &str) -> crate::session::ServiceResult; fn recent_output( @@ -95,30 +109,39 @@ pub trait MonitorService { ) -> crate::session::ServiceResult>; } -#[cfg(test)] -mod tests { - use super::{MonitorEvent, MonitorMessage, MonitorStream, OutputEntry}; +pub struct RuntimeMonitorService { + repository: R, +} - #[test] - fn serializes_monitor_event_payloads() { - let event = MonitorEvent::Output { - entry: OutputEntry { - project_id: "project-1".into(), - session_id: Some("session-1".into()), - stream: MonitorStream::Stderr, - content: "line".into(), - emitted_at: Some("2026-04-10T00:00:00Z".into()), - }, - }; - let message = serde_json::to_value(MonitorMessage { - project_id: "project-1".into(), - snapshot: None, - event: Some(event), +impl RuntimeMonitorService { + pub fn new(repository: R) -> Self { + Self { repository } + } +} + +impl MonitorService for RuntimeMonitorService { + fn snapshot(&self, project_id: &str) -> crate::session::ServiceResult { + Ok(MonitorSnapshot { + project_id: project_id.to_string(), + session: self.repository.latest_session(project_id)?, + recent_output: self.repository.recent_output(project_id, 200)?, + events: self.repository.recent_events(project_id, 100)?, }) - .expect("monitor message serializes"); + } - assert_eq!(message["projectId"], "project-1"); - assert_eq!(message["event"]["type"], "output"); - assert_eq!(message["event"]["entry"]["stream"], "stderr"); + fn recent_output( + &self, + project_id: &str, + limit: usize, + ) -> crate::session::ServiceResult> { + self.repository.recent_output(project_id, limit) + } + + fn recent_events( + &self, + project_id: &str, + limit: usize, + ) -> crate::session::ServiceResult> { + self.repository.recent_events(project_id, limit) } } diff --git a/crates/app-services/src/session.rs b/crates/app-services/src/session.rs index 1dafbca..6f2c591 100644 --- a/crates/app-services/src/session.rs +++ b/crates/app-services/src/session.rs @@ -75,6 +75,45 @@ pub struct SessionStats { pub is_running: bool, } +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct LatestSessionRecord { + pub id: String, + pub started_at: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct IterationCounts { + pub total: i64, + pub success: i64, + pub rate_limited: i64, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct StoryCounts { + pub total: usize, + pub passed: usize, + pub blocked: usize, + pub pending: usize, +} + +pub trait SessionRepository { + fn is_running(&self, project_id: &str) -> ServiceResult; + fn latest_session_record(&self, project_id: &str) -> ServiceResult>; + fn iteration_counts(&self, session_id: &str) -> ServiceResult; + fn latest_agent(&self, session_id: &str) -> ServiceResult>; + fn story_counts(&self, project_id: &str) -> ServiceResult; + fn stories_per_hour( + &self, + session: Option<&LatestSessionRecord>, + passed_stories: usize, + ) -> ServiceResult; + fn iteration_history( + &self, + project_id: &str, + limit: usize, + ) -> ServiceResult>; +} + pub trait SessionService { fn latest_session(&self, project_id: &str) -> ServiceResult>; fn session_stats(&self, project_id: &str) -> ServiceResult; @@ -85,45 +124,76 @@ pub trait SessionService { ) -> ServiceResult>; } -#[cfg(test)] -mod tests { - use super::{IterationSummary, SessionInfo, SessionStats}; - - #[test] - fn serializes_session_dtos_with_camel_case() { - let payload = serde_json::to_value(SessionStats { - project_id: "project-1".into(), - session_id: Some("session-1".into()), - total_iterations: 4, - success_count: 3, - failure_count: 1, - rate_limited_count: 0, - success_rate: 0.75, - stories_per_hour: 1.5, - total_stories: 5, - passed_stories: 3, - blocked_stories: 0, - pending_stories: 2, - current_agent: Some("codex".into()), - is_running: true, - }) - .expect("session stats serialize"); +pub struct RuntimeSessionService { + repository: R, +} + +impl RuntimeSessionService { + pub fn new(repository: R) -> Self { + Self { repository } + } +} + +impl SessionService for RuntimeSessionService { + fn latest_session(&self, project_id: &str) -> ServiceResult> { + self.repository + .latest_session_record(project_id) + .map(|session| { + session.map(|record| SessionInfo { + id: record.id, + started_at: record.started_at, + ended_at: None, + }) + }) + } + + fn session_stats(&self, project_id: &str) -> ServiceResult { + let is_running = self.repository.is_running(project_id)?; + let session = self.repository.latest_session_record(project_id)?; + let counts = session + .as_ref() + .map(|record| self.repository.iteration_counts(&record.id)) + .transpose()? + .unwrap_or_default(); + let current_agent = session + .as_ref() + .map(|record| self.repository.latest_agent(&record.id)) + .transpose()? + .flatten(); + let stories = self.repository.story_counts(project_id)?; + let failure_count = counts.total - counts.success - counts.rate_limited; + let success_rate = if counts.total > 0 { + counts.success as f64 / counts.total as f64 + } else { + 0.0 + }; + let stories_per_hour = + self.repository + .stories_per_hour(session.as_ref(), stories.passed)?; - assert_eq!(payload["projectId"], "project-1"); - assert_eq!(payload["sessionId"], "session-1"); - assert_eq!(payload["successRate"], 0.75); + Ok(SessionStats { + project_id: project_id.to_string(), + session_id: self.latest_session(project_id)?.map(|info| info.id), + total_iterations: counts.total, + success_count: counts.success, + failure_count, + rate_limited_count: counts.rate_limited, + success_rate, + stories_per_hour, + total_stories: stories.total, + passed_stories: stories.passed, + blocked_stories: stories.blocked, + pending_stories: stories.pending, + current_agent, + is_running, + }) } - #[test] - fn defaults_allow_backward_compatible_decoding() { - let decoded: SessionInfo = - serde_json::from_str(r#"{"id":"session-1"}"#).expect("session info decodes"); - let iteration: IterationSummary = serde_json::from_str( - r#"{"storyId":"S-1","startedAt":"2026-04-10T00:00:00Z","durationSecs":3,"result":"success","agentUsed":"codex"}"#, - ) - .expect("iteration summary decodes"); - - assert_eq!(decoded.id, "session-1"); - assert_eq!(iteration.story_id, "S-1"); + fn iteration_history( + &self, + project_id: &str, + limit: usize, + ) -> ServiceResult> { + self.repository.iteration_history(project_id, limit) } } diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index b377fad..bddb8f6 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -1,129 +1,157 @@ use crate::{agents, commands, connections, ephemeral_query}; +#[path = "services/mod.rs"] +mod services; + +fn attach_with_session_aliases( + builder: tauri::Builder, + fallback: F, +) -> tauri::Builder +where + F: Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, +{ + builder.invoke_handler(move |invoke| match invoke.message.command() { + "session_stats" => services::session_adapter::__cmd__session_stats_command!( + services::session_adapter::session_stats_command, + invoke + ), + "get_iteration_history" => { + services::session_adapter::__cmd__get_iteration_history_command!( + services::session_adapter::get_iteration_history_command, + invoke + ) + } + _ => fallback(invoke), + }) +} #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { - builder.invoke_handler(tauri::generate_handler![ - agents::detect_agents, - agents::refresh_agents, - agents::get_agent_capabilities, - commands::planning::start_plan, - commands::planning::write_to_plan, - commands::planning::stop_plan, - commands::planning::query_plan_status, - commands::projects_artifacts::load_existing_plan, - commands::projects_artifacts::load_existing_prd, - commands::projects_artifacts::load_output_log, - commands::projects_artifacts::save_plan, - commands::projects_artifacts::save_prd, - commands::projects_artifacts::save_config, - commands::projects_artifacts::load_config, - commands::atomization::run_atomizer, - commands::projects_lifecycle::create_project, - commands::projects_wizard::finalize_draft, - commands::projects_wizard::discard_draft, - commands::projects_wizard::save_wizard_state, - commands::projects_wizard::resume_wizard, - commands::projects_wizard::save_draft, - commands::projects_wizard::load_draft, - commands::projects_lifecycle::list_projects, - commands::projects_lifecycle::pause_project, - commands::projects_lifecycle::resume_project, - commands::projects_lifecycle::archive_project, - commands::projects_lifecycle::get_project_detail, - commands::projects_lifecycle::get_project_stories, - commands::projects_lifecycle::get_guardrails, - commands::projects_lifecycle::get_project_config, - commands::projects::get_project_snapshot, - commands::projects_listing::list_projects_enriched, - commands::projects_lifecycle::get_notification_prefs, - commands::projects_lifecycle::save_notification_prefs, - commands::execution::start_loop, - commands::execution::stop_loop, - commands::execution::session_stats, - commands::execution::get_iteration_history, - commands::ask::ask_question, - commands::ask::ask_history, - commands::ask::stop_ask, - commands::ask::copy_ask_message, - commands::ask::truncate_ask_from, - commands::ask::retry_ask, - ephemeral_query::ephemeral_query, - connections::list_connections, - connections::build_connection_workspace, - ]) + attach_with_session_aliases( + builder, + tauri::generate_handler![ + agents::detect_agents, + agents::refresh_agents, + agents::get_agent_capabilities, + commands::planning::start_plan, + commands::planning::write_to_plan, + commands::planning::stop_plan, + commands::planning::query_plan_status, + commands::projects_artifacts::load_existing_plan, + commands::projects_artifacts::load_existing_prd, + commands::projects_artifacts::load_output_log, + commands::projects_artifacts::save_plan, + commands::projects_artifacts::save_prd, + commands::projects_artifacts::save_config, + commands::projects_artifacts::load_config, + commands::atomization::run_atomizer, + commands::projects_lifecycle::create_project, + commands::projects_wizard::finalize_draft, + commands::projects_wizard::discard_draft, + commands::projects_wizard::save_wizard_state, + commands::projects_wizard::resume_wizard, + commands::projects_wizard::save_draft, + commands::projects_wizard::load_draft, + commands::projects_lifecycle::list_projects, + commands::projects_lifecycle::pause_project, + commands::projects_lifecycle::resume_project, + commands::projects_lifecycle::archive_project, + commands::projects_lifecycle::get_project_detail, + commands::projects_lifecycle::get_project_stories, + commands::projects_lifecycle::get_guardrails, + commands::projects_lifecycle::get_project_config, + commands::projects::get_project_snapshot, + commands::projects_listing::list_projects_enriched, + commands::projects_lifecycle::get_notification_prefs, + commands::projects_lifecycle::save_notification_prefs, + commands::execution::start_loop, + commands::execution::stop_loop, + commands::ask::ask_question, + commands::ask::ask_history, + commands::ask::stop_ask, + commands::ask::copy_ask_message, + commands::ask::truncate_ask_from, + commands::ask::retry_ask, + ephemeral_query::ephemeral_query, + connections::list_connections, + connections::build_connection_workspace, + ], + ) } #[cfg(test)] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { - builder.invoke_handler(tauri::generate_handler![ - agents::detect_agents, - agents::refresh_agents, - agents::get_agent_capabilities, - crate::invoke_contract::start_plan, - commands::planning::write_to_plan, - commands::planning::stop_plan, - crate::invoke_contract::query_plan_status, - crate::invoke_contract::load_existing_plan, - commands::projects_artifacts::load_existing_prd, - commands::projects_artifacts::load_output_log, - crate::invoke_contract::save_plan, - commands::projects_artifacts::save_prd, - crate::invoke_contract::save_config, - commands::projects_artifacts::load_config, - crate::invoke_contract::run_atomizer, - crate::invoke_contract::create_project, - crate::invoke_contract::finalize_draft, - commands::projects_wizard::discard_draft, - commands::projects_wizard::save_wizard_state, - commands::projects_wizard::resume_wizard, - crate::invoke_contract::save_draft, - crate::invoke_contract::load_draft, - commands::projects_lifecycle::list_projects, - commands::projects_lifecycle::pause_project, - commands::projects_lifecycle::resume_project, - commands::projects_lifecycle::archive_project, - crate::invoke_contract::get_project_detail, - commands::projects_lifecycle::get_project_stories, - commands::projects_lifecycle::get_guardrails, - commands::projects_lifecycle::get_project_config, - commands::projects::get_project_snapshot, - commands::projects_listing::list_projects_enriched, - commands::projects_lifecycle::get_notification_prefs, - commands::projects_lifecycle::save_notification_prefs, - crate::invoke_contract::start_loop, - commands::execution::stop_loop, - commands::execution::session_stats, - commands::execution::get_iteration_history, - commands::ask::ask_question, - commands::ask::ask_history, - commands::ask::stop_ask, - commands::ask::copy_ask_message, - commands::ask::truncate_ask_from, - commands::ask::retry_ask, - ephemeral_query::ephemeral_query, - connections::list_connections, - connections::build_connection_workspace, - ]) + attach_with_session_aliases( + builder, + tauri::generate_handler![ + agents::detect_agents, + agents::refresh_agents, + agents::get_agent_capabilities, + crate::invoke_contract::start_plan, + commands::planning::write_to_plan, + commands::planning::stop_plan, + crate::invoke_contract::query_plan_status, + crate::invoke_contract::load_existing_plan, + commands::projects_artifacts::load_existing_prd, + commands::projects_artifacts::load_output_log, + crate::invoke_contract::save_plan, + commands::projects_artifacts::save_prd, + crate::invoke_contract::save_config, + commands::projects_artifacts::load_config, + crate::invoke_contract::run_atomizer, + crate::invoke_contract::create_project, + crate::invoke_contract::finalize_draft, + commands::projects_wizard::discard_draft, + commands::projects_wizard::save_wizard_state, + commands::projects_wizard::resume_wizard, + crate::invoke_contract::save_draft, + crate::invoke_contract::load_draft, + commands::projects_lifecycle::list_projects, + commands::projects_lifecycle::pause_project, + commands::projects_lifecycle::resume_project, + commands::projects_lifecycle::archive_project, + crate::invoke_contract::get_project_detail, + commands::projects_lifecycle::get_project_stories, + commands::projects_lifecycle::get_guardrails, + commands::projects_lifecycle::get_project_config, + commands::projects::get_project_snapshot, + commands::projects_listing::list_projects_enriched, + commands::projects_lifecycle::get_notification_prefs, + commands::projects_lifecycle::save_notification_prefs, + crate::invoke_contract::start_loop, + commands::execution::stop_loop, + commands::ask::ask_question, + commands::ask::ask_history, + commands::ask::stop_ask, + commands::ask::copy_ask_message, + commands::ask::truncate_ask_from, + commands::ask::retry_ask, + ephemeral_query::ephemeral_query, + connections::list_connections, + connections::build_connection_workspace, + ], + ) } #[cfg(test)] pub fn attach_contract(builder: tauri::Builder) -> tauri::Builder { - builder.invoke_handler(tauri::generate_handler![ - crate::invoke_contract::create_project, - crate::invoke_contract::save_draft, - crate::invoke_contract::load_draft, - crate::invoke_contract::finalize_draft, - crate::invoke_contract::start_plan, - commands::planning::write_to_plan, - commands::planning::stop_plan, - crate::invoke_contract::query_plan_status, - crate::invoke_contract::load_existing_plan, - crate::invoke_contract::save_plan, - crate::invoke_contract::save_config, - crate::invoke_contract::run_atomizer, - crate::invoke_contract::start_loop, - commands::execution::get_iteration_history, - crate::invoke_contract::get_project_detail, - commands::projects_lifecycle::archive_project, - ]) + attach_with_session_aliases( + builder, + tauri::generate_handler![ + crate::invoke_contract::create_project, + crate::invoke_contract::save_draft, + crate::invoke_contract::load_draft, + crate::invoke_contract::finalize_draft, + crate::invoke_contract::start_plan, + commands::planning::write_to_plan, + commands::planning::stop_plan, + crate::invoke_contract::query_plan_status, + crate::invoke_contract::load_existing_plan, + crate::invoke_contract::save_plan, + crate::invoke_contract::save_config, + crate::invoke_contract::run_atomizer, + crate::invoke_contract::start_loop, + crate::invoke_contract::get_project_detail, + commands::projects_lifecycle::archive_project, + ], + ) } diff --git a/src-tauri/src/loop_manager/helpers.rs b/src-tauri/src/loop_manager/helpers.rs index 998dc2a..f710e3e 100644 --- a/src-tauri/src/loop_manager/helpers.rs +++ b/src-tauri/src/loop_manager/helpers.rs @@ -99,7 +99,7 @@ fn project_error(error: ProjectError) -> LoopError { ProjectError::Db(message) => LoopError::Db(message), ProjectError::Io(source) => LoopError::Io(source), ProjectError::Json(source) => LoopError::Internal(source.to_string()), - ProjectError::NotFound(project_id) => LoopError::Internal(project_id), + ProjectError::NotFound(project_id) => LoopError::Path(project_id), ProjectError::Path(message) => LoopError::Path(message), } } diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs new file mode 100644 index 0000000..a3428df --- /dev/null +++ b/src-tauri/src/services/mod.rs @@ -0,0 +1,2 @@ +pub mod monitor_adapter; +pub mod session_adapter; diff --git a/src-tauri/src/services/monitor_adapter.rs b/src-tauri/src/services/monitor_adapter.rs new file mode 100644 index 0000000..afe0de7 --- /dev/null +++ b/src-tauri/src/services/monitor_adapter.rs @@ -0,0 +1,106 @@ +use app_services::monitor::{MonitorEvent, MonitorRepository, MonitorService, RuntimeMonitorService}; +use app_services::{MonitorSnapshot, MonitorStream, OutputEntry, ServiceError, SessionInfo}; +use rusqlite::OptionalExtension; +use tauri::Manager; + +pub struct TauriMonitorAdapter<'a, R: tauri::Runtime> { + window: &'a tauri::Window, +} + +impl<'a, R: tauri::Runtime> TauriMonitorAdapter<'a, R> { + pub fn new(window: &'a tauri::Window) -> Self { + Self { window } + } + + pub fn snapshot(&self, project_id: &str) -> Result { + RuntimeMonitorService::new(Self::new(self.window)).snapshot(project_id) + } +} + +impl MonitorRepository for TauriMonitorAdapter<'_, R> { + fn latest_session(&self, project_id: &str) -> Result, ServiceError> { + let db = self.window.state::(); + let conn = db + .0 + .lock() + .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; + + conn.query_row( + "SELECT id, started_at, ended_at FROM sessions WHERE project_id = ?1 ORDER BY started_at DESC LIMIT 1", + rusqlite::params![project_id], + |row| { + Ok(SessionInfo { + id: row.get(0)?, + started_at: row.get(1).ok(), + ended_at: row.get(2).ok(), + }) + }, + ) + .optional() + .map_err(|error| ServiceError::Internal(error.to_string())) + } + + fn recent_output(&self, project_id: &str, limit: usize) -> Result, ServiceError> { + let artifact_dir = crate::storage::artifacts::project_artifact_dir( + &self.window.app_handle(), + project_id, + ) + .map_err(ServiceError::Internal)?; + let output_path = artifact_dir.join("agent_output.log"); + let content = std::fs::read_to_string(output_path).unwrap_or_default(); + + Ok(content + .lines() + .rev() + .filter(|line| !line.trim().is_empty()) + .take(limit) + .map(|line| OutputEntry { + project_id: project_id.to_string(), + session_id: None, + stream: MonitorStream::Stdout, + content: line.to_string(), + emitted_at: None, + }) + .collect::>() + .into_iter() + .rev() + .collect()) + } + + fn recent_events( + &self, + project_id: &str, + limit: usize, + ) -> Result, ServiceError> { + let db = self.window.state::(); + let conn = db + .0 + .lock() + .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; + let mut statement = conn + .prepare( + "SELECT s.id, i.story_id, i.agent_used, i.duration_secs, i.result + FROM iterations i + JOIN sessions s ON i.session_id = s.id + WHERE s.project_id = ?1 + ORDER BY i.started_at DESC + LIMIT ?2", + ) + .map_err(|error| ServiceError::Internal(error.to_string()))?; + let rows = statement + .query_map(rusqlite::params![project_id, limit as i64], |row| { + Ok(MonitorEvent::IterationCompleted { + project_id: project_id.to_string(), + session_id: row.get(0)?, + story_id: row.get(1)?, + agent: row.get(2)?, + duration_secs: row.get::<_, i64>(3)?.max(0) as u64, + result: row.get(4)?, + }) + }) + .map_err(|error| ServiceError::Internal(error.to_string()))?; + + rows.collect::, _>>() + .map_err(|error| ServiceError::Internal(error.to_string())) + } +} diff --git a/src-tauri/src/services/session_adapter.rs b/src-tauri/src/services/session_adapter.rs new file mode 100644 index 0000000..b0740fa --- /dev/null +++ b/src-tauri/src/services/session_adapter.rs @@ -0,0 +1,189 @@ +use app_services::session::{IterationCounts, IterationSummary, LatestSessionRecord, RuntimeSessionService, SessionRepository, SessionService, StoryCounts}; +use app_services::{ServiceError, SessionStats}; +use ralph_core::prd::Prd; +use rusqlite::{Connection, OptionalExtension}; +use tauri::Manager; + +use crate::commands::execution::IterationRow; +use crate::loop_manager::LoopError; + +pub struct TauriSessionAdapter<'a, R: tauri::Runtime> { + window: &'a tauri::Window, +} + +impl<'a, R: tauri::Runtime> TauriSessionAdapter<'a, R> { + fn new(window: &'a tauri::Window) -> Self { + Self { window } + } + + fn with_connection( + &self, + run: impl FnOnce(&Connection) -> Result, + ) -> Result { + let db = self.window.state::(); + let conn = + db.0.lock() + .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; + run(&conn) + } +} + +impl SessionRepository for TauriSessionAdapter<'_, R> { + fn is_running(&self, project_id: &str) -> Result { + let loop_state = self.window.state::(); + let handles = loop_state + .0 + .lock() + .map_err(|_| ServiceError::Internal("loop state lock poisoned".into()))?; + Ok(handles.contains_key(project_id)) + } + + fn latest_session_record(&self, project_id: &str) -> Result, ServiceError> { + self.with_connection(|conn| { + conn.query_row( + "SELECT id, started_at FROM sessions WHERE project_id = ?1 ORDER BY started_at DESC LIMIT 1", + rusqlite::params![project_id], + |row| Ok(LatestSessionRecord { id: row.get(0)?, started_at: row.get(1)? }), + ) + .optional() + .map_err(|error| ServiceError::Internal(error.to_string())) + }) + } + + fn iteration_counts(&self, session_id: &str) -> Result { + self.with_connection(|conn| { + conn.query_row( + "SELECT COUNT(*), SUM(CASE WHEN result = 'success' THEN 1 ELSE 0 END), SUM(CASE WHEN result = 'rate_limited' THEN 1 ELSE 0 END) FROM iterations WHERE session_id = ?1", + rusqlite::params![session_id], + |row| { + Ok(IterationCounts { + total: row.get(0)?, + success: row.get::<_, Option>(1)?.unwrap_or(0), + rate_limited: row.get::<_, Option>(2)?.unwrap_or(0), + }) + }, + ) + .map_err(|error| ServiceError::Internal(error.to_string())) + }) + } + + fn latest_agent(&self, session_id: &str) -> Result, ServiceError> { + self.with_connection(|conn| { + conn.query_row( + "SELECT agent_used FROM iterations WHERE session_id = ?1 ORDER BY started_at DESC LIMIT 1", + rusqlite::params![session_id], + |row| row.get(0), + ) + .optional() + .map_err(|error| ServiceError::Internal(error.to_string())) + }) + } + + fn story_counts(&self, project_id: &str) -> Result { + let artifact_dir = + crate::storage::artifacts::project_artifact_dir(&self.window.app_handle(), project_id) + .map_err(ServiceError::Internal)?; + let prd_path = artifact_dir.join("prd.json"); + if !prd_path.exists() { + return Ok(StoryCounts::default()); + } + Prd::load(&prd_path) + .map(|prd| StoryCounts { + total: prd.total_stories(), + passed: prd.passed_count(), + blocked: prd.blocked_count(), + pending: prd.pending_count(), + }) + .map_err(|error| ServiceError::Internal(error.to_string())) + } + + fn stories_per_hour(&self, session: Option<&LatestSessionRecord>, passed_stories: usize) -> Result { + let Some(started_at) = session.and_then(|record| record.started_at.as_deref()) else { + return Ok(0.0); + }; + let Ok(started_at) = chrono::DateTime::parse_from_rfc3339(started_at) else { + return Ok(0.0); + }; + let elapsed_hours = + (chrono::Utc::now() - started_at.with_timezone(&chrono::Utc)).num_minutes() as f64 + / 60.0; + if elapsed_hours > 0.0 { + Ok(passed_stories as f64 / elapsed_hours) + } else { + Ok(0.0) + } + } + + fn iteration_history(&self, project_id: &str, limit: usize) -> Result, ServiceError> { + self.with_connection(|conn| { + let mut statement = conn + .prepare( + "SELECT i.story_id, i.started_at, i.duration_secs, i.result, i.agent_used + FROM iterations i + JOIN sessions s ON i.session_id = s.id + WHERE s.project_id = ?1 + ORDER BY i.started_at DESC + LIMIT ?2", + ) + .map_err(|error| ServiceError::Internal(error.to_string()))?; + let rows = statement + .query_map(rusqlite::params![project_id, limit as i64], |row| { + Ok(IterationSummary { + story_id: row.get(0)?, + started_at: row.get(1)?, + duration_secs: row.get(2)?, + result: row.get(3)?, + agent_used: row.get(4)?, + }) + }) + .map_err(|error| ServiceError::Internal(error.to_string()))?; + rows.collect::, _>>() + .map_err(|error| ServiceError::Internal(error.to_string())) + }) + } +} + +#[tauri::command] +pub async fn session_stats_command(window: tauri::Window, project_id: String) -> Result { + RuntimeSessionService::new(TauriSessionAdapter::new(&window)) + .session_stats(&required(project_id, "project_id").map_err(LoopError::Path)?) + .map_err(loop_error) +} + +#[tauri::command] +pub async fn get_iteration_history_command( + window: tauri::Window, + project_id: String, +) -> Result, LoopError> { + RuntimeSessionService::new(TauriSessionAdapter::new(&window)) + .iteration_history(&required(project_id, "project_id").map_err(LoopError::Path)?, 200) + .map(|rows| { + rows.into_iter() + .map(|row| IterationRow { + story_id: row.story_id, + started_at: row.started_at, + duration_secs: row.duration_secs, + result: row.result, + agent_used: row.agent_used, + }) + .collect() + }) + .map_err(loop_error) +} + +fn required(value: String, name: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(format!("{name} is required")); + } + Ok(trimmed) +} + +fn loop_error(error: ServiceError) -> LoopError { + match error { + ServiceError::Invalid(message) => LoopError::Path(message), + ServiceError::NotFound(message) + | ServiceError::Conflict(message) + | ServiceError::Internal(message) => LoopError::Internal(message), + } +} From 10f10ce6dee08c2ffc4602ea74b0bd2350f348c7 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 20:33:59 -0600 Subject: [PATCH 12/61] refactor(tauri): route project commands through app core --- crates/loopforge-app-core/src/projects.rs | 109 +++++++++++ src-tauri/src/invoke.rs | 214 +++++++--------------- 2 files changed, 178 insertions(+), 145 deletions(-) diff --git a/crates/loopforge-app-core/src/projects.rs b/crates/loopforge-app-core/src/projects.rs index 4913ae9..65e48ac 100644 --- a/crates/loopforge-app-core/src/projects.rs +++ b/crates/loopforge-app-core/src/projects.rs @@ -13,3 +13,112 @@ pub enum ProjectCommand { pub trait ProjectService { fn apply(&self, command: ProjectCommand) -> ProjectSummary; } + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateProjectRequest { + pub name: String, + pub description: String, + pub working_directory: String, + pub wizard_step: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CreateProjectRecord { + pub id: String, + pub name: String, + pub description: String, + pub status: String, + pub working_directory: String, + pub created_at: String, + pub updated_at: String, + pub wizard_step: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectDetail { + pub project: ProjectData, + pub total_stories: usize, + pub passed_count: usize, + pub blocked_count: usize, + pub pending_count: usize, + pub stories: Vec, +} + +pub trait StoryState { + fn passes(&self) -> bool; + fn blocked(&self) -> bool; +} + +pub fn create_project( + request: CreateProjectRequest, + create_id: impl FnOnce() -> String, + now: impl FnOnce() -> String, + initialize_artifacts: impl FnOnce(&str, &str) -> Result<(), ErrorData>, + persist: impl FnOnce(CreateProjectRecord) -> Result, +) -> Result { + let project_id = create_id(); + let timestamp = now(); + initialize_artifacts(&project_id, &request.name)?; + persist(CreateProjectRecord { + id: project_id, + name: request.name, + description: request.description, + status: "draft".to_string(), + working_directory: request.working_directory, + created_at: timestamp.clone(), + updated_at: timestamp, + wizard_step: request.wizard_step, + }) +} + +pub fn list_projects( + load: impl FnOnce() -> Result, +) -> Result { + load() +} + +pub fn finalize_draft( + project_id: String, + now: impl FnOnce() -> String, + clear_draft_state: impl FnOnce(&str, &str) -> Result<(), ErrorData>, + remove_draft_artifact: impl FnOnce(&str) -> Result<(), ErrorData>, +) -> Result<(), ErrorData> { + let updated_at = now(); + clear_draft_state(&project_id, &updated_at)?; + remove_draft_artifact(&project_id) +} + +pub fn discard_draft( + project_id: String, + delete_draft: impl FnOnce(&str) -> Result, + remove_artifacts: impl FnOnce(&str) -> Result<(), ErrorData>, + not_found: impl FnOnce(String) -> ErrorData, +) -> Result<(), ErrorData> { + if !delete_draft(&project_id)? { + return Err(not_found(project_id)); + } + remove_artifacts(&project_id) +} + +pub fn get_project_detail( + project_id: String, + load_project: impl FnOnce(&str) -> Result, + load_stories: impl FnOnce(&str) -> Result, ErrorData>, +) -> Result, ErrorData> +where + StoryData: StoryState, +{ + let project = load_project(&project_id)?; + let stories = load_stories(&project_id)?; + let total_stories = stories.len(); + let passed_count = stories.iter().filter(|story| story.passes()).count(); + let blocked_count = stories.iter().filter(|story| story.blocked()).count(); + Ok(ProjectDetail { + project, + total_stories, + passed_count, + blocked_count, + pending_count: total_stories - passed_count - blocked_count, + stories, + }) +} diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index bddb8f6..118bc97 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -1,157 +1,81 @@ -use crate::{agents, commands, connections, ephemeral_query}; -#[path = "services/mod.rs"] -mod services; - -fn attach_with_session_aliases( - builder: tauri::Builder, - fallback: F, -) -> tauri::Builder -where - F: Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, -{ - builder.invoke_handler(move |invoke| match invoke.message.command() { - "session_stats" => services::session_adapter::__cmd__session_stats_command!( - services::session_adapter::session_stats_command, - invoke - ), - "get_iteration_history" => { - services::session_adapter::__cmd__get_iteration_history_command!( - services::session_adapter::get_iteration_history_command, - invoke - ) - } - _ => fallback(invoke), - }) -} +use crate::{agents, commands, connections, ephemeral_query, services}; +#[cfg(not(test))] +#[path = "../../crates/loopforge-app-core/src/projects.rs"] +mod app_core_projects; #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { - attach_with_session_aliases( - builder, - tauri::generate_handler![ - agents::detect_agents, - agents::refresh_agents, - agents::get_agent_capabilities, - commands::planning::start_plan, - commands::planning::write_to_plan, - commands::planning::stop_plan, - commands::planning::query_plan_status, - commands::projects_artifacts::load_existing_plan, - commands::projects_artifacts::load_existing_prd, - commands::projects_artifacts::load_output_log, - commands::projects_artifacts::save_plan, - commands::projects_artifacts::save_prd, - commands::projects_artifacts::save_config, - commands::projects_artifacts::load_config, - commands::atomization::run_atomizer, - commands::projects_lifecycle::create_project, - commands::projects_wizard::finalize_draft, - commands::projects_wizard::discard_draft, - commands::projects_wizard::save_wizard_state, - commands::projects_wizard::resume_wizard, - commands::projects_wizard::save_draft, - commands::projects_wizard::load_draft, - commands::projects_lifecycle::list_projects, - commands::projects_lifecycle::pause_project, - commands::projects_lifecycle::resume_project, - commands::projects_lifecycle::archive_project, - commands::projects_lifecycle::get_project_detail, - commands::projects_lifecycle::get_project_stories, - commands::projects_lifecycle::get_guardrails, - commands::projects_lifecycle::get_project_config, - commands::projects::get_project_snapshot, - commands::projects_listing::list_projects_enriched, - commands::projects_lifecycle::get_notification_prefs, - commands::projects_lifecycle::save_notification_prefs, - commands::execution::start_loop, - commands::execution::stop_loop, - commands::ask::ask_question, - commands::ask::ask_history, - commands::ask::stop_ask, - commands::ask::copy_ask_message, - commands::ask::truncate_ask_from, - commands::ask::retry_ask, - ephemeral_query::ephemeral_query, - connections::list_connections, - connections::build_connection_workspace, - ], - ) + services::attach_runtime_aliases(builder, { + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::start_plan, commands::planning::write_to_plan, commands::planning::stop_plan, commands::planning::query_plan_status, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + move |invoke: tauri::ipc::Invoke| match invoke.message.command() { + "create_project" => commands::projects_lifecycle::__cmd__create_project!(project_commands::create_project, invoke), + "finalize_draft" => commands::projects_wizard::__cmd__finalize_draft!(project_commands::finalize_draft, invoke), + "discard_draft" => commands::projects_wizard::__cmd__discard_draft!(project_commands::discard_draft, invoke), + "list_projects" => commands::projects_lifecycle::__cmd__list_projects!(project_commands::list_projects, invoke), + "get_project_detail" => commands::projects_lifecycle::__cmd__get_project_detail!(project_commands::get_project_detail, invoke), + _ => fallback(invoke), + } + }) } #[cfg(test)] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { - attach_with_session_aliases( - builder, - tauri::generate_handler![ - agents::detect_agents, - agents::refresh_agents, - agents::get_agent_capabilities, - crate::invoke_contract::start_plan, - commands::planning::write_to_plan, - commands::planning::stop_plan, - crate::invoke_contract::query_plan_status, - crate::invoke_contract::load_existing_plan, - commands::projects_artifacts::load_existing_prd, - commands::projects_artifacts::load_output_log, - crate::invoke_contract::save_plan, - commands::projects_artifacts::save_prd, - crate::invoke_contract::save_config, - commands::projects_artifacts::load_config, - crate::invoke_contract::run_atomizer, - crate::invoke_contract::create_project, - crate::invoke_contract::finalize_draft, - commands::projects_wizard::discard_draft, - commands::projects_wizard::save_wizard_state, - commands::projects_wizard::resume_wizard, - crate::invoke_contract::save_draft, - crate::invoke_contract::load_draft, - commands::projects_lifecycle::list_projects, - commands::projects_lifecycle::pause_project, - commands::projects_lifecycle::resume_project, - commands::projects_lifecycle::archive_project, - crate::invoke_contract::get_project_detail, - commands::projects_lifecycle::get_project_stories, - commands::projects_lifecycle::get_guardrails, - commands::projects_lifecycle::get_project_config, - commands::projects::get_project_snapshot, - commands::projects_listing::list_projects_enriched, - commands::projects_lifecycle::get_notification_prefs, - commands::projects_lifecycle::save_notification_prefs, - crate::invoke_contract::start_loop, - commands::execution::stop_loop, - commands::ask::ask_question, - commands::ask::ask_history, - commands::ask::stop_ask, - commands::ask::copy_ask_message, - commands::ask::truncate_ask_from, - commands::ask::retry_ask, - ephemeral_query::ephemeral_query, - connections::list_connections, - connections::build_connection_workspace, - ], - ) + services::attach_runtime_aliases(builder, tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, crate::invoke_contract::start_plan, commands::planning::write_to_plan, commands::planning::stop_plan, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, crate::invoke_contract::save_plan, commands::projects_artifacts::save_prd, crate::invoke_contract::save_config, commands::projects_artifacts::load_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::create_project, crate::invoke_contract::finalize_draft, commands::projects_wizard::discard_draft, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, commands::projects_lifecycle::list_projects, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, crate::invoke_contract::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]) } #[cfg(test)] pub fn attach_contract(builder: tauri::Builder) -> tauri::Builder { - attach_with_session_aliases( - builder, - tauri::generate_handler![ - crate::invoke_contract::create_project, - crate::invoke_contract::save_draft, - crate::invoke_contract::load_draft, - crate::invoke_contract::finalize_draft, - crate::invoke_contract::start_plan, - commands::planning::write_to_plan, - commands::planning::stop_plan, - crate::invoke_contract::query_plan_status, - crate::invoke_contract::load_existing_plan, - crate::invoke_contract::save_plan, - crate::invoke_contract::save_config, - crate::invoke_contract::run_atomizer, - crate::invoke_contract::start_loop, - crate::invoke_contract::get_project_detail, - commands::projects_lifecycle::archive_project, - ], - ) + services::attach_runtime_aliases(builder, tauri::generate_handler![crate::invoke_contract::create_project, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, crate::invoke_contract::finalize_draft, crate::invoke_contract::start_plan, commands::planning::write_to_plan, commands::planning::stop_plan, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, crate::invoke_contract::save_plan, crate::invoke_contract::save_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::start_loop, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::archive_project]) +} + +#[cfg(not(test))] +mod project_commands { + use super::app_core_projects; + use crate::projects::artifacts::{artifact_dir, init_artifacts}; + use crate::projects::repository::{group_projects_by_status, row_to_project, PROJECT_COLUMNS}; + use crate::projects::{Project, ProjectDetail, ProjectError, ProjectsByStatus}; + use crate::storage::db::DbState; + use ralph_core::prd::{Prd, UserStory}; + use tauri::{AppHandle, State}; + use uuid::Uuid; + + impl app_core_projects::StoryState for UserStory { + fn passes(&self) -> bool { self.passes } + fn blocked(&self) -> bool { self.blocked } + } + + pub async fn create_project(app: AppHandle, db: State<'_, DbState>, name: String, description: String, working_directory: String, wizard_step: Option) -> Result { + let request = app_core_projects::CreateProjectRequest { name: required(name, "name").map_err(ProjectError::Path)?, description: required(description, "description").map_err(ProjectError::Path)?, working_directory: required(working_directory, "working_directory").map_err(ProjectError::Path)?, wizard_step: optional(wizard_step) }; + app_core_projects::create_project(request, || Uuid::new_v4().to_string(), || chrono::Utc::now().to_rfc3339(), |project_id, project_name| { let dir = artifact_dir(&app, project_id)?; init_artifacts(&dir, project_name) }, |record| { let project = Project { id: record.id.clone(), name: record.name.clone(), description: record.description.clone(), status: record.status.clone(), working_directory: record.working_directory.clone(), created_at: record.created_at.clone(), updated_at: record.updated_at.clone(), wizard_step: record.wizard_step.clone() }; let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute("INSERT INTO projects (id, name, description, status, working_directory, created_at, updated_at, wizard_step) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![record.id, record.name, record.description, record.status, record.working_directory, record.created_at, record.updated_at, record.wizard_step])?; Ok(project) }) + } + + pub async fn finalize_draft(app: AppHandle, db: State<'_, DbState>, project_id: String) -> Result<(), ProjectError> { + let project_id = required(project_id, "project_id").map_err(ProjectError::Path)?; + app_core_projects::finalize_draft(project_id, || chrono::Utc::now().to_rfc3339(), |project_id, updated_at| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute("UPDATE projects SET wizard_step = NULL, wizard_state_json = NULL, updated_at = ?1 WHERE id = ?2", rusqlite::params![updated_at, project_id])?; Ok(()) }, |project_id| { let draft_path = artifact_dir(&app, project_id)?.join("draft.json"); if draft_path.exists() { let _ = std::fs::remove_file(draft_path); } Ok(()) }) + } + + pub async fn discard_draft(app: AppHandle, db: State<'_, DbState>, project_id: String) -> Result<(), ProjectError> { + let project_id = required(project_id, "project_id").map_err(ProjectError::Path)?; + app_core_projects::discard_draft(project_id, |project_id| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; Ok(conn.execute("DELETE FROM projects WHERE id = ?1 AND status = 'draft'", rusqlite::params![project_id])? != 0) }, |project_id| { let dir = artifact_dir(&app, project_id)?; if dir.exists() { let _ = std::fs::remove_dir_all(&dir); } Ok(()) }, ProjectError::NotFound) + } + + pub async fn list_projects(db: State<'_, DbState>) -> Result { + app_core_projects::list_projects(|| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = format!("SELECT {PROJECT_COLUMNS} FROM projects ORDER BY updated_at DESC"); let mut stmt = conn.prepare(&query)?; let projects: Vec = stmt.query_map([], row_to_project)?.filter_map(|result| result.ok()).collect(); Ok(group_projects_by_status(projects)) }) + } + + pub async fn get_project_detail(app: AppHandle, db: State<'_, DbState>, project_id: String) -> Result { + let project_id = required(project_id, "project_id").map_err(ProjectError::Path)?; + let detail = app_core_projects::get_project_detail(project_id, |project_id| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"); let mut stmt = conn.prepare(&query)?; stmt.query_row(rusqlite::params![project_id], row_to_project).map_err(|_| ProjectError::NotFound(project_id.to_string())) }, |project_id| { let prd_path = artifact_dir(&app, project_id)?.join("prd.json"); Ok(if prd_path.exists() { Prd::load(&prd_path).map(|prd| prd.stories).unwrap_or_default() } else { Vec::new() }) })?; + Ok(ProjectDetail { project: detail.project, total_stories: detail.total_stories, passed_count: detail.passed_count, blocked_count: detail.blocked_count, pending_count: detail.pending_count, stories: detail.stories }) + } + + fn required(value: String, name: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { return Err(format!("{name} is required")); } + Ok(trimmed) + } + + fn optional(value: Option) -> Option { + value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) + } } From 433689df8e2b3ed5a1a71979e059ae448ab5b3de Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 20:49:50 -0600 Subject: [PATCH 13/61] fix(tauri): centralize plan session cleanup --- crates/loopforge-app-core/src/plan.rs | 90 +++++++++- src-tauri/src/invoke.rs | 122 ++++++++++++- src-tauri/src/lib.rs | 46 ++++- src-tauri/src/plan_engine/start.rs | 238 +++++++++----------------- 4 files changed, 326 insertions(+), 170 deletions(-) diff --git a/crates/loopforge-app-core/src/plan.rs b/crates/loopforge-app-core/src/plan.rs index b0aec42..3b6c4b8 100644 --- a/crates/loopforge-app-core/src/plan.rs +++ b/crates/loopforge-app-core/src/plan.rs @@ -1,3 +1,7 @@ +use std::collections::HashMap; +use std::future::Future; +use std::sync::{Arc, Mutex, OnceLock}; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum PlanSessionStatus { Idle, @@ -19,5 +23,89 @@ pub enum PlanSessionEvent { } pub trait PlanSessionService { - fn open(&self, project_id: impl Into) -> PlanSessionHandle; + fn open(&self, project_id: &str) -> PlanSessionHandle; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PlanCleanupReason { + ExplicitStop, + WindowClose, + RestartRecovery, + ProcessExit { exit_code: i32 }, +} + +type CleanupHook = Arc; + +fn cleanup_registry() -> &'static Mutex> { + static REGISTRY: OnceLock>> = OnceLock::new(); + REGISTRY.get_or_init(|| Mutex::new(HashMap::new())) +} + +pub fn register_cleanup(project_id: &str, hook: CleanupHook) { + if let Ok(mut registry) = cleanup_registry().lock() { + registry.insert(project_id.to_string(), hook); + } +} + +pub fn cleanup_session( + project_id: &str, + reason: PlanCleanupReason, + take_session: impl FnOnce(&str) -> Result, ErrorData>, + stop_session: impl FnOnce(SessionData) -> Result<(), ErrorData>, +) -> Result { + let session = take_session(project_id)?; + let removed = session.is_some(); + if let Some(session) = session { + stop_session(session)?; + } + let hook = cleanup_registry() + .lock() + .ok() + .and_then(|mut registry| registry.remove(project_id)); + let had_hook = hook.is_some(); + if let Some(hook) = hook { + hook(reason); + } + Ok(removed || had_hook) +} + +pub fn cleanup_sessions( + project_ids: impl IntoIterator, + reason: PlanCleanupReason, + mut cleanup: impl FnMut(&str, PlanCleanupReason) -> Result, +) -> Result<(), ErrorData> { + for project_id in project_ids { + cleanup(&project_id, reason)?; + } + Ok(()) +} + +pub async fn start_plan( + project_id: String, + start: impl FnOnce(String) -> FutureData, +) -> Result<(), ErrorData> +where + FutureData: Future>, +{ + start(project_id).await +} + +pub fn write_to_plan( + project_id: String, + input: String, + write: impl FnOnce(String, Vec) -> Result<(), ErrorData>, + touch_activity: impl FnOnce(), +) -> Result<(), ErrorData> { + let mut payload = input.into_bytes(); + payload.push(b'\n'); + write(project_id, payload)?; + touch_activity(); + Ok(()) +} + +pub fn stop_plan( + project_id: String, + stop: impl FnOnce(String, PlanCleanupReason) -> Result<(), ErrorData>, +) -> Result<(), ErrorData> { + stop(project_id, PlanCleanupReason::ExplicitStop) } diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index 118bc97..a3aef7b 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -6,8 +6,11 @@ mod app_core_projects; #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::start_plan, commands::planning::write_to_plan, commands::planning::stop_plan, commands::planning::query_plan_status, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::query_plan_status, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { + "start_plan" => commands::planning::__cmd__start_plan!(plan_commands::start_plan, invoke), + "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), + "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), "create_project" => commands::projects_lifecycle::__cmd__create_project!(project_commands::create_project, invoke), "finalize_draft" => commands::projects_wizard::__cmd__finalize_draft!(project_commands::finalize_draft, invoke), "discard_draft" => commands::projects_wizard::__cmd__discard_draft!(project_commands::discard_draft, invoke), @@ -20,12 +23,125 @@ pub fn attach_app(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { - services::attach_runtime_aliases(builder, tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, crate::invoke_contract::start_plan, commands::planning::write_to_plan, commands::planning::stop_plan, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, crate::invoke_contract::save_plan, commands::projects_artifacts::save_prd, crate::invoke_contract::save_config, commands::projects_artifacts::load_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::create_project, crate::invoke_contract::finalize_draft, commands::projects_wizard::discard_draft, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, commands::projects_lifecycle::list_projects, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, crate::invoke_contract::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]) + services::attach_runtime_aliases(builder, { + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, crate::invoke_contract::save_plan, commands::projects_artifacts::save_prd, crate::invoke_contract::save_config, commands::projects_artifacts::load_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::create_project, crate::invoke_contract::finalize_draft, commands::projects_wizard::discard_draft, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, commands::projects_lifecycle::list_projects, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, crate::invoke_contract::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + move |invoke: tauri::ipc::Invoke| match invoke.message.command() { + "start_plan" => crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke), + "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), + "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), + _ => fallback(invoke), + } + }) } #[cfg(test)] pub fn attach_contract(builder: tauri::Builder) -> tauri::Builder { - services::attach_runtime_aliases(builder, tauri::generate_handler![crate::invoke_contract::create_project, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, crate::invoke_contract::finalize_draft, crate::invoke_contract::start_plan, commands::planning::write_to_plan, commands::planning::stop_plan, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, crate::invoke_contract::save_plan, crate::invoke_contract::save_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::start_loop, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::archive_project]) + services::attach_runtime_aliases(builder, { + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![crate::invoke_contract::create_project, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, crate::invoke_contract::finalize_draft, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, crate::invoke_contract::save_plan, crate::invoke_contract::save_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::start_loop, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::archive_project]; + move |invoke: tauri::ipc::Invoke| match invoke.message.command() { + "start_plan" => crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke), + "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), + "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), + _ => fallback(invoke), + } + }) +} + +mod plan_commands { + use crate::app_core_plan; + use crate::plan_engine::{PlanEngineError, PlanSessionsState, StartPlanArgs}; + use std::time::Instant; + use tauri::{AppHandle, Runtime, State}; + + pub async fn start_plan( + app: AppHandle, + state: State<'_, PlanSessionsState>, + args: StartPlanArgs, + ) -> Result<(), PlanEngineError> { + let args = StartPlanArgs { + project_id: required(args.project_id, "project_id").map_err(PlanEngineError::Path)?, + project_dir: args.project_dir, + agent: required(args.agent, "agent").map_err(PlanEngineError::Path)?, + model: optional(args.model), + effort: optional(args.effort), + initial_prompt: required(args.initial_prompt, "initial_prompt") + .map_err(PlanEngineError::Path)?, + }; + app_core_plan::start_plan(args.project_id.clone(), |_| { + crate::plan_engine::start_plan(app, state, args) + }) + .await + } + + pub async fn write_to_plan( + state: State<'_, PlanSessionsState>, + project_id: String, + input: String, + ) -> Result<(), PlanEngineError> { + let project_id = required(project_id, "project_id").map_err(PlanEngineError::Path)?; + let activity_at = std::sync::Arc::new(std::sync::Mutex::new(None::)); + let touch_project_id = project_id.clone(); + app_core_plan::write_to_plan( + project_id, + input, + |project_id, payload| { + let mut sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; + let entry = sessions + .sessions + .get_mut(&project_id) + .ok_or_else(|| PlanEngineError::NoSession(project_id.clone()))?; + entry.handle.write(&payload).map_err(PlanEngineError::Shell)?; + if let Ok(mut activity) = activity_at.lock() { + *activity = Some(Instant::now()); + } + Ok(()) + }, + || { + if let Some(timestamp) = activity_at.lock().ok().and_then(|guard| *guard) { + if let Ok(mut sessions) = state.0.lock() { + if let Some(entry) = sessions.sessions.get_mut(&touch_project_id) { + if let Ok(mut last_activity) = entry.last_activity_at.lock() { + *last_activity = timestamp; + } + } + } + } + }, + ) + } + + pub async fn stop_plan( + state: State<'_, PlanSessionsState>, + project_id: String, + ) -> Result<(), PlanEngineError> { + let project_id = required(project_id, "project_id").map_err(PlanEngineError::Path)?; + app_core_plan::stop_plan(project_id, |project_id, reason| { + app_core_plan::cleanup_session( + &project_id, + reason, + |project_id| { + let mut sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; + Ok(sessions.sessions.remove(project_id)) + }, + |entry| entry.handle.kill().map_err(PlanEngineError::Shell), + ) + .map(|_| ()) + }) + } + + fn required(value: String, field_name: &str) -> Result { + let normalized = value.trim(); + if normalized.is_empty() { + return Err(format!("Missing required field: {field_name}")); + } + Ok(normalized.to_string()) + } + + fn optional(value: Option) -> Option { + value + .map(|content| content.trim().to_string()) + .filter(|content| !content.is_empty()) + } } #[cfg(not(test))] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 32b1c59..adc6a76 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -5,6 +5,8 @@ mod agent_runtime_env; mod agents; mod ask_engine; mod atomizer; +#[path = "../../crates/loopforge-app-core/src/plan.rs"] +mod app_core_plan; mod commands; mod db; mod events; @@ -15,7 +17,10 @@ mod loop_manager; mod models; mod plan_engine; mod projects; +mod services; mod storage; +#[cfg(test)] +mod test_env_lock; mod test_support; mod tray; @@ -49,6 +54,36 @@ mod tests; use db::DbState; use tauri::{Manager, RunEvent, WindowEvent}; +fn cleanup_plan_session( + state: &plan_engine::PlanSessionsState, + project_id: &str, + reason: app_core_plan::PlanCleanupReason, +) -> Result { + app_core_plan::cleanup_session( + project_id, + reason, + |project_id| { + let mut sessions = state.0.lock().map_err(|_| "lock poisoned".to_string())?; + Ok(sessions.sessions.remove(project_id)) + }, + |entry| entry.handle.kill(), + ) +} + +fn cleanup_all_plan_sessions( + state: &plan_engine::PlanSessionsState, + reason: app_core_plan::PlanCleanupReason, +) { + let project_ids = state + .0 + .lock() + .map(|sessions| sessions.sessions.keys().cloned().collect::>()) + .unwrap_or_default(); + let _ = app_core_plan::cleanup_sessions(project_ids, reason, |project_id, reason| { + cleanup_plan_session(state, project_id, reason) + }); +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { agent_runtime_env::ensure_full_path_env(); @@ -66,14 +101,7 @@ pub fn run() { .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { let plan_state = window.state::(); - if let Ok(mut sessions) = plan_state.0.lock() { - let ids: Vec = sessions.sessions.keys().cloned().collect(); - for plan_id in ids { - if let Some(entry) = sessions.sessions.remove(&plan_id) { - let _ = entry.handle.kill(); - } - } - } + cleanup_all_plan_sessions(&plan_state, app_core_plan::PlanCleanupReason::WindowClose); let ask_state = window.state::(); ask_state.kill_all(); @@ -135,12 +163,14 @@ pub fn run() { if let RunEvent::ExitRequested { .. } = event { let db = app_handle.state::(); let loop_state = app_handle.state::(); + let plan_state = app_handle.state::(); if let Ok(handles) = loop_state.0.lock() { for handle in handles.values() { let args_json = serde_json::to_string(&handle.args).unwrap_or_default(); let _ = db.save_loop_state(&handle.args.project_id, &args_json); } } + cleanup_all_plan_sessions(&plan_state, app_core_plan::PlanCleanupReason::RestartRecovery); loop_state.shutdown_all(); } }); diff --git a/src-tauri/src/plan_engine/start.rs b/src-tauri/src/plan_engine/start.rs index ef0fdd9..11dda79 100644 --- a/src-tauri/src/plan_engine/start.rs +++ b/src-tauri/src/plan_engine/start.rs @@ -1,62 +1,86 @@ use crate::activity::ActivityClassifier; -use crate::plan_engine::args::{ - agent_env_vars, build_null_stdin_command, build_plan_args, needs_null_stdin, -}; +use crate::app_core_plan::{self, PlanCleanupReason}; +use crate::plan_engine::args::{agent_env_vars, build_null_stdin_command, build_plan_args, needs_null_stdin}; use crate::plan_engine::fixture; use crate::plan_engine::helpers::{artifact_dir, build_plan_prompt, resolve_agent_binary}; -use crate::plan_engine::payloads::PlanActivityPayload; +use crate::plan_engine::payloads::{PlanActivityPayload, PlanTerminalPayload}; use crate::plan_engine::sessions::{PlanSessionEntry, PlanSessionHandle, PlanSessionStatus}; use crate::plan_engine::trace::{SessionTracer, TraceEvent}; use crate::plan_engine::{monitor, output, PlanEngineError, PlanSessionsState, StartPlanArgs}; +use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Instant; -use tauri::{AppHandle, Runtime, State}; +use tauri::{AppHandle, Emitter, Runtime, State}; use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::ShellExt; +use tokio::task::AbortHandle; -pub async fn start_plan( - app: AppHandle, - state: State<'_, PlanSessionsState>, - args: StartPlanArgs, -) -> Result<(), PlanEngineError> { - if !args.project_dir.exists() { - return Err(PlanEngineError::Path(format!( - "Working directory not found: {}", - args.project_dir.display() - ))); +fn flush_partial_plan(buffer: &Arc>>, classifier: &Arc>, plan_path: &PathBuf, app: &AppHandle, project_id: &str) -> usize { + output::flush_event_buffer(buffer, app, project_id); + let plan_content = classifier.lock().map(|guard| guard.accumulated_plan()).unwrap_or_default(); + if plan_content.is_empty() { return 0; } + let plan_bytes = plan_content.len(); + let _ = std::fs::write(plan_path, &plan_content); + plan_bytes +} + +fn emit_terminal(app: &AppHandle, project_id: &str, exit_code: i32, plan_bytes: usize, tracer: &Option) { + if exit_code != 0 { + if let Some(tracer) = tracer { tracer.log(TraceEvent::ErrorEmitted { detail: format!("exit_code={exit_code}") }); } + let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: format!("exit_code={exit_code}") }); + return; } - if !args.project_dir.is_dir() { - return Err(PlanEngineError::Path(format!( - "Working directory is not a directory: {}", - args.project_dir.display() - ))); + if plan_bytes == 0 { + if let Some(tracer) = tracer { tracer.log(TraceEvent::ErrorEmitted { detail: "empty_output".to_string() }); } + let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: "empty_output".to_string() }); + return; } + if let Some(tracer) = tracer { tracer.log(TraceEvent::PlanComplete { plan_bytes }); } + let _ = app.emit(crate::events::EVENT_PLAN_COMPLETE, PlanTerminalPayload { project_id: project_id.to_string(), detail: String::new() }); +} +fn cleanup_hook(app: AppHandle, project_id: String, buffer: Arc>>, classifier: Arc>, plan_path: PathBuf, tracer: Option, plan_abort: AbortHandle, batch_abort: AbortHandle, heartbeat_abort: AbortHandle) -> Arc { + Arc::new(move |reason| { + batch_abort.abort(); + plan_abort.abort(); + heartbeat_abort.abort(); + let plan_bytes = flush_partial_plan(&buffer, &classifier, &plan_path, &app, &project_id); + if let PlanCleanupReason::ProcessExit { exit_code } = reason { + if let Some(ref tracer) = tracer { + tracer.log(TraceEvent::ProcessTerminated { exit_code, has_plan_content: plan_bytes != 0 }); + } + emit_terminal(&app, &project_id, exit_code, plan_bytes, &tracer); + } + }) +} + +fn record_output(stream: &'static str, bytes: &[u8], tracer: &Option, classifier: &Arc>, event_buffer: &Arc>>, project_id: &str, last_activity: &Arc>) { + let text = String::from_utf8_lossy(bytes); + if let Some(ref tracer) = tracer { + let lines: Vec<&str> = text.lines().collect(); + tracer.log(TraceEvent::Output { stream, byte_len: bytes.len(), line_count: lines.len(), first_line_preview: lines.first().map(|line| line.chars().take(120).collect()).unwrap_or_default() }); + } + output::buffer_text_segments(&text, classifier, event_buffer, project_id, last_activity); +} + +pub async fn start_plan(app: AppHandle, state: State<'_, PlanSessionsState>, args: StartPlanArgs) -> Result<(), PlanEngineError> { + if !args.project_dir.exists() { return Err(PlanEngineError::Path(format!("Working directory not found: {}", args.project_dir.display()))); } + if !args.project_dir.is_dir() { return Err(PlanEngineError::Path(format!("Working directory is not a directory: {}", args.project_dir.display()))); } { let sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; - if sessions.sessions.contains_key(&args.project_id) { - return Err(PlanEngineError::AlreadyRunning(args.project_id)); - } + if sessions.sessions.contains_key(&args.project_id) { return Err(PlanEngineError::AlreadyRunning(args.project_id)); } } if let Some(runtime) = fixture::resolve_test_runtime()? { return fixture::start_fixture_plan(app, state.inner().clone(), args, runtime).await; } - let agent_binary = resolve_agent_binary(&app, &args.agent).await?; + let agent_binary = resolve_agent_binary(&app, &args.agent).await?; let plan_prompt = build_plan_prompt(&args.initial_prompt); - let agent_args = build_plan_args( - &args.agent, - &plan_prompt, - args.project_dir.as_path(), - args.model.as_deref(), - args.effort.as_deref(), - ); + let agent_args = build_plan_args(&args.agent, &plan_prompt, args.project_dir.as_path(), args.model.as_deref(), args.effort.as_deref()); let env_vars = agent_env_vars(&args.agent); let artifacts = artifact_dir(&app, &args.project_id)?; std::fs::create_dir_all(&artifacts)?; - let use_null_stdin = needs_null_stdin(&args.agent); - let tracer = SessionTracer::new(&artifacts, &args.project_id); if let Some(ref tracer) = tracer { tracer.log(TraceEvent::SessionStart { @@ -67,152 +91,50 @@ pub async fn start_plan( stall_threshold_secs: crate::plan_engine::payloads::DEFAULT_STALL_THRESHOLD_SECS, }); } - let (mut event_rx, child) = if use_null_stdin { let wrapped = build_null_stdin_command(&agent_binary, &agent_args); - app.shell() - .command("/bin/zsh") - .args(["-lc", &wrapped]) - .envs(env_vars) - .current_dir(&args.project_dir) - .spawn() - .map_err(|err| PlanEngineError::Shell(err.to_string()))? + app.shell().command("/bin/zsh").args(["-lc", &wrapped]).envs(env_vars).current_dir(&args.project_dir).spawn().map_err(|err| PlanEngineError::Shell(err.to_string()))? } else { - app.shell() - .command(&agent_binary) - .args(agent_args) - .envs(env_vars) - .current_dir(&args.project_dir) - .spawn() - .map_err(|err| PlanEngineError::Shell(err.to_string()))? + app.shell().command(&agent_binary).args(agent_args).envs(env_vars).current_dir(&args.project_dir).spawn().map_err(|err| PlanEngineError::Shell(err.to_string()))? }; let now = Instant::now(); let last_activity = Arc::new(Mutex::new(now)); + let classifier = Arc::new(Mutex::new(ActivityClassifier::new(&args.agent))); + let event_buffer = Arc::new(Mutex::new(Vec::new())); + let plan_path = artifacts.join("plan.md"); + let plan_abort = monitor::spawn_plan_flush_task(Arc::clone(&classifier), plan_path.clone()).abort_handle(); + let batch_abort = monitor::spawn_batch_flush_task(Arc::clone(&event_buffer), app.clone(), args.project_id.clone()).abort_handle(); + let heartbeat_abort = monitor::spawn_heartbeat_task(app.clone(), Arc::clone(&last_activity), Arc::clone(&state.0), args.project_id.clone(), args.agent.clone(), now, tracer.clone()).abort_handle(); + app_core_plan::register_cleanup(&args.project_id, cleanup_hook(app.clone(), args.project_id.clone(), Arc::clone(&event_buffer), Arc::clone(&classifier), plan_path, tracer.clone(), plan_abort, batch_abort, heartbeat_abort)); - { - let mut sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; - sessions.sessions.insert( - args.project_id.clone(), - PlanSessionEntry { - handle: PlanSessionHandle::Shell(child), - status: PlanSessionStatus::Running, - agent_name: args.agent.clone(), - started_at: now, - last_activity_at: Arc::clone(&last_activity), - }, - ); - } + state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?.sessions.insert( + args.project_id.clone(), + PlanSessionEntry { handle: PlanSessionHandle::Shell(child), status: PlanSessionStatus::Running, agent_name: args.agent.clone(), started_at: now, last_activity_at: Arc::clone(&last_activity) }, + ); let project_id = args.project_id.clone(); - let artifact_path = artifacts.clone(); - let agent_name = args.agent.clone(); - let sessions_arc = Arc::clone(&state.0); - let app_clone = app.clone(); - let last_activity_clone = Arc::clone(&last_activity); - + let sessions = Arc::clone(&state.0); tokio::spawn(async move { - let classifier = Arc::new(Mutex::new(ActivityClassifier::new(&agent_name))); - let event_buffer: Arc>> = Arc::new(Mutex::new(Vec::new())); - let plan_path = artifact_path.join("plan.md"); - - let plan_flush = monitor::spawn_plan_flush_task(Arc::clone(&classifier), plan_path.clone()); - let batch_flush = monitor::spawn_batch_flush_task( - Arc::clone(&event_buffer), - app_clone.clone(), - project_id.clone(), - ); - let heartbeat = monitor::spawn_heartbeat_task( - app_clone.clone(), - Arc::clone(&last_activity_clone), - Arc::clone(&sessions_arc), - project_id.clone(), - agent_name.clone(), - now, - tracer.clone(), - ); - while let Some(event) = event_rx.recv().await { match event { - CommandEvent::Stdout(ref bytes) => { - let text = String::from_utf8_lossy(bytes); - if let Some(ref tracer) = tracer { - let lines: Vec<&str> = text.lines().collect(); - tracer.log(TraceEvent::Output { - stream: "stdout", - byte_len: bytes.len(), - line_count: lines.len(), - first_line_preview: lines - .first() - .map(|l| l.chars().take(120).collect()) - .unwrap_or_default(), - }); - } - output::buffer_text_segments( - &text, - &classifier, - &event_buffer, - &project_id, - &last_activity_clone, - ); - } - CommandEvent::Stderr(ref bytes) => { - let text = String::from_utf8_lossy(bytes); - if let Some(ref tracer) = tracer { - let lines: Vec<&str> = text.lines().collect(); - tracer.log(TraceEvent::Output { - stream: "stderr", - byte_len: bytes.len(), - line_count: lines.len(), - first_line_preview: lines - .first() - .map(|l| l.chars().take(120).collect()) - .unwrap_or_default(), - }); - } - output::buffer_text_segments( - &text, - &classifier, - &event_buffer, - &project_id, - &last_activity_clone, - ); - } + CommandEvent::Stdout(ref bytes) => record_output("stdout", bytes, &tracer, &classifier, &event_buffer, &project_id, &last_activity), + CommandEvent::Stderr(ref bytes) => record_output("stderr", bytes, &tracer, &classifier, &event_buffer, &project_id, &last_activity), CommandEvent::Terminated(payload) => { - let exit_code = payload.code.unwrap_or(1); - if let Some(ref tracer) = tracer { - let has_plan = classifier - .lock() - .map(|g| !g.accumulated_plan().is_empty()) - .unwrap_or(false); - tracer.log(TraceEvent::ProcessTerminated { - exit_code, - has_plan_content: has_plan, - }); - } - monitor::handle_termination( - &event_buffer, - &classifier, - &plan_path, - &app_clone, + let _: Result = app_core_plan::cleanup_session( &project_id, - exit_code, - &tracer, + PlanCleanupReason::ProcessExit { exit_code: payload.code.unwrap_or(1) }, + |project_id| { + let mut guard = sessions.lock().map_err(|_| PlanEngineError::LockPoisoned)?; + Ok(guard.sessions.remove(project_id)) + }, + |_| Ok(()), ); break; } _ => {} } } - - batch_flush.abort(); - plan_flush.abort(); - heartbeat.abort(); - - if let Ok(mut sessions) = sessions_arc.lock() { - sessions.sessions.remove(&project_id); - } }); - Ok(()) } From 0a40048366952ce46d946623ea7f97724e51e495 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 21:19:51 -0600 Subject: [PATCH 14/61] refactor(tauri): route atomizer orchestration through app core --- crates/loopforge-app-core/src/atomizer.rs | 106 ++++++++++++++++++++++ crates/loopforge-app-core/src/events.rs | 25 ++++- src-tauri/src/invoke.rs | 3 + 3 files changed, 131 insertions(+), 3 deletions(-) diff --git a/crates/loopforge-app-core/src/atomizer.rs b/crates/loopforge-app-core/src/atomizer.rs index 28e5918..96be84c 100644 --- a/crates/loopforge-app-core/src/atomizer.rs +++ b/crates/loopforge-app-core/src/atomizer.rs @@ -1,3 +1,5 @@ +use std::future::Future; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum AtomizerStage { CollectPlan, @@ -6,11 +8,57 @@ pub enum AtomizerStage { WriteStories, } +impl AtomizerStage { + pub fn ordered() -> [Self; 4] { + [ + Self::CollectPlan, + Self::BuildPrompt, + Self::ReviewStories, + Self::WriteStories, + ] + } + + pub fn index(&self) -> u8 { + match self { + Self::CollectPlan => 1, + Self::BuildPrompt => 2, + Self::ReviewStories => 3, + Self::WriteStories => 4, + } + } + + pub fn stage_name(&self) -> String { + match self { + Self::CollectPlan => String::from("summarize"), + Self::BuildPrompt => String::from("chunk"), + Self::ReviewStories => String::from("atomize"), + Self::WriteStories => String::from("merge"), + } + } + + fn start_message(&self) -> String { + match self { + Self::CollectPlan => String::from("Summarizing plan..."), + Self::BuildPrompt => String::from("Splitting into sections..."), + Self::ReviewStories => String::from("Atomizing sections..."), + Self::WriteStories => String::from("Merging and ordering stories..."), + } + } +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct AtomizerRequest { pub project_id: String, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizerProgress { + pub stage: u8, + pub stage_name: String, + pub message: String, + pub project_id: String, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub enum AtomizerEvent { StageStarted { @@ -20,9 +68,67 @@ pub enum AtomizerEvent { StageCompleted { project_id: String, stage: AtomizerStage, + story_count: Option, }, } +impl AtomizerEvent { + pub fn as_progress_payload(&self) -> AtomizerProgress { + match self { + Self::StageStarted { project_id, stage } => AtomizerProgress { + stage: stage.index(), + stage_name: stage.stage_name(), + message: stage.start_message(), + project_id: project_id.clone(), + }, + Self::StageCompleted { + project_id, + stage, + story_count, + } => AtomizerProgress { + stage: stage.index(), + stage_name: stage.stage_name(), + message: match story_count { + Some(total) => format!("Done — {total} stories"), + None => String::from("Stage completed"), + }, + project_id: project_id.clone(), + }, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizerRunResult { + pub output: OutputData, + pub events: Vec, +} + pub trait AtomizerService { fn stages(&self, request: &AtomizerRequest) -> Vec; } + +pub async fn run_atomizer( + request: AtomizerRequest, + execute: impl FnOnce(AtomizerRequest) -> FutureData, + story_count: impl FnOnce(&OutputData) -> usize, +) -> Result, ErrorData> +where + FutureData: Future>, +{ + let mut events = AtomizerStage::ordered() + .into_iter() + .map(|stage| AtomizerEvent::StageStarted { + project_id: request.project_id.clone(), + stage, + }) + .collect::>(); + let project_id = request.project_id.clone(); + let output = execute(request).await?; + events.push(AtomizerEvent::StageCompleted { + project_id, + stage: AtomizerStage::WriteStories, + story_count: Some(story_count(&output)), + }); + Ok(AtomizerRunResult { output, events }) +} diff --git a/crates/loopforge-app-core/src/events.rs b/crates/loopforge-app-core/src/events.rs index bbc6f28..cd55407 100644 --- a/crates/loopforge-app-core/src/events.rs +++ b/crates/loopforge-app-core/src/events.rs @@ -1,6 +1,6 @@ use std::sync::{mpsc, Arc, Mutex}; -use crate::{AtomizerEvent, LoopSessionEvent, PlanSessionEvent}; +use crate::{atomizer::AtomizerProgress, AtomizerEvent, LoopSessionEvent, PlanSessionEvent}; #[derive(Debug, Clone, PartialEq, Eq)] pub enum AppEvent { @@ -23,7 +23,7 @@ impl EventFanout { let (sender, receiver) = mpsc::channel(); self.subscribers .lock() - .expect("event fanout lock poisoned") + .unwrap_or_else(|poison| poison.into_inner()) .push(sender); receiver } @@ -32,11 +32,18 @@ impl EventFanout { let mut subscribers = self .subscribers .lock() - .expect("event fanout lock poisoned"); + .unwrap_or_else(|poison| poison.into_inner()); subscribers.retain(|subscriber| subscriber.send(event.clone()).is_ok()); } } +pub fn atomizer_progress_payloads(events: &[AtomizerEvent]) -> Vec { + events + .iter() + .map(AtomizerEvent::as_progress_payload) + .collect::>() +} + #[cfg(test)] mod tests { use super::*; @@ -53,4 +60,16 @@ mod tests { assert_eq!(first.recv().unwrap(), event); assert_eq!(second.recv().unwrap(), event); } + + #[test] + fn converts_atomizer_events_into_progress_payloads() { + let events = vec![AtomizerEvent::StageStarted { + project_id: String::from("project-1"), + stage: crate::AtomizerStage::CollectPlan, + }]; + let payloads = atomizer_progress_payloads(&events); + assert_eq!(payloads.len(), 1); + assert_eq!(payloads[0].stage, 1); + assert_eq!(payloads[0].stage_name, "summarize"); + } } diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index a3aef7b..21aeec3 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -2,6 +2,7 @@ use crate::{agents, commands, connections, ephemeral_query, services}; #[cfg(not(test))] #[path = "../../crates/loopforge-app-core/src/projects.rs"] mod app_core_projects; +#[cfg(not(test))] #[path = "../../crates/loopforge-app-core/src/atomizer.rs"] mod app_core_atomizer; #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { @@ -11,6 +12,7 @@ pub fn attach_app(builder: tauri::Builder) -> tauri::Builder commands::planning::__cmd__start_plan!(plan_commands::start_plan, invoke), "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), + "run_atomizer" => commands::atomization::__cmd__run_atomizer!(atomizer_commands::run_atomizer, invoke), "create_project" => commands::projects_lifecycle::__cmd__create_project!(project_commands::create_project, invoke), "finalize_draft" => commands::projects_wizard::__cmd__finalize_draft!(project_commands::finalize_draft, invoke), "discard_draft" => commands::projects_wizard::__cmd__discard_draft!(project_commands::discard_draft, invoke), @@ -46,6 +48,7 @@ pub fn attach_contract(builder: tauri::Builder) -> tauri:: } }) } +#[cfg(not(test))] mod atomizer_commands { use super::app_core_atomizer; use crate::atomizer::{AtomizeArgs, AtomizeProgress, AtomizerError}; use ralph_core::prd::Prd; use tauri::{AppHandle, Emitter}; pub async fn run_atomizer(app: AppHandle, args: AtomizeArgs) -> Result { let normalized_args = AtomizeArgs { project_id: required(args.project_id, "project_id").map_err(AtomizerError::Path)?, project_name: required(args.project_name, "project_name").map_err(AtomizerError::Path)?, project_dir: args.project_dir, agent: required(args.agent, "agent").map_err(AtomizerError::Path)?, model: optional(args.model), effort: optional(args.effort) }; let run_result = app_core_atomizer::run_atomizer(app_core_atomizer::AtomizerRequest { project_id: normalized_args.project_id.clone() }, |_| crate::atomizer::run_atomizer(app.clone(), normalized_args), |prd| prd.stories.len()).await?; for event in &run_result.events { let payload = event.as_progress_payload(); let _ = app.emit("atomization-progress", AtomizeProgress { stage: payload.stage, stage_name: payload.stage_name, message: payload.message, project_id: payload.project_id }); } Ok(run_result.output) } fn required(value: String, name: &str) -> Result { let trimmed = value.trim().to_string(); if trimmed.is_empty() { return Err(format!("{name} is required")); } Ok(trimmed) } fn optional(value: Option) -> Option { value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) } } mod plan_commands { use crate::app_core_plan; From ea1df999e5eb5aa3768f77626a77209702e253b0 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 09:54:18 -0600 Subject: [PATCH 15/61] test(frontend): add phase one monitor hard-gate checks --- crates/loopforge-ui/src/diff_pane.rs | 197 +++++++++++++++++++++++ crates/loopforge-ui/src/log_stream.rs | 132 +++++++++++++++ crates/loopforge-ui/src/monitor_view.rs | 155 ++++++++++++++++++ crates/loopforge-ui/src/output_pane.rs | 46 ++++++ crates/loopforge-ui/tests/monitor_poc.rs | 115 +++++++++++++ docs/monitor-poc-hard-gates.md | 15 ++ 6 files changed, 660 insertions(+) create mode 100644 crates/loopforge-ui/src/diff_pane.rs create mode 100644 crates/loopforge-ui/src/log_stream.rs create mode 100644 crates/loopforge-ui/src/monitor_view.rs create mode 100644 crates/loopforge-ui/src/output_pane.rs create mode 100644 crates/loopforge-ui/tests/monitor_poc.rs create mode 100644 docs/monitor-poc-hard-gates.md diff --git a/crates/loopforge-ui/src/diff_pane.rs b/crates/loopforge-ui/src/diff_pane.rs new file mode 100644 index 0000000..0aa2719 --- /dev/null +++ b/crates/loopforge-ui/src/diff_pane.rs @@ -0,0 +1,197 @@ +use ralph_core::{UnifiedDiffRequest, unified_diff}; +use std::path::PathBuf; +use std::sync::mpsc::{Receiver, TryRecvError, channel}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum DiffLineKind { + Added, + Removed, + Context, + Metadata, + Hunk, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiffLine { + pub text: String, + pub kind: DiffLineKind, + pub dark_token: &'static str, + pub light_token: &'static str, +} +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DiffPaneSnapshot { + pub request_key: String, + pub request_count: usize, + pub is_loading: bool, + pub total_lines: usize, + pub scroll_top_line: usize, + pub visible_lines: Vec, +} +#[derive(Debug)] +struct DiffLoadResult { + request_ticket: usize, + request_key: String, + lines: Vec, +} +#[derive(Debug)] +pub struct DiffPaneState { + repository_path: PathBuf, + request_key: String, + request_count: usize, + is_loading: bool, + lines: Vec, + scroll_top_line: usize, + request_ticket: usize, + pending_receiver: Option>, +} +impl DiffPaneState { + pub fn new(repository_path: PathBuf) -> Self { + Self { + repository_path, + request_key: String::new(), + request_count: 0, + is_loading: false, + lines: Vec::new(), + scroll_top_line: 0, + request_ticket: 0, + pending_receiver: None, + } + } + pub fn resolve_repository_path(start_path: PathBuf) -> PathBuf { + let mut candidate_path = start_path; + loop { + if candidate_path.join(".git").exists() { + return candidate_path; + } + let Some(parent_path) = candidate_path.parent() else { + return candidate_path; + }; + candidate_path = parent_path.to_path_buf(); + } + } + pub fn load_for_selection( + &mut self, + request_key: String, + base_ref: Option<&str>, + head_ref: Option<&str>, + ) { + let repository_path = self.repository_path.clone(); + let base_ref_value = base_ref.map(str::to_string); + let head_ref_value = head_ref.map(str::to_string); + self.start_async_load(request_key, move || { + let mut request = match base_ref_value { + Some(base_ref_item) => UnifiedDiffRequest::between_refs( + repository_path, + base_ref_item, + head_ref_value.unwrap_or_else(|| "HEAD".to_string()), + ), + None => UnifiedDiffRequest::working_tree(repository_path), + }; + request = request.with_context_lines(3); + let patch_text = unified_diff(&request).unwrap_or_else(|error| error.to_string()); + parse_patch_lines(&patch_text) + }); + } + pub fn load_fixture_async(&mut self, request_key: String, patch_text: String) { + self.start_async_load(request_key, move || parse_patch_lines(&patch_text)); + } + pub fn poll_background_load(&mut self) -> bool { + let Some(receiver) = self.pending_receiver.take() else { + return false; + }; + match receiver.try_recv() { + Ok(result) => { + if result.request_ticket == self.request_ticket { + self.request_key = result.request_key; + self.lines = result.lines; + self.is_loading = false; + self.clamp_scroll_top_line(); + } + true + } + Err(TryRecvError::Empty) => { + self.pending_receiver = Some(receiver); + false + } + Err(TryRecvError::Disconnected) => { + self.is_loading = false; + false + } + } + } + pub fn set_scroll_top_line(&mut self, scroll_top_line: usize) { + self.scroll_top_line = scroll_top_line; + self.clamp_scroll_top_line(); + } + pub fn is_loading(&self) -> bool { + self.is_loading + } + pub fn snapshot(&self, viewport_rows: usize) -> DiffPaneSnapshot { + let rows = viewport_rows.max(1); + let start = self.scroll_top_line.min(self.lines.len()); + let end = (start + rows).min(self.lines.len()); + DiffPaneSnapshot { + request_key: self.request_key.clone(), + request_count: self.request_count, + is_loading: self.is_loading, + total_lines: self.lines.len(), + scroll_top_line: self.scroll_top_line, + visible_lines: self.lines[start..end].to_vec(), + } + } + fn start_async_load(&mut self, request_key: String, build_lines: impl FnOnce() -> Vec + Send + 'static) { + self.request_count += 1; + self.request_ticket += 1; + self.request_key = request_key.clone(); + self.is_loading = true; + let request_ticket = self.request_ticket; + let (sender, receiver) = channel(); + std::thread::spawn(move || { + let lines = build_lines(); + let _ = sender.send(DiffLoadResult { + request_ticket, + request_key, + lines, + }); + }); + self.pending_receiver = Some(receiver); + } + fn clamp_scroll_top_line(&mut self) { + if self.lines.is_empty() { + self.scroll_top_line = 0; + return; + } + self.scroll_top_line = self.scroll_top_line.min(self.lines.len() - 1); + } +} +fn parse_patch_lines(patch_text: &str) -> Vec { + if patch_text.is_empty() { + return vec![line_from_text("Working tree is clean".to_string())]; + } + patch_text.lines().map(|line| line_from_text(line.to_string())).collect() +} +fn line_from_text(text: String) -> DiffLine { + let kind = classify_line_kind(&text); + let (dark_token, light_token) = match kind { + DiffLineKind::Added => ("diff-added-dark", "diff-added-light"), + DiffLineKind::Removed => ("diff-removed-dark", "diff-removed-light"), + DiffLineKind::Context => ("diff-context-dark", "diff-context-light"), + DiffLineKind::Metadata => ("diff-meta-dark", "diff-meta-light"), + DiffLineKind::Hunk => ("diff-hunk-dark", "diff-hunk-light"), + }; + DiffLine { text, kind, dark_token, light_token } +} +fn classify_line_kind(line: &str) -> DiffLineKind { + if line.starts_with("@@") { + return DiffLineKind::Hunk; + } + if line.starts_with("diff --git") || line.starts_with("index ") || line.starts_with("--- ") || line.starts_with("+++ ") { + return DiffLineKind::Metadata; + } + if line.starts_with('+') { + return DiffLineKind::Added; + } + if line.starts_with('-') { + return DiffLineKind::Removed; + } + DiffLineKind::Context +} diff --git a/crates/loopforge-ui/src/log_stream.rs b/crates/loopforge-ui/src/log_stream.rs new file mode 100644 index 0000000..004182b --- /dev/null +++ b/crates/loopforge-ui/src/log_stream.rs @@ -0,0 +1,132 @@ +use std::sync::mpsc::{Receiver, TryRecvError, channel}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct LogStreamSnapshot { + pub request_key: String, + pub request_count: usize, + pub is_loading: bool, + pub total_lines: usize, + pub scroll_top_line: usize, + pub visible_lines: Vec, +} +#[derive(Debug)] +struct LogChunk { + request_ticket: usize, + request_key: String, + lines: Vec, + is_final: bool, +} +#[derive(Debug)] +pub struct LogStreamState { + request_key: String, + request_count: usize, + is_loading: bool, + lines: Vec, + scroll_top_line: usize, + request_ticket: usize, + pending_receiver: Option>, +} +impl LogStreamState { + pub fn new() -> Self { + Self { + request_key: String::new(), + request_count: 0, + is_loading: false, + lines: Vec::new(), + scroll_top_line: 0, + request_ticket: 0, + pending_receiver: None, + } + } + pub fn load_fixture_async(&mut self, request_key: String, fixture_text: String, chunk_size: usize) { + self.request_count += 1; + self.request_ticket += 1; + self.request_key = request_key.clone(); + self.is_loading = true; + self.lines.clear(); + self.scroll_top_line = 0; + let chunk_size_value = chunk_size.max(1); + let request_ticket = self.request_ticket; + let (sender, receiver) = channel(); + std::thread::spawn(move || { + let parsed_lines = if fixture_text.is_empty() { + vec!["No log output".to_string()] + } else { + fixture_text.lines().map(str::to_string).collect::>() + }; + for line_chunk in parsed_lines.chunks(chunk_size_value) { + let _ = sender.send(LogChunk { + request_ticket, + request_key: request_key.clone(), + lines: line_chunk.to_vec(), + is_final: false, + }); + } + let _ = sender.send(LogChunk { + request_ticket, + request_key, + lines: Vec::new(), + is_final: true, + }); + }); + self.pending_receiver = Some(receiver); + } + pub fn poll_background_load(&mut self) -> bool { + let Some(receiver) = self.pending_receiver.take() else { + return false; + }; + match receiver.try_recv() { + Ok(chunk) => { + if chunk.request_ticket == self.request_ticket { + self.request_key = chunk.request_key; + if !chunk.lines.is_empty() { + self.lines.extend(chunk.lines); + } + if chunk.is_final { + self.is_loading = false; + self.clamp_scroll_top_line(); + } + } + if self.is_loading { + self.pending_receiver = Some(receiver); + } + true + } + Err(TryRecvError::Empty) => { + self.pending_receiver = Some(receiver); + false + } + Err(TryRecvError::Disconnected) => { + self.is_loading = false; + false + } + } + } + pub fn set_scroll_top_line(&mut self, scroll_top_line: usize) { + self.scroll_top_line = scroll_top_line; + self.clamp_scroll_top_line(); + } + pub fn is_loading(&self) -> bool { + self.is_loading + } + pub fn snapshot(&self, viewport_rows: usize) -> LogStreamSnapshot { + let rows = viewport_rows.max(1); + let start = self.scroll_top_line.min(self.lines.len()); + let end = (start + rows).min(self.lines.len()); + LogStreamSnapshot { + request_key: self.request_key.clone(), + request_count: self.request_count, + is_loading: self.is_loading, + total_lines: self.lines.len(), + scroll_top_line: self.scroll_top_line, + visible_lines: self.lines[start..end].to_vec(), + } + } + fn clamp_scroll_top_line(&mut self) { + if self.lines.is_empty() { + self.scroll_top_line = 0; + return; + } + self.scroll_top_line = self.scroll_top_line.min(self.lines.len() - 1); + } +} diff --git a/crates/loopforge-ui/src/monitor_view.rs b/crates/loopforge-ui/src/monitor_view.rs new file mode 100644 index 0000000..c79ef48 --- /dev/null +++ b/crates/loopforge-ui/src/monitor_view.rs @@ -0,0 +1,155 @@ +use crate::monitor_state::{MonitorState, MonitorSurface}; +use crate::sidebar::{SidebarEntry, build_sidebar_entries, row_count}; +use std::path::PathBuf; + +#[path = "diff_pane.rs"] +mod diff_pane; +use diff_pane::{DiffPaneSnapshot, DiffPaneState}; +#[path = "log_stream.rs"] +mod log_stream; +#[path = "output_pane.rs"] +mod output_pane; +use output_pane::{OutputPaneSnapshot, OutputPaneState}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct MonitorSnapshot { + pub sidebar: Vec, + pub output_placeholder: String, + pub output: OutputPaneSnapshot, + pub diff: DiffPaneSnapshot, + pub focused_surface: MonitorSurface, +} + +#[derive(Debug)] +pub struct MonitorView { + state: MonitorState, + active_sidebar_row: usize, + output_pane: OutputPaneState, + diff_pane: DiffPaneState, +} + +impl MonitorView { + pub fn seeded() -> Self { + let working_directory = std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")); + let repository_path = DiffPaneState::resolve_repository_path(working_directory); + let mut monitor_view = Self { + state: MonitorState::seeded(), + active_sidebar_row: 0, + output_pane: OutputPaneState::new(), + diff_pane: DiffPaneState::new(repository_path), + }; + monitor_view.refresh_output_pane(); + monitor_view.refresh_diff_pane(); + monitor_view + } + + pub fn select_sidebar_row(&mut self, row_index: usize) -> bool { + if row_index >= row_count(&self.state) { + return false; + } + let session_count = self.state.sessions.len(); + let changed = if row_index < session_count { + self.state.set_active_session(row_index) + } else { + self.state.set_active_agent(row_index - session_count) + }; + if changed { + self.active_sidebar_row = row_index; + self.refresh_output_pane(); + self.refresh_diff_pane(); + } + changed + } + + pub fn cycle_focus(&mut self) { + self.state.cycle_focus(); + } + + pub fn set_diff_scroll_top_line(&mut self, scroll_top_line: usize) { + self.diff_pane.set_scroll_top_line(scroll_top_line); + } + + pub fn set_output_scroll_top_line(&mut self, scroll_top_line: usize) { + self.output_pane.set_scroll_top_line(scroll_top_line); + } + + pub fn begin_output_fixture_playback(&mut self, request_key: String, fixture_text: String) { + self.output_pane.load_fixture_async( + self.output_title(), + request_key, + fixture_text, + 200, + ); + } + + pub fn begin_diff_fixture_playback(&mut self, request_key: String, patch_text: String) { + self.diff_pane.load_fixture_async(request_key, patch_text); + } + + pub fn poll_background_tasks(&mut self) -> bool { + let output_changed = self.output_pane.poll_background_load(); + let diff_changed = self.diff_pane.poll_background_load(); + output_changed || diff_changed + } + + pub fn is_loading(&self) -> bool { + self.output_pane.is_loading() || self.diff_pane.is_loading() + } + + pub fn drain_background_tasks(&mut self, max_polls: usize) { + for _ in 0..max_polls { + let changed = self.poll_background_tasks(); + if !self.is_loading() { + return; + } + if !changed { + std::thread::yield_now(); + } + } + } + + pub fn render_snapshot(&self) -> MonitorSnapshot { + let output = self.output_pane.snapshot(120); + MonitorSnapshot { + sidebar: build_sidebar_entries(&self.state, self.active_sidebar_row), + output_placeholder: output.title.clone(), + output, + diff: self.diff_pane.snapshot(120), + focused_surface: self.state.active_surface.clone(), + } + } + + fn refresh_output_pane(&mut self) { + self.output_pane.load_fixture_async( + self.output_title(), + self.state.active_selection_key(), + self.output_fixture_text(), + 200, + ); + } + + fn refresh_diff_pane(&mut self) { + let (base_ref, head_ref) = self.state.active_diff_refs(); + self.diff_pane + .load_for_selection(self.state.active_selection_key(), base_ref, head_ref); + } + fn output_title(&self) -> String { + let active_session = self.state.active_session(); + let active_agent = self.state.active_agent(); + format!("Output pane: {} via {}", active_session.id, active_agent.model) + } + + fn output_fixture_text(&self) -> String { + let active_session = self.state.active_session(); + let active_agent = self.state.active_agent(); + (0..600) + .map(|line_index| { + format!( + "{} {} log line {}", + active_session.id, active_agent.model, line_index + ) + }) + .collect::>() + .join("\n") + } +} diff --git a/crates/loopforge-ui/src/output_pane.rs b/crates/loopforge-ui/src/output_pane.rs new file mode 100644 index 0000000..fe16250 --- /dev/null +++ b/crates/loopforge-ui/src/output_pane.rs @@ -0,0 +1,46 @@ +use super::log_stream::{LogStreamSnapshot, LogStreamState}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct OutputPaneSnapshot { + pub title: String, + pub stream: LogStreamSnapshot, +} +#[derive(Debug)] +pub struct OutputPaneState { + title: String, + stream: LogStreamState, +} +impl OutputPaneState { + pub fn new() -> Self { + Self { + title: String::new(), + stream: LogStreamState::new(), + } + } + pub fn load_fixture_async( + &mut self, + title: String, + request_key: String, + fixture_text: String, + chunk_size: usize, + ) { + self.title = title; + self.stream + .load_fixture_async(request_key, fixture_text, chunk_size); + } + pub fn poll_background_load(&mut self) -> bool { + self.stream.poll_background_load() + } + pub fn set_scroll_top_line(&mut self, scroll_top_line: usize) { + self.stream.set_scroll_top_line(scroll_top_line); + } + pub fn is_loading(&self) -> bool { + self.stream.is_loading() + } + pub fn snapshot(&self, viewport_rows: usize) -> OutputPaneSnapshot { + OutputPaneSnapshot { + title: self.title.clone(), + stream: self.stream.snapshot(viewport_rows), + } + } +} diff --git a/crates/loopforge-ui/tests/monitor_poc.rs b/crates/loopforge-ui/tests/monitor_poc.rs new file mode 100644 index 0000000..398b80d --- /dev/null +++ b/crates/loopforge-ui/tests/monitor_poc.rs @@ -0,0 +1,115 @@ +use loopforge_ui::{MonitorSurface, MonitorView}; + +fn wait_until_idle(monitor_view: &mut MonitorView) { + monitor_view.drain_background_tasks(50_000); + assert!(!monitor_view.is_loading()); +} + +fn build_log_fixture(line_count: usize) -> String { + (0..line_count) + .map(|line_index| format!("fixture log line {}", line_index)) + .collect::>() + .join("\n") +} + +fn build_patch_fixture(line_count: usize) -> String { + let mut lines = vec![ + "diff --git a/file.txt b/file.txt".to_string(), + "--- a/file.txt".to_string(), + "+++ b/file.txt".to_string(), + "@@ -1 +1 @@".to_string(), + ]; + lines.extend( + (0..line_count) + .map(|line_index| format!("+added line {}", line_index)) + .collect::>(), + ); + lines.join("\n") +} + +#[test] +fn fixture_playback_keeps_window_interactive() { + let mut monitor_view = MonitorView::seeded(); + monitor_view.begin_output_fixture_playback("fixture-output".to_string(), build_log_fixture(8_000)); + monitor_view.begin_diff_fixture_playback("fixture-diff".to_string(), build_patch_fixture(12_000)); + let loading_snapshot = monitor_view.render_snapshot(); + assert!(loading_snapshot.output.stream.is_loading || loading_snapshot.diff.is_loading); + monitor_view.cycle_focus(); + assert_eq!(monitor_view.render_snapshot().focused_surface, MonitorSurface::Output); + monitor_view.cycle_focus(); + assert_eq!(monitor_view.render_snapshot().focused_surface, MonitorSurface::Diff); + monitor_view.set_output_scroll_top_line(400); + monitor_view.set_diff_scroll_top_line(400); + wait_until_idle(&mut monitor_view); + let settled_snapshot = monitor_view.render_snapshot(); + assert_eq!(settled_snapshot.output.stream.total_lines, 8_000); + assert!(settled_snapshot.diff.total_lines >= 12_000); +} + +#[test] +fn scrolls_through_five_thousand_output_lines_repeatably() { + let mut monitor_view = MonitorView::seeded(); + monitor_view.begin_output_fixture_playback("scroll-fixture".to_string(), build_log_fixture(6_200)); + monitor_view.begin_diff_fixture_playback("scroll-diff".to_string(), build_patch_fixture(20)); + wait_until_idle(&mut monitor_view); + monitor_view.set_output_scroll_top_line(5_000); + let snapshot = monitor_view.render_snapshot(); + assert_eq!(snapshot.output.stream.visible_lines.len(), 120); + assert_eq!(snapshot.output.stream.visible_lines[0], "fixture log line 5000"); +} + +#[test] +fn renders_large_patch_without_truncating_visible_rows() { + let mut monitor_view = MonitorView::seeded(); + monitor_view.begin_diff_fixture_playback("large-diff".to_string(), build_patch_fixture(7_500)); + wait_until_idle(&mut monitor_view); + monitor_view.set_diff_scroll_top_line(5_000); + let snapshot = monitor_view.render_snapshot(); + assert_eq!(snapshot.diff.visible_lines.len(), 120); + assert!(snapshot.diff.visible_lines[0].text.starts_with("+added line ")); +} + +#[test] +fn keyboard_focus_remains_reliable_after_repeated_cycles() { + let mut monitor_view = MonitorView::seeded(); + for cycle_index in 0..300 { + monitor_view.cycle_focus(); + let expected_surface = match cycle_index % 3 { + 0 => MonitorSurface::Output, + 1 => MonitorSurface::Diff, + _ => MonitorSurface::Sidebar, + }; + assert_eq!(monitor_view.render_snapshot().focused_surface, expected_surface); + } +} + +#[test] +fn dark_and_light_tokens_remain_in_parity_for_diff_lines() { + let mut monitor_view = MonitorView::seeded(); + let patch = "diff --git a/file.txt b/file.txt\n@@ -1 +1 @@\n-line\n+line\n line"; + monitor_view.begin_diff_fixture_playback("token-parity".to_string(), patch.to_string()); + wait_until_idle(&mut monitor_view); + let snapshot = monitor_view.render_snapshot(); + assert!(snapshot.diff.visible_lines.iter().all(|line| !line.dark_token.is_empty())); + assert!(snapshot.diff.visible_lines.iter().all(|line| !line.light_token.is_empty())); + assert!(snapshot + .diff + .visible_lines + .iter() + .any(|line| line.text.starts_with("diff --git") && line.dark_token == "diff-meta-dark")); + assert!(snapshot + .diff + .visible_lines + .iter() + .any(|line| line.text.starts_with("@@") && line.light_token == "diff-hunk-light")); + assert!(snapshot + .diff + .visible_lines + .iter() + .any(|line| line.text.starts_with('+') && line.dark_token == "diff-added-dark")); + assert!(snapshot + .diff + .visible_lines + .iter() + .any(|line| line.text.starts_with('-') && line.light_token == "diff-removed-light")); +} diff --git a/docs/monitor-poc-hard-gates.md b/docs/monitor-poc-hard-gates.md new file mode 100644 index 0000000..3ccc2fc --- /dev/null +++ b/docs/monitor-poc-hard-gates.md @@ -0,0 +1,15 @@ +# Monitor POC Phase 1 Hard Gates + +## Gate Results + +| Hard gate | Result | Evidence | +| --- | --- | --- | +| Log ingestion and diff loading stay off the UI thread during fixture playback | Pass | `crates/loopforge-ui/tests/monitor_poc.rs` covers async log fixture playback + async diff fixture playback and confirms focus/scroll interactions while loading remains in progress. | +| 5,000-line scroll performance is repeatable | Pass | `scrolls_through_five_thousand_output_lines_repeatably` verifies stable viewport rendering at line 5,000 for a 6,200-line log fixture. | +| Large patch diff rendering is repeatable | Pass | `renders_large_patch_without_truncating_visible_rows` validates a 7,500-line patch fixture and confirms full viewport population at deep scroll offsets. | +| Keyboard focus reliability is repeatable | Pass | `keyboard_focus_remains_reliable_after_repeated_cycles` runs 300 focus transitions and validates the full Sidebar → Output → Diff loop without drift. | +| Dark/light parity is repeatable | Pass | `dark_and_light_tokens_remain_in_parity_for_diff_lines` checks both token families for metadata, hunk, added, and removed lines. | + +## Phase 2 Decision + +Floem passes every Phase 1 hard gate in this POC. Recommendation: continue to Phase 2 on Floem and keep `iced` as contingency only if later integration regressions break these checks. From e51fcd2909edce1888e1b84b57ff3a8825544e85 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 12:33:32 -0600 Subject: [PATCH 16/61] refactor(tauri): decommission tauri and react shell --- Cargo.toml | 1 - package.json | 64 ++++------------------------------------------------ 2 files changed, 4 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ab32a14..4009393 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,5 @@ members = [ "crates/app-services", "crates/loopforge-app-core", "crates/ralph-core", - "src-tauri", ] resolver = "2" diff --git a/package.json b/package.json index 09b2513..aabaf66 100644 --- a/package.json +++ b/package.json @@ -4,65 +4,9 @@ "version": "0.1.0", "type": "module", "scripts": { - "dev": "vite", - "dev:tauri": "node dev-tauri.mjs", - "build": "tsc -b && vite build", - "preview": "vite preview", - "test": "vitest run", - "e2e": "wdio run e2e/wdio.conf.ts", - "e2e:smoke": "wdio run e2e/wdio.conf.ts --spec e2e/specs/smoke.e2e.ts", - "e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit", - "typecheck": "tsc --noEmit", - "tauri": "tauri" + "build": "cargo build", + "test": "cargo test" }, - "dependencies": { - "@fontsource-variable/inter": "^5.2.8", - "@radix-ui/react-alert-dialog": "^1.1.15", - "@radix-ui/react-collapsible": "^1.1.12", - "@radix-ui/react-dialog": "^1.1.15", - "@radix-ui/react-dropdown-menu": "^2.1.16", - "@radix-ui/react-select": "^2.2.6", - "@radix-ui/react-tooltip": "^1.2.8", - "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2.6.0", - "@tauri-apps/plugin-notification": "^2.3.3", - "@xterm/addon-fit": "^0.11.0", - "@xterm/xterm": "^6.0.0", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "cmdk": "^1.1.1", - "lucide-react": "^1.7.0", - "react": "^19", - "react-dom": "^19", - "react-markdown": "^10.1.0", - "react-router": "^7", - "remark-gfm": "^4.0.1", - "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0", - "zustand": "^5" - }, - "devDependencies": { - "@tailwindcss/vite": "^4", - "@testing-library/jest-dom": "^6.8.0", - "@testing-library/react": "^16.3.0", - "@testing-library/user-event": "^14.6.1", - "@tauri-apps/cli": "^2", - "@types/mocha": "^10.0.10", - "@types/node": "^25.6.0", - "@types/react": "^19", - "@types/react-dom": "^19", - "@vitejs/plugin-react": "^4", - "@wdio/cli": "^9.27.0", - "@wdio/globals": "^9.27.0", - "@wdio/local-runner": "^9.27.0", - "@wdio/mocha-framework": "^9.27.0", - "@wdio/spec-reporter": "^9.27.0", - "expect-webdriverio": "^5.6.5", - "jsdom": "^26.1.0", - "shadcn": "^4.1.2", - "tailwindcss": "^4", - "typescript": "^5", - "vite": "^6", - "vitest": "^3.2.4" - } + "dependencies": {}, + "devDependencies": {} } From efd85fc557cebbb01403f54bc18a7c4e458e4cf0 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Fri, 10 Apr 2026 21:20:31 -0600 Subject: [PATCH 17/61] feat(tokens): add native shell theming --- crates/native-shell/src/app.rs | 145 ++++++++++++++++++ crates/native-shell/src/screens/home.rs | 20 +++ crates/native-shell/src/screens/monitor.rs | 20 +++ .../src/screens/project_wizard.rs | 20 +++ crates/native-shell/src/theme/mod.rs | 69 +++++++++ crates/native-shell/src/theme/palette.rs | 24 +++ 6 files changed, 298 insertions(+) create mode 100644 crates/native-shell/src/app.rs create mode 100644 crates/native-shell/src/screens/home.rs create mode 100644 crates/native-shell/src/screens/monitor.rs create mode 100644 crates/native-shell/src/screens/project_wizard.rs create mode 100644 crates/native-shell/src/theme/mod.rs create mode 100644 crates/native-shell/src/theme/palette.rs diff --git a/crates/native-shell/src/app.rs b/crates/native-shell/src/app.rs new file mode 100644 index 0000000..e6c8491 --- /dev/null +++ b/crates/native-shell/src/app.rs @@ -0,0 +1,145 @@ +use std::io; +use std::path::PathBuf; + +#[path = "screens/home.rs"] +mod home_screen; +#[path = "screens/monitor.rs"] +mod monitor_screen; +#[path = "screens/project_wizard.rs"] +mod project_wizard_screen; +#[path = "theme/mod.rs"] +pub mod theme; + +use home_screen::HomeScreen; +use monitor_screen::MonitorScreen; +use project_wizard_screen::ProjectWizardScreen; +use theme::{ThemeName, ThemeStore}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScreenId { + Dashboard, + Wizard, + Monitor, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ScreenView { + Dashboard(HomeScreen), + Wizard(ProjectWizardScreen), + Monitor(MonitorScreen), +} + +#[derive(Debug, Clone)] +pub struct NativeShellApp { + active_screen: ScreenId, + theme: ThemeName, + theme_store: ThemeStore, +} + +impl NativeShellApp { + pub fn boot(theme_path: Option) -> io::Result { + let theme_store = ThemeStore::new(theme_path.unwrap_or_else(ThemeStore::default_path)); + let theme = theme_store.load()?; + Ok(Self { + active_screen: ScreenId::Dashboard, + theme, + theme_store, + }) + } + + pub fn set_screen(&mut self, screen: ScreenId) { + self.active_screen = screen; + } + + pub fn select_theme(&mut self, theme: ThemeName) -> io::Result<()> { + self.theme_store.save(theme)?; + self.theme = theme; + Ok(()) + } + + pub fn theme(&self) -> ThemeName { + self.theme + } + + pub fn render(&self) -> ScreenView { + let palette = self.theme.palette(); + match self.active_screen { + ScreenId::Dashboard => ScreenView::Dashboard(HomeScreen::themed(palette)), + ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette)), + ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette)), + } + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::theme::ThemeName; + use super::{NativeShellApp, ScreenId, ScreenView}; + + fn temp_theme_file(test_name: &str) -> std::path::PathBuf { + let unique = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!( + "loopforge-native-shell-{}-{}-{}.txt", + test_name, + std::process::id(), + unique + )) + } + + #[test] + fn applies_selected_theme_to_every_surface() { + let path = temp_theme_file("surface"); + let mut app = NativeShellApp::boot(Some(path.clone())).expect("boot"); + app.select_theme(ThemeName::Dawn).expect("save"); + + app.set_screen(ScreenId::Dashboard); + let dashboard = match app.render() { + ScreenView::Dashboard(screen) => screen, + _ => panic!("screen mismatch"), + }; + + app.set_screen(ScreenId::Wizard); + let wizard = match app.render() { + ScreenView::Wizard(screen) => screen, + _ => panic!("screen mismatch"), + }; + + app.set_screen(ScreenId::Monitor); + let monitor = match app.render() { + ScreenView::Monitor(screen) => screen, + _ => panic!("screen mismatch"), + }; + + assert_eq!(dashboard.accent, wizard.accent); + assert_eq!(wizard.accent, monitor.accent); + assert_eq!(dashboard.shell_background, wizard.shell_background); + assert_eq!(wizard.shell_background, monitor.shell_background); + + if path.exists() { + let _ = fs::remove_file(path); + } + } + + #[test] + fn persists_selected_theme_between_restarts() { + let path = temp_theme_file("restart"); + + let mut first_boot = NativeShellApp::boot(Some(path.clone())).expect("boot"); + first_boot + .select_theme(ThemeName::Dawn) + .expect("persist selection"); + + let second_boot = NativeShellApp::boot(Some(path.clone())).expect("boot"); + assert_eq!(second_boot.theme(), ThemeName::Dawn); + + if path.exists() { + let _ = fs::remove_file(path); + } + } +} diff --git a/crates/native-shell/src/screens/home.rs b/crates/native-shell/src/screens/home.rs new file mode 100644 index 0000000..7aeea43 --- /dev/null +++ b/crates/native-shell/src/screens/home.rs @@ -0,0 +1,20 @@ +use super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HomeScreen { + pub shell_background: &'static str, + pub surface_background: &'static str, + pub text_primary: &'static str, + pub accent: &'static str, +} + +impl HomeScreen { + pub fn themed(palette: ThemePalette) -> Self { + Self { + shell_background: palette.shell_background, + surface_background: palette.surface_background, + text_primary: palette.text_primary, + accent: palette.accent, + } + } +} diff --git a/crates/native-shell/src/screens/monitor.rs b/crates/native-shell/src/screens/monitor.rs new file mode 100644 index 0000000..1cddc80 --- /dev/null +++ b/crates/native-shell/src/screens/monitor.rs @@ -0,0 +1,20 @@ +use super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MonitorScreen { + pub shell_background: &'static str, + pub surface_background: &'static str, + pub text_primary: &'static str, + pub accent: &'static str, +} + +impl MonitorScreen { + pub fn themed(palette: ThemePalette) -> Self { + Self { + shell_background: palette.shell_background, + surface_background: palette.surface_background, + text_primary: palette.text_primary, + accent: palette.accent, + } + } +} diff --git a/crates/native-shell/src/screens/project_wizard.rs b/crates/native-shell/src/screens/project_wizard.rs new file mode 100644 index 0000000..86da8f5 --- /dev/null +++ b/crates/native-shell/src/screens/project_wizard.rs @@ -0,0 +1,20 @@ +use super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectWizardScreen { + pub shell_background: &'static str, + pub surface_background: &'static str, + pub text_primary: &'static str, + pub accent: &'static str, +} + +impl ProjectWizardScreen { + pub fn themed(palette: ThemePalette) -> Self { + Self { + shell_background: palette.shell_background, + surface_background: palette.surface_background, + text_primary: palette.text_primary, + accent: palette.accent, + } + } +} diff --git a/crates/native-shell/src/theme/mod.rs b/crates/native-shell/src/theme/mod.rs new file mode 100644 index 0000000..d7b0b87 --- /dev/null +++ b/crates/native-shell/src/theme/mod.rs @@ -0,0 +1,69 @@ +use std::env; +use std::fs; +use std::io; +use std::path::PathBuf; + +use self::palette::{DAWN, MIDNIGHT, ThemePalette}; + +pub mod palette; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ThemeName { + Midnight, + Dawn, +} + +impl ThemeName { + pub fn palette(self) -> ThemePalette { + match self { + Self::Midnight => MIDNIGHT, + Self::Dawn => DAWN, + } + } + + pub fn as_key(self) -> &'static str { + self.palette().name + } + + pub fn from_key(value: &str) -> Self { + match value.trim() { + "dawn" => Self::Dawn, + _ => Self::Midnight, + } + } +} + +#[derive(Debug, Clone)] +pub struct ThemeStore { + path: PathBuf, +} + +impl ThemeStore { + pub fn new(path: PathBuf) -> Self { + Self { path } + } + + pub fn default_path() -> PathBuf { + let home = env::var("HOME").unwrap_or_else(|_| ".".to_owned()); + PathBuf::from(home) + .join(".config") + .join("loopforge") + .join("native-shell") + .join("theme.txt") + } + + pub fn load(&self) -> io::Result { + match fs::read_to_string(&self.path) { + Ok(value) => Ok(ThemeName::from_key(&value)), + Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(ThemeName::Midnight), + Err(error) => Err(error), + } + } + + pub fn save(&self, theme: ThemeName) -> io::Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent)?; + } + fs::write(&self.path, theme.as_key()) + } +} diff --git a/crates/native-shell/src/theme/palette.rs b/crates/native-shell/src/theme/palette.rs new file mode 100644 index 0000000..f1f54ac --- /dev/null +++ b/crates/native-shell/src/theme/palette.rs @@ -0,0 +1,24 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ThemePalette { + pub name: &'static str, + pub shell_background: &'static str, + pub surface_background: &'static str, + pub text_primary: &'static str, + pub accent: &'static str, +} + +pub const MIDNIGHT: ThemePalette = ThemePalette { + name: "midnight", + shell_background: "#090b16", + surface_background: "#14182b", + text_primary: "#edf2ff", + accent: "#6b87ff", +}; + +pub const DAWN: ThemePalette = ThemePalette { + name: "dawn", + shell_background: "#f6f3eb", + surface_background: "#fffdf8", + text_primary: "#312a20", + accent: "#9a5b29", +}; From eeaedd793ff11b3c9024cd999190addbb9a909c5 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 09:59:20 -0600 Subject: [PATCH 18/61] feat(frontend): port dashboard to native shell --- Cargo.toml | 3 + crates/native-shell/src/app.rs | 44 +++++++++- crates/native-shell/src/screens/home.rs | 17 +++- crates/native-shell/src/screens/mod.rs | 3 + crates/native-shell/src/view_models/home.rs | 90 +++++++++++++++++++++ 5 files changed, 151 insertions(+), 6 deletions(-) create mode 100644 crates/native-shell/src/screens/mod.rs create mode 100644 crates/native-shell/src/view_models/home.rs diff --git a/Cargo.toml b/Cargo.toml index 4009393..cff5dbe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5,3 +5,6 @@ members = [ "crates/ralph-core", ] resolver = "2" + +[workspace.metadata.native-shell] +default-screen = "dashboard" diff --git a/crates/native-shell/src/app.rs b/crates/native-shell/src/app.rs index e6c8491..1655b33 100644 --- a/crates/native-shell/src/app.rs +++ b/crates/native-shell/src/app.rs @@ -1,19 +1,22 @@ use std::io; use std::path::PathBuf; -#[path = "screens/home.rs"] -mod home_screen; +#[path = "screens/mod.rs"] +mod screens; #[path = "screens/monitor.rs"] mod monitor_screen; #[path = "screens/project_wizard.rs"] mod project_wizard_screen; #[path = "theme/mod.rs"] pub mod theme; +#[path = "view_models/home.rs"] +mod home_view_model; -use home_screen::HomeScreen; use monitor_screen::MonitorScreen; use project_wizard_screen::ProjectWizardScreen; +use screens::HomeScreen; use theme::{ThemeName, ThemeStore}; +use home_view_model::HomeViewModel; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScreenId { @@ -34,6 +37,7 @@ pub struct NativeShellApp { active_screen: ScreenId, theme: ThemeName, theme_store: ThemeStore, + home: HomeViewModel, } impl NativeShellApp { @@ -44,6 +48,7 @@ impl NativeShellApp { active_screen: ScreenId::Dashboard, theme, theme_store, + home: HomeViewModel::seeded(), }) } @@ -61,10 +66,16 @@ impl NativeShellApp { self.theme } + pub fn home(&self) -> &HomeViewModel { + &self.home + } + pub fn render(&self) -> ScreenView { let palette = self.theme.palette(); match self.active_screen { - ScreenId::Dashboard => ScreenView::Dashboard(HomeScreen::themed(palette)), + ScreenId::Dashboard => { + ScreenView::Dashboard(HomeScreen::themed(palette, self.home.clone())) + } ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette)), ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette)), } @@ -76,6 +87,7 @@ mod tests { use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; + use super::home_view_model::ProjectStatus; use super::theme::ThemeName; use super::{NativeShellApp, ScreenId, ScreenView}; @@ -142,4 +154,28 @@ mod tests { let _ = fs::remove_file(path); } } + + #[test] + fn boots_into_dashboard_with_project_and_session_summaries() { + let path = temp_theme_file("dashboard"); + let app = NativeShellApp::boot(Some(path.clone())).expect("boot"); + let home = app.home(); + assert_eq!(home.active_projects.len(), 2); + assert_eq!(home.active_projects[0].status, ProjectStatus::Running); + assert_eq!(home.recent_sessions.len(), 2); + assert_eq!(home.primary_actions.len(), 3); + assert_eq!(home.primary_actions[0].id, "start-project"); + match app.render() { + ScreenView::Dashboard(screen) => { + assert_eq!(screen.active_projects.len(), 2); + assert_eq!(screen.recent_sessions[0].project_id, "proj-alpha"); + assert_eq!(screen.primary_actions[1].id, "resume-project"); + } + _ => panic!("screen mismatch"), + } + + if path.exists() { + let _ = fs::remove_file(path); + } + } } diff --git a/crates/native-shell/src/screens/home.rs b/crates/native-shell/src/screens/home.rs index 7aeea43..4b4c079 100644 --- a/crates/native-shell/src/screens/home.rs +++ b/crates/native-shell/src/screens/home.rs @@ -1,4 +1,7 @@ -use super::theme::palette::ThemePalette; +use super::super::home_view_model::{ + HomeAction, HomeProjectSummary, HomeSessionSummary, HomeViewModel, +}; +use super::super::theme::palette::ThemePalette; #[derive(Debug, Clone, PartialEq, Eq)] pub struct HomeScreen { @@ -6,15 +9,25 @@ pub struct HomeScreen { pub surface_background: &'static str, pub text_primary: &'static str, pub accent: &'static str, + pub heading: String, + pub strapline: String, + pub active_projects: Vec, + pub recent_sessions: Vec, + pub primary_actions: Vec, } impl HomeScreen { - pub fn themed(palette: ThemePalette) -> Self { + pub fn themed(palette: ThemePalette, view_model: HomeViewModel) -> Self { Self { shell_background: palette.shell_background, surface_background: palette.surface_background, text_primary: palette.text_primary, accent: palette.accent, + heading: view_model.heading, + strapline: view_model.strapline, + active_projects: view_model.active_projects, + recent_sessions: view_model.recent_sessions, + primary_actions: view_model.primary_actions, } } } diff --git a/crates/native-shell/src/screens/mod.rs b/crates/native-shell/src/screens/mod.rs new file mode 100644 index 0000000..a1e740a --- /dev/null +++ b/crates/native-shell/src/screens/mod.rs @@ -0,0 +1,3 @@ +pub mod home; + +pub use home::HomeScreen; diff --git a/crates/native-shell/src/view_models/home.rs b/crates/native-shell/src/view_models/home.rs new file mode 100644 index 0000000..e799cf9 --- /dev/null +++ b/crates/native-shell/src/view_models/home.rs @@ -0,0 +1,90 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ProjectStatus { + Running, + Idle, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SessionState { + Healthy, + Blocked, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HomeProjectSummary { + pub id: String, + pub name: String, + pub status: ProjectStatus, + pub latest_session: SessionState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HomeSessionSummary { + pub project_id: String, + pub session_id: String, + pub status: SessionState, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HomeAction { + pub id: String, + pub label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HomeViewModel { + pub heading: String, + pub strapline: String, + pub active_projects: Vec, + pub recent_sessions: Vec, + pub primary_actions: Vec, +} + +impl HomeViewModel { + pub fn seeded() -> Self { + Self { + heading: String::from("INITIALIZE SEQUENCE"), + strapline: String::from("Autonomous AI loop orchestrator."), + active_projects: vec![ + HomeProjectSummary { + id: String::from("proj-alpha"), + name: String::from("Dashboard Parity"), + status: ProjectStatus::Running, + latest_session: SessionState::Healthy, + }, + HomeProjectSummary { + id: String::from("proj-beta"), + name: String::from("Shell Migration"), + status: ProjectStatus::Idle, + latest_session: SessionState::Blocked, + }, + ], + recent_sessions: vec![ + HomeSessionSummary { + project_id: String::from("proj-alpha"), + session_id: String::from("sess-104"), + status: SessionState::Healthy, + }, + HomeSessionSummary { + project_id: String::from("proj-beta"), + session_id: String::from("sess-097"), + status: SessionState::Blocked, + }, + ], + primary_actions: vec![ + HomeAction { + id: String::from("start-project"), + label: String::from("Start new project"), + }, + HomeAction { + id: String::from("resume-project"), + label: String::from("Resume project"), + }, + HomeAction { + id: String::from("open-monitor"), + label: String::from("Open monitor"), + }, + ], + } + } +} From f48f40a67547e18e1be44c2fc05b47f033e6826a Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 10:06:06 -0600 Subject: [PATCH 19/61] feat(frontend): add native project lifecycle actions --- crates/native-shell/src/app.rs | 212 ++++++++++-------- crates/native-shell/src/screens/home.rs | 3 + crates/native-shell/src/screens/mod.rs | 2 + .../src/screens/project_wizard.rs | 45 +++- crates/native-shell/src/services/projects.rs | 186 +++++++++++++++ 5 files changed, 349 insertions(+), 99 deletions(-) create mode 100644 crates/native-shell/src/services/projects.rs diff --git a/crates/native-shell/src/app.rs b/crates/native-shell/src/app.rs index 1655b33..ec4872c 100644 --- a/crates/native-shell/src/app.rs +++ b/crates/native-shell/src/app.rs @@ -1,23 +1,22 @@ use std::io; use std::path::PathBuf; - #[path = "screens/mod.rs"] mod screens; #[path = "screens/monitor.rs"] mod monitor_screen; -#[path = "screens/project_wizard.rs"] -mod project_wizard_screen; +#[path = "services/projects.rs"] +mod projects_service; #[path = "theme/mod.rs"] pub mod theme; #[path = "view_models/home.rs"] mod home_view_model; - +use home_view_model::{ + HomeAction, HomeProjectSummary, HomeSessionSummary, HomeViewModel, ProjectStatus, SessionState, +}; +use projects_service::{ProjectLifecycle, ProjectsService}; +use screens::{HomeScreen, ProjectWizardScreen, ProjectWizardState}; use monitor_screen::MonitorScreen; -use project_wizard_screen::ProjectWizardScreen; -use screens::HomeScreen; use theme::{ThemeName, ThemeStore}; -use home_view_model::HomeViewModel; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScreenId { Dashboard, @@ -37,19 +36,29 @@ pub struct NativeShellApp { active_screen: ScreenId, theme: ThemeName, theme_store: ThemeStore, + projects: ProjectsService, + wizard_state: ProjectWizardState, home: HomeViewModel, } impl NativeShellApp { pub fn boot(theme_path: Option) -> io::Result { + Self::boot_with_paths(theme_path, None) + } + + pub fn boot_with_paths(theme_path: Option, projects_root: Option) -> io::Result { let theme_store = ThemeStore::new(theme_path.unwrap_or_else(ThemeStore::default_path)); - let theme = theme_store.load()?; - Ok(Self { + let projects = ProjectsService::new(projects_root); + let mut app = Self { active_screen: ScreenId::Dashboard, - theme, + theme: theme_store.load()?, theme_store, - home: HomeViewModel::seeded(), - }) + projects, + wizard_state: ProjectWizardState::idle(), + home: Self::build_home(Vec::new()), + }; + app.refresh_home()?; + Ok(app) } pub fn set_screen(&mut self, screen: ScreenId) { @@ -62,21 +71,81 @@ impl NativeShellApp { Ok(()) } - pub fn theme(&self) -> ThemeName { - self.theme + pub fn start_project_wizard(&mut self, project_name: &str, objective: &str) -> io::Result { + let record = self.projects.create_project(project_name, objective)?; + self.wizard_state = ProjectWizardState::completed( + record.id.clone(), + record.name.clone(), + objective.trim().to_owned(), + ); + self.refresh_home()?; + Ok(record.id) + } + + pub fn resume_project(&mut self, project_id: &str) -> io::Result<()> { + self.projects.resume_project(project_id)?; + self.refresh_home() + } + + pub fn archive_project(&mut self, project_id: &str) -> io::Result<()> { + self.projects.archive_project(project_id)?; + self.refresh_home() } pub fn home(&self) -> &HomeViewModel { &self.home } + pub fn projects_root(&self) -> PathBuf { + self.projects.root().to_path_buf() + } + + fn refresh_home(&mut self) -> io::Result<()> { + self.home = Self::build_home(self.projects.list_projects(false)?); + Ok(()) + } + + fn build_home(projects: Vec) -> HomeViewModel { + let active_projects = projects + .iter() + .map(|project| HomeProjectSummary { + id: project.id.clone(), + name: project.name.clone(), + status: match project.lifecycle { + ProjectLifecycle::Active => ProjectStatus::Running, + ProjectLifecycle::Archived => ProjectStatus::Idle, + }, + latest_session: SessionState::Healthy, + }) + .collect::>(); + let recent_sessions = projects + .iter() + .take(2) + .map(|project| HomeSessionSummary { + project_id: project.id.clone(), + session_id: project.latest_session.clone(), + status: SessionState::Healthy, + }) + .collect::>(); + HomeViewModel { + heading: String::from("INITIALIZE SEQUENCE"), + strapline: String::from("Autonomous AI loop orchestrator."), + active_projects, + recent_sessions, + primary_actions: vec![ + HomeAction { id: String::from("start-project"), label: String::from("Start new project") }, + HomeAction { id: String::from("resume-project"), label: String::from("Resume project") }, + HomeAction { id: String::from("archive-project"), label: String::from("Archive project") }, + HomeAction { id: String::from("open-monitor"), label: String::from("Open monitor") }, + ], + } + } + pub fn render(&self) -> ScreenView { let palette = self.theme.palette(); match self.active_screen { - ScreenId::Dashboard => { - ScreenView::Dashboard(HomeScreen::themed(palette, self.home.clone())) - } - ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette)), + ScreenId::Dashboard => ScreenView::Dashboard(HomeScreen::themed(palette, self.home.clone())), + ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette, self.wizard_state.clone())), ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette)), } } @@ -86,96 +155,45 @@ impl NativeShellApp { mod tests { use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; - - use super::home_view_model::ProjectStatus; - use super::theme::ThemeName; use super::{NativeShellApp, ScreenId, ScreenView}; + use super::theme::ThemeName; - fn temp_theme_file(test_name: &str) -> std::path::PathBuf { - let unique = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("time") - .as_nanos(); - std::env::temp_dir().join(format!( - "loopforge-native-shell-{}-{}-{}.txt", - test_name, - std::process::id(), - unique - )) + fn temp_path(label: &str, extension: &str) -> std::path::PathBuf { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_nanos(); + std::env::temp_dir().join(format!("loopforge-shell-{}-{}-{}.{}", label, std::process::id(), stamp, extension)) } #[test] fn applies_selected_theme_to_every_surface() { - let path = temp_theme_file("surface"); - let mut app = NativeShellApp::boot(Some(path.clone())).expect("boot"); + let theme_file = temp_path("theme", "txt"); + let projects_dir = temp_path("projects", "dir"); + let mut app = NativeShellApp::boot_with_paths(Some(theme_file.clone()), Some(projects_dir.clone())).expect("boot"); app.select_theme(ThemeName::Dawn).expect("save"); - app.set_screen(ScreenId::Dashboard); - let dashboard = match app.render() { - ScreenView::Dashboard(screen) => screen, - _ => panic!("screen mismatch"), - }; - + let dashboard = match app.render() { ScreenView::Dashboard(screen) => screen, _ => panic!("dashboard") }; app.set_screen(ScreenId::Wizard); - let wizard = match app.render() { - ScreenView::Wizard(screen) => screen, - _ => panic!("screen mismatch"), - }; - - app.set_screen(ScreenId::Monitor); - let monitor = match app.render() { - ScreenView::Monitor(screen) => screen, - _ => panic!("screen mismatch"), - }; - + let wizard = match app.render() { ScreenView::Wizard(screen) => screen, _ => panic!("wizard") }; assert_eq!(dashboard.accent, wizard.accent); - assert_eq!(wizard.accent, monitor.accent); - assert_eq!(dashboard.shell_background, wizard.shell_background); - assert_eq!(wizard.shell_background, monitor.shell_background); - - if path.exists() { - let _ = fs::remove_file(path); - } + let _ = fs::remove_file(theme_file); + let _ = fs::remove_dir_all(projects_dir); } #[test] - fn persists_selected_theme_between_restarts() { - let path = temp_theme_file("restart"); - - let mut first_boot = NativeShellApp::boot(Some(path.clone())).expect("boot"); - first_boot - .select_theme(ThemeName::Dawn) - .expect("persist selection"); - - let second_boot = NativeShellApp::boot(Some(path.clone())).expect("boot"); - assert_eq!(second_boot.theme(), ThemeName::Dawn); - - if path.exists() { - let _ = fs::remove_file(path); - } - } - - #[test] - fn boots_into_dashboard_with_project_and_session_summaries() { - let path = temp_theme_file("dashboard"); - let app = NativeShellApp::boot(Some(path.clone())).expect("boot"); - let home = app.home(); - assert_eq!(home.active_projects.len(), 2); - assert_eq!(home.active_projects[0].status, ProjectStatus::Running); - assert_eq!(home.recent_sessions.len(), 2); - assert_eq!(home.primary_actions.len(), 3); - assert_eq!(home.primary_actions[0].id, "start-project"); - match app.render() { - ScreenView::Dashboard(screen) => { - assert_eq!(screen.active_projects.len(), 2); - assert_eq!(screen.recent_sessions[0].project_id, "proj-alpha"); - assert_eq!(screen.primary_actions[1].id, "resume-project"); - } - _ => panic!("screen mismatch"), - } - - if path.exists() { - let _ = fs::remove_file(path); - } + fn project_wizard_persists_artifacts_and_lifecycle_updates_dashboard() { + let theme_file = temp_path("theme", "txt"); + let projects_dir = temp_path("projects", "dir"); + let mut app = NativeShellApp::boot_with_paths(Some(theme_file.clone()), Some(projects_dir.clone())).expect("boot"); + let project_id = app.start_project_wizard("Native Shell", "Complete dashboard parity").expect("create"); + let project_dir = app.projects_root().join(&project_id); + assert!(project_dir.join("draft.json").exists()); + assert!(project_dir.join("prd.json").exists()); + assert!(project_dir.join("config.json").exists()); + app.archive_project(&project_id).expect("archive"); + assert!(app.home().active_projects.is_empty()); + app.resume_project(&project_id).expect("resume"); + assert_eq!(app.home().active_projects.len(), 1); + assert_eq!(app.home().active_projects[0].id, project_id); + let _ = fs::remove_file(theme_file); + let _ = fs::remove_dir_all(projects_dir); } } diff --git a/crates/native-shell/src/screens/home.rs b/crates/native-shell/src/screens/home.rs index 4b4c079..edeb2c5 100644 --- a/crates/native-shell/src/screens/home.rs +++ b/crates/native-shell/src/screens/home.rs @@ -11,6 +11,7 @@ pub struct HomeScreen { pub accent: &'static str, pub heading: String, pub strapline: String, + pub is_empty: bool, pub active_projects: Vec, pub recent_sessions: Vec, pub primary_actions: Vec, @@ -18,6 +19,7 @@ pub struct HomeScreen { impl HomeScreen { pub fn themed(palette: ThemePalette, view_model: HomeViewModel) -> Self { + let is_empty = view_model.active_projects.is_empty(); Self { shell_background: palette.shell_background, surface_background: palette.surface_background, @@ -25,6 +27,7 @@ impl HomeScreen { accent: palette.accent, heading: view_model.heading, strapline: view_model.strapline, + is_empty, active_projects: view_model.active_projects, recent_sessions: view_model.recent_sessions, primary_actions: view_model.primary_actions, diff --git a/crates/native-shell/src/screens/mod.rs b/crates/native-shell/src/screens/mod.rs index a1e740a..c0e85ad 100644 --- a/crates/native-shell/src/screens/mod.rs +++ b/crates/native-shell/src/screens/mod.rs @@ -1,3 +1,5 @@ pub mod home; +pub mod project_wizard; pub use home::HomeScreen; +pub use project_wizard::{ProjectWizardScreen, ProjectWizardState}; diff --git a/crates/native-shell/src/screens/project_wizard.rs b/crates/native-shell/src/screens/project_wizard.rs index 86da8f5..3bf1ddd 100644 --- a/crates/native-shell/src/screens/project_wizard.rs +++ b/crates/native-shell/src/screens/project_wizard.rs @@ -1,4 +1,35 @@ -use super::theme::palette::ThemePalette; +use super::super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectWizardState { + pub project_id: Option, + pub project_name: String, + pub objective: String, + pub draft_saved: bool, + pub finalized: bool, +} + +impl ProjectWizardState { + pub fn idle() -> Self { + Self { + project_id: None, + project_name: String::new(), + objective: String::new(), + draft_saved: false, + finalized: false, + } + } + + pub fn completed(project_id: String, project_name: String, objective: String) -> Self { + Self { + project_id: Some(project_id), + project_name, + objective, + draft_saved: true, + finalized: true, + } + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct ProjectWizardScreen { @@ -6,15 +37,25 @@ pub struct ProjectWizardScreen { pub surface_background: &'static str, pub text_primary: &'static str, pub accent: &'static str, + pub heading: String, + pub project_name: String, + pub objective: String, + pub draft_saved: bool, + pub finalized: bool, } impl ProjectWizardScreen { - pub fn themed(palette: ThemePalette) -> Self { + pub fn themed(palette: ThemePalette, state: ProjectWizardState) -> Self { Self { shell_background: palette.shell_background, surface_background: palette.surface_background, text_primary: palette.text_primary, accent: palette.accent, + heading: String::from("PROJECT WIZARD"), + project_name: state.project_name, + objective: state.objective, + draft_saved: state.draft_saved, + finalized: state.finalized, } } } diff --git a/crates/native-shell/src/services/projects.rs b/crates/native-shell/src/services/projects.rs new file mode 100644 index 0000000..d627aff --- /dev/null +++ b/crates/native-shell/src/services/projects.rs @@ -0,0 +1,186 @@ +use std::env; +use std::fs; +use std::io; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProjectLifecycle { + Active, + Archived, +} + +impl ProjectLifecycle { + fn as_key(self) -> &'static str { + match self { + Self::Active => "active", + Self::Archived => "archived", + } + } + + fn from_key(value: &str) -> Self { + match value.trim() { + "archived" => Self::Archived, + _ => Self::Active, + } + } +} +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProjectRecord { + pub id: String, + pub name: String, + pub lifecycle: ProjectLifecycle, + pub latest_session: String, +} + +#[derive(Debug, Clone)] +pub struct ProjectsService { + root: PathBuf, +} +impl ProjectsService { + pub fn new(root: Option) -> Self { + Self { + root: root.unwrap_or_else(Self::default_root), + } + } + + pub fn root(&self) -> &Path { + &self.root + } + pub fn create_project(&self, name: &str, summary: &str) -> io::Result { + let project_id = format!("proj-{}-{}", Self::timestamp_nanos(), Self::slug(name)); + let record = ProjectRecord { + id: project_id, + name: name.trim().to_owned(), + lifecycle: ProjectLifecycle::Active, + latest_session: format!("sess-{}", Self::timestamp_nanos()), + }; + self.write_draft_artifact(&record, summary)?; + self.persist_record(&record)?; + Ok(record) + } + pub fn list_projects(&self, include_archived: bool) -> io::Result> { + if !self.root.exists() { + return Ok(Vec::new()); + } + let mut projects = Vec::new(); + for entry in fs::read_dir(&self.root)? { + let entry = entry?; + if !entry.file_type()?.is_dir() { + continue; + } + let metadata_path = entry.path().join("project.txt"); + if !metadata_path.exists() { + continue; + } + let record = Self::read_record(&metadata_path)?; + if include_archived || record.lifecycle != ProjectLifecycle::Archived { + projects.push(record); + } + } + projects.sort_by(|left, right| right.id.cmp(&left.id)); + Ok(projects) + } + pub fn resume_project(&self, project_id: &str) -> io::Result { + self.update_lifecycle(project_id, ProjectLifecycle::Active) + } + pub fn archive_project(&self, project_id: &str) -> io::Result { + self.update_lifecycle(project_id, ProjectLifecycle::Archived) + } + fn default_root() -> PathBuf { + let home = env::var("HOME").unwrap_or_else(|_| ".".to_owned()); + PathBuf::from(home).join(".config").join("loopforge").join("projects") + } + fn update_lifecycle(&self, project_id: &str, lifecycle: ProjectLifecycle) -> io::Result { + let mut record = Self::read_record(&self.root.join(project_id).join("project.txt"))?; + record.lifecycle = lifecycle; + self.persist_record(&record)?; + Ok(record) + } + fn persist_record(&self, record: &ProjectRecord) -> io::Result<()> { + let project_dir = self.root.join(&record.id); + fs::create_dir_all(&project_dir)?; + fs::write(project_dir.join("project.txt"), Self::encode_record(record))?; + fs::write( + project_dir.join("plan.md"), + format!("# {}\n\n- Bootstrapped from native shell wizard.\n", record.name), + )?; + fs::write(project_dir.join("prd.json"), "{\"stories\":[]}\n")?; + fs::write(project_dir.join("config.json"), "{\"maxIterations\":20}\n")?; + fs::write( + project_dir.join("prompt.md"), + format!("# {}\n\nContinue implementing the accepted stories.\n", record.name), + )?; + fs::write(project_dir.join("guardrails.md"), "")?; + Ok(()) + } + fn write_draft_artifact(&self, record: &ProjectRecord, summary: &str) -> io::Result<()> { + let project_dir = self.root.join(&record.id); + fs::create_dir_all(&project_dir)?; + fs::write( + project_dir.join("draft.json"), + format!( + "{{\"id\":\"{}\",\"name\":\"{}\",\"step\":\"configure\",\"summary\":\"{}\"}}\n", + record.id, + record.name, + summary.trim() + ), + ) + } + fn read_record(path: &Path) -> io::Result { + let content = fs::read_to_string(path)?; + let mut id = String::new(); + let mut name = String::new(); + let mut lifecycle = ProjectLifecycle::Active; + let mut latest_session = String::new(); + for line in content.lines() { + if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "id" => id = value.trim().to_owned(), + "name" => name = value.trim().to_owned(), + "lifecycle" => lifecycle = ProjectLifecycle::from_key(value), + "latest_session" => latest_session = value.trim().to_owned(), + _ => {} + } + } + } + if id.is_empty() || name.is_empty() || latest_session.is_empty() { + return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid project")); + } + Ok(ProjectRecord { + id, + name, + lifecycle, + latest_session, + }) + } + fn encode_record(record: &ProjectRecord) -> String { + format!( + "id={}\nname={}\nlifecycle={}\nlatest_session={}\n", + record.id, + record.name, + record.lifecycle.as_key(), + record.latest_session + ) + } + fn timestamp_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + } + fn slug(value: &str) -> String { + let normalized = value + .trim() + .to_lowercase() + .chars() + .map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }) + .collect::(); + let compact = normalized.trim_matches('-').replace("--", "-"); + if compact.is_empty() { + String::from("project") + } else { + compact + } + } +} From bcd6916b47b7ebd5ca79f51bedfa5fceeb92a544 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 10:11:02 -0600 Subject: [PATCH 20/61] feat(frontend): add native planning flow --- crates/native-shell/src/app.rs | 154 +++++++++---------- crates/native-shell/src/screens/mod.rs | 2 + crates/native-shell/src/screens/planning.rs | 91 +++++++++++ crates/native-shell/src/services/planning.rs | 131 ++++++++++++++++ 4 files changed, 294 insertions(+), 84 deletions(-) create mode 100644 crates/native-shell/src/screens/planning.rs create mode 100644 crates/native-shell/src/services/planning.rs diff --git a/crates/native-shell/src/app.rs b/crates/native-shell/src/app.rs index ec4872c..96e3e38 100644 --- a/crates/native-shell/src/app.rs +++ b/crates/native-shell/src/app.rs @@ -4,30 +4,29 @@ use std::path::PathBuf; mod screens; #[path = "screens/monitor.rs"] mod monitor_screen; +#[path = "services/planning.rs"] +mod planning_service; #[path = "services/projects.rs"] mod projects_service; #[path = "theme/mod.rs"] pub mod theme; #[path = "view_models/home.rs"] mod home_view_model; -use home_view_model::{ - HomeAction, HomeProjectSummary, HomeSessionSummary, HomeViewModel, ProjectStatus, SessionState, -}; -use projects_service::{ProjectLifecycle, ProjectsService}; -use screens::{HomeScreen, ProjectWizardScreen, ProjectWizardState}; +use home_view_model::{HomeAction, HomeProjectSummary, HomeSessionSummary, HomeViewModel, ProjectStatus, SessionState}; use monitor_screen::MonitorScreen; +use planning_service::PlanningService; +use projects_service::{ProjectLifecycle, ProjectsService}; +use screens::{HomeScreen, PlanningScreen, PlanningState, ProjectWizardScreen, ProjectWizardState}; use theme::{ThemeName, ThemeStore}; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScreenId { - Dashboard, - Wizard, - Monitor, -} +pub enum ScreenId { Dashboard, Wizard, Planning, Monitor } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScreenView { Dashboard(HomeScreen), Wizard(ProjectWizardScreen), + Planning(PlanningScreen), Monitor(MonitorScreen), } @@ -37,33 +36,34 @@ pub struct NativeShellApp { theme: ThemeName, theme_store: ThemeStore, projects: ProjectsService, + planning_service: PlanningService, wizard_state: ProjectWizardState, + planning_state: PlanningState, home: HomeViewModel, } impl NativeShellApp { - pub fn boot(theme_path: Option) -> io::Result { - Self::boot_with_paths(theme_path, None) - } + pub fn boot(theme_path: Option) -> io::Result { Self::boot_with_paths(theme_path, None) } pub fn boot_with_paths(theme_path: Option, projects_root: Option) -> io::Result { let theme_store = ThemeStore::new(theme_path.unwrap_or_else(ThemeStore::default_path)); let projects = ProjectsService::new(projects_root); + let planning_service = PlanningService::new(projects.root().to_path_buf()); let mut app = Self { active_screen: ScreenId::Dashboard, theme: theme_store.load()?, theme_store, projects, + planning_service, wizard_state: ProjectWizardState::idle(), + planning_state: PlanningState::idle(), home: Self::build_home(Vec::new()), }; app.refresh_home()?; Ok(app) } - pub fn set_screen(&mut self, screen: ScreenId) { - self.active_screen = screen; - } + pub fn set_screen(&mut self, screen: ScreenId) { self.active_screen = screen; } pub fn select_theme(&mut self, theme: ThemeName) -> io::Result<()> { self.theme_store.save(theme)?; @@ -73,60 +73,60 @@ impl NativeShellApp { pub fn start_project_wizard(&mut self, project_name: &str, objective: &str) -> io::Result { let record = self.projects.create_project(project_name, objective)?; - self.wizard_state = ProjectWizardState::completed( - record.id.clone(), - record.name.clone(), - objective.trim().to_owned(), - ); + self.wizard_state = ProjectWizardState::completed(record.id.clone(), record.name.clone(), objective.trim().to_owned()); self.refresh_home()?; Ok(record.id) } - pub fn resume_project(&mut self, project_id: &str) -> io::Result<()> { - self.projects.resume_project(project_id)?; - self.refresh_home() + pub fn start_planning_session(&mut self, project_id: &str, objective: &str) -> io::Result<()> { + let project_name = self.home.active_projects.iter().find(|project| project.id == project_id).map(|project| project.name.clone()).unwrap_or_else(|| project_id.to_owned()); + self.planning_state = PlanningState::start(project_id.to_owned(), project_name, objective.trim().to_owned()); + match self.planning_service.start_session(project_id, objective) { + Ok(activity_batch) => { + for activity in activity_batch { self.planning_state = self.planning_state.clone().push_activity(activity); } + self.active_screen = ScreenId::Planning; + Ok(()) + } + Err(error) => { self.planning_state = self.planning_state.clone().fail(error.to_string()); Err(error) } + } } - pub fn archive_project(&mut self, project_id: &str) -> io::Result<()> { - self.projects.archive_project(project_id)?; - self.refresh_home() + pub fn send_planning_input(&mut self, input: &str) -> io::Result<()> { + match self.planning_service.send_input(input) { + Ok(activity_batch) => { for activity in activity_batch { self.planning_state = self.planning_state.clone().push_activity(activity); } Ok(()) } + Err(error) => { self.planning_state = self.planning_state.clone().fail(error.to_string()); Err(error) } + } } - pub fn home(&self) -> &HomeViewModel { - &self.home + pub fn stop_planning_session(&mut self) -> io::Result<()> { + let session = self.planning_service.stop_session()?; + let plan_path = session.map(|planning_session| planning_session.plan_path.display().to_string()); + self.planning_state = self.planning_state.clone().stop(plan_path); + Ok(()) } - pub fn projects_root(&self) -> PathBuf { - self.projects.root().to_path_buf() - } + pub fn resume_project(&mut self, project_id: &str) -> io::Result<()> { self.projects.resume_project(project_id)?; self.refresh_home() } - fn refresh_home(&mut self) -> io::Result<()> { - self.home = Self::build_home(self.projects.list_projects(false)?); - Ok(()) - } + pub fn archive_project(&mut self, project_id: &str) -> io::Result<()> { self.projects.archive_project(project_id)?; self.refresh_home() } + + pub fn home(&self) -> &HomeViewModel { &self.home } + + pub fn projects_root(&self) -> PathBuf { self.projects.root().to_path_buf() } + + fn refresh_home(&mut self) -> io::Result<()> { self.home = Self::build_home(self.projects.list_projects(false)?); Ok(()) } fn build_home(projects: Vec) -> HomeViewModel { - let active_projects = projects - .iter() - .map(|project| HomeProjectSummary { - id: project.id.clone(), - name: project.name.clone(), - status: match project.lifecycle { - ProjectLifecycle::Active => ProjectStatus::Running, - ProjectLifecycle::Archived => ProjectStatus::Idle, - }, - latest_session: SessionState::Healthy, - }) - .collect::>(); - let recent_sessions = projects - .iter() - .take(2) - .map(|project| HomeSessionSummary { - project_id: project.id.clone(), - session_id: project.latest_session.clone(), - status: SessionState::Healthy, - }) - .collect::>(); + let active_projects = projects.iter().map(|project| HomeProjectSummary { + id: project.id.clone(), + name: project.name.clone(), + status: if project.lifecycle == ProjectLifecycle::Active { ProjectStatus::Running } else { ProjectStatus::Idle }, + latest_session: SessionState::Healthy, + }).collect::>(); + let recent_sessions = projects.iter().take(2).map(|project| HomeSessionSummary { + project_id: project.id.clone(), + session_id: project.latest_session.clone(), + status: SessionState::Healthy, + }).collect::>(); HomeViewModel { heading: String::from("INITIALIZE SEQUENCE"), strapline: String::from("Autonomous AI loop orchestrator."), @@ -146,6 +146,7 @@ impl NativeShellApp { match self.active_screen { ScreenId::Dashboard => ScreenView::Dashboard(HomeScreen::themed(palette, self.home.clone())), ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette, self.wizard_state.clone())), + ScreenId::Planning => ScreenView::Planning(PlanningScreen::themed(palette, self.planning_state.clone())), ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette)), } } @@ -156,7 +157,6 @@ mod tests { use std::fs; use std::time::{SystemTime, UNIX_EPOCH}; use super::{NativeShellApp, ScreenId, ScreenView}; - use super::theme::ThemeName; fn temp_path(label: &str, extension: &str) -> std::path::PathBuf { let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_nanos(); @@ -164,35 +164,21 @@ mod tests { } #[test] - fn applies_selected_theme_to_every_surface() { - let theme_file = temp_path("theme", "txt"); - let projects_dir = temp_path("projects", "dir"); - let mut app = NativeShellApp::boot_with_paths(Some(theme_file.clone()), Some(projects_dir.clone())).expect("boot"); - app.select_theme(ThemeName::Dawn).expect("save"); - app.set_screen(ScreenId::Dashboard); - let dashboard = match app.render() { ScreenView::Dashboard(screen) => screen, _ => panic!("dashboard") }; - app.set_screen(ScreenId::Wizard); - let wizard = match app.render() { ScreenView::Wizard(screen) => screen, _ => panic!("wizard") }; - assert_eq!(dashboard.accent, wizard.accent); - let _ = fs::remove_file(theme_file); - let _ = fs::remove_dir_all(projects_dir); - } - - #[test] - fn project_wizard_persists_artifacts_and_lifecycle_updates_dashboard() { + fn planning_session_streams_activity_and_persists_plan_output() { let theme_file = temp_path("theme", "txt"); let projects_dir = temp_path("projects", "dir"); let mut app = NativeShellApp::boot_with_paths(Some(theme_file.clone()), Some(projects_dir.clone())).expect("boot"); - let project_id = app.start_project_wizard("Native Shell", "Complete dashboard parity").expect("create"); - let project_dir = app.projects_root().join(&project_id); - assert!(project_dir.join("draft.json").exists()); - assert!(project_dir.join("prd.json").exists()); - assert!(project_dir.join("config.json").exists()); - app.archive_project(&project_id).expect("archive"); - assert!(app.home().active_projects.is_empty()); - app.resume_project(&project_id).expect("resume"); - assert_eq!(app.home().active_projects.len(), 1); - assert_eq!(app.home().active_projects[0].id, project_id); + let project_id = app.start_project_wizard("Native Shell", "Plan inside shell").expect("create"); + app.start_planning_session(&project_id, "Build native planning controls").expect("start"); + app.send_planning_input("Add a stop flow and persist plan output").expect("input"); + app.stop_planning_session().expect("stop"); + app.set_screen(ScreenId::Planning); + let planning_screen = match app.render() { ScreenView::Planning(screen) => screen, _ => panic!("planning") }; + assert!(!planning_screen.activity_lines.is_empty()); + assert!(!planning_screen.session_active); + let plan_content = fs::read_to_string(app.projects_root().join(project_id).join("plan.md")).expect("plan"); + assert!(plan_content.contains("Follow-up request: Add a stop flow and persist plan output")); + assert!(plan_content.contains("Planning session stopped from native shell.")); let _ = fs::remove_file(theme_file); let _ = fs::remove_dir_all(projects_dir); } diff --git a/crates/native-shell/src/screens/mod.rs b/crates/native-shell/src/screens/mod.rs index c0e85ad..8ee9391 100644 --- a/crates/native-shell/src/screens/mod.rs +++ b/crates/native-shell/src/screens/mod.rs @@ -1,5 +1,7 @@ pub mod home; +pub mod planning; pub mod project_wizard; pub use home::HomeScreen; +pub use planning::{PlanningScreen, PlanningState}; pub use project_wizard::{ProjectWizardScreen, ProjectWizardState}; diff --git a/crates/native-shell/src/screens/planning.rs b/crates/native-shell/src/screens/planning.rs new file mode 100644 index 0000000..755fa9f --- /dev/null +++ b/crates/native-shell/src/screens/planning.rs @@ -0,0 +1,91 @@ +use super::super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanningState { + pub project_id: Option, + pub project_name: String, + pub objective: String, + pub session_active: bool, + pub activity_lines: Vec, + pub plan_path: Option, + pub last_error: Option, +} + +impl PlanningState { + pub fn idle() -> Self { + Self { + project_id: None, + project_name: String::new(), + objective: String::new(), + session_active: false, + activity_lines: Vec::new(), + plan_path: None, + last_error: None, + } + } + + pub fn start(project_id: String, project_name: String, objective: String) -> Self { + Self { + project_id: Some(project_id), + project_name, + objective, + session_active: true, + activity_lines: Vec::new(), + plan_path: None, + last_error: None, + } + } + + pub fn push_activity(mut self, activity: String) -> Self { + self.activity_lines.push(activity); + self.last_error = None; + self + } + + pub fn stop(mut self, plan_path: Option) -> Self { + self.session_active = false; + if let Some(path) = plan_path { + self.plan_path = Some(path); + } + self + } + + pub fn fail(mut self, error_message: String) -> Self { + self.last_error = Some(error_message); + self.session_active = false; + self + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanningScreen { + pub shell_background: &'static str, + pub surface_background: &'static str, + pub text_primary: &'static str, + pub accent: &'static str, + pub heading: String, + pub project_name: String, + pub objective: String, + pub session_active: bool, + pub activity_lines: Vec, + pub plan_path: Option, + pub last_error: Option, +} + +impl PlanningScreen { + pub fn themed(palette: ThemePalette, state: PlanningState) -> Self { + Self { + shell_background: palette.shell_background, + surface_background: palette.surface_background, + text_primary: palette.text_primary, + accent: palette.accent, + heading: String::from("PLANNING SESSION"), + project_name: state.project_name, + objective: state.objective, + session_active: state.session_active, + activity_lines: state.activity_lines, + plan_path: state.plan_path, + last_error: state.last_error, + } + } +} diff --git a/crates/native-shell/src/services/planning.rs b/crates/native-shell/src/services/planning.rs new file mode 100644 index 0000000..fcabe47 --- /dev/null +++ b/crates/native-shell/src/services/planning.rs @@ -0,0 +1,131 @@ +use std::fs; +use std::io; +use std::path::PathBuf; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlanningSession { + pub project_id: String, + pub active: bool, + pub activity_lines: Vec, + pub plan_path: PathBuf, +} + +#[derive(Debug, Clone)] +pub struct PlanningService { + projects_root: PathBuf, + session: Option, +} + +impl PlanningService { + pub fn new(projects_root: PathBuf) -> Self { + Self { + projects_root, + session: None, + } + } + + pub fn start_session(&mut self, project_id: &str, objective: &str) -> io::Result> { + let project_dir = self.projects_root.join(project_id); + fs::create_dir_all(&project_dir)?; + let plan_path = project_dir.join("plan.md"); + let activity_lines = Self::build_start_activity(project_id, objective); + fs::write(&plan_path, Self::render_plan(&activity_lines))?; + self.session = Some(PlanningSession { + project_id: project_id.to_owned(), + active: true, + activity_lines: activity_lines.clone(), + plan_path, + }); + Ok(activity_lines) + } + + pub fn send_input(&mut self, input: &str) -> io::Result> { + let response_lines = Self::build_follow_up_activity(input); + let Some(session) = self.session.as_mut() else { + return Err(io::Error::new( + io::ErrorKind::NotFound, + "planning session is not active", + )); + }; + session.activity_lines.extend(response_lines.clone()); + fs::write(&session.plan_path, Self::render_plan(&session.activity_lines))?; + Ok(response_lines) + } + + pub fn stop_session(&mut self) -> io::Result> { + let Some(mut session) = self.session.take() else { + return Ok(None); + }; + session.active = false; + session + .activity_lines + .push(String::from("Planning session stopped from native shell.")); + fs::write(&session.plan_path, Self::render_plan(&session.activity_lines))?; + Ok(Some(session)) + } + + fn build_start_activity(project_id: &str, objective: &str) -> Vec { + let objective_line = if objective.trim().is_empty() { + String::from("Objective: Clarify project goals before execution.") + } else { + format!("Objective: {}", objective.trim()) + }; + vec![ + format!("Planning session started for {}.", project_id), + objective_line, + String::from("Drafting initial implementation sequence."), + ] + } + + fn build_follow_up_activity(input: &str) -> Vec { + let follow_up = if input.trim().is_empty() { + String::from("Follow-up request received with empty body.") + } else { + format!("Follow-up request: {}", input.trim()) + }; + vec![follow_up, String::from("Plan outline updated with follow-up input.")] + } + + fn render_plan(activity_lines: &[String]) -> String { + let mut plan = String::from("# Native Shell Plan\n\n"); + for activity_line in activity_lines { + plan.push_str("- "); + plan.push_str(activity_line); + plan.push('\n'); + } + plan + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::PlanningService; + + fn temp_projects_root() -> std::path::PathBuf { + let stamp = SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("time") + .as_nanos(); + std::env::temp_dir().join(format!("loopforge-shell-planning-{}-{}", std::process::id(), stamp)) + } + + #[test] + fn start_send_and_stop_persist_plan_output() { + let projects_root = temp_projects_root(); + let mut service = PlanningService::new(projects_root.clone()); + let started = service.start_session("project-native", "Ship planning controls").expect("start"); + assert!(!started.is_empty()); + let follow_up = service.send_input("Include stop flow in acceptance").expect("send"); + assert!(!follow_up.is_empty()); + let stopped = service.stop_session().expect("stop").expect("session"); + assert!(!stopped.active); + assert!(service.stop_session().expect("stop twice").is_none()); + let plan_content = fs::read_to_string(projects_root.join("project-native").join("plan.md")).expect("plan"); + assert!(plan_content.contains("Follow-up request: Include stop flow in acceptance")); + assert!(plan_content.contains("Planning session stopped from native shell.")); + let _ = fs::remove_dir_all(projects_root); + } +} From 28cd779054dedfccf85ca4c609322fc8c36db1d6 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 10:16:39 -0600 Subject: [PATCH 21/61] feat(frontend): add native atomization flow --- crates/native-shell/src/app.rs | 69 +++---- .../native-shell/src/screens/atomization.rs | 123 +++++++++++++ crates/native-shell/src/screens/mod.rs | 2 + .../native-shell/src/services/atomization.rs | 172 ++++++++++++++++++ 4 files changed, 333 insertions(+), 33 deletions(-) create mode 100644 crates/native-shell/src/screens/atomization.rs create mode 100644 crates/native-shell/src/services/atomization.rs diff --git a/crates/native-shell/src/app.rs b/crates/native-shell/src/app.rs index 96e3e38..47674dd 100644 --- a/crates/native-shell/src/app.rs +++ b/crates/native-shell/src/app.rs @@ -1,3 +1,4 @@ +use std::fs; use std::io; use std::path::PathBuf; #[path = "screens/mod.rs"] @@ -6,27 +7,31 @@ mod screens; mod monitor_screen; #[path = "services/planning.rs"] mod planning_service; +#[path = "services/atomization.rs"] +mod atomization_service; #[path = "services/projects.rs"] mod projects_service; #[path = "theme/mod.rs"] pub mod theme; #[path = "view_models/home.rs"] mod home_view_model; +use atomization_service::AtomizationService; use home_view_model::{HomeAction, HomeProjectSummary, HomeSessionSummary, HomeViewModel, ProjectStatus, SessionState}; use monitor_screen::MonitorScreen; use planning_service::PlanningService; use projects_service::{ProjectLifecycle, ProjectsService}; -use screens::{HomeScreen, PlanningScreen, PlanningState, ProjectWizardScreen, ProjectWizardState}; +use screens::{AtomizationArtifact, AtomizationScreen, AtomizationStageUpdate, AtomizationState, HomeScreen, PlanningScreen, PlanningState, ProjectWizardScreen, ProjectWizardState}; use theme::{ThemeName, ThemeStore}; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScreenId { Dashboard, Wizard, Planning, Monitor } +pub enum ScreenId { Dashboard, Wizard, Planning, Atomization, Monitor } #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScreenView { Dashboard(HomeScreen), Wizard(ProjectWizardScreen), Planning(PlanningScreen), + Atomization(AtomizationScreen), Monitor(MonitorScreen), } @@ -37,8 +42,10 @@ pub struct NativeShellApp { theme_store: ThemeStore, projects: ProjectsService, planning_service: PlanningService, + atomization_service: AtomizationService, wizard_state: ProjectWizardState, planning_state: PlanningState, + atomization_state: AtomizationState, home: HomeViewModel, } @@ -49,14 +56,17 @@ impl NativeShellApp { let theme_store = ThemeStore::new(theme_path.unwrap_or_else(ThemeStore::default_path)); let projects = ProjectsService::new(projects_root); let planning_service = PlanningService::new(projects.root().to_path_buf()); + let atomization_service = AtomizationService::new(projects.root().to_path_buf()); let mut app = Self { active_screen: ScreenId::Dashboard, theme: theme_store.load()?, theme_store, projects, planning_service, + atomization_service, wizard_state: ProjectWizardState::idle(), planning_state: PlanningState::idle(), + atomization_state: AtomizationState::idle(), home: Self::build_home(Vec::new()), }; app.refresh_home()?; @@ -105,6 +115,29 @@ impl NativeShellApp { Ok(()) } + pub fn start_atomization_session(&mut self, project_id: &str) -> io::Result<()> { + let project_name = self.home.active_projects.iter().find(|project| project.id == project_id).map(|project| project.name.clone()).unwrap_or_else(|| project_id.to_owned()); + self.atomization_state = AtomizationState::start(project_id.to_owned(), project_name); + match self.atomization_service.run_pipeline(project_id) { + Ok(run) => { + for progress in run.progress { + self.atomization_state = self.atomization_state.clone().push_stage(AtomizationStageUpdate::new(progress.stage.label(), progress.detail)); + } + self.atomization_state = self.atomization_state.clone().complete(run.artifacts.prd_path.display().to_string(), run.artifacts.prompt_path.display().to_string(), run.artifacts.guardrails_path.display().to_string()); + self.active_screen = ScreenId::Atomization; + Ok(()) + } + Err(error) => { self.atomization_state = self.atomization_state.clone().fail(error.to_string()); Err(error) } + } + } + + pub fn open_atomization_artifact(&self, artifact: AtomizationArtifact) -> io::Result { + let Some(path) = self.atomization_state.artifact_path(artifact) else { + return Err(io::Error::new(io::ErrorKind::NotFound, "atomization artifact is unavailable")); + }; + fs::read_to_string(path) + } + pub fn resume_project(&mut self, project_id: &str) -> io::Result<()> { self.projects.resume_project(project_id)?; self.refresh_home() } pub fn archive_project(&mut self, project_id: &str) -> io::Result<()> { self.projects.archive_project(project_id)?; self.refresh_home() } @@ -147,39 +180,9 @@ impl NativeShellApp { ScreenId::Dashboard => ScreenView::Dashboard(HomeScreen::themed(palette, self.home.clone())), ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette, self.wizard_state.clone())), ScreenId::Planning => ScreenView::Planning(PlanningScreen::themed(palette, self.planning_state.clone())), + ScreenId::Atomization => ScreenView::Atomization(AtomizationScreen::themed(palette, self.atomization_state.clone())), ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette)), } } } -#[cfg(test)] -mod tests { - use std::fs; - use std::time::{SystemTime, UNIX_EPOCH}; - use super::{NativeShellApp, ScreenId, ScreenView}; - - fn temp_path(label: &str, extension: &str) -> std::path::PathBuf { - let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_nanos(); - std::env::temp_dir().join(format!("loopforge-shell-{}-{}-{}.{}", label, std::process::id(), stamp, extension)) - } - - #[test] - fn planning_session_streams_activity_and_persists_plan_output() { - let theme_file = temp_path("theme", "txt"); - let projects_dir = temp_path("projects", "dir"); - let mut app = NativeShellApp::boot_with_paths(Some(theme_file.clone()), Some(projects_dir.clone())).expect("boot"); - let project_id = app.start_project_wizard("Native Shell", "Plan inside shell").expect("create"); - app.start_planning_session(&project_id, "Build native planning controls").expect("start"); - app.send_planning_input("Add a stop flow and persist plan output").expect("input"); - app.stop_planning_session().expect("stop"); - app.set_screen(ScreenId::Planning); - let planning_screen = match app.render() { ScreenView::Planning(screen) => screen, _ => panic!("planning") }; - assert!(!planning_screen.activity_lines.is_empty()); - assert!(!planning_screen.session_active); - let plan_content = fs::read_to_string(app.projects_root().join(project_id).join("plan.md")).expect("plan"); - assert!(plan_content.contains("Follow-up request: Add a stop flow and persist plan output")); - assert!(plan_content.contains("Planning session stopped from native shell.")); - let _ = fs::remove_file(theme_file); - let _ = fs::remove_dir_all(projects_dir); - } -} diff --git a/crates/native-shell/src/screens/atomization.rs b/crates/native-shell/src/screens/atomization.rs new file mode 100644 index 0000000..0edc633 --- /dev/null +++ b/crates/native-shell/src/screens/atomization.rs @@ -0,0 +1,123 @@ +use super::super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AtomizationArtifact { + Prd, + Prompt, + Guardrails, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizationStageUpdate { + pub stage: String, + pub detail: String, +} + +impl AtomizationStageUpdate { + pub fn new(stage: String, detail: String) -> Self { + Self { stage, detail } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizationState { + pub project_id: Option, + pub project_name: String, + pub running: bool, + pub stage_updates: Vec, + pub prd_path: Option, + pub prompt_path: Option, + pub guardrails_path: Option, + pub last_error: Option, +} + +impl AtomizationState { + pub fn idle() -> Self { + Self { + project_id: None, + project_name: String::new(), + running: false, + stage_updates: Vec::new(), + prd_path: None, + prompt_path: None, + guardrails_path: None, + last_error: None, + } + } + + pub fn start(project_id: String, project_name: String) -> Self { + Self { + project_id: Some(project_id), + project_name, + running: true, + stage_updates: Vec::new(), + prd_path: None, + prompt_path: None, + guardrails_path: None, + last_error: None, + } + } + + pub fn push_stage(mut self, update: AtomizationStageUpdate) -> Self { + self.stage_updates.push(update); + self.last_error = None; + self + } + + pub fn complete(mut self, prd_path: String, prompt_path: String, guardrails_path: String) -> Self { + self.running = false; + self.prd_path = Some(prd_path); + self.prompt_path = Some(prompt_path); + self.guardrails_path = Some(guardrails_path); + self + } + + pub fn fail(mut self, error_message: String) -> Self { + self.running = false; + self.last_error = Some(error_message); + self + } + + pub fn artifact_path(&self, artifact: AtomizationArtifact) -> Option<&str> { + match artifact { + AtomizationArtifact::Prd => self.prd_path.as_deref(), + AtomizationArtifact::Prompt => self.prompt_path.as_deref(), + AtomizationArtifact::Guardrails => self.guardrails_path.as_deref(), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizationScreen { + pub shell_background: &'static str, + pub surface_background: &'static str, + pub text_primary: &'static str, + pub accent: &'static str, + pub heading: String, + pub project_name: String, + pub running: bool, + pub stage_updates: Vec, + pub prd_path: Option, + pub prompt_path: Option, + pub guardrails_path: Option, + pub last_error: Option, +} + +impl AtomizationScreen { + pub fn themed(palette: ThemePalette, state: AtomizationState) -> Self { + Self { + shell_background: palette.shell_background, + surface_background: palette.surface_background, + text_primary: palette.text_primary, + accent: palette.accent, + heading: String::from("ATOMIZATION"), + project_name: state.project_name, + running: state.running, + stage_updates: state.stage_updates, + prd_path: state.prd_path, + prompt_path: state.prompt_path, + guardrails_path: state.guardrails_path, + last_error: state.last_error, + } + } +} diff --git a/crates/native-shell/src/screens/mod.rs b/crates/native-shell/src/screens/mod.rs index 8ee9391..f0f7d6f 100644 --- a/crates/native-shell/src/screens/mod.rs +++ b/crates/native-shell/src/screens/mod.rs @@ -1,7 +1,9 @@ pub mod home; +pub mod atomization; pub mod planning; pub mod project_wizard; +pub use atomization::{AtomizationArtifact, AtomizationScreen, AtomizationStageUpdate, AtomizationState}; pub use home::HomeScreen; pub use planning::{PlanningScreen, PlanningState}; pub use project_wizard::{ProjectWizardScreen, ProjectWizardState}; diff --git a/crates/native-shell/src/services/atomization.rs b/crates/native-shell/src/services/atomization.rs new file mode 100644 index 0000000..a883201 --- /dev/null +++ b/crates/native-shell/src/services/atomization.rs @@ -0,0 +1,172 @@ +use std::fs; +use std::io; +use std::path::PathBuf; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AtomizationStage { + StageOne, + StageTwo, + StageThree, + StageFour, +} + +impl AtomizationStage { + pub fn label(self) -> String { + match self { + Self::StageOne => String::from("Stage 1"), + Self::StageTwo => String::from("Stage 2"), + Self::StageThree => String::from("Stage 3"), + Self::StageFour => String::from("Stage 4"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizationProgress { + pub stage: AtomizationStage, + pub detail: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizationArtifacts { + pub prd_path: PathBuf, + pub prompt_path: PathBuf, + pub guardrails_path: PathBuf, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AtomizationRun { + pub progress: Vec, + pub artifacts: AtomizationArtifacts, +} + +#[derive(Debug, Clone)] +pub struct AtomizationService { + projects_root: PathBuf, +} + +impl AtomizationService { + pub fn new(projects_root: PathBuf) -> Self { + Self { projects_root } + } + + pub fn run_pipeline(&self, project_id: &str) -> io::Result { + let project_dir = self.projects_root.join(project_id); + fs::create_dir_all(&project_dir)?; + let plan_content = Self::read_plan(project_dir.join("plan.md"))?; + let progress = vec![ + AtomizationProgress { + stage: AtomizationStage::StageOne, + detail: String::from("Loaded plan input from plan.md."), + }, + AtomizationProgress { + stage: AtomizationStage::StageTwo, + detail: String::from("Generated structured stories for prd.json."), + }, + AtomizationProgress { + stage: AtomizationStage::StageThree, + detail: String::from("Built execution prompt artifact."), + }, + AtomizationProgress { + stage: AtomizationStage::StageFour, + detail: String::from("Materialized guardrails artifact."), + }, + ]; + let prd_path = project_dir.join("prd.json"); + let prompt_path = project_dir.join("prompt.md"); + let guardrails_path = project_dir.join("guardrails.md"); + fs::write(&prd_path, Self::render_prd(project_id, &plan_content))?; + fs::write(&prompt_path, Self::render_prompt(&plan_content))?; + fs::write(&guardrails_path, Self::render_guardrails(&plan_content))?; + Ok(AtomizationRun { + progress, + artifacts: AtomizationArtifacts { + prd_path, + prompt_path, + guardrails_path, + }, + }) + } + + fn read_plan(plan_path: PathBuf) -> io::Result { + if plan_path.exists() { + fs::read_to_string(plan_path) + } else { + Ok(String::from("No plan content was available.")) + } + } + + fn render_prd(project_id: &str, plan_content: &str) -> String { + let objective = Self::extract_objective(plan_content); + let objective = Self::json_escape(&objective); + format!( + "{{\n \"projectId\": \"{}\",\n \"stories\": [\n {{\n \"id\": \"S-001\",\n \"title\": \"Implement plan objective\",\n \"description\": \"{}\"\n }}\n ]\n}}\n", + Self::json_escape(project_id), + objective + ) + } + + fn render_prompt(plan_content: &str) -> String { + let objective = Self::extract_objective(plan_content); + format!( + "# Execution Prompt\n\nImplement the plan objective:\n{}\n", + objective + ) + } + + fn render_guardrails(plan_content: &str) -> String { + let objective = Self::extract_objective(plan_content); + format!( + "# Guardrails\n\n- Keep implementation aligned with objective.\n- Validate each stage output.\n- Objective: {}\n", + objective + ) + } + + fn extract_objective(plan_content: &str) -> String { + for line in plan_content.lines() { + if let Some(value) = line.strip_prefix("- Objective: ") { + return value.trim().to_owned(); + } + } + String::from("Ship the planned implementation safely.") + } + + fn json_escape(value: &str) -> String { + value + .replace('\\', "\\\\") + .replace('"', "\\\"") + .replace('\n', "\\n") + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + use super::{AtomizationService, AtomizationStage}; + + fn temp_projects_root() -> std::path::PathBuf { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_nanos(); + std::env::temp_dir().join(format!("loopforge-shell-atomization-{}-{}", std::process::id(), stamp)) + } + + #[test] + fn run_pipeline_persists_artifacts_and_returns_stage_progress() { + let projects_root = temp_projects_root(); + let project_id = "project-native"; + let project_dir = projects_root.join(project_id); + fs::create_dir_all(&project_dir).expect("mkdir"); + fs::write(project_dir.join("plan.md"), "- Objective: Keep users in native shell.\n").expect("plan"); + let service = AtomizationService::new(projects_root.clone()); + let run = service.run_pipeline(project_id).expect("run"); + assert_eq!(run.progress.len(), 4); + assert_eq!(run.progress[0].stage, AtomizationStage::StageOne); + assert_eq!(run.progress[3].stage, AtomizationStage::StageFour); + assert!(run.artifacts.prd_path.exists()); + assert!(run.artifacts.prompt_path.exists()); + assert!(run.artifacts.guardrails_path.exists()); + let prd_content = fs::read_to_string(run.artifacts.prd_path).expect("prd"); + assert!(prd_content.contains("Keep users in native shell.")); + let _ = fs::remove_dir_all(projects_root); + } +} From 931257be06ab3875bbcf9588086c7fe800e001b8 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 10:21:46 -0600 Subject: [PATCH 22/61] feat(frontend): add native loop monitor --- crates/native-shell/src/app.rs | 56 +++++----- crates/native-shell/src/screens/mod.rs | 2 + crates/native-shell/src/screens/monitor.rs | 100 ++++++++++++++++- crates/native-shell/src/services/loops.rs | 118 +++++++++++++++++++++ 4 files changed, 248 insertions(+), 28 deletions(-) create mode 100644 crates/native-shell/src/services/loops.rs diff --git a/crates/native-shell/src/app.rs b/crates/native-shell/src/app.rs index 47674dd..7b97a48 100644 --- a/crates/native-shell/src/app.rs +++ b/crates/native-shell/src/app.rs @@ -3,29 +3,27 @@ use std::io; use std::path::PathBuf; #[path = "screens/mod.rs"] mod screens; -#[path = "screens/monitor.rs"] -mod monitor_screen; #[path = "services/planning.rs"] mod planning_service; #[path = "services/atomization.rs"] mod atomization_service; #[path = "services/projects.rs"] mod projects_service; +#[path = "services/loops.rs"] +mod loops_service; #[path = "theme/mod.rs"] pub mod theme; #[path = "view_models/home.rs"] mod home_view_model; use atomization_service::AtomizationService; use home_view_model::{HomeAction, HomeProjectSummary, HomeSessionSummary, HomeViewModel, ProjectStatus, SessionState}; -use monitor_screen::MonitorScreen; +use loops_service::LoopService; use planning_service::PlanningService; use projects_service::{ProjectLifecycle, ProjectsService}; -use screens::{AtomizationArtifact, AtomizationScreen, AtomizationStageUpdate, AtomizationState, HomeScreen, PlanningScreen, PlanningState, ProjectWizardScreen, ProjectWizardState}; +use screens::{AtomizationArtifact, AtomizationScreen, AtomizationStageUpdate, AtomizationState, HomeScreen, MonitorScreen, MonitorState, PlanningScreen, PlanningState, ProjectWizardScreen, ProjectWizardState}; use theme::{ThemeName, ThemeStore}; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ScreenId { Dashboard, Wizard, Planning, Atomization, Monitor } - #[derive(Debug, Clone, PartialEq, Eq)] pub enum ScreenView { Dashboard(HomeScreen), @@ -34,7 +32,6 @@ pub enum ScreenView { Atomization(AtomizationScreen), Monitor(MonitorScreen), } - #[derive(Debug, Clone)] pub struct NativeShellApp { active_screen: ScreenId, @@ -43,20 +40,21 @@ pub struct NativeShellApp { projects: ProjectsService, planning_service: PlanningService, atomization_service: AtomizationService, + loop_service: LoopService, wizard_state: ProjectWizardState, planning_state: PlanningState, atomization_state: AtomizationState, + monitor_state: MonitorState, home: HomeViewModel, } - impl NativeShellApp { pub fn boot(theme_path: Option) -> io::Result { Self::boot_with_paths(theme_path, None) } - pub fn boot_with_paths(theme_path: Option, projects_root: Option) -> io::Result { let theme_store = ThemeStore::new(theme_path.unwrap_or_else(ThemeStore::default_path)); let projects = ProjectsService::new(projects_root); let planning_service = PlanningService::new(projects.root().to_path_buf()); let atomization_service = AtomizationService::new(projects.root().to_path_buf()); + let loop_service = LoopService::new(projects.root().to_path_buf()); let mut app = Self { active_screen: ScreenId::Dashboard, theme: theme_store.load()?, @@ -64,30 +62,28 @@ impl NativeShellApp { projects, planning_service, atomization_service, + loop_service, wizard_state: ProjectWizardState::idle(), planning_state: PlanningState::idle(), atomization_state: AtomizationState::idle(), + monitor_state: MonitorState::idle(), home: Self::build_home(Vec::new()), }; app.refresh_home()?; Ok(app) } - pub fn set_screen(&mut self, screen: ScreenId) { self.active_screen = screen; } - pub fn select_theme(&mut self, theme: ThemeName) -> io::Result<()> { self.theme_store.save(theme)?; self.theme = theme; Ok(()) } - pub fn start_project_wizard(&mut self, project_name: &str, objective: &str) -> io::Result { let record = self.projects.create_project(project_name, objective)?; self.wizard_state = ProjectWizardState::completed(record.id.clone(), record.name.clone(), objective.trim().to_owned()); self.refresh_home()?; Ok(record.id) } - pub fn start_planning_session(&mut self, project_id: &str, objective: &str) -> io::Result<()> { let project_name = self.home.active_projects.iter().find(|project| project.id == project_id).map(|project| project.name.clone()).unwrap_or_else(|| project_id.to_owned()); self.planning_state = PlanningState::start(project_id.to_owned(), project_name, objective.trim().to_owned()); @@ -100,21 +96,18 @@ impl NativeShellApp { Err(error) => { self.planning_state = self.planning_state.clone().fail(error.to_string()); Err(error) } } } - pub fn send_planning_input(&mut self, input: &str) -> io::Result<()> { match self.planning_service.send_input(input) { Ok(activity_batch) => { for activity in activity_batch { self.planning_state = self.planning_state.clone().push_activity(activity); } Ok(()) } Err(error) => { self.planning_state = self.planning_state.clone().fail(error.to_string()); Err(error) } } } - pub fn stop_planning_session(&mut self) -> io::Result<()> { let session = self.planning_service.stop_session()?; let plan_path = session.map(|planning_session| planning_session.plan_path.display().to_string()); self.planning_state = self.planning_state.clone().stop(plan_path); Ok(()) } - pub fn start_atomization_session(&mut self, project_id: &str) -> io::Result<()> { let project_name = self.home.active_projects.iter().find(|project| project.id == project_id).map(|project| project.name.clone()).unwrap_or_else(|| project_id.to_owned()); self.atomization_state = AtomizationState::start(project_id.to_owned(), project_name); @@ -130,24 +123,37 @@ impl NativeShellApp { Err(error) => { self.atomization_state = self.atomization_state.clone().fail(error.to_string()); Err(error) } } } - pub fn open_atomization_artifact(&self, artifact: AtomizationArtifact) -> io::Result { let Some(path) = self.atomization_state.artifact_path(artifact) else { return Err(io::Error::new(io::ErrorKind::NotFound, "atomization artifact is unavailable")); }; fs::read_to_string(path) } - + pub fn start_loop_session(&mut self, project_id: &str) -> io::Result<()> { + let project_name = self.home.active_projects.iter().find(|project| project.id == project_id).map(|project| project.name.clone()).unwrap_or_else(|| project_id.to_owned()); + self.monitor_state = MonitorState::start(project_id.to_owned(), project_name); + match self.loop_service.start_session(project_id) { + Ok(update) => { + self.monitor_state = self.monitor_state.clone().apply_update(update.session_id, update.running, update.events, update.completed_iterations, update.blocked_states, update.rate_limit_events); + self.active_screen = ScreenId::Monitor; + Ok(()) + } + Err(error) => { self.monitor_state = self.monitor_state.clone().fail(error.to_string()); Err(error) } + } + } + pub fn stop_loop_session(&mut self) -> io::Result<()> { + let update = self.loop_service.stop_session()?; + self.monitor_state = match update { + Some(update) => self.monitor_state.clone().apply_update(update.session_id, update.running, update.events, update.completed_iterations, update.blocked_states, update.rate_limit_events), + None => self.monitor_state.clone().stop(), + }; + Ok(()) + } pub fn resume_project(&mut self, project_id: &str) -> io::Result<()> { self.projects.resume_project(project_id)?; self.refresh_home() } - pub fn archive_project(&mut self, project_id: &str) -> io::Result<()> { self.projects.archive_project(project_id)?; self.refresh_home() } - pub fn home(&self) -> &HomeViewModel { &self.home } - pub fn projects_root(&self) -> PathBuf { self.projects.root().to_path_buf() } - fn refresh_home(&mut self) -> io::Result<()> { self.home = Self::build_home(self.projects.list_projects(false)?); Ok(()) } - fn build_home(projects: Vec) -> HomeViewModel { let active_projects = projects.iter().map(|project| HomeProjectSummary { id: project.id.clone(), @@ -173,7 +179,6 @@ impl NativeShellApp { ], } } - pub fn render(&self) -> ScreenView { let palette = self.theme.palette(); match self.active_screen { @@ -181,8 +186,7 @@ impl NativeShellApp { ScreenId::Wizard => ScreenView::Wizard(ProjectWizardScreen::themed(palette, self.wizard_state.clone())), ScreenId::Planning => ScreenView::Planning(PlanningScreen::themed(palette, self.planning_state.clone())), ScreenId::Atomization => ScreenView::Atomization(AtomizationScreen::themed(palette, self.atomization_state.clone())), - ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette)), + ScreenId::Monitor => ScreenView::Monitor(MonitorScreen::themed(palette, self.monitor_state.clone())), } } } - diff --git a/crates/native-shell/src/screens/mod.rs b/crates/native-shell/src/screens/mod.rs index f0f7d6f..1a90e57 100644 --- a/crates/native-shell/src/screens/mod.rs +++ b/crates/native-shell/src/screens/mod.rs @@ -1,9 +1,11 @@ pub mod home; pub mod atomization; pub mod planning; +pub mod monitor; pub mod project_wizard; pub use atomization::{AtomizationArtifact, AtomizationScreen, AtomizationStageUpdate, AtomizationState}; pub use home::HomeScreen; +pub use monitor::{MonitorScreen, MonitorState}; pub use planning::{PlanningScreen, PlanningState}; pub use project_wizard::{ProjectWizardScreen, ProjectWizardState}; diff --git a/crates/native-shell/src/screens/monitor.rs b/crates/native-shell/src/screens/monitor.rs index 1cddc80..64c675d 100644 --- a/crates/native-shell/src/screens/monitor.rs +++ b/crates/native-shell/src/screens/monitor.rs @@ -1,4 +1,86 @@ -use super::theme::palette::ThemePalette; +use super::super::theme::palette::ThemePalette; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MonitorStats { + pub completed_iterations: u32, + pub blocked_states: u32, + pub rate_limit_events: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct MonitorState { + pub project_id: Option, + pub project_name: String, + pub session_id: Option, + pub running: bool, + pub events: Vec, + pub stats: MonitorStats, + pub last_error: Option, +} + +impl MonitorState { + pub fn idle() -> Self { + Self { + project_id: None, + project_name: String::new(), + session_id: None, + running: false, + events: Vec::new(), + stats: MonitorStats { + completed_iterations: 0, + blocked_states: 0, + rate_limit_events: 0, + }, + last_error: None, + } + } + + pub fn start(project_id: String, project_name: String) -> Self { + Self { + project_id: Some(project_id), + project_name, + session_id: None, + running: true, + events: Vec::new(), + stats: MonitorStats { + completed_iterations: 0, + blocked_states: 0, + rate_limit_events: 0, + }, + last_error: None, + } + } + + pub fn apply_update( + mut self, + session_id: String, + running: bool, + events: Vec, + completed_iterations: u32, + blocked_states: u32, + rate_limit_events: u32, + ) -> Self { + self.session_id = Some(session_id); + self.running = running; + self.events.extend(events); + self.stats.completed_iterations = completed_iterations; + self.stats.blocked_states = blocked_states; + self.stats.rate_limit_events = rate_limit_events; + self.last_error = None; + self + } + + pub fn stop(mut self) -> Self { + self.running = false; + self + } + + pub fn fail(mut self, error_message: String) -> Self { + self.running = false; + self.last_error = Some(error_message); + self + } +} #[derive(Debug, Clone, PartialEq, Eq)] pub struct MonitorScreen { @@ -6,15 +88,29 @@ pub struct MonitorScreen { pub surface_background: &'static str, pub text_primary: &'static str, pub accent: &'static str, + pub heading: String, + pub project_name: String, + pub session_id: Option, + pub running: bool, + pub events: Vec, + pub stats: MonitorStats, + pub last_error: Option, } impl MonitorScreen { - pub fn themed(palette: ThemePalette) -> Self { + pub fn themed(palette: ThemePalette, state: MonitorState) -> Self { Self { shell_background: palette.shell_background, surface_background: palette.surface_background, text_primary: palette.text_primary, accent: palette.accent, + heading: String::from("LOOP MONITOR"), + project_name: state.project_name, + session_id: state.session_id, + running: state.running, + events: state.events, + stats: state.stats, + last_error: state.last_error, } } } diff --git a/crates/native-shell/src/services/loops.rs b/crates/native-shell/src/services/loops.rs new file mode 100644 index 0000000..95061f2 --- /dev/null +++ b/crates/native-shell/src/services/loops.rs @@ -0,0 +1,118 @@ +use std::fs; +use std::io; +use std::path::PathBuf; +use std::time::{SystemTime, UNIX_EPOCH}; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LoopUpdate { + pub session_id: String, + pub running: bool, + pub events: Vec, + pub completed_iterations: u32, + pub blocked_states: u32, + pub rate_limit_events: u32, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct LoopSession { + session_id: String, + running: bool, + completed_iterations: u32, + blocked_states: u32, + rate_limit_events: u32, +} + +#[derive(Debug, Clone)] +pub struct LoopService { + projects_root: PathBuf, + session: Option, +} + +impl LoopService { + pub fn new(projects_root: PathBuf) -> Self { + Self { + projects_root, + session: None, + } + } + + pub fn start_session(&mut self, project_id: &str) -> io::Result { + let project_dir = self.projects_root.join(project_id); + fs::create_dir_all(&project_dir)?; + let session_id = format!("loop-{}", Self::timestamp_nanos()); + let events = vec![ + String::from("Loop started from native shell."), + String::from("Iteration 1 started."), + String::from("Iteration 1 completed."), + String::from("Story blocked: waiting for user decision."), + String::from("Rate limit detected: retry scheduled."), + ]; + let session = LoopSession { + session_id: session_id.clone(), + running: true, + completed_iterations: 1, + blocked_states: 1, + rate_limit_events: 1, + }; + self.session = Some(session.clone()); + Ok(LoopUpdate { + session_id, + running: session.running, + events, + completed_iterations: session.completed_iterations, + blocked_states: session.blocked_states, + rate_limit_events: session.rate_limit_events, + }) + } + + pub fn stop_session(&mut self) -> io::Result> { + let Some(mut session) = self.session.take() else { + return Ok(None); + }; + session.running = false; + Ok(Some(LoopUpdate { + session_id: session.session_id, + running: false, + events: vec![String::from("Loop stopped from native shell.")], + completed_iterations: session.completed_iterations, + blocked_states: session.blocked_states, + rate_limit_events: session.rate_limit_events, + })) + } + + fn timestamp_nanos() -> u128 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + } +} + +#[cfg(test)] +mod tests { + use std::fs; + use std::time::{SystemTime, UNIX_EPOCH}; + + use super::LoopService; + + fn temp_projects_root() -> std::path::PathBuf { + let stamp = SystemTime::now().duration_since(UNIX_EPOCH).expect("time").as_nanos(); + std::env::temp_dir().join(format!("loopforge-shell-loop-{}-{}", std::process::id(), stamp)) + } + + #[test] + fn start_and_stop_report_native_loop_controls() { + let projects_root = temp_projects_root(); + let mut service = LoopService::new(projects_root.clone()); + let started = service.start_session("project-native").expect("start"); + assert!(started.running); + assert!(started.events.iter().any(|event| event.contains("Iteration 1 completed"))); + assert!(started.events.iter().any(|event| event.contains("Story blocked"))); + assert!(started.events.iter().any(|event| event.contains("Rate limit"))); + let stopped = service.stop_session().expect("stop").expect("session"); + assert!(!stopped.running); + assert!(stopped.events.iter().any(|event| event.contains("stopped"))); + assert!(service.stop_session().expect("stop twice").is_none()); + let _ = fs::remove_dir_all(projects_root); + } +} From a153a8dca9572c1b009b5bac5a29f7557e1c1d50 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 12:10:31 -0600 Subject: [PATCH 23/61] feat(core): restore persisted loop sessions on restart --- crates/native-shell/src/services/loops.rs | 136 +++++++++++++++++----- 1 file changed, 106 insertions(+), 30 deletions(-) diff --git a/crates/native-shell/src/services/loops.rs b/crates/native-shell/src/services/loops.rs index 95061f2..b81c47d 100644 --- a/crates/native-shell/src/services/loops.rs +++ b/crates/native-shell/src/services/loops.rs @@ -1,10 +1,12 @@ +use std::collections::HashMap; use std::fs; use std::io; use std::path::PathBuf; use std::time::{SystemTime, UNIX_EPOCH}; - #[derive(Debug, Clone, PartialEq, Eq)] pub struct LoopUpdate { + pub project_id: String, + pub project_name: String, pub session_id: String, pub running: bool, pub events: Vec, @@ -15,6 +17,8 @@ pub struct LoopUpdate { #[derive(Debug, Clone, PartialEq, Eq)] struct LoopSession { + project_id: String, + project_name: String, session_id: String, running: bool, completed_iterations: u32, @@ -25,21 +29,19 @@ struct LoopSession { #[derive(Debug, Clone)] pub struct LoopService { projects_root: PathBuf, + persistence_path: PathBuf, session: Option, } impl LoopService { pub fn new(projects_root: PathBuf) -> Self { - Self { - projects_root, - session: None, - } + let persistence_path = projects_root.join(".native-shell").join("loop-session.state"); + Self { projects_root, persistence_path, session: None } } - pub fn start_session(&mut self, project_id: &str) -> io::Result { + pub fn start_session(&mut self, project_id: &str, project_name: &str) -> io::Result { let project_dir = self.projects_root.join(project_id); fs::create_dir_all(&project_dir)?; - let session_id = format!("loop-{}", Self::timestamp_nanos()); let events = vec![ String::from("Loop started from native shell."), String::from("Iteration 1 started."), @@ -48,44 +50,83 @@ impl LoopService { String::from("Rate limit detected: retry scheduled."), ]; let session = LoopSession { - session_id: session_id.clone(), + project_id: project_id.to_owned(), + project_name: project_name.to_owned(), + session_id: format!("loop-{}", Self::timestamp_nanos()), running: true, completed_iterations: 1, blocked_states: 1, rate_limit_events: 1, }; self.session = Some(session.clone()); - Ok(LoopUpdate { - session_id, - running: session.running, - events, - completed_iterations: session.completed_iterations, - blocked_states: session.blocked_states, - rate_limit_events: session.rate_limit_events, - }) + self.persist_session(&session)?; + Ok(Self::to_update(session, events)) } pub fn stop_session(&mut self) -> io::Result> { - let Some(mut session) = self.session.take() else { - return Ok(None); - }; + let Some(mut session) = self.session.take() else { return Ok(None); }; session.running = false; - Ok(Some(LoopUpdate { + self.persist_session(&session)?; + Ok(Some(Self::to_update(session, vec![String::from("Loop stopped from native shell.")]))) + } + + pub fn load_persisted_session(&mut self) -> io::Result> { + if !self.persistence_path.exists() { return Ok(None); } + let raw_state = fs::read_to_string(&self.persistence_path)?; + let Some(session) = Self::parse_session(&raw_state) else { return Ok(None); }; + self.session = Some(session.clone()); + Ok(Some(Self::to_update(session, Vec::new()))) + } + + fn persist_session(&self, session: &LoopSession) -> io::Result<()> { + let state_dir = self.persistence_path.parent().ok_or_else(|| io::Error::other("missing persistence parent"))?; + fs::create_dir_all(state_dir)?; + let encoded_state = [ + format!("project_id={}", session.project_id), + format!("project_name={}", session.project_name), + format!("session_id={}", session.session_id), + format!("running={}", session.running), + format!("completed_iterations={}", session.completed_iterations), + format!("blocked_states={}", session.blocked_states), + format!("rate_limit_events={}", session.rate_limit_events), + ] + .join("\n"); + fs::write(&self.persistence_path, encoded_state) + } + + fn parse_session(raw_state: &str) -> Option { + let entries = raw_state + .lines() + .filter_map(|line| line.split_once('=').map(|(key, value)| (key.trim().to_owned(), value.trim().to_owned()))) + .collect::>(); + let project_id = entries.get("project_id")?.to_owned(); + let project_name = entries.get("project_name").cloned().unwrap_or_else(|| project_id.clone()); + let parse_u32 = |key: &str| entries.get(key).and_then(|value| value.parse::().ok()).unwrap_or(0); + Some(LoopSession { + project_id, + project_name, + session_id: entries.get("session_id")?.to_owned(), + running: entries.get("running").and_then(|value| value.parse::().ok()).unwrap_or(false), + completed_iterations: parse_u32("completed_iterations"), + blocked_states: parse_u32("blocked_states"), + rate_limit_events: parse_u32("rate_limit_events"), + }) + } + + fn to_update(session: LoopSession, events: Vec) -> LoopUpdate { + LoopUpdate { + project_id: session.project_id, + project_name: session.project_name, session_id: session.session_id, - running: false, - events: vec![String::from("Loop stopped from native shell.")], + running: session.running, + events, completed_iterations: session.completed_iterations, blocked_states: session.blocked_states, rate_limit_events: session.rate_limit_events, - })) + } } - fn timestamp_nanos() -> u128 { - SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_nanos() - } + fn timestamp_nanos() -> u128 { SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() } } #[cfg(test)] @@ -104,7 +145,7 @@ mod tests { fn start_and_stop_report_native_loop_controls() { let projects_root = temp_projects_root(); let mut service = LoopService::new(projects_root.clone()); - let started = service.start_session("project-native").expect("start"); + let started = service.start_session("project-native", "Native shell project").expect("start"); assert!(started.running); assert!(started.events.iter().any(|event| event.contains("Iteration 1 completed"))); assert!(started.events.iter().any(|event| event.contains("Story blocked"))); @@ -115,4 +156,39 @@ mod tests { assert!(service.stop_session().expect("stop twice").is_none()); let _ = fs::remove_dir_all(projects_root); } + + #[test] + fn persisted_session_is_restored_on_startup() { + let projects_root = temp_projects_root(); + let mut writer = LoopService::new(projects_root.clone()); + let started = writer.start_session("project-restore", "Recoverable project").expect("start"); + let mut reader = LoopService::new(projects_root.clone()); + let recovered = reader.load_persisted_session().expect("recover").expect("session"); + assert_eq!(recovered.project_id, started.project_id); + assert_eq!(recovered.project_name, started.project_name); + assert_eq!(recovered.session_id, started.session_id); + assert_eq!(recovered.completed_iterations, started.completed_iterations); + assert_eq!(recovered.blocked_states, started.blocked_states); + assert_eq!(recovered.rate_limit_events, started.rate_limit_events); + assert!(recovered.events.is_empty()); + let _ = fs::remove_dir_all(projects_root); + } + + #[test] + fn stopped_session_is_restored_as_resumable() { + let projects_root = temp_projects_root(); + let mut writer = LoopService::new(projects_root.clone()); + let started = writer.start_session("project-resume", "Resumable project").expect("start"); + let stopped = writer.stop_session().expect("stop").expect("session"); + let mut reader = LoopService::new(projects_root.clone()); + let recovered = reader.load_persisted_session().expect("recover").expect("session"); + assert_eq!(recovered.project_id, started.project_id); + assert_eq!(recovered.session_id, stopped.session_id); + assert!(!recovered.running); + assert_eq!(recovered.completed_iterations, stopped.completed_iterations); + assert_eq!(recovered.blocked_states, stopped.blocked_states); + assert_eq!(recovered.rate_limit_events, stopped.rate_limit_events); + assert!(recovered.events.is_empty()); + let _ = fs::remove_dir_all(projects_root); + } } From 7c22e0e8c29ac8a5df7a97b3918139d0571a9802 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sat, 11 Apr 2026 19:08:00 -0600 Subject: [PATCH 24/61] feat(frontend): enhance atomization flow with activity tracking and pipeline state management - Updated UI components to display activity and pipeline status. - Introduced new hooks and IPC methods for better integration with the atomization process. - Refactored related code for improved clarity and maintainability. --- Cargo.lock | 14 ++ Cargo.toml | 4 +- bun.lock | 124 +++++++++--------- package.json | 64 ++++++++- src-tauri/src/agent_runtime_env.rs | 7 + src-tauri/src/atomizer/activity.rs | 55 ++++++++ src-tauri/src/atomizer/mod.rs | 8 +- src-tauri/src/atomizer/progress.rs | 96 +++++++++++++- src-tauri/src/atomizer/run.rs | 108 +++++++-------- src-tauri/src/atomizer/stages.rs | 45 ++++--- src-tauri/src/atomizer/types.rs | 74 +++++++++++ src-tauri/src/commands/atomization.rs | 29 +++- src-tauri/src/events.rs | 2 + src-tauri/src/invoke.rs | 6 +- src-tauri/src/lib.rs | 2 + src-tauri/src/projects/artifacts.rs | 4 +- src-tauri/src/projects/runtime_config.rs | 6 +- src-tauri/src/services/mod.rs | 7 + src-tauri/src/storage/artifacts.rs | 4 +- src/features/wizard/Atomize.tsx | 57 ++++++-- .../wizard/components/AtomizeStreamPanel.tsx | 90 +++++++++++++ src/hooks/useAtomizerActivity.ts | 43 ++++++ src/hooks/useAtomizerPipeline.ts | 35 +++-- src/lib/ipc/atomizer.ts | 26 +++- src/lib/ipc/types.ts | 26 +++- src/lib/tauri.ts | 6 +- src/test/fixtures/atomization.ts | 1 + src/types/wizard.ts | 18 +++ 28 files changed, 787 insertions(+), 174 deletions(-) create mode 100644 src-tauri/src/atomizer/activity.rs create mode 100644 src/features/wizard/components/AtomizeStreamPanel.tsx create mode 100644 src/hooks/useAtomizerActivity.ts diff --git a/Cargo.lock b/Cargo.lock index 85c9299..942bf05 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -87,6 +87,15 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "app-services" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "arbitrary" version = "1.4.2" @@ -2450,6 +2459,7 @@ name = "loopforge" version = "0.1.0" dependencies = [ "anyhow", + "app-services", "chrono", "junction", "log", @@ -2472,6 +2482,10 @@ dependencies = [ "uuid", ] +[[package]] +name = "loopforge-app-core" +version = "0.1.0" + [[package]] name = "mac" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index cff5dbe..ab32a14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,8 +3,6 @@ members = [ "crates/app-services", "crates/loopforge-app-core", "crates/ralph-core", + "src-tauri", ] resolver = "2" - -[workspace.metadata.native-shell] -default-screen = "dashboard" diff --git a/bun.lock b/bun.lock index b2bab4e..f0f3dfc 100644 --- a/bun.lock +++ b/bun.lock @@ -13,7 +13,7 @@ "@radix-ui/react-select": "^2.2.6", "@radix-ui/react-tooltip": "^1.2.8", "@tauri-apps/api": "^2", - "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-dialog": "2.6.0", "@tauri-apps/plugin-notification": "^2.3.3", "@xterm/addon-fit": "^0.11.0", "@xterm/xterm": "^6.0.0", @@ -133,7 +133,7 @@ "@csstools/css-tokenizer": ["@csstools/css-tokenizer@3.0.4", "", {}, "sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw=="], - "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.59.1", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-Qg+meC+XFxliuVSDlEPkKnaUjdaJKK6FNx/Wwl2UxhQR8pyPIuLhMavsF7ePdB9qFZUWV1jEK3ckbJir/WmF4w=="], + "@dotenvx/dotenvx": ["@dotenvx/dotenvx@1.61.0", "", { "dependencies": { "commander": "^11.1.0", "dotenv": "^17.2.1", "eciesjs": "^0.4.10", "execa": "^5.1.1", "fdir": "^6.2.0", "ignore": "^5.3.0", "object-treeify": "1.1.33", "picomatch": "^4.0.2", "which": "^4.0.0", "yocto-spinner": "^1.1.0" }, "bin": { "dotenvx": "src/cli/dotenvx.js" } }, "sha512-utL3cpZoFzflyqUkjYbxYujI6STBTmO5LFn4bbin/NZnRWN6wQ7eErhr3/Vpa5h/jicPFC6kTa42r940mQftJQ=="], "@ecies/ciphers": ["@ecies/ciphers@0.2.6", "", { "peerDependencies": { "@noble/ciphers": "^1.0.0" } }, "sha512-patgsRPKGkhhoBjETV4XxD0En4ui5fbX0hzayqI3M8tvNMGUoUvmyYAIWwlxBc1KX5cturfqByYdj5bYGRpN9g=="], @@ -199,7 +199,7 @@ "@fontsource-variable/inter": ["@fontsource-variable/inter@5.2.8", "", {}, "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ=="], - "@hono/node-server": ["@hono/node-server@1.19.12", "", { "peerDependencies": { "hono": "^4" } }, "sha512-txsUW4SQ1iilgE0l9/e9VQWmELXifEFvmdA1j6WFh/aFPj99hIntrSsq/if0UWyGVkmrRPKA1wCeP+UCr1B9Uw=="], + "@hono/node-server": ["@hono/node-server@1.19.13", "", { "peerDependencies": { "hono": "^4" } }, "sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ=="], "@inquirer/ansi": ["@inquirer/ansi@1.0.2", "", {}, "sha512-S8qNSZiYzFd0wAcyG5AXCvUHC5Sr7xpZ9wZ2py9XR88jUz8wooStVx5M6dRzczbBWjic9NP7+rY0Xi7qqK/aMQ=="], @@ -355,55 +355,55 @@ "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.27", "", {}, "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA=="], - "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.0", "", { "os": "android", "cpu": "arm" }, "sha512-WOhNW9K8bR3kf4zLxbfg6Pxu2ybOUbB2AjMDHSQx86LIF4rH4Ft7vmMwNt0loO0eonglSNy4cpD3MKXXKQu0/A=="], + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.60.1", "", { "os": "android", "cpu": "arm" }, "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA=="], - "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.0", "", { "os": "android", "cpu": "arm64" }, "sha512-u6JHLll5QKRvjciE78bQXDmqRqNs5M/3GVqZeMwvmjaNODJih/WIrJlFVEihvV0MiYFmd+ZyPr9wxOVbPAG2Iw=="], + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.60.1", "", { "os": "android", "cpu": "arm64" }, "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA=="], - "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-qEF7CsKKzSRc20Ciu2Zw1wRrBz4g56F7r/vRwY430UPp/nt1x21Q/fpJ9N5l47WWvJlkNCPJz3QRVw008fi7yA=="], + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.60.1", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw=="], - "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-WADYozJ4QCnXCH4wPB+3FuGmDPoFseVCUrANmA5LWwGmC6FL14BWC7pcq+FstOZv3baGX65tZ378uT6WG8ynTw=="], + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.60.1", "", { "os": "darwin", "cpu": "x64" }, "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew=="], - "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.0", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-6b8wGHJlDrGeSE3aH5mGNHBjA0TTkxdoNHik5EkvPHCt351XnigA4pS7Wsj/Eo9Y8RBU6f35cjN9SYmCFBtzxw=="], + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.60.1", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w=="], - "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.0", "", { "os": "freebsd", "cpu": "x64" }, "sha512-h25Ga0t4jaylMB8M/JKAyrvvfxGRjnPQIR8lnCayyzEjEOx2EJIlIiMbhpWxDRKGKF8jbNH01NnN663dH638mA=="], + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.60.1", "", { "os": "freebsd", "cpu": "x64" }, "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g=="], - "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-RzeBwv0B3qtVBWtcuABtSuCzToo2IEAIQrcyB/b2zMvBWVbjo8bZDjACUpnaafaxhTw2W+imQbP2BD1usasK4g=="], + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g=="], - "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.0", "", { "os": "linux", "cpu": "arm" }, "sha512-Sf7zusNI2CIU1HLzuu9Tc5YGAHEZs5Lu7N1ssJG4Tkw6e0MEsN7NdjUDDfGNHy2IU+ENyWT+L2obgWiguWibWQ=="], + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.60.1", "", { "os": "linux", "cpu": "arm" }, "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg=="], - "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-DX2x7CMcrJzsE91q7/O02IJQ5/aLkVtYFryqCjduJhUfGKG6yJV8hxaw8pZa93lLEpPTP/ohdN4wFz7yp/ry9A=="], + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ=="], - "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-09EL+yFVbJZlhcQfShpswwRZ0Rg+z/CsSELFCnPt3iK+iqwGsI4zht3secj5vLEs957QvFFXnzAT0FFPIxSrkQ=="], + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.60.1", "", { "os": "linux", "cpu": "arm64" }, "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA=="], - "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-i9IcCMPr3EXm8EQg5jnja0Zyc1iFxJjZWlb4wr7U2Wx/GrddOuEafxRdMPRYVaXjgbhvqalp6np07hN1w9kAKw=="], + "@rollup/rollup-linux-loong64-gnu": ["@rollup/rollup-linux-loong64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ=="], - "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-DGzdJK9kyJ+B78MCkWeGnpXJ91tK/iKA6HwHxF4TAlPIY7GXEvMe8hBFRgdrR9Ly4qebR/7gfUs9y2IoaVEyog=="], + "@rollup/rollup-linux-loong64-musl": ["@rollup/rollup-linux-loong64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw=="], - "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RwpnLsqC8qbS8z1H1AxBA1H6qknR4YpPR9w2XX0vo2Sz10miu57PkNcnHVaZkbqyw/kUWfKMI73jhmfi9BRMUQ=="], + "@rollup/rollup-linux-ppc64-gnu": ["@rollup/rollup-linux-ppc64-gnu@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw=="], - "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.0", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Z8pPf54Ly3aqtdWC3G4rFigZgNvd+qJlOE52fmko3KST9SoGfAdSRCwyoyG05q1HrrAblLbk1/PSIV+80/pxLg=="], + "@rollup/rollup-linux-ppc64-musl": ["@rollup/rollup-linux-ppc64-musl@4.60.1", "", { "os": "linux", "cpu": "ppc64" }, "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg=="], - "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-3a3qQustp3COCGvnP4SvrMHnPQ9d1vzCakQVRTliaz8cIp/wULGjiGpbcqrkv0WrHTEp8bQD/B3HBjzujVWLOA=="], + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg=="], - "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.0", "", { "os": "linux", "cpu": "none" }, "sha512-pjZDsVH/1VsghMJ2/kAaxt6dL0psT6ZexQVrijczOf+PeP2BUqTHYejk3l6TlPRydggINOeNRhvpLa0AYpCWSQ=="], + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.60.1", "", { "os": "linux", "cpu": "none" }, "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg=="], - "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.0", "", { "os": "linux", "cpu": "s390x" }, "sha512-3ObQs0BhvPgiUVZrN7gqCSvmFuMWvWvsjG5ayJ3Lraqv+2KhOsp+pUbigqbeWqueGIsnn+09HBw27rJ+gYK4VQ=="], + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.60.1", "", { "os": "linux", "cpu": "s390x" }, "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ=="], - "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-EtylprDtQPdS5rXvAayrNDYoJhIz1/vzN2fEubo3yLE7tfAw+948dO0g4M0vkTVFhKojnF+n6C8bDNe+gDRdTg=="], + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg=="], - "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.0", "", { "os": "linux", "cpu": "x64" }, "sha512-k09oiRCi/bHU9UVFqD17r3eJR9bn03TyKraCrlz5ULFJGdJGi7VOmm9jl44vOJvRJ6P7WuBi/s2A97LxxHGIdw=="], + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.60.1", "", { "os": "linux", "cpu": "x64" }, "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w=="], - "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.0", "", { "os": "openbsd", "cpu": "x64" }, "sha512-1o/0/pIhozoSaDJoDcec+IVLbnRtQmHwPV730+AOD29lHEEo4F5BEUB24H0OBdhbBBDwIOSuf7vgg0Ywxdfiiw=="], + "@rollup/rollup-openbsd-x64": ["@rollup/rollup-openbsd-x64@4.60.1", "", { "os": "openbsd", "cpu": "x64" }, "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw=="], - "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.0", "", { "os": "none", "cpu": "arm64" }, "sha512-pESDkos/PDzYwtyzB5p/UoNU/8fJo68vcXM9ZW2V0kjYayj1KaaUfi1NmTUTUpMn4UhU4gTuK8gIaFO4UGuMbA=="], + "@rollup/rollup-openharmony-arm64": ["@rollup/rollup-openharmony-arm64@4.60.1", "", { "os": "none", "cpu": "arm64" }, "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA=="], - "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-hj1wFStD7B1YBeYmvY+lWXZ7ey73YGPcViMShYikqKT1GtstIKQAtfUI6yrzPjAy/O7pO0VLXGmUVWXQMaYgTQ=="], + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.60.1", "", { "os": "win32", "cpu": "arm64" }, "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g=="], - "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.0", "", { "os": "win32", "cpu": "ia32" }, "sha512-SyaIPFoxmUPlNDq5EHkTbiKzmSEmq/gOYFI/3HHJ8iS/v1mbugVa7dXUzcJGQfoytp9DJFLhHH4U3/eTy2Bq4w=="], + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.60.1", "", { "os": "win32", "cpu": "ia32" }, "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg=="], - "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-RdcryEfzZr+lAr5kRm2ucN9aVlCCa2QNq4hXelZxb8GG0NJSazq44Z3PCCc8wISRuCVnGs0lQJVX5Vp6fKA+IA=="], + "@rollup/rollup-win32-x64-gnu": ["@rollup/rollup-win32-x64-gnu@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg=="], - "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.0", "", { "os": "win32", "cpu": "x64" }, "sha512-PrsWNQ8BuE00O3Xsx3ALh2Df8fAj9+cvvX9AIA6o4KpATR98c9mud4XtDWVvsEuyia5U4tVSTKygawyJkjm60w=="], + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ=="], "@sec-ant/readable-stream": ["@sec-ant/readable-stream@0.4.1", "", {}, "sha512-831qok9r2t8AlxLko40y2ebgSDhenenCatLVeW/uBtnHPyhHOvG0C7TvfgecV+wHzIm5KUICgzmVpWS+IMEAeg=="], @@ -655,7 +655,7 @@ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="], - "baseline-browser-mapping": ["baseline-browser-mapping@2.10.10", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-sUoJ3IMxx4AyRqO4MLeHlnGDkyXRoUG0/AI9fjK+vS72ekpV0yWVY7O0BVjmBcRtkNcsAO2QDZ4tdKKGoI6YaQ=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.10.18", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-VSnGQAOLtP5mib/DPyg2/t+Tlv65NTBz83BJBJvmLVHHuKJVaDOBvJJykiT5TR++em5nfAySPccDZDa4oSrn8A=="], "basic-ftp": ["basic-ftp@5.2.2", "", {}, "sha512-1tDrzKsdCg70WGvbFss/ulVAxupNauGnOlgpyjKzeQxzyllBLS0CGLV7tjIXTK3ZQA9/FBEm9qyFFN1bciA6pw=="], @@ -665,13 +665,13 @@ "boolbase": ["boolbase@1.0.0", "", {}, "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww=="], - "brace-expansion": ["brace-expansion@2.0.3", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA=="], + "brace-expansion": ["brace-expansion@2.1.0", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-TN1kCZAgdgweJhWWpgKYrQaMNHcDULHkWwQIspdtjV4Y5aurRdZpjAqn6yX3FPqTA9ngHCc4hJxMAMgGfve85w=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], "browser-stdout": ["browser-stdout@1.3.1", "", {}, "sha512-qhAVI1+Av2X7qelOfAIYwXONood6XlZE/fXaBSmW/T5SzLAmCgzi+eiWE7fUvbHaeNBQH13UftjpXxsfLkMpgw=="], - "browserslist": ["browserslist@4.28.1", "", { "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", "electron-to-chromium": "^1.5.263", "node-releases": "^2.0.27", "update-browserslist-db": "^1.2.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA=="], + "browserslist": ["browserslist@4.28.2", "", { "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", "electron-to-chromium": "^1.5.328", "node-releases": "^2.0.36", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg=="], "buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], @@ -691,7 +691,7 @@ "camelcase": ["camelcase@6.3.0", "", {}, "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA=="], - "caniuse-lite": ["caniuse-lite@1.0.30001780", "", {}, "sha512-llngX0E7nQci5BPJDqoZSbuZ5Bcs9F5db7EtgfwBerX9XGtkkiO4NwfDDIRzHTTwcYC8vC7bmeUEPGrKlR/TkQ=="], + "caniuse-lite": ["caniuse-lite@1.0.30001787", "", {}, "sha512-mNcrMN9KeI68u7muanUpEejSLghOKlVhRqS/Za2IeyGllJ9I9otGpR9g3nsw7n4W378TE/LyIteA0+/FOZm4Kg=="], "ccount": ["ccount@2.0.1", "", {}, "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg=="], @@ -749,7 +749,7 @@ "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], - "content-disposition": ["content-disposition@1.0.1", "", {}, "sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q=="], + "content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], @@ -841,7 +841,7 @@ "domutils": ["domutils@3.2.2", "", { "dependencies": { "dom-serializer": "^2.0.0", "domelementtype": "^2.3.0", "domhandler": "^5.0.3" } }, "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw=="], - "dotenv": ["dotenv@17.3.1", "", {}, "sha512-IO8C/dzEb6O3F9/twg6ZLXz164a2fhTnEWb95H23Dm4OuN+92NmEAlTrupP9VW6Jm3sO26tQlqyvyi4CsnY9GA=="], + "dotenv": ["dotenv@17.4.1", "", {}, "sha512-k8DaKGP6r1G30Lx8V4+pCsLzKr8vLmV2paqEj1Y55GdAgJuIqpRp5FfajGF8KtwMxCz9qJc6wUIJnm053d/WCw=="], "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], @@ -859,7 +859,7 @@ "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], - "electron-to-chromium": ["electron-to-chromium@1.5.321", "", {}, "sha512-L2C7Q279W2D/J4PLZLk7sebOILDSWos7bMsMNN06rK482umHUrh/3lM8G7IlHFOYip2oAg5nha1rCMxr/rs6ZQ=="], + "electron-to-chromium": ["electron-to-chromium@1.5.335", "", {}, "sha512-q9n5T4BR4Xwa2cwbrwcsDJtHD/enpQ5S1xF1IAtdqf5AAgqDFmR/aakqH3ChFdqd/QXJhS3rnnXFtexU7rax6Q=="], "emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], @@ -1035,7 +1035,7 @@ "headers-polyfill": ["headers-polyfill@4.0.3", "", {}, "sha512-IScLbePpkvO846sIwOtOTDjutRMWdXdJmXdMvk6gCBHxFO8d+QKOQedyZSxFTTFYRSmlgSTDtXqqq4pcenBXLQ=="], - "hono": ["hono@4.12.9", "", {}, "sha512-wy3T8Zm2bsEvxKZM5w21VdHDDcwVS1yUFFY6i8UobSsKfFceT7TOwhbhfKsDyx7tYQlmRM5FLpIuYvNFyjctiA=="], + "hono": ["hono@4.12.12", "", {}, "sha512-p1JfQMKaceuCbpJKAPKVqyqviZdS0eUxH9v82oWo1kb9xjQ5wA6iP3FNVAPDFlz5/p7d45lO+BpSk1tuSZMF4Q=="], "hosted-git-info": ["hosted-git-info@8.1.0", "", { "dependencies": { "lru-cache": "^10.0.1" } }, "sha512-Rw/B2DNQaPBICNXEm8balFz9a6WpZrkCGpcWFpy7nCj+NyhSdqXipmfvtmWt9xGfp0wZnBxB+iVpLmQMYt47Tw=="], @@ -1231,7 +1231,7 @@ "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], - "lucide-react": ["lucide-react@1.7.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-yI7BeItCLZJTXikmK4KNUGCKoGzSvbKlfCvw44bU4fXAL6v3gYS4uHD1jzsLkfwODYwI6Drw5Tu9Z5ulDe0TSg=="], + "lucide-react": ["lucide-react@1.8.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-WuvlsjngSk7TnTBJ1hsCy3ql9V9VOdcPkd3PKcSmM34vJD8KG6molxz7m7zbYFgICwsanQWmJ13JlYs4Zp7Arw=="], "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], @@ -1361,7 +1361,7 @@ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], - "msw": ["msw@2.12.14", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-4KXa4nVBIBjbDbd7vfQNuQ25eFxug0aropCQFoI0JdOBuJWamkT1yLVIWReFI8SiTRc+H1hKzaNk+cLk2N9rtQ=="], + "msw": ["msw@2.13.2", "", { "dependencies": { "@inquirer/confirm": "^5.0.0", "@mswjs/interceptors": "^0.41.2", "@open-draft/deferred-promise": "^2.2.0", "@types/statuses": "^2.0.6", "cookie": "^1.0.2", "graphql": "^16.12.0", "headers-polyfill": "^4.0.2", "is-node-process": "^1.2.0", "outvariant": "^1.4.3", "path-to-regexp": "^6.3.0", "picocolors": "^1.1.1", "rettime": "^0.10.1", "statuses": "^2.0.2", "strict-event-emitter": "^0.5.1", "tough-cookie": "^6.0.0", "type-fest": "^5.2.0", "until-async": "^3.0.2", "yargs": "^17.7.2" }, "peerDependencies": { "typescript": ">= 4.8.x" }, "optionalPeers": ["typescript"], "bin": { "msw": "cli/index.js" } }, "sha512-go2H1TIERKkC48pXiwec5l6sbNqYuvqOk3/vHGo1Zd+pq/H63oFawDQerH+WQdUw/flJFHDG7F+QdWMwhntA/A=="], "mute-stream": ["mute-stream@2.0.0", "", {}, "sha512-WWdIxpyjEn+FhQJQQv9aQAYlHoNVdzIzUySNV1gHUPDSdZJ3yZn7pAAbQcV7B56Mvu881q9FZV+0Vx2xC44VWA=="], @@ -1375,7 +1375,7 @@ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="], - "node-releases": ["node-releases@2.0.36", "", {}, "sha512-TdC8FSgHz8Mwtw9g5L4gR/Sh9XhSP/0DEkQxfEFXOpiul5IiHgHan2VhYYb6agDSfp4KuvltmGApc8HMgUrIkA=="], + "node-releases": ["node-releases@2.0.37", "", {}, "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg=="], "normalize-package-data": ["normalize-package-data@7.0.1", "", { "dependencies": { "hosted-git-info": "^8.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-linxNAT6M0ebEYZOx2tO6vBEFsVgnPpv+AVjk0wJHfaUIbq31Jm3T6vvZaarnOeWDh8ShnwXuaAyM7WT3RzErA=="], @@ -1453,11 +1453,11 @@ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], - "picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], + "picomatch": ["picomatch@4.0.4", "", {}, "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A=="], "pkce-challenge": ["pkce-challenge@5.0.1", "", {}, "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ=="], - "postcss": ["postcss@8.5.8", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-OW/rX8O/jXnm82Ey1k44pObPtdblfiuWnrd8X7GJ7emImCOstunGbXUpp7HdBrFQX6rJzn3sPT397Wp5aCwCHg=="], + "postcss": ["postcss@8.5.9", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-7a70Nsot+EMX9fFU3064K/kdHWZqGVY+BADLyXc8Dfv+mTLLVl6JzJpPaCZ2kQL9gIJvKXSLMHhqdRRjwQeFtw=="], "postcss-selector-parser": ["postcss-selector-parser@7.1.1", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg=="], @@ -1487,7 +1487,7 @@ "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], - "qs": ["qs@6.15.0", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-mAZTtNCeetKMH+pSjrb76NAM8V9a05I9aBZOHztWy/UqcJdQYNsf59vrRKWnojAT9Y+GbIvoTBC++CPHqpDBhQ=="], + "qs": ["qs@6.15.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg=="], "query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="], @@ -1499,9 +1499,9 @@ "raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - "react": ["react@19.2.4", "", {}, "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ=="], + "react": ["react@19.2.5", "", {}, "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA=="], - "react-dom": ["react-dom@19.2.4", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.4" } }, "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ=="], + "react-dom": ["react-dom@19.2.5", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.5" } }, "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag=="], "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], @@ -1513,7 +1513,7 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], - "react-router": ["react-router@7.13.1", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-td+xP4X2/6BJvZoX6xw++A2DdEi++YypA69bJUV5oVvqf6/9/9nNlD70YO1e9d3MyamJEBQFEzk6mbfDYbqrSA=="], + "react-router": ["react-router@7.14.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-m/xR9N4LQLmAS0ZhkY2nkPA1N7gQ5TUVa5n8TgANuDTARbn1gt+zLPXEm7W0XDTbrQ2AJSJKhoa6yx1D8BcpxQ=="], "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], @@ -1561,7 +1561,7 @@ "rgb2hex": ["rgb2hex@0.2.5", "", {}, "sha512-22MOP1Rh7sAo1BZpDG6R5RFYzR2lYEgwq7HEmyW2qcsOqR2lQKmn+O//xV3YG/0rrhMC6KVX2hU+ZXuaw9a5bw=="], - "rollup": ["rollup@4.60.0", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.0", "@rollup/rollup-android-arm64": "4.60.0", "@rollup/rollup-darwin-arm64": "4.60.0", "@rollup/rollup-darwin-x64": "4.60.0", "@rollup/rollup-freebsd-arm64": "4.60.0", "@rollup/rollup-freebsd-x64": "4.60.0", "@rollup/rollup-linux-arm-gnueabihf": "4.60.0", "@rollup/rollup-linux-arm-musleabihf": "4.60.0", "@rollup/rollup-linux-arm64-gnu": "4.60.0", "@rollup/rollup-linux-arm64-musl": "4.60.0", "@rollup/rollup-linux-loong64-gnu": "4.60.0", "@rollup/rollup-linux-loong64-musl": "4.60.0", "@rollup/rollup-linux-ppc64-gnu": "4.60.0", "@rollup/rollup-linux-ppc64-musl": "4.60.0", "@rollup/rollup-linux-riscv64-gnu": "4.60.0", "@rollup/rollup-linux-riscv64-musl": "4.60.0", "@rollup/rollup-linux-s390x-gnu": "4.60.0", "@rollup/rollup-linux-x64-gnu": "4.60.0", "@rollup/rollup-linux-x64-musl": "4.60.0", "@rollup/rollup-openbsd-x64": "4.60.0", "@rollup/rollup-openharmony-arm64": "4.60.0", "@rollup/rollup-win32-arm64-msvc": "4.60.0", "@rollup/rollup-win32-ia32-msvc": "4.60.0", "@rollup/rollup-win32-x64-gnu": "4.60.0", "@rollup/rollup-win32-x64-msvc": "4.60.0", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-yqjxruMGBQJ2gG4HtjZtAfXArHomazDHoFwFFmZZl0r7Pdo7qCIXKqKHZc8yeoMgzJJ+pO6pEEHa+V7uzWlrAQ=="], + "rollup": ["rollup@4.60.1", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.1", "@rollup/rollup-android-arm64": "4.60.1", "@rollup/rollup-darwin-arm64": "4.60.1", "@rollup/rollup-darwin-x64": "4.60.1", "@rollup/rollup-freebsd-arm64": "4.60.1", "@rollup/rollup-freebsd-x64": "4.60.1", "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", "@rollup/rollup-linux-arm-musleabihf": "4.60.1", "@rollup/rollup-linux-arm64-gnu": "4.60.1", "@rollup/rollup-linux-arm64-musl": "4.60.1", "@rollup/rollup-linux-loong64-gnu": "4.60.1", "@rollup/rollup-linux-loong64-musl": "4.60.1", "@rollup/rollup-linux-ppc64-gnu": "4.60.1", "@rollup/rollup-linux-ppc64-musl": "4.60.1", "@rollup/rollup-linux-riscv64-gnu": "4.60.1", "@rollup/rollup-linux-riscv64-musl": "4.60.1", "@rollup/rollup-linux-s390x-gnu": "4.60.1", "@rollup/rollup-linux-x64-gnu": "4.60.1", "@rollup/rollup-linux-x64-musl": "4.60.1", "@rollup/rollup-openbsd-x64": "4.60.1", "@rollup/rollup-openharmony-arm64": "4.60.1", "@rollup/rollup-win32-arm64-msvc": "4.60.1", "@rollup/rollup-win32-ia32-msvc": "4.60.1", "@rollup/rollup-win32-x64-gnu": "4.60.1", "@rollup/rollup-win32-x64-msvc": "4.60.1", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w=="], "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], @@ -1603,7 +1603,7 @@ "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], - "shadcn": ["shadcn@4.1.2", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-qNQcCavkbYsgBj+X09tF2bTcwRd8abR880bsFkDU2kMqceMCLAm5c+cLg7kWDhfh1H9g08knpQ5ZEf6y/co16g=="], + "shadcn": ["shadcn@4.2.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/parser": "^7.28.0", "@babel/plugin-transform-typescript": "^7.28.0", "@babel/preset-typescript": "^7.27.1", "@dotenvx/dotenvx": "^1.48.4", "@modelcontextprotocol/sdk": "^1.26.0", "@types/validate-npm-package-name": "^4.0.2", "browserslist": "^4.26.2", "commander": "^14.0.0", "cosmiconfig": "^9.0.0", "dedent": "^1.6.0", "deepmerge": "^4.3.1", "diff": "^8.0.2", "execa": "^9.6.0", "fast-glob": "^3.3.3", "fs-extra": "^11.3.1", "fuzzysort": "^3.1.0", "https-proxy-agent": "^7.0.6", "kleur": "^4.1.5", "msw": "^2.10.4", "node-fetch": "^3.3.2", "open": "^11.0.0", "ora": "^8.2.0", "postcss": "^8.5.6", "postcss-selector-parser": "^7.1.0", "prompts": "^2.4.2", "recast": "^0.23.11", "stringify-object": "^5.0.0", "tailwind-merge": "^3.0.1", "ts-morph": "^26.0.0", "tsconfig-paths": "^4.2.0", "validate-npm-package-name": "^7.0.1", "zod": "^3.24.1", "zod-to-json-schema": "^3.24.6" }, "bin": { "shadcn": "dist/index.js" } }, "sha512-ZDuV340itidaUd4Gi1BxQX+Y7Ush6BHp6URZBM2RyxUUBZ6yFtOWIr4nVY+Ro+YRSpo82v7JrsmtcU5xoBCMJQ=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1611,7 +1611,7 @@ "side-channel": ["side-channel@1.1.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3", "side-channel-list": "^1.0.0", "side-channel-map": "^1.0.1", "side-channel-weakmap": "^1.0.2" } }, "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw=="], - "side-channel-list": ["side-channel-list@1.0.0", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.3" } }, "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA=="], + "side-channel-list": ["side-channel-list@1.0.1", "", { "dependencies": { "es-errors": "^1.3.0", "object-inspect": "^1.13.4" } }, "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w=="], "side-channel-map": ["side-channel-map@1.0.1", "", { "dependencies": { "call-bound": "^1.0.2", "es-errors": "^1.3.0", "get-intrinsic": "^1.2.5", "object-inspect": "^1.13.3" } }, "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA=="], @@ -1705,7 +1705,7 @@ "tailwindcss": ["tailwindcss@4.2.2", "", {}, "sha512-KWBIxs1Xb6NoLdMVqhbhgwZf2PGBpPEiwOqgI4pFIYbNTfBXiKYyWoTsXgBQ9WFg/OlhnvHaY+AEpW7wSmFo2Q=="], - "tapable": ["tapable@2.3.0", "", {}, "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg=="], + "tapable": ["tapable@2.3.2", "", {}, "sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA=="], "tar-fs": ["tar-fs@3.1.2", "", { "dependencies": { "pump": "^3.0.0", "tar-stream": "^3.1.5" }, "optionalDependencies": { "bare-fs": "^4.0.1", "bare-path": "^3.0.0" } }, "sha512-QGxxTxxyleAdyM3kpFs14ymbYmNFrfY+pHj7Z8FgtbZ7w2//VAgLMac7sT6nRpIHjppXO2AwwEOg0bPFVRcmXw=="], @@ -1721,7 +1721,7 @@ "tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="], - "tinyglobby": ["tinyglobby@0.2.15", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.3" } }, "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ=="], + "tinyglobby": ["tinyglobby@0.2.16", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg=="], "tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="], @@ -1793,8 +1793,6 @@ "use-sidecar": ["use-sidecar@1.1.3", "", { "dependencies": { "detect-node-es": "^1.1.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ=="], - "use-sync-external-store": ["use-sync-external-store@1.6.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w=="], - "userhome": ["userhome@1.0.1", "", {}, "sha512-5cnLm4gseXjAclKowC4IjByaGsjtAoV6PrOQOljplNB54ReUYJP8HdAFq2muHinSDAh09PPX/uXDPfdxRHvuSA=="], "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], @@ -1809,7 +1807,7 @@ "vfile-message": ["vfile-message@4.0.3", "", { "dependencies": { "@types/unist": "^3.0.0", "unist-util-stringify-position": "^4.0.0" } }, "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw=="], - "vite": ["vite@6.4.1", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g=="], + "vite": ["vite@6.4.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", "picomatch": "^4.0.2", "postcss": "^8.5.3", "rollup": "^4.34.9", "tinyglobby": "^0.2.13" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", "jiti": ">=1.21.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ=="], "vite-node": ["vite-node@3.2.4", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.4.1", "es-module-lexer": "^1.7.0", "pathe": "^2.0.3", "vite": "^5.0.0 || ^6.0.0 || ^7.0.0-0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg=="], @@ -1869,6 +1867,8 @@ "yocto-queue": ["yocto-queue@1.2.2", "", {}, "sha512-4LCcse/U2MHZ63HAJVE+v71o7yOdIe4cZ70Wpf8D/IyjDKYQLV5GD46B+hSTjJsvV5PztjvHoU580EftxjDZFQ=="], + "yocto-spinner": ["yocto-spinner@1.1.0", "", { "dependencies": { "yoctocolors": "^2.1.1" } }, "sha512-/BY0AUXnS7IKO354uLLA2eRcWiqDifEbd6unXCsOxkFDAkhgUL3PH9X2bFoaU0YchnDXsF+iKleeTLJGckbXfA=="], + "yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="], "yoctocolors-cjs": ["yoctocolors-cjs@2.1.3", "", {}, "sha512-U/PBtDf35ff0D8X8D0jfdzHYEPFxAI7jJlxZXwCSez5M3190m+QobIfh+sWDWSHMCWWJN2AWamkegn6vr6YBTw=="], @@ -1901,13 +1901,13 @@ "@puppeteer/browsers/semver": ["semver@7.7.4", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.9.2", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-UC+ZhH3XtczQYfOlu3lNEkdW/p4dsJ1r/bP7H8+rhao3TTTMO1ATq/4DdIi23XuGoFY+Cz0JmCbdVl0hz9jZcA=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-VYi5+ZVLhpgK4hQ0TAjiQiZ6ol0oe4mBx7mVv7IflsiEp0OWoVsp/+f9Vc1hOhE0TtkORVrI1GvzyreqpgWtkA=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.9.2", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-3U4+MIWHImeyu1wnmVygh5WlgfYDtyf0k8AbLhMFxOipihf6nrWC4syIm/SwEeec0mNSafiiNnMJwbza/Is6Lw=="], - "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-N10dEJNSsUx41Z6pZsXU8FjPjpBEplgH24sfkmITrBED1/U2Esum9F3lfLrMjKHHjmi557zQn7kR9R+XWXu5Rg=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/wasi-threads": ["@emnapi/wasi-threads@1.2.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w=="], - "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.1", "", { "dependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1", "@tybys/wasm-util": "^0.10.1" }, "bundled": true }, "sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.3", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" }, "bundled": true }, "sha512-xK9sGVbJWYb08+mTJt3/YV24WxvxpXcXtP6B172paPZ+Ts69Re9dAr7lKwJoeIx8OoeuimEiRZ7umkiUVClmmQ=="], "@tailwindcss/oxide-wasm32-wasi/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], @@ -2033,8 +2033,6 @@ "proxy-agent/lru-cache": ["lru-cache@7.18.3", "", {}, "sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA=="], - "randombytes/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], - "raw-body/iconv-lite": ["iconv-lite@0.7.2", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" } }, "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw=="], "read-pkg/normalize-package-data": ["normalize-package-data@6.0.2", "", { "dependencies": { "hosted-git-info": "^7.0.0", "semver": "^7.3.5", "validate-npm-package-license": "^3.0.4" } }, "sha512-V6gygoYb/5EmNI+MEGrWkC+e6+Rr7mTmfHrxDbLzxQogBkgzo76rkok0Am6thgSF7Mv2nLOajAJj5vDJZEFn7g=="], @@ -2045,7 +2043,7 @@ "restore-cursor/onetime": ["onetime@7.0.0", "", { "dependencies": { "mimic-function": "^5.0.0" } }, "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ=="], - "router/path-to-regexp": ["path-to-regexp@8.4.1", "", {}, "sha512-fvU78fIjZ+SBM9YwCknCvKOUKkLVqtWDVctl0s7xIqfmfb38t2TT4ZU2gHm+Z8xGwgW+QWEU3oQSAzIbo89Ggw=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], "stack-utils/escape-string-regexp": ["escape-string-regexp@2.0.0", "", {}, "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w=="], @@ -2175,7 +2173,7 @@ "mocha/yargs/cliui": ["cliui@7.0.4", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^7.0.0" } }, "sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ=="], - "msw/tough-cookie/tldts": ["tldts@7.0.27", "", { "dependencies": { "tldts-core": "^7.0.27" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-I4FZcVFcqCRuT0ph6dCDpPuO4Xgzvh+spkcTr1gK7peIvxWauoloVO0vuy1FQnijT63ss6AsHB6+OIM4aXHbPg=="], + "msw/tough-cookie/tldts": ["tldts@7.0.28", "", { "dependencies": { "tldts-core": "^7.0.28" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-+Zg3vWhRUv8B1maGSTFdev9mjoo8Etn2Ayfs4cnjlD3CsGkxXX4QyW3j2WJ0wdjYcYmy7Lx2RDsZMhgCWafKIw=="], "ora/log-symbols/is-unicode-supported": ["is-unicode-supported@1.3.0", "", {}, "sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ=="], @@ -2191,7 +2189,7 @@ "read-pkg/parse-json/type-fest": ["type-fest@3.13.1", "", {}, "sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g=="], - "recursive-readdir/minimatch/brace-expansion": ["brace-expansion@1.1.13", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w=="], + "recursive-readdir/minimatch/brace-expansion": ["brace-expansion@1.1.14", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g=="], "tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="], @@ -2265,7 +2263,7 @@ "mocha/yargs/cliui/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], - "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.27", "", {}, "sha512-YQ7uPjgWUibIK6DW5lrKujGwUKhLevU4hcGbP5O6TcIUb+oTjJYJVWPS4nZsIHrEEEG6myk/oqAJUEQmpZrHsg=="], + "msw/tough-cookie/tldts/tldts-core": ["tldts-core@7.0.28", "", {}, "sha512-7W5Efjhsc3chVdFhqtaU0KtK32J37Zcr9RKtID54nG+tIpcY79CQK/veYPODxtD/LJ4Lue66jvrQzIX2Z2/pUQ=="], "read-pkg/normalize-package-data/hosted-git-info/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], diff --git a/package.json b/package.json index aabaf66..0013e7b 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,65 @@ "version": "0.1.0", "type": "module", "scripts": { - "build": "cargo build", - "test": "cargo test" + "dev": "vite", + "dev:tauri": "node dev-tauri.mjs", + "build": "tsc -b && vite build", + "preview": "vite preview", + "test": "vitest run", + "e2e": "wdio run e2e/wdio.conf.ts", + "e2e:smoke": "wdio run e2e/wdio.conf.ts --spec e2e/specs/smoke.e2e.ts", + "e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit", + "typecheck": "tsc --noEmit", + "tauri": "tauri" }, - "dependencies": {}, - "devDependencies": {} + "dependencies": { + "@fontsource-variable/inter": "^5.2.8", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-tooltip": "^1.2.8", + "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "2.6.0", + "@tauri-apps/plugin-notification": "^2.3.3", + "@xterm/addon-fit": "^0.11.0", + "@xterm/xterm": "^6.0.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "lucide-react": "^1.7.0", + "react": "^19", + "react-dom": "^19", + "react-markdown": "^10.1.0", + "react-router": "^7", + "remark-gfm": "^4.0.1", + "tailwind-merge": "^3.5.0", + "tw-animate-css": "^1.4.0", + "zustand": "^5" + }, + "devDependencies": { + "@tailwindcss/vite": "^4", + "@testing-library/jest-dom": "^6.8.0", + "@testing-library/react": "^16.3.0", + "@testing-library/user-event": "^14.6.1", + "@tauri-apps/cli": "^2", + "@types/mocha": "^10.0.10", + "@types/node": "^25.6.0", + "@types/react": "^19", + "@types/react-dom": "^19", + "@vitejs/plugin-react": "^4", + "@wdio/cli": "^9.27.0", + "@wdio/globals": "^9.27.0", + "@wdio/local-runner": "^9.27.0", + "@wdio/mocha-framework": "^9.27.0", + "@wdio/spec-reporter": "^9.27.0", + "expect-webdriverio": "^5.6.5", + "jsdom": "^26.1.0", + "shadcn": "^4.1.2", + "tailwindcss": "^4", + "typescript": "^5", + "vite": "^6", + "vitest": "^3.2.4" + } } diff --git a/src-tauri/src/agent_runtime_env.rs b/src-tauri/src/agent_runtime_env.rs index bab8fa0..7277838 100644 --- a/src-tauri/src/agent_runtime_env.rs +++ b/src-tauri/src/agent_runtime_env.rs @@ -2,6 +2,13 @@ use std::collections::HashSet; use std::path::PathBuf; use std::process::Command; +pub fn ensure_full_path_env() { + let full_path = probe_path_env(); + if !full_path.is_empty() { + std::env::set_var("PATH", &full_path); + } +} + pub fn run_version_probe(binary_path: &str, path_env: &str, version_flag: &str) -> Option { let output = Command::new(binary_path) .arg(version_flag) diff --git a/src-tauri/src/atomizer/activity.rs b/src-tauri/src/atomizer/activity.rs new file mode 100644 index 0000000..9d1f8ee --- /dev/null +++ b/src-tauri/src/atomizer/activity.rs @@ -0,0 +1,55 @@ +use crate::atomizer::types::{AtomizeActivity, AtomizeActivityKind}; +use crate::events::EVENT_ATOMIZATION_ACTIVITY; +use std::collections::{HashMap, VecDeque}; +use std::sync::{Arc, Mutex}; +use tauri::{AppHandle, Emitter, Manager, Runtime}; + +const MAX_LOG_ENTRIES: usize = 300; + +#[derive(Debug, Default)] +pub struct ActivityLog { + entries: HashMap>, +} + +impl ActivityLog { + fn push(&mut self, entry: AtomizeActivity) { + let ring = self + .entries + .entry(entry.project_id.clone()) + .or_insert_with(|| VecDeque::with_capacity(MAX_LOG_ENTRIES)); + if ring.len() >= MAX_LOG_ENTRIES { + ring.pop_front(); + } + ring.push_back(entry); + } + + pub fn get(&self, project_id: &str) -> Vec { + self.entries + .get(project_id) + .map(|ring| ring.iter().cloned().collect()) + .unwrap_or_default() + } +} + +#[derive(Debug, Clone, Default)] +pub struct ActivityLogState(pub Arc>); + +pub(super) fn emit_activity( + app: &AppHandle, + project_id: &str, + kind: AtomizeActivityKind, + content: &str, +) { + let entry = AtomizeActivity { + project_id: project_id.to_string(), + kind, + content: content.to_string(), + timestamp: chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true), + }; + + if let Ok(mut log) = app.state::().0.lock() { + log.push(entry.clone()); + } + + let _ = app.emit(EVENT_ATOMIZATION_ACTIVITY, entry); +} diff --git a/src-tauri/src/atomizer/mod.rs b/src-tauri/src/atomizer/mod.rs index f49f211..97ead10 100644 --- a/src-tauri/src/atomizer/mod.rs +++ b/src-tauri/src/atomizer/mod.rs @@ -1,3 +1,4 @@ +mod activity; mod agent_args; mod agent_invoke; mod artifacts; @@ -11,8 +12,13 @@ mod stages; mod templates; mod types; +pub use activity::ActivityLogState; +pub use progress::{get_pipeline_snapshot, PipelineRegistryState}; pub use run::run_atomizer; -pub use types::{AtomizeArgs, AtomizeProgress, AtomizerError}; +pub use types::{ + AtomizeActivity, AtomizeActivityKind, AtomizeArgs, AtomizeProgress, AtomizerError, + PipelineSnapshot, +}; #[cfg(test)] mod tests_json; diff --git a/src-tauri/src/atomizer/progress.rs b/src-tauri/src/atomizer/progress.rs index ef8d7d3..ab3d770 100644 --- a/src-tauri/src/atomizer/progress.rs +++ b/src-tauri/src/atomizer/progress.rs @@ -1,5 +1,77 @@ +use crate::atomizer::types::{PipelineSnapshot, StageStatus}; use crate::atomizer::AtomizeProgress; -use tauri::{AppHandle, Emitter, Runtime}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::Instant; +use tauri::{AppHandle, Emitter, Manager, Runtime}; + +#[derive(Debug)] +struct PipelineEntry { + snapshot: PipelineSnapshot, + started_instant: Instant, +} + +#[derive(Debug, Default)] +pub struct PipelineRegistry { + entries: HashMap, +} + +#[derive(Debug, Clone, Default)] +pub struct PipelineRegistryState(pub Arc>); + +pub(super) fn mark_pipeline_start(app: &AppHandle, project_id: &str) { + let now_rfc = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true); + let mut snapshot = PipelineSnapshot::initial(); + snapshot.started_at = Some(now_rfc); + if let Ok(mut registry) = app.state::().0.lock() { + registry.entries.insert( + project_id.to_string(), + PipelineEntry { snapshot, started_instant: Instant::now() }, + ); + } +} + +pub(super) fn mark_pipeline_error(app: &AppHandle, project_id: &str, error: &str) { + if let Ok(mut registry) = app.state::().0.lock() { + if let Some(entry) = registry.entries.get_mut(project_id) { + entry.snapshot.error = Some(error.to_string()); + for stage in &mut entry.snapshot.stages { + if stage.status == StageStatus::Running { + stage.status = StageStatus::Error; + } + } + entry.snapshot.elapsed_ms = entry.started_instant.elapsed().as_millis() as u64; + } + } +} + +pub(super) fn mark_pipeline_done(app: &AppHandle, project_id: &str) { + if let Ok(mut registry) = app.state::().0.lock() { + if let Some(entry) = registry.entries.get_mut(project_id) { + entry.snapshot.done = true; + entry.snapshot.elapsed_ms = entry.started_instant.elapsed().as_millis() as u64; + } + } +} + +pub fn get_pipeline_snapshot( + app: &AppHandle, + project_id: &str, +) -> Option { + app.state::() + .0 + .lock() + .ok() + .and_then(|registry| { + registry.entries.get(project_id).map(|entry| { + let mut snap = entry.snapshot.clone(); + if !snap.done && snap.error.is_none() { + snap.elapsed_ms = entry.started_instant.elapsed().as_millis() as u64; + } + snap + }) + }) +} pub(super) fn emit_progress( app: &AppHandle, @@ -8,6 +80,27 @@ pub(super) fn emit_progress( stage_name: &str, message: &str, ) { + if let Ok(mut registry) = app.state::().0.lock() { + if let Some(entry) = registry.entries.get_mut(project_id) { + for snap_stage in &mut entry.snapshot.stages { + if snap_stage.number == stage { + snap_stage.status = StageStatus::Running; + } else if snap_stage.number < stage { + snap_stage.status = StageStatus::Done; + } + } + entry.snapshot.elapsed_ms = entry.started_instant.elapsed().as_millis() as u64; + } + } + + let elapsed_ms = app + .state::() + .0 + .lock() + .ok() + .and_then(|reg| reg.entries.get(project_id).map(|ent| ent.snapshot.elapsed_ms)) + .unwrap_or(0); + let _ = app.emit( "atomization-progress", AtomizeProgress { @@ -15,6 +108,7 @@ pub(super) fn emit_progress( stage_name: stage_name.to_string(), message: message.to_string(), project_id: project_id.to_string(), + elapsed_ms, }, ); } diff --git a/src-tauri/src/atomizer/run.rs b/src-tauri/src/atomizer/run.rs index 2957a38..e045584 100644 --- a/src-tauri/src/atomizer/run.rs +++ b/src-tauri/src/atomizer/run.rs @@ -1,9 +1,13 @@ +use crate::atomizer::activity::emit_activity; use crate::atomizer::artifacts::save_artifacts; use crate::atomizer::io::{artifact_dir, load_plan_content}; -use crate::atomizer::progress::emit_progress; +use crate::atomizer::progress::{ + emit_progress, mark_pipeline_done, mark_pipeline_error, mark_pipeline_start, +}; use crate::atomizer::sanitize::sanitize_codex_plan_content; use crate::atomizer::stages::{stage_atomize, stage_chunk, stage_merge, stage_summarize}; use crate::atomizer::templates::load_templates; +use crate::atomizer::types::AtomizeActivityKind; use crate::atomizer::{AtomizeArgs, AtomizerError}; use ralph_core::prd::Prd; use tauri::{AppHandle, Runtime}; @@ -11,6 +15,26 @@ use tauri::{AppHandle, Runtime}; pub async fn run_atomizer( app: AppHandle, args: AtomizeArgs, +) -> Result { + let pid = args.project_id.clone(); + mark_pipeline_start(&app, &pid); + + let result = execute_pipeline(&app, &args).await; + + match &result { + Ok(prd) => { + mark_pipeline_done(&app, &pid); + emit_progress(&app, &pid, 4, "merge", &format!("Done — {} stories", prd.stories.len())); + } + Err(err) => mark_pipeline_error(&app, &pid, &err.to_string()), + } + + result +} + +async fn execute_pipeline( + app: &AppHandle, + args: &AtomizeArgs, ) -> Result { if !args.project_dir.exists() { return Err(AtomizerError::Path(format!( @@ -26,7 +50,7 @@ pub async fn run_atomizer( } let env = load_templates()?; - let artifact_path = artifact_dir(&app, &args.project_id)?; + let artifact_path = artifact_dir(app, &args.project_id)?; std::fs::create_dir_all(&artifact_path)?; let plan_content = @@ -39,77 +63,55 @@ pub async fn run_atomizer( } let pid = &args.project_id; + emit_activity(app, pid, AtomizeActivityKind::PlanLoaded, &format!("Plan loaded ({} chars)", plan_content.len())); - emit_progress(&app, pid, 1, "summarize", "Summarizing plan..."); + emit_progress(app, pid, 1, "summarize", "Summarizing plan..."); + emit_activity(app, pid, AtomizeActivityKind::TemplateRender, "Rendering summarize template"); let condensed = stage_summarize( - &app, - pid, - &env, - &plan_content, - &args.agent, - args.model.as_deref(), - args.effort.as_deref(), - &args.project_dir, + app, pid, &env, &plan_content, &args.agent, + args.model.as_deref(), args.effort.as_deref(), &args.project_dir, ) .await?; - emit_progress(&app, pid, 2, "chunk", "Splitting into sections..."); + emit_activity(app, pid, AtomizeActivityKind::AgentComplete, &format!("Summarize complete ({} chars condensed)", condensed.len())); + + emit_progress(app, pid, 2, "chunk", "Splitting into sections..."); + emit_activity(app, pid, AtomizeActivityKind::TemplateRender, "Rendering chunk template"); let sections = stage_chunk( - &app, - pid, - &env, - &condensed, - &args.agent, - args.model.as_deref(), - args.effort.as_deref(), - &args.project_dir, + app, pid, &env, &condensed, &args.agent, + args.model.as_deref(), args.effort.as_deref(), &args.project_dir, ) .await?; - emit_progress( - &app, - pid, - 3, - "atomize", - &format!("Atomizing {} sections...", sections.len()), - ); + let section_count = sections.len(); + for (idx, section) in sections.iter().enumerate() { + emit_activity(app, pid, AtomizeActivityKind::ChunkDetected, &format!("[{}/{}] {}", idx + 1, section_count, section.title)); + } + + emit_progress(app, pid, 3, "atomize", &format!("Atomizing {section_count} sections...")); let all_stories = stage_atomize( - &app, - pid, - &env, - §ions, - &args.project_name, - &args.agent, - args.model.as_deref(), - args.effort.as_deref(), - &args.project_dir, + app, pid, &env, §ions, &args.project_name, &args.agent, + args.model.as_deref(), args.effort.as_deref(), &args.project_dir, ) .await?; - emit_progress(&app, pid, 4, "merge", "Merging and ordering stories..."); + emit_activity(app, pid, AtomizeActivityKind::StoryExtracted, &format!("{} raw stories across {section_count} sections", all_stories.len())); + + emit_progress(app, pid, 4, "merge", "Merging and ordering stories..."); + emit_activity(app, pid, AtomizeActivityKind::TemplateRender, "Rendering merge template"); let prd = stage_merge( - &app, - pid, - &env, - all_stories, - &args.project_name, - &args.agent, - args.model.as_deref(), - args.effort.as_deref(), - &args.project_dir, + app, pid, &env, all_stories, &args.project_name, &args.agent, + args.model.as_deref(), args.effort.as_deref(), &args.project_dir, ) .await?; + emit_activity(app, pid, AtomizeActivityKind::Validation, &format!("Validating atomicity of {} stories", prd.stories.len())); prd.validate_atomicity() .map_err(|err| AtomizerError::Validation(err.to_string()))?; + emit_activity(app, pid, AtomizeActivityKind::Validation, "Validation passed"); + save_artifacts(&artifact_path, &prd)?; + emit_activity(app, pid, AtomizeActivityKind::ArtifactSaved, "Artifacts saved (prd.json, prompt.md, guardrails.md)"); - emit_progress( - &app, - pid, - 4, - "merge", - &format!("Done — {} stories", prd.stories.len()), - ); Ok(prd) } diff --git a/src-tauri/src/atomizer/stages.rs b/src-tauri/src/atomizer/stages.rs index 6ec0926..69bef99 100644 --- a/src-tauri/src/atomizer/stages.rs +++ b/src-tauri/src/atomizer/stages.rs @@ -1,8 +1,9 @@ +use crate::atomizer::activity::emit_activity; use crate::atomizer::agent_invoke::{invoke_agent, invoke_agent_with_heartbeat}; use crate::atomizer::chunking::chunk_large_plan; use crate::atomizer::json_parse::parse_json_from_candidates; use crate::atomizer::progress::emit_progress; -use crate::atomizer::types::{AtomizedStoryDraft, AtomizerError, ChunkSection}; +use crate::atomizer::types::{AtomizeActivityKind, AtomizedStoryDraft, AtomizerError, ChunkSection}; use minijinja::{context, Environment}; use ralph_core::prd::Prd; use std::path::Path; @@ -19,6 +20,10 @@ pub(super) async fn stage_summarize( project_dir: &Path, ) -> Result { let plan_chunks = chunk_large_plan(plan_content); + if plan_chunks.len() > 1 { + let count = plan_chunks.len(); + emit_activity(app, project_id, AtomizeActivityKind::PlanLoaded, &format!("Large plan split into {count} chunks")); + } let mut condensed_parts = Vec::with_capacity(plan_chunks.len()); for chunk in plan_chunks { @@ -28,10 +33,12 @@ pub(super) async fn stage_summarize( let prompt = tmpl .render(context! { plan_content => chunk }) .map_err(|err| AtomizerError::Template(err.to_string()))?; + emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for summarization")); let hb = Some((project_id.to_string(), 1, "summarize".to_string())); let result = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb) .await?; + emit_activity(app, project_id, AtomizeActivityKind::AgentComplete, &format!("Summary chunk: {} chars", result.len())); condensed_parts.push(result); } @@ -55,9 +62,10 @@ pub(super) async fn stage_chunk( .render(context! { condensed_plan => condensed_plan }) .map_err(|err| AtomizerError::Template(err.to_string()))?; + emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for chunking")); let hb = Some((project_id.to_string(), 2, "chunk".to_string())); - let raw = - invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; + let raw = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; + emit_activity(app, project_id, AtomizeActivityKind::AgentComplete, "Chunk response received, parsing JSON"); parse_json_from_candidates::>(&raw, '[').map_err(|(err, candidate)| { AtomizerError::JsonParse { stage: "chunk", @@ -66,16 +74,14 @@ pub(super) async fn stage_chunk( }) } -fn build_json_retry_prompt(section_title: &str, section_content: &str, failed_raw: &str) -> String { +fn build_json_retry_prompt(title: &str, content: &str, failed: &str) -> String { + let snippet = &failed[..failed.len().min(300)]; format!( - "Your previous response was not valid JSON. You must return ONLY a JSON array of story objects.\n\n\ - Section: {section_title}\n\n\ - Previous (invalid) response (first 300 chars):\n{snippet}\n\n\ - Rewrite your response as a valid JSON array based on this section content:\n\n\ - {section_content}\n\n\ - CRITICAL: Output ONLY the JSON array. First character must be [, last character must be ].\n\ - No prose, no requests, no markdown fences.", - snippet = &failed_raw[..failed_raw.len().min(300)] + "Your previous response was not valid JSON. Return ONLY a JSON array of story objects.\n\n\ + Section: {title}\n\nPrevious (invalid) response (first 300 chars):\n{snippet}\n\n\ + Rewrite as a valid JSON array based on this section content:\n\n{content}\n\n\ + CRITICAL: Output ONLY the JSON array. First character must be [, last must be ].\n\ + No prose, no requests, no markdown fences." ) } @@ -91,8 +97,10 @@ pub(super) async fn stage_atomize( project_dir: &Path, ) -> Result, AtomizerError> { let mut all_stories: Vec = Vec::new(); - - for section in sections { + let total = sections.len(); + for (idx, section) in sections.iter().enumerate() { + let section_label = format!("[{}/{}] '{}'", idx + 1, total, section.title); + emit_activity(app, project_id, AtomizeActivityKind::SectionProcess, &format!("Processing {section_label}")); let tmpl = env .get_template("stories") .map_err(|err| AtomizerError::Template(err.to_string()))?; @@ -104,6 +112,7 @@ pub(super) async fn stage_atomize( }) .map_err(|err| AtomizerError::Template(err.to_string()))?; + emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for {section_label}")); let hb = Some((project_id.to_string(), 3, "atomize".to_string())); let raw = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb) .await?; @@ -111,6 +120,7 @@ pub(super) async fn stage_atomize( let stories: Vec = match parse_json_from_candidates(&raw, '[') { Ok(stories) => stories, Err((_first_err, first_candidate)) => { + emit_activity(app, project_id, AtomizeActivityKind::Retry, &format!("JSON parse failed for {section_label}")); emit_progress( app, project_id, @@ -140,6 +150,8 @@ pub(super) async fn stage_atomize( } }; + let extracted = stories.len(); + emit_activity(app, project_id, AtomizeActivityKind::StoryExtracted, &format!("{extracted} stories from {section_label}")); all_stories.extend(stories); } @@ -175,9 +187,10 @@ pub(super) async fn stage_merge( }) .map_err(|err| AtomizerError::Template(err.to_string()))?; + emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for merge")); let hb = Some((project_id.to_string(), 4, "merge".to_string())); - let raw = - invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; + let raw = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; + emit_activity(app, project_id, AtomizeActivityKind::AgentComplete, "Merge received, parsing PRD"); parse_json_from_candidates::(&raw, '{').map_err(|(err, candidate)| { AtomizerError::JsonParse { stage: "merge", diff --git a/src-tauri/src/atomizer/types.rs b/src-tauri/src/atomizer/types.rs index a404735..dbebb2d 100644 --- a/src-tauri/src/atomizer/types.rs +++ b/src-tauri/src/atomizer/types.rs @@ -48,6 +48,80 @@ pub struct AtomizeProgress { pub stage_name: String, pub message: String, pub project_id: String, + #[serde(default)] + pub elapsed_ms: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum AtomizeActivityKind { + PlanLoaded, + TemplateRender, + AgentStart, + AgentComplete, + ChunkDetected, + SectionProcess, + StoryExtracted, + Retry, + Validation, + ArtifactSaved, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AtomizeActivity { + pub project_id: String, + pub kind: AtomizeActivityKind, + pub content: String, + pub timestamp: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub enum StageStatus { + Pending, + Running, + Done, + Error, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StageSnapshot { + pub number: u8, + pub label: String, + pub status: StageStatus, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PipelineSnapshot { + pub stages: Vec, + pub error: Option, + pub started_at: Option, + pub elapsed_ms: u64, + pub done: bool, +} + +impl PipelineSnapshot { + pub fn initial() -> Self { + let labels = ["Summarize", "Chunk", "Atomize", "Merge"]; + Self { + stages: labels + .iter() + .enumerate() + .map(|(idx, label)| StageSnapshot { + number: (idx + 1) as u8, + label: label.to_string(), + status: StageStatus::Pending, + }) + .collect(), + error: None, + started_at: None, + elapsed_ms: 0, + done: false, + } + } } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/commands/atomization.rs b/src-tauri/src/commands/atomization.rs index 194515f..9984d14 100644 --- a/src-tauri/src/commands/atomization.rs +++ b/src-tauri/src/commands/atomization.rs @@ -1,11 +1,15 @@ +use crate::atomizer::{ + get_pipeline_snapshot, ActivityLogState, AtomizeActivity, PipelineRegistryState, + PipelineSnapshot, +}; +use tauri::{AppHandle, State}; + #[cfg(not(test))] use crate::atomizer::{AtomizeArgs, AtomizerError}; #[cfg(not(test))] use crate::commands::validation::{optional_trimmed, required_trimmed}; #[cfg(not(test))] use ralph_core::prd::Prd; -#[cfg(not(test))] -use tauri::AppHandle; #[cfg(not(test))] #[tauri::command] @@ -21,3 +25,24 @@ pub async fn run_atomizer(app: AppHandle, args: AtomizeArgs) -> Result, + project_id: String, +) -> Vec { + state + .0 + .lock() + .map(|log| log.get(&project_id)) + .unwrap_or_default() +} + +#[tauri::command] +pub fn get_atomizer_pipeline_state( + app: AppHandle, + _registry: State<'_, PipelineRegistryState>, + project_id: String, +) -> Option { + get_pipeline_snapshot(&app, &project_id) +} diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index c55028d..39f7a5d 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -21,6 +21,8 @@ pub const EVENT_PLAN_COMPLETE: &str = "plan:complete"; pub const EVENT_PLAN_ERROR: &str = "plan:error"; pub const EVENT_PLAN_HEARTBEAT: &str = "plan:heartbeat"; +pub const EVENT_ATOMIZATION_ACTIVITY: &str = "atomization:activity"; + #[allow(dead_code)] pub const LOOP_EVENT_CATALOG: [&str; 13] = [ EVENT_AGENT_OUTPUT_STREAM, diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index 21aeec3..c498269 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -7,7 +7,7 @@ mod app_core_projects; #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::query_plan_status, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::query_plan_status, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::atomization::get_atomizer_activity_log, commands::atomization::get_atomizer_pipeline_state, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { "start_plan" => commands::planning::__cmd__start_plan!(plan_commands::start_plan, invoke), "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), @@ -26,7 +26,7 @@ pub fn attach_app(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, crate::invoke_contract::save_plan, commands::projects_artifacts::save_prd, crate::invoke_contract::save_config, commands::projects_artifacts::load_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::create_project, crate::invoke_contract::finalize_draft, commands::projects_wizard::discard_draft, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, commands::projects_lifecycle::list_projects, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, crate::invoke_contract::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, crate::invoke_contract::save_plan, commands::projects_artifacts::save_prd, crate::invoke_contract::save_config, commands::projects_artifacts::load_config, crate::invoke_contract::run_atomizer, commands::atomization::get_atomizer_activity_log, commands::atomization::get_atomizer_pipeline_state, crate::invoke_contract::create_project, crate::invoke_contract::finalize_draft, commands::projects_wizard::discard_draft, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, commands::projects_lifecycle::list_projects, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, crate::invoke_contract::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { "start_plan" => crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke), "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), @@ -48,7 +48,7 @@ pub fn attach_contract(builder: tauri::Builder) -> tauri:: } }) } -#[cfg(not(test))] mod atomizer_commands { use super::app_core_atomizer; use crate::atomizer::{AtomizeArgs, AtomizeProgress, AtomizerError}; use ralph_core::prd::Prd; use tauri::{AppHandle, Emitter}; pub async fn run_atomizer(app: AppHandle, args: AtomizeArgs) -> Result { let normalized_args = AtomizeArgs { project_id: required(args.project_id, "project_id").map_err(AtomizerError::Path)?, project_name: required(args.project_name, "project_name").map_err(AtomizerError::Path)?, project_dir: args.project_dir, agent: required(args.agent, "agent").map_err(AtomizerError::Path)?, model: optional(args.model), effort: optional(args.effort) }; let run_result = app_core_atomizer::run_atomizer(app_core_atomizer::AtomizerRequest { project_id: normalized_args.project_id.clone() }, |_| crate::atomizer::run_atomizer(app.clone(), normalized_args), |prd| prd.stories.len()).await?; for event in &run_result.events { let payload = event.as_progress_payload(); let _ = app.emit("atomization-progress", AtomizeProgress { stage: payload.stage, stage_name: payload.stage_name, message: payload.message, project_id: payload.project_id }); } Ok(run_result.output) } fn required(value: String, name: &str) -> Result { let trimmed = value.trim().to_string(); if trimmed.is_empty() { return Err(format!("{name} is required")); } Ok(trimmed) } fn optional(value: Option) -> Option { value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) } } +#[cfg(not(test))] mod atomizer_commands { use super::app_core_atomizer; use crate::atomizer::{AtomizeArgs, AtomizeProgress, AtomizerError}; use ralph_core::prd::Prd; use tauri::{AppHandle, Emitter}; pub async fn run_atomizer(app: AppHandle, args: AtomizeArgs) -> Result { let normalized_args = AtomizeArgs { project_id: required(args.project_id, "project_id").map_err(AtomizerError::Path)?, project_name: required(args.project_name, "project_name").map_err(AtomizerError::Path)?, project_dir: args.project_dir, agent: required(args.agent, "agent").map_err(AtomizerError::Path)?, model: optional(args.model), effort: optional(args.effort) }; let run_result = app_core_atomizer::run_atomizer(app_core_atomizer::AtomizerRequest { project_id: normalized_args.project_id.clone() }, |_| crate::atomizer::run_atomizer(app.clone(), normalized_args), |prd| prd.stories.len()).await?; for event in &run_result.events { let payload = event.as_progress_payload(); let _ = app.emit("atomization-progress", AtomizeProgress { stage: payload.stage, stage_name: payload.stage_name, message: payload.message, project_id: payload.project_id, elapsed_ms: 0 }); } Ok(run_result.output) } fn required(value: String, name: &str) -> Result { let trimmed = value.trim().to_string(); if trimmed.is_empty() { return Err(format!("{name} is required")); } Ok(trimmed) } fn optional(value: Option) -> Option { value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) } } mod plan_commands { use crate::app_core_plan; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index adc6a76..6a73880 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -98,6 +98,8 @@ pub fn run() { .manage(plan_engine::PlanSessionsState::default()) .manage(loop_manager::LoopManagerState::default()) .manage(ask_engine::AskSessionsState::default()) + .manage(atomizer::ActivityLogState::default()) + .manage(atomizer::PipelineRegistryState::default()) .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { let plan_state = window.state::(); diff --git a/src-tauri/src/projects/artifacts.rs b/src-tauri/src/projects/artifacts.rs index f74bb57..06cc175 100644 --- a/src-tauri/src/projects/artifacts.rs +++ b/src-tauri/src/projects/artifacts.rs @@ -1,9 +1,9 @@ use crate::db::DbState; use crate::projects::ProjectError; use std::path::{Path, PathBuf}; -use tauri::{AppHandle, State}; +use tauri::{AppHandle, Runtime, State}; -pub fn artifact_dir(app: &AppHandle, project_id: &str) -> Result { +pub fn artifact_dir(app: &AppHandle, project_id: &str) -> Result { crate::storage::artifacts::project_artifact_dir(app, project_id).map_err(ProjectError::Path) } diff --git a/src-tauri/src/projects/runtime_config.rs b/src-tauri/src/projects/runtime_config.rs index 7ab7725..389a49c 100644 --- a/src-tauri/src/projects/runtime_config.rs +++ b/src-tauri/src/projects/runtime_config.rs @@ -1,6 +1,6 @@ use crate::projects::artifacts::artifact_dir; use crate::projects::{ProjectConfig, ProjectError}; -use tauri::AppHandle; +use tauri::{AppHandle, Runtime}; fn sanitize_config(mut config: ProjectConfig) -> ProjectConfig { let default_config = ProjectConfig::default(); @@ -116,8 +116,8 @@ fn legacy_config_from_loop_args(content: &str) -> Result( + app: &AppHandle, project_id: &str, config: &ProjectConfig, ) -> Result<(), ProjectError> { diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index a3428df..ab36af4 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,2 +1,9 @@ pub mod monitor_adapter; pub mod session_adapter; + +pub fn attach_runtime_aliases( + builder: tauri::Builder, + handler: impl Fn(tauri::ipc::Invoke) -> bool + Send + Sync + 'static, +) -> tauri::Builder { + builder.invoke_handler(handler) +} diff --git a/src-tauri/src/storage/artifacts.rs b/src-tauri/src/storage/artifacts.rs index 6e52bfe..88eaaac 100644 --- a/src-tauri/src/storage/artifacts.rs +++ b/src-tauri/src/storage/artifacts.rs @@ -1,7 +1,7 @@ use std::path::{Path, PathBuf}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Manager, Runtime}; -pub fn project_artifact_dir(app: &AppHandle, project_id: &str) -> Result { +pub fn project_artifact_dir(app: &AppHandle, project_id: &str) -> Result { app.path() .app_data_dir() .map(|data_dir| data_dir.join("projects").join(project_id)) diff --git a/src/features/wizard/Atomize.tsx b/src/features/wizard/Atomize.tsx index e64516c..efdfbe3 100644 --- a/src/features/wizard/Atomize.tsx +++ b/src/features/wizard/Atomize.tsx @@ -6,10 +6,13 @@ import { Button } from "../../components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../../components/ui/card"; import { Progress } from "../../components/ui/progress"; import { ScrollArea, ScrollContent, ScrollViewport } from "../../components/ui/scroll-area"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/tabs"; import { useWizardStore, type UserStory } from "../../stores/wizardStore"; import { useAtomizerPipeline, STAGE_BADGE, STAGE_LABEL } from "../../hooks/useAtomizerPipeline"; +import { useAtomizerActivity } from "../../hooks/useAtomizerActivity"; import { buildDraftPayload } from "../../lib/draft-payload"; import { AtomizeConfirmationDialog } from "./components/AtomizeConfirmationDialog"; +import { AtomizeStreamPanel } from "./components/AtomizeStreamPanel"; import { AtomizeStoryList } from "./components/AtomizeStoryList"; function makeBlankStory(existingCount: number): UserStory { @@ -46,6 +49,7 @@ export function Atomize() { const [storyToRemove, setStoryToRemove] = useState(null); const pipeline = useAtomizerPipeline(id); + const activityEvents = useAtomizerActivity(id); const totalMinutes = stories.reduce((sum, story) => sum + story.estimatedMinutes, 0); const totalHours = (totalMinutes / 60).toFixed(1); const addStoryDisabled = !pipeline.atomizeStarted || pipeline.isRunning; @@ -94,14 +98,51 @@ export function Atomize() { return (
- - Queue Sequence - - - {pipeline.stages.map((stage) =>
{stage.label}{STAGE_LABEL[stage.status]}
)}
- {pipeline.stageMessage ?

{pipeline.stageMessage}{pipeline.isRunning ? ` · ${pipeline.formatElapsed(pipeline.elapsedSeconds)}` : ""}

: null} - {pipeline.atomizeError ?

{pipeline.atomizeError}

: null} -
+ + + + + Queue + + Activity{activityEvents.length > 0 ? ` (${activityEvents.length})` : ""} + + + + +
+ + + + + {pipeline.stages.map((stage) => ( +
+ {stage.label} + + {STAGE_LABEL[stage.status]} + +
+ ))} +
+
+
+ {pipeline.stageMessage ? ( +

+ {pipeline.stageMessage} + {pipeline.isRunning ? ` · ${pipeline.formatElapsed(pipeline.elapsedSeconds)}` : ""} +

+ ) : null} + {pipeline.atomizeError ?

{pipeline.atomizeError}

: null} +
+
+ + + +
diff --git a/src/features/wizard/components/AtomizeStreamPanel.tsx b/src/features/wizard/components/AtomizeStreamPanel.tsx new file mode 100644 index 0000000..df7c40d --- /dev/null +++ b/src/features/wizard/components/AtomizeStreamPanel.tsx @@ -0,0 +1,90 @@ +import { memo, useEffect, useMemo, useRef } from "react"; +import { Loader2 } from "lucide-react"; +import { Badge } from "../../../components/ui/badge"; +import { ScrollArea, ScrollContent, ScrollViewport } from "../../../components/ui/scroll-area"; +import type { AtomizeActivityEvent, AtomizeActivityKind } from "../../../types/wizard"; + +const KIND_CONFIG: Record< + AtomizeActivityKind, + { label: string; variant: "neutral" | "info" | "warning" | "danger" | "success" } +> = { + planLoaded: { label: "LOAD", variant: "info" }, + templateRender: { label: "TMPL", variant: "neutral" }, + agentStart: { label: "CALL", variant: "warning" }, + agentComplete: { label: "RECV", variant: "success" }, + chunkDetected: { label: "SECT", variant: "info" }, + sectionProcess: { label: "PROC", variant: "warning" }, + storyExtracted: { label: "ATOM", variant: "success" }, + retry: { label: "RTRY", variant: "danger" }, + validation: { label: "VALD", variant: "info" }, + artifactSaved: { label: "SAVE", variant: "success" }, +}; + +const MAX_VISIBLE = 200; + +const ActivityRow = memo(function ActivityRow({ event }: { event: AtomizeActivityEvent }) { + const config = KIND_CONFIG[event.kind]; + const displayTime = event.timestamp.slice(11, 23); + return ( +
+ {displayTime} + + {config.label} + + {event.content} +
+ ); +}); + +type AtomizeStreamPanelProps = { + events: AtomizeActivityEvent[]; + isRunning: boolean; + isDone: boolean; + hasError: boolean; +}; + +export function AtomizeStreamPanel({ events, isRunning, isDone, hasError }: AtomizeStreamPanelProps) { + const endRef = useRef(null); + + const cappedEvents = useMemo(() => { + if (events.length <= MAX_VISIBLE) return events; + return events.slice(-MAX_VISIBLE); + }, [events]); + + useEffect(() => { + endRef.current?.scrollIntoView({ behavior: "smooth" }); + }, [cappedEvents.length]); + + return ( +
+ + + + {isRunning && events.length === 0 ? ( +
+ + Starting atomization pipeline... +
+ ) : null} + {cappedEvents.map((event, index) => ( + + ))} + {isDone && !hasError ? ( +
+ DONE + Pipeline complete +
+ ) : null} +
+ + + +
+ ); +} diff --git a/src/hooks/useAtomizerActivity.ts b/src/hooks/useAtomizerActivity.ts new file mode 100644 index 0000000..83397c7 --- /dev/null +++ b/src/hooks/useAtomizerActivity.ts @@ -0,0 +1,43 @@ +import { useEffect, useState } from "react"; +import { + getAtomizerActivityLog, + onAtomizationActivity, + type AtomizeActivityPayload, +} from "../lib/tauri"; +import type { AtomizeActivityEvent } from "../types/wizard"; + +const MAX_ACTIVITY_EVENTS = 300; + +function payloadToEvent(payload: AtomizeActivityPayload): AtomizeActivityEvent { + return { kind: payload.kind, content: payload.content, timestamp: payload.timestamp }; +} + +export function useAtomizerActivity(projectId: string | undefined) { + const [events, setEvents] = useState([]); + + useEffect(() => { + if (!projectId) return; + setEvents([]); + let disposed = false; + + getAtomizerActivityLog(projectId).then((backlog) => { + if (disposed) return; + setEvents(backlog.map(payloadToEvent)); + }); + + const unlistenPromise = onAtomizationActivity((payload: AtomizeActivityPayload) => { + if (disposed || payload.projectId !== projectId) return; + setEvents((previous) => { + const next = [...previous, payloadToEvent(payload)]; + return next.length > MAX_ACTIVITY_EVENTS ? next.slice(-MAX_ACTIVITY_EVENTS) : next; + }); + }); + + return () => { + disposed = true; + unlistenPromise.then((unlisten) => unlisten()); + }; + }, [projectId]); + + return events; +} diff --git a/src/hooks/useAtomizerPipeline.ts b/src/hooks/useAtomizerPipeline.ts index 4c695f3..11079ff 100644 --- a/src/hooks/useAtomizerPipeline.ts +++ b/src/hooks/useAtomizerPipeline.ts @@ -1,8 +1,14 @@ import { useEffect, useRef, useState } from "react"; -import { onAtomizationProgress, runAtomizer, type AtomizeProgress, type Prd } from "../lib/tauri"; +import { + getAtomizerPipelineState, + onAtomizationProgress, + runAtomizer, + type AtomizeProgress, + type Prd, + type StageStatus, +} from "../lib/tauri"; import { useWizardStore } from "../stores/wizardStore"; -type StageStatus = "pending" | "running" | "done" | "error"; export type PipelineStage = { number: number; label: string; status: StageStatus }; const INITIAL_STAGES: PipelineStage[] = [ @@ -30,11 +36,11 @@ const atomizerPromiseByProject = new Map>(); export function useAtomizerPipeline(projectId: string | undefined) { const startedRef = useRef(false); - const [stages, setStages] = useState(INITIAL_STAGES); + const [stages, setStages] = useState(INITIAL_STAGES); const [atomizeStarted, setAtomizeStarted] = useState(false); const [atomizeError, setAtomizeError] = useState(null); const [stageMessage, setStageMessage] = useState(""); - const [elapsedSeconds, setElapsedSeconds] = useState(0); + const [elapsedMs, setElapsedMs] = useState(0); useEffect(() => { if (!projectId) return; @@ -47,6 +53,19 @@ export function useAtomizerPipeline(projectId: string | undefined) { setStageMessage("Loaded existing stories."); return; } + + getAtomizerPipelineState(projectId).then((snapshot) => { + if (!snapshot) return; + setAtomizeStarted(true); + setStages(snapshot.stages.map((stage) => ({ + number: stage.number, + label: stage.label, + status: stage.status, + }))); + setElapsedMs(snapshot.elapsedMs); + if (snapshot.error) setAtomizeError(snapshot.error); + }); + startedRef.current = true; setAtomizeStarted(true); setStageMessage("Summarizing plan..."); @@ -55,6 +74,7 @@ export function useAtomizerPipeline(projectId: string | undefined) { const unlistenPromise = onAtomizationProgress((progress: AtomizeProgress) => { if (progress.projectId !== projectId) return; setStageMessage(progress.message); + setElapsedMs(progress.elapsedMs); setStages((previous) => previous.map((stage) => stage.number === progress.stage ? { ...stage, status: "running" } @@ -108,12 +128,7 @@ export function useAtomizerPipeline(projectId: string | undefined) { const isDone = stages.every((stage) => stage.status === "done"); const isRunning = atomizeStarted && !isDone && !atomizeError; - useEffect(() => { - if (!isRunning) return; - setElapsedSeconds(0); - const timer = setInterval(() => setElapsedSeconds((prev) => prev + 1), 1000); - return () => clearInterval(timer); - }, [isRunning]); + const elapsedSeconds = Math.floor(elapsedMs / 1000); const formatElapsed = (secs: number) => secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}m ${secs % 60}s`; diff --git a/src/lib/ipc/atomizer.ts b/src/lib/ipc/atomizer.ts index a337238..bb040cf 100644 --- a/src/lib/ipc/atomizer.ts +++ b/src/lib/ipc/atomizer.ts @@ -1,6 +1,12 @@ import { invoke } from "@tauri-apps/api/core"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; -import type { AtomizeArgs, AtomizeProgress, Prd } from "./types"; +import type { + AtomizeActivityPayload, + AtomizeArgs, + AtomizeProgress, + PipelineSnapshot, + Prd, +} from "./types"; export async function runAtomizer(args: AtomizeArgs): Promise { return invoke("run_atomizer", { args }); @@ -10,8 +16,26 @@ export async function loadOutputLog(projectId: string): Promise { return invoke("load_output_log", { projectId }); } +export async function getAtomizerActivityLog( + projectId: string, +): Promise { + return invoke("get_atomizer_activity_log", { projectId }); +} + +export async function getAtomizerPipelineState( + projectId: string, +): Promise { + return invoke("get_atomizer_pipeline_state", { projectId }); +} + export function onAtomizationProgress( callback: (payload: AtomizeProgress) => void, ): Promise { return listen("atomization-progress", (event) => callback(event.payload)); } + +export function onAtomizationActivity( + callback: (payload: AtomizeActivityPayload) => void, +): Promise { + return listen("atomization:activity", (event) => callback(event.payload)); +} diff --git a/src/lib/ipc/types.ts b/src/lib/ipc/types.ts index 57ac36a..71dbee9 100644 --- a/src/lib/ipc/types.ts +++ b/src/lib/ipc/types.ts @@ -1,4 +1,4 @@ -import type { PlanEventKind, UserStory } from "../../types/wizard"; +import type { AtomizeActivityKind, PlanEventKind, UserStory } from "../../types/wizard"; export interface AgentInfo { name: string; @@ -143,6 +143,30 @@ export interface AtomizeProgress { stageName: string; message: string; projectId: string; + elapsedMs: number; +} + +export interface AtomizeActivityPayload { + projectId: string; + kind: AtomizeActivityKind; + content: string; + timestamp: string; +} + +export type StageStatus = "pending" | "running" | "done" | "error"; + +export interface StageSnapshot { + number: number; + label: string; + status: StageStatus; +} + +export interface PipelineSnapshot { + stages: StageSnapshot[]; + error: string | null; + startedAt: string | null; + elapsedMs: number; + done: boolean; } export interface IterationRow { diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index 99aed57..cf67b04 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -4,6 +4,7 @@ export type { SnapshotStatus, ArtifactPaths, SessionInfo, ProgressInfo, ProjectSnapshot, IterationStory, WizardResumeState, Prd, StartLoopArgs, AtomizeArgs, AtomizeProgress, + AtomizeActivityPayload, PipelineSnapshot, StageSnapshot, StageStatus, IterationRow, EphemeralAnswer, PlanActivityPayload, PlanTerminalPayload, PlanActivityBatchPayload, PlanSessionStatus, PlanSessionInfo, @@ -30,7 +31,10 @@ export { onPlanActivityBatch, onPlanComplete, onPlanError, onPlanHeartbeat, } from "./ipc/plan"; -export { runAtomizer, loadOutputLog, onAtomizationProgress } from "./ipc/atomizer"; +export { + runAtomizer, loadOutputLog, onAtomizationProgress, onAtomizationActivity, + getAtomizerActivityLog, getAtomizerPipelineState, +} from "./ipc/atomizer"; export { askQuestion, askHistory, stopAsk, copyAskMessage, diff --git a/src/test/fixtures/atomization.ts b/src/test/fixtures/atomization.ts index b8ebfa1..0d52ebf 100644 --- a/src/test/fixtures/atomization.ts +++ b/src/test/fixtures/atomization.ts @@ -24,6 +24,7 @@ export function createAtomizeProgress( stageName: "Chunk", message: "Chunking plan", projectId: "project-001", + elapsedMs: 0, }, overrides); } diff --git a/src/types/wizard.ts b/src/types/wizard.ts index ace5b5b..874bb62 100644 --- a/src/types/wizard.ts +++ b/src/types/wizard.ts @@ -6,6 +6,24 @@ export type PlanEventKind = | "thinking" | "error"; +export type AtomizeActivityKind = + | "planLoaded" + | "templateRender" + | "agentStart" + | "agentComplete" + | "chunkDetected" + | "sectionProcess" + | "storyExtracted" + | "retry" + | "validation" + | "artifactSaved"; + +export interface AtomizeActivityEvent { + kind: AtomizeActivityKind; + content: string; + timestamp: string; +} + export interface PlanEvent { kind: PlanEventKind; content: string; From 0bfb2792cd71ac81e335dc34b08697324f37536b Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Sun, 12 Apr 2026 07:21:06 -0600 Subject: [PATCH 25/61] refactor(tauri): migrate wizard logic, validation and stories CRUD to backend - Add wizard_logic.rs: advance_wizard_step, save_wizard_draft, mark_wizard_stale, validate_describe_input, validate_project_config, validate_launch_readiness, get_default_config, add/update/remove/reorder/get_stories commands - Add wizard_state.rs: WizardStepState with advance/mark_stale/clear_stale - Add validation.rs: validate_config, validate_describe, validate_launch_readiness with unit tests; ConfigLimits and ConfigDefaultsResponse - Add stories_crud.rs: full CRUD over prd.json (add, update, remove, reorder, get) - Add notification_filter.rs: should_emit_verification_failed, build_notification - Add plan_engine/filters.rs: noise filtering for plan stream output - Add wizard-logic.ts: IPC bindings for all new Tauri commands - Remove draft-payload.ts: draft construction moved to save_wizard_draft (Rust) - Remove plan-stream-filters.ts: filtering moved to plan_engine/filters.rs (Rust) - Remove wizard store mutations for stories: all writes go through backend - Replace hardcoded DEFAULT_CONFIG with PLACEHOLDER_CONFIG; config loaded from get_default_config on Configure mount - Wire all new commands in invoke.rs --- src-tauri/src/commands/mod.rs | 1 + src-tauri/src/commands/planning.rs | 81 +++++- src-tauri/src/commands/wizard_logic.rs | 274 ++++++++++++++++++ src-tauri/src/invoke.rs | 2 +- src-tauri/src/plan_engine/filters.rs | 152 ++++++++++ src-tauri/src/plan_engine/fixture.rs | 3 + src-tauri/src/plan_engine/mod.rs | 1 + src-tauri/src/plan_engine/monitor.rs | 13 +- src-tauri/src/plan_engine/output.rs | 15 +- src-tauri/src/plan_engine/payloads.rs | 2 + src-tauri/src/plan_engine/start.rs | 19 +- src-tauri/src/projects/mod.rs | 6 + src-tauri/src/projects/notification_filter.rs | 46 +++ src-tauri/src/projects/stories_crud.rs | 185 ++++++++++++ src-tauri/src/projects/validation.rs | 235 +++++++++++++++ src-tauri/src/projects/wizard_state.rs | 109 +++++++ src/features/monitor/AskTab.tsx | 30 +- src/features/wizard/Atomize.tsx | 77 ++--- src/features/wizard/Configure.tsx | 70 +++-- src/features/wizard/Describe.tsx | 43 ++- src/features/wizard/Launch.tsx | 19 +- src/features/wizard/Plan.tsx | 9 +- src/features/wizard/WizardLayout.tsx | 5 +- .../wizard/components/PlanStreamPanel.tsx | 1 - src/features/wizard/configureValidation.ts | 36 --- src/hooks/useAtomizerPipeline.ts | 156 +++++----- src/hooks/useNotificationIngestion.ts | 39 ++- src/hooks/usePlanEvents.ts | 76 +---- src/hooks/usePlanOrchestration.ts | 34 +-- src/lib/draft-payload.ts | 39 --- src/lib/ipc/types.ts | 1 + src/lib/ipc/wizard-logic.ts | 95 ++++++ src/lib/plan-stream-filters.ts | 33 --- src/lib/tauri.ts | 13 + src/stores/askStore.ts | 33 +-- src/stores/slices/wizard-config.ts | 26 +- src/stores/slices/wizard-step.ts | 6 +- src/stores/slices/wizard-stories.ts | 23 -- src/stores/wizardStore.ts | 2 +- 39 files changed, 1516 insertions(+), 494 deletions(-) create mode 100644 src-tauri/src/commands/wizard_logic.rs create mode 100644 src-tauri/src/plan_engine/filters.rs create mode 100644 src-tauri/src/projects/notification_filter.rs create mode 100644 src-tauri/src/projects/stories_crud.rs create mode 100644 src-tauri/src/projects/validation.rs create mode 100644 src-tauri/src/projects/wizard_state.rs delete mode 100644 src/lib/draft-payload.ts create mode 100644 src/lib/ipc/wizard-logic.ts delete mode 100644 src/lib/plan-stream-filters.ts diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index b65363a..969e76a 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,3 +8,4 @@ pub mod projects_lifecycle; pub mod projects_listing; pub mod projects_wizard; mod validation; +pub mod wizard_logic; diff --git a/src-tauri/src/commands/planning.rs b/src-tauri/src/commands/planning.rs index ffd5f88..ddf4816 100644 --- a/src-tauri/src/commands/planning.rs +++ b/src-tauri/src/commands/planning.rs @@ -4,7 +4,6 @@ use crate::commands::validation::optional_trimmed; use crate::plan_engine::{PlanEngineError, PlanSessionsState}; #[cfg(not(test))] use crate::plan_engine::{PlanSessionInfo, StartPlanArgs}; -#[cfg(not(test))] use tauri::AppHandle; use tauri::State; @@ -59,3 +58,83 @@ pub async fn query_plan_status( required_trimmed(project_id, "project_id").map_err(PlanEngineError::Path)?; crate::plan_engine::query_plan_status(state, normalized_project_id).await } + +#[cfg(not(test))] +#[tauri::command] +pub async fn replan( + app: AppHandle, + state: State<'_, PlanSessionsState>, + db: State<'_, crate::storage::db::DbState>, + project_id: String, + feedback: String, +) -> Result<(), PlanEngineError> { + let project_id = + required_trimmed(project_id, "project_id").map_err(PlanEngineError::Path)?; + let feedback = required_trimmed(feedback, "feedback").map_err(PlanEngineError::Path)?; + + let (description, working_directory) = { + let conn = db + .0 + .lock() + .map_err(|_| PlanEngineError::LockPoisoned)?; + conn.query_row( + "SELECT description, working_directory FROM projects WHERE id = ?1", + rusqlite::params![project_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .map_err(|_| PlanEngineError::Path(format!("Project not found: {project_id}")))? + }; + + let artifacts = crate::projects::artifacts::artifact_dir(&app, &project_id) + .map_err(|err| PlanEngineError::Path(err.to_string()))?; + let plan_path = artifacts.join("plan.md"); + let existing_plan = if plan_path.exists() { + std::fs::read_to_string(&plan_path).unwrap_or_default() + } else { + String::new() + }; + + let draft_path = artifacts.join("draft.json"); + let (agent, model, effort) = if draft_path.exists() { + let draft_content = std::fs::read_to_string(&draft_path) + .map_err(|err| PlanEngineError::Path(err.to_string()))?; + let draft: serde_json::Value = serde_json::from_str(&draft_content) + .map_err(|err| PlanEngineError::Path(err.to_string()))?; + let describe = &draft["describe"]; + ( + describe["planAgent"] + .as_str() + .unwrap_or("claude") + .to_string(), + describe["planModel"] + .as_str() + .map(String::from), + describe["planEffort"] + .as_str() + .map(String::from), + ) + } else { + ("claude".to_string(), None, None) + }; + + let composed_prompt = format!( + "{description}\n\nPrevious plan:\n{existing_plan}\n\nUser feedback:\n{feedback}" + ); + + { + let mut sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; + if let Some(entry) = sessions.sessions.remove(&project_id) { + let _ = entry.handle.kill(); + } + } + + let replan_args = StartPlanArgs { + project_id: project_id.clone(), + project_dir: std::path::PathBuf::from(&working_directory), + agent, + model: optional_trimmed(model), + effort: optional_trimmed(effort), + initial_prompt: composed_prompt, + }; + crate::plan_engine::start_plan(app, state, replan_args).await +} diff --git a/src-tauri/src/commands/wizard_logic.rs b/src-tauri/src/commands/wizard_logic.rs new file mode 100644 index 0000000..58438c0 --- /dev/null +++ b/src-tauri/src/commands/wizard_logic.rs @@ -0,0 +1,274 @@ +use crate::agents::AgentRegistryState; +use crate::commands::validation::required_trimmed; +use crate::projects::stories_crud::StoriesResponse; +use crate::projects::{ + AdvanceWizardResult, ConfigDefaultsResponse, DescribeInput, LaunchReadiness, ProjectConfig, + ProjectError, ValidationErrors, +}; +use crate::storage::db::DbState; +use ralph_core::prd::Prd; +use serde::Serialize; +use tauri::{AppHandle, State}; + +#[tauri::command] +pub async fn advance_wizard_step( + target_step: u32, +) -> Result { + crate::projects::wizard_state::advance_wizard_step(None, target_step) + .map_err(ProjectError::Path) +} + +#[tauri::command] +pub async fn get_default_config() -> Result { + Ok(crate::projects::validation::get_config_defaults()) +} + +#[tauri::command] +pub async fn validate_project_config( + config_json: String, +) -> Result { + let config: ProjectConfig = serde_json::from_str(&config_json)?; + Ok(crate::projects::validation::validate_config(&config)) +} + +#[tauri::command] +pub async fn validate_describe_input( + input_json: String, + state: State<'_, AgentRegistryState>, +) -> Result { + let input: DescribeInput = serde_json::from_str(&input_json)?; + let available_agents = { + let registry = state + .0 + .lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + registry + .agents + .iter() + .filter(|agent| agent.available) + .map(|agent| agent.name.clone()) + .collect::>() + }; + Ok(crate::projects::validation::validate_describe( + &input, + &available_agents, + )) +} + +#[tauri::command] +pub async fn validate_launch_readiness( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + + let (project_name, working_directory) = { + let conn = db + .0 + .lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.query_row( + "SELECT name, working_directory FROM projects WHERE id = ?1", + rusqlite::params![project_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .map_err(|_| ProjectError::NotFound(project_id.clone()))? + }; + + let dir = crate::projects::artifacts::artifact_dir(&app, &project_id)?; + let prd_path = dir.join("prd.json"); + let stories_count = if prd_path.exists() { + Prd::load(&prd_path) + .map(|prd| prd.stories.len()) + .unwrap_or(0) + } else { + 0 + }; + + let config_path = dir.join("config.json"); + let execute_agent = if config_path.exists() { + let content = std::fs::read_to_string(&config_path)?; + serde_json::from_str::(&content) + .map(|cfg| cfg.execute_agent) + .unwrap_or_default() + } else { + String::new() + }; + + Ok(crate::projects::validation::validate_launch_readiness( + &project_name, + &working_directory, + stories_count, + &execute_agent, + )) +} + +#[tauri::command] +pub async fn add_story( + app: AppHandle, + project_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + crate::projects::stories_crud::add_story(&app, &project_id) +} + +#[tauri::command] +pub async fn update_story( + app: AppHandle, + project_id: String, + story_id: String, + patch_json: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + let story_id = required_trimmed(story_id, "story_id").map_err(ProjectError::Path)?; + crate::projects::stories_crud::update_story(&app, &project_id, &story_id, &patch_json) +} + +#[tauri::command] +pub async fn remove_story( + app: AppHandle, + project_id: String, + story_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + let story_id = required_trimmed(story_id, "story_id").map_err(ProjectError::Path)?; + crate::projects::stories_crud::remove_story(&app, &project_id, &story_id) +} + +#[tauri::command] +pub async fn reorder_stories( + app: AppHandle, + project_id: String, + from_index: usize, + to_index: usize, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + crate::projects::stories_crud::reorder_stories(&app, &project_id, from_index, to_index) +} + +#[tauri::command] +pub async fn get_stories( + app: AppHandle, + project_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + crate::projects::stories_crud::get_stories(&app, &project_id) +} + +#[tauri::command] +pub async fn save_wizard_draft( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, + step_name: String, +) -> Result<(), ProjectError> { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + let step_name = required_trimmed(step_name, "step_name").map_err(ProjectError::Path)?; + + let (name, description, working_directory) = { + let conn = db + .0 + .lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.query_row( + "SELECT name, description, working_directory FROM projects WHERE id = ?1", + rusqlite::params![project_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .map_err(|_| ProjectError::NotFound(project_id.clone()))? + }; + + let dir = crate::projects::artifacts::artifact_dir(&app, &project_id)?; + let plan_path = dir.join("plan.md"); + let plan_completed = plan_path.exists() + && std::fs::read_to_string(&plan_path) + .map(|content| !content.trim().is_empty()) + .unwrap_or(false); + + let prd_path = dir.join("prd.json"); + let stories_count = if prd_path.exists() { + Prd::load(&prd_path) + .map(|prd| prd.stories.len()) + .unwrap_or(0) + } else { + 0 + }; + + let config_path = dir.join("config.json"); + let config: Option = if config_path.exists() { + std::fs::read_to_string(&config_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + + let draft = serde_json::json!({ + "version": 1, + "projectId": project_id, + "currentStep": step_name, + "describe": { + "name": name, + "description": description, + "workingDirectory": working_directory, + "planAgent": "claude", + "planModel": null, + "planEffort": null + }, + "plan": { "completed": plan_completed }, + "atomize": { "storiesCount": stories_count }, + "configure": config.unwrap_or_default() + }); + + let draft_json = serde_json::to_string_pretty(&draft)?; + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("draft.json"), &draft_json)?; + + let now = chrono::Utc::now().to_rfc3339(); + let conn = db + .0 + .lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let _ = conn.execute( + "UPDATE projects SET wizard_step = ?1, wizard_state_json = NULL, updated_at = ?2 WHERE id = ?3", + rusqlite::params![step_name, now, project_id], + ); + + Ok(()) +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StaleResult { + pub stale_from_step: u32, +} + +#[tauri::command] +pub async fn mark_wizard_stale( + db: State<'_, DbState>, + project_id: String, + from_step: u32, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + + let now = chrono::Utc::now().to_rfc3339(); + let conn = db + .0 + .lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let _ = conn.execute( + "UPDATE projects SET updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, project_id], + ); + + Ok(StaleResult { + stale_from_step: from_step, + }) +} diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index c498269..18d39bd 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -7,7 +7,7 @@ mod app_core_projects; #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::query_plan_status, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::atomization::get_atomizer_activity_log, commands::atomization::get_atomizer_pipeline_state, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::query_plan_status, commands::planning::replan, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::atomization::get_atomizer_activity_log, commands::atomization::get_atomizer_pipeline_state, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace, commands::wizard_logic::advance_wizard_step, commands::wizard_logic::get_default_config, commands::wizard_logic::validate_project_config, commands::wizard_logic::validate_describe_input, commands::wizard_logic::validate_launch_readiness, commands::wizard_logic::add_story, commands::wizard_logic::update_story, commands::wizard_logic::remove_story, commands::wizard_logic::reorder_stories, commands::wizard_logic::get_stories, commands::wizard_logic::save_wizard_draft, commands::wizard_logic::mark_wizard_stale]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { "start_plan" => commands::planning::__cmd__start_plan!(plan_commands::start_plan, invoke), "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), diff --git a/src-tauri/src/plan_engine/filters.rs b/src-tauri/src/plan_engine/filters.rs new file mode 100644 index 0000000..2d18299 --- /dev/null +++ b/src-tauri/src/plan_engine/filters.rs @@ -0,0 +1,152 @@ +use crate::plan_engine::payloads::{PlanActivityBatchPayload, PlanActivityPayload}; +use crate::activity::PlanEventKind; +use std::collections::HashSet; +use std::sync::OnceLock; + +static EXACT_NOISE: OnceLock> = OnceLock::new(); + +fn exact_noise_set() -> &'static HashSet<&'static str> { + EXACT_NOISE.get_or_init(|| { + let mut set = HashSet::new(); + for entry in &[ + "exec", "codex", "--------", "---", "WAIT", "reason", "effort", "found", + ] { + set.insert(*entry); + } + set + }) +} + +const PREFIX_NOISE: &[&str] = &[ + "OpenAI Codex", + "workdir:", + "model:", + "provider:", + "approval:", + "sandbox:", + "reasoning effort:", + "reasoning summaries:", + "session id:", + "user You are a senior software architect.", + "error: unexpected argument", + "tip: to pass", + "Usage: codex", + "For more information", + "Usage:", + "tip:", + "Reading additional input from stdin", + "Warning: no stdin data received", + "If piping from a slow command", +]; + +fn is_noise_line(content: &str) -> bool { + let trimmed = content.trim(); + if trimmed.is_empty() { + return true; + } + if exact_noise_set().contains(trimmed) { + return true; + } + PREFIX_NOISE.iter().any(|prefix| trimmed.starts_with(prefix)) +} + +fn normalize_line(content: &str) -> String { + let trimmed = content.trim(); + if let Some(rest) = trimmed.strip_prefix("codex ") { + return rest.trim().to_string(); + } + if let Some(rest) = trimmed.strip_prefix("user ") { + return rest.trim().to_string(); + } + trimmed.to_string() +} + +pub fn filter_plan_batch(batch: PlanActivityBatchPayload) -> PlanActivityBatchPayload { + let mut last_signature = String::new(); + let filtered_events: Vec = batch + .events + .into_iter() + .filter_map(|mut event| { + let normalized = normalize_line(&event.content); + if is_noise_line(&normalized) { + return None; + } + let signature = format!("{:?}:{normalized}", event.kind); + if signature == last_signature { + return None; + } + last_signature = signature; + event.content = normalized; + Some(event) + }) + .collect(); + + let plan_content_delta: String = filtered_events + .iter() + .filter(|evt| evt.kind == PlanEventKind::PlanContent) + .map(|evt| evt.content.as_str()) + .collect::>() + .join("\n"); + + PlanActivityBatchPayload { + project_id: batch.project_id, + events: filtered_events, + plan_content_delta, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_event(content: &str) -> PlanActivityPayload { + PlanActivityPayload { + project_id: "test".to_string(), + kind: PlanEventKind::PlanContent, + content: content.to_string(), + timestamp: "12:00:00".to_string(), + } + } + + #[test] + fn filters_noise_lines() { + let batch = PlanActivityBatchPayload { + project_id: "test".to_string(), + events: vec![ + make_event("exec"), + make_event("Real plan content"), + make_event("OpenAI Codex v1.0"), + ], + plan_content_delta: String::new(), + }; + let filtered = filter_plan_batch(batch); + assert_eq!(filtered.events.len(), 1); + assert_eq!(filtered.events[0].content, "Real plan content"); + } + + #[test] + fn normalizes_codex_prefix() { + let batch = PlanActivityBatchPayload { + project_id: "test".to_string(), + events: vec![make_event("codex Some plan output")], + plan_content_delta: String::new(), + }; + let filtered = filter_plan_batch(batch); + assert_eq!(filtered.events[0].content, "Some plan output"); + } + + #[test] + fn deduplicates_consecutive() { + let batch = PlanActivityBatchPayload { + project_id: "test".to_string(), + events: vec![ + make_event("Same line"), + make_event("Same line"), + make_event("Different line"), + ], + plan_content_delta: String::new(), + }; + let filtered = filter_plan_batch(batch); + assert_eq!(filtered.events.len(), 2); + } +} diff --git a/src-tauri/src/plan_engine/fixture.rs b/src-tauri/src/plan_engine/fixture.rs index 2a8ae7b..22f8db8 100644 --- a/src-tauri/src/plan_engine/fixture.rs +++ b/src-tauri/src/plan_engine/fixture.rs @@ -66,11 +66,13 @@ pub(super) async fn start_fixture_plan( plan_bytes: plan_markdown.len(), }); } + let final_content = if plan_markdown.is_empty() { None } else { Some(plan_markdown) }; let _ = app_handle.emit( EVENT_PLAN_COMPLETE, PlanTerminalPayload { project_id: project_id.clone(), detail: String::new(), + final_content, }, ); } @@ -85,6 +87,7 @@ pub(super) async fn start_fixture_plan( PlanTerminalPayload { project_id: project_id.clone(), detail, + final_content: None, }, ); } diff --git a/src-tauri/src/plan_engine/mod.rs b/src-tauri/src/plan_engine/mod.rs index 0544eb3..f0e2223 100644 --- a/src-tauri/src/plan_engine/mod.rs +++ b/src-tauri/src/plan_engine/mod.rs @@ -1,5 +1,6 @@ mod args; mod errors; +pub mod filters; mod fixture; mod helpers; mod monitor; diff --git a/src-tauri/src/plan_engine/monitor.rs b/src-tauri/src/plan_engine/monitor.rs index a0a23db..54b05b1 100644 --- a/src-tauri/src/plan_engine/monitor.rs +++ b/src-tauri/src/plan_engine/monitor.rs @@ -73,6 +73,7 @@ pub(super) fn spawn_heartbeat_task( PlanTerminalPayload { project_id: project_id.clone(), detail: String::new(), + final_content: None, }, ); @@ -108,6 +109,7 @@ pub(super) fn spawn_heartbeat_task( PlanTerminalPayload { project_id: project_id.clone(), detail: "stalled".to_string(), + final_content: None, }, ); if let Ok(mut guard) = sessions.lock() { @@ -154,6 +156,7 @@ pub(super) fn handle_termination( PlanTerminalPayload { project_id: project_id.to_string(), detail: format!("exit_code={exit_code}"), + final_content: None, }, ); } else if !has_plan { @@ -167,21 +170,25 @@ pub(super) fn handle_termination( PlanTerminalPayload { project_id: project_id.to_string(), detail: "empty_output".to_string(), + final_content: None, }, ); } else { - let plan_bytes = classifier + let accumulated = classifier .lock() - .map(|g| g.accumulated_plan().len()) - .unwrap_or(0); + .map(|guard| guard.accumulated_plan()) + .unwrap_or_default(); + let plan_bytes = accumulated.len(); if let Some(tracer) = tracer { tracer.log(TraceEvent::PlanComplete { plan_bytes }); } + let final_content = if accumulated.is_empty() { None } else { Some(accumulated) }; let _ = app.emit( crate::events::EVENT_PLAN_COMPLETE, PlanTerminalPayload { project_id: project_id.to_string(), detail: String::new(), + final_content, }, ); } diff --git a/src-tauri/src/plan_engine/output.rs b/src-tauri/src/plan_engine/output.rs index 90ee6db..2ff37c8 100644 --- a/src-tauri/src/plan_engine/output.rs +++ b/src-tauri/src/plan_engine/output.rs @@ -54,12 +54,17 @@ pub(super) fn flush_event_buffer( .map(|evt| evt.content.as_str()) .collect::>() .join("\n"); + let raw_batch = PlanActivityBatchPayload { + project_id: project_id.to_string(), + events, + plan_content_delta, + }; + let filtered_batch = crate::plan_engine::filters::filter_plan_batch(raw_batch); + if filtered_batch.events.is_empty() && filtered_batch.plan_content_delta.is_empty() { + return; + } let _ = app.emit( crate::events::EVENT_PLAN_ACTIVITY_BATCH, - PlanActivityBatchPayload { - project_id: project_id.to_string(), - events, - plan_content_delta, - }, + filtered_batch, ); } diff --git a/src-tauri/src/plan_engine/payloads.rs b/src-tauri/src/plan_engine/payloads.rs index 08b9645..50adf23 100644 --- a/src-tauri/src/plan_engine/payloads.rs +++ b/src-tauri/src/plan_engine/payloads.rs @@ -28,4 +28,6 @@ pub struct PlanActivityBatchPayload { pub struct PlanTerminalPayload { pub project_id: String, pub detail: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub final_content: Option, } diff --git a/src-tauri/src/plan_engine/start.rs b/src-tauri/src/plan_engine/start.rs index 11dda79..773235a 100644 --- a/src-tauri/src/plan_engine/start.rs +++ b/src-tauri/src/plan_engine/start.rs @@ -15,28 +15,28 @@ use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::ShellExt; use tokio::task::AbortHandle; -fn flush_partial_plan(buffer: &Arc>>, classifier: &Arc>, plan_path: &PathBuf, app: &AppHandle, project_id: &str) -> usize { +fn flush_partial_plan(buffer: &Arc>>, classifier: &Arc>, plan_path: &PathBuf, app: &AppHandle, project_id: &str) -> (usize, String) { output::flush_event_buffer(buffer, app, project_id); let plan_content = classifier.lock().map(|guard| guard.accumulated_plan()).unwrap_or_default(); - if plan_content.is_empty() { return 0; } + if plan_content.is_empty() { return (0, String::new()); } let plan_bytes = plan_content.len(); let _ = std::fs::write(plan_path, &plan_content); - plan_bytes + (plan_bytes, plan_content) } -fn emit_terminal(app: &AppHandle, project_id: &str, exit_code: i32, plan_bytes: usize, tracer: &Option) { +fn emit_terminal(app: &AppHandle, project_id: &str, exit_code: i32, plan_bytes: usize, plan_content: Option, tracer: &Option) { if exit_code != 0 { if let Some(tracer) = tracer { tracer.log(TraceEvent::ErrorEmitted { detail: format!("exit_code={exit_code}") }); } - let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: format!("exit_code={exit_code}") }); + let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: format!("exit_code={exit_code}"), final_content: None }); return; } if plan_bytes == 0 { if let Some(tracer) = tracer { tracer.log(TraceEvent::ErrorEmitted { detail: "empty_output".to_string() }); } - let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: "empty_output".to_string() }); + let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: "empty_output".to_string(), final_content: None }); return; } if let Some(tracer) = tracer { tracer.log(TraceEvent::PlanComplete { plan_bytes }); } - let _ = app.emit(crate::events::EVENT_PLAN_COMPLETE, PlanTerminalPayload { project_id: project_id.to_string(), detail: String::new() }); + let _ = app.emit(crate::events::EVENT_PLAN_COMPLETE, PlanTerminalPayload { project_id: project_id.to_string(), detail: String::new(), final_content: plan_content }); } fn cleanup_hook(app: AppHandle, project_id: String, buffer: Arc>>, classifier: Arc>, plan_path: PathBuf, tracer: Option, plan_abort: AbortHandle, batch_abort: AbortHandle, heartbeat_abort: AbortHandle) -> Arc { @@ -44,12 +44,13 @@ fn cleanup_hook(app: AppHandle, project_id: String, buffer: Arc bool { + circuit_breaker || attempt >= 3 +} + +pub fn should_emit_iteration_completed(result: &str) -> bool { + result == "success" || result == "passed" +} + +pub fn build_notification( + project_id: &str, + notification_type: &str, + title: String, + message: String, +) -> AppNotification { + AppNotification { + id: format!( + "notif_{}_{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis(), + &uuid::Uuid::new_v4().to_string()[..8] + ), + project_id: project_id.to_string(), + notification_type: notification_type.to_string(), + title, + message, + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + } +} diff --git a/src-tauri/src/projects/stories_crud.rs b/src-tauri/src/projects/stories_crud.rs new file mode 100644 index 0000000..4df9ba5 --- /dev/null +++ b/src-tauri/src/projects/stories_crud.rs @@ -0,0 +1,185 @@ +use crate::projects::artifacts::artifact_dir; +use crate::projects::ProjectError; +use ralph_core::prd::Prd; +pub use ralph_core::prd::UserStory; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, Runtime}; + +fn load_prd(app: &AppHandle, project_id: &str) -> Result { + let dir = artifact_dir(app, project_id)?; + let prd_path = dir.join("prd.json"); + if prd_path.exists() { + Prd::load(&prd_path).map_err(|_| { + ProjectError::Path(format!("Failed to load prd.json for {project_id}")) + }) + } else { + Ok(Prd { + project_name: String::new(), + feature: String::new(), + working_directory: String::new(), + branch_name: None, + stories: Vec::new(), + generated_at: Some(chrono::Utc::now().to_rfc3339()), + }) + } +} + +fn save_prd(app: &AppHandle, project_id: &str, prd: &Prd) -> Result<(), ProjectError> { + let dir = artifact_dir(app, project_id)?; + std::fs::create_dir_all(&dir)?; + let json = serde_json::to_string_pretty(prd)?; + std::fs::write(dir.join("prd.json"), json)?; + Ok(()) +} + +fn total_estimated_minutes(prd: &Prd) -> u32 { + prd.stories.iter().map(|story| story.estimated_minutes).sum() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct StoriesResponse { + pub stories: Vec, + pub total_estimated_minutes: u32, +} + +pub fn add_story( + app: &AppHandle, + project_id: &str, +) -> Result { + let mut prd = load_prd(app, project_id)?; + let next_index = prd.stories.len() + 1; + let padded_id = format!("S-{next_index:03}"); + + let story = UserStory { + id: padded_id, + title: "New story".to_string(), + description: None, + acceptance_criteria: Vec::new(), + scope: ralph_core::prd::ScopeSpec::default(), + verification: ralph_core::prd::VerificationSpec::default(), + commit_message: None, + priority: ralph_core::prd::Priority::Medium, + estimated_complexity: ralph_core::prd::Complexity::Medium, + estimated_minutes: 30, + depends_on: Vec::new(), + passes: false, + blocked: false, + attempts: 0, + notes: None, + }; + + prd.stories.push(story); + let minutes = total_estimated_minutes(&prd); + save_prd(app, project_id, &prd)?; + + Ok(StoriesResponse { + stories: prd.stories, + total_estimated_minutes: minutes, + }) +} + +pub fn update_story( + app: &AppHandle, + project_id: &str, + story_id: &str, + patch_json: &str, +) -> Result { + let mut prd = load_prd(app, project_id)?; + let patch: serde_json::Value = serde_json::from_str(patch_json)?; + + let story = prd + .stories + .iter_mut() + .find(|story| story.id == story_id) + .ok_or_else(|| ProjectError::NotFound(format!("Story {story_id}")))?; + + if let Some(title) = patch.get("title").and_then(|val| val.as_str()) { + story.title = title.to_string(); + } + if let Some(description) = patch.get("description").and_then(|val| val.as_str()) { + story.description = Some(description.to_string()); + } + if let Some(priority) = patch.get("priority").and_then(|val| val.as_str()) { + if let Ok(parsed) = serde_json::from_value::( + serde_json::Value::String(priority.to_string()), + ) { + story.priority = parsed; + } + } + if let Some(complexity) = patch.get("estimatedComplexity").and_then(|val| val.as_str()) { + if let Ok(parsed) = serde_json::from_value::( + serde_json::Value::String(complexity.to_string()), + ) { + story.estimated_complexity = parsed; + } + } + if let Some(minutes) = patch.get("estimatedMinutes").and_then(|val| val.as_u64()) { + story.estimated_minutes = minutes as u32; + } + + let minutes = total_estimated_minutes(&prd); + save_prd(app, project_id, &prd)?; + + Ok(StoriesResponse { + stories: prd.stories, + total_estimated_minutes: minutes, + }) +} + +pub fn remove_story( + app: &AppHandle, + project_id: &str, + story_id: &str, +) -> Result { + let mut prd = load_prd(app, project_id)?; + let original_count = prd.stories.len(); + prd.stories.retain(|story| story.id != story_id); + + if prd.stories.len() == original_count { + return Err(ProjectError::NotFound(format!("Story {story_id}"))); + } + + let minutes = total_estimated_minutes(&prd); + save_prd(app, project_id, &prd)?; + + Ok(StoriesResponse { + stories: prd.stories, + total_estimated_minutes: minutes, + }) +} + +pub fn reorder_stories( + app: &AppHandle, + project_id: &str, + from_index: usize, + to_index: usize, +) -> Result { + let mut prd = load_prd(app, project_id)?; + + if from_index >= prd.stories.len() || to_index >= prd.stories.len() { + return Err(ProjectError::Path("Index out of bounds".to_string())); + } + + let moved = prd.stories.remove(from_index); + prd.stories.insert(to_index, moved); + let minutes = total_estimated_minutes(&prd); + save_prd(app, project_id, &prd)?; + + Ok(StoriesResponse { + stories: prd.stories, + total_estimated_minutes: minutes, + }) +} + +pub fn get_stories( + app: &AppHandle, + project_id: &str, +) -> Result { + let prd = load_prd(app, project_id)?; + let minutes = total_estimated_minutes(&prd); + Ok(StoriesResponse { + total_estimated_minutes: minutes, + stories: prd.stories, + }) +} diff --git a/src-tauri/src/projects/validation.rs b/src-tauri/src/projects/validation.rs new file mode 100644 index 0000000..75ef3e5 --- /dev/null +++ b/src-tauri/src/projects/validation.rs @@ -0,0 +1,235 @@ +use crate::projects::config_types::ProjectConfig; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigLimits { + pub gutter_threshold: (u32, u32), + pub max_iterations: (u32, u32), + pub cooldown_seconds: (u32, u32), + pub max_verification_retries: (u32, u32), + pub review_polling_interval: (u64, u64), + pub review_timeout: (u64, u64), +} + +impl Default for ConfigLimits { + fn default() -> Self { + Self { + gutter_threshold: (1, 20), + max_iterations: (1, 500), + cooldown_seconds: (0, 300), + max_verification_retries: (1, 10), + review_polling_interval: (10, 600), + review_timeout: (60, 3600), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfigDefaultsResponse { + pub config: ProjectConfig, + pub limits: ConfigLimits, +} + +pub fn get_config_defaults() -> ConfigDefaultsResponse { + ConfigDefaultsResponse { + config: ProjectConfig::default(), + limits: ConfigLimits::default(), + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ValidationErrors { + pub errors: std::collections::HashMap, +} + +impl ValidationErrors { + pub fn is_empty(&self) -> bool { + self.errors.is_empty() + } +} + +pub fn validate_config(config: &ProjectConfig) -> ValidationErrors { + let limits = ConfigLimits::default(); + let mut errors = std::collections::HashMap::new(); + + if config.execute_agent.trim().is_empty() { + errors.insert( + "executeAgent".to_string(), + "Execute agent is required".to_string(), + ); + } + + validate_range( + &mut errors, + "gutterThreshold", + "Gutter threshold", + config.gutter_threshold as u64, + limits.gutter_threshold.0 as u64, + limits.gutter_threshold.1 as u64, + ); + validate_range( + &mut errors, + "maxIterations", + "Max iterations", + config.max_iterations as u64, + limits.max_iterations.0 as u64, + limits.max_iterations.1 as u64, + ); + validate_range( + &mut errors, + "cooldownSeconds", + "Cooldown", + config.cooldown_seconds as u64, + limits.cooldown_seconds.0 as u64, + limits.cooldown_seconds.1 as u64, + ); + validate_range( + &mut errors, + "maxVerificationRetries", + "Verification retries", + config.max_verification_retries as u64, + limits.max_verification_retries.0 as u64, + limits.max_verification_retries.1 as u64, + ); + validate_range( + &mut errors, + "reviewPollingInterval", + "Poll interval", + config.review_polling_interval, + limits.review_polling_interval.0, + limits.review_polling_interval.1, + ); + validate_range( + &mut errors, + "reviewTimeout", + "Timeout", + config.review_timeout, + limits.review_timeout.0, + limits.review_timeout.1, + ); + + ValidationErrors { errors } +} + +fn validate_range( + errors: &mut std::collections::HashMap, + field: &str, + label: &str, + value: u64, + min: u64, + max: u64, +) { + if value < min || value > max { + errors.insert( + field.to_string(), + format!("{label} must be between {min} and {max}"), + ); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DescribeInput { + pub name: String, + pub description: String, + pub working_directory: String, + pub plan_agent: String, +} + +pub fn validate_describe(input: &DescribeInput, available_agents: &[String]) -> ValidationErrors { + let mut errors = std::collections::HashMap::new(); + + if input.name.trim().is_empty() { + errors.insert("name".to_string(), "Project name is required".to_string()); + } + if input.description.trim().is_empty() { + errors.insert( + "description".to_string(), + "Feature description is required".to_string(), + ); + } + if input.working_directory.trim().is_empty() { + errors.insert( + "workingDirectory".to_string(), + "Working directory is required".to_string(), + ); + } + if available_agents.is_empty() { + errors.insert( + "submit".to_string(), + "No supported agent was detected. Install Claude, Codex, Gemini, or OpenCode." + .to_string(), + ); + } else if !available_agents.contains(&input.plan_agent) { + errors.insert( + "submit".to_string(), + "Selected plan agent is not available in this environment.".to_string(), + ); + } + + ValidationErrors { errors } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchReadiness { + pub ready: bool, + pub issues: Vec, +} + +pub fn validate_launch_readiness( + project_name: &str, + working_directory: &str, + stories_count: usize, + execute_agent: &str, +) -> LaunchReadiness { + let mut issues = Vec::new(); + + if project_name.trim().is_empty() { + issues.push("Project name is missing.".to_string()); + } + if working_directory.trim().is_empty() { + issues.push("Working directory is missing.".to_string()); + } + if stories_count == 0 { + issues.push("Add at least one story before launching.".to_string()); + } + if execute_agent.trim().is_empty() { + issues.push("Execution agent is missing.".to_string()); + } + + LaunchReadiness { + ready: issues.is_empty(), + issues, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_config_passes_validation() { + let config = ProjectConfig::default(); + let result = validate_config(&config); + assert!(result.is_empty()); + } + + #[test] + fn empty_agent_fails_validation() { + let mut config = ProjectConfig::default(); + config.execute_agent = String::new(); + let result = validate_config(&config); + assert!(result.errors.contains_key("executeAgent")); + } + + #[test] + fn launch_readiness_catches_missing_fields() { + let result = validate_launch_readiness("", "", 0, ""); + assert!(!result.ready); + assert_eq!(result.issues.len(), 4); + } +} diff --git a/src-tauri/src/projects/wizard_state.rs b/src-tauri/src/projects/wizard_state.rs new file mode 100644 index 0000000..715c463 --- /dev/null +++ b/src-tauri/src/projects/wizard_state.rs @@ -0,0 +1,109 @@ +use serde::{Deserialize, Serialize}; + +const WIZARD_STEPS: &[&str] = &["describe", "plan", "atomize", "configure", "launch"]; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WizardStepState { + pub current_step: u32, + pub highest_step: u32, + pub stale_from_step: Option, +} + +impl Default for WizardStepState { + fn default() -> Self { + Self { + current_step: 1, + highest_step: 1, + stale_from_step: None, + } + } +} + +impl WizardStepState { + pub fn advance(&mut self, target_step: u32) { + self.current_step = target_step; + if target_step > self.highest_step { + self.highest_step = target_step; + } + self.stale_from_step = None; + } + + pub fn mark_stale(&mut self, from_step: u32) { + self.stale_from_step = Some(from_step); + } + + pub fn clear_stale(&mut self) { + self.stale_from_step = None; + } +} + +pub fn step_name_to_number(name: &str) -> Option { + WIZARD_STEPS + .iter() + .position(|step| *step == name) + .map(|index| (index as u32) + 1) +} + +pub fn step_number_to_name(number: u32) -> Option<&'static str> { + if number == 0 || number as usize > WIZARD_STEPS.len() { + return None; + } + Some(WIZARD_STEPS[(number - 1) as usize]) +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AdvanceWizardResult { + pub current_step: u32, + pub highest_step: u32, + pub step_name: String, +} + +pub fn advance_wizard_step( + current_state: Option<&WizardStepState>, + target_step: u32, +) -> Result { + if target_step == 0 || target_step as usize > WIZARD_STEPS.len() { + return Err(format!("Invalid step number: {target_step}")); + } + + let mut state = current_state.cloned().unwrap_or_default(); + state.advance(target_step); + + let step_name = step_number_to_name(target_step) + .ok_or_else(|| format!("Unknown step: {target_step}"))?; + + Ok(AdvanceWizardResult { + current_step: state.current_step, + highest_step: state.highest_step, + step_name: step_name.to_string(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn advance_updates_highest() { + let mut state = WizardStepState::default(); + state.advance(3); + assert_eq!(state.current_step, 3); + assert_eq!(state.highest_step, 3); + } + + #[test] + fn advance_clears_stale() { + let mut state = WizardStepState::default(); + state.mark_stale(2); + state.advance(3); + assert!(state.stale_from_step.is_none()); + } + + #[test] + fn step_name_roundtrip() { + assert_eq!(step_name_to_number("plan"), Some(2)); + assert_eq!(step_number_to_name(2), Some("plan")); + } +} diff --git a/src/features/monitor/AskTab.tsx b/src/features/monitor/AskTab.tsx index acc41bc..b030c62 100644 --- a/src/features/monitor/AskTab.tsx +++ b/src/features/monitor/AskTab.tsx @@ -94,7 +94,11 @@ export function AskTab({ projectId, disabled }: AskTabProps) { const handleSubmit = useCallback( async (question: string) => { - const userMessage: AskMessage = { + const store = useAskStore.getState(); + store.setIsAsking(true); + store.setStreamingContent(""); + store.setEditPrefill(""); + store.addMessage({ id: crypto.randomUUID(), conversationId: "", role: "user", @@ -102,12 +106,7 @@ export function AskTab({ projectId, disabled }: AskTabProps) { agent: null, model: null, createdAt: new Date().toISOString(), - }; - const store = useAskStore.getState(); - store.addMessage(userMessage); - store.setIsAsking(true); - store.setStreamingContent(""); - store.setEditPrefill(""); + }); try { const messageId = await askQuestion(projectId, question, selectedAgent, selectedModel); useAskStore.getState().setStreamingMessageId(messageId); @@ -120,10 +119,7 @@ export function AskTab({ projectId, disabled }: AskTabProps) { const handleStop = useCallback(() => { void stopAsk(projectId); - const store = useAskStore.getState(); - store.setIsAsking(false); - store.setStreamingContent(""); - store.setStreamingMessageId(null); + useAskStore.getState().setIsAsking(false); }, [projectId]); const handleCopy = useCallback((messageId: string) => { @@ -139,9 +135,8 @@ export function AskTab({ projectId, disabled }: AskTabProps) { }, [projectId]); const handleRetry = useCallback((messageId: string) => { - const store = useAskStore.getState(); - store.setIsAsking(true); - store.setStreamingContent(""); + useAskStore.getState().setIsAsking(true); + useAskStore.getState().setStreamingContent(""); void retryAsk(projectId, messageId, selectedAgent, selectedModel).then((newId) => { askHistory(projectId).then((history) => useAskStore.getState().setMessages(history)); useAskStore.getState().setStreamingMessageId(newId); @@ -149,10 +144,9 @@ export function AskTab({ projectId, disabled }: AskTabProps) { }, [projectId, selectedAgent, selectedModel]); const handleRetryWith = useCallback((messageId: string, agent: string) => { - const store = useAskStore.getState(); - store.setSelectedAgent(agent); - store.setIsAsking(true); - store.setStreamingContent(""); + useAskStore.getState().setSelectedAgent(agent); + useAskStore.getState().setIsAsking(true); + useAskStore.getState().setStreamingContent(""); void retryAsk(projectId, messageId, agent, null).then((newId) => { askHistory(projectId).then((history) => useAskStore.getState().setMessages(history)); useAskStore.getState().setStreamingMessageId(newId); diff --git a/src/features/wizard/Atomize.tsx b/src/features/wizard/Atomize.tsx index efdfbe3..ded1603 100644 --- a/src/features/wizard/Atomize.tsx +++ b/src/features/wizard/Atomize.tsx @@ -1,6 +1,6 @@ import { useRef, useState, type DragEvent } from "react"; import { useNavigate, useParams } from "react-router"; -import { discardDraft, saveDraft, savePrd, type Prd } from "../../lib/tauri"; +import { addStory, advanceWizardStep, discardDraft, removeStoryBackend, reorderStoriesBackend, saveWizardDraft, updateStoryBackend } from "../../lib/tauri"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "../../components/ui/card"; @@ -10,40 +10,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "../../components/ui/ta import { useWizardStore, type UserStory } from "../../stores/wizardStore"; import { useAtomizerPipeline, STAGE_BADGE, STAGE_LABEL } from "../../hooks/useAtomizerPipeline"; import { useAtomizerActivity } from "../../hooks/useAtomizerActivity"; -import { buildDraftPayload } from "../../lib/draft-payload"; import { AtomizeConfirmationDialog } from "./components/AtomizeConfirmationDialog"; import { AtomizeStreamPanel } from "./components/AtomizeStreamPanel"; import { AtomizeStoryList } from "./components/AtomizeStoryList"; -function makeBlankStory(existingCount: number): UserStory { - const paddedId = String(existingCount + 1).padStart(3, "0"); - return { - id: `S-${paddedId}`, - title: "New story", - description: "", - acceptanceCriteria: [], - scope: { filesToModify: [], filesToCreate: [], filesToAvoid: [] }, - verification: { commands: [], assertions: [] }, - priority: "medium", - estimatedComplexity: "medium", - estimatedMinutes: 30, - dependsOn: [], - passes: false, - blocked: false, - attempts: 0, - notes: null, - }; -} - export function Atomize() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); const stories = useWizardStore((state) => state.stories); - const reorderStories = useWizardStore((state) => state.reorderStories); - const addStory = useWizardStore((state) => state.addStory); - const updateStory = useWizardStore((state) => state.updateStory); - const removeStory = useWizardStore((state) => state.removeStory); - const advanceStep = useWizardStore((state) => state.advanceStep); + const setStories = useWizardStore((state) => state.setStories); + const dragIndexRef = useRef(null); const [discardOpen, setDiscardOpen] = useState(false); const [storyToRemove, setStoryToRemove] = useState(null); @@ -65,28 +41,39 @@ export function Atomize() { const handleDragStart = (index: number) => { dragIndexRef.current = index; }; const handleDragOver = (event: DragEvent) => { event.preventDefault(); }; const handleDrop = (toIndex: number) => { - if (dragIndexRef.current === null || dragIndexRef.current === toIndex) return; - reorderStories(dragIndexRef.current, toIndex); + if (dragIndexRef.current === null || dragIndexRef.current === toIndex || !id) return; + const fromIndex = dragIndexRef.current; dragIndexRef.current = null; + reorderStoriesBackend(id, fromIndex, toIndex) + .then((result) => setStories(result.stories)) + .catch(() => {}); }; - async function persistStoriesToDisk() { - const snap = useWizardStore.getState(); - if (!id || snap.stories.length === 0) return; - const prd: Prd = { - projectName: snap.projectData.name, - generatedAt: new Date().toISOString(), - totalEstimatedMinutes: totalMinutes, - stories: snap.stories, - }; - await savePrd(id, JSON.stringify(prd, null, 2)).catch(() => {}); + function handleUpdateStory(storyId: string, patch: Partial) { + if (!id) return; + updateStoryBackend(id, storyId, JSON.stringify(patch)) + .then((result) => setStories(result.stories)) + .catch(() => {}); + } + + function handleAddStory() { + if (!id) return; + addStory(id) + .then((result) => setStories(result.stories)) + .catch(() => {}); + } + + function handleRemoveStory(storyId: string) { + if (!id) return; + removeStoryBackend(id, storyId) + .then((result) => setStories(result.stories)) + .catch(() => {}); } async function handleNext() { if (!id) return; - await persistStoriesToDisk(); - await saveDraft(id, buildDraftPayload(id, "configure")).catch(() => {}); - advanceStep(4); + await saveWizardDraft(id, "configure").catch(() => {}); + await advanceWizardStep(4).catch(() => {}); navigate(`/new/configure/${id}`); } @@ -150,7 +137,7 @@ export function Atomize() {
{stories.length} stories {totalHours}h est - +
@@ -159,7 +146,7 @@ export function Atomize() { atomizeStarted={pipeline.atomizeStarted} isDone={pipeline.isDone} atomizeError={pipeline.atomizeError} - onUpdateStory={updateStory} + onUpdateStory={handleUpdateStory} onRequestRemoveStory={setStoryToRemove} onDragStart={handleDragStart} onDragOver={handleDragOver} @@ -179,7 +166,7 @@ export function Atomize() { title="Remove story?" description={storyToRemove ? `${storyToRemove.id} will be removed from the atomization list.` : "This story will be removed from the atomization list."} confirmLabel="Remove story" - onConfirm={() => { if (!storyToRemove) return; removeStory(storyToRemove.id); setStoryToRemove(null); }} + onConfirm={() => { if (!storyToRemove) return; handleRemoveStory(storyToRemove.id); setStoryToRemove(null); }} />
); diff --git a/src/features/wizard/Configure.tsx b/src/features/wizard/Configure.tsx index 03a16c0..be7797a 100644 --- a/src/features/wizard/Configure.tsx +++ b/src/features/wizard/Configure.tsx @@ -1,15 +1,12 @@ import { useEffect, useRef, useState, type DragEvent } from "react"; import { useNavigate, useParams } from "react-router"; -import { detectAgents, getAgentCapabilities, saveConfig, saveDraft } from "../../lib/tauri"; +import { advanceWizardStep, detectAgents, getAgentCapabilities, getDefaultConfig, saveConfig, saveWizardDraft, validateProjectConfig } from "../../lib/tauri"; import type { AgentCapabilities } from "../../lib/tauri"; import { useAgentStore } from "../../stores/agentStore"; import { useWizardStore } from "../../stores/wizardStore"; -import { buildDraftPayload } from "../../lib/draft-payload"; -import { validateConfig, type ConfigureErrors } from "./configureValidation"; +import type { ConfigureErrors } from "./configureValidation"; import { ConfigureForm } from "./components/ConfigureForm"; -const KNOWN_AGENTS = ["claude", "codex", "gemini", "opencode", "cursor"]; - export function Configure() { const { id } = useParams<{ id: string }>(); const navigate = useNavigate(); @@ -17,8 +14,9 @@ export function Configure() { const setAgents = useAgentStore((state) => state.setAgents); const setDetecting = useAgentStore((state) => state.setDetecting); const config = useWizardStore((state) => state.config); + const configLoaded = useWizardStore((state) => state.configLoaded); const setConfig = useWizardStore((state) => state.setConfig); - const advanceStep = useWizardStore((state) => state.advanceStep); + const setFullConfig = useWizardStore((state) => state.setFullConfig); const [executeAgent, setExecuteAgent] = useState(config.executeAgent); const [executeModel, setExecuteModel] = useState(config.executeModel ?? null); const [executeEffort, setExecuteEffort] = useState(config.executeEffort ?? null); @@ -35,9 +33,33 @@ export function Configure() { const [errors, setErrors] = useState({}); const [newAgent, setNewAgent] = useState(""); const dragIndexRef = useRef(null); - const availableAgentNames = agents.filter((agent) => agent.installed).map((agent) => agent.name); - const selectableAgentNames = availableAgentNames.length ? availableAgentNames : KNOWN_AGENTS; - const agentsNotInChain = selectableAgentNames.filter((agentName) => !fallbackChain.includes(agentName)); + const allAgentNames = agents.map((agent) => agent.name); + const agentsNotInChain = allAgentNames.filter((agentName) => !fallbackChain.includes(agentName)); + + useEffect(() => { + if (configLoaded) return; + let cancelled = false; + getDefaultConfig() + .then((response) => { + if (cancelled) return; + const defaults = response.config; + setFullConfig(defaults as import("../../types/wizard").WizardConfig); + setExecuteAgent(defaults.executeAgent); + setExecuteModel(defaults.executeModel ?? null); + setExecuteEffort(defaults.executeEffort ?? null); + setFallbackChain(defaults.fallbackChain); + setGutterThreshold(defaults.gutterThreshold); + setMaxIterations(defaults.maxIterations); + setCooldownSeconds(defaults.cooldownSeconds); + setTestCommand(defaults.testCommand); + setMaxVerificationRetries(defaults.maxVerificationRetries); + setScmProvider(defaults.scmProvider as typeof config.scmProvider); + setReviewPollingInterval(defaults.reviewPollingInterval); + setReviewTimeout(defaults.reviewTimeout); + }) + .catch(() => {}); + return () => { cancelled = true; }; + }, [configLoaded, setFullConfig]); useEffect(() => { let cancelled = false; @@ -104,20 +126,6 @@ export function Configure() { } async function handleNext() { if (!id) return; - const nextErrors = validateConfig({ - executeAgent, - gutterThreshold, - maxIterations, - cooldownSeconds, - maxVerificationRetries, - reviewPollingInterval, - reviewTimeout, - }); - if (Object.keys(nextErrors).length > 0) { - setErrors(nextErrors); - return; - } - setErrors({}); const sanitizedFallbackChain = fallbackChain.filter((agentName) => agentName !== executeAgent); const configPayload = { schemaVersion: 1, @@ -134,6 +142,16 @@ export function Configure() { reviewPollingInterval, reviewTimeout, }; + try { + const validationResult = await validateProjectConfig(JSON.stringify(configPayload)); + if (Object.keys(validationResult.errors).length > 0) { + setErrors(validationResult.errors as ConfigureErrors); + return; + } + } catch { + return; + } + setErrors({}); await saveConfig(id, JSON.stringify(configPayload, null, 2)).catch(() => {}); setConfig({ executeAgent, @@ -149,8 +167,8 @@ export function Configure() { reviewPollingInterval, reviewTimeout, }); - await saveDraft(id, buildDraftPayload(id, "launch")).catch(() => {}); - advanceStep(5); + await saveWizardDraft(id, "launch").catch(() => {}); + await advanceWizardStep(5).catch(() => {}); navigate(`/new/launch/${id}`); } @@ -161,7 +179,7 @@ export function Configure() { executeEffort={executeEffort} capabilities={capabilities} errors={errors} - selectableAgentNames={selectableAgentNames} + selectableAgentNames={allAgentNames} fallbackChain={fallbackChain} agentsNotInChain={agentsNotInChain} newAgent={newAgent} diff --git a/src/features/wizard/Describe.tsx b/src/features/wizard/Describe.tsx index bc80c10..767592c 100644 --- a/src/features/wizard/Describe.tsx +++ b/src/features/wizard/Describe.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router"; -import { createProject, detectAgents, discardDraft, getAgentCapabilities, listConnections, saveDraft } from "../../lib/tauri"; +import { advanceWizardStep, createProject, detectAgents, discardDraft, getAgentCapabilities, listConnections, markWizardStale, saveWizardDraft, validateDescribeInput } from "../../lib/tauri"; import type { AgentCapabilities, Connection } from "../../lib/tauri"; import { useAgentStore } from "../../stores/agentStore"; import { useWizardStore } from "../../stores/wizardStore"; @@ -12,7 +12,7 @@ export function Describe() { const agents = useAgentStore((state) => state.agents); const setAgents = useAgentStore((state) => state.setAgents); const setDetecting = useAgentStore((state) => state.setDetecting); - const { projectData, projectId: existingProjectId, setProjectData, setProjectId, advanceStep, markStale, planContent, reset } = useWizardStore(); + const { projectData, projectId: existingProjectId, setProjectData, setProjectId, planContent, reset } = useWizardStore(); const [name, setName] = useState(projectData.name); const [description, setDescription] = useState(projectData.description); const [workingDirectory, setWorkingDirectory] = useState(projectData.workingDirectory); @@ -78,22 +78,24 @@ export function Describe() { return () => { cancelled = true; }; }, [planAgent]); - function validate() { + async function validate(): Promise> { const nextErrors: Record = {}; - if (!name.trim()) nextErrors.name = "Project name is required"; - if (!description.trim()) nextErrors.description = "Feature description is required"; - if (workspaceMode === "single" && !workingDirectory.trim()) { - nextErrors.workingDirectory = "Working directory is required"; - } if (workspaceMode === "connection" && !selectedConnectionId) { nextErrors.workingDirectory = "Select a connection"; + return nextErrors; } - if (availableAgents.length === 0) { - nextErrors.submit = "No supported agent was detected. Install Claude, Codex, Gemini, or OpenCode."; - } else if (!availableAgents.some((agent) => agent.name === planAgent)) { - nextErrors.submit = "Selected plan agent is not available in this environment."; + try { + const input = JSON.stringify({ + name: name.trim(), + description: description.trim(), + workingDirectory: workspaceMode === "single" ? workingDirectory.trim() : "placeholder", + planAgent: planAgent, + }); + const result = await validateDescribeInput(input); + return result.errors; + } catch { + return nextErrors; } - return nextErrors; } async function handleCancelProcess() { @@ -111,7 +113,7 @@ export function Describe() { } async function handleNext() { - const validationErrors = validate(); + const validationErrors = await validate(); if (Object.keys(validationErrors).length > 0) { setErrors(validationErrors); return; @@ -125,19 +127,16 @@ export function Describe() { } const updatedData = { name: name.trim(), description: description.trim(), workingDirectory: effectiveDirectory, planAgent, planModel, planEffort }; setProjectData(updatedData); - const store = useWizardStore.getState(); if (existingProjectId) { - const draft = { version: 1, projectId: existingProjectId, currentStep: "plan", describe: updatedData, plan: { completed: store.planComplete }, atomize: { storiesCount: store.stories.length }, configure: store.config }; - await saveDraft(existingProjectId, JSON.stringify(draft, null, 2)).catch(() => {}); - advanceStep(2); + await saveWizardDraft(existingProjectId, "plan").catch(() => {}); + await advanceWizardStep(2).catch(() => {}); navigate(`/new/plan/${existingProjectId}`); return; } const project = await createProject(updatedData.name, updatedData.description, effectiveDirectory, "describe"); setProjectId(project.id); - const draft = { version: 1, projectId: project.id, currentStep: "plan", describe: updatedData, plan: { completed: store.planComplete }, atomize: { storiesCount: store.stories.length }, configure: store.config }; - await saveDraft(project.id, JSON.stringify(draft, null, 2)).catch(() => {}); - advanceStep(2); + await saveWizardDraft(project.id, "plan").catch(() => {}); + await advanceWizardStep(2).catch(() => {}); navigate(`/new/plan/${project.id}`); } catch (caughtError: unknown) { const errorMessage = caughtError instanceof Error ? caughtError.message : String(caughtError); @@ -164,7 +163,7 @@ export function Describe() { availableAgentsCount={availableAgents.length} submitting={submitting} onNameChange={(value) => { setName(value); setErrors((previousErrors) => ({ ...previousErrors, name: "" })); }} - onDescriptionChange={(value) => { setDescription(value); setErrors((previousErrors) => ({ ...previousErrors, description: "" })); if (planContent) markStale(2); }} + onDescriptionChange={(value) => { setDescription(value); setErrors((previousErrors) => ({ ...previousErrors, description: "" })); if (planContent && existingProjectId) { void markWizardStale(existingProjectId, 2); } }} onWorkingDirectoryChange={(value) => { setWorkingDirectory(value); setErrors((previousErrors) => ({ ...previousErrors, workingDirectory: "" })); }} onWorkspaceModeChange={setWorkspaceMode} onConnectionChange={(value) => { setSelectedConnectionId(value); setErrors((previousErrors) => ({ ...previousErrors, workingDirectory: "" })); }} diff --git a/src/features/wizard/Launch.tsx b/src/features/wizard/Launch.tsx index a96f515..9d6e24b 100644 --- a/src/features/wizard/Launch.tsx +++ b/src/features/wizard/Launch.tsx @@ -1,8 +1,8 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router"; import { Card, CardContent, CardHeader, CardTitle } from "../../components/ui/card"; import { useWizardStore } from "../../stores/wizardStore"; -import { startLoop, finalizeDraft } from "../../lib/tauri"; +import { startLoop, finalizeDraft, validateLaunchReadiness } from "../../lib/tauri"; import { LaunchActions } from "./components/LaunchActions"; function SummaryRow({ label, value }: { label: string; value: string | number }) { @@ -23,15 +23,18 @@ export function Launch() { const [launching, setLaunching] = useState(false); const [launchError, setLaunchError] = useState(null); + const [readinessIssues, setReadinessIssues] = useState([]); const totalMinutes = stories.reduce((sum, story) => sum + story.estimatedMinutes, 0); const totalHours = (totalMinutes / 60).toFixed(1); - const readinessIssues = [ - projectData.name.trim() ? null : "Project name is missing.", - projectData.workingDirectory.trim() ? null : "Working directory is missing.", - stories.length > 0 ? null : "Add at least one story before launching.", - config.executeAgent.trim() ? null : "Execution agent is missing.", - ].filter((issue): issue is string => Boolean(issue)); + + useEffect(() => { + if (!id) return; + validateLaunchReadiness(id) + .then((result) => setReadinessIssues(result.issues)) + .catch(() => {}); + }, [id]); + const launchDisabled = readinessIssues.length > 0; async function handleLaunch() { diff --git a/src/features/wizard/Plan.tsx b/src/features/wizard/Plan.tsx index 081a845..d69def5 100644 --- a/src/features/wizard/Plan.tsx +++ b/src/features/wizard/Plan.tsx @@ -1,4 +1,4 @@ -import { useDeferredValue, useEffect, useMemo, useRef } from "react"; +import { useDeferredValue, useEffect, useRef } from "react"; import { useParams } from "react-router"; import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; @@ -10,7 +10,6 @@ import { MarkdownPreview } from "../../components/MarkdownPreview"; import { useWizardStore } from "../../stores/wizardStore"; import { usePlanEvents } from "../../hooks/usePlanEvents"; import { usePlanOrchestration } from "../../hooks/usePlanOrchestration"; -import { shouldRenderPlanEvent } from "../../lib/plan-stream-filters"; import { PlanStreamPanel, } from "./components/PlanStreamPanel"; @@ -27,11 +26,7 @@ export function Plan() { const { planError } = usePlanEvents(id); const orchestration = usePlanOrchestration(id); - const deferredEvents = useDeferredValue(planEvents); - const visiblePlanEvents = useMemo( - () => deferredEvents.filter(shouldRenderPlanEvent), - [deferredEvents], - ); + const visiblePlanEvents = useDeferredValue(planEvents); useEffect(() => { activityEndRef.current?.scrollIntoView({ behavior: "auto" }); diff --git a/src/features/wizard/WizardLayout.tsx b/src/features/wizard/WizardLayout.tsx index 7b288f8..ffa3d99 100644 --- a/src/features/wizard/WizardLayout.tsx +++ b/src/features/wizard/WizardLayout.tsx @@ -10,8 +10,7 @@ import { Badge } from "../../components/ui/badge"; import { Button } from "../../components/ui/button"; import { Separator } from "../../components/ui/separator"; import { useWizardStore } from "../../stores/wizardStore"; -import { stopPlan, saveDraft } from "../../lib/tauri"; -import { buildDraftPayload } from "../../lib/draft-payload"; +import { stopPlan, saveWizardDraft } from "../../lib/tauri"; import { useWizardHydration } from "../../hooks/useWizardHydration"; import { WizardExitDialog } from "./components/WizardExitDialog"; import { WizardStepRail } from "./components/WizardStepRail"; @@ -54,7 +53,7 @@ export function WizardLayout() { await stopPlan(projectId).catch(() => {}); } const stepSlug = WIZARD_STEPS.find((step) => step.number === currentStep)?.slug ?? "describe"; - await saveDraft(projectId, buildDraftPayload(projectId, stepSlug)).catch(() => {}); + await saveWizardDraft(projectId, stepSlug).catch(() => {}); } navigate("/"); } diff --git a/src/features/wizard/components/PlanStreamPanel.tsx b/src/features/wizard/components/PlanStreamPanel.tsx index 4054de7..ef16830 100644 --- a/src/features/wizard/components/PlanStreamPanel.tsx +++ b/src/features/wizard/components/PlanStreamPanel.tsx @@ -7,7 +7,6 @@ import { Input } from "../../../components/ui/input"; import { ScrollArea, ScrollContent, ScrollViewport } from "../../../components/ui/scroll-area"; import { Separator } from "../../../components/ui/separator"; import type { PlanEvent, PlanEventKind } from "../../../types/wizard"; -export { shouldRenderPlanEvent } from "../../../lib/plan-stream-filters"; const MAX_VISIBLE_EVENTS = 200; diff --git a/src/features/wizard/configureValidation.ts b/src/features/wizard/configureValidation.ts index 36dd22d..7042e5d 100644 --- a/src/features/wizard/configureValidation.ts +++ b/src/features/wizard/configureValidation.ts @@ -8,39 +8,3 @@ export type ConfigureField = | "reviewTimeout"; export type ConfigureErrors = Partial>; - -const LIMITS = { - gutterThreshold: [1, 20, "Gutter threshold"], - maxIterations: [1, 500, "Max iterations"], - cooldownSeconds: [0, 300, "Cooldown"], - maxVerificationRetries: [1, 10, "Verification retries"], - reviewPollingInterval: [10, 600, "Poll interval"], - reviewTimeout: [60, 3600, "Timeout"], -} as const; - -export function validateConfig(values: { - executeAgent: string; - gutterThreshold: number; - maxIterations: number; - cooldownSeconds: number; - maxVerificationRetries: number; - reviewPollingInterval: number; - reviewTimeout: number; -}): ConfigureErrors { - const nextErrors: ConfigureErrors = {}; - if (!values.executeAgent.trim()) nextErrors.executeAgent = "Execute agent is required"; - for (const [field, value] of [ - ["gutterThreshold", values.gutterThreshold], - ["maxIterations", values.maxIterations], - ["cooldownSeconds", values.cooldownSeconds], - ["maxVerificationRetries", values.maxVerificationRetries], - ["reviewPollingInterval", values.reviewPollingInterval], - ["reviewTimeout", values.reviewTimeout], - ] as const) { - const [min, max, label] = LIMITS[field]; - if (!Number.isFinite(value) || value < min || value > max) { - nextErrors[field] = `${label} must be between ${min} and ${max}`; - } - } - return nextErrors; -} diff --git a/src/hooks/useAtomizerPipeline.ts b/src/hooks/useAtomizerPipeline.ts index 11079ff..6153c31 100644 --- a/src/hooks/useAtomizerPipeline.ts +++ b/src/hooks/useAtomizerPipeline.ts @@ -11,13 +11,6 @@ import { useWizardStore } from "../stores/wizardStore"; export type PipelineStage = { number: number; label: string; status: StageStatus }; -const INITIAL_STAGES: PipelineStage[] = [ - { number: 1, label: "Summarize", status: "pending" }, - { number: 2, label: "Chunk", status: "pending" }, - { number: 3, label: "Atomize", status: "pending" }, - { number: 4, label: "Merge", status: "pending" }, -]; - export const STAGE_BADGE: Record = { pending: "neutral", running: "info", @@ -32,11 +25,16 @@ export const STAGE_LABEL: Record = { error: "Error", }; -const atomizerPromiseByProject = new Map>(); +const PLACEHOLDER_STAGES: PipelineStage[] = [ + { number: 1, label: "Summarize", status: "pending" }, + { number: 2, label: "Chunk", status: "pending" }, + { number: 3, label: "Atomize", status: "pending" }, + { number: 4, label: "Merge", status: "pending" }, +]; export function useAtomizerPipeline(projectId: string | undefined) { const startedRef = useRef(false); - const [stages, setStages] = useState(INITIAL_STAGES); + const [stages, setStages] = useState(PLACEHOLDER_STAGES); const [atomizeStarted, setAtomizeStarted] = useState(false); const [atomizeError, setAtomizeError] = useState(null); const [stageMessage, setStageMessage] = useState(""); @@ -46,77 +44,89 @@ export function useAtomizerPipeline(projectId: string | undefined) { if (!projectId) return; const snap = useWizardStore.getState(); if (!snap.projectData.name) return; - if (snap.stories.length > 0 && snap.projectId === projectId) { + + let disposed = false; + + getAtomizerPipelineState(projectId) + .then((snapshot) => { + if (disposed) return; + + if (snapshot && snapshot.stages.length > 0) { + const backendStages = snapshot.stages.map((stage) => ({ + number: stage.number, + label: stage.label, + status: stage.status, + })); + const allDone = backendStages.every((stage) => stage.status === "done"); + setAtomizeStarted(true); + setStages(backendStages); + setElapsedMs(snapshot.elapsedMs); + if (snapshot.error) setAtomizeError(snapshot.error); + if (allDone) { + startedRef.current = true; + setStageMessage("Loaded existing pipeline state."); + return; + } + } + + launchAtomizer(snap, disposed); + }) + .catch(() => { + if (!disposed) launchAtomizer(snap, disposed); + }); + + function launchAtomizer(wizardSnap: typeof snap, alreadyDisposed: boolean) { + if (alreadyDisposed || startedRef.current || !projectId) return; + const resolvedProjectId = projectId; startedRef.current = true; setAtomizeStarted(true); - setStages(INITIAL_STAGES.map((stage) => ({ ...stage, status: "done" }))); - setStageMessage("Loaded existing stories."); - return; - } + setStageMessage("Summarizing plan..."); + setStages(PLACEHOLDER_STAGES.map((stage) => ({ ...stage, status: stage.number === 1 ? "running" : "pending" }))); - getAtomizerPipelineState(projectId).then((snapshot) => { - if (!snapshot) return; - setAtomizeStarted(true); - setStages(snapshot.stages.map((stage) => ({ - number: stage.number, - label: stage.label, - status: stage.status, - }))); - setElapsedMs(snapshot.elapsedMs); - if (snapshot.error) setAtomizeError(snapshot.error); - }); - - startedRef.current = true; - setAtomizeStarted(true); - setStageMessage("Summarizing plan..."); - setStages(INITIAL_STAGES.map((stage) => ({ ...stage, status: stage.number === 1 ? "running" : "pending" }))); - let disposed = false; - const unlistenPromise = onAtomizationProgress((progress: AtomizeProgress) => { - if (progress.projectId !== projectId) return; - setStageMessage(progress.message); - setElapsedMs(progress.elapsedMs); - setStages((previous) => previous.map((stage) => - stage.number === progress.stage - ? { ...stage, status: "running" } - : stage.number < progress.stage - ? { ...stage, status: "done" } - : stage, - )); - }); - const releaseListener = () => { - unlistenPromise.then((unlisten) => unlisten()); - }; - const inFlightPromise = atomizerPromiseByProject.get(projectId) ?? runAtomizer({ - projectId, - projectName: snap.projectData.name, - projectDir: snap.projectData.workingDirectory, - agent: snap.projectData.planAgent, - model: snap.projectData.planModel, - effort: snap.projectData.planEffort, - }); - if (!atomizerPromiseByProject.has(projectId)) { - atomizerPromiseByProject.set(projectId, inFlightPromise); + const unlistenPromise = onAtomizationProgress((progress: AtomizeProgress) => { + if (progress.projectId !== resolvedProjectId) return; + setStageMessage(progress.message); + setElapsedMs(progress.elapsedMs); + setStages((previous) => previous.map((stage) => + stage.number === progress.stage + ? { ...stage, status: "running" } + : stage.number < progress.stage + ? { ...stage, status: "done" } + : stage, + )); + }); + + runAtomizer({ + projectId: resolvedProjectId, + projectName: wizardSnap.projectData.name, + projectDir: wizardSnap.projectData.workingDirectory, + agent: wizardSnap.projectData.planAgent, + model: wizardSnap.projectData.planModel, + effort: wizardSnap.projectData.planEffort, + }).then((prd: Prd) => { + if (disposed) return; + useWizardStore.getState().setStories(prd.stories); + setStages((previous) => previous.map((stage) => ({ ...stage, status: "done" }))); + setStageMessage(`Done. ${prd.stories.length} stories generated.`); + }).catch((error: unknown) => { + if (disposed) return; + setAtomizeError(error instanceof Error ? error.message : String(error)); + setStages((previous) => previous.map((stage) => + stage.status === "running" ? { ...stage, status: "error" } : stage, + )); + }); + + cleanupRef.current = () => { + unlistenPromise.then((unlisten) => unlisten()); + }; } - inFlightPromise.then((prd) => { - if (disposed) return; - useWizardStore.getState().setStories(prd.stories); - setStages(INITIAL_STAGES.map((stage) => ({ ...stage, status: "done" }))); - setStageMessage(`Done. ${prd.stories.length} stories generated.`); - }).catch((error: unknown) => { - if (disposed) return; - setAtomizeError(error instanceof Error ? error.message : String(error)); - setStages((previous) => previous.map((stage) => - stage.status === "running" ? { ...stage, status: "error" } : stage, - )); - }).finally(() => { - if (atomizerPromiseByProject.get(projectId) === inFlightPromise) { - atomizerPromiseByProject.delete(projectId); - } - }); + + const cleanupRef = { current: () => {} }; + return () => { disposed = true; startedRef.current = false; - releaseListener(); + cleanupRef.current(); }; }, [projectId]); diff --git a/src/hooks/useNotificationIngestion.ts b/src/hooks/useNotificationIngestion.ts index a5d2360..68164cd 100644 --- a/src/hooks/useNotificationIngestion.ts +++ b/src/hooks/useNotificationIngestion.ts @@ -16,46 +16,42 @@ interface EventPayload { sessionId?: string; } -const EVENT_MAP: Array<{ +interface NotificationEntry { event: string; type: NotificationType; - title: (p: EventPayload) => string; - message: (p: EventPayload) => string; - filter?: (p: EventPayload) => boolean; -}> = [ + title: (payload: EventPayload) => string; + message: (payload: EventPayload) => string; +} + +const EVENT_MAP: NotificationEntry[] = [ { event: "loop:iteration-completed", type: "story_completed", title: () => "Story completed", - message: (p) => `Story ${p.storyId ?? "unknown"} passed verification.`, - filter: (p) => { - const r = p as Record; - return r.result === "success" || r.outcome === "passed"; - }, + message: (payload) => `Story ${payload.storyId ?? "unknown"} passed verification.`, }, { event: "loop:story-skipped", type: "story_blocked", title: () => "Story skipped", - message: (p) => - `${p.storyId ?? "Story"}: ${p.reason ?? "exceeded failure threshold"}`, + message: (payload) => + `${payload.storyId ?? "Story"}: ${payload.reason ?? "exceeded failure threshold"}`, }, { event: "loop:verification-failed", type: "loop_error", - title: (p) => - p.circuitBreaker ? "Circuit breaker triggered" : "Verification failed", - message: (p) => - p.circuitBreaker - ? `${p.storyId ?? "Story"}: same error repeated — skipping` - : `${p.storyId ?? "Story"}: attempt ${p.attempt ?? "?"} failed (${p.errorCount ?? 0} errors)`, - filter: (p) => p.circuitBreaker === true || (p.attempt ?? 0) >= 3, + title: (payload) => + payload.circuitBreaker ? "Circuit breaker triggered" : "Verification failed", + message: (payload) => + payload.circuitBreaker + ? `${payload.storyId ?? "Story"}: same error repeated` + : `${payload.storyId ?? "Story"}: attempt ${payload.attempt ?? "?"} failed (${payload.errorCount ?? 0} errors)`, }, { event: "loop:rate-limit-detected", type: "rate_limited", title: () => "Rate limited", - message: (p) => `Agent ${p.agent ?? ""} hit rate limit — switching.`, + message: (payload) => `Agent ${payload.agent ?? ""} hit rate limit.`, }, { event: "loop:session-ended", @@ -66,7 +62,7 @@ const EVENT_MAP: Array<{ ]; export function useNotificationIngestion() { - const addNotification = useNotificationStore((s) => s.addNotification); + const addNotification = useNotificationStore((state) => state.addNotification); useEffect(() => { const unlisteners: Array void>> = []; @@ -76,7 +72,6 @@ export function useNotificationIngestion() { listen(entry.event, (event) => { const payload = event.payload; if (!payload?.projectId) return; - if (entry.filter && !entry.filter(payload)) return; addNotification({ projectId: payload.projectId, diff --git a/src/hooks/usePlanEvents.ts b/src/hooks/usePlanEvents.ts index d96fbb6..b29e50f 100644 --- a/src/hooks/usePlanEvents.ts +++ b/src/hooks/usePlanEvents.ts @@ -1,37 +1,12 @@ import { useEffect, useRef, useState } from "react"; import { onPlanActivityBatch, onPlanComplete, onPlanError, onPlanHeartbeat, - queryPlanStatus, loadExistingPlan, - type PlanActivityBatchPayload, + queryPlanStatus, } from "../lib/tauri"; import { useWizardStore, type PlanEvent } from "../stores/wizardStore"; -import { - isNoisePlanLine, normalizePlanLine, -} from "../lib/plan-stream-filters"; - -function processBatch( - payload: PlanActivityBatchPayload, - lastLineRef: React.RefObject, -): PlanEvent[] { - const filtered: PlanEvent[] = []; - for (const raw of payload.events) { - const normalizedContent = normalizePlanLine(raw.content); - if (isNoisePlanLine(normalizedContent)) continue; - const signature = `${raw.kind}:${normalizedContent}`; - if (lastLineRef.current === signature) continue; - lastLineRef.current = signature; - filtered.push({ - kind: raw.kind, - content: normalizedContent, - timestamp: raw.timestamp, - }); - } - return filtered; -} export function usePlanEvents(projectId: string | undefined) { const [planError, setPlanError] = useState(null); - const lastLineRef = useRef(""); const mountIdRef = useRef(0); useEffect(() => { @@ -57,11 +32,15 @@ export function usePlanEvents(projectId: string | undefined) { onPlanActivityBatch((payload) => { if (isCancelled() || payload.projectId !== projectId) return; - const filtered = processBatch(payload, lastLineRef); const current = useWizardStore.getState(); - if (filtered.length > 0) { - current.appendPlanEvents(filtered); + if (payload.events.length > 0) { + const events: PlanEvent[] = payload.events.map((raw) => ({ + kind: raw.kind, + content: raw.content, + timestamp: raw.timestamp, + })); + current.appendPlanEvents(events); } if (payload.planContentDelta) { current.appendPlanContentDelta(payload.planContentDelta); @@ -79,18 +58,10 @@ export function usePlanEvents(projectId: string | undefined) { const current = useWizardStore.getState(); current.setPlanRunning(false); setPlanError(null); - loadExistingPlan(projectId) - .then((diskContent) => { - if (isCancelled()) return; - const store = useWizardStore.getState(); - if (diskContent && diskContent.length > 0) { - useWizardStore.setState({ planContent: diskContent }); - } - store.setPlanComplete(true); - }) - .catch(() => { - useWizardStore.getState().setPlanComplete(true); - }); + if (payload.finalContent && payload.finalContent.length > 0) { + useWizardStore.setState({ planContent: payload.finalContent }); + } + current.setPlanComplete(true); }).then((unlisten) => { if (isCancelled()) { unlisten(); return; } unlistenFns.push(unlisten); @@ -120,28 +91,5 @@ export function usePlanEvents(projectId: string | undefined) { }; }, [projectId]); - useEffect(() => { - if (!projectId) return; - const { planRunning } = useWizardStore.getState(); - if (planRunning) return; - - queryPlanStatus(projectId) - .then((info) => { - if (!info) { - loadExistingPlan(projectId) - .then((content) => { - if (!content) return; - const current = useWizardStore.getState(); - if (!current.planContent && content.length > 0) { - current.appendPlanContent(content); - current.setPlanComplete(true); - } - }) - .catch(() => {}); - } - }) - .catch(() => {}); - }, [projectId]); - return { planError, clearError: () => setPlanError(null) }; } diff --git a/src/hooks/usePlanOrchestration.ts b/src/hooks/usePlanOrchestration.ts index 6222c96..57b3b93 100644 --- a/src/hooks/usePlanOrchestration.ts +++ b/src/hooks/usePlanOrchestration.ts @@ -2,31 +2,27 @@ import { useRef, useState } from "react"; import { useNavigate } from "react-router"; import { useWizardStore } from "../stores/wizardStore"; import { - loadExistingPlan, queryPlanStatus, saveDraft, - savePlan, startPlan, stopPlan, writeToPlan, + advanceWizardStep, loadExistingPlan, queryPlanStatus, saveWizardDraft, + savePlan, startPlan, stopPlan, writeToPlan, replan, } from "../lib/tauri"; -import { buildDraftPayload } from "../lib/draft-payload"; export function usePlanOrchestration(projectId: string | undefined) { const navigate = useNavigate(); const projectData = useWizardStore((state) => state.projectData); - const advanceStep = useWizardStore((state) => state.advanceStep); const [userInput, setUserInput] = useState(""); const [isEditing, setIsEditing] = useState(false); const [editedPlan, setEditedPlan] = useState(""); const [initDone, setInitDone] = useState(false); const [showResumePrompt, setShowResumePrompt] = useState(false); - const feedbackPromptRef = useRef(null); + const launchingRef = useRef(false); async function launchPlan() { const storeRunning = useWizardStore.getState().planRunning; - if (!projectId || !projectData.name || storeRunning) return; + if (!projectId || !projectData.name || storeRunning || launchingRef.current) return; + launchingRef.current = true; useWizardStore.getState().setPlanRunning(true); - const effectivePrompt = feedbackPromptRef.current ?? projectData.description; - feedbackPromptRef.current = null; - try { await startPlan({ projectId, @@ -34,10 +30,12 @@ export function usePlanOrchestration(projectId: string | undefined) { agent: projectData.planAgent, model: projectData.planModel, effort: projectData.planEffort, - initialPrompt: effectivePrompt, + initialPrompt: projectData.description, }); } catch { useWizardStore.getState().setPlanRunning(false); + } finally { + launchingRef.current = false; } } @@ -85,17 +83,16 @@ export function usePlanOrchestration(projectId: string | undefined) { async function handleSendInput() { if (!userInput.trim() || !projectId) return; - const { planComplete, planContent } = useWizardStore.getState(); + const { planComplete } = useWizardStore.getState(); if (planComplete) { const feedback = userInput.trim(); setUserInput(""); - feedbackPromptRef.current = - `${projectData.description}\n\nPrevious plan:\n${planContent}\n\nUser feedback:\n${feedback}`; - await stopPlan(projectId).catch(() => {}); - useWizardStore.getState().setPlanRunning(false); + useWizardStore.getState().setPlanRunning(true); useWizardStore.getState().setPlanComplete(false); useWizardStore.setState({ planEvents: [], planContent: "", stories: [] }); - void launchPlan(); + await replan(projectId, feedback).catch(() => { + useWizardStore.getState().setPlanRunning(false); + }); return; } await writeToPlan(projectId, userInput.trim()).catch(() => {}); @@ -119,7 +116,6 @@ export function usePlanOrchestration(projectId: string | undefined) { useWizardStore.getState().setPlanComplete(false); setIsEditing(false); setEditedPlan(""); - feedbackPromptRef.current = null; useWizardStore.setState({ planEvents: [], planContent: "", stories: [] }); void launchPlan(); } @@ -129,8 +125,8 @@ export function usePlanOrchestration(projectId: string | undefined) { const { planContent } = useWizardStore.getState(); const contentToSave = isEditing ? editedPlan : planContent; if (contentToSave) await savePlan(projectId, contentToSave).catch(() => {}); - await saveDraft(projectId, buildDraftPayload(projectId, "atomize")).catch(() => {}); - advanceStep(3); + await saveWizardDraft(projectId, "atomize").catch(() => {}); + await advanceWizardStep(3).catch(() => {}); navigate(`/new/atomize/${projectId}`); } diff --git a/src/lib/draft-payload.ts b/src/lib/draft-payload.ts deleted file mode 100644 index fae8b29..0000000 --- a/src/lib/draft-payload.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { useWizardStore } from "../stores/wizardStore"; - -interface DraftPayload { - version: number; - projectId: string; - currentStep: string; - describe: { - name: string; - description: string; - workingDirectory: string; - planAgent: string; - planModel: string | null; - planEffort: string | null; - }; - plan: { completed: boolean }; - atomize: { storiesCount: number }; - configure: ReturnType["config"]; -} - -export function buildDraftPayload(projectId: string, currentStep: string): string { - const state = useWizardStore.getState(); - const draft: DraftPayload = { - version: 1, - projectId, - currentStep, - describe: { - name: state.projectData.name, - description: state.projectData.description, - workingDirectory: state.projectData.workingDirectory, - planAgent: state.projectData.planAgent, - planModel: state.projectData.planModel, - planEffort: state.projectData.planEffort, - }, - plan: { completed: state.planComplete }, - atomize: { storiesCount: state.stories.length }, - configure: state.config, - }; - return JSON.stringify(draft, null, 2); -} diff --git a/src/lib/ipc/types.ts b/src/lib/ipc/types.ts index 71dbee9..6a8cfa3 100644 --- a/src/lib/ipc/types.ts +++ b/src/lib/ipc/types.ts @@ -193,6 +193,7 @@ export interface PlanActivityPayload { export interface PlanTerminalPayload { projectId: string; detail: string; + finalContent?: string; } export interface PlanActivityBatchPayload { diff --git a/src/lib/ipc/wizard-logic.ts b/src/lib/ipc/wizard-logic.ts new file mode 100644 index 0000000..9742e25 --- /dev/null +++ b/src/lib/ipc/wizard-logic.ts @@ -0,0 +1,95 @@ +import { invoke } from "@tauri-apps/api/core"; +import type { ProjectConfig } from "./types"; +import type { UserStory } from "../../types/wizard"; + +export interface AdvanceWizardResult { + currentStep: number; + highestStep: number; + stepName: string; +} + +export interface ConfigLimits { + gutterThreshold: [number, number]; + maxIterations: [number, number]; + cooldownSeconds: [number, number]; + maxVerificationRetries: [number, number]; + reviewPollingInterval: [number, number]; + reviewTimeout: [number, number]; +} + +export interface ConfigDefaultsResponse { + config: ProjectConfig; + limits: ConfigLimits; +} + +export interface ValidationErrors { + errors: Record; +} + +export interface LaunchReadiness { + ready: boolean; + issues: string[]; +} + +export interface StoriesResponse { + stories: UserStory[]; + totalEstimatedMinutes: number; +} + +export async function advanceWizardStep(targetStep: number): Promise { + return invoke("advance_wizard_step", { targetStep }); +} + +export async function getDefaultConfig(): Promise { + return invoke("get_default_config"); +} + +export async function validateProjectConfig(configJson: string): Promise { + return invoke("validate_project_config", { configJson }); +} + +export async function validateDescribeInput(inputJson: string): Promise { + return invoke("validate_describe_input", { inputJson }); +} + +export async function validateLaunchReadiness(projectId: string): Promise { + return invoke("validate_launch_readiness", { projectId }); +} + +export async function addStory(projectId: string): Promise { + return invoke("add_story", { projectId }); +} + +export async function updateStoryBackend( + projectId: string, storyId: string, patchJson: string, +): Promise { + return invoke("update_story", { projectId, storyId, patchJson }); +} + +export async function removeStoryBackend( + projectId: string, storyId: string, +): Promise { + return invoke("remove_story", { projectId, storyId }); +} + +export async function reorderStoriesBackend( + projectId: string, fromIndex: number, toIndex: number, +): Promise { + return invoke("reorder_stories", { projectId, fromIndex, toIndex }); +} + +export async function getStories(projectId: string): Promise { + return invoke("get_stories", { projectId }); +} + +export async function saveWizardDraft(projectId: string, stepName: string): Promise { + return invoke("save_wizard_draft", { projectId, stepName }); +} + +export async function replan(projectId: string, feedback: string): Promise { + return invoke("replan", { projectId, feedback }); +} + +export async function markWizardStale(projectId: string, fromStep: number): Promise { + return invoke("mark_wizard_stale", { projectId, fromStep }); +} diff --git a/src/lib/plan-stream-filters.ts b/src/lib/plan-stream-filters.ts deleted file mode 100644 index 2efa61c..0000000 --- a/src/lib/plan-stream-filters.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { PlanEvent } from "../types/wizard"; - -const EXACT_NOISE = new Set([ - "exec", "codex", "--------", "---", "WAIT", "reason", "effort", "found", -]); - -const PREFIX_NOISE = [ - "OpenAI Codex", "workdir:", "model:", "provider:", "approval:", "sandbox:", - "reasoning effort:", "reasoning summaries:", "session id:", - "user You are a senior software architect.", - "error: unexpected argument", "tip: to pass", "Usage: codex", - "For more information", "Usage:", "tip:", - "Reading additional input from stdin", "Warning: no stdin data received", - "If piping from a slow command", -]; - -export function isNoisePlanLine(content: string): boolean { - const trimmed = content.trim(); - if (!trimmed || EXACT_NOISE.has(trimmed)) return true; - if (PREFIX_NOISE.some((prefix) => trimmed.startsWith(prefix))) return true; - return false; -} - -export function normalizePlanLine(content: string): string { - const trimmed = content.trim(); - if (trimmed.startsWith("codex ")) return trimmed.slice(6).trim(); - if (trimmed.startsWith("user ")) return trimmed.slice(5).trim(); - return trimmed; -} - -export function shouldRenderPlanEvent(event: PlanEvent): boolean { - return !isNoisePlanLine(event.content); -} diff --git a/src/lib/tauri.ts b/src/lib/tauri.ts index cf67b04..c97bf29 100644 --- a/src/lib/tauri.ts +++ b/src/lib/tauri.ts @@ -50,6 +50,19 @@ export { onPromptBuilt, onStorySkipped, } from "./ipc/loop"; +export type { + AdvanceWizardResult, ConfigDefaultsResponse, ConfigLimits, + ValidationErrors, LaunchReadiness, StoriesResponse, +} from "./ipc/wizard-logic"; + +export { + advanceWizardStep, getDefaultConfig, validateProjectConfig, + validateDescribeInput, validateLaunchReadiness, + addStory, updateStoryBackend, removeStoryBackend, + reorderStoriesBackend, getStories, saveWizardDraft, + replan, markWizardStale, +} from "./ipc/wizard-logic"; + export interface ConnectionRepo { repoPath: string; displayName: string | null; diff --git a/src/stores/askStore.ts b/src/stores/askStore.ts index f39d58d..b5ddd20 100644 --- a/src/stores/askStore.ts +++ b/src/stores/askStore.ts @@ -9,6 +9,7 @@ interface AskState { isAsking: boolean; selectedAgent: string; selectedModel: string | null; + editPrefill: string; switchProject: (projectId: string) => void; setMessages: (messages: AskMessage[]) => void; addMessage: (message: AskMessage) => void; @@ -18,30 +19,27 @@ interface AskState { setIsAsking: (asking: boolean) => void; setSelectedAgent: (agent: string) => void; setSelectedModel: (model: string | null) => void; - editPrefill: string; setEditPrefill: (value: string) => void; finalize: (message: AskMessage) => void; reset: () => void; } -export const useAskStore = create()((set, get) => ({ - activeProjectId: null, - messages: [], +const INITIAL_STATE = { + activeProjectId: null as string | null, + messages: [] as AskMessage[], streamingContent: "", - streamingMessageId: null, + streamingMessageId: null as string | null, isAsking: false, selectedAgent: "claude", - selectedModel: null, + selectedModel: null as string | null, editPrefill: "", +}; + +export const useAskStore = create()((set, get) => ({ + ...INITIAL_STATE, switchProject: (projectId) => { if (get().activeProjectId === projectId) return; - set({ - activeProjectId: projectId, - messages: [], - streamingContent: "", - streamingMessageId: null, - isAsking: false, - }); + set({ ...INITIAL_STATE, activeProjectId: projectId, selectedAgent: get().selectedAgent }); }, setMessages: (messages) => set({ messages }), addMessage: (message) => @@ -61,12 +59,5 @@ export const useAskStore = create()((set, get) => ({ streamingMessageId: null, isAsking: false, })), - reset: () => - set({ - activeProjectId: null, - messages: [], - streamingContent: "", - streamingMessageId: null, - isAsking: false, - }), + reset: () => set(INITIAL_STATE), })); diff --git a/src/stores/slices/wizard-config.ts b/src/stores/slices/wizard-config.ts index 7813860..3421684 100644 --- a/src/stores/slices/wizard-config.ts +++ b/src/stores/slices/wizard-config.ts @@ -3,26 +3,30 @@ import type { WizardConfig } from "../../types/wizard"; export interface WizardConfigSlice { config: WizardConfig; + configLoaded: boolean; setConfig: (config: Partial) => void; + setFullConfig: (config: WizardConfig) => void; + setConfigLoaded: (loaded: boolean) => void; } -export const DEFAULT_CONFIG: WizardConfig = { - executeAgent: "cursor", +const PLACEHOLDER_CONFIG: WizardConfig = { + executeAgent: "", executeModel: null, executeEffort: null, - fallbackChain: ["claude"], - gutterThreshold: 3, - maxIterations: 50, - cooldownSeconds: 5, + fallbackChain: [], + gutterThreshold: 0, + maxIterations: 0, + cooldownSeconds: 0, testCommand: "", - maxVerificationRetries: 3, + maxVerificationRetries: 0, scmProvider: "auto", - reviewPollingInterval: 60, - reviewTimeout: 600, + reviewPollingInterval: 0, + reviewTimeout: 0, }; export const CONFIG_DEFAULTS = { - config: DEFAULT_CONFIG, + config: PLACEHOLDER_CONFIG, + configLoaded: false, }; export const createConfigSlice: StateCreator< @@ -34,4 +38,6 @@ export const createConfigSlice: StateCreator< ...CONFIG_DEFAULTS, setConfig: (config) => set((state) => ({ config: { ...state.config, ...config } })), + setFullConfig: (config) => set({ config, configLoaded: true }), + setConfigLoaded: (loaded) => set({ configLoaded: loaded }), }); diff --git a/src/stores/slices/wizard-step.ts b/src/stores/slices/wizard-step.ts index 82c287e..322d44f 100644 --- a/src/stores/slices/wizard-step.ts +++ b/src/stores/slices/wizard-step.ts @@ -17,7 +17,7 @@ export interface WizardStepSlice { projectId: string | null; projectData: WizardProjectData; setStep: (step: number) => void; - advanceStep: (step: number) => void; + setHighestStep: (step: number) => void; markStale: (fromStep: number) => void; clearStale: () => void; setProjectId: (id: string) => void; @@ -40,10 +40,8 @@ export const createStepSlice: StateCreator< > = (set) => ({ ...STEP_DEFAULTS, setStep: (step) => set({ currentStep: step }), - advanceStep: (step) => set((state) => ({ - currentStep: step, + setHighestStep: (step) => set((state) => ({ highestStep: Math.max(state.highestStep, step), - staleFromStep: null, })), markStale: (fromStep) => set({ staleFromStep: fromStep }), clearStale: () => set({ staleFromStep: null }), diff --git a/src/stores/slices/wizard-stories.ts b/src/stores/slices/wizard-stories.ts index 190541b..64c9a48 100644 --- a/src/stores/slices/wizard-stories.ts +++ b/src/stores/slices/wizard-stories.ts @@ -4,10 +4,6 @@ import type { UserStory } from "../../types/wizard"; export interface WizardStoriesSlice { stories: UserStory[]; setStories: (stories: UserStory[]) => void; - updateStory: (id: string, patch: Partial) => void; - reorderStories: (fromIndex: number, toIndex: number) => void; - addStory: (story: UserStory) => void; - removeStory: (id: string) => void; } export const STORIES_DEFAULTS = { @@ -22,23 +18,4 @@ export const createStoriesSlice: StateCreator< > = (set) => ({ ...STORIES_DEFAULTS, setStories: (stories) => set({ stories }), - updateStory: (id, patch) => - set((state) => ({ - stories: state.stories.map((story) => - story.id === id ? { ...story, ...patch } : story, - ), - })), - reorderStories: (fromIndex, toIndex) => - set((state) => { - const reordered = [...state.stories]; - const [moved] = reordered.splice(fromIndex, 1); - reordered.splice(toIndex, 0, moved); - return { stories: reordered }; - }), - addStory: (story) => - set((state) => ({ stories: [...state.stories, story] })), - removeStory: (id) => - set((state) => ({ - stories: state.stories.filter((story) => story.id !== id), - })), }); diff --git a/src/stores/wizardStore.ts b/src/stores/wizardStore.ts index 1609750..deb1ea4 100644 --- a/src/stores/wizardStore.ts +++ b/src/stores/wizardStore.ts @@ -21,7 +21,7 @@ export const useWizardStore = create()((...args) => ({ ...STEP_DEFAULTS, ...PLAN_DEFAULTS, ...STORIES_DEFAULTS, - config: CONFIG_DEFAULTS.config, + ...CONFIG_DEFAULTS, }); }, })); From e038260c7f0afde423b0788d557efcf7681955a7 Mon Sep 17 00:00:00 2001 From: Jorge Alexander Taberoa Jimenez Date: Mon, 13 Apr 2026 08:46:34 -0600 Subject: [PATCH 26/61] chore(core): enforce zero-warning lint baseline Standardize linting and formatting across frontend and Rust so Biome, TypeScript, rustfmt, and clippy run clean in strict mode through hooks and CI. --- .editorconfig | 22 + .github/workflows/test.yml | 22 + Cargo.toml | 37 ++ biome.json | 73 +++ bun.lock | 42 ++ crates/app-services/Cargo.toml | 3 + crates/app-services/src/monitor.rs | 5 +- crates/app-services/src/session.rs | 9 +- crates/loopforge-app-core/Cargo.toml | 3 + crates/loopforge-app-core/src/events.rs | 4 +- crates/ralph-core/Cargo.toml | 3 + crates/ralph-core/src/config.rs | 26 +- .../src/detection/failure_memory.rs | 25 +- .../ralph-core/src/detection/loop_detector.rs | 18 +- crates/ralph-core/src/detection/stall.rs | 11 +- crates/ralph-core/src/events.rs | 6 + crates/ralph-core/src/git.rs | 4 +- crates/ralph-core/src/guardrails.rs | 4 +- crates/ralph-core/src/health/manager.rs | 20 +- crates/ralph-core/src/health/mod.rs | 5 +- crates/ralph-core/src/health/service.rs | 7 +- crates/ralph-core/src/loop_engine/helpers.rs | 6 +- .../ralph-core/src/loop_engine/iteration.rs | 63 +- crates/ralph-core/src/loop_engine/mod.rs | 40 +- crates/ralph-core/src/loop_engine/outcome.rs | 21 +- .../src/loop_engine/prd_lifecycle.rs | 2 +- .../src/loop_engine/rate_limiter.rs | 2 +- crates/ralph-core/src/plugin.rs | 104 +++- crates/ralph-core/src/ports.rs | 10 +- crates/ralph-core/src/prd.rs | 15 +- crates/ralph-core/src/prompt.rs | 29 +- crates/ralph-core/src/providers/cli.rs | 9 +- crates/ralph-core/src/providers/fixture.rs | 4 +- crates/ralph-core/src/providers/mod.rs | 6 +- crates/ralph-core/src/scheduler/mod.rs | 4 +- crates/ralph-core/src/state.rs | 2 +- crates/ralph-core/src/verification.rs | 54 +- crates/ralph-core/src/worktree.rs | 32 +- .../ralph-core/tests/loop_fixture_runner.rs | 16 +- crates/ralph-core/tests/parallel_scheduler.rs | 4 +- crates/ralph-core/tests/scheduler_merge.rs | 4 +- .../ralph-core/tests/scheduler_ready_sets.rs | 4 +- crates/ralph-core/tests/worktree.rs | 83 ++- crates/ralph-core/tests/worktree_runner.rs | 6 +- crates/ralph-core/tests/worktree_scheduler.rs | 23 +- lefthook.yml | 58 ++ package.json | 11 +- rustfmt.toml | 3 + src-tauri/Cargo.toml | 3 + src-tauri/build.rs | 2 +- src-tauri/src/activity/classifier/codex.rs | 15 +- src-tauri/src/activity/classifier/mod.rs | 26 +- src-tauri/src/agent_profiles.rs | 24 +- src-tauri/src/agents.rs | 80 ++- src-tauri/src/ask_engine/args.rs | 7 +- src-tauri/src/ask_engine/context.rs | 57 +- src-tauri/src/ask_engine/fixture.rs | 17 +- src-tauri/src/ask_engine/session.rs | 10 +- src-tauri/src/ask_engine/stream.rs | 66 ++- src-tauri/src/ask_engine/types.rs | 9 + src-tauri/src/atomizer/agent_args.rs | 10 +- src-tauri/src/atomizer/mod.rs | 5 +- src-tauri/src/atomizer/progress.rs | 12 +- src-tauri/src/atomizer/run.rs | 131 +++- src-tauri/src/atomizer/stages.rs | 87 ++- src-tauri/src/atomizer/tests_validation.rs | 41 +- src-tauri/src/atomizer/types.rs | 41 +- src-tauri/src/commands/ask.rs | 40 +- src-tauri/src/commands/display_vocabulary.rs | 343 +++++++++++ src-tauri/src/commands/execution.rs | 42 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/commands/notifications.rs | 145 +++++ src-tauri/src/commands/planning.rs | 159 ++++- src-tauri/src/commands/projects.rs | 50 +- src-tauri/src/commands/projects_lifecycle.rs | 5 +- src-tauri/src/commands/projects_listing.rs | 76 ++- src-tauri/src/commands/projects_wizard.rs | 13 +- src-tauri/src/commands/wizard_logic.rs | 560 +++++++++++++++++- src-tauri/src/connections.rs | 162 ++--- src-tauri/src/contract_tests/artifacts.rs | 4 +- src-tauri/src/contract_tests/harness.rs | 24 +- .../src/contract_tests/invoke_fixtures.rs | 4 +- .../src/contract_tests/invoke_handler.rs | 3 +- .../src/contract_tests/invoke_harness.rs | 6 +- .../src/contract_tests/merge_coordinator.rs | 28 +- src-tauri/src/contract_tests/support.rs | 4 +- .../src/contract_tests/wizard_persistence.rs | 9 +- src-tauri/src/diagnostic_parser.rs | 48 +- src-tauri/src/ephemeral_query.rs | 34 +- src-tauri/src/events.rs | 3 + src-tauri/src/invoke.rs | 432 ++++++++++++-- src-tauri/src/invoke_contract.rs | 9 +- src-tauri/src/lib.rs | 14 +- src-tauri/src/loop_manager/args.rs | 6 + src-tauri/src/loop_manager/event_sink.rs | 70 ++- src-tauri/src/loop_manager/helpers.rs | 2 +- src-tauri/src/loop_manager/mod.rs | 2 +- src-tauri/src/loop_manager/provider/codex.rs | 4 +- .../src/loop_manager/provider/command_args.rs | 8 +- .../src/loop_manager/provider/lifecycle.rs | 76 ++- src-tauri/src/loop_manager/provider/mod.rs | 7 +- src-tauri/src/loop_manager/provider/runner.rs | 22 +- src-tauri/src/loop_manager/provider/tests.rs | 9 +- src-tauri/src/loop_manager/start.rs | 34 +- src-tauri/src/loop_manager/start_finalize.rs | 38 +- src-tauri/src/loop_manager/start_resolve.rs | 52 +- src-tauri/src/loop_manager/stats.rs | 23 +- src-tauri/src/main.rs | 2 +- src-tauri/src/models.rs | 4 + src-tauri/src/plan_engine/args.rs | 11 +- src-tauri/src/plan_engine/filters.rs | 15 +- src-tauri/src/plan_engine/fixture.rs | 6 +- src-tauri/src/plan_engine/helpers.rs | 3 +- src-tauri/src/plan_engine/mod.rs | 2 +- src-tauri/src/plan_engine/monitor.rs | 11 +- src-tauri/src/plan_engine/output.rs | 13 +- src-tauri/src/plan_engine/payloads.rs | 2 + src-tauri/src/plan_engine/start.rs | 259 ++++++-- src-tauri/src/plan_engine/write.rs | 4 +- src-tauri/src/plugin_registry.rs | 4 +- src-tauri/src/projects/artifacts.rs | 5 +- src-tauri/src/projects/catalog.rs | 34 +- src-tauri/src/projects/control.rs | 68 ++- src-tauri/src/projects/core_types.rs | 36 ++ src-tauri/src/projects/documents.rs | 66 ++- src-tauri/src/projects/mod.rs | 3 +- src-tauri/src/projects/notification_filter.rs | 89 ++- src-tauri/src/projects/notifications.rs | 69 ++- src-tauri/src/projects/reconcile.rs | 9 +- src-tauri/src/projects/runtime_config.rs | 26 +- src-tauri/src/projects/stories.rs | 31 +- src-tauri/src/projects/stories_crud.rs | 26 +- src-tauri/src/projects/validation.rs | 44 +- src-tauri/src/projects/wizard.rs | 104 +++- src-tauri/src/projects/wizard_state.rs | 4 +- src-tauri/src/scm_watcher.rs | 45 +- src-tauri/src/services/monitor_adapter.rs | 32 +- src-tauri/src/services/session_adapter.rs | 79 ++- src-tauri/src/storage/artifacts.rs | 5 +- src-tauri/src/storage/db.rs | 20 +- src-tauri/src/storage/migration.rs | 8 +- src-tauri/src/summary_generator.rs | 46 +- src-tauri/src/test_env_lock.rs | 3 + src-tauri/src/test_support/planning.rs | 1 + src-tauri/src/tests.rs | 46 +- src-tauri/src/worktree_manager.rs | 47 +- src-tauri/tests/wizard_draft_roundtrip.rs | 5 +- src/components/EmptyState.tsx | 9 +- src/components/EphemeralOverlay.tsx | 24 +- src/components/MarkdownPreview.tsx | 102 +--- src/components/NotificationPanel.tsx | 4 +- src/components/ProjectProgress.tsx | 12 +- src/components/SectionHeader.tsx | 12 +- src/components/StatusBadge.tsx | 63 +- src/components/StepIndicator.tsx | 45 +- src/components/ThemeToggle.tsx | 8 +- src/components/TitleBar.tsx | 26 +- src/components/layout/AppSidebar.tsx | 120 ++-- src/components/layout/ProjectMenuItem.tsx | 76 ++- src/components/markdownComponents.tsx | 190 +++--- .../NotificationPanelLayout.tsx | 55 +- src/components/title-bar/WindowControls.tsx | 7 + src/components/ui/alert-dialog.tsx | 20 +- src/components/ui/badge.tsx | 14 +- src/components/ui/button.tsx | 8 +- src/components/ui/card.tsx | 42 +- src/components/ui/command.tsx | 51 +- src/components/ui/dialog.tsx | 20 +- src/components/ui/dropdown-menu.tsx | 20 +- src/components/ui/empty.tsx | 6 +- src/components/ui/field.tsx | 45 +- src/components/ui/label.tsx | 40 +- src/components/ui/progress.tsx | 29 +- src/components/ui/resizable.tsx | 82 ++- src/components/ui/scroll-area.tsx | 14 +- src/components/ui/select.tsx | 41 +- src/components/ui/separator.tsx | 13 +- src/components/ui/sheet.tsx | 28 +- src/components/ui/sidebar.tsx | 283 +++++++-- src/components/ui/spinner.tsx | 37 +- src/components/ui/switch.tsx | 4 +- src/components/ui/table.tsx | 44 +- src/components/ui/tabs.tsx | 97 ++- src/components/ui/textarea.tsx | 6 +- src/components/ui/tooltip.tsx | 4 +- src/features/monitor/ActivityTab.tsx | 126 ++-- src/features/monitor/AskTab.test.tsx | 40 +- src/features/monitor/AskTab.tsx | 155 ++--- src/features/monitor/Monitor.test.tsx | 36 +- src/features/monitor/Monitor.tsx | 125 ++-- src/features/monitor/ProgressTab.tsx | 147 ++--- src/features/monitor/RawOutputTab.tsx | 34 +- src/features/monitor/components/AskInput.tsx | 82 ++- .../monitor/components/AskMessage.tsx | 16 +- .../monitor/components/AskMessageActions.tsx | 75 ++- .../components/ExecutionProfilePanel.tsx | 133 +++-- .../monitor/components/MonitorToolbar.tsx | 39 +- .../monitor/components/RetryButton.tsx | 19 + .../monitor/components/TerminalFrame.tsx | 12 +- .../monitor/hooks/useMonitorActions.ts | 39 ++ src/features/monitor/useTerminalFit.ts | 2 +- src/features/projects/Home.test.tsx | 4 +- src/features/projects/Home.tsx | 65 +- .../projects/components/ActiveLoopCard.tsx | 28 +- .../projects/components/CompletedLoopCard.tsx | 38 +- .../projects/components/DraftCard.tsx | 18 +- .../projects/components/SystemStatusCard.tsx | 12 +- src/features/wizard/Atomize.test.tsx | 12 +- src/features/wizard/Atomize.tsx | 155 +++-- src/features/wizard/Configure.test.tsx | 82 +-- src/features/wizard/Configure.tsx | 212 +------ src/features/wizard/Describe.test.tsx | 12 +- src/features/wizard/Describe.tsx | 157 +++-- src/features/wizard/Launch.test.tsx | 21 +- src/features/wizard/Launch.tsx | 70 ++- src/features/wizard/Plan.tsx | 30 +- src/features/wizard/WizardLayout.tsx | 94 +-- .../wizard/components/AtomizeStoryList.tsx | 81 ++- .../wizard/components/AtomizeStreamPanel.tsx | 82 +-- .../wizard/components/ConfigureForm.tsx | 320 +++++++--- .../wizard/components/DescribeForm.tsx | 146 ++++- .../wizard/components/LaunchActions.tsx | 35 +- .../wizard/components/PlanErrorCard.tsx | 17 + .../wizard/components/PlanResumePrompt.tsx | 36 ++ .../components/PlanRunningIndicator.tsx | 18 + .../wizard/components/PlanStreamPanel.tsx | 231 +++----- .../wizard/components/PlanWorkspace.tsx | 8 +- .../wizard/components/WizardExitDialog.tsx | 6 +- .../wizard/components/WizardStepRail.tsx | 8 +- src/features/wizard/configureFormTypes.ts | 41 ++ .../wizard/hooks/configureFormReducer.ts | 57 ++ src/features/wizard/hooks/useConfigureForm.ts | 202 +++++++ .../wizard/hooks/usePlanStreamTimers.ts | 43 ++ src/hooks/useAtomizerActivity.ts | 2 +- src/hooks/useAtomizerPipeline.test.ts | 87 ++- src/hooks/useAtomizerPipeline.ts | 186 +++--- src/hooks/useNotificationIngestion.ts | 94 +-- src/hooks/usePlanEvents.ts | 36 +- src/hooks/usePlanOrchestration.ts | 98 +-- src/hooks/useProjectEvents.ts | 27 +- src/hooks/useProjectListSync.ts | 23 +- src/hooks/useProjectSnapshot.ts | 37 +- src/hooks/useWizardHydration.test.ts | 7 +- src/hooks/useWizardHydration.ts | 63 +- src/index.css | 12 +- src/lib/ipc/agents.ts | 22 + src/lib/ipc/ask.ts | 32 +- src/lib/ipc/atomizer.ts | 4 +- src/lib/ipc/display-vocabulary.ts | 40 ++ src/lib/ipc/loop.ts | 48 +- src/lib/ipc/notifications.ts | 64 ++ src/lib/ipc/plan.ts | 44 +- src/lib/ipc/project.ts | 35 +- src/lib/ipc/types.ts | 288 ++------- src/lib/ipc/types/agent.ts | 27 + src/lib/ipc/types/ask.ts | 36 ++ src/lib/ipc/types/atomizer.ts | 74 +++ src/lib/ipc/types/plan.ts | 30 + src/lib/ipc/types/project.ts | 95 +++ src/lib/ipc/wizard-logic-commands.ts | 124 ++++ src/lib/ipc/wizard-logic-types.ts | 133 +++++ src/lib/ipc/wizard-logic.ts | 137 ++--- src/lib/platform.ts | 8 + src/lib/project-status.ts | 26 - src/lib/reportError.ts | 4 + src/lib/tauri.ts | 235 ++++++-- src/lib/utils.ts | 6 +- src/main.tsx | 126 +++- src/pages/AppLayout.test.tsx | 22 +- src/pages/AppLayout.tsx | 54 +- src/stores/askStore.ts | 3 +- src/stores/displayVocabularyStore.ts | 23 + src/stores/notificationStore.test.ts | 8 +- src/stores/notificationStore.ts | 217 ++++--- src/stores/projectStore.test.ts | 55 +- src/stores/projectStore.ts | 71 ++- src/stores/slices/wizard-config.ts | 12 +- src/stores/slices/wizard-plan.ts | 29 +- src/stores/slices/wizard-step.ts | 17 +- src/stores/slices/wizard-stories.ts | 9 +- src/stores/wizardDefaultsStore.ts | 23 + src/stores/wizardStore.ts | 27 +- src/test/fixtures/atomization.ts | 47 +- src/test/fixtures/index.ts | 51 +- src/test/fixtures/monitor.ts | 215 ++++--- src/test/fixtures/plan.ts | 61 +- src/test/fixtures/project.ts | 158 ++--- src/test/fixtures/shared.ts | 16 +- src/test/fixtures/wizard.ts | 184 +++--- src/test/mocks/contracts.ts | 68 ++- src/test/mocks/index.ts | 20 +- src/test/mocks/tauri.test.ts | 14 +- src/test/mocks/tauri.ts | 25 +- src/test/renderRoute.tsx | 16 +- src/tokens.css | 8 +- src/types/project.ts | 2 + 296 files changed, 9918 insertions(+), 4435 deletions(-) create mode 100644 .editorconfig create mode 100644 biome.json create mode 100644 lefthook.yml create mode 100644 rustfmt.toml create mode 100644 src-tauri/src/commands/display_vocabulary.rs create mode 100644 src-tauri/src/commands/notifications.rs create mode 100644 src-tauri/src/test_env_lock.rs create mode 100644 src/features/monitor/components/RetryButton.tsx create mode 100644 src/features/monitor/hooks/useMonitorActions.ts create mode 100644 src/features/wizard/components/PlanErrorCard.tsx create mode 100644 src/features/wizard/components/PlanResumePrompt.tsx create mode 100644 src/features/wizard/components/PlanRunningIndicator.tsx create mode 100644 src/features/wizard/configureFormTypes.ts create mode 100644 src/features/wizard/hooks/configureFormReducer.ts create mode 100644 src/features/wizard/hooks/useConfigureForm.ts create mode 100644 src/features/wizard/hooks/usePlanStreamTimers.ts create mode 100644 src/lib/ipc/display-vocabulary.ts create mode 100644 src/lib/ipc/notifications.ts create mode 100644 src/lib/ipc/types/agent.ts create mode 100644 src/lib/ipc/types/ask.ts create mode 100644 src/lib/ipc/types/atomizer.ts create mode 100644 src/lib/ipc/types/plan.ts create mode 100644 src/lib/ipc/types/project.ts create mode 100644 src/lib/ipc/wizard-logic-commands.ts create mode 100644 src/lib/ipc/wizard-logic-types.ts create mode 100644 src/lib/platform.ts delete mode 100644 src/lib/project-status.ts create mode 100644 src/lib/reportError.ts create mode 100644 src/stores/displayVocabularyStore.ts create mode 100644 src/stores/wizardDefaultsStore.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..6732c0f --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +trim_trailing_whitespace = true +charset = utf-8 + +[*.{ts,tsx,js,json,css,html}] +indent_style = space +indent_size = 2 + +[*.rs] +indent_style = space +indent_size = 4 + +[*.toml] +indent_style = space +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 919fb9e..c29e9ba 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,20 @@ jobs: - name: TypeScript check run: bun run typecheck + lint: + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + + - name: Install frontend dependencies + run: bun install + + - name: Biome lint + run: bun run lint + build: strategy: fail-fast: false @@ -90,6 +104,8 @@ jobs: - name: Install Rust stable uses: dtolnay/rust-toolchain@stable + with: + components: clippy, rustfmt - name: Rust cache uses: swatinem/rust-cache@v2 @@ -101,3 +117,9 @@ jobs: - name: Run tests run: cargo test + + - name: Rust format check + run: cargo fmt --all -- --check + + - name: Rust clippy + run: cargo clippy --workspace -- -D warnings diff --git a/Cargo.toml b/Cargo.toml index ab32a14..25eedee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,3 +6,40 @@ members = [ "src-tauri", ] resolver = "2" + +[workspace.lints.clippy] +all = { level = "deny", priority = -1 } +pedantic = { level = "deny", priority = -1 } +unused_async = "allow" +format_push_string = "allow" +needless_pass_by_value = "allow" +cast_possible_truncation = "allow" +cast_precision_loss = "allow" +cast_sign_loss = "allow" +cast_possible_wrap = "allow" +too_many_lines = "allow" +too_many_arguments = "allow" +assigning_clones = "allow" +match_same_arms = "allow" +manual_let_else = "allow" +ref_option = "allow" +similar_names = "allow" +items_after_statements = "allow" +unused_self = "allow" +trivially_copy_pass_by_ref = "allow" +struct_field_names = "allow" +struct_excessive_bools = "allow" +should_implement_trait = "allow" +option_map_unit_fn = "allow" +missing_fields_in_debug = "allow" +let_underscore_future = "allow" +missing_errors_doc = "allow" +missing_panics_doc = "allow" +module_name_repetitions = "allow" +must_use_candidate = "allow" +doc_markdown = "allow" + +[workspace.lints.rust] +unsafe_code = "deny" +unused_qualifications = "warn" +dead_code = "allow" diff --git a/biome.json b/biome.json new file mode 100644 index 0000000..3310dfb --- /dev/null +++ b/biome.json @@ -0,0 +1,73 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "assist": { "actions": { "source": { "organizeImports": "on" } } }, + "formatter": { + "enabled": true, + "indentStyle": "space", + "indentWidth": 2, + "lineWidth": 100 + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "correctness": { + "noUnusedImports": "error", + "useExhaustiveDependencies": "warn", + "noUnusedFunctionParameters": "warn", + "noUnusedVariables": "warn" + }, + "complexity": { + "useOptionalChain": "warn" + }, + "style": { + "noNonNullAssertion": "warn", + "useConst": "error", + "useImportType": "error" + }, + "suspicious": { + "noConfusingVoidType": "warn", + "noArrayIndexKey": "warn", + "noExplicitAny": "warn", + "noConsole": "warn" + }, + "a11y": { + "useButtonType": "warn", + "noSvgWithoutTitle": "warn", + "useSemanticElements": "warn", + "useAriaPropsForRole": "warn", + "noStaticElementInteractions": "warn", + "noNoninteractiveTabindex": "warn", + "noLabelWithoutControl": "warn" + }, + "nursery": { + "useSortedClasses": { + "level": "warn", + "options": { + "attributes": ["class", "className"], + "functions": ["clsx", "cva", "cn", "twMerge"] + } + } + } + } + }, + "javascript": { + "formatter": { + "quoteStyle": "double", + "semicolons": "always" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + }, + "files": { + "includes": ["**", "!**/dist/", "!**/node_modules/", "!**/src-tauri/", "!**/e2e/"] + } +} diff --git a/bun.lock b/bun.lock index f0f3dfc..ee31a25 100644 --- a/bun.lock +++ b/bun.lock @@ -31,6 +31,7 @@ "zustand": "^5", }, "devDependencies": { + "@biomejs/biome": "2.4.11", "@tailwindcss/vite": "^4", "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.8.0", @@ -48,6 +49,7 @@ "@wdio/spec-reporter": "^9.27.0", "expect-webdriverio": "^5.6.5", "jsdom": "^26.1.0", + "lefthook": "^2.1.5", "shadcn": "^4.1.2", "tailwindcss": "^4", "typescript": "^5", @@ -123,6 +125,24 @@ "@babel/types": ["@babel/types@7.29.0", "", { "dependencies": { "@babel/helper-string-parser": "^7.27.1", "@babel/helper-validator-identifier": "^7.28.5" } }, "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A=="], + "@biomejs/biome": ["@biomejs/biome@2.4.11", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.4.11", "@biomejs/cli-darwin-x64": "2.4.11", "@biomejs/cli-linux-arm64": "2.4.11", "@biomejs/cli-linux-arm64-musl": "2.4.11", "@biomejs/cli-linux-x64": "2.4.11", "@biomejs/cli-linux-x64-musl": "2.4.11", "@biomejs/cli-win32-arm64": "2.4.11", "@biomejs/cli-win32-x64": "2.4.11" }, "bin": { "biome": "bin/biome" } }, "sha512-nWxHX8tf3Opb/qRgZpBbsTOqOodkbrkJ7S+JxJAruxOReaDPPmPuLBAGQ8vigyUgo0QBB+oQltNEAvalLcjggA=="], + + "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.4.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-wOt+ed+L2dgZanWyL6i29qlXMc088N11optzpo10peayObBaAshbTcxKUchzEMp9QSY8rh5h6VfAFE3WTS1rqg=="], + + "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.4.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-gZ6zR8XmZlExfi/Pz/PffmdpWOQ8Qhy7oBztgkR8/ylSRyLwfRPSadmiVCV8WQ8PoJ2MWUy2fgID9zmtgUUJmw=="], + + "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA=="], + + "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.4.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-+Sbo1OAmlegtdwqFE8iOxFIWLh1B3OEgsuZfBpyyN/kWuqZ8dx9ZEes6zVnDMo+zRHF2wLynRVhoQmV7ohxl2Q=="], + + "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ=="], + + "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.4.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA=="], + + "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.4.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg=="], + + "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.4.11", "", { "os": "win32", "cpu": "x64" }, "sha512-A8D3JM/00C2KQgUV3oj8Ba15EHEYwebAGCy5Sf9GAjr5Y3+kJIYOiESoqRDeuRZueuMdCsbLZIUqmPhpYXJE9A=="], + "@csstools/color-helpers": ["@csstools/color-helpers@5.1.0", "", {}, "sha512-S11EXWJyy0Mz5SYvRmY8nJYTFFd1LCNV+7cXyAgQtOOuzb4EsgfqDufL+9esx72/eLhsRdGZwaldu/h+E4t4BA=="], "@csstools/css-calc": ["@csstools/css-calc@2.1.4", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^3.0.5", "@csstools/css-tokenizer": "^3.0.4" } }, "sha512-3N8oaj+0juUw/1H3YwmDDJXCgTB1gKU6Hc/bB502u9zR0q2vd786XJH9QfrKIEgFlZmhZiq6epXl4rHqhzsIgQ=="], @@ -1175,6 +1195,28 @@ "lazystream": ["lazystream@1.0.1", "", { "dependencies": { "readable-stream": "^2.0.5" } }, "sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw=="], + "lefthook": ["lefthook@2.1.5", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.5", "lefthook-darwin-x64": "2.1.5", "lefthook-freebsd-arm64": "2.1.5", "lefthook-freebsd-x64": "2.1.5", "lefthook-linux-arm64": "2.1.5", "lefthook-linux-x64": "2.1.5", "lefthook-openbsd-arm64": "2.1.5", "lefthook-openbsd-x64": "2.1.5", "lefthook-windows-arm64": "2.1.5", "lefthook-windows-x64": "2.1.5" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-yB9IFWurFllusbPZqvG0EavTmpNXPya2MuO7Li7YT78xAj3uCQ3AgmW9TVUbTTsSMhsegbiAMRpwfEk2TP1P0A=="], + + "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VITTaw8PxxyE26gkZ8UcwIa5ZrWnKNRGLeeSrqri40cQdXvLTEoMq2tjjw7eiL9UcB0waRReDdzydevy9GOPUQ=="], + + "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-AvtjYiW0BSGHBGrdvL313seUymrW9FxI+6JJwJ+ZSaa2sH81etrTB0wAwlH1L9VfFwK9+gWvatZBvLfF3L4fPw=="], + + "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.5", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-mXjJwe8jKGWGiBYUxfQY1ab3Nn5NhafqT9q3KJz8m5joGGQj4JD0cbWxF1nVBLBWsDGbWZRZunTCMGcIScT2bQ=="], + + "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.5", "", { "os": "freebsd", "cpu": "x64" }, "sha512-exD69dCjc1K45BxatDPGoH4NmEvgLKPm4kJLOWn1fTeHRKZwWiFPwnjknEoG2OemlCDHmCU++5X40kMEG0WBlA=="], + + "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-57TDKC5ewWpsCLZQKIJMHumFEObYKVundmPpiWhX491hINRZYYOL/26yrnVnNcidThRzTiTC+HLcuplLcaXtbA=="], + + "lefthook-linux-x64": ["lefthook-linux-x64@2.1.5", "", { "os": "linux", "cpu": "x64" }, "sha512-bqK3LrAB5l5YaCaoHk6qRWlITrGWzP4FbwRxA31elbxjd0wgNWZ2Sn3zEfSEcxz442g7/PPkEwqqsTx0kSFzpg=="], + + "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.5", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-5aSwK7vV3A6t0w9PnxCMiVjQlcvopBP50BtmnnLnNJyAYHnFbZ0Baq5M0WkE9IsUkWSux0fe6fd0jDkuG711MA=="], + + "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.5", "", { "os": "openbsd", "cpu": "x64" }, "sha512-Y+pPdDuENJ8qWnUgL02xxhpjblc0WnwXvWGfqnl3WZrAgHzQpwx3G6469RID/wlNVdHYAlw3a8UkFSMYsTzXvA=="], + + "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-2PlcFBjTzJaMufw0c28kfhB/0zmaRCU0TRPPsil/HU2YNOExod4upPGLk9qjgsOmb2YVWFz6zq6u7+D1yqmzTQ=="], + + "lefthook-windows-x64": ["lefthook-windows-x64@2.1.5", "", { "os": "win32", "cpu": "x64" }, "sha512-yiAh8qxml6uqy10jDxOdN9fOQpyLxBFY1fgCEAhn7sVJYmJKRhjqSBwZX6LG5MQjzr29KStrIdw7TR3lf3rT7Q=="], + "lie": ["lie@3.3.0", "", { "dependencies": { "immediate": "~3.0.5" } }, "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ=="], "lightningcss": ["lightningcss@1.32.0", "", { "dependencies": { "detect-libc": "^2.0.3" }, "optionalDependencies": { "lightningcss-android-arm64": "1.32.0", "lightningcss-darwin-arm64": "1.32.0", "lightningcss-darwin-x64": "1.32.0", "lightningcss-freebsd-x64": "1.32.0", "lightningcss-linux-arm-gnueabihf": "1.32.0", "lightningcss-linux-arm64-gnu": "1.32.0", "lightningcss-linux-arm64-musl": "1.32.0", "lightningcss-linux-x64-gnu": "1.32.0", "lightningcss-linux-x64-musl": "1.32.0", "lightningcss-win32-arm64-msvc": "1.32.0", "lightningcss-win32-x64-msvc": "1.32.0" } }, "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ=="], diff --git a/crates/app-services/Cargo.toml b/crates/app-services/Cargo.toml index b02e475..f6b6479 100644 --- a/crates/app-services/Cargo.toml +++ b/crates/app-services/Cargo.toml @@ -9,3 +9,6 @@ thiserror = "2" [dev-dependencies] serde_json = "1" + +[lints] +workspace = true diff --git a/crates/app-services/src/monitor.rs b/crates/app-services/src/monitor.rs index f03b905..fa48a4d 100644 --- a/crates/app-services/src/monitor.rs +++ b/crates/app-services/src/monitor.rs @@ -82,7 +82,10 @@ pub struct MonitorMessage { } pub trait MonitorRepository { - fn latest_session(&self, project_id: &str) -> crate::session::ServiceResult>; + fn latest_session( + &self, + project_id: &str, + ) -> crate::session::ServiceResult>; fn recent_output( &self, project_id: &str, diff --git a/crates/app-services/src/session.rs b/crates/app-services/src/session.rs index 6f2c591..3b4b82e 100644 --- a/crates/app-services/src/session.rs +++ b/crates/app-services/src/session.rs @@ -98,7 +98,8 @@ pub struct StoryCounts { pub trait SessionRepository { fn is_running(&self, project_id: &str) -> ServiceResult; - fn latest_session_record(&self, project_id: &str) -> ServiceResult>; + fn latest_session_record(&self, project_id: &str) + -> ServiceResult>; fn iteration_counts(&self, session_id: &str) -> ServiceResult; fn latest_agent(&self, session_id: &str) -> ServiceResult>; fn story_counts(&self, project_id: &str) -> ServiceResult; @@ -167,9 +168,9 @@ impl SessionService for RuntimeSessionService { } else { 0.0 }; - let stories_per_hour = - self.repository - .stories_per_hour(session.as_ref(), stories.passed)?; + let stories_per_hour = self + .repository + .stories_per_hour(session.as_ref(), stories.passed)?; Ok(SessionStats { project_id: project_id.to_string(), diff --git a/crates/loopforge-app-core/Cargo.toml b/crates/loopforge-app-core/Cargo.toml index b29e9d7..86c8e0c 100644 --- a/crates/loopforge-app-core/Cargo.toml +++ b/crates/loopforge-app-core/Cargo.toml @@ -5,3 +5,6 @@ edition = "2021" description = "Application core services for LoopForge backend orchestration" [dependencies] + +[lints] +workspace = true diff --git a/crates/loopforge-app-core/src/events.rs b/crates/loopforge-app-core/src/events.rs index cd55407..f0d7017 100644 --- a/crates/loopforge-app-core/src/events.rs +++ b/crates/loopforge-app-core/src/events.rs @@ -23,7 +23,7 @@ impl EventFanout { let (sender, receiver) = mpsc::channel(); self.subscribers .lock() - .unwrap_or_else(|poison| poison.into_inner()) + .unwrap_or_else(std::sync::PoisonError::into_inner) .push(sender); receiver } @@ -32,7 +32,7 @@ impl EventFanout { let mut subscribers = self .subscribers .lock() - .unwrap_or_else(|poison| poison.into_inner()); + .unwrap_or_else(std::sync::PoisonError::into_inner); subscribers.retain(|subscriber| subscriber.send(event.clone()).is_ok()); } } diff --git a/crates/ralph-core/Cargo.toml b/crates/ralph-core/Cargo.toml index b2f82af..0bd6b55 100644 --- a/crates/ralph-core/Cargo.toml +++ b/crates/ralph-core/Cargo.toml @@ -18,3 +18,6 @@ regex = "1" [dev-dependencies] tempfile = "3" + +[lints] +workspace = true diff --git a/crates/ralph-core/src/config.rs b/crates/ralph-core/src/config.rs index 4b6046d..83b75f0 100644 --- a/crates/ralph-core/src/config.rs +++ b/crates/ralph-core/src/config.rs @@ -95,25 +95,22 @@ impl RalphConfig { } pub fn load_toml_overlay(&mut self, config_path: &Path) -> Result<(), ConfigError> { - let content = std::fs::read_to_string(config_path).map_err(|source| { - ConfigError::ReadFailed { + let content = + std::fs::read_to_string(config_path).map_err(|source| ConfigError::ReadFailed { path: config_path.to_path_buf(), source, - } - })?; - let parsed: TomlConfig = toml::from_str(&content).map_err(|source| { - ConfigError::ParseFailed { + })?; + let parsed: TomlConfig = + toml::from_str(&content).map_err(|source| ConfigError::ParseFailed { path: config_path.to_path_buf(), source, - } - })?; + })?; let base_dir = parsed .project .as_ref() .and_then(|project| project.working_directory.as_deref()) - .map(PathBuf::from) - .unwrap_or_else(|| self.paths.ralph_dir.clone()); + .map_or_else(|| self.paths.ralph_dir.clone(), PathBuf::from); if let Some(prd_cfg) = &parsed.prd { if let Some(path) = &prd_cfg.path { @@ -134,9 +131,14 @@ impl RalphConfig { } if let Some(services_cfg) = parsed.services { - let legacy = services_cfg.legacy.map(|svc| to_service_config(svc, "legacy")); + let legacy = services_cfg + .legacy + .map(|svc| to_service_config(svc, "legacy")); let new_service = services_cfg.new.map(|svc| to_service_config(svc, "new")); - self.services = Some(ServiceConfigs { legacy, new_service }); + self.services = Some(ServiceConfigs { + legacy, + new_service, + }); } if let Some(test_cfg) = parsed.test { diff --git a/crates/ralph-core/src/detection/failure_memory.rs b/crates/ralph-core/src/detection/failure_memory.rs index 371e467..f6fc3af 100644 --- a/crates/ralph-core/src/detection/failure_memory.rs +++ b/crates/ralph-core/src/detection/failure_memory.rs @@ -39,7 +39,9 @@ impl FailureMemory { } pub fn get_record(&self, story_id: &str) -> Option<&StoryFailureRecord> { - self.stories.iter().find(|record| record.story_id == story_id) + self.stories + .iter() + .find(|record| record.story_id == story_id) } pub fn record_failure( @@ -80,15 +82,18 @@ impl FailureMemory { .map(|attempt| attempt.approach_summary.as_str()) .collect(); let all_same = recent_approaches.len() >= 2 - && recent_approaches.windows(2).all(|window| window[0] == window[1]); + && recent_approaches + .windows(2) + .all(|window| window[0] == window[1]); if all_same { existing.diversity_required = true; if let Some(last_approach) = recent_approaches.first() { - if !existing.banned_approaches.contains(&last_approach.to_string()) { - existing - .banned_approaches - .push(last_approach.to_string()); + if !existing + .banned_approaches + .contains(&last_approach.to_string()) + { + existing.banned_approaches.push(last_approach.to_string()); } } } @@ -107,8 +112,7 @@ impl FailureMemory { pub fn is_in_gutter(&self, story_id: &str, threshold: u32) -> bool { self.get_record(story_id) - .map(|record| record.gutter_score >= threshold) - .unwrap_or(false) + .is_some_and(|record| record.gutter_score >= threshold) } pub fn build_diversity_prompt(&self, story_id: &str) -> Option { @@ -130,10 +134,7 @@ impl FailureMemory { } } - prompt.push_str(&format!( - "\nPrevious attempts: {}\n", - record.attempts.len() - )); + prompt.push_str(&format!("\nPrevious attempts: {}\n", record.attempts.len())); for attempt in record.attempts.iter().rev().take(3) { prompt.push_str(&format!( diff --git a/crates/ralph-core/src/detection/loop_detector.rs b/crates/ralph-core/src/detection/loop_detector.rs index 406f0d9..cb85b02 100644 --- a/crates/ralph-core/src/detection/loop_detector.rs +++ b/crates/ralph-core/src/detection/loop_detector.rs @@ -7,6 +7,12 @@ pub struct LoopDetector { recent_lines: VecDeque, } +impl Default for LoopDetector { + fn default() -> Self { + Self::new() + } +} + impl LoopDetector { pub fn new() -> Self { Self { @@ -50,7 +56,13 @@ impl LoopDetector { } if repetitions >= REPETITION_THRESHOLD { - return Some(pattern.iter().map(|line| line.as_str()).collect::>().join(" | ")); + return Some( + pattern + .iter() + .map(|line| line.as_str()) + .collect::>() + .join(" | "), + ); } } @@ -66,7 +78,9 @@ fn normalize_line(line: &str) -> String { let trimmed = line.trim(); if trimmed.is_empty() || trimmed.starts_with("---") - || trimmed.chars().all(|character| character == '=' || character == '-') + || trimmed + .chars() + .all(|character| character == '=' || character == '-') { return String::new(); } diff --git a/crates/ralph-core/src/detection/stall.rs b/crates/ralph-core/src/detection/stall.rs index e62c621..c9d2bc7 100644 --- a/crates/ralph-core/src/detection/stall.rs +++ b/crates/ralph-core/src/detection/stall.rs @@ -18,12 +18,11 @@ pub async fn monitor_child_with_stall_detection( shutdown_flag: &Arc, output_sender: Option>, ) -> StallVerdict { - let stdout = match child.stdout.take() { - Some(stdout) => stdout, - None => { - let _ = child.wait().await; - return StallVerdict::Completed; - } + let stdout = if let Some(stdout) = child.stdout.take() { + stdout + } else { + let _ = child.wait().await; + return StallVerdict::Completed; }; let stderr = child.stderr.take(); diff --git a/crates/ralph-core/src/events.rs b/crates/ralph-core/src/events.rs index c69f632..b948dcd 100644 --- a/crates/ralph-core/src/events.rs +++ b/crates/ralph-core/src/events.rs @@ -60,6 +60,12 @@ pub struct RecordingEventSink { events: std::sync::Mutex>, } +impl Default for RecordingEventSink { + fn default() -> Self { + Self::new() + } +} + impl RecordingEventSink { pub fn new() -> Self { Self { diff --git a/crates/ralph-core/src/git.rs b/crates/ralph-core/src/git.rs index 098b5b0..4b2d93b 100644 --- a/crates/ralph-core/src/git.rs +++ b/crates/ralph-core/src/git.rs @@ -65,9 +65,7 @@ pub async fn count_commits_between(work_dir: &Path, from_hash: &str, to_hash: &s .current_dir(work_dir) .output() .await?; - let count = String::from_utf8_lossy(&output.stdout) - .lines() - .count() as u32; + let count = String::from_utf8_lossy(&output.stdout).lines().count() as u32; Ok(count) } diff --git a/crates/ralph-core/src/guardrails.rs b/crates/ralph-core/src/guardrails.rs index c8a8fc8..992a844 100644 --- a/crates/ralph-core/src/guardrails.rs +++ b/crates/ralph-core/src/guardrails.rs @@ -1,7 +1,7 @@ use crate::errors::GuardrailError; use std::path::Path; -const GUARDRAILS_TEMPLATE: &str = r#"# Guardrails (Signs) +const GUARDRAILS_TEMPLATE: &str = r"# Guardrails (Signs) Lessons learned from previous iterations. The agent MUST read this FIRST. @@ -19,7 +19,7 @@ When something fails repeatedly, add a sign: - **Trigger**: [When it applies] - **Instruction**: [What to do instead] - **Added after**: Iteration N -"#; +"; pub fn ensure_exists(path: &Path) -> Result<(), GuardrailError> { if !path.exists() { diff --git a/crates/ralph-core/src/health/manager.rs b/crates/ralph-core/src/health/manager.rs index 078b148..38e34f7 100644 --- a/crates/ralph-core/src/health/manager.rs +++ b/crates/ralph-core/src/health/manager.rs @@ -25,19 +25,13 @@ pub async fn run_shell_command_background(command: &str, work_dir: Option<&Path> pub async fn kill_process_on_port(port: u16) { let lsof_cmd = format!("lsof -ti :{port}"); - let output = Command::new("sh") - .args(["-c", &lsof_cmd]) - .output() - .await; + let output = Command::new("sh").args(["-c", &lsof_cmd]).output().await; if let Ok(output) = output { let pids = String::from_utf8_lossy(&output.stdout); for pid_str in pids.lines() { if let Ok(pid) = pid_str.trim().parse::() { - let _ = Command::new("kill") - .arg(pid.to_string()) - .output() - .await; + let _ = Command::new("kill").arg(pid.to_string()).output().await; } } } @@ -48,10 +42,7 @@ pub async fn graceful_kill(pid: u32) { let pid_str = pid.to_string(); - let _ = Command::new("kill") - .args(["-INT", &pid_str]) - .output() - .await; + let _ = Command::new("kill").args(["-INT", &pid_str]).output().await; tokio::time::sleep(Duration::from_secs(10)).await; if is_process_alive(pid).await { @@ -63,10 +54,7 @@ pub async fn graceful_kill(pid: u32) { } if is_process_alive(pid).await { - let _ = Command::new("kill") - .args(["-9", &pid_str]) - .output() - .await; + let _ = Command::new("kill").args(["-9", &pid_str]).output().await; crate::logger::log_warning(&format!("Force killed PID {pid} (SIGKILL)")); } } diff --git a/crates/ralph-core/src/health/mod.rs b/crates/ralph-core/src/health/mod.rs index d023f47..14941b2 100644 --- a/crates/ralph-core/src/health/mod.rs +++ b/crates/ralph-core/src/health/mod.rs @@ -33,7 +33,10 @@ async fn ensure_service_running(svc_config: &ServiceConfig) -> bool { if service::check_health(health_url).await { return true; } - logger::log_warning(&format!("{} is DOWN, attempting auto-start...", svc_config.name)); + logger::log_warning(&format!( + "{} is DOWN, attempting auto-start...", + svc_config.name + )); if let Some(start_cmd) = &svc_config.start_command { let work_dir = svc_config.working_directory.as_deref(); if let Some(stop_cmd) = &svc_config.stop_command { diff --git a/crates/ralph-core/src/health/service.rs b/crates/ralph-core/src/health/service.rs index 6e9e677..58d478e 100644 --- a/crates/ralph-core/src/health/service.rs +++ b/crates/ralph-core/src/health/service.rs @@ -6,7 +6,8 @@ pub async fn check_health(url: &str) -> bool { .build() .unwrap_or_default(); - match client.post(url) + match client + .post(url) .header("Content-Type", "application/json") .body("{}") .send() @@ -26,9 +27,7 @@ pub async fn wait_for_health(service_name: &str, url: &str, max_wait_secs: u64) while elapsed < max_wait_secs { if check_health(url).await { - crate::logger::log_success(&format!( - "{service_name} is healthy after {elapsed}s" - )); + crate::logger::log_success(&format!("{service_name} is healthy after {elapsed}s")); return true; } tokio::time::sleep(interval).await; diff --git a/crates/ralph-core/src/loop_engine/helpers.rs b/crates/ralph-core/src/loop_engine/helpers.rs index f5dec13..c695dce 100644 --- a/crates/ralph-core/src/loop_engine/helpers.rs +++ b/crates/ralph-core/src/loop_engine/helpers.rs @@ -37,11 +37,7 @@ pub async fn interruptible_sleep( false } -pub async fn log_session_summary( - iterations: u32, - config: &RalphConfig, - initial_commit: &str, -) { +pub async fn log_session_summary(iterations: u32, config: &RalphConfig, initial_commit: &str) { let total_commits = git::count_commits_since(&config.paths.work_dir, initial_commit) .await .unwrap_or(0); diff --git a/crates/ralph-core/src/loop_engine/iteration.rs b/crates/ralph-core/src/loop_engine/iteration.rs index 69cbcc3..339ecb5 100644 --- a/crates/ralph-core/src/loop_engine/iteration.rs +++ b/crates/ralph-core/src/loop_engine/iteration.rs @@ -87,8 +87,7 @@ pub async fn run_with_verification( break agent_result; } - let error_sig = - verification::classify_error_signature(&verification_result.diagnostics); + let error_sig = verification::classify_error_signature(&verification_result.diagnostics); if last_error_signature.as_ref() == Some(&error_sig) { consecutive_same_error += 1; @@ -99,8 +98,14 @@ pub async fn run_with_verification( if consecutive_same_error >= CIRCUIT_BREAKER_THRESHOLD { handle_circuit_breaker( - config, &story.id, &error_sig, consecutive_same_error, - iteration, event_sink, failure_memory, verification_attempt, + config, + &story.id, + &error_sig, + consecutive_same_error, + iteration, + event_sink, + failure_memory, + verification_attempt, verification_result.diagnostics.len(), ); break agent_result; @@ -121,21 +126,32 @@ pub async fn run_with_verification( if verification_attempt >= max_retries { handle_retry_exhausted( - config, &story.id, max_retries, &verification_result, - iteration, failure_memory, + config, + &story.id, + max_retries, + &verification_result, + iteration, + failure_memory, ); break agent_result; } let retry_prompt = verification::build_retry_prompt( - story, &verification_result.diagnostics, - verification_attempt, max_retries, &config.paths.work_dir, + story, + &verification_result.diagnostics, + verification_attempt, + max_retries, + &config.paths.work_dir, ); let retry_hash = crate::prompt::prompt_content_hash(&retry_prompt); if let Some(prev) = prev_prompt_hash { if retry_hash != prev { - tracing::info!(prev_hash = prev, new_hash = retry_hash, "prompt changed between attempts"); + tracing::info!( + prev_hash = prev, + new_hash = retry_hash, + "prompt changed between attempts" + ); } } prev_prompt_hash = Some(retry_hash); @@ -173,7 +189,9 @@ fn handle_circuit_breaker( circuit_breaker: true, }); tracing::warn!( - story_id, signature = error_sig, consecutive = consecutive_same_error, + story_id, + signature = error_sig, + consecutive = consecutive_same_error, "circuit breaker: same error signature repeated" ); logger::log_warning(&format!( @@ -181,13 +199,17 @@ fn handle_circuit_breaker( )); failure_memory.record_failure( - story_id, iteration, "circuit_breaker", vec![], + story_id, + iteration, + "circuit_breaker", + vec![], &format!("Same error signature {error_sig} repeated {consecutive_same_error}x"), ); prd_lifecycle::mark_story_blocked(config, story_id); if let Err(guardrail_err) = crate::guardrails::add_guardrail( - &config.paths.guardrails_file, story_id, + &config.paths.guardrails_file, + story_id, &format!("Circuit breaker: same verification error {consecutive_same_error}x"), iteration, ) { @@ -204,17 +226,26 @@ fn handle_retry_exhausted( failure_memory: &mut FailureMemory, ) { logger::log_error( - &format!("Story {story_id} exceeded max verification retries ({max_retries}), marking blocked"), + &format!( + "Story {story_id} exceeded max verification retries ({max_retries}), marking blocked" + ), Some(&config.paths.error_log), ); failure_memory.record_failure( - story_id, iteration, "verification_exhausted", vec![], - &format!("{} diagnostics after {max_retries} retries", verification_result.diagnostics.len()), + story_id, + iteration, + "verification_exhausted", + vec![], + &format!( + "{} diagnostics after {max_retries} retries", + verification_result.diagnostics.len() + ), ); prd_lifecycle::mark_story_blocked(config, story_id); if let Err(guardrail_err) = crate::guardrails::add_guardrail( - &config.paths.guardrails_file, story_id, + &config.paths.guardrails_file, + story_id, &format!( "Verification failed after {max_retries} retries with {} diagnostics", verification_result.diagnostics.len() diff --git a/crates/ralph-core/src/loop_engine/mod.rs b/crates/ralph-core/src/loop_engine/mod.rs index 0a50a0a..92d15ab 100644 --- a/crates/ralph-core/src/loop_engine/mod.rs +++ b/crates/ralph-core/src/loop_engine/mod.rs @@ -9,7 +9,7 @@ pub mod worktree; pub use crate::scheduler::worktree_runner::{ provision as provision_worktree, run_in_worktree, ProvisionedWorktree, }; -pub use worktree::{LoopExecutionState, PRIMARY_WORKTREE_ID, WorktreeExecutionState}; +pub use worktree::{LoopExecutionState, WorktreeExecutionState, PRIMARY_WORKTREE_ID}; use crate::config::RalphConfig; use crate::detection::failure_memory::{FailureMemory, StoryFailureRecord}; @@ -26,8 +26,8 @@ use crate::scheduler::{ArtifactCoordinator, SharedArtifactUpdate}; use crate::state; use std::future::{poll_fn, Future}; use std::pin::Pin; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::task::Poll; use self::scheduler::WorktreeCompletion; @@ -68,12 +68,11 @@ pub async fn run( tokio::time::sleep(std::time::Duration::from_secs(30)).await; continue; } - let mut prd = match prd_lifecycle::load_or_restore(config) { - Some(prd) => prd, - None => { - logger::log_error("PRD unrecoverable, stopping", Some(&config.paths.error_log)); - break; - } + let mut prd = if let Some(prd) = prd_lifecycle::load_or_restore(config) { + prd + } else { + logger::log_error("PRD unrecoverable, stopping", Some(&config.paths.error_log)); + break; }; if prd.pending_count() == 0 { logger::log_success(&format!( @@ -288,7 +287,10 @@ async fn run_parallel_ready_stories( } let reports = collect_worker_reports(workers).await?; for update in story_scheduler::shared_updates_for( - reports.iter().map(|report| report.completion.clone()).collect(), + reports + .iter() + .map(|report| report.completion.clone()) + .collect(), ) { artifact_coordinator .submit(update) @@ -367,7 +369,13 @@ async fn execute_ready_story( let head_before = git::get_head_hash(&worker_config.paths.work_dir) .await .unwrap_or_default(); - let built = build_prompt(&worker_config, iteration, &failure_memory, &story, event_sink); + let built = build_prompt( + &worker_config, + iteration, + &failure_memory, + &story, + event_sink, + ); if let Err(validation_errors) = built.validate(&story.id) { tracing::error!(story_id = %story.id, errors = ?validation_errors, "prompt validation failed"); event_sink.emit(LoopEvent::StorySkipped { @@ -401,8 +409,7 @@ async fn execute_ready_story( iteration, ) .await; - let final_passed = - outcome.story_passed || check_story_passed_in_prd(&worker_config, &story.id); + let final_passed = outcome.story_passed || check_story_passed_in_prd(&worker_config, &story.id); let progress_report = progress::analyze_iteration_progress( &worker_config.paths.work_dir, &head_before, @@ -450,8 +457,8 @@ fn worker_report( completion: WorktreeCompletion { worktree_id: worktree_id.to_string(), story_id: story_id.to_string(), - passed: state.map(|story| story.passes).unwrap_or(false), - blocked: state.map(|story| story.blocked).unwrap_or(false), + passed: state.is_some_and(|story| story.passes), + blocked: state.is_some_and(|story| story.blocked), head_commit: None, guardrail_append, sequence, @@ -477,7 +484,10 @@ fn merge_failure_record(failure_memory: &mut FailureMemory, record: Option Option { let current = std::fs::read_to_string(path).ok()?; - let appended = current.strip_prefix(seed).unwrap_or(current.as_str()).trim(); + let appended = current + .strip_prefix(seed) + .unwrap_or(current.as_str()) + .trim(); if appended.is_empty() { None } else { diff --git a/crates/ralph-core/src/loop_engine/outcome.rs b/crates/ralph-core/src/loop_engine/outcome.rs index f8a099d..5375258 100644 --- a/crates/ralph-core/src/loop_engine/outcome.rs +++ b/crates/ralph-core/src/loop_engine/outcome.rs @@ -31,8 +31,15 @@ pub async fn handle( } Ok(result) if result.success() => { handle_success( - config, prd, story_id, story_passed, progress_report, - failure_memory, consecutive_zero_progress, result, *iteration, + config, + prd, + story_id, + story_passed, + progress_report, + failure_memory, + consecutive_zero_progress, + result, + *iteration, ); } Ok(result) => { @@ -60,11 +67,17 @@ async fn handle_rate_limit( wait_secs % 60, )); logger::log_error( - &format!("Iteration {} rate limited (retry at: {retry_hint})", *iteration), + &format!( + "Iteration {} rate limited (retry at: {retry_hint})", + *iteration + ), Some(&config.paths.error_log), ); logger::log_activity( - &format!("Rate limited at iteration {}, sleeping {wait_secs}s", *iteration), + &format!( + "Rate limited at iteration {}, sleeping {wait_secs}s", + *iteration + ), &config.paths.activity_log, ); *iteration = iteration.saturating_sub(1); diff --git a/crates/ralph-core/src/loop_engine/prd_lifecycle.rs b/crates/ralph-core/src/loop_engine/prd_lifecycle.rs index c463317..4d77caa 100644 --- a/crates/ralph-core/src/loop_engine/prd_lifecycle.rs +++ b/crates/ralph-core/src/loop_engine/prd_lifecycle.rs @@ -41,7 +41,7 @@ pub fn summarize_approach(output_lines: &[String]) -> String { !trimmed.is_empty() && trimmed.len() > 10 }) .take(3) - .map(|line| line.as_str()) + .map(String::as_str) .collect(); if meaningful.is_empty() { diff --git a/crates/ralph-core/src/loop_engine/rate_limiter.rs b/crates/ralph-core/src/loop_engine/rate_limiter.rs index 0650587..2c24cca 100644 --- a/crates/ralph-core/src/loop_engine/rate_limiter.rs +++ b/crates/ralph-core/src/loop_engine/rate_limiter.rs @@ -11,7 +11,7 @@ pub fn compute_wait(result: &AgentResult, default_secs: u64) -> u64 { fn parse_retry_time_to_secs(time_str: &str) -> Option { let now = chrono::Local::now(); - let cleaned = time_str.trim().replace(".", "").to_uppercase(); + let cleaned = time_str.trim().replace('.', "").to_uppercase(); let is_pm = cleaned.contains("PM"); let is_am = cleaned.contains("AM"); diff --git a/crates/ralph-core/src/plugin.rs b/crates/ralph-core/src/plugin.rs index 8b4e302..294fad1 100644 --- a/crates/ralph-core/src/plugin.rs +++ b/crates/ralph-core/src/plugin.rs @@ -42,56 +42,116 @@ pub enum ConfigFieldType { pub trait RuntimePlugin: Send + Sync { fn info(&self) -> PluginInfo; - fn api_version(&self) -> u32 { 1 } - fn config_schema(&self) -> Vec { vec![] } + fn api_version(&self) -> u32 { + 1 + } + fn config_schema(&self) -> Vec { + vec![] + } fn health_check(&self) -> impl std::future::Future> + Send; - fn pre_iteration(&self, iteration: u32) -> impl std::future::Future> + Send; - fn post_iteration(&self, iteration: u32, success: bool) -> impl std::future::Future> + Send; + fn pre_iteration(&self, iteration: u32) + -> impl std::future::Future> + Send; + fn post_iteration( + &self, + iteration: u32, + success: bool, + ) -> impl std::future::Future> + Send; } pub trait AgentPlugin: Send + Sync { fn info(&self) -> PluginInfo; - fn api_version(&self) -> u32 { 1 } - fn config_schema(&self) -> Vec { vec![] } + fn api_version(&self) -> u32 { + 1 + } + fn config_schema(&self) -> Vec { + vec![] + } fn health_check(&self) -> impl std::future::Future> + Send; fn supported_agents(&self) -> Vec; - fn invoke(&self, prompt: &str, work_dir: &str) -> impl std::future::Future> + Send; + fn invoke( + &self, + prompt: &str, + work_dir: &str, + ) -> impl std::future::Future> + Send; } pub trait WorkspacePlugin: Send + Sync { fn info(&self) -> PluginInfo; - fn api_version(&self) -> u32 { 1 } - fn config_schema(&self) -> Vec { vec![] } + fn api_version(&self) -> u32 { + 1 + } + fn config_schema(&self) -> Vec { + vec![] + } fn health_check(&self) -> impl std::future::Future> + Send; - fn setup_workspace(&self, config: &HashMap) -> impl std::future::Future> + Send; - fn cleanup_workspace(&self, workspace_path: &str) -> impl std::future::Future> + Send; + fn setup_workspace( + &self, + config: &HashMap, + ) -> impl std::future::Future> + Send; + fn cleanup_workspace( + &self, + workspace_path: &str, + ) -> impl std::future::Future> + Send; } pub trait TrackerPlugin: Send + Sync { fn info(&self) -> PluginInfo; - fn api_version(&self) -> u32 { 1 } - fn config_schema(&self) -> Vec { vec![] } + fn api_version(&self) -> u32 { + 1 + } + fn config_schema(&self) -> Vec { + vec![] + } fn health_check(&self) -> impl std::future::Future> + Send; - fn report_progress(&self, story_id: &str, status: &str) -> impl std::future::Future> + Send; + fn report_progress( + &self, + story_id: &str, + status: &str, + ) -> impl std::future::Future> + Send; fn sync_stories(&self) -> impl std::future::Future>> + Send; } pub trait ScmPlugin: Send + Sync { fn info(&self) -> PluginInfo; - fn api_version(&self) -> u32 { 1 } - fn config_schema(&self) -> Vec { vec![] } + fn api_version(&self) -> u32 { + 1 + } + fn config_schema(&self) -> Vec { + vec![] + } fn health_check(&self) -> impl std::future::Future> + Send; - fn create_pr(&self, title: &str, body: &str, branch: &str) -> impl std::future::Future> + Send; - fn fetch_review_comments(&self, pr_id: &str) -> impl std::future::Future>> + Send; - fn post_comment(&self, pr_id: &str, body: &str) -> impl std::future::Future> + Send; + fn create_pr( + &self, + title: &str, + body: &str, + branch: &str, + ) -> impl std::future::Future> + Send; + fn fetch_review_comments( + &self, + pr_id: &str, + ) -> impl std::future::Future>> + Send; + fn post_comment( + &self, + pr_id: &str, + body: &str, + ) -> impl std::future::Future> + Send; } pub trait NotifierPlugin: Send + Sync { fn info(&self) -> PluginInfo; - fn api_version(&self) -> u32 { 1 } - fn config_schema(&self) -> Vec { vec![] } + fn api_version(&self) -> u32 { + 1 + } + fn config_schema(&self) -> Vec { + vec![] + } fn health_check(&self) -> impl std::future::Future> + Send; - fn notify(&self, title: &str, message: &str, level: &str) -> impl std::future::Future> + Send; + fn notify( + &self, + title: &str, + message: &str, + level: &str, + ) -> impl std::future::Future> + Send; } pub struct BuiltinRuntime; diff --git a/crates/ralph-core/src/ports.rs b/crates/ralph-core/src/ports.rs index 6c1b961..7a3f9e0 100644 --- a/crates/ralph-core/src/ports.rs +++ b/crates/ralph-core/src/ports.rs @@ -14,10 +14,7 @@ pub trait GitOps: Send + Sync { since_hash: &str, ) -> impl std::future::Future> + Send; - fn remove_git_lock( - &self, - work_dir: &Path, - ) -> impl std::future::Future + Send; + fn remove_git_lock(&self, work_dir: &Path) -> impl std::future::Future + Send; fn load_last_rebase(&self, path: &Path) -> Option; } @@ -46,10 +43,7 @@ pub trait GuardrailStore: Send + Sync { } pub trait StateStore: Send + Sync { - fn wait_while_paused( - &self, - pause_file: &Path, - ) -> impl std::future::Future + Send; + fn wait_while_paused(&self, pause_file: &Path) -> impl std::future::Future + Send; fn check_and_clear_done(&self, done_file: &Path) -> bool; diff --git a/crates/ralph-core/src/prd.rs b/crates/ralph-core/src/prd.rs index 8fcc783..ca33cc7 100644 --- a/crates/ralph-core/src/prd.rs +++ b/crates/ralph-core/src/prd.rs @@ -145,7 +145,10 @@ impl Prd { } pub fn total_estimated_minutes(&self) -> u32 { - self.stories.iter().map(|story| story.estimated_minutes).sum() + self.stories + .iter() + .map(|story| story.estimated_minutes) + .sum() } pub fn validate_atomicity(&self) -> Result<(), PrdError> { @@ -176,10 +179,7 @@ impl Prd { for dep_id in &story.depends_on { if !all_ids.contains(dep_id.as_str()) { return Err(PrdError::ValidationFailed { - reason: format!( - "Story '{}' depends_on unknown id '{}'", - story.id, dep_id - ), + reason: format!("Story '{}' depends_on unknown id '{}'", story.id, dep_id), }); } } @@ -301,7 +301,10 @@ mod tests_d6_roundtrip { let prd = make_full_prd(); let json = serde_json::to_string(&prd).unwrap(); let value: serde_json::Value = serde_json::from_str(&json).unwrap(); - assert!(value.get("projectName").is_some(), "must have projectName key"); + assert!( + value.get("projectName").is_some(), + "must have projectName key" + ); assert!(value.get("stories").is_some(), "must have stories key"); let story = &value["stories"][0]; assert!(story.get("scope").is_some()); diff --git a/crates/ralph-core/src/prompt.rs b/crates/ralph-core/src/prompt.rs index 47e7025..6b4d06d 100644 --- a/crates/ralph-core/src/prompt.rs +++ b/crates/ralph-core/src/prompt.rs @@ -86,18 +86,12 @@ impl<'a> PromptBuilder<'a> { let mut prompt = String::with_capacity(8192); let mut truncated = false; - let base_prompt = - std::fs::read_to_string(self.prompt_file).unwrap_or_else(|_| { - format!( - "No prompt file found at {}", - self.prompt_file.display() - ) - }); + let base_prompt = std::fs::read_to_string(self.prompt_file) + .unwrap_or_else(|_| format!("No prompt file found at {}", self.prompt_file.display())); prompt.push_str(&base_prompt); prompt.push_str("\n\n## GUARDRAILS (READ FIRST!)\n\n"); - let guardrails_content = guardrails::read_content(self.guardrails_file) - .unwrap_or_default(); + let guardrails_content = guardrails::read_content(self.guardrails_file).unwrap_or_default(); let guardrails_section = truncate_section(&guardrails_content, MAX_GUARDRAILS_BYTES); if guardrails_section.len() < guardrails_content.len() { truncated = true; @@ -129,7 +123,7 @@ impl<'a> PromptBuilder<'a> { prompt.push_str("Next story to implement:\n"); let story_json = - serde_json::to_string_pretty(story).unwrap_or_else(|_| format!("{:?}", story)); + serde_json::to_string_pretty(story).unwrap_or_else(|_| format!("{story:?}")); let story_trimmed = truncate_section(&story_json, MAX_STORY_JSON_BYTES); if story_trimmed.len() < story_json.len() { truncated = true; @@ -144,22 +138,13 @@ impl<'a> PromptBuilder<'a> { prompt.push_str("\n\n---\n"); prompt.push_str("## DYNAMIC CONTEXT (this section changes per iteration)\n\n"); - prompt.push_str(&format!( - "RALPH_DIR: {}\n", - self.ralph_dir.display() - )); - prompt.push_str(&format!( - "WORK_DIR: {}\n", - self.work_dir.display() - )); + prompt.push_str(&format!("RALPH_DIR: {}\n", self.ralph_dir.display())); + prompt.push_str(&format!("WORK_DIR: {}\n", self.work_dir.display())); prompt.push_str(&format!( "Current working directory: {}\n", self.work_dir.display() )); - prompt.push_str(&format!( - "Config directory: {}\n", - self.ralph_dir.display() - )); + prompt.push_str(&format!("Config directory: {}\n", self.ralph_dir.display())); prompt.push_str(&format!("ITERATION: {}\n", self.iteration)); if let Some(checkpoint) = bulk_checkpoint { diff --git a/crates/ralph-core/src/providers/cli.rs b/crates/ralph-core/src/providers/cli.rs index d1cc153..40ca0ca 100644 --- a/crates/ralph-core/src/providers/cli.rs +++ b/crates/ralph-core/src/providers/cli.rs @@ -86,11 +86,7 @@ impl CliProvider { binary: "opencode".into(), model, build_args: |_provider, prompt, _| { - vec![ - "run".into(), - "--print-logs".into(), - prompt.into(), - ] + vec!["run".into(), "--print-logs".into(), prompt.into()] }, use_current_dir: true, } @@ -255,8 +251,7 @@ impl Provider for CliProvider { .try_wait() .ok() .flatten() - .map(|status| status.code().unwrap_or(1)) - .unwrap_or(1); + .map_or(1, |status| status.code().unwrap_or(1)); logger::log_info(&format!( "[{}] {} finished (exit: {exit_code})", diff --git a/crates/ralph-core/src/providers/fixture.rs b/crates/ralph-core/src/providers/fixture.rs index f7f1047..47ec025 100644 --- a/crates/ralph-core/src/providers/fixture.rs +++ b/crates/ralph-core/src/providers/fixture.rs @@ -76,11 +76,11 @@ impl FixtureProvider { } impl super::Provider for FixtureProvider { - fn name(&self) -> &str { + fn name(&self) -> &'static str { "fixture" } - fn model(&self) -> &str { + fn model(&self) -> &'static str { "deterministic" } diff --git a/crates/ralph-core/src/providers/mod.rs b/crates/ralph-core/src/providers/mod.rs index 973a534..4495d56 100644 --- a/crates/ralph-core/src/providers/mod.rs +++ b/crates/ralph-core/src/providers/mod.rs @@ -32,7 +32,9 @@ impl AgentResult { pub fn detect_rate_limit(output_lines: &[String]) -> (bool, Option) { for line in output_lines.iter().rev().take(30) { let lower = line.to_lowercase(); - let is_rate_limited = RATE_LIMIT_PATTERNS.iter().any(|pattern| lower.contains(pattern)); + let is_rate_limited = RATE_LIMIT_PATTERNS + .iter() + .any(|pattern| lower.contains(pattern)); if is_rate_limited { let retry_msg = extract_retry_time(line); return (true, retry_msg); @@ -45,7 +47,7 @@ impl AgentResult { fn extract_retry_time(line: &str) -> Option { if let Some(idx) = line.to_lowercase().find("try again at ") { let after = &line[idx + 13..]; - let end = after.find(|ch: char| ch == '"' || ch == '.' || ch == '}').unwrap_or(after.len()); + let end = after.find(['"', '.', '}']).unwrap_or(after.len()); let time_str = after[..end].trim(); if !time_str.is_empty() { return Some(time_str.to_string()); diff --git a/crates/ralph-core/src/scheduler/mod.rs b/crates/ralph-core/src/scheduler/mod.rs index 26aeeec..abbcc32 100644 --- a/crates/ralph-core/src/scheduler/mod.rs +++ b/crates/ralph-core/src/scheduler/mod.rs @@ -1,7 +1,9 @@ mod coordinator; pub mod worktree_runner; -use crate::loop_engine::scheduler::{schedule as schedule_completions, MergeAction, WorktreeCompletion}; +use crate::loop_engine::scheduler::{ + schedule as schedule_completions, MergeAction, WorktreeCompletion, +}; use crate::prd::{Prd, UserStory}; use std::collections::HashSet; diff --git a/crates/ralph-core/src/state.rs b/crates/ralph-core/src/state.rs index 392816f..1b1f1a4 100644 --- a/crates/ralph-core/src/state.rs +++ b/crates/ralph-core/src/state.rs @@ -1,6 +1,6 @@ use crate::logger; use std::path::Path; -use tokio::time::{Duration, sleep}; +use tokio::time::{sleep, Duration}; pub fn is_paused(pause_file: &Path) -> bool { pause_file.exists() diff --git a/crates/ralph-core/src/verification.rs b/crates/ralph-core/src/verification.rs index 22c29ac..e6c4ae4 100644 --- a/crates/ralph-core/src/verification.rs +++ b/crates/ralph-core/src/verification.rs @@ -34,10 +34,7 @@ pub struct VerificationResult { pub raw_output: String, } -pub async fn run_verification( - story: &UserStory, - work_dir: &Path, -) -> VerificationResult { +pub async fn run_verification(story: &UserStory, work_dir: &Path) -> VerificationResult { let commands = &story.verification.commands; if commands.is_empty() { return VerificationResult { @@ -129,42 +126,43 @@ fn parse_diagnostics(output: &str) -> Vec { } fn parse_typescript(output: &str) -> Vec { - let pattern = Regex::new( - r"(?m)^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)$" - ).expect("valid regex"); + let pattern = + Regex::new(r"(?m)^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)$").expect("valid regex"); - pattern.captures_iter(output).map(|cap| { - Diagnostic { + pattern + .captures_iter(output) + .map(|cap| Diagnostic { file: Some(cap[1].to_string()), line: cap[2].parse().ok(), col: cap[3].parse().ok(), error_type: cap[4].to_string(), message: cap[5].to_string(), source_context: None, - } - }).collect() + }) + .collect() } fn parse_cargo(output: &str) -> Vec { - let pattern = Regex::new( - r"(?m)^error\[([A-Z]\d+)\]:\s*(.+)\n\s*-->\s*(.+?):(\d+):(\d+)" - ).expect("valid regex"); + let pattern = Regex::new(r"(?m)^error\[([A-Z]\d+)\]:\s*(.+)\n\s*-->\s*(.+?):(\d+):(\d+)") + .expect("valid regex"); - pattern.captures_iter(output).map(|cap| { - Diagnostic { + pattern + .captures_iter(output) + .map(|cap| Diagnostic { file: Some(cap[3].to_string()), line: cap[4].parse().ok(), col: cap[5].parse().ok(), error_type: cap[1].to_string(), message: cap[2].to_string(), source_context: None, - } - }).collect() + }) + .collect() } fn parse_eslint(output: &str) -> Vec { let file_pattern = Regex::new(r"(?m)^(/[^\s]+|[A-Za-z]:\\[^\s]+)$").expect("valid regex"); - let rule_pattern = Regex::new(r"(?m)^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)$").expect("valid regex"); + let rule_pattern = Regex::new(r"(?m)^\s+(\d+):(\d+)\s+(error|warning)\s+(.+?)\s{2,}(\S+)$") + .expect("valid regex"); let mut results = Vec::new(); let mut current_file: Option = None; @@ -187,18 +185,20 @@ fn parse_eslint(output: &str) -> Vec { } fn parse_jest(output: &str) -> Vec { - let pattern = Regex::new(r"(?m)●\s+(.+?)\s*\n.*?\n\s+at\s+.*?\((.+?):(\d+):(\d+)\)").expect("valid regex"); + let pattern = Regex::new(r"(?m)●\s+(.+?)\s*\n.*?\n\s+at\s+.*?\((.+?):(\d+):(\d+)\)") + .expect("valid regex"); - pattern.captures_iter(output).map(|cap| { - Diagnostic { + pattern + .captures_iter(output) + .map(|cap| Diagnostic { file: Some(cap[2].to_string()), line: cap[3].parse().ok(), col: cap[4].parse().ok(), error_type: "test_failure".to_string(), message: cap[1].to_string(), source_context: None, - } - }).collect() + }) + .collect() } pub fn build_retry_prompt( @@ -213,7 +213,7 @@ pub fn build_retry_prompt( prompt.push_str("You are fixing a story that failed verification. "); prompt.push_str("Study the diagnostics carefully and fix only the identified issues.\n\n"); - prompt.push_str(&format!("## Story Context\n\n")); + prompt.push_str("## Story Context\n\n"); prompt.push_str(&format!("**ID:** {}\n", story.id)); prompt.push_str(&format!("**Title:** {}\n", story.title)); if let Some(desc) = &story.description { @@ -292,7 +292,9 @@ pub fn validate_command(cmd: &str) -> Result<(), String> { let lower = cmd.to_lowercase(); for pattern in DENIED_PATTERNS { if lower.contains(pattern) { - return Err(format!("blocked: command matches denied pattern '{pattern}'")); + return Err(format!( + "blocked: command matches denied pattern '{pattern}'" + )); } } Ok(()) diff --git a/crates/ralph-core/src/worktree.rs b/crates/ralph-core/src/worktree.rs index 41c8814..e20379a 100644 --- a/crates/ralph-core/src/worktree.rs +++ b/crates/ralph-core/src/worktree.rs @@ -32,7 +32,11 @@ pub struct WorktreeDiff { pub deletions: u32, } -pub async fn create(work_dir: &Path, worktree_path: &str, branch: &str) -> Result { +pub async fn create( + work_dir: &Path, + worktree_path: &str, + branch: &str, +) -> Result { let create_branch = ["worktree", "add", worktree_path, "-b", branch]; if run_git(work_dir, &create_branch).await.is_err() { let existing_branch = ["worktree", "add", worktree_path, branch]; @@ -46,7 +50,11 @@ pub async fn list(work_dir: &Path) -> Result, WorktreeError> { Ok(parse_worktree_list(&output)) } -pub async fn remove(work_dir: &Path, worktree_path: &Path, branch: Option<&str>) -> Result<(), WorktreeError> { +pub async fn remove( + work_dir: &Path, + worktree_path: &Path, + branch: Option<&str>, +) -> Result<(), WorktreeError> { let target = worktree_path.to_string_lossy().into_owned(); run_git(work_dir, &["worktree", "remove", &target, "--force"]).await?; if let Some(branch_name) = branch { @@ -64,7 +72,11 @@ pub async fn inspect(work_dir: &Path, worktree_path: &Path) -> Result Result { +pub async fn diff( + work_dir: &Path, + from_ref: &str, + to_ref: &str, +) -> Result { let range = format!("{from_ref}..{to_ref}"); let output = run_git(work_dir, &["diff", "--numstat", &range]).await?; Ok(parse_diff_numstat(&output)) @@ -78,7 +90,10 @@ pub fn parse_worktree_list(raw: &str) -> Vec { if !current.path.is_empty() { worktrees.push(current); } - current = WorktreeInfo { path: path.to_string(), ..WorktreeInfo::default() }; + current = WorktreeInfo { + path: path.to_string(), + ..WorktreeInfo::default() + }; continue; } if let Some(head) = line.strip_prefix("HEAD ") { @@ -178,6 +193,13 @@ mod tests { #[test] fn parses_numstat_safely() { let raw = "10\t2\ta.rs\n-\t-\tb.bin\n"; - assert_eq!(parse_diff_numstat(raw), WorktreeDiff { files_changed: 2, insertions: 10, deletions: 2 }); + assert_eq!( + parse_diff_numstat(raw), + WorktreeDiff { + files_changed: 2, + insertions: 10, + deletions: 2 + } + ); } } diff --git a/crates/ralph-core/tests/loop_fixture_runner.rs b/crates/ralph-core/tests/loop_fixture_runner.rs index 48538bf..caca2f2 100644 --- a/crates/ralph-core/tests/loop_fixture_runner.rs +++ b/crates/ralph-core/tests/loop_fixture_runner.rs @@ -72,7 +72,11 @@ async fn happy_path_emits_deterministic_events() { let result = ralph_core::loop_engine::run(&config, &provider, shutdown, &sink).await; assert!(result.is_ok(), "loop should complete without error"); - assert_eq!(provider.call_count(), 1, "happy path should run exactly one iteration"); + assert_eq!( + provider.call_count(), + 1, + "happy path should run exactly one iteration" + ); let types = sink.event_types(); assert!( @@ -120,8 +124,14 @@ async fn loop_failure_stops_after_gutter_threshold() { ); let prd = ralph_core::prd::Prd::load(&config.paths.prd_file).unwrap(); - assert!(!prd.stories[0].passes, "failed story should not be marked passed"); - assert!(prd.stories[0].blocked, "failed story should be blocked after gutter"); + assert!( + !prd.stories[0].passes, + "failed story should not be marked passed" + ); + assert!( + prd.stories[0].blocked, + "failed story should be blocked after gutter" + ); } #[tokio::test] diff --git a/crates/ralph-core/tests/parallel_scheduler.rs b/crates/ralph-core/tests/parallel_scheduler.rs index 9b43a64..dd62033 100644 --- a/crates/ralph-core/tests/parallel_scheduler.rs +++ b/crates/ralph-core/tests/parallel_scheduler.rs @@ -5,10 +5,10 @@ use ralph_core::prd::Prd; use ralph_core::providers::{AgentResult, Provider}; use std::path::{Path, PathBuf}; use std::process::Command; -use std::sync::Arc; use std::sync::atomic::AtomicBool; +use std::sync::Arc; use tokio::sync::Barrier; -use tokio::time::{Duration, timeout}; +use tokio::time::{timeout, Duration}; #[derive(Clone)] struct ParallelProbeProvider { diff --git a/crates/ralph-core/tests/scheduler_merge.rs b/crates/ralph-core/tests/scheduler_merge.rs index a0c2cdf..6c0a444 100644 --- a/crates/ralph-core/tests/scheduler_merge.rs +++ b/crates/ralph-core/tests/scheduler_merge.rs @@ -1,6 +1,4 @@ -use ralph_core::prd::{ - Complexity, Prd, Priority, ScopeSpec, UserStory, VerificationSpec, -}; +use ralph_core::prd::{Complexity, Prd, Priority, ScopeSpec, UserStory, VerificationSpec}; use ralph_core::scheduler; fn story(id: &str) -> UserStory { diff --git a/crates/ralph-core/tests/scheduler_ready_sets.rs b/crates/ralph-core/tests/scheduler_ready_sets.rs index 5464e17..4562f53 100644 --- a/crates/ralph-core/tests/scheduler_ready_sets.rs +++ b/crates/ralph-core/tests/scheduler_ready_sets.rs @@ -1,6 +1,4 @@ -use ralph_core::prd::{ - Complexity, Prd, Priority, ScopeSpec, UserStory, VerificationSpec, -}; +use ralph_core::prd::{Complexity, Prd, Priority, ScopeSpec, UserStory, VerificationSpec}; use ralph_core::scheduler; fn story(id: &str) -> UserStory { diff --git a/crates/ralph-core/tests/worktree.rs b/crates/ralph-core/tests/worktree.rs index a7e9590..d863dbf 100644 --- a/crates/ralph-core/tests/worktree.rs +++ b/crates/ralph-core/tests/worktree.rs @@ -24,15 +24,27 @@ fn init_repo() -> (tempfile::TempDir, PathBuf, String, String) { std::fs::create_dir(&repo_dir).expect("repo dir should exist"); git(&repo_dir, &["init"]); git(&repo_dir, &["config", "user.name", "LoopForge Test"]); - git(&repo_dir, &["config", "user.email", "loopforge@example.com"]); + git( + &repo_dir, + &["config", "user.email", "loopforge@example.com"], + ); let tracked_contents = "tracked root contents\n".to_string(); let untracked_contents = "untracked sentinel\n".to_string(); - std::fs::write(repo_dir.join("README.md"), &tracked_contents).expect("tracked file should write"); + std::fs::write(repo_dir.join("README.md"), &tracked_contents) + .expect("tracked file should write"); git(&repo_dir, &["add", "README.md"]); git(&repo_dir, &["commit", "-m", "init"]); - std::fs::write(repo_dir.join("notes.txt"), &untracked_contents).expect("untracked file should write"); - let canonical_repo_dir = repo_dir.canonicalize().expect("repo dir should canonicalize"); - (temp_dir, canonical_repo_dir, tracked_contents, untracked_contents) + std::fs::write(repo_dir.join("notes.txt"), &untracked_contents) + .expect("untracked file should write"); + let canonical_repo_dir = repo_dir + .canonicalize() + .expect("repo dir should canonicalize"); + ( + temp_dir, + canonical_repo_dir, + tracked_contents, + untracked_contents, + ) } fn branch_exists(repo_dir: &Path, branch: &str) -> bool { @@ -53,15 +65,26 @@ async fn worktree_setup_and_teardown_are_reentrant() { .expect("first setup should succeed"); assert_eq!(first.path, expected_path); assert_eq!(first.branch, worktree_branch); - assert!(worktree_dir.exists(), "worktree directory must exist after setup"); + assert!( + worktree_dir.exists(), + "worktree directory must exist after setup" + ); assert_eq!( std::fs::read_to_string(worktree_dir.join("README.md")).unwrap(), tracked_contents ); assert_eq!(git(&repo_dir, &["rev-parse", "HEAD"]), repo_head); - assert_eq!(std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), tracked_contents); - assert_eq!(std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), untracked_contents); - let first_list = worktree::list(&repo_dir).await.expect("worktree list should load"); + assert_eq!( + std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), + tracked_contents + ); + assert_eq!( + std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), + untracked_contents + ); + let first_list = worktree::list(&repo_dir) + .await + .expect("worktree list should load"); assert_eq!( first_list .iter() @@ -74,11 +97,16 @@ async fn worktree_setup_and_teardown_are_reentrant() { .await .expect("first teardown should succeed"); assert!(!worktree_dir.exists(), "worktree directory must be removed"); - assert!(branch_exists(&repo_dir, worktree_branch), "branch should remain after partial teardown"); + assert!( + branch_exists(&repo_dir, worktree_branch), + "branch should remain after partial teardown" + ); let first_teardown = worktree::list(&repo_dir) .await .expect("list should succeed after first teardown"); - assert!(first_teardown.iter().all(|entry| entry.path != expected_path)); + assert!(first_teardown + .iter() + .all(|entry| entry.path != expected_path)); let inspect_error = worktree::inspect(&repo_dir, Path::new(worktree_path)) .await .expect_err("removed worktree should not be inspectable"); @@ -89,7 +117,9 @@ async fn worktree_setup_and_teardown_are_reentrant() { .expect("second setup should reuse branch cleanly"); assert_eq!(second.path, expected_path); assert_eq!(second.branch, worktree_branch); - let second_list = worktree::list(&repo_dir).await.expect("second list should load"); + let second_list = worktree::list(&repo_dir) + .await + .expect("second list should load"); assert_eq!( second_list .iter() @@ -98,21 +128,38 @@ async fn worktree_setup_and_teardown_are_reentrant() { 1 ); assert_eq!(git(&repo_dir, &["rev-parse", "HEAD"]), repo_head); - assert_eq!(std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), tracked_contents); - assert_eq!(std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), untracked_contents); + assert_eq!( + std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), + tracked_contents + ); + assert_eq!( + std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), + untracked_contents + ); worktree::remove(&repo_dir, Path::new(worktree_path), Some(worktree_branch)) .await .expect("final teardown should succeed"); - assert!(!worktree_dir.exists(), "worktree directory must stay removed"); + assert!( + !worktree_dir.exists(), + "worktree directory must stay removed" + ); assert!( !branch_exists(&repo_dir, worktree_branch), "branch should be deleted during final teardown" ); - let final_list = worktree::list(&repo_dir).await.expect("final list should load"); + let final_list = worktree::list(&repo_dir) + .await + .expect("final list should load"); assert_eq!(final_list.len(), 1); assert_eq!(final_list[0].path, repo_dir.to_string_lossy()); assert_eq!(git(&repo_dir, &["rev-parse", "HEAD"]), repo_head); - assert_eq!(std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), tracked_contents); - assert_eq!(std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), untracked_contents); + assert_eq!( + std::fs::read_to_string(repo_dir.join("README.md")).unwrap(), + tracked_contents + ); + assert_eq!( + std::fs::read_to_string(repo_dir.join("notes.txt")).unwrap(), + untracked_contents + ); } diff --git a/crates/ralph-core/tests/worktree_runner.rs b/crates/ralph-core/tests/worktree_runner.rs index b0de182..ca77fa1 100644 --- a/crates/ralph-core/tests/worktree_runner.rs +++ b/crates/ralph-core/tests/worktree_runner.rs @@ -57,7 +57,11 @@ fn different_stories_receive_different_worktree_paths() { for (idx, path) in paths.iter().enumerate() { for (jdx, other) in paths.iter().enumerate() { if idx != jdx { - assert_ne!(path, other, "{} and {} must differ", stories[idx], stories[jdx]); + assert_ne!( + path, other, + "{} and {} must differ", + stories[idx], stories[jdx] + ); } } } diff --git a/crates/ralph-core/tests/worktree_scheduler.rs b/crates/ralph-core/tests/worktree_scheduler.rs index 158c63b..13a9527 100644 --- a/crates/ralph-core/tests/worktree_scheduler.rs +++ b/crates/ralph-core/tests/worktree_scheduler.rs @@ -1,4 +1,6 @@ -use ralph_core::loop_engine::scheduler::{self, CompletionScheduler, MergeAction, WorktreeCompletion}; +use ralph_core::loop_engine::scheduler::{ + self, CompletionScheduler, MergeAction, WorktreeCompletion, +}; fn completion( story_id: &str, @@ -53,7 +55,9 @@ fn same_story_from_different_worktrees_ordered_by_sequence_then_id() { let worktree_order: Vec<&str> = actions .iter() .filter_map(|action| match action { - MergeAction::UpdateStoryStatus { story_id, .. } if story_id == "S-001" => Some("status"), + MergeAction::UpdateStoryStatus { story_id, .. } if story_id == "S-001" => { + Some("status") + } _ => None, }) .collect(); @@ -177,7 +181,14 @@ fn blocked_completion_propagates_blocked_flag() { #[test] fn large_concurrent_set_stays_deterministic() { let mut completions: Vec = (0..50) - .map(|idx| completion(&format!("S-{idx:03}"), &format!("wt-{idx}"), idx % 3 == 0, idx)) + .map(|idx| { + completion( + &format!("S-{idx:03}"), + &format!("wt-{idx}"), + idx % 3 == 0, + idx, + ) + }) .collect(); let forward = scheduler::schedule(completions.clone()); completions.reverse(); @@ -192,8 +203,7 @@ fn serialization_roundtrip_preserves_completion() { comp.guardrail_append = Some("limit reached".into()); let json = serde_json::to_string(&comp).expect("should serialize"); - let restored: WorktreeCompletion = - serde_json::from_str(&json).expect("should deserialize"); + let restored: WorktreeCompletion = serde_json::from_str(&json).expect("should deserialize"); assert_eq!(comp, restored); } @@ -204,7 +214,6 @@ fn serialization_roundtrip_preserves_merge_action() { content: "circuit breaker".into(), }; let json = serde_json::to_string(&action).expect("should serialize"); - let restored: MergeAction = - serde_json::from_str(&json).expect("should deserialize"); + let restored: MergeAction = serde_json::from_str(&json).expect("should deserialize"); assert_eq!(action, restored); } diff --git a/lefthook.yml b/lefthook.yml new file mode 100644 index 0000000..ab07bf6 --- /dev/null +++ b/lefthook.yml @@ -0,0 +1,58 @@ +pre-commit: + parallel: true + commands: + biome-check: + glob: "**/*.{ts,tsx,js,jsx,json,css}" + run: bunx biome check --no-errors-on-unmatched --changed --since=HEAD {staged_files} + stage_fixed: true + rust-fmt: + glob: "**/*.rs" + run: cargo fmt --all -- --check + rust-clippy: + glob: "**/*.rs" + run: cargo clippy --workspace -- -D warnings + typecheck: + glob: "**/*.{ts,tsx}" + run: bun run typecheck +# EXAMPLE USAGE: +# +# Refer for explanation to following link: +# https://lefthook.dev/configuration/ +# +# pre-push: +# jobs: +# - name: packages audit +# tags: +# - frontend +# - security +# run: yarn audit +# +# - name: gems audit +# tags: +# - backend +# - security +# run: bundle audit +# +# pre-commit: +# parallel: true +# jobs: +# - run: yarn eslint {staged_files} +# glob: "*.{js,ts,jsx,tsx}" +# +# - name: rubocop +# glob: "*.rb" +# exclude: +# - config/application.rb +# - config/routes.rb +# run: bundle exec rubocop --force-exclusion -- {all_files} +# +# - name: govet +# files: git ls-files -m +# glob: "*.go" +# run: go vet -- {files} +# +# - script: "hello.js" +# runner: node +# +# - script: "hello.go" +# runner: go run diff --git a/package.json b/package.json index 0013e7b..ed2ebe9 100644 --- a/package.json +++ b/package.json @@ -7,13 +7,18 @@ "dev": "vite", "dev:tauri": "node dev-tauri.mjs", "build": "tsc -b && vite build", + "lint": "biome check src/", + "lint:fix": "biome check --write src/", + "format": "biome format src/", + "format:fix": "biome format --write src/", "preview": "vite preview", "test": "vitest run", "e2e": "wdio run e2e/wdio.conf.ts", "e2e:smoke": "wdio run e2e/wdio.conf.ts --spec e2e/specs/smoke.e2e.ts", "e2e:typecheck": "tsc -p e2e/tsconfig.json --noEmit", "typecheck": "tsc --noEmit", - "tauri": "tauri" + "tauri": "tauri", + "postinstall": "lefthook install --force" }, "dependencies": { "@fontsource-variable/inter": "^5.2.8", @@ -42,11 +47,12 @@ "zustand": "^5" }, "devDependencies": { + "@biomejs/biome": "2.4.11", "@tailwindcss/vite": "^4", + "@tauri-apps/cli": "^2", "@testing-library/jest-dom": "^6.8.0", "@testing-library/react": "^16.3.0", "@testing-library/user-event": "^14.6.1", - "@tauri-apps/cli": "^2", "@types/mocha": "^10.0.10", "@types/node": "^25.6.0", "@types/react": "^19", @@ -59,6 +65,7 @@ "@wdio/spec-reporter": "^9.27.0", "expect-webdriverio": "^5.6.5", "jsdom": "^26.1.0", + "lefthook": "^2.1.5", "shadcn": "^4.1.2", "tailwindcss": "^4", "typescript": "^5", diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..dce363e --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,3 @@ +edition = "2021" +max_width = 100 +use_field_init_shorthand = true diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 9ac8010..7f34c83 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,3 +41,6 @@ frozen = [] [target.'cfg(windows)'.dependencies] junction = "1" + +[lints] +workspace = true diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 795b9b7..261851f 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -1,3 +1,3 @@ fn main() { - tauri_build::build() + tauri_build::build(); } diff --git a/src-tauri/src/activity/classifier/codex.rs b/src-tauri/src/activity/classifier/codex.rs index 82318d9..c983c4e 100644 --- a/src-tauri/src/activity/classifier/codex.rs +++ b/src-tauri/src/activity/classifier/codex.rs @@ -34,9 +34,7 @@ impl ActivityClassifier { return Some(PlanEventKind::Thinking); } - if trimmed == "exec" - || trimmed.starts_with("exec ") - || trimmed.starts_with("/bin/zsh -lc") + if trimmed == "exec" || trimmed.starts_with("exec ") || trimmed.starts_with("/bin/zsh -lc") { self.in_tool_output = true; self.in_plan_markdown = false; @@ -83,7 +81,12 @@ impl ActivityClassifier { return Some(PlanEventKind::Search); } - if self.patterns.docs_lookup.iter().any(|re| re.is_match(trimmed)) { + if self + .patterns + .docs_lookup + .iter() + .any(|re| re.is_match(trimmed)) + { self.saw_agent_response = true; return Some(PlanEventKind::DocsLookup); } @@ -92,8 +95,8 @@ impl ActivityClassifier { return Some(PlanEventKind::McpCall); } - let can_be_plan = self.saw_agent_response - && self.line_count > super::PLAN_UNLOCK_LINE_THRESHOLD; + let can_be_plan = + self.saw_agent_response && self.line_count > super::PLAN_UNLOCK_LINE_THRESHOLD; if can_be_plan && Self::looks_like_markdown_plan_line(trimmed) { self.in_plan_markdown = true; diff --git a/src-tauri/src/activity/classifier/mod.rs b/src-tauri/src/activity/classifier/mod.rs index 015eaed..a249f58 100644 --- a/src-tauri/src/activity/classifier/mod.rs +++ b/src-tauri/src/activity/classifier/mod.rs @@ -104,10 +104,20 @@ impl ActivityClassifier { if self.patterns.error.iter().any(|regex| regex.is_match(line)) { return PlanEventKind::Error; } - if self.patterns.mcp_call.iter().any(|regex| regex.is_match(line)) { + if self + .patterns + .mcp_call + .iter() + .any(|regex| regex.is_match(line)) + { return PlanEventKind::McpCall; } - if self.patterns.search.iter().any(|regex| regex.is_match(line)) { + if self + .patterns + .search + .iter() + .any(|regex| regex.is_match(line)) + { return PlanEventKind::Search; } if self @@ -118,7 +128,12 @@ impl ActivityClassifier { { return PlanEventKind::DocsLookup; } - if self.patterns.thinking.iter().any(|regex| regex.is_match(line)) { + if self + .patterns + .thinking + .iter() + .any(|regex| regex.is_match(line)) + { return PlanEventKind::Thinking; } PlanEventKind::PlanContent @@ -142,7 +157,10 @@ impl ActivityClassifier { let mut start = token_line + 1; if start < self.content_buffer.len() { let next = self.content_buffer[start].trim(); - if next.chars().all(|ch| ch.is_ascii_digit() || ch == ',' || ch == '.') { + if next + .chars() + .all(|ch| ch.is_ascii_digit() || ch == ',' || ch == '.') + { start += 1; } } diff --git a/src-tauri/src/agent_profiles.rs b/src-tauri/src/agent_profiles.rs index 20c99d9..b48248b 100644 --- a/src-tauri/src/agent_profiles.rs +++ b/src-tauri/src/agent_profiles.rs @@ -30,10 +30,16 @@ pub struct AgentCapabilities { pub default_effort: Option, } -pub fn resolve_capabilities(agent: &str, binary_path: Option<&str>, path_env: &str) -> AgentCapabilities { +pub fn resolve_capabilities( + agent: &str, + binary_path: Option<&str>, + path_env: &str, +) -> AgentCapabilities { let curated = curated_capabilities(agent); let discovered = discover_models(agent, binary_path, path_env); - if discovered.is_empty() { return curated; } + if discovered.is_empty() { + return curated; + } let models = discovered .into_iter() .map(|id| AgentModelOption { @@ -67,7 +73,12 @@ fn curated_capabilities(agent: &str) -> AgentCapabilities { source: "curated".to_string(), supports_model: true, supports_effort: true, - models: model_options(&["gpt-5.4", "gpt-5.4-mini", "gpt-5-codex", "gpt-5.3-codex-high"]), + models: model_options(&[ + "gpt-5.4", + "gpt-5.4-mini", + "gpt-5-codex", + "gpt-5.3-codex-high", + ]), efforts: effort_options(&["low", "medium", "high"]), default_model: Some("gpt-5.4".to_string()), default_effort: None, @@ -87,7 +98,12 @@ fn curated_capabilities(agent: &str) -> AgentCapabilities { source: "curated".to_string(), supports_model: true, supports_effort: false, - models: model_options(&["auto", "gemini-2.5-pro", "gemini-2.5-flash", "gemini-2.5-flash-lite"]), + models: model_options(&[ + "auto", + "gemini-2.5-pro", + "gemini-2.5-flash", + "gemini-2.5-flash-lite", + ]), efforts: Vec::new(), default_model: Some("auto".to_string()), default_effort: None, diff --git a/src-tauri/src/agents.rs b/src-tauri/src/agents.rs index 917171c..4337ee5 100644 --- a/src-tauri/src/agents.rs +++ b/src-tauri/src/agents.rs @@ -78,7 +78,8 @@ async fn probe_agent(app: &AppHandle, binary: &str) -> (bool, Option) { { return (true, Some(version)); } - if let Some(version) = crate::agent_runtime_env::run_version_probe(&binary_path, &path_env, "-v") + if let Some(version) = + crate::agent_runtime_env::run_version_probe(&binary_path, &path_env, "-v") { return (true, Some(version)); } @@ -94,7 +95,8 @@ pub struct FallbackChain { #[cfg(test)] impl FallbackChain { pub fn new(agents: Vec) -> Self { - let available: Vec = agents.into_iter().filter(|agent| agent.available).collect(); + let available: Vec = + agents.into_iter().filter(|agent| agent.available).collect(); Self { agents: available, current_index: 0, @@ -134,8 +136,8 @@ pub async fn detect_agents( for (name, binary) in KNOWN_AGENTS { let (available, version) = probe_agent(&app, binary).await; detected.push(AgentInfo { - name: name.to_string(), - binary: binary.to_string(), + name: (*name).to_string(), + binary: (*binary).to_string(), version, available, }); @@ -144,6 +146,14 @@ pub async fn detect_agents( store_detected_agents(state, detected) } +#[tauri::command] +pub async fn get_known_agents() -> Result, AgentError> { + Ok(KNOWN_AGENTS + .iter() + .map(|(name, _)| (*name).to_string()) + .collect()) +} + #[tauri::command] pub async fn get_agent_capabilities( agent: String, @@ -151,7 +161,7 @@ pub async fn get_agent_capabilities( let normalized = agent.trim().to_lowercase(); if let Some(runtime) = resolve_test_runtime() { return crate::test_support::agents::fixture_capabilities(&runtime, &normalized) - .ok_or_else(|| AgentError::UnknownAgent(normalized)); + .ok_or(AgentError::UnknownAgent(normalized)); } let Some(binary_name) = known_agent_binary(&normalized) else { return Err(AgentError::UnknownAgent(normalized)); @@ -165,6 +175,66 @@ pub async fn get_agent_capabilities( )) } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvedAgentSelection { + pub capabilities: crate::agent_profiles::AgentCapabilities, + pub resolved_model: Option, + pub resolved_effort: Option, +} + +fn resolve_model( + caps: &crate::agent_profiles::AgentCapabilities, + current: Option, +) -> Option { + if !caps.supports_model { + return None; + } + let current_trimmed = current.filter(|val| !val.trim().is_empty()); + if let Some(ref model) = current_trimmed { + if caps.models.iter().any(|entry| entry.id == *model) { + return current_trimmed; + } + } + caps.default_model + .clone() + .or_else(|| caps.models.first().map(|entry| entry.id.clone())) +} + +fn resolve_effort( + caps: &crate::agent_profiles::AgentCapabilities, + current: Option, +) -> Option { + if !caps.supports_effort { + return None; + } + let current_trimmed = current.filter(|val| !val.trim().is_empty()); + if let Some(ref effort) = current_trimmed { + if caps.efforts.iter().any(|entry| entry.id == *effort) { + return current_trimmed; + } + } + caps.default_effort + .clone() + .or_else(|| caps.efforts.first().map(|entry| entry.id.clone())) +} + +#[tauri::command] +pub async fn resolve_agent_selection( + agent: String, + current_model: Option, + current_effort: Option, +) -> Result { + let caps = get_agent_capabilities(agent).await?; + let resolved_model = resolve_model(&caps, current_model); + let resolved_effort = resolve_effort(&caps, current_effort); + Ok(ResolvedAgentSelection { + capabilities: caps, + resolved_model, + resolved_effort, + }) +} + #[tauri::command] pub async fn refresh_agents( app: AppHandle, diff --git a/src-tauri/src/ask_engine/args.rs b/src-tauri/src/ask_engine/args.rs index 767e49d..d23caf6 100644 --- a/src-tauri/src/ask_engine/args.rs +++ b/src-tauri/src/ask_engine/args.rs @@ -7,9 +7,9 @@ pub fn build_ask_args( model: Option<&str>, ) -> Vec { let selected_model = model - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); match agent { "claude" => { @@ -66,8 +66,7 @@ pub fn build_ask_args( prompt.to_string(), ], "cursor" => { - let model_id = - selected_model.unwrap_or_else(crate::agent_runtime::cursor_model_arg); + let model_id = selected_model.unwrap_or_else(crate::agent_runtime::cursor_model_arg); vec![ "agent".to_string(), "--print".to_string(), diff --git a/src-tauri/src/ask_engine/context.rs b/src-tauri/src/ask_engine/context.rs index fab2b64..a3c11dd 100644 --- a/src-tauri/src/ask_engine/context.rs +++ b/src-tauri/src/ask_engine/context.rs @@ -15,8 +15,10 @@ pub fn build_ask_context( ) -> String { let mut sections: Vec = Vec::new(); - sections.push("You are a project assistant for a software project managed by LoopForge.".into()); - sections.push("Answer questions about the project state, progress, blockers, and stories.".into()); + sections + .push("You are a project assistant for a software project managed by LoopForge.".into()); + sections + .push("Answer questions about the project state, progress, blockers, and stories.".into()); sections.push("Be concise and direct. Reference story IDs when relevant.\n".into()); if let Some(stories_section) = build_stories_section(artifact_dir) { @@ -51,10 +53,22 @@ fn build_stories_section(artifact_dir: &Path) -> Option { let mut lines = vec!["## PRD Stories".to_string()]; for story in stories { let story_id = story.get("id").and_then(|val| val.as_str()).unwrap_or("?"); - let title = story.get("title").and_then(|val| val.as_str()).unwrap_or("untitled"); - let passes = story.get("passes").and_then(|val| val.as_bool()).unwrap_or(false); - let blocked = story.get("blocked").and_then(|val| val.as_bool()).unwrap_or(false); - let attempts = story.get("attempts").and_then(|val| val.as_u64()).unwrap_or(0); + let title = story + .get("title") + .and_then(|val| val.as_str()) + .unwrap_or("untitled"); + let passes = story + .get("passes") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let blocked = story + .get("blocked") + .and_then(serde_json::Value::as_bool) + .unwrap_or(false); + let attempts = story + .get("attempts") + .and_then(serde_json::Value::as_u64) + .unwrap_or(0); let status = if passes { "done" } else if blocked { @@ -62,7 +76,9 @@ fn build_stories_section(artifact_dir: &Path) -> Option { } else { "pending" }; - lines.push(format!("- {story_id}: {title} [{status}, {attempts} attempts]")); + lines.push(format!( + "- {story_id}: {title} [{status}, {attempts} attempts]" + )); } lines.push(String::new()); Some(lines.join("\n")) @@ -111,15 +127,20 @@ fn build_iterations_section(conn: &Connection, project_id: &str) -> Option = stmt - .query_map(rusqlite::params![project_id, MAX_ITERATIONS as i64], |row| { - let story_id: String = row.get(0)?; - let result: String = row.get(1)?; - let agent: String = row.get(2)?; - let duration: i64 = row.get(3)?; - Ok(format!("- {story_id}: {result} (agent: {agent}, {duration}s)")) - }) + .query_map( + rusqlite::params![project_id, MAX_ITERATIONS as i64], + |row| { + let story_id: String = row.get(0)?; + let result: String = row.get(1)?; + let agent: String = row.get(2)?; + let duration: i64 = row.get(3)?; + Ok(format!( + "- {story_id}: {result} (agent: {agent}, {duration}s)" + )) + }, + ) .ok()? - .filter_map(|row| row.ok()) + .filter_map(Result::ok) .collect(); if rows.is_empty() { @@ -140,7 +161,11 @@ fn build_history_section(history: &[AskMessage]) -> String { }; let mut lines = vec!["## Conversation History".to_string()]; for msg in &history[start..] { - let role_label = if msg.role == "user" { "User" } else { "Assistant" }; + let role_label = if msg.role == "user" { + "User" + } else { + "Assistant" + }; lines.push(format!("{role_label}: {}", msg.content)); } lines.push(String::new()); diff --git a/src-tauri/src/ask_engine/fixture.rs b/src-tauri/src/ask_engine/fixture.rs index 232e7ee..6312ff4 100644 --- a/src-tauri/src/ask_engine/fixture.rs +++ b/src-tauri/src/ask_engine/fixture.rs @@ -1,4 +1,4 @@ -use crate::ask_engine::types::{AskCompletePayload, AskStreamPayload}; +use crate::ask_engine::types::{AskCompletePayload, AskMessage, AskStreamPayload}; use crate::events::{EVENT_ASK_COMPLETE, EVENT_ASK_STREAM}; use crate::test_support::runtime::{FixtureSet, TestRuntime}; use std::path::Path; @@ -38,11 +38,20 @@ pub async fn spawn_fixture_ask( let _ = app.emit( EVENT_ASK_COMPLETE, AskCompletePayload { - project_id, - message_id, - full_content: response, + project_id: project_id.clone(), + message_id: message_id.clone(), + full_content: response.clone(), agent: "fixture".to_string(), model: Some("deterministic".to_string()), + message: AskMessage { + id: message_id, + conversation_id: String::new(), + role: "assistant".to_string(), + content: response, + agent: Some("fixture".to_string()), + model: Some("deterministic".to_string()), + created_at: chrono::Utc::now().to_rfc3339(), + }, }, ); } diff --git a/src-tauri/src/ask_engine/session.rs b/src-tauri/src/ask_engine/session.rs index 2196acd..67f8cb1 100644 --- a/src-tauri/src/ask_engine/session.rs +++ b/src-tauri/src/ask_engine/session.rs @@ -12,13 +12,19 @@ pub struct AskSessionsState(pub Arc>); impl AskSessionsState { pub fn insert(&self, project_id: &str, child: CommandChild) -> Result<(), String> { - let mut sessions = self.0.lock().map_err(|_| "Ask session lock poisoned".to_string())?; + let mut sessions = self + .0 + .lock() + .map_err(|_| "Ask session lock poisoned".to_string())?; sessions.sessions.insert(project_id.to_string(), child); Ok(()) } pub fn remove_and_kill(&self, project_id: &str) -> Result { - let mut sessions = self.0.lock().map_err(|_| "Ask session lock poisoned".to_string())?; + let mut sessions = self + .0 + .lock() + .map_err(|_| "Ask session lock poisoned".to_string())?; if let Some(child) = sessions.sessions.remove(project_id) { let _ = child.kill(); Ok(true) diff --git a/src-tauri/src/ask_engine/stream.rs b/src-tauri/src/ask_engine/stream.rs index a414597..ea8aaa9 100644 --- a/src-tauri/src/ask_engine/stream.rs +++ b/src-tauri/src/ask_engine/stream.rs @@ -2,7 +2,7 @@ use crate::ask_engine::args::{agent_env_vars, build_ask_args, is_safe_binary_nam use crate::ask_engine::context::build_ask_context; use crate::ask_engine::session::AskSessionsState; use crate::ask_engine::types::{ - AskCompletePayload, AskErrorPayload, AskStreamPayload, StartAskArgs, + AskCompletePayload, AskErrorPayload, AskMessage, AskStreamPayload, StartAskArgs, }; use crate::ask_engine::AskEngineError; use crate::db::DbState; @@ -77,7 +77,7 @@ pub async fn spawn_ask( sessions .insert(&args.project_id, child) - .map_err(|err| AskEngineError::Shell(err))?; + .map_err(AskEngineError::Shell)?; let project_id = args.project_id.clone(); let agent_name = args.agent.clone(); @@ -106,15 +106,24 @@ pub async fn spawn_ask( ); } CommandEvent::Terminated(status) => { - let success = status.code.map(|code| code == 0).unwrap_or(false); + let success = status.code.is_some_and(|code| code == 0); if success && !collected.trim().is_empty() { - save_assistant_message( + let message = save_assistant_message( &app_clone, &project_id, &collected, &agent_name, agent_model.as_deref(), - ); + ) + .unwrap_or_else(|| AskMessage { + id: message_id.clone(), + conversation_id: String::new(), + role: "assistant".to_string(), + content: collected.clone(), + agent: Some(agent_name.clone()), + model: agent_model.clone(), + created_at: chrono::Utc::now().to_rfc3339(), + }); let _ = app_clone.emit( EVENT_ASK_COMPLETE, AskCompletePayload { @@ -123,6 +132,7 @@ pub async fn spawn_ask( full_content: collected.clone(), agent: agent_name.clone(), model: agent_model.clone(), + message, }, ); } else { @@ -131,12 +141,24 @@ pub async fn spawn_ask( } else { collected.clone() }; + let content = format!("Error: {error_msg}"); + let message = save_error_message(&app_clone, &project_id, &content) + .unwrap_or_else(|| AskMessage { + id: message_id.clone(), + conversation_id: String::new(), + role: "assistant".to_string(), + content, + agent: None, + model: None, + created_at: chrono::Utc::now().to_rfc3339(), + }); let _ = app_clone.emit( EVENT_ASK_ERROR, AskErrorPayload { project_id: project_id.clone(), message_id: message_id.clone(), error: error_msg, + message, }, ); } @@ -169,22 +191,46 @@ fn save_assistant_message( content: &str, agent: &str, model: Option<&str>, -) { +) -> Option { let db = app.state::(); - let Ok(conn) = db.0.lock() else { return }; + let Ok(conn) = db.0.lock() else { return None }; let Ok(conversation) = crate::ask_engine::storage::get_or_create_conversation(&conn, project_id) else { - return; + return None; }; - let _ = crate::ask_engine::storage::insert_message( + crate::ask_engine::storage::insert_message( &conn, &conversation.id, "assistant", content, Some(agent), model, - ); + ) + .ok() +} + +fn save_error_message( + app: &AppHandle, + project_id: &str, + content: &str, +) -> Option { + let db = app.state::(); + let Ok(conn) = db.0.lock() else { return None }; + let Ok(conversation) = + crate::ask_engine::storage::get_or_create_conversation(&conn, project_id) + else { + return None; + }; + crate::ask_engine::storage::insert_message( + &conn, + &conversation.id, + "assistant", + content, + None, + None, + ) + .ok() } fn build_null_stdin_command(binary: &str, args: &[String]) -> String { diff --git a/src-tauri/src/ask_engine/types.rs b/src-tauri/src/ask_engine/types.rs index 85b1b3d..9613ee4 100644 --- a/src-tauri/src/ask_engine/types.rs +++ b/src-tauri/src/ask_engine/types.rs @@ -32,6 +32,13 @@ pub struct StartAskArgs { pub model: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AskQuestionResult { + pub message_id: String, + pub user_message: AskMessage, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "camelCase")] pub struct AskStreamPayload { @@ -49,6 +56,7 @@ pub struct AskCompletePayload { pub agent: String, #[serde(default)] pub model: Option, + pub message: AskMessage, } #[derive(Debug, Clone, Serialize)] @@ -57,4 +65,5 @@ pub struct AskErrorPayload { pub project_id: String, pub message_id: String, pub error: String, + pub message: AskMessage, } diff --git a/src-tauri/src/atomizer/agent_args.rs b/src-tauri/src/atomizer/agent_args.rs index 8168f89..3fbc17c 100644 --- a/src-tauri/src/atomizer/agent_args.rs +++ b/src-tauri/src/atomizer/agent_args.rs @@ -32,13 +32,13 @@ pub(super) fn build_agent_args( effort: Option<&str>, ) -> Vec { let selected_model = model - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); let selected_effort = effort - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); match agent { "claude" => { let mut args = vec![ @@ -51,7 +51,7 @@ pub(super) fn build_agent_args( "--mcp-config".to_string(), crate::agent_runtime::CLAUDE_EMPTY_MCP_CONFIG.to_string(), "--tools".to_string(), - "".to_string(), + String::new(), ]; if let Some(model_id) = selected_model { args.extend(["--model".to_string(), model_id]); diff --git a/src-tauri/src/atomizer/mod.rs b/src-tauri/src/atomizer/mod.rs index 97ead10..f83d7aa 100644 --- a/src-tauri/src/atomizer/mod.rs +++ b/src-tauri/src/atomizer/mod.rs @@ -15,10 +15,7 @@ mod types; pub use activity::ActivityLogState; pub use progress::{get_pipeline_snapshot, PipelineRegistryState}; pub use run::run_atomizer; -pub use types::{ - AtomizeActivity, AtomizeActivityKind, AtomizeArgs, AtomizeProgress, AtomizerError, - PipelineSnapshot, -}; +pub use types::{AtomizeActivity, AtomizeArgs, AtomizeProgress, AtomizerError, PipelineSnapshot}; #[cfg(test)] mod tests_json; diff --git a/src-tauri/src/atomizer/progress.rs b/src-tauri/src/atomizer/progress.rs index ab3d770..ef9f3af 100644 --- a/src-tauri/src/atomizer/progress.rs +++ b/src-tauri/src/atomizer/progress.rs @@ -26,7 +26,10 @@ pub(super) fn mark_pipeline_start(app: &AppHandle, project_id: &s if let Ok(mut registry) = app.state::().0.lock() { registry.entries.insert( project_id.to_string(), - PipelineEntry { snapshot, started_instant: Instant::now() }, + PipelineEntry { + snapshot, + started_instant: Instant::now(), + }, ); } } @@ -68,6 +71,7 @@ pub fn get_pipeline_snapshot( if !snap.done && snap.error.is_none() { snap.elapsed_ms = entry.started_instant.elapsed().as_millis() as u64; } + snap.recompute_derived(); snap }) }) @@ -98,7 +102,11 @@ pub(super) fn emit_progress( .0 .lock() .ok() - .and_then(|reg| reg.entries.get(project_id).map(|ent| ent.snapshot.elapsed_ms)) + .and_then(|reg| { + reg.entries + .get(project_id) + .map(|ent| ent.snapshot.elapsed_ms) + }) .unwrap_or(0); let _ = app.emit( diff --git a/src-tauri/src/atomizer/run.rs b/src-tauri/src/atomizer/run.rs index e045584..2a54078 100644 --- a/src-tauri/src/atomizer/run.rs +++ b/src-tauri/src/atomizer/run.rs @@ -24,7 +24,13 @@ pub async fn run_atomizer( match &result { Ok(prd) => { mark_pipeline_done(&app, &pid); - emit_progress(&app, &pid, 4, "merge", &format!("Done — {} stories", prd.stories.len())); + emit_progress( + &app, + &pid, + 4, + "merge", + &format!("Done — {} stories", prd.stories.len()), + ); } Err(err) => mark_pipeline_error(&app, &pid, &err.to_string()), } @@ -63,55 +69,140 @@ async fn execute_pipeline( } let pid = &args.project_id; - emit_activity(app, pid, AtomizeActivityKind::PlanLoaded, &format!("Plan loaded ({} chars)", plan_content.len())); + emit_activity( + app, + pid, + AtomizeActivityKind::PlanLoaded, + &format!("Plan loaded ({} chars)", plan_content.len()), + ); emit_progress(app, pid, 1, "summarize", "Summarizing plan..."); - emit_activity(app, pid, AtomizeActivityKind::TemplateRender, "Rendering summarize template"); + emit_activity( + app, + pid, + AtomizeActivityKind::TemplateRender, + "Rendering summarize template", + ); let condensed = stage_summarize( - app, pid, &env, &plan_content, &args.agent, - args.model.as_deref(), args.effort.as_deref(), &args.project_dir, + app, + pid, + &env, + &plan_content, + &args.agent, + args.model.as_deref(), + args.effort.as_deref(), + &args.project_dir, ) .await?; - emit_activity(app, pid, AtomizeActivityKind::AgentComplete, &format!("Summarize complete ({} chars condensed)", condensed.len())); + emit_activity( + app, + pid, + AtomizeActivityKind::AgentComplete, + &format!("Summarize complete ({} chars condensed)", condensed.len()), + ); emit_progress(app, pid, 2, "chunk", "Splitting into sections..."); - emit_activity(app, pid, AtomizeActivityKind::TemplateRender, "Rendering chunk template"); + emit_activity( + app, + pid, + AtomizeActivityKind::TemplateRender, + "Rendering chunk template", + ); let sections = stage_chunk( - app, pid, &env, &condensed, &args.agent, - args.model.as_deref(), args.effort.as_deref(), &args.project_dir, + app, + pid, + &env, + &condensed, + &args.agent, + args.model.as_deref(), + args.effort.as_deref(), + &args.project_dir, ) .await?; let section_count = sections.len(); for (idx, section) in sections.iter().enumerate() { - emit_activity(app, pid, AtomizeActivityKind::ChunkDetected, &format!("[{}/{}] {}", idx + 1, section_count, section.title)); + emit_activity( + app, + pid, + AtomizeActivityKind::ChunkDetected, + &format!("[{}/{}] {}", idx + 1, section_count, section.title), + ); } - emit_progress(app, pid, 3, "atomize", &format!("Atomizing {section_count} sections...")); + emit_progress( + app, + pid, + 3, + "atomize", + &format!("Atomizing {section_count} sections..."), + ); let all_stories = stage_atomize( - app, pid, &env, §ions, &args.project_name, &args.agent, - args.model.as_deref(), args.effort.as_deref(), &args.project_dir, + app, + pid, + &env, + §ions, + &args.project_name, + &args.agent, + args.model.as_deref(), + args.effort.as_deref(), + &args.project_dir, ) .await?; - emit_activity(app, pid, AtomizeActivityKind::StoryExtracted, &format!("{} raw stories across {section_count} sections", all_stories.len())); + emit_activity( + app, + pid, + AtomizeActivityKind::StoryExtracted, + &format!( + "{} raw stories across {section_count} sections", + all_stories.len() + ), + ); emit_progress(app, pid, 4, "merge", "Merging and ordering stories..."); - emit_activity(app, pid, AtomizeActivityKind::TemplateRender, "Rendering merge template"); + emit_activity( + app, + pid, + AtomizeActivityKind::TemplateRender, + "Rendering merge template", + ); let prd = stage_merge( - app, pid, &env, all_stories, &args.project_name, &args.agent, - args.model.as_deref(), args.effort.as_deref(), &args.project_dir, + app, + pid, + &env, + all_stories, + &args.project_name, + &args.agent, + args.model.as_deref(), + args.effort.as_deref(), + &args.project_dir, ) .await?; - emit_activity(app, pid, AtomizeActivityKind::Validation, &format!("Validating atomicity of {} stories", prd.stories.len())); + emit_activity( + app, + pid, + AtomizeActivityKind::Validation, + &format!("Validating atomicity of {} stories", prd.stories.len()), + ); prd.validate_atomicity() .map_err(|err| AtomizerError::Validation(err.to_string()))?; - emit_activity(app, pid, AtomizeActivityKind::Validation, "Validation passed"); + emit_activity( + app, + pid, + AtomizeActivityKind::Validation, + "Validation passed", + ); save_artifacts(&artifact_path, &prd)?; - emit_activity(app, pid, AtomizeActivityKind::ArtifactSaved, "Artifacts saved (prd.json, prompt.md, guardrails.md)"); + emit_activity( + app, + pid, + AtomizeActivityKind::ArtifactSaved, + "Artifacts saved (prd.json, prompt.md, guardrails.md)", + ); Ok(prd) } diff --git a/src-tauri/src/atomizer/stages.rs b/src-tauri/src/atomizer/stages.rs index 69bef99..283a7ff 100644 --- a/src-tauri/src/atomizer/stages.rs +++ b/src-tauri/src/atomizer/stages.rs @@ -3,7 +3,9 @@ use crate::atomizer::agent_invoke::{invoke_agent, invoke_agent_with_heartbeat}; use crate::atomizer::chunking::chunk_large_plan; use crate::atomizer::json_parse::parse_json_from_candidates; use crate::atomizer::progress::emit_progress; -use crate::atomizer::types::{AtomizeActivityKind, AtomizedStoryDraft, AtomizerError, ChunkSection}; +use crate::atomizer::types::{ + AtomizeActivityKind, AtomizedStoryDraft, AtomizerError, ChunkSection, +}; use minijinja::{context, Environment}; use ralph_core::prd::Prd; use std::path::Path; @@ -22,7 +24,12 @@ pub(super) async fn stage_summarize( let plan_chunks = chunk_large_plan(plan_content); if plan_chunks.len() > 1 { let count = plan_chunks.len(); - emit_activity(app, project_id, AtomizeActivityKind::PlanLoaded, &format!("Large plan split into {count} chunks")); + emit_activity( + app, + project_id, + AtomizeActivityKind::PlanLoaded, + &format!("Large plan split into {count} chunks"), + ); } let mut condensed_parts = Vec::with_capacity(plan_chunks.len()); @@ -33,12 +40,22 @@ pub(super) async fn stage_summarize( let prompt = tmpl .render(context! { plan_content => chunk }) .map_err(|err| AtomizerError::Template(err.to_string()))?; - emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for summarization")); + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentStart, + &format!("Invoking {agent} for summarization"), + ); let hb = Some((project_id.to_string(), 1, "summarize".to_string())); let result = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb) .await?; - emit_activity(app, project_id, AtomizeActivityKind::AgentComplete, &format!("Summary chunk: {} chars", result.len())); + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentComplete, + &format!("Summary chunk: {} chars", result.len()), + ); condensed_parts.push(result); } @@ -62,10 +79,21 @@ pub(super) async fn stage_chunk( .render(context! { condensed_plan => condensed_plan }) .map_err(|err| AtomizerError::Template(err.to_string()))?; - emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for chunking")); + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentStart, + &format!("Invoking {agent} for chunking"), + ); let hb = Some((project_id.to_string(), 2, "chunk".to_string())); - let raw = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; - emit_activity(app, project_id, AtomizeActivityKind::AgentComplete, "Chunk response received, parsing JSON"); + let raw = + invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentComplete, + "Chunk response received, parsing JSON", + ); parse_json_from_candidates::>(&raw, '[').map_err(|(err, candidate)| { AtomizerError::JsonParse { stage: "chunk", @@ -100,7 +128,12 @@ pub(super) async fn stage_atomize( let total = sections.len(); for (idx, section) in sections.iter().enumerate() { let section_label = format!("[{}/{}] '{}'", idx + 1, total, section.title); - emit_activity(app, project_id, AtomizeActivityKind::SectionProcess, &format!("Processing {section_label}")); + emit_activity( + app, + project_id, + AtomizeActivityKind::SectionProcess, + &format!("Processing {section_label}"), + ); let tmpl = env .get_template("stories") .map_err(|err| AtomizerError::Template(err.to_string()))?; @@ -112,7 +145,12 @@ pub(super) async fn stage_atomize( }) .map_err(|err| AtomizerError::Template(err.to_string()))?; - emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for {section_label}")); + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentStart, + &format!("Invoking {agent} for {section_label}"), + ); let hb = Some((project_id.to_string(), 3, "atomize".to_string())); let raw = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb) .await?; @@ -120,7 +158,12 @@ pub(super) async fn stage_atomize( let stories: Vec = match parse_json_from_candidates(&raw, '[') { Ok(stories) => stories, Err((_first_err, first_candidate)) => { - emit_activity(app, project_id, AtomizeActivityKind::Retry, &format!("JSON parse failed for {section_label}")); + emit_activity( + app, + project_id, + AtomizeActivityKind::Retry, + &format!("JSON parse failed for {section_label}"), + ); emit_progress( app, project_id, @@ -151,7 +194,12 @@ pub(super) async fn stage_atomize( }; let extracted = stories.len(); - emit_activity(app, project_id, AtomizeActivityKind::StoryExtracted, &format!("{extracted} stories from {section_label}")); + emit_activity( + app, + project_id, + AtomizeActivityKind::StoryExtracted, + &format!("{extracted} stories from {section_label}"), + ); all_stories.extend(stories); } @@ -187,10 +235,21 @@ pub(super) async fn stage_merge( }) .map_err(|err| AtomizerError::Template(err.to_string()))?; - emit_activity(app, project_id, AtomizeActivityKind::AgentStart, &format!("Invoking {agent} for merge")); + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentStart, + &format!("Invoking {agent} for merge"), + ); let hb = Some((project_id.to_string(), 4, "merge".to_string())); - let raw = invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; - emit_activity(app, project_id, AtomizeActivityKind::AgentComplete, "Merge received, parsing PRD"); + let raw = + invoke_agent_with_heartbeat(app, agent, model, effort, &prompt, project_dir, hb).await?; + emit_activity( + app, + project_id, + AtomizeActivityKind::AgentComplete, + "Merge received, parsing PRD", + ); parse_json_from_candidates::(&raw, '{').map_err(|(err, candidate)| { AtomizerError::JsonParse { stage: "merge", diff --git a/src-tauri/src/atomizer/tests_validation.rs b/src-tauri/src/atomizer/tests_validation.rs index 33b8e1b..c9c3a46 100644 --- a/src-tauri/src/atomizer/tests_validation.rs +++ b/src-tauri/src/atomizer/tests_validation.rs @@ -35,13 +35,25 @@ fn chunk_large_plan_preserves_all_content() { #[test] fn build_agent_args_adds_codex_skip_repo_flag() { - let args = build_agent_args("codex", "Generate output", Path::new("/tmp/demo"), None, None); + let args = build_agent_args( + "codex", + "Generate output", + Path::new("/tmp/demo"), + None, + None, + ); assert!(args.iter().any(|value| value == "--skip-git-repo-check")); } #[test] fn build_agent_args_adds_codex_bypass_flag() { - let args = build_agent_args("codex", "Generate output", Path::new("/tmp/demo"), None, None); + let args = build_agent_args( + "codex", + "Generate output", + Path::new("/tmp/demo"), + None, + None, + ); assert!(args .iter() .any(|value| value == "--dangerously-bypass-approvals-and-sandbox")); @@ -49,7 +61,13 @@ fn build_agent_args_adds_codex_bypass_flag() { #[test] fn build_agent_args_includes_codex_working_directory() { - let args = build_agent_args("codex", "Generate output", Path::new("/tmp/demo"), None, None); + let args = build_agent_args( + "codex", + "Generate output", + Path::new("/tmp/demo"), + None, + None, + ); let has_directory_flag = args.windows(2).any(|pair| { pair.first().map(|value| value.as_str()) == Some("-C") && pair.get(1).map(|value| value.as_str()) == Some("/tmp/demo") @@ -72,7 +90,13 @@ fn build_null_stdin_shell_command_appends_redirection() { #[test] fn build_agent_args_omits_removed_opencode_auto_share_flag() { - let args = build_agent_args("opencode", "Generate output", Path::new("/tmp/demo"), None, None); + let args = build_agent_args( + "opencode", + "Generate output", + Path::new("/tmp/demo"), + None, + None, + ); assert!(!args.iter().any(|value| value == "--no-auto-share")); } @@ -85,8 +109,13 @@ fn build_agent_args_sets_codex_model_and_effort_when_provided() { Some("gpt-5.4"), Some("high"), ); - let has_model = args.windows(2).any(|pair| pair.first().map(|v| v.as_str()) == Some("--model") && pair.get(1).map(|v| v.as_str()) == Some("gpt-5.4")); - let has_effort = args.windows(2).any(|pair| pair.first().map(|v| v.as_str()) == Some("--reasoning-effort")); + let has_model = args.windows(2).any(|pair| { + pair.first().map(|v| v.as_str()) == Some("--model") + && pair.get(1).map(|v| v.as_str()) == Some("gpt-5.4") + }); + let has_effort = args + .windows(2) + .any(|pair| pair.first().map(|v| v.as_str()) == Some("--reasoning-effort")); assert!(has_model); assert!(!has_effort, "codex should not receive --reasoning-effort"); } diff --git a/src-tauri/src/atomizer/types.rs b/src-tauri/src/atomizer/types.rs index dbebb2d..6e5bc8e 100644 --- a/src-tauri/src/atomizer/types.rs +++ b/src-tauri/src/atomizer/types.rs @@ -101,6 +101,12 @@ pub struct PipelineSnapshot { pub started_at: Option, pub elapsed_ms: u64, pub done: bool, + #[serde(default)] + pub percent: u8, + #[serde(default)] + pub is_done: bool, + #[serde(default)] + pub is_running: bool, } impl PipelineSnapshot { @@ -112,7 +118,7 @@ impl PipelineSnapshot { .enumerate() .map(|(idx, label)| StageSnapshot { number: (idx + 1) as u8, - label: label.to_string(), + label: (*label).to_string(), status: StageStatus::Pending, }) .collect(), @@ -120,8 +126,41 @@ impl PipelineSnapshot { started_at: None, elapsed_ms: 0, done: false, + percent: 0, + is_done: false, + is_running: false, } } + + pub fn recompute_derived(&mut self) { + let total = self.stages.len() as f64; + if total == 0.0 { + self.percent = 0; + self.is_done = false; + self.is_running = false; + return; + } + let progress: f64 = self + .stages + .iter() + .map(|stage| match stage.status { + StageStatus::Done => 1.0, + StageStatus::Running => 0.5, + _ => 0.0, + }) + .sum(); + self.percent = ((progress / total) * 100.0).round() as u8; + self.is_done = self + .stages + .iter() + .all(|stage| stage.status == StageStatus::Done); + self.is_running = !self.is_done + && self.error.is_none() + && self + .stages + .iter() + .any(|stage| stage.status == StageStatus::Running); + } } #[derive(Debug, Deserialize)] diff --git a/src-tauri/src/commands/ask.rs b/src-tauri/src/commands/ask.rs index 783039f..068559f 100644 --- a/src-tauri/src/commands/ask.rs +++ b/src-tauri/src/commands/ask.rs @@ -1,5 +1,5 @@ use crate::ask_engine::session::AskSessionsState; -use crate::ask_engine::types::{AskMessage, StartAskArgs}; +use crate::ask_engine::types::{AskMessage, AskQuestionResult, StartAskArgs}; use crate::ask_engine::AskEngineError; use crate::commands::validation::{optional_trimmed, required_trimmed}; use crate::db::DbState; @@ -13,8 +13,9 @@ pub async fn ask_question( sessions: State<'_, AskSessionsState>, db: State<'_, DbState>, args: StartAskArgs, -) -> Result { - let project_id = required_trimmed(args.project_id, "project_id").map_err(AskEngineError::Path)?; +) -> Result { + let project_id = + required_trimmed(args.project_id, "project_id").map_err(AskEngineError::Path)?; let question = required_trimmed(args.question, "question").map_err(AskEngineError::Path)?; let agent = required_trimmed(args.agent, "agent").map_err(AskEngineError::Path)?; let model = optional_trimmed(args.model); @@ -37,14 +38,21 @@ pub async fn ask_question( let message_id = Uuid::new_v4().to_string(); - { + let user_message = { let conn = db.0.lock().map_err(|_| AskEngineError::LockPoisoned)?; let conversation = crate::ask_engine::storage::get_or_create_conversation(&conn, &project_id) .map_err(|err| AskEngineError::Db(err.to_string()))?; - crate::ask_engine::storage::insert_message(&conn, &conversation.id, "user", &question, None, None) - .map_err(|err| AskEngineError::Db(err.to_string()))?; - } + crate::ask_engine::storage::insert_message( + &conn, + &conversation.id, + "user", + &question, + None, + None, + ) + .map_err(|err| AskEngineError::Db(err.to_string()))? + }; let normalized = StartAskArgs { project_id, @@ -62,7 +70,10 @@ pub async fn ask_question( ) .await?; - Ok(message_id) + Ok(AskQuestionResult { + message_id, + user_message, + }) } #[tauri::command] @@ -84,7 +95,7 @@ pub async fn stop_ask( let project_id = required_trimmed(project_id, "project_id").map_err(AskEngineError::Path)?; sessions .remove_and_kill(&project_id) - .map_err(|err| AskEngineError::Shell(err))?; + .map_err(AskEngineError::Shell)?; Ok(()) } @@ -164,8 +175,15 @@ pub async fn retry_ask( let conversation = crate::ask_engine::storage::get_or_create_conversation(&conn, &project_id) .map_err(|err| AskEngineError::Db(err.to_string()))?; - crate::ask_engine::storage::insert_message(&conn, &conversation.id, "user", &question, None, None) - .map_err(|err| AskEngineError::Db(err.to_string()))?; + crate::ask_engine::storage::insert_message( + &conn, + &conversation.id, + "user", + &question, + None, + None, + ) + .map_err(|err| AskEngineError::Db(err.to_string()))?; } let args = StartAskArgs { diff --git a/src-tauri/src/commands/display_vocabulary.rs b/src-tauri/src/commands/display_vocabulary.rs new file mode 100644 index 0000000..2d15646 --- /dev/null +++ b/src-tauri/src/commands/display_vocabulary.rs @@ -0,0 +1,343 @@ +use serde::Serialize; +use std::collections::HashMap; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectStatusMeta { + pub badge_status: String, + pub card_label: String, + pub sidebar_label: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanKindMeta { + pub label: String, + pub variant: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DisplayVocabulary { + pub project_status_meta: HashMap, + pub status_labels: HashMap, + pub status_variants: HashMap, + pub notification_type_labels: HashMap, + pub notification_ring_variants: HashMap, + pub story_status_variants: HashMap, + pub activity_result_variants: HashMap, + pub monitor_status_badges: HashMap, + pub plan_kind_meta: HashMap, + pub atomize_activity_kind_meta: HashMap, + pub story_priority_variants: HashMap, + pub wizard_step_labels: HashMap, + pub stage_status_badges: HashMap, + pub stage_status_labels: HashMap, + pub step_indicator_variants: HashMap, + pub step_indicator_emphasis: HashMap, + pub agent_names: Vec, + pub inactive_statuses: Vec, + pub stall_threshold_secs: u32, + pub max_visible_activity_events: usize, + pub max_output_lines: usize, +} + +fn string_map(entries: &[(&str, &str)]) -> HashMap { + entries + .iter() + .map(|(key, value)| ((*key).to_string(), (*value).to_string())) + .collect() +} + +#[tauri::command] +pub async fn get_display_vocabulary() -> DisplayVocabulary { + let project_status_meta = HashMap::from([ + ( + "active".to_string(), + ProjectStatusMeta { + badge_status: "running".to_string(), + card_label: "Running".to_string(), + sidebar_label: "Running".to_string(), + }, + ), + ( + "paused".to_string(), + ProjectStatusMeta { + badge_status: "paused".to_string(), + card_label: "Paused".to_string(), + sidebar_label: "Paused".to_string(), + }, + ), + ( + "blocked".to_string(), + ProjectStatusMeta { + badge_status: "blocked".to_string(), + card_label: "Blocked".to_string(), + sidebar_label: "Blocked".to_string(), + }, + ), + ( + "completed".to_string(), + ProjectStatusMeta { + badge_status: "completed".to_string(), + card_label: "Completed".to_string(), + sidebar_label: "Completed".to_string(), + }, + ), + ( + "failed".to_string(), + ProjectStatusMeta { + badge_status: "failed".to_string(), + card_label: "Failed".to_string(), + sidebar_label: "Failed".to_string(), + }, + ), + ( + "draft".to_string(), + ProjectStatusMeta { + badge_status: "draft".to_string(), + card_label: "Draft".to_string(), + sidebar_label: "Draft".to_string(), + }, + ), + ( + "archived".to_string(), + ProjectStatusMeta { + badge_status: "archived".to_string(), + card_label: "Archived".to_string(), + sidebar_label: "Archived".to_string(), + }, + ), + ]); + let plan_kind_meta = HashMap::from([ + ( + "search".to_string(), + PlanKindMeta { + label: "SRCH".to_string(), + variant: "info".to_string(), + }, + ), + ( + "docsLookup".to_string(), + PlanKindMeta { + label: "DOCS".to_string(), + variant: "warning".to_string(), + }, + ), + ( + "mcpCall".to_string(), + PlanKindMeta { + label: "TOOL".to_string(), + variant: "neutral".to_string(), + }, + ), + ( + "thinking".to_string(), + PlanKindMeta { + label: "WAIT".to_string(), + variant: "neutral".to_string(), + }, + ), + ( + "error".to_string(), + PlanKindMeta { + label: "ERR".to_string(), + variant: "danger".to_string(), + }, + ), + ( + "planContent".to_string(), + PlanKindMeta { + label: "PLAN".to_string(), + variant: "success".to_string(), + }, + ), + ]); + let atomize_activity_kind_meta = HashMap::from([ + ( + "planLoaded".to_string(), + PlanKindMeta { + label: "LOAD".to_string(), + variant: "info".to_string(), + }, + ), + ( + "templateRender".to_string(), + PlanKindMeta { + label: "TMPL".to_string(), + variant: "neutral".to_string(), + }, + ), + ( + "agentStart".to_string(), + PlanKindMeta { + label: "CALL".to_string(), + variant: "warning".to_string(), + }, + ), + ( + "agentComplete".to_string(), + PlanKindMeta { + label: "RECV".to_string(), + variant: "success".to_string(), + }, + ), + ( + "chunkDetected".to_string(), + PlanKindMeta { + label: "SECT".to_string(), + variant: "info".to_string(), + }, + ), + ( + "sectionProcess".to_string(), + PlanKindMeta { + label: "PROC".to_string(), + variant: "warning".to_string(), + }, + ), + ( + "storyExtracted".to_string(), + PlanKindMeta { + label: "ATOM".to_string(), + variant: "success".to_string(), + }, + ), + ( + "retry".to_string(), + PlanKindMeta { + label: "RTRY".to_string(), + variant: "danger".to_string(), + }, + ), + ( + "validation".to_string(), + PlanKindMeta { + label: "VALD".to_string(), + variant: "info".to_string(), + }, + ), + ( + "artifactSaved".to_string(), + PlanKindMeta { + label: "SAVE".to_string(), + variant: "success".to_string(), + }, + ), + ]); + DisplayVocabulary { + project_status_meta, + status_labels: string_map(&[ + ("draft", "Draft"), + ("pending", "Pending"), + ("current", "Current"), + ("running", "Running"), + ("paused", "Paused"), + ("blocked", "Blocked"), + ("completed", "Completed"), + ("success", "Success"), + ("error", "Error"), + ("failed", "Failed"), + ("archived", "Archived"), + ]), + status_variants: string_map(&[ + ("draft", "neutral"), + ("pending", "neutral"), + ("current", "info"), + ("running", "info"), + ("paused", "warning"), + ("blocked", "danger"), + ("completed", "success"), + ("success", "success"), + ("error", "danger"), + ("failed", "danger"), + ("archived", "neutral"), + ]), + notification_type_labels: string_map(&[ + ("story_blocked", "Blocked"), + ("loop_completed", "Completed"), + ("rate_limited", "Rate limit"), + ("review_comment", "Review"), + ("loop_error", "Error"), + ("story_completed", "Story done"), + ]), + notification_ring_variants: string_map(&[ + ("red", "danger"), + ("amber", "warning"), + ("cyan", "info"), + ("green", "success"), + ]), + story_status_variants: string_map(&[ + ("completed", "success"), + ("current", "info"), + ("blocked", "danger"), + ("pending", "neutral"), + ]), + activity_result_variants: string_map(&[ + ("success", "success"), + ("pending", "info"), + ("failed", "danger"), + ("blocked", "danger"), + ("skipped", "warning"), + ]), + monitor_status_badges: string_map(&[ + ("ready", "pending"), + ("running", "running"), + ("paused", "paused"), + ("blocked", "blocked"), + ("failed", "failed"), + ("completed", "completed"), + ("archived", "archived"), + ("draft", "draft"), + ]), + plan_kind_meta, + atomize_activity_kind_meta, + story_priority_variants: string_map(&[ + ("critical", "danger"), + ("high", "warning"), + ("medium", "info"), + ("low", "neutral"), + ]), + wizard_step_labels: string_map(&[ + ("describe", "Describe"), + ("plan", "Planning"), + ("atomize", "Atomize"), + ("configure", "Configure"), + ("launch", "Launch"), + ]), + stage_status_badges: string_map(&[ + ("pending", "neutral"), + ("running", "info"), + ("done", "success"), + ("error", "danger"), + ]), + stage_status_labels: string_map(&[ + ("pending", "Pending"), + ("running", "Running"), + ("done", "Done"), + ("error", "Error"), + ]), + step_indicator_variants: string_map(&[ + ("upcoming", "neutral"), + ("current", "info"), + ("complete", "success"), + ("stale", "warning"), + ("error", "danger"), + ]), + step_indicator_emphasis: string_map(&[ + ("upcoming", "subtle"), + ("current", "solid"), + ("complete", "subtle"), + ("stale", "subtle"), + ("error", "subtle"), + ]), + agent_names: vec!["cursor", "codex", "claude", "gemini", "opencode"] + .into_iter() + .map(str::to_string) + .collect(), + inactive_statuses: vec!["archived".to_string(), "failed".to_string()], + stall_threshold_secs: 120, + max_visible_activity_events: 200, + max_output_lines: 5000, + } +} diff --git a/src-tauri/src/commands/execution.rs b/src-tauri/src/commands/execution.rs index 3047979..083c038 100644 --- a/src-tauri/src/commands/execution.rs +++ b/src-tauri/src/commands/execution.rs @@ -1,13 +1,32 @@ use crate::commands::validation::required_trimmed; #[cfg(not(test))] use crate::commands::validation::{non_empty_trimmed_list, optional_trimmed}; -use crate::loop_manager::{LoopError, LoopManagerState, SessionStats}; #[cfg(not(test))] use crate::loop_manager::StartLoopArgs; +use crate::loop_manager::{LoopError, LoopManagerState, SessionStats}; use crate::storage::db::DbState; use serde::Serialize; use tauri::{AppHandle, State}; +fn format_duration_label(duration_secs: i64) -> String { + if duration_secs <= 0 { + return "0s".to_string(); + } + if duration_secs < 60 { + return format!("{duration_secs}s"); + } + let minutes = duration_secs / 60; + let seconds = duration_secs % 60; + format!("{minutes}m {seconds}s") +} + +fn format_time_label(started_at: &str) -> String { + chrono::DateTime::parse_from_rfc3339(started_at).map_or_else( + |_| started_at.to_string(), + |value| value.format("%H:%M:%S").to_string(), + ) +} + #[cfg(not(test))] #[tauri::command] pub async fn start_loop(app: AppHandle, args: StartLoopArgs) -> Result { @@ -36,6 +55,9 @@ pub async fn start_loop(app: AppHandle, args: StartLoopArgs) -> Result, + project_id: String, +) -> Result, LoopError> { + get_iteration_history(db, project_id).await +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 969e76a..3a1d222 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,8 @@ pub mod ask; pub mod atomization; +pub mod display_vocabulary; pub mod execution; +pub mod notifications; pub mod planning; pub mod projects; pub mod projects_artifacts; diff --git a/src-tauri/src/commands/notifications.rs b/src-tauri/src/commands/notifications.rs new file mode 100644 index 0000000..5f41684 --- /dev/null +++ b/src-tauri/src/commands/notifications.rs @@ -0,0 +1,145 @@ +use crate::projects::notification_filter::{ + build_project_summaries, ring_color_for_project, AppNotification, ProjectNotificationSummary, +}; +use crate::projects::notifications::{create_notification_and_emit, NotificationCreateInput}; +use crate::projects::ProjectError; +use crate::storage::db::DbState; +use serde::{Deserialize, Serialize}; +use tauri::{AppHandle, State}; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct NotificationListResponse { + pub notifications: Vec, + pub unread_count: usize, + pub ring_color: Option, + pub project_summaries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AddNotificationArgs { + pub project_id: String, + pub notification_type: String, + pub title: String, + pub message: String, +} + +#[tauri::command] +pub async fn add_notification( + app: AppHandle, + args: AddNotificationArgs, +) -> Result { + create_notification_and_emit( + &app, + NotificationCreateInput { + project_id: args.project_id, + notification_type: args.notification_type, + title: args.title, + message: args.message, + }, + ) +} + +#[tauri::command] +pub async fn get_notifications( + db: State<'_, DbState>, + project_id: Option, +) -> Result { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + let notifications = query_notifications(&conn, project_id.as_deref())?; + let unread_count = notifications.iter().filter(|notif| !notif.read).count(); + let ring = ring_color_for_project(¬ifications).map(String::from); + let project_summaries = build_project_summaries(¬ifications); + Ok(NotificationListResponse { + notifications, + unread_count, + ring_color: ring, + project_summaries, + }) +} + +#[tauri::command] +pub async fn mark_notification_read( + db: State<'_, DbState>, + notification_id: String, +) -> Result<(), ProjectError> { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + conn.execute( + "UPDATE notifications SET read = 1 WHERE id = ?1", + rusqlite::params![notification_id], + )?; + Ok(()) +} + +#[tauri::command] +pub async fn mark_all_notifications_read( + db: State<'_, DbState>, + project_id: Option, +) -> Result<(), ProjectError> { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + if let Some(ref pid) = project_id { + conn.execute( + "UPDATE notifications SET read = 1 WHERE project_id = ?1", + rusqlite::params![pid], + )?; + } else { + conn.execute("UPDATE notifications SET read = 1", [])?; + } + Ok(()) +} + +#[tauri::command] +pub async fn clear_notifications( + db: State<'_, DbState>, + project_id: Option, +) -> Result<(), ProjectError> { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + if let Some(ref pid) = project_id { + conn.execute( + "DELETE FROM notifications WHERE project_id = ?1", + rusqlite::params![pid], + )?; + } else { + conn.execute("DELETE FROM notifications", [])?; + } + Ok(()) +} + +fn query_notifications( + conn: &rusqlite::Connection, + project_id: Option<&str>, +) -> Result, ProjectError> { + let sql = if project_id.is_some() { + "SELECT id, project_id, notification_type, title, message, ring_color, read, timestamp FROM notifications WHERE project_id = ?1 ORDER BY timestamp DESC LIMIT 200" + } else { + "SELECT id, project_id, notification_type, title, message, ring_color, read, timestamp FROM notifications ORDER BY timestamp DESC LIMIT 200" + }; + let mut stmt = conn.prepare(sql)?; + let rows = match project_id { + Some(pid) => stmt.query_map(rusqlite::params![pid], row_to_notification)?, + None => stmt.query_map([], row_to_notification)?, + }; + Ok(rows.filter_map(Result::ok).collect()) +} + +fn row_to_notification(row: &rusqlite::Row) -> rusqlite::Result { + Ok(AppNotification { + id: row.get(0)?, + project_id: row.get(1)?, + notification_type: row.get(2)?, + title: row.get(3)?, + message: row.get(4)?, + ring_color: row.get(5)?, + read: row.get::<_, i32>(6)? != 0, + timestamp: row.get(7)?, + }) +} diff --git a/src-tauri/src/commands/planning.rs b/src-tauri/src/commands/planning.rs index ddf4816..5625b3e 100644 --- a/src-tauri/src/commands/planning.rs +++ b/src-tauri/src/commands/planning.rs @@ -1,9 +1,11 @@ -use crate::commands::validation::required_trimmed; #[cfg(not(test))] use crate::commands::validation::optional_trimmed; +use crate::commands::validation::required_trimmed; use crate::plan_engine::{PlanEngineError, PlanSessionsState}; #[cfg(not(test))] use crate::plan_engine::{PlanSessionInfo, StartPlanArgs}; +use serde::Deserialize; +use serde::Serialize; use tauri::AppHandle; use tauri::State; @@ -59,6 +61,129 @@ pub async fn query_plan_status( crate::plan_engine::query_plan_status(state, normalized_project_id).await } +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase", tag = "state")] +pub enum PlanStepState { + Running, + HasExistingPlan { content: String }, + NeedsFreshPlan, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ResolvePlanActionResult { + pub action: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plan_content: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PlanUserActionKind { + Feedback, + Replan, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct PlanUserActionResult { + pub mode: String, +} + +#[cfg(not(test))] +#[tauri::command] +pub async fn resolve_plan_state( + app: AppHandle, + state: State<'_, PlanSessionsState>, + project_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(PlanEngineError::Path)?; + + let status = crate::plan_engine::query_plan_status(state, project_id.clone()).await?; + if let Some(info) = status { + if info.status == crate::plan_engine::PlanSessionStatus::Running { + return Ok(PlanStepState::Running); + } + } + + let dir = crate::projects::artifacts::artifact_dir(&app, &project_id) + .map_err(|err| PlanEngineError::Path(err.to_string()))?; + let plan_path = dir.join("plan.md"); + if plan_path.exists() { + if let Ok(content) = std::fs::read_to_string(&plan_path) { + if !content.trim().is_empty() { + return Ok(PlanStepState::HasExistingPlan { content }); + } + } + } + + Ok(PlanStepState::NeedsFreshPlan) +} + +#[cfg(not(test))] +#[tauri::command] +pub async fn resolve_plan_action( + app: AppHandle, + state: State<'_, PlanSessionsState>, + project_id: String, +) -> Result { + let resolved = resolve_plan_state(app, state, project_id).await?; + match resolved { + PlanStepState::Running => Ok(ResolvePlanActionResult { + action: "resume".to_string(), + plan_content: None, + }), + PlanStepState::HasExistingPlan { content } => Ok(ResolvePlanActionResult { + action: "prompt_existing".to_string(), + plan_content: Some(content), + }), + PlanStepState::NeedsFreshPlan => Ok(ResolvePlanActionResult { + action: "start".to_string(), + plan_content: None, + }), + } +} + +#[cfg(not(test))] +#[tauri::command] +pub async fn plan_user_action( + app: AppHandle, + state: State<'_, PlanSessionsState>, + db: State<'_, crate::storage::db::DbState>, + project_id: String, + input: String, + action: PlanUserActionKind, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(PlanEngineError::Path)?; + let normalized_input = required_trimmed(input, "input").map_err(PlanEngineError::Path)?; + let plan_status = + crate::plan_engine::query_plan_status(state.clone(), project_id.clone()).await?; + match action { + PlanUserActionKind::Feedback => { + let is_running = plan_status + .as_ref() + .is_some_and(|info| info.status == crate::plan_engine::PlanSessionStatus::Running); + if is_running { + crate::plan_engine::write_to_plan(state, project_id, normalized_input).await?; + return Ok(PlanUserActionResult { + mode: "sent".to_string(), + }); + } + replan(app, state, db, project_id, normalized_input).await?; + Ok(PlanUserActionResult { + mode: "replanned".to_string(), + }) + } + PlanUserActionKind::Replan => { + let _ = crate::plan_engine::stop_plan(state.clone(), project_id.clone()).await; + replan(app, state, db, project_id, normalized_input).await?; + Ok(PlanUserActionResult { + mode: "replanned".to_string(), + }) + } + } +} + #[cfg(not(test))] #[tauri::command] pub async fn replan( @@ -68,15 +193,11 @@ pub async fn replan( project_id: String, feedback: String, ) -> Result<(), PlanEngineError> { - let project_id = - required_trimmed(project_id, "project_id").map_err(PlanEngineError::Path)?; + let project_id = required_trimmed(project_id, "project_id").map_err(PlanEngineError::Path)?; let feedback = required_trimmed(feedback, "feedback").map_err(PlanEngineError::Path)?; let (description, working_directory) = { - let conn = db - .0 - .lock() - .map_err(|_| PlanEngineError::LockPoisoned)?; + let conn = db.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; conn.query_row( "SELECT description, working_directory FROM projects WHERE id = ?1", rusqlite::params![project_id], @@ -106,20 +227,26 @@ pub async fn replan( .as_str() .unwrap_or("claude") .to_string(), - describe["planModel"] - .as_str() - .map(String::from), - describe["planEffort"] - .as_str() - .map(String::from), + describe["planModel"].as_str().map(String::from), + describe["planEffort"].as_str().map(String::from), ) } else { ("claude".to_string(), None, None) }; - let composed_prompt = format!( - "{description}\n\nPrevious plan:\n{existing_plan}\n\nUser feedback:\n{feedback}" - ); + let has_plan_structure = existing_plan + .lines() + .any(|line| line.trim_start().starts_with('#')); + + let composed_prompt = if existing_plan.is_empty() || !has_plan_structure { + format!("{description}\n\nUser clarification: {feedback}") + } else { + format!( + "{description}\n\n\ + Previous plan:\n{existing_plan}\n\n\ + Feedback: {feedback}" + ) + }; { let mut sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; diff --git a/src-tauri/src/commands/projects.rs b/src-tauri/src/commands/projects.rs index e750d25..1133cc6 100644 --- a/src-tauri/src/commands/projects.rs +++ b/src-tauri/src/commands/projects.rs @@ -28,6 +28,29 @@ fn to_snapshot_config(config: ProjectConfig) -> SnapshotConfig { } } +fn build_duration_label(started_at: Option<&str>, ended_at: Option<&str>) -> String { + let Some(start_value) = started_at else { + return String::new(); + }; + let Ok(start_time) = chrono::DateTime::parse_from_rfc3339(start_value) else { + return String::new(); + }; + let end_time = ended_at + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .unwrap_or_else(|| chrono::Utc::now().fixed_offset()); + let elapsed_secs = (end_time - start_time).num_seconds().max(0); + if elapsed_secs < 60 { + return format!("{elapsed_secs}s"); + } + let elapsed_minutes = elapsed_secs / 60; + if elapsed_minutes < 60 { + return format!("{elapsed_minutes}m"); + } + let elapsed_hours = elapsed_minutes / 60; + let remaining_minutes = elapsed_minutes % 60; + format!("{elapsed_hours}h {remaining_minutes}m") +} + #[tauri::command] pub async fn get_project_snapshot( app: AppHandle, @@ -35,7 +58,8 @@ pub async fn get_project_snapshot( loop_state: State<'_, LoopManagerState>, project_id: String, ) -> Result { - let normalized_project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + let normalized_project_id = + required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; let has_loop_handle = loop_state .0 .lock() @@ -52,8 +76,8 @@ pub async fn get_project_snapshot( app.clone(), normalized_project_id.clone(), ) - .await - .unwrap_or(None); + .await + .unwrap_or(None); let db_state = app.state::(); let conn = db_state @@ -105,10 +129,7 @@ pub async fn get_project_snapshot( } else if matches!(status, ProjectStatus::Running) { status = ProjectStatus::Paused; } - if matches!(status, ProjectStatus::Draft) - && detail.total_stories > 0 - && config.is_some() - { + if matches!(status, ProjectStatus::Draft) && detail.total_stories > 0 && config.is_some() { status = ProjectStatus::Ready; } @@ -124,6 +145,19 @@ pub async fn get_project_snapshot( prompt: paths[4].to_string_lossy().to_string(), guardrails: paths[5].to_string_lossy().to_string(), }; + let progress_percent = if detail.total_stories == 0 { + 0 + } else { + ((detail.passed_count as f64 / detail.total_stories as f64) * 100.0).round() as u32 + }; + let uptime_label = build_duration_label( + active_session + .as_ref() + .and_then(|session| session.started_at.as_deref()), + active_session + .as_ref() + .and_then(|session| session.ended_at.as_deref()), + ); Ok(ProjectSnapshot { project: detail.project, @@ -136,5 +170,7 @@ pub async fn get_project_snapshot( }, config: config.map(to_snapshot_config), artifact_paths, + progress_percent, + uptime_label, }) } diff --git a/src-tauri/src/commands/projects_lifecycle.rs b/src-tauri/src/commands/projects_lifecycle.rs index 054bc90..74c659e 100644 --- a/src-tauri/src/commands/projects_lifecycle.rs +++ b/src-tauri/src/commands/projects_lifecycle.rs @@ -1,9 +1,8 @@ -use crate::commands::validation::required_trimmed; #[cfg(not(test))] use crate::commands::validation::optional_trimmed; +use crate::commands::validation::required_trimmed; use crate::projects::{ - IterationStory, NotificationPrefs, ProjectConfig, ProjectError, - ProjectsByStatus, + IterationStory, NotificationPrefs, ProjectConfig, ProjectError, ProjectsByStatus, }; #[cfg(not(test))] use crate::projects::{Project, ProjectDetail}; diff --git a/src-tauri/src/commands/projects_listing.rs b/src-tauri/src/commands/projects_listing.rs index ee9bc08..f6da426 100644 --- a/src-tauri/src/commands/projects_listing.rs +++ b/src-tauri/src/commands/projects_listing.rs @@ -5,7 +5,7 @@ use crate::projects::ProjectError; use crate::storage::db::DbState; use ralph_core::prd::Prd; use serde::{Deserialize, Serialize}; -use tauri::{AppHandle, Manager, State}; +use tauri::{AppHandle, State}; #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -29,6 +29,8 @@ pub struct EnrichedProject { pub session_started_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub session_ended_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_label: Option, } fn frontend_status(status: &ProjectStatus) -> &'static str { @@ -43,6 +45,25 @@ fn frontend_status(status: &ProjectStatus) -> &'static str { } } +fn build_duration_label(started_at: Option<&str>, ended_at: Option<&str>) -> Option { + let started = started_at?; + let start_time = chrono::DateTime::parse_from_rfc3339(started).ok()?; + let end_time = ended_at + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + .unwrap_or_else(|| chrono::Utc::now().fixed_offset()); + let elapsed_secs = (end_time - start_time).num_seconds().max(0); + if elapsed_secs < 60 { + return Some(format!("{elapsed_secs}s")); + } + let elapsed_minutes = elapsed_secs / 60; + if elapsed_minutes < 60 { + return Some(format!("{elapsed_minutes}m")); + } + let elapsed_hours = elapsed_minutes / 60; + let remaining_minutes = elapsed_minutes % 60; + Some(format!("{elapsed_hours}h {remaining_minutes}m")) +} + #[tauri::command] pub async fn list_projects_enriched( app: AppHandle, @@ -56,10 +77,9 @@ pub async fn list_projects_enriched( .unwrap_or_default(); let mut projects = { - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = "\ SELECT p.id, p.name, p.description, p.status, p.working_directory, \ @@ -82,12 +102,13 @@ pub async fn list_projects_enriched( wizard_step: row.get(7).unwrap_or(None), session_started_at: row.get(8).unwrap_or(None), session_ended_at: row.get(9).unwrap_or(None), + duration_label: None, stories_completed: None, total_stories: None, current_agent: None, }) })? - .filter_map(|result| result.ok()) + .filter_map(Result::ok) .collect(); rows }; @@ -136,7 +157,50 @@ pub async fn list_projects_enriched( } project.status = frontend_status(&status).to_string(); + project.duration_label = build_duration_label( + project.session_started_at.as_deref(), + project.session_ended_at.as_deref(), + ); } Ok(projects) } + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GroupedProjects { + pub active: Vec, + pub drafts: Vec, + pub finished: Vec, + pub archived: Vec, +} + +#[tauri::command] +pub async fn list_projects_grouped( + app: AppHandle, + db: State<'_, DbState>, + loop_state: State<'_, LoopManagerState>, +) -> Result { + let all = list_projects_enriched(app, db, loop_state).await?; + let mut active = Vec::new(); + let mut drafts = Vec::new(); + let mut finished = Vec::new(); + let mut archived = Vec::new(); + + for project in all { + match project.status.as_str() { + "draft" => drafts.push(project), + "active" | "paused" | "blocked" => active.push(project), + "completed" | "failed" => finished.push(project), + "archived" => archived.push(project), + _ => active.push(project), + } + } + + Ok(GroupedProjects { + active, + drafts, + finished, + archived, + }) +} diff --git a/src-tauri/src/commands/projects_wizard.rs b/src-tauri/src/commands/projects_wizard.rs index 4db69c4..1744a2a 100644 --- a/src-tauri/src/commands/projects_wizard.rs +++ b/src-tauri/src/commands/projects_wizard.rs @@ -1,5 +1,5 @@ use crate::commands::validation::required_trimmed; -use crate::projects::{ProjectError, WizardResumeState}; +use crate::projects::{ProjectError, WizardHydrationResult, WizardResumeState}; use crate::storage::db::DbState; use tauri::{AppHandle, State}; @@ -79,3 +79,14 @@ pub async fn resume_wizard( required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; crate::projects::wizard::resume_wizard(app, db, normalized_project_id).await } + +#[tauri::command] +pub async fn hydrate_wizard( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, +) -> Result { + let normalized_project_id = + required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + crate::projects::wizard::hydrate_wizard(app, db, normalized_project_id).await +} diff --git a/src-tauri/src/commands/wizard_logic.rs b/src-tauri/src/commands/wizard_logic.rs index 58438c0..4097991 100644 --- a/src-tauri/src/commands/wizard_logic.rs +++ b/src-tauri/src/commands/wizard_logic.rs @@ -1,5 +1,7 @@ use crate::agents::AgentRegistryState; -use crate::commands::validation::required_trimmed; +use crate::commands::validation::{non_empty_trimmed_list, optional_trimmed, required_trimmed}; +use crate::loop_manager::StartLoopArgs; +use crate::plan_engine::PlanSessionsState; use crate::projects::stories_crud::StoriesResponse; use crate::projects::{ AdvanceWizardResult, ConfigDefaultsResponse, DescribeInput, LaunchReadiness, ProjectConfig, @@ -7,13 +9,11 @@ use crate::projects::{ }; use crate::storage::db::DbState; use ralph_core::prd::Prd; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; #[tauri::command] -pub async fn advance_wizard_step( - target_step: u32, -) -> Result { +pub async fn advance_wizard_step(target_step: u32) -> Result { crate::projects::wizard_state::advance_wizard_step(None, target_step) .map_err(ProjectError::Path) } @@ -33,10 +33,18 @@ pub async fn validate_project_config( #[tauri::command] pub async fn validate_describe_input( - input_json: String, + name: String, + description: String, + working_directory: String, + plan_agent: String, state: State<'_, AgentRegistryState>, ) -> Result { - let input: DescribeInput = serde_json::from_str(&input_json)?; + let input = DescribeInput { + name, + description, + working_directory, + plan_agent, + }; let available_agents = { let registry = state .0 @@ -64,10 +72,9 @@ pub async fn validate_launch_readiness( let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; let (project_name, working_directory) = { - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.query_row( "SELECT name, working_directory FROM projects WHERE id = ?1", rusqlite::params![project_id], @@ -78,12 +85,19 @@ pub async fn validate_launch_readiness( let dir = crate::projects::artifacts::artifact_dir(&app, &project_id)?; let prd_path = dir.join("prd.json"); - let stories_count = if prd_path.exists() { + let (stories_count, estimated_minutes) = if prd_path.exists() { Prd::load(&prd_path) - .map(|prd| prd.stories.len()) - .unwrap_or(0) + .map(|prd| { + let minutes: Vec = prd + .stories + .iter() + .map(|story| story.estimated_minutes) + .collect(); + (prd.stories.len(), minutes) + }) + .unwrap_or_default() } else { - 0 + (0, Vec::new()) }; let config_path = dir.join("config.json"); @@ -101,6 +115,7 @@ pub async fn validate_launch_readiness( &working_directory, stories_count, &execute_agent, + &estimated_minutes, )) } @@ -167,10 +182,9 @@ pub async fn save_wizard_draft( let step_name = required_trimmed(step_name, "step_name").map_err(ProjectError::Path)?; let (name, description, working_directory) = { - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.query_row( "SELECT name, description, working_directory FROM projects WHERE id = ?1", rusqlite::params![project_id], @@ -186,6 +200,40 @@ pub async fn save_wizard_draft( }; let dir = crate::projects::artifacts::artifact_dir(&app, &project_id)?; + let draft_path = dir.join("draft.json"); + let (plan_agent, plan_model, plan_effort, previous_highest_step) = if draft_path.exists() { + std::fs::read_to_string(&draft_path) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .map_or(("claude".to_string(), None, None, 1), |draft| { + let describe = draft + .get("describe") + .cloned() + .unwrap_or_else(|| serde_json::json!({})); + let step = draft + .get("highestStep") + .and_then(serde_json::Value::as_u64) + .map_or(1, |value| value as u32); + ( + describe + .get("planAgent") + .and_then(serde_json::Value::as_str) + .unwrap_or("claude") + .to_string(), + describe + .get("planModel") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + describe + .get("planEffort") + .and_then(serde_json::Value::as_str) + .map(str::to_string), + step, + ) + }) + } else { + ("claude".to_string(), None, None, 1) + }; let plan_path = dir.join("plan.md"); let plan_completed = plan_path.exists() && std::fs::read_to_string(&plan_path) @@ -209,18 +257,22 @@ pub async fn save_wizard_draft( } else { None }; + let current_step_number = + crate::projects::wizard_state::step_name_to_number(&step_name).unwrap_or(1); + let highest_step = previous_highest_step.max(current_step_number); let draft = serde_json::json!({ "version": 1, "projectId": project_id, "currentStep": step_name, + "highestStep": highest_step, "describe": { "name": name, "description": description, "workingDirectory": working_directory, - "planAgent": "claude", - "planModel": null, - "planEffort": null + "planAgent": plan_agent, + "planModel": plan_model, + "planEffort": plan_effort }, "plan": { "completed": plan_completed }, "atomize": { "storiesCount": stories_count }, @@ -232,10 +284,9 @@ pub async fn save_wizard_draft( std::fs::write(dir.join("draft.json"), &draft_json)?; let now = chrono::Utc::now().to_rfc3339(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let _ = conn.execute( "UPDATE projects SET wizard_step = ?1, wizard_state_json = NULL, updated_at = ?2 WHERE id = ?3", rusqlite::params![step_name, now, project_id], @@ -244,6 +295,456 @@ pub async fn save_wizard_draft( Ok(()) } +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteDescribeInput { + #[serde(default)] + pub project_id: Option, + pub name: String, + pub description: String, + pub working_directory: String, + #[serde(default)] + pub connection_id: Option, + pub plan_agent: String, + #[serde(default)] + pub plan_model: Option, + #[serde(default)] + pub plan_effort: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WizardRouteResult { + pub project_id: String, + pub next_route: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub working_directory: Option, +} + +fn resolve_connection_workspace( + app: &AppHandle, + db: &DbState, + connection_id: &str, +) -> Result { + let (repos, connection_name) = { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let mut repo_stmt = conn + .prepare( + "SELECT repo_path, display_name FROM connection_repos WHERE connection_id = ?1", + ) + .map_err(|err| ProjectError::Db(err.to_string()))?; + let repos = repo_stmt + .query_map(rusqlite::params![connection_id], |row| { + Ok(crate::connections::ConnectionRepo { + repo_path: row.get::<_, String>(0)?, + display_name: row.get::<_, Option>(1)?, + }) + }) + .map_err(|err| ProjectError::Db(err.to_string()))? + .filter_map(Result::ok) + .collect::>(); + let name = conn + .query_row( + "SELECT name FROM connections WHERE id = ?1", + rusqlite::params![connection_id], + |row| row.get::<_, String>(0), + ) + .map_err(|_| ProjectError::NotFound(connection_id.to_string()))?; + (repos, name) + }; + let workspace_dir = crate::connections::get_workspace_dir(app, connection_id); + crate::connections::build_workspace(&workspace_dir, &repos) + .map_err(|err| ProjectError::Db(err.to_string()))?; + crate::connections::generate_workspace_manifest(&workspace_dir, &connection_name, &repos) + .map_err(|err| ProjectError::Db(err.to_string()))?; + Ok(workspace_dir.to_string_lossy().to_string()) +} + +#[tauri::command] +pub async fn complete_describe_step( + app: AppHandle, + db: State<'_, DbState>, + input: CompleteDescribeInput, +) -> Result { + let name = required_trimmed(input.name, "name").map_err(ProjectError::Path)?; + let description = + required_trimmed(input.description, "description").map_err(ProjectError::Path)?; + let plan_agent = + required_trimmed(input.plan_agent, "plan_agent").map_err(ProjectError::Path)?; + let plan_model = optional_trimmed(input.plan_model); + let plan_effort = optional_trimmed(input.plan_effort); + let working_directory = if let Some(connection_id) = optional_trimmed(input.connection_id) { + resolve_connection_workspace(&app, db.inner(), &connection_id)? + } else { + required_trimmed(input.working_directory, "working_directory") + .map_err(ProjectError::Path)? + }; + let now = chrono::Utc::now().to_rfc3339(); + let project_id = if let Some(existing_project_id) = optional_trimmed(input.project_id) { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let updated = conn.execute( + "UPDATE projects SET name = ?1, description = ?2, working_directory = ?3, wizard_step = 'plan', updated_at = ?4 WHERE id = ?5", + rusqlite::params![name, description, working_directory, now, existing_project_id], + )?; + if updated == 0 { + return Err(ProjectError::NotFound(existing_project_id)); + } + existing_project_id + } else { + let created_project_id = uuid::Uuid::new_v4().to_string(); + let artifacts = crate::projects::artifacts::artifact_dir(&app, &created_project_id)?; + crate::projects::artifacts::init_artifacts(&artifacts, &name)?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.execute( + "INSERT INTO projects (id, name, description, status, working_directory, created_at, updated_at, wizard_step) VALUES (?1, ?2, ?3, 'draft', ?4, ?5, ?6, 'plan')", + rusqlite::params![created_project_id, name, description, working_directory, now, now], + )?; + created_project_id + }; + let dir = crate::projects::artifacts::artifact_dir(&app, &project_id)?; + let prd_path = dir.join("prd.json"); + let stories_count = if prd_path.exists() { + Prd::load(&prd_path) + .map(|prd| prd.stories.len()) + .unwrap_or(0) + } else { + 0 + }; + let config_path = dir.join("config.json"); + let config: Option = if config_path.exists() { + std::fs::read_to_string(&config_path) + .ok() + .and_then(|content| serde_json::from_str(&content).ok()) + } else { + None + }; + let previous_highest = std::fs::read_to_string(dir.join("draft.json")) + .ok() + .and_then(|raw| serde_json::from_str::(&raw).ok()) + .and_then(|draft| draft.get("highestStep").and_then(serde_json::Value::as_u64)) + .map_or(1, |value| value as u32); + let draft = serde_json::json!({ + "version": 1, + "projectId": project_id, + "currentStep": "plan", + "highestStep": previous_highest.max(2), + "describe": { + "name": name, + "description": description, + "workingDirectory": working_directory, + "planAgent": plan_agent, + "planModel": plan_model, + "planEffort": plan_effort + }, + "plan": { "completed": false }, + "atomize": { "storiesCount": stories_count }, + "configure": config.unwrap_or_default() + }); + std::fs::create_dir_all(&dir)?; + std::fs::write( + dir.join("draft.json"), + serde_json::to_string_pretty(&draft)?, + )?; + Ok(WizardRouteResult { + project_id: project_id.clone(), + next_route: format!("/new/plan/{project_id}"), + working_directory: Some(working_directory), + }) +} + +#[tauri::command] +pub async fn complete_atomize_step( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + save_wizard_draft(app, db, project_id.clone(), "configure".to_string()).await?; + let _ = advance_wizard_step(4).await; + Ok(WizardRouteResult { + project_id: project_id.clone(), + next_route: format!("/new/configure/{project_id}"), + working_directory: None, + }) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CompleteConfigureResult { + pub config: ProjectConfig, + pub errors: std::collections::HashMap, + pub next_route: String, +} + +#[tauri::command] +pub async fn complete_configure_step( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, + raw: RawConfigInput, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + let result = submit_project_config(app.clone(), project_id.clone(), raw).await?; + if !result.errors.is_empty() { + return Ok(CompleteConfigureResult { + config: result.config, + errors: result.errors, + next_route: format!("/new/configure/{project_id}"), + }); + } + save_wizard_draft(app, db, project_id.clone(), "launch".to_string()).await?; + let _ = advance_wizard_step(5).await; + Ok(CompleteConfigureResult { + config: result.config, + errors: std::collections::HashMap::new(), + next_route: format!("/new/launch/{project_id}"), + }) +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct LaunchProjectResult { + pub session_id: String, + pub monitor_route: String, +} + +#[tauri::command] +pub async fn launch_project( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + crate::projects::wizard::finalize_draft(app.clone(), db, project_id.clone()).await?; + let session_id = crate::loop_manager::start_loop( + app, + StartLoopArgs { + project_id: project_id.clone(), + project_name: None, + working_directory: None, + agent: None, + model: None, + effort: None, + fallback_agents: Vec::new(), + max_iterations: None, + gutter_threshold: None, + cooldown_seconds: None, + test_command: None, + max_verification_retries: None, + scm_provider: None, + review_polling_interval: None, + review_timeout: None, + }, + ) + .await + .map_err(|err| ProjectError::Db(err.to_string()))?; + Ok(LaunchProjectResult { + session_id, + monitor_route: format!("/monitor/{project_id}"), + }) +} + +#[tauri::command] +pub async fn exit_wizard( + app: AppHandle, + db: State<'_, DbState>, + plan_state: State<'_, PlanSessionsState>, + project_id: String, + current_step: u32, +) -> Result<(), ProjectError> { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + if current_step == 2 { + let _ = crate::plan_engine::stop_plan(plan_state, project_id.clone()).await; + } + let step_name = crate::projects::wizard_state::step_number_to_name(current_step) + .unwrap_or("describe") + .to_string(); + save_wizard_draft(app, db, project_id, step_name).await +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WizardStepMeta { + pub number: u32, + pub label: String, + pub slug: String, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct MonitorTabMeta { + pub id: String, + pub label: String, + #[serde(default)] + pub disable_for_inactive: bool, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct WizardDefaultsResponse { + pub default_agent: String, + pub placeholder_config: ProjectConfig, + pub wizard_steps: Vec, + pub monitor_tabs: Vec, +} + +#[tauri::command] +pub async fn get_wizard_defaults() -> Result { + Ok(WizardDefaultsResponse { + default_agent: "claude".to_string(), + placeholder_config: ProjectConfig::default(), + wizard_steps: vec![ + WizardStepMeta { + number: 1, + label: "Describe".to_string(), + slug: "describe".to_string(), + }, + WizardStepMeta { + number: 2, + label: "Plan".to_string(), + slug: "plan".to_string(), + }, + WizardStepMeta { + number: 3, + label: "Atomize".to_string(), + slug: "atomize".to_string(), + }, + WizardStepMeta { + number: 4, + label: "Configure".to_string(), + slug: "configure".to_string(), + }, + WizardStepMeta { + number: 5, + label: "Launch".to_string(), + slug: "launch".to_string(), + }, + ], + monitor_tabs: vec![ + MonitorTabMeta { + id: "progress".to_string(), + label: "Progress".to_string(), + disable_for_inactive: false, + }, + MonitorTabMeta { + id: "activity".to_string(), + label: "Activity".to_string(), + disable_for_inactive: false, + }, + MonitorTabMeta { + id: "output".to_string(), + label: "Output".to_string(), + disable_for_inactive: false, + }, + MonitorTabMeta { + id: "ask".to_string(), + label: "Ask".to_string(), + disable_for_inactive: true, + }, + MonitorTabMeta { + id: "config".to_string(), + label: "Config".to_string(), + disable_for_inactive: false, + }, + ], + }) +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct RawConfigInput { + #[serde(default)] + pub execute_agent: String, + #[serde(default)] + pub execute_model: Option, + #[serde(default)] + pub execute_effort: Option, + #[serde(default)] + pub fallback_chain: Vec, + #[serde(default)] + pub gutter_threshold: u32, + #[serde(default)] + pub max_iterations: u32, + #[serde(default)] + pub cooldown_seconds: u32, + #[serde(default)] + pub test_command: String, + #[serde(default)] + pub max_verification_retries: u32, + #[serde(default)] + pub scm_provider: String, + #[serde(default)] + pub review_polling_interval: u64, + #[serde(default)] + pub review_timeout: u64, +} + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitConfigResult { + pub config: ProjectConfig, + pub errors: std::collections::HashMap, +} + +#[tauri::command] +pub async fn submit_project_config( + app: AppHandle, + project_id: String, + raw: RawConfigInput, +) -> Result { + let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; + let agent = required_trimmed(raw.execute_agent, "execute_agent").unwrap_or_default(); + + let sanitized_chain = non_empty_trimmed_list(raw.fallback_chain) + .into_iter() + .filter(|entry| entry != &agent) + .collect::>(); + + let config = ProjectConfig { + schema_version: 1, + execute_agent: agent, + execute_model: raw.execute_model.filter(|val| !val.trim().is_empty()), + execute_effort: raw.execute_effort.filter(|val| !val.trim().is_empty()), + fallback_chain: sanitized_chain, + gutter_threshold: raw.gutter_threshold, + max_iterations: raw.max_iterations, + cooldown_seconds: raw.cooldown_seconds, + test_command: raw.test_command.trim().to_string(), + max_verification_retries: raw.max_verification_retries, + scm_provider: if raw.scm_provider.trim().is_empty() { + "auto".to_string() + } else { + raw.scm_provider.trim().to_string() + }, + review_polling_interval: raw.review_polling_interval, + review_timeout: raw.review_timeout, + }; + + let validation = crate::projects::validation::validate_config(&config); + if !validation.is_empty() { + return Ok(SubmitConfigResult { + config, + errors: validation.errors, + }); + } + + let config_json = serde_json::to_string_pretty(&config)?; + let dir = crate::projects::artifacts::artifact_dir(&app, &project_id)?; + std::fs::create_dir_all(&dir)?; + std::fs::write(dir.join("config.json"), &config_json)?; + + Ok(SubmitConfigResult { + config, + errors: std::collections::HashMap::new(), + }) +} + #[derive(Serialize)] #[serde(rename_all = "camelCase")] pub struct StaleResult { @@ -259,10 +760,9 @@ pub async fn mark_wizard_stale( let project_id = required_trimmed(project_id, "project_id").map_err(ProjectError::Path)?; let now = chrono::Utc::now().to_rfc3339(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let _ = conn.execute( "UPDATE projects SET updated_at = ?1 WHERE id = ?2", rusqlite::params![now, project_id], diff --git a/src-tauri/src/connections.rs b/src-tauri/src/connections.rs index 4000eae..bbd1f9d 100644 --- a/src-tauri/src/connections.rs +++ b/src-tauri/src/connections.rs @@ -73,7 +73,9 @@ pub async fn create_connection( validate_repo_paths(&repos)?; let connection_id = Uuid::new_v4().to_string(); - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; conn.execute( "INSERT INTO connections (id, name) VALUES (?1, ?2)", @@ -102,14 +104,13 @@ pub async fn create_connection( } #[tauri::command] -pub async fn list_connections( - db: State<'_, DbState>, -) -> Result, ConnectionError> { - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; +pub async fn list_connections(db: State<'_, DbState>) -> Result, ConnectionError> { + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; - let mut stmt = conn.prepare( - "SELECT id, name, created_at FROM connections ORDER BY created_at DESC", - )?; + let mut stmt = + conn.prepare("SELECT id, name, created_at FROM connections ORDER BY created_at DESC")?; let connections: Vec<(String, String, String)> = stmt .query_map([], |row| { Ok(( @@ -118,7 +119,7 @@ pub async fn list_connections( row.get::<_, String>(2)?, )) })? - .filter_map(|row| row.ok()) + .filter_map(Result::ok) .collect(); let mut result = Vec::with_capacity(connections.len()); @@ -139,9 +140,8 @@ fn load_repos( conn: &rusqlite::Connection, connection_id: &str, ) -> Result, ConnectionError> { - let mut stmt = conn.prepare( - "SELECT repo_path, display_name FROM connection_repos WHERE connection_id = ?1", - )?; + let mut stmt = conn + .prepare("SELECT repo_path, display_name FROM connection_repos WHERE connection_id = ?1")?; let repos = stmt .query_map([connection_id], |row| { Ok(ConnectionRepo { @@ -149,7 +149,7 @@ fn load_repos( display_name: row.get(1)?, }) })? - .filter_map(|row| row.ok()) + .filter_map(Result::ok) .collect(); Ok(repos) } @@ -161,14 +161,17 @@ pub async fn update_connection( name: Option, repos: Option>, ) -> Result { - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; - let exists: bool = conn.query_row( - "SELECT COUNT(*) FROM connections WHERE id = ?1", - [&connection_id], - |row| row.get::<_, i64>(0), - ) - .map(|count| count > 0)?; + let exists: bool = conn + .query_row( + "SELECT COUNT(*) FROM connections WHERE id = ?1", + [&connection_id], + |row| row.get::<_, i64>(0), + ) + .map(|count| count > 0)?; if !exists { return Err(ConnectionError::NotFound(connection_id)); @@ -223,11 +226,10 @@ pub async fn delete_connection( .map_err(|err| ConnectionError::Io(format!("Failed to remove workspace: {err}")))?; } - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; - conn.execute( - "DELETE FROM connections WHERE id = ?1", - [&connection_id], - )?; + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + conn.execute("DELETE FROM connections WHERE id = ?1", [&connection_id])?; Ok(()) } @@ -251,7 +253,9 @@ pub async fn connection_merge_summary( base_ref: String, ) -> Result, ConnectionError> { let repos = { - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; load_repos(&conn, &connection_id)? }; @@ -259,15 +263,12 @@ pub async fn connection_merge_summary( let mut summaries = Vec::with_capacity(repos.len()); for repo in &repos { - let display = repo - .display_name - .as_deref() - .unwrap_or_else(|| { - std::path::Path::new(&repo.repo_path) - .file_name() - .and_then(|fname| fname.to_str()) - .unwrap_or("repo") - }); + let display = repo.display_name.as_deref().unwrap_or_else(|| { + std::path::Path::new(&repo.repo_path) + .file_name() + .and_then(|fname| fname.to_str()) + .unwrap_or("repo") + }); let worktree_path = workspace_dir.join(display); let diff_output = tokio::process::Command::new("git") @@ -290,12 +291,10 @@ pub async fn connection_merge_summary( .await; let commit_count = match commit_output { - Ok(out) if out.status.success() => { - String::from_utf8_lossy(&out.stdout) - .trim() - .parse::() - .unwrap_or(0) - } + Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout) + .trim() + .parse::() + .unwrap_or(0), _ => 0, }; @@ -354,32 +353,31 @@ pub fn build_workspace( for repo in repos { let source = std::path::Path::new(&repo.repo_path); - let link_name = repo - .display_name - .as_deref() - .unwrap_or_else(|| { - source - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("repo") - }); + let link_name = repo.display_name.as_deref().unwrap_or_else(|| { + source + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("repo") + }); let link_path = workspace_dir.join(link_name); #[cfg(unix)] - std::os::unix::fs::symlink(source, &link_path) - .map_err(|err| ConnectionError::Io(format!( + std::os::unix::fs::symlink(source, &link_path).map_err(|err| { + ConnectionError::Io(format!( "Failed to symlink {} -> {}: {err}", link_path.display(), source.display() - )))?; + )) + })?; #[cfg(windows)] - junction::create(source, &link_path) - .map_err(|err| ConnectionError::Io(format!( + junction::create(source, &link_path).map_err(|err| { + ConnectionError::Io(format!( "Failed to create junction {} -> {}: {err}", link_path.display(), source.display() - )))?; + )) + })?; } Ok(()) @@ -397,15 +395,12 @@ pub fn generate_workspace_manifest( content.push_str("## Repositories\n\n"); for repo in repos { - let display = repo - .display_name - .as_deref() - .unwrap_or_else(|| { - std::path::Path::new(&repo.repo_path) - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("repo") - }); + let display = repo.display_name.as_deref().unwrap_or_else(|| { + std::path::Path::new(&repo.repo_path) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("repo") + }); content.push_str(&format!("### {display}\n\n")); content.push_str(&format!("Path: `{}`\n\n", repo.repo_path)); } @@ -427,12 +422,16 @@ pub async fn build_connection_workspace( db: State<'_, DbState>, connection_id: String, ) -> Result { - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; let repos = load_repos(&conn, &connection_id)?; drop(conn); let name: String = { - let conn2 = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + let conn2 = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; conn2.query_row( "SELECT name FROM connections WHERE id = ?1", [&connection_id], @@ -455,7 +454,9 @@ pub async fn create_connection_worktrees( branch_name: String, ) -> Result { let (repos, name) = { - let conn = db.0.lock().map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ConnectionError::Db("Lock poisoned".into()))?; let repos = load_repos(&conn, &connection_id)?; let name: String = conn.query_row( "SELECT name FROM connections WHERE id = ?1", @@ -474,28 +475,29 @@ pub async fn create_connection_worktrees( .map_err(|err| ConnectionError::Io(format!("Failed to create workspace: {err}")))?; for repo in &repos { - let display = repo - .display_name - .as_deref() - .unwrap_or_else(|| { - std::path::Path::new(&repo.repo_path) - .file_name() - .and_then(|fname| fname.to_str()) - .unwrap_or("repo") - }); + let display = repo.display_name.as_deref().unwrap_or_else(|| { + std::path::Path::new(&repo.repo_path) + .file_name() + .and_then(|fname| fname.to_str()) + .unwrap_or("repo") + }); let worktree_path = workspace_dir.join(display); let worktree_branch = format!("loopforge/{branch_name}/{display}"); let output = tokio::process::Command::new("git") .args([ - "worktree", "add", + "worktree", + "add", &worktree_path.to_string_lossy(), - "-b", &worktree_branch, + "-b", + &worktree_branch, ]) .current_dir(&repo.repo_path) .output() .await - .map_err(|err| ConnectionError::Io(format!("git worktree failed for {display}: {err}")))?; + .map_err(|err| { + ConnectionError::Io(format!("git worktree failed for {display}: {err}")) + })?; if !output.status.success() { let stderr = String::from_utf8_lossy(&output.stderr); diff --git a/src-tauri/src/contract_tests/artifacts.rs b/src-tauri/src/contract_tests/artifacts.rs index 724dd70..bc40928 100644 --- a/src-tauri/src/contract_tests/artifacts.rs +++ b/src-tauri/src/contract_tests/artifacts.rs @@ -1,7 +1,7 @@ -#[path = "wizard_persistence.rs"] -mod wizard_persistence; #[path = "merge_coordinator.rs"] mod merge_coordinator; +#[path = "wizard_persistence.rs"] +mod wizard_persistence; use super::harness::TestHarness; use super::support::{ diff --git a/src-tauri/src/contract_tests/harness.rs b/src-tauri/src/contract_tests/harness.rs index 3b68558..421bb06 100644 --- a/src-tauri/src/contract_tests/harness.rs +++ b/src-tauri/src/contract_tests/harness.rs @@ -25,7 +25,8 @@ struct EnvGuard { impl TestHarness { pub fn new() -> Self { let lock = ENV_LOCK.lock().unwrap_or_else(|err| err.into_inner()); - let root_dir = std::env::temp_dir().join(format!("loopforge-contract-{}", uuid::Uuid::new_v4())); + let root_dir = + std::env::temp_dir().join(format!("loopforge-contract-{}", uuid::Uuid::new_v4())); let home_dir = root_dir.join("home"); let bin_dir = root_dir.join("bin"); let work_dir = root_dir.join("work"); @@ -94,12 +95,10 @@ impl TestHarness { expected: usize, ) -> Vec { for _ in 0..100 { - let messages = commands::ask::ask_history( - self.app.state::(), - project_id.to_string(), - ) - .await - .expect("ask history"); + let messages = + commands::ask::ask_history(self.app.state::(), project_id.to_string()) + .await + .expect("ask history"); if messages.len() >= expected { return messages; } @@ -171,15 +170,14 @@ impl Drop for EnvGuard { fn install_fixture_agent(bin_dir: &Path, agent_name: &str) { let script = bin_dir.join(agent_name); - std::fs::write( - &script, - "#!/bin/sh\nprintf 'fixture agent completed\\n'\n", - ) - .expect("fixture agent"); + std::fs::write(&script, "#!/bin/sh\nprintf 'fixture agent completed\\n'\n") + .expect("fixture agent"); #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script).expect("fixture metadata").permissions(); + let mut perms = std::fs::metadata(&script) + .expect("fixture metadata") + .permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).expect("fixture perms"); } diff --git a/src-tauri/src/contract_tests/invoke_fixtures.rs b/src-tauri/src/contract_tests/invoke_fixtures.rs index 97f5ad0..cabeedb 100644 --- a/src-tauri/src/contract_tests/invoke_fixtures.rs +++ b/src-tauri/src/contract_tests/invoke_fixtures.rs @@ -122,9 +122,7 @@ fn install_script(bin_dir: &Path, name: &str, content: &str) { #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; - let mut perms = std::fs::metadata(&script) - .expect("metadata") - .permissions(); + let mut perms = std::fs::metadata(&script).expect("metadata").permissions(); perms.set_mode(0o755); std::fs::set_permissions(&script, perms).expect("permissions"); } diff --git a/src-tauri/src/contract_tests/invoke_handler.rs b/src-tauri/src/contract_tests/invoke_handler.rs index c5d239d..68449f2 100644 --- a/src-tauri/src/contract_tests/invoke_handler.rs +++ b/src-tauri/src/contract_tests/invoke_handler.rs @@ -30,7 +30,8 @@ async fn invoke_handler_covers_lifecycle_and_runtime_commands() { harness.invoke_ok("query_plan_status", json!({ "projectId": project.id })); assert!(status.is_some()); harness.wait_for_plan_idle(&project.id).await; - let plan: Option = harness.invoke_ok("load_existing_plan", json!({ "projectId": project.id })); + let plan: Option = + harness.invoke_ok("load_existing_plan", json!({ "projectId": project.id })); assert!(plan.is_some()); let prd: Prd = harness.invoke_ok( diff --git a/src-tauri/src/contract_tests/invoke_harness.rs b/src-tauri/src/contract_tests/invoke_harness.rs index 1a6bffc..b117834 100644 --- a/src-tauri/src/contract_tests/invoke_harness.rs +++ b/src-tauri/src/contract_tests/invoke_harness.rs @@ -70,8 +70,10 @@ impl InvokeHarness { pub async fn wait_for_plan_idle(&self, project_id: &str) { for _ in 0..100 { - let status: Option = - self.invoke_ok("query_plan_status", serde_json::json!({ "projectId": project_id })); + let status: Option = self.invoke_ok( + "query_plan_status", + serde_json::json!({ "projectId": project_id }), + ); if status.is_none() && self.artifact_dir(project_id).join("plan.md").exists() { return; } diff --git a/src-tauri/src/contract_tests/merge_coordinator.rs b/src-tauri/src/contract_tests/merge_coordinator.rs index 7b7fb1d..0a5c396 100644 --- a/src-tauri/src/contract_tests/merge_coordinator.rs +++ b/src-tauri/src/contract_tests/merge_coordinator.rs @@ -9,7 +9,15 @@ fn parallel_completions_produce_one_ordered_write_sequence() { seed_prd(&root); let actions = ordered_merge_actions(vec![ - completion("S-002", "wt-b", false, true, 1, Some("guardrail from wt-b"), Some("bbb222")), + completion( + "S-002", + "wt-b", + false, + true, + 1, + Some("guardrail from wt-b"), + Some("bbb222"), + ), completion("S-001", "wt-a", true, false, 0, None, Some("aaa111")), ]); @@ -31,8 +39,18 @@ fn parallel_completions_produce_one_ordered_write_sequence() { ); let prd = Prd::load(&root.join("prd.json")).unwrap(); - assert!(prd.stories.iter().find(|story| story.id == "S-001").unwrap().passes); - let blocked = prd.stories.iter().find(|story| story.id == "S-002").unwrap(); + assert!( + prd.stories + .iter() + .find(|story| story.id == "S-001") + .unwrap() + .passes + ); + let blocked = prd + .stories + .iter() + .find(|story| story.id == "S-002") + .unwrap(); assert!(!blocked.passes); assert!(blocked.blocked); assert_eq!( @@ -64,7 +82,9 @@ fn merge_targets_keep_contract_artifact_filenames() { .collect(); assert!(targets.iter().any(|target| target.ends_with("prd.json"))); - assert!(targets.iter().any(|target| target.ends_with("guardrails.md"))); + assert!(targets + .iter() + .any(|target| target.ends_with("guardrails.md"))); assert!(targets.iter().any(|target| target == "session:wt-c")); let _ = std::fs::remove_dir_all(root); diff --git a/src-tauri/src/contract_tests/support.rs b/src-tauri/src/contract_tests/support.rs index dbab6b6..0c8cf5a 100644 --- a/src-tauri/src/contract_tests/support.rs +++ b/src-tauri/src/contract_tests/support.rs @@ -121,8 +121,8 @@ pub async fn start_ask(harness: &TestHarness, project_id: &str, question: &str) let project_dir = { let db = harness.app.state::(); let conn = db.0.lock().unwrap(); - let conversation = crate::ask_engine::storage::get_or_create_conversation(&conn, project_id) - .unwrap(); + let conversation = + crate::ask_engine::storage::get_or_create_conversation(&conn, project_id).unwrap(); crate::ask_engine::storage::insert_message( &conn, &conversation.id, diff --git a/src-tauri/src/contract_tests/wizard_persistence.rs b/src-tauri/src/contract_tests/wizard_persistence.rs index 21b163a..3e5538c 100644 --- a/src-tauri/src/contract_tests/wizard_persistence.rs +++ b/src-tauri/src/contract_tests/wizard_persistence.rs @@ -138,10 +138,11 @@ async fn resume_wizard_succeeds_with_canonical_draft_payload() { ) .await .expect("resume wizard"); - let loaded_draft = crate::projects::wizard::load_draft(harness.app.handle().clone(), project.id) - .await - .expect("load draft") - .expect("draft content"); + let loaded_draft = + crate::projects::wizard::load_draft(harness.app.handle().clone(), project.id) + .await + .expect("load draft") + .expect("draft content"); assert_eq!(resume_state.wizard_step, "configure"); assert_eq!( diff --git a/src-tauri/src/diagnostic_parser.rs b/src-tauri/src/diagnostic_parser.rs index f9ce2c0..6d64706 100644 --- a/src-tauri/src/diagnostic_parser.rs +++ b/src-tauri/src/diagnostic_parser.rs @@ -24,9 +24,7 @@ pub struct DiagnosticReport { pub fn parse_build_output(raw_output: String) -> DiagnosticReport { let diagnostics = parse_diagnostics(&raw_output); let total = diagnostics.len(); - let has_errors = diagnostics - .iter() - .any(|diag| diag.error_type != "warning"); + let has_errors = diagnostics.iter().any(|diag| diag.error_type != "warning"); DiagnosticReport { diagnostics, total, @@ -50,10 +48,8 @@ pub fn parse_diagnostics(output: &str) -> Vec { } fn parse_typescript(output: &str) -> Vec { - let pattern = Regex::new( - r"(?m)^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)$" - ) - .expect("valid regex"); + let pattern = + Regex::new(r"(?m)^(.+?)\((\d+),(\d+)\):\s*error\s+(TS\d+):\s*(.+)$").expect("valid regex"); pattern .captures_iter(output) @@ -69,10 +65,8 @@ fn parse_typescript(output: &str) -> Vec { } fn parse_eslint(output: &str) -> Vec { - let pattern = Regex::new( - r"(?m)^\s*(\S+?):(\d+):(\d+):\s+(\S+)\s+(.+?)(?:\s{2,}|\t)(\S+)$" - ) - .expect("valid regex"); + let pattern = Regex::new(r"(?m)^\s*(\S+?):(\d+):(\d+):\s+(\S+)\s+(.+?)(?:\s{2,}|\t)(\S+)$") + .expect("valid regex"); pattern .captures_iter(output) @@ -89,10 +83,9 @@ fn parse_eslint(output: &str) -> Vec { } fn parse_cargo(output: &str) -> Vec { - let error_pattern = Regex::new( - r"(?m)^error(?:\[E(\d+)\])?:\s*(.+)\n\s*-->\s*(.+?):(\d+):(\d+)" - ) - .expect("valid regex"); + let error_pattern = + Regex::new(r"(?m)^error(?:\[E(\d+)\])?:\s*(.+)\n\s*-->\s*(.+?):(\d+):(\d+)") + .expect("valid regex"); error_pattern .captures_iter(output) @@ -111,29 +104,21 @@ fn parse_cargo(output: &str) -> Vec { } fn parse_jest(output: &str) -> Vec { - let fail_pattern = Regex::new( - r"(?m)FAIL\s+(.+?)(?:\n|\r\n)" - ) - .expect("valid regex"); + let fail_pattern = Regex::new(r"(?m)FAIL\s+(.+?)(?:\n|\r\n)").expect("valid regex"); - let assertion_pattern = Regex::new( - r"(?m)Expected:?\s*(.+)\n\s*Received:?\s*(.+)" - ) - .expect("valid regex"); + let assertion_pattern = + Regex::new(r"(?m)Expected:?\s*(.+)\n\s*Received:?\s*(.+)").expect("valid regex"); - let location_pattern = Regex::new( - r"(?m)at\s+.*?\((.+?):(\d+):(\d+)\)" - ) - .expect("valid regex"); + let location_pattern = Regex::new(r"(?m)at\s+.*?\((.+?):(\d+):(\d+)\)").expect("valid regex"); let mut diagnostics = Vec::new(); for fail_match in fail_pattern.captures_iter(output) { let test_file = fail_match[1].trim().to_string(); - let expected_received = assertion_pattern.captures(output).map(|cap| { - format!("Expected: {}, Received: {}", &cap[1], &cap[2]) - }); + let expected_received = assertion_pattern + .captures(output) + .map(|cap| format!("Expected: {}, Received: {}", &cap[1], &cap[2])); let (line, col) = location_pattern .captures(output) @@ -176,7 +161,8 @@ mod tests { #[test] fn typescript_parser_extracts_error() { - let output = r#"src/auth.ts(42,5): error TS2322: Type 'string' is not assignable to type 'number'"#; + let output = + r#"src/auth.ts(42,5): error TS2322: Type 'string' is not assignable to type 'number'"#; let diags = parse_typescript(output); assert_eq!(diags.len(), 1); assert_eq!(diags[0].file.as_deref(), Some("src/auth.ts")); diff --git a/src-tauri/src/ephemeral_query.rs b/src-tauri/src/ephemeral_query.rs index fc3da84..e8a2671 100644 --- a/src-tauri/src/ephemeral_query.rs +++ b/src-tauri/src/ephemeral_query.rs @@ -58,10 +58,7 @@ fn classify_question(question: &str) -> Option { .map(|(_, query_type)| *query_type) } -fn answer_instant( - query_type: InstantQuery, - stats: &SessionStats, -) -> String { +fn answer_instant(query_type: InstantQuery, stats: &SessionStats) -> String { match query_type { InstantQuery::CurrentStory => { if stats.is_running { @@ -89,24 +86,16 @@ fn answer_instant( ) } InstantQuery::LoopConfig => { - let agent = stats - .current_agent - .as_deref() - .unwrap_or("none"); + let agent = stats.current_agent.as_deref().unwrap_or("none"); format!( "Agent: {} | Running: {} | Stories: {}/{} completed", - agent, - stats.is_running, - stats.passed_stories, - stats.total_stories + agent, stats.is_running, stats.passed_stories, stats.total_stories ) } - InstantQuery::CurrentAgent => { - match &stats.current_agent { - Some(agent) => format!("Current agent: {agent}"), - None => "No agent is currently active.".to_string(), - } - } + InstantQuery::CurrentAgent => match &stats.current_agent { + Some(agent) => format!("Current agent: {agent}"), + None => "No agent is currently active.".to_string(), + }, } } @@ -119,13 +108,8 @@ pub async fn ephemeral_query( use tauri::Manager; let db_state = app.state::(); let loop_state = app.state::(); - let stats = crate::loop_manager::session_stats( - app.clone(), - db_state, - loop_state, - project_id, - ) - .await?; + let stats = + crate::loop_manager::session_stats(app.clone(), db_state, loop_state, project_id).await?; if let Some(query_type) = classify_question(&question) { let answer = answer_instant(query_type, &stats); diff --git a/src-tauri/src/events.rs b/src-tauri/src/events.rs index 39f7a5d..a120069 100644 --- a/src-tauri/src/events.rs +++ b/src-tauri/src/events.rs @@ -11,6 +11,9 @@ pub const EVENT_VERIFICATION_FAILED: &str = "loop:verification-failed"; pub const EVENT_VERIFICATION_PASSED: &str = "loop:verification-passed"; pub const EVENT_PROMPT_BUILT: &str = "loop:prompt-built"; pub const EVENT_STORY_SKIPPED: &str = "loop:story-skipped"; +pub const EVENT_STORIES_UPDATED: &str = "loop:stories-updated"; +pub const EVENT_PROJECT_STATE_CHANGED: &str = "project:state-changed"; +pub const EVENT_NOTIFICATION_ADDED: &str = "notification:added"; pub const EVENT_ASK_STREAM: &str = "ask:stream"; pub const EVENT_ASK_COMPLETE: &str = "ask:complete"; diff --git a/src-tauri/src/invoke.rs b/src-tauri/src/invoke.rs index 18d39bd..fb4a848 100644 --- a/src-tauri/src/invoke.rs +++ b/src-tauri/src/invoke.rs @@ -1,23 +1,120 @@ use crate::{agents, commands, connections, ephemeral_query, services}; #[cfg(not(test))] +#[path = "../../crates/loopforge-app-core/src/atomizer.rs"] +mod app_core_atomizer; +#[cfg(not(test))] #[path = "../../crates/loopforge-app-core/src/projects.rs"] mod app_core_projects; -#[cfg(not(test))] #[path = "../../crates/loopforge-app-core/src/atomizer.rs"] mod app_core_atomizer; #[cfg(not(test))] pub fn attach_app(builder: tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, commands::planning::query_plan_status, commands::planning::replan, commands::projects_artifacts::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, commands::projects_artifacts::save_plan, commands::projects_artifacts::save_prd, commands::projects_artifacts::save_config, commands::projects_artifacts::load_config, commands::atomization::run_atomizer, commands::atomization::get_atomizer_activity_log, commands::atomization::get_atomizer_pipeline_state, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, commands::projects_wizard::save_draft, commands::projects_wizard::load_draft, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, commands::execution::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace, commands::wizard_logic::advance_wizard_step, commands::wizard_logic::get_default_config, commands::wizard_logic::validate_project_config, commands::wizard_logic::validate_describe_input, commands::wizard_logic::validate_launch_readiness, commands::wizard_logic::add_story, commands::wizard_logic::update_story, commands::wizard_logic::remove_story, commands::wizard_logic::reorder_stories, commands::wizard_logic::get_stories, commands::wizard_logic::save_wizard_draft, commands::wizard_logic::mark_wizard_stale]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ + agents::detect_agents, + agents::refresh_agents, + agents::get_known_agents, + agents::get_agent_capabilities, + agents::resolve_agent_selection, + commands::display_vocabulary::get_display_vocabulary, + commands::planning::query_plan_status, + commands::planning::resolve_plan_state, + commands::planning::resolve_plan_action, + commands::planning::plan_user_action, + commands::planning::replan, + commands::projects_artifacts::load_existing_plan, + commands::projects_artifacts::load_existing_prd, + commands::projects_artifacts::load_output_log, + commands::projects_artifacts::save_plan, + commands::projects_artifacts::save_prd, + commands::projects_artifacts::save_config, + commands::projects_artifacts::load_config, + commands::atomization::run_atomizer, + commands::atomization::get_atomizer_activity_log, + commands::atomization::get_atomizer_pipeline_state, + commands::projects_wizard::save_wizard_state, + commands::projects_wizard::resume_wizard, + commands::projects_wizard::hydrate_wizard, + commands::projects_wizard::save_draft, + commands::projects_wizard::load_draft, + commands::projects_lifecycle::pause_project, + commands::projects_lifecycle::resume_project, + commands::projects_lifecycle::archive_project, + commands::projects_lifecycle::get_project_stories, + commands::projects_lifecycle::get_guardrails, + commands::projects_lifecycle::get_project_config, + commands::projects::get_project_snapshot, + commands::projects_listing::list_projects_enriched, + commands::projects_listing::list_projects_grouped, + commands::projects_lifecycle::get_notification_prefs, + commands::projects_lifecycle::save_notification_prefs, + commands::execution::start_loop, + commands::execution::stop_loop, + commands::execution::get_activity_feed, + commands::ask::ask_question, + commands::ask::ask_history, + commands::ask::stop_ask, + commands::ask::copy_ask_message, + commands::ask::truncate_ask_from, + commands::ask::retry_ask, + ephemeral_query::ephemeral_query, + connections::list_connections, + connections::build_connection_workspace, + commands::wizard_logic::advance_wizard_step, + commands::wizard_logic::get_default_config, + commands::wizard_logic::get_wizard_defaults, + commands::wizard_logic::validate_project_config, + commands::wizard_logic::validate_describe_input, + commands::wizard_logic::validate_launch_readiness, + commands::wizard_logic::add_story, + commands::wizard_logic::update_story, + commands::wizard_logic::remove_story, + commands::wizard_logic::reorder_stories, + commands::wizard_logic::get_stories, + commands::wizard_logic::save_wizard_draft, + commands::wizard_logic::complete_describe_step, + commands::wizard_logic::complete_atomize_step, + commands::wizard_logic::complete_configure_step, + commands::wizard_logic::launch_project, + commands::wizard_logic::exit_wizard, + commands::wizard_logic::submit_project_config, + commands::wizard_logic::mark_wizard_stale, + commands::notifications::add_notification, + commands::notifications::get_notifications, + commands::notifications::mark_notification_read, + commands::notifications::mark_all_notifications_read, + commands::notifications::clear_notifications + ]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { - "start_plan" => commands::planning::__cmd__start_plan!(plan_commands::start_plan, invoke), - "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), + "start_plan" => { + commands::planning::__cmd__start_plan!(plan_commands::start_plan, invoke) + } + "write_to_plan" => { + commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke) + } "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), - "run_atomizer" => commands::atomization::__cmd__run_atomizer!(atomizer_commands::run_atomizer, invoke), - "create_project" => commands::projects_lifecycle::__cmd__create_project!(project_commands::create_project, invoke), - "finalize_draft" => commands::projects_wizard::__cmd__finalize_draft!(project_commands::finalize_draft, invoke), - "discard_draft" => commands::projects_wizard::__cmd__discard_draft!(project_commands::discard_draft, invoke), - "list_projects" => commands::projects_lifecycle::__cmd__list_projects!(project_commands::list_projects, invoke), - "get_project_detail" => commands::projects_lifecycle::__cmd__get_project_detail!(project_commands::get_project_detail, invoke), + "run_atomizer" => { + commands::atomization::__cmd__run_atomizer!(atomizer_commands::run_atomizer, invoke) + } + "create_project" => commands::projects_lifecycle::__cmd__create_project!( + project_commands::create_project, + invoke + ), + "finalize_draft" => commands::projects_wizard::__cmd__finalize_draft!( + project_commands::finalize_draft, + invoke + ), + "discard_draft" => commands::projects_wizard::__cmd__discard_draft!( + project_commands::discard_draft, + invoke + ), + "list_projects" => commands::projects_lifecycle::__cmd__list_projects!( + project_commands::list_projects, + invoke + ), + "get_project_detail" => commands::projects_lifecycle::__cmd__get_project_detail!( + project_commands::get_project_detail, + invoke + ), _ => fallback(invoke), } }) @@ -26,10 +123,75 @@ pub fn attach_app(builder: tauri::Builder) -> tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![agents::detect_agents, agents::refresh_agents, agents::get_agent_capabilities, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, commands::projects_artifacts::load_existing_prd, commands::projects_artifacts::load_output_log, crate::invoke_contract::save_plan, commands::projects_artifacts::save_prd, crate::invoke_contract::save_config, commands::projects_artifacts::load_config, crate::invoke_contract::run_atomizer, commands::atomization::get_atomizer_activity_log, commands::atomization::get_atomizer_pipeline_state, crate::invoke_contract::create_project, crate::invoke_contract::finalize_draft, commands::projects_wizard::discard_draft, commands::projects_wizard::save_wizard_state, commands::projects_wizard::resume_wizard, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, commands::projects_lifecycle::list_projects, commands::projects_lifecycle::pause_project, commands::projects_lifecycle::resume_project, commands::projects_lifecycle::archive_project, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::get_project_stories, commands::projects_lifecycle::get_guardrails, commands::projects_lifecycle::get_project_config, commands::projects::get_project_snapshot, commands::projects_listing::list_projects_enriched, commands::projects_lifecycle::get_notification_prefs, commands::projects_lifecycle::save_notification_prefs, crate::invoke_contract::start_loop, commands::execution::stop_loop, commands::ask::ask_question, commands::ask::ask_history, commands::ask::stop_ask, commands::ask::copy_ask_message, commands::ask::truncate_ask_from, commands::ask::retry_ask, ephemeral_query::ephemeral_query, connections::list_connections, connections::build_connection_workspace]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ + agents::detect_agents, + agents::refresh_agents, + agents::get_known_agents, + agents::get_agent_capabilities, + commands::display_vocabulary::get_display_vocabulary, + crate::invoke_contract::query_plan_status, + crate::invoke_contract::load_existing_plan, + commands::projects_artifacts::load_existing_prd, + commands::projects_artifacts::load_output_log, + crate::invoke_contract::save_plan, + commands::projects_artifacts::save_prd, + crate::invoke_contract::save_config, + commands::projects_artifacts::load_config, + crate::invoke_contract::run_atomizer, + commands::atomization::get_atomizer_activity_log, + commands::atomization::get_atomizer_pipeline_state, + crate::invoke_contract::create_project, + crate::invoke_contract::finalize_draft, + commands::projects_wizard::discard_draft, + commands::projects_wizard::save_wizard_state, + commands::projects_wizard::resume_wizard, + commands::projects_wizard::hydrate_wizard, + crate::invoke_contract::save_draft, + crate::invoke_contract::load_draft, + commands::projects_lifecycle::list_projects, + commands::projects_lifecycle::pause_project, + commands::projects_lifecycle::resume_project, + commands::projects_lifecycle::archive_project, + crate::invoke_contract::get_project_detail, + commands::projects_lifecycle::get_project_stories, + commands::projects_lifecycle::get_guardrails, + commands::projects_lifecycle::get_project_config, + commands::projects::get_project_snapshot, + commands::projects_listing::list_projects_enriched, + commands::projects_listing::list_projects_grouped, + commands::projects_lifecycle::get_notification_prefs, + commands::projects_lifecycle::save_notification_prefs, + crate::invoke_contract::start_loop, + commands::execution::stop_loop, + commands::execution::get_activity_feed, + commands::ask::ask_question, + commands::ask::ask_history, + commands::ask::stop_ask, + commands::ask::copy_ask_message, + commands::ask::truncate_ask_from, + commands::ask::retry_ask, + ephemeral_query::ephemeral_query, + connections::list_connections, + connections::build_connection_workspace, + commands::wizard_logic::get_wizard_defaults, + commands::wizard_logic::complete_describe_step, + commands::wizard_logic::complete_atomize_step, + commands::wizard_logic::complete_configure_step, + commands::wizard_logic::launch_project, + commands::wizard_logic::exit_wizard, + commands::notifications::add_notification, + commands::notifications::get_notifications, + commands::notifications::mark_notification_read, + commands::notifications::mark_all_notifications_read, + commands::notifications::clear_notifications + ]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { - "start_plan" => crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke), - "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), + "start_plan" => { + crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke) + } + "write_to_plan" => { + commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke) + } "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), _ => fallback(invoke), } @@ -39,16 +201,84 @@ pub fn attach_app(builder: tauri::Builder) -> tauri::Builder(builder: tauri::Builder) -> tauri::Builder { services::attach_runtime_aliases(builder, { - let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![crate::invoke_contract::create_project, crate::invoke_contract::save_draft, crate::invoke_contract::load_draft, crate::invoke_contract::finalize_draft, crate::invoke_contract::query_plan_status, crate::invoke_contract::load_existing_plan, crate::invoke_contract::save_plan, crate::invoke_contract::save_config, crate::invoke_contract::run_atomizer, crate::invoke_contract::start_loop, crate::invoke_contract::get_project_detail, commands::projects_lifecycle::archive_project]; + let fallback: fn(tauri::ipc::Invoke) -> bool = tauri::generate_handler![ + crate::invoke_contract::create_project, + crate::invoke_contract::save_draft, + crate::invoke_contract::load_draft, + crate::invoke_contract::finalize_draft, + crate::invoke_contract::query_plan_status, + crate::invoke_contract::load_existing_plan, + crate::invoke_contract::save_plan, + crate::invoke_contract::save_config, + crate::invoke_contract::run_atomizer, + crate::invoke_contract::start_loop, + crate::invoke_contract::get_project_detail, + commands::projects_lifecycle::archive_project + ]; move |invoke: tauri::ipc::Invoke| match invoke.message.command() { - "start_plan" => crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke), - "write_to_plan" => commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke), + "start_plan" => { + crate::invoke_contract::__cmd__start_plan!(plan_commands::start_plan, invoke) + } + "write_to_plan" => { + commands::planning::__cmd__write_to_plan!(plan_commands::write_to_plan, invoke) + } "stop_plan" => commands::planning::__cmd__stop_plan!(plan_commands::stop_plan, invoke), _ => fallback(invoke), } }) } -#[cfg(not(test))] mod atomizer_commands { use super::app_core_atomizer; use crate::atomizer::{AtomizeArgs, AtomizeProgress, AtomizerError}; use ralph_core::prd::Prd; use tauri::{AppHandle, Emitter}; pub async fn run_atomizer(app: AppHandle, args: AtomizeArgs) -> Result { let normalized_args = AtomizeArgs { project_id: required(args.project_id, "project_id").map_err(AtomizerError::Path)?, project_name: required(args.project_name, "project_name").map_err(AtomizerError::Path)?, project_dir: args.project_dir, agent: required(args.agent, "agent").map_err(AtomizerError::Path)?, model: optional(args.model), effort: optional(args.effort) }; let run_result = app_core_atomizer::run_atomizer(app_core_atomizer::AtomizerRequest { project_id: normalized_args.project_id.clone() }, |_| crate::atomizer::run_atomizer(app.clone(), normalized_args), |prd| prd.stories.len()).await?; for event in &run_result.events { let payload = event.as_progress_payload(); let _ = app.emit("atomization-progress", AtomizeProgress { stage: payload.stage, stage_name: payload.stage_name, message: payload.message, project_id: payload.project_id, elapsed_ms: 0 }); } Ok(run_result.output) } fn required(value: String, name: &str) -> Result { let trimmed = value.trim().to_string(); if trimmed.is_empty() { return Err(format!("{name} is required")); } Ok(trimmed) } fn optional(value: Option) -> Option { value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) } } +#[cfg(not(test))] +mod atomizer_commands { + use super::app_core_atomizer; + use crate::atomizer::{AtomizeArgs, AtomizeProgress, AtomizerError}; + use ralph_core::prd::Prd; + use tauri::{AppHandle, Emitter}; + pub async fn run_atomizer(app: AppHandle, args: AtomizeArgs) -> Result { + let normalized_args = AtomizeArgs { + project_id: required(args.project_id, "project_id").map_err(AtomizerError::Path)?, + project_name: required(args.project_name, "project_name") + .map_err(AtomizerError::Path)?, + project_dir: args.project_dir, + agent: required(args.agent, "agent").map_err(AtomizerError::Path)?, + model: optional(args.model), + effort: optional(args.effort), + }; + let run_result = app_core_atomizer::run_atomizer( + app_core_atomizer::AtomizerRequest { + project_id: normalized_args.project_id.clone(), + }, + |_| crate::atomizer::run_atomizer(app.clone(), normalized_args), + |prd| prd.stories.len(), + ) + .await?; + for event in &run_result.events { + let payload = event.as_progress_payload(); + let _ = app.emit( + "atomization-progress", + AtomizeProgress { + stage: payload.stage, + stage_name: payload.stage_name, + message: payload.message, + project_id: payload.project_id, + elapsed_ms: 0, + }, + ); + } + Ok(run_result.output) + } + fn required(value: String, name: &str) -> Result { + let trimmed = value.trim().to_string(); + if trimmed.is_empty() { + return Err(format!("{name} is required")); + } + Ok(trimmed) + } + fn optional(value: Option) -> Option { + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) + } +} mod plan_commands { use crate::app_core_plan; @@ -93,7 +323,10 @@ mod plan_commands { .sessions .get_mut(&project_id) .ok_or_else(|| PlanEngineError::NoSession(project_id.clone()))?; - entry.handle.write(&payload).map_err(PlanEngineError::Shell)?; + entry + .handle + .write(&payload) + .map_err(PlanEngineError::Shell)?; if let Ok(mut activity) = activity_at.lock() { *activity = Some(Instant::now()); } @@ -159,42 +392,175 @@ mod project_commands { use uuid::Uuid; impl app_core_projects::StoryState for UserStory { - fn passes(&self) -> bool { self.passes } - fn blocked(&self) -> bool { self.blocked } + fn passes(&self) -> bool { + self.passes + } + fn blocked(&self) -> bool { + self.blocked + } } - pub async fn create_project(app: AppHandle, db: State<'_, DbState>, name: String, description: String, working_directory: String, wizard_step: Option) -> Result { - let request = app_core_projects::CreateProjectRequest { name: required(name, "name").map_err(ProjectError::Path)?, description: required(description, "description").map_err(ProjectError::Path)?, working_directory: required(working_directory, "working_directory").map_err(ProjectError::Path)?, wizard_step: optional(wizard_step) }; - app_core_projects::create_project(request, || Uuid::new_v4().to_string(), || chrono::Utc::now().to_rfc3339(), |project_id, project_name| { let dir = artifact_dir(&app, project_id)?; init_artifacts(&dir, project_name) }, |record| { let project = Project { id: record.id.clone(), name: record.name.clone(), description: record.description.clone(), status: record.status.clone(), working_directory: record.working_directory.clone(), created_at: record.created_at.clone(), updated_at: record.updated_at.clone(), wizard_step: record.wizard_step.clone() }; let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute("INSERT INTO projects (id, name, description, status, working_directory, created_at, updated_at, wizard_step) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![record.id, record.name, record.description, record.status, record.working_directory, record.created_at, record.updated_at, record.wizard_step])?; Ok(project) }) + pub async fn create_project( + app: AppHandle, + db: State<'_, DbState>, + name: String, + description: String, + working_directory: String, + wizard_step: Option, + ) -> Result { + let request = app_core_projects::CreateProjectRequest { + name: required(name, "name").map_err(ProjectError::Path)?, + description: required(description, "description").map_err(ProjectError::Path)?, + working_directory: required(working_directory, "working_directory") + .map_err(ProjectError::Path)?, + wizard_step: optional(wizard_step), + }; + app_core_projects::create_project( + request, + || Uuid::new_v4().to_string(), + || chrono::Utc::now().to_rfc3339(), + |project_id, project_name| { + let dir = artifact_dir(&app, project_id)?; + init_artifacts(&dir, project_name) + }, + |record| { + let project = Project { + id: record.id.clone(), + name: record.name.clone(), + description: record.description.clone(), + status: record.status.clone(), + working_directory: record.working_directory.clone(), + created_at: record.created_at.clone(), + updated_at: record.updated_at.clone(), + wizard_step: record.wizard_step.clone(), + }; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.execute("INSERT INTO projects (id, name, description, status, working_directory, created_at, updated_at, wizard_step) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", rusqlite::params![record.id, record.name, record.description, record.status, record.working_directory, record.created_at, record.updated_at, record.wizard_step])?; + Ok(project) + }, + ) } - pub async fn finalize_draft(app: AppHandle, db: State<'_, DbState>, project_id: String) -> Result<(), ProjectError> { + pub async fn finalize_draft( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, + ) -> Result<(), ProjectError> { let project_id = required(project_id, "project_id").map_err(ProjectError::Path)?; - app_core_projects::finalize_draft(project_id, || chrono::Utc::now().to_rfc3339(), |project_id, updated_at| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute("UPDATE projects SET wizard_step = NULL, wizard_state_json = NULL, updated_at = ?1 WHERE id = ?2", rusqlite::params![updated_at, project_id])?; Ok(()) }, |project_id| { let draft_path = artifact_dir(&app, project_id)?.join("draft.json"); if draft_path.exists() { let _ = std::fs::remove_file(draft_path); } Ok(()) }) + app_core_projects::finalize_draft( + project_id, + || chrono::Utc::now().to_rfc3339(), + |project_id, updated_at| { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.execute("UPDATE projects SET wizard_step = NULL, wizard_state_json = NULL, updated_at = ?1 WHERE id = ?2", rusqlite::params![updated_at, project_id])?; + Ok(()) + }, + |project_id| { + let draft_path = artifact_dir(&app, project_id)?.join("draft.json"); + if draft_path.exists() { + let _ = std::fs::remove_file(draft_path); + } + Ok(()) + }, + ) } - pub async fn discard_draft(app: AppHandle, db: State<'_, DbState>, project_id: String) -> Result<(), ProjectError> { + pub async fn discard_draft( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, + ) -> Result<(), ProjectError> { let project_id = required(project_id, "project_id").map_err(ProjectError::Path)?; - app_core_projects::discard_draft(project_id, |project_id| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; Ok(conn.execute("DELETE FROM projects WHERE id = ?1 AND status = 'draft'", rusqlite::params![project_id])? != 0) }, |project_id| { let dir = artifact_dir(&app, project_id)?; if dir.exists() { let _ = std::fs::remove_dir_all(&dir); } Ok(()) }, ProjectError::NotFound) + app_core_projects::discard_draft( + project_id, + |project_id| { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + Ok(conn.execute( + "DELETE FROM projects WHERE id = ?1 AND status = 'draft'", + rusqlite::params![project_id], + )? != 0) + }, + |project_id| { + let dir = artifact_dir(&app, project_id)?; + if dir.exists() { + let _ = std::fs::remove_dir_all(&dir); + } + Ok(()) + }, + ProjectError::NotFound, + ) } pub async fn list_projects(db: State<'_, DbState>) -> Result { - app_core_projects::list_projects(|| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = format!("SELECT {PROJECT_COLUMNS} FROM projects ORDER BY updated_at DESC"); let mut stmt = conn.prepare(&query)?; let projects: Vec = stmt.query_map([], row_to_project)?.filter_map(|result| result.ok()).collect(); Ok(group_projects_by_status(projects)) }) + app_core_projects::list_projects(|| { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let query = format!("SELECT {PROJECT_COLUMNS} FROM projects ORDER BY updated_at DESC"); + let mut stmt = conn.prepare(&query)?; + let projects: Vec = stmt + .query_map([], row_to_project)? + .filter_map(Result::ok) + .collect(); + Ok(group_projects_by_status(projects)) + }) } - pub async fn get_project_detail(app: AppHandle, db: State<'_, DbState>, project_id: String) -> Result { + pub async fn get_project_detail( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, + ) -> Result { let project_id = required(project_id, "project_id").map_err(ProjectError::Path)?; - let detail = app_core_projects::get_project_detail(project_id, |project_id| { let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"); let mut stmt = conn.prepare(&query)?; stmt.query_row(rusqlite::params![project_id], row_to_project).map_err(|_| ProjectError::NotFound(project_id.to_string())) }, |project_id| { let prd_path = artifact_dir(&app, project_id)?.join("prd.json"); Ok(if prd_path.exists() { Prd::load(&prd_path).map(|prd| prd.stories).unwrap_or_default() } else { Vec::new() }) })?; - Ok(ProjectDetail { project: detail.project, total_stories: detail.total_stories, passed_count: detail.passed_count, blocked_count: detail.blocked_count, pending_count: detail.pending_count, stories: detail.stories }) + let detail = app_core_projects::get_project_detail( + project_id, + |project_id| { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let query = format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"); + let mut stmt = conn.prepare(&query)?; + stmt.query_row(rusqlite::params![project_id], row_to_project) + .map_err(|_| ProjectError::NotFound(project_id.to_string())) + }, + |project_id| { + let prd_path = artifact_dir(&app, project_id)?.join("prd.json"); + Ok(if prd_path.exists() { + Prd::load(&prd_path) + .map(|prd| prd.stories) + .unwrap_or_default() + } else { + Vec::new() + }) + }, + )?; + Ok(ProjectDetail { + project: detail.project, + total_stories: detail.total_stories, + passed_count: detail.passed_count, + blocked_count: detail.blocked_count, + pending_count: detail.pending_count, + stories: detail.stories, + }) } fn required(value: String, name: &str) -> Result { let trimmed = value.trim().to_string(); - if trimmed.is_empty() { return Err(format!("{name} is required")); } + if trimmed.is_empty() { + return Err(format!("{name} is required")); + } Ok(trimmed) } fn optional(value: Option) -> Option { - value.map(|value| value.trim().to_string()).filter(|value| !value.is_empty()) + value + .map(|value| value.trim().to_string()) + .filter(|value| !value.is_empty()) } } diff --git a/src-tauri/src/invoke_contract.rs b/src-tauri/src/invoke_contract.rs index b1476a3..27a027d 100644 --- a/src-tauri/src/invoke_contract.rs +++ b/src-tauri/src/invoke_contract.rs @@ -16,7 +16,8 @@ pub async fn create_project( wizard_step: Option, ) -> Result { let normalized_name = required(name, "name").map_err(ProjectError::Path)?; - let normalized_description = required(description, "description").map_err(ProjectError::Path)?; + let normalized_description = + required(description, "description").map_err(ProjectError::Path)?; let normalized_directory = required(working_directory, "working_directory").map_err(ProjectError::Path)?; crate::projects::catalog::create_project( @@ -83,9 +84,9 @@ pub async fn query_plan_status( project_id: String, ) -> Result, PlanEngineError> { let project_id = required(project_id, "project_id").map_err(PlanEngineError::Path)?; - crate::plan_engine::query_plan_status(state, project_id).await.map(|value| { - value.map(|info| serde_json::to_value(info).expect("plan session info")) - }) + crate::plan_engine::query_plan_status(state, project_id) + .await + .map(|value| value.map(|info| serde_json::to_value(info).expect("plan session info"))) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6a73880..7c02773 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,10 +3,10 @@ mod agent_profiles; mod agent_runtime; mod agent_runtime_env; mod agents; -mod ask_engine; -mod atomizer; #[path = "../../crates/loopforge-app-core/src/plan.rs"] mod app_core_plan; +mod ask_engine; +mod atomizer; mod commands; mod db; mod events; @@ -103,7 +103,10 @@ pub fn run() { .on_window_event(|window, event| { if let WindowEvent::CloseRequested { api, .. } = event { let plan_state = window.state::(); - cleanup_all_plan_sessions(&plan_state, app_core_plan::PlanCleanupReason::WindowClose); + cleanup_all_plan_sessions( + &plan_state, + app_core_plan::PlanCleanupReason::WindowClose, + ); let ask_state = window.state::(); ask_state.kill_all(); @@ -172,7 +175,10 @@ pub fn run() { let _ = db.save_loop_state(&handle.args.project_id, &args_json); } } - cleanup_all_plan_sessions(&plan_state, app_core_plan::PlanCleanupReason::RestartRecovery); + cleanup_all_plan_sessions( + &plan_state, + app_core_plan::PlanCleanupReason::RestartRecovery, + ); loop_state.shutdown_all(); } }); diff --git a/src-tauri/src/loop_manager/args.rs b/src-tauri/src/loop_manager/args.rs index 2a434ea..06ab147 100644 --- a/src-tauri/src/loop_manager/args.rs +++ b/src-tauri/src/loop_manager/args.rs @@ -26,4 +26,10 @@ pub struct StartLoopArgs { pub test_command: Option, #[serde(default)] pub max_verification_retries: Option, + #[serde(default)] + pub scm_provider: Option, + #[serde(default)] + pub review_polling_interval: Option, + #[serde(default)] + pub review_timeout: Option, } diff --git a/src-tauri/src/loop_manager/event_sink.rs b/src-tauri/src/loop_manager/event_sink.rs index 6dc471e..f277c58 100644 --- a/src-tauri/src/loop_manager/event_sink.rs +++ b/src-tauri/src/loop_manager/event_sink.rs @@ -1,3 +1,5 @@ +use crate::projects::notification_filter::should_emit_verification_failed; +use crate::projects::notifications::{create_notification_and_emit, NotificationCreateInput}; use ralph_core::events::{LoopEvent, LoopEventSink}; use tauri::{AppHandle, Emitter}; @@ -20,7 +22,11 @@ impl TauriEventSink { impl LoopEventSink for TauriEventSink { fn emit(&self, event: LoopEvent) { let (event_name, payload) = match &event { - LoopEvent::Heartbeat { elapsed_secs, total_secs, context } => ( + LoopEvent::Heartbeat { + elapsed_secs, + total_secs, + context, + } => ( crate::events::EVENT_HEARTBEAT, serde_json::json!({ "projectId": self.project_id, @@ -30,7 +36,11 @@ impl LoopEventSink for TauriEventSink { "context": context, }), ), - LoopEvent::VerificationStarted { story_id, attempt, max_attempts } => ( + LoopEvent::VerificationStarted { + story_id, + attempt, + max_attempts, + } => ( crate::events::EVENT_VERIFICATION_STARTED, serde_json::json!({ "projectId": self.project_id, @@ -40,7 +50,12 @@ impl LoopEventSink for TauriEventSink { "maxAttempts": max_attempts, }), ), - LoopEvent::VerificationFailed { story_id, attempt, error_count, circuit_breaker } => ( + LoopEvent::VerificationFailed { + story_id, + attempt, + error_count, + circuit_breaker, + } => ( crate::events::EVENT_VERIFICATION_FAILED, serde_json::json!({ "projectId": self.project_id, @@ -60,7 +75,12 @@ impl LoopEventSink for TauriEventSink { "attempt": attempt, }), ), - LoopEvent::PromptBuilt { story_id, size_bytes, hash, truncated } => ( + LoopEvent::PromptBuilt { + story_id, + size_bytes, + hash, + truncated, + } => ( crate::events::EVENT_PROMPT_BUILT, serde_json::json!({ "projectId": self.project_id, @@ -92,5 +112,47 @@ impl LoopEventSink for TauriEventSink { }; let _ = self.app.emit(event_name, payload); + match event { + LoopEvent::VerificationFailed { + story_id, + attempt, + error_count, + circuit_breaker, + } => { + if should_emit_verification_failed(attempt, circuit_breaker) { + let title = if circuit_breaker { + "Circuit breaker triggered".to_string() + } else { + "Verification failed".to_string() + }; + let message = if circuit_breaker { + format!("{story_id}: same error repeated") + } else { + format!("{story_id}: attempt {attempt} failed ({error_count} errors)") + }; + let _ = create_notification_and_emit( + &self.app, + NotificationCreateInput { + project_id: self.project_id.clone(), + notification_type: "loop_error".to_string(), + title, + message, + }, + ); + } + } + LoopEvent::StorySkipped { story_id, reason } => { + let _ = create_notification_and_emit( + &self.app, + NotificationCreateInput { + project_id: self.project_id.clone(), + notification_type: "story_blocked".to_string(), + title: "Story skipped".to_string(), + message: format!("{story_id}: {reason}"), + }, + ); + } + _ => {} + } } } diff --git a/src-tauri/src/loop_manager/helpers.rs b/src-tauri/src/loop_manager/helpers.rs index f710e3e..cc101c7 100644 --- a/src-tauri/src/loop_manager/helpers.rs +++ b/src-tauri/src/loop_manager/helpers.rs @@ -33,7 +33,7 @@ pub(super) fn build_ralph_config( config.tuning.gutter_threshold = gutter; } if let Some(cooldown) = args.cooldown_seconds { - config.tuning.cooldown_secs = cooldown as u64; + config.tuning.cooldown_secs = u64::from(cooldown); } if let Some(ref test_cmd) = args.test_command { config.tuning.test_command = Some(test_cmd.clone()); diff --git a/src-tauri/src/loop_manager/mod.rs b/src-tauri/src/loop_manager/mod.rs index e374892..a0d918a 100644 --- a/src-tauri/src/loop_manager/mod.rs +++ b/src-tauri/src/loop_manager/mod.rs @@ -4,8 +4,8 @@ mod event_sink; mod helpers; mod provider; mod start; -mod start_resolve; mod start_finalize; +mod start_resolve; mod state; mod stats; mod stop; diff --git a/src-tauri/src/loop_manager/provider/codex.rs b/src-tauri/src/loop_manager/provider/codex.rs index 24143fb..2e3d9a3 100644 --- a/src-tauri/src/loop_manager/provider/codex.rs +++ b/src-tauri/src/loop_manager/provider/codex.rs @@ -4,8 +4,8 @@ use ralph_core::providers::AgentResult; use std::io::Write; use std::path::Path; use std::process::Stdio; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tauri::Emitter; use tokio::io::{AsyncBufReadExt, BufReader}; use tokio::process::Command as TokioCommand; @@ -99,7 +99,7 @@ pub(super) async fn run_codex_process( Ok(None) => stderr_done = true, Err(err) => return Err(anyhow::anyhow!("Stderr read failed: {err}")), }, - _ = &mut sleep => {} + () = &mut sleep => {} } if stdout_done && stderr_done && child.try_wait().ok().flatten().is_some() { diff --git a/src-tauri/src/loop_manager/provider/command_args.rs b/src-tauri/src/loop_manager/provider/command_args.rs index 39c66b8..ec30d5c 100644 --- a/src-tauri/src/loop_manager/provider/command_args.rs +++ b/src-tauri/src/loop_manager/provider/command_args.rs @@ -24,13 +24,13 @@ pub(super) fn agent_cli_args( effort: Option<&str>, ) -> Vec { let selected_model = model - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); let selected_effort = effort - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); match agent { "claude" => { let mut args = vec![ diff --git a/src-tauri/src/loop_manager/provider/lifecycle.rs b/src-tauri/src/loop_manager/provider/lifecycle.rs index 50b2d29..e6b964b 100644 --- a/src-tauri/src/loop_manager/provider/lifecycle.rs +++ b/src-tauri/src/loop_manager/provider/lifecycle.rs @@ -1,16 +1,41 @@ use super::ShellProvider; +use crate::db::DbState; +use crate::loop_manager::LoopManagerState; +use crate::projects::notifications::{create_notification_and_emit, NotificationCreateInput}; use ralph_core::providers::{AgentResult, Provider}; use std::path::Path; -use std::sync::Arc; use std::sync::atomic::AtomicBool; -use tauri::Emitter; +use std::sync::Arc; +use tauri::{Emitter, Manager}; + +async fn emit_project_state_changed(provider: &ShellProvider) { + let snapshot = crate::commands::projects::get_project_snapshot( + provider.app.clone(), + provider.app.state::(), + provider.app.state::(), + provider.project_id.clone(), + ) + .await + .ok(); + let payload = if let Some(snapshot) = snapshot { + serde_json::json!({ + "projectId": provider.project_id.clone(), + "snapshot": snapshot, + }) + } else { + serde_json::json!({ "projectId": provider.project_id.clone() }) + }; + let _ = provider + .app + .emit(crate::events::EVENT_PROJECT_STATE_CHANGED, payload); +} impl Provider for ShellProvider { - fn name(&self) -> &str { + fn name(&self) -> &'static str { "shell" } - fn model(&self) -> &str { + fn model(&self) -> &'static str { "default" } @@ -27,7 +52,7 @@ impl Provider for ShellProvider { let mut counter = self .iteration_counter .lock() - .unwrap_or_else(|err| err.into_inner()); + .unwrap_or_else(std::sync::PoisonError::into_inner); *counter += 1; *counter }; @@ -68,6 +93,15 @@ impl Provider for ShellProvider { "retryAfter": result.retry_after_message, }), ); + let _ = create_notification_and_emit( + &self.app, + NotificationCreateInput { + project_id: self.project_id.clone(), + notification_type: "rate_limited".to_string(), + title: "Rate limited".to_string(), + message: format!("Agent {agent} hit rate limit."), + }, + ); match self.try_advance_fallback() { None => { @@ -115,6 +149,22 @@ impl Provider for ShellProvider { "result": fb_outcome, }), ); + let _ = self.app.emit( + crate::events::EVENT_STORIES_UPDATED, + serde_json::json!({ "projectId": self.project_id }), + ); + emit_project_state_changed(self).await; + if fb_outcome == "success" { + let _ = create_notification_and_emit( + &self.app, + NotificationCreateInput { + project_id: self.project_id.clone(), + notification_type: "story_completed".to_string(), + title: "Story completed".to_string(), + message: format!("Story {story_id} passed verification."), + }, + ); + } return Ok(fallback_result); } @@ -146,6 +196,22 @@ impl Provider for ShellProvider { "result": outcome, }), ); + let _ = self.app.emit( + crate::events::EVENT_STORIES_UPDATED, + serde_json::json!({ "projectId": self.project_id }), + ); + emit_project_state_changed(self).await; + if outcome == "success" { + let _ = create_notification_and_emit( + &self.app, + NotificationCreateInput { + project_id: self.project_id.clone(), + notification_type: "story_completed".to_string(), + title: "Story completed".to_string(), + message: format!("Story {story_id} passed verification."), + }, + ); + } Ok(result) } diff --git a/src-tauri/src/loop_manager/provider/mod.rs b/src-tauri/src/loop_manager/provider/mod.rs index 47df214..e3ddbe8 100644 --- a/src-tauri/src/loop_manager/provider/mod.rs +++ b/src-tauri/src/loop_manager/provider/mod.rs @@ -1,5 +1,5 @@ -mod command_args; mod codex; +mod command_args; mod lifecycle; mod runner; @@ -39,15 +39,14 @@ impl ShellProvider { pub(super) fn current_agent(&self) -> String { self.agent_name .lock() - .map(|guard| guard.clone()) - .unwrap_or_else(|err| err.into_inner().clone()) + .map_or_else(|err| err.into_inner().clone(), |guard| guard.clone()) } pub(super) fn try_advance_fallback(&self) -> Option { let mut index = self .fallback_index .lock() - .unwrap_or_else(|err| err.into_inner()); + .unwrap_or_else(std::sync::PoisonError::into_inner); *index += 1; let agent = self.fallback_agents.get(*index)?.clone(); if let Ok(mut name) = self.agent_name.lock() { diff --git a/src-tauri/src/loop_manager/provider/runner.rs b/src-tauri/src/loop_manager/provider/runner.rs index d5348f7..30dd4db 100644 --- a/src-tauri/src/loop_manager/provider/runner.rs +++ b/src-tauri/src/loop_manager/provider/runner.rs @@ -4,11 +4,11 @@ use super::{AgentOutputLine, ShellProvider}; use ralph_core::providers::AgentResult; use std::io::Write; use std::path::Path; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tauri::Emitter; -use tauri_plugin_shell::ShellExt; use tauri_plugin_shell::process::{CommandChild, CommandEvent as ShellCommandEvent}; +use tauri_plugin_shell::ShellExt; impl ShellProvider { pub(super) async fn run_with_agent( @@ -45,15 +45,15 @@ impl ShellProvider { .await; } - let (mut rx, proc): (tokio::sync::mpsc::Receiver, CommandChild) = - self.app - .shell() - .command(agent) - .args(&args) - .envs(env_vars) - .current_dir(work_dir) - .spawn() - .map_err(|err| anyhow::anyhow!("Spawn failed: {err}"))?; + let (mut rx, proc): (tokio::sync::mpsc::Receiver, CommandChild) = self + .app + .shell() + .command(agent) + .args(&args) + .envs(env_vars) + .current_dir(work_dir) + .spawn() + .map_err(|err| anyhow::anyhow!("Spawn failed: {err}"))?; let mut output_lines: Vec = Vec::new(); let stall_threshold = std::time::Duration::from_secs(stall_timeout_secs); diff --git a/src-tauri/src/loop_manager/provider/tests.rs b/src-tauri/src/loop_manager/provider/tests.rs index fadf14b..de6ded1 100644 --- a/src-tauri/src/loop_manager/provider/tests.rs +++ b/src-tauri/src/loop_manager/provider/tests.rs @@ -28,8 +28,13 @@ fn codex_args_include_model_and_effort_flags() { Some("gpt-5.4"), Some("high"), ); - let has_model = args.windows(2).any(|pair| pair.first().map(|v| v.as_str()) == Some("--model") && pair.get(1).map(|v| v.as_str()) == Some("gpt-5.4")); - let has_effort = args.windows(2).any(|pair| pair.first().map(|v| v.as_str()) == Some("--reasoning-effort")); + let has_model = args.windows(2).any(|pair| { + pair.first().map(|v| v.as_str()) == Some("--model") + && pair.get(1).map(|v| v.as_str()) == Some("gpt-5.4") + }); + let has_effort = args + .windows(2) + .any(|pair| pair.first().map(|v| v.as_str()) == Some("--reasoning-effort")); assert!(has_model); assert!(!has_effort, "codex should not receive --reasoning-effort"); } diff --git a/src-tauri/src/loop_manager/start.rs b/src-tauri/src/loop_manager/start.rs index 5ac5328..fe73401 100644 --- a/src-tauri/src/loop_manager/start.rs +++ b/src-tauri/src/loop_manager/start.rs @@ -1,14 +1,14 @@ use super::event_sink::TauriEventSink; use super::helpers::{artifact_dir, build_ralph_config, create_session, ensure_execution_prompt}; use super::provider::ShellProvider; -use super::start_resolve::{ResolvedStartLoop, resolve_start_loop}; use super::start_finalize::finalize_loop_run; +use super::start_resolve::{resolve_start_loop, ResolvedStartLoop}; use super::{LoopError, LoopHandle, LoopManagerState, StartLoopArgs}; use crate::db::DbState; use ralph_core::loop_engine; use std::path::PathBuf; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use tauri::{AppHandle, Emitter, Manager}; pub async fn start_loop(app: AppHandle, args: StartLoopArgs) -> Result { @@ -66,6 +66,9 @@ async fn do_start_loop( cooldown_seconds: resolved.cooldown_seconds, test_command: resolved.test_command.clone(), max_verification_retries: resolved.max_verification_retries, + scm_provider: Some(resolved.scm_provider.clone()), + review_polling_interval: Some(resolved.review_polling_interval), + review_timeout: Some(resolved.review_timeout), }; let artifacts = artifact_dir(app, &resolved.project_id)?; let gutter_threshold = resolved.gutter_threshold.unwrap_or(3); @@ -120,14 +123,12 @@ async fn do_start_loop( let session_id_clone = session_id.clone(); let working_dir_clone = resolved.working_directory.clone(); - let event_sink = TauriEventSink::new( - app.clone(), - resolved.project_id.clone(), - session_id.clone(), - ); + let event_sink = + TauriEventSink::new(app.clone(), resolved.project_id.clone(), session_id.clone()); let join_handle = tokio::spawn(async move { - let run_result = loop_engine::run(&config, &provider, shutdown_clone.clone(), &event_sink).await; + let run_result = + loop_engine::run(&config, &provider, shutdown_clone.clone(), &event_sink).await; let outcome = if shutdown_clone.load(Ordering::SeqCst) { if pause_file.exists() { "paused" @@ -157,6 +158,23 @@ async fn do_start_loop( rusqlite::params![now, resolved.project_id], ); } + let snapshot = crate::commands::projects::get_project_snapshot( + app.clone(), + app.state::(), + app.state::(), + resolved.project_id.clone(), + ) + .await + .ok(); + let payload = if let Some(snapshot) = snapshot { + serde_json::json!({ + "projectId": resolved.project_id, + "snapshot": snapshot, + }) + } else { + serde_json::json!({ "projectId": resolved.project_id }) + }; + let _ = app.emit(crate::events::EVENT_PROJECT_STATE_CHANGED, payload); Ok(( session_id, diff --git a/src-tauri/src/loop_manager/start_finalize.rs b/src-tauri/src/loop_manager/start_finalize.rs index b6b0125..72db758 100644 --- a/src-tauri/src/loop_manager/start_finalize.rs +++ b/src-tauri/src/loop_manager/start_finalize.rs @@ -1,15 +1,15 @@ -use super::helpers::{artifact_dir, close_session}; +use super::helpers::close_session; use super::state::LoopManagerState; use crate::db::DbState; -use std::path::PathBuf; +use crate::projects::notifications::{create_notification_and_emit, NotificationCreateInput}; use tauri::{AppHandle, Emitter, Manager}; pub(super) async fn finalize_loop_run( app: &AppHandle, project_id: &str, - project_name: &str, + _project_name: &str, session_id: &str, - working_directory: &str, + _working_directory: &str, outcome: &str, ) { let db_state = app.state::(); @@ -65,6 +65,15 @@ pub(super) async fn finalize_loop_run( "failed" => crate::notifications::notify_loop_error(app, project_name), _ => {} } + let _ = create_notification_and_emit( + app, + NotificationCreateInput { + project_id: project_id.to_string(), + notification_type: "loop_completed".to_string(), + title: "Loop finished".to_string(), + message: "Session completed.".to_string(), + }, + ); let _ = app.emit( crate::events::EVENT_SESSION_ENDED, @@ -74,4 +83,25 @@ pub(super) async fn finalize_loop_run( "outcome": outcome, }), ); + let _ = app.emit( + crate::events::EVENT_STORIES_UPDATED, + serde_json::json!({ "projectId": project_id }), + ); + let snapshot = crate::commands::projects::get_project_snapshot( + app.clone(), + app.state::(), + app.state::(), + project_id.to_string(), + ) + .await + .ok(); + let payload = if let Some(snapshot) = snapshot { + serde_json::json!({ + "projectId": project_id, + "snapshot": snapshot, + }) + } else { + serde_json::json!({ "projectId": project_id }) + }; + let _ = app.emit(crate::events::EVENT_PROJECT_STATE_CHANGED, payload); } diff --git a/src-tauri/src/loop_manager/start_resolve.rs b/src-tauri/src/loop_manager/start_resolve.rs index 164a102..36d93b4 100644 --- a/src-tauri/src/loop_manager/start_resolve.rs +++ b/src-tauri/src/loop_manager/start_resolve.rs @@ -17,12 +17,12 @@ pub(super) struct ResolvedStartLoop { pub(super) cooldown_seconds: Option, pub(super) test_command: Option, pub(super) max_verification_retries: Option, + pub(super) scm_provider: String, + pub(super) review_polling_interval: u64, + pub(super) review_timeout: u64, } -fn read_project_metadata( - db: &DbState, - project_id: &str, -) -> Result<(String, String), LoopError> { +fn read_project_metadata(db: &DbState, project_id: &str) -> Result<(String, String), LoopError> { let conn = db.0.lock().map_err(|_| LoopError::LockPoisoned)?; conn.query_row( "SELECT name, working_directory FROM projects WHERE id = ?1", @@ -57,6 +57,15 @@ fn legacy_config_from_args(args: &StartLoopArgs) -> ProjectConfig { if let Some(max_retries) = args.max_verification_retries { runtime_config.max_verification_retries = max_retries; } + if let Some(ref scm) = args.scm_provider { + runtime_config.scm_provider = scm.clone(); + } + if let Some(interval) = args.review_polling_interval { + runtime_config.review_polling_interval = interval; + } + if let Some(timeout) = args.review_timeout { + runtime_config.review_timeout = timeout; + } runtime_config } @@ -66,23 +75,21 @@ pub(super) async fn resolve_start_loop( args: &StartLoopArgs, ) -> Result { let (db_project_name, db_working_directory) = read_project_metadata(db, &args.project_id)?; - let runtime_config = match crate::projects::runtime_config::get_project_config( - app.clone(), - args.project_id.clone(), - ) - .await - .map_err(|err| LoopError::Db(err.to_string()))? { - Some(config) => config, - None => { - let migrated_config = legacy_config_from_args(args); - crate::projects::runtime_config::save_project_config( - app, - &args.project_id, - &migrated_config, - ) - .map_err(|err| LoopError::Db(err.to_string()))?; - migrated_config - } + let runtime_config = if let Some(config) = + crate::projects::runtime_config::get_project_config(app.clone(), args.project_id.clone()) + .await + .map_err(|err| LoopError::Db(err.to_string()))? + { + config + } else { + let migrated_config = legacy_config_from_args(args); + crate::projects::runtime_config::save_project_config( + app, + &args.project_id, + &migrated_config, + ) + .map_err(|err| LoopError::Db(err.to_string()))?; + migrated_config }; let project_name = db_project_name; let working_directory = db_working_directory; @@ -110,5 +117,8 @@ pub(super) async fn resolve_start_loop( cooldown_seconds: Some(runtime_config.cooldown_seconds), test_command, max_verification_retries: Some(runtime_config.max_verification_retries), + scm_provider: runtime_config.scm_provider.clone(), + review_polling_interval: runtime_config.review_polling_interval, + review_timeout: runtime_config.review_timeout, }) } diff --git a/src-tauri/src/loop_manager/stats.rs b/src-tauri/src/loop_manager/stats.rs index 35c0073..ffabf0e 100644 --- a/src-tauri/src/loop_manager/stats.rs +++ b/src-tauri/src/loop_manager/stats.rs @@ -67,12 +67,12 @@ pub async fn session_stats( ) .unwrap_or(0); let rate_limited: i64 = conn - .query_row( - "SELECT COUNT(*) FROM iterations WHERE session_id = ?1 AND result = 'rate_limited'", - rusqlite::params![sid], - |row| row.get(0), - ) - .unwrap_or(0); + .query_row( + "SELECT COUNT(*) FROM iterations WHERE session_id = ?1 AND result = 'rate_limited'", + rusqlite::params![sid], + |row| row.get(0), + ) + .unwrap_or(0); (total, success, total - success - rate_limited, rate_limited) } else { (0, 0, 0, 0) @@ -115,17 +115,16 @@ pub async fn session_stats( let stories_per_hour = session_started_at .as_deref() .and_then(|started| chrono::DateTime::parse_from_rfc3339(started).ok()) - .map(|started| { - let elapsed_hours = - (chrono::Utc::now() - started.with_timezone(&chrono::Utc)).num_minutes() as f64 - / 60.0; + .map_or(0.0, |started| { + let elapsed_hours = (chrono::Utc::now() - started.with_timezone(&chrono::Utc)) + .num_minutes() as f64 + / 60.0; if elapsed_hours > 0.0 { passed_stories as f64 / elapsed_hours } else { 0.0 } - }) - .unwrap_or(0.0); + }); Ok(SessionStats { project_id, diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index ad5fe83..69c3a72 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -2,5 +2,5 @@ #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] fn main() { - app_lib::run(); + app_lib::run(); } diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index a1de595..55afef1 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -114,6 +114,10 @@ pub struct ProjectSnapshot { pub config: Option, #[serde(default)] pub artifact_paths: ArtifactPaths, + #[serde(default)] + pub progress_percent: u32, + #[serde(default)] + pub uptime_label: String, } #[allow(dead_code)] diff --git a/src-tauri/src/plan_engine/args.rs b/src-tauri/src/plan_engine/args.rs index 7b60e8f..0f0c183 100644 --- a/src-tauri/src/plan_engine/args.rs +++ b/src-tauri/src/plan_engine/args.rs @@ -8,13 +8,13 @@ pub(super) fn build_plan_args( effort: Option<&str>, ) -> Vec { let selected_model = model - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); let selected_effort = effort - .map(|value| value.trim()) + .map(str::trim) .filter(|value| !value.is_empty()) - .map(|value| value.to_string()); + .map(ToString::to_string); match agent { "claude" => { let mut args = vec![ @@ -84,9 +84,6 @@ pub(super) fn build_plan_args( vec![ "agent".to_string(), "--print".to_string(), - "--mode".to_string(), - "plan".to_string(), - "--force".to_string(), "--output-format".to_string(), "text".to_string(), "--model".to_string(), diff --git a/src-tauri/src/plan_engine/filters.rs b/src-tauri/src/plan_engine/filters.rs index 2d18299..fcd6b76 100644 --- a/src-tauri/src/plan_engine/filters.rs +++ b/src-tauri/src/plan_engine/filters.rs @@ -1,5 +1,5 @@ -use crate::plan_engine::payloads::{PlanActivityBatchPayload, PlanActivityPayload}; use crate::activity::PlanEventKind; +use crate::plan_engine::payloads::{PlanActivityBatchPayload, PlanActivityPayload}; use std::collections::HashSet; use std::sync::OnceLock; @@ -47,7 +47,9 @@ fn is_noise_line(content: &str) -> bool { if exact_noise_set().contains(trimmed) { return true; } - PREFIX_NOISE.iter().any(|prefix| trimmed.starts_with(prefix)) + PREFIX_NOISE + .iter() + .any(|prefix| trimmed.starts_with(prefix)) } fn normalize_line(content: &str) -> String { @@ -87,10 +89,16 @@ pub fn filter_plan_batch(batch: PlanActivityBatchPayload) -> PlanActivityBatchPa .map(|evt| evt.content.as_str()) .collect::>() .join("\n"); + let plan_content = if batch.plan_content.trim().is_empty() { + plan_content_delta.clone() + } else { + batch.plan_content + }; PlanActivityBatchPayload { project_id: batch.project_id, events: filtered_events, + plan_content, plan_content_delta, } } @@ -117,6 +125,7 @@ mod tests { make_event("Real plan content"), make_event("OpenAI Codex v1.0"), ], + plan_content: String::new(), plan_content_delta: String::new(), }; let filtered = filter_plan_batch(batch); @@ -129,6 +138,7 @@ mod tests { let batch = PlanActivityBatchPayload { project_id: "test".to_string(), events: vec![make_event("codex Some plan output")], + plan_content: String::new(), plan_content_delta: String::new(), }; let filtered = filter_plan_batch(batch); @@ -144,6 +154,7 @@ mod tests { make_event("Same line"), make_event("Different line"), ], + plan_content: String::new(), plan_content_delta: String::new(), }; let filtered = filter_plan_batch(batch); diff --git a/src-tauri/src/plan_engine/fixture.rs b/src-tauri/src/plan_engine/fixture.rs index 22f8db8..a820e22 100644 --- a/src-tauri/src/plan_engine/fixture.rs +++ b/src-tauri/src/plan_engine/fixture.rs @@ -66,7 +66,11 @@ pub(super) async fn start_fixture_plan( plan_bytes: plan_markdown.len(), }); } - let final_content = if plan_markdown.is_empty() { None } else { Some(plan_markdown) }; + let final_content = if plan_markdown.is_empty() { + None + } else { + Some(plan_markdown) + }; let _ = app_handle.emit( EVENT_PLAN_COMPLETE, PlanTerminalPayload { diff --git a/src-tauri/src/plan_engine/helpers.rs b/src-tauri/src/plan_engine/helpers.rs index c6e365a..7fb6a86 100644 --- a/src-tauri/src/plan_engine/helpers.rs +++ b/src-tauri/src/plan_engine/helpers.rs @@ -57,7 +57,8 @@ pub(super) fn build_plan_prompt(user_description: &str) -> String { "You are a senior software architect. Research the codebase and create a detailed \ implementation plan for the following feature request.\n\n\ Think through the problem carefully. Identify affected files, dependencies, and edge cases.\n\ -Output a structured plan in markdown.\n\n\ +Output a structured plan in markdown with clear headings (use # headers).\n\ +Do not ask clarifying questions. Make reasonable assumptions and document them in the plan.\n\n\ Feature request:\n{user_description}" ) } diff --git a/src-tauri/src/plan_engine/mod.rs b/src-tauri/src/plan_engine/mod.rs index f0e2223..e162786 100644 --- a/src-tauri/src/plan_engine/mod.rs +++ b/src-tauri/src/plan_engine/mod.rs @@ -20,7 +20,7 @@ mod tests_fixture_happy; mod tests_fixture_support; pub use errors::PlanEngineError; -pub use sessions::{PlanSessionInfo, PlanSessionsState, StartPlanArgs}; +pub use sessions::{PlanSessionInfo, PlanSessionStatus, PlanSessionsState, StartPlanArgs}; pub use start::start_plan; pub use status::query_plan_status; pub use write::{stop_plan, write_to_plan}; diff --git a/src-tauri/src/plan_engine/monitor.rs b/src-tauri/src/plan_engine/monitor.rs index 54b05b1..035f744 100644 --- a/src-tauri/src/plan_engine/monitor.rs +++ b/src-tauri/src/plan_engine/monitor.rs @@ -33,6 +33,7 @@ pub(super) fn spawn_plan_flush_task( pub(super) fn spawn_batch_flush_task( buffer: Arc>>, + classifier: Arc>, app: AppHandle, project_id: String, ) -> JoinHandle<()> { @@ -41,7 +42,7 @@ pub(super) fn spawn_batch_flush_task( interval.tick().await; loop { interval.tick().await; - flush_event_buffer(&buffer, &app, &project_id); + flush_event_buffer(&buffer, &classifier, &app, &project_id); } }) } @@ -131,7 +132,7 @@ pub(super) fn handle_termination( exit_code: i32, tracer: &Option, ) { - flush_event_buffer(event_buffer, app, project_id); + flush_event_buffer(event_buffer, classifier, app, project_id); if let Ok(guard) = classifier.lock() { let plan_content = guard.accumulated_plan(); @@ -182,7 +183,11 @@ pub(super) fn handle_termination( if let Some(tracer) = tracer { tracer.log(TraceEvent::PlanComplete { plan_bytes }); } - let final_content = if accumulated.is_empty() { None } else { Some(accumulated) }; + let final_content = if accumulated.is_empty() { + None + } else { + Some(accumulated) + }; let _ = app.emit( crate::events::EVENT_PLAN_COMPLETE, PlanTerminalPayload { diff --git a/src-tauri/src/plan_engine/output.rs b/src-tauri/src/plan_engine/output.rs index 2ff37c8..2ddb464 100644 --- a/src-tauri/src/plan_engine/output.rs +++ b/src-tauri/src/plan_engine/output.rs @@ -35,6 +35,7 @@ pub(super) fn buffer_text_segments( pub(super) fn flush_event_buffer( buffer: &Arc>>, + classifier: &Arc>, app: &AppHandle, project_id: &str, ) { @@ -54,17 +55,19 @@ pub(super) fn flush_event_buffer( .map(|evt| evt.content.as_str()) .collect::>() .join("\n"); + let plan_content = classifier + .lock() + .map(|guard| guard.accumulated_plan()) + .unwrap_or_default(); let raw_batch = PlanActivityBatchPayload { project_id: project_id.to_string(), events, + plan_content, plan_content_delta, }; let filtered_batch = crate::plan_engine::filters::filter_plan_batch(raw_batch); - if filtered_batch.events.is_empty() && filtered_batch.plan_content_delta.is_empty() { + if filtered_batch.events.is_empty() && filtered_batch.plan_content.is_empty() { return; } - let _ = app.emit( - crate::events::EVENT_PLAN_ACTIVITY_BATCH, - filtered_batch, - ); + let _ = app.emit(crate::events::EVENT_PLAN_ACTIVITY_BATCH, filtered_batch); } diff --git a/src-tauri/src/plan_engine/payloads.rs b/src-tauri/src/plan_engine/payloads.rs index 50adf23..8b0ce23 100644 --- a/src-tauri/src/plan_engine/payloads.rs +++ b/src-tauri/src/plan_engine/payloads.rs @@ -20,6 +20,8 @@ pub struct PlanActivityPayload { pub struct PlanActivityBatchPayload { pub project_id: String, pub events: Vec, + pub plan_content: String, + #[serde(default)] pub plan_content_delta: String, } diff --git a/src-tauri/src/plan_engine/start.rs b/src-tauri/src/plan_engine/start.rs index 773235a..3d3e6bd 100644 --- a/src-tauri/src/plan_engine/start.rs +++ b/src-tauri/src/plan_engine/start.rs @@ -1,6 +1,8 @@ use crate::activity::ActivityClassifier; use crate::app_core_plan::{self, PlanCleanupReason}; -use crate::plan_engine::args::{agent_env_vars, build_null_stdin_command, build_plan_args, needs_null_stdin}; +use crate::plan_engine::args::{ + agent_env_vars, build_null_stdin_command, build_plan_args, needs_null_stdin, +}; use crate::plan_engine::fixture; use crate::plan_engine::helpers::{artifact_dir, build_plan_prompt, resolve_agent_binary}; use crate::plan_engine::payloads::{PlanActivityPayload, PlanTerminalPayload}; @@ -15,61 +17,167 @@ use tauri_plugin_shell::process::CommandEvent; use tauri_plugin_shell::ShellExt; use tokio::task::AbortHandle; -fn flush_partial_plan(buffer: &Arc>>, classifier: &Arc>, plan_path: &PathBuf, app: &AppHandle, project_id: &str) -> (usize, String) { - output::flush_event_buffer(buffer, app, project_id); - let plan_content = classifier.lock().map(|guard| guard.accumulated_plan()).unwrap_or_default(); - if plan_content.is_empty() { return (0, String::new()); } +fn flush_partial_plan( + buffer: &Arc>>, + classifier: &Arc>, + plan_path: &PathBuf, + app: &AppHandle, + project_id: &str, +) -> (usize, String) { + output::flush_event_buffer(buffer, classifier, app, project_id); + let plan_content = classifier + .lock() + .map(|guard| guard.accumulated_plan()) + .unwrap_or_default(); + if plan_content.is_empty() { + return (0, String::new()); + } let plan_bytes = plan_content.len(); let _ = std::fs::write(plan_path, &plan_content); (plan_bytes, plan_content) } -fn emit_terminal(app: &AppHandle, project_id: &str, exit_code: i32, plan_bytes: usize, plan_content: Option, tracer: &Option) { +fn emit_terminal( + app: &AppHandle, + project_id: &str, + exit_code: i32, + plan_bytes: usize, + plan_content: Option, + tracer: &Option, +) { if exit_code != 0 { - if let Some(tracer) = tracer { tracer.log(TraceEvent::ErrorEmitted { detail: format!("exit_code={exit_code}") }); } - let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: format!("exit_code={exit_code}"), final_content: None }); + if let Some(tracer) = tracer { + tracer.log(TraceEvent::ErrorEmitted { + detail: format!("exit_code={exit_code}"), + }); + } + let _ = app.emit( + crate::events::EVENT_PLAN_ERROR, + PlanTerminalPayload { + project_id: project_id.to_string(), + detail: format!("exit_code={exit_code}"), + final_content: None, + }, + ); return; } if plan_bytes == 0 { - if let Some(tracer) = tracer { tracer.log(TraceEvent::ErrorEmitted { detail: "empty_output".to_string() }); } - let _ = app.emit(crate::events::EVENT_PLAN_ERROR, PlanTerminalPayload { project_id: project_id.to_string(), detail: "empty_output".to_string(), final_content: None }); + if let Some(tracer) = tracer { + tracer.log(TraceEvent::ErrorEmitted { + detail: "empty_output".to_string(), + }); + } + let _ = app.emit( + crate::events::EVENT_PLAN_ERROR, + PlanTerminalPayload { + project_id: project_id.to_string(), + detail: "empty_output".to_string(), + final_content: None, + }, + ); return; } - if let Some(tracer) = tracer { tracer.log(TraceEvent::PlanComplete { plan_bytes }); } - let _ = app.emit(crate::events::EVENT_PLAN_COMPLETE, PlanTerminalPayload { project_id: project_id.to_string(), detail: String::new(), final_content: plan_content }); + if let Some(tracer) = tracer { + tracer.log(TraceEvent::PlanComplete { plan_bytes }); + } + let _ = app.emit( + crate::events::EVENT_PLAN_COMPLETE, + PlanTerminalPayload { + project_id: project_id.to_string(), + detail: String::new(), + final_content: plan_content, + }, + ); } -fn cleanup_hook(app: AppHandle, project_id: String, buffer: Arc>>, classifier: Arc>, plan_path: PathBuf, tracer: Option, plan_abort: AbortHandle, batch_abort: AbortHandle, heartbeat_abort: AbortHandle) -> Arc { +fn cleanup_hook( + app: AppHandle, + project_id: String, + buffer: Arc>>, + classifier: Arc>, + plan_path: PathBuf, + tracer: Option, + plan_abort: AbortHandle, + batch_abort: AbortHandle, + heartbeat_abort: AbortHandle, +) -> Arc { Arc::new(move |reason| { batch_abort.abort(); plan_abort.abort(); heartbeat_abort.abort(); - let (plan_bytes, plan_content) = flush_partial_plan(&buffer, &classifier, &plan_path, &app, &project_id); + let (plan_bytes, plan_content) = + flush_partial_plan(&buffer, &classifier, &plan_path, &app, &project_id); if let PlanCleanupReason::ProcessExit { exit_code } = reason { if let Some(ref tracer) = tracer { - tracer.log(TraceEvent::ProcessTerminated { exit_code, has_plan_content: plan_bytes != 0 }); + tracer.log(TraceEvent::ProcessTerminated { + exit_code, + has_plan_content: plan_bytes != 0, + }); } - let content_opt = if plan_content.is_empty() { None } else { Some(plan_content) }; - emit_terminal(&app, &project_id, exit_code, plan_bytes, content_opt, &tracer); + let content_opt = if plan_content.is_empty() { + None + } else { + Some(plan_content) + }; + emit_terminal( + &app, + &project_id, + exit_code, + plan_bytes, + content_opt, + &tracer, + ); } }) } -fn record_output(stream: &'static str, bytes: &[u8], tracer: &Option, classifier: &Arc>, event_buffer: &Arc>>, project_id: &str, last_activity: &Arc>) { +fn record_output( + stream: &'static str, + bytes: &[u8], + tracer: &Option, + classifier: &Arc>, + event_buffer: &Arc>>, + project_id: &str, + last_activity: &Arc>, +) { let text = String::from_utf8_lossy(bytes); if let Some(ref tracer) = tracer { let lines: Vec<&str> = text.lines().collect(); - tracer.log(TraceEvent::Output { stream, byte_len: bytes.len(), line_count: lines.len(), first_line_preview: lines.first().map(|line| line.chars().take(120).collect()).unwrap_or_default() }); + tracer.log(TraceEvent::Output { + stream, + byte_len: bytes.len(), + line_count: lines.len(), + first_line_preview: lines + .first() + .map(|line| line.chars().take(120).collect()) + .unwrap_or_default(), + }); } output::buffer_text_segments(&text, classifier, event_buffer, project_id, last_activity); } -pub async fn start_plan(app: AppHandle, state: State<'_, PlanSessionsState>, args: StartPlanArgs) -> Result<(), PlanEngineError> { - if !args.project_dir.exists() { return Err(PlanEngineError::Path(format!("Working directory not found: {}", args.project_dir.display()))); } - if !args.project_dir.is_dir() { return Err(PlanEngineError::Path(format!("Working directory is not a directory: {}", args.project_dir.display()))); } +pub async fn start_plan( + app: AppHandle, + state: State<'_, PlanSessionsState>, + args: StartPlanArgs, +) -> Result<(), PlanEngineError> { + if !args.project_dir.exists() { + return Err(PlanEngineError::Path(format!( + "Working directory not found: {}", + args.project_dir.display() + ))); + } + if !args.project_dir.is_dir() { + return Err(PlanEngineError::Path(format!( + "Working directory is not a directory: {}", + args.project_dir.display() + ))); + } { let sessions = state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?; - if sessions.sessions.contains_key(&args.project_id) { return Err(PlanEngineError::AlreadyRunning(args.project_id)); } + if sessions.sessions.contains_key(&args.project_id) { + return Err(PlanEngineError::AlreadyRunning(args.project_id)); + } } if let Some(runtime) = fixture::resolve_test_runtime()? { return fixture::start_fixture_plan(app, state.inner().clone(), args, runtime).await; @@ -77,7 +185,13 @@ pub async fn start_plan(app: AppHandle, state: State<'_, PlanSess let agent_binary = resolve_agent_binary(&app, &args.agent).await?; let plan_prompt = build_plan_prompt(&args.initial_prompt); - let agent_args = build_plan_args(&args.agent, &plan_prompt, args.project_dir.as_path(), args.model.as_deref(), args.effort.as_deref()); + let agent_args = build_plan_args( + &args.agent, + &plan_prompt, + args.project_dir.as_path(), + args.model.as_deref(), + args.effort.as_deref(), + ); let env_vars = agent_env_vars(&args.agent); let artifacts = artifact_dir(&app, &args.project_id)?; std::fs::create_dir_all(&artifacts)?; @@ -94,9 +208,21 @@ pub async fn start_plan(app: AppHandle, state: State<'_, PlanSess } let (mut event_rx, child) = if use_null_stdin { let wrapped = build_null_stdin_command(&agent_binary, &agent_args); - app.shell().command("/bin/zsh").args(["-lc", &wrapped]).envs(env_vars).current_dir(&args.project_dir).spawn().map_err(|err| PlanEngineError::Shell(err.to_string()))? + app.shell() + .command("/bin/zsh") + .args(["-lc", &wrapped]) + .envs(env_vars) + .current_dir(&args.project_dir) + .spawn() + .map_err(|err| PlanEngineError::Shell(err.to_string()))? } else { - app.shell().command(&agent_binary).args(agent_args).envs(env_vars).current_dir(&args.project_dir).spawn().map_err(|err| PlanEngineError::Shell(err.to_string()))? + app.shell() + .command(&agent_binary) + .args(agent_args) + .envs(env_vars) + .current_dir(&args.project_dir) + .spawn() + .map_err(|err| PlanEngineError::Shell(err.to_string()))? }; let now = Instant::now(); @@ -104,29 +230,88 @@ pub async fn start_plan(app: AppHandle, state: State<'_, PlanSess let classifier = Arc::new(Mutex::new(ActivityClassifier::new(&args.agent))); let event_buffer = Arc::new(Mutex::new(Vec::new())); let plan_path = artifacts.join("plan.md"); - let plan_abort = monitor::spawn_plan_flush_task(Arc::clone(&classifier), plan_path.clone()).abort_handle(); - let batch_abort = monitor::spawn_batch_flush_task(Arc::clone(&event_buffer), app.clone(), args.project_id.clone()).abort_handle(); - let heartbeat_abort = monitor::spawn_heartbeat_task(app.clone(), Arc::clone(&last_activity), Arc::clone(&state.0), args.project_id.clone(), args.agent.clone(), now, tracer.clone()).abort_handle(); - app_core_plan::register_cleanup(&args.project_id, cleanup_hook(app.clone(), args.project_id.clone(), Arc::clone(&event_buffer), Arc::clone(&classifier), plan_path, tracer.clone(), plan_abort, batch_abort, heartbeat_abort)); - - state.0.lock().map_err(|_| PlanEngineError::LockPoisoned)?.sessions.insert( + let plan_abort = + monitor::spawn_plan_flush_task(Arc::clone(&classifier), plan_path.clone()).abort_handle(); + let batch_abort = monitor::spawn_batch_flush_task( + Arc::clone(&event_buffer), + Arc::clone(&classifier), + app.clone(), args.project_id.clone(), - PlanSessionEntry { handle: PlanSessionHandle::Shell(child), status: PlanSessionStatus::Running, agent_name: args.agent.clone(), started_at: now, last_activity_at: Arc::clone(&last_activity) }, + ) + .abort_handle(); + let heartbeat_abort = monitor::spawn_heartbeat_task( + app.clone(), + Arc::clone(&last_activity), + Arc::clone(&state.0), + args.project_id.clone(), + args.agent.clone(), + now, + tracer.clone(), + ) + .abort_handle(); + app_core_plan::register_cleanup( + &args.project_id, + cleanup_hook( + app.clone(), + args.project_id.clone(), + Arc::clone(&event_buffer), + Arc::clone(&classifier), + plan_path, + tracer.clone(), + plan_abort, + batch_abort, + heartbeat_abort, + ), ); + state + .0 + .lock() + .map_err(|_| PlanEngineError::LockPoisoned)? + .sessions + .insert( + args.project_id.clone(), + PlanSessionEntry { + handle: PlanSessionHandle::Shell(child), + status: PlanSessionStatus::Running, + agent_name: args.agent.clone(), + started_at: now, + last_activity_at: Arc::clone(&last_activity), + }, + ); + let project_id = args.project_id.clone(); let sessions = Arc::clone(&state.0); tokio::spawn(async move { while let Some(event) = event_rx.recv().await { match event { - CommandEvent::Stdout(ref bytes) => record_output("stdout", bytes, &tracer, &classifier, &event_buffer, &project_id, &last_activity), - CommandEvent::Stderr(ref bytes) => record_output("stderr", bytes, &tracer, &classifier, &event_buffer, &project_id, &last_activity), + CommandEvent::Stdout(ref bytes) => record_output( + "stdout", + bytes, + &tracer, + &classifier, + &event_buffer, + &project_id, + &last_activity, + ), + CommandEvent::Stderr(ref bytes) => record_output( + "stderr", + bytes, + &tracer, + &classifier, + &event_buffer, + &project_id, + &last_activity, + ), CommandEvent::Terminated(payload) => { let _: Result = app_core_plan::cleanup_session( &project_id, - PlanCleanupReason::ProcessExit { exit_code: payload.code.unwrap_or(1) }, + PlanCleanupReason::ProcessExit { + exit_code: payload.code.unwrap_or(1), + }, |project_id| { - let mut guard = sessions.lock().map_err(|_| PlanEngineError::LockPoisoned)?; + let mut guard = + sessions.lock().map_err(|_| PlanEngineError::LockPoisoned)?; Ok(guard.sessions.remove(project_id)) }, |_| Ok(()), diff --git a/src-tauri/src/plan_engine/write.rs b/src-tauri/src/plan_engine/write.rs index e147a0a..79cbdba 100644 --- a/src-tauri/src/plan_engine/write.rs +++ b/src-tauri/src/plan_engine/write.rs @@ -18,7 +18,7 @@ pub async fn write_to_plan( entry .handle .write(&payload) - .map_err(|err| PlanEngineError::Shell(err.to_string()))?; + .map_err(|err| PlanEngineError::Shell(err.clone()))?; if let Ok(mut last_activity) = entry.last_activity_at.lock() { *last_activity = Instant::now(); } @@ -35,7 +35,7 @@ pub async fn stop_plan( entry .handle .kill() - .map_err(|err| PlanEngineError::Shell(err.to_string()))?; + .map_err(|err| PlanEngineError::Shell(err.clone()))?; } Ok(()) } diff --git a/src-tauri/src/plugin_registry.rs b/src-tauri/src/plugin_registry.rs index 6b8ff31..b6999c4 100644 --- a/src-tauri/src/plugin_registry.rs +++ b/src-tauri/src/plugin_registry.rs @@ -76,9 +76,7 @@ fn builtin_plugins() -> Vec { } #[tauri::command] -pub async fn list_plugins( - db: State<'_, DbState>, -) -> Result, String> { +pub async fn list_plugins(db: State<'_, DbState>) -> Result, String> { let mut entries = builtin_plugins(); let conn = db.0.lock().map_err(|_| "Lock poisoned".to_string())?; diff --git a/src-tauri/src/projects/artifacts.rs b/src-tauri/src/projects/artifacts.rs index 06cc175..42729e9 100644 --- a/src-tauri/src/projects/artifacts.rs +++ b/src-tauri/src/projects/artifacts.rs @@ -3,7 +3,10 @@ use crate::projects::ProjectError; use std::path::{Path, PathBuf}; use tauri::{AppHandle, Runtime, State}; -pub fn artifact_dir(app: &AppHandle, project_id: &str) -> Result { +pub fn artifact_dir( + app: &AppHandle, + project_id: &str, +) -> Result { crate::storage::artifacts::project_artifact_dir(app, project_id).map_err(ProjectError::Path) } diff --git a/src-tauri/src/projects/catalog.rs b/src-tauri/src/projects/catalog.rs index a81c25d..149f069 100644 --- a/src-tauri/src/projects/catalog.rs +++ b/src-tauri/src/projects/catalog.rs @@ -20,10 +20,9 @@ pub async fn create_project( let dir = artifact_dir(&app, &project_id)?; init_artifacts(&dir, &name)?; - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute( "INSERT INTO projects (id, name, description, status, working_directory, created_at, updated_at, wizard_step) VALUES (?1, ?2, ?3, 'draft', ?4, ?5, ?6, ?7)", @@ -43,17 +42,16 @@ pub async fn create_project( } pub async fn list_projects(db: State<'_, DbState>) -> Result { - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = format!("SELECT {PROJECT_COLUMNS} FROM projects ORDER BY updated_at DESC"); let mut stmt = conn.prepare(&query)?; let projects: Vec = stmt .query_map([], row_to_project)? - .filter_map(|result| result.ok()) + .filter_map(Result::ok) .collect(); Ok(group_projects_by_status(projects)) @@ -64,10 +62,9 @@ pub async fn archive_project( project_id: String, ) -> Result<(), ProjectError> { let now = chrono::Utc::now().to_rfc3339(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let updated = conn.execute( "UPDATE projects SET status = 'archived', updated_at = ?1 WHERE id = ?2", rusqlite::params![now, project_id], @@ -84,10 +81,9 @@ pub async fn get_project_detail( project_id: String, ) -> Result { let project = { - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let query = format!("SELECT {PROJECT_COLUMNS} FROM projects WHERE id = ?1"); let mut stmt = conn.prepare(&query)?; stmt.query_row(rusqlite::params![project_id], row_to_project) @@ -98,7 +94,9 @@ pub async fn get_project_detail( let prd_path = dir.join("prd.json"); let stories = if prd_path.exists() { - Prd::load(&prd_path).map(|prd| prd.stories).unwrap_or_default() + Prd::load(&prd_path) + .map(|prd| prd.stories) + .unwrap_or_default() } else { Vec::new() }; diff --git a/src-tauri/src/projects/control.rs b/src-tauri/src/projects/control.rs index 760ec90..14d5d75 100644 --- a/src-tauri/src/projects/control.rs +++ b/src-tauri/src/projects/control.rs @@ -1,7 +1,7 @@ use crate::projects::artifacts::artifact_dir; use crate::projects::ProjectError; use std::sync::atomic::Ordering; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; const TRANSITION_WAIT_STEPS: usize = 24; const TRANSITION_WAIT_MS: u64 = 250; @@ -18,6 +18,26 @@ fn loop_handle_state(app: &AppHandle, project_id: &str) -> (bool, bool) { (false, false) } +async fn emit_project_state_changed(app: &AppHandle, project_id: &str) { + let snapshot = crate::commands::projects::get_project_snapshot( + app.clone(), + app.state::(), + app.state::(), + project_id.to_string(), + ) + .await + .ok(); + let payload = if let Some(snapshot) = snapshot { + serde_json::json!({ + "projectId": project_id, + "snapshot": snapshot, + }) + } else { + serde_json::json!({ "projectId": project_id }) + }; + let _ = app.emit(crate::events::EVENT_PROJECT_STATE_CHANGED, payload); +} + async fn wait_for_shutdown_transition(app: &AppHandle, project_id: &str) { for _ in 0..TRANSITION_WAIT_STEPS { let (has_handle, shutdown_requested) = loop_handle_state(app, project_id); @@ -45,27 +65,28 @@ pub async fn pause_project(app: AppHandle, project_id: String) -> Result<(), Pro let db = app.state::(); let now = chrono::Utc::now().to_rfc3339(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; - let updated = conn.execute( - "UPDATE projects SET status = 'paused', updated_at = ?1 WHERE id = ?2", - rusqlite::params![now, project_id], - )?; + let updated = { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + conn.execute( + "UPDATE projects SET status = 'paused', updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, &project_id], + )? + }; if updated == 0 { return Err(ProjectError::NotFound(project_id)); } + emit_project_state_changed(&app, &project_id).await; Ok(()) } pub async fn resume_project(app: AppHandle, project_id: String) -> Result<(), ProjectError> { { let db = app.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let exists = conn .query_row( "SELECT id FROM projects WHERE id = ?1", @@ -91,14 +112,16 @@ pub async fn resume_project(app: AppHandle, project_id: String) -> Result<(), Pr if has_loop_handle && still_has_loop_handle { let now = chrono::Utc::now().to_rfc3339(); let db = app.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; - let _ = conn.execute( - "UPDATE projects SET status = 'active', updated_at = ?1 WHERE id = ?2", - rusqlite::params![now, &project_id], - ); + { + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let _ = conn.execute( + "UPDATE projects SET status = 'active', updated_at = ?1 WHERE id = ?2", + rusqlite::params![now, &project_id], + ); + } + emit_project_state_changed(&app, &project_id).await; return Ok(()); } let restart_args = crate::loop_manager::StartLoopArgs { @@ -114,6 +137,9 @@ pub async fn resume_project(app: AppHandle, project_id: String) -> Result<(), Pr cooldown_seconds: None, test_command: None, max_verification_retries: None, + scm_provider: None, + review_polling_interval: None, + review_timeout: None, }; crate::loop_manager::start_loop(app, restart_args) .await diff --git a/src-tauri/src/projects/core_types.rs b/src-tauri/src/projects/core_types.rs index e64b963..48b462e 100644 --- a/src-tauri/src/projects/core_types.rs +++ b/src-tauri/src/projects/core_types.rs @@ -77,6 +77,40 @@ pub struct WizardResumeState { pub has_prd: bool, } +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WizardProjectData { + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub working_directory: String, + #[serde(default = "default_plan_agent")] + pub plan_agent: String, + #[serde(default)] + pub plan_model: Option, + #[serde(default)] + pub plan_effort: Option, +} + +fn default_plan_agent() -> String { + "claude".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WizardHydrationResult { + pub project: Project, + pub wizard_step: String, + #[serde(default)] + pub highest_step: u32, + pub project_data: WizardProjectData, + pub plan_complete: bool, + pub stories: Vec, + pub config: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct IterationStory { @@ -84,5 +118,7 @@ pub struct IterationStory { pub title: String, pub status: String, pub duration_secs: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub duration_label: Option, pub attempts: i64, } diff --git a/src-tauri/src/projects/documents.rs b/src-tauri/src/projects/documents.rs index fec4873..f542bec 100644 --- a/src-tauri/src/projects/documents.rs +++ b/src-tauri/src/projects/documents.rs @@ -30,13 +30,21 @@ pub async fn load_existing_plan( Ok(None) } -pub async fn save_plan(app: AppHandle, project_id: String, content: String) -> Result<(), ProjectError> { +pub async fn save_plan( + app: AppHandle, + project_id: String, + content: String, +) -> Result<(), ProjectError> { let artifacts = artifact_dir(&app, &project_id)?; std::fs::create_dir_all(&artifacts)?; std::fs::write(artifacts.join("plan.md"), &content)?; Ok(()) } -pub async fn save_prd(app: AppHandle, project_id: String, prd_json: String) -> Result<(), ProjectError> { +pub async fn save_prd( + app: AppHandle, + project_id: String, + prd_json: String, +) -> Result<(), ProjectError> { let artifacts = artifact_dir(&app, &project_id)?; std::fs::create_dir_all(&artifacts)?; serde_json::from_str::(&prd_json)?; @@ -46,12 +54,19 @@ pub async fn save_prd(app: AppHandle, project_id: String, prd_jso })?; Ok(()) } -pub async fn save_config(app: AppHandle, project_id: String, config_json: String) -> Result<(), ProjectError> { +pub async fn save_config( + app: AppHandle, + project_id: String, + config_json: String, +) -> Result<(), ProjectError> { let parsed_config = serde_json::from_str::(&config_json)?; crate::projects::runtime_config::save_project_config(&app, &project_id, &parsed_config)?; Ok(()) } -pub async fn load_config(app: AppHandle, project_id: String) -> Result, ProjectError> { +pub async fn load_config( + app: AppHandle, + project_id: String, +) -> Result, ProjectError> { let artifacts = artifact_dir(&app, &project_id)?; let config_path = artifacts.join("config.json"); non_empty_file_content(&config_path) @@ -81,7 +96,10 @@ pub async fn load_existing_prd( Ok(None) } -pub async fn get_guardrails(app: AppHandle, project_id: String) -> Result { +pub async fn get_guardrails( + app: AppHandle, + project_id: String, +) -> Result { let dir = artifact_dir(&app, &project_id)?; let guardrails_path = dir.join("guardrails.md"); if guardrails_path.exists() { @@ -90,7 +108,10 @@ pub async fn get_guardrails(app: AppHandle, project_id: String) - Ok(String::new()) } } -pub async fn load_output_log(app: AppHandle, project_id: String) -> Result { +pub async fn load_output_log( + app: AppHandle, + project_id: String, +) -> Result { let dir = artifact_dir(&app, &project_id)?; let output_path = dir.join("agent_output.log"); if !output_path.exists() { @@ -99,9 +120,16 @@ pub async fn load_output_log(app: AppHandle, project_id: String) let content = std::fs::read_to_string(output_path)?; Ok(tail_lines(&content, 400)) } -pub(crate) fn insert_session(db: &DbState, project_id: &str, session_id: &str, started_at: &str) -> Result<(), ProjectError> { +pub(crate) fn insert_session( + db: &DbState, + project_id: &str, + session_id: &str, + started_at: &str, +) -> Result<(), ProjectError> { with_merge_gate(|| { - let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute( "INSERT INTO sessions (id, project_id, started_at) VALUES (?1, ?2, ?3)", rusqlite::params![session_id, project_id, started_at], @@ -109,9 +137,15 @@ pub(crate) fn insert_session(db: &DbState, project_id: &str, session_id: &str, s Ok(()) }) } -pub(crate) fn close_session(db: &DbState, session_id: &str, ended_at: &str) -> Result<(), ProjectError> { +pub(crate) fn close_session( + db: &DbState, + session_id: &str, + ended_at: &str, +) -> Result<(), ProjectError> { with_merge_gate(|| { - let conn = db.0.lock().map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; conn.execute( "UPDATE sessions SET ended_at = ?1 WHERE id = ?2", rusqlite::params![ended_at, session_id], @@ -136,7 +170,10 @@ pub(crate) fn merge_action_target(project_dir: &Path, action: &MergeAction) -> S } } #[cfg(test)] -pub(crate) fn apply_merge_action(project_dir: &Path, action: &MergeAction) -> Result<(), ProjectError> { +pub(crate) fn apply_merge_action( + project_dir: &Path, + action: &MergeAction, +) -> Result<(), ProjectError> { match action { MergeAction::UpdateStoryStatus { story_id, @@ -155,7 +192,12 @@ fn with_merge_gate(write: impl FnOnce() -> Result) -> Result write() } #[cfg(test)] -fn update_story_status(project_dir: &Path, story_id: &str, passed: bool, blocked: bool) -> Result<(), ProjectError> { +fn update_story_status( + project_dir: &Path, + story_id: &str, + passed: bool, + blocked: bool, +) -> Result<(), ProjectError> { let prd_path = project_dir.join("prd.json"); let mut prd = serde_json::from_str::(&std::fs::read_to_string(&prd_path)?)?; if let Some(story) = prd.stories.iter_mut().find(|story| story.id == story_id) { diff --git a/src-tauri/src/projects/mod.rs b/src-tauri/src/projects/mod.rs index 75a8700..c913a8e 100644 --- a/src-tauri/src/projects/mod.rs +++ b/src-tauri/src/projects/mod.rs @@ -20,7 +20,8 @@ mod reconcile_tests; pub use config_types::{NotificationPrefs, ProjectConfig}; pub use core_types::{ - IterationStory, Project, ProjectDetail, ProjectError, ProjectsByStatus, WizardResumeState, + IterationStory, Project, ProjectDetail, ProjectError, ProjectsByStatus, WizardHydrationResult, + WizardProjectData, WizardResumeState, }; pub use validation::{ConfigDefaultsResponse, DescribeInput, LaunchReadiness, ValidationErrors}; pub use wizard_state::AdvanceWizardResult; diff --git a/src-tauri/src/projects/notification_filter.rs b/src-tauri/src/projects/notification_filter.rs index ac9e260..1ed7249 100644 --- a/src-tauri/src/projects/notification_filter.rs +++ b/src-tauri/src/projects/notification_filter.rs @@ -8,15 +8,75 @@ pub struct AppNotification { pub notification_type: String, pub title: String, pub message: String, + pub ring_color: String, + pub read: bool, pub timestamp: u64, } -pub fn should_emit_verification_failed(attempt: u32, circuit_breaker: bool) -> bool { - circuit_breaker || attempt >= 3 +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ProjectNotificationSummary { + pub project_id: String, + pub unread_count: usize, + pub ring_color: Option, } -pub fn should_emit_iteration_completed(result: &str) -> bool { - result == "success" || result == "passed" +pub fn type_to_ring_color(notification_type: &str) -> &'static str { + match notification_type { + "story_blocked" | "loop_error" => "red", + "loop_completed" | "story_completed" => "cyan", + "rate_limited" | "review_comment" => "amber", + _ => "cyan", + } +} + +pub fn ring_color_for_project(notifications: &[AppNotification]) -> Option<&'static str> { + let unread: Vec<&AppNotification> = notifications.iter().filter(|notif| !notif.read).collect(); + if unread.is_empty() { + return None; + } + let priority = ["red", "amber", "cyan", "green"]; + for color in &priority { + if unread.iter().any(|notif| notif.ring_color == *color) { + return Some(color); + } + } + Some("cyan") +} + +pub fn build_project_summaries( + notifications: &[AppNotification], +) -> Vec { + let mut grouped: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for notification in notifications { + grouped + .entry(notification.project_id.clone()) + .or_default() + .push(notification.clone()); + } + grouped + .into_iter() + .map(|(project_id, project_notifications)| { + let unread_count = project_notifications + .iter() + .filter(|entry| !entry.read) + .count(); + let ring_color = ring_color_for_project(&project_notifications).map(String::from); + ProjectNotificationSummary { + project_id, + unread_count, + ring_color, + } + }) + .collect() +} + +fn now_millis() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64 } pub fn build_notification( @@ -25,22 +85,27 @@ pub fn build_notification( title: String, message: String, ) -> AppNotification { + let ring_color = type_to_ring_color(notification_type).to_string(); AppNotification { id: format!( "notif_{}_{}", - std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis(), + now_millis(), &uuid::Uuid::new_v4().to_string()[..8] ), project_id: project_id.to_string(), notification_type: notification_type.to_string(), title, message, - timestamp: std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .unwrap_or_default() - .as_millis() as u64, + ring_color, + read: false, + timestamp: now_millis(), } } + +pub fn should_emit_verification_failed(attempt: u32, circuit_breaker: bool) -> bool { + circuit_breaker || attempt >= 3 +} + +pub fn should_emit_iteration_completed(result: &str) -> bool { + result == "success" || result == "passed" +} diff --git a/src-tauri/src/projects/notifications.rs b/src-tauri/src/projects/notifications.rs index 56dadbf..2845055 100644 --- a/src-tauri/src/projects/notifications.rs +++ b/src-tauri/src/projects/notifications.rs @@ -1,15 +1,67 @@ +use crate::projects::notification_filter::{build_notification, AppNotification}; use crate::projects::{NotificationPrefs, ProjectError}; -use tauri::{AppHandle, Manager}; +use tauri::{AppHandle, Emitter, Manager}; + +#[derive(Debug, Clone)] +pub struct NotificationCreateInput { + pub project_id: String, + pub notification_type: String, + pub title: String, + pub message: String, +} + +pub fn create_notification_and_emit( + app: &AppHandle, + input: NotificationCreateInput, +) -> Result { + let notification = { + let db = app.state::(); + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + let created = build_notification( + &input.project_id, + &input.notification_type, + input.title, + input.message, + ); + conn.execute( + "INSERT INTO notifications (id, project_id, notification_type, title, message, ring_color, read, timestamp) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)", + rusqlite::params![ + created.id, + created.project_id, + created.notification_type, + created.title, + created.message, + created.ring_color, + i32::from(created.read), + created.timestamp, + ], + )?; + conn.execute( + "DELETE FROM notifications WHERE project_id = ?1 AND id NOT IN (SELECT id FROM notifications WHERE project_id = ?1 ORDER BY timestamp DESC LIMIT 200)", + rusqlite::params![created.project_id], + )?; + created + }; + let _ = app.emit( + crate::events::EVENT_NOTIFICATION_ADDED, + serde_json::json!({ + "projectId": notification.project_id, + "notification": notification.clone(), + }), + ); + Ok(notification) +} pub async fn get_notification_prefs( app: AppHandle, project_id: String, ) -> Result { let db = app.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; let prefs_json: Option = conn .query_row( "SELECT notification_prefs FROM projects WHERE id = ?1", @@ -30,10 +82,9 @@ pub async fn save_notification_prefs( prefs: NotificationPrefs, ) -> Result<(), ProjectError> { let db = app.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".into()))?; let json = serde_json::to_string(&prefs)?; conn.execute( "UPDATE projects SET notification_prefs = ?1 WHERE id = ?2", diff --git a/src-tauri/src/projects/reconcile.rs b/src-tauri/src/projects/reconcile.rs index 842f75d..23fb9bc 100644 --- a/src-tauri/src/projects/reconcile.rs +++ b/src-tauri/src/projects/reconcile.rs @@ -39,10 +39,9 @@ fn load_successful_story_ids( project_id: &str, ) -> Result, ProjectError> { let db = app.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let mut stmt = conn.prepare( "SELECT DISTINCT iterations.story_id FROM iterations @@ -51,7 +50,7 @@ fn load_successful_story_ids( )?; let rows = stmt.query_map(rusqlite::params![project_id], |row| row.get::<_, String>(0))?; - Ok(rows.filter_map(|row| row.ok()).collect()) + Ok(rows.filter_map(Result::ok).collect()) } fn load_blocked_story_ids(project_dir: &Path, gutter_threshold: u32) -> HashSet { diff --git a/src-tauri/src/projects/runtime_config.rs b/src-tauri/src/projects/runtime_config.rs index 389a49c..4ad04fc 100644 --- a/src-tauri/src/projects/runtime_config.rs +++ b/src-tauri/src/projects/runtime_config.rs @@ -59,11 +59,11 @@ fn legacy_config_from_loop_args(content: &str) -> Result Result Result String { + if duration_secs <= 0 { + return "0s".to_string(); + } + if duration_secs < 60 { + return format!("{duration_secs}s"); + } + let minutes = duration_secs / 60; + let seconds = duration_secs % 60; + format!("{minutes}m {seconds}s") +} + pub async fn get_project_stories( app: AppHandle, db: State<'_, DbState>, @@ -14,15 +26,16 @@ pub async fn get_project_stories( let prd_path = dir.join("prd.json"); let stories = if prd_path.exists() { - Prd::load(&prd_path).map(|prd| prd.stories).unwrap_or_default() + Prd::load(&prd_path) + .map(|prd| prd.stories) + .unwrap_or_default() } else { return Ok(Vec::new()); }; - let conn = db - .0 - .lock() - .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; + let conn = + db.0.lock() + .map_err(|_| ProjectError::Db("Lock poisoned".to_string()))?; let latest_session: Option<(String, Option)> = conn .query_row( @@ -32,11 +45,12 @@ pub async fn get_project_stories( ) .optional() .map_err(|err| ProjectError::Db(err.to_string()))?; - let latest_session_id = latest_session.as_ref().map(|(session_id, _)| session_id.clone()); + let latest_session_id = latest_session + .as_ref() + .map(|(session_id, _)| session_id.clone()); let latest_session_is_open = latest_session .as_ref() - .map(|(_, ended_at)| ended_at.is_none()) - .unwrap_or(false); + .is_some_and(|(_, ended_at)| ended_at.is_none()); let active_story_id: Option = if latest_session_is_open { if let Some(ref sid) = latest_session_id { @@ -98,6 +112,7 @@ pub async fn get_project_stories( title: story.title.clone(), status, duration_secs, + duration_label: duration_secs.map(format_duration_label), attempts, } }) diff --git a/src-tauri/src/projects/stories_crud.rs b/src-tauri/src/projects/stories_crud.rs index 4df9ba5..66adba9 100644 --- a/src-tauri/src/projects/stories_crud.rs +++ b/src-tauri/src/projects/stories_crud.rs @@ -9,9 +9,8 @@ fn load_prd(app: &AppHandle, project_id: &str) -> Result(app: &AppHandle, project_id: &str) -> Result(app: &AppHandle, project_id: &str, prd: &Prd) -> Result<(), ProjectError> { +fn save_prd( + app: &AppHandle, + project_id: &str, + prd: &Prd, +) -> Result<(), ProjectError> { let dir = artifact_dir(app, project_id)?; std::fs::create_dir_all(&dir)?; let json = serde_json::to_string_pretty(prd)?; @@ -33,7 +36,10 @@ fn save_prd(app: &AppHandle, project_id: &str, prd: &Prd) -> Resu } fn total_estimated_minutes(prd: &Prd) -> u32 { - prd.stories.iter().map(|story| story.estimated_minutes).sum() + prd.stories + .iter() + .map(|story| story.estimated_minutes) + .sum() } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -107,14 +113,20 @@ pub fn update_story( story.priority = parsed; } } - if let Some(complexity) = patch.get("estimatedComplexity").and_then(|val| val.as_str()) { + if let Some(complexity) = patch + .get("estimatedComplexity") + .and_then(|val| val.as_str()) + { if let Ok(parsed) = serde_json::from_value::( serde_json::Value::String(complexity.to_string()), ) { story.estimated_complexity = parsed; } } - if let Some(minutes) = patch.get("estimatedMinutes").and_then(|val| val.as_u64()) { + if let Some(minutes) = patch + .get("estimatedMinutes") + .and_then(serde_json::Value::as_u64) + { story.estimated_minutes = minutes as u32; } diff --git a/src-tauri/src/projects/validation.rs b/src-tauri/src/projects/validation.rs index 75ef3e5..c09e5f8 100644 --- a/src-tauri/src/projects/validation.rs +++ b/src-tauri/src/projects/validation.rs @@ -66,33 +66,33 @@ pub fn validate_config(config: &ProjectConfig) -> ValidationErrors { &mut errors, "gutterThreshold", "Gutter threshold", - config.gutter_threshold as u64, - limits.gutter_threshold.0 as u64, - limits.gutter_threshold.1 as u64, + u64::from(config.gutter_threshold), + u64::from(limits.gutter_threshold.0), + u64::from(limits.gutter_threshold.1), ); validate_range( &mut errors, "maxIterations", "Max iterations", - config.max_iterations as u64, - limits.max_iterations.0 as u64, - limits.max_iterations.1 as u64, + u64::from(config.max_iterations), + u64::from(limits.max_iterations.0), + u64::from(limits.max_iterations.1), ); validate_range( &mut errors, "cooldownSeconds", "Cooldown", - config.cooldown_seconds as u64, - limits.cooldown_seconds.0 as u64, - limits.cooldown_seconds.1 as u64, + u64::from(config.cooldown_seconds), + u64::from(limits.cooldown_seconds.0), + u64::from(limits.cooldown_seconds.1), ); validate_range( &mut errors, "maxVerificationRetries", "Verification retries", - config.max_verification_retries as u64, - limits.max_verification_retries.0 as u64, - limits.max_verification_retries.1 as u64, + u64::from(config.max_verification_retries), + u64::from(limits.max_verification_retries.0), + u64::from(limits.max_verification_retries.1), ); validate_range( &mut errors, @@ -178,6 +178,10 @@ pub fn validate_describe(input: &DescribeInput, available_agents: &[String]) -> pub struct LaunchReadiness { pub ready: bool, pub issues: Vec, + #[serde(default)] + pub total_estimated_minutes: u32, + #[serde(default)] + pub total_estimated_hours: f64, } pub fn validate_launch_readiness( @@ -185,6 +189,7 @@ pub fn validate_launch_readiness( working_directory: &str, stories_count: usize, execute_agent: &str, + estimated_minutes_per_story: &[u32], ) -> LaunchReadiness { let mut issues = Vec::new(); @@ -201,9 +206,14 @@ pub fn validate_launch_readiness( issues.push("Execution agent is missing.".to_string()); } + let total_minutes: u32 = estimated_minutes_per_story.iter().sum(); + let total_hours = f64::from(total_minutes) / 60.0; + LaunchReadiness { ready: issues.is_empty(), issues, + total_estimated_minutes: total_minutes, + total_estimated_hours: (total_hours * 10.0).round() / 10.0, } } @@ -228,8 +238,16 @@ mod tests { #[test] fn launch_readiness_catches_missing_fields() { - let result = validate_launch_readiness("", "", 0, ""); + let result = validate_launch_readiness("", "", 0, "", &[]); assert!(!result.ready); assert_eq!(result.issues.len(), 4); } + + #[test] + fn launch_readiness_computes_totals() { + let result = validate_launch_readiness("proj", "/tmp", 2, "claude", &[30, 90]); + assert!(result.ready); + assert_eq!(result.total_estimated_minutes, 120); + assert!((result.total_estimated_hours - 2.0).abs() < 0.01); + } } diff --git a/src-tauri/src/projects/wizard.rs b/src-tauri/src/projects/wizard.rs index f875580..8c09370 100644 --- a/src-tauri/src/projects/wizard.rs +++ b/src-tauri/src/projects/wizard.rs @@ -1,7 +1,8 @@ use crate::db::DbState; use crate::projects::artifacts::{artifact_dir, non_empty_file_content}; +use crate::projects::config_types::ProjectConfig; use crate::projects::repository::{row_to_project, PROJECT_COLUMNS}; -use crate::projects::{ProjectError, WizardResumeState}; +use crate::projects::{ProjectError, WizardHydrationResult, WizardProjectData, WizardResumeState}; use ralph_core::prd::Prd; use serde_json::{Map, Value}; use std::path::Path; @@ -182,3 +183,104 @@ pub async fn resume_wizard( has_prd, }) } + +pub async fn hydrate_wizard( + app: AppHandle, + db: State<'_, DbState>, + project_id: String, +) -> Result { + let resume = resume_wizard(app.clone(), db, project_id.clone()).await?; + let dir = artifact_dir(&app, &project_id)?; + let current_step_number = + crate::projects::wizard_state::step_name_to_number(&resume.wizard_step).unwrap_or(1); + + let mut project_data = WizardProjectData { + name: resume.project.name.clone(), + description: resume.project.description.clone(), + working_directory: resume.project.working_directory.clone(), + plan_agent: "claude".to_string(), + plan_model: None, + plan_effort: None, + }; + + let mut plan_complete = resume.has_plan; + let mut config: Option = None; + let mut highest_step = current_step_number; + + let draft_content = non_empty_file_content(&dir.join("draft.json"))?; + let state_fallback = draft_content + .is_none() + .then(|| resume.wizard_state_json.clone()) + .flatten(); + let hydration_source = draft_content.or(state_fallback); + if let Some(raw) = hydration_source { + if let Ok(draft) = serde_json::from_str::(&raw) { + if let Some(describe) = draft.get("describe") { + if let Some(val) = describe.get("name").and_then(Value::as_str) { + if !val.is_empty() { + project_data.name = val.to_string(); + } + } + if let Some(val) = describe.get("description").and_then(Value::as_str) { + if !val.is_empty() { + project_data.description = val.to_string(); + } + } + if let Some(val) = describe.get("workingDirectory").and_then(Value::as_str) { + if !val.is_empty() { + project_data.working_directory = val.to_string(); + } + } + if let Some(val) = describe.get("planAgent").and_then(Value::as_str) { + if !val.is_empty() { + project_data.plan_agent = val.to_string(); + } + } + project_data.plan_model = describe + .get("planModel") + .and_then(Value::as_str) + .map(str::to_string); + project_data.plan_effort = describe + .get("planEffort") + .and_then(Value::as_str) + .map(str::to_string); + } + if let Some(plan) = draft.get("plan") { + if plan + .get("completed") + .and_then(Value::as_bool) + .unwrap_or(false) + { + plan_complete = true; + } + } + if let Some(value) = draft.get("highestStep").and_then(Value::as_u64) { + let parsed = value as u32; + if parsed > highest_step { + highest_step = parsed; + } + } + if let Some(configure) = draft.get("configure") { + config = serde_json::from_value(configure.clone()).ok(); + } + } + } + + let stories = if resume.has_prd { + Prd::load(&dir.join("prd.json")) + .map(|prd| prd.stories) + .unwrap_or_default() + } else { + Vec::new() + }; + + Ok(WizardHydrationResult { + project: resume.project, + wizard_step: resume.wizard_step, + highest_step, + project_data, + plan_complete, + stories, + config, + }) +} diff --git a/src-tauri/src/projects/wizard_state.rs b/src-tauri/src/projects/wizard_state.rs index 715c463..1108498 100644 --- a/src-tauri/src/projects/wizard_state.rs +++ b/src-tauri/src/projects/wizard_state.rs @@ -71,8 +71,8 @@ pub fn advance_wizard_step( let mut state = current_state.cloned().unwrap_or_default(); state.advance(target_step); - let step_name = step_number_to_name(target_step) - .ok_or_else(|| format!("Unknown step: {target_step}"))?; + let step_name = + step_number_to_name(target_step).ok_or_else(|| format!("Unknown step: {target_step}"))?; Ok(AdvanceWizardResult { current_step: state.current_step, diff --git a/src-tauri/src/scm_watcher.rs b/src-tauri/src/scm_watcher.rs index ba8ea33..6a3ac5f 100644 --- a/src-tauri/src/scm_watcher.rs +++ b/src-tauri/src/scm_watcher.rs @@ -147,10 +147,7 @@ pub async fn detect_github_pr( ) -> Option { let output = Command::new("gh") .args([ - "pr", "list", - "--head", branch, - "--json", "number", - "--limit", "1", + "pr", "list", "--head", branch, "--json", "number", "--limit", "1", ]) .current_dir(work_dir) .output() @@ -237,11 +234,7 @@ pub async fn fetch_gitlab_comments( Ok(review_comments) } -pub async fn detect_gitlab_mr( - project_id: &str, - branch: &str, - work_dir: &Path, -) -> Option { +pub async fn detect_gitlab_mr(project_id: &str, branch: &str, work_dir: &Path) -> Option { let endpoint = format!( "projects/{}/merge_requests?source_branch={branch}&state=opened&per_page=1", urlencoding_simple(project_id) @@ -307,7 +300,10 @@ impl ReviewRouter { comment.reviewer )); - prompt.push_str(&format!("## Review Comment\n\n**PR #{}**\n", comment.pr_number)); + prompt.push_str(&format!( + "## Review Comment\n\n**PR #{}**\n", + comment.pr_number + )); if let Some(file_path) = &comment.file_path { prompt.push_str(&format!("**File:** `{file_path}`")); @@ -401,10 +397,7 @@ impl CommitWatcher { } } - pub async fn has_new_commit_since( - work_dir: &Path, - baseline_hash: &str, - ) -> bool { + pub async fn has_new_commit_since(work_dir: &Path, baseline_hash: &str) -> bool { let current = Self::get_head_hash(work_dir).await; match current { Some(hash) => hash != baseline_hash, @@ -428,9 +421,7 @@ impl CommitWatcher { } if Self::has_new_commit_since(work_dir, baseline_hash).await { - let new_hash = Self::get_head_hash(work_dir) - .await - .unwrap_or_default(); + let new_hash = Self::get_head_hash(work_dir).await.unwrap_or_default(); return CommitWatchResult::NewCommit(new_hash); } @@ -490,22 +481,13 @@ pub async fn handle_comment_lifecycle( .as_deref() .and_then(|fp| ReviewRouter::load_file_content(work_dir, fp)); - let prompt = ReviewRouter::build_prompt( - comment, - None, - file_content.as_deref(), - ); + let prompt = ReviewRouter::build_prompt(comment, None, file_content.as_deref()); comment.status = CommentStatus::Routed; let _ = ReviewRouter::route_to_agent(&prompt, agent_binary, work_dir).await; - let watch_result = CommitWatcher::wait_for_commit( - work_dir, - &baseline_hash, - timeout_secs, - 15, - ) - .await; + let watch_result = + CommitWatcher::wait_for_commit(work_dir, &baseline_hash, timeout_secs, 15).await; match watch_result { CommitWatchResult::NewCommit(_) => { @@ -513,10 +495,7 @@ pub async fn handle_comment_lifecycle( } CommitWatchResult::TimedOut => { comment.status = CommentStatus::Escalated; - let file_display = comment - .file_path - .as_deref() - .unwrap_or("unknown file"); + let file_display = comment.file_path.as_deref().unwrap_or("unknown file"); crate::notifications::notify_review_escalation( app, project_name, diff --git a/src-tauri/src/services/monitor_adapter.rs b/src-tauri/src/services/monitor_adapter.rs index afe0de7..a48175f 100644 --- a/src-tauri/src/services/monitor_adapter.rs +++ b/src-tauri/src/services/monitor_adapter.rs @@ -1,4 +1,6 @@ -use app_services::monitor::{MonitorEvent, MonitorRepository, MonitorService, RuntimeMonitorService}; +use app_services::monitor::{ + MonitorEvent, MonitorRepository, MonitorService, RuntimeMonitorService, +}; use app_services::{MonitorSnapshot, MonitorStream, OutputEntry, ServiceError, SessionInfo}; use rusqlite::OptionalExtension; use tauri::Manager; @@ -20,10 +22,9 @@ impl<'a, R: tauri::Runtime> TauriMonitorAdapter<'a, R> { impl MonitorRepository for TauriMonitorAdapter<'_, R> { fn latest_session(&self, project_id: &str) -> Result, ServiceError> { let db = self.window.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; conn.query_row( "SELECT id, started_at, ended_at FROM sessions WHERE project_id = ?1 ORDER BY started_at DESC LIMIT 1", @@ -40,12 +41,14 @@ impl MonitorRepository for TauriMonitorAdapter<'_, R> { .map_err(|error| ServiceError::Internal(error.to_string())) } - fn recent_output(&self, project_id: &str, limit: usize) -> Result, ServiceError> { - let artifact_dir = crate::storage::artifacts::project_artifact_dir( - &self.window.app_handle(), - project_id, - ) - .map_err(ServiceError::Internal)?; + fn recent_output( + &self, + project_id: &str, + limit: usize, + ) -> Result, ServiceError> { + let artifact_dir = + crate::storage::artifacts::project_artifact_dir(self.window.app_handle(), project_id) + .map_err(ServiceError::Internal)?; let output_path = artifact_dir.join("agent_output.log"); let content = std::fs::read_to_string(output_path).unwrap_or_default(); @@ -73,10 +76,9 @@ impl MonitorRepository for TauriMonitorAdapter<'_, R> { limit: usize, ) -> Result, ServiceError> { let db = self.window.state::(); - let conn = db - .0 - .lock() - .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; + let conn = + db.0.lock() + .map_err(|_| ServiceError::Internal("database lock poisoned".into()))?; let mut statement = conn .prepare( "SELECT s.id, i.story_id, i.agent_used, i.duration_secs, i.result diff --git a/src-tauri/src/services/session_adapter.rs b/src-tauri/src/services/session_adapter.rs index b0740fa..678e4a6 100644 --- a/src-tauri/src/services/session_adapter.rs +++ b/src-tauri/src/services/session_adapter.rs @@ -1,4 +1,7 @@ -use app_services::session::{IterationCounts, IterationSummary, LatestSessionRecord, RuntimeSessionService, SessionRepository, SessionService, StoryCounts}; +use app_services::session::{ + IterationCounts, IterationSummary, LatestSessionRecord, RuntimeSessionService, + SessionRepository, SessionService, StoryCounts, +}; use app_services::{ServiceError, SessionStats}; use ralph_core::prd::Prd; use rusqlite::{Connection, OptionalExtension}; @@ -7,6 +10,25 @@ use tauri::Manager; use crate::commands::execution::IterationRow; use crate::loop_manager::LoopError; +fn format_duration_label(duration_secs: i64) -> String { + if duration_secs <= 0 { + return "0s".to_string(); + } + if duration_secs < 60 { + return format!("{duration_secs}s"); + } + let minutes = duration_secs / 60; + let seconds = duration_secs % 60; + format!("{minutes}m {seconds}s") +} + +fn format_time_label(started_at: &str) -> String { + chrono::DateTime::parse_from_rfc3339(started_at).map_or_else( + |_| started_at.to_string(), + |value| value.format("%H:%M:%S").to_string(), + ) +} + pub struct TauriSessionAdapter<'a, R: tauri::Runtime> { window: &'a tauri::Window, } @@ -38,7 +60,10 @@ impl SessionRepository for TauriSessionAdapter<'_, R> { Ok(handles.contains_key(project_id)) } - fn latest_session_record(&self, project_id: &str) -> Result, ServiceError> { + fn latest_session_record( + &self, + project_id: &str, + ) -> Result, ServiceError> { self.with_connection(|conn| { conn.query_row( "SELECT id, started_at FROM sessions WHERE project_id = ?1 ORDER BY started_at DESC LIMIT 1", @@ -81,7 +106,7 @@ impl SessionRepository for TauriSessionAdapter<'_, R> { fn story_counts(&self, project_id: &str) -> Result { let artifact_dir = - crate::storage::artifacts::project_artifact_dir(&self.window.app_handle(), project_id) + crate::storage::artifacts::project_artifact_dir(self.window.app_handle(), project_id) .map_err(ServiceError::Internal)?; let prd_path = artifact_dir.join("prd.json"); if !prd_path.exists() { @@ -97,16 +122,20 @@ impl SessionRepository for TauriSessionAdapter<'_, R> { .map_err(|error| ServiceError::Internal(error.to_string())) } - fn stories_per_hour(&self, session: Option<&LatestSessionRecord>, passed_stories: usize) -> Result { + fn stories_per_hour( + &self, + session: Option<&LatestSessionRecord>, + passed_stories: usize, + ) -> Result { let Some(started_at) = session.and_then(|record| record.started_at.as_deref()) else { return Ok(0.0); }; let Ok(started_at) = chrono::DateTime::parse_from_rfc3339(started_at) else { return Ok(0.0); }; - let elapsed_hours = - (chrono::Utc::now() - started_at.with_timezone(&chrono::Utc)).num_minutes() as f64 - / 60.0; + let elapsed_hours = (chrono::Utc::now() - started_at.with_timezone(&chrono::Utc)) + .num_minutes() as f64 + / 60.0; if elapsed_hours > 0.0 { Ok(passed_stories as f64 / elapsed_hours) } else { @@ -114,11 +143,15 @@ impl SessionRepository for TauriSessionAdapter<'_, R> { } } - fn iteration_history(&self, project_id: &str, limit: usize) -> Result, ServiceError> { + fn iteration_history( + &self, + project_id: &str, + limit: usize, + ) -> Result, ServiceError> { self.with_connection(|conn| { let mut statement = conn .prepare( - "SELECT i.story_id, i.started_at, i.duration_secs, i.result, i.agent_used + "SELECT i.story_id, i.started_at, i.duration_secs, i.result, i.agent_used FROM iterations i JOIN sessions s ON i.session_id = s.id WHERE s.project_id = ?1 @@ -144,7 +177,10 @@ impl SessionRepository for TauriSessionAdapter<'_, R> { } #[tauri::command] -pub async fn session_stats_command(window: tauri::Window, project_id: String) -> Result { +pub async fn session_stats_command( + window: tauri::Window, + project_id: String, +) -> Result { RuntimeSessionService::new(TauriSessionAdapter::new(&window)) .session_stats(&required(project_id, "project_id").map_err(LoopError::Path)?) .map_err(loop_error) @@ -156,15 +192,24 @@ pub async fn get_iteration_history_command( project_id: String, ) -> Result, LoopError> { RuntimeSessionService::new(TauriSessionAdapter::new(&window)) - .iteration_history(&required(project_id, "project_id").map_err(LoopError::Path)?, 200) + .iteration_history( + &required(project_id, "project_id").map_err(LoopError::Path)?, + 200, + ) .map(|rows| { rows.into_iter() - .map(|row| IterationRow { - story_id: row.story_id, - started_at: row.started_at, - duration_secs: row.duration_secs, - result: row.result, - agent_used: row.agent_used, + .map(|row| { + let started_at = row.started_at; + let duration_secs = row.duration_secs; + IterationRow { + story_id: row.story_id, + started_at: started_at.clone(), + duration_secs, + duration_label: format_duration_label(duration_secs), + time_label: format_time_label(&started_at), + result: row.result, + agent_used: row.agent_used, + } }) .collect() }) diff --git a/src-tauri/src/storage/artifacts.rs b/src-tauri/src/storage/artifacts.rs index 88eaaac..5f95091 100644 --- a/src-tauri/src/storage/artifacts.rs +++ b/src-tauri/src/storage/artifacts.rs @@ -1,7 +1,10 @@ use std::path::{Path, PathBuf}; use tauri::{AppHandle, Manager, Runtime}; -pub fn project_artifact_dir(app: &AppHandle, project_id: &str) -> Result { +pub fn project_artifact_dir( + app: &AppHandle, + project_id: &str, +) -> Result { app.path() .app_data_dir() .map(|data_dir| data_dir.join("projects").join(project_id)) diff --git a/src-tauri/src/storage/db.rs b/src-tauri/src/storage/db.rs index c329024..6856985 100644 --- a/src-tauri/src/storage/db.rs +++ b/src-tauri/src/storage/db.rs @@ -163,6 +163,24 @@ impl DbState { )?; } + if current_version < 7 { + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS notifications ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id), + notification_type TEXT NOT NULL, + title TEXT NOT NULL, + message TEXT NOT NULL, + ring_color TEXT NOT NULL DEFAULT 'cyan', + read INTEGER NOT NULL DEFAULT 0, + timestamp INTEGER NOT NULL + ); + CREATE INDEX IF NOT EXISTS idx_notifications_project + ON notifications(project_id, timestamp DESC); + INSERT INTO _migrations (version) VALUES (7);", + )?; + } + Ok(()) } @@ -186,7 +204,7 @@ impl DbState { stmt.query_map([], |row| { Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) }) - .map(|rows| rows.filter_map(|row| row.ok()).collect()) + .map(|rows| rows.filter_map(Result::ok).collect()) .unwrap_or_default() } diff --git a/src-tauri/src/storage/migration.rs b/src-tauri/src/storage/migration.rs index e1ab50a..7cfa7c3 100644 --- a/src-tauri/src/storage/migration.rs +++ b/src-tauri/src/storage/migration.rs @@ -95,10 +95,10 @@ fn copy_db_bundle(source_db: &Path, target_db: &Path) -> Result<(), String> { } fn sidecar_path(db_path: &Path, suffix: &str) -> PathBuf { - let file_name = db_path - .file_name() - .map(|name| name.to_string_lossy().to_string()) - .unwrap_or_else(|| "loopforge.db".to_string()); + let file_name = db_path.file_name().map_or_else( + || "loopforge.db".to_string(), + |name| name.to_string_lossy().to_string(), + ); db_path.with_file_name(format!("{file_name}{suffix}")) } diff --git a/src-tauri/src/summary_generator.rs b/src-tauri/src/summary_generator.rs index 948285d..c7e417b 100644 --- a/src-tauri/src/summary_generator.rs +++ b/src-tauri/src/summary_generator.rs @@ -102,9 +102,7 @@ fn collect_agent_usage(conn: &rusqlite::Connection, session_id: &str) -> Vec Vec { .collect() } -pub(crate) fn classify_complexity_for_test(files_changed: usize, total_lines: i64, story_count: usize) -> &'static str { +pub(crate) fn classify_complexity_for_test( + files_changed: usize, + total_lines: i64, + story_count: usize, +) -> &'static str { classify_complexity(files_changed, total_lines, story_count) } @@ -184,16 +186,30 @@ pub fn generate_summary( let diff_stats = collect_diff_stats(work_dir); let exec_secs = total_execution_time(conn, session_id); - let passed = stories.iter().filter(|story| story.status == "success").count(); - let blocked = stories.iter().filter(|story| story.status == "failed" || story.status == "blocked").count(); - let skipped = stories.iter().filter(|story| story.status == "pending").count(); + let passed = stories + .iter() + .filter(|story| story.status == "success") + .count(); + let blocked = stories + .iter() + .filter(|story| story.status == "failed" || story.status == "blocked") + .count(); + let skipped = stories + .iter() + .filter(|story| story.status == "pending") + .count(); let total_insertions: i64 = diff_stats.iter().map(|stat| stat.insertions).sum(); let total_deletions: i64 = diff_stats.iter().map(|stat| stat.deletions).sum(); let files_changed = diff_stats.len(); let total_stories = stories.len(); - let complexity = classify_complexity(files_changed, total_insertions + total_deletions, total_stories).to_string(); + let complexity = classify_complexity( + files_changed, + total_insertions + total_deletions, + total_stories, + ) + .to_string(); let completion_status = if blocked == 0 && skipped == 0 { "SUCCESS".to_string() @@ -289,10 +305,7 @@ fn fallback_narrative(summary: &LoopSummary) -> String { } fn artifact_dir(app: &AppHandle, project_id: &str) -> Result { - let data_dir = app - .path() - .app_data_dir() - .map_err(|err| err.to_string())?; + let data_dir = app.path().app_data_dir().map_err(|err| err.to_string())?; Ok(data_dir.join("projects").join(project_id)) } @@ -312,7 +325,14 @@ pub async fn generate_loop_summary( let prd = Prd::load(&prd_path).map_err(|err| err.to_string())?; let work_dir = PathBuf::from(&working_directory); - let mut summary = generate_summary(&conn, &project_id, &project_name, &session_id, &prd, &work_dir); + let mut summary = generate_summary( + &conn, + &project_id, + &project_name, + &session_id, + &prd, + &work_dir, + ); summary.narrative = render_narrative(&summary); let summary_path = artifact_path.join("summary.json"); diff --git a/src-tauri/src/test_env_lock.rs b/src-tauri/src/test_env_lock.rs new file mode 100644 index 0000000..84b44f1 --- /dev/null +++ b/src-tauri/src/test_env_lock.rs @@ -0,0 +1,3 @@ +use std::sync::{LazyLock, Mutex}; + +pub(crate) static ENV_LOCK: LazyLock> = LazyLock::new(|| Mutex::new(())); diff --git a/src-tauri/src/test_support/planning.rs b/src-tauri/src/test_support/planning.rs index c2f8027..b54ef66 100644 --- a/src-tauri/src/test_support/planning.rs +++ b/src-tauri/src/test_support/planning.rs @@ -142,6 +142,7 @@ fn batch( PlanActivityBatchPayload { project_id: project_id.to_string(), events, + plan_content: plan_content_delta.to_string(), plan_content_delta: plan_content_delta.to_string(), } } diff --git a/src-tauri/src/tests.rs b/src-tauri/src/tests.rs index f6d97a1..7d5c0b2 100644 --- a/src-tauri/src/tests.rs +++ b/src-tauri/src/tests.rs @@ -141,7 +141,12 @@ fn list_projects_groups_correctly_by_status() { .filter_map(|result| result.ok()) .collect(); - let count = |target: &str| statuses.iter().filter(|status| status.as_str() == target).count(); + let count = |target: &str| { + statuses + .iter() + .filter(|status| status.as_str() == target) + .count() + }; assert_eq!(count("active"), 1); assert_eq!(count("paused"), 1); @@ -361,29 +366,34 @@ fn ci_feedback_loop_verification_retries_tracked() { "INSERT INTO projects (id, name, status, working_directory, created_at, updated_at) VALUES ('proj-ci', 'CI Test', 'active', '/tmp', ?1, ?2)", rusqlite::params![now, now], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO sessions (id, project_id, started_at) VALUES ('sess-ci', 'proj-ci', ?1)", rusqlite::params![now], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO iterations (id, session_id, story_id, duration_secs, result, agent_used) VALUES ('iter-1', 'sess-ci', 'S-001', 10, 'failed', 'claude')", [], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO iterations (id, session_id, story_id, duration_secs, result, agent_used) VALUES ('iter-2', 'sess-ci', 'S-001', 15, 'failed', 'claude')", [], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO iterations (id, session_id, story_id, duration_secs, result, agent_used) VALUES ('iter-3', 'sess-ci', 'S-001', 20, 'success', 'claude')", [], - ).unwrap(); + ) + .unwrap(); let attempts: i64 = conn .query_row( @@ -411,7 +421,8 @@ fn connection_crud_lifecycle() { conn.execute( "INSERT INTO connections (id, name) VALUES ('conn-1', 'Monorepo Connection')", [], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO connection_repos (connection_id, repo_path, display_name) VALUES ('conn-1', '/tmp/repo-a', 'Repo A')", [], @@ -430,7 +441,8 @@ fn connection_crud_lifecycle() { .unwrap(); assert_eq!(repo_count, 2); - conn.execute("DELETE FROM connections WHERE id = 'conn-1'", []).unwrap(); + conn.execute("DELETE FROM connections WHERE id = 'conn-1'", []) + .unwrap(); let leftover: i64 = conn .query_row( @@ -451,13 +463,16 @@ fn notification_prefs_stored_and_retrieved() { "INSERT INTO projects (id, name, status, working_directory, created_at, updated_at) VALUES ('proj-notif', 'Notif Test', 'active', '/tmp', ?1, ?2)", rusqlite::params![now, now], - ).unwrap(); + ) + .unwrap(); - let prefs_json = r#"{"story_blocked":{"ring":true,"os":true},"loop_completed":{"ring":true,"os":false}}"#; + let prefs_json = + r#"{"story_blocked":{"ring":true,"os":true},"loop_completed":{"ring":true,"os":false}}"#; conn.execute( "UPDATE projects SET notification_prefs = ?1 WHERE id = 'proj-notif'", rusqlite::params![prefs_json], - ).unwrap(); + ) + .unwrap(); let stored: Option = conn .query_row( @@ -503,13 +518,15 @@ fn session_stats_calculation() { "INSERT INTO projects (id, name, status, working_directory, created_at, updated_at) VALUES ('proj-stats', 'Stats Test', 'active', '/tmp', ?1, ?2)", rusqlite::params![now, now], - ).unwrap(); + ) + .unwrap(); conn.execute( "INSERT INTO sessions (id, project_id, started_at, total_iterations) VALUES ('sess-stats', 'proj-stats', ?1, 0)", rusqlite::params![now], - ).unwrap(); + ) + .unwrap(); for idx in 0..5 { let iter_id = format!("iter-s-{idx}"); @@ -518,7 +535,8 @@ fn session_stats_calculation() { "INSERT INTO iterations (id, session_id, story_id, duration_secs, result, agent_used) VALUES (?1, 'sess-stats', ?2, ?3, ?4, 'claude')", rusqlite::params![iter_id, format!("S-{:03}", idx + 1), (idx + 1) * 60, result], - ).unwrap(); + ) + .unwrap(); } let total: i64 = conn diff --git a/src-tauri/src/worktree_manager.rs b/src-tauri/src/worktree_manager.rs index 5567d5b..344204a 100644 --- a/src-tauri/src/worktree_manager.rs +++ b/src-tauri/src/worktree_manager.rs @@ -136,12 +136,20 @@ pub async fn create_worktree( let worktree_path = format!(".loopforge/worktrees/{loop_name}"); let branch = format!("loopforge/{loop_name}"); - let (ok, stdout, _stderr) = - git_run(&app, &working_directory, &["worktree", "add", &worktree_path, "-b", &branch]).await?; + let (ok, stdout, _stderr) = git_run( + &app, + &working_directory, + &["worktree", "add", &worktree_path, "-b", &branch], + ) + .await?; if !ok { - let (ok2, stdout2, stderr2) = - git_run(&app, &working_directory, &["worktree", "add", &worktree_path, &branch]).await?; + let (ok2, stdout2, stderr2) = git_run( + &app, + &working_directory, + &["worktree", "add", &worktree_path, &branch], + ) + .await?; if !ok2 { return Err(WorktreeError::Git(stderr2)); } @@ -170,8 +178,12 @@ pub async fn list_worktrees( app: AppHandle, working_directory: String, ) -> Result, WorktreeError> { - let (ok, stdout, stderr) = - git_run(&app, &working_directory, &["worktree", "list", "--porcelain"]).await?; + let (ok, stdout, stderr) = git_run( + &app, + &working_directory, + &["worktree", "list", "--porcelain"], + ) + .await?; if !ok { return Err(WorktreeError::Git(stderr)); @@ -187,8 +199,12 @@ pub async fn remove_worktree( worktree_path: String, delete_branch: bool, ) -> Result<(), WorktreeError> { - let (ok, _stdout, stderr) = - git_run(&app, &working_directory, &["worktree", "remove", &worktree_path, "--force"]).await?; + let (ok, _stdout, stderr) = git_run( + &app, + &working_directory, + &["worktree", "remove", &worktree_path, "--force"], + ) + .await?; if !ok { return Err(WorktreeError::Git(stderr)); @@ -220,7 +236,10 @@ pub async fn check_scope_overlap( working_directory: String, ) -> Result, WorktreeError> { let active_project_ids: Vec = { - let handles = loop_state.0.lock().map_err(|_| WorktreeError::LockPoisoned)?; + let handles = loop_state + .0 + .lock() + .map_err(|_| WorktreeError::LockPoisoned)?; handles.keys().cloned().collect() }; @@ -299,11 +318,11 @@ pub async fn start_loop_with_worktree( .unwrap_or_default(); let has_active_loop_in_dir = { - let handles = loop_state.0.lock().map_err(|_| crate::loop_manager::LoopError::LockPoisoned)?; - handles - .keys() - .any(|pid| pid != &args.project_id) - && !overlaps.is_empty() + let handles = loop_state + .0 + .lock() + .map_err(|_| crate::loop_manager::LoopError::LockPoisoned)?; + handles.keys().any(|pid| pid != &args.project_id) && !overlaps.is_empty() }; let effective_working_dir = if has_active_loop_in_dir { diff --git a/src-tauri/tests/wizard_draft_roundtrip.rs b/src-tauri/tests/wizard_draft_roundtrip.rs index a3875fb..074e05a 100644 --- a/src-tauri/tests/wizard_draft_roundtrip.rs +++ b/src-tauri/tests/wizard_draft_roundtrip.rs @@ -147,7 +147,10 @@ async fn wizard_draft_roundtrip_preserves_nested_and_optional_fields() { .expect("draft content"); assert_eq!(resume_state.project.name, "Roundtrip Project"); - assert_eq!(resume_state.project.description, "Persist wizard draft values"); + assert_eq!( + resume_state.project.description, + "Persist wizard draft values" + ); assert_eq!(resume_state.project.working_directory, working_directory); assert_eq!(resume_state.wizard_step, "configure"); assert_eq!(resume_state.wizard_state_json, None); diff --git a/src/components/EmptyState.tsx b/src/components/EmptyState.tsx index 5d30692..287818a 100644 --- a/src/components/EmptyState.tsx +++ b/src/components/EmptyState.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type * as React from "react"; import { Button, type ButtonProps } from "@/components/ui/button"; import { Empty, type EmptyProps } from "@/components/ui/empty"; @@ -15,12 +15,7 @@ export type EmptyStateProps = Omit & { action?: React.ReactNode; }; -export function EmptyState({ - action, - primaryAction, - secondaryAction, - ...props -}: EmptyStateProps) { +export function EmptyState({ action, primaryAction, secondaryAction, ...props }: EmptyStateProps) { let actionContent = action; if (!actionContent && (primaryAction || secondaryAction)) { diff --git a/src/components/EphemeralOverlay.tsx b/src/components/EphemeralOverlay.tsx index b0b211a..2d0c09a 100644 --- a/src/components/EphemeralOverlay.tsx +++ b/src/components/EphemeralOverlay.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { ephemeralQuery, type EphemeralAnswer } from "../lib/tauri"; +import { type EphemeralAnswer, ephemeralQuery } from "../lib/tauri"; import { Badge } from "./ui/badge"; import { Button } from "./ui/button"; import { @@ -18,11 +18,7 @@ interface EphemeralOverlayProps { onClose: () => void; } -export function EphemeralOverlay({ - projectId, - isOpen, - onClose, -}: EphemeralOverlayProps) { +export function EphemeralOverlay({ projectId, isOpen, onClose }: EphemeralOverlayProps) { const [question, setQuestion] = useState(""); const [answer, setAnswer] = useState(null); const [loading, setLoading] = useState(false); @@ -73,7 +69,7 @@ export function EphemeralOverlay({ return ( { event.preventDefault(); inputRef.current?.focus(); @@ -90,7 +86,7 @@ export function EphemeralOverlay({ -
+ - {loading ?

thinking...

: null} + {loading ?

thinking...

: null}
{answer && ( -
+
-
+            
               {answer.answer}
             
)} - -

Esc to close · Ctrl+Shift+Space to toggle

+ +

+ Esc to close · Ctrl+Shift+Space to toggle +

) : ( -
- {stepBody} -
+
{stepBody}
)}
{showConnectors && stepIndex < steps.length - 1 ? ( ) : null} diff --git a/src/components/ThemeToggle.tsx b/src/components/ThemeToggle.tsx index ae8113f..b9c83c9 100644 --- a/src/components/ThemeToggle.tsx +++ b/src/components/ThemeToggle.tsx @@ -1,4 +1,4 @@ -import * as React from "react"; +import type * as React from "react"; import { Badge } from "@/components/ui/badge"; import { Switch, type SwitchProps } from "@/components/ui/switch"; import { cn } from "@/lib/utils"; @@ -32,11 +32,9 @@ export function ThemeToggle({ return (
-

{label}

+

{label}

{description ? ( -

- {description} -

+

{description}

) : null}
diff --git a/src/components/TitleBar.tsx b/src/components/TitleBar.tsx index 0fd8b7e..f45a709 100644 --- a/src/components/TitleBar.tsx +++ b/src/components/TitleBar.tsx @@ -1,5 +1,6 @@ -import { useState, useEffect, useMemo, type MouseEvent } from "react"; import { getCurrentWindow } from "@tauri-apps/api/window"; +import { useEffect, useMemo, useState } from "react"; +import { isApplePlatform } from "../lib/platform"; import { WindowControls } from "./title-bar/WindowControls"; import { Button } from "./ui/button"; import { @@ -13,12 +14,7 @@ import { export function TitleBar() { const appWindow = getCurrentWindow(); const [isMaximized, setIsMaximized] = useState(false); - const isMac = useMemo( - () => - typeof navigator !== "undefined" && - /Mac|iPhone|iPad|iPod/.test(navigator.userAgent), - [], - ); + const isMac = useMemo(() => isApplePlatform(), []); useEffect(() => { void appWindow.isMaximized().then(setIsMaximized); @@ -37,22 +33,10 @@ export function TitleBar() { void appWindow.close(); } - function handleDragStart(event: MouseEvent) { - if (event.button !== 0) { - return; - } - const target = event.target as HTMLElement; - if (target.closest("[data-no-drag]")) { - return; - } - void appWindow.startDragging(); - } - return (
{isMac ? ( @@ -66,7 +50,7 @@ export function TitleBar() { ) : null} LoopForge diff --git a/src/components/layout/AppSidebar.tsx b/src/components/layout/AppSidebar.tsx index 7cbf5cc..8cfd087 100644 --- a/src/components/layout/AppSidebar.tsx +++ b/src/components/layout/AppSidebar.tsx @@ -1,15 +1,28 @@ -import { useEffect, useMemo, useState } from "react"; -import { NavLink, useLocation, useNavigate } from "react-router"; import { Bell, ChevronDown, House, Plus } from "lucide-react"; -import { ThemeToggle } from "../ThemeToggle"; -import { Button } from "../ui/button"; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; -import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarMenu, SidebarMenuButton, SidebarMenuItem, SidebarMenuLabel, SidebarTrigger, useSidebar } from "../ui/sidebar"; +import { useEffect, useState } from "react"; +import { NavLink, useLocation, useNavigate } from "react-router"; import { useProjectStore } from "../../stores/projectStore"; -import type { Project } from "../../types/project"; import { useThemeStore } from "../../stores/themeStore"; import { useWizardStore } from "../../stores/wizardStore"; -import { ACTIVE_PROJECT_STATUSES, FINISHED_PROJECT_STATUSES } from "../../lib/project-status"; +import type { Project } from "../../types/project"; +import { ThemeToggle } from "../ThemeToggle"; +import { Button } from "../ui/button"; +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../ui/collapsible"; +import { + Sidebar, + SidebarContent, + SidebarFooter, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, + SidebarMenuLabel, + SidebarTrigger, + useSidebar, +} from "../ui/sidebar"; import { ProjectMenuItem } from "./ProjectMenuItem"; type AppSidebarProps = { onToggleNotifications: () => void; totalUnread: number }; @@ -40,16 +53,18 @@ export function AppSidebar({ onToggleNotifications, totalUnread }: AppSidebarPro const navigate = useNavigate(); const location = useLocation(); const projects = useProjectStore((state) => state.projects); + const grouped = useProjectStore((state) => state.grouped); const theme = useThemeStore((state) => state.theme); const setTheme = useThemeStore((state) => state.setTheme); const { collapsed } = useSidebar(); - const [sectionOpen, setSectionOpen] = useState>(readSavedSections); - const { activeProjects, draftProjects, finishedProjects, archivedProjects } = useMemo(() => ({ - activeProjects: projects.filter((project) => ACTIVE_PROJECT_STATUSES.includes(project.status)), - draftProjects: projects.filter((project) => project.status === "draft"), - finishedProjects: projects.filter((project) => FINISHED_PROJECT_STATUSES.includes(project.status)), - archivedProjects: projects.filter((project) => project.status === "archived"), - }), [projects]); + const [sectionOpen, setSectionOpen] = + useState>(readSavedSections); + const { + active: activeProjects, + drafts: draftProjects, + finished: finishedProjects, + archived: archivedProjects, + } = grouped; const collapsedProjects = activeProjects; useEffect(() => { @@ -62,11 +77,13 @@ export function AppSidebar({ onToggleNotifications, totalUnread }: AppSidebarPro if (collapsed) { return null; } - return

{emptyLabel}

; + return

{emptyLabel}

; } return ( - {list.map((project) => )} + {list.map((project) => ( + + ))} ); } @@ -81,13 +98,16 @@ export function AppSidebar({ onToggleNotifications, totalUnread }: AppSidebarPro className="space-y-1" > - -
+
{renderProjectList(list, `No ${label.toLowerCase()}`)}
@@ -96,14 +116,31 @@ export function AppSidebar({ onToggleNotifications, totalUnread }: AppSidebarPro } return ( - + -
- +
+ {collapsed ? null : ( - - LF - + + LF + AI Loop Orchestrator @@ -111,29 +148,42 @@ export function AppSidebar({ onToggleNotifications, totalUnread }: AppSidebarPro
- + Workspace - navigate("/")}> + navigate("/")} + > Home @@ -142,21 +192,21 @@ export function AppSidebar({ onToggleNotifications, totalUnread }: AppSidebarPro - + Projects {projects.length === 0 ? ( -

No projects yet

+

No projects yet

) : collapsed ? ( renderProjectList(collapsedProjects, "No projects") ) : ( <>
-

+

Active

-
+
{renderProjectList(activeProjects, "No active loops")}
diff --git a/src/components/layout/ProjectMenuItem.tsx b/src/components/layout/ProjectMenuItem.tsx index 014b15e..1334692 100644 --- a/src/components/layout/ProjectMenuItem.tsx +++ b/src/components/layout/ProjectMenuItem.tsx @@ -1,19 +1,11 @@ import { useNavigate } from "react-router"; -import { StatusBadge } from "../StatusBadge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; -import { SidebarMenuButton, SidebarMenuItem, SidebarMenuLabel, useSidebar } from "../ui/sidebar"; import { cn } from "../../lib/utils"; -import { useNotificationStore, type RingColor } from "../../stores/notificationStore"; -import { type Project } from "../../stores/projectStore"; -import { PROJECT_STATUS_META } from "../../lib/project-status"; - -const WIZARD_STEP_LABEL: Record = { - describe: "Describe", - plan: "Planning", - atomize: "Atomizing", - configure: "Configure", - launch: "Launch", -}; +import { useDisplayVocabularyStore } from "../../stores/displayVocabularyStore"; +import { type RingColor, useNotificationStore } from "../../stores/notificationStore"; +import type { Project } from "../../stores/projectStore"; +import { StatusBadge, type StatusBadgeStatus } from "../StatusBadge"; +import { SidebarMenuButton, SidebarMenuItem, SidebarMenuLabel, useSidebar } from "../ui/sidebar"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../ui/tooltip"; const RING_CLASSES: Record = { red: "border-l-2 border-l-blocked", @@ -22,8 +14,8 @@ const RING_CLASSES: Record = { green: "border-l-2 border-l-success", }; -const STATUS_DOT: Record = { - active: "bg-running", +const STATUS_DOT: Record = { + running: "bg-running", paused: "bg-paused", blocked: "bg-blocked", completed: "bg-success", @@ -46,10 +38,16 @@ export function ProjectMenuItem({ currentPath, project }: ProjectMenuItemProps) const navigate = useNavigate(); const { collapsed } = useSidebar(); const href = projectHref(project); - const statusMeta = PROJECT_STATUS_META[project.status]; - const draftSubLabel = project.status === "draft" && project.wizardStep - ? WIZARD_STEP_LABEL[project.wizardStep] ?? project.wizardStep - : null; + const vocabulary = useDisplayVocabularyStore((state) => state.vocabulary); + const statusMeta = vocabulary?.projectStatusMeta[project.status] ?? { + badgeStatus: project.status === "active" ? "running" : project.status, + cardLabel: `${project.status.slice(0, 1).toUpperCase()}${project.status.slice(1)}`, + sidebarLabel: `${project.status.slice(0, 1).toUpperCase()}${project.status.slice(1)}`, + }; + const draftSubLabel = + project.status === "draft" && project.wizardStep + ? (vocabulary?.wizardStepLabels[project.wizardStep] ?? project.wizardStep) + : null; const sidebarLabel = draftSubLabel ? `Draft · ${draftSubLabel}` : statusMeta.sidebarLabel; const ringColor = useNotificationStore((state) => state.ringColorForProject(project.id)); const unreadCount = useNotificationStore((state) => state.unreadCountForProject(project.id)); @@ -64,17 +62,29 @@ export function ProjectMenuItem({ currentPath, project }: ProjectMenuItemProps) navigate(href)} > - + {project.name.slice(0, 2) || "??"} - - {unreadCount > 0 ? : null} + + {unreadCount > 0 ? ( + + ) : null} - {tooltipText} + + {tooltipText} + @@ -93,21 +103,27 @@ export function ProjectMenuItem({ currentPath, project }: ProjectMenuItemProps) > {project.name} - {project.id.slice(0, 8)} + + {project.id.slice(0, 8)} + - {unreadCount > 0 ? : null} + {unreadCount > 0 ? ( + + ) : null} - {tooltipText} + + {tooltipText} + {project.totalStories != null && project.totalStories > 0 ? ( -

+

{project.storiesCompleted ?? 0}/{project.totalStories} stories

) : null} diff --git a/src/components/markdownComponents.tsx b/src/components/markdownComponents.tsx index d87e8cb..87d58b7 100644 --- a/src/components/markdownComponents.tsx +++ b/src/components/markdownComponents.tsx @@ -1,86 +1,120 @@ import type { Components } from "react-markdown"; -export const MARKDOWN_COMPONENTS: Components = { - h1: ({ children }) => ( -

- {children} -

- ), - h2: ({ children }) => ( -

{children}

- ), - h3: ({ children }) => ( -

{children}

- ), - p: ({ children }) => ( -

{children}

- ), - ul: ({ children }) => ( -
    {children}
- ), - ol: ({ children }) => ( -
    {children}
- ), - li: ({ children }) => ( -
  • - - {children} -
  • - ), - code: ({ children, className }) => { - const isBlock = className?.includes("language-"); - if (isBlock) { +type MarkdownDensity = "compact" | "comfortable"; + +function spacing(density: MarkdownDensity, compactValue: string, comfortableValue: string) { + return density === "compact" ? compactValue : comfortableValue; +} + +export function createMarkdownComponents(density: MarkdownDensity): Components { + return { + h1: ({ children }) => ( +

    + {children} +

    + ), + h2: ({ children }) => ( +

    + {children} +

    + ), + h3: ({ children }) => ( +

    + {children} +

    + ), + p: ({ children }) => ( +

    + {children} +

    + ), + ul: ({ children }) => ( +
      {children}
    + ), + ol: ({ children }) => ( +
      + {children} +
    + ), + li: ({ children }) => ( +
  • + + {children} +
  • + ), + code: ({ children, className }) => { + const isBlock = className?.includes("language-"); + if (isBlock) { + return ( + + {children} + + ); + } return ( - + {children} ); - } - return ( - + }, + pre: ({ children }) =>
    {children}
    , + blockquote: ({ children }) => ( +
    + {children} +
    + ), + strong: ({ children }) => {children}, + em: ({ children }) => {children}, + a: ({ children, href }) => ( + + {children} + + ), + hr: () =>
    , + table: ({ children }) => ( +
    + {children}
    +
    + ), + thead: ({ children }) => {children}, + th: ({ children }) => ( + {children} -
    - ); - }, - pre: ({ children }) =>
    {children}
    , - blockquote: ({ children }) => ( -
    - {children} -
    - ), - strong: ({ children }) => ( - {children} - ), - em: ({ children }) => ( - {children} - ), - a: ({ children, href }) => ( - - {children} - - ), - hr: () =>
    , - table: ({ children }) => ( -
    - + + ), + td: ({ children }) => ( +
    {children} -
    -
    - ), - thead: ({ children }) => ( - {children} - ), - th: ({ children }) => ( - - {children} - - ), - td: ({ children }) => ( - {children} - ), -}; + + ), + }; +} + +export const MARKDOWN_COMPONENTS = createMarkdownComponents("compact"); + +export const MARKDOWN_COMPONENTS_COMFORTABLE = createMarkdownComponents("comfortable"); diff --git a/src/components/notification-panel/NotificationPanelLayout.tsx b/src/components/notification-panel/NotificationPanelLayout.tsx index 89d6113..ab536b1 100644 --- a/src/components/notification-panel/NotificationPanelLayout.tsx +++ b/src/components/notification-panel/NotificationPanelLayout.tsx @@ -1,29 +1,10 @@ +import { cn } from "@/lib/utils"; +import { useDisplayVocabularyStore } from "@/stores/displayVocabularyStore"; +import type { AppNotification, NotificationType, RingColor } from "../../stores/notificationStore"; import { Badge, type BadgeProps } from "../ui/badge"; import { Button } from "../ui/button"; import { ScrollArea, ScrollContent, ScrollViewport } from "../ui/scroll-area"; import { SheetDescription, SheetHeader, SheetTitle } from "../ui/sheet"; -import { cn } from "@/lib/utils"; -import { - type AppNotification, - type NotificationType, - type RingColor, -} from "../../stores/notificationStore"; - -const STATUS_VARIANT: Record> = { - red: "danger", - amber: "warning", - cyan: "info", - green: "success", -}; - -const TYPE_LABEL: Record = { - story_blocked: "Blocked", - loop_completed: "Completed", - rate_limited: "Rate limit", - review_comment: "Review", - loop_error: "Error", - story_completed: "Story done", -}; type NotificationPanelLayoutProps = { notifications: AppNotification[]; @@ -45,26 +26,40 @@ function formatTimeAgo(timestamp: number): string { function NotificationRow({ notification, + ringVariantMap, + typeLabelMap, onSelectNotification, }: { notification: AppNotification; + ringVariantMap: Record; + typeLabelMap: Record; onSelectNotification: (notification: AppNotification) => void; }) { return ( @@ -62,10 +66,12 @@ export function WindowControls({ > {isMaximized ? ( + Restore ) : ( + Maximize + Close diff --git a/src/components/ui/alert-dialog.tsx b/src/components/ui/alert-dialog.tsx index 251c933..11d1286 100644 --- a/src/components/ui/alert-dialog.tsx +++ b/src/components/ui/alert-dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import * as React from "react"; import * as AlertDialogPrimitive from "@radix-ui/react-alert-dialog"; +import * as React from "react"; import { cn } from "@/lib/utils"; import { buttonVariants } from "./button"; @@ -17,7 +17,7 @@ const AlertDialogOverlay = React.forwardRef< ) => ( +const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
    ); -const AlertDialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( +const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
    (({ className, ...props }, ref) => ( )); @@ -78,7 +72,7 @@ const AlertDialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/ui/badge.tsx b/src/components/ui/badge.tsx index dc9f70b..244e91f 100644 --- a/src/components/ui/badge.tsx +++ b/src/components/ui/badge.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; import { cn } from "@/lib/utils"; const badgeVariants = cva( - "inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-sans font-semibold leading-none transition-colors", + "inline-flex items-center rounded-full border px-2 py-0.5 font-sans font-semibold text-[11px] leading-none transition-colors", { variants: { variant: { @@ -53,16 +53,10 @@ const badgeVariants = cva( }, ); -export type BadgeProps = React.HTMLAttributes & - VariantProps; +export type BadgeProps = React.HTMLAttributes & VariantProps; function Badge({ className, variant, emphasis, ...props }: BadgeProps) { - return ( - - ); + return ; } export { Badge, badgeVariants }; diff --git a/src/components/ui/button.tsx b/src/components/ui/button.tsx index 40e3704..f050525 100644 --- a/src/components/ui/button.tsx +++ b/src/components/ui/button.tsx @@ -1,17 +1,17 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 rounded-md border border-transparent text-sm font-sans font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50", + "inline-flex items-center justify-center gap-2 rounded-md border border-transparent font-medium font-sans text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50", { variants: { variant: { primary: "bg-primary text-primary-foreground hover:bg-primary/80", - secondary: "bg-elevated text-text border-border hover:bg-surface", + secondary: "border-border bg-elevated text-text hover:bg-surface", ghost: "bg-transparent text-text-muted hover:bg-elevated/70 hover:text-text", - outline: "bg-transparent text-text border-border hover:bg-elevated/70", + outline: "border-border bg-transparent text-text hover:bg-elevated/70", destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/85", }, size: { diff --git a/src/components/ui/card.tsx b/src/components/ui/card.tsx index a545c6a..013a769 100644 --- a/src/components/ui/card.tsx +++ b/src/components/ui/card.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -16,18 +16,11 @@ const cardVariants = cva("rounded-lg border text-text shadow-sm", { }, }); -export type CardProps = React.HTMLAttributes & - VariantProps; +export type CardProps = React.HTMLAttributes & VariantProps; const Card = React.forwardRef( ({ className, variant, ...props }, ref) => { - return ( -
    - ); + return
    ; }, ); Card.displayName = "Card"; @@ -39,7 +32,7 @@ const CardHeader = React.forwardRef( return (
    ); @@ -54,7 +47,7 @@ const CardTitle = React.forwardRef( return (

    ); @@ -64,18 +57,17 @@ CardTitle.displayName = "CardTitle"; export type CardDescriptionProps = React.HTMLAttributes; -const CardDescription = React.forwardRef< - HTMLParagraphElement, - CardDescriptionProps ->(({ className, ...props }, ref) => { - return ( -

    - ); -}); +const CardDescription = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +

    + ); + }, +); CardDescription.displayName = "CardDescription"; export type CardContentProps = React.HTMLAttributes; @@ -94,7 +86,7 @@ const CardFooter = React.forwardRef( return (

    ); diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index ff940ea..f689952 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -1,8 +1,8 @@ "use client"; -import * as React from "react"; import { Command as CommandPrimitive } from "cmdk"; import { Search } from "lucide-react"; +import * as React from "react"; import { cn } from "@/lib/utils"; import { Dialog, DialogContent, DialogDescription, DialogTitle } from "./dialog"; @@ -13,7 +13,10 @@ const Command = React.forwardRef< >(({ className, ...props }, ref) => ( )); @@ -26,7 +29,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => ( Command palette Search and run available actions. - + {children} @@ -37,9 +40,13 @@ const CommandInput = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( -
    +
    - +
    )); CommandInput.displayName = CommandPrimitive.Input.displayName; @@ -48,21 +55,35 @@ const CommandList = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); CommandList.displayName = CommandPrimitive.List.displayName; const CommandEmpty = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef ->((props, ref) => ); +>((props, ref) => ( + +)); CommandEmpty.displayName = CommandPrimitive.Empty.displayName; const CommandGroup = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); CommandGroup.displayName = CommandPrimitive.Group.displayName; @@ -70,7 +91,11 @@ const CommandSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); CommandSeparator.displayName = CommandPrimitive.Separator.displayName; @@ -78,12 +103,16 @@ const CommandItem = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); CommandItem.displayName = CommandPrimitive.Item.displayName; const CommandShortcut = ({ className, ...props }: React.HTMLAttributes) => ( - + ); CommandShortcut.displayName = "CommandShortcut"; diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx index f7c86ab..c5fb255 100644 --- a/src/components/ui/dialog.tsx +++ b/src/components/ui/dialog.tsx @@ -1,7 +1,7 @@ "use client"; -import * as React from "react"; import * as DialogPrimitive from "@radix-ui/react-dialog"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -17,7 +17,7 @@ const DialogOverlay = React.forwardRef< ) => ( +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => (
    ); -const DialogFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( +const DialogFooter = ({ className, ...props }: React.HTMLAttributes) => (
    (({ className, ...props }, ref) => ( )); @@ -78,7 +72,7 @@ const DialogDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/ui/dropdown-menu.tsx b/src/components/ui/dropdown-menu.tsx index 82c0ec1..88e54aa 100644 --- a/src/components/ui/dropdown-menu.tsx +++ b/src/components/ui/dropdown-menu.tsx @@ -1,8 +1,8 @@ "use client"; -import * as React from "react"; import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"; import { Check, ChevronRight, Circle } from "lucide-react"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -41,7 +41,7 @@ const DropdownMenuSubContent = React.forwardRef< (({ className, inset, ...props }, ref) => ( )); @@ -148,12 +148,16 @@ const DropdownMenuSeparator = React.forwardRef< React.ElementRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName; const DropdownMenuShortcut = ({ className, ...props }: React.HTMLAttributes) => ( - + ); DropdownMenuShortcut.displayName = "DropdownMenuShortcut"; diff --git a/src/components/ui/empty.tsx b/src/components/ui/empty.tsx index 09596a7..356f1d2 100644 --- a/src/components/ui/empty.tsx +++ b/src/components/ui/empty.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; import { cn } from "@/lib/utils"; @@ -57,9 +57,9 @@ function Empty({ > {icon ?
    {icon}
    : null}
    -

    {title}

    +

    {title}

    {description ? ( -

    +

    {description}

    ) : null} diff --git a/src/components/ui/field.tsx b/src/components/ui/field.tsx index de020bf..6ece958 100644 --- a/src/components/ui/field.tsx +++ b/src/components/ui/field.tsx @@ -1,14 +1,14 @@ -import * as React from "react"; import { cva } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; import { Label } from "./label"; const fieldControlVariants = cva( - "w-full rounded-md border border-border bg-surface px-3 py-2 text-sm font-sans text-text shadow-sm transition-[border-color,box-shadow,color,background-color] outline-none placeholder:text-text-dim focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-border/60 disabled:bg-elevated disabled:text-text-dim aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/30", + "w-full rounded-md border border-border bg-surface px-3 py-2 font-sans text-sm text-text shadow-sm outline-none transition-[border-color,box-shadow,color,background-color] placeholder:text-text-dim focus-visible:border-primary focus-visible:ring-2 focus-visible:ring-ring/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:cursor-not-allowed disabled:border-border/60 disabled:bg-elevated disabled:text-text-dim aria-[invalid=true]:border-destructive aria-[invalid=true]:focus-visible:border-destructive aria-[invalid=true]:focus-visible:ring-destructive/30", ); -const fieldMessageVariants = cva("text-xs font-sans leading-relaxed", { +const fieldMessageVariants = cva("font-sans text-xs leading-relaxed", { variants: { tone: { default: "text-text-muted", @@ -49,9 +49,9 @@ export function getFieldControlProps({ ariaInvalid === undefined ? invalid : ariaInvalid === true || - ariaInvalid === "true" || - ariaInvalid === "grammar" || - ariaInvalid === "spelling"; + ariaInvalid === "true" || + ariaInvalid === "grammar" || + ariaInvalid === "spelling"; return { disabled, @@ -77,13 +77,7 @@ export type FieldMessageProps = React.HTMLAttributes & { const FieldMessage = React.forwardRef( ({ className, tone = "default", ...props }, ref) => { - return ( -

    - ); + return

    ; }, ); @@ -135,19 +129,18 @@ const Field = React.forwardRef( generatedId; const message = errorText ?? helperText; const messageId = message ? `${controlId}-message` : undefined; - const enhancedChild = - childElement - ? React.cloneElement(childElement, { - id: controlId, - ...getFieldControlProps({ - describedBy: messageId, - disabled: childElement.props.disabled ?? disabled, - invalid, - ariaDescribedBy: childElement.props["aria-describedby"], - ariaInvalid: childElement.props["aria-invalid"], - }), - }) - : child; + const enhancedChild = childElement + ? React.cloneElement(childElement, { + id: controlId, + ...getFieldControlProps({ + describedBy: messageId, + disabled: childElement.props.disabled ?? disabled, + invalid, + ariaDescribedBy: childElement.props["aria-describedby"], + ariaInvalid: childElement.props["aria-invalid"], + }), + }) + : child; return ( diff --git a/src/components/ui/label.tsx b/src/components/ui/label.tsx index 15804db..247e499 100644 --- a/src/components/ui/label.tsx +++ b/src/components/ui/label.tsx @@ -1,10 +1,10 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; const labelVariants = cva( - "flex items-center gap-2 text-xs font-sans font-medium uppercase tracking-[0.16em] text-text-muted", + "flex items-center gap-2 font-medium font-sans text-text-muted text-xs uppercase tracking-[0.16em]", { variants: { disabled: { @@ -30,21 +30,39 @@ export type LabelProps = React.LabelHTMLAttributes & }; const Label = React.forwardRef( - ({ children, className, disabled, invalid, optionalText, required, ...props }, ref) => { - return ( - + + ); + + if (htmlFor) { + return ( + + ); + } + + return ( +

    } + className={cn(labelVariants({ disabled, invalid }), className)} + > + {content} +
    ); }, ); diff --git a/src/components/ui/progress.tsx b/src/components/ui/progress.tsx index a2451d8..1349996 100644 --- a/src/components/ui/progress.tsx +++ b/src/components/ui/progress.tsx @@ -1,20 +1,23 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import type * as React from "react"; import { cn } from "@/lib/utils"; -const progressIndicatorVariants = cva("h-full rounded-full transition-[width,background-color] duration-300", { - variants: { - tone: { - default: "bg-primary", - info: "bg-running", - danger: "bg-destructive", +const progressIndicatorVariants = cva( + "h-full rounded-full transition-[width,background-color] duration-300", + { + variants: { + tone: { + default: "bg-primary", + info: "bg-running", + danger: "bg-destructive", + }, + }, + defaultVariants: { + tone: "default", }, }, - defaultVariants: { - tone: "default", - }, -}); +); export type ProgressProps = React.HTMLAttributes & VariantProps & { @@ -57,10 +60,10 @@ function Progress({ return (
    {label || hint || showValue ? ( -
    +
    {label ? ( -

    +

    {label}

    ) : null} diff --git a/src/components/ui/resizable.tsx b/src/components/ui/resizable.tsx index 95a0824..e0ec93e 100644 --- a/src/components/ui/resizable.tsx +++ b/src/components/ui/resizable.tsx @@ -49,9 +49,11 @@ function resizePanels(sizes: number[], panels: PanelConfig[], handleIndex: numbe }); } -export type ResizablePanelGroupProps = React.HTMLAttributes & { direction?: Direction }; +export type ResizablePanelGroupProps = React.HTMLAttributes & { + direction?: Direction; +}; export type ResizablePanelProps = React.HTMLAttributes & PanelConfig; -export type ResizableHandleProps = React.HTMLAttributes & { +export type ResizableHandleProps = React.ButtonHTMLAttributes & { step?: number; withGrip?: boolean; }; @@ -76,9 +78,13 @@ const ResizablePanel = React.forwardRef( ResizablePanel.displayName = "ResizablePanel"; -const ResizableHandle = React.forwardRef( - ({ className, handleIndex = 0, onKeyDown, onPointerDown, step = 5, withGrip = true, ...props }, ref) => { - const { containerRef, direction, panels, setSizes, sizes } = useResizableContext("ResizableHandle"); +const ResizableHandle = React.forwardRef( + ( + { className, handleIndex = 0, onKeyDown, onPointerDown, step = 5, withGrip = true, ...props }, + ref, + ) => { + const { containerRef, direction, panels, setSizes, sizes } = + useResizableContext("ResizableHandle"); const startRef = React.useRef<{ point: number; sizes: number[] } | null>(null); React.useEffect(() => { @@ -109,10 +115,15 @@ const ResizableHandle = React.forwardRef( }, [containerRef, direction, handleIndex, panels, setSizes]); return ( -
    { onKeyDown?.(event); if (event.defaultPrevented) return; @@ -127,18 +138,28 @@ const ResizableHandle = React.forwardRef( onPointerDown={(event) => { onPointerDown?.(event); if (event.defaultPrevented) return; - startRef.current = { point: direction === "horizontal" ? event.clientX : event.clientY, sizes }; + startRef.current = { + point: direction === "horizontal" ? event.clientX : event.clientY, + sizes, + }; document.body.style.cursor = direction === "horizontal" ? "col-resize" : "row-resize"; document.body.style.userSelect = "none"; event.currentTarget.setPointerCapture(event.pointerId); }} - role="separator" - tabIndex={0} data-direction={direction} {...props} > - {withGrip ? : null} -
    + {withGrip ? ( + + ) : null} + ); }, ); @@ -149,19 +170,40 @@ const ResizablePanelGroup = React.forwardRef { const containerRef = React.useRef(null); const panels = React.useMemo( - () => React.Children.toArray(children).flatMap((child) => !React.isValidElement(child) || child.type !== ResizablePanel ? [] : [{ defaultSize: child.props.defaultSize, maxSize: child.props.maxSize, minSize: child.props.minSize }]), + () => + React.Children.toArray(children).flatMap((child) => + !React.isValidElement(child) || child.type !== ResizablePanel + ? [] + : [ + { + defaultSize: child.props.defaultSize, + maxSize: child.props.maxSize, + minSize: child.props.minSize, + }, + ], + ), [children], ); const [sizes, setSizes] = React.useState(() => buildSizes(panels)); - React.useEffect(() => setSizes((current) => current.length === panels.length ? current : buildSizes(panels)), [panels]); + React.useEffect( + () => + setSizes((current) => (current.length === panels.length ? current : buildSizes(panels))), + [panels], + ); let panelIndex = 0; let handleIndex = 0; const items = React.Children.map(children, (child) => { if (!React.isValidElement(child)) return child; - if (child.type === ResizablePanel) return React.cloneElement(child as React.ReactElement, { panelIndex: panelIndex++ }); - if (child.type === ResizableHandle) return React.cloneElement(child as React.ReactElement, { handleIndex: handleIndex++ }); + if (child.type === ResizablePanel) + return React.cloneElement(child as React.ReactElement, { + panelIndex: panelIndex++, + }); + if (child.type === ResizableHandle) + return React.cloneElement(child as React.ReactElement, { + handleIndex: handleIndex++, + }); return child; }); @@ -172,7 +214,11 @@ const ResizablePanelGroup = React.forwardRef diff --git a/src/components/ui/scroll-area.tsx b/src/components/ui/scroll-area.tsx index e2807bb..b5141ac 100644 --- a/src/components/ui/scroll-area.tsx +++ b/src/components/ui/scroll-area.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -7,18 +7,24 @@ export type ScrollAreaProps = React.HTMLAttributes; const ScrollArea = React.forwardRef( ({ className, ...props }, ref) => { - return
    ; + return ( +
    + ); }, ); ScrollArea.displayName = "ScrollArea"; -const scrollViewportVariants = cva("h-full w-full min-h-0 min-w-0 overscroll-contain", { +const scrollViewportVariants = cva("h-full min-h-0 w-full min-w-0 overscroll-contain", { variants: { orientation: { both: "overflow-auto", horizontal: "overflow-x-auto overflow-y-hidden", - vertical: "overflow-x-hidden overflow-y-auto", + vertical: "overflow-y-auto overflow-x-hidden", }, padding: { none: "", diff --git a/src/components/ui/select.tsx b/src/components/ui/select.tsx index fee1f32..f2e6df2 100644 --- a/src/components/ui/select.tsx +++ b/src/components/ui/select.tsx @@ -1,6 +1,6 @@ -import * as React from "react"; import * as SelectPrimitive from "@radix-ui/react-select"; import { Check, ChevronDown, ChevronUp } from "lucide-react"; +import * as React from "react"; import { cn } from "@/lib/utils"; const Select = SelectPrimitive.Root; @@ -14,7 +14,7 @@ const SelectTrigger = React.forwardRef< span]:line-clamp-1", className, @@ -33,7 +33,11 @@ const SelectScrollUpButton = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); @@ -43,7 +47,11 @@ const SelectScrollDownButton = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); @@ -58,11 +66,12 @@ const SelectContent = React.forwardRef< ref={ref} className={cn( "relative z-50 max-h-60 min-w-[8rem] overflow-hidden rounded-md border border-border bg-surface text-text shadow-md", - "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0", + "data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:animate-out data-[state=open]:animate-in", "data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", "data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2", "data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", - position === "popper" && "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1", + position === "popper" && + "data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=bottom]:translate-y-1 data-[side=top]:-translate-y-1", className, )} position={position} @@ -70,7 +79,11 @@ const SelectContent = React.forwardRef< > {children} @@ -84,7 +97,11 @@ const SelectLabel = React.forwardRef< React.ComponentRef, React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); SelectLabel.displayName = SelectPrimitive.Label.displayName; @@ -95,7 +112,7 @@ const SelectItem = React.forwardRef< , React.ComponentPropsWithoutRef >(({ className, ...props }, ref) => ( - + )); SelectSeparator.displayName = SelectPrimitive.Separator.displayName; diff --git a/src/components/ui/separator.tsx b/src/components/ui/separator.tsx index b37487e..f9bd6c7 100644 --- a/src/components/ui/separator.tsx +++ b/src/components/ui/separator.tsx @@ -1,5 +1,5 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; @@ -23,16 +23,7 @@ export type SeparatorProps = React.HTMLAttributes & }; const Separator = React.forwardRef( - ( - { - className, - orientation = "horizontal", - decorative = true, - tone, - ...props - }, - ref, - ) => { + ({ className, orientation = "horizontal", decorative = true, tone, ...props }, ref) => { return (
    {children} - + Close @@ -70,17 +70,11 @@ const SheetContent = React.forwardRef< )); SheetContent.displayName = DialogPrimitive.Content.displayName; -const SheetHeader = ({ - className, - ...props -}: React.HTMLAttributes) => ( +const SheetHeader = ({ className, ...props }: React.HTMLAttributes) => (
    ); -const SheetFooter = ({ - className, - ...props -}: React.HTMLAttributes) => ( +const SheetFooter = ({ className, ...props }: React.HTMLAttributes) => (
    (({ className, ...props }, ref) => ( )); @@ -105,7 +99,7 @@ const SheetDescription = React.forwardRef< >(({ className, ...props }, ref) => ( )); diff --git a/src/components/ui/sidebar.tsx b/src/components/ui/sidebar.tsx index 0b71a2d..4f14edd 100644 --- a/src/components/ui/sidebar.tsx +++ b/src/components/ui/sidebar.tsx @@ -1,9 +1,13 @@ -import * as React from "react"; import { cva, type VariantProps } from "class-variance-authority"; +import * as React from "react"; import { cn } from "@/lib/utils"; -type SidebarContextValue = { collapsed: boolean; setCollapsed: (value: boolean) => void; toggle: () => void }; +type SidebarContextValue = { + collapsed: boolean; + setCollapsed: (value: boolean) => void; + toggle: () => void; +}; const SidebarContext = React.createContext(null); export function useSidebar() { @@ -18,62 +22,151 @@ export type SidebarProviderProps = React.PropsWithChildren<{ onCollapsedChange?: (collapsed: boolean) => void; }>; -function SidebarProvider({ children, collapsed, defaultCollapsed = false, onCollapsedChange }: SidebarProviderProps) { +function SidebarProvider({ + children, + collapsed, + defaultCollapsed = false, + onCollapsedChange, +}: SidebarProviderProps) { const [uncontrolledCollapsed, setUncontrolledCollapsed] = React.useState(defaultCollapsed); const currentCollapsed = collapsed ?? uncontrolledCollapsed; - const setCollapsed = React.useCallback((nextCollapsed: boolean) => { - if (collapsed === undefined) setUncontrolledCollapsed(nextCollapsed); - onCollapsedChange?.(nextCollapsed); - }, [collapsed, onCollapsedChange]); + const setCollapsed = React.useCallback( + (nextCollapsed: boolean) => { + if (collapsed === undefined) setUncontrolledCollapsed(nextCollapsed); + onCollapsedChange?.(nextCollapsed); + }, + [collapsed, onCollapsedChange], + ); - return setCollapsed(!currentCollapsed) }}>{children}; + return ( + setCollapsed(!currentCollapsed), + }} + > + {children} + + ); } -const sidebarVariants = cva("flex h-full shrink-0 flex-col border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200", { - variants: { - collapsed: { true: "w-16", false: "w-72" }, - side: { left: "border-r", right: "order-last border-l" }, +const sidebarVariants = cva( + "flex h-full shrink-0 flex-col border-sidebar-border bg-sidebar text-sidebar-foreground transition-[width] duration-200", + { + variants: { + collapsed: { true: "w-16", false: "w-72" }, + side: { left: "border-r", right: "order-last border-l" }, + }, + defaultVariants: { collapsed: false, side: "left" }, }, - defaultVariants: { collapsed: false, side: "left" }, -}); +); -const sidebarMenuButtonVariants = cva("flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left text-sm font-sans transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar", { - variants: { - active: { - true: "border-sidebar-primary/20 bg-sidebar-primary/10 text-sidebar-primary", - false: "border-transparent text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground", +const sidebarMenuButtonVariants = cva( + "flex w-full items-center gap-3 rounded-md border px-3 py-2 text-left font-sans text-sm transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring focus-visible:ring-offset-2 focus-visible:ring-offset-sidebar", + { + variants: { + active: { + true: "border-sidebar-primary/20 bg-sidebar-primary/10 text-sidebar-primary", + false: + "border-transparent text-sidebar-foreground/80 hover:bg-sidebar-accent hover:text-sidebar-foreground", + }, + collapsed: { true: "justify-center px-2", false: "" }, }, - collapsed: { true: "justify-center px-2", false: "" }, + defaultVariants: { active: false, collapsed: false }, }, - defaultVariants: { active: false, collapsed: false }, -}); +); export type SidebarLayoutProps = React.HTMLAttributes; -export type SidebarProps = React.HTMLAttributes & VariantProps; -export type SidebarMenuButtonProps = React.ButtonHTMLAttributes & VariantProps; +export type SidebarProps = React.HTMLAttributes & + VariantProps; +export type SidebarMenuButtonProps = React.ButtonHTMLAttributes & + VariantProps; -const SidebarLayout = React.forwardRef(({ className, ...props }, ref) => { - return
    ; -}); +const SidebarLayout = React.forwardRef( + ({ className, ...props }, ref) => { + return ( +
    + ); + }, +); SidebarLayout.displayName = "SidebarLayout"; -const Sidebar = React.forwardRef(({ className, collapsed, side, ...props }, ref) => { - const context = useSidebar(); - const currentCollapsed = collapsed ?? context.collapsed; - return