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

Commit 960811b

Browse files
z23ccclaude
andcommitted
feat(cli): migrate epic, workflow, admin from DB to json_store
Complete CLI migration: epic.rs (all commands), workflow/mod.rs (helpers), workflow/scheduling.rs (runtime), admin/status.rs (status/validate/doctor). Zero DB calls remain for epic/task data in these modules. Net deletion of 89 lines (159 removed, 70 added) — simpler code without DB boilerplate. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ef46402 commit 960811b

4 files changed

Lines changed: 70 additions & 159 deletions

File tree

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

Lines changed: 17 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,6 @@ use flowctl_core::types::{
1616

1717
use super::get_flow_dir;
1818

19-
/// Open DB connection (hard error on failure, DB is sole source of truth).
20-
fn require_db() -> crate::commands::db_shim::Connection {
21-
crate::commands::db_shim::require_db()
22-
.unwrap_or_else(|e| {
23-
crate::output::error_exit(&format!("Cannot open database: {e}"));
24-
})
25-
}
2619

2720
// ── Status command ──────────────────────────────────────────────────
2821

@@ -156,13 +149,10 @@ pub fn cmd_status(json: bool, interrupted: bool) {
156149
}
157150
}
158151

159-
/// Try to get status counts from SQLite database.
152+
/// Try to get status counts from JSON files.
160153
fn status_from_db() -> Option<(serde_json::Value, serde_json::Value)> {
161-
let cwd = env::current_dir().ok()?;
162-
let conn = crate::commands::db_shim::open(&cwd).ok()?;
163-
164-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
165-
let epics = epic_repo.list(None).ok()?;
154+
let flow_dir = crate::commands::helpers::get_flow_dir();
155+
let epics = flowctl_core::json_store::epic_list(&flow_dir).ok()?;
166156

167157
let mut epic_open = 0u64;
168158
let mut epic_done = 0u64;
@@ -173,8 +163,7 @@ fn status_from_db() -> Option<(serde_json::Value, serde_json::Value)> {
173163
}
174164
}
175165

176-
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
177-
let tasks = task_repo.list_all(None, None).ok()?;
166+
let tasks = flowctl_core::json_store::task_list_all(&flow_dir).ok()?;
178167

179168
let mut todo = 0u64;
180169
let mut in_progress = 0u64;
@@ -219,29 +208,21 @@ fn is_daemon_heartbeat_alive(flow_dir: &Path) -> bool {
219208
.unwrap_or(false)
220209
}
221210

