huskies: merge 492_story_remove_filesystem_pipeline_state_and_store_story_content_in_database
This commit is contained in:
@@ -61,32 +61,51 @@ 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;
|
||||
|
||||
'outer: for stage_dir in SEARCH_DIRS {
|
||||
let dir = ctx
|
||||
.project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(stage_dir);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
// --- 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 Some(item) = crate::crdt_state::read_item(&id) {
|
||||
let path = ctx
|
||||
.project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(&item.stage)
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, id));
|
||||
break;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -102,8 +121,9 @@ pub(super) fn handle_depends(ctx: &CommandContext) -> Option<String> {
|
||||
}
|
||||
};
|
||||
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|c| parse_front_matter(&c).ok())
|
||||
.and_then(|m| m.name)
|
||||
.unwrap_or_else(|| story_id.clone());
|
||||
|
||||
@@ -105,15 +105,21 @@ fn find_story_merge_commit(root: &std::path::Path, num_str: &str) -> Option<Stri
|
||||
if hash.is_empty() { None } else { Some(hash) }
|
||||
}
|
||||
|
||||
/// Find the human-readable name of a story by searching all pipeline stages.
|
||||
/// Find the human-readable name of a story by searching content store then filesystem.
|
||||
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
// Try content store first.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
||||
return crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: filesystem scan.
|
||||
let stages = [
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
||||
];
|
||||
for stage in &stages {
|
||||
let dir = root.join(".huskies").join("work").join(stage);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! and returns a confirmation.
|
||||
|
||||
use super::CommandContext;
|
||||
use crate::io::story_metadata::{clear_front_matter_field, parse_front_matter, set_front_matter_field};
|
||||
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.
|
||||
@@ -41,7 +41,24 @@ 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.
|
||||
pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> String {
|
||||
// Find the story file across all pipeline stages by numeric prefix.
|
||||
// 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 {
|
||||
@@ -80,6 +97,49 @@ pub(crate) fn unblock_by_number(project_root: &Path, story_number: &str) -> Stri
|
||||
unblock_by_path(&path, &story_id)
|
||||
}
|
||||
|
||||
/// Unblock a story using the content store (DB-backed).
|
||||
fn unblock_by_story_id(story_id: &str) -> String {
|
||||
let contents = match crate::db::read_content(story_id) {
|
||||
Some(c) => c,
|
||||
None => return format!("Failed to read story content for **{story_id}**"),
|
||||
};
|
||||
|
||||
let meta = match parse_front_matter(&contents) {
|
||||
Ok(m) => m,
|
||||
Err(e) => return format!("Failed to parse front matter for **{story_id}**: {e}"),
|
||||
};
|
||||
|
||||
let story_name = meta.name.as_deref().unwrap_or(story_id).to_string();
|
||||
let has_blocked = meta.blocked == Some(true);
|
||||
let has_merge_failure = meta.merge_failure.is_some();
|
||||
|
||||
if !has_blocked && !has_merge_failure {
|
||||
return format!(
|
||||
"**{story_name}** ({story_id}) is not blocked. Nothing to unblock."
|
||||
);
|
||||
}
|
||||
|
||||
let mut updated = contents;
|
||||
if has_blocked {
|
||||
updated = clear_front_matter_field_in_content(&updated, "blocked");
|
||||
}
|
||||
if has_merge_failure {
|
||||
updated = clear_front_matter_field_in_content(&updated, "merge_failure");
|
||||
}
|
||||
updated = set_front_matter_field(&updated, "retry_count", "0");
|
||||
|
||||
crate::db::write_content(story_id, &updated);
|
||||
let stage = crate::crdt_state::read_item(story_id)
|
||||
.map(|i| i.stage)
|
||||
.unwrap_or_else(|| "2_current".to_string());
|
||||
crate::db::write_item_with_content(story_id, &stage, &updated);
|
||||
|
||||
let mut cleared = Vec::new();
|
||||
if has_blocked { cleared.push("blocked"); }
|
||||
if has_merge_failure { cleared.push("merge_failure"); }
|
||||
format!("Unblocked **{story_name}** ({story_id}). Cleared: {}. Retry count reset to 0.", cleared.join(", "))
|
||||
}
|
||||
|
||||
/// Core unblock logic: reset blocked state for a known story file path.
|
||||
///
|
||||
/// Reads front matter, verifies the story is blocked, clears the `blocked`
|
||||
@@ -234,27 +294,34 @@ mod tests {
|
||||
#[test]
|
||||
fn unblock_command_clears_blocked_and_resets_retry_count() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Use a high story number (9903) to avoid collisions with other tests in the
|
||||
// global content store.
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"2_current",
|
||||
"7_story_stuck.md",
|
||||
"9903_story_stuck.md",
|
||||
"---\nname: Stuck Story\nblocked: true\nretry_count: 5\n---\n# Story\n",
|
||||
);
|
||||
|
||||
let output = unblock_cmd_with_root(tmp.path(), "7").unwrap();
|
||||
let output = unblock_cmd_with_root(tmp.path(), "9903").unwrap();
|
||||
assert!(
|
||||
output.contains("Unblocked") && output.contains("Stuck Story"),
|
||||
"should confirm unblock with story name: {output}"
|
||||
);
|
||||
assert!(
|
||||
output.contains("7_story_stuck"),
|
||||
output.contains("9903_story_stuck"),
|
||||
"should include story_id in response: {output}"
|
||||
);
|
||||
|
||||
let contents = std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/2_current/7_story_stuck.md"),
|
||||
)
|
||||
.unwrap();
|
||||
// The unblock command writes back via the content store; read from there.
|
||||
let contents = crate::db::read_content("9903_story_stuck")
|
||||
.or_else(|| {
|
||||
std::fs::read_to_string(
|
||||
tmp.path().join(".huskies/work/2_current/9903_story_stuck.md"),
|
||||
)
|
||||
.ok()
|
||||
})
|
||||
.expect("story content should be readable after unblock");
|
||||
assert!(
|
||||
!contents.contains("blocked:"),
|
||||
"blocked field should be removed: {contents}"
|
||||
@@ -268,14 +335,16 @@ mod tests {
|
||||
#[test]
|
||||
fn unblock_command_finds_story_in_any_stage() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Use a high story number (9901) to avoid collisions with other tests in the
|
||||
// global content store.
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"3_qa",
|
||||
"10_story_in_qa.md",
|
||||
"9901_story_in_qa.md",
|
||||
"---\nname: In QA\nblocked: true\nretry_count: 3\n---\n# Story\n",
|
||||
);
|
||||
|
||||
let output = unblock_cmd_with_root(tmp.path(), "10").unwrap();
|
||||
let output = unblock_cmd_with_root(tmp.path(), "9901").unwrap();
|
||||
assert!(
|
||||
output.contains("Unblocked"),
|
||||
"should unblock story in qa stage: {output}"
|
||||
@@ -285,16 +354,18 @@ mod tests {
|
||||
#[test]
|
||||
fn unblock_command_includes_story_id_in_response() {
|
||||
let tmp = tempfile::TempDir::new().unwrap();
|
||||
// Use a high story number (9902) to avoid collisions with other tests in the
|
||||
// global content store.
|
||||
write_story_file(
|
||||
tmp.path(),
|
||||
"1_backlog",
|
||||
"5_story_blocked_one.md",
|
||||
"9902_story_blocked_one.md",
|
||||
"---\nname: Blocked One\nblocked: true\nretry_count: 2\n---\n",
|
||||
);
|
||||
|
||||
let output = unblock_cmd_with_root(tmp.path(), "5").unwrap();
|
||||
let output = unblock_cmd_with_root(tmp.path(), "9902").unwrap();
|
||||
assert!(
|
||||
output.contains("5_story_blocked_one"),
|
||||
output.contains("9902_story_blocked_one"),
|
||||
"response should include story_id: {output}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -148,15 +148,21 @@ fn slug_to_name(slug: &str) -> String {
|
||||
words.join(" ")
|
||||
}
|
||||
|
||||
/// Find the human-readable name of a story by searching all pipeline stages.
|
||||
/// Find the human-readable name of a story by searching content store then filesystem.
|
||||
fn find_story_name(root: &std::path::Path, num_str: &str) -> Option<String> {
|
||||
// Try content store first.
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == num_str && let Some(c) = crate::db::read_content(&id) {
|
||||
return crate::io::story_metadata::parse_front_matter(&c)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: filesystem scan.
|
||||
const STAGES: &[&str] = &[
|
||||
"1_backlog",
|
||||
"2_current",
|
||||
"3_qa",
|
||||
"4_merge",
|
||||
"5_done",
|
||||
"6_archived",
|
||||
"1_backlog", "2_current", "3_qa", "4_merge", "5_done", "6_archived",
|
||||
];
|
||||
for stage in STAGES {
|
||||
let dir = root.join(".huskies").join("work").join(stage);
|
||||
|
||||
@@ -7,7 +7,10 @@ use std::path::Path;
|
||||
/// Write a work-item file into the standard pipeline directory structure.
|
||||
///
|
||||
/// Creates `.huskies/work/{stage}/{filename}` under `root`, creating any
|
||||
/// missing parent directories.
|
||||
/// missing parent directories. Also writes to the global content store so
|
||||
/// that code paths that prefer the content store over the filesystem (e.g.
|
||||
/// `unblock_by_number`) see this test's content rather than a stale entry
|
||||
/// left by a parallel test with the same numeric prefix.
|
||||
pub(crate) fn write_story_file(root: &Path, stage: &str, filename: &str, content: &str) {
|
||||
let dir = root.join(".huskies/work").join(stage);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
@@ -402,10 +402,15 @@ pub async fn handle_timer_command(
|
||||
|
||||
// The story must be in backlog or current. When the timer fires,
|
||||
// backlog stories are moved to current automatically.
|
||||
let work_dir = project_root.join(".huskies").join("work");
|
||||
let in_backlog = work_dir.join("1_backlog").join(format!("{story_id}.md")).exists();
|
||||
let in_current = work_dir.join("2_current").join(format!("{story_id}.md")).exists();
|
||||
if !in_backlog && !in_current {
|
||||
// Check CRDT state first, then fall back to filesystem.
|
||||
let in_valid_stage = if let Some(item) = crate::crdt_state::read_item(&story_id) {
|
||||
matches!(item.stage.as_str(), "1_backlog" | "2_current")
|
||||
} else {
|
||||
let work_dir = project_root.join(".huskies").join("work");
|
||||
work_dir.join("1_backlog").join(format!("{story_id}.md")).exists()
|
||||
|| work_dir.join("2_current").join(format!("{story_id}.md")).exists()
|
||||
};
|
||||
if !in_valid_stage {
|
||||
return format!(
|
||||
"Story **{story_id}** is not in backlog or current."
|
||||
);
|
||||
@@ -558,6 +563,15 @@ fn resolve_story_id(number_or_id: &str, project_root: &Path) -> Option<String> {
|
||||
return None;
|
||||
}
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == number_or_id && crate::crdt_state::read_item(&id).is_some() {
|
||||
return Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
@@ -1111,15 +1125,19 @@ mod tests {
|
||||
let current = root.join(".huskies/work/2_current");
|
||||
fs::create_dir_all(&backlog).unwrap();
|
||||
fs::create_dir_all(¤t).unwrap();
|
||||
// Use a unique high-numbered story ID (9905) that is unlikely to be in
|
||||
// the global content store from a parallel test. Write ONLY to the
|
||||
// filesystem so that move_story_to_current uses the filesystem path,
|
||||
// which actually moves the file on disk.
|
||||
fs::write(
|
||||
backlog.join("421_story_foo.md"),
|
||||
backlog.join("9905_story_foo.md"),
|
||||
"---\nname: Foo\n---\n",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let store = Arc::new(TimerStore::load(root.join("timers.json")));
|
||||
let past = Utc::now() - Duration::seconds(5);
|
||||
store.add("421_story_foo".to_string(), past).unwrap();
|
||||
store.add("9905_story_foo".to_string(), past).unwrap();
|
||||
assert_eq!(store.list().len(), 1, "precondition: one pending timer");
|
||||
|
||||
let agents = Arc::new(crate::agents::AgentPool::new_test(19999));
|
||||
@@ -1134,7 +1152,7 @@ mod tests {
|
||||
);
|
||||
// Story should have been moved to current.
|
||||
assert!(
|
||||
current.join("421_story_foo.md").exists(),
|
||||
current.join("9905_story_foo.md").exists(),
|
||||
"story should be in 2_current/ after tick fires"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,32 +102,51 @@ pub async fn handle_assign(
|
||||
agents: &AgentPool,
|
||||
) -> String {
|
||||
// Find the story file across all pipeline stages.
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None;
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == story_number && let Some(item) = crate::crdt_state::read_item(&id) {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(&item.stage)
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, id));
|
||||
break;
|
||||
}
|
||||
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())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage in STAGES {
|
||||
let dir = 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())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -144,8 +163,9 @@ pub async fn handle_assign(
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the response.
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|contents| {
|
||||
parse_front_matter(&contents)
|
||||
.ok()
|
||||
|
||||
@@ -70,32 +70,51 @@ pub async fn handle_delete(
|
||||
];
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
let mut found: Option<(std::path::PathBuf, &str, String)> = None; // (path, stage, story_id)
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String, String)> = None; // (path, stage, story_id)
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == story_number && let Some(item) = crate::crdt_state::read_item(&id) {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(&item.stage)
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, item.stage, id));
|
||||
break;
|
||||
}
|
||||
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())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stage, stem));
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage in STAGES {
|
||||
let dir = 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())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stage.to_string(), stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,8 +129,9 @@ pub async fn handle_delete(
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the confirmation message.
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
@@ -161,7 +181,7 @@ pub async fn handle_delete(
|
||||
.output();
|
||||
|
||||
// Build the response.
|
||||
let stage_label = stage_display_name(stage);
|
||||
let stage_label = stage_display_name(&stage);
|
||||
let mut response = format!("Deleted **{story_name}** from **{stage_label}**.");
|
||||
if !stopped_agents.is_empty() {
|
||||
let agent_list = stopped_agents.join(", ");
|
||||
|
||||
@@ -89,32 +89,51 @@ pub async fn handle_start(
|
||||
];
|
||||
|
||||
// Find the story file across all pipeline stages.
|
||||
// Try the content store / CRDT state first, then fall back to filesystem.
|
||||
let mut found: Option<(std::path::PathBuf, String)> = None; // (path, story_id)
|
||||
'outer: for stage in STAGES {
|
||||
let dir = project_root.join(".huskies").join("work").join(stage);
|
||||
if !dir.exists() {
|
||||
continue;
|
||||
|
||||
// --- DB-first lookup ---
|
||||
for id in crate::db::all_content_ids() {
|
||||
let file_num = id.split('_').next().unwrap_or("");
|
||||
if file_num == story_number && let Some(item) = crate::crdt_state::read_item(&id) {
|
||||
let path = project_root
|
||||
.join(".huskies")
|
||||
.join("work")
|
||||
.join(&item.stage)
|
||||
.join(format!("{id}.md"));
|
||||
found = Some((path, id));
|
||||
break;
|
||||
}
|
||||
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())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
|
||||
// --- Filesystem fallback ---
|
||||
if found.is_none() {
|
||||
'outer: for stage in STAGES {
|
||||
let dir = 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())
|
||||
.map(|s| s.to_string())
|
||||
{
|
||||
let file_num = stem
|
||||
.split('_')
|
||||
.next()
|
||||
.filter(|s| !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()))
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
if file_num == story_number {
|
||||
found = Some((path, stem));
|
||||
break 'outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -131,8 +150,9 @@ pub async fn handle_start(
|
||||
};
|
||||
|
||||
// Read the human-readable name from front matter for the response.
|
||||
let story_name = std::fs::read_to_string(&path)
|
||||
.ok()
|
||||
// Try the content store first, then fall back to reading from disk.
|
||||
let story_name = crate::db::read_content(&story_id)
|
||||
.or_else(|| std::fs::read_to_string(&path).ok())
|
||||
.and_then(|contents| {
|
||||
crate::io::story_metadata::parse_front_matter(&contents)
|
||||
.ok()
|
||||
|
||||
Reference in New Issue
Block a user