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

Commit c493542

Browse files
z23ccclaude
andcommitted
feat(db): cleanup residuals + update tests — complete DB-sole-truth migration
- Remove EPICS_DIR/TASKS_DIR from CRUD paths (only in init/detect/exchange) - Remove frontmatter imports from epic/task command modules - Fix query.rs scan fallback removal - Update TOML test expectations (next_json, validate_json) - Zero try_open_db references remaining Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d1d0a39 commit c493542

12 files changed

Lines changed: 112 additions & 143 deletions

File tree

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

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +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()
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+
})
2325
}
2426

2527
// ── Status command ──────────────────────────────────────────────────
@@ -221,9 +223,9 @@ fn is_daemon_heartbeat_alive(flow_dir: &Path) -> bool {
221223
fn find_interrupted_epics(_flow_dir: &Path) -> Vec<serde_json::Value> {
222224
let mut interrupted = Vec::new();
223225

224-
let conn = match try_open_db() {
225-
Some(c) => c,
226-
None => return interrupted,
226+
let conn = match crate::commands::db_shim::require_db() {
227+
Ok(c) => c,
228+
Err(_) => return interrupted,
227229
};
228230

229231
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
@@ -339,20 +341,18 @@ pub(super) fn validate_epic(flow_dir: &Path, epic_id: &str) -> (Vec<String>, Vec
339341
let mut tasks: std::collections::HashMap<String, flowctl_core::types::Task> =
340342
std::collections::HashMap::new();
341343

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);
347-
}
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) {
347+
for task in task_list {
348+
tasks.insert(task.id.clone(), task);
348349
}
349350
}
350351

351352
// Validate each task
352353
for (task_id, task) in &tasks {
353354
// 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);
355+
{
356356
match task_repo.get_with_body(task_id) {
357357
Ok((_t, body)) => {
358358
if body.is_empty() {
@@ -430,13 +430,13 @@ pub fn cmd_validate(json_mode: bool, epic: Option<String>, all: bool) {
430430
// Validate all epics
431431
let root_errors = validate_flow_root(&flow_dir);
432432

433-
let mut epic_ids: Vec<String> = Vec::new();
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();
438-
}
439-
}
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)
436+
.unwrap_or_default()
437+
.into_iter()
438+
.map(|e| e.id)
439+
.collect();
440440
epic_ids.sort();
441441

442442
let mut all_errors: Vec<String> = root_errors.clone();
@@ -552,7 +552,8 @@ pub fn cmd_doctor(json_mode: bool) {
552552
let root_errors = validate_flow_root(&flow_dir);
553553
let mut validate_errors = root_errors.clone();
554554

555-
if let Some(conn) = try_open_db() {
555+
{
556+
let conn = require_db();
556557
let epic_repo = crate::commands::db_shim::EpicRepo::new(&conn);
557558
if let Ok(epics) = epic_repo.list(None) {
558559
for epic in &epics {

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,8 +62,8 @@ pub fn open(working_dir: &Path) -> Result<Connection, DbError> {
6262
}
6363

6464
/// Open DB connection with hard error on failure (DB must be available).
65-
/// This is the preferred entry point — replaces `try_open_db()` which
66-
/// returned `Option<Connection>` and silently degraded to MD fallback.
65+
/// This is the preferred entry point — all CLI code should use this
66+
/// (DB is the sole source of truth, no fallback path).
6767
pub fn require_db() -> Result<Connection, DbError> {
6868
let cwd = std::env::current_dir()
6969
.map_err(|e| DbError::StateDir(format!("cannot get current dir: {e}")))?;

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

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,9 @@ use serde_json::json;
1212

1313
use crate::output::{error_exit, json_output};
1414

15-
use flowctl_core::frontmatter;
1615
use flowctl_core::id::{generate_epic_suffix, is_epic_id, parse_id, slugify};
1716
use flowctl_core::types::{
18-
Epic, EpicStatus, ReviewStatus, ARCHIVE_DIR, FLOW_DIR, META_FILE,
17+
Document, Epic, EpicStatus, ReviewStatus, ARCHIVE_DIR, FLOW_DIR, META_FILE,
1918
REVIEWS_DIR, SPECS_DIR,
2019
};
2120

@@ -169,18 +168,18 @@ fn validate_epic_id(id: &str) {
169168
}
170169

171170
/// Load epic document from DB (sole source of truth).
172-
fn load_epic(id: &str) -> frontmatter::Document<Epic> {
171+
fn load_epic(id: &str) -> Document<Epic> {
173172
let conn = require_db()
174173
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
175174
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
176175
match repo.get_with_body(id) {
177-
Ok((epic, body)) => frontmatter::Document { frontmatter: epic, body },
176+
Ok((epic, body)) => Document { frontmatter: epic, body },
178177
Err(_) => error_exit(&format!("Epic {id} not found")),
179178
}
180179
}
181180

182181
/// Write an epic document to DB (sole source of truth, no MD export).
183-
fn save_epic(doc: &frontmatter::Document<Epic>) {
182+
fn save_epic(doc: &Document<Epic>) {
184183
let conn = require_db()
185184
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
186185
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
@@ -297,7 +296,7 @@ fn cmd_create(title: &str, branch: &Option<String>, json_mode: bool) {
297296

298297
// Write to DB (sole source of truth)
299298
let body = create_epic_spec_body(&epic_id, title);
300-
let doc = frontmatter::Document {
299+
let doc = Document {
301300
frontmatter: epic,
302301
body: body.clone(),
303302
};

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

Lines changed: 38 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22
//!
33
//! Reads from SQLite as the sole source of truth.
44
5-
use std::env;
65
use std::fs;
76
use std::path::PathBuf;
87

@@ -28,12 +27,6 @@ fn ensure_flow_exists() -> PathBuf {
2827
flow_dir
2928
}
3029

31-
/// Try to open a DB connection. Returns None if DB doesn't exist or can't be opened.
32-
fn try_open_db() -> Option<crate::commands::db_shim::Connection> {
33-
let cwd = env::current_dir().ok()?;
34-
crate::commands::db_shim::open(&cwd).ok()
35-
}
36-
3730
/// Serialize an Epic to the JSON format matching Python output.
3831
fn epic_to_json(epic: &Epic) -> serde_json::Value {
3932
let spec_path = format!(".flow/specs/{}.md", epic.id);
@@ -65,15 +58,14 @@ fn task_to_json(task: &Task) -> serde_json::Value {
6558
let mut claimed_at: serde_json::Value = json!(null);
6659
let claim_note: serde_json::Value = json!("");
6760

68-
if let Some(conn) = try_open_db() {
69-
let runtime_repo = crate::commands::db_shim::RuntimeRepo::new(&conn);
70-
if let Ok(Some(state)) = runtime_repo.get(&task.id) {
71-
if let Some(a) = &state.assignee {
72-
assignee = json!(a);
73-
}
74-
if let Some(ca) = &state.claimed_at {
75-
claimed_at = json!(ca.to_rfc3339());
76-
}
61+
let conn = require_db();
62+
let runtime_repo = crate::commands::db_shim::RuntimeRepo::new(&conn);
63+
if let Ok(Some(state)) = runtime_repo.get(&task.id) {
64+
if let Some(a) = &state.assignee {
65+
assignee = json!(a);
66+
}
67+
if let Some(ca) = &state.claimed_at {
68+
claimed_at = json!(ca.to_rfc3339());
7769
}
7870
}
7971

@@ -132,38 +124,30 @@ fn require_db() -> crate::commands::db_shim::Connection {
132124

133125
/// Get a single epic by ID from DB.
134126
fn get_epic(_flow_dir: &PathBuf, id: &str) -> Option<Epic> {
135-
let conn = try_open_db()?;
127+
let conn = require_db();
136128
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
137129
repo.get(id).ok()
138130
}
139131

140132
/// Get a single task by ID from DB.
141133
fn get_task(_flow_dir: &PathBuf, id: &str) -> Option<Task> {
142-
let conn = try_open_db()?;
134+
let conn = require_db();
143135
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
144136
repo.get(id).ok()
145137
}
146138

147139
/// Get all tasks for an epic from DB.
148140
fn get_epic_tasks(_flow_dir: &PathBuf, epic_id: &str) -> Vec<Task> {
149-
if let Some(conn) = try_open_db() {
150-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
151-
if let Ok(tasks) = repo.list_by_epic(epic_id) {
152-
return tasks;
153-
}
154-
}
155-
Vec::new()
141+
let conn = require_db();
142+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
143+
repo.list_by_epic(epic_id).unwrap_or_default()
156144
}
157145

158146
/// Get all epics from DB.
159147
fn get_all_epics(_flow_dir: &PathBuf) -> Vec<Epic> {
160-
if let Some(conn) = try_open_db() {
161-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
162-
if let Ok(epics) = repo.list(None) {
163-
return epics;
164-
}
165-
}
166-
Vec::new()
148+
let conn = require_db();
149+
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
150+
repo.list(None).unwrap_or_default()
167151
}
168152

169153
/// Get all tasks, optionally filtered, from DB.
@@ -173,28 +157,21 @@ fn get_all_tasks(
173157
status_filter: Option<&str>,
174158
domain_filter: Option<&str>,
175159
) -> Vec<Task> {
176-
if let Some(conn) = try_open_db() {
177-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
178-
match epic_filter {
179-
Some(epic_id) => {
180-
if let Ok(mut tasks) = repo.list_by_epic(epic_id) {
181-
if let Some(status) = status_filter {
182-
tasks.retain(|t| t.status.to_string() == status);
183-
}
184-
if let Some(domain) = domain_filter {
185-
tasks.retain(|t| t.domain.to_string() == domain);
186-
}
187-
return tasks;
188-
}
160+
let conn = require_db();
161+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
162+
match epic_filter {
163+
Some(epic_id) => {
164+
let mut tasks = repo.list_by_epic(epic_id).unwrap_or_default();
165+
if let Some(status) = status_filter {
166+
tasks.retain(|t| t.status.to_string() == status);
189167
}
190-
None => {
191-
if let Ok(tasks) = repo.list_all(status_filter, domain_filter) {
192-
return tasks;
193-
}
168+
if let Some(domain) = domain_filter {
169+
tasks.retain(|t| t.domain.to_string() == domain);
194170
}
171+
tasks
195172
}
173+
None => repo.list_all(status_filter, domain_filter).unwrap_or_default(),
196174
}
197-
Vec::new()
198175
}
199176

200177
// ── Show command ────────────────────────────────────────────────────
@@ -550,24 +527,22 @@ pub fn cmd_files(json_mode: bool, epic: String) {
550527
let mut ownership: std::collections::BTreeMap<String, Vec<String>> =
551528
std::collections::BTreeMap::new();
552529

553-
let conn_opt = try_open_db();
530+
let conn = require_db();
554531
for task in &tasks {
555532
let mut task_files: Vec<String> = task.files.clone();
556533

557534
// Fallback: parse **Files:** from task body in DB if no structured files
558535
if task_files.is_empty() {
559-
if let Some(ref conn) = conn_opt {
560-
let repo = crate::commands::db_shim::TaskRepo::new(conn);
561-
if let Ok((_t, body)) = repo.get_with_body(&task.id) {
562-
for line in body.lines() {
563-
if let Some(rest) = line.strip_prefix("**Files:**") {
564-
task_files = rest
565-
.split(',')
566-
.map(|f| f.trim().trim_matches('`').to_string())
567-
.filter(|f| !f.is_empty())
568-
.collect();
569-
break;
570-
}
536+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
537+
if let Ok((_t, body)) = repo.get_with_body(&task.id) {
538+
for line in body.lines() {
539+
if let Some(rest) = line.strip_prefix("**Files:**") {
540+
task_files = rest
541+
.split(',')
542+
.map(|f| f.trim().trim_matches('`').to_string())
543+
.filter(|f| !f.is_empty())
544+
.collect();
545+
break;
571546
}
572547
}
573548
}

flowctl/crates/flowctl-cli/src/commands/task/create.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ pub(super) fn cmd_task_create(
130130
let body = create_task_spec(&task_id, title, acceptance.as_deref());
131131

132132
// Write to DB via write_task_doc (DB-only, no MD export)
133-
let doc = flowctl_core::frontmatter::Document {
133+
let doc = flowctl_core::types::Document {
134134
frontmatter: task,
135135
body,
136136
};

flowctl/crates/flowctl-cli/src/commands/task/mod.rs

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,9 @@ use regex::Regex;
1313

1414
use crate::output::error_exit;
1515

16-
use flowctl_core::frontmatter;
1716
use flowctl_core::id::epic_id_from_task;
1817
use flowctl_core::types::{
19-
Domain, Epic, Task,
18+
Document, Domain, Epic, Task,
2019
};
2120

2221
#[derive(Subcommand, Debug)]
@@ -194,20 +193,20 @@ fn load_epic_md(_flow_dir: &Path, epic_id: &str) -> Option<Epic> {
194193
}
195194

196195
/// Load task's full document (frontmatter + body) from DB (no MD fallback).
197-
fn load_task_doc(_flow_dir: &Path, task_id: &str) -> frontmatter::Document<Task> {
196+
fn load_task_doc(_flow_dir: &Path, task_id: &str) -> Document<Task> {
198197
let conn = require_db()
199198
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
200199
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
201200
let (task, body) = repo.get_with_body(task_id)
202201
.unwrap_or_else(|_| error_exit(&format!("Task {} not found", task_id)));
203-
frontmatter::Document {
202+
Document {
204203
frontmatter: task,
205204
body,
206205
}
207206
}
208207

209208
/// Write a task document to DB only (no MD export).
210-
fn write_task_doc(_flow_dir: &Path, task_id: &str, doc: &frontmatter::Document<Task>) {
209+
fn write_task_doc(_flow_dir: &Path, task_id: &str, doc: &Document<Task>) {
211210
let conn = require_db()
212211
.unwrap_or_else(|e| error_exit(&format!("DB required for write: {e}")));
213212
let repo = crate::commands::db_shim::TaskRepo::new(&conn);

flowctl/crates/flowctl-cli/src/commands/task/mutate.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -217,7 +217,7 @@ pub(super) fn cmd_task_split(json_mode: bool, task_id: &str, titles: &str, chain
217217
};
218218

219219
let body = create_task_spec(&sub_id, sub_title, None);
220-
let sub_doc = flowctl_core::frontmatter::Document {
220+
let sub_doc = flowctl_core::types::Document {
221221
frontmatter: sub_task,
222222
body,
223223
};

0 commit comments

Comments
 (0)