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,