huskies: merge 894
This commit is contained in:
+31
-4
@@ -19,11 +19,9 @@ pub mod content_store;
|
|||||||
pub mod ops;
|
pub mod ops;
|
||||||
/// Background shadow-write task — persists pipeline items to SQLite asynchronously.
|
/// Background shadow-write task — persists pipeline items to SQLite asynchronously.
|
||||||
pub mod shadow_write;
|
pub mod shadow_write;
|
||||||
/// Legacy YAML helpers — used by the migration and by callers reading the
|
/// Legacy YAML helpers — used by callers reading the small set of fields not
|
||||||
/// small set of fields not yet mirrored into the CRDT.
|
/// yet mirrored into the CRDT.
|
||||||
pub(crate) mod yaml_legacy;
|
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 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};
|
pub use ops::{ItemMeta, delete_item, move_item_stage, next_item_number, write_item_with_content};
|
||||||
@@ -384,6 +382,35 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Regression (story 894): startup with no YAML migration call leaves
|
||||||
|
/// existing clean content readable and unmodified. Verifies that removing
|
||||||
|
/// `db::yaml_migration::run()` from the startup path does not break reads.
|
||||||
|
#[test]
|
||||||
|
fn startup_reads_clean_content_unchanged() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
|
ensure_content_store();
|
||||||
|
let story_id = "9894_story_clean_content";
|
||||||
|
|
||||||
|
// Plain body — no YAML block, representing post-migration state.
|
||||||
|
let body = "# Story Heading\n\nSome content.\n";
|
||||||
|
write_item_with_content(
|
||||||
|
story_id,
|
||||||
|
"2_current",
|
||||||
|
body,
|
||||||
|
ItemMeta {
|
||||||
|
name: Some("Clean".into()),
|
||||||
|
..ItemMeta::default()
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let read_back = read_content(story_id).expect("content present");
|
||||||
|
assert_eq!(read_back, body, "plain content must be readable as-is");
|
||||||
|
assert!(
|
||||||
|
!read_back.trim_start().starts_with("---"),
|
||||||
|
"no YAML header should appear"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Bug 780: stage transitions must reset retry_count to 0 in the CRDT.
|
/// Bug 780: stage transitions must reset retry_count to 0 in the CRDT.
|
||||||
/// Carryover from prior-stage retries was tripping the auto-assigner's
|
/// Carryover from prior-stage retries was tripping the auto-assigner's
|
||||||
/// deterministic-merge skip logic.
|
/// deterministic-merge skip logic.
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
//! 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<String>,
|
|
||||||
agent: Option<String>,
|
|
||||||
retry_count: Option<u32>,
|
|
||||||
blocked: Option<bool>,
|
|
||||||
depends_on: Option<Vec<u32>>,
|
|
||||||
qa: Option<String>,
|
|
||||||
mergemaster_attempted: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 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<Option<LegacyFrontMatter>, 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::<LegacyFrontMatter>(&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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -157,10 +157,6 @@ pub(crate) async fn init_subsystems(app_state: &Arc<SessionState>, cwd: &Path) {
|
|||||||
{
|
{
|
||||||
worktree::migrate_slug_paths(project_root, &id_migrations);
|
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();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user