huskies: merge 946

This commit is contained in:
dave
2026-05-13 07:54:50 +00:00
parent 4a0fbcaa95
commit a7840ea4b0
49 changed files with 378 additions and 314 deletions
+5 -4
View File
@@ -105,11 +105,12 @@ mod tests {
// Confirm the stale claim is in place. // Confirm the stale claim is in place.
let before = read_item(story_id).expect("item should exist"); let before = read_item(story_id).expect("item should exist");
assert_eq!( assert_eq!(
before.claimed_by(), before.claim().map(|c| c.node.as_str()),
Some(stale_holder), Some(stale_holder),
"pre-condition: item should be claimed by the stale holder" "pre-condition: item should be claimed by the stale holder"
); );
let age = chrono::Utc::now().timestamp() as f64 - before.claimed_at().unwrap_or(0.0); let age = chrono::Utc::now().timestamp() as f64
- before.claim().map(|c| c.at as f64).unwrap_or(0.0);
assert!( assert!(
age >= CLAIM_TIMEOUT_SECS, age >= CLAIM_TIMEOUT_SECS,
"pre-condition: claim age ({age}s) must exceed TTL ({CLAIM_TIMEOUT_SECS}s)" "pre-condition: claim age ({age}s) must exceed TTL ({CLAIM_TIMEOUT_SECS}s)"
@@ -136,12 +137,12 @@ mod tests {
let our_id = our_node_id().expect("node id should be available after init_for_test"); let our_id = our_node_id().expect("node id should be available after init_for_test");
let after = read_item(story_id).expect("item should still exist"); let after = read_item(story_id).expect("item should still exist");
assert_eq!( assert_eq!(
after.claimed_by(), after.claim().map(|c| c.node.as_str()),
Some(our_id.as_str()), Some(our_id.as_str()),
"new claim should have displaced the stale holder" "new claim should have displaced the stale holder"
); );
assert_ne!( assert_ne!(
after.claimed_by(), after.claim().map(|c| c.node.as_str()),
Some(stale_holder), Some(stale_holder),
"stale holder must no longer own the claim" "stale holder must no longer own the claim"
); );
+11 -17
View File
@@ -52,20 +52,18 @@ pub(super) async fn scan_and_claim(
} }
// If already claimed by us, skip. // If already claimed by us, skip.
if item.claimed_by() == Some(our_node.as_str()) { if item.claim().is_some_and(|c| c.node == our_node) {
continue; continue;
} }
// If claimed by another node, respect the claim while it is fresh. // If claimed by another node, respect the claim while it is fresh.
// Once the TTL expires the claim is considered stale regardless of // Once the TTL expires the claim is considered stale regardless of
// whether the holder appears alive — displacement is purely TTL-driven. // whether the holder appears alive — displacement is purely TTL-driven.
if let Some(claimer) = item.claimed_by() if let Some(claim) = item.claim()
&& !claimer.is_empty() && claim.node != our_node
&& claimer != our_node.as_str()
&& let Some(claimed_at) = item.claimed_at()
{ {
let now = chrono::Utc::now().timestamp() as f64; let now = chrono::Utc::now().timestamp() as u64;
let age = now - claimed_at; let age = now.saturating_sub(claim.at) as f64;
if age < CLAIM_TIMEOUT_SECS { if age < CLAIM_TIMEOUT_SECS {
// Claim is still fresh — respect it. // Claim is still fresh — respect it.
continue; continue;
@@ -75,7 +73,7 @@ pub(super) async fn scan_and_claim(
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \ "[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
(age {}s > TTL {}s)", (age {}s > TTL {}s)",
item.story_id(), item.story_id(),
claimer, claim.node,
age as u64, age as u64,
CLAIM_TIMEOUT_SECS as u64, CLAIM_TIMEOUT_SECS as u64,
); );
@@ -172,18 +170,14 @@ pub(super) fn reclaim_timed_out_work(_project_root: &Path) {
// Release the claim if the TTL has expired — regardless of whether the // Release the claim if the TTL has expired — regardless of whether the
// holder is still alive. A node actively working should refresh its // holder is still alive. A node actively working should refresh its
// claim before the TTL window closes. // claim before the TTL window closes.
if let Some(claimer) = item.claimed_by() { if let Some(claim) = item.claim() {
if claimer.is_empty() { let age = now as u64 - claim.at.min(now as u64);
continue; if age as f64 >= CLAIM_TIMEOUT_SECS {
}
if let Some(claimed_at) = item.claimed_at()
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
{
slog!( slog!(
"[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)", "[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)",
item.story_id(), item.story_id(),
claimer, claim.node,
(now - claimed_at) as u64, age,
); );
crdt_state::release_claim(item.story_id()); crdt_state::release_claim(item.story_id());
} }
+5 -4
View File
@@ -35,10 +35,11 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
&& let Some(view) = crate::crdt_state::read_item(item_id) && let Some(view) = crate::crdt_state::read_item(item_id)
&& let Some(t) = view.item_type() && let Some(t) = view.item_type()
{ {
use crate::io::story_metadata::ItemType;
return match t { return match t {
"bug" => "bug", ItemType::Bug => "bug",
"spike" => "spike", ItemType::Spike => "spike",
"refactor" => "refactor", ItemType::Refactor => "refactor",
_ => "story", _ => "story",
}; };
} }
@@ -527,7 +528,7 @@ mod tests {
&format!("# Test {t}\n"), &format!("# Test {t}\n"),
crate::db::ItemMeta::named(format!("Test {t}")), crate::db::ItemMeta::named(format!("Test {t}")),
); );
crate::crdt_state::set_item_type(id, Some(t)); crate::crdt_state::set_item_type(id, crate::io::story_metadata::ItemType::from_str(t));
} }
assert_eq!(item_type_from_id("9999"), "bug"); assert_eq!(item_type_from_id("9999"), "bug");
@@ -60,9 +60,7 @@ pub(super) fn resolve_qa_mode_from_store(
default: crate::io::story_metadata::QaMode, default: crate::io::story_metadata::QaMode,
) -> crate::io::story_metadata::QaMode { ) -> crate::io::story_metadata::QaMode {
crate::crdt_state::read_item(story_id) crate::crdt_state::read_item(story_id)
.and_then(|view| view.qa_mode().map(str::to_string)) .and_then(|view| view.qa_mode())
.as_deref()
.and_then(crate::io::story_metadata::QaMode::from_str)
.unwrap_or(default) .unwrap_or(default)
} }
+1 -1
View File
@@ -230,7 +230,7 @@ pub(super) async fn run_agent_spawn(
// Story 933: epic linkage is now a typed CRDT register on PipelineItemCrdt. // Story 933: epic linkage is now a typed CRDT register on PipelineItemCrdt.
if let Some(view) = crate::crdt_state::read_item(&sid) if let Some(view) = crate::crdt_state::read_item(&sid)
&& let Some(epic_id) = view.epic() && let Some(epic_id) = view.epic()
&& let Some(epic_content) = crate::db::read_content(epic_id) && let Some(epic_content) = crate::db::read_content(&epic_id.to_string())
{ {
let block = format!( let block = format!(
"# Epic Context\n\nThis work item belongs to epic `{epic_id}`.\ "# Epic Context\n\nThis work item belongs to epic `{epic_id}`.\
+1 -1
View File
@@ -147,7 +147,7 @@ mod tests {
"60_story_cleanup", "60_story_cleanup",
"2_current", "2_current",
story_content, story_content,
crate::db::ItemMeta::default(), crate::db::ItemMeta::named("Cleanup"),
); );
let pool = AgentPool::new_test(3001); let pool = AgentPool::new_test(3001);
+5 -5
View File
@@ -63,7 +63,7 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
// Story name comes from the CRDT register, not the on-disk YAML // Story name comes from the CRDT register, not the on-disk YAML
// (story 929 — CRDT is the sole source of story metadata). // (story 929 — CRDT is the sole source of story metadata).
let story_name = crate::crdt_state::read_item(&story_id) let story_name = crate::crdt_state::read_item(&story_id)
.and_then(|w| w.name().map(str::to_string)) .map(|w| w.name().to_string())
.unwrap_or_else(|| story_id.clone()); .unwrap_or_else(|| story_id.clone());
// Write depends_on to the typed CRDT register — single source of truth. // Write depends_on to the typed CRDT register — single source of truth.
@@ -183,7 +183,7 @@ mod tests {
"1_backlog", "1_backlog",
"9910_story_foo.md", "9910_story_foo.md",
"---\nname: Foo\n---\n", "---\nname: Foo\n---\n",
None, Some("Foo"),
); );
let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap(); let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap();
assert!( assert!(
@@ -214,7 +214,7 @@ mod tests {
"2_current", "2_current",
"9911_story_bar.md", "9911_story_bar.md",
"---\nname: Bar\n---\n", "---\nname: Bar\n---\n",
None, Some("Bar"),
); );
// Pre-seed CRDT with deps so we can verify clearing. // Pre-seed CRDT with deps so we can verify clearing.
crate::crdt_state::set_depends_on("9911_story_bar", &[477]); crate::crdt_state::set_depends_on("9911_story_bar", &[477]);
@@ -251,7 +251,7 @@ mod tests {
"1_backlog", "1_backlog",
"8790_story_chat_dep.md", "8790_story_chat_dep.md",
"---\nname: Chat Dep\n---\n", "---\nname: Chat Dep\n---\n",
None, Some("Chat Dep"),
); );
let out = depends_cmd_with_root(tmp.path(), "8790 500 501").unwrap(); let out = depends_cmd_with_root(tmp.path(), "8790 500 501").unwrap();
@@ -286,7 +286,7 @@ mod tests {
"1_backlog", "1_backlog",
"9920_story_scr.md", "9920_story_scr.md",
"---\nname: SCR\n---\n", "---\nname: SCR\n---\n",
None, Some("SCR"),
); );
// Set to [1, 2, 3]. // Set to [1, 2, 3].
+1 -1
View File
@@ -212,7 +212,7 @@ mod tests {
"2_current", "2_current",
"55551_story_no_worktree.md", "55551_story_no_worktree.md",
"---\nname: No Worktree\n---\n", "---\nname: No Worktree\n---\n",
None, Some("No Worktree"),
); );
let output = diff_cmd(tmp.path(), "55551").unwrap(); let output = diff_cmd(tmp.path(), "55551").unwrap();
assert!( assert!(
+2 -2
View File
@@ -98,7 +98,7 @@ fn unfreeze_by_story_id(story_id: &str) -> String {
/// Falls back to `story_id` if no CRDT entry exists. /// Falls back to `story_id` if no CRDT entry exists.
fn resolve_story_name(story_id: &str) -> String { fn resolve_story_name(story_id: &str) -> String {
crate::crdt_state::read_item(story_id) crate::crdt_state::read_item(story_id)
.and_then(|w| w.name().map(str::to_string)) .map(|w| w.name().to_string())
.unwrap_or_else(|| story_id.to_string()) .unwrap_or_else(|| story_id.to_string())
} }
@@ -274,7 +274,7 @@ mod tests {
"2_current", "2_current",
"9943_story_alreadyfrozen.md", "9943_story_alreadyfrozen.md",
"---\nname: Already Frozen\n---\n# Story\n", "---\nname: Already Frozen\n---\n# Story\n",
None, Some("Already Frozen"),
); );
// Freeze it first. // Freeze it first.
freeze_cmd_with_root(tmp.path(), "9943").unwrap(); freeze_cmd_with_root(tmp.path(), "9943").unwrap();
+2 -2
View File
@@ -202,7 +202,7 @@ mod tests {
"2_current", "2_current",
"77_story_no_log.md", "77_story_no_log.md",
"---\nname: No Log\n---\n", "---\nname: No Log\n---\n",
None, Some("No Log"),
); );
let output = logs_cmd(tmp.path(), "77").unwrap(); let output = logs_cmd(tmp.path(), "77").unwrap();
assert!( assert!(
@@ -222,7 +222,7 @@ mod tests {
"2_current", "2_current",
"88_story_has_log.md", "88_story_has_log.md",
"---\nname: Has Log\n---\n", "---\nname: Has Log\n---\n",
None, Some("Has Log"),
); );
// Write a log file in the expected location. // Write a log file in the expected location.
let log_dir = tmp let log_dir = tmp
+1 -2
View File
@@ -57,8 +57,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
}; };
// Display name comes from the CRDT name register (story 929). // Display name comes from the CRDT name register (story 929).
let found_name = let found_name = crate::crdt_state::read_item(&story_id).map(|w| w.name().to_string());
crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string));
let display_name = found_name.as_deref().unwrap_or(&story_id); let display_name = found_name.as_deref().unwrap_or(&story_id);
+1 -1
View File
@@ -110,7 +110,7 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<Stri
/// (story 929 — CRDT is the sole source of story metadata). /// (story 929 — CRDT is the sole source of story metadata).
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> { fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
let (story_id, _, _, _) = crate::chat::lookup::find_story_by_number(root, num_str)?; let (story_id, _, _, _) = crate::chat::lookup::find_story_by_number(root, num_str)?;
crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string)) crate::crdt_state::read_item(&story_id).map(|w| w.name().to_string())
} }
/// Return the `git show --stat` output for a commit. /// Return the `git show --stat` output for a commit.
+4 -7
View File
@@ -72,10 +72,7 @@ fn build_triage_dump(
// Story metadata now comes from the CRDT registers and adjacent CRDT entries // Story metadata now comes from the CRDT registers and adjacent CRDT entries
// (MergeJob.error), not from YAML front matter (story 929). // (MergeJob.error), not from YAML front matter (story 929).
let crdt_item = crate::crdt_state::read_item(story_id); let crdt_item = crate::crdt_state::read_item(story_id);
let name = crdt_item let name = crdt_item.as_ref().map(|w| w.name()).unwrap_or("(unnamed)");
.as_ref()
.and_then(|w| w.name())
.unwrap_or("(unnamed)");
let mut out = String::new(); let mut out = String::new();
@@ -377,7 +374,7 @@ mod tests {
"2_current", "2_current",
"99_story_criteria_test.md", "99_story_criteria_test.md",
"---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n", "---\nname: Criteria Test\n---\n\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n",
None, Some("Criteria Test"),
); );
let output = status_triage_cmd(tmp.path(), "99").unwrap(); let output = status_triage_cmd(tmp.path(), "99").unwrap();
assert!( assert!(
@@ -403,7 +400,7 @@ mod tests {
"2_current", "2_current",
"55_story_blocked_story.md", "55_story_blocked_story.md",
"---\nname: Blocked Story\nblocked: true\n---\n", "---\nname: Blocked Story\nblocked: true\n---\n",
None, Some("Blocked Story"),
); );
let output = status_triage_cmd(tmp.path(), "55").unwrap(); let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!( assert!(
@@ -460,7 +457,7 @@ mod tests {
"2_current", "2_current",
"77_story_no_worktree.md", "77_story_no_worktree.md",
"---\nname: No Worktree\n---\n", "---\nname: No Worktree\n---\n",
None, Some("No Worktree"),
); );
let output = status_triage_cmd(tmp.path(), "77").unwrap(); let output = status_triage_cmd(tmp.path(), "77").unwrap();
// Branch name should still appear // Branch name should still appear
+1 -1
View File
@@ -55,7 +55,7 @@ fn unblock_by_story_id(story_id: &str) -> String {
let crdt_item = crate::crdt_state::read_item(story_id); let crdt_item = crate::crdt_state::read_item(story_id);
let story_name = crdt_item let story_name = crdt_item
.as_ref() .as_ref()
.and_then(|i| i.name().map(str::to_string)) .map(|i| i.name().to_string())
.unwrap_or_else(|| story_id.to_string()); .unwrap_or_else(|| story_id.to_string());
// Story 945: `Stage::Blocked` / `Stage::MergeFailure` are the single // Story 945: `Stage::Blocked` / `Stage::MergeFailure` are the single
+1 -1
View File
@@ -146,7 +146,7 @@ fn find_story_name(_root: &std::path::Path, num_str: &str) -> Option<String> {
let items = crate::crdt_state::read_all_items()?; let items = crate::crdt_state::read_all_items()?;
for item in items { for item in items {
if item.story_id().split('_').next().unwrap_or("") == num_str { if item.story_id().split('_').next().unwrap_or("") == num_str {
return item.name().map(str::to_string); return Some(item.name().to_string());
} }
} }
None None
+1 -1
View File
@@ -103,7 +103,7 @@ pub async fn handle_assign(
// Story name comes from the CRDT name register (story 929). // Story name comes from the CRDT name register (story 929).
let story_name = crate::crdt_state::read_item(&story_id) let story_name = crate::crdt_state::read_item(&story_id)
.and_then(|w| w.name().map(str::to_string)) .map(|w| w.name().to_string())
.unwrap_or_else(|| story_id.clone()); .unwrap_or_else(|| story_id.clone());
let agent_name = resolve_agent_name(model_str); let agent_name = resolve_agent_name(model_str);
+1 -1
View File
@@ -71,7 +71,7 @@ pub async fn handle_delete(
// Story name comes from the CRDT name register (story 929). // Story name comes from the CRDT name register (story 929).
let story_name = crate::crdt_state::read_item(&story_id) let story_name = crate::crdt_state::read_item(&story_id)
.and_then(|w| w.name().map(str::to_string)) .map(|w| w.name().to_string())
.unwrap_or_else(|| story_id.clone()); .unwrap_or_else(|| story_id.clone());
let outcome = match crate::service::work_item::delete::delete_work_item( let outcome = match crate::service::work_item::delete::delete_work_item(
+1 -1
View File
@@ -90,7 +90,7 @@ pub async fn handle_start(
// Story name comes from the CRDT name register (story 929). // Story name comes from the CRDT name register (story 929).
let story_name = crate::crdt_state::read_item(&story_id) let story_name = crate::crdt_state::read_item(&story_id)
.and_then(|w| w.name().map(str::to_string)) .map(|w| w.name().to_string())
.unwrap_or_else(|| story_id.clone()); .unwrap_or_else(|| story_id.clone());
// Resolve agent name: try "coder-{hint}" first, then the hint as-is. // Resolve agent name: try "coder-{hint}" first, then the hint as-is.
+2 -2
View File
@@ -46,8 +46,8 @@ pub use read::{
}; };
pub use state::{init, subscribe}; pub use state::{init, subscribe};
pub use types::{ pub use types::{
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent, ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, Claim, CrdtEvent,
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView, EpicId, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView, NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView,
TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem, TestJobCrdt, TestJobView, TokenUsageCrdt, TokenUsageView, WorkItem,
}; };
+1 -1
View File
@@ -110,7 +110,7 @@ pub fn is_claimed_by_us(story_id: &str) -> bool {
let Some(item) = read_item(story_id) else { let Some(item) = read_item(story_id) else {
return false; return false;
}; };
item.claimed_by.as_deref() == Some(&node_id) item.claim().is_some_and(|c| c.node == node_id)
} }
/// Write or update a node presence entry in the CRDT. /// Write or update a node presence entry in the CRDT.
+33 -21
View File
@@ -292,9 +292,12 @@ pub fn evict_item(story_id: &str) -> Result<(), String> {
/// ///
/// Projects the loose CRDT `stage` register into a typed /// Projects the loose CRDT `stage` register into a typed
/// [`crate::pipeline_state::Stage`]. Items with an unknown or missing stage /// [`crate::pipeline_state::Stage`]. Items with an unknown or missing stage
/// string are filtered out (`None`), so every `WorkItem` that escapes the /// string, or with no name set, are filtered out (`None`) — a nameless item
/// read path carries a valid typed stage. /// is treated as malformed and never surfaces to callers.
pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> { pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemView> {
use super::types::{Claim, EpicId};
use crate::io::story_metadata::{ItemType, QaMode};
let story_id = match item.story_id.view() { let story_id = match item.story_id.view() {
JsonValue::String(s) if !s.is_empty() => s, JsonValue::String(s) if !s.is_empty() => s,
_ => return None, _ => return None,
@@ -303,48 +306,58 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
JsonValue::String(s) if !s.is_empty() => s, JsonValue::String(s) if !s.is_empty() => s,
_ => return None, _ => return None,
}; };
// AC 5: nameless item = malformed; filter it out.
let name = match item.name.view() { let name = match item.name.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => s,
_ => None, _ => return None,
}; };
let agent = match item.agent.view() { let agent = match item.agent.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None, _ => None,
}; };
let retry_count = match item.retry_count.view() { let retry_count = match item.retry_count.view() {
JsonValue::Number(n) => Some(n as i64), JsonValue::Number(n) if n >= 0.0 => n as u32,
_ => None, _ => 0u32,
}; };
let depends_on = match item.depends_on.view() { let depends_on = match item.depends_on.view() {
JsonValue::String(s) if !s.is_empty() => serde_json::from_str::<Vec<u32>>(&s).ok(), JsonValue::String(s) if !s.is_empty() => {
_ => None, serde_json::from_str::<Vec<u32>>(&s).unwrap_or_default()
}
_ => Vec::new(),
}; };
let claimed_by = match item.claimed_by.view() { let claimed_by = match item.claimed_by.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None, _ => None,
}; };
let claimed_at = match item.claimed_at.view() { let claimed_at_secs = match item.claimed_at.view() {
JsonValue::Number(n) if n > 0.0 => Some(n), JsonValue::Number(n) if n > 0.0 => Some(n as u64),
_ => None, _ => None,
}; };
let merged_at = match item.merged_at.view() { let claim = match (claimed_by, claimed_at_secs) {
(Some(node), Some(at)) => Some(Claim { node, at }),
_ => None,
};
// `merged_at` is read only to project into `Stage::Done`; it is not
// stored on `WorkItem` (callers access it via `Stage::Done { merged_at }`).
let merged_at_float = match item.merged_at.view() {
JsonValue::Number(n) if n > 0.0 => Some(n), JsonValue::Number(n) if n > 0.0 => Some(n),
_ => None, _ => None,
}; };
let qa_mode = match item.qa_mode.view() { let qa_mode = match item.qa_mode.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => QaMode::from_str(&s),
_ => None, _ => None,
}; };
let item_type = match item.item_type.view() { let item_type = match item.item_type.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => ItemType::from_str(&s),
_ => None, _ => None,
}; };
let epic = match item.epic.view() { let epic = match item.epic.view() {
JsonValue::String(s) if !s.is_empty() => Some(s), JsonValue::String(s) if !s.is_empty() => EpicId::from_crdt_str(&s),
_ => None, _ => None,
}; };
@@ -353,7 +366,8 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
_ => None, _ => None,
}; };
let stage = project_stage_for_view(&stage_str, &story_id, merged_at, resume_to.as_deref())?; let stage =
project_stage_for_view(&stage_str, &story_id, merged_at_float, resume_to.as_deref())?;
Some(PipelineItemView { Some(PipelineItemView {
story_id, story_id,
@@ -362,9 +376,7 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
agent, agent,
retry_count, retry_count,
depends_on, depends_on,
claimed_by, claim,
claimed_at,
merged_at,
qa_mode, qa_mode,
item_type, item_type,
epic, epic,
@@ -571,10 +583,10 @@ mod tests {
let view = extract_item_view(&crdt.doc.items[0]).unwrap(); let view = extract_item_view(&crdt.doc.items[0]).unwrap();
assert_eq!(view.story_id, "40_story_view"); assert_eq!(view.story_id, "40_story_view");
assert!(matches!(view.stage, crate::pipeline_state::Stage::Qa)); assert!(matches!(view.stage, crate::pipeline_state::Stage::Qa));
assert_eq!(view.name.as_deref(), Some("View Test")); assert_eq!(view.name, "View Test");
assert_eq!(view.agent.as_deref(), Some("coder-1")); assert_eq!(view.agent.as_deref(), Some("coder-1"));
assert_eq!(view.retry_count, Some(2)); assert_eq!(view.retry_count, 2u32);
assert_eq!(view.depends_on, Some(vec![10, 20])); assert_eq!(view.depends_on, vec![10u32, 20u32]);
} }
#[test] #[test]
+1 -1
View File
@@ -205,7 +205,7 @@ async fn init_and_write_read_roundtrip() {
let view = extract_item_view(&crdt2.doc.items[0]).unwrap(); let view = extract_item_view(&crdt2.doc.items[0]).unwrap();
assert_eq!(view.story_id, "50_story_roundtrip"); assert_eq!(view.story_id, "50_story_roundtrip");
assert!(matches!(view.stage, crate::pipeline_state::Stage::Backlog)); assert!(matches!(view.stage, crate::pipeline_state::Stage::Backlog));
assert_eq!(view.name.as_deref(), Some("Roundtrip")); assert_eq!(view.name, "Roundtrip");
} }
#[test] #[test]
+87 -56
View File
@@ -117,6 +117,47 @@ pub struct NodePresenceCrdt {
// ── Read-side view types ───────────────────────────────────────────── // ── Read-side view types ─────────────────────────────────────────────
/// Active claim on a pipeline item — node that owns it and when the claim was written.
///
/// Both fields must be present for a claim to be valid; a partial claim (node
/// but no timestamp, or vice versa) is treated as absent by `extract_item_view`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Claim {
/// Hex-encoded Ed25519 public key of the node that holds the claim.
pub node: String,
/// Unix timestamp (seconds, integer) when the claim was written.
pub at: u64,
}
/// Numeric identifier for an epic work item.
///
/// The numeric prefix of the epic's story_id (e.g. `EpicId(9990)` for the
/// epic whose CRDT story_id register holds `"9990"`). Epics are always
/// created with a pure-numeric story_id by `create_epic_file`.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
pub struct EpicId(pub u32);
impl EpicId {
/// Parse a CRDT epic string (e.g. `"9990"`) into an `EpicId`.
///
/// Accepts both pure-numeric strings (`"9990"`) and slug-prefixed strings
/// (`"9990_epic_foo"`) by stripping any non-digit suffix.
pub fn from_crdt_str(s: &str) -> Option<Self> {
// Try pure-numeric parse first.
if let Ok(n) = s.parse::<u32>() {
return Some(Self(n));
}
// Fall back: take the leading numeric prefix before the first `_`.
s.split('_').next()?.parse::<u32>().ok().map(Self)
}
}
impl std::fmt::Display for EpicId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}", self.0)
}
}
/// A typed snapshot of a single pipeline work item derived from the CRDT document. /// A typed snapshot of a single pipeline work item derived from the CRDT document.
/// ///
/// Access fields exclusively through the typed accessor methods — raw field access is /// Access fields exclusively through the typed accessor methods — raw field access is
@@ -130,22 +171,24 @@ pub struct NodePresenceCrdt {
pub struct WorkItem { pub struct WorkItem {
pub(super) story_id: String, pub(super) story_id: String,
pub(super) stage: crate::pipeline_state::Stage, pub(super) stage: crate::pipeline_state::Stage,
pub(super) name: Option<String>, /// Human-readable name. `extract_item_view` returns `None` (filtering the item
/// out of all read paths) when this register is empty — a nameless item is
/// treated as malformed, not surfaced with an empty string.
pub(super) name: String,
pub(super) agent: Option<String>, pub(super) agent: Option<String>,
pub(super) retry_count: Option<i64>, /// Retry counter — `0` when the CRDT register is unset.
pub(super) depends_on: Option<Vec<u32>>, pub(super) retry_count: u32,
/// Node ID of the node that claimed this item (hex-encoded Ed25519 pubkey). /// Dependency story numbers — empty `Vec` when the register is unset.
pub(super) claimed_by: Option<String>, pub(super) depends_on: Vec<u32>,
/// Unix timestamp (seconds) when the claim was written. /// Active claim (node + timestamp). `None` when the item is unclaimed or
pub(super) claimed_at: Option<f64>, /// when only one of the two companion registers is set.
/// Unix timestamp (seconds) when the item was merged to master. pub(super) claim: Option<Claim>,
pub(super) merged_at: Option<f64>, /// QA mode override. `None` means "use the project default".
/// QA mode override: `"server"`, `"agent"`, or `"human"`. pub(super) qa_mode: Option<crate::io::story_metadata::QaMode>,
pub(super) qa_mode: Option<String>, /// Item type. `None` means "infer from the story_id slug prefix".
/// Item type (sub-story 933): `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`. pub(super) item_type: Option<crate::io::story_metadata::ItemType>,
pub(super) item_type: Option<String>, /// Epic this item belongs to. `None` when the item has no parent epic.
/// Epic ID this item belongs to, or `None` (sub-story 933). pub(super) epic: Option<EpicId>,
pub(super) epic: Option<String>,
} }
impl WorkItem { impl WorkItem {
@@ -159,9 +202,12 @@ impl WorkItem {
&self.stage &self.stage
} }
/// Human-readable story name, or `None` when unset. /// Human-readable story name.
pub fn name(&self) -> Option<&str> { ///
self.name.as_deref() /// Items without a name never reach callers — `extract_item_view` returns
/// `None` for nameless items so they are filtered out of all read paths.
pub fn name(&self) -> &str {
&self.name
} }
/// Agent name pinned to this item, or `None` when unset. /// Agent name pinned to this item, or `None` when unset.
@@ -171,43 +217,32 @@ impl WorkItem {
/// Retry counter. Returns `0` when the register is unset. /// Retry counter. Returns `0` when the register is unset.
pub fn retry_count(&self) -> u32 { pub fn retry_count(&self) -> u32 {
self.retry_count.unwrap_or(0).max(0) as u32 self.retry_count
} }
/// Dependency story numbers. Returns an empty slice when unset. /// Dependency story numbers. Returns an empty slice when unset.
pub fn depends_on(&self) -> &[u32] { pub fn depends_on(&self) -> &[u32] {
self.depends_on.as_deref().unwrap_or(&[]) &self.depends_on
} }
/// Node ID of the current claim holder, or `None` when unclaimed. /// Active claim on this item, or `None` when unclaimed.
pub fn claimed_by(&self) -> Option<&str> { pub fn claim(&self) -> Option<&Claim> {
self.claimed_by.as_deref() self.claim.as_ref()
} }
/// Unix timestamp (seconds) when the current claim was written, or `None`. /// QA mode override, or `None` when the register is unset (use project default).
pub fn claimed_at(&self) -> Option<f64> { pub fn qa_mode(&self) -> Option<crate::io::story_metadata::QaMode> {
self.claimed_at self.qa_mode
} }
/// Unix timestamp (seconds) when the item was merged to master, or `None`. /// Item type, or `None` when the register is unset (infer from story_id prefix).
pub fn merged_at(&self) -> Option<f64> { pub fn item_type(&self) -> Option<crate::io::story_metadata::ItemType> {
self.merged_at self.item_type
} }
/// QA mode override (`"server"`, `"agent"`, or `"human"`), or `None` when unset. /// Epic this item belongs to, or `None` when unset.
pub fn qa_mode(&self) -> Option<&str> { pub fn epic(&self) -> Option<EpicId> {
self.qa_mode.as_deref() self.epic
}
/// Item type (`"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`), or
/// `None` when the register is unset.
pub fn item_type(&self) -> Option<&str> {
self.item_type.as_deref()
}
/// Epic ID this item is a member of, or `None` when unset.
pub fn epic(&self) -> Option<&str> {
self.epic.as_deref()
} }
/// Construct a `WorkItem` for use in tests outside `crdt_state::*`. /// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
@@ -219,27 +254,23 @@ impl WorkItem {
pub fn for_test( pub fn for_test(
story_id: impl Into<String>, story_id: impl Into<String>,
stage: crate::pipeline_state::Stage, stage: crate::pipeline_state::Stage,
name: Option<String>, name: impl Into<String>,
agent: Option<String>, agent: Option<String>,
retry_count: Option<i64>, retry_count: u32,
depends_on: Option<Vec<u32>>, depends_on: Vec<u32>,
claimed_by: Option<String>, claim: Option<Claim>,
claimed_at: Option<f64>, qa_mode: Option<crate::io::story_metadata::QaMode>,
merged_at: Option<f64>, item_type: Option<crate::io::story_metadata::ItemType>,
qa_mode: Option<String>, epic: Option<EpicId>,
item_type: Option<String>,
epic: Option<String>,
) -> Self { ) -> Self {
Self { Self {
story_id: story_id.into(), story_id: story_id.into(),
stage, stage,
name, name: name.into(),
agent, agent,
retry_count, retry_count,
depends_on, depends_on,
claimed_by, claim,
claimed_at,
merged_at,
qa_mode, qa_mode,
item_type, item_type,
epic, epic,
+12 -6
View File
@@ -41,12 +41,15 @@ pub fn set_depends_on(story_id: &str, deps: &[u32]) -> bool {
/// Set the `item_type` CRDT register for a pipeline item (sub-story 933). /// Set the `item_type` CRDT register for a pipeline item (sub-story 933).
/// ///
/// `Some(t)` writes the type string (e.g. `"story"`, `"epic"`, `"bug"`). /// `Some(t)` writes the canonical type string (e.g. `"story"`, `"epic"`, `"bug"`).
/// `None` clears the register to an empty string, which means "use the /// `None` clears the register to an empty string, which means "use the
/// id-prefix heuristic" (see `item_type_from_id`). /// id-prefix heuristic" (see `item_type_from_id`).
/// ///
/// Returns `true` if the item was found and the op was applied, `false` otherwise. /// Returns `true` if the item was found and the op was applied, `false` otherwise.
pub fn set_item_type(story_id: &str, item_type: Option<&str>) -> bool { pub fn set_item_type(
story_id: &str,
item_type: Option<crate::io::story_metadata::ItemType>,
) -> bool {
let Some(state_mutex) = get_crdt() else { let Some(state_mutex) = get_crdt() else {
return false; return false;
}; };
@@ -56,18 +59,21 @@ pub fn set_item_type(story_id: &str, item_type: Option<&str>) -> bool {
let Some(&idx) = state.index.get(story_id) else { let Some(&idx) = state.index.get(story_id) else {
return false; return false;
}; };
let value = item_type.unwrap_or("").to_string(); let value = item_type
.map(|t| t.as_str().to_string())
.unwrap_or_default();
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].item_type.set(value)); apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].item_type.set(value));
true true
} }
/// Set the `epic` CRDT register for a pipeline item (sub-story 933). /// Set the `epic` CRDT register for a pipeline item (sub-story 933).
/// ///
/// `Some(epic_id)` links the item to its parent epic. /// `Some(id)` links the item to its parent epic (stored as the numeric string,
/// e.g. `"9990"` for `EpicId(9990)`).
/// `None` clears the register to an empty string (no epic membership). /// `None` clears the register to an empty string (no epic membership).
/// ///
/// Returns `true` if the item was found and the op was applied, `false` otherwise. /// Returns `true` if the item was found and the op was applied, `false` otherwise.
pub fn set_epic(story_id: &str, epic_id: Option<&str>) -> bool { pub fn set_epic(story_id: &str, epic_id: Option<crate::crdt_state::types::EpicId>) -> bool {
let Some(state_mutex) = get_crdt() else { let Some(state_mutex) = get_crdt() else {
return false; return false;
}; };
@@ -77,7 +83,7 @@ pub fn set_epic(story_id: &str, epic_id: Option<&str>) -> bool {
let Some(&idx) = state.index.get(story_id) else { let Some(&idx) = state.index.get(story_id) else {
return false; return false;
}; };
let value = epic_id.unwrap_or("").to_string(); let value = epic_id.map(|e| e.to_string()).unwrap_or_default();
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].epic.set(value)); apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].epic.set(value));
true true
} }
+33 -34
View File
@@ -154,7 +154,18 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
write_item_str( write_item_str(
"44_story_foo", "44_story_foo",
"1_backlog", "1_backlog",
None, Some("Foo slug"),
None,
None,
None,
None,
None,
None,
);
write_item_str(
"44",
"2_current",
Some("Foo numeric"),
None, None,
None, None,
None, None,
@@ -162,7 +173,6 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
None, None,
None, None,
); );
write_item_str("44", "2_current", None, None, None, None, None, None, None);
let result = migrate_story_ids_to_numeric(); let result = migrate_story_ids_to_numeric();
// The slug entry must NOT be migrated because "44" is already occupied. // The slug entry must NOT be migrated because "44" is already occupied.
@@ -202,7 +212,7 @@ fn migrate_story_ids_to_numeric_preserves_stage_and_name() {
let item = read_item("45").expect("item must be accessible by numeric ID"); let item = read_item("45").expect("item must be accessible by numeric ID");
assert!(matches!(item.stage, crate::pipeline_state::Stage::Coding)); assert!(matches!(item.stage, crate::pipeline_state::Stage::Coding));
assert_eq!(item.name.as_deref(), Some("Crash Bug")); assert_eq!(item.name, "Crash Bug");
assert_eq!(item.agent.as_deref(), Some("coder-1")); assert_eq!(item.agent.as_deref(), Some("coder-1"));
} }
@@ -223,20 +233,18 @@ fn migrate_names_from_slugs_fills_empty_names() {
None, None,
); );
// Before migration the name should be empty. // Before migration: nameless item is filtered by read_item (AC 5).
let before = read_item("42_story_my_feature").unwrap();
assert!( assert!(
before.name.as_deref().unwrap_or("").is_empty(), read_item("42_story_my_feature").is_none(),
"name should be empty before migration" "nameless item must not be returned by read_item before migration"
); );
migrate_names_from_slugs(); migrate_names_from_slugs();
// After migration the name should be derived from the slug. // After migration the item has a name and is visible to read_item.
let after = read_item("42_story_my_feature").unwrap(); let after = read_item("42_story_my_feature").unwrap();
assert_eq!( assert_eq!(
after.name.as_deref(), after.name, "My feature",
Some("My feature"),
"name should be derived from slug after migration" "name should be derived from slug after migration"
); );
} }
@@ -261,8 +269,7 @@ fn migrate_names_from_slugs_leaves_existing_names_unchanged() {
let after = read_item("43_story_named_item").unwrap(); let after = read_item("43_story_named_item").unwrap();
assert_eq!( assert_eq!(
after.name.as_deref(), after.name, "Already Named",
Some("Already Named"),
"pre-existing name must not be overwritten" "pre-existing name must not be overwritten"
); );
} }
@@ -300,7 +307,7 @@ fn set_depends_on_round_trip_and_clear() {
let view = read_item("872_test_target").unwrap(); let view = read_item("872_test_target").unwrap();
assert_eq!( assert_eq!(
view.depends_on, view.depends_on,
Some(vec![837]), vec![837u32],
"CRDT register should hold [837]" "CRDT register should hold [837]"
); );
@@ -308,8 +315,8 @@ fn set_depends_on_round_trip_and_clear() {
let ok = set_depends_on("872_test_target", &[]); let ok = set_depends_on("872_test_target", &[]);
assert!(ok, "set_depends_on([]) should return true"); assert!(ok, "set_depends_on([]) should return true");
let view = read_item("872_test_target").unwrap(); let view = read_item("872_test_target").unwrap();
assert_eq!( assert!(
view.depends_on, None, view.depends_on.is_empty(),
"clearing should leave register unset" "clearing should leave register unset"
); );
@@ -412,7 +419,7 @@ fn set_qa_mode_round_trip_server_then_human() {
write_item_str( write_item_str(
"869_story_qa_roundtrip", "869_story_qa_roundtrip",
"1_backlog", "1_backlog",
None, Some("Qa Roundtrip"),
None, None,
None, None,
None, None,
@@ -426,9 +433,9 @@ fn set_qa_mode_round_trip_server_then_human() {
assert!(ok, "set_qa_mode should return true for known item"); assert!(ok, "set_qa_mode should return true for known item");
let view = read_item("869_story_qa_roundtrip").unwrap(); let view = read_item("869_story_qa_roundtrip").unwrap();
assert_eq!( assert_eq!(
view.qa_mode.as_deref(), view.qa_mode,
Some("server"), Some(QaMode::Server),
"CRDT register should hold \"server\"" "CRDT register should hold Server"
); );
// Set qa=human via typed path and assert CRDT register is updated. // Set qa=human via typed path and assert CRDT register is updated.
@@ -436,9 +443,9 @@ fn set_qa_mode_round_trip_server_then_human() {
assert!(ok, "set_qa_mode should return true for known item"); assert!(ok, "set_qa_mode should return true for known item");
let view = read_item("869_story_qa_roundtrip").unwrap(); let view = read_item("869_story_qa_roundtrip").unwrap();
assert_eq!( assert_eq!(
view.qa_mode.as_deref(), view.qa_mode,
Some("human"), Some(QaMode::Human),
"CRDT register should hold \"human\"" "CRDT register should hold Human"
); );
// Clear via None — register goes back to unset. // Clear via None — register goes back to unset.
@@ -467,7 +474,7 @@ fn bump_retry_count_increments_by_one() {
write_item_str( write_item_str(
"9001_story_bump_test", "9001_story_bump_test",
"2_current", "2_current",
None, Some("Bump Test"),
None, None,
None, None,
None, None,
@@ -483,11 +490,7 @@ fn bump_retry_count_increments_by_one() {
assert_eq!(v2, 2, "second bump should return 2"); assert_eq!(v2, 2, "second bump should return 2");
let item = read_item("9001_story_bump_test").expect("item must exist"); let item = read_item("9001_story_bump_test").expect("item must exist");
assert_eq!( assert_eq!(item.retry_count, 2u32, "CRDT must reflect final bump value");
item.retry_count,
Some(2),
"CRDT must reflect final bump value"
);
} }
#[test] #[test]
@@ -496,7 +499,7 @@ fn set_retry_count_resets_to_zero() {
write_item_str( write_item_str(
"9002_story_set_test", "9002_story_set_test",
"2_current", "2_current",
None, Some("Set Test"),
None, None,
Some(5), Some(5),
None, None,
@@ -508,11 +511,7 @@ fn set_retry_count_resets_to_zero() {
set_retry_count("9002_story_set_test", 0); set_retry_count("9002_story_set_test", 0);
let item = read_item("9002_story_set_test").expect("item must exist"); let item = read_item("9002_story_set_test").expect("item must exist");
assert_eq!( assert_eq!(item.retry_count, 0u32, "set_retry_count(0) must reset to 0");
item.retry_count,
Some(0),
"set_retry_count(0) must reset to 0"
);
} }
#[test] #[test]
+5 -12
View File
@@ -321,7 +321,7 @@ mod tests {
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT"); let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
assert_eq!(view.stage().dir_name(), "coding"); assert_eq!(view.stage().dir_name(), "coding");
assert_eq!(view.name(), Some("Typed Name")); assert_eq!(view.name(), "Typed Name");
assert_eq!(view.agent(), Some("coder-1")); assert_eq!(view.agent(), Some("coder-1"));
assert_eq!(view.retry_count(), 2); assert_eq!(view.retry_count(), 2);
assert_eq!(view.depends_on(), &[100, 200]); assert_eq!(view.depends_on(), &[100, 200]);
@@ -343,17 +343,10 @@ mod tests {
let content = "---\nname: Should Not Appear\nagent: ghost\n---\n# Body\n"; let content = "---\nname: Should Not Appear\nagent: ghost\n---\n# Body\n";
write_item_with_content(story_id, "2_current", content, ItemMeta::default()); write_item_with_content(story_id, "2_current", content, ItemMeta::default());
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT"); // Nameless items are filtered out by read_item (AC 5: nameless = malformed).
assert_eq!(view.stage().dir_name(), "coding"); assert!(
assert_eq!( crate::crdt_state::read_item(story_id).is_none(),
view.name(), "name must come from typed meta, not parsed YAML — nameless items must not be surfaced"
None,
"name must come from typed meta, not parsed YAML"
);
assert_eq!(
view.agent(),
None,
"agent must come from typed meta, not parsed YAML"
); );
} }
+1 -1
View File
@@ -170,7 +170,7 @@ pub fn move_item_stage(
// mirror stays in sync. Always reset retry_count to 0 on stage transition. // mirror stays in sync. Always reset retry_count to 0 on stage transition.
if let Some(db) = PIPELINE_DB.get() { if let Some(db) = PIPELINE_DB.get() {
let view = crate::crdt_state::read_item(story_id); let view = crate::crdt_state::read_item(story_id);
let name = view.as_ref().and_then(|v| v.name().map(str::to_string)); let name = view.as_ref().map(|v| v.name().to_string());
let agent = view.as_ref().and_then(|v| v.agent().map(str::to_string)); let agent = view.as_ref().and_then(|v| v.agent().map(str::to_string));
let depends_on = view let depends_on = view
.as_ref() .as_ref()
@@ -453,7 +453,7 @@ mod tests {
"5_story_test", "5_story_test",
"1_backlog", "1_backlog",
content, content,
crate::db::ItemMeta::default(), crate::db::ItemMeta::named("Test"),
); );
let ctx = test_ctx(root); let ctx = test_ctx(root);
@@ -485,7 +485,7 @@ mod tests {
"6_story_back", "6_story_back",
"2_current", "2_current",
content, content,
crate::db::ItemMeta::default(), crate::db::ItemMeta::named("Back"),
); );
let ctx = test_ctx(root); let ctx = test_ctx(root);
@@ -517,7 +517,7 @@ mod tests {
"9907_story_idem", "9907_story_idem",
"2_current", "2_current",
content, content,
crate::db::ItemMeta::default(), crate::db::ItemMeta::named("Idem"),
); );
let ctx = test_ctx(root); let ctx = test_ctx(root);
+5 -13
View File
@@ -177,14 +177,12 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
// --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) --- // --- Metadata (story 929: CRDT-first, yaml_residue marks gaps) ---
let mut front_matter = serde_json::Map::new(); let mut front_matter = serde_json::Map::new();
if let Some(view) = crate::crdt_state::read_item(story_id) { if let Some(view) = crate::crdt_state::read_item(story_id) {
if let Some(name) = view.name() { front_matter.insert("name".to_string(), json!(view.name()));
front_matter.insert("name".to_string(), json!(name));
}
if let Some(agent) = view.agent() { if let Some(agent) = view.agent() {
front_matter.insert("agent".to_string(), json!(agent)); front_matter.insert("agent".to_string(), json!(agent));
} }
if let Some(qa) = view.qa_mode() { if let Some(qa) = view.qa_mode() {
front_matter.insert("qa".to_string(), json!(qa)); front_matter.insert("qa".to_string(), json!(qa.as_str()));
} }
let rc = view.retry_count(); let rc = view.retry_count();
if rc > 0 { if rc > 0 {
@@ -194,15 +192,9 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
if !deps.is_empty() { if !deps.is_empty() {
front_matter.insert("depends_on".to_string(), json!(deps)); front_matter.insert("depends_on".to_string(), json!(deps));
} }
if let Some(cb) = view.claimed_by() if let Some(claim) = view.claim() {
&& !cb.is_empty() front_matter.insert("claimed_by".to_string(), json!(claim.node));
{ front_matter.insert("claimed_at".to_string(), json!(claim.at));
front_matter.insert("claimed_by".to_string(), json!(cb));
}
if let Some(ca) = view.claimed_at()
&& ca > 0.0
{
front_matter.insert("claimed_at".to_string(), json!(ca));
} }
} }
+1 -1
View File
@@ -464,7 +464,7 @@ mod tests {
"9901_bug_crash", "9901_bug_crash",
"1_backlog", "1_backlog",
content, content,
crate::db::ItemMeta::default(), crate::db::ItemMeta::named("Crash"),
); );
// Stage the file so it's tracked // Stage the file so it's tracked
std::process::Command::new("git") std::process::Command::new("git")
+1 -2
View File
@@ -37,8 +37,7 @@ pub(crate) fn tool_get_story_todos(args: &Value, ctx: &AppContext) -> Result<Str
let contents = crate::http::workflow::read_story_content(&root, story_id) let contents = crate::http::workflow::read_story_content(&root, story_id)
.map_err(|_| format!("Story file not found: {story_id}.md"))?; .map_err(|_| format!("Story file not found: {story_id}.md"))?;
let story_name = let story_name = crate::crdt_state::read_item(story_id).map(|v| v.name().to_string());
crate::crdt_state::read_item(story_id).and_then(|v| v.name().map(str::to_string));
let todos = parse_unchecked_todos(&contents); let todos = parse_unchecked_todos(&contents);
serde_json::to_string_pretty(&json!({ serde_json::to_string_pretty(&json!({
+19 -10
View File
@@ -61,7 +61,8 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
continue; continue;
}; };
if view.item_type() == Some("epic") { use crate::io::story_metadata::ItemType;
if view.item_type() == Some(ItemType::Epic) {
epics.push((sid.clone(), item.name.clone())); epics.push((sid.clone(), item.name.clone()));
} }
@@ -110,13 +111,17 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
let epic_view = crate::crdt_state::read_item(epic_id) let epic_view = crate::crdt_state::read_item(epic_id)
.ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?; .ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?;
if epic_view.item_type() != Some("epic") { use crate::io::story_metadata::ItemType;
if epic_view.item_type() != Some(ItemType::Epic) {
return Err(format!( return Err(format!(
"'{epic_id}' is not an epic (item_type: {:?})", "'{epic_id}' is not an epic (item_type: {:?})",
epic_view.item_type() epic_view.item_type()
)); ));
} }
// Parse the epic_id argument to a numeric EpicId for comparison.
let epic_numeric = crate::crdt_state::EpicId::from_crdt_str(epic_id);
// Find member items. // Find member items.
let all_items = crate::pipeline_state::read_all_typed(); let all_items = crate::pipeline_state::read_all_typed();
let mut member_items: Vec<Value> = Vec::new(); let mut member_items: Vec<Value> = Vec::new();
@@ -126,7 +131,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
let Some(member_view) = crate::crdt_state::read_item(sid) else { let Some(member_view) = crate::crdt_state::read_item(sid) else {
continue; continue;
}; };
if member_view.epic() == Some(epic_id) { if member_view.epic() == epic_numeric {
// Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class // Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class
// Stage variants — no more orthogonal boolean flags. // Stage variants — no more orthogonal boolean flags.
let stage_name = match &item.stage { let stage_name = match &item.stage {
@@ -238,14 +243,18 @@ mod tests {
crate::crdt_state::init_for_test(); crate::crdt_state::init_for_test();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
use crate::crdt_state::EpicId;
use crate::io::story_metadata::ItemType;
// Write a fake epic with the typed CRDT registers (story 933). // Write a fake epic with the typed CRDT registers (story 933).
// Epics use numeric-only story_ids (see create_epic_file).
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9990_epic_rollup", "9990",
"1_backlog", "1_backlog",
"# Rollup Epic\n\n## Goal\n\nTest\n", "# Rollup Epic\n\n## Goal\n\nTest\n",
crate::db::ItemMeta::named("Rollup Epic"), crate::db::ItemMeta::named("Rollup Epic"),
); );
crate::crdt_state::set_item_type("9990_epic_rollup", Some("epic")); crate::crdt_state::set_item_type("9990", Some(ItemType::Epic));
// Write two member items: one done, one current. // Write two member items: one done, one current.
crate::db::write_item_with_content( crate::db::write_item_with_content(
@@ -254,8 +263,8 @@ mod tests {
"# Done Member\n", "# Done Member\n",
crate::db::ItemMeta::named("Done Member"), crate::db::ItemMeta::named("Done Member"),
); );
crate::crdt_state::set_item_type("9991_story_member_done", Some("story")); crate::crdt_state::set_item_type("9991_story_member_done", Some(ItemType::Story));
crate::crdt_state::set_epic("9991_story_member_done", Some("9990_epic_rollup")); crate::crdt_state::set_epic("9991_story_member_done", Some(EpicId(9990)));
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9992_story_member_current", "9992_story_member_current",
@@ -263,8 +272,8 @@ mod tests {
"# Current Member\n", "# Current Member\n",
crate::db::ItemMeta::named("Current Member"), crate::db::ItemMeta::named("Current Member"),
); );
crate::crdt_state::set_item_type("9992_story_member_current", Some("story")); crate::crdt_state::set_item_type("9992_story_member_current", Some(ItemType::Story));
crate::crdt_state::set_epic("9992_story_member_current", Some("9990_epic_rollup")); crate::crdt_state::set_epic("9992_story_member_current", Some(EpicId(9990)));
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let ctx = crate::http::test_helpers::test_ctx(tmp.path()); let ctx = crate::http::test_helpers::test_ctx(tmp.path());
@@ -272,7 +281,7 @@ mod tests {
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap(); let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let epic = parsed let epic = parsed
.iter() .iter()
.find(|e| e["epic_id"] == "9990_epic_rollup") .find(|e| e["epic_id"] == "9990")
.expect("expected rollup epic in list"); .expect("expected rollup epic in list");
assert_eq!(epic["members_total"], 2, "two members expected"); assert_eq!(epic["members_total"], 2, "two members expected");
assert_eq!(epic["members_done"], 1, "one done member expected"); assert_eq!(epic["members_done"], 1, "one done member expected");
@@ -230,7 +230,7 @@ mod tests {
"51_story_no_branch", "51_story_no_branch",
"2_current", "2_current",
content, content,
crate::db::ItemMeta::default(), crate::db::ItemMeta::named("No Branch"),
); );
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
@@ -246,6 +246,9 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
crate::db::ensure_content_store(); crate::db::ensure_content_store();
// Story 946 AC 5: nameless items are invisible at the CRDT layer.
// `extract_item_view` returns `None` for items with no name register set,
// so they are filtered from all read paths including `validate_story_dirs`.
crate::db::write_item_with_content( crate::db::write_item_with_content(
"9908_test", "9908_test",
"2_current", "2_current",
@@ -256,10 +259,9 @@ mod tests {
let ctx = test_ctx(tmp.path()); let ctx = test_ctx(tmp.path());
let result = tool_validate_stories(&ctx).unwrap(); let result = tool_validate_stories(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap(); let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let item = parsed assert!(
.iter() parsed.iter().all(|v| v["story_id"] != "9908_test"),
.find(|v| v["story_id"] == "9908_test") "nameless items must be invisible to tool_validate_stories"
.expect("expected 9908_test in validation results"); );
assert_eq!(item["valid"], false);
} }
} }
@@ -24,7 +24,7 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
crate::crdt_state::set_agent(story_id, Some(agent)); crate::crdt_state::set_agent(story_id, Some(agent));
} }
if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) { if let Some(epic) = args.get("epic").and_then(|v| v.as_str()) {
crate::crdt_state::set_epic(story_id, Some(epic).filter(|s| !s.is_empty())); crate::crdt_state::set_epic(story_id, crate::crdt_state::EpicId::from_crdt_str(epic));
} }
if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) { if let Some(obj) = args.get("front_matter").and_then(|v| v.as_object()) {
@@ -52,12 +52,16 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
crate::crdt_state::set_qa_mode(story_id, mode); crate::crdt_state::set_qa_mode(story_id, mode);
} }
"epic" => { "epic" => {
let s = value.as_str().filter(|s| !s.is_empty()); let parsed = value
crate::crdt_state::set_epic(story_id, s); .as_str()
.and_then(crate::crdt_state::EpicId::from_crdt_str);
crate::crdt_state::set_epic(story_id, parsed);
} }
"type" => { "type" => {
let s = value.as_str().filter(|s| !s.is_empty()); let parsed = value
crate::crdt_state::set_item_type(story_id, s); .as_str()
.and_then(crate::io::story_metadata::ItemType::from_str);
crate::crdt_state::set_item_type(story_id, parsed);
} }
"depends_on" => { "depends_on" => {
if let Some(arr) = value.as_array() { if let Some(arr) = value.as_array() {
+2 -3
View File
@@ -77,9 +77,8 @@ pub(super) fn is_bug_item(stem: &str) -> bool {
} }
if after_num.is_empty() { if after_num.is_empty() {
return crate::crdt_state::read_item(stem) return crate::crdt_state::read_item(stem)
.and_then(|v| v.item_type().map(str::to_string)) .and_then(|v| v.item_type())
.map(|t| t == "bug") .is_some_and(|t| t == crate::io::story_metadata::ItemType::Bug);
.unwrap_or(false);
} }
false false
} }
+1 -1
View File
@@ -73,7 +73,7 @@ pub fn create_epic_file(
write_story_content(root, &epic_id, "1_backlog", &content, Some(name)); write_story_content(root, &epic_id, "1_backlog", &content, Some(name));
// Story 933: typed CRDT register for item_type. // Story 933: typed CRDT register for item_type.
crate::crdt_state::set_item_type(&epic_id, Some("epic")); crate::crdt_state::set_item_type(&epic_id, Some(crate::io::story_metadata::ItemType::Epic));
Ok(epic_id) Ok(epic_id)
} }
+2 -3
View File
@@ -71,9 +71,8 @@ pub(super) fn is_refactor_item(stem: &str) -> bool {
} }
if after_num.is_empty() { if after_num.is_empty() {
return crate::crdt_state::read_item(stem) return crate::crdt_state::read_item(stem)
.and_then(|v| v.item_type().map(str::to_string)) .and_then(|v| v.item_type())
.map(|t| t == "refactor") .is_some_and(|t| t == crate::io::story_metadata::ItemType::Refactor);
.unwrap_or(false);
} }
false false
} }
+1 -1
View File
@@ -362,7 +362,7 @@ fn create_spike_file_with_special_chars_in_name_produces_valid_yaml() {
let spike_id = result.unwrap(); let spike_id = result.unwrap();
let view = crate::crdt_state::read_item(&spike_id).expect("CRDT entry should exist"); let view = crate::crdt_state::read_item(&spike_id).expect("CRDT entry should exist");
assert_eq!(view.name(), Some(name)); assert_eq!(view.name(), name);
} }
#[test] #[test]
+28 -36
View File
@@ -103,8 +103,10 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
} else { } else {
None None
}; };
let qa = view.as_ref().and_then(|v| v.qa_mode().map(str::to_string)); let qa = view
let epic_id = view.as_ref().and_then(|v| v.epic().map(str::to_string)); .as_ref()
.and_then(|v| v.qa_mode().map(|q| q.as_str().to_string()));
let epic_id = view.as_ref().and_then(|v| v.epic().map(|e| e.to_string()));
let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error); let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error);
let story = UpcomingStory { let story = UpcomingStory {
@@ -219,7 +221,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
.map(|item| { .map(|item| {
let sid = &item.story_id.0; let sid = &item.story_id.0;
let epic_id = let epic_id =
crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(str::to_string)); crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(|e| e.to_string()));
UpcomingStory { UpcomingStory {
story_id: item.story_id.0.clone(), story_id: item.story_id.0.clone(),
name: if item.name.is_empty() { name: if item.name.is_empty() {
@@ -265,34 +267,25 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
/// ///
/// Story 929: validation reads the typed CRDT `name` register; the legacy YAML /// Story 929: validation reads the typed CRDT `name` register; the legacy YAML
/// front-matter parse is gone. /// front-matter parse is gone.
///
/// Story 946: nameless items are filtered at the CRDT layer (`extract_item_view`
/// returns `None` for items with no name register set) and therefore never reach
/// this function. Every item in `read_all_typed()` is guaranteed to have a
/// non-empty name, so the only validation left here is stage filtering.
pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, String> { pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, String> {
use crate::pipeline_state::Stage; use crate::pipeline_state::Stage;
let mut results = Vec::new(); let mut results = Vec::new();
let typed_items = crate::pipeline_state::read_all_typed(); for item in crate::pipeline_state::read_all_typed() {
for item in typed_items {
if !matches!(item.stage, Stage::Backlog | Stage::Coding) { if !matches!(item.stage, Stage::Backlog | Stage::Coding) {
continue; continue;
} }
let story_id = item.story_id.0.clone(); results.push(StoryValidationResult {
let name = crate::crdt_state::read_item(&story_id) story_id: item.story_id.0.clone(),
.and_then(|v| v.name().map(str::to_string)) valid: true,
.filter(|s| !s.is_empty()); error: None,
});
if name.is_some() {
results.push(StoryValidationResult {
story_id,
valid: true,
error: None,
});
} else {
results.push(StoryValidationResult {
story_id,
valid: false,
error: Some("Missing 'name' field".to_string()),
});
}
} }
results.sort_by(|a, b| a.story_id.cmp(&b.story_id)); results.sort_by(|a, b| a.story_id.cmp(&b.story_id));
@@ -591,12 +584,13 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap(); let results = validate_story_dirs(tmp.path()).unwrap();
let r = results // Story 946: nameless items are invisible at the CRDT layer (AC 5).
.iter() // `extract_item_view` returns `None` for items with no name register,
.find(|r| r.story_id == "9875_story_no_fm") // so they never surface to `validate_story_dirs`.
.unwrap(); assert!(
assert!(!r.valid); results.iter().all(|r| r.story_id != "9875_story_no_fm"),
assert_eq!(r.error.as_deref(), Some("Missing 'name' field")); "nameless items must be invisible to validate_story_dirs"
);
} }
#[test] #[test]
@@ -611,13 +605,11 @@ mod tests {
let tmp = tempfile::tempdir().unwrap(); let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap(); let results = validate_story_dirs(tmp.path()).unwrap();
let r = results // Story 946: nameless items are invisible at the CRDT layer (AC 5).
.iter() assert!(
.find(|r| r.story_id == "9876_story_no_name") results.iter().all(|r| r.story_id != "9876_story_no_name"),
.unwrap(); "nameless items must be invisible to validate_story_dirs"
assert!(!r.valid); );
let err = r.error.as_deref().unwrap();
assert!(err.contains("Missing 'name' field"));
} }
#[test] #[test]
+2 -2
View File
@@ -185,7 +185,7 @@ mod tests {
let story_id = result.unwrap(); let story_id = result.unwrap();
let view = let view =
crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create"); crate::crdt_state::read_item(&story_id).expect("CRDT entry should exist after create");
assert_eq!(view.name(), Some(name)); assert_eq!(view.name(), name);
} }
// ── check_criterion_in_file tests ───────────────────────────────────────── // ── check_criterion_in_file tests ─────────────────────────────────────────
@@ -242,7 +242,7 @@ mod tests {
let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist"); let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist");
assert_eq!( assert_eq!(
view.item_type(), view.item_type(),
Some("story"), Some(crate::io::story_metadata::ItemType::Story),
"CRDT register must be set to story" "CRDT register must be set to story"
); );
} }
@@ -435,7 +435,10 @@ mod tests {
setup_story_in_fs(tmp.path(), "100_spike_my_spike", spike_content); setup_story_in_fs(tmp.path(), "100_spike_my_spike", spike_content);
// Convert spike to story by updating the typed item_type CRDT register. // Convert spike to story by updating the typed item_type CRDT register.
crate::crdt_state::set_item_type("100_spike_my_spike", Some("story")); crate::crdt_state::set_item_type(
"100_spike_my_spike",
Some(crate::io::story_metadata::ItemType::Story),
);
// Add three acceptance criteria. // Add three acceptance criteria.
add_criterion_to_file(tmp.path(), "100_spike_my_spike", "First criterion") add_criterion_to_file(tmp.path(), "100_spike_my_spike", "First criterion")
+4 -1
View File
@@ -266,7 +266,10 @@ pub(crate) fn create_item_in_backlog(
write_story_content(root, &item_id, "1_backlog", &content, Some(name)); write_story_content(root, &item_id, "1_backlog", &content, Some(name));
crate::crdt_state::set_depends_on(&item_id, depends_on.unwrap_or(&[])); crate::crdt_state::set_depends_on(&item_id, depends_on.unwrap_or(&[]));
crate::crdt_state::set_item_type(&item_id, Some(item_type)); crate::crdt_state::set_item_type(
&item_id,
crate::io::story_metadata::ItemType::from_str(item_type),
);
Ok(item_id) Ok(item_id)
} }
+2 -2
View File
@@ -2,7 +2,7 @@
//! //!
//! Story 865 stripped YAML front matter from the content store; this module //! Story 865 stripped YAML front matter from the content store; this module
//! no longer parses or writes YAML. What remains: //! no longer parses or writes YAML. What remains:
//! - `types` — `QaMode` enum. //! - `types` — `QaMode` and `ItemType` enums.
//! - `parser` — `parse_unchecked_todos`, `resolve_qa_mode`, `is_story_frozen_in_store`. //! - `parser` — `parse_unchecked_todos`, `resolve_qa_mode`, `is_story_frozen_in_store`.
//! - `deps` — dependency satisfaction checks (CRDT-backed). //! - `deps` — dependency satisfaction checks (CRDT-backed).
@@ -11,4 +11,4 @@ mod parser;
mod types; mod types;
pub use parser::{is_story_frozen_in_store, parse_unchecked_todos, resolve_qa_mode}; pub use parser::{is_story_frozen_in_store, parse_unchecked_todos, resolve_qa_mode};
pub use types::QaMode; pub use types::{ItemType, QaMode};
+1 -3
View File
@@ -23,9 +23,7 @@ pub fn parse_unchecked_todos(contents: &str) -> Vec<String> {
/// spikes themselves. /// spikes themselves.
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode { pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
crate::crdt_state::read_item(story_id) crate::crdt_state::read_item(story_id)
.and_then(|view| view.qa_mode().map(str::to_string)) .and_then(|view| view.qa_mode())
.as_deref()
.and_then(QaMode::from_str)
.unwrap_or(default) .unwrap_or(default)
} }
+47 -2
View File
@@ -1,4 +1,4 @@
//! Core data types for story metadata. //! Core data types for story metadata: [`QaMode`] and [`ItemType`] enums.
/// QA mode for a story: determines how the pipeline handles post-coder review. /// QA mode for a story: determines how the pipeline handles post-coder review.
/// ///
@@ -6,7 +6,7 @@
/// If gates pass, advance straight to merge. /// If gates pass, advance straight to merge.
/// - `Agent` — spin up a QA agent (Claude session) to review code and run gates. /// - `Agent` — spin up a QA agent (Claude session) to review code and run gates.
/// - `Human` — hold in QA for human approval after server gates pass. /// - `Human` — hold in QA for human approval after server gates pass.
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum QaMode { pub enum QaMode {
Server, Server,
Agent, Agent,
@@ -39,3 +39,48 @@ impl std::fmt::Display for QaMode {
f.write_str(self.as_str()) f.write_str(self.as_str())
} }
} }
/// The type of a pipeline work item.
///
/// Stored as a typed register in the CRDT (`"story"`, `"bug"`, `"spike"`,
/// `"refactor"`, `"epic"`). `None` in the view means "infer from the
/// story_id slug prefix".
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum ItemType {
Story,
Bug,
Spike,
Refactor,
Epic,
}
impl ItemType {
/// Parse a string into an `ItemType`. Returns `None` for unrecognised values.
pub fn from_str(s: &str) -> Option<Self> {
match s.trim().to_lowercase().as_str() {
"story" => Some(Self::Story),
"bug" => Some(Self::Bug),
"spike" => Some(Self::Spike),
"refactor" => Some(Self::Refactor),
"epic" => Some(Self::Epic),
_ => None,
}
}
/// Return the lowercase string representation of this item type.
pub fn as_str(&self) -> &'static str {
match self {
Self::Story => "story",
Self::Bug => "bug",
Self::Spike => "spike",
Self::Refactor => "refactor",
Self::Epic => "epic",
}
}
}
impl std::fmt::Display for ItemType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
+16 -26
View File
@@ -44,7 +44,7 @@ impl TryFrom<&PipelineItemView> for PipelineItem {
fn try_from(view: &PipelineItemView) -> Result<Self, ProjectionError> { fn try_from(view: &PipelineItemView) -> Result<Self, ProjectionError> {
let story_id = StoryId(view.story_id().to_string()); let story_id = StoryId(view.story_id().to_string());
let name = view.name().unwrap_or("").to_string(); let name = view.name().to_string();
let depends_on: Vec<StoryId> = view let depends_on: Vec<StoryId> = view
.depends_on() .depends_on()
@@ -115,12 +115,10 @@ mod tests {
PipelineItemView::for_test( PipelineItemView::for_test(
story_id, story_id,
stage, stage,
name.map(str::to_string), name.unwrap_or("(unnamed)"),
None,
None,
None,
None,
None, None,
0u32,
vec![],
None, None,
None, None,
None, None,
@@ -140,12 +138,10 @@ mod tests {
let view = PipelineItemView::for_test( let view = PipelineItemView::for_test(
"42_story_test", "42_story_test",
Stage::Backlog, Stage::Backlog,
Some("Test Story".to_string()), "Test Story",
None,
None,
Some(vec![10, 20]),
None,
None, None,
0u32,
vec![10, 20],
None, None,
None, None,
None, None,
@@ -164,12 +160,10 @@ mod tests {
let view = PipelineItemView::for_test( let view = PipelineItemView::for_test(
"42_story_test", "42_story_test",
Stage::Coding, Stage::Coding,
Some("Test".to_string()), "Test",
Some("coder-1".to_string()), Some("coder-1".to_string()),
Some(2), 2u32,
None, vec![],
None,
None,
None, None,
None, None,
None, None,
@@ -225,12 +219,10 @@ mod tests {
reason: "migrated from legacy blocked field".to_string(), reason: "migrated from legacy blocked field".to_string(),
}, },
}, },
Some("Test".to_string()), "Test",
None,
None,
None,
None,
None, None,
0u32,
vec![],
None, None,
None, None,
None, None,
@@ -254,12 +246,10 @@ mod tests {
archived_at: Utc::now(), archived_at: Utc::now(),
reason: ArchiveReason::Completed, reason: ArchiveReason::Completed,
}, },
Some("Test".to_string()), "Test",
None,
None,
None,
None,
None, None,
0u32,
vec![],
None, None,
None, None,
None, None,
+1 -3
View File
@@ -197,9 +197,7 @@ pub fn get_work_item_content(
let filename = format!("{story_id}.md"); let filename = format!("{story_id}.md");
let crdt_view = crate::crdt_state::read_item(story_id); let crdt_view = crate::crdt_state::read_item(story_id);
let crdt_name = crdt_view let crdt_name = crdt_view.as_ref().map(|v| v.name().to_string());
.as_ref()
.and_then(|v| v.name().map(str::to_string));
let crdt_agent = crdt_view let crdt_agent = crdt_view
.as_ref() .as_ref()
.and_then(|v| v.agent().map(str::to_string)); .and_then(|v| v.agent().map(str::to_string));
+1 -1
View File
@@ -20,7 +20,7 @@ mod tests_stage;
/// ///
/// Returns `None` if the item is not in the CRDT or has no name set. /// 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<String> { pub fn read_story_name(_project_root: &Path, _stage: &str, item_id: &str) -> Option<String> {
crate::crdt_state::read_item(item_id).and_then(|v| v.name().map(str::to_string)) crate::crdt_state::read_item(item_id).map(|v| v.name().to_string())
} }
/// Look up a story name from the CRDT content store regardless of stage. /// Look up a story name from the CRDT content store regardless of stage.