wip(929): stage 10 sweep — production callsites move to CRDT, yaml_legacy shrinks

After 932 (review_hold register) and 933 (item_type + epic registers), the
remaining production yaml_legacy callers all had typed CRDT equivalents.
Migrated:

- agents/lifecycle.rs:
  - transition_to_merge_failure writes to MergeJob.error CRDT entry instead
    of YAML body. The legacy `merge_failure: "..."` front-matter write is gone.
  - reject_story_from_qa inlines the QA-rejection notes append; no longer
    needs yaml_legacy::write_rejection_notes_to_content.
  - fields_to_clear_transform helper deleted along with all five callers —
    blocked/retry_count/merge_failure are typed CRDT fields now, so clearing
    the equivalent YAML keys is redundant.

- http/workflow/pipeline.rs:
  - load_pipeline_state reads merge_failure from MergeJob.error (mirrors
    status_tools.rs).
  - validate_story_dirs checks the typed CRDT `name` register instead of
    parsing YAML front matter.

- http/mcp/status_tools.rs: review_hold reads the typed CRDT register
  (yaml_residue wrap was the last one in this file).
- http/mcp/story_tools/criteria.rs: story_name reads from CRDT.
- service/agents/mod.rs::get_work_item_content: name/agent come from CRDT.
- service/notifications/io/mod.rs::read_story_name: same.
- http/workflow/bug_ops/{bug,refactor}.rs: name-fallback paths drop YAML
  parsing in favour of the CRDT-derived item.name.

Dead helpers removed from db/yaml_legacy.rs:
  yaml_residue, write_merge_failure_in_content, write_rejection_notes_to_content,
  clear_front_matter_field_in_content, write_review_hold_in_content,
  clear_front_matter_field, write_review_hold (the last four shipped in 932).
Remaining surface: FrontMatter / StoryMetadata structs, parse_front_matter,
set_front_matter_field — kept for `coverage_baseline` writes via
test_results.rs and the generic update_story front_matter escape hatch.

Test fixtures rewritten to seed the CRDT register instead of relying on
YAML parsing during write_item_with_content:
- has_review_hold_returns_* tests
- item_type_from_id_uses_crdt_register_for_numeric_ids
- tool_list_epics_shows_member_rollup
- get_work_item_content (both copies — http/agents + service/agents)
- validate_story_dirs_missing_name_in_crdt
- server_side_merge_*_sets_merge_failure (assert MergeJob.error, not YAML)

