2026-03-22 19:07:07 +00:00
use crate ::http ::context ::AppContext ;
use serde_json ::{ Value , json } ;
use std ::fs ;
use std ::path ::{ Path , PathBuf } ;
/// Parse all AC items from a story file, returning (text, is_checked) pairs.
fn parse_ac_items ( contents : & str ) -> Vec < ( String , bool ) > {
let mut in_ac_section = false ;
let mut items = Vec ::new ( ) ;
for line in contents . lines ( ) {
let trimmed = line . trim ( ) ;
if trimmed = = " ## Acceptance Criteria " {
in_ac_section = true ;
continue ;
}
// Stop at the next heading
if in_ac_section & & trimmed . starts_with ( " ## " ) {
break ;
}
if in_ac_section {
if let Some ( rest ) = trimmed . strip_prefix ( " - [x] " ) . or ( trimmed . strip_prefix ( " - [X] " ) ) {
items . push ( ( rest . to_string ( ) , true ) ) ;
} else if let Some ( rest ) = trimmed . strip_prefix ( " - [ ] " ) {
items . push ( ( rest . to_string ( ) , false ) ) ;
}
}
}
items
}
2026-04-03 16:12:52 +01:00
/// Find the most recent log file for any agent under `.huskies/logs/{story_id}/`.
2026-03-22 19:07:07 +00:00
fn find_most_recent_log ( project_root : & Path , story_id : & str ) -> Option < PathBuf > {
let dir = project_root
2026-04-03 16:12:52 +01:00
. join ( " .huskies " )
2026-03-22 19:07:07 +00:00
. join ( " logs " )
. join ( story_id ) ;
if ! dir . is_dir ( ) {
return None ;
}
let mut best : Option < ( PathBuf , std ::time ::SystemTime ) > = None ;
let entries = fs ::read_dir ( & dir ) . ok ( ) ? ;
for entry in entries . flatten ( ) {
let path = entry . path ( ) ;
let name = match path . file_name ( ) . and_then ( | n | n . to_str ( ) ) {
Some ( n ) = > n . to_string ( ) ,
None = > continue ,
} ;
if ! name . ends_with ( " .log " ) {
continue ;
}
let modified = match entry . metadata ( ) . and_then ( | m | m . modified ( ) ) {
Ok ( t ) = > t ,
Err ( _ ) = > continue ,
} ;
if best . as_ref ( ) . is_none_or ( | ( _ , t ) | modified > * t ) {
best = Some ( ( path , modified ) ) ;
}
}
best . map ( | ( p , _ ) | p )
}
/// Return the last N raw lines from a file.
fn last_n_lines ( path : & Path , n : usize ) -> Result < Vec < String > , String > {
let content =
fs ::read_to_string ( path ) . map_err ( | e | format! ( " Failed to read log file: {e} " ) ) ? ;
let lines : Vec < String > = content
. lines ( )
. rev ( )
. take ( n )
. map ( | l | l . to_string ( ) )
. collect ::< Vec < _ > > ( )
. into_iter ( )
. rev ( )
. collect ( ) ;
Ok ( lines )
}
/// Run `git diff --stat {base}...HEAD` in the worktree.
async fn git_diff_stat ( worktree : & Path , base : & str ) -> Option < String > {
let dir = worktree . to_path_buf ( ) ;
let base_arg = format! ( " {base} ...HEAD " ) ;
tokio ::task ::spawn_blocking ( move | | {
let output = std ::process ::Command ::new ( " git " )
. args ( [ " diff " , " --stat " , & base_arg ] )
. current_dir ( & dir )
. output ( )
. ok ( ) ? ;
if output . status . success ( ) {
Some ( String ::from_utf8_lossy ( & output . stdout ) . trim ( ) . to_string ( ) )
} else {
None
}
} )
. await
. ok ( )
. flatten ( )
}
/// Return the last N commit messages on the current branch relative to base.
async fn git_log_commits ( worktree : & Path , base : & str , count : usize ) -> Option < Vec < String > > {
let dir = worktree . to_path_buf ( ) ;
let range = format! ( " {base} ..HEAD " ) ;
let count_str = count . to_string ( ) ;
tokio ::task ::spawn_blocking ( move | | {
let output = std ::process ::Command ::new ( " git " )
. args ( [ " log " , & range , " --oneline " , & format! ( " - {count_str} " ) ] )
. current_dir ( & dir )
. output ( )
. ok ( ) ? ;
if output . status . success ( ) {
let lines : Vec < String > = String ::from_utf8 ( output . stdout )
. ok ( ) ?
. lines ( )
. filter ( | l | ! l . is_empty ( ) )
. map ( | l | l . to_string ( ) )
. collect ( ) ;
Some ( lines )
} else {
None
}
} )
. await
. ok ( )
. flatten ( )
}
/// Return the active branch name for the given directory.
async fn git_branch ( dir : & Path ) -> Option < String > {
let dir = dir . to_path_buf ( ) ;
tokio ::task ::spawn_blocking ( move | | {
let output = std ::process ::Command ::new ( " git " )
. args ( [ " rev-parse " , " --abbrev-ref " , " HEAD " ] )
. current_dir ( & dir )
. output ( )
. ok ( ) ? ;
if output . status . success ( ) {
Some ( String ::from_utf8_lossy ( & output . stdout ) . trim ( ) . to_string ( ) )
} else {
None
}
} )
. await
. ok ( )
. flatten ( )
}
2026-03-24 11:06:43 +00:00
pub ( super ) async fn tool_status ( args : & Value , ctx : & AppContext ) -> Result < String , String > {
2026-03-22 19:07:07 +00:00
let story_id = args
. get ( " story_id " )
. and_then ( | v | v . as_str ( ) )
. ok_or ( " Missing required argument: story_id " ) ? ;
let root = ctx . state . get_project_root ( ) ? ;
2026-04-10 19:01:31 +00:00
// Read from CRDT/DB content store — works for stories in any pipeline stage.
let _typed_item = crate ::pipeline_state ::read_typed ( story_id )
2026-04-10 14:56:13 +00:00
. map_err ( | e | format! ( " Failed to read pipeline state: {e} " ) ) ?
. ok_or_else ( | | format! (
2026-04-10 19:01:31 +00:00
" Story ' {story_id} ' not found in the pipeline. "
2026-04-10 14:56:13 +00:00
) ) ? ;
let contents = crate ::db ::read_content ( story_id ) . ok_or_else ( | | {
format! ( " Story ' {story_id} ' has no content in the content store. " )
} ) ? ;
2026-03-22 19:07:07 +00:00
// --- Front matter ---
let mut front_matter = serde_json ::Map ::new ( ) ;
if let Ok ( meta ) = crate ::io ::story_metadata ::parse_front_matter ( & contents ) {
if let Some ( name ) = & meta . name {
front_matter . insert ( " name " . to_string ( ) , json! ( name ) ) ;
}
if let Some ( agent ) = & meta . agent {
front_matter . insert ( " agent " . to_string ( ) , json! ( agent ) ) ;
}
if let Some ( true ) = meta . blocked {
front_matter . insert ( " blocked " . to_string ( ) , json! ( true ) ) ;
}
if let Some ( qa ) = & meta . qa {
front_matter . insert ( " qa " . to_string ( ) , json! ( qa . as_str ( ) ) ) ;
}
if let Some ( rc ) = meta . retry_count
& & rc > 0
{
front_matter . insert ( " retry_count " . to_string ( ) , json! ( rc ) ) ;
}
if let Some ( mf ) = & meta . merge_failure {
front_matter . insert ( " merge_failure " . to_string ( ) , json! ( mf ) ) ;
}
if let Some ( rh ) = meta . review_hold
& & rh
{
front_matter . insert ( " review_hold " . to_string ( ) , json! ( rh ) ) ;
}
}
// --- AC checklist ---
let ac_items : Vec < Value > = parse_ac_items ( & contents )
. into_iter ( )
. map ( | ( text , checked ) | json! ( { " text " : text , " checked " : checked } ) )
. collect ( ) ;
// --- Worktree ---
2026-04-03 16:12:52 +01:00
let worktree_path = root . join ( " .huskies " ) . join ( " worktrees " ) . join ( story_id ) ;
2026-03-22 19:07:07 +00:00
let ( _ , worktree_info ) = if worktree_path . is_dir ( ) {
let branch = git_branch ( & worktree_path ) . await ;
(
branch . clone ( ) ,
Some ( json! ( {
" path " : worktree_path . to_string_lossy ( ) ,
" branch " : branch ,
} ) ) ,
)
} else {
( None , None )
} ;
// --- Git diff stat ---
let diff_stat = if worktree_path . is_dir ( ) {
git_diff_stat ( & worktree_path , " master " ) . await
} else {
None
} ;
// --- Last 5 commits ---
let commits = if worktree_path . is_dir ( ) {
git_log_commits ( & worktree_path , " master " , 5 ) . await
} else {
None
} ;
// --- Most recent agent log (last 20 lines) ---
let agent_log = match find_most_recent_log ( & root , story_id ) {
Some ( log_path ) = > {
let filename = log_path
. file_name ( )
. and_then ( | n | n . to_str ( ) )
. unwrap_or ( " " )
. to_string ( ) ;
match last_n_lines ( & log_path , 20 ) {
Ok ( lines ) = > Some ( json! ( {
" file " : filename ,
" lines " : lines ,
} ) ) ,
Err ( _ ) = > None ,
}
}
None = > None ,
} ;
let result = json! ( {
" story_id " : story_id ,
" front_matter " : front_matter ,
" acceptance_criteria " : ac_items ,
" worktree " : worktree_info ,
" git_diff_stat " : diff_stat ,
" commits " : commits ,
" agent_log " : agent_log ,
} ) ;
serde_json ::to_string_pretty ( & result ) . map_err ( | e | format! ( " Serialization error: {e} " ) )
}
#[ cfg(test) ]
mod tests {
use super ::* ;
use tempfile ::tempdir ;
#[ test ]
fn parse_ac_items_returns_checked_and_unchecked ( ) {
let content = " --- \n name: test \n --- \n \n ## Acceptance Criteria \n \n - [ ] item one \n - [x] item two \n - [X] item three \n \n ## Out of Scope \n \n - [ ] not an ac \n " ;
let items = parse_ac_items ( content ) ;
assert_eq! ( items . len ( ) , 3 ) ;
assert_eq! ( items [ 0 ] , ( " item one " . to_string ( ) , false ) ) ;
assert_eq! ( items [ 1 ] , ( " item two " . to_string ( ) , true ) ) ;
assert_eq! ( items [ 2 ] , ( " item three " . to_string ( ) , true ) ) ;
}
#[ test ]
fn parse_ac_items_empty_when_no_section ( ) {
let content = " --- \n name: test \n --- \n \n No AC section here. \n " ;
let items = parse_ac_items ( content ) ;
assert! ( items . is_empty ( ) ) ;
}
#[ test ]
fn find_most_recent_log_returns_none_for_missing_dir ( ) {
let tmp = tempdir ( ) . unwrap ( ) ;
let result = find_most_recent_log ( tmp . path ( ) , " nonexistent_story " ) ;
assert! ( result . is_none ( ) ) ;
}
#[ test ]
fn find_most_recent_log_returns_newest_file ( ) {
let tmp = tempdir ( ) . unwrap ( ) ;
let log_dir = tmp
. path ( )
2026-04-03 16:12:52 +01:00
. join ( " .huskies " )
2026-03-22 19:07:07 +00:00
. join ( " logs " )
. join ( " 42_story_foo " ) ;
fs ::create_dir_all ( & log_dir ) . unwrap ( ) ;
let old_path = log_dir . join ( " coder-1-sess-old.log " ) ;
fs ::write ( & old_path , " old content " ) . unwrap ( ) ;
// Ensure different mtime
std ::thread ::sleep ( std ::time ::Duration ::from_millis ( 50 ) ) ;
let new_path = log_dir . join ( " coder-1-sess-new.log " ) ;
fs ::write ( & new_path , " new content " ) . unwrap ( ) ;
let result = find_most_recent_log ( tmp . path ( ) , " 42_story_foo " ) . unwrap ( ) ;
assert! (
result . to_string_lossy ( ) . contains ( " sess-new " ) ,
" Expected newest file, got: {} " ,
result . display ( )
) ;
}
#[ tokio::test ]
2026-03-24 11:06:43 +00:00
async fn tool_status_returns_error_for_missing_story ( ) {
2026-03-22 19:07:07 +00:00
let tmp = tempdir ( ) . unwrap ( ) ;
let ctx = crate ::http ::context ::AppContext ::new_test ( tmp . path ( ) . to_path_buf ( ) ) ;
2026-03-24 11:06:43 +00:00
let result = tool_status ( & json! ( { " story_id " : " 999_story_nonexistent " } ) , & ctx ) . await ;
2026-03-22 19:07:07 +00:00
assert! ( result . is_err ( ) ) ;
2026-04-10 19:01:31 +00:00
assert! ( result . unwrap_err ( ) . contains ( " not found in the pipeline " ) ) ;
2026-03-22 19:07:07 +00:00
}
#[ tokio::test ]
2026-03-24 11:06:43 +00:00
async fn tool_status_returns_story_data ( ) {
2026-03-22 19:07:07 +00:00
let tmp = tempdir ( ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
crate ::db ::ensure_content_store ( ) ;
2026-03-22 19:07:07 +00:00
let story_content = " --- \n name: My Test Story \n agent: coder-1 \n --- \n \n ## Acceptance Criteria \n \n - [ ] First criterion \n - [x] Second criterion \n \n ## Out of Scope \n \n - nothing \n " ;
2026-04-10 14:56:13 +00:00
crate ::db ::write_item_with_content ( " 9886_story_status_test " , " 2_current " , story_content ) ;
2026-03-22 19:07:07 +00:00
let ctx = crate ::http ::context ::AppContext ::new_test ( tmp . path ( ) . to_path_buf ( ) ) ;
2026-04-10 14:56:13 +00:00
let result = tool_status ( & json! ( { " story_id " : " 9886_story_status_test " } ) , & ctx )
2026-03-22 19:07:07 +00:00
. await
. unwrap ( ) ;
let parsed : serde_json ::Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
2026-04-10 14:56:13 +00:00
assert_eq! ( parsed [ " story_id " ] , " 9886_story_status_test " ) ;
2026-03-22 19:07:07 +00:00
assert_eq! ( parsed [ " front_matter " ] [ " name " ] , " My Test Story " ) ;
assert_eq! ( parsed [ " front_matter " ] [ " agent " ] , " coder-1 " ) ;
let ac = parsed [ " acceptance_criteria " ] . as_array ( ) . unwrap ( ) ;
assert_eq! ( ac . len ( ) , 2 ) ;
assert_eq! ( ac [ 0 ] [ " text " ] , " First criterion " ) ;
assert_eq! ( ac [ 0 ] [ " checked " ] , false ) ;
assert_eq! ( ac [ 1 ] [ " text " ] , " Second criterion " ) ;
assert_eq! ( ac [ 1 ] [ " checked " ] , true ) ;
}
2026-04-10 19:01:31 +00:00
#[ tokio::test ]
async fn tool_status_works_for_story_in_backlog ( ) {
let tmp = tempdir ( ) . unwrap ( ) ;
crate ::db ::ensure_content_store ( ) ;
let story_content = " --- \n name: Backlog Story \n --- \n \n ## Acceptance Criteria \n \n - [ ] One thing \n " ;
crate ::db ::write_item_with_content ( " 9887_story_backlog_test " , " 1_backlog " , story_content ) ;
let ctx = crate ::http ::context ::AppContext ::new_test ( tmp . path ( ) . to_path_buf ( ) ) ;
let result = tool_status ( & json! ( { " story_id " : " 9887_story_backlog_test " } ) , & ctx )
. await
. unwrap ( ) ;
let parsed : serde_json ::Value = serde_json ::from_str ( & result ) . unwrap ( ) ;
assert_eq! ( parsed [ " story_id " ] , " 9887_story_backlog_test " ) ;
assert_eq! ( parsed [ " front_matter " ] [ " name " ] , " Backlog Story " ) ;
}
2026-03-22 19:07:07 +00:00
}