From 9a6963ac04fa87a58eac241c2fd902c6a9d91ef9 Mon Sep 17 00:00:00 2001 From: dave Date: Wed, 13 May 2026 12:47:09 +0000 Subject: [PATCH] huskies: merge 963 --- server/src/crdt_state/types.rs | 21 ++++++++++++- server/src/http/workflow/pipeline.rs | 39 +++++++++++++----------- server/src/service/agents/mod.rs | 8 ++--- server/src/service/ws/message/convert.rs | 4 +-- 4 files changed, 46 insertions(+), 26 deletions(-) diff --git a/server/src/crdt_state/types.rs b/server/src/crdt_state/types.rs index 445e7e52..2a2b5c16 100644 --- a/server/src/crdt_state/types.rs +++ b/server/src/crdt_state/types.rs @@ -134,7 +134,11 @@ pub struct Claim { /// The numeric prefix of the epic's story_id (e.g. `EpicId(9990)` for the /// epic whose CRDT story_id register holds `"9990"`). Epics are always /// created with a pure-numeric story_id by `create_epic_file`. -#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] +/// +/// Serialises as a decimal string (`"9990"`) so JSON consumers see a stable +/// string identifier regardless of whether the inner value fits in a JS integer. +/// Deserialises from a decimal string. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct EpicId(pub u32); impl EpicId { @@ -158,6 +162,21 @@ impl std::fmt::Display for EpicId { } } +impl serde::Serialize for EpicId { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(&self.0.to_string()) + } +} + +impl<'de> serde::Deserialize<'de> for EpicId { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + s.parse::() + .map(EpicId) + .map_err(serde::de::Error::custom) + } +} + /// A typed snapshot of a single pipeline work item derived from the CRDT document. /// /// Access fields exclusively through the typed accessor methods — raw field access is diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index 5d4c2d70..a0413d7e 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -9,9 +9,9 @@ use std::path::Path; /// Agent assignment embedded in a pipeline stage item. #[derive(Clone, Debug, Serialize)] pub struct AgentAssignment { - pub agent_name: String, + pub agent_name: crate::config::AgentName, pub model: Option, - pub status: String, + pub status: crate::agents::AgentStatus, } /// A story/bug/spike item as it appears in a pipeline stage listing. @@ -27,9 +27,9 @@ pub struct UpcomingStory { /// True when the item is held in QA for human review. #[serde(skip_serializing_if = "Option::is_none")] pub review_hold: Option, - /// QA mode for this item: "human", "server", or "agent". + /// QA mode for this item. #[serde(skip_serializing_if = "Option::is_none")] - pub qa: Option, + pub qa: Option, /// Number of retries at the current pipeline stage. #[serde(skip_serializing_if = "Option::is_none")] pub retry_count: Option, @@ -42,9 +42,9 @@ pub struct UpcomingStory { /// Story numbers this story depends on. #[serde(skip_serializing_if = "Option::is_none")] pub depends_on: Option>, - /// Epic this item belongs to (numeric ID as string, e.g. "880"). + /// Epic this item belongs to. #[serde(skip_serializing_if = "Option::is_none")] - pub epic_id: Option, + pub epic_id: Option, } /// Validation outcome for a single story. @@ -117,10 +117,8 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { } else { None }; - let qa = view - .as_ref() - .and_then(|v| v.qa_mode().map(|q| q.as_str().to_string())); - let epic_id = view.as_ref().and_then(|v| v.epic().map(|e| e.to_string())); + let qa = view.as_ref().and_then(|v| v.qa_mode()); + let epic_id = view.as_ref().and_then(|v| v.epic()); let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error); let story = UpcomingStory { @@ -217,16 +215,19 @@ fn build_active_agent_map(ctx: &AppContext) -> HashMap if !matches!(agent.status, AgentStatus::Pending | AgentStatus::Running) { continue; } + let Ok(agent_name) = agent.agent_name.parse::() else { + continue; + }; let model = config_opt .as_ref() - .and_then(|cfg| cfg.find_agent(&agent.agent_name)) + .and_then(|cfg| cfg.find_agent(agent_name.as_str())) .and_then(|ac| ac.model.clone()); map.insert( agent.story_id.clone(), AgentAssignment { - agent_name: agent.agent_name, + agent_name, model, - status: agent.status.to_string(), + status: agent.status, }, ); } @@ -244,8 +245,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St .filter(|item| matches!(item.stage, Stage::Backlog)) .map(|item| { let sid = &item.story_id.0; - let epic_id = - crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(|e| e.to_string())); + let epic_id = crate::crdt_state::read_item(sid).and_then(|v| v.epic()); UpcomingStory { story_id: item.story_id.0.clone(), name: if item.name.is_empty() { @@ -412,8 +412,8 @@ mod tests { "running agent should appear on work item" ); let agent = item.agent.as_ref().unwrap(); - assert_eq!(agent.agent_name, "coder-1"); - assert_eq!(agent.status, "running"); + assert_eq!(agent.agent_name, crate::config::AgentName::Coder1); + assert_eq!(agent.status, crate::agents::AgentStatus::Running); } #[test] @@ -480,7 +480,10 @@ mod tests { item.agent.is_some(), "pending agent should appear on work item" ); - assert_eq!(item.agent.as_ref().unwrap().status, "pending"); + assert_eq!( + item.agent.as_ref().unwrap().status, + crate::agents::AgentStatus::Pending + ); } #[test] diff --git a/server/src/service/agents/mod.rs b/server/src/service/agents/mod.rs index 21089713..f054ceaa 100644 --- a/server/src/service/agents/mod.rs +++ b/server/src/service/agents/mod.rs @@ -59,7 +59,7 @@ pub struct WorkItemContent { pub content: String, pub stage: crate::pipeline_state::Stage, pub name: Option, - pub agent: Option, + pub agent: Option, } /// A single entry in the project's configured agent roster. @@ -163,9 +163,7 @@ pub fn get_work_item_content( let crdt_view = crate::crdt_state::read_item(story_id); let crdt_name = crdt_view.as_ref().map(|v| v.name().to_string()); - let crdt_agent = crdt_view - .as_ref() - .and_then(|v| v.agent().map(|a| a.to_string())); + let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent()); for (stage_dir, stage) in &stages { if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? { @@ -173,7 +171,7 @@ pub fn get_work_item_content( content, stage: stage.clone(), name: crdt_name.clone(), - agent: crdt_agent.clone(), + agent: crdt_agent, }); } } diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index f7be11a0..3f7ada5d 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -293,9 +293,9 @@ mod tests { error: None, merge_failure: None, agent: Some(crate::http::workflow::pipeline::AgentAssignment { - agent_name: "coder-1".to_string(), + agent_name: crate::config::AgentName::Coder1, model: Some("claude-3-5-sonnet".to_string()), - status: "running".to_string(), + status: crate::agents::AgentStatus::Running, }), review_hold: None, qa: None,