cargo fmt --check, clippy --all-targets -- -D warnings, and the
2856-test suite all pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Timmy
2026-05-12 20:13:17 +01:00
parent 7d7ab85994
commit 4888f051c3
14 changed files with 132 additions and 206 deletions
+26 -47
View File
@@ -10,7 +10,6 @@ use std::num::NonZeroU32;
use std::path::Path;
use std::process::Command;
use crate::db::yaml_legacy::clear_front_matter_field_in_content;
use crate::pipeline_state::{
ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, TransitionFired,
apply_transition, stage_label,
@@ -46,23 +45,6 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
"story"
}
type ContentTransform = Box<dyn Fn(&str) -> String>;
/// Build a content-transform closure that clears the given front-matter fields.
fn fields_to_clear_transform(fields: &[&str]) -> Option<ContentTransform> {
if fields.is_empty() {
return None;
}
let fields: Vec<String> = fields.iter().map(|s| s.to_string()).collect();
Some(Box::new(move |content: &str| {
let mut result = content.to_string();
for field in &fields {
result = clear_front_matter_field_in_content(&result, field);
}
result
}))
}
/// Move a work item (story, bug, or spike) from `1_backlog` to `work/2_current/`.
///
/// Only promotes from `1_backlog` — stories already in later stages (3_qa, 4_merge,
@@ -142,8 +124,7 @@ pub fn move_story_to_done(story_id: &str) -> Result<(), String> {
}
};
let transform = fields_to_clear_transform(&["merge_failure", "blocked"]);
apply_transition(story_id, event, transform.as_ref().map(|f| f.as_ref()))
apply_transition(story_id, event, None)
.map(|_| ())
.map_err(|e| e.to_string())
}
@@ -179,8 +160,7 @@ pub fn move_story_to_merge(story_id: &str) -> Result<(), String> {
}
};
let transform = fields_to_clear_transform(&["blocked"]);
apply_transition(story_id, event, transform.as_ref().map(|f| f.as_ref()))
apply_transition(story_id, event, None)
.map(|_| ())
.map_err(|e| e.to_string())
}
@@ -197,12 +177,7 @@ pub fn move_story_to_qa(story_id: &str) -> Result<(), String> {
return Ok(());
}
let transform = fields_to_clear_transform(&["blocked"]);
apply_transition(
story_id,
PipelineEvent::GatesStarted,
transform.as_ref().map(|f| f.as_ref()),
)
apply_transition(story_id, PipelineEvent::GatesStarted, None)
.map(|_| ())
.map_err(|e| e.to_string())
}
@@ -225,7 +200,7 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
} else {
let notes_owned = notes.to_string();
let transform = move |content: &str| -> String {
crate::db::yaml_legacy::write_rejection_notes_to_content(content, &notes_owned)
format!("{content}\n\n## QA Rejection Notes\n\n{notes_owned}\n")
};
apply_transition(
story_id,
@@ -245,13 +220,12 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> {
/// writes the resulting `Stage::Blocked` to the CRDT. Returns `Err` on
/// `TransitionError` — callers must NOT fall back to direct register writes.
pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String> {
let transform = fields_to_clear_transform(&["blocked"]);
apply_transition(
story_id,
PipelineEvent::Block {
reason: reason.to_string(),
},
transform.as_ref().map(|f| f.as_ref()),
None,
)
.map(|_| ())
.map_err(|e| e.to_string())
@@ -260,9 +234,10 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String>
/// Transition a story from `Stage::Merge` (or `Stage::MergeFailure`) to
/// `Stage::MergeFailure` via the state machine.
///
/// Builds a `PipelineEvent::MergeFailed { reason }`, validates the transition, writes
/// the resulting `Stage::MergeFailure` to the CRDT, and persists the reason to front
/// matter so it survives server restarts.
/// Builds a `PipelineEvent::MergeFailed { reason }`, validates the transition,
/// writes the resulting `Stage::MergeFailure` to the CRDT, and persists the
/// reason to the typed `MergeJob.error` CRDT register so it survives server
/// restarts (story 929: the legacy YAML write of `merge_failure: "..."` is gone).
///
/// When the story is already in `MergeFailure`, this is a silent self-loop: the
/// returned `TransitionFired::before` will be `Stage::MergeFailure`. Callers
@@ -273,18 +248,27 @@ pub fn transition_to_merge_failure(
story_id: &str,
reason: &str,
) -> Result<TransitionFired, String> {
let reason_owned = reason.to_string();
let transform: Box<dyn Fn(&str) -> String> = Box::new(move |content: &str| {
crate::db::yaml_legacy::write_merge_failure_in_content(content, &reason_owned)
});
apply_transition(
let fired = apply_transition(
story_id,
PipelineEvent::MergeFailed {
reason: reason.to_string(),
},
Some(&*transform),
None,
)
.map_err(|e| e.to_string())
.map_err(|e| e.to_string())?;
// Persist the failure reason on the MergeJob CRDT entry so display tools
// (status_tools, chat status renderer, pipeline.rs::load_pipeline_state)
// can surface it without re-parsing YAML.
crate::crdt_state::write_merge_job(
story_id,
"failed",
chrono::Utc::now().timestamp() as f64,
None,
Some(reason),
);
Ok(fired)
}
/// Transition a story out of `Blocked` back to `Coding` via the state machine.
@@ -294,12 +278,7 @@ pub fn transition_to_merge_failure(
/// Returns `Err` on `TransitionError` — callers must NOT fall back to direct
/// register writes.
pub fn transition_to_unblocked(story_id: &str) -> Result<(), String> {
let transform = fields_to_clear_transform(&["blocked", "merge_failure", "retry_count"]);
apply_transition(
story_id,
PipelineEvent::Unblock,
transform.as_ref().map(|f| f.as_ref()),
)
apply_transition(story_id, PipelineEvent::Unblock, None)
.map(|_| ())
.map_err(|e| e.to_string())?;
+10 -8
View File
@@ -805,11 +805,12 @@ async fn server_side_merge_conflict_sets_merge_failure() {
job.status
);
// merge_failure must be set in the content store.
let content = crate::db::read_content("757b_conflict").expect("story content must be in store");
// Story 929: merge_failure detail is persisted on the MergeJob CRDT entry,
// not the YAML body.
let mj = crate::crdt_state::read_merge_job("757b_conflict").expect("merge job must be in CRDT");
assert!(
content.contains("merge_failure"),
"merge_failure must be written to story on conflict: {content}"
mj.error.is_some(),
"MergeJob.error must be set on conflict: {mj:?}"
);
// Story must remain in 4_merge (not advanced to 5_done).
@@ -924,11 +925,12 @@ async fn server_side_merge_gate_failure_sets_merge_failure() {
MergeJobStatus::Running => panic!("should not still be running"),
}
// merge_failure must be set in the content store.
let content = crate::db::read_content("757c_gates").expect("story content must be in store");
// Story 929: merge_failure detail is persisted on the MergeJob CRDT
// entry, not the YAML body.
let mj = crate::crdt_state::read_merge_job("757c_gates").expect("merge job must be in CRDT");
assert!(
content.contains("merge_failure"),
"merge_failure must be written when gates fail: {content}"
mj.error.is_some(),
"MergeJob.error must be set on gate failure: {mj:?}"
);
// Story must remain in 4_merge.
-65
View File
@@ -10,21 +10,6 @@
use crate::io::story_metadata::QaMode;
use serde::Deserialize;
/// Identity wrapper that flags a yaml_legacy callsite blocked on adding a
/// CRDT register (story 929 residue). Every wrap is a grep-findable marker —
/// `grep -rn yaml_residue` enumerates every remaining gap — so it stays
/// visible in every code review.
///
/// When the CRDT register lands and the caller is migrated, delete the wrap.
/// Once every wrap is gone, delete this function and `db::yaml_legacy`
/// entirely (929 stage 10).
///
/// Filed sub-stories enumerate each gap:
/// - 933: epic mechanism — `item_type` and `epic` link fields.
pub fn yaml_residue<T>(v: T) -> T {
v
}
/// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors
/// the original `io::story_metadata::FrontMatter`.
#[derive(Debug, Default, Deserialize)]
@@ -155,47 +140,6 @@ pub(crate) fn set_front_matter_field(contents: &str, key: &str, value: &str) ->
result
}
/// Remove a `key: value` line from the YAML front matter of a markdown string.
pub(crate) fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String {
let mut lines: Vec<String> = contents.lines().map(String::from).collect();
if lines.is_empty() || lines[0].trim() != "---" {
return contents.to_string();
}
let close_idx = match lines[1..].iter().position(|l| l.trim() == "---") {
Some(i) => i + 1,
None => return contents.to_string(),
};
let key_prefix = format!("{key}:");
if let Some(idx) = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1)
{
lines.remove(idx);
} else {
return contents.to_string();
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
/// Append rejection notes to a markdown body.
pub(crate) fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String {
format!("{contents}\n\n## QA Rejection Notes\n\n{notes}\n")
}
/// Write or update `merge_failure` in story content.
pub(crate) fn write_merge_failure_in_content(contents: &str, reason: &str) -> String {
let escaped = reason
.replace('"', "\\\"")
.replace('\n', " ")
.replace('\r', "");
set_front_matter_field(contents, "merge_failure", &format!("\"{escaped}\""))
}
#[cfg(test)]
mod tests {
use super::*;
@@ -219,13 +163,4 @@ mod tests {
let out = set_front_matter_field("---\nname: X\n---\n# B\n", "agent", "coder-1");
assert!(out.contains("agent: coder-1"));
}
#[test]
fn clear_front_matter_field_removes_key() {
let out = clear_front_matter_field_in_content(
"---\nname: X\nblocked: true\n---\n# B\n",
"blocked",
);
assert!(!out.contains("blocked"));
}
}
+27
View File
@@ -265,6 +265,7 @@ fn make_stage_dir(root: &path::Path, stage: &str) {
#[tokio::test]
async fn get_work_item_content_returns_content_from_backlog() {
crate::crdt_state::init_for_test();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_stage_dir(root, "1_backlog");
@@ -273,6 +274,19 @@ async fn get_work_item_content_returns_content_from_backlog() {
"---\nname: \"Foo Story\"\n---\n\n# Story 42: Foo Story\n\nSome content.",
)
.unwrap();
// Story 929: name lives in the typed CRDT register, not in YAML on disk.
crate::crdt_state::write_item(
"42_story_foo",
"1_backlog",
Some("Foo Story"),
None,
None,
None,
None,
None,
None,
None,
);
let ctx = AppContext::new_test(root.to_path_buf());
let api = AgentsApi { ctx: Arc::new(ctx) };
let result = api
@@ -287,6 +301,7 @@ async fn get_work_item_content_returns_content_from_backlog() {
#[tokio::test]
async fn get_work_item_content_returns_content_from_current() {
crate::crdt_state::init_for_test();
let tmp = TempDir::new().unwrap();
let root = tmp.path();
make_stage_dir(root, "2_current");
@@ -295,6 +310,18 @@ async fn get_work_item_content_returns_content_from_current() {
"---\nname: \"Bar Story\"\n---\n\nBar content.",
)
.unwrap();
crate::crdt_state::write_item(
"43_story_bar",
"2_current",
Some("Bar Story"),
None,
None,
None,
None,
None,
None,
None,
);
let ctx = AppContext::new_test(root.to_path_buf());
let api = AgentsApi { ctx: Arc::new(ctx) };
let result = api
+4 -5
View File
@@ -216,11 +216,10 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
front_matter.insert("merge_failure".to_string(), json!(mf));
}
// review_hold has no CRDT register yet — see story 932. Wrap the
// yaml_legacy read in `yaml_residue(...)` so it's grep-findable.
if let Ok(meta) =
crate::db::yaml_legacy::yaml_residue(crate::db::yaml_legacy::parse_front_matter(&contents))
&& let Some(true) = meta.review_hold
// review_hold is a typed CRDT register (story 932).
if crate::crdt_state::read_item(story_id)
.map(|v| v.review_hold())
.unwrap_or(false)
{
front_matter.insert("review_hold".to_string(), json!(true));
}
-1
View File
@@ -5,7 +5,6 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
+2 -2
View File
@@ -5,7 +5,6 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
@@ -38,7 +37,8 @@ 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 = parse_front_matter(&contents).ok().and_then(|m| m.name);
let story_name =
crate::crdt_state::read_item(story_id).and_then(|v| v.name().map(str::to_string));
let todos = parse_unchecked_todos(&contents);
serde_json::to_string_pretty(&json!({
@@ -5,7 +5,6 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
-1
View File
@@ -5,7 +5,6 @@
use crate::agents::{
close_bug_to_archive, feature_branch_has_unmerged_changes, move_story_to_done,
};
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use crate::http::workflow::{
add_criterion_to_file, check_criterion_in_file, create_bug_file, create_refactor_file,
+2 -9
View File
@@ -1,6 +1,5 @@
//! Bug-item creation and listing operations.
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content};
@@ -90,16 +89,10 @@ pub(super) fn is_bug_item(stem: &str) -> bool {
false
}
/// Extract bug name from content (heading or front matter).
/// Extract bug name from the `# Bug N: name` heading (last-resort fallback when
/// the CRDT name register is empty).
#[allow(clippy::string_slice)] // colon_pos from find(": "); +2 skips ASCII ": " → valid boundary
pub(super) fn extract_bug_name_from_content(content: &str) -> Option<String> {
// Try front matter first.
if let Ok(meta) = parse_front_matter(content)
&& let Some(name) = meta.name
{
return Some(name);
}
// Fallback: heading.
for line in content.lines() {
if let Some(rest) = line.strip_prefix("# Bug ")
&& let Some(colon_pos) = rest.find(": ")
+3 -10
View File
@@ -1,6 +1,5 @@
//! Refactor-item creation and listing operations.
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
use super::super::{next_item_number, slugify_name, write_story_content};
@@ -100,16 +99,10 @@ pub fn list_refactor_files(_root: &Path) -> Result<Vec<(String, String)>, String
}
let sid = item.story_id.0;
let name = if item.name.is_empty() {
None
sid.clone()
} else {
Some(item.name)
}
.or_else(|| {
crate::db::read_content(&sid)
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.name)
})
.unwrap_or_else(|| sid.clone());
item.name
};
refactors.push((sid, name));
}
+17 -33
View File
@@ -1,7 +1,6 @@
//! Pipeline state — types and loading functions for the story pipeline.
use crate::agents::AgentStatus;
use crate::db::yaml_legacy::parse_front_matter;
use crate::http::context::AppContext;
use serde::Serialize;
use std::collections::HashMap;
@@ -96,15 +95,13 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
let agent = agent_map.get(sid).cloned();
// Stories 929/932/933: review_hold, qa_mode, epic_id come from typed
// CRDT registers. merge_failure remains in YAML for now (tracked by
// 929 stage 10's sweep).
// CRDT registers. merge_failure detail lives on the MergeJob CRDT
// entry (same as status_tools.rs).
let view = crate::crdt_state::read_item(sid);
let review_hold = view.as_ref().map(|v| v.review_hold()).filter(|b| *b);
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 merge_failure = crate::db::read_content(sid)
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|meta| meta.merge_failure);
let merge_failure = crate::crdt_state::read_merge_job(sid).and_then(|j| j.error);
let story = UpcomingStory {
story_id: sid.clone(),
@@ -252,7 +249,10 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
Ok(stories)
}
/// Validate story front matter for all backlog and current items.
/// Validate stories for all backlog and current items.
///
/// Story 929: validation reads the typed CRDT `name` register; the legacy YAML
/// front-matter parse is gone.
pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, String> {
use crate::pipeline_state::Stage;
@@ -260,20 +260,15 @@ pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, S
let typed_items = crate::pipeline_state::read_all_typed();
for item in typed_items {
// Only validate backlog and current items (matching the old behaviour).
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());
match crate::db::read_content(&story_id) {
Some(contents) => match parse_front_matter(&contents) {
Ok(meta) => {
let mut errors = Vec::new();
if meta.name.is_none() {
errors.push("Missing 'name' field".to_string());
}
if errors.is_empty() {
if name.is_some() {
results.push(StoryValidationResult {
story_id,
valid: true,
@@ -283,23 +278,10 @@ pub fn validate_story_dirs(_root: &Path) -> Result<Vec<StoryValidationResult>, S
results.push(StoryValidationResult {
story_id,
valid: false,
error: Some(errors.join("; ")),
error: Some("Missing 'name' field".to_string()),
});
}
}
Err(e) => results.push(StoryValidationResult {
story_id,
valid: false,
error: Some(e.to_string()),
}),
},
None => results.push(StoryValidationResult {
story_id,
valid: false,
error: Some("No content found in content store".to_string()),
}),
}
}
results.sort_by(|a, b| a.story_id.cmp(&b.story_id));
Ok(results)
@@ -581,13 +563,15 @@ mod tests {
}
#[test]
fn validate_story_dirs_missing_front_matter() {
fn validate_story_dirs_missing_name_in_crdt() {
crate::crdt_state::init_for_test();
crate::db::ensure_content_store();
// Item exists in CRDT but with no name register set.
crate::db::write_item_with_content(
"9875_story_no_fm",
"2_current",
"# No front matter\n",
crate::db::ItemMeta::from_yaml("# No front matter\n"),
crate::db::ItemMeta::default(),
);
let tmp = tempfile::tempdir().unwrap();
@@ -597,7 +581,7 @@ mod tests {
.find(|r| r.story_id == "9875_story_no_fm")
.unwrap();
assert!(!r.valid);
assert_eq!(r.error.as_deref(), Some("Missing front matter"));
assert_eq!(r.error.as_deref(), Some("Missing 'name' field"));
}
#[test]
+26 -6
View File
@@ -183,14 +183,21 @@ pub fn get_work_item_content(
let work_dir = project_root.join(".huskies").join("work");
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_agent = crdt_view
.as_ref()
.and_then(|v| v.agent().map(str::to_string));
for (stage_dir, stage_name) in &stages {
if let Some(content) = io::read_work_item_from_stage(&work_dir, stage_dir, &filename)? {
let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok();
return Ok(WorkItemContent {
content,
stage: stage_name.to_string(),
name: metadata.as_ref().and_then(|m| m.name.clone()),
agent: metadata.and_then(|m| m.agent),
name: crdt_name.clone(),
agent: crdt_agent.clone(),
});
}
}
@@ -215,12 +222,11 @@ pub fn get_work_item_content(
})
.unwrap_or("unknown")
.to_string();
let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok();
return Ok(WorkItemContent {
content,
stage,
name: metadata.as_ref().and_then(|m| m.name.clone()),
agent: metadata.and_then(|m| m.agent),
name: crdt_name,
agent: crdt_agent,
});
}
@@ -329,6 +335,7 @@ max_budget_usd = 5.0
#[test]
fn get_work_item_content_reads_from_backlog() {
crate::crdt_state::init_for_test();
let tmp = TempDir::new().unwrap();
make_stage_dirs(&tmp);
write_story_file(
@@ -336,6 +343,19 @@ max_budget_usd = 5.0
".huskies/work/1_backlog/42_story_foo.md",
"---\nname: \"Foo Story\"\n---\n\nSome content.",
);
// Story 929: name lives in the CRDT register.
crate::crdt_state::write_item(
"42_story_foo",
"1_backlog",
Some("Foo Story"),
None,
None,
None,
None,
None,
None,
None,
);
let item = get_work_item_content(tmp.path(), "42_story_foo").unwrap();
assert!(item.content.contains("Some content."));
assert_eq!(item.stage, "backlog");
+3 -6
View File
@@ -4,7 +4,6 @@
//! side effects: reading from the CRDT content store, loading configuration,
//! and spawning the background listener task.
use crate::db::yaml_legacy::parse_front_matter;
use std::path::Path;
mod listener;
@@ -17,13 +16,11 @@ mod tests_notifications;
#[cfg(test)]
mod tests_stage;
/// Read the story name from the CRDT content store's YAML front matter.
/// Read the story name from the typed CRDT register (story 929).
///
/// Returns `None` if the item is not in the content store or has no parseable name.
/// 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> {
let contents = crate::db::read_content(item_id)?;
let meta = parse_front_matter(&contents).ok()?;
meta.name
crate::crdt_state::read_item(item_id).and_then(|v| v.name().map(str::to_string))
}
/// Look up a story name from the CRDT content store regardless of stage.