huskies: merge 530_story_eliminate_filesystem_markdown_shadows_entirely_crdt_db_is_the_only_story_store
This commit is contained in:
@@ -157,17 +157,23 @@ pub(super) async fn tool_status(args: &Value, ctx: &AppContext) -> Result<String
|
||||
.ok_or("Missing required argument: story_id")?;
|
||||
|
||||
let root = ctx.state.get_project_root()?;
|
||||
let current_dir = root.join(".huskies").join("work").join("2_current");
|
||||
let filepath = current_dir.join(format!("{story_id}.md"));
|
||||
|
||||
if !filepath.exists() {
|
||||
// Read from CRDT/DB content store — verify the item is in 2_current.
|
||||
let typed_item = crate::pipeline_state::read_typed(story_id)
|
||||
.map_err(|e| format!("Failed to read pipeline state: {e}"))?
|
||||
.ok_or_else(|| format!(
|
||||
"Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage."
|
||||
))?;
|
||||
|
||||
if typed_item.stage.dir_name() != "2_current" {
|
||||
return Err(format!(
|
||||
"Story '{story_id}' not found in work/2_current/. Check the story_id and ensure it is in the current stage."
|
||||
));
|
||||
}
|
||||
|
||||
let contents =
|
||||
fs::read_to_string(&filepath).map_err(|e| format!("Failed to read story file: {e}"))?;
|
||||
let contents = crate::db::read_content(story_id).ok_or_else(|| {
|
||||
format!("Story '{story_id}' has no content in the content store.")
|
||||
})?;
|
||||
|
||||
// --- Front matter ---
|
||||
let mut front_matter = serde_json::Map::new();
|
||||
@@ -334,23 +340,18 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn tool_status_returns_story_data() {
|
||||
let tmp = tempdir().unwrap();
|
||||
let current_dir = tmp
|
||||
.path()
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
let story_content = "---\nname: My Test Story\nagent: coder-1\n---\n\n## Acceptance Criteria\n\n- [ ] First criterion\n- [x] Second criterion\n\n## Out of Scope\n\n- nothing\n";
|
||||
fs::write(current_dir.join("42_story_test.md"), story_content).unwrap();
|
||||
crate::db::write_item_with_content("9886_story_status_test", "2_current", story_content);
|
||||
|
||||
let ctx = crate::http::context::AppContext::new_test(tmp.path().to_path_buf());
|
||||
let result = tool_status(&json!({"story_id": "42_story_test"}), &ctx)
|
||||
let result = tool_status(&json!({"story_id": "9886_story_status_test"}), &ctx)
|
||||
.await
|
||||
.unwrap();
|
||||
let parsed: serde_json::Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
assert_eq!(parsed["story_id"], "42_story_test");
|
||||
assert_eq!(parsed["story_id"], "9886_story_status_test");
|
||||
assert_eq!(parsed["front_matter"]["name"], "My Test Story");
|
||||
assert_eq!(parsed["front_matter"]["agent"], "coder-1");
|
||||
|
||||
|
||||
+124
-123
@@ -757,8 +757,9 @@ mod tests {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_validate_stories(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_empty());
|
||||
// CRDT is global; other tests may have inserted items.
|
||||
// Just verify it parses without error.
|
||||
let _parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -775,11 +776,13 @@ mod tests {
|
||||
.unwrap();
|
||||
assert!(result.contains("Created story:"));
|
||||
|
||||
// List should return it
|
||||
// List should return it (CRDT is global, so filter for our story)
|
||||
let list = tool_list_upcoming(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&list).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0]["name"], "Test Story");
|
||||
assert!(
|
||||
parsed.iter().any(|s| s["name"] == "Test Story"),
|
||||
"expected 'Test Story' in upcoming list: {parsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -831,32 +834,28 @@ mod tests {
|
||||
#[test]
|
||||
fn tool_get_pipeline_status_returns_structured_response() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
for (stage, id, name) in &[
|
||||
("1_backlog", "10_story_upcoming", "Upcoming Story"),
|
||||
("2_current", "20_story_current", "Current Story"),
|
||||
("3_qa", "30_story_qa", "QA Story"),
|
||||
("4_merge", "40_story_merge", "Merge Story"),
|
||||
("5_done", "50_story_done", "Done Story"),
|
||||
("1_backlog", "9910_story_upcoming", "Upcoming Story"),
|
||||
("2_current", "9920_story_current", "Current Story"),
|
||||
("3_qa", "9930_story_qa", "QA Story"),
|
||||
("4_merge", "9940_story_merge", "Merge Story"),
|
||||
("5_done", "9950_story_done", "Done Story"),
|
||||
] {
|
||||
let dir = root.join(".huskies/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(
|
||||
dir.join(format!("{id}.md")),
|
||||
format!("---\nname: \"{name}\"\n---\n"),
|
||||
)
|
||||
.unwrap();
|
||||
crate::db::write_item_with_content(
|
||||
id,
|
||||
stage,
|
||||
&format!("---\nname: \"{name}\"\n---\n"),
|
||||
);
|
||||
}
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_pipeline_status(&ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
// Active stages include current, qa, merge, done
|
||||
let active = parsed["active"].as_array().unwrap();
|
||||
assert_eq!(active.len(), 4);
|
||||
|
||||
let stages: Vec<&str> = active
|
||||
.iter()
|
||||
.map(|i| i["stage"].as_str().unwrap())
|
||||
@@ -866,29 +865,28 @@ mod tests {
|
||||
assert!(stages.contains(&"merge"));
|
||||
assert!(stages.contains(&"done"));
|
||||
|
||||
// Backlog
|
||||
// Backlog should contain our item
|
||||
let backlog = parsed["backlog"].as_array().unwrap();
|
||||
assert_eq!(backlog.len(), 1);
|
||||
assert_eq!(backlog[0]["story_id"], "10_story_upcoming");
|
||||
assert_eq!(parsed["backlog_count"], 1);
|
||||
assert!(
|
||||
backlog.iter().any(|b| b["story_id"] == "9910_story_upcoming"),
|
||||
"expected 9910_story_upcoming in backlog: {backlog:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_get_pipeline_status_includes_agent_assignment() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let root = tmp.path();
|
||||
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
std::fs::create_dir_all(¤t).unwrap();
|
||||
std::fs::write(
|
||||
current.join("20_story_active.md"),
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9921_story_active",
|
||||
"2_current",
|
||||
"---\nname: \"Active Story\"\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let ctx = test_ctx(root);
|
||||
let ctx = test_ctx(tmp.path());
|
||||
ctx.agents.inject_test_agent(
|
||||
"20_story_active",
|
||||
"9921_story_active",
|
||||
"coder-1",
|
||||
crate::agents::AgentStatus::Running,
|
||||
);
|
||||
@@ -897,9 +895,8 @@ mod tests {
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
|
||||
let active = parsed["active"].as_array().unwrap();
|
||||
assert_eq!(active.len(), 1);
|
||||
let item = &active[0];
|
||||
assert_eq!(item["story_id"], "20_story_active");
|
||||
let item = active.iter().find(|i| i["story_id"] == "9921_story_active")
|
||||
.expect("expected 9921_story_active in active items");
|
||||
assert_eq!(item["stage"], "current");
|
||||
assert!(!item["agent"].is_null(), "agent should be present");
|
||||
assert_eq!(item["agent"]["agent_name"], "coder-1");
|
||||
@@ -918,16 +915,16 @@ mod tests {
|
||||
#[test]
|
||||
fn tool_get_story_todos_returns_unchecked() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
fs::write(
|
||||
current_dir.join("1_test.md"),
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9901_test",
|
||||
"2_current",
|
||||
"---\nname: Test\n---\n## AC\n- [ ] First\n- [x] Done\n- [ ] Second\n",
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_get_story_todos(&json!({"story_id": "1_test"}), &ctx).unwrap();
|
||||
let result = tool_get_story_todos(&json!({"story_id": "9901_test"}), &ctx).unwrap();
|
||||
let parsed: Value = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed["todos"].as_array().unwrap().len(), 2);
|
||||
assert_eq!(parsed["story_name"], "Test");
|
||||
@@ -1120,41 +1117,52 @@ mod tests {
|
||||
assert!(result.contains("_bug_login_crash"), "result should contain bug ID: {result}");
|
||||
// Extract the actual bug ID from the result message (format: "Created bug: <id>").
|
||||
let bug_id = result.trim_start_matches("Created bug: ").trim();
|
||||
let bug_file = tmp
|
||||
.path()
|
||||
.join(format!(".huskies/work/1_backlog/{bug_id}.md"));
|
||||
assert!(bug_file.exists(), "expected bug file at {}", bug_file.display());
|
||||
// Bug content should exist in the CRDT content store.
|
||||
assert!(
|
||||
crate::db::read_content(bug_id).is_some(),
|
||||
"expected bug content in CRDT for {bug_id}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_list_bugs_empty() {
|
||||
fn tool_list_bugs_no_crash_on_empty_root() {
|
||||
// list_bugs reads from the global CRDT, not the filesystem.
|
||||
// Verify it returns valid JSON without panicking.
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_list_bugs(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert!(parsed.is_empty());
|
||||
// Verify result is valid JSON array (may contain bugs from
|
||||
// the shared global CRDT populated by other tests).
|
||||
let _parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_list_bugs_returns_open_bugs() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let backlog_dir = tmp.path().join(".huskies/work/1_backlog");
|
||||
std::fs::create_dir_all(&backlog_dir).unwrap();
|
||||
std::fs::write(backlog_dir.join("1_bug_crash.md"), "# Bug 1: App Crash\n").unwrap();
|
||||
std::fs::write(
|
||||
backlog_dir.join("2_bug_typo.md"),
|
||||
"# Bug 2: Typo in Header\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9902_bug_crash",
|
||||
"1_backlog",
|
||||
"---\nname: \"App Crash\"\n---\n# Bug 9902: App Crash\n",
|
||||
);
|
||||
crate::db::write_item_with_content(
|
||||
"9903_bug_typo",
|
||||
"1_backlog",
|
||||
"---\nname: \"Typo in Header\"\n---\n# Bug 9903: Typo in Header\n",
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_list_bugs(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed.len(), 2);
|
||||
assert_eq!(parsed[0]["bug_id"], "1_bug_crash");
|
||||
assert_eq!(parsed[0]["name"], "App Crash");
|
||||
assert_eq!(parsed[1]["bug_id"], "2_bug_typo");
|
||||
assert_eq!(parsed[1]["name"], "Typo in Header");
|
||||
assert!(
|
||||
parsed.iter().any(|b| b["bug_id"] == "9902_bug_crash" && b["name"] == "App Crash"),
|
||||
"expected 9902_bug_crash in bugs list: {parsed:?}"
|
||||
);
|
||||
assert!(
|
||||
parsed.iter().any(|b| b["bug_id"] == "9903_bug_typo" && b["name"] == "Typo in Header"),
|
||||
"expected 9903_bug_typo in bugs list: {parsed:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -1246,11 +1254,9 @@ mod tests {
|
||||
assert!(result.contains("_spike_compare_encoders"), "result should contain spike ID: {result}");
|
||||
// Extract the actual spike ID from the result message (format: "Created spike: <id>").
|
||||
let spike_id = result.trim_start_matches("Created spike: ").trim();
|
||||
let spike_file = tmp
|
||||
.path()
|
||||
.join(format!(".huskies/work/1_backlog/{spike_id}.md"));
|
||||
assert!(spike_file.exists(), "expected spike file at {}", spike_file.display());
|
||||
let contents = std::fs::read_to_string(&spike_file).unwrap();
|
||||
// Spike content should exist in the CRDT content store.
|
||||
let contents = crate::db::read_content(spike_id)
|
||||
.expect("expected spike content in CRDT");
|
||||
assert!(contents.starts_with("---\nname: \"Compare Encoders\"\n---"));
|
||||
assert!(contents.contains("Which encoder is fastest?"));
|
||||
}
|
||||
@@ -1265,11 +1271,9 @@ mod tests {
|
||||
// Extract the actual spike ID from the result message (format: "Created spike: <id>").
|
||||
let spike_id = result.trim_start_matches("Created spike: ").trim();
|
||||
|
||||
let spike_file = tmp
|
||||
.path()
|
||||
.join(format!(".huskies/work/1_backlog/{spike_id}.md"));
|
||||
assert!(spike_file.exists(), "expected spike file at {}", spike_file.display());
|
||||
let contents = std::fs::read_to_string(&spike_file).unwrap();
|
||||
// Spike content should exist in the CRDT content store.
|
||||
let contents = crate::db::read_content(spike_id)
|
||||
.expect("expected spike content in CRDT");
|
||||
assert!(contents.starts_with("---\nname: \"My Spike\"\n---"));
|
||||
assert!(contents.contains("## Question\n\n- TBD\n"));
|
||||
}
|
||||
@@ -1310,48 +1314,56 @@ mod tests {
|
||||
#[test]
|
||||
fn tool_validate_stories_with_valid_story() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
fs::write(
|
||||
current_dir.join("1_test.md"),
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9907_test",
|
||||
"2_current",
|
||||
"---\nname: \"Valid Story\"\n---\n## AC\n- [ ] First\n",
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_validate_stories(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert_eq!(parsed.len(), 1);
|
||||
assert_eq!(parsed[0]["valid"], true);
|
||||
let item = parsed.iter().find(|v| v["story_id"] == "9907_test")
|
||||
.expect("expected 9907_test in validation results");
|
||||
assert_eq!(item["valid"], true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_validate_stories_with_invalid_front_matter() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
fs::write(current_dir.join("1_test.md"), "## No front matter at all\n").unwrap();
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9908_test",
|
||||
"2_current",
|
||||
"## No front matter at all\n",
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result = tool_validate_stories(&ctx).unwrap();
|
||||
let parsed: Vec<Value> = serde_json::from_str(&result).unwrap();
|
||||
assert!(!parsed.is_empty());
|
||||
assert_eq!(parsed[0]["valid"], false);
|
||||
let item = parsed.iter().find(|v| v["story_id"] == "9908_test")
|
||||
.expect("expected 9908_test in validation results");
|
||||
assert_eq!(item["valid"], false);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn record_tests_persists_to_story_file() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
fs::write(
|
||||
current.join("1_story_persist.md"),
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9906_story_persist",
|
||||
"2_current",
|
||||
"---\nname: Persist\n---\n# Story\n",
|
||||
)
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
tool_record_tests(
|
||||
&json!({
|
||||
"story_id": "1_story_persist",
|
||||
"story_id": "9906_story_persist",
|
||||
"unit": [{"name": "u1", "status": "pass"}],
|
||||
"integration": []
|
||||
}),
|
||||
@@ -1359,36 +1371,35 @@ mod tests {
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let contents = fs::read_to_string(current.join("1_story_persist.md")).unwrap();
|
||||
let contents = crate::db::read_content("9906_story_persist")
|
||||
.expect("story content should exist in CRDT");
|
||||
assert!(
|
||||
contents.contains("## Test Results"),
|
||||
"file should have Test Results section"
|
||||
"content should have Test Results section"
|
||||
);
|
||||
assert!(
|
||||
contents.contains("huskies-test-results:"),
|
||||
"file should have JSON marker"
|
||||
"content should have JSON marker"
|
||||
);
|
||||
assert!(contents.contains("u1"), "file should contain test name");
|
||||
assert!(contents.contains("u1"), "content should contain test name");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ensure_acceptance_reads_from_file_when_not_in_memory() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
let current = tmp.path().join(".huskies/work/2_current");
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
|
||||
// Write a story file with a pre-populated Test Results section (simulating a restart)
|
||||
// Write story content to CRDT with a pre-populated Test Results section
|
||||
let story_content = "---\nname: Persist\n---\n# Story\n\n## Test Results\n\n<!-- huskies-test-results: {\"unit\":[{\"name\":\"u1\",\"status\":\"pass\",\"details\":null}],\"integration\":[{\"name\":\"i1\",\"status\":\"pass\",\"details\":null}]} -->\n";
|
||||
fs::write(current.join("2_story_file_only.md"), story_content).unwrap();
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content("9905_story_file_only", "2_current", story_content);
|
||||
|
||||
// Use a fresh context (empty in-memory state, simulating a restart)
|
||||
let ctx = test_ctx(tmp.path());
|
||||
|
||||
// ensure_acceptance should read from file and succeed
|
||||
let result = tool_ensure_acceptance(&json!({"story_id": "2_story_file_only"}), &ctx);
|
||||
// ensure_acceptance should read from content store and succeed
|
||||
let result = tool_ensure_acceptance(&json!({"story_id": "9905_story_file_only"}), &ctx);
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"should accept based on file data, got: {:?}",
|
||||
"should accept based on content store data, got: {:?}",
|
||||
result
|
||||
);
|
||||
assert!(result.unwrap().contains("All gates pass"));
|
||||
@@ -1656,27 +1667,17 @@ mod tests {
|
||||
fn tool_check_criterion_marks_unchecked_item() {
|
||||
let tmp = tempfile::tempdir().unwrap();
|
||||
setup_git_repo_in(tmp.path());
|
||||
let current_dir = tmp.path().join(".huskies").join("work").join("2_current");
|
||||
fs::create_dir_all(¤t_dir).unwrap();
|
||||
fs::write(
|
||||
current_dir.join("1_test.md"),
|
||||
|
||||
crate::db::ensure_content_store();
|
||||
crate::db::write_item_with_content(
|
||||
"9904_test",
|
||||
"2_current",
|
||||
"---\nname: Test\n---\n## AC\n- [ ] First criterion\n- [x] Already done\n",
|
||||
)
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["add", "."])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
std::process::Command::new("git")
|
||||
.args(["commit", "-m", "add story"])
|
||||
.current_dir(tmp.path())
|
||||
.output()
|
||||
.unwrap();
|
||||
);
|
||||
|
||||
let ctx = test_ctx(tmp.path());
|
||||
let result =
|
||||
tool_check_criterion(&json!({"story_id": "1_test", "criterion_index": 0}), &ctx);
|
||||
tool_check_criterion(&json!({"story_id": "9904_test", "criterion_index": 0}), &ctx);
|
||||
assert!(result.is_ok(), "Expected ok: {result:?}");
|
||||
assert!(result.unwrap().contains("Criterion 0 checked"));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user