feat(929): delete db/yaml_legacy.rs entirely — CRDT is the sole source of truth

Final 929 sweep: every YAML-shaped helper is gone. No production code
parses or writes YAML front matter anywhere.

Surface removed:
- db/yaml_legacy.rs (FrontMatter/StoryMetadata structs, parse_front_matter,
  set_front_matter_field, yaml_residue marker) — file deleted.
- ItemMeta::from_yaml — deleted; callers pass typed ItemMeta::named(...) or
  ItemMeta::default() and use typed CRDT setters (set_depends_on,
  set_blocked, set_retry_count, set_agent, set_qa_mode, set_review_hold,
  set_item_type, set_epic, set_mergemaster_attempted) for the rest.
- write_coverage_baseline_to_story_file + read_coverage_percent_from_json —
  the coverage_baseline YAML field was write-only (nothing read it back);
  removed along with its caller in agent_tools/lifecycle.rs.
- update_story_in_file's generic `front_matter` HashMap parameter —
  tool_update_story now intercepts every known field name and routes it
  to a typed CRDT setter; unknown keys are rejected with an explicit error
  pointing at the typed setters. The function only takes user_story /
  description sections now.
- All 117 ItemMeta::from_yaml callsites migrated. Where tests previously
  passed a YAML-shaped content blob and relied on the helper to extract
  name/depends_on/blocked/agent/qa, they now pass:
    write_item_with_content(id, stage, content, ItemMeta::named("Foo"))
    crate::crdt_state::set_depends_on(id, &[...])    // when needed
    crate::crdt_state::set_blocked(id, true)         // when needed
    crate::crdt_state::set_agent(id, Some("..."))    // when needed
- write_story_content + write_story_file (test helper) now take an
  explicit `name: Option<&str>` instead of parsing it from content.
- db::ops::move_item_stage stopped re-parsing YAML on every stage
  transition; metadata is read straight from the CRDT view when mirroring
  the row into SQLite.

New CRDT setters added for symmetry:
- crdt_state::set_name (mirrors set_agent — explicit name updates).

