huskies: merge 864

This commit is contained in:
dave
2026-04-30 22:23:21 +00:00
parent 3911c24c26
commit 61cf7684de
41 changed files with 540 additions and 71 deletions
+60 -1
View File
@@ -21,7 +21,7 @@ pub mod ops;
pub mod shadow_write;
pub use content_store::{all_content_ids, delete_content, read_content, write_content};
pub use ops::{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};
pub use shadow_write::init;
#[cfg(test)]
@@ -320,6 +320,65 @@ mod tests {
);
}
/// Story 864: `write_item_with_content` no longer parses YAML front-matter
/// from `content`. The CRDT metadata reflects ONLY what the caller passes
/// via `ItemMeta`. This test writes a body without any front-matter at
/// all, sets metadata explicitly, and asserts the CRDT picks up the typed
/// values, not anything derived from `content`.
#[test]
fn write_item_typed_meta_takes_precedence_over_content() {
crate::crdt_state::init_for_test();
ensure_content_store();
let story_id = "9864_story_typed_meta";
// Body has NO YAML header — just plain markdown.
let content = "# Just a heading\n\nNo front matter here.\n";
let meta = ItemMeta {
name: Some("Typed Name".into()),
agent: Some("coder-1".into()),
retry_count: Some(2),
blocked: Some(true),
depends_on: Some(vec![100, 200]),
};
write_item_with_content(story_id, "2_current", content, meta);
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
assert_eq!(view.stage, "2_current");
assert_eq!(view.name.as_deref(), Some("Typed Name"));
assert_eq!(view.agent.as_deref(), Some("coder-1"));
assert_eq!(view.retry_count, Some(2));
assert_eq!(view.blocked, Some(true));
assert_eq!(view.depends_on, Some(vec![100, 200]));
// Content is stored verbatim (no parsing, no rewrite).
assert_eq!(read_content(story_id).as_deref(), Some(content));
}
/// Story 864: passing `ItemMeta::default()` against a content blob that
/// LOOKS like front-matter must NOT silently extract metadata into the
/// CRDT. The whole point of removing the implicit YAML round-trip is
/// that metadata only flows in through the typed `ItemMeta` arg.
#[test]
fn write_item_default_meta_ignores_yaml_in_content() {
crate::crdt_state::init_for_test();
ensure_content_store();
let story_id = "9864_story_yaml_ignored";
let content = "---\nname: Should Not Appear\nagent: ghost\n---\n# Body\n";
write_item_with_content(story_id, "2_current", content, ItemMeta::default());
let view = crate::crdt_state::read_item(story_id).expect("story exists in CRDT");
assert_eq!(view.stage, "2_current");
assert_eq!(
view.name, None,
"name must come from typed meta, not parsed YAML"
);
assert_eq!(
view.agent, None,
"agent must come from typed meta, not parsed YAML"
);
}
/// Bug 780: stage transitions must reset retry_count to 0 in the CRDT.
/// Carryover from prior-stage retries was tripping the auto-assigner's
/// deterministic-merge skip logic.
+65 -23
View File
@@ -9,23 +9,65 @@ use super::content_store::{
use super::shadow_write::{PIPELINE_DB, PipelineWriteMsg};
use crate::io::story_metadata::parse_front_matter;
/// Typed metadata for a pipeline item write.
///
/// Replaces the prior YAML-parsing write path (story 864): callers now pass
/// metadata explicitly instead of round-tripping it through a serialized
/// front-matter blob. Every field is `Option`-typed; `None` means
/// "leave unchanged" on update, "use the default" on insert.
#[derive(Default, Clone, Debug)]
pub struct ItemMeta {
pub name: Option<String>,
pub agent: Option<String>,
pub retry_count: Option<i64>,
pub blocked: Option<bool>,
pub depends_on: Option<Vec<u32>>,
}
impl ItemMeta {
/// Convenience constructor for the common "just set a name" case.
#[cfg(test)]
pub fn named(name: impl Into<String>) -> Self {
Self {
name: Some(name.into()),
..Self::default()
}
}
/// Parse YAML front-matter from a content string into typed metadata.
///
/// This is an explicit caller-side conversion — the write path itself
/// no longer parses YAML. Use this when the caller has a raw content
/// string with front-matter and wants the metadata to flow into the
/// CRDT. Returns `Self::default()` if parsing fails or there is no
/// front-matter present.
pub fn from_yaml(content: &str) -> Self {
match parse_front_matter(content) {
Ok(m) => Self {
name: m.name,
agent: m.agent,
retry_count: m.retry_count.map(|r| r as i64),
blocked: m.blocked,
depends_on: m.depends_on,
},
Err(_) => Self::default(),
}
}
}
/// Write a pipeline item from in-memory content (no filesystem access).
///
/// This is the primary write path for the DB-backed pipeline. It updates
/// the CRDT, the in-memory content store, and the SQLite shadow table.
pub fn write_item_with_content(story_id: &str, stage: &str, content: &str) {
let (name, agent, retry_count, blocked, depends_on) = match parse_front_matter(content) {
Ok(meta) => (
meta.name,
meta.agent,
meta.retry_count.map(|r| r as i64),
meta.blocked,
meta.depends_on
.as_ref()
.and_then(|d| serde_json::to_string(d).ok()),
),
Err(_) => (None, None, None, None, None),
};
///
/// The metadata in `meta` is authoritative: this function does NOT parse
/// `content` to extract front-matter fields. Callers must pass typed
/// metadata explicitly via `ItemMeta`.
pub fn write_item_with_content(story_id: &str, stage: &str, content: &str, meta: ItemMeta) {
let depends_on_json = meta
.depends_on
.as_ref()
.and_then(|d| serde_json::to_string(d).ok());
// Update in-memory content store.
ensure_content_store();
@@ -42,11 +84,11 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str) {
crate::crdt_state::write_item(
story_id,
stage,
name.as_deref(),
agent.as_deref(),
retry_count,
blocked,
depends_on.as_deref(),
meta.name.as_deref(),
meta.agent.as_deref(),
meta.retry_count,
meta.blocked,
depends_on_json.as_deref(),
None,
None,
merged_at_ts,
@@ -57,11 +99,11 @@ pub fn write_item_with_content(story_id: &str, stage: &str, content: &str) {
let msg = PipelineWriteMsg {
story_id: story_id.to_string(),
stage: stage.to_string(),
name,
agent,
retry_count,
blocked,
depends_on,
name: meta.name,
agent: meta.agent,
retry_count: meta.retry_count,
blocked: meta.blocked,
depends_on: depends_on_json,
content: Some(content.to_string()),
};
let _ = db.tx.send(msg);