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:
@@ -4,10 +4,6 @@
|
||||
//! and refactors. They are not pipeline-driven but provide authoritative context
|
||||
//! 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::workflow::create_epic_file;
|
||||
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();
|
||||
|
||||
// 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)
|
||||
// Collect member items: map from epic_id → list of (story_id, is_done).
|
||||
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 {
|
||||
let sid = &item.story_id.0;
|
||||
let content = match crate::db::read_content(sid) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
};
|
||||
let meta = match yaml_residue(parse_front_matter(&content)) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
let Some(view) = crate::crdt_state::read_item(sid) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if meta.item_type.as_deref() == Some("epic") {
|
||||
if view.item_type() == Some("epic") {
|
||||
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 { .. });
|
||||
members
|
||||
.entry(epic_id)
|
||||
.entry(epic_id.to_string())
|
||||
.or_default()
|
||||
.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)
|
||||
.ok_or_else(|| format!("Epic '{epic_id}' not found in content store"))?;
|
||||
|
||||
let meta = yaml_residue(parse_front_matter(&content))
|
||||
.map_err(|e| format!("Failed to parse epic front matter: {e}"))?;
|
||||
let epic_view = crate::crdt_state::read_item(epic_id)
|
||||
.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!(
|
||||
"'{epic_id}' is not an epic (type: {:?})",
|
||||
meta.item_type
|
||||
"'{epic_id}' is not an epic (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();
|
||||
for item in &all_items {
|
||||
let sid = &item.story_id.0;
|
||||
let member_content = match crate::db::read_content(sid) {
|
||||
Some(c) => c,
|
||||
None => continue,
|
||||
let Some(member_view) = crate::crdt_state::read_item(sid) else {
|
||||
continue;
|
||||
};
|
||||
let member_meta = match yaml_residue(parse_front_matter(&member_content)) {
|
||||
Ok(m) => m,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if member_meta.epic.as_deref() == Some(epic_id) {
|
||||
if member_view.epic() == Some(epic_id) {
|
||||
let stage_name = match &item.stage {
|
||||
Stage::Upcoming | Stage::Backlog => "backlog",
|
||||
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!({
|
||||
"epic_id": epic_id,
|
||||
"name": meta.name,
|
||||
"name": epic_view.name(),
|
||||
"content": content,
|
||||
"members": member_items,
|
||||
"rollup": format!("{done}/{total} done"),
|
||||
@@ -242,34 +228,36 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tool_list_epics_shows_member_rollup() {
|
||||
crate::crdt_state::init_for_test();
|
||||
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(
|
||||
"9990_epic_rollup",
|
||||
"1_backlog",
|
||||
"---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n",
|
||||
crate::db::ItemMeta::from_yaml(
|
||||
"---\ntype: epic\nname: \"Rollup Epic\"\n---\n\n## Goal\n\nTest\n",
|
||||
),
|
||||
"# Rollup Epic\n\n## Goal\n\nTest\n",
|
||||
crate::db::ItemMeta::named("Rollup Epic"),
|
||||
);
|
||||
crate::crdt_state::set_item_type("9990_epic_rollup", Some("epic"));
|
||||
|
||||
// Write two member items: one done, one current.
|
||||
crate::db::write_item_with_content(
|
||||
"9991_story_member_done",
|
||||
"5_done",
|
||||
"---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
||||
crate::db::ItemMeta::from_yaml(
|
||||
"---\ntype: story\nname: \"Done Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
||||
),
|
||||
"# Done Member\n",
|
||||
crate::db::ItemMeta::named("Done Member"),
|
||||
);
|
||||
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(
|
||||
"9992_story_member_current",
|
||||
"2_current",
|
||||
"---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
||||
crate::db::ItemMeta::from_yaml(
|
||||
"---\ntype: story\nname: \"Current Member\"\nepic: \"9990_epic_rollup\"\n---\n",
|
||||
),
|
||||
"# Current Member\n",
|
||||
crate::db::ItemMeta::named("Current Member"),
|
||||
);
|
||||
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 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);
|
||||
}
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
Reference in New Issue
Block a user