huskies: merge 512_story_migrate_chat_commands_from_filesystem_lookup_to_crdt_db

This commit is contained in:
dave
2026-04-09 23:00:01 +00:00
parent c324452b38
commit 0de9200d48
9 changed files with 318 additions and 452 deletions
+10 -71
View File
@@ -9,16 +9,6 @@
use super::CommandContext;
use crate::io::story_metadata::{parse_front_matter, write_depends_on};
/// All pipeline stage directories searched when finding a work item by number.
const SEARCH_DIRS: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// Handle the `depends` command.
///
/// Syntax: `depends <number> [dep1 dep2 ...]`
@@ -60,69 +50,18 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
}
}
// Find the story file across all pipeline stages by numeric prefix.
// Try the content store / CRDT state first, then fall back to filesystem.
let mut found: Option<(std::path::PathBuf, String)> = None;
// --- DB-first lookup ---
for id in crate::db::all_content_ids() {
let file_num = id.split('_').next().unwrap_or("");
if file_num == num_str && let Ok(Some(item)) = crate::pipeline_state::read_typed(&id) {
let path = ctx
.project_root
.join(".huskies")
.join("work")
.join(item.stage.dir_name())
.join(format!("{id}.md"));
found = Some((path, id));
break;
}
}
// --- Filesystem fallback ---
if found.is_none() {
'outer: for stage_dir in SEARCH_DIRS {
let dir = ctx
.project_root
.join(".huskies")
.join("work")
.join(stage_dir);
if !dir.exists() {
continue;
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
Some(found) => found,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
found = Some((path.to_path_buf(), stem.to_string()));
break 'outer;
}
}
}
}
}
}
};
let (path, story_id) = match found {
Some(f) => f,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
};
// Try the content store first, then fall back to reading from disk.
let story_name = crate::db::read_content(&story_id)
let story_name = content
.or_else(|| std::fs::read_to_string(&path).ok())
.and_then(|c| parse_front_matter(&c).ok())
.and_then(|m| m.name)
+12 -57
View File
@@ -10,16 +10,6 @@ use crate::agents::move_story_to_stage;
/// Valid stage names accepted by the move command.
const VALID_STAGES: &[&str] = &["backlog", "current", "qa", "merge", "done"];
/// All pipeline stage directories to search when finding a work item by number.
const SEARCH_DIRS: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// Handle the `move` command.
///
/// Parses `<number> <stage>` from `ctx.args`, locates the work item by its
@@ -55,55 +45,20 @@ pub(super) fn handle_move(ctx: &CommandContext) -> Option<String> {
));
}
// Find the story file across all pipeline stages by numeric prefix.
let mut found_story_id: Option<String> = None;
let mut found_name: Option<String> = None;
'outer: for stage_dir in SEARCH_DIRS {
let dir = ctx
.project_root
.join(".huskies")
.join("work")
.join(stage_dir);
if !dir.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
found_story_id = Some(stem.to_string());
found_name = std::fs::read_to_string(&path)
.ok()
.and_then(|contents| {
crate::io::story_metadata::parse_front_matter(&contents)
.ok()
.and_then(|m| m.name)
});
break 'outer;
}
}
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, _path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
Some(found) => found,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
}
}
};
let story_id = match found_story_id {
Some(id) => id,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
};
let found_name = content
.and_then(|c| crate::io::story_metadata::parse_front_matter(&c).ok())
.and_then(|m| m.name);
let display_name = found_name.as_deref().unwrap_or(&story_id);
+21 -45
View File
@@ -4,9 +4,8 @@ use super::CommandContext;
/// Display the full markdown text of a work item identified by its numeric ID.
///
/// Searches all pipeline stages in order and returns the raw file contents of
/// the first matching story, bug, or spike. Returns a friendly message when
/// no match is found.
/// Lookup priority: CRDT → content store → filesystem (Story 512).
/// Returns a friendly message when no match is found.
pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
let num_str = ctx.args.trim();
if num_str.is_empty() {
@@ -22,50 +21,27 @@ pub(super) fn handle_show(ctx: &CommandContext) -> Option<String> {
));
}
let stages = [
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
for stage in &stages {
let dir = ctx
.project_root
.join(".huskies")
.join("work")
.join(stage);
if !dir.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == num_str {
return match std::fs::read_to_string(&path) {
Ok(contents) => Some(contents),
Err(e) => Some(format!("Failed to read story {num_str}: {e}")),
};
}
}
// Find the story by numeric prefix: CRDT → content store → filesystem.
let (story_id, _stage_dir, path, content) =
match crate::chat::lookup::find_story_by_number(ctx.project_root, num_str) {
Some(found) => found,
None => {
return Some(format!(
"No story, bug, or spike with number **{num_str}** found."
));
}
}
}
};
Some(format!(
"No story, bug, or spike with number **{num_str}** found."
))
// `content` is populated from the content store (CRDT/DB path) or read
// from disk during the filesystem fallback. If it is None (story found in
// CRDT but no content-store entry yet), attempt a direct disk read.
Some(
content
.or_else(|| std::fs::read_to_string(&path).ok())
.unwrap_or_else(|| {
format!("Story {story_id} found in pipeline but its content is unavailable.")
}),
)
}
#[cfg(test)]
+19 -62
View File
@@ -8,16 +8,6 @@ use super::CommandContext;
use crate::io::story_metadata::{clear_front_matter_field, clear_front_matter_field_in_content, parse_front_matter, set_front_matter_field};
use std::path::Path;
/// All pipeline stage directories to search when finding a work item by number.
const SEARCH_DIRS: &[&str] = &[
"1_backlog",
"2_current",
"3_qa",
"4_merge",
"5_done",
"6_archived",
];
/// Handle the `unblock` command.
///
/// Parses `<number>` from `ctx.args`, locates the work item, checks that it is
@@ -40,61 +30,28 @@ pub(super) fn handle_unblock(ctx: &CommandContext) -> Option<String> {
///
/// Returns a Markdown-formatted response string suitable for all transports.
/// Also used by the MCP `unblock` tool.
///
/// Lookup priority: CRDT → content store → filesystem (Story 512).
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
// Try content store / CRDT first to find story by numeric prefix.
let mut found_id: Option<String> = None;
// Check content store IDs.
for id in crate::db::all_content_ids() {
let num = id.split('_').next().unwrap_or("");
if num == story_number {
found_id = Some(id);
break;
}
}
// If found in content store, use DB-backed unblock.
if let Some(story_id) = found_id {
return unblock_by_story_id(&story_id);
}
// Fallback: find the story file across all pipeline stages on filesystem.
let mut found: Option<(std::path::PathBuf, String)> = None;
'outer: for stage_dir in SEARCH_DIRS {
let dir = project_root.join(".huskies").join("work").join(stage_dir);
if !dir.exists() {
continue;
}
if let Ok(entries) = std::fs::read_dir(&dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
let file_num = stem
.split('_')
.next()
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
.unwrap_or("");
if file_num == story_number {
found = Some((path.to_path_buf(), stem.to_string()));
break 'outer;
}
}
let (story_id, _stage_dir, path, _content) =
match crate::chat::lookup::find_story_by_number(project_root, story_number) {
Some(found) => found,
None => {
return format!(
"No story, bug, or spike with number **{story_number}** found."
);
}
}
};
// Prefer DB-backed unblock when the story is in the content store.
// Note: `content` may have come from the filesystem fallback in
// `find_story_by_number`, so we must re-check the DB rather than
// relying on `content.is_some()` alone.
if crate::db::read_content(&story_id).is_some() {
unblock_by_story_id(&story_id)
} else {
unblock_by_path(&path, &story_id)
}
let (path, story_id) = match found {
Some(f) => f,
None => {
return format!("No story, bug, or spike with number **{story_number}** found.");
}
};
unblock_by_path(&path, &story_id)
}
/// Unblock a story using the content store (DB-backed).