From b0792acbc24ca812d8db51defd895e939d508117 Mon Sep 17 00:00:00 2001 From: 2witstudios <2witstudios@gmail.com> Date: Tue, 10 Mar 2026 10:10:40 -0500 Subject: [PATCH] refactor: Split types.rs into domain-focused submodules Decompose 960-line types.rs (highest churn: 16 changes) into types/ directory with agent.rs, config.rs, manifest.rs, and worktree.rs. Re-exports from mod.rs preserve all existing import paths. Add AgentEntry::new() constructor to reduce test boilerplate churn. Co-Authored-By: Claude Opus 4.6 --- crates/pu-core/src/types.rs | 960 --------------------------- crates/pu-core/src/types/agent.rs | 443 ++++++++++++ crates/pu-core/src/types/config.rs | 218 ++++++ crates/pu-core/src/types/manifest.rs | 222 +++++++ crates/pu-core/src/types/mod.rs | 9 + crates/pu-core/src/types/worktree.rs | 100 +++ 6 files changed, 992 insertions(+), 960 deletions(-) delete mode 100644 crates/pu-core/src/types.rs create mode 100644 crates/pu-core/src/types/agent.rs create mode 100644 crates/pu-core/src/types/config.rs create mode 100644 crates/pu-core/src/types/manifest.rs create mode 100644 crates/pu-core/src/types/mod.rs create mode 100644 crates/pu-core/src/types/worktree.rs diff --git a/crates/pu-core/src/types.rs b/crates/pu-core/src/types.rs deleted file mode 100644 index eab8632..0000000 --- a/crates/pu-core/src/types.rs +++ /dev/null @@ -1,960 +0,0 @@ -use chrono::{DateTime, Utc}; -use indexmap::IndexMap; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum AgentStatus { - Streaming, - Waiting, - Broken, -} - -impl AgentStatus { - pub fn is_alive(self) -> bool { - matches!(self, Self::Streaming | Self::Waiting) - } -} - -impl Serialize for AgentStatus { - fn serialize(&self, serializer: S) -> Result { - let s = match self { - AgentStatus::Streaming => "streaming", - AgentStatus::Waiting => "waiting", - AgentStatus::Broken => "broken", - }; - serializer.serialize_str(s) - } -} - -impl<'de> Deserialize<'de> for AgentStatus { - fn deserialize>(deserializer: D) -> Result { - let s = String::deserialize(deserializer)?; - match s.as_str() { - "streaming" => Ok(AgentStatus::Streaming), - "waiting" => Ok(AgentStatus::Waiting), - "broken" => Ok(AgentStatus::Broken), - // Backward compat: map old status values - "spawning" | "running" => Ok(AgentStatus::Streaming), - "idle" | "suspended" => Ok(AgentStatus::Waiting), - "completed" | "failed" | "killed" | "lost" => Ok(AgentStatus::Broken), - other => Err(serde::de::Error::unknown_variant( - other, - &["streaming", "waiting", "broken"], - )), - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "snake_case")] -pub enum TriggerState { - Active, - Gating, - Completed, - Failed, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub enum WorktreeStatus { - Active, - Merging, - Merged, - Failed, - Cleaned, -} - -#[derive(Debug, Clone, Serialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentEntry { - pub id: String, - pub name: String, - pub agent_type: String, - pub status: AgentStatus, - pub prompt: Option, - pub started_at: DateTime, - #[serde(skip_serializing_if = "Option::is_none")] - pub completed_at: Option>, - #[serde(skip_serializing_if = "Option::is_none")] - pub exit_code: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub error: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub pid: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub session_id: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub suspended_at: Option>, - /// Whether this agent is currently suspended. Only meaningful when `status.is_alive()`. - /// Invariant: custom Deserialize infers `true` when `suspended_at` is present (backward - /// compat with old manifests). The engine sets both `suspended` and `suspended_at` - /// atomically on suspend/resume. - #[serde(default)] - pub suspended: bool, - #[serde(skip_serializing_if = "Option::is_none")] - pub command: Option, - #[serde(default, skip_serializing_if = "std::ops::Not::not")] - pub plan_mode: bool, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trigger_seq_index: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trigger_state: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trigger_total: Option, - #[serde(default, skip_serializing_if = "Option::is_none")] - pub gate_attempts: Option, - #[serde(default, skip_serializing_if = "crate::serde_defaults::is_false")] - pub no_trigger: bool, - /// Name of the trigger definition this agent is bound to. - /// Set at spawn time so the sequence is stable across ticks. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub trigger_name: Option, -} - -impl<'de> Deserialize<'de> for AgentEntry { - fn deserialize>(deserializer: D) -> Result { - #[derive(Deserialize)] - #[serde(rename_all = "camelCase")] - struct Raw { - id: String, - name: String, - agent_type: String, - status: AgentStatus, - prompt: Option, - started_at: DateTime, - completed_at: Option>, - exit_code: Option, - error: Option, - pid: Option, - session_id: Option, - suspended_at: Option>, - #[serde(default)] - suspended: bool, - #[serde(default)] - command: Option, - #[serde(default)] - plan_mode: bool, - #[serde(default)] - trigger_seq_index: Option, - #[serde(default)] - trigger_state: Option, - #[serde(default)] - trigger_total: Option, - #[serde(default)] - gate_attempts: Option, - #[serde(default)] - no_trigger: bool, - #[serde(default)] - trigger_name: Option, - } - let raw = Raw::deserialize(deserializer)?; - // Backward compat: old manifests have suspended_at set but no suspended field. - // Ensure suspended is true when suspended_at is present. - let suspended = raw.suspended || raw.suspended_at.is_some(); - Ok(AgentEntry { - id: raw.id, - name: raw.name, - agent_type: raw.agent_type, - status: raw.status, - prompt: raw.prompt, - started_at: raw.started_at, - completed_at: raw.completed_at, - exit_code: raw.exit_code, - error: raw.error, - pid: raw.pid, - session_id: raw.session_id, - suspended_at: raw.suspended_at, - suspended, - command: raw.command, - plan_mode: raw.plan_mode, - trigger_seq_index: raw.trigger_seq_index, - trigger_state: raw.trigger_state, - trigger_total: raw.trigger_total, - gate_attempts: raw.gate_attempts, - no_trigger: raw.no_trigger, - trigger_name: raw.trigger_name, - }) - } -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct WorktreeEntry { - pub id: String, - pub name: String, - pub path: String, - pub branch: String, - pub base_branch: Option, - pub status: WorktreeStatus, - pub agents: IndexMap, - pub created_at: DateTime, - #[serde(skip_serializing_if = "Option::is_none")] - pub merged_at: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Manifest { - pub version: u32, - pub project_root: String, - pub worktrees: IndexMap, - #[serde(default)] - pub agents: IndexMap, - pub created_at: DateTime, - pub updated_at: DateTime, -} - -impl Manifest { - pub fn new(project_root: String) -> Self { - let now = Utc::now(); - Self { - version: 1, - project_root, - worktrees: IndexMap::new(), - agents: IndexMap::new(), - created_at: now, - updated_at: now, - } - } - - pub fn find_agent(&self, agent_id: &str) -> Option> { - if let Some(agent) = self.agents.get(agent_id) { - return Some(AgentLocation::Root(agent)); - } - for wt in self.worktrees.values() { - if let Some(agent) = wt.agents.get(agent_id) { - return Some(AgentLocation::Worktree { - worktree: wt, - agent, - }); - } - } - None - } - - pub fn all_agents(&self) -> Vec<&AgentEntry> { - let mut agents: Vec<&AgentEntry> = self.agents.values().collect(); - for wt in self.worktrees.values() { - agents.extend(wt.agents.values()); - } - agents - } - - pub fn find_agent_mut(&mut self, id: &str) -> Option<&mut AgentEntry> { - if let Some(agent) = self.agents.get_mut(id) { - return Some(agent); - } - for wt in self.worktrees.values_mut() { - if let Some(agent) = wt.agents.get_mut(id) { - return Some(agent); - } - } - None - } -} - -pub enum AgentLocation<'a> { - Root(&'a AgentEntry), - Worktree { - worktree: &'a WorktreeEntry, - agent: &'a AgentEntry, - }, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct AgentConfig { - pub name: String, - pub command: String, - #[serde(default)] - pub prompt_flag: Option, - #[serde(default = "crate::serde_defaults::default_true")] - pub interactive: bool, - #[serde(default)] - pub launch_args: Option>, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct Config { - #[serde(default = "crate::serde_defaults::default_agent")] - pub default_agent: String, - #[serde(default = "default_agents")] - pub agents: IndexMap, - #[serde(default = "default_env_files")] - pub env_files: Vec, -} - -fn default_env_files() -> Vec { - vec![".env".to_string(), ".env.local".to_string()] -} - -pub fn default_agents() -> IndexMap { - // (name, command) — command "shell" is a sentinel the engine resolves to $SHELL - [ - ("claude", "claude"), - ("codex", "codex"), - ("opencode", "opencode"), - ("terminal", "shell"), - ] - .into_iter() - .map(|(name, cmd)| { - ( - name.to_string(), - AgentConfig { - name: name.to_string(), - command: cmd.to_string(), - prompt_flag: None, - interactive: true, - launch_args: None, - }, - ) - }) - .collect() -} - -/// Resolve launch args for an agent type. -/// - `None` → use built-in defaults per agent type -/// - `Some([])` → no launch args (user explicitly disabled auto-mode) -/// - `Some([...])` → use exactly these args -pub fn resolved_launch_args(agent_type: &str, launch_args: Option<&[String]>) -> Vec { - match launch_args { - Some(args) => args.to_vec(), - None => match agent_type { - "claude" => vec!["--dangerously-skip-permissions".into()], - "codex" => vec!["--full-auto".into()], - _ => vec![], - }, - } -} - -impl Default for Config { - fn default() -> Self { - Self { - default_agent: crate::serde_defaults::default_agent(), - agents: default_agents(), - env_files: default_env_files(), - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use serde_json; - - // --- AgentStatus --- - - #[test] - fn given_agent_status_streaming_should_serialize() { - let json = serde_json::to_string(&AgentStatus::Streaming).unwrap(); - assert_eq!(json, r#""streaming""#); - } - - #[test] - fn given_agent_status_waiting_should_serialize() { - let json = serde_json::to_string(&AgentStatus::Waiting).unwrap(); - assert_eq!(json, r#""waiting""#); - } - - #[test] - fn given_agent_status_broken_should_serialize() { - let json = serde_json::to_string(&AgentStatus::Broken).unwrap(); - assert_eq!(json, r#""broken""#); - } - - #[test] - fn given_all_agent_statuses_should_round_trip_json() { - let statuses = vec![ - AgentStatus::Streaming, - AgentStatus::Waiting, - AgentStatus::Broken, - ]; - for status in statuses { - let json = serde_json::to_string(&status).unwrap(); - let parsed: AgentStatus = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, status); - } - } - - #[test] - fn given_old_status_values_should_deserialize_to_new() { - // spawning, running → Streaming - assert_eq!( - serde_json::from_str::(r#""spawning""#).unwrap(), - AgentStatus::Streaming - ); - assert_eq!( - serde_json::from_str::(r#""running""#).unwrap(), - AgentStatus::Streaming - ); - // idle, suspended → Waiting - assert_eq!( - serde_json::from_str::(r#""idle""#).unwrap(), - AgentStatus::Waiting - ); - assert_eq!( - serde_json::from_str::(r#""suspended""#).unwrap(), - AgentStatus::Waiting - ); - // completed, failed, killed, lost → Broken - assert_eq!( - serde_json::from_str::(r#""completed""#).unwrap(), - AgentStatus::Broken - ); - assert_eq!( - serde_json::from_str::(r#""failed""#).unwrap(), - AgentStatus::Broken - ); - assert_eq!( - serde_json::from_str::(r#""killed""#).unwrap(), - AgentStatus::Broken - ); - assert_eq!( - serde_json::from_str::(r#""lost""#).unwrap(), - AgentStatus::Broken - ); - } - - #[test] - fn given_broken_status_should_not_be_alive() { - assert!(!AgentStatus::Broken.is_alive()); - } - - #[test] - fn given_active_statuses_should_be_alive() { - assert!(AgentStatus::Streaming.is_alive()); - assert!(AgentStatus::Waiting.is_alive()); - } - - // --- WorktreeStatus --- - - #[test] - fn given_worktree_status_should_round_trip_json() { - let statuses = vec![ - WorktreeStatus::Active, - WorktreeStatus::Merging, - WorktreeStatus::Merged, - WorktreeStatus::Failed, - WorktreeStatus::Cleaned, - ]; - for status in statuses { - let json = serde_json::to_string(&status).unwrap(); - let parsed: WorktreeStatus = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, status); - } - } - - // --- AgentEntry --- - - #[test] - fn given_agent_entry_should_serialize_camel_case_keys() { - let entry = AgentEntry { - id: "ag-abc".into(), - name: "claude".into(), - agent_type: "claude".into(), - status: AgentStatus::Streaming, - prompt: Some("fix bug".into()), - started_at: chrono::Utc::now(), - completed_at: None, - exit_code: None, - error: None, - pid: Some(1234), - session_id: None, - suspended_at: None, - suspended: false, - command: None, - plan_mode: false, - trigger_seq_index: None, - trigger_state: None, - trigger_total: None, - gate_attempts: None, - no_trigger: false, - trigger_name: None, - }; - let json = serde_json::to_string(&entry).unwrap(); - // Should use camelCase per manifest compat - assert!(json.contains("agentType")); - assert!(json.contains("startedAt")); - // Optional None fields with skip_serializing_if should be absent - assert!(!json.contains("completedAt")); - assert!(!json.contains("exitCode")); - assert!(!json.contains("sessionId")); - // plan_mode false should be omitted - assert!(!json.contains("planMode")); - } - - #[test] - fn given_agent_entry_with_suspended_at_but_no_suspended_field_should_infer_suspended() { - let json = r#"{ - "id": "ag-old", - "name": "claude", - "agentType": "claude", - "status": "waiting", - "prompt": null, - "startedAt": "2026-03-01T00:00:00Z", - "suspendedAt": "2026-03-01T01:00:00Z" - }"#; - let entry: AgentEntry = serde_json::from_str(json).unwrap(); - assert!( - entry.suspended, - "suspended should be true when suspendedAt is present" - ); - assert!(entry.suspended_at.is_some()); - } - - #[test] - fn given_agent_entry_json_should_deserialize_camel_case() { - let json = r#"{ - "id": "ag-xyz", - "name": "claude", - "agentType": "claude", - "status": "idle", - "prompt": null, - "startedAt": "2026-03-01T00:00:00Z", - "pid": 5678 - }"#; - let entry: AgentEntry = serde_json::from_str(json).unwrap(); - assert_eq!(entry.id, "ag-xyz"); - assert_eq!(entry.status, AgentStatus::Waiting); - assert_eq!(entry.pid, Some(5678)); - } - - #[test] - fn given_agent_entry_with_plan_mode_true_should_serialize() { - // given - let entry = AgentEntry { - id: "ag-plan".into(), - name: "claude".into(), - agent_type: "claude".into(), - status: AgentStatus::Streaming, - prompt: Some("research this".into()), - started_at: chrono::Utc::now(), - completed_at: None, - exit_code: None, - error: None, - pid: Some(1234), - session_id: None, - suspended_at: None, - suspended: false, - command: None, - plan_mode: true, - trigger_seq_index: None, - trigger_state: None, - trigger_total: None, - gate_attempts: None, - no_trigger: false, - trigger_name: None, - }; - - // when - let json = serde_json::to_string(&entry).unwrap(); - - // then - assert!(json.contains("planMode")); - let parsed: AgentEntry = serde_json::from_str(&json).unwrap(); - assert!(parsed.plan_mode); - } - - #[test] - fn given_old_manifest_without_plan_mode_should_default_to_false() { - // given: JSON without planMode field (backward compat with old manifests) - let json = r#"{ - "id": "ag-old", - "name": "claude", - "agentType": "claude", - "status": "streaming", - "prompt": "hello", - "startedAt": "2026-03-01T00:00:00Z", - "pid": 1234 - }"#; - - // when - let entry: AgentEntry = serde_json::from_str(json).unwrap(); - - // then - assert!(!entry.plan_mode); - } - - // --- WorktreeEntry --- - - #[test] - fn given_worktree_entry_should_round_trip_json() { - let mut agents = IndexMap::new(); - agents.insert( - "ag-1".to_string(), - AgentEntry { - id: "ag-1".into(), - name: "claude".into(), - agent_type: "claude".into(), - status: AgentStatus::Streaming, - prompt: Some("test".into()), - started_at: chrono::Utc::now(), - completed_at: None, - exit_code: None, - error: None, - pid: None, - session_id: None, - suspended_at: None, - suspended: false, - command: None, - plan_mode: false, - trigger_seq_index: None, - trigger_state: None, - trigger_total: None, - gate_attempts: None, - no_trigger: false, - trigger_name: None, - }, - ); - let entry = WorktreeEntry { - id: "wt-abc".into(), - name: "fix-auth".into(), - path: "/tmp/wt".into(), - branch: "pu/fix-auth".into(), - base_branch: Some("main".into()), - status: WorktreeStatus::Active, - agents, - created_at: chrono::Utc::now(), - merged_at: None, - }; - let json = serde_json::to_string(&entry).unwrap(); - let parsed: WorktreeEntry = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed.id, "wt-abc"); - assert_eq!(parsed.branch, "pu/fix-auth"); - assert!(parsed.agents.contains_key("ag-1")); - } - - // --- Manifest --- - - #[test] - fn given_new_manifest_should_have_version_1_and_empty_collections() { - let m = Manifest::new("/test".into()); - assert_eq!(m.version, 1); - assert!(m.worktrees.is_empty()); - assert!(m.agents.is_empty()); - assert_eq!(m.project_root, "/test"); - } - - #[test] - fn given_manifest_with_root_agent_should_find_by_id() { - let mut m = Manifest::new("/test".into()); - m.agents.insert( - "ag-1".into(), - AgentEntry { - id: "ag-1".into(), - name: "claude".into(), - agent_type: "claude".into(), - status: AgentStatus::Streaming, - prompt: None, - started_at: chrono::Utc::now(), - completed_at: None, - exit_code: None, - error: None, - pid: None, - session_id: None, - suspended_at: None, - suspended: false, - command: None, - plan_mode: false, - trigger_seq_index: None, - trigger_state: None, - trigger_total: None, - gate_attempts: None, - no_trigger: false, - trigger_name: None, - }, - ); - assert!(matches!(m.find_agent("ag-1"), Some(AgentLocation::Root(_)))); - assert!(m.find_agent("ag-999").is_none()); - } - - #[test] - fn given_manifest_with_worktree_agent_should_find_by_id() { - let mut m = Manifest::new("/test".into()); - let mut agents = IndexMap::new(); - agents.insert( - "ag-2".to_string(), - AgentEntry { - id: "ag-2".into(), - name: "claude".into(), - agent_type: "claude".into(), - status: AgentStatus::Waiting, - prompt: None, - started_at: chrono::Utc::now(), - completed_at: None, - exit_code: None, - error: None, - pid: None, - session_id: None, - suspended_at: None, - suspended: false, - command: None, - plan_mode: false, - trigger_seq_index: None, - trigger_state: None, - trigger_total: None, - gate_attempts: None, - no_trigger: false, - trigger_name: None, - }, - ); - m.worktrees.insert( - "wt-1".into(), - WorktreeEntry { - id: "wt-1".into(), - name: "test".into(), - path: "/tmp".into(), - branch: "pu/test".into(), - base_branch: None, - status: WorktreeStatus::Active, - agents, - created_at: chrono::Utc::now(), - merged_at: None, - }, - ); - assert!(matches!( - m.find_agent("ag-2"), - Some(AgentLocation::Worktree { .. }) - )); - } - - #[test] - fn given_manifest_with_mixed_agents_should_return_all() { - let mut m = Manifest::new("/test".into()); - let now = chrono::Utc::now(); - let make_agent = |id: &str| AgentEntry { - id: id.into(), - name: "claude".into(), - agent_type: "claude".into(), - status: AgentStatus::Streaming, - prompt: None, - started_at: now, - completed_at: None, - exit_code: None, - error: None, - pid: None, - session_id: None, - suspended_at: None, - suspended: false, - command: None, - plan_mode: false, - trigger_seq_index: None, - trigger_state: None, - trigger_total: None, - gate_attempts: None, - no_trigger: false, - trigger_name: None, - }; - m.agents.insert("ag-root".into(), make_agent("ag-root")); - let mut wt_agents = IndexMap::new(); - wt_agents.insert("ag-wt".to_string(), make_agent("ag-wt")); - m.worktrees.insert( - "wt-1".into(), - WorktreeEntry { - id: "wt-1".into(), - name: "test".into(), - path: "/tmp".into(), - branch: "pu/test".into(), - base_branch: None, - status: WorktreeStatus::Active, - agents: wt_agents, - created_at: now, - merged_at: None, - }, - ); - let all = m.all_agents(); - assert_eq!(all.len(), 2); - } - - // --- Config --- - - #[test] - fn given_default_config_should_have_claude_agent() { - let config = Config::default(); - assert_eq!(config.default_agent, "claude"); - assert!(config.agents.contains_key("claude")); - let claude = &config.agents["claude"]; - assert_eq!(claude.command, "claude"); - assert!(claude.prompt_flag.is_none()); - assert!(claude.interactive); - } - - #[test] - fn given_default_config_should_have_codex_and_opencode_agents() { - let config = Config::default(); - assert!(config.agents.contains_key("codex")); - assert_eq!(config.agents["codex"].command, "codex"); - assert!(config.agents.contains_key("opencode")); - assert_eq!(config.agents["opencode"].command, "opencode"); - } - - #[test] - fn given_default_config_should_have_terminal_agent() { - let config = Config::default(); - assert!(config.agents.contains_key("terminal")); - let terminal = &config.agents["terminal"]; - assert_eq!(terminal.command, "shell"); - assert!(terminal.prompt_flag.is_none()); - assert!(terminal.interactive); - } - - #[test] - fn given_config_should_round_trip_yaml() { - let config = Config::default(); - let yaml = serde_yml::to_string(&config).unwrap(); - let parsed: Config = serde_yml::from_str(&yaml).unwrap(); - assert_eq!(parsed.default_agent, "claude"); - assert!(parsed.agents.contains_key("claude")); - } - - // --- launch_args --- - - #[test] - fn given_agent_config_without_launch_args_should_default_to_none() { - let yaml = r#" -name: claude -command: claude -"#; - let config: AgentConfig = serde_yml::from_str(yaml).unwrap(); - assert!(config.launch_args.is_none()); - } - - #[test] - fn given_agent_config_with_empty_launch_args_should_deserialize_as_empty_vec() { - let yaml = r#" -name: claude -command: claude -launchArgs: [] -"#; - let config: AgentConfig = serde_yml::from_str(yaml).unwrap(); - assert_eq!(config.launch_args, Some(vec![])); - } - - #[test] - fn given_agent_config_with_launch_args_should_deserialize_flags() { - let yaml = r#" -name: claude -command: claude -launchArgs: - - "--dangerously-skip-permissions" - - "--verbose" -"#; - let config: AgentConfig = serde_yml::from_str(yaml).unwrap(); - assert_eq!( - config.launch_args, - Some(vec![ - "--dangerously-skip-permissions".to_string(), - "--verbose".to_string() - ]) - ); - } - - #[test] - fn given_agent_config_with_launch_args_should_round_trip_yaml() { - let config = AgentConfig { - name: "claude".into(), - command: "claude".into(), - prompt_flag: None, - interactive: true, - launch_args: Some(vec!["--dangerously-skip-permissions".into()]), - }; - let yaml = serde_yml::to_string(&config).unwrap(); - let parsed: AgentConfig = serde_yml::from_str(&yaml).unwrap(); - assert_eq!(parsed.launch_args, config.launch_args); - } - - #[test] - fn given_claude_agent_type_should_resolve_default_launch_args() { - // When launch_args is None, claude should get --dangerously-skip-permissions - let args = resolved_launch_args("claude", None); - assert_eq!(args, vec!["--dangerously-skip-permissions"]); - } - - #[test] - fn given_codex_agent_type_should_resolve_default_launch_args() { - let args = resolved_launch_args("codex", None); - assert_eq!(args, vec!["--full-auto"]); - } - - #[test] - fn given_opencode_agent_type_should_resolve_empty_default_launch_args() { - let args = resolved_launch_args("opencode", None); - assert!(args.is_empty()); - } - - #[test] - fn given_terminal_agent_type_should_resolve_empty_default_launch_args() { - let args = resolved_launch_args("terminal", None); - assert!(args.is_empty()); - } - - #[test] - fn given_explicit_empty_launch_args_should_override_defaults() { - // User explicitly sets launchArgs: [] to disable auto-mode - let args = resolved_launch_args("claude", Some(&[])); - assert!(args.is_empty()); - } - - #[test] - fn given_explicit_launch_args_should_override_defaults() { - let custom = vec!["--verbose".to_string()]; - let args = resolved_launch_args("claude", Some(&custom)); - assert_eq!(args, vec!["--verbose"]); - } - - // --- TriggerState --- - - #[test] - fn given_trigger_state_should_round_trip_json() { - let states = vec![ - TriggerState::Active, - TriggerState::Gating, - TriggerState::Completed, - TriggerState::Failed, - ]; - for state in states { - let json = serde_json::to_string(&state).unwrap(); - let parsed: TriggerState = serde_json::from_str(&json).unwrap(); - assert_eq!(parsed, state); - } - } - - #[test] - fn given_agent_entry_without_trigger_fields_should_deserialize_with_defaults() { - let json = r#"{ - "id": "ag-old", - "name": "claude", - "agentType": "claude", - "status": "streaming", - "prompt": null, - "startedAt": "2026-03-01T00:00:00Z" - }"#; - let entry: AgentEntry = serde_json::from_str(json).unwrap(); - assert!(entry.trigger_seq_index.is_none()); - assert!(entry.trigger_state.is_none()); - assert!(entry.gate_attempts.is_none()); - assert!(!entry.no_trigger); - } - - #[test] - fn given_agent_entry_with_trigger_fields_should_round_trip() { - let json = r#"{ - "id": "ag-new", - "name": "claude", - "agentType": "claude", - "status": "waiting", - "prompt": null, - "startedAt": "2026-03-01T00:00:00Z", - "triggerSeqIndex": 2, - "triggerState": "active", - "gateAttempts": 1, - "noTrigger": false - }"#; - let entry: AgentEntry = serde_json::from_str(json).unwrap(); - assert_eq!(entry.trigger_seq_index, Some(2)); - assert_eq!(entry.trigger_state, Some(TriggerState::Active)); - assert_eq!(entry.gate_attempts, Some(1)); - assert!(!entry.no_trigger); - } -} diff --git a/crates/pu-core/src/types/agent.rs b/crates/pu-core/src/types/agent.rs new file mode 100644 index 0000000..aabf51f --- /dev/null +++ b/crates/pu-core/src/types/agent.rs @@ -0,0 +1,443 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AgentStatus { + Streaming, + Waiting, + Broken, +} + +impl AgentStatus { + pub fn is_alive(self) -> bool { + matches!(self, Self::Streaming | Self::Waiting) + } +} + +impl Serialize for AgentStatus { + fn serialize(&self, serializer: S) -> Result { + let s = match self { + AgentStatus::Streaming => "streaming", + AgentStatus::Waiting => "waiting", + AgentStatus::Broken => "broken", + }; + serializer.serialize_str(s) + } +} + +impl<'de> Deserialize<'de> for AgentStatus { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "streaming" => Ok(AgentStatus::Streaming), + "waiting" => Ok(AgentStatus::Waiting), + "broken" => Ok(AgentStatus::Broken), + // Backward compat: map old status values + "spawning" | "running" => Ok(AgentStatus::Streaming), + "idle" | "suspended" => Ok(AgentStatus::Waiting), + "completed" | "failed" | "killed" | "lost" => Ok(AgentStatus::Broken), + other => Err(serde::de::Error::unknown_variant( + other, + &["streaming", "waiting", "broken"], + )), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum TriggerState { + Active, + Gating, + Completed, + Failed, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentEntry { + pub id: String, + pub name: String, + pub agent_type: String, + pub status: AgentStatus, + pub prompt: Option, + pub started_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub completed_at: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub exit_code: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub suspended_at: Option>, + /// Whether this agent is currently suspended. Only meaningful when `status.is_alive()`. + /// Invariant: custom Deserialize infers `true` when `suspended_at` is present (backward + /// compat with old manifests). The engine sets both `suspended` and `suspended_at` + /// atomically on suspend/resume. + #[serde(default)] + pub suspended: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub command: Option, + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub plan_mode: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_seq_index: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_state: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gate_attempts: Option, + #[serde(default, skip_serializing_if = "crate::serde_defaults::is_false")] + pub no_trigger: bool, + /// Name of the trigger definition this agent is bound to. + /// Set at spawn time so the sequence is stable across ticks. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trigger_name: Option, +} + +impl<'de> Deserialize<'de> for AgentEntry { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(rename_all = "camelCase")] + struct Raw { + id: String, + name: String, + agent_type: String, + status: AgentStatus, + prompt: Option, + started_at: DateTime, + completed_at: Option>, + exit_code: Option, + error: Option, + pid: Option, + session_id: Option, + suspended_at: Option>, + #[serde(default)] + suspended: bool, + #[serde(default)] + command: Option, + #[serde(default)] + plan_mode: bool, + #[serde(default)] + trigger_seq_index: Option, + #[serde(default)] + trigger_state: Option, + #[serde(default)] + trigger_total: Option, + #[serde(default)] + gate_attempts: Option, + #[serde(default)] + no_trigger: bool, + #[serde(default)] + trigger_name: Option, + } + let raw = Raw::deserialize(deserializer)?; + // Backward compat: old manifests have suspended_at set but no suspended field. + // Ensure suspended is true when suspended_at is present. + let suspended = raw.suspended || raw.suspended_at.is_some(); + Ok(AgentEntry { + id: raw.id, + name: raw.name, + agent_type: raw.agent_type, + status: raw.status, + prompt: raw.prompt, + started_at: raw.started_at, + completed_at: raw.completed_at, + exit_code: raw.exit_code, + error: raw.error, + pid: raw.pid, + session_id: raw.session_id, + suspended_at: raw.suspended_at, + suspended, + command: raw.command, + plan_mode: raw.plan_mode, + trigger_seq_index: raw.trigger_seq_index, + trigger_state: raw.trigger_state, + trigger_total: raw.trigger_total, + gate_attempts: raw.gate_attempts, + no_trigger: raw.no_trigger, + trigger_name: raw.trigger_name, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + + // --- AgentStatus --- + + #[test] + fn given_agent_status_streaming_should_serialize() { + let json = serde_json::to_string(&AgentStatus::Streaming).unwrap(); + assert_eq!(json, r#""streaming""#); + } + + #[test] + fn given_agent_status_waiting_should_serialize() { + let json = serde_json::to_string(&AgentStatus::Waiting).unwrap(); + assert_eq!(json, r#""waiting""#); + } + + #[test] + fn given_agent_status_broken_should_serialize() { + let json = serde_json::to_string(&AgentStatus::Broken).unwrap(); + assert_eq!(json, r#""broken""#); + } + + #[test] + fn given_all_agent_statuses_should_round_trip_json() { + let statuses = vec![ + AgentStatus::Streaming, + AgentStatus::Waiting, + AgentStatus::Broken, + ]; + for status in statuses { + let json = serde_json::to_string(&status).unwrap(); + let parsed: AgentStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, status); + } + } + + #[test] + fn given_old_status_values_should_deserialize_to_new() { + // spawning, running → Streaming + assert_eq!( + serde_json::from_str::(r#""spawning""#).unwrap(), + AgentStatus::Streaming + ); + assert_eq!( + serde_json::from_str::(r#""running""#).unwrap(), + AgentStatus::Streaming + ); + // idle, suspended → Waiting + assert_eq!( + serde_json::from_str::(r#""idle""#).unwrap(), + AgentStatus::Waiting + ); + assert_eq!( + serde_json::from_str::(r#""suspended""#).unwrap(), + AgentStatus::Waiting + ); + // completed, failed, killed, lost → Broken + assert_eq!( + serde_json::from_str::(r#""completed""#).unwrap(), + AgentStatus::Broken + ); + assert_eq!( + serde_json::from_str::(r#""failed""#).unwrap(), + AgentStatus::Broken + ); + assert_eq!( + serde_json::from_str::(r#""killed""#).unwrap(), + AgentStatus::Broken + ); + assert_eq!( + serde_json::from_str::(r#""lost""#).unwrap(), + AgentStatus::Broken + ); + } + + #[test] + fn given_broken_status_should_not_be_alive() { + assert!(!AgentStatus::Broken.is_alive()); + } + + #[test] + fn given_active_statuses_should_be_alive() { + assert!(AgentStatus::Streaming.is_alive()); + assert!(AgentStatus::Waiting.is_alive()); + } + + // --- AgentEntry --- + + #[test] + fn given_agent_entry_should_serialize_camel_case_keys() { + let entry = AgentEntry { + id: "ag-abc".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + prompt: Some("fix bug".into()), + started_at: chrono::Utc::now(), + completed_at: None, + exit_code: None, + error: None, + pid: Some(1234), + session_id: None, + suspended_at: None, + suspended: false, + command: None, + plan_mode: false, + trigger_seq_index: None, + trigger_state: None, + trigger_total: None, + gate_attempts: None, + no_trigger: false, + trigger_name: None, + }; + let json = serde_json::to_string(&entry).unwrap(); + // Should use camelCase per manifest compat + assert!(json.contains("agentType")); + assert!(json.contains("startedAt")); + // Optional None fields with skip_serializing_if should be absent + assert!(!json.contains("completedAt")); + assert!(!json.contains("exitCode")); + assert!(!json.contains("sessionId")); + // plan_mode false should be omitted + assert!(!json.contains("planMode")); + } + + #[test] + fn given_agent_entry_with_suspended_at_but_no_suspended_field_should_infer_suspended() { + let json = r#"{ + "id": "ag-old", + "name": "claude", + "agentType": "claude", + "status": "waiting", + "prompt": null, + "startedAt": "2026-03-01T00:00:00Z", + "suspendedAt": "2026-03-01T01:00:00Z" + }"#; + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + assert!( + entry.suspended, + "suspended should be true when suspendedAt is present" + ); + assert!(entry.suspended_at.is_some()); + } + + #[test] + fn given_agent_entry_json_should_deserialize_camel_case() { + let json = r#"{ + "id": "ag-xyz", + "name": "claude", + "agentType": "claude", + "status": "idle", + "prompt": null, + "startedAt": "2026-03-01T00:00:00Z", + "pid": 5678 + }"#; + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.id, "ag-xyz"); + assert_eq!(entry.status, AgentStatus::Waiting); + assert_eq!(entry.pid, Some(5678)); + } + + #[test] + fn given_agent_entry_with_plan_mode_true_should_serialize() { + // given + let entry = AgentEntry { + id: "ag-plan".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + prompt: Some("research this".into()), + started_at: chrono::Utc::now(), + completed_at: None, + exit_code: None, + error: None, + pid: Some(1234), + session_id: None, + suspended_at: None, + suspended: false, + command: None, + plan_mode: true, + trigger_seq_index: None, + trigger_state: None, + trigger_total: None, + gate_attempts: None, + no_trigger: false, + trigger_name: None, + }; + + // when + let json = serde_json::to_string(&entry).unwrap(); + + // then + assert!(json.contains("planMode")); + let parsed: AgentEntry = serde_json::from_str(&json).unwrap(); + assert!(parsed.plan_mode); + } + + #[test] + fn given_old_manifest_without_plan_mode_should_default_to_false() { + // given: JSON without planMode field (backward compat with old manifests) + let json = r#"{ + "id": "ag-old", + "name": "claude", + "agentType": "claude", + "status": "streaming", + "prompt": "hello", + "startedAt": "2026-03-01T00:00:00Z", + "pid": 1234 + }"#; + + // when + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + + // then + assert!(!entry.plan_mode); + } + + // --- TriggerState --- + + #[test] + fn given_trigger_state_should_round_trip_json() { + let states = vec![ + TriggerState::Active, + TriggerState::Gating, + TriggerState::Completed, + TriggerState::Failed, + ]; + for state in states { + let json = serde_json::to_string(&state).unwrap(); + let parsed: TriggerState = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, state); + } + } + + #[test] + fn given_agent_entry_without_trigger_fields_should_deserialize_with_defaults() { + let json = r#"{ + "id": "ag-old", + "name": "claude", + "agentType": "claude", + "status": "streaming", + "prompt": null, + "startedAt": "2026-03-01T00:00:00Z" + }"#; + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + assert!(entry.trigger_seq_index.is_none()); + assert!(entry.trigger_state.is_none()); + assert!(entry.gate_attempts.is_none()); + assert!(!entry.no_trigger); + } + + #[test] + fn given_agent_entry_with_trigger_fields_should_round_trip() { + let json = r#"{ + "id": "ag-new", + "name": "claude", + "agentType": "claude", + "status": "waiting", + "prompt": null, + "startedAt": "2026-03-01T00:00:00Z", + "triggerSeqIndex": 2, + "triggerState": "active", + "gateAttempts": 1, + "noTrigger": false + }"#; + let entry: AgentEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.trigger_seq_index, Some(2)); + assert_eq!(entry.trigger_state, Some(TriggerState::Active)); + assert_eq!(entry.gate_attempts, Some(1)); + assert!(!entry.no_trigger); + } +} diff --git a/crates/pu-core/src/types/config.rs b/crates/pu-core/src/types/config.rs new file mode 100644 index 0000000..732fcdf --- /dev/null +++ b/crates/pu-core/src/types/config.rs @@ -0,0 +1,218 @@ +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AgentConfig { + pub name: String, + pub command: String, + #[serde(default)] + pub prompt_flag: Option, + #[serde(default = "crate::serde_defaults::default_true")] + pub interactive: bool, + #[serde(default)] + pub launch_args: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Config { + #[serde(default = "crate::serde_defaults::default_agent")] + pub default_agent: String, + #[serde(default = "default_agents")] + pub agents: IndexMap, + #[serde(default = "default_env_files")] + pub env_files: Vec, +} + +fn default_env_files() -> Vec { + vec![".env".to_string(), ".env.local".to_string()] +} + +pub fn default_agents() -> IndexMap { + // (name, command) — command "shell" is a sentinel the engine resolves to $SHELL + [ + ("claude", "claude"), + ("codex", "codex"), + ("opencode", "opencode"), + ("terminal", "shell"), + ] + .into_iter() + .map(|(name, cmd)| { + ( + name.to_string(), + AgentConfig { + name: name.to_string(), + command: cmd.to_string(), + prompt_flag: None, + interactive: true, + launch_args: None, + }, + ) + }) + .collect() +} + +/// Resolve launch args for an agent type. +/// - `None` → use built-in defaults per agent type +/// - `Some([])` → no launch args (user explicitly disabled auto-mode) +/// - `Some([...])` → use exactly these args +pub fn resolved_launch_args(agent_type: &str, launch_args: Option<&[String]>) -> Vec { + match launch_args { + Some(args) => args.to_vec(), + None => match agent_type { + "claude" => vec!["--dangerously-skip-permissions".into()], + "codex" => vec!["--full-auto".into()], + _ => vec![], + }, + } +} + +impl Default for Config { + fn default() -> Self { + Self { + default_agent: crate::serde_defaults::default_agent(), + agents: default_agents(), + env_files: default_env_files(), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn given_default_config_should_have_claude_agent() { + let config = Config::default(); + assert_eq!(config.default_agent, "claude"); + assert!(config.agents.contains_key("claude")); + let claude = &config.agents["claude"]; + assert_eq!(claude.command, "claude"); + assert!(claude.prompt_flag.is_none()); + assert!(claude.interactive); + } + + #[test] + fn given_default_config_should_have_codex_and_opencode_agents() { + let config = Config::default(); + assert!(config.agents.contains_key("codex")); + assert_eq!(config.agents["codex"].command, "codex"); + assert!(config.agents.contains_key("opencode")); + assert_eq!(config.agents["opencode"].command, "opencode"); + } + + #[test] + fn given_default_config_should_have_terminal_agent() { + let config = Config::default(); + assert!(config.agents.contains_key("terminal")); + let terminal = &config.agents["terminal"]; + assert_eq!(terminal.command, "shell"); + assert!(terminal.prompt_flag.is_none()); + assert!(terminal.interactive); + } + + #[test] + fn given_config_should_round_trip_yaml() { + let config = Config::default(); + let yaml = serde_yml::to_string(&config).unwrap(); + let parsed: Config = serde_yml::from_str(&yaml).unwrap(); + assert_eq!(parsed.default_agent, "claude"); + assert!(parsed.agents.contains_key("claude")); + } + + // --- launch_args --- + + #[test] + fn given_agent_config_without_launch_args_should_default_to_none() { + let yaml = r#" +name: claude +command: claude +"#; + let config: AgentConfig = serde_yml::from_str(yaml).unwrap(); + assert!(config.launch_args.is_none()); + } + + #[test] + fn given_agent_config_with_empty_launch_args_should_deserialize_as_empty_vec() { + let yaml = r#" +name: claude +command: claude +launchArgs: [] +"#; + let config: AgentConfig = serde_yml::from_str(yaml).unwrap(); + assert_eq!(config.launch_args, Some(vec![])); + } + + #[test] + fn given_agent_config_with_launch_args_should_deserialize_flags() { + let yaml = r#" +name: claude +command: claude +launchArgs: + - "--dangerously-skip-permissions" + - "--verbose" +"#; + let config: AgentConfig = serde_yml::from_str(yaml).unwrap(); + assert_eq!( + config.launch_args, + Some(vec![ + "--dangerously-skip-permissions".to_string(), + "--verbose".to_string() + ]) + ); + } + + #[test] + fn given_agent_config_with_launch_args_should_round_trip_yaml() { + let config = AgentConfig { + name: "claude".into(), + command: "claude".into(), + prompt_flag: None, + interactive: true, + launch_args: Some(vec!["--dangerously-skip-permissions".into()]), + }; + let yaml = serde_yml::to_string(&config).unwrap(); + let parsed: AgentConfig = serde_yml::from_str(&yaml).unwrap(); + assert_eq!(parsed.launch_args, config.launch_args); + } + + #[test] + fn given_claude_agent_type_should_resolve_default_launch_args() { + // When launch_args is None, claude should get --dangerously-skip-permissions + let args = resolved_launch_args("claude", None); + assert_eq!(args, vec!["--dangerously-skip-permissions"]); + } + + #[test] + fn given_codex_agent_type_should_resolve_default_launch_args() { + let args = resolved_launch_args("codex", None); + assert_eq!(args, vec!["--full-auto"]); + } + + #[test] + fn given_opencode_agent_type_should_resolve_empty_default_launch_args() { + let args = resolved_launch_args("opencode", None); + assert!(args.is_empty()); + } + + #[test] + fn given_terminal_agent_type_should_resolve_empty_default_launch_args() { + let args = resolved_launch_args("terminal", None); + assert!(args.is_empty()); + } + + #[test] + fn given_explicit_empty_launch_args_should_override_defaults() { + // User explicitly sets launchArgs: [] to disable auto-mode + let args = resolved_launch_args("claude", Some(&[])); + assert!(args.is_empty()); + } + + #[test] + fn given_explicit_launch_args_should_override_defaults() { + let custom = vec!["--verbose".to_string()]; + let args = resolved_launch_args("claude", Some(&custom)); + assert_eq!(args, vec!["--verbose"]); + } +} diff --git a/crates/pu-core/src/types/manifest.rs b/crates/pu-core/src/types/manifest.rs new file mode 100644 index 0000000..afc2fe4 --- /dev/null +++ b/crates/pu-core/src/types/manifest.rs @@ -0,0 +1,222 @@ +use chrono::{DateTime, Utc}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use super::agent::AgentEntry; +use super::worktree::WorktreeEntry; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Manifest { + pub version: u32, + pub project_root: String, + pub worktrees: IndexMap, + #[serde(default)] + pub agents: IndexMap, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl Manifest { + pub fn new(project_root: String) -> Self { + let now = Utc::now(); + Self { + version: 1, + project_root, + worktrees: IndexMap::new(), + agents: IndexMap::new(), + created_at: now, + updated_at: now, + } + } + + pub fn find_agent(&self, agent_id: &str) -> Option> { + if let Some(agent) = self.agents.get(agent_id) { + return Some(AgentLocation::Root(agent)); + } + for wt in self.worktrees.values() { + if let Some(agent) = wt.agents.get(agent_id) { + return Some(AgentLocation::Worktree { + worktree: wt, + agent, + }); + } + } + None + } + + pub fn all_agents(&self) -> Vec<&AgentEntry> { + let mut agents: Vec<&AgentEntry> = self.agents.values().collect(); + for wt in self.worktrees.values() { + agents.extend(wt.agents.values()); + } + agents + } + + pub fn find_agent_mut(&mut self, id: &str) -> Option<&mut AgentEntry> { + if let Some(agent) = self.agents.get_mut(id) { + return Some(agent); + } + for wt in self.worktrees.values_mut() { + if let Some(agent) = wt.agents.get_mut(id) { + return Some(agent); + } + } + None + } +} + +pub enum AgentLocation<'a> { + Root(&'a AgentEntry), + Worktree { + worktree: &'a WorktreeEntry, + agent: &'a AgentEntry, + }, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{AgentStatus, WorktreeStatus}; + use indexmap::IndexMap; + + #[test] + fn given_new_manifest_should_have_version_1_and_empty_collections() { + let m = Manifest::new("/test".into()); + assert_eq!(m.version, 1); + assert!(m.worktrees.is_empty()); + assert!(m.agents.is_empty()); + assert_eq!(m.project_root, "/test"); + } + + #[test] + fn given_manifest_with_root_agent_should_find_by_id() { + let mut m = Manifest::new("/test".into()); + m.agents.insert( + "ag-1".into(), + AgentEntry { + id: "ag-1".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + prompt: None, + started_at: chrono::Utc::now(), + completed_at: None, + exit_code: None, + error: None, + pid: None, + session_id: None, + suspended_at: None, + suspended: false, + command: None, + plan_mode: false, + trigger_seq_index: None, + trigger_state: None, + trigger_total: None, + gate_attempts: None, + no_trigger: false, + trigger_name: None, + }, + ); + assert!(matches!(m.find_agent("ag-1"), Some(AgentLocation::Root(_)))); + assert!(m.find_agent("ag-999").is_none()); + } + + #[test] + fn given_manifest_with_worktree_agent_should_find_by_id() { + let mut m = Manifest::new("/test".into()); + let mut agents = IndexMap::new(); + agents.insert( + "ag-2".to_string(), + AgentEntry { + id: "ag-2".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Waiting, + prompt: None, + started_at: chrono::Utc::now(), + completed_at: None, + exit_code: None, + error: None, + pid: None, + session_id: None, + suspended_at: None, + suspended: false, + command: None, + plan_mode: false, + trigger_seq_index: None, + trigger_state: None, + trigger_total: None, + gate_attempts: None, + no_trigger: false, + trigger_name: None, + }, + ); + m.worktrees.insert( + "wt-1".into(), + WorktreeEntry { + id: "wt-1".into(), + name: "test".into(), + path: "/tmp".into(), + branch: "pu/test".into(), + base_branch: None, + status: WorktreeStatus::Active, + agents, + created_at: chrono::Utc::now(), + merged_at: None, + }, + ); + assert!(matches!( + m.find_agent("ag-2"), + Some(AgentLocation::Worktree { .. }) + )); + } + + #[test] + fn given_manifest_with_mixed_agents_should_return_all() { + let mut m = Manifest::new("/test".into()); + let now = chrono::Utc::now(); + let make_agent = |id: &str| AgentEntry { + id: id.into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + prompt: None, + started_at: now, + completed_at: None, + exit_code: None, + error: None, + pid: None, + session_id: None, + suspended_at: None, + suspended: false, + command: None, + plan_mode: false, + trigger_seq_index: None, + trigger_state: None, + trigger_total: None, + gate_attempts: None, + no_trigger: false, + trigger_name: None, + }; + m.agents.insert("ag-root".into(), make_agent("ag-root")); + let mut wt_agents = IndexMap::new(); + wt_agents.insert("ag-wt".to_string(), make_agent("ag-wt")); + m.worktrees.insert( + "wt-1".into(), + WorktreeEntry { + id: "wt-1".into(), + name: "test".into(), + path: "/tmp".into(), + branch: "pu/test".into(), + base_branch: None, + status: WorktreeStatus::Active, + agents: wt_agents, + created_at: now, + merged_at: None, + }, + ); + let all = m.all_agents(); + assert_eq!(all.len(), 2); + } +} diff --git a/crates/pu-core/src/types/mod.rs b/crates/pu-core/src/types/mod.rs new file mode 100644 index 0000000..18a9ca6 --- /dev/null +++ b/crates/pu-core/src/types/mod.rs @@ -0,0 +1,9 @@ +mod agent; +mod config; +mod manifest; +mod worktree; + +pub use agent::*; +pub use config::*; +pub use manifest::*; +pub use worktree::*; diff --git a/crates/pu-core/src/types/worktree.rs b/crates/pu-core/src/types/worktree.rs new file mode 100644 index 0000000..f74da60 --- /dev/null +++ b/crates/pu-core/src/types/worktree.rs @@ -0,0 +1,100 @@ +use chrono::{DateTime, Utc}; +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; + +use super::agent::AgentEntry; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum WorktreeStatus { + Active, + Merging, + Merged, + Failed, + Cleaned, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WorktreeEntry { + pub id: String, + pub name: String, + pub path: String, + pub branch: String, + pub base_branch: Option, + pub status: WorktreeStatus, + pub agents: IndexMap, + pub created_at: DateTime, + #[serde(skip_serializing_if = "Option::is_none")] + pub merged_at: Option>, +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{AgentStatus, WorktreeStatus}; + use serde_json; + + #[test] + fn given_worktree_status_should_round_trip_json() { + let statuses = vec![ + WorktreeStatus::Active, + WorktreeStatus::Merging, + WorktreeStatus::Merged, + WorktreeStatus::Failed, + WorktreeStatus::Cleaned, + ]; + for status in statuses { + let json = serde_json::to_string(&status).unwrap(); + let parsed: WorktreeStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, status); + } + } + + #[test] + fn given_worktree_entry_should_round_trip_json() { + let mut agents = IndexMap::new(); + agents.insert( + "ag-1".to_string(), + AgentEntry { + id: "ag-1".into(), + name: "claude".into(), + agent_type: "claude".into(), + status: AgentStatus::Streaming, + prompt: Some("test".into()), + started_at: chrono::Utc::now(), + completed_at: None, + exit_code: None, + error: None, + pid: None, + session_id: None, + suspended_at: None, + suspended: false, + command: None, + plan_mode: false, + trigger_seq_index: None, + trigger_state: None, + trigger_total: None, + gate_attempts: None, + no_trigger: false, + trigger_name: None, + }, + ); + let entry = WorktreeEntry { + id: "wt-abc".into(), + name: "fix-auth".into(), + path: "/tmp/wt".into(), + branch: "pu/fix-auth".into(), + base_branch: Some("main".into()), + status: WorktreeStatus::Active, + agents, + created_at: chrono::Utc::now(), + merged_at: None, + }; + let json = serde_json::to_string(&entry).unwrap(); + let parsed: WorktreeEntry = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed.id, "wt-abc"); + assert_eq!(parsed.branch, "pu/fix-auth"); + assert!(parsed.agents.contains_key("ag-1")); + } +}