cargo fmt --check, clippy --all-targets -- -D warnings, and the
2830-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:55:25 +01:00
parent 6c62e0fa31
commit 69d91d7707
58 changed files with 433 additions and 1344 deletions
+5 -23
View File
@@ -19,9 +19,6 @@ pub mod content_store;
pub mod ops;
/// Background shadow-write task — persists pipeline items to SQLite asynchronously.
pub mod shadow_write;
/// Legacy YAML helpers — used by callers reading the small set of fields not
/// yet mirrored into the CRDT.
pub(crate) mod yaml_legacy;
pub use content_store::{all_content_ids, delete_content, read_content, write_content};
pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content};
@@ -33,7 +30,6 @@ pub use content_store::ensure_content_store;
#[cfg(test)]
mod tests {
use super::*;
use crate::db::yaml_legacy::parse_front_matter;
use std::fs;
/// Helper: write a minimal story .md file with front matter.
@@ -104,25 +100,11 @@ mod tests {
assert_eq!(row.0, "10_story_shadow_test");
assert_eq!(row.1.as_deref(), Some("Shadow Test"));
assert_eq!(row.2, "2_current");
// Verify metadata was parsed correctly from the story file.
let (name, _agent, retry_count, _blocked, _depends_on) =
match std::fs::read_to_string(&story_path) {
Ok(contents) => match parse_front_matter(&contents) {
Ok(meta) => (
meta.name,
meta.agent,
meta.retry_count.map(|r| r as i64),
meta.blocked,
meta.depends_on,
),
Err(_) => (None, None, None, None, None),
},
Err(_) => (None, None, None, None, None),
};
assert_eq!(name.as_deref(), Some("Shadow Test"));
assert_eq!(retry_count, Some(2));
// The shadow row's name + retry_count came through from the INSERT
// params above; that's what the test exercises. Story 929 dropped
// the redundant "re-parse the YAML body to double-check" step that
// used to live here.
let _ = story_path;
}
#[tokio::test]
+3 -25
View File
@@ -7,14 +7,12 @@ use super::content_store::{
all_content_ids, delete_content, ensure_content_store, read_content, write_content,
};
use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg};
use super::yaml_legacy::parse_front_matter;
/// Typed metadata for a pipeline item write.
///
/// Replaces the prior YAML-parsing write path (story 864): callers now pass
/// metadata explicitly instead of round-tripping it through a serialized
/// front-matter blob. Every field is `Option`-typed; `None` means
/// "leave unchanged" on update, "use the default" on insert.
/// Story 929: callers pass metadata explicitly — no YAML parsing. Every
/// field is `Option`-typed; `None` means "leave unchanged" on update,
/// "use the default" on insert.
#[derive(Default, Clone, Debug)]
pub struct ItemMeta {
pub name: Option<String>,
@@ -33,26 +31,6 @@ impl ItemMeta {
..Self::default()
}
}
/// Parse YAML front-matter from a content string into typed metadata.
///
/// This is an explicit caller-side conversion — the write path itself
/// no longer parses YAML. Use this when the caller has a raw content
/// string with front-matter and wants the metadata to flow into the
/// CRDT. Returns `Self::default()` if parsing fails or there is no
/// front-matter present.
pub fn from_yaml(content: &str) -> Self {
match parse_front_matter(content) {
Ok(m) => Self {
name: m.name,
agent: m.agent,
retry_count: m.retry_count.map(|r| r as i64),
blocked: m.blocked,
depends_on: m.depends_on,
},
Err(_) => Self::default(),
}
}
}
/// Write a pipeline item from in-memory content (no filesystem access).
-166
View File
@@ -1,166 +0,0 @@
//! Legacy YAML front-matter helpers — kept ONLY for the one-shot migration
//! and for the small set of fields not yet mirrored into the CRDT
//! (`item_type`, `epic`, `review_hold`, `merge_failure` reason text, etc.).
//!
//! After the migration runs, every body in the content store is YAML-free, so
//! every helper here returns `Ok(None)` / a no-op on the next read. Callers
//! should treat this module as a deprecated escape hatch — new code should
//! read typed CRDT registers instead.
use crate::io::story_metadata::QaMode;
use serde::Deserialize;
/// Front-matter fields used by the legacy `parse_front_matter` API. Mirrors
/// the original `io::story_metadata::FrontMatter`.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct FrontMatter {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<String>,
pub retry_count: Option<u32>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub frozen: Option<bool>,
pub resume_to_stage: Option<String>,
pub run_tests_passed: Option<bool>,
#[serde(rename = "type")]
pub item_type: Option<String>,
pub mergemaster_attempted: Option<bool>,
pub epic: Option<String>,
}
/// Parsed metadata view returned by [`parse_front_matter`].
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct StoryMetadata {
pub name: Option<String>,
pub coverage_baseline: Option<String>,
pub merge_failure: Option<String>,
pub agent: Option<String>,
pub review_hold: Option<bool>,
pub qa: Option<QaMode>,
pub retry_count: Option<u32>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
pub frozen: Option<bool>,
pub resume_to_stage: Option<String>,
pub run_tests_passed: Option<bool>,
pub item_type: Option<String>,
pub mergemaster_attempted: Option<bool>,
pub epic: Option<String>,
}
/// Errors that can occur when parsing legacy YAML front matter.
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum StoryMetaError {
MissingFrontMatter,
InvalidFrontMatter(String),
}
impl std::fmt::Display for StoryMetaError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::MissingFrontMatter => write!(f, "Missing front matter"),
Self::InvalidFrontMatter(m) => write!(f, "Invalid front matter: {m}"),
}
}
}
/// Parse the YAML front-matter block from a markdown body.
///
/// Post-migration this returns `Err(StoryMetaError::MissingFrontMatter)` for
/// every body since the front matter has been stripped. Callers that need
/// fields not stored in the CRDT (`item_type`, `epic`, …) should treat the
/// missing-front-matter case as "default value".
pub(crate) fn parse_front_matter(contents: &str) -> Result<StoryMetadata, StoryMetaError> {
let mut lines = contents.lines();
let first = lines.next().unwrap_or_default().trim();
if first != "---" {
return Err(StoryMetaError::MissingFrontMatter);
}
let mut front_lines = Vec::new();
for line in &mut lines {
if line.trim() == "---" {
let raw = front_lines.join("\n");
let front: FrontMatter = serde_yaml::from_str(&raw)
.map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?;
return Ok(StoryMetadata {
qa: front.qa.as_deref().and_then(QaMode::from_str),
name: front.name,
coverage_baseline: front.coverage_baseline,
merge_failure: front.merge_failure,
agent: front.agent,
review_hold: front.review_hold,
retry_count: front.retry_count,
blocked: front.blocked,
depends_on: front.depends_on,
frozen: front.frozen,
resume_to_stage: front.resume_to_stage,
run_tests_passed: front.run_tests_passed,
item_type: front.item_type,
mergemaster_attempted: front.mergemaster_attempted,
epic: front.epic,
});
}
front_lines.push(line);
}
Err(StoryMetaError::InvalidFrontMatter(
"Missing closing front matter delimiter".to_string(),
))
}
/// Insert or update a `key: value` line in the YAML front matter of a
/// markdown string. Returns the input unchanged if no `---` block is found.
pub(crate) fn set_front_matter_field(contents: &str, key: &str, value: &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}:");
let existing_idx = lines[1..close_idx]
.iter()
.position(|l| l.trim_start().starts_with(&key_prefix))
.map(|i| i + 1);
let new_line = format!("{key}: {value}");
if let Some(idx) = existing_idx {
lines[idx] = new_line;
} else {
lines.insert(close_idx, new_line);
}
let mut result = lines.join("\n");
if contents.ends_with('\n') {
result.push('\n');
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_front_matter_round_trips_basic_fields() {
let input = "---\nname: Test\nagent: coder-1\n---\n# Body\n";
let meta = parse_front_matter(input).expect("parse");
assert_eq!(meta.name.as_deref(), Some("Test"));
assert_eq!(meta.agent.as_deref(), Some("coder-1"));
}
#[test]
fn parse_front_matter_returns_missing_when_no_yaml() {
let err = parse_front_matter("# Plain markdown\n").unwrap_err();
assert_eq!(err, StoryMetaError::MissingFrontMatter);
}
#[test]
fn set_front_matter_field_inserts_when_absent() {
let out = set_front_matter_field("---\nname: X\n---\n# B\n", "agent", "coder-1");
assert!(out.contains("agent: coder-1"));
}
}