feat(933): add item_type + epic CRDT registers + migrate epic mechanism
Replaces the YAML-only `type: epic` / `epic: <id>` front-matter fields with typed CRDT registers on PipelineItemCrdt. The epic-mechanism MCP tools (`tool_list_epics`, `tool_show_epic`), the epic-context injection in agent spawn, and the type-classifier helpers (`item_type_from_id`, `is_bug_item`, `is_refactor_item`) now all read from the CRDT. Schema: - PipelineItemCrdt: `item_type: LwwRegisterCrdt<String>` and `epic: LwwRegisterCrdt<String>` registers. - WorkItem: typed `item_type()` and `epic()` accessors returning `Option<&str>`. - crdt_state::set_item_type(story_id, Option<&str>) and crdt_state::set_epic(story_id, Option<&str>) typed setters. Write paths populate the new registers: - create_story_file / create_bug_file / create_spike_file / create_refactor_file / create_epic_file — each calls set_item_type after write_story_content. - tool_update_story intercepts `epic` and `type` fields and routes them to the typed setters (same pattern as qa / depends_on). Read paths migrated off yaml_legacy: - http/mcp/story_tools/epic.rs: tool_list_epics + tool_show_epic. - agents/lifecycle.rs::item_type_from_id (numeric-only IDs). - agents/pool/start/spawn.rs epic-context injection. - http/workflow/bug_ops/bug.rs::is_bug_item, refactor.rs::is_refactor_item. - http/workflow/pipeline.rs::load_pipeline_state — review_hold/qa/epic_id all come from the CRDT now; only merge_failure is still YAML (sweep in 929 stage 10). All `yaml_residue(...)` wraps for item_type / epic are removed; the remaining residue marker doc no longer references 933. cargo fmt --check, clippy --all-targets -- -D warnings, and the 2857-test suite all pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -20,8 +20,8 @@ use crate::slog;
|
|||||||
/// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID.
|
/// Determine the item type ("story", "bug", "spike", or "refactor") from the item ID.
|
||||||
///
|
///
|
||||||
/// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID.
|
/// For slug-format IDs (e.g. `"4_bug_login_crash"`), the type is embedded in the ID.
|
||||||
/// For numeric-only IDs (e.g. `"4"`), the type is read from the `type:` field in
|
/// For numeric-only IDs (e.g. `"4"`), the type is read from the typed CRDT
|
||||||
/// the content-store front matter. Falls back to `"story"` if not found.
|
/// `item_type` register (story 933). Falls back to `"story"` if not found.
|
||||||
pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
|
pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
|
||||||
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
let after_num = item_id.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||||
if after_num.starts_with("_bug_") {
|
if after_num.starts_with("_bug_") {
|
||||||
@@ -31,15 +31,10 @@ pub(crate) fn item_type_from_id(item_id: &str) -> &'static str {
|
|||||||
} else if after_num.starts_with("_refactor_") {
|
} else if after_num.starts_with("_refactor_") {
|
||||||
return "refactor";
|
return "refactor";
|
||||||
}
|
}
|
||||||
// Numeric-only ID: check content store front matter for explicit type.
|
// Numeric-only ID: consult the typed CRDT register.
|
||||||
// `item_type` has no CRDT register yet (story 933 — epic mechanism).
|
|
||||||
// Marked with yaml_residue so the gap is grep-findable.
|
|
||||||
if after_num.is_empty()
|
if after_num.is_empty()
|
||||||
&& let Some(content) = crate::db::read_content(item_id)
|
&& let Some(view) = crate::crdt_state::read_item(item_id)
|
||||||
&& let Ok(meta) = crate::db::yaml_legacy::yaml_residue(
|
&& let Some(t) = view.item_type()
|
||||||
crate::db::yaml_legacy::parse_front_matter(&content),
|
|
||||||
)
|
|
||||||
&& let Some(t) = meta.item_type.as_deref()
|
|
||||||
{
|
{
|
||||||
return match t {
|
return match t {
|
||||||
"bug" => "bug",
|
"bug" => "bug",
|
||||||
@@ -519,30 +514,31 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn item_type_from_id_falls_back_to_content_store_for_numeric_ids() {
|
fn item_type_from_id_uses_crdt_register_for_numeric_ids() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
|
|
||||||
// Write a bug item with numeric-only ID into the content store.
|
// Story 933: numeric-only IDs read item_type from the CRDT register.
|
||||||
let bug_content = "---\ntype: bug\nname: \"Test Bug\"\n---\n\n# Bug 9999: Test Bug\n";
|
for (id, t) in [
|
||||||
crate::db::write_content("9999", bug_content);
|
("9999", "bug"),
|
||||||
|
("9998", "spike"),
|
||||||
let spike_content =
|
("9997", "refactor"),
|
||||||
"---\ntype: spike\nname: \"Test Spike\"\n---\n\n# Spike 9998: Test Spike\n";
|
("9996", "story"),
|
||||||
crate::db::write_content("9998", spike_content);
|
] {
|
||||||
|
crate::db::write_item_with_content(
|
||||||
let refactor_content =
|
id,
|
||||||
"---\ntype: refactor\nname: \"Test Refactor\"\n---\n\n# Refactor 9997: Test Refactor\n";
|
"1_backlog",
|
||||||
crate::db::write_content("9997", refactor_content);
|
&format!("# Test {t}\n"),
|
||||||
|
crate::db::ItemMeta::named(format!("Test {t}")),
|
||||||
let story_content =
|
);
|
||||||
"---\ntype: story\nname: \"Test Story\"\n---\n\n# Story 9996: Test Story\n";
|
crate::crdt_state::set_item_type(id, Some(t));
|
||||||
crate::db::write_content("9996", story_content);
|
}
|
||||||
|
|
||||||
assert_eq!(item_type_from_id("9999"), "bug");
|
assert_eq!(item_type_from_id("9999"), "bug");
|
||||||
assert_eq!(item_type_from_id("9998"), "spike");
|
assert_eq!(item_type_from_id("9998"), "spike");
|
||||||
assert_eq!(item_type_from_id("9997"), "refactor");
|
assert_eq!(item_type_from_id("9997"), "refactor");
|
||||||
assert_eq!(item_type_from_id("9996"), "story");
|
assert_eq!(item_type_from_id("9996"), "story");
|
||||||
// No content store entry → defaults to "story".
|
// No CRDT entry → defaults to "story".
|
||||||
assert_eq!(item_type_from_id("99999"), "story");
|
assert_eq!(item_type_from_id("99999"), "story");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -227,17 +227,9 @@ pub(super) async fn run_agent_spawn(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Prepend epic context when the story belongs to an epic (AC3, story 880).
|
// Prepend epic context when the story belongs to an epic (AC3, story 880).
|
||||||
// Read the story's front matter to find the epic ID, then load the epic's
|
// Story 933: epic linkage is now a typed CRDT register on PipelineItemCrdt.
|
||||||
// content and prepend it to the system prompt so the agent treats it as
|
if let Some(view) = crate::crdt_state::read_item(&sid)
|
||||||
// authoritative context.
|
&& let Some(epic_id) = view.epic()
|
||||||
//
|
|
||||||
// Epic linkage has no CRDT register yet (story 933) — wrap the parse in
|
|
||||||
// `yaml_residue` so the gap is grep-findable.
|
|
||||||
if let Some(story_content) = crate::db::read_content(&sid)
|
|
||||||
&& let Ok(meta) = crate::db::yaml_legacy::yaml_residue(
|
|
||||||
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)
|
&& let Some(epic_content) = crate::db::read_content(epic_id)
|
||||||
{
|
{
|
||||||
let block = format!(
|
let block = format!(
|
||||||
|
|||||||
@@ -53,8 +53,8 @@ pub use types::{
|
|||||||
};
|
};
|
||||||
pub use write::{
|
pub use write::{
|
||||||
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id,
|
bump_retry_count, migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id,
|
||||||
set_agent, set_blocked, set_depends_on, set_mergemaster_attempted, set_qa_mode,
|
set_agent, set_blocked, set_depends_on, set_epic, set_item_type, set_mergemaster_attempted,
|
||||||
set_retry_count, set_review_hold, write_item,
|
set_qa_mode, set_retry_count, set_review_hold, write_item,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -353,6 +353,16 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
|
|||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let item_type = match item.item_type.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() => Some(s),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
|
let epic = match item.epic.view() {
|
||||||
|
JsonValue::String(s) if !s.is_empty() => Some(s),
|
||||||
|
_ => None,
|
||||||
|
};
|
||||||
|
|
||||||
Some(PipelineItemView {
|
Some(PipelineItemView {
|
||||||
story_id,
|
story_id,
|
||||||
stage,
|
stage,
|
||||||
@@ -367,6 +377,8 @@ pub(super) fn extract_item_view(item: &PipelineItemCrdt) -> Option<PipelineItemV
|
|||||||
qa_mode,
|
qa_mode,
|
||||||
mergemaster_attempted,
|
mergemaster_attempted,
|
||||||
review_hold,
|
review_hold,
|
||||||
|
item_type,
|
||||||
|
epic,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -86,6 +86,14 @@ pub struct PipelineItemCrdt {
|
|||||||
/// the 929 CRDT-only migration; replaces the legacy `review_hold: true`
|
/// the 929 CRDT-only migration; replaces the legacy `review_hold: true`
|
||||||
/// YAML front-matter field.
|
/// YAML front-matter field.
|
||||||
pub review_hold: LwwRegisterCrdt<bool>,
|
pub review_hold: LwwRegisterCrdt<bool>,
|
||||||
|
/// Item type: `"story"`, `"bug"`, `"spike"`, `"refactor"`, or `"epic"`.
|
||||||
|
/// Empty string means "infer from story_id slug or default to story".
|
||||||
|
/// Sub-story 933; replaces the legacy `type:` YAML front-matter field.
|
||||||
|
pub item_type: LwwRegisterCrdt<String>,
|
||||||
|
/// Epic ID this item belongs to, or empty string when the item is not a
|
||||||
|
/// member of any epic. Sub-story 933; replaces the legacy `epic:` YAML
|
||||||
|
/// front-matter field that linked member work items to their epic.
|
||||||
|
pub epic: LwwRegisterCrdt<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// CRDT node that holds a single peer's presence entry.
|
/// CRDT node that holds a single peer's presence entry.
|
||||||
@@ -207,6 +215,10 @@ pub struct WorkItem {
|
|||||||
pub(super) mergemaster_attempted: Option<bool>,
|
pub(super) mergemaster_attempted: Option<bool>,
|
||||||
/// Whether the item is held for human review (sub-story 932).
|
/// Whether the item is held for human review (sub-story 932).
|
||||||
pub(super) review_hold: Option<bool>,
|
pub(super) review_hold: Option<bool>,
|
||||||
|
/// Item type (sub-story 933): `"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`.
|
||||||
|
pub(super) item_type: Option<String>,
|
||||||
|
/// Epic ID this item belongs to, or `None` (sub-story 933).
|
||||||
|
pub(super) epic: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WorkItem {
|
impl WorkItem {
|
||||||
@@ -280,6 +292,17 @@ impl WorkItem {
|
|||||||
self.review_hold.unwrap_or(false)
|
self.review_hold.unwrap_or(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Item type (`"story"`, `"bug"`, `"spike"`, `"refactor"`, `"epic"`), or
|
||||||
|
/// `None` when the register is unset.
|
||||||
|
pub fn item_type(&self) -> Option<&str> {
|
||||||
|
self.item_type.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Epic ID this item is a member of, or `None` when unset.
|
||||||
|
pub fn epic(&self) -> Option<&str> {
|
||||||
|
self.epic.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
/// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
|
/// Construct a `WorkItem` for use in tests outside `crdt_state::*`.
|
||||||
///
|
///
|
||||||
/// Within `crdt_state` use a struct literal directly (fields are `pub(super)`).
|
/// Within `crdt_state` use a struct literal directly (fields are `pub(super)`).
|
||||||
@@ -300,6 +323,8 @@ impl WorkItem {
|
|||||||
qa_mode: Option<String>,
|
qa_mode: Option<String>,
|
||||||
mergemaster_attempted: Option<bool>,
|
mergemaster_attempted: Option<bool>,
|
||||||
review_hold: Option<bool>,
|
review_hold: Option<bool>,
|
||||||
|
item_type: Option<String>,
|
||||||
|
epic: Option<String>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
story_id: story_id.into(),
|
story_id: story_id.into(),
|
||||||
@@ -315,6 +340,8 @@ impl WorkItem {
|
|||||||
qa_mode,
|
qa_mode,
|
||||||
mergemaster_attempted,
|
mergemaster_attempted,
|
||||||
review_hold,
|
review_hold,
|
||||||
|
item_type,
|
||||||
|
epic,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,49 @@ pub fn set_depends_on(story_id: &str, deps: &[u32]) -> bool {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the `item_type` CRDT register for a pipeline item (sub-story 933).
|
||||||
|
///
|
||||||
|
/// `Some(t)` writes the type string (e.g. `"story"`, `"epic"`, `"bug"`).
|
||||||
|
/// `None` clears the register to an empty string, which means "use the
|
||||||
|
/// id-prefix heuristic" (see `item_type_from_id`).
|
||||||
|
///
|
||||||
|
/// Returns `true` if the item was found and the op was applied, `false` otherwise.
|
||||||
|
pub fn set_item_type(story_id: &str, item_type: Option<&str>) -> bool {
|
||||||
|
let Some(state_mutex) = get_crdt() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(mut state) = state_mutex.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(&idx) = state.index.get(story_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let value = item_type.unwrap_or("").to_string();
|
||||||
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].item_type.set(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the `epic` CRDT register for a pipeline item (sub-story 933).
|
||||||
|
///
|
||||||
|
/// `Some(epic_id)` links the item to its parent epic.
|
||||||
|
/// `None` clears the register to an empty string (no epic membership).
|
||||||
|
///
|
||||||
|
/// Returns `true` if the item was found and the op was applied, `false` otherwise.
|
||||||
|
pub fn set_epic(story_id: &str, epic_id: Option<&str>) -> bool {
|
||||||
|
let Some(state_mutex) = get_crdt() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Ok(mut state) = state_mutex.lock() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(&idx) = state.index.get(story_id) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let value = epic_id.unwrap_or("").to_string();
|
||||||
|
apply_and_persist(&mut state, |s| s.crdt.doc.items[idx].epic.set(value));
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Set the `review_hold` CRDT flag for a pipeline item (sub-story 932).
|
/// Set the `review_hold` CRDT flag for a pipeline item (sub-story 932).
|
||||||
///
|
///
|
||||||
/// `true` marks the item as held for human review at a pipeline-stage boundary;
|
/// `true` marks the item as held for human review at a pipeline-stage boundary;
|
||||||
@@ -250,6 +293,8 @@ pub fn write_item(
|
|||||||
"qa_mode": "",
|
"qa_mode": "",
|
||||||
"mergemaster_attempted": false,
|
"mergemaster_attempted": false,
|
||||||
"review_hold": false,
|
"review_hold": false,
|
||||||
|
"item_type": "",
|
||||||
|
"epic": "",
|
||||||
})
|
})
|
||||||
.into();
|
.into();
|
||||||
|
|
||||||
@@ -278,6 +323,8 @@ pub fn write_item(
|
|||||||
item.qa_mode.advance_seq(floor);
|
item.qa_mode.advance_seq(floor);
|
||||||
item.mergemaster_attempted.advance_seq(floor);
|
item.mergemaster_attempted.advance_seq(floor);
|
||||||
item.review_hold.advance_seq(floor);
|
item.review_hold.advance_seq(floor);
|
||||||
|
item.item_type.advance_seq(floor);
|
||||||
|
item.epic.advance_seq(floor);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Broadcast a CrdtEvent for the new item.
|
// Broadcast a CrdtEvent for the new item.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ mod migrations;
|
|||||||
mod tests;
|
mod tests;
|
||||||
|
|
||||||
pub use item::{
|
pub use item::{
|
||||||
bump_retry_count, set_agent, set_blocked, set_depends_on, set_mergemaster_attempted,
|
bump_retry_count, set_agent, set_blocked, set_depends_on, set_epic, set_item_type,
|
||||||
set_qa_mode, set_retry_count, set_review_hold, write_item,
|
set_mergemaster_attempted, set_qa_mode, set_retry_count, set_review_hold, write_item,
|
||||||
};
|
};
|
||||||
pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id};
|
pub use migrations::{migrate_names_from_slugs, migrate_story_ids_to_numeric, name_from_story_id};
|
||||||
|
|||||||
@@ -4,10 +4,6 @@
|
|||||||
//! and refactors. They are not pipeline-driven but provide authoritative context
|
//! and refactors. They are not pipeline-driven but provide authoritative context
|
||||||
//! injected into agent prompts for all member work items.
|
//! injected into agent prompts for all member work items.
|
||||||
|
|
||||||
// Epic mechanism (item_type, epic link) has no CRDT register yet — story 933.
|
|
||||||
// parse_front_matter calls here are wrapped in `yaml_residue` so they're
|
|
||||||
// grep-findable until 933 lands.
|
|
||||||
use crate::db::yaml_legacy::{parse_front_matter, yaml_residue};
|
|
||||||
use crate::http::context::AppContext;
|
use crate::http::context::AppContext;
|
||||||
use crate::http::workflow::create_epic_file;
|
use crate::http::workflow::create_epic_file;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -53,7 +49,7 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
|
|||||||
|
|
||||||
let all_items = crate::pipeline_state::read_all_typed();
|
let all_items = crate::pipeline_state::read_all_typed();
|
||||||
|
|
||||||
// Collect epics: items with type == "epic".
|
// Collect epics: items with item_type == "epic" in the CRDT register.
|
||||||
let mut epics: Vec<(String, String)> = Vec::new(); // (id, name)
|
let mut epics: Vec<(String, String)> = Vec::new(); // (id, name)
|
||||||
// Collect member items: map from epic_id → list of (story_id, is_done).
|
// Collect member items: map from epic_id → list of (story_id, is_done).
|
||||||
let mut members: std::collections::HashMap<String, Vec<(String, bool)>> =
|
let mut members: std::collections::HashMap<String, Vec<(String, bool)>> =
|
||||||
@@ -61,23 +57,18 @@ pub(crate) fn tool_list_epics(_ctx: &AppContext) -> Result<String, String> {
|
|||||||
|
|
||||||
for item in &all_items {
|
for item in &all_items {
|
||||||
let sid = &item.story_id.0;
|
let sid = &item.story_id.0;
|
||||||
let content = match crate::db::read_content(sid) {
|
let Some(view) = crate::crdt_state::read_item(sid) else {
|
||||||
Some(c) => c,
|
continue;
|
||||||
None => continue,
|
|
||||||
};
|
|
||||||
let meta = match yaml_residue(parse_front_matter(&content)) {
|
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if meta.item_type.as_deref() == Some("epic") {
|
if view.item_type() == Some("epic") {
|
||||||
epics.push((sid.clone(), item.name.clone()));
|
epics.push((sid.clone(), item.name.clone()));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(epic_id) = meta.epic {
|
if let Some(epic_id) = view.epic() {
|
||||||
let is_done = matches!(item.stage, Stage::Done { .. });
|
let is_done = matches!(item.stage, Stage::Done { .. });
|
||||||
members
|
members
|
||||||
.entry(epic_id)
|
.entry(epic_id.to_string())
|
||||||
.or_default()
|
.or_default()
|
||||||
.push((sid.clone(), is_done));
|
.push((sid.clone(), is_done));
|
||||||
}
|
}
|
||||||
@@ -116,13 +107,13 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
let content = crate::db::read_content(epic_id)
|
let content = crate::db::read_content(epic_id)
|
||||||
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
|
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
|
||||||
|
|
||||||
let meta = yaml_residue(parse_front_matter(&content))
|
let epic_view = crate::crdt_state::read_item(epic_id)
|
||||||
.map_err(|e| format!("Failed to parse epic front matter: {e}"))?;
|
.ok_or_else(|| format!("Epic '{epic_id}' not found in CRDT"))?;
|
||||||
|
|
||||||
if meta.item_type.as_deref() != Some("epic") {
|
if epic_view.item_type() != Some("epic") {
|
||||||
return Err(format!(
|
return Err(format!(
|
||||||
"'{epic_id}' is not an epic (type: {:?})",
|
"'{epic_id}' is not an epic (item_type: {:?})",
|
||||||
meta.item_type
|
epic_view.item_type()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,15 +122,10 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
let mut member_items: Vec<Value> = Vec::new();
|
let mut member_items: Vec<Value> = Vec::new();
|
||||||
for item in &all_items {
|
for item in &all_items {
|
||||||
let sid = &item.story_id.0;
|
let sid = &item.story_id.0;
|
||||||
let member_content = match crate::db::read_content(sid) {
|
let Some(member_view) = crate::crdt_state::read_item(sid) else {
|
||||||
Some(c) => c,
|
continue;
|
||||||
None => continue,
|
|
||||||
};
|
};
|
||||||
let member_meta = match yaml_residue(parse_front_matter(&member_content)) {
|
if member_view.epic() == Some(epic_id) {
|
||||||
Ok(m) => m,
|
|
||||||
Err(_) => continue,
|
|
||||||
};
|
|
||||||
if member_meta.epic.as_deref() == Some(epic_id) {
|
|
||||||
let stage_name = match &item.stage {
|
let stage_name = match &item.stage {
|
||||||
Stage::Upcoming | Stage::Backlog => "backlog",
|
Stage::Upcoming | Stage::Backlog => "backlog",
|
||||||
Stage::Coding => "current",
|
Stage::Coding => "current",
|
||||||
@@ -164,7 +150,7 @@ pub(crate) fn tool_show_epic(args: &Value, _ctx: &AppContext) -> Result<String,
|
|||||||
|
|
||||||
serde_json::to_string_pretty(&json!({
|
serde_json::to_string_pretty(&json!({
|
||||||
"epic_id": epic_id,
|
"epic_id": epic_id,
|
||||||
"name": meta.name,
|
"name": epic_view.name(),
|
||||||
"content": content,
|
"content": content,
|
||||||
"members": member_items,
|
"members": member_items,
|
||||||
"rollup": format!("{done}/{total} done"),
|
"rollup": format!("{done}/{total} done"),
|
||||||
@@ -242,34 +228,36 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn tool_list_epics_shows_member_rollup() {
|
fn tool_list_epics_shows_member_rollup() {
|
||||||
|
crate::crdt_state::init_for_test();
|
||||||
crate::db::ensure_content_store();
|
crate::db::ensure_content_store();
|
||||||
|
|
||||||
// Write a fake epic.
|
// Write a fake epic with the typed CRDT registers (story 933).
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content(
|
||||||
"9990_epic_rollup",
|
"9990_epic_rollup",
|
||||||
"1_backlog",
|
"1_backlog",
|
||||||
"---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n",
|
"# Rollup Epic\n\n## Goal\n\nTest\n",
|
||||||
crate::db::ItemMeta::from_yaml(
|
crate::db::ItemMeta::named("Rollup Epic"),
|
||||||
"---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
crate::crdt_state::set_item_type("9990_epic_rollup", Some("epic"));
|
||||||
|
|
||||||
// Write two member items: one done, one current.
|
// Write two member items: one done, one current.
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content(
|
||||||
"9991_story_member_done",
|
"9991_story_member_done",
|
||||||
"5_done",
|
"5_done",
|
||||||
"---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
"# Done Member\n",
|
||||||
crate::db::ItemMeta::from_yaml(
|
crate::db::ItemMeta::named("Done Member"),
|
||||||
"---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
crate::crdt_state::set_item_type("9991_story_member_done", Some("story"));
|
||||||
|
crate::crdt_state::set_epic("9991_story_member_done", Some("9990_epic_rollup"));
|
||||||
|
|
||||||
crate::db::write_item_with_content(
|
crate::db::write_item_with_content(
|
||||||
"9992_story_member_current",
|
"9992_story_member_current",
|
||||||
"2_current",
|
"2_current",
|
||||||
"---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
"# Current Member\n",
|
||||||
crate::db::ItemMeta::from_yaml(
|
crate::db::ItemMeta::named("Current Member"),
|
||||||
"---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
|
crate::crdt_state::set_item_type("9992_story_member_current", Some("story"));
|
||||||
|
crate::crdt_state::set_epic("9992_story_member_current", Some("9990_epic_rollup"));
|
||||||
|
|
||||||
let tmp = tempfile::tempdir().unwrap();
|
let tmp = tempfile::tempdir().unwrap();
|
||||||
let ctx = crate::http::test_helpers::test_ctx(tmp.path());
|
let ctx = crate::http::test_helpers::test_ctx(tmp.path());
|
||||||
|
|||||||
@@ -40,6 +40,17 @@ pub(crate) fn tool_update_story(args: &Value, ctx: &AppContext) -> Result<String
|
|||||||
crate::crdt_state::set_qa_mode(story_id, mode);
|
crate::crdt_state::set_qa_mode(story_id, mode);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Story 933: intercept `epic` and `type` fields — route to typed CRDT
|
||||||
|
// registers so the auto-assigner / epic-rollup tools see the change.
|
||||||
|
if let Some(epic_val) = front_matter.remove("epic") {
|
||||||
|
let epic_id = epic_val.as_str().filter(|s| !s.is_empty());
|
||||||
|
crate::crdt_state::set_epic(story_id, epic_id);
|
||||||
|
}
|
||||||
|
if let Some(type_val) = front_matter.remove("type") {
|
||||||
|
let item_type = type_val.as_str().filter(|s| !s.is_empty());
|
||||||
|
crate::crdt_state::set_item_type(story_id, item_type);
|
||||||
|
}
|
||||||
|
|
||||||
// Route `depends_on` to the typed CRDT register and remove it from the
|
// Route `depends_on` to the typed CRDT register and remove it from the
|
||||||
// front-matter map so it is NOT written back to the YAML content store.
|
// front-matter map so it is NOT written back to the YAML content store.
|
||||||
// This matches the `qa` field pattern: CRDT is the single source of truth.
|
// This matches the `qa` field pattern: CRDT is the single source of truth.
|
||||||
|
|||||||
@@ -66,23 +66,24 @@ pub fn create_bug_file(
|
|||||||
// Sync depends_on to the typed CRDT register.
|
// Sync depends_on to the typed CRDT register.
|
||||||
crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[]));
|
crate::crdt_state::set_depends_on(&bug_id, depends_on.unwrap_or(&[]));
|
||||||
|
|
||||||
|
// Story 933: typed CRDT register for item_type.
|
||||||
|
crate::crdt_state::set_item_type(&bug_id, Some("bug"));
|
||||||
|
|
||||||
Ok(bug_id)
|
Ok(bug_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the item stem is a bug item.
|
/// Returns true if the item stem is a bug item.
|
||||||
///
|
///
|
||||||
/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then
|
/// Checks the slug-based ID format first (e.g. `"4_bug_login_crash"`), then
|
||||||
/// falls back to reading `type: bug` from the content store for numeric-only IDs.
|
/// consults the typed CRDT `item_type` register for numeric-only IDs (story 933).
|
||||||
pub(super) fn is_bug_item(stem: &str) -> bool {
|
pub(super) fn is_bug_item(stem: &str) -> bool {
|
||||||
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||||
if after_num.starts_with("_bug_") {
|
if after_num.starts_with("_bug_") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Numeric-only ID: check content store front matter.
|
|
||||||
if after_num.is_empty() {
|
if after_num.is_empty() {
|
||||||
return crate::db::read_content(stem)
|
return crate::crdt_state::read_item(stem)
|
||||||
.and_then(|c| parse_front_matter(&c).ok())
|
.and_then(|v| v.item_type().map(str::to_string))
|
||||||
.and_then(|m| m.item_type)
|
|
||||||
.map(|t| t == "bug")
|
.map(|t| t == "bug")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,5 +72,8 @@ pub fn create_epic_file(
|
|||||||
// Epics are stored in backlog (no pipeline advancement).
|
// Epics are stored in backlog (no pipeline advancement).
|
||||||
write_story_content(root, &epic_id, "1_backlog", &content);
|
write_story_content(root, &epic_id, "1_backlog", &content);
|
||||||
|
|
||||||
|
// Story 933: typed CRDT register for item_type.
|
||||||
|
crate::crdt_state::set_item_type(&epic_id, Some("epic"));
|
||||||
|
|
||||||
Ok(epic_id)
|
Ok(epic_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -62,23 +62,24 @@ pub fn create_refactor_file(
|
|||||||
// Sync depends_on to the typed CRDT register.
|
// Sync depends_on to the typed CRDT register.
|
||||||
crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[]));
|
crate::crdt_state::set_depends_on(&refactor_id, depends_on.unwrap_or(&[]));
|
||||||
|
|
||||||
|
// Story 933: typed CRDT register for item_type.
|
||||||
|
crate::crdt_state::set_item_type(&refactor_id, Some("refactor"));
|
||||||
|
|
||||||
Ok(refactor_id)
|
Ok(refactor_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Returns true if the item stem is a refactor item.
|
/// Returns true if the item stem is a refactor item.
|
||||||
///
|
///
|
||||||
/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`), then
|
/// Checks the slug-based ID format first (e.g. `"5_refactor_split_agents_rs"`),
|
||||||
/// falls back to reading `type: refactor` from the content store for numeric-only IDs.
|
/// then consults the typed CRDT `item_type` register for numeric-only IDs (933).
|
||||||
pub(super) fn is_refactor_item(stem: &str) -> bool {
|
pub(super) fn is_refactor_item(stem: &str) -> bool {
|
||||||
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
let after_num = stem.trim_start_matches(|c: char| c.is_ascii_digit());
|
||||||
if after_num.starts_with("_refactor_") {
|
if after_num.starts_with("_refactor_") {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
// Numeric-only ID: check content store front matter.
|
|
||||||
if after_num.is_empty() {
|
if after_num.is_empty() {
|
||||||
return crate::db::read_content(stem)
|
return crate::crdt_state::read_item(stem)
|
||||||
.and_then(|c| parse_front_matter(&c).ok())
|
.and_then(|v| v.item_type().map(str::to_string))
|
||||||
.and_then(|m| m.item_type)
|
|
||||||
.map(|t| t == "refactor")
|
.map(|t| t == "refactor")
|
||||||
.unwrap_or(false);
|
.unwrap_or(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,5 +66,8 @@ pub fn create_spike_file(
|
|||||||
// Sync depends_on to the typed CRDT register.
|
// Sync depends_on to the typed CRDT register.
|
||||||
crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[]));
|
crate::crdt_state::set_depends_on(&spike_id, depends_on.unwrap_or(&[]));
|
||||||
|
|
||||||
|
// Story 933: typed CRDT register for item_type.
|
||||||
|
crate::crdt_state::set_item_type(&spike_id, Some("spike"));
|
||||||
|
|
||||||
Ok(spike_id)
|
Ok(spike_id)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,18 +95,16 @@ pub fn load_pipeline_state(ctx: &AppContext) -> Result<PipelineState, String> {
|
|||||||
let sid = &item.story_id.0;
|
let sid = &item.story_id.0;
|
||||||
let agent = agent_map.get(sid).cloned();
|
let agent = agent_map.get(sid).cloned();
|
||||||
|
|
||||||
// Enrich with content-derived metadata (merge_failure, review_hold, qa, epic_id).
|
// Stories 929/932/933: review_hold, qa_mode, epic_id come from typed
|
||||||
let (merge_failure, review_hold, qa, epic_id) = crate::db::read_content(sid)
|
// CRDT registers. merge_failure remains in YAML for now (tracked by
|
||||||
|
// 929 stage 10's sweep).
|
||||||
|
let view = crate::crdt_state::read_item(sid);
|
||||||
|
let review_hold = view.as_ref().map(|v| v.review_hold()).filter(|b| *b);
|
||||||
|
let qa = view.as_ref().and_then(|v| v.qa_mode().map(str::to_string));
|
||||||
|
let epic_id = view.as_ref().and_then(|v| v.epic().map(str::to_string));
|
||||||
|
let merge_failure = crate::db::read_content(sid)
|
||||||
.and_then(|c| parse_front_matter(&c).ok())
|
.and_then(|c| parse_front_matter(&c).ok())
|
||||||
.map(|meta| {
|
.and_then(|meta| meta.merge_failure);
|
||||||
(
|
|
||||||
meta.merge_failure,
|
|
||||||
meta.review_hold,
|
|
||||||
meta.qa.map(|m| m.as_str().to_string()),
|
|
||||||
meta.epic,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.unwrap_or((None, None, None, None));
|
|
||||||
|
|
||||||
let story = UpcomingStory {
|
let story = UpcomingStory {
|
||||||
story_id: sid.clone(),
|
story_id: sid.clone(),
|
||||||
@@ -211,9 +209,8 @@ pub fn load_upcoming_stories(_ctx: &AppContext) -> Result<Vec<UpcomingStory>, St
|
|||||||
.filter(|item| matches!(item.stage, Stage::Backlog))
|
.filter(|item| matches!(item.stage, Stage::Backlog))
|
||||||
.map(|item| {
|
.map(|item| {
|
||||||
let sid = &item.story_id.0;
|
let sid = &item.story_id.0;
|
||||||
let epic_id = crate::db::read_content(sid)
|
let epic_id =
|
||||||
.and_then(|c| parse_front_matter(&c).ok())
|
crate::crdt_state::read_item(sid).and_then(|v| v.epic().map(str::to_string));
|
||||||
.and_then(|meta| meta.epic);
|
|
||||||
UpcomingStory {
|
UpcomingStory {
|
||||||
story_id: item.story_id.0.clone(),
|
story_id: item.story_id.0.clone(),
|
||||||
name: if item.name.is_empty() {
|
name: if item.name.is_empty() {
|
||||||
|
|||||||
@@ -71,6 +71,9 @@ pub fn create_story_file(
|
|||||||
// Sync depends_on to the typed CRDT register.
|
// Sync depends_on to the typed CRDT register.
|
||||||
crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[]));
|
crate::crdt_state::set_depends_on(&story_id, depends_on.unwrap_or(&[]));
|
||||||
|
|
||||||
|
// Story 933: typed CRDT register for item_type.
|
||||||
|
crate::crdt_state::set_item_type(&story_id, Some("story"));
|
||||||
|
|
||||||
Ok(story_id)
|
Ok(story_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -230,6 +230,8 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,6 +258,8 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
assert_eq!(item.story_id, StoryId("42_story_test".to_string()));
|
||||||
@@ -281,6 +285,8 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(item.stage, Stage::Coding));
|
assert!(matches!(item.stage, Stage::Coding));
|
||||||
@@ -325,6 +331,8 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
@@ -352,6 +360,8 @@ mod tests {
|
|||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
None,
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
let item = PipelineItem::try_from(&view).unwrap();
|
let item = PipelineItem::try_from(&view).unwrap();
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
|
|||||||
Reference in New Issue
Block a user