diff --git a/crates/miyabi-cli/src/main.rs b/crates/miyabi-cli/src/main.rs index 40899f9..e0ddce2 100644 --- a/crates/miyabi-cli/src/main.rs +++ b/crates/miyabi-cli/src/main.rs @@ -189,6 +189,8 @@ enum GateCommand { #[arg(long, value_enum)] risk: ImpactRiskArg, #[arg(long)] + approve: bool, + #[arg(long)] symbols: usize, #[arg(long, value_delimiter = ',')] depth1: Vec, @@ -198,20 +200,11 @@ enum GateCommand { input_hash: Option, }, /// Record branch creation - Branch { - task_id: String, - name: String, - }, + Branch { task_id: String, name: String }, /// Record PR creation - Pr { - task_id: String, - number: u64, - }, + Pr { task_id: String, number: u64 }, /// Record merge verification - Merge { - task_id: String, - sha: String, - }, + Merge { task_id: String, sha: String }, /// List active locks Locks, /// Show DAG levels @@ -405,7 +398,10 @@ async fn main() -> anyhow::Result<()> { Ok(Some(rules)) => { println!("Rules: {} rules loaded", rules.rules.len()); if !rules.agent_preferences.is_empty() { - println!("Agents: {} agent preferences", rules.agent_preferences.len()); + println!( + "Agents: {} agent preferences", + rules.agent_preferences.len() + ); } } Ok(None) => { @@ -576,14 +572,20 @@ async fn main() -> anyhow::Result<()> { "warning" => "🟡", _ => "🔵", }; - println!(" {} {} {} - {}", status, severity, rule.name, rule.suggestion); + println!( + " {} {} {} - {}", + status, severity, rule.name, rule.suggestion + ); if verbose { if let Some(pattern) = &rule.pattern { println!(" Pattern: {}", pattern); } if !rule.file_extensions.is_empty() { - println!(" Extensions: {}", rule.file_extensions.join(", ")); + println!( + " Extensions: {}", + rule.file_extensions.join(", ") + ); } println!(); } @@ -616,7 +618,10 @@ async fn main() -> anyhow::Result<()> { } } Ok(None) => { - println!("No .miyabirules file found in {} or parent directories.", cwd.display()); + println!( + "No .miyabirules file found in {} or parent directories.", + cwd.display() + ); println!(); println!("Create a .miyabirules file to define project-specific rules."); println!("See: miyabi --help for more information."); @@ -788,33 +793,32 @@ async fn main() -> anyhow::Result<()> { // Get OpenClaw configuration let gateway_url = env::var("OPENCLAW_GATEWAY_URL") .unwrap_or_else(|_| "http://127.0.0.1:18789".to_string()); - let token = env::var("OPENCLAW_TOKEN") - .unwrap_or_else(|_| { - // Try to read from openclaw.json - #[allow(unused_imports)] - use std::fs; - #[allow(unused_imports)] - use std::path::PathBuf; + let token = env::var("OPENCLAW_TOKEN").unwrap_or_else(|_| { + // Try to read from openclaw.json + #[allow(unused_imports)] + use std::fs; + #[allow(unused_imports)] + use std::path::PathBuf; - let config_path = PathBuf::from(env::var("HOME").unwrap_or_default()) - .join(".openclaw") - .join("openclaw.json"); + let config_path = PathBuf::from(env::var("HOME").unwrap_or_default()) + .join(".openclaw") + .join("openclaw.json"); - // Fallback: try to read token from config - if let Ok(content) = fs::read_to_string(&config_path) { - if let Ok(json) = serde_json::from_str::(&content) { - if let Some(gateway) = json.get("gateway") { - if let Some(auth) = gateway.get("auth") { - if let Some(t) = auth.get("token") { - return t.as_str().unwrap_or("").to_string(); - } + // Fallback: try to read token from config + if let Ok(content) = fs::read_to_string(&config_path) { + if let Ok(json) = serde_json::from_str::(&content) { + if let Some(gateway) = json.get("gateway") { + if let Some(auth) = gateway.get("auth") { + if let Some(t) = auth.get("token") { + return t.as_str().unwrap_or("").to_string(); } } } } + } - String::new() - }); + String::new() + }); if token.is_empty() { eprintln!("❌ Error: OPENCLAW_TOKEN not set"); @@ -899,8 +903,25 @@ async fn main() -> anyhow::Result<()> { // Broadcast to specific society let society_agents = match society.to_lowercase().as_str() { "core" => vec!["maestro", "kade", "sakura", "tsubaki", "botan", "nagare"], - "investment" => vec!["scout", "crystal", "dealer", "sentinel", "architect", "watchman", "chart", "fundy", "scribe"], - "content" => vec!["tweeter", "pen", "vidpro", "artist", "optimizer", "scheduler"], + "investment" => vec![ + "scout", + "crystal", + "dealer", + "sentinel", + "architect", + "watchman", + "chart", + "fundy", + "scribe", + ], + "content" => vec![ + "tweeter", + "pen", + "vidpro", + "artist", + "optimizer", + "scheduler", + ], "marketing" => vec!["hiro", "kazoeru", "funnel", "adops"], _ => { eprintln!("❌ 不明なSociety: {}", society); @@ -989,15 +1010,19 @@ async fn main() -> anyhow::Result<()> { println!(); println!("【環境変数】"); println!(); - println!(" OPENCLAW_GATEWAY_URL - Gateway URL (default: http://127.0.0.1:18789)"); + println!( + " OPENCLAW_GATEWAY_URL - Gateway URL (default: http://127.0.0.1:18789)" + ); println!(" OPENCLAW_TOKEN - Gateway認証トークン"); println!(); println!("【設定ファイル】"); println!(); println!(" ~/.openclaw/openclaw.json - 設定ファイルから自動読み込み"); println!(); - println!("--- - 🌸 Miyabi Framework - OpenClaw Integration"); + println!( + "--- + 🌸 Miyabi Framework - OpenClaw Integration" + ); } OpenclawCommand::Status => { // Already handled above @@ -1007,8 +1032,8 @@ async fn main() -> anyhow::Result<()> { } Some(Commands::Collab { command }) => { - use std::process::Command; use std::env; + use std::process::Command; let collab_bin = { let home = env::var("HOME").unwrap_or_else(|_| "/tmp".to_string()); @@ -1018,17 +1043,31 @@ async fn main() -> anyhow::Result<()> { let mut args: Vec = Vec::new(); match command { - CollabCommand::List { json, r#type, count } => { + CollabCommand::List { + json, + r#type, + count, + } => { args.push("tile".to_string()); args.push("list".to_string()); - if json { args.push("--json".to_string()); } - if count { args.push("--count".to_string()); } + if json { + args.push("--json".to_string()); + } + if count { + args.push("--count".to_string()); + } if let Some(t) = r#type { args.push("--type".to_string()); args.push(t); } } - CollabCommand::Add { tile_type, file, pos, size, idempotent } => { + CollabCommand::Add { + tile_type, + file, + pos, + size, + idempotent, + } => { args.push("tile".to_string()); args.push("add".to_string()); args.push(tile_type); @@ -1044,7 +1083,9 @@ async fn main() -> anyhow::Result<()> { args.push("--size".to_string()); args.push(s); } - if idempotent { args.push("--idempotent".to_string()); } + if idempotent { + args.push("--idempotent".to_string()); + } } CollabCommand::Rm { tile_id } => { args.push("tile".to_string()); @@ -1086,9 +1127,7 @@ async fn main() -> anyhow::Result<()> { } } - let status = Command::new(&collab_bin) - .args(&args) - .status(); + let status = Command::new(&collab_bin).args(&args).status(); match status { Ok(s) => { @@ -1098,7 +1137,9 @@ async fn main() -> anyhow::Result<()> { } Err(e) => { eprintln!("error: failed to run collab CLI ({}): {}", collab_bin, e); - eprintln!(" → Install collab CLI: https://github.com/ShunsukeHayashi/collab-cli"); + eprintln!( + " → Install collab CLI: https://github.com/ShunsukeHayashi/collab-cli" + ); std::process::exit(1); } } @@ -1140,6 +1181,7 @@ fn handle_gate_command( protocol .register( RegisterTaskRequest { + issue, task_id, title, dependencies, @@ -1162,27 +1204,29 @@ fn handle_gate_command( } }) } - GateCommand::Status { task_id } => protocol.status(task_id.as_deref()).map(|status| { - match status { - StatusReport::Task(task) => { - if matches!(format, OutputFormat::Json) { - println!("{}", serde_json::to_string_pretty(&task).unwrap()); - } else { - println!("{}: {:?} - {}", task.id, task.current_state, task.title); - } - } - StatusReport::Snapshot(snapshot) => { - if matches!(format, OutputFormat::Json) { - println!("{}", serde_json::to_string_pretty(&snapshot).unwrap()); - } else { - println!("tasks: {}", snapshot.tasks.len()); - for task in snapshot.tasks { - println!(" {} [{:?}] {}", task.id, task.current_state, task.title); + GateCommand::Status { task_id } => { + protocol + .status(task_id.as_deref()) + .map(|status| match status { + StatusReport::Task(task) => { + if matches!(format, OutputFormat::Json) { + println!("{}", serde_json::to_string_pretty(&task).unwrap()); + } else { + println!("{}: {:?} - {}", task.id, task.current_state, task.title); } } - } - } - }), + StatusReport::Snapshot(snapshot) => { + if matches!(format, OutputFormat::Json) { + println!("{}", serde_json::to_string_pretty(&snapshot).unwrap()); + } else { + println!("tasks: {}", snapshot.tasks.len()); + for task in snapshot.tasks { + println!(" {} [{:?}] {}", task.id, task.current_state, task.title); + } + } + } + }) + } GateCommand::Assign { task_id, agent, @@ -1194,15 +1238,13 @@ fn handle_gate_command( if matches!(format, OutputFormat::Json) { println!("{}", serde_json::to_string_pretty(&result).unwrap()); } else { - println!( - "assigned: {} -> {}@{}", - result.task.id, agent, agent_node - ); + println!("assigned: {} -> {}@{}", result.task.id, agent, agent_node); } }), GateCommand::Impact { task_id, risk, + approve, symbols, depth1, analyzed_commit, @@ -1221,6 +1263,7 @@ fn handle_gate_command( depth1, analyzed_commit, input_hash, + approve, }, actor, &node, diff --git a/crates/miyabi-core/src/gate.rs b/crates/miyabi-core/src/gate.rs index 0ccc0ea..f20b9c3 100644 --- a/crates/miyabi-core/src/gate.rs +++ b/crates/miyabi-core/src/gate.rs @@ -5,7 +5,9 @@ use crate::store::{ CompletionMode, ExecutionTask, GitHubIssueState, GitHubPrState, ReviewDecision, TaskState, TasksSnapshot, }; +use regex::Regex; use serde::{Deserialize, Serialize}; +use std::sync::OnceLock; use std::time::{Duration, Instant}; #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -51,6 +53,16 @@ pub struct GateReport { pub duration: Duration, } +pub fn validate_branch_name(name: &str) -> bool { + static BRANCH_NAME_REGEX: OnceLock = OnceLock::new(); + BRANCH_NAME_REGEX + .get_or_init(|| { + Regex::new(r"^(feature|fix|hotfix)/issue-[1-9][0-9]*-[A-Za-z0-9][A-Za-z0-9._-]*$") + .expect("branch name regex must compile") + }) + .is_match(name) +} + pub fn evaluate_gate( gate: Gate, task: &ExecutionTask, @@ -119,12 +131,13 @@ pub fn evaluate_gate( .unwrap_or_else(|| "lock window available".to_string()), ) } - Gate::Gate5 => ( - task.branch_name.is_some(), - task.branch_name - .clone() - .unwrap_or_else(|| "missing branch_name".to_string()), - ), + Gate::Gate5 => match task.branch_name.as_deref() { + Some(branch_name) if validate_branch_name(branch_name) => { + (true, format!("valid branch_name: {branch_name}")) + } + Some(branch_name) => (false, format!("invalid branch_name: {branch_name}")), + None => (false, "missing branch_name".to_string()), + }, Gate::Gate6 => { let ok = task.github_evidence.as_ref().is_some_and(|evidence| { evidence.pr_number > 0 @@ -158,11 +171,10 @@ pub fn evaluate_gate( } Gate::Gate8 => { let ok = match task.completion_mode { - CompletionMode::GithubPr => { - task.github_evidence.as_ref().is_some_and(|evidence| { - evidence.issue_state == GitHubIssueState::Closed - }) - } + CompletionMode::GithubPr => task + .github_evidence + .as_ref() + .is_some_and(|evidence| evidence.issue_state == GitHubIssueState::Closed), CompletionMode::Manual | CompletionMode::ExternalOp => true, }; ( @@ -263,4 +275,18 @@ mod tests { assert!(evaluate_gate(Gate::Gate7, &task, &snapshot, &GateContext::default()).success); assert!(evaluate_gate(Gate::Gate8, &task, &snapshot, &GateContext::default()).success); } + + #[test] + fn validate_branch_name_accepts_supported_prefixes() { + assert!(validate_branch_name("feature/issue-1-test")); + assert!(validate_branch_name("fix/issue-12-bugfix")); + assert!(validate_branch_name("hotfix/issue-999-hot-patch")); + } + + #[test] + fn validate_branch_name_rejects_invalid_patterns() { + assert!(!validate_branch_name("feature/phase-a")); + assert!(!validate_branch_name("feature/issue-0-test")); + assert!(!validate_branch_name("main")); + } } diff --git a/crates/miyabi-core/src/protocol.rs b/crates/miyabi-core/src/protocol.rs index 189edfe..18b2a75 100644 --- a/crates/miyabi-core/src/protocol.rs +++ b/crates/miyabi-core/src/protocol.rs @@ -1,12 +1,12 @@ //! Deterministic execution protocol entry point. use crate::error::Error; -use crate::gate::{evaluate_gate, Gate, GateContext, GateReport}; +use crate::gate::{evaluate_gate, validate_branch_name, Gate, GateContext, GateReport}; use crate::lock::{FileLockManager, LeaseConfig, LockConflict}; use crate::store::{ CompletionMode, EventStore, ExecutionTask, GitHubEvidence, GitHubIssueState, GitHubPrState, - ImpactRiskLevel, ReviewDecision, SnapshotStore, TaskEvent, TaskEventType, TaskImpact, - TaskState, TasksSnapshot, + HumanApproval, ImpactRiskLevel, ReviewDecision, SnapshotStore, TaskEvent, TaskEventType, + TaskImpact, TaskState, TasksSnapshot, }; use chrono::Utc; use serde::{Deserialize, Serialize}; @@ -63,9 +63,9 @@ impl DeterministicExecutionProtocol { for gate in gates { let snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?; - let task = snapshot.get_task(task_id).ok_or_else(|| { - ProtocolError::input(format!("unknown task: {task_id}")) - })?; + let task = snapshot + .get_task(task_id) + .ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))?; let context = if matches!(gate, Gate::Gate4) { let files = task @@ -120,6 +120,9 @@ impl DeterministicExecutionProtocol { actor: &str, node: &str, ) -> ProtocolResult { + if request.issue == 0 { + return Err(ProtocolError::input("issue number must be greater than 0")); + } let mut snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?; if snapshot.get_task(&request.task_id).is_some() { return Err(ProtocolError::input(format!( @@ -179,17 +182,30 @@ impl DeterministicExecutionProtocol { files: &[String], ) -> ProtocolResult { let snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?; - let blocked_by = snapshot + let task = snapshot .get_task(task_id) - .ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))? + .ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))?; + let approval_granted = task + .human_approval + .as_ref() + .is_some_and(|approval| !approval.required || approval.approved_by.is_some()); + if matches!( + task.impact.as_ref().map(|impact| impact.risk_level), + Some(ImpactRiskLevel::High | ImpactRiskLevel::Critical) + ) && !approval_granted + { + return Err(ProtocolError::gate_rejected( + "HIGH/CRITICAL risk requires --approve", + )); + } + + let blocked_by = task .dependencies .iter() .filter_map(|dependency| { snapshot .get_task(dependency) - .filter(|dep| { - !matches!(dep.current_state, TaskState::Done | TaskState::Merged) - }) + .filter(|dep| !matches!(dep.current_state, TaskState::Done | TaskState::Merged)) .map(|_| dependency.clone()) }) .collect::>(); @@ -234,25 +250,46 @@ impl DeterministicExecutionProtocol { actor: &str, node: &str, ) -> ProtocolResult { - let payload_risk = impact.risk_level; - let payload_symbols = impact.affected_symbols; - let depth1 = impact.depth1.clone(); - let analyzed_commit = impact.analyzed_commit.clone(); - let input_hash = impact.input_hash.clone(); - self.update_task(task_id, actor, node, TaskEventType::ImpactRecorded, |task| { - task.impact = Some(TaskImpact { - risk_level: impact.risk_level, - affected_symbols: impact.affected_symbols, - depth1: depth1.clone(), - analyzed_at: Utc::now(), - analyzed_commit: analyzed_commit.clone(), - input_hash: input_hash.clone(), - }); - Ok(serde_json::json!({ - "risk_level": payload_risk, - "affected_symbols": payload_symbols - })) - }) + let ImpactInput { + risk_level, + affected_symbols, + depth1, + analyzed_commit, + input_hash, + approve, + } = impact; + let approval = approve.then(|| HumanApproval { + required: matches!( + risk_level, + ImpactRiskLevel::High | ImpactRiskLevel::Critical + ), + approved_by: Some(actor.to_string()), + approved_at: Some(Utc::now()), + reason: Some("approved via impact --approve".to_string()), + }); + self.update_task( + task_id, + actor, + node, + TaskEventType::ImpactRecorded, + |task| { + task.impact = Some(TaskImpact { + risk_level, + affected_symbols, + depth1: depth1.clone(), + analyzed_at: Utc::now(), + analyzed_commit: analyzed_commit.clone(), + input_hash: input_hash.clone(), + }); + task.human_approval = approval.clone(); + Ok(serde_json::json!({ + "risk_level": risk_level, + "affected_symbols": affected_symbols, + "approve": approve, + "human_approval": approval.clone() + })) + }, + ) } pub fn record_branch( @@ -265,6 +302,11 @@ impl DeterministicExecutionProtocol { if branch_name.trim().is_empty() { return Err(ProtocolError::input("branch name must not be empty")); } + if !validate_branch_name(branch_name) { + return Err(ProtocolError::gate_rejected(format!( + "invalid branch name: {branch_name}" + ))); + } self.update_task(task_id, actor, node, TaskEventType::BranchCreated, |task| { task.branch_name = Some(branch_name.to_string()); Ok(serde_json::json!({ "branch_name": branch_name })) @@ -305,26 +347,36 @@ impl DeterministicExecutionProtocol { actor: &str, node: &str, ) -> ProtocolResult { - if merge_commit_sha.len() != 40 || !merge_commit_sha.chars().all(|c| c.is_ascii_hexdigit()) { + if merge_commit_sha.len() != 40 || !merge_commit_sha.chars().all(|c| c.is_ascii_hexdigit()) + { return Err(ProtocolError::gate_rejected( "merge sha must be a 40-char hex string", )); } - self.update_task(task_id, actor, node, TaskEventType::MergeVerified, |task| { - let mut evidence = task.github_evidence.clone().ok_or_else(|| { - ProtocolError::gate_rejected("pull request must be recorded before merge") + let merged = + self.update_task(task_id, actor, node, TaskEventType::MergeVerified, |task| { + let mut evidence = task.github_evidence.clone().ok_or_else(|| { + ProtocolError::gate_rejected("pull request must be recorded before merge") + })?; + evidence.pr_state = GitHubPrState::Merged; + evidence.merge_commit_sha = Some(merge_commit_sha.to_string()); + evidence.merged_at = Some(Utc::now()); + evidence.review_decision = Some(ReviewDecision::Approved); + evidence.issue_state = GitHubIssueState::Closed; + evidence.issue_closed_by_pr = true; + task.github_evidence = Some(evidence); + task.current_state = TaskState::Merged; + Ok(serde_json::json!({ "merge_commit_sha": merge_commit_sha })) })?; - evidence.pr_state = GitHubPrState::Merged; - evidence.merge_commit_sha = Some(merge_commit_sha.to_string()); - evidence.merged_at = Some(Utc::now()); - evidence.review_decision = Some(ReviewDecision::Approved); - evidence.issue_state = GitHubIssueState::Closed; - evidence.issue_closed_by_pr = true; - task.github_evidence = Some(evidence); - task.current_state = TaskState::Merged; - Ok(serde_json::json!({ "merge_commit_sha": merge_commit_sha })) - }) + + self.lock_manager + .release_lock(task_id) + .map_err(ProtocolError::from)?; + self.unblock_dependents_after_merge(task_id, actor, node)?; + + let snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?; + Ok(snapshot.get_task(task_id).cloned().unwrap_or(merged)) } pub fn locks(&self) -> ProtocolResult> { @@ -368,10 +420,16 @@ impl DeterministicExecutionProtocol { actor: &str, node: &str, ) -> ProtocolResult { - self.update_task(task_id, actor, node, TaskEventType::StateTransition, |task| { - task.current_state = to; - Ok(serde_json::json!({ "to": to })) - }) + self.update_task( + task_id, + actor, + node, + TaskEventType::StateTransition, + |task| { + task.current_state = to; + Ok(serde_json::json!({ "to": to })) + }, + ) } fn update_task( @@ -455,6 +513,63 @@ impl DeterministicExecutionProtocol { }), ) } + + fn unblock_dependents_after_merge( + &self, + task_id: &str, + actor: &str, + node: &str, + ) -> ProtocolResult<()> { + let mut snapshot = self.snapshot_store.load().map_err(ProtocolError::from)?; + let dependent_ids = snapshot + .get_task(task_id) + .ok_or_else(|| ProtocolError::input(format!("unknown task: {task_id}")))? + .dependents + .clone(); + + let pending_dependents: Vec = dependent_ids + .into_iter() + .filter(|dependent_id| { + snapshot.get_task(dependent_id).is_some_and(|task| { + task.current_state == TaskState::Blocked + && dependencies_satisfied(task, &snapshot) + }) + }) + .collect(); + + if pending_dependents.is_empty() { + return Ok(()); + } + + let now = Utc::now(); + for dependent_id in &pending_dependents { + if let Some(task) = snapshot.get_task_mut(dependent_id) { + task.current_state = TaskState::Pending; + task.updated_at = now; + } + } + + let version = snapshot.version; + self.snapshot_store + .save(&snapshot, version) + .map_err(ProtocolError::from)?; + for dependent_id in pending_dependents { + self.append_event( + &dependent_id, + TaskEventType::StateTransition, + actor, + node, + version + 1, + serde_json::json!({ + "to": TaskState::Pending, + "reason": "dependencies_resolved_after_merge", + "unblocked_by": task_id, + }), + )?; + } + + Ok(()) + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -467,6 +582,7 @@ pub struct ProtocolReport { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct RegisterTaskRequest { + pub issue: u64, pub task_id: String, pub title: String, pub dependencies: Vec, @@ -488,6 +604,7 @@ pub struct ImpactInput { pub depth1: Vec, pub analyzed_commit: Option, pub input_hash: Option, + pub approve: bool, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] @@ -537,9 +654,9 @@ pub type ProtocolResult = std::result::Result; fn dependencies_satisfied(task: &ExecutionTask, snapshot: &TasksSnapshot) -> bool { task.dependencies.iter().all(|dep_id| { - snapshot.get_task(dep_id).is_some_and(|dep| { - matches!(dep.current_state, TaskState::Done | TaskState::Merged) - }) + snapshot + .get_task(dep_id) + .is_some_and(|dep| matches!(dep.current_state, TaskState::Done | TaskState::Merged)) }) } @@ -572,8 +689,11 @@ fn compute_dag(snapshot: &TasksSnapshot) -> DagReport { .map(|task| task.id.clone()) .collect(); let mut levels = Vec::new(); - let task_map: HashMap<&str, &ExecutionTask> = - snapshot.tasks.iter().map(|task| (task.id.as_str(), task)).collect(); + let task_map: HashMap<&str, &ExecutionTask> = snapshot + .tasks + .iter() + .map(|task| (task.id.as_str(), task)) + .collect(); while !queue.is_empty() { let mut next = VecDeque::new(); @@ -628,6 +748,7 @@ mod tests { protocol .register( RegisterTaskRequest { + issue: 1, task_id: "phase-0".into(), title: "Phase 0".into(), dependencies: vec![], @@ -642,6 +763,7 @@ mod tests { protocol .register( RegisterTaskRequest { + issue: 2, task_id: "phase-a".into(), title: "Phase A".into(), dependencies: vec!["phase-0".into()], @@ -676,6 +798,7 @@ mod tests { protocol .register( RegisterTaskRequest { + issue: 1, task_id: "phase-a".into(), title: "Phase A".into(), dependencies: vec![], @@ -699,12 +822,38 @@ mod tests { assert_eq!(dispatchable.tasks[0].id, "phase-a"); } + #[test] + fn register_rejects_issue_zero() { + let (_tmp, protocol) = fixture(); + let err = protocol + .register( + RegisterTaskRequest { + issue: 0, + task_id: "test-task".into(), + title: "Test Task".into(), + dependencies: vec![], + soft_dependencies: vec![], + priority: 0, + completion_mode: CompletionMode::GithubPr, + }, + "codex", + "macbook", + ) + .unwrap_err(); + + assert!(matches!(err, ProtocolError::Input(_))); + assert!(err + .to_string() + .contains("issue number must be greater than 0")); + } + #[test] fn assign_branch_pr_merge_updates_task() { let (_tmp, protocol) = fixture(); protocol .register( RegisterTaskRequest { + issue: 1, task_id: "phase-a".into(), title: "Phase A".into(), dependencies: vec![], @@ -725,6 +874,7 @@ mod tests { depth1: vec!["main".into()], analyzed_commit: None, input_hash: None, + approve: false, }, "codex", "macbook", @@ -742,9 +892,11 @@ mod tests { assert_eq!(assigned.task.current_state, TaskState::Implementing); protocol - .record_branch("phase-a", "feature/phase-a", "codex", "macbook") + .record_branch("phase-a", "feature/issue-1-phase-a", "codex", "macbook") + .unwrap(); + protocol + .record_pr("phase-a", 12, "codex", "macbook") .unwrap(); - protocol.record_pr("phase-a", 12, "codex", "macbook").unwrap(); let merged = protocol .record_merge( "phase-a", @@ -764,14 +916,102 @@ mod tests { .as_deref(), Some("0123456789abcdef0123456789abcdef01234567") ); + assert!(protocol.locks().unwrap().is_empty()); } #[test] - fn assign_returns_dependency_blocked_when_hard_dependency_is_unresolved() { + fn assign_rejects_high_risk_without_human_approval() { let (_tmp, protocol) = fixture(); protocol .register( RegisterTaskRequest { + issue: 1, + task_id: "phase-a".into(), + title: "Phase A".into(), + dependencies: vec![], + soft_dependencies: vec![], + priority: 0, + completion_mode: CompletionMode::GithubPr, + }, + "codex", + "macbook", + ) + .unwrap(); + protocol + .record_impact( + "phase-a", + ImpactInput { + risk_level: ImpactRiskLevel::High, + affected_symbols: 2, + depth1: vec!["assign".into()], + analyzed_commit: None, + input_hash: None, + approve: false, + }, + "codex", + "macbook", + ) + .unwrap(); + + let err = protocol + .assign("phase-a", "codex", "macbook", &[String::from("src/lib.rs")]) + .unwrap_err(); + + assert!(matches!(err, ProtocolError::GateRejected(_))); + assert_eq!( + err.to_string(), + "gate rejected: HIGH/CRITICAL risk requires --approve" + ); + } + + #[test] + fn impact_approve_records_human_approval() { + let (_tmp, protocol) = fixture(); + protocol + .register( + RegisterTaskRequest { + issue: 1, + task_id: "phase-a".into(), + title: "Phase A".into(), + dependencies: vec![], + soft_dependencies: vec![], + priority: 0, + completion_mode: CompletionMode::GithubPr, + }, + "codex", + "macbook", + ) + .unwrap(); + + let task = protocol + .record_impact( + "phase-a", + ImpactInput { + risk_level: ImpactRiskLevel::High, + affected_symbols: 2, + depth1: vec!["assign".into()], + analyzed_commit: None, + input_hash: None, + approve: true, + }, + "codex", + "macbook", + ) + .unwrap(); + + let approval = task.human_approval.as_ref().unwrap(); + assert!(approval.required); + assert_eq!(approval.approved_by.as_deref(), Some("codex")); + assert!(approval.approved_at.is_some()); + } + + #[test] + fn merge_releases_locks_and_unblocks_dependents() { + let (_tmp, protocol) = fixture(); + protocol + .register( + RegisterTaskRequest { + issue: 1, task_id: "phase-a".into(), title: "Phase A".into(), dependencies: vec![], @@ -786,6 +1026,115 @@ mod tests { protocol .register( RegisterTaskRequest { + issue: 2, + task_id: "phase-b".into(), + title: "Phase B".into(), + dependencies: vec!["phase-a".into()], + soft_dependencies: vec![], + priority: 0, + completion_mode: CompletionMode::GithubPr, + }, + "codex", + "macbook", + ) + .unwrap(); + protocol + .record_impact( + "phase-a", + ImpactInput { + risk_level: ImpactRiskLevel::Low, + affected_symbols: 1, + depth1: vec!["main".into()], + analyzed_commit: None, + input_hash: None, + approve: false, + }, + "codex", + "macbook", + ) + .unwrap(); + protocol + .assign("phase-a", "codex", "macbook", &[String::from("src/a.rs")]) + .unwrap(); + protocol + .record_branch("phase-a", "feature/issue-1-phase-a", "codex", "macbook") + .unwrap(); + protocol + .record_pr("phase-a", 12, "codex", "macbook") + .unwrap(); + + let mut snapshot = protocol.snapshot_store.load().unwrap(); + let version = snapshot.version; + let dependent = snapshot.get_task_mut("phase-b").unwrap(); + dependent.current_state = TaskState::Blocked; + protocol.snapshot_store.save(&snapshot, version).unwrap(); + + protocol + .record_merge( + "phase-a", + "0123456789abcdef0123456789abcdef01234567", + "codex", + "macbook", + ) + .unwrap(); + + assert!(protocol.locks().unwrap().is_empty()); + let status = protocol.status(Some("phase-b")).unwrap(); + match status { + StatusReport::Task(task) => assert_eq!(task.current_state, TaskState::Pending), + StatusReport::Snapshot(_) => panic!("expected task status"), + } + } + + #[test] + fn record_branch_rejects_invalid_branch_names() { + let (_tmp, protocol) = fixture(); + protocol + .register( + RegisterTaskRequest { + issue: 1, + task_id: "phase-a".into(), + title: "Phase A".into(), + dependencies: vec![], + soft_dependencies: vec![], + priority: 0, + completion_mode: CompletionMode::GithubPr, + }, + "codex", + "macbook", + ) + .unwrap(); + + let err = protocol + .record_branch("phase-a", "bad-name", "codex", "macbook") + .unwrap_err(); + + assert!(matches!(err, ProtocolError::GateRejected(_))); + assert!(err.to_string().contains("invalid branch name")); + } + + #[test] + fn assign_returns_dependency_blocked_when_hard_dependency_is_unresolved() { + let (_tmp, protocol) = fixture(); + protocol + .register( + RegisterTaskRequest { + issue: 1, + task_id: "phase-a".into(), + title: "Phase A".into(), + dependencies: vec![], + soft_dependencies: vec![], + priority: 0, + completion_mode: CompletionMode::GithubPr, + }, + "codex", + "macbook", + ) + .unwrap(); + protocol + .register( + RegisterTaskRequest { + issue: 2, task_id: "phase-b".into(), title: "Phase B".into(), dependencies: vec!["phase-a".into()], @@ -816,6 +1165,7 @@ mod tests { protocol .register( RegisterTaskRequest { + issue: 1, task_id: "phase-a".into(), title: "Phase A".into(), dependencies: vec![],