222-
/// Find open epics with undone tasks (interrupted work) from DB.
223-
fn find_interrupted_epics(_flow_dir: &Path) -> Vec<serde_json::Value> {
211+
/// Find open epics with undone tasks (interrupted work) from JSON files.
212+
fn find_interrupted_epics(flow_dir: &Path) -> Vec<serde_json::Value> {
224213
let mut interrupted = Vec::new();
225214

226-
let conn = match crate::commands::db_shim::require_db() {
227-
Ok(c) => c,
228-
Err(_) => return interrupted,
229-
};
230-
231-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
232-
let epics = match epic_repo.list(None) {
215+
let epics = match flowctl_core::json_store::epic_list(flow_dir) {
233216
Ok(e) => e,
234217
Err(_) => return interrupted,
235218
};
236219

237-
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
238-
239220
for epic in epics {
240221
if epic.status != flowctl_core::types::EpicStatus::Open {
241222
continue;
242223
}
243224

244-
let tasks = match task_repo.list_by_epic(&epic.id) {
225+
let tasks = match flowctl_core::json_store::task_list_by_epic(flow_dir, &epic.id) {
245226
Ok(t) => t,
246227
Err(_) => continue,
247228
};
@@ -337,26 +318,24 @@ pub(super) fn validate_epic(flow_dir: &Path, epic_id: &str) -> (Vec<String>, Vec
337318
let mut errors = Vec::new();
338319
let mut warnings = Vec::new();
339320

340-
// Read tasks from DB (sole source of truth)
321+
// Read tasks from JSON files
341322
let mut tasks: std::collections::HashMap<String, flowctl_core::types::Task> =
342323
std::collections::HashMap::new();
343324

344-
let conn = require_db();
345-
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
346-
if let Ok(task_list) = task_repo.list_by_epic(epic_id) {
325+
if let Ok(task_list) = flowctl_core::json_store::task_list_by_epic(flow_dir, epic_id) {
347326
for task in task_list {
348327
tasks.insert(task.id.clone(), task);
349328
}
350329
}
351330

352331
// Validate each task
353332
for (task_id, task) in &tasks {
354-
// Validate task body has required headings (read from DB)
333+
// Validate task body has required headings (read from JSON)
355334
{
356-
match task_repo.get_with_body(task_id) {
357-
Ok((_t, body)) => {
335+
match flowctl_core::json_store::task_spec_read(flow_dir, task_id) {
336+
Ok(body) => {
358337
if body.is_empty() {
359-
warnings.push(format!("Task {}: no body in DB", task_id));
338+
warnings.push(format!("Task {}: no spec body", task_id));
360339
} else {
361340
for heading in flowctl_core::types::TASK_SPEC_HEADINGS {
362341
if !body.contains(heading) {
@@ -366,7 +345,7 @@ pub(super) fn validate_epic(flow_dir: &Path, epic_id: &str) -> (Vec<String>, Vec
366345
}
367346
}
368347
Err(_) => {
369-
errors.push(format!("Task {}: could not read body from DB", task_id));
348+
errors.push(format!("Task {}: could not read spec", task_id));
370349
}
371350
}
372351
}
@@ -430,9 +409,7 @@ pub fn cmd_validate(json_mode: bool, epic: Option<String>, all: bool) {
430409
// Validate all epics
431410
let root_errors = validate_flow_root(&flow_dir);
432411

433-
let conn = require_db();
434-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
435-
let mut epic_ids: Vec<String> = epic_repo.list(None)
412+
let mut epic_ids: Vec<String> = flowctl_core::json_store::epic_list(&flow_dir)
436413
.unwrap_or_default()
437414
.into_iter()
438415
.map(|e| e.id)
@@ -553,9 +530,7 @@ pub fn cmd_doctor(json_mode: bool) {
553530
let mut validate_errors = root_errors.clone();
554531

555532
{
556-
let conn = require_db();
557-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
558-
if let Ok(epics) = epic_repo.list(None) {
533+
if let Ok(epics) = flowctl_core::json_store::epic_list(&flow_dir) {
559534
for epic in &epics {
560535
let (errors, _, _) = validate_epic(&flow_dir, &epic.id);
561536
validate_errors.extend(errors);

flowctl/crates/flowctl-cli/src/commands/epic.rs

Lines changed: 33 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -194,11 +194,6 @@ fn find_max_epic_number() -> u32 {
194194
flowctl_core::json_store::epic_max_num(&flow_dir).unwrap_or(0)
195195
}
196196

197-
/// Bridge: DB connection for functions not yet migrated to json_store.
198-
/// TODO(fn-24): Remove once all epic commands use json_store.
199-
fn require_db() -> Result<crate::commands::db_shim::Connection, crate::commands::db_shim::DbError> {
200-
crate::commands::db_shim::require_db()
201-
}
202197

203198
/// Create default epic spec Markdown body.
204199
fn create_epic_spec_body(id: &str, title: &str) -> String {
@@ -460,12 +455,9 @@ fn cmd_set_title(id: &str, new_title: &str, json_mode: bool) {
460455

461456
let specs_dir = flow_dir.join(SPECS_DIR);
462457

463-
// Check collision (if ID changed) via DB
458+
// Check collision (if ID changed) via JSON
464459
if new_id != old_id {
465-
let conn = require_db()
466-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
467-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
468-
if repo.get(&new_id).is_ok() {
460+
if flowctl_core::json_store::epic_read(&flow_dir, &new_id).is_ok() {
469461
error_exit(&format!(
470462
"Epic {new_id} already exists. Choose a different title."
471463
));
@@ -501,11 +493,8 @@ fn cmd_set_title(id: &str, new_title: &str, json_mode: bool) {
501493
new_doc.frontmatter.updated_at = Utc::now();
502494
save_epic(&new_doc);
503495

504-
// Update task records in DB
505-
let conn = require_db()
506-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
507-
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
508-
let tasks = task_repo.list_by_epic(old_id).unwrap_or_default();
496+
// Update task records via JSON files
497+
let tasks = flowctl_core::json_store::task_list_by_epic(&flow_dir, old_id).unwrap_or_default();
509498
let mut task_renames: Vec<(String, String)> = Vec::new();
510499
for task in &tasks {
511500
if let Ok(p) = parse_id(&task.id) {
@@ -540,23 +529,19 @@ fn cmd_set_title(id: &str, new_title: &str, json_mode: bool) {
540529
})
541530
.collect();
542531
updated_task.updated_at = Utc::now();
543-
let _ = task_repo.upsert(&updated_task);
532+
// Delete old files, write new ones
533+
let _ = flowctl_core::json_store::task_delete(&flow_dir, &task.id);
534+
let _ = flowctl_core::json_store::task_write_definition(&flow_dir, &updated_task);
544535
}
545536

546-
// Update depends_on_epics in other epics that reference old_id (via DB)
537+
// Update depends_on_epics in other epics that reference old_id
547538
let mut updated_deps_in: Vec<String> = Vec::new();
548-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
549-
if let Ok(all_epics) = epic_repo.list(None) {
550-
let dep_repo = crate::commands::db_shim::DepRepo::new(&conn);
539+
if let Ok(all_epics) = flowctl_core::json_store::epic_list(&flow_dir) {
551540
for other_epic in &all_epics {
552541
if other_epic.id == new_id || other_epic.id == old_id {
553542
continue;
554543
}
555544
if other_epic.depends_on_epics.contains(&old_id.to_string()) {
556-
// Update: remove old dep, add new one
557-
let _ = dep_repo.remove_epic_dep(&other_epic.id, old_id);
558-
let _ = dep_repo.add_epic_dep(&other_epic.id, &new_id);
559-
// Also update the Epic struct's depends_on_epics
560545
let mut updated_other = other_epic.clone();
561546
updated_other.depends_on_epics = updated_other
562547
.depends_on_epics
@@ -570,7 +555,7 @@ fn cmd_set_title(id: &str, new_title: &str, json_mode: bool) {
570555
})
571556
.collect();
572557
updated_other.updated_at = Utc::now();
573-
let _ = epic_repo.upsert(&updated_other);
558+
let _ = flowctl_core::json_store::epic_write(&flow_dir, &updated_other);
574559
updated_deps_in.push(other_epic.id.clone());
575560
}
576561
}
@@ -677,14 +662,10 @@ fn cmd_reopen(id: &str, json_mode: bool) {
677662
let flow_dir = ensure_flow_exists();
678663
validate_epic_id(id);
679664

680-
// Check if archived (check .archive/ dir for specs/reviews)
665+
// Check if archived
681666
let archive_path = flow_dir.join(ARCHIVE_DIR).join(id);
682667
if archive_path.exists() {
683-
// Check if epic is marked archived in DB
684-
let conn = require_db()
685-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
686-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
687-
if let Ok(epic) = repo.get(id) {
668+
if let Ok(epic) = flowctl_core::json_store::epic_read(&flow_dir, id) {
688669
if epic.archived {
689670
error_exit(&format!(
690671
"Epic {id} is archived. Unarchive it first before reopening."
@@ -793,15 +774,11 @@ fn cmd_archive(id: &str, force: bool, json_mode: bool) {
793774
fn cmd_clean(json_mode: bool) {
794775
let flow_dir = ensure_flow_exists();
795776

796-
let conn = require_db()
797-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
798-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
799-
800777
let mut archived: Vec<String> = Vec::new();
801778

802-
if let Ok(epics) = epic_repo.list(Some("done")) {
779+
if let Ok(epics) = flowctl_core::json_store::epic_list(&flow_dir) {
803780
for epic in &epics {
804-
if !epic.archived {
781+
if epic.status == EpicStatus::Done && !epic.archived {
805782
cmd_archive_silent(&epic.id, &flow_dir);
806783
archived.push(epic.id.clone());
807784
}
@@ -827,14 +804,11 @@ fn cmd_clean(json_mode: bool) {
827804
/// Silent archive helper for clean command (no output).
828805
/// Sets archived=true in DB, moves only specs and reviews to .archive/.
829806
fn cmd_archive_silent(id: &str, flow_dir: &Path) {
830-
// Set archived=true in DB
831-
if let Ok(conn) = require_db() {
832-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
833-
if let Ok(mut epic) = repo.get(id) {
834-
epic.archived = true;
835-
epic.updated_at = Utc::now();
836-
let _ = repo.upsert(&epic);
837-
}
807+
// Set archived=true in JSON
808+
if let Ok(mut epic) = flowctl_core::json_store::epic_read(flow_dir, id) {
809+
epic.archived = true;
810+
epic.updated_at = Utc::now();
811+
let _ = flowctl_core::json_store::epic_write(flow_dir, &epic);
838812
}
839813

840814
let archive_dir = flow_dir.join(ARCHIVE_DIR).join(id);
@@ -870,11 +844,9 @@ fn cmd_add_dep(epic_id: &str, dep_id: &str, json_mode: bool) {
870844
error_exit("Epic cannot depend on itself");
871845
}
872846

873-
// Verify dep epic exists in DB
874-
let conn = require_db()
875-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
876-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
877-
if repo.get(dep_id).is_err() {
847+
// Verify dep epic exists
848+
let flow_dir = get_flow_dir();
849+
if flowctl_core::json_store::epic_read(&flow_dir, dep_id).is_err() {
878850
error_exit(&format!("Epic {dep_id} not found"));
879851
}
880852

@@ -893,12 +865,6 @@ fn cmd_add_dep(epic_id: &str, dep_id: &str, json_mode: bool) {
893865
return;
894866
}
895867

896-
// Update DB via DepRepo
897-
let dep_repo = crate::commands::db_shim::DepRepo::new(&conn);
898-
if let Err(e) = dep_repo.add_epic_dep(epic_id, dep_id) {
899-
error_exit(&format!("Failed to add epic dep: {e}"));
900-
}
901-
902868
doc.frontmatter.depends_on_epics.push(dep_id.to_string());
903869
doc.frontmatter.updated_at = Utc::now();
904870
save_epic(&doc);
@@ -933,14 +899,6 @@ fn cmd_rm_dep(epic_id: &str, dep_id: &str, json_mode: bool) {
933899
return;
934900
}
935901

936-
// Update DB via DepRepo
937-
let conn = require_db()
938-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
939-
let dep_repo = crate::commands::db_shim::DepRepo::new(&conn);
940-
if let Err(e) = dep_repo.remove_epic_dep(epic_id, dep_id) {
941-
error_exit(&format!("Failed to remove epic dep: {e}"));
942-
}
943-
944902
doc.frontmatter
945903
.depends_on_epics
946904
.retain(|d| d != dep_id);
@@ -1141,9 +1099,8 @@ fn cmd_audit(id: &str, force: bool, json_mode: bool) {
11411099
let epic_doc = load_epic(id);
11421100
let epic_body = epic_doc.body.clone();
11431101

1144-
// Load tasks from DB.
1145-
let conn = require_db().ok();
1146-
let tasks: Vec<flowctl_core::types::Task> = load_epic_tasks(conn.as_ref(), &flow_dir, id);
1102+
// Load tasks from JSON.
1103+
let tasks: Vec<flowctl_core::types::Task> = flowctl_core::json_store::task_list_by_epic(&flow_dir, id).unwrap_or_default();
11471104

11481105
// Shape task summaries for the payload.
11491106
let task_entries: Vec<serde_json::Value> = tasks
@@ -1255,12 +1212,10 @@ pub fn cmd_replay(json_mode: bool, epic_id: &str, dry_run: bool, force: bool) {
12551212
ensure_flow_exists();
12561213
validate_epic_id(epic_id);
12571214

1258-
let conn = require_db()
1259-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
1215+
let flow_dir = get_flow_dir();
12601216

1261-
// Load tasks from DB
1262-
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
1263-
let tasks = task_repo.list_by_epic(epic_id).unwrap_or_default();
1217+
// Load tasks from JSON
1218+
let tasks = flowctl_core::json_store::task_list_by_epic(&flow_dir, epic_id).unwrap_or_default();
12641219
if tasks.is_empty() {
12651220
error_exit(&format!("No tasks found for epic {}", epic_id));
12661221
}
@@ -1304,11 +1259,12 @@ pub fn cmd_replay(json_mode: bool, epic_id: &str, dry_run: bool, force: bool) {
13041259
return;
13051260
}
13061261

1307-
// Reset all tasks to todo in DB only
1262+
// Reset all tasks to todo via JSON state
13081263
let mut reset_count = 0;
13091264
for task in &to_reset {
1310-
if let Err(e) = task_repo.update_status(&task.id, flowctl_core::state_machine::Status::Todo) {
1311-
eprintln!("Warning: failed to reset {} in DB: {}", task.id, e);
1265+
let blank = flowctl_core::json_store::TaskState::default();
1266+
if let Err(e) = flowctl_core::json_store::state_write(&flow_dir, &task.id, &blank) {
1267+
eprintln!("Warning: failed to reset {} state: {}", task.id, e);
13121268
}
13131269
reset_count += 1;
13141270
}
@@ -1328,20 +1284,6 @@ pub fn cmd_replay(json_mode: bool, epic_id: &str, dry_run: bool, force: bool) {
13281284
}
13291285
}
13301286

1331-
/// Load tasks for an epic from DB (sole source of truth).
1332-
fn load_epic_tasks(
1333-
conn: Option<&crate::commands::db_shim::Connection>,
1334-
_flow_dir: &Path,
1335-
epic_id: &str,
1336-
) -> Vec<flowctl_core::types::Task> {
1337-
if let Some(c) = conn {
1338-
let task_repo = crate::commands::db_shim::TaskRepo::new(c);
1339-
if let Ok(tasks) = task_repo.list_by_epic(epic_id) {
1340-
return tasks;
1341-
}
1342-
}
1343-
Vec::new()
1344-
}
13451287

13461288
// ── Diff command ────────────��───────────────────────────���──────────
13471289

@@ -1452,8 +1394,7 @@ pub fn cmd_diff(json_mode: bool, epic_id: &str) {
14521394

14531395
/// Load branch name for an epic from DB (sole source of truth).
14541396
fn load_epic_branch(epic_id: &str) -> Option<String> {
1455-
let conn = require_db().ok()?;
1456-
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
1457-
let epic = epic_repo.get(epic_id).ok()?;
1397+
let flow_dir = get_flow_dir();
1398+
let epic = flowctl_core::json_store::epic_read(&flow_dir, epic_id).ok()?;
14581399
epic.branch_name.filter(|b| !b.is_empty())
14591400
}

0 commit comments

Comments
 (0)