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.
let before = read_item(story_id).expect("item should exist");
assert_eq!(
before.claimed_by(),
before.claim().map(|c| c.node.as_str()),
Some(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!(
age >= CLAIM_TIMEOUT_SECS,
"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 after = read_item(story_id).expect("item should still exist");
assert_eq!(
after.claimed_by(),
after.claim().map(|c| c.node.as_str()),
Some(our_id.as_str()),
"new claim should have displaced the stale holder"
);
assert_ne!(
after.claimed_by(),
after.claim().map(|c| c.node.as_str()),
Some(stale_holder),
"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 item.claimed_by() == Some(our_node.as_str()) {
if item.claim().is_some_and(|c| c.node == our_node) {
continue;
}
// If claimed by another node, respect the claim while it is fresh.
// Once the TTL expires the claim is considered stale regardless of
// whether the holder appears alive — displacement is purely TTL-driven.
if let Some(claimer) = item.claimed_by()
&& !claimer.is_empty()
&& claimer != our_node.as_str()
&& let Some(claimed_at) = item.claimed_at()
if let Some(claim) = item.claim()
&& claim.node != our_node
{
let now = chrono::Utc::now().timestamp() as f64;
let age = now - claimed_at;
let now = chrono::Utc::now().timestamp() as u64;
let age = now.saturating_sub(claim.at) as f64;
if age < CLAIM_TIMEOUT_SECS {
// Claim is still fresh — respect it.
continue;
@@ -75,7 +73,7 @@ pub(super) async fn scan_and_claim(
"[agent-mode] Displacing stale claim on '{}' held by {:.12}… \
(age {}s > TTL {}s)",
item.story_id(),
claimer,
claim.node,
age 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
// holder is still alive. A node actively working should refresh its
// claim before the TTL window closes.
if let Some(claimer) = item.claimed_by() {
if claimer.is_empty() {
continue;
}
if let Some(claimed_at) = item.claimed_at()
&& now - claimed_at >= CLAIM_TIMEOUT_SECS
{
if let Some(claim) = item.claim() {
let age = now as u64 - claim.at.min(now as u64);
if age as f64 >= CLAIM_TIMEOUT_SECS {
slog!(
"[agent-mode] Releasing stale claim on '{}' held by {:.12}… (age {}s)",
item.story_id(),
claimer,
(now - claimed_at) as u64,
claim.node,
age,
);
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(t) = view.item_type()
{
use crate::io::story_metadata::ItemType;
return match t {
"bug" => "bug",
"spike" => "spike",
"refactor" => "refactor",
ItemType::Bug => "bug",
ItemType::Spike => "spike",
ItemType::Refactor => "refactor",
_ => "story",
};
}
@@ -527,7 +528,7 @@ mod tests {
&format!("# Test {t}\n"),
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");
@@ -60,9 +60,7 @@ pub(super) fn resolve_qa_mode_from_store(
default: crate::io::story_metadata::QaMode,
) -> crate::io::story_metadata::QaMode {
crate::crdt_state::read_item(story_id)
.and_then(|view| view.qa_mode().map(str::to_string))
.as_deref()
.and_then(crate::io::story_metadata::QaMode::from_str)
.and_then(|view| view.qa_mode())
.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.
if let Some(view) = crate::crdt_state::read_item(&sid)
&& 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!(
"# Epic Context\n\nThis work item belongs to epic `{epic_id}`.\
+1 -1
View File
@@ -147,7 +147,7 @@ mod tests {
"60_story_cleanup",
"2_current",
story_content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Cleanup"),
);
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 929 — CRDT is the sole source of story metadata).
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());
// Write depends_on to the typed CRDT register — single source of truth.
@@ -183,7 +183,7 @@ mod tests {
"1_backlog",
"9910_story_foo.md",
"---\nname: Foo\n---\n",
None,
Some("Foo"),
);
let output = depends_cmd_with_root(tmp.path(), "9910 477 478").unwrap();
assert!(
@@ -214,7 +214,7 @@ mod tests {
"2_current",
"9911_story_bar.md",
"---\nname: Bar\n---\n",
None,
Some("Bar"),
);
// Pre-seed CRDT with deps so we can verify clearing.
crate::crdt_state::set_depends_on("9911_story_bar", &[477]);
@@ -251,7 +251,7 @@ mod tests {
"1_backlog",
"8790_story_chat_dep.md",
"---\nname: Chat Dep\n---\n",
None,
Some("Chat Dep"),
);
let out = depends_cmd_with_root(tmp.path(), "8790 500 501").unwrap();
@@ -286,7 +286,7 @@ mod tests {
"1_backlog",
"9920_story_scr.md",
"---\nname: SCR\n---\n",
None,
Some("SCR"),
);
// Set to [1, 2, 3].
+1 -1
View File
@@ -212,7 +212,7 @@ mod tests {
"2_current",
"55551_story_no_worktree.md",
"---\nname: No Worktree\n---\n",
None,
Some("No Worktree"),
);
let output = diff_cmd(tmp.path(), "55551").unwrap();
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.
fn resolve_story_name(story_id: &str) -> String {
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())
}
@@ -274,7 +274,7 @@ mod tests {
"2_current",
"9943_story_alreadyfrozen.md",
"---\nname: Already Frozen\n---\n# Story\n",
None,
Some("Already Frozen"),
);
// Freeze it first.
freeze_cmd_with_root(tmp.path(), "9943").unwrap();
+2 -2
View File
@@ -202,7 +202,7 @@ mod tests {
"2_current",
"77_story_no_log.md",
"---\nname: No Log\n---\n",
None,
Some("No Log"),
);
let output = logs_cmd(tmp.path(), "77").unwrap();
assert!(
@@ -222,7 +222,7 @@ mod tests {
"2_current",
"88_story_has_log.md",
"---\nname: Has Log\n---\n",
None,
Some("Has Log"),
);
// Write a log file in the expected location.
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).
let found_name =
crate::crdt_state::read_item(&story_id).and_then(|w| w.name().map(str::to_string));
let found_name = crate::crdt_state::read_item(&story_id).map(|w| w.name().to_string());
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).
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)?;
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.
+4 -7
View File
@@ -72,10 +72,7 @@ fn build_triage_dump(
// Story metadata now comes from the CRDT registers and adjacent CRDT entries
// (MergeJob.error), not from YAML front matter (story 929).
let crdt_item = crate::crdt_state::read_item(story_id);
let name = crdt_item
.as_ref()
.and_then(|w| w.name())
.unwrap_or("(unnamed)");
let name = crdt_item.as_ref().map(|w| w.name()).unwrap_or("(unnamed)");
let mut out = String::new();
@@ -377,7 +374,7 @@ mod tests {
"2_current",
"99_story_criteria_test.md",
"---\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();
assert!(
@@ -403,7 +400,7 @@ mod tests {
"2_current",
"55_story_blocked_story.md",
"---\nname: Blocked Story\nblocked: true\n---\n",
None,
Some("Blocked Story"),
);
let output = status_triage_cmd(tmp.path(), "55").unwrap();
assert!(
@@ -460,7 +457,7 @@ mod tests {
"2_current",
"77_story_no_worktree.md",
"---\nname: No Worktree\n---\n",
None,
Some("No Worktree"),
);
let output = status_triage_cmd(tmp.path(), "77").unwrap();
// 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 story_name = crdt_item
.as_ref()
.and_then(|i| i.name().map(str::to_string))
.map(|i| i.name().to_string())
.unwrap_or_else(|| story_id.to_string());
// 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()?;
for item in items {
if item.story_id().split('_').next().unwrap_or("") == num_str {
return item.name().map(str::to_string);
return Some(item.name().to_string());
}
}
None
+1 -1
View File
@@ -103,7 +103,7 @@ pub async fn handle_assign(
// Story name comes from the CRDT name register (story 929).
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());
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).
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());
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).
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());
// 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 types::{
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, CrdtEvent,
GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
ActiveAgentCrdt, ActiveAgentView, AgentThrottleCrdt, AgentThrottleView, Claim, CrdtEvent,
EpicId, GatewayConfigCrdt, GatewayProjectCrdt, GatewayProjectView, MergeJobCrdt, MergeJobView,
NodePresenceCrdt, NodePresenceView, PipelineDoc, PipelineItemCrdt, PipelineItemView,
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 {
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.
+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
/// [`crate::pipeline_state::Stage`]. Items with an unknown or missing stage
/// string are filtered out (`None`), so every `WorkItem` that escapes the
/// read path carries a valid typed stage.
/// string, or with no name set, are filtered out (`None`) — a nameless item
/// is treated as malformed and never surfaces to callers.
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() {
JsonValue::String(s) if !s.is_empty() => s,
_ => return None,
@@ -303,48 +306,58 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
JsonValue::String(s) if !s.is_empty() => s,
_ => return None,
};
// AC 5: nameless item = malformed; filter it out.
let name = match item.name.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
JsonValue::String(s) if !s.is_empty() => s,
_ => return None,
};
let agent = match item.agent.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
};
let retry_count = match item.retry_count.view() {
JsonValue::Number(n) => Some(n as i64),
_ => None,
JsonValue::Number(n) if n >= 0.0 => n as u32,
_ => 0u32,
};
let depends_on = match item.depends_on.view() {
JsonValue::String(s) if !s.is_empty() => serde_json::from_str::<Vec<u32>>(&s).ok(),
_ => None,
JsonValue::String(s) if !s.is_empty() => {
serde_json::from_str::<Vec<u32>>(&s).unwrap_or_default()
}
_ => Vec::new(),
};
let claimed_by = match item.claimed_by.view() {
JsonValue::String(s) if !s.is_empty() => Some(s),
_ => None,
};
let claimed_at = match item.claimed_at.view() {
JsonValue::Number(n) if n > 0.0 => Some(n),
let claimed_at_secs = match item.claimed_at.view() {
JsonValue::Number(n) if n > 0.0 => Some(n as u64),
_ => 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),
_ => None,
};
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,
};
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,
};
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,
};
@@ -353,7 +366,8 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
_ => 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 {
story_id,
@@ -362,9 +376,7 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
agent,
retry_count,
depends_on,
claimed_by,
claimed_at,
merged_at,
claim,
qa_mode,
item_type,
epic,
@@ -571,10 +583,10 @@ mod tests {
let view = extract_item_view(&crdt.doc.items[0]).unwrap();
assert_eq!(view.story_id, "40_story_view");
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.retry_count, Some(2));
assert_eq!(view.depends_on, Some(vec![10, 20]));
assert_eq!(view.retry_count, 2u32);
assert_eq!(view.depends_on, vec![10u32, 20u32]);
}
#[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();
assert_eq!(view.story_id, "50_story_roundtrip");
assert!(matches!(view.stage, crate::pipeline_state::Stage::Backlog));
assert_eq!(view.name.as_deref(), Some("Roundtrip"));
assert_eq!(view.name, "Roundtrip");
}
#[test]
+87 -56
View File
@@ -117,6 +117,47 @@ pub struct NodePresenceCrdt {
// ── 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.
///
/// Access fields exclusively through the typed accessor methods — raw field access is
@@ -130,22 +171,24 @@ pub struct NodePresenceCrdt {
pub struct WorkItem {
pub(super) story_id: String,
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) retry_count: Option<i64>,
pub(super) depends_on: Option<Vec<u32>>,
/// Node ID of the node that claimed this item (hex-encoded Ed25519 pubkey).
pub(super) claimed_by: Option<String>,
/// Unix timestamp (seconds) when the claim was written.
pub(super) claimed_at: Option<f64>,
/// Unix timestamp (seconds) when the item was merged to master.
pub(super) merged_at: Option<f64>,
/// QA mode override: `"server"`, `"agent"`, or `"human"`.
pub(super) qa_mode: Option<String>,
/// Item type (sub-story 933): `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`.
pub(super) item_type: Option<String>,
/// Epic ID this item belongs to, or `None` (sub-story 933).
pub(super) epic: Option<String>,
/// Retry counter — `0` when the CRDT register is unset.
pub(super) retry_count: u32,
/// Dependency story numbers — empty `Vec` when the register is unset.
pub(super) depends_on: Vec<u32>,
/// Active claim (node + timestamp). `None` when the item is unclaimed or
/// when only one of the two companion registers is set.
pub(super) claim: Option<Claim>,
/// QA mode override. `None` means "use the project default".
pub(super) qa_mode: Option<crate::io::story_metadata::QaMode>,
/// Item type. `None` means "infer from the story_id slug prefix".
pub(super) item_type: Option<crate::io::story_metadata::ItemType>,
/// Epic this item belongs to. `None` when the item has no parent epic.
pub(super) epic: Option<EpicId>,
}
impl WorkItem {
@@ -159,9 +202,12 @@ impl WorkItem {
&self.stage
}
/// Human-readable story name, or `None` when unset.
pub fn name(&self) -> Option<&str> {
self.name.as_deref()
/// Human-readable story name.
///
/// 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.
@@ -171,43 +217,32 @@ impl WorkItem {
/// Retry counter. Returns `0` when the register is unset.
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.
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.
pub fn claimed_by(&self) -> Option<&str> {
self.claimed_by.as_deref()
/// Active claim on this item, or `None` when unclaimed.
pub fn claim(&self) -> Option<&Claim> {
self.claim.as_ref()
}
/// Unix timestamp (seconds) when the current claim was written, or `None`.
pub fn claimed_at(&self) -> Option<f64> {
self.claimed_at
/// QA mode override, or `None` when the register is unset (use project default).
pub fn qa_mode(&self) -> Option<crate::io::story_metadata::QaMode> {
self.qa_mode
}
/// Unix timestamp (seconds) when the item was merged to master, or `None`.
pub fn merged_at(&self) -> Option<f64> {
self.merged_at
/// Item type, or `None` when the register is unset (infer from story_id prefix).
pub fn item_type(&self) -> Option<crate::io::story_metadata::ItemType> {
self.item_type
}
/// QA mode override (`"server"`, `"agent"`, or `"human"`), or `None` when unset.
pub fn qa_mode(&self) -> Option<&str> {
self.qa_mode.as_deref()
}
/// 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()
/// Epic this item belongs to, or `None` when unset.
pub fn epic(&self) -> Option<EpicId> {
self.epic
}
/// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
@@ -219,27 +254,23 @@ impl WorkItem {
pub fn for_test(
story_id: impl Into<String>,
stage: crate::pipeline_state::Stage,
name: Option<String>,
name: impl Into<String>,
agent: Option<String>,
retry_count: Option<i64>,
depends_on: Option<Vec<u32>>,
claimed_by: Option<String>,
claimed_at: Option<f64>,
merged_at: Option<f64>,
qa_mode: Option<String>,
item_type: Option<String>,
epic: Option<String>,
retry_count: u32,
depends_on: Vec<u32>,
claim: Option<Claim>,
qa_mode: Option<crate::io::story_metadata::QaMode>,
item_type: Option<crate::io::story_metadata::ItemType>,
epic: Option<EpicId>,
) -> Self {
Self {
story_id: story_id.into(),
stage,
name,
name: name.into(),
agent,
retry_count,
depends_on,
claimed_by,
claimed_at,
merged_at,
claim,
qa_mode,
item_type,
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).
///
/// `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
/// id-prefix heuristic" (see `item_type_from_id`).
///
/// 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 {
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 {
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));
true
}
/// 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).
///
/// 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 {
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 {
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));
true
}
+33 -34
View File
@@ -154,7 +154,18 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
write_item_str(
"44_story_foo",
"1_backlog",
None,
Some("Foo slug"),
None,
None,
None,
None,
None,
None,
);
write_item_str(
"44",
"2_current",
Some("Foo numeric"),
None,
None,
None,
@@ -162,7 +173,6 @@ fn migrate_story_ids_to_numeric_skips_conflict() {
None,
None,
);
write_item_str("44", "2_current", None, None, None, None, None, None, None);
let result = migrate_story_ids_to_numeric();
// 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");
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"));
}
@@ -223,20 +233,18 @@ fn migrate_names_from_slugs_fills_empty_names() {
None,
);
// Before migration the name should be empty.
let before = read_item("42_story_my_feature").unwrap();
// Before migration: nameless item is filtered by read_item (AC 5).
assert!(
before.name.as_deref().unwrap_or("").is_empty(),
"name should be empty before migration"
read_item("42_story_my_feature").is_none(),
"nameless item must not be returned by read_item before migration"
);
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();
assert_eq!(
after.name.as_deref(),
Some("My feature"),
after.name, "My feature",
"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();
assert_eq!(
after.name.as_deref(),
Some("Already Named"),
after.name, "Already Named",
"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();
assert_eq!(
view.depends_on,
Some(vec![837]),
vec![837u32],
"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", &[]);
assert!(ok, "set_depends_on([]) should return true");
let view = read_item("872_test_target").unwrap();
assert_eq!(
view.depends_on, None,
assert!(
view.depends_on.is_empty(),
"clearing should leave register unset"
);
@@ -412,7 +419,7 @@ fn set_qa_mode_round_trip_server_then_human() {
write_item_str(
"869_story_qa_roundtrip",
"1_backlog",
None,
Some("Qa Roundtrip"),
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");
let view = read_item("869_story_qa_roundtrip").unwrap();
assert_eq!(
view.qa_mode.as_deref(),
Some("server"),
"CRDT register should hold \"server\""
view.qa_mode,
Some(QaMode::Server),
"CRDT register should hold Server"
);
// 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");
let view = read_item("869_story_qa_roundtrip").unwrap();
assert_eq!(
view.qa_mode.as_deref(),
Some("human"),
"CRDT register should hold \"human\""
view.qa_mode,
Some(QaMode::Human),
"CRDT register should hold Human"
);
// Clear via None — register goes back to unset.
@@ -467,7 +474,7 @@ fn bump_retry_count_increments_by_one() {
write_item_str(
"9001_story_bump_test",
"2_current",
None,
Some("Bump Test"),
None,
None,
None,
@@ -483,11 +490,7 @@ fn bump_retry_count_increments_by_one() {
assert_eq!(v2, 2, "second bump should return 2");
let item = read_item("9001_story_bump_test").expect("item must exist");
assert_eq!(
item.retry_count,
Some(2),
"CRDT must reflect final bump value"
);
assert_eq!(item.retry_count, 2u32, "CRDT must reflect final bump value");
}
#[test]
@@ -496,7 +499,7 @@ fn set_retry_count_resets_to_zero() {
write_item_str(
"9002_story_set_test",
"2_current",
None,
Some("Set Test"),
None,
Some(5),
None,
@@ -508,11 +511,7 @@ fn set_retry_count_resets_to_zero() {
set_retry_count("9002_story_set_test", 0);
let item = read_item("9002_story_set_test").expect("item must exist");
assert_eq!(
item.retry_count,
Some(0),
"set_retry_count(0) must reset to 0"
);
assert_eq!(item.retry_count, 0u32, "set_retry_count(0) must reset to 0");
}
#[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");
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.retry_count(), 2);
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";
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");
assert_eq!(view.stage().dir_name(), "coding");
assert_eq!(
view.name(),
None,
"name must come from typed meta, not parsed YAML"
);
assert_eq!(
view.agent(),
None,
"agent must come from typed meta, not parsed YAML"
// Nameless items are filtered out by read_item (AC 5: nameless = malformed).
assert!(
crate::crdt_state::read_item(story_id).is_none(),
"name must come from typed meta, not parsed YAML — nameless items must not be surfaced"
);
}
+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.
if let Some(db) = PIPELINE_DB.get() {
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 depends_on = view
.as_ref()
@@ -453,7 +453,7 @@ mod tests {
"5_story_test",
"1_backlog",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Test"),
);
let ctx = test_ctx(root);
@@ -485,7 +485,7 @@ mod tests {
"6_story_back",
"2_current",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Back"),
);
let ctx = test_ctx(root);
@@ -517,7 +517,7 @@ mod tests {
"9907_story_idem",
"2_current",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Idem"),
);
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) ---
let mut front_matter = serde_json::Map::new();
if let Some(view) = crate::crdt_state::read_item(story_id) {
if let Some(name) = view.name() {
front_matter.insert("name".to_string(), json!(name));
}
front_matter.insert("name".to_string(), json!(view.name()));
if let Some(agent) = view.agent() {
front_matter.insert("agent".to_string(), json!(agent));
}
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();
if rc > 0 {
@@ -194,15 +192,9 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
if !deps.is_empty() {
front_matter.insert("depends_on".to_string(), json!(deps));
}
if let Some(cb) = view.claimed_by()
&& !cb.is_empty()
{
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));
if let Some(claim) = view.claim() {
front_matter.insert("claimed_by".to_string(), json!(claim.node));
front_matter.insert("claimed_at".to_string(), json!(claim.at));
}
}
+1 -1
View File
@@ -464,7 +464,7 @@ mod tests {
"9901_bug_crash",
"1_backlog",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("Crash"),
);
// Stage the file so it's tracked
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)
.map_err(|_| format!("Story file not found: {story_id}.md"))?;
let story_name =
crate::crdt_state::read_item(story_id).and_then(|v| v.name().map(str::to_string));
let story_name = crate::crdt_state::read_item(story_id).map(|v| v.name().to_string());
let todos = parse_unchecked_todos(&contents);
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;
};
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()));
}
@@ -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)
.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!(
"'{epic_id}' is not an epic (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.
let all_items = crate::pipeline_state::read_all_typed();
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 {
continue;
};
if member_view.epic() == Some(epic_id) {
if member_view.epic() == epic_numeric {
// Story 945: Frozen / ReviewHold / MergeFailureFinal are first-class
// Stage variants — no more orthogonal boolean flags.
let stage_name = match &item.stage {
@@ -238,14 +243,18 @@ mod tests {
crate::crdt_state::init_for_test();
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).
// Epics use numeric-only story_ids (see create_epic_file).
crate::db::write_item_with_content(
"9990_epic_rollup",
"9990",
"1_backlog",
"# Rollup Epic\n\n## Goal\n\nTest\n",
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.
crate::db::write_item_with_content(
@@ -254,8 +263,8 @@ mod tests {
"# Done Member\n",
crate::db::ItemMeta::named("Done Member"),
);
crate::crdt_state::set_item_type("9991_story_member_done", Some("story"));
crate::crdt_state::set_epic("9991_story_member_done", Some("9990_epic_rollup"));
crate::crdt_state::set_item_type("9991_story_member_done", Some(ItemType::Story));
crate::crdt_state::set_epic("9991_story_member_done", Some(EpicId(9990)));
crate::db::write_item_with_content(
"9992_story_member_current",
@@ -263,8 +272,8 @@ mod tests {
"# Current Member\n",
crate::db::ItemMeta::named("Current Member"),
);
crate::crdt_state::set_item_type("9992_story_member_current", Some("story"));
crate::crdt_state::set_epic("9992_story_member_current", Some("9990_epic_rollup"));
crate::crdt_state::set_item_type("9992_story_member_current", Some(ItemType::Story));
crate::crdt_state::set_epic("9992_story_member_current", Some(EpicId(9990)));
let tmp = tempfile::tempdir().unwrap();
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 epic = parsed
.iter()
.find(|e| e["epic_id"] == "9990_epic_rollup")
.find(|e| e["epic_id"] == "9990")
.expect("expected rollup epic in list");
assert_eq!(epic["members_total"], 2, "two members expected");
assert_eq!(epic["members_done"], 1, "one done member expected");
@@ -230,7 +230,7 @@ mod tests {
"51_story_no_branch",
"2_current",
content,
crate::db::ItemMeta::default(),
crate::db::ItemMeta::named("No Branch"),
);
let ctx = test_ctx(tmp.path());
@@ -246,6 +246,9 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
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(
"9908_test",
"2_current",
@@ -256,10 +259,9 @@ mod tests {
let ctx = test_ctx(tmp.path());
let result = tool_validate_stories(&ctx).unwrap();
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
let item = parsed
.iter()
.find(|v| v["story_id"] == "9908_test")
.expect("expected 9908_test in validation results");
assert_eq!(item["valid"], false);
assert!(
parsed.iter().all(|v| v["story_id"] != "9908_test"),
"nameless items must be invisible to tool_validate_stories"
);
}
}
@@ -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));
}
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()) {
@@ -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);
}
"epic" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_epic(story_id, s);
let parsed = value
.as_str()
.and_then(crate::crdt_state::EpicId::from_crdt_str);
crate::crdt_state::set_epic(story_id, parsed);
}
"type" => {
let s = value.as_str().filter(|s| !s.is_empty());
crate::crdt_state::set_item_type(story_id, s);
let parsed = value
.as_str()
.and_then(crate::io::story_metadata::ItemType::from_str);
crate::crdt_state::set_item_type(story_id, parsed);
}
"depends_on" => {
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() {
return crate::crdt_state::read_item(stem)
.and_then(|v| v.item_type().map(str::to_string))
.map(|t| t == "bug")
.unwrap_or(false);
.and_then(|v| v.item_type())
.is_some_and(|t| t == crate::io::story_metadata::ItemType::Bug);
}
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));
// 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)
}
+2 -3
View File
@@ -71,9 +71,8 @@ pub(super) fn is_refactor_item(stem: &str) -> bool {
}
if after_num.is_empty() {
return crate::crdt_state::read_item(stem)
.and_then(|v| v.item_type().map(str::to_string))
.map(|t| t == "refactor")
.unwrap_or(false);
.and_then(|v| v.item_type())
.is_some_and(|t| t == crate::io::story_metadata::ItemType::Refactor);
}
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 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]
+24 -32
View File
@@ -103,8 +103,10 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
} else {
None
};
let qa = view.as_ref().and_then(|v| v.qa_mode().map(str::to_string));
let epic_id = view.as_ref().and_then(|v| v.epic().map(str::to_string));
let qa = view
.as_ref()
.and_then(|v| v.qa_mode().map(|q| q.as_str().to_string()));
let epic_id = view.as_ref().and_then(|v| v.epic().map(|e| e.to_string()));
let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error);
let story = UpcomingStory {
@@ -219,7 +221,7 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
.map(|item| {
let sid = &item.story_id.0;
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 {
story_id: item.story_id.0.clone(),
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
/// 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> {
use crate::pipeline_state::Stage;
let mut results = Vec::new();
let typed_items = crate::pipeline_state::read_all_typed();
for item in typed_items {
for item in crate::pipeline_state::read_all_typed() {
if !matches!(item.stage, Stage::Backlog | Stage::Coding) {
continue;
}
let story_id = item.story_id.0.clone();
let name = crate::crdt_state::read_item(&story_id)
.and_then(|v| v.name().map(str::to_string))
.filter(|s| !s.is_empty());
if name.is_some() {
results.push(StoryValidationResult {
story_id,
story_id: item.story_id.0.clone(),
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));
@@ -591,12 +584,13 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap();
let r = results
.iter()
.find(|r| r.story_id == "9875_story_no_fm")
.unwrap();
assert!(!r.valid);
assert_eq!(r.error.as_deref(), Some("Missing 'name' field"));
// Story 946: nameless items are invisible at the CRDT layer (AC 5).
// `extract_item_view` returns `None` for items with no name register,
// so they never surface to `validate_story_dirs`.
assert!(
results.iter().all(|r| r.story_id != "9875_story_no_fm"),
"nameless items must be invisible to validate_story_dirs"
);
}
#[test]
@@ -611,13 +605,11 @@ mod tests {
let tmp = tempfile::tempdir().unwrap();
let results = validate_story_dirs(tmp.path()).unwrap();
let r = results
.iter()
.find(|r| r.story_id == "9876_story_no_name")
.unwrap();
assert!(!r.valid);
let err = r.error.as_deref().unwrap();
assert!(err.contains("Missing 'name' field"));
// Story 946: nameless items are invisible at the CRDT layer (AC 5).
assert!(
results.iter().all(|r| r.story_id != "9876_story_no_name"),
"nameless items must be invisible to validate_story_dirs"
);
}
#[test]
+2 -2
View File
@@ -185,7 +185,7 @@ mod tests {
let story_id = result.unwrap();
let view =
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 ─────────────────────────────────────────
@@ -242,7 +242,7 @@ mod tests {
let view = crate::crdt_state::read_item(&story_id).expect("CRDT entry must exist");
assert_eq!(
view.item_type(),
Some("story"),
Some(crate::io::story_metadata::ItemType::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);
// 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_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));
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)
}
+2 -2
View File
@@ -2,7 +2,7 @@
//!
//! Story 865 stripped YAML front matter from the content store; this module
//! 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`.
//! - `deps` — dependency satisfaction checks (CRDT-backed).
@@ -11,4 +11,4 @@ mod parser;
mod types;
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.
pub fn resolve_qa_mode(story_id: &str, default: QaMode) -> QaMode {
crate::crdt_state::read_item(story_id)
.and_then(|view| view.qa_mode().map(str::to_string))
.as_deref()
.and_then(QaMode::from_str)
.and_then(|view| view.qa_mode())
.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.
///
@@ -6,7 +6,7 @@
/// If gates pass, advance straight to merge.
/// - `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.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub enum QaMode {
Server,
Agent,
@@ -39,3 +39,48 @@ impl std::fmt::Display for QaMode {
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> {
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
.depends_on()
@@ -115,12 +115,10 @@ mod tests {
PipelineItemView::for_test(
story_id,
stage,
name.map(str::to_string),
None,
None,
None,
None,
name.unwrap_or("(unnamed)"),
None,
0u32,
vec![],
None,
None,
None,
@@ -140,12 +138,10 @@ mod tests {
let view = PipelineItemView::for_test(
"42_story_test",
Stage::Backlog,
Some("Test Story".to_string()),
None,
None,
Some(vec![10, 20]),
None,
"Test Story",
None,
0u32,
vec![10, 20],
None,
None,
None,
@@ -164,12 +160,10 @@ mod tests {
let view = PipelineItemView::for_test(
"42_story_test",
Stage::Coding,
Some("Test".to_string()),
"Test",
Some("coder-1".to_string()),
Some(2),
None,
None,
None,
2u32,
vec![],
None,
None,
None,
@@ -225,12 +219,10 @@ mod tests {
reason: "migrated from legacy blocked field".to_string(),
},
},
Some("Test".to_string()),
None,
None,
None,
None,
"Test",
None,
0u32,
vec![],
None,
None,
None,
@@ -254,12 +246,10 @@ mod tests {
archived_at: Utc::now(),
reason: ArchiveReason::Completed,
},
Some("Test".to_string()),
None,
None,
None,
None,
"Test",
None,
0u32,
vec![],
None,
None,
None,
+1 -3
View File
@@ -197,9 +197,7 @@ 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()
.and_then(|v| v.name().map(str::to_string));
let crdt_name = crdt_view.as_ref().map(|v| v.name().to_string());
let crdt_agent = crdt_view
.as_ref()
.and_then(|v| v.agent().map(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.
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.