Skip to content
This repository was archived by the owner on Apr 11, 2026. It is now read-only.

Commit d1d0a39

Browse files
z23ccclaude
andcommitted
feat(db): remove MD dual-write from lifecycle, query, workflow, admin
- lifecycle.rs: remove all 13 fs::write sites for task MD updates - query.rs: delete scan_epics_md/scan_tasks_md, cmd_cat reads DB body for tasks - workflow/mod.rs: remove MD fallback from load helpers - admin/status.rs: validate/doctor use DB instead of filesystem scan Net: -620 lines (185 added, 805 removed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 8bd0f33 commit d1d0a39

5 files changed

Lines changed: 184 additions & 804 deletions

File tree

flowctl/crates/flowctl-cli/src/commands/admin/status.rs

Lines changed: 62 additions & 191 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ use flowctl_core::types::{
1616

1717
use 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

2127
pub 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

Comments
 (0)