diff --git a/frontend/src/api/client/types.ts b/frontend/src/api/client/types.ts index 458b92f3..e7486e53 100644 --- a/frontend/src/api/client/types.ts +++ b/frontend/src/api/client/types.ts @@ -53,7 +53,7 @@ export interface AgentAssignment { /** A single item in any pipeline stage (backlog, current, QA, merge, or done). */ export interface PipelineStageItem { story_id: string; - name: string | null; + name: string; error: string | null; merge_failure: string | null; agent: AgentAssignment | null; @@ -142,32 +142,32 @@ export type StatusEvent = | { type: "stage_transition"; story_id: string; - story_name: string | null; + story_name: string; from_stage: string; to_stage: string; } | { type: "merge_failure"; story_id: string; - story_name: string | null; + story_name: string; reason: string; } | { type: "story_blocked"; story_id: string; - story_name: string | null; + story_name: string; reason: string; } | { type: "rate_limit_warning"; story_id: string; - story_name: string | null; + story_name: string; agent_name: string; } | { type: "rate_limit_hard_block"; story_id: string; - story_name: string | null; + story_name: string; agent_name: string; reset_at: string; }; @@ -212,7 +212,7 @@ export interface AnthropicModelInfo { export interface WorkItemContent { content: string; stage: string; - name: string | null; + name: string; agent: string | null; } diff --git a/frontend/src/components/ChatPipelinePanel.tsx b/frontend/src/components/ChatPipelinePanel.tsx index 28fbc37b..cc407e5e 100644 --- a/frontend/src/components/ChatPipelinePanel.tsx +++ b/frontend/src/components/ChatPipelinePanel.tsx @@ -15,7 +15,7 @@ import { WorkItemDetailPanel } from "./WorkItemDetailPanel"; * This conversion happens at render time, not at the WebSocket boundary, * so the original StatusEvent structure is preserved in state. */ function formatStatusEventMessage(event: StatusEvent): string { - const name = event.story_name ?? event.story_id; + const name = event.story_name || event.story_id; switch (event.type) { case "stage_transition": return `${name} — ${event.from_stage} → ${event.to_stage}`; diff --git a/frontend/src/components/StagePanel.test.tsx b/frontend/src/components/StagePanel.test.tsx index 82f5ec5c..b6f4900a 100644 --- a/frontend/src/components/StagePanel.test.tsx +++ b/frontend/src/components/StagePanel.test.tsx @@ -113,7 +113,7 @@ describe("StagePanel", () => { const items: PipelineStageItem[] = [ { story_id: "1_story_bad", - name: null, + name: "", error: "Missing front matter", merge_failure: null, agent: null, diff --git a/frontend/src/components/StagePanel.tsx b/frontend/src/components/StagePanel.tsx index bd8e84f4..f384944c 100644 --- a/frontend/src/components/StagePanel.tsx +++ b/frontend/src/components/StagePanel.tsx @@ -526,7 +526,7 @@ export function StagePanel({ ${costs.get(item.story_id)?.toFixed(2)} )} - {item.name ?? item.story_id} + {item.name || item.story_id} {item.error && (
{ e.stopPropagation(); - const label = item.name ?? item.story_id; + const label = item.name || item.story_id; if ( window.confirm( `Delete "${label}"? This cannot be undone.`, diff --git a/server/src/agents/pool/mod.rs b/server/src/agents/pool/mod.rs index bc233607..995c9ab5 100644 --- a/server/src/agents/pool/mod.rs +++ b/server/src/agents/pool/mod.rs @@ -138,7 +138,7 @@ mod tests { fn stage_transition_event(story_id: &str) -> StatusEvent { StatusEvent::StageTransition { story_id: story_id.to_string(), - story_name: None, + story_name: String::new(), from_stage: "1_backlog".to_string(), to_stage: "2_current".to_string(), } @@ -249,7 +249,7 @@ mod tests { for i in 0..5u32 { bc.publish(StatusEvent::MergeFailure { story_id: format!("story_{i}"), - story_name: None, + story_name: String::new(), reason: "test".to_string(), }); } @@ -299,7 +299,7 @@ mod tests { for i in 0..n { bc.publish(StatusEvent::MergeFailure { story_id: format!("other-{i}"), - story_name: None, + story_name: String::new(), reason: format!("reason-{i}"), }); } diff --git a/server/src/crdt_state/ops.rs b/server/src/crdt_state/ops.rs index 277d725b..9e778fad 100644 --- a/server/src/crdt_state/ops.rs +++ b/server/src/crdt_state/ops.rs @@ -176,8 +176,8 @@ pub fn apply_remote_op(op: SignedOp) -> bool { }; let from_stage = old_stage_str.and_then(|s| crate::pipeline_state::Stage::from_dir(&s)); let name = match state.crdt.doc.items[idx].name.view() { - JsonValue::String(s) if !s.is_empty() => Some(s), - _ => None, + JsonValue::String(s) => s.clone(), + _ => String::new(), }; emit_event(CrdtEvent { story_id: sid.clone(), diff --git a/server/src/crdt_state/types.rs b/server/src/crdt_state/types.rs index de768536..5faefc9a 100644 --- a/server/src/crdt_state/types.rs +++ b/server/src/crdt_state/types.rs @@ -15,8 +15,8 @@ pub struct CrdtEvent { pub from_stage: Option, /// The stage the item is now in. pub to_stage: crate::pipeline_state::Stage, - /// Human-readable story name from the CRDT document. - pub name: Option, + /// Human-readable story name from the CRDT document (empty string when unset). + pub name: String, } // ── CRDT document types ────────────────────────────────────────────── @@ -538,7 +538,7 @@ mod tests { story_id: "42_story_foo".to_string(), from_stage: Some(crate::pipeline_state::Stage::Backlog), to_stage: crate::pipeline_state::Stage::Coding, - name: Some("Foo Feature".to_string()), + name: "Foo Feature".to_string(), }; assert_eq!(evt.story_id, "42_story_foo"); assert!(matches!( @@ -546,7 +546,7 @@ mod tests { Some(crate::pipeline_state::Stage::Backlog) )); assert!(matches!(evt.to_stage, crate::pipeline_state::Stage::Coding)); - assert_eq!(evt.name.as_deref(), Some("Foo Feature")); + assert_eq!(evt.name, "Foo Feature"); } #[test] @@ -555,12 +555,12 @@ mod tests { story_id: "10_story_bar".to_string(), from_stage: None, to_stage: crate::pipeline_state::Stage::Backlog, - name: None, + name: String::new(), }; let cloned = evt.clone(); assert_eq!(cloned.story_id, "10_story_bar"); assert!(cloned.from_stage.is_none()); - assert!(cloned.name.is_none()); + assert!(cloned.name.is_empty()); } #[test] @@ -573,7 +573,7 @@ mod tests { story_id: "99_story_noop".to_string(), from_stage: None, to_stage: crate::pipeline_state::Stage::Backlog, - name: None, + name: String::new(), }); } @@ -695,7 +695,7 @@ mod tests { story_id: "70_story_broadcast".to_string(), from_stage: Some(Stage::Backlog), to_stage: Stage::Coding, - name: Some("Broadcast Test".to_string()), + name: "Broadcast Test".to_string(), }; tx.send(evt).unwrap(); @@ -703,6 +703,6 @@ mod tests { assert_eq!(received.story_id, "70_story_broadcast"); assert!(matches!(received.from_stage, Some(Stage::Backlog))); assert!(matches!(received.to_stage, Stage::Coding)); - assert_eq!(received.name.as_deref(), Some("Broadcast Test")); + assert_eq!(received.name, "Broadcast Test"); } } diff --git a/server/src/crdt_state/write/item.rs b/server/src/crdt_state/write/item.rs index 52d7e641..9a4d05a6 100644 --- a/server/src/crdt_state/write/item.rs +++ b/server/src/crdt_state/write/item.rs @@ -273,8 +273,8 @@ pub fn write_item( if stage_changed { // Read the current name from the CRDT document for the event. let current_name = match state.crdt.doc.items[idx].name.view() { - JsonValue::String(s) if !s.is_empty() => Some(s), - _ => None, + JsonValue::String(s) if !s.is_empty() => s, + _ => String::new(), }; // Storage seam: convert the old raw CRDT stage string to a typed Stage. let from_stage = old_stage.and_then(|s| Stage::from_dir(&s)); @@ -336,7 +336,7 @@ pub fn write_item( story_id: story_id.to_string(), from_stage: None, to_stage: stage.clone(), - name: name.map(String::from), + name: name.unwrap_or("").to_string(), }); } } diff --git a/server/src/gateway_relay.rs b/server/src/gateway_relay.rs index dcc166f7..bbf7de37 100644 --- a/server/src/gateway_relay.rs +++ b/server/src/gateway_relay.rs @@ -203,7 +203,7 @@ mod tests { fn status_to_stored_stage_transition() { let ev = StatusEvent::StageTransition { story_id: "42".into(), - story_name: None, + story_name: String::new(), from_stage: "1_backlog".into(), to_stage: "2_current".into(), }; @@ -217,7 +217,7 @@ mod tests { fn status_to_stored_merge_failure() { let ev = StatusEvent::MergeFailure { story_id: "7".into(), - story_name: None, + story_name: String::new(), reason: "conflict".into(), }; let stored = status_to_stored(ev).unwrap(); @@ -228,7 +228,7 @@ mod tests { fn status_to_stored_story_blocked() { let ev = StatusEvent::StoryBlocked { story_id: "3".into(), - story_name: None, + story_name: String::new(), reason: "retry limit".into(), }; let stored = status_to_stored(ev).unwrap(); @@ -239,7 +239,7 @@ mod tests { fn status_to_stored_rate_limit_warning_is_none() { let ev = StatusEvent::RateLimitWarning { story_id: "1".into(), - story_name: None, + story_name: String::new(), agent_name: "coder".into(), }; assert!(status_to_stored(ev).is_none()); @@ -249,7 +249,7 @@ mod tests { fn status_to_stored_rate_limit_hard_block_is_none() { let ev = StatusEvent::RateLimitHardBlock { story_id: "2".into(), - story_name: None, + story_name: String::new(), agent_name: "coder".into(), reset_at: chrono::Utc::now(), }; diff --git a/server/src/http/mcp/story_tools/criteria.rs b/server/src/http/mcp/story_tools/criteria.rs index bb920ab2..bba5d89e 100644 --- a/server/src/http/mcp/story_tools/criteria.rs +++ b/server/src/http/mcp/story_tools/criteria.rs @@ -37,7 +37,9 @@ pub(crate) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result Result().ok()); @@ -38,8 +46,15 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result { - let s = value.as_str().filter(|s| !s.is_empty()); - crate::crdt_state::set_name(story_id, s); + let s = value.as_str().filter(|s| !s.trim().is_empty()); + if s.is_none() { + return Err("name must not be empty".to_string()); + } + if !crate::crdt_state::set_name(story_id, s) { + return Err(format!( + "Story '{story_id}' not found in CRDT — name was not updated." + )); + } } "agent" => { let parsed = value diff --git a/server/src/http/workflow/pipeline.rs b/server/src/http/workflow/pipeline.rs index a0413d7e..7358c90e 100644 --- a/server/src/http/workflow/pipeline.rs +++ b/server/src/http/workflow/pipeline.rs @@ -18,7 +18,7 @@ pub struct AgentAssignment { #[derive(Clone, Debug, Serialize)] pub struct UpcomingStory { pub story_id: String, - pub name: Option, + pub name: String, pub error: Option, /// Merge failure reason persisted to front matter by the mergemaster agent. pub merge_failure: Option, @@ -123,11 +123,7 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result { let story = UpcomingStory { story_id: sid.clone(), - name: if item.name.is_empty() { - None - } else { - Some(item.name.clone()) - }, + name: item.name.clone(), error: None, merge_failure, agent, @@ -248,11 +244,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result, St 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() { - None - } else { - Some(item.name) - }, + name: item.name, error: None, merge_failure: None, agent: None, @@ -546,12 +538,12 @@ mod tests { .iter() .find(|s| s.story_id == "9870_story_view_upcoming") .unwrap(); - assert_eq!(s1.name.as_deref(), Some("View Upcoming")); + assert_eq!(s1.name, "View Upcoming"); let s2 = stories .iter() .find(|s| s.story_id == "9871_story_worktree") .unwrap(); - assert_eq!(s2.name.as_deref(), Some("Worktree Orchestration")); + assert_eq!(s2.name, "Worktree Orchestration"); } #[test] diff --git a/server/src/http/ws/tests.rs b/server/src/http/ws/tests.rs index a13310d9..a0313182 100644 --- a/server/src/http/ws/tests.rs +++ b/server/src/http/ws/tests.rs @@ -363,7 +363,7 @@ async fn ws_handler_forwards_status_events_as_status_update() { // Use a story ID unique enough that genuine server logs won't match it. ctx.services.status.publish(StatusEvent::StageTransition { story_id: "77_story_status_fwd_test".to_string(), - story_name: Some("StatusFwdTest".to_string()), + story_name: "StatusFwdTest".to_string(), from_stage: "1_backlog".to_string(), to_stage: "2_current".to_string(), }); @@ -396,7 +396,7 @@ async fn ws_handler_multi_project_status_isolation() { let needle = "ProjAIsolation7734"; ctx_a.services.status.publish(StatusEvent::MergeFailure { story_id: "10_story_proj_a_isolation".to_string(), - story_name: Some(needle.to_string()), + story_name: needle.to_string(), reason: "conflict".to_string(), }); @@ -453,7 +453,7 @@ async fn ws_handler_status_consumer_disabled_via_config() { let needle = "DisabledConsumer9182"; ctx.services.status.publish(StatusEvent::StoryBlocked { story_id: "55_story_disabled_consumer".to_string(), - story_name: Some(needle.to_string()), + story_name: needle.to_string(), reason: "test".to_string(), }); diff --git a/server/src/service/agents/mod.rs b/server/src/service/agents/mod.rs index f054ceaa..0fa380a1 100644 --- a/server/src/service/agents/mod.rs +++ b/server/src/service/agents/mod.rs @@ -58,7 +58,7 @@ impl std::fmt::Display for Error { pub struct WorkItemContent { pub content: String, pub stage: crate::pipeline_state::Stage, - pub name: Option, + pub name: String, pub agent: Option, } @@ -162,7 +162,10 @@ pub fn get_work_item_content( let filename = format!("{story_id}.md"); 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_name = crdt_view + .as_ref() + .map(|v| v.name().to_string()) + .unwrap_or_default(); let crdt_agent = crdt_view.as_ref().and_then(|v| v.agent()); for (stage_dir, stage) in &stages { @@ -320,7 +323,7 @@ max_budget_usd = 5.0 let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap(); assert!(item.content.contains("Some content.")); assert_eq!(item.stage, crate::pipeline_state::Stage::Backlog); - assert_eq!(item.name, Some("Foo Story".to_string())); + assert_eq!(item.name, "Foo Story"); } #[test] diff --git a/server/src/service/gateway/polling.rs b/server/src/service/gateway/polling.rs index 6bd32455..e731eb9f 100644 --- a/server/src/service/gateway/polling.rs +++ b/server/src/service/gateway/polling.rs @@ -26,19 +26,19 @@ pub fn format_gateway_event(project_name: &str, event: &StoredEvent) -> (String, } => { let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); - let (plain, html) = format_stage_notification(story_id, None, &from_typed, &to_typed); + let (plain, html) = format_stage_notification(story_id, "", &from_typed, &to_typed); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } StoredEvent::MergeFailure { story_id, reason, .. } => { - let (plain, html) = format_error_notification(story_id, None, reason); + let (plain, html) = format_error_notification(story_id, "", reason); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } StoredEvent::StoryBlocked { story_id, reason, .. } => { - let (plain, html) = format_blocked_notification(story_id, None, reason); + let (plain, html) = format_blocked_notification(story_id, "", reason); (format!("{prefix}{plain}"), format!("{prefix}{html}")) } } diff --git a/server/src/service/notifications/format.rs b/server/src/service/notifications/format.rs index 26798e62..7e323836 100644 --- a/server/src/service/notifications/format.rs +++ b/server/src/service/notifications/format.rs @@ -30,12 +30,16 @@ pub fn stage_display_name(stage: &Stage) -> &'static str { /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_stage_notification( item_id: &str, - story_name: Option<&str>, + story_name: &str, from_stage: &Stage, to_stage: &Stage, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); - let effective_name = story_name.filter(|n| !n.is_empty()); + let effective_name = if story_name.is_empty() { + None + } else { + Some(story_name) + }; let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) @@ -61,11 +65,15 @@ pub fn format_stage_notification( /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_error_notification( item_id: &str, - story_name: Option<&str>, + story_name: &str, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); - let effective_name = story_name.filter(|n| !n.is_empty()); + let effective_name = if story_name.is_empty() { + None + } else { + Some(story_name) + }; let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) @@ -81,11 +89,15 @@ pub fn format_error_notification( /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_blocked_notification( item_id: &str, - story_name: Option<&str>, + story_name: &str, reason: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); - let effective_name = story_name.filter(|n| !n.is_empty()); + let effective_name = if story_name.is_empty() { + None + } else { + Some(story_name) + }; let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) @@ -102,11 +114,15 @@ pub fn format_blocked_notification( /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_rate_limit_notification( item_id: &str, - story_name: Option<&str>, + story_name: &str, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); - let effective_name = story_name.filter(|n| !n.is_empty()); + let effective_name = if story_name.is_empty() { + None + } else { + Some(story_name) + }; let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) @@ -149,11 +165,15 @@ pub fn format_oauth_accounts_exhausted(earliest_reset_msg: &str) -> (String, Str /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_agent_started_notification( item_id: &str, - story_name: Option<&str>, + story_name: &str, agent_name: &str, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); - let effective_name = story_name.filter(|n| !n.is_empty()); + let effective_name = if story_name.is_empty() { + None + } else { + Some(story_name) + }; let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) @@ -171,12 +191,16 @@ pub fn format_agent_started_notification( /// Returns `(plain_text, html)` suitable for `ChatTransport::send_message`. pub fn format_agent_completed_notification( item_id: &str, - story_name: Option<&str>, + story_name: &str, agent_name: &str, success: bool, ) -> (String, String) { let number = extract_item_number(item_id).unwrap_or(item_id); - let effective_name = story_name.filter(|n| !n.is_empty()); + let effective_name = if story_name.is_empty() { + None + } else { + Some(story_name) + }; let name_plain = effective_name.map(|n| format!("{n} ")).unwrap_or_default(); let name_html = effective_name .map(|n| format!("{n} ")) @@ -243,7 +267,7 @@ mod tests { fn format_notification_done_stage_includes_party_emoji() { let (plain, html) = format_stage_notification( "353_story_done", - Some("Done Story"), + "Done Story", &merge_stage(), &done_stage(), ); @@ -261,7 +285,7 @@ mod tests { fn format_notification_non_done_stage_has_no_emoji() { let (plain, _html) = format_stage_notification( "42_story_thing", - Some("Some Story"), + "Some Story", &Stage::Backlog, &Stage::Coding, ); @@ -272,7 +296,7 @@ mod tests { fn format_notification_with_story_name() { let (plain, html) = format_stage_notification( "261_story_bot_notifications", - Some("Bot notifications"), + "Bot notifications", &Stage::Upcoming, &Stage::Coding, ); @@ -289,19 +313,15 @@ mod tests { #[test] fn format_stage_notification_without_story_name_falls_back_to_number() { let (plain, html) = - format_stage_notification("42_bug_fix_thing", None, &Stage::Coding, &Stage::Qa); + format_stage_notification("42_bug_fix_thing", "", &Stage::Coding, &Stage::Qa); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(html, "#42 \u{2014} Current \u{2192} QA"); } #[test] fn format_notification_non_numeric_id_uses_full_id() { - let (plain, _html) = format_stage_notification( - "abc_story_thing", - Some("Some Story"), - &Stage::Qa, - &merge_stage(), - ); + let (plain, _html) = + format_stage_notification("abc_story_thing", "Some Story", &Stage::Qa, &merge_stage()); assert_eq!( plain, "#abc_story_thing Some Story \u{2014} QA \u{2192} Merge" @@ -312,14 +332,14 @@ mod tests { fn format_stage_notification_long_name_is_preserved() { let long_name = "A".repeat(300); let (plain, _html) = - format_stage_notification("1_story_long", Some(&long_name), &Stage::Coding, &Stage::Qa); + format_stage_notification("1_story_long", &long_name, &Stage::Coding, &Stage::Qa); assert!(plain.contains(&long_name)); } #[test] fn format_stage_notification_empty_story_name_falls_back_to_number() { let (plain, html) = - format_stage_notification("42_story_empty", Some(""), &Stage::Coding, &Stage::Qa); + format_stage_notification("42_story_empty", "", &Stage::Coding, &Stage::Qa); assert_eq!(plain, "#42 \u{2014} Current \u{2192} QA"); assert_eq!(html, "#42 \u{2014} Current \u{2192} QA"); } @@ -328,7 +348,7 @@ mod tests { fn format_stage_notification_unicode_name() { let (plain, html) = format_stage_notification( "7_story_i18n", - Some("Ünïcödé Ñämé 🎉"), + "Ünïcödé Ñämé 🎉", &Stage::Qa, &merge_stage(), ); @@ -342,7 +362,7 @@ mod tests { fn format_error_notification_with_story_name() { let (plain, html) = format_error_notification( "262_story_bot_errors", - Some("Bot error notifications"), + "Bot error notifications", "merge conflict in src/main.rs", ); assert_eq!( @@ -357,7 +377,7 @@ mod tests { #[test] fn format_error_notification_without_story_name_falls_back_to_number() { - let (plain, html) = format_error_notification("42_bug_fix_thing", None, "tests failed"); + let (plain, html) = format_error_notification("42_bug_fix_thing", "", "tests failed"); assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed"); assert_eq!(html, "\u{274c} #42 \u{2014} tests failed"); } @@ -365,7 +385,7 @@ mod tests { #[test] fn format_error_notification_non_numeric_id_uses_full_id() { let (plain, _html) = - format_error_notification("abc_story_thing", Some("Some Story"), "clippy errors"); + format_error_notification("abc_story_thing", "Some Story", "clippy errors"); assert_eq!( plain, "\u{274c} #abc_story_thing Some Story \u{2014} clippy errors" @@ -375,21 +395,19 @@ mod tests { #[test] fn format_error_notification_long_reason_preserved() { let long_reason = "x".repeat(500); - let (plain, _html) = format_error_notification("1_story_foo", None, &long_reason); + let (plain, _html) = format_error_notification("1_story_foo", "", &long_reason); assert!(plain.contains(&long_reason)); } #[test] fn format_error_notification_unicode_reason() { - let (plain, _html) = - format_error_notification("5_story_foo", Some("Foo"), "错误:合并冲突"); + let (plain, _html) = format_error_notification("5_story_foo", "Foo", "错误:合并冲突"); assert!(plain.contains("错误:合并冲突")); } #[test] fn format_error_notification_empty_story_name_falls_back_to_number() { - let (plain, _html) = - format_error_notification("42_bug_fix_thing", Some(""), "tests failed"); + let (plain, _html) = format_error_notification("42_bug_fix_thing", "", "tests failed"); assert_eq!(plain, "\u{274c} #42 \u{2014} tests failed"); } @@ -399,7 +417,7 @@ mod tests { fn format_blocked_notification_with_story_name() { let (plain, html) = format_blocked_notification( "425_story_blocking_reason", - Some("Blocking Reason Story"), + "Blocking Reason Story", "Retry limit exceeded (3/3) at coder stage", ); assert_eq!( @@ -414,7 +432,7 @@ mod tests { #[test] fn format_blocked_notification_falls_back_to_number() { - let (plain, html) = format_blocked_notification("42_story_thing", None, "empty diff"); + let (plain, html) = format_blocked_notification("42_story_thing", "", "empty diff"); assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff"); assert_eq!( html, @@ -424,13 +442,13 @@ mod tests { #[test] fn format_blocked_notification_empty_story_name_falls_back_to_number() { - let (plain, _html) = format_blocked_notification("42_story_thing", Some(""), "empty diff"); + let (plain, _html) = format_blocked_notification("42_story_thing", "", "empty diff"); assert_eq!(plain, "\u{1f6ab} #42 \u{2014} BLOCKED: empty diff"); } #[test] fn format_blocked_notification_unicode_reason() { - let (plain, _html) = format_blocked_notification("3_story_x", Some("X"), "理由:空の差分"); + let (plain, _html) = format_blocked_notification("3_story_x", "X", "理由:空の差分"); assert!(plain.contains("BLOCKED: 理由:空の差分")); } @@ -439,7 +457,7 @@ mod tests { #[test] fn format_rate_limit_notification_includes_agent_and_story() { let (plain, html) = - format_rate_limit_notification("365_story_my_feature", Some("My Feature"), "coder-2"); + format_rate_limit_notification("365_story_my_feature", "My Feature", "coder-2"); assert_eq!( plain, "\u{26a0}\u{fe0f} #365 My Feature \u{2014} coder-2 hit an API rate limit" @@ -452,7 +470,7 @@ mod tests { #[test] fn format_rate_limit_notification_falls_back_to_number() { - let (plain, html) = format_rate_limit_notification("42_story_thing", None, "coder-1"); + let (plain, html) = format_rate_limit_notification("42_story_thing", "", "coder-1"); assert_eq!( plain, "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" @@ -465,7 +483,7 @@ mod tests { #[test] fn format_rate_limit_notification_empty_story_name_falls_back_to_number() { - let (plain, _html) = format_rate_limit_notification("42_story_thing", Some(""), "coder-1"); + let (plain, _html) = format_rate_limit_notification("42_story_thing", "", "coder-1"); assert_eq!( plain, "\u{26a0}\u{fe0f} #42 \u{2014} coder-1 hit an API rate limit" @@ -474,7 +492,7 @@ mod tests { #[test] fn format_rate_limit_notification_unicode_agent_name() { - let (plain, _html) = format_rate_limit_notification("9_story_foo", Some("Foo"), "агент-1"); + let (plain, _html) = format_rate_limit_notification("9_story_foo", "Foo", "агент-1"); assert!(plain.contains("агент-1")); assert!(plain.contains("hit an API rate limit")); } @@ -484,7 +502,7 @@ mod tests { #[test] fn format_agent_started_notification_with_story_name() { let (plain, html) = - format_agent_started_notification("42_story_foo", Some("My Feature"), "coder-1"); + format_agent_started_notification("42_story_foo", "My Feature", "coder-1"); assert_eq!(plain, "\u{1F916} #42 My Feature \u{2014} coder-1 started"); assert_eq!( html, @@ -494,7 +512,7 @@ mod tests { #[test] fn format_agent_started_notification_falls_back_to_number() { - let (plain, html) = format_agent_started_notification("42_story_foo", None, "coder-1"); + let (plain, html) = format_agent_started_notification("42_story_foo", "", "coder-1"); assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started"); assert_eq!( html, @@ -504,7 +522,7 @@ mod tests { #[test] fn format_agent_started_notification_empty_name_falls_back_to_number() { - let (plain, _html) = format_agent_started_notification("42_story_foo", Some(""), "coder-1"); + let (plain, _html) = format_agent_started_notification("42_story_foo", "", "coder-1"); assert_eq!(plain, "\u{1F916} #42 \u{2014} coder-1 started"); } @@ -512,12 +530,8 @@ mod tests { #[test] fn format_agent_completed_notification_success_with_story_name() { - let (plain, html) = format_agent_completed_notification( - "42_story_foo", - Some("My Feature"), - "coder-1", - true, - ); + let (plain, html) = + format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", true); assert_eq!(plain, "\u{2705} #42 My Feature \u{2014} coder-1 completed"); assert_eq!( html, @@ -527,19 +541,15 @@ mod tests { #[test] fn format_agent_completed_notification_failure_with_story_name() { - let (plain, _html) = format_agent_completed_notification( - "42_story_foo", - Some("My Feature"), - "coder-1", - false, - ); + let (plain, _html) = + format_agent_completed_notification("42_story_foo", "My Feature", "coder-1", false); assert_eq!(plain, "\u{274C} #42 My Feature \u{2014} coder-1 failed"); } #[test] fn format_agent_completed_notification_falls_back_to_number() { let (plain, html) = - format_agent_completed_notification("42_story_foo", None, "coder-1", true); + format_agent_completed_notification("42_story_foo", "", "coder-1", true); assert_eq!(plain, "\u{2705} #42 \u{2014} coder-1 completed"); assert_eq!( html, @@ -550,7 +560,7 @@ mod tests { #[test] fn format_agent_completed_notification_empty_name_falls_back_to_number() { let (plain, _html) = - format_agent_completed_notification("42_story_foo", Some(""), "coder-1", false); + format_agent_completed_notification("42_story_foo", "", "coder-1", false); assert_eq!(plain, "\u{274C} #42 \u{2014} coder-1 failed"); } } diff --git a/server/src/service/notifications/io/listener.rs b/server/src/service/notifications/io/listener.rs index 9c8789ad..4fb8d707 100644 --- a/server/src/service/notifications/io/listener.rs +++ b/server/src/service/notifications/io/listener.rs @@ -52,8 +52,7 @@ pub fn spawn_notification_listener( // Rapid successive transitions for the same item are coalesced: the // original `from_stage` is kept while `to_stage` is updated to the // latest destination, so only one notification fires for the final stage. - let mut pending_transitions: HashMap)> = - HashMap::new(); + let mut pending_transitions: HashMap = HashMap::new(); let mut flush_deadline: Option = None; // Pending agent-status notifications, keyed by "{story_id}:{event_kind}". @@ -88,7 +87,7 @@ pub fn spawn_notification_listener( { let (plain, html) = format_stage_notification( &item_id, - story_name.as_deref(), + &story_name, &from_stage, &to_stage, ); @@ -139,7 +138,7 @@ pub fn spawn_notification_listener( { let (plain, html) = format_stage_notification( &item_id, - story_name.as_deref(), + &story_name, &from_stage, &to_stage, ); @@ -191,8 +190,14 @@ pub fn spawn_notification_listener( // Look up the story name in the expected stage directory; fall // back to a full search so stale events still show the name. - let story_name = read_story_name(&project_root, stage, item_id) - .or_else(|| find_story_name_any_stage(&project_root, item_id)); + let story_name = { + let n = read_story_name(&project_root, stage, item_id); + if n.is_empty() { + find_story_name_any_stage(&project_root, item_id) + } else { + n + } + }; // Buffer the transition. If this item_id is already pending (rapid // succession), update the destination stage to the latest while @@ -201,7 +206,7 @@ pub fn spawn_notification_listener( .entry(item_id.clone()) .and_modify(|e| { e.1 = to_typed.clone(); - if story_name.is_some() { + if !story_name.is_empty() { e.2 = story_name.clone(); } }) @@ -225,8 +230,7 @@ pub fn spawn_notification_listener( // AC3: include only the first non-empty line of the failure, // truncated to ~120 chars. let snippet = merge_failure_snippet(reason, 120); - let (plain, html) = - format_error_notification(story_id, story_name.as_deref(), &snippet); + let (plain, html) = format_error_notification(story_id, &story_name, &snippet); slog!("[bot] Sending error notification: {plain}"); for room_id in &rooms_for_notification(&get_room_ids) { if let Err(e) = transport.send_message(room_id, &plain, &html).await { @@ -267,7 +271,7 @@ pub fn spawn_notification_listener( rate_limit_last_notified.insert(debounce_key, now); let story_name = find_story_name_any_stage(&project_root, story_id); let (plain, html) = - format_rate_limit_notification(story_id, story_name.as_deref(), agent_name); + format_rate_limit_notification(story_id, &story_name, agent_name); slog!("[bot] Sending rate-limit notification: {plain}"); for room_id in &rooms_for_notification(&get_room_ids) { if let Err(e) = transport.send_message(room_id, &plain, &html).await { @@ -290,8 +294,7 @@ pub fn spawn_notification_listener( continue; }; let story_name = find_story_name_any_stage(&project_root, story_id); - let (plain, html) = - format_blocked_notification(story_id, story_name.as_deref(), reason); + let (plain, html) = format_blocked_notification(story_id, &story_name, reason); slog!("[bot] Sending blocked notification: {plain}"); for room_id in &rooms_for_notification(&get_room_ids) { if let Err(e) = transport.send_message(room_id, &plain, &html).await { @@ -350,11 +353,8 @@ pub fn spawn_notification_listener( continue; }; let story_name = find_story_name_any_stage(&project_root, story_id); - let (plain, html) = format_agent_started_notification( - story_id, - story_name.as_deref(), - agent_name, - ); + let (plain, html) = + format_agent_started_notification(story_id, &story_name, agent_name); // Buffer with 5s debounce; later arrivals overwrite earlier ones. let key = format!("{story_id}:started"); pending_agent_events.insert(key, (plain, html)); @@ -375,7 +375,7 @@ pub fn spawn_notification_listener( let story_name = find_story_name_any_stage(&project_root, story_id); let (plain, html) = format_agent_completed_notification( story_id, - story_name.as_deref(), + &story_name, agent_name, success, ); diff --git a/server/src/service/notifications/io/mod.rs b/server/src/service/notifications/io/mod.rs index a4a8ff1e..ed1087e0 100644 --- a/server/src/service/notifications/io/mod.rs +++ b/server/src/service/notifications/io/mod.rs @@ -16,16 +16,20 @@ mod tests_notifications; #[cfg(test)] mod tests_stage; -/// Read the story name from the typed CRDT register (story 929). +/// Read the story name from the typed CRDT register. /// -/// Returns `None` if the item is not in the CRDT or has no name set. -pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option { - crate::crdt_state::read_item(item_id).map(|v| v.name().to_string()) +/// Returns the name as a `String`, or an empty string if the item is not in +/// the CRDT or has no name set. Callers that display the name unconditionally +/// should pass this directly to the format_* notification helpers. +pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> String { + crate::crdt_state::read_item(item_id) + .map(|v| v.name().to_string()) + .unwrap_or_default() } /// Look up a story name from the CRDT content store regardless of stage. /// /// Used for events (like rate-limit warnings) that arrive without a known stage. -fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> Option { +fn find_story_name_any_stage(project_root: &Path, item_id: &str) -> String { read_story_name(project_root, "", item_id) } diff --git a/server/src/service/notifications/io/tests_stage.rs b/server/src/service/notifications/io/tests_stage.rs index 68f2070e..acf98112 100644 --- a/server/src/service/notifications/io/tests_stage.rs +++ b/server/src/service/notifications/io/tests_stage.rs @@ -116,7 +116,7 @@ fn read_story_name_reads_from_front_matter() { let tmp = tempfile::tempdir().unwrap(); let name = read_story_name(tmp.path(), "2_current", "9942_story_my_feature"); - assert_eq!(name.as_deref(), Some("My Cool Feature")); + assert_eq!(name, "My Cool Feature"); } #[test] @@ -124,7 +124,7 @@ fn read_story_name_returns_none_for_missing_file() { crate::db::ensure_content_store(); let tmp = tempfile::tempdir().unwrap(); let name = read_story_name(tmp.path(), "2_current", "99_story_missing_notif_test"); - assert_eq!(name, None); + assert!(name.is_empty()); } #[test] @@ -139,7 +139,7 @@ fn read_story_name_returns_none_for_missing_name_field() { let tmp = tempfile::tempdir().unwrap(); let name = read_story_name(tmp.path(), "2_current", "9943_story_no_name"); - assert_eq!(name, None); + assert!(name.is_empty()); } // ── Bug 549: synthetic events with from_stage=None must not notify ─────────── diff --git a/server/src/service/status/buffer.rs b/server/src/service/status/buffer.rs index b1de6829..d4b965df 100644 --- a/server/src/service/status/buffer.rs +++ b/server/src/service/status/buffer.rs @@ -204,7 +204,7 @@ mod tests { fn make_event(id: &str) -> StatusEvent { StatusEvent::MergeFailure { story_id: id.to_string(), - story_name: None, + story_name: String::new(), reason: "test".to_string(), } } @@ -398,12 +398,12 @@ mod tests { let items = vec![ BufferedItem::Event(StatusEvent::MergeFailure { story_id: "42_story_foo".to_string(), - story_name: Some("Foo".to_string()), + story_name: "Foo".to_string(), reason: "conflict".to_string(), }), BufferedItem::Event(StatusEvent::StoryBlocked { story_id: "7_story_bar".to_string(), - story_name: None, + story_name: String::new(), reason: "retry limit".to_string(), }), ]; @@ -426,7 +426,7 @@ mod tests { BufferedItem::Truncated(3), BufferedItem::Event(StatusEvent::MergeFailure { story_id: "1_story_x".to_string(), - story_name: None, + story_name: String::new(), reason: "test".to_string(), }), ]; diff --git a/server/src/service/status/format.rs b/server/src/service/status/format.rs index c0d2fa86..8eddda20 100644 --- a/server/src/service/status/format.rs +++ b/server/src/service/status/format.rs @@ -25,7 +25,11 @@ pub fn format_status_event(event: &StatusEvent) -> String { to_stage, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); - let name = story_name.as_deref().unwrap_or(story_id.as_str()); + let name = if story_name.is_empty() { + story_id.as_str() + } else { + story_name.as_str() + }; let from_typed = Stage::from_dir(from_stage).unwrap_or(Stage::Upcoming); let to_typed = Stage::from_dir(to_stage).unwrap_or(Stage::Upcoming); let from = stage_display_name(&from_typed); @@ -43,7 +47,11 @@ pub fn format_status_event(event: &StatusEvent) -> String { reason, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); - let name = story_name.as_deref().unwrap_or(story_id.as_str()); + let name = if story_name.is_empty() { + story_id.as_str() + } else { + story_name.as_str() + }; format!("\u{274c} #{number} {name} \u{2014} {reason}") } StatusEvent::StoryBlocked { @@ -52,7 +60,11 @@ pub fn format_status_event(event: &StatusEvent) -> String { reason, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); - let name = story_name.as_deref().unwrap_or(story_id.as_str()); + let name = if story_name.is_empty() { + story_id.as_str() + } else { + story_name.as_str() + }; format!("\u{1f6ab} #{number} {name} \u{2014} BLOCKED: {reason}") } StatusEvent::RateLimitWarning { @@ -61,7 +73,11 @@ pub fn format_status_event(event: &StatusEvent) -> String { agent_name, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); - let name = story_name.as_deref().unwrap_or(story_id.as_str()); + let name = if story_name.is_empty() { + story_id.as_str() + } else { + story_name.as_str() + }; format!("\u{26a0}\u{fe0f} #{number} {name} \u{2014} {agent_name} hit an API rate limit") } StatusEvent::RateLimitHardBlock { @@ -71,7 +87,11 @@ pub fn format_status_event(event: &StatusEvent) -> String { reset_at, } => { let number = extract_item_number(story_id).unwrap_or(story_id.as_str()); - let name = story_name.as_deref().unwrap_or(story_id.as_str()); + let name = if story_name.is_empty() { + story_id.as_str() + } else { + story_name.as_str() + }; let reset = reset_at.format("%H:%M UTC").to_string(); format!( "\u{26d4} #{number} {name} \u{2014} {agent_name} hard rate-limited until {reset}" @@ -89,7 +109,7 @@ mod tests { fn formats_stage_transition_to_done_with_emoji() { let event = StatusEvent::StageTransition { story_id: "42_story_foo".to_string(), - story_name: Some("Foo Story".to_string()), + story_name: "Foo Story".to_string(), from_stage: "merge".to_string(), to_stage: "done".to_string(), }; @@ -107,7 +127,7 @@ mod tests { fn formats_stage_transition_no_emoji_for_non_done() { let event = StatusEvent::StageTransition { story_id: "10_story_bar".to_string(), - story_name: Some("Bar".to_string()), + story_name: "Bar".to_string(), from_stage: "backlog".to_string(), to_stage: "coding".to_string(), }; @@ -120,7 +140,7 @@ mod tests { fn formats_stage_transition_falls_back_to_story_id_when_no_name() { let event = StatusEvent::StageTransition { story_id: "5_story_x".to_string(), - story_name: None, + story_name: String::new(), from_stage: "coding".to_string(), to_stage: "qa".to_string(), }; @@ -132,7 +152,7 @@ mod tests { fn formats_merge_failure() { let event = StatusEvent::MergeFailure { story_id: "7_story_fail".to_string(), - story_name: Some("Failing Story".to_string()), + story_name: "Failing Story".to_string(), reason: "conflicts detected".to_string(), }; let s = format_status_event(&event); @@ -145,7 +165,7 @@ mod tests { fn formats_story_blocked() { let event = StatusEvent::StoryBlocked { story_id: "8_story_blk".to_string(), - story_name: Some("Blocked Story".to_string()), + story_name: "Blocked Story".to_string(), reason: "retry limit exceeded".to_string(), }; let s = format_status_event(&event); @@ -157,7 +177,7 @@ mod tests { fn formats_rate_limit_warning() { let event = StatusEvent::RateLimitWarning { story_id: "9_story_rl".to_string(), - story_name: Some("RL Story".to_string()), + story_name: "RL Story".to_string(), agent_name: "coder-1".to_string(), }; let s = format_status_event(&event); @@ -171,7 +191,7 @@ mod tests { .unwrap(); let event = StatusEvent::RateLimitHardBlock { story_id: "3_story_hb".to_string(), - story_name: Some("HB Story".to_string()), + story_name: "HB Story".to_string(), agent_name: "coder-2".to_string(), reset_at: reset, }; @@ -188,18 +208,18 @@ mod tests { let events: Vec = vec![ StatusEvent::StageTransition { story_id: "1_story_a".to_string(), - story_name: None, + story_name: String::new(), from_stage: "backlog".to_string(), to_stage: "coding".to_string(), }, StatusEvent::MergeFailure { story_id: "2_story_b".to_string(), - story_name: None, + story_name: String::new(), reason: "test".to_string(), }, StatusEvent::StoryBlocked { story_id: "3_story_c".to_string(), - story_name: None, + story_name: String::new(), reason: "limit".to_string(), }, ]; diff --git a/server/src/service/status/mod.rs b/server/src/service/status/mod.rs index 98f20c6f..1273c3a9 100644 --- a/server/src/service/status/mod.rs +++ b/server/src/service/status/mod.rs @@ -55,8 +55,8 @@ pub enum StatusEvent { StageTransition { /// Work item ID (e.g. `"42_story_my_feature"`). story_id: String, - /// Human-readable story name, if available. - story_name: Option, + /// Human-readable story name (empty string when unset). + story_name: String, /// Pipeline stage directory the item moved FROM (e.g. `"2_current"`). from_stage: String, /// Pipeline stage directory the item moved TO (e.g. `"3_qa"`). @@ -66,8 +66,8 @@ pub enum StatusEvent { MergeFailure { /// Work item ID (e.g. `"42_story_my_feature"`). story_id: String, - /// Human-readable story name, if available. - story_name: Option, + /// Human-readable story name (empty string when unset). + story_name: String, /// Human-readable description of the failure. reason: String, }, @@ -75,8 +75,8 @@ pub enum StatusEvent { StoryBlocked { /// Work item ID (e.g. `"42_story_my_feature"`). story_id: String, - /// Human-readable story name, if available. - story_name: Option, + /// Human-readable story name (empty string when unset). + story_name: String, /// Human-readable reason the story was blocked. reason: String, }, @@ -84,8 +84,8 @@ pub enum StatusEvent { RateLimitWarning { /// Work item ID the agent was working on. story_id: String, - /// Human-readable story name, if available. - story_name: Option, + /// Human-readable story name (empty string when unset). + story_name: String, /// Name of the agent that hit the limit. agent_name: String, }, @@ -93,8 +93,8 @@ pub enum StatusEvent { RateLimitHardBlock { /// Work item ID the agent was working on. story_id: String, - /// Human-readable story name, if available. - story_name: Option, + /// Human-readable story name (empty string when unset). + story_name: String, /// Name of the agent that hit the hard limit. agent_name: String, /// UTC instant at which the rate limit resets. @@ -237,7 +237,7 @@ mod tests { broadcaster.publish(StatusEvent::MergeFailure { story_id: "1_story_a".to_string(), - story_name: None, + story_name: String::new(), reason: "conflict".to_string(), }); @@ -253,7 +253,7 @@ mod tests { broadcaster.publish(StatusEvent::StoryBlocked { story_id: "2_story_b".to_string(), - story_name: None, + story_name: String::new(), reason: "retry limit".to_string(), }); @@ -273,7 +273,7 @@ mod tests { // Publish an event while disabled. broadcaster.publish(StatusEvent::StoryBlocked { story_id: "3_story_c".to_string(), - story_name: None, + story_name: String::new(), reason: "empty diff".to_string(), }); @@ -290,7 +290,7 @@ mod tests { sub.enable(); broadcaster.publish(StatusEvent::MergeFailure { story_id: "4_story_d".to_string(), - story_name: None, + story_name: String::new(), reason: "gate failed".to_string(), }); @@ -329,7 +329,7 @@ mod tests { // Publish an event only to project A. broadcaster_a.publish(StatusEvent::StageTransition { story_id: "10_story_project_a".to_string(), - story_name: Some("Project A Story".to_string()), + story_name: "Project A Story".to_string(), from_stage: "1_backlog".to_string(), to_stage: "2_current".to_string(), }); @@ -337,7 +337,7 @@ mod tests { // Publish a different event only to project B. broadcaster_b.publish(StatusEvent::MergeFailure { story_id: "20_story_project_b".to_string(), - story_name: Some("Project B Story".to_string()), + story_name: "Project B Story".to_string(), reason: "b conflict".to_string(), }); @@ -378,19 +378,19 @@ mod tests { // Project A fires two events. ba.publish(StatusEvent::StoryBlocked { story_id: "1_story_a_blocked".to_string(), - story_name: None, + story_name: String::new(), reason: "retry".to_string(), }); ba.publish(StatusEvent::MergeFailure { story_id: "2_story_a_fail".to_string(), - story_name: None, + story_name: String::new(), reason: "conflict".to_string(), }); // Project B fires one event. bb.publish(StatusEvent::StageTransition { story_id: "3_story_b_move".to_string(), - story_name: None, + story_name: String::new(), from_stage: "1_backlog".to_string(), to_stage: "2_current".to_string(), }); @@ -447,7 +447,7 @@ mod tests { // No subscriber — send is silently dropped. broadcaster.publish(StatusEvent::MergeFailure { story_id: "99_story_nobody".to_string(), - story_name: None, + story_name: String::new(), reason: "no one listening".to_string(), }); } diff --git a/server/src/service/ws/message/convert.rs b/server/src/service/ws/message/convert.rs index 3f7ada5d..c68b1a57 100644 --- a/server/src/service/ws/message/convert.rs +++ b/server/src/service/ws/message/convert.rs @@ -206,7 +206,7 @@ mod tests { let state = PipelineState { backlog: vec![UpcomingStory { story_id: "1_story_a".to_string(), - name: Some("Story A".to_string()), + name: "Story A".to_string(), error: None, merge_failure: None, agent: None, @@ -220,7 +220,7 @@ mod tests { }], current: vec![UpcomingStory { story_id: "2_story_b".to_string(), - name: Some("Story B".to_string()), + name: "Story B".to_string(), error: None, merge_failure: None, agent: None, @@ -236,7 +236,7 @@ mod tests { merge: vec![], done: vec![UpcomingStory { story_id: "50_story_done".to_string(), - name: Some("Done Story".to_string()), + name: "Done Story".to_string(), error: None, merge_failure: None, agent: None, @@ -289,7 +289,7 @@ mod tests { backlog: vec![], current: vec![UpcomingStory { story_id: "10_story_x".to_string(), - name: Some("Story X".to_string()), + name: "Story X".to_string(), error: None, merge_failure: None, agent: Some(crate::http::workflow::pipeline::AgentAssignment { diff --git a/server/src/service/ws/message/response.rs b/server/src/service/ws/message/response.rs index b4983ca0..751df126 100644 --- a/server/src/service/ws/message/response.rs +++ b/server/src/service/ws/message/response.rs @@ -201,7 +201,7 @@ mod tests { fn serialize_pipeline_state_response() { let story = UpcomingStory { story_id: "10_story_test".to_string(), - name: Some("Test".to_string()), + name: "Test".to_string(), error: None, merge_failure: None, agent: None,