From 9be438e6d33927e8718b0654a9f4009f6ce710a4 Mon Sep 17 00:00:00 2001 From: dave Date: Fri, 8 May 2026 14:24:20 +0000 Subject: [PATCH] huskies: merge 865 --- server/src/agents/lifecycle.rs | 8 +- server/src/agents/pool/auto_assign/backlog.rs | 2 +- server/src/agents/pool/auto_assign/merge.rs | 2 +- .../src/agents/pool/auto_assign/reconcile.rs | 16 +- .../agents/pool/auto_assign/story_checks.rs | 12 +- .../agents/pool/pipeline/advance/helpers.rs | 2 +- server/src/agents/pool/start/spawn.rs | 2 +- server/src/agents/pool/start/validation.rs | 2 +- server/src/chat/commands/depends.rs | 2 +- server/src/chat/commands/freeze.rs | 2 +- server/src/chat/commands/move_story.rs | 2 +- server/src/chat/commands/overview.rs | 2 +- server/src/chat/commands/status/render.rs | 2 +- server/src/chat/commands/triage.rs | 2 +- server/src/chat/commands/unblock.rs | 2 +- server/src/chat/commands/unreleased.rs | 4 +- server/src/chat/transport/matrix/assign.rs | 2 +- server/src/chat/transport/matrix/delete.rs | 2 +- server/src/chat/transport/matrix/start.rs | 2 +- server/src/db/mod.rs | 7 +- server/src/db/ops.rs | 2 +- server/src/db/yaml_legacy.rs | 254 ++++++++++++++ server/src/db/yaml_migration.rs | 316 ++++++++++++++++++ server/src/http/mcp/qa_tools.rs | 4 +- server/src/http/mcp/status_tools.rs | 2 +- server/src/http/mcp/story_tools/bug.rs | 3 +- server/src/http/mcp/story_tools/criteria.rs | 3 +- server/src/http/mcp/story_tools/epic.rs | 2 +- server/src/http/mcp/story_tools/refactor.rs | 3 +- server/src/http/mcp/story_tools/spike.rs | 3 +- server/src/http/workflow/bug_ops/bug.rs | 2 +- server/src/http/workflow/bug_ops/refactor.rs | 2 +- server/src/http/workflow/bug_ops/tests.rs | 2 +- server/src/http/workflow/pipeline.rs | 2 +- server/src/http/workflow/story_ops/create.rs | 4 +- server/src/http/workflow/story_ops/update.rs | 4 +- server/src/http/workflow/test_results.rs | 2 +- server/src/io/story_metadata/deps.rs | 208 +++--------- server/src/io/story_metadata/fields.rs | 212 ------------ server/src/io/story_metadata/mod.rs | 21 +- server/src/io/story_metadata/parser.rs | 263 ++------------- server/src/io/story_metadata/types.rs | 57 +--- server/src/pipeline_state/apply.rs | 4 +- server/src/pipeline_state/projection.rs | 2 +- server/src/service/agents/mod.rs | 4 +- server/src/service/notifications/io/mod.rs | 2 +- server/src/startup/project.rs | 4 + 47 files changed, 733 insertions(+), 731 deletions(-) create mode 100644 server/src/db/yaml_legacy.rs create mode 100644 server/src/db/yaml_migration.rs delete mode 100644 server/src/io/story_metadata/fields.rs diff --git a/server/src/agents/lifecycle.rs b/server/src/agents/lifecycle.rs index 337e39e6..1d06c460 100644 --- a/server/src/agents/lifecycle.rs +++ b/server/src/agents/lifecycle.rs @@ -10,7 +10,7 @@ use std::num::NonZeroU32; use std::path::Path; use std::process::Command; -use crate::io::story_metadata::clear_front_matter_field_in_content; +use crate::db::yaml_legacy::clear_front_matter_field_in_content; use crate::pipeline_state::{ ApplyError, ArchiveReason, BranchName, GitSha, PipelineEvent, Stage, apply_transition, stage_label, @@ -34,7 +34,7 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str { // Numeric-only ID: check content store front matter for explicit type. if after_num.is_empty() && let Some(content) = crate::db::read_content(item_id) - && let Ok(meta) = crate::io::story_metadata::parse_front_matter(&content) + && let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&content) && let Some(t) = meta.item_type.as_deref() { return match t { @@ -214,7 +214,7 @@ pub fn reject_story_from_qa(story_id: &str, notes: &str) -> Result<(), String> { let mut result = clear_front_matter_field_in_content(content, "review_hold"); if !notes_owned.is_empty() { result = - crate::io::story_metadata::write_rejection_notes_to_content(&result, ¬es_owned); + crate::db::yaml_legacy::write_rejection_notes_to_content(&result, ¬es_owned); } result }); @@ -257,7 +257,7 @@ pub fn transition_to_blocked(story_id: &str, reason: &str) -> Result<(), String> pub fn transition_to_merge_failure(story_id: &str, reason: &str) -> Result<(), String> { let reason_owned = reason.to_string(); let transform: Box String> = Box::new(move |content: &str| { - crate::io::story_metadata::write_merge_failure_in_content(content, &reason_owned) + crate::db::yaml_legacy::write_merge_failure_in_content(content, &reason_owned) }); apply_transition( story_id, diff --git a/server/src/agents/pool/auto_assign/backlog.rs b/server/src/agents/pool/auto_assign/backlog.rs index 2a4991bf..4c0e13ce 100644 --- a/server/src/agents/pool/auto_assign/backlog.rs +++ b/server/src/agents/pool/auto_assign/backlog.rs @@ -24,7 +24,7 @@ impl AgentPool { /// logged so the user can see the promotion was triggered by an archived dep, not /// a clean completion. pub(super) fn promote_ready_backlog_stories(&self, project_root: &Path) { - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let items = scan_stage_items(project_root, "1_backlog"); for story_id in &items { diff --git a/server/src/agents/pool/auto_assign/merge.rs b/server/src/agents/pool/auto_assign/merge.rs index f2bf74a1..47532cb8 100644 --- a/server/src/agents/pool/auto_assign/merge.rs +++ b/server/src/agents/pool/auto_assign/merge.rs @@ -79,7 +79,7 @@ impl AgentPool { // crash/restart doesn't re-trigger an infinite loop. if let Some(contents) = crate::db::read_content(story_id) { let updated = - crate::io::story_metadata::write_mergemaster_attempted_in_content( + crate::db::yaml_legacy::write_mergemaster_attempted_in_content( &contents, ); crate::db::write_content(story_id, &updated); diff --git a/server/src/agents/pool/auto_assign/reconcile.rs b/server/src/agents/pool/auto_assign/reconcile.rs index aab56ba1..88488ad2 100644 --- a/server/src/agents/pool/auto_assign/reconcile.rs +++ b/server/src/agents/pool/auto_assign/reconcile.rs @@ -159,10 +159,7 @@ impl AgentPool { let default_qa = crate::config::ProjectConfig::load(project_root) .unwrap_or_default() .default_qa_mode(); - let story_path = project_root - .join(".huskies/work/2_current") - .join(format!("{story_id}.md")); - crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa) + crate::io::story_metadata::resolve_qa_mode(story_id, default_qa) } }; @@ -221,9 +218,7 @@ impl AgentPool { let story_path = project_root .join(".huskies/work/3_qa") .join(format!("{story_id}.md")); - if let Err(e) = - crate::io::story_metadata::write_review_hold(&story_path) - { + if let Err(e) = crate::db::yaml_legacy::write_review_hold(&story_path) { eprintln!( "[startup:reconcile] Failed to set review_hold on '{story_id}': {e}" ); @@ -278,14 +273,11 @@ impl AgentPool { if item_type == "spike" { true } else { - let story_path = project_root - .join(".huskies/work/3_qa") - .join(format!("{story_id}.md")); let default_qa = crate::config::ProjectConfig::load(project_root) .unwrap_or_default() .default_qa_mode(); matches!( - crate::io::story_metadata::resolve_qa_mode(&story_path, default_qa), + crate::io::story_metadata::resolve_qa_mode(story_id, default_qa), crate::io::story_metadata::QaMode::Human ) } @@ -295,7 +287,7 @@ impl AgentPool { let story_path = project_root .join(".huskies/work/3_qa") .join(format!("{story_id}.md")); - if let Err(e) = crate::io::story_metadata::write_review_hold(&story_path) { + if let Err(e) = crate::db::yaml_legacy::write_review_hold(&story_path) { eprintln!( "[startup:reconcile] Failed to set review_hold on '{story_id}': {e}" ); diff --git a/server/src/agents/pool/auto_assign/story_checks.rs b/server/src/agents/pool/auto_assign/story_checks.rs index 8e1ca976..5e4aacd5 100644 --- a/server/src/agents/pool/auto_assign/story_checks.rs +++ b/server/src/agents/pool/auto_assign/story_checks.rs @@ -24,14 +24,14 @@ pub(super) fn read_story_front_matter_agent( { return Some(agent.clone()); } - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let contents = read_story_contents(project_root, story_id)?; parse_front_matter(&contents).ok()?.agent } /// Return `true` if the story file in the given stage has `review_hold: true` in its front matter. pub(super) fn has_review_hold(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool { - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, @@ -52,7 +52,7 @@ pub(super) fn is_story_blocked(project_root: &Path, _stage_dir: &str, story_id: return true; } // Legacy fallback: check front-matter field for backward compatibility. - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, @@ -122,7 +122,7 @@ pub(super) fn is_story_frozen(_project_root: &Path, _stage_dir: &str, story_id: /// Return `true` if the story file has a `merge_failure` field in its front matter. pub(super) fn has_merge_failure(project_root: &Path, _stage_dir: &str, story_id: &str) -> bool { - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, @@ -142,7 +142,7 @@ pub(super) fn has_content_conflict_failure( _stage_dir: &str, story_id: &str, ) -> bool { - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, @@ -163,7 +163,7 @@ pub(super) fn has_mergemaster_attempted( _stage_dir: &str, story_id: &str, ) -> bool { - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; let contents = match read_story_contents(project_root, story_id) { Some(c) => c, None => return false, diff --git a/server/src/agents/pool/pipeline/advance/helpers.rs b/server/src/agents/pool/pipeline/advance/helpers.rs index 3dee06fa..91da3ba4 100644 --- a/server/src/agents/pool/pipeline/advance/helpers.rs +++ b/server/src/agents/pool/pipeline/advance/helpers.rs @@ -80,7 +80,7 @@ pub(super) fn resolve_qa_mode_from_store( /// Write review_hold to the content store. pub(super) fn write_review_hold_to_store(story_id: &str) { if let Some(contents) = crate::db::read_content(story_id) { - let updated = crate::io::story_metadata::write_review_hold_in_content(&contents); + let updated = crate::db::yaml_legacy::write_review_hold_in_content(&contents); crate::db::write_content(story_id, &updated); // Also persist to SQLite via shadow write. let stage = crate::pipeline_state::read_typed(story_id) diff --git a/server/src/agents/pool/start/spawn.rs b/server/src/agents/pool/start/spawn.rs index b8746824..52ece712 100644 --- a/server/src/agents/pool/start/spawn.rs +++ b/server/src/agents/pool/start/spawn.rs @@ -230,7 +230,7 @@ pub(super) async fn run_agent_spawn( // content and prepend it to the system prompt so the agent treats it as // authoritative context. if let Some(story_content) = crate::db::read_content(&sid) - && let Ok(meta) = crate::io::story_metadata::parse_front_matter(&story_content) + && let Ok(meta) = crate::db::yaml_legacy::parse_front_matter(&story_content) && let Some(ref epic_id) = meta.epic && let Some(epic_content) = crate::db::read_content(epic_id) { diff --git a/server/src/agents/pool/start/validation.rs b/server/src/agents/pool/start/validation.rs index 25b866ce..ae5e61f0 100644 --- a/server/src/agents/pool/start/validation.rs +++ b/server/src/agents/pool/start/validation.rs @@ -70,7 +70,7 @@ pub(super) fn read_front_matter_agent(story_id: &str, agent_name: Option<&str>) return Some(agent.clone()); } crate::db::read_content(story_id).and_then(|contents| { - crate::io::story_metadata::parse_front_matter(&contents) + crate::db::yaml_legacy::parse_front_matter(&contents) .ok()? .agent }) diff --git a/server/src/chat/commands/depends.rs b/server/src/chat/commands/depends.rs index 62cd6fd8..97714de5 100644 --- a/server/src/chat/commands/depends.rs +++ b/server/src/chat/commands/depends.rs @@ -7,7 +7,7 @@ //! Passing no dependency numbers clears the field entirely. use super::CommandContext; -use crate::io::story_metadata::parse_front_matter; +use crate::db::yaml_legacy::parse_front_matter; /// Handle the `depends` command. /// diff --git a/server/src/chat/commands/freeze.rs b/server/src/chat/commands/freeze.rs index 49a34792..98ff5232 100644 --- a/server/src/chat/commands/freeze.rs +++ b/server/src/chat/commands/freeze.rs @@ -4,7 +4,7 @@ //! advancement and auto-assign until `unfreeze ` restores the prior stage. use super::CommandContext; -use crate::io::story_metadata::parse_front_matter; +use crate::db::yaml_legacy::parse_front_matter; use std::path::Path; /// Handle the `freeze` command. diff --git a/server/src/chat/commands/move_story.rs b/server/src/chat/commands/move_story.rs index eeedab71..cb3b9b0a 100644 --- a/server/src/chat/commands/move_story.rs +++ b/server/src/chat/commands/move_story.rs @@ -57,7 +57,7 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option { }; let found_name = content - .and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok()) + .and_then(|c| crate::db::yaml_legacy::parse_front_matter(&c).ok()) .and_then(|m| m.name); let display_name = found_name.as_deref().unwrap_or(&story_id); diff --git a/server/src/chat/commands/overview.rs b/server/src/chat/commands/overview.rs index 0fa72f65..5ca676c8 100644 --- a/server/src/chat/commands/overview.rs +++ b/server/src/chat/commands/overview.rs @@ -109,7 +109,7 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option Option { let (_, _, _, content) = crate::chat::lookup::find_story_by_number(root, num_str)?; let content = content?; - crate::io::story_metadata::parse_front_matter(&content) + crate::db::yaml_legacy::parse_front_matter(&content) .ok() .and_then(|m| m.name) } diff --git a/server/src/chat/commands/status/render.rs b/server/src/chat/commands/status/render.rs index fc507c74..173ecf9e 100644 --- a/server/src/chat/commands/status/render.rs +++ b/server/src/chat/commands/status/render.rs @@ -86,7 +86,7 @@ pub(crate) fn build_status_from_items( .filter(|i| matches!(i.stage, Stage::Merge { .. })) .filter_map(|i| { let content = crate::db::read_content(&i.story_id.0)?; - let meta = crate::io::story_metadata::parse_front_matter(&content).ok()?; + let meta = crate::db::yaml_legacy::parse_front_matter(&content).ok()?; let mf = meta.merge_failure?; Some((i.story_id.0.clone(), mf)) }) diff --git a/server/src/chat/commands/triage.rs b/server/src/chat/commands/triage.rs index 91ff0054..5f57fd6b 100644 --- a/server/src/chat/commands/triage.rs +++ b/server/src/chat/commands/triage.rs @@ -69,7 +69,7 @@ fn build_triage_dump( None => return format!("Story {num_str}: content not found in content store."), }; - let meta = crate::io::story_metadata::parse_front_matter(&contents).ok(); + let meta = crate::db::yaml_legacy::parse_front_matter(&contents).ok(); let name = meta .as_ref() .and_then(|m| m.name.as_deref()) diff --git a/server/src/chat/commands/unblock.rs b/server/src/chat/commands/unblock.rs index d43c7516..31d1c255 100644 --- a/server/src/chat/commands/unblock.rs +++ b/server/src/chat/commands/unblock.rs @@ -5,7 +5,7 @@ //! and returns a confirmation. use super::CommandContext; -use crate::io::story_metadata::{clear_front_matter_field_in_content, parse_front_matter}; +use crate::db::yaml_legacy::{clear_front_matter_field_in_content, parse_front_matter}; use std::path::Path; /// Handle the `unblock` command. diff --git a/server/src/chat/commands/unreleased.rs b/server/src/chat/commands/unreleased.rs index 31f199ff..4a24ced7 100644 --- a/server/src/chat/commands/unreleased.rs +++ b/server/src/chat/commands/unreleased.rs @@ -148,7 +148,7 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { if file_num == num_str && let Some(c) = crate::db::read_content(&id) { - return crate::io::story_metadata::parse_front_matter(&c) + return crate::db::yaml_legacy::parse_front_matter(&c) .ok() .and_then(|m| m.name); } @@ -182,7 +182,7 @@ fn find_story_name(root: &std::path::Path, num_str: &str) -> Option { .unwrap_or(""); if file_num == num_str { return std::fs::read_to_string(&path).ok().and_then(|c| { - crate::io::story_metadata::parse_front_matter(&c) + crate::db::yaml_legacy::parse_front_matter(&c) .ok() .and_then(|m| m.name) }); diff --git a/server/src/chat/transport/matrix/assign.rs b/server/src/chat/transport/matrix/assign.rs index 320bdd61..8b0f576b 100644 --- a/server/src/chat/transport/matrix/assign.rs +++ b/server/src/chat/transport/matrix/assign.rs @@ -11,7 +11,7 @@ use crate::agents::{AgentPool, AgentStatus}; use crate::chat::util::strip_bot_mention; -use crate::io::story_metadata::parse_front_matter; +use crate::db::yaml_legacy::parse_front_matter; use std::path::Path; /// A parsed assign command from a Matrix message body. diff --git a/server/src/chat/transport/matrix/delete.rs b/server/src/chat/transport/matrix/delete.rs index 00b5a208..4a78c049 100644 --- a/server/src/chat/transport/matrix/delete.rs +++ b/server/src/chat/transport/matrix/delete.rs @@ -71,7 +71,7 @@ pub async fn handle_delete( let story_name = content .and_then(|contents| { - crate::io::story_metadata::parse_front_matter(&contents) + crate::db::yaml_legacy::parse_front_matter(&contents) .ok() .and_then(|m| m.name) }) diff --git a/server/src/chat/transport/matrix/start.rs b/server/src/chat/transport/matrix/start.rs index 483c6232..85152d74 100644 --- a/server/src/chat/transport/matrix/start.rs +++ b/server/src/chat/transport/matrix/start.rs @@ -90,7 +90,7 @@ pub async fn handle_start( let story_name = content .and_then(|contents| { - crate::io::story_metadata::parse_front_matter(&contents) + crate::db::yaml_legacy::parse_front_matter(&contents) .ok() .and_then(|m| m.name) }) diff --git a/server/src/db/mod.rs b/server/src/db/mod.rs index 2e1a135b..d91faaf3 100644 --- a/server/src/db/mod.rs +++ b/server/src/db/mod.rs @@ -19,6 +19,11 @@ 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 the migration and by callers reading the +/// small set of fields not yet mirrored into the CRDT. +pub(crate) mod yaml_legacy; +/// One-shot migration that strips legacy YAML front-matter from stored content (story 865). +pub mod yaml_migration; 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}; @@ -30,7 +35,7 @@ pub use content_store::ensure_content_store; #[cfg(test)] mod tests { use super::*; - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; use std::fs; /// Helper: write a minimal story .md file with front matter. diff --git a/server/src/db/ops.rs b/server/src/db/ops.rs index fddeb9f4..3f282945 100644 --- a/server/src/db/ops.rs +++ b/server/src/db/ops.rs @@ -7,7 +7,7 @@ use super::content_store::{ all_content_ids, delete_content, ensure_content_store, read_content, write_content, }; use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg}; -use crate::io::story_metadata::parse_front_matter; +use super::yaml_legacy::parse_front_matter; /// Typed metadata for a pipeline item write. /// diff --git a/server/src/db/yaml_legacy.rs b/server/src/db/yaml_legacy.rs new file mode 100644 index 00000000..e44aadd5 --- /dev/null +++ b/server/src/db/yaml_legacy.rs @@ -0,0 +1,254 @@ +//! 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; +use std::fs; +use std::path::Path; + +/// 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, + pub coverage_baseline: Option, + pub merge_failure: Option, + pub agent: Option, + pub review_hold: Option, + pub qa: Option, + pub retry_count: Option, + pub blocked: Option, + pub depends_on: Option>, + pub frozen: Option, + pub resume_to_stage: Option, + pub run_tests_passed: Option, + #[serde(rename = "type")] + pub item_type: Option, + pub mergemaster_attempted: Option, + pub epic: Option, +} + +/// Parsed metadata view returned by [`parse_front_matter`]. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct StoryMetadata { + pub name: Option, + pub coverage_baseline: Option, + pub merge_failure: Option, + pub agent: Option, + pub review_hold: Option, + pub qa: Option, + pub retry_count: Option, + pub blocked: Option, + pub depends_on: Option>, + pub frozen: Option, + pub resume_to_stage: Option, + pub run_tests_passed: Option, + pub item_type: Option, + pub mergemaster_attempted: Option, + pub epic: Option, +} + +/// 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 { + 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 = 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 +} + +/// 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 = 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}\"")) +} + +/// Write `review_hold: true` to story content. +pub(crate) fn write_review_hold_in_content(contents: &str) -> String { + set_front_matter_field(contents, "review_hold", "true") +} + +/// Write `mergemaster_attempted: true` to story content. +pub(crate) fn write_mergemaster_attempted_in_content(contents: &str) -> String { + set_front_matter_field(contents, "mergemaster_attempted", "true") +} + +/// Remove a key from the YAML front matter of a story file on disk. +/// +/// Legacy filesystem-backed wrapper around +/// [`clear_front_matter_field_in_content`] for the small number of callers +/// that still read story files directly. +pub(crate) fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + let updated = clear_front_matter_field_in_content(&contents, key); + if updated != contents { + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + } + Ok(()) +} + +/// Write `review_hold: true` to the YAML front matter of a story file on disk. +/// +/// Legacy filesystem-backed wrapper around [`write_review_hold_in_content`]. +pub(crate) fn write_review_hold(path: &Path) -> Result<(), String> { + let contents = + fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; + let updated = write_review_hold_in_content(&contents); + fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; + Ok(()) +} + +#[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")); + } + + #[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")); + } +} diff --git a/server/src/db/yaml_migration.rs b/server/src/db/yaml_migration.rs new file mode 100644 index 00000000..7a2c2326 --- /dev/null +++ b/server/src/db/yaml_migration.rs @@ -0,0 +1,316 @@ +//! One-shot migration: strip YAML front-matter from stored story content. +//! +//! Story 865 finishes the move to CRDT registers as the single source of truth +//! for pipeline metadata. At server startup we walk every entry in the content +//! store, parse its (possibly-present) YAML block one last time, assert that +//! every YAML field still agrees with the corresponding CRDT register, and +//! rewrite the body without the `---` block. Divergence between YAML and the +//! CRDT is logged as ERROR but does not stop the migration — the YAML is +//! authoritative-of-history; the CRDT is authoritative-of-truth. +//! +//! After this runs once (idempotent — a second run finds no YAML headers), +//! `parse_front_matter` and friends can be deleted. The parsing logic now +//! lives privately in this module so the rest of the codebase can drop the +//! YAML helpers entirely. +use crate::io::story_metadata::QaMode; +use crate::slog; +use serde::Deserialize; + +/// Snapshot of the legacy YAML front-matter fields we still need to read once +/// during the migration. After this run completes, every body in the content +/// store is YAML-free and this struct is unused. +#[derive(Debug, Default, Deserialize)] +struct LegacyFrontMatter { + name: Option, + agent: Option, + retry_count: Option, + blocked: Option, + depends_on: Option>, + qa: Option, + mergemaster_attempted: Option, +} + +/// Parse the YAML front-matter block from a markdown body. +/// +/// Returns `Ok(None)` when the body has no opening `---`. Returns an error +/// message when an opening `---` is present but parsing fails. +fn parse_legacy_front_matter(contents: &str) -> Result, String> { + let mut lines = contents.lines(); + let first = lines.next().unwrap_or_default().trim(); + if first != "---" { + return Ok(None); + } + let mut front_lines = Vec::new(); + for line in &mut lines { + if line.trim() == "---" { + let raw = front_lines.join("\n"); + return serde_yaml::from_str::(&raw) + .map(Some) + .map_err(|e| e.to_string()); + } + front_lines.push(line); + } + Err("missing closing front-matter delimiter".to_string()) +} + +/// Strip the YAML front-matter block (`---\n...\n---\n`) from a markdown body. +/// +/// Returns the body without the leading YAML block and its trailing delimiter, +/// preserving any leading newline that follows the closing `---`. If the input +/// has no opening `---` the body is returned unchanged. +pub(super) fn strip_yaml_block(content: &str) -> String { + let mut lines = content.lines(); + let first = match lines.next() { + Some(l) => l, + None => return String::new(), + }; + if first.trim() != "---" { + return content.to_string(); + } + + let mut consumed = first.len() + 1; // +1 for the newline + for line in &mut lines { + consumed += line.len() + 1; + if line.trim() == "---" { + // Drop a single blank line that often follows the closing fence. + let rest = &content[consumed.min(content.len())..]; + return rest.trim_start_matches('\n').to_string(); + } + } + // No closing fence — leave content alone rather than mangle it. + content.to_string() +} + +/// Run the one-shot YAML-strip migration over every story in the content store. +/// +/// For each story: +/// 1. Read the markdown body from the content store. +/// 2. If it starts with a `---` block, parse the YAML one last time. +/// 3. Compare every CRDT-mirrored field against the YAML value; any +/// mismatch is logged at ERROR level (the migration trusts the CRDT). +/// 4. Rewrite the body in the content store and shadow table without the +/// YAML block. +/// +/// Bodies that already lack a `---` opener are skipped (idempotent re-runs). +pub fn run() { + let ids = super::all_content_ids(); + let mut migrated = 0usize; + let mut diverged = 0usize; + + for story_id in ids { + let Some(content) = super::read_content(&story_id) else { + continue; + }; + if !content.trim_start().starts_with("---") { + continue; + } + + // One last parse — this is the only remaining caller after AC2. + let yaml = match parse_legacy_front_matter(&content) { + Ok(Some(m)) => m, + Ok(None) => continue, + Err(e) => { + slog!( + "[migrate-yaml] {}: parse failed ({}); leaving content untouched", + story_id, + e + ); + continue; + } + }; + + let crdt = crate::crdt_state::read_item(&story_id); + + if let Some(ref view) = crdt { + // Compare every YAML field to its CRDT counterpart and log + // divergence. The CRDT wins; YAML is dropped regardless. + let mut field_diverged = false; + macro_rules! cmp { + ($field:expr, $yaml:expr, $crdt:expr) => { + let yv = $yaml; + let cv = $crdt; + if yv.is_some() && yv != cv { + slog!( + "[migrate-yaml][ERROR] {} field '{}' diverged: yaml={:?} crdt={:?}", + story_id, + $field, + yv, + cv, + ); + field_diverged = true; + } + }; + } + cmp!("name", yaml.name.clone(), view.name.clone()); + cmp!("agent", yaml.agent.clone(), view.agent.clone()); + cmp!( + "retry_count", + yaml.retry_count.map(|n| n as i64), + view.retry_count + ); + cmp!("blocked", yaml.blocked, view.blocked); + cmp!( + "depends_on", + yaml.depends_on.clone(), + view.depends_on.clone() + ); + cmp!( + "qa", + yaml.qa + .as_deref() + .and_then(QaMode::from_str) + .map(|q| q.as_str().to_string()), + view.qa_mode.clone() + ); + cmp!( + "mergemaster_attempted", + yaml.mergemaster_attempted, + view.mergemaster_attempted + ); + if field_diverged { + diverged += 1; + } + } else if yaml.name.is_some() || yaml.agent.is_some() { + // YAML had real fields but the CRDT has no entry. Log and proceed. + slog!( + "[migrate-yaml][ERROR] {} has YAML fields but no CRDT entry — yaml dropped", + story_id + ); + diverged += 1; + } + + let stripped = strip_yaml_block(&content); + if stripped == content { + continue; + } + + // Persist the stripped body. We use `move_item_stage` with a transform + // so the shadow table stays in sync; if the CRDT has no entry we fall + // back to writing the content store directly. + if let Some(view) = crdt { + super::move_item_stage(&story_id, &view.stage, Some(&|_| stripped.clone())); + } else { + super::write_content(&story_id, &stripped); + } + migrated += 1; + } + + if migrated > 0 || diverged > 0 { + slog!( + "[migrate-yaml] stripped YAML from {} stories ({} had divergent CRDT)", + migrated, + diverged + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::db::{ItemMeta, ensure_content_store, read_content, write_item_with_content}; + + #[test] + fn strip_yaml_block_removes_leading_block() { + let input = "---\nname: Test\nagent: coder-1\n---\n# Body\n"; + assert_eq!(strip_yaml_block(input), "# Body\n"); + } + + #[test] + fn strip_yaml_block_preserves_body_without_yaml() { + let input = "# Just a body\n\nNo YAML here.\n"; + assert_eq!(strip_yaml_block(input), input); + } + + #[test] + fn strip_yaml_block_leaves_content_alone_when_unclosed() { + // No closing --- — we don't want to mangle the body. + let input = "---\nname: Test\n# Story\n"; + assert_eq!(strip_yaml_block(input), input); + } + + #[test] + fn strip_yaml_block_drops_only_one_blank_line_after_fence() { + let input = "---\nname: Test\n---\n\n# Body\n"; + // The closing `---\n` is dropped plus one blank line; the actual body starts at `# Body`. + assert_eq!(strip_yaml_block(input), "# Body\n"); + } + + #[test] + fn run_migration_strips_yaml_when_crdt_matches() { + crate::crdt_state::init_for_test(); + ensure_content_store(); + let story_id = "9001_story_strip_match"; + + let body = "---\nname: Match Story\nagent: coder-1\n---\n# Body\n"; + write_item_with_content( + story_id, + "2_current", + body, + ItemMeta { + name: Some("Match Story".into()), + agent: Some("coder-1".into()), + ..ItemMeta::default() + }, + ); + + run(); + + let after = read_content(story_id).expect("content present"); + assert!( + !after.trim_start().starts_with("---"), + "YAML block should be stripped: {after:?}" + ); + assert!(after.contains("# Body")); + } + + #[test] + fn run_migration_logs_divergence_but_still_strips() { + crate::crdt_state::init_for_test(); + ensure_content_store(); + let story_id = "9002_story_strip_diverge"; + + // YAML says name = "OLD"; CRDT says name = "NEW". + let body = "---\nname: OLD\n---\n# Body\n"; + write_item_with_content( + story_id, + "2_current", + body, + ItemMeta { + name: Some("NEW".into()), + ..ItemMeta::default() + }, + ); + + run(); + + let after = read_content(story_id).expect("content present"); + assert!(!after.contains("---")); + // CRDT wins — name is still "NEW". + let view = crate::crdt_state::read_item(story_id).expect("crdt item"); + assert_eq!(view.name.as_deref(), Some("NEW")); + } + + #[test] + fn run_migration_is_idempotent_on_already_stripped_content() { + crate::crdt_state::init_for_test(); + ensure_content_store(); + let story_id = "9003_story_strip_idempotent"; + + let body = "# Already-stripped body\n"; + write_item_with_content( + story_id, + "2_current", + body, + ItemMeta { + name: Some("Already".into()), + ..ItemMeta::default() + }, + ); + + run(); + run(); + + let after = read_content(story_id).expect("content present"); + assert_eq!(after, body); + } +} diff --git a/server/src/http/mcp/qa_tools.rs b/server/src/http/mcp/qa_tools.rs index aefd197c..e69cd709 100644 --- a/server/src/http/mcp/qa_tools.rs +++ b/server/src/http/mcp/qa_tools.rs @@ -59,7 +59,7 @@ pub(super) async fn tool_approve_qa(args: &Value, ctx: &AppContext) -> Result Result Result String { match value { @@ -114,7 +114,7 @@ pub fn update_story_in_file( #[cfg(test)] mod tests { use super::*; - use crate::io::story_metadata::parse_front_matter; + use crate::db::yaml_legacy::parse_front_matter; use std::fs; #[allow(dead_code)] diff --git a/server/src/http/workflow/test_results.rs b/server/src/http/workflow/test_results.rs index b96c3307..2c874a7f 100644 --- a/server/src/http/workflow/test_results.rs +++ b/server/src/http/workflow/test_results.rs @@ -1,5 +1,5 @@ //! Test result persistence — writes structured test results into story markdown files. -use crate::io::story_metadata::set_front_matter_field; +use crate::db::yaml_legacy::set_front_matter_field; use crate::workflow::{StoryTestResults, TestCaseResult, TestStatus}; use std::path::Path; diff --git a/server/src/io/story_metadata/deps.rs b/server/src/io/story_metadata/deps.rs index 4a0549b7..95d2f920 100644 --- a/server/src/io/story_metadata/deps.rs +++ b/server/src/io/story_metadata/deps.rs @@ -1,18 +1,15 @@ -//! Dependency resolution: check whether story dependencies are satisfied. +//! Dependency resolution helpers — filesystem-backed lookups that don't +//! require any in-memory CRDT state. +//! +//! The CRDT-backed equivalents (`check_unmet_deps_crdt`, +//! `check_archived_deps_crdt`) live in `crate::crdt_state::read`; callers +//! that already have a CRDT entry should prefer those. This module exists +//! for the story-creation path, where dependency IDs are known in memory +//! before any CRDT entry has been written. use std::fs; use std::path::Path; -use super::parser::parse_front_matter; - /// Return `true` if a story with the given numeric ID exists in `5_done` or `6_archived`. -/// -/// **Dependency semantics:** Both `5_done` and `6_archived` satisfy a `depends_on` entry. -/// Stories auto-sweep from `5_done` to `6_archived` after 4 hours, so by the time a dep -/// reaches `6_archived`, the dependent story has already been promoted. When a dep is -/// already in `6_archived` at the moment of promotion (e.g., it was manually archived or -/// abandoned before the dependent story was created), the dependency is still considered -/// satisfied — but a warning is logged so the user can see that the dep was archived, not -/// cleanly completed. Use `check_archived_deps` to detect this case. fn dep_is_done(project_root: &Path, dep_number: u32) -> bool { let prefix = format!("{dep_number}_"); let exact = dep_number.to_string(); @@ -35,8 +32,7 @@ fn dep_is_done(project_root: &Path, dep_number: u32) -> bool { false } -/// Return `true` if a story with the given numeric ID exists specifically in `6_archived` -/// (i.e., it satisfies a `depends_on` but via the archive rather than via a clean done). +/// Return `true` if a story with the given numeric ID exists specifically in `6_archived`. fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool { let prefix = format!("{dep_number}_"); let exact = dep_number.to_string(); @@ -60,11 +56,37 @@ fn dep_is_archived(project_root: &Path, dep_number: u32) -> bool { false } -/// Return the list of dependency story numbers from `story_id`'s front matter -/// that have **not** yet reached `5_done` or `6_archived`. +/// Given an explicit list of dep numbers, return those that have NOT reached +/// `5_done` or `6_archived`. /// -/// Returns an empty `Vec` when there are no unmet dependencies (including when -/// the story has no `depends_on` field at all). +/// Used by callers that have the dep list in memory (e.g. story update at +/// promotion time) and want a filesystem fact rather than an in-memory CRDT +/// state which may be stale during transitions. +pub fn check_unmet_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec { + deps.iter() + .copied() + .filter(|&dep| !dep_is_done(project_root, dep)) + .collect() +} + +/// Given an explicit list of dep numbers, return those already in `6_archived`. +/// +/// Used at story-creation time when the dep list is known in memory (before +/// the story file has been written), so the caller does not need to parse +/// the story. +pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec { + deps.iter() + .copied() + .filter(|&dep| dep_is_archived(project_root, dep)) + .collect() +} + +/// Filesystem-backed unmet-dep check for a story file in `/`. +/// +/// Reads the story's `depends_on` list from its YAML front matter and returns +/// the numeric deps still pending (not yet in `5_done` or `6_archived`). This +/// is the legacy API used by the auto-assigner when the CRDT layer is not yet +/// initialised; CRDT-aware callers should prefer `check_unmet_deps_crdt`. pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { let path = project_root .join(".huskies") @@ -75,23 +97,20 @@ pub fn check_unmet_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Ok(c) => c, Err(_) => return Vec::new(), }; - let deps = match parse_front_matter(&contents) + let deps = match crate::db::yaml_legacy::parse_front_matter(&contents) .ok() .and_then(|m| m.depends_on) { Some(d) => d, None => return Vec::new(), }; - deps.into_iter() - .filter(|&dep| !dep_is_done(project_root, dep)) - .collect() + check_unmet_deps_from_list(project_root, &deps) } -/// Return the list of dependency story numbers from `story_id`'s front matter -/// that are in `6_archived` (satisfied via archive rather than via normal done). +/// Filesystem-backed archived-dep check for a story file in `/`. /// -/// Used to emit a warning when backlog promotion fires because a dep was archived -/// rather than cleanly completed. Returns an empty `Vec` when no deps are archived. +/// Reads the story's `depends_on` list from its YAML front matter and returns +/// the numeric deps that satisfied via `6_archived` rather than `5_done`. pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) -> Vec { let path = project_root .join(".huskies") @@ -102,141 +121,40 @@ pub fn check_archived_deps(project_root: &Path, stage_dir: &str, story_id: &str) Ok(c) => c, Err(_) => return Vec::new(), }; - let deps = match parse_front_matter(&contents) + let deps = match crate::db::yaml_legacy::parse_front_matter(&contents) .ok() .and_then(|m| m.depends_on) { Some(d) => d, None => return Vec::new(), }; - deps.into_iter() - .filter(|&dep| dep_is_archived(project_root, dep)) - .collect() -} - -/// Given an explicit list of dep numbers, return those already in `6_archived`. -/// -/// Used at story-creation time when the dep list is known in memory (before the -/// story file has been written), so the caller does not need to parse the story. -pub fn check_archived_deps_from_list(project_root: &Path, deps: &[u32]) -> Vec { - deps.iter() - .copied() - .filter(|&dep| dep_is_archived(project_root, dep)) - .collect() + check_archived_deps_from_list(project_root, &deps) } #[cfg(test)] mod tests { use super::*; - #[test] - fn check_unmet_deps_returns_empty_when_no_deps() { - let tmp = tempfile::tempdir().unwrap(); - let stage = tmp.path().join(".huskies/work/2_current"); - std::fs::create_dir_all(&stage).unwrap(); - std::fs::write(stage.join("10_story_foo.md"), "---\nname: Foo\n---\n").unwrap(); - let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); - assert!(unmet.is_empty()); - } - - #[test] - fn check_unmet_deps_returns_unmet_numbers() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::create_dir_all(&done).unwrap(); - // Dep 477 is done, dep 478 is not. - std::fs::write(done.join("477_story_dep.md"), "---\nname: Dep\n---\n").unwrap(); - std::fs::write( - current.join("10_story_foo.md"), - "---\nname: Foo\ndepends_on: [477, 478]\n---\n", - ) - .unwrap(); - let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); - assert_eq!(unmet, vec![478]); - } - - #[test] - fn check_unmet_deps_returns_empty_when_all_deps_done() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::create_dir_all(&done).unwrap(); - std::fs::write(done.join("477_story_a.md"), "---\nname: A\n---\n").unwrap(); - std::fs::write(done.join("478_story_b.md"), "---\nname: B\n---\n").unwrap(); - std::fs::write( - current.join("10_story_foo.md"), - "---\nname: Foo\ndepends_on: [477, 478]\n---\n", - ) - .unwrap(); - let unmet = check_unmet_deps(tmp.path(), "2_current", "10_story_foo"); - assert!(unmet.is_empty()); - } - #[test] fn dep_is_done_finds_story_in_archived() { let tmp = tempfile::tempdir().unwrap(); let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&archived).unwrap(); - std::fs::write(archived.join("100_story_old.md"), "---\nname: Old\n---\n").unwrap(); + std::fs::write(archived.join("100_story_old.md"), "# old\n").unwrap(); assert!(dep_is_done(tmp.path(), 100)); assert!(!dep_is_done(tmp.path(), 101)); } - // ── Bug 503: archived-dep visibility ───────────────────────────────────── - - /// check_archived_deps returns the dep IDs that are in 6_archived. #[test] - fn check_archived_deps_returns_archived_dep_numbers() { + fn check_unmet_deps_from_list_returns_unmet_numbers() { let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - let archived = tmp.path().join(".huskies/work/6_archived"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::create_dir_all(&archived).unwrap(); - // Dep 100 is in 6_archived; dep 101 is not anywhere. - std::fs::write(archived.join("100_spike_old.md"), "---\nname: Old\n---\n").unwrap(); - std::fs::write( - current.join("5_story_dependent.md"), - "---\nname: Dep\ndepends_on: [100, 101]\n---\n", - ) - .unwrap(); - let archived_deps = check_archived_deps(tmp.path(), "2_current", "5_story_dependent"); - assert_eq!(archived_deps, vec![100]); - } - - /// check_archived_deps returns empty when no deps are in 6_archived. - #[test] - fn check_archived_deps_returns_empty_when_dep_in_done() { - let tmp = tempfile::tempdir().unwrap(); - let backlog = tmp.path().join(".huskies/work/1_backlog"); let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(&backlog).unwrap(); std::fs::create_dir_all(&done).unwrap(); - // Dep 200 is in 5_done (not archived). - std::fs::write(done.join("200_story_done.md"), "---\nname: Done\n---\n").unwrap(); - std::fs::write( - backlog.join("5_story_waiting.md"), - "---\nname: Waiting\ndepends_on: [200]\n---\n", - ) - .unwrap(); - let archived_deps = check_archived_deps(tmp.path(), "1_backlog", "5_story_waiting"); - assert!(archived_deps.is_empty()); + std::fs::write(done.join("477_story_dep.md"), "# dep\n").unwrap(); + let unmet = check_unmet_deps_from_list(tmp.path(), &[477, 478]); + assert_eq!(unmet, vec![478]); } - /// check_archived_deps returns empty when story has no depends_on. - #[test] - fn check_archived_deps_returns_empty_when_no_deps() { - let tmp = tempfile::tempdir().unwrap(); - let current = tmp.path().join(".huskies/work/2_current"); - std::fs::create_dir_all(¤t).unwrap(); - std::fs::write(current.join("3_story_free.md"), "---\nname: Free\n---\n").unwrap(); - let archived_deps = check_archived_deps(tmp.path(), "2_current", "3_story_free"); - assert!(archived_deps.is_empty()); - } - - /// check_archived_deps_from_list returns archived dep IDs from an in-memory list. #[test] fn check_archived_deps_from_list_returns_archived_ids() { let tmp = tempfile::tempdir().unwrap(); @@ -244,25 +162,12 @@ mod tests { let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&done).unwrap(); std::fs::create_dir_all(&archived).unwrap(); - std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); - std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); - // Only 20 is archived; 10 is in done, 30 is nowhere. + std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap(); + std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap(); let result = check_archived_deps_from_list(tmp.path(), &[10, 20, 30]); assert_eq!(result, vec![20]); } - /// check_archived_deps_from_list returns empty when no deps are archived. - #[test] - fn check_archived_deps_from_list_empty_when_no_archived_deps() { - let tmp = tempfile::tempdir().unwrap(); - let done = tmp.path().join(".huskies/work/5_done"); - std::fs::create_dir_all(&done).unwrap(); - std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); - let result = check_archived_deps_from_list(tmp.path(), &[10]); - assert!(result.is_empty()); - } - - /// dep_is_archived returns true only for stories in 6_archived, not 5_done. #[test] fn dep_is_archived_distinguishes_done_from_archived() { let tmp = tempfile::tempdir().unwrap(); @@ -270,13 +175,10 @@ mod tests { let archived = tmp.path().join(".huskies/work/6_archived"); std::fs::create_dir_all(&done).unwrap(); std::fs::create_dir_all(&archived).unwrap(); - std::fs::write(done.join("10_story_done.md"), "---\nname: Done\n---\n").unwrap(); - std::fs::write(archived.join("20_story_old.md"), "---\nname: Old\n---\n").unwrap(); - // 10 is in 5_done only — not archived. + std::fs::write(done.join("10_story_done.md"), "# done\n").unwrap(); + std::fs::write(archived.join("20_story_old.md"), "# old\n").unwrap(); assert!(!dep_is_archived(tmp.path(), 10)); - // 20 is in 6_archived — archived. assert!(dep_is_archived(tmp.path(), 20)); - // 99 doesn't exist anywhere. assert!(!dep_is_archived(tmp.path(), 99)); } } diff --git a/server/src/io/story_metadata/fields.rs b/server/src/io/story_metadata/fields.rs deleted file mode 100644 index 139f3752..00000000 --- a/server/src/io/story_metadata/fields.rs +++ /dev/null @@ -1,212 +0,0 @@ -//! Front-matter field manipulation: insert, update, remove, and write helpers. -use std::fs; -use std::path::Path; - -/// Insert or update a key: value pair in the YAML front matter of a markdown string. -/// -/// If no front matter (opening `---`) is found, returns the content unchanged. -pub fn set_front_matter_field(contents: &str, key: &str, value: &str) -> String { - let mut lines: Vec = contents.lines().map(String::from).collect(); - if lines.is_empty() || lines[0].trim() != "---" { - return contents.to_string(); - } - - // Find closing --- (search from index 1) - 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 -} - -/// Remove a key: value line from the YAML front matter of a markdown string. -/// -/// If no front matter (opening `---`) is found or the key is absent, returns content unchanged. -pub(super) fn remove_front_matter_field(contents: &str, key: &str) -> String { - let mut lines: Vec = 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 -} - -/// Remove a key from the YAML front matter of a story file on disk. -/// -/// If front matter is present and contains the key, the line is removed. -/// If no front matter or key is not found, the file is left unchanged. -pub fn clear_front_matter_field(path: &Path, key: &str) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - let updated = remove_front_matter_field(&contents, key); - if updated != contents { - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - } - Ok(()) -} - -/// Write `review_hold: true` to the YAML front matter of a story file. -/// -/// Used to mark spikes that have passed QA and are waiting for human review. -pub fn write_review_hold(path: &Path) -> Result<(), String> { - let contents = - fs::read_to_string(path).map_err(|e| format!("Failed to read story file: {e}"))?; - let updated = set_front_matter_field(&contents, "review_hold", "true"); - fs::write(path, &updated).map_err(|e| format!("Failed to write story file: {e}"))?; - Ok(()) -} - -/// Remove a key from the YAML front matter of a markdown string (pure function). -/// -/// Returns the updated content. If no front matter or key is not found, -/// returns the original content unchanged. -pub fn clear_front_matter_field_in_content(contents: &str, key: &str) -> String { - remove_front_matter_field(contents, key) -} - -/// Append rejection notes to a markdown string (pure function). -/// -/// Returns the updated content with a `## QA Rejection Notes` section appended. -pub fn write_rejection_notes_to_content(contents: &str, notes: &str) -> String { - let section = format!("\n\n## QA Rejection Notes\n\n{notes}\n"); - format!("{contents}{section}") -} - -/// Write or update `merge_failure` in story content (pure function). -pub fn write_merge_failure_in_content(contents: &str, reason: &str) -> String { - let escaped = reason - .replace('"', "\\\"") - .replace('\n', " ") - .replace('\r', ""); - let yaml_value = format!("\"{escaped}\""); - set_front_matter_field(contents, "merge_failure", &yaml_value) -} - -/// Write `review_hold: true` to story content (pure function). -pub fn write_review_hold_in_content(contents: &str) -> String { - set_front_matter_field(contents, "review_hold", "true") -} - -/// Write `mergemaster_attempted: true` to story content (pure function). -/// -/// Used by the auto-assigner to record that a mergemaster session has been -/// spawned for a content-conflict failure, preventing repeated auto-spawns. -pub fn write_mergemaster_attempted_in_content(contents: &str) -> String { - set_front_matter_field(contents, "mergemaster_attempted", "true") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn set_front_matter_field_inserts_new_key() { - let input = "---\nname: My Story\n---\n# Body\n"; - let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); - assert!(output.contains("coverage_baseline: 55.0%")); - assert!(output.contains("name: My Story")); - assert!(output.ends_with('\n')); - } - - #[test] - fn set_front_matter_field_updates_existing_key() { - let input = "---\nname: My Story\ncoverage_baseline: 40.0%\n---\n# Body\n"; - let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); - assert!(output.contains("coverage_baseline: 55.0%")); - assert!(!output.contains("40.0%")); - } - - #[test] - fn set_front_matter_field_no_op_without_front_matter() { - let input = "# No front matter\n"; - let output = set_front_matter_field(input, "coverage_baseline", "55.0%"); - assert_eq!(output, input); - } - - #[test] - fn remove_front_matter_field_removes_key() { - let input = "---\nname: My Story\nmerge_failure: \"something broke\"\n---\n# Body\n"; - let output = remove_front_matter_field(input, "merge_failure"); - assert!(!output.contains("merge_failure")); - assert!(output.contains("name: My Story")); - assert!(output.ends_with('\n')); - } - - #[test] - fn remove_front_matter_field_no_op_when_absent() { - let input = "---\nname: My Story\n---\n# Body\n"; - let output = remove_front_matter_field(input, "merge_failure"); - assert_eq!(output, input); - } - - #[test] - fn remove_front_matter_field_no_op_without_front_matter() { - let input = "# No front matter\n"; - let output = remove_front_matter_field(input, "merge_failure"); - assert_eq!(output, input); - } - - #[test] - fn clear_front_matter_field_updates_file() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("story.md"); - std::fs::write( - &path, - "---\nname: Test\nmerge_failure: \"bad\"\n---\n# Story\n", - ) - .unwrap(); - clear_front_matter_field(&path, "merge_failure").unwrap(); - let contents = std::fs::read_to_string(&path).unwrap(); - assert!(!contents.contains("merge_failure")); - assert!(contents.contains("name: Test")); - } - - #[test] - fn write_review_hold_sets_field() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("spike.md"); - std::fs::write(&path, "---\nname: My Spike\n---\n# Spike\n").unwrap(); - write_review_hold(&path).unwrap(); - let contents = std::fs::read_to_string(&path).unwrap(); - assert!(contents.contains("review_hold: true")); - assert!(contents.contains("name: My Spike")); - } -} diff --git a/server/src/io/story_metadata/mod.rs b/server/src/io/story_metadata/mod.rs index e07e0cab..8243f31e 100644 --- a/server/src/io/story_metadata/mod.rs +++ b/server/src/io/story_metadata/mod.rs @@ -1,24 +1,17 @@ -//! Story metadata — parses and modifies YAML front matter in story markdown files. +//! Story metadata helpers — CRDT-backed lookups plus pure-content parsers. //! -//! Submodules: -//! - `types` — core data types (`QaMode`, `StoryMetadata`, `StoryMetaError`) — types used internally by the other submodules -//! - `parser` — YAML front-matter parsing and QA-mode resolution -//! - `fields` — front-matter field insertion, update, and removal helpers -//! - `deps` — dependency satisfaction checks (`check_unmet_deps`, etc.) +//! Story 865 stripped YAML front matter from the content store; this module +//! no longer parses or writes YAML. What remains: +//! - `types` — `QaMode` enum. +//! - `parser` — `parse_unchecked_todos`, `resolve_qa_mode`, `is_story_frozen_in_store`. +//! - `deps` — dependency satisfaction checks (CRDT-backed). mod deps; -mod fields; mod parser; mod types; pub use deps::{check_archived_deps, check_archived_deps_from_list, check_unmet_deps}; -pub use fields::{ - clear_front_matter_field, clear_front_matter_field_in_content, set_front_matter_field, - write_merge_failure_in_content, write_mergemaster_attempted_in_content, - write_rejection_notes_to_content, write_review_hold, write_review_hold_in_content, -}; pub use parser::{ - is_story_frozen_in_store, parse_front_matter, parse_unchecked_todos, resolve_qa_mode, - resolve_qa_mode_from_content, + is_story_frozen_in_store, parse_unchecked_todos, resolve_qa_mode, resolve_qa_mode_from_content, }; pub use types::QaMode; diff --git a/server/src/io/story_metadata/parser.rs b/server/src/io/story_metadata/parser.rs index 8fa5e55c..3001da10 100644 --- a/server/src/io/story_metadata/parser.rs +++ b/server/src/io/story_metadata/parser.rs @@ -1,90 +1,9 @@ -//! Parsing logic for story YAML front matter and todo checkboxes. -use serde::Deserialize; -use std::fs; -use std::path::Path; - -use super::types::{QaMode, StoryMetaError, StoryMetadata}; - -#[derive(Debug, Deserialize)] -pub(super) struct FrontMatter { - pub name: Option, - pub coverage_baseline: Option, - pub merge_failure: Option, - pub agent: Option, - pub review_hold: Option, - /// Configurable QA mode field: "human", "server", or "agent". - pub qa: Option, - /// Number of times this story has been retried at its current pipeline stage. - pub retry_count: Option, - /// When `true`, auto-assign will skip this story (retry limit exceeded). - pub blocked: Option, - /// Story numbers this story depends on. - pub depends_on: Option>, - /// When `true`, the story is frozen. - pub frozen: Option, - /// Stage directory to restore on unfreeze (e.g. `"2_current"`). - pub resume_to_stage: Option, - /// Set to `true` when an agent's `run_tests` call returns `passed=true`. - /// Used by the bug-645 salvage path to distinguish a genuine test-passing - /// session from one that merely compiled. - pub run_tests_passed: Option, - /// Item type: "story", "bug", "spike", or "refactor". - #[serde(rename = "type")] - pub item_type: Option, - /// Set to `true` when the auto-assigner has already spawned a mergemaster - /// session for a content-conflict failure. - pub mergemaster_attempted: Option, - /// Epic this item belongs to (numeric ID as string, e.g. "880"). - pub epic: Option, -} - -/// Parse the YAML front matter block from a story markdown string. -pub fn parse_front_matter(contents: &str) -> Result { - 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 { - let trimmed = line.trim(); - if trimmed == "---" { - let raw = front_lines.join("\n"); - let front: FrontMatter = serde_yaml::from_str(&raw) - .map_err(|e| StoryMetaError::InvalidFrontMatter(e.to_string()))?; - return Ok(build_metadata(front)); - } - front_lines.push(line); - } - - Err(StoryMetaError::InvalidFrontMatter( - "Missing closing front matter delimiter".to_string(), - )) -} - -fn build_metadata(front: FrontMatter) -> StoryMetadata { - let qa = front.qa.as_deref().and_then(QaMode::from_str); - - StoryMetadata { - name: front.name, - coverage_baseline: front.coverage_baseline, - merge_failure: front.merge_failure, - agent: front.agent, - review_hold: front.review_hold, - qa, - 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, - } -} +//! Pure-content helpers and CRDT-backed metadata lookups. +//! +//! Story 865 stripped YAML front matter from stored content and the codebase +//! at large; the only remaining functions here read the CRDT or operate on +//! the markdown body directly. +use super::types::QaMode; /// Parse unchecked todo items (`- [ ] ...`) from a markdown string. pub fn parse_unchecked_todos(contents: &str) -> Vec { @@ -97,46 +16,32 @@ pub fn parse_unchecked_todos(contents: &str) -> Vec { .collect() } -/// Resolve the effective QA mode for a story file. +/// Resolve the effective QA mode for a story by ID via the CRDT. /// -/// Reads the `qa` front matter field. If absent, falls back to `default`. -/// Spikes are **not** handled here — the caller is responsible for overriding -/// to `Human` for spikes. -pub fn resolve_qa_mode(path: &Path, default: QaMode) -> QaMode { - let contents = match fs::read_to_string(path) { - Ok(c) => c, - Err(_) => return default, - }; - match parse_front_matter(&contents) { - Ok(meta) => meta.qa.unwrap_or(default), - Err(_) => default, - } +/// Returns `default` when the story has no entry or its `qa_mode` register is +/// unset. Spikes are **not** handled here — callers override to `Human` for +/// 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) + .as_deref() + .and_then(QaMode::from_str) + .unwrap_or(default) } -/// Resolve the effective QA mode for a story by story ID. -/// -/// Checks the typed `qa_mode` CRDT register first. If the register holds a -/// recognised value (`"server"`, `"agent"`, or `"human"`), returns it. -/// Otherwise falls back to parsing the `qa` YAML front-matter field from -/// `contents`. If neither source provides a value, returns `default`. -pub fn resolve_qa_mode_from_content(story_id: &str, contents: &str, default: QaMode) -> QaMode { - // CRDT register takes precedence over YAML front matter. - if let Some(view) = crate::crdt_state::read_item(story_id) - && let Some(ref s) = view.qa_mode - && let Some(mode) = QaMode::from_str(s) - { - return mode; - } - // Fall back to YAML front matter for backward compatibility. - match parse_front_matter(contents) { - Ok(meta) => meta.qa.unwrap_or(default), - Err(_) => default, - } +/// Resolve the effective QA mode by parsing legacy YAML front matter from a +/// markdown body. Used during one-time fallbacks when the CRDT register isn't +/// set; new code should always read `qa_mode` from the CRDT. +pub fn resolve_qa_mode_from_content(_story_id: &str, content: &str, default: QaMode) -> QaMode { + crate::db::yaml_legacy::parse_front_matter(content) + .ok() + .and_then(|m| m.qa) + .unwrap_or(default) } /// Return `true` if the story is in the `Frozen` pipeline stage. /// -/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance +/// Checks the typed CRDT stage via `read_typed`. Used by the pipeline advance /// code to suppress stage transitions for frozen stories. pub fn is_story_frozen_in_store(story_id: &str) -> bool { crate::pipeline_state::read_typed(story_id) @@ -150,48 +55,6 @@ pub fn is_story_frozen_in_store(story_id: &str) -> bool { mod tests { use super::*; - #[test] - fn parses_front_matter_metadata() { - let input = r#"--- -name: Establish the TDD Workflow and Gates -workflow: tdd ---- -# Story 26 -"#; - - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!( - meta.name.as_deref(), - Some("Establish the TDD Workflow and Gates") - ); - assert_eq!(meta.coverage_baseline, None); - } - - #[test] - fn parses_coverage_baseline_from_front_matter() { - let input = "---\nname: Test Story\ncoverage_baseline: 78.5%\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.coverage_baseline.as_deref(), Some("78.5%")); - } - - #[test] - fn rejects_missing_front_matter() { - let input = "# Story 26\n"; - assert_eq!( - parse_front_matter(input), - Err(StoryMetaError::MissingFrontMatter) - ); - } - - #[test] - fn rejects_unclosed_front_matter() { - let input = "---\nname: Test\n"; - assert!(matches!( - parse_front_matter(input), - Err(StoryMetaError::InvalidFrontMatter(_)) - )); - } - #[test] fn parse_unchecked_todos_mixed() { let input = "## AC\n- [ ] First thing\n- [x] Done thing\n- [ ] Second thing\n"; @@ -220,75 +83,11 @@ workflow: tdd } #[test] - fn parses_review_hold_from_front_matter() { - let input = "---\nname: Spike\nreview_hold: true\n---\n# Spike\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.review_hold, Some(true)); - } - - #[test] - fn review_hold_defaults_to_none() { - let input = "---\nname: Story\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.review_hold, None); - } - - #[test] - fn parses_qa_mode_from_front_matter() { - let input = "---\nname: Story\nqa: server\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.qa, Some(QaMode::Server)); - - let input = "---\nname: Story\nqa: agent\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.qa, Some(QaMode::Agent)); - - let input = "---\nname: Story\nqa: human\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.qa, Some(QaMode::Human)); - } - - #[test] - fn qa_mode_defaults_to_none() { - let input = "---\nname: Story\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.qa, None); - } - - #[test] - fn resolve_qa_mode_uses_file_value() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("story.md"); - std::fs::write(&path, "---\nname: Test\nqa: human\n---\n# Story\n").unwrap(); - assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Human); - } - - #[test] - fn resolve_qa_mode_falls_back_to_default() { - let tmp = tempfile::tempdir().unwrap(); - let path = tmp.path().join("story.md"); - std::fs::write(&path, "---\nname: Test\n---\n# Story\n").unwrap(); - assert_eq!(resolve_qa_mode(&path, QaMode::Server), QaMode::Server); - assert_eq!(resolve_qa_mode(&path, QaMode::Agent), QaMode::Agent); - } - - #[test] - fn resolve_qa_mode_missing_file_uses_default() { - let path = std::path::Path::new("/nonexistent/story.md"); - assert_eq!(resolve_qa_mode(path, QaMode::Server), QaMode::Server); - } - - #[test] - fn parses_depends_on_from_front_matter() { - let input = "---\nname: Story\ndepends_on: [477, 478]\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.depends_on, Some(vec![477, 478])); - } - - #[test] - fn depends_on_defaults_to_none() { - let input = "---\nname: Story\n---\n# Story\n"; - let meta = parse_front_matter(input).expect("front matter"); - assert_eq!(meta.depends_on, None); + fn resolve_qa_mode_falls_back_to_default_when_crdt_empty() { + crate::crdt_state::init_for_test(); + assert_eq!( + resolve_qa_mode("9999_no_such_story", QaMode::Server), + QaMode::Server + ); } } diff --git a/server/src/io/story_metadata/types.rs b/server/src/io/story_metadata/types.rs index 77f5f05f..66115ad4 100644 --- a/server/src/io/story_metadata/types.rs +++ b/server/src/io/story_metadata/types.rs @@ -1,4 +1,4 @@ -//! Core data types for story front-matter metadata. +//! Core data types for story metadata. /// QA mode for a story: determines how the pipeline handles post-coder review. /// @@ -39,58 +39,3 @@ impl std::fmt::Display for QaMode { f.write_str(self.as_str()) } } - -/// Parsed YAML front-matter fields from a story markdown file. -#[derive(Debug, Clone, PartialEq, Eq, Default)] -pub struct StoryMetadata { - pub name: Option, - pub coverage_baseline: Option, - pub merge_failure: Option, - pub agent: Option, - pub review_hold: Option, - pub qa: Option, - /// Number of times this story has been retried at its current pipeline stage. - pub retry_count: Option, - /// When `true`, auto-assign will skip this story (retry limit exceeded). - pub blocked: Option, - /// Story numbers this story depends on. Auto-assign will skip this story - /// until all dependencies have reached `5_done` or `6_archived`. - pub depends_on: Option>, - /// When `true`, the story is frozen: auto-assign skips it, the pipeline - /// does not advance it, and no mergemaster is spawned. - pub frozen: Option, - /// Pipeline stage to restore when unfreezing (e.g. `"2_current"`). - /// Written by `transition_to_frozen`; cleared by `transition_to_unfrozen`. - pub resume_to_stage: Option, - /// Set to `true` when an agent's `run_tests` call returns `passed=true`. - /// Used by the bug-645 salvage path to require real test evidence, not just - /// compilation success. - pub run_tests_passed: Option, - /// Item type: "story", "bug", "spike", or "refactor". - /// - /// Present on items created with numeric-only IDs (no slug suffix). - /// Used by the pipeline to determine routing (e.g. spikes skip QA). - pub item_type: Option, - /// Set to `true` when the auto-assigner has already spawned a mergemaster - /// session for a content-conflict failure. Prevents repeated spawns. - pub mergemaster_attempted: Option, - /// Epic this item belongs to. The value is the epic's numeric ID (e.g. "880"). - /// Set on story/bug/spike/refactor items to declare membership in an epic. - pub epic: Option, -} - -/// Errors that can occur when parsing story front-matter metadata. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum StoryMetaError { - MissingFrontMatter, - InvalidFrontMatter(String), -} - -impl std::fmt::Display for StoryMetaError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - StoryMetaError::MissingFrontMatter => write!(f, "Missing front matter"), - StoryMetaError::InvalidFrontMatter(msg) => write!(f, "Invalid front matter: {msg}"), - } - } -} diff --git a/server/src/pipeline_state/apply.rs b/server/src/pipeline_state/apply.rs index f2d6f40e..2c877433 100644 --- a/server/src/pipeline_state/apply.rs +++ b/server/src/pipeline_state/apply.rs @@ -107,7 +107,7 @@ pub fn transition_to_frozen(story_id: &str) -> Result String { - crate::io::story_metadata::set_front_matter_field(content, "resume_to_stage", &resume_dir) + crate::db::yaml_legacy::set_front_matter_field(content, "resume_to_stage", &resume_dir) }; apply_transition(story_id, PipelineEvent::Freeze, Some(&transform)) } @@ -118,7 +118,7 @@ pub fn transition_to_frozen(story_id: &str) -> Result Result { let transform = |content: &str| -> String { - crate::io::story_metadata::clear_front_matter_field_in_content(content, "resume_to_stage") + crate::db::yaml_legacy::clear_front_matter_field_in_content(content, "resume_to_stage") }; apply_transition(story_id, PipelineEvent::Unfreeze, Some(&transform)) } diff --git a/server/src/pipeline_state/projection.rs b/server/src/pipeline_state/projection.rs index 1f629dac..16d29318 100644 --- a/server/src/pipeline_state/projection.rs +++ b/server/src/pipeline_state/projection.rs @@ -135,7 +135,7 @@ pub fn project_stage(view: &PipelineItemView) -> Result // Fall back to Coding if the field is absent (e.g. legacy frozen items). let resume_to = crate::db::read_content(&view.story_id) .and_then(|content| { - crate::io::story_metadata::parse_front_matter(&content) + crate::db::yaml_legacy::parse_front_matter(&content) .ok() .and_then(|m| m.resume_to_stage) .and_then(|dir| Stage::from_dir(&dir)) diff --git a/server/src/service/agents/mod.rs b/server/src/service/agents/mod.rs index 3a97be69..ec3e990a 100644 --- a/server/src/service/agents/mod.rs +++ b/server/src/service/agents/mod.rs @@ -185,7 +185,7 @@ pub fn get_work_item_content( 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::io::story_metadata::parse_front_matter(&content).ok(); + let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok(); return Ok(WorkItemContent { content, stage: stage_name.to_string(), @@ -215,7 +215,7 @@ pub fn get_work_item_content( }) .unwrap_or("unknown") .to_string(); - let metadata = crate::io::story_metadata::parse_front_matter(&content).ok(); + let metadata = crate::db::yaml_legacy::parse_front_matter(&content).ok(); return Ok(WorkItemContent { content, stage, diff --git a/server/src/service/notifications/io/mod.rs b/server/src/service/notifications/io/mod.rs index 986b6d29..afd33abe 100644 --- a/server/src/service/notifications/io/mod.rs +++ b/server/src/service/notifications/io/mod.rs @@ -4,7 +4,7 @@ //! side effects: reading from the CRDT content store, loading configuration, //! and spawning the background listener task. -use crate::io::story_metadata::parse_front_matter; +use crate::db::yaml_legacy::parse_front_matter; use std::path::Path; mod listener; diff --git a/server/src/startup/project.rs b/server/src/startup/project.rs index 856f36f6..425f7a05 100644 --- a/server/src/startup/project.rs +++ b/server/src/startup/project.rs @@ -157,6 +157,10 @@ pub(crate) async fn init_subsystems(app_state: &Arc, cwd: &Path) { { worktree::migrate_slug_paths(project_root, &id_migrations); } + // Story 865: one-shot strip of legacy YAML front-matter from + // every stored body. Idempotent — bodies without `---` are + // skipped on subsequent runs. + db::yaml_migration::run(); } } }