diff --git a/.github/assets/screenstudio-demo.jpg b/.github/assets/screenstudio-demo.jpg new file mode 100644 index 0000000..077121b Binary files /dev/null and b/.github/assets/screenstudio-demo.jpg differ diff --git a/Cargo.lock b/Cargo.lock index e916761..6e9a6e4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2489,6 +2489,37 @@ dependencies = [ "uuid", ] +[[package]] +name = "loopforge-adapters" +version = "0.1.0" +dependencies = [ + "rusqlite", + "serde", + "thiserror 1.0.69", + "tokio", +] + +[[package]] +name = "loopforge-api" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "loopforge-app" +version = "0.1.0" +dependencies = [ + "async-trait", + "chrono", + "ralph-core", + "serde", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "loopforge-app-core" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index df01094..0711e60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,9 @@ [workspace] members = [ "crates/app-services", + "crates/loopforge-adapters", + "crates/loopforge-api", + "crates/loopforge-app", "crates/loopforge-app-core", "crates/native-shell", "crates/ralph-core", diff --git a/README.md b/README.md index 64049e8..1e871b8 100644 --- a/README.md +++ b/README.md @@ -96,7 +96,11 @@ At any iteration the loop honours three control files: `.ralph-pause` freezes wo ## Demo > [!TIP] -> A one minute video walkthrough is on the [Releases page](https://github.com/taberoajorge/loopforge/releases). For a text walkthrough of a real session, see [Your first loop](#your-first-loop). +> Watch the short product walkthrough on Screen Studio. +> +> [![LoopForge demo preview](.github/assets/screenstudio-demo.jpg)](https://screen.studio/share/1LFJxw9J) +> +> Installers and release notes are on the [Releases page](https://github.com/taberoajorge/loopforge/releases). For a text walkthrough of a real session, see [Your first loop](#your-first-loop). ## Quick start diff --git a/crates/app-services/src/events/ask.rs b/crates/app-services/src/events/ask.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/app-services/src/events/ask.rs @@ -0,0 +1 @@ + diff --git a/crates/app-services/src/events/loop_events.rs b/crates/app-services/src/events/loop_events.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/app-services/src/events/loop_events.rs @@ -0,0 +1 @@ + diff --git a/crates/app-services/src/events/mod.rs b/crates/app-services/src/events/mod.rs new file mode 100644 index 0000000..12a20ef --- /dev/null +++ b/crates/app-services/src/events/mod.rs @@ -0,0 +1,12 @@ +use serde::Serialize; + +pub mod ask; +pub mod loop_events; +pub mod plan; + +pub trait Event: Send + 'static { + const NAME: &'static str; + type Payload: Serialize + Send + 'static; + + fn payload(&self) -> Self::Payload; +} diff --git a/crates/app-services/src/events/plan.rs b/crates/app-services/src/events/plan.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/app-services/src/events/plan.rs @@ -0,0 +1 @@ + diff --git a/crates/app-services/src/lib.rs b/crates/app-services/src/lib.rs index 489762d..791c6b9 100644 --- a/crates/app-services/src/lib.rs +++ b/crates/app-services/src/lib.rs @@ -1,3 +1,4 @@ +pub mod events; pub mod monitor; pub mod project_queries; pub mod session; diff --git a/crates/loopforge-adapters/Cargo.toml b/crates/loopforge-adapters/Cargo.toml new file mode 100644 index 0000000..89cf989 --- /dev/null +++ b/crates/loopforge-adapters/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "loopforge-adapters" +version = "0.1.0" +edition = "2021" + +[dependencies] +rusqlite = { version = "0.32", features = ["bundled"] } +tokio = { version = "1", features = ["full"] } +thiserror = "1" +serde = { version = "1", features = ["derive"] } diff --git a/crates/loopforge-adapters/src/channel_events.rs b/crates/loopforge-adapters/src/channel_events.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-adapters/src/channel_events.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-adapters/src/filesystem/mod.rs b/crates/loopforge-adapters/src/filesystem/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-adapters/src/filesystem/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-adapters/src/inmemory/mod.rs b/crates/loopforge-adapters/src/inmemory/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-adapters/src/inmemory/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-adapters/src/lib.rs b/crates/loopforge-adapters/src/lib.rs new file mode 100644 index 0000000..72eb8b6 --- /dev/null +++ b/crates/loopforge-adapters/src/lib.rs @@ -0,0 +1,5 @@ +pub mod channel_events; +pub mod filesystem; +pub mod inmemory; +pub mod paths; +pub mod sqlite; diff --git a/crates/loopforge-adapters/src/paths.rs b/crates/loopforge-adapters/src/paths.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-adapters/src/paths.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-adapters/src/sqlite/mod.rs b/crates/loopforge-adapters/src/sqlite/mod.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-adapters/src/sqlite/mod.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-api/Cargo.toml b/crates/loopforge-api/Cargo.toml new file mode 100644 index 0000000..1028214 --- /dev/null +++ b/crates/loopforge-api/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "loopforge-api" +version = "0.1.0" +edition = "2021" +description = "API contract definitions for LoopForge methods and events" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" diff --git a/crates/loopforge-api/src/events.rs b/crates/loopforge-api/src/events.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-api/src/events.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-api/src/lib.rs b/crates/loopforge-api/src/lib.rs new file mode 100644 index 0000000..4174cb7 --- /dev/null +++ b/crates/loopforge-api/src/lib.rs @@ -0,0 +1,2 @@ +pub mod events; +pub mod methods; diff --git a/crates/loopforge-api/src/methods.rs b/crates/loopforge-api/src/methods.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-api/src/methods.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-app/Cargo.toml b/crates/loopforge-app/Cargo.toml new file mode 100644 index 0000000..a186c97 --- /dev/null +++ b/crates/loopforge-app/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "loopforge-app" +version = "0.1.0" +edition = "2021" +description = "Application layer for LoopForge hexagonal architecture" + +[dependencies] +ralph-core = { path = "../ralph-core" } +async-trait = "0.1" +serde = { version = "1", features = ["derive"] } +thiserror = "2" +tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"] } +chrono = { version = "0.4", features = ["serde"] } +uuid = { version = "1", features = ["v4", "serde"] } diff --git a/crates/loopforge-app/src/errors.rs b/crates/loopforge-app/src/errors.rs new file mode 100644 index 0000000..e8244c7 --- /dev/null +++ b/crates/loopforge-app/src/errors.rs @@ -0,0 +1,50 @@ +use std::path::PathBuf; +use thiserror::Error; +use uuid::Uuid; + +pub type AppResult = Result; + +#[derive(Debug, Error)] +pub enum AppError { + #[error("Project not found: {0}")] + ProjectNotFound(Uuid), + + #[error("Session not found: {0}")] + SessionNotFound(Uuid), + + #[error("Artifact error for '{path}': {message}")] + ArtifactError { path: PathBuf, message: String }, + + #[error("Storage error: {0}")] + StorageError(String), + + #[error("Validation error: {0}")] + ValidationError(String), + + #[error("Service error in '{service}': {message}")] + ServiceError { service: String, message: String }, +} + +impl AppError { + pub fn artifact(path: impl Into, message: impl Into) -> Self { + Self::ArtifactError { + path: path.into(), + message: message.into(), + } + } + + pub fn storage(message: impl Into) -> Self { + Self::StorageError(message.into()) + } + + pub fn validation(message: impl Into) -> Self { + Self::ValidationError(message.into()) + } + + pub fn service(service: impl Into, message: impl Into) -> Self { + Self::ServiceError { + service: service.into(), + message: message.into(), + } + } +} diff --git a/crates/loopforge-app/src/lib.rs b/crates/loopforge-app/src/lib.rs new file mode 100644 index 0000000..d9f07e1 --- /dev/null +++ b/crates/loopforge-app/src/lib.rs @@ -0,0 +1,4 @@ +pub mod errors; +pub mod models; +pub mod ports; +pub mod services; diff --git a/crates/loopforge-app/src/models.rs b/crates/loopforge-app/src/models.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-app/src/models.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-app/src/ports.rs b/crates/loopforge-app/src/ports.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-app/src/ports.rs @@ -0,0 +1 @@ + diff --git a/crates/loopforge-app/src/services.rs b/crates/loopforge-app/src/services.rs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/crates/loopforge-app/src/services.rs @@ -0,0 +1 @@ + diff --git a/crates/native-shell/src/services/backend_adapter.rs b/crates/native-shell/src/services/backend_adapter.rs index 562feab..e6d6fee 100644 --- a/crates/native-shell/src/services/backend_adapter.rs +++ b/crates/native-shell/src/services/backend_adapter.rs @@ -147,8 +147,9 @@ mod tests { }) }) .expect("open plan"); - let output = BackendAdapter::write_plan_input("project-1", "Continue", |_, _| Ok::<_, ()>(())) - .expect("write plan"); + let output = + BackendAdapter::write_plan_input("project-1", "Continue", |_, _| Ok::<_, ()>(())) + .expect("write plan"); let stopped = BackendAdapter::stop_plan_session("project-1", |_| Ok::<_, ()>(())).expect("stop plan"); @@ -171,7 +172,9 @@ mod tests { project_id: String::from("project-1"), }; let stages = BackendAdapter::atomizer_stages(request.clone(), |_| { - Ok::<_, ()>(vec![loopforge_app_core::atomizer::AtomizerStage::CollectPlan]) + Ok::<_, ()>(vec![ + loopforge_app_core::atomizer::AtomizerStage::CollectPlan, + ]) }) .expect("stages"); let run = BackendAdapter::run_atomizer(request.clone(), |request| { diff --git a/crates/native-shell/src/services/loops.rs b/crates/native-shell/src/services/loops.rs index b81c47d..194b6cd 100644 --- a/crates/native-shell/src/services/loops.rs +++ b/crates/native-shell/src/services/loops.rs @@ -35,11 +35,21 @@ pub struct LoopService { impl LoopService { pub fn new(projects_root: PathBuf) -> Self { - let persistence_path = projects_root.join(".native-shell").join("loop-session.state"); - Self { projects_root, persistence_path, 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, project_name: &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 events = vec![ @@ -64,22 +74,34 @@ impl LoopService { } 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; self.persist_session(&session)?; - Ok(Some(Self::to_update(session, vec![String::from("Loop stopped from native shell.")]))) + 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); } + 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); }; + 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"))?; + 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), @@ -97,16 +119,30 @@ impl LoopService { 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()))) + .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); + 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), + 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"), @@ -126,7 +162,12 @@ impl LoopService { } } - 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)] @@ -137,19 +178,37 @@ mod tests { 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)) + 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", "Native shell project").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"))); - assert!(started.events.iter().any(|event| event.contains("Rate limit"))); + 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"))); @@ -161,9 +220,14 @@ mod tests { 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 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"); + 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); @@ -178,10 +242,15 @@ mod tests { 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 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"); + 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); diff --git a/crates/native-shell/src/services/projects.rs b/crates/native-shell/src/services/projects.rs index 3154891..1caa0cc 100644 --- a/crates/native-shell/src/services/projects.rs +++ b/crates/native-shell/src/services/projects.rs @@ -14,15 +14,25 @@ pub struct ProjectsService { } impl ProjectsService { pub fn new(root: Option) -> Self { - Self { root: root.unwrap_or_else(Self::default_root) } + Self { + root: root.unwrap_or_else(Self::default_root), + } + } + pub fn root(&self) -> &Path { + &self.root } - pub fn root(&self) -> &Path { &self.root } pub fn create_project(&self, request: CreateProjectRequest) -> io::Result { let project_name = request.name.clone(); BackendAdapter::create_project(request, |request| { projects::create_project( request, - || format!("proj-{}-{}", Self::timestamp_nanos(), Self::slug(&project_name)), + || { + format!( + "proj-{}-{}", + Self::timestamp_nanos(), + Self::slug(&project_name) + ) + }, Self::timestamp, |project_id, artifact_name| self.initialize_artifacts(project_id, artifact_name), |record| self.persist_record(record), @@ -36,8 +46,14 @@ impl ProjectsService { BackendAdapter::project_detail(project_id, |resolved_id| { let project = self.read_project(&resolved_id)?; let stories = self.read_stories(); - let passed_count = stories.iter().filter(|story| story.status == "passed").count(); - let blocked_count = stories.iter().filter(|story| story.status == "blocked").count(); + let passed_count = stories + .iter() + .filter(|story| story.status == "passed") + .count(); + let blocked_count = stories + .iter() + .filter(|story| story.status == "blocked") + .count(); let total_stories = stories.len(); Ok(SharedProjectDetail { project, @@ -64,15 +80,22 @@ impl ProjectsService { Self::find_project(&projects, project_id) } fn default_root() -> PathBuf { - PathBuf::from(env::var("HOME").unwrap_or_else(|_| ".".to_owned())).join(".config/loopforge/projects") + PathBuf::from(env::var("HOME").unwrap_or_else(|_| ".".to_owned())) + .join(".config/loopforge/projects") } fn initialize_artifacts(&self, project_id: &str, project_name: &str) -> io::Result<()> { let project_dir = self.root.join(project_id); fs::create_dir_all(&project_dir)?; - fs::write(project_dir.join("plan.md"), format!("# {project_name}\n\n- Bootstrapped from native shell wizard.\n"))?; + fs::write( + project_dir.join("plan.md"), + format!("# {project_name}\n\n- Bootstrapped from native shell wizard.\n"), + )?; 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!("# {project_name}\n\nContinue implementing the accepted stories.\n"))?; + fs::write( + project_dir.join("prompt.md"), + format!("# {project_name}\n\nContinue implementing the accepted stories.\n"), + )?; fs::write(project_dir.join("guardrails.md"), "")?; Ok(()) } @@ -89,8 +112,23 @@ impl ProjectsService { }; let project_dir = self.root.join(&project.id); fs::create_dir_all(&project_dir)?; - fs::write(project_dir.join("project.txt"), Self::encode_project(&project))?; - fs::write(project_dir.join("draft.json"), format!("id={}\nname={}\nstep={}\ndescription={}\n", project.id, project.name, project.wizard_step.clone().unwrap_or_else(|| "describe".to_owned()), project.description))?; + fs::write( + project_dir.join("project.txt"), + Self::encode_project(&project), + )?; + fs::write( + project_dir.join("draft.json"), + format!( + "id={}\nname={}\nstep={}\ndescription={}\n", + project.id, + project.name, + project + .wizard_step + .clone() + .unwrap_or_else(|| "describe".to_owned()), + project.description + ), + )?; Ok(project) } fn read_projects(&self) -> io::Result { @@ -126,13 +164,19 @@ impl ProjectsService { "working_directory" => project.working_directory = value.trim().to_owned(), "created_at" => project.created_at = value.trim().to_owned(), "updated_at" => project.updated_at = value.trim().to_owned(), - "wizard_step" => project.wizard_step = (!value.trim().is_empty()).then(|| value.trim().to_owned()), + "wizard_step" => { + project.wizard_step = + (!value.trim().is_empty()).then(|| value.trim().to_owned()) + } _ => {} } } } if project.id.is_empty() || project.name.is_empty() { - return Err(io::Error::new(io::ErrorKind::InvalidData, "invalid project")); + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "invalid project", + )); } Ok(project) } @@ -140,7 +184,10 @@ impl ProjectsService { let mut project = self.read_project(project_id)?; project.status = status.to_owned(); project.updated_at = Self::timestamp(); - fs::write(self.root.join(project_id).join("project.txt"), Self::encode_project(&project)) + fs::write( + self.root.join(project_id).join("project.txt"), + Self::encode_project(&project), + ) } fn encode_project(project: &ProjectRecord) -> String { format!("id={}\nname={}\ndescription={}\nstatus={}\nworking_directory={}\ncreated_at={}\nupdated_at={}\nwizard_step={}\n", project.id, project.name, project.description, project.status, project.working_directory, project.created_at, project.updated_at, project.wizard_step.clone().unwrap_or_default()) @@ -157,16 +204,51 @@ impl ProjectsService { } } fn find_project(grouped: &SharedProjects, project_id: &str) -> io::Result { - grouped.active.iter().chain(grouped.paused.iter()).chain(grouped.completed.iter()).chain(grouped.draft.iter()).chain(grouped.archived.iter()).chain(grouped.blocked.iter()).chain(grouped.failed.iter()).find(|project| project.id == project_id).cloned().ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project not found")) + grouped + .active + .iter() + .chain(grouped.paused.iter()) + .chain(grouped.completed.iter()) + .chain(grouped.draft.iter()) + .chain(grouped.archived.iter()) + .chain(grouped.blocked.iter()) + .chain(grouped.failed.iter()) + .find(|project| project.id == project_id) + .cloned() + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "project not found")) + } + fn read_stories(&self) -> Vec { + Vec::new() + } + fn timestamp() -> String { + Self::timestamp_nanos().to_string() } - fn read_stories(&self) -> Vec { Vec::new() } - fn timestamp() -> String { Self::timestamp_nanos().to_string() } fn timestamp_nanos() -> u128 { - SystemTime::now().duration_since(UNIX_EPOCH).unwrap_or_default().as_nanos() + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() } fn slug(value: &str) -> String { - let compact = value.trim().to_lowercase().chars().map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }).collect::().trim_matches('-').replace("--", "-"); - if compact.is_empty() { String::from("project") } else { compact } + let compact = value + .trim() + .to_lowercase() + .chars() + .map(|character| { + if character.is_ascii_alphanumeric() { + character + } else { + '-' + } + }) + .collect::() + .trim_matches('-') + .replace("--", "-"); + if compact.is_empty() { + String::from("project") + } else { + compact + } } } @@ -174,12 +256,24 @@ impl ProjectsService { mod tests { use super::ProjectsService; use loopforge_app_core::projects::CreateProjectRequest; - fn temp_root() -> std::path::PathBuf { std::env::temp_dir().join(format!("native-shell-projects-{}", super::ProjectsService::timestamp_nanos())) } + fn temp_root() -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "native-shell-projects-{}", + super::ProjectsService::timestamp_nanos() + )) + } #[test] fn projects_service_uses_shared_project_contracts() { let root = temp_root(); let service = ProjectsService::new(Some(root.clone())); - let project = service.create_project(CreateProjectRequest { name: String::from("LoopForge"), description: String::from("Backend-owned wizard"), working_directory: String::from("/tmp/loopforge"), wizard_step: Some(String::from("describe")) }).expect("create"); + let project = service + .create_project(CreateProjectRequest { + name: String::from("LoopForge"), + description: String::from("Backend-owned wizard"), + working_directory: String::from("/tmp/loopforge"), + wizard_step: Some(String::from("describe")), + }) + .expect("create"); let grouped = service.list_projects().expect("list"); let archived = service.archive_project(&project.id).expect("archive"); let detail = service.project_detail(&project.id).expect("detail"); diff --git a/src-tauri/src/adapters/mod.rs b/src-tauri/src/adapters/mod.rs new file mode 100644 index 0000000..ef45ebe --- /dev/null +++ b/src-tauri/src/adapters/mod.rs @@ -0,0 +1 @@ +mod paths_tauri; diff --git a/src-tauri/src/adapters/paths_tauri.rs b/src-tauri/src/adapters/paths_tauri.rs new file mode 100644 index 0000000..f060e1c --- /dev/null +++ b/src-tauri/src/adapters/paths_tauri.rs @@ -0,0 +1,28 @@ +use std::path::PathBuf; +use std::sync::Arc; +use tauri::{AppHandle, Manager, Runtime}; + +pub struct TauriPathResolver { + app: Arc>, +} + +impl TauriPathResolver { + pub fn new(app: AppHandle) -> Self { + Self { app: Arc::new(app) } + } + + pub fn project_artifact_dir(&self, project_id: &str) -> Result { + self.app + .path() + .app_data_dir() + .map(|data_dir| data_dir.join("projects").join(project_id)) + .map_err(|err| err.to_string()) + } + + pub fn app_data_dir(&self) -> Result { + self.app + .path() + .app_data_dir() + .map_err(|err| err.to_string()) + } +} diff --git a/src-tauri/src/agent_runtime_env.rs b/src-tauri/src/agent_runtime_env.rs index 2fe9096..29ccc7b 100644 --- a/src-tauri/src/agent_runtime_env.rs +++ b/src-tauri/src/agent_runtime_env.rs @@ -8,7 +8,6 @@ pub fn ensure_full_path_env() { 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/lib.rs b/src-tauri/src/lib.rs index 736d88f..5c05b5e 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod activity; +mod adapters; mod agent_profiles; mod agent_runtime; mod agent_runtime_env; diff --git a/src-tauri/src/plan_engine/args.rs b/src-tauri/src/plan_engine/args.rs index f1062a4..b7d7021 100644 --- a/src-tauri/src/plan_engine/args.rs +++ b/src-tauri/src/plan_engine/args.rs @@ -84,6 +84,7 @@ pub(super) fn build_plan_args( vec![ "agent".to_string(), "--print".to_string(), + "--force".to_string(), "--output-format".to_string(), "text".to_string(), "--model".to_string(), diff --git a/src-tauri/src/services/project_query_adapter.rs b/src-tauri/src/services/project_query_adapter.rs index 5c16b9d..1be18f1 100644 --- a/src-tauri/src/services/project_query_adapter.rs +++ b/src-tauri/src/services/project_query_adapter.rs @@ -160,7 +160,9 @@ pub fn monitor_snapshot( }) } -fn entry(key: &str) -> (String, Vec) { (key.to_string(), Vec::new()) } +fn entry(key: &str) -> (String, Vec) { + (key.to_string(), Vec::new()) +} fn bucket(status: ProjectStatus) -> Option<&'static str> { match status { ProjectStatus::Draft => Some("draft"), @@ -196,4 +198,6 @@ fn recent_output(dir: &Path, project_id: &str) -> Vec { .collect() } -fn db(error: rusqlite::Error) -> ServiceError { ServiceError::Internal(error.to_string()) } +fn db(error: rusqlite::Error) -> ServiceError { + ServiceError::Internal(error.to_string()) +} diff --git a/src-tauri/src/services/wizard_session_adapter.rs b/src-tauri/src/services/wizard_session_adapter.rs index 88bafc4..e807434 100644 --- a/src-tauri/src/services/wizard_session_adapter.rs +++ b/src-tauri/src/services/wizard_session_adapter.rs @@ -97,8 +97,10 @@ pub fn save_canonical_session( Some(&project.id), project.wizard_step.as_deref(), )?; - session.stale_from_step = - merged_stale_step(session.stale_from_step, invalidated_step(previous.as_ref(), &session)); + session.stale_from_step = merged_stale_step( + session.stale_from_step, + invalidated_step(previous.as_ref(), &session), + ); clear_invalidated_descendants(&artifacts, &mut session)?; std::fs::write(artifacts.join("draft.json"), session.to_json()?)?; Ok(session) @@ -116,9 +118,7 @@ fn hydrate_draft( wizard_state_json.map(str::to_string) } }; - let Some(raw) = raw else { - return None; - }; + let raw = raw?; let Ok(draft) = serde_json::from_str::(&raw) else { invalidate( &mut session.validation.describe, @@ -342,7 +342,13 @@ fn clear_invalidated_descendants( if step_is_stale(session.stale_from_step, "plan") { clear_artifacts( artifacts, - &["plan.md", "prd.json", "config.json", "prompt.md", "guardrails.md"], + &[ + "plan.md", + "prd.json", + "config.json", + "prompt.md", + "guardrails.md", + ], )?; session.plan = crate::projects::wizard_state::WizardPlanState::default(); session.atomize = crate::projects::wizard_state::WizardAtomizeState::default(); diff --git a/src-tauri/tests/project_query_adapter.rs b/src-tauri/tests/project_query_adapter.rs index b2ccebc..56cc350 100644 --- a/src-tauri/tests/project_query_adapter.rs +++ b/src-tauri/tests/project_query_adapter.rs @@ -1,6 +1,15 @@ mod models { #[derive(Debug, Clone)] - pub enum ProjectStatus { Draft, Ready, Running, Paused, Blocked, Failed, Completed, Archived } + pub enum ProjectStatus { + Draft, + Ready, + Running, + Paused, + Blocked, + Failed, + Completed, + Archived, + } impl ProjectStatus { pub fn from_db_status(status: &str) -> Self { match status { @@ -21,16 +30,26 @@ mod models { has_active_session: bool, ) -> Self { let base_status = Self::from_db_status(db_status); - if has_active_session { return Self::Running; } - if matches!(base_status, Self::Running | Self::Paused) { return Self::Paused; } + if has_active_session { + return Self::Running; + } + if matches!(base_status, Self::Running | Self::Paused) { + return Self::Paused; + } if matches!( base_status, Self::Blocked | Self::Failed | Self::Completed | Self::Archived ) { return base_status; } - if !has_prd { return Self::Draft; } - if has_config { Self::Ready } else { Self::Draft } + if !has_prd { + return Self::Draft; + } + if has_config { + Self::Ready + } else { + Self::Draft + } } pub fn as_project_status(&self) -> &'static str { match self { @@ -68,8 +87,20 @@ fn home_listing_groups_state_transitions() { insert_project(&conn, "ready-1", "Ready", "draft"); insert_project(&conn, "paused-1", "Paused", "active"); write_project(&root, "draft-1", None, None, None); - write_project(&root, "ready-1", Some(prd_json(2, 1)), Some(r#"{"executeAgent":"codex"}"#), None); - write_project(&root, "paused-1", Some(prd_json(1, 0)), Some(r#"{"executeAgent":"codex"}"#), None); + write_project( + &root, + "ready-1", + Some(prd_json(2, 1)), + Some(r#"{"executeAgent":"codex"}"#), + None, + ); + write_project( + &root, + "paused-1", + Some(prd_json(1, 0)), + Some(r#"{"executeAgent":"codex"}"#), + None, + ); let groups = home_listings(&conn, &HashSet::new(), |id| Ok(root.join(id))).unwrap(); assert_eq!(groups["draft"][0].id, "draft-1"); assert_eq!(groups["ready"][0].id, "ready-1"); @@ -84,8 +115,20 @@ fn monitor_snapshot_handles_stale_and_partial_states() { let conn = schema(); let root = temp_root(); insert_project(&conn, "project-1", "Monitor", "active"); - insert_session(&conn, "session-1", "project-1", "2026-01-01T00:00:00Z", Some("2026-01-01T00:06:00Z")); - insert_iteration(&conn, "iter-1", "session-1", "S-001", "2026-01-01T00:05:00Z"); + insert_session( + &conn, + "session-1", + "project-1", + "2026-01-01T00:00:00Z", + Some("2026-01-01T00:06:00Z"), + ); + insert_iteration( + &conn, + "iter-1", + "session-1", + "S-001", + "2026-01-01T00:05:00Z", + ); write_project(&root, "project-1", None, None, Some("line 1\nline 2\n")); let snapshot = monitor_snapshot(&conn, &root.join("project-1"), "project-1", false).unwrap(); let value = serde_json::to_value(&snapshot).unwrap(); @@ -93,7 +136,10 @@ fn monitor_snapshot_handles_stale_and_partial_states() { assert!(snapshot.is_stale); assert!(snapshot.has_partial_progress); assert_eq!(snapshot.current_story.as_deref(), Some("S-001")); - assert_eq!(snapshot.events, vec!["session_started", "iteration_completed", "session_ended"]); + assert_eq!( + snapshot.events, + vec!["session_started", "iteration_completed", "session_ended"] + ); assert_eq!(value["recentOutput"][1]["content"], "line 2"); let _ = fs::remove_dir_all(root); } diff --git a/src-tauri/tests/wizard_session_invalidation.rs b/src-tauri/tests/wizard_session_invalidation.rs index e7bb29e..d9b662d 100644 --- a/src-tauri/tests/wizard_session_invalidation.rs +++ b/src-tauri/tests/wizard_session_invalidation.rs @@ -90,8 +90,17 @@ async fn wizard_session_invalidation_clears_all_descendants_after_description_ch assert!(saved.atomize.stories.is_empty()); assert!(saved.prompt.is_none()); assert!(saved.guardrails.is_none()); - for file_name in ["plan.md", "prd.json", "config.json", "prompt.md", "guardrails.md"] { - assert!(!artifact_dir.join(file_name).exists(), "{file_name} should be removed"); + for file_name in [ + "plan.md", + "prd.json", + "config.json", + "prompt.md", + "guardrails.md", + ] { + assert!( + !artifact_dir.join(file_name).exists(), + "{file_name} should be removed" + ); } let session = projects::runtime_config::wizard_session_adapter::hydrate_canonical_session( @@ -147,7 +156,10 @@ async fn wizard_session_invalidation_preserves_plan_and_prd_after_config_change( assert!(artifact_dir.join("plan.md").exists()); assert!(artifact_dir.join("prd.json").exists()); for file_name in ["config.json", "prompt.md", "guardrails.md"] { - assert!(!artifact_dir.join(file_name).exists(), "{file_name} should be removed"); + assert!( + !artifact_dir.join(file_name).exists(), + "{file_name} should be removed" + ); } let session = projects::runtime_config::wizard_session_adapter::hydrate_canonical_session( @@ -160,7 +172,10 @@ async fn wizard_session_invalidation_preserves_plan_and_prd_after_config_change( assert_eq!(session.plan.as_deref(), Some("# Persisted plan")); assert_eq!(session.stories.len(), 1); assert_eq!( - session.config.as_ref().map(|config| config.execute_agent.as_str()), + session + .config + .as_ref() + .map(|config| config.execute_agent.as_str()), Some("codex") ); assert!(session.prompt.is_none());