@@ -16,6 +16,12 @@ use flowctl_core::types::{
1616
1717use super :: get_flow_dir;
1818
19+ /// Try to open a DB connection. Returns None if DB doesn't exist or can't be opened.
20+ fn try_open_db ( ) -> Option < crate :: commands:: db_shim:: Connection > {
21+ let cwd = env:: current_dir ( ) . ok ( ) ?;
22+ crate :: commands:: db_shim:: open ( & cwd) . ok ( )
23+ }
24+
1925// ── Status command ──────────────────────────────────────────────────
2026
2127pub fn cmd_status ( json : bool , interrupted : bool ) {
@@ -104,69 +110,14 @@ pub fn cmd_status(json: bool, interrupted: bool) {
104110 return ;
105111 }
106112
107- // Count epics and tasks by status using Markdown scanning
113+ // Count epics and tasks by status from DB (sole source of truth)
108114 let mut epic_counts = json ! ( { "open" : 0 , "done" : 0 } ) ;
109115 let mut task_counts = json ! ( { "todo" : 0 , "in_progress" : 0 , "blocked" : 0 , "done" : 0 } ) ;
110116
111117 if flow_exists {
112- let epics_dir = flow_dir. join ( EPICS_DIR ) ;
113- let tasks_dir = flow_dir. join ( TASKS_DIR ) ;
114-
115- // Try DB first, fall back to Markdown
116118 if let Some ( counts) = status_from_db ( ) {
117119 epic_counts = counts. 0 ;
118120 task_counts = counts. 1 ;
119- } else {
120- // Scan Markdown files
121- if epics_dir. is_dir ( ) {
122- if let Ok ( entries) = fs:: read_dir ( & epics_dir) {
123- for entry in entries. flatten ( ) {
124- let path = entry. path ( ) ;
125- if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
126- continue ;
127- }
128- let stem = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
129- if !flowctl_core:: id:: is_epic_id ( stem) {
130- continue ;
131- }
132- if let Ok ( content) = fs:: read_to_string ( & path) {
133- if let Ok ( epic) =
134- flowctl_core:: frontmatter:: parse_frontmatter :: < flowctl_core:: types:: Epic > ( & content)
135- {
136- let key = epic. status . to_string ( ) ;
137- if let Some ( count) = epic_counts. get_mut ( & key) {
138- * count = json ! ( count. as_u64( ) . unwrap_or( 0 ) + 1 ) ;
139- }
140- }
141- }
142- }
143- }
144- }
145-
146- if tasks_dir. is_dir ( ) {
147- if let Ok ( entries) = fs:: read_dir ( & tasks_dir) {
148- for entry in entries. flatten ( ) {
149- let path = entry. path ( ) ;
150- if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
151- continue ;
152- }
153- let stem = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
154- if !flowctl_core:: id:: is_task_id ( stem) {
155- continue ;
156- }
157- if let Ok ( content) = fs:: read_to_string ( & path) {
158- if let Ok ( task) =
159- flowctl_core:: frontmatter:: parse_frontmatter :: < flowctl_core:: types:: Task > ( & content)
160- {
161- let key = task. status . to_string ( ) ;
162- if let Some ( count) = task_counts. get_mut ( & key) {
163- * count = json ! ( count. as_u64( ) . unwrap_or( 0 ) + 1 ) ;
164- }
165- }
166- }
167- }
168- }
169- }
170121 }
171122 }
172123
@@ -220,12 +171,6 @@ fn status_from_db() -> Option<(serde_json::Value, serde_json::Value)> {
220171 }
221172 }
222173
223- // Check if there are actually any epics/tasks indexed
224- if epics. is_empty ( ) {
225- // DB might be empty (not yet indexed), fall back to Markdown
226- return None ;
227- }
228-
229174 let task_repo = crate :: commands:: db_shim:: TaskRepo :: new ( & conn) ;
230175 let tasks = task_repo. list_all ( None , None ) . ok ( ) ?;
231176
@@ -272,48 +217,33 @@ fn is_daemon_heartbeat_alive(flow_dir: &Path) -> bool {
272217 . unwrap_or ( false )
273218}
274219
275- /// Find open epics with undone tasks (interrupted work).
276- fn find_interrupted_epics ( flow_dir : & Path ) -> Vec < serde_json:: Value > {
220+ /// Find open epics with undone tasks (interrupted work) from DB .
221+ fn find_interrupted_epics ( _flow_dir : & Path ) -> Vec < serde_json:: Value > {
277222 let mut interrupted = Vec :: new ( ) ;
278- let epics_dir = flow_dir. join ( EPICS_DIR ) ;
279- let tasks_dir = flow_dir. join ( TASKS_DIR ) ;
280223
281- if !epics_dir. is_dir ( ) {
282- return interrupted;
283- }
224+ let conn = match try_open_db ( ) {
225+ Some ( c) => c,
226+ None => return interrupted,
227+ } ;
284228
285- // Collect all epics
286- let mut epic_entries : Vec < _ > = match fs :: read_dir ( & epics_dir ) {
287- Ok ( entries ) => entries . flatten ( ) . collect ( ) ,
229+ let epic_repo = crate :: commands :: db_shim :: EpicRepo :: new ( & conn ) ;
230+ let epics = match epic_repo . list ( None ) {
231+ Ok ( e ) => e ,
288232 Err ( _) => return interrupted,
289233 } ;
290- epic_entries. sort_by_key ( |e| e. path ( ) ) ;
291234
292- for entry in epic_entries {
293- let path = entry. path ( ) ;
294- if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
295- continue ;
296- }
297- let stem = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
298- if !flowctl_core:: id:: is_epic_id ( stem) {
235+ let task_repo = crate :: commands:: db_shim:: TaskRepo :: new ( & conn) ;
236+
237+ for epic in epics {
238+ if epic. status != flowctl_core:: types:: EpicStatus :: Open {
299239 continue ;
300240 }
301241
302- let content = match fs:: read_to_string ( & path) {
303- Ok ( c) => c,
304- Err ( _) => continue ,
305- } ;
306-
307- let epic = match flowctl_core:: frontmatter:: parse_frontmatter :: < flowctl_core:: types:: Epic > ( & content) {
308- Ok ( e) => e,
242+ let tasks = match task_repo. list_by_epic ( & epic. id ) {
243+ Ok ( t) => t,
309244 Err ( _) => continue ,
310245 } ;
311246
312- if epic. status != flowctl_core:: types:: EpicStatus :: Open {
313- continue ;
314- }
315-
316- // Count tasks for this epic and collect in_progress task IDs
317247 let mut counts = std:: collections:: HashMap :: new ( ) ;
318248 counts. insert ( "todo" , 0u64 ) ;
319249 counts. insert ( "in_progress" , 0u64 ) ;
@@ -322,34 +252,13 @@ fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
322252 counts. insert ( "skipped" , 0u64 ) ;
323253 let mut stale_task_ids: Vec < String > = Vec :: new ( ) ;
324254
325- if tasks_dir. is_dir ( ) {
326- if let Ok ( task_entries) = fs:: read_dir ( & tasks_dir) {
327- for task_entry in task_entries. flatten ( ) {
328- let task_path = task_entry. path ( ) ;
329- if task_path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
330- continue ;
331- }
332- let task_stem = task_path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
333- if !flowctl_core:: id:: is_task_id ( task_stem) {
334- continue ;
335- }
336- if let Ok ( task_content) = fs:: read_to_string ( & task_path) {
337- if let Ok ( task) =
338- flowctl_core:: frontmatter:: parse_frontmatter :: < flowctl_core:: types:: Task > ( & task_content)
339- {
340- if task. epic != epic. id {
341- continue ;
342- }
343- let status_key = task. status . to_string ( ) ;
344- if status_key == "in_progress" {
345- stale_task_ids. push ( task. id . clone ( ) ) ;
346- }
347- if let Some ( count) = counts. get_mut ( status_key. as_str ( ) ) {
348- * count += 1 ;
349- }
350- }
351- }
352- }
255+ for task in & tasks {
256+ let status_key = task. status . to_string ( ) ;
257+ if status_key == "in_progress" {
258+ stale_task_ids. push ( task. id . clone ( ) ) ;
259+ }
260+ if let Some ( count) = counts. get_mut ( status_key. as_str ( ) ) {
261+ * count += 1 ;
353262 }
354263 }
355264 stale_task_ids. sort ( ) ;
@@ -426,60 +335,38 @@ pub(super) fn validate_epic(flow_dir: &Path, epic_id: &str) -> (Vec<String>, Vec
426335 let mut errors = Vec :: new ( ) ;
427336 let mut warnings = Vec :: new ( ) ;
428337
429- let tasks_dir = flow_dir. join ( TASKS_DIR ) ;
430-
431- // Scan tasks for this epic from Markdown frontmatter
338+ // Read tasks from DB (sole source of truth)
432339 let mut tasks: std:: collections:: HashMap < String , flowctl_core:: types:: Task > =
433340 std:: collections:: HashMap :: new ( ) ;
434341
435- if tasks_dir. is_dir ( ) {
436- if let Ok ( entries) = fs:: read_dir ( & tasks_dir) {
437- for entry in entries. flatten ( ) {
438- let path = entry. path ( ) ;
439- if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
440- continue ;
441- }
442- let stem = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
443- if !flowctl_core:: id:: is_task_id ( stem) {
444- continue ;
445- }
446- // Check if task belongs to this epic
447- if !stem. starts_with ( & format ! ( "{}." , epic_id) ) {
448- continue ;
449- }
450- if let Ok ( content) = fs:: read_to_string ( & path) {
451- match flowctl_core:: frontmatter:: parse_frontmatter :: < flowctl_core:: types:: Task > (
452- & content,
453- ) {
454- Ok ( task) => {
455- tasks. insert ( task. id . clone ( ) , task) ;
456- }
457- Err ( e) => {
458- let path_str = path. display ( ) . to_string ( ) ;
459- errors. push ( format ! ( "Task {}: frontmatter parse error: {}" , stem, e) ) ;
460- crate :: diagnostics:: report_frontmatter_error (
461- & path_str,
462- & content,
463- & e. to_string ( ) ,
464- ) ;
465- }
466- }
467- }
342+ if let Some ( conn) = try_open_db ( ) {
343+ let task_repo = crate :: commands:: db_shim:: TaskRepo :: new ( & conn) ;
344+ if let Ok ( task_list) = task_repo. list_by_epic ( epic_id) {
345+ for task in task_list {
346+ tasks. insert ( task. id . clone ( ) , task) ;
468347 }
469348 }
470349 }
471350
472351 // Validate each task
473352 for ( task_id, task) in & tasks {
474- // Check task spec exists
475- let task_spec_path = tasks_dir. join ( format ! ( "{}.md" , task_id) ) ;
476- if !task_spec_path. exists ( ) {
477- errors. push ( format ! ( "Task spec missing: {}" , task_spec_path. display( ) ) ) ;
478- } else if let Ok ( spec_content) = fs:: read_to_string ( & task_spec_path) {
479- // Validate required headings
480- for heading in flowctl_core:: types:: TASK_SPEC_HEADINGS {
481- if !spec_content. contains ( heading) {
482- errors. push ( format ! ( "Task {}: missing required heading '{}'" , task_id, heading) ) ;
353+ // Validate task body has required headings (read from DB)
354+ if let Some ( conn) = try_open_db ( ) {
355+ let task_repo = crate :: commands:: db_shim:: TaskRepo :: new ( & conn) ;
356+ match task_repo. get_with_body ( task_id) {
357+ Ok ( ( _t, body) ) => {
358+ if body. is_empty ( ) {
359+ warnings. push ( format ! ( "Task {}: no body in DB" , task_id) ) ;
360+ } else {
361+ for heading in flowctl_core:: types:: TASK_SPEC_HEADINGS {
362+ if !body. contains ( heading) {
363+ errors. push ( format ! ( "Task {}: missing required heading '{}'" , task_id, heading) ) ;
364+ }
365+ }
366+ }
367+ }
368+ Err ( _) => {
369+ errors. push ( format ! ( "Task {}: could not read body from DB" , task_id) ) ;
483370 }
484371 }
485372 }
@@ -542,21 +429,12 @@ pub fn cmd_validate(json_mode: bool, epic: Option<String>, all: bool) {
542429 if all {
543430 // Validate all epics
544431 let root_errors = validate_flow_root ( & flow_dir) ;
545- let epics_dir = flow_dir. join ( EPICS_DIR ) ;
546432
547433 let mut epic_ids: Vec < String > = Vec :: new ( ) ;
548- if epics_dir. is_dir ( ) {
549- if let Ok ( entries) = fs:: read_dir ( & epics_dir) {
550- for entry in entries. flatten ( ) {
551- let path = entry. path ( ) ;
552- if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
553- continue ;
554- }
555- let stem = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
556- if flowctl_core:: id:: is_epic_id ( stem) {
557- epic_ids. push ( stem. to_string ( ) ) ;
558- }
559- }
434+ if let Some ( conn) = try_open_db ( ) {
435+ let epic_repo = crate :: commands:: db_shim:: EpicRepo :: new ( & conn) ;
436+ if let Ok ( epics) = epic_repo. list ( None ) {
437+ epic_ids = epics. into_iter ( ) . map ( |e| e. id ) . collect ( ) ;
560438 }
561439 }
562440 epic_ids. sort ( ) ;
@@ -672,21 +550,14 @@ pub fn cmd_doctor(json_mode: bool) {
672550
673551 // Check 1: Run validate --all internally
674552 let root_errors = validate_flow_root ( & flow_dir) ;
675- let epics_dir = flow_dir. join ( EPICS_DIR ) ;
676553 let mut validate_errors = root_errors. clone ( ) ;
677554
678- if epics_dir. is_dir ( ) {
679- if let Ok ( entries) = fs:: read_dir ( & epics_dir) {
680- for entry in entries. flatten ( ) {
681- let path = entry. path ( ) ;
682- if path. extension ( ) . and_then ( |e| e. to_str ( ) ) != Some ( "md" ) {
683- continue ;
684- }
685- let stem = path. file_stem ( ) . and_then ( |s| s. to_str ( ) ) . unwrap_or ( "" ) ;
686- if flowctl_core:: id:: is_epic_id ( stem) {
687- let ( errors, _, _) = validate_epic ( & flow_dir, stem) ;
688- validate_errors. extend ( errors) ;
689- }
555+ if let Some ( conn) = try_open_db ( ) {
556+ let epic_repo = crate :: commands:: db_shim:: EpicRepo :: new ( & conn) ;
557+ if let Ok ( epics) = epic_repo. list ( None ) {
558+ for epic in & epics {
559+ let ( errors, _, _) = validate_epic ( & flow_dir, & epic. id ) ;
560+ validate_errors. extend ( errors) ;
690561 }
691562 }
692563 }
0 commit comments