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

Commit 8bd0f33

Browse files
z23ccclaude
andcommitted
feat(task): remove MD file paths from task module, use DB as sole source of truth
- Remove MD fallback from load_task_md(), load_epic_md(), load_task_doc() - Remove MD file write from write_task_doc() (DB upsert_with_body only) - Replace filesystem scan_max_task_id() with DB max_task_num() query - Replace filesystem find_dependents() with DB list_by_epic() query - Remove duplicate DB upserts in create.rs, query.rs, mutate.rs (write_task_doc now handles all DB writes) - Replace MD collision check in task create with DB check - Replace filesystem-based dep update in task split with DB query - Remove unused imports: std::fs, TASKS_DIR, EPICS_DIR, is_task_id, frontmatter (from mutate.rs) Task: fn-2-db-as-sole-truth-db.3 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d655b51 commit 8bd0f33

4 files changed

Lines changed: 112 additions & 249 deletions

File tree

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

Lines changed: 14 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use crate::output::{error_exit, json_output};
77

88
use flowctl_core::id::{epic_id_from_task, is_epic_id, is_task_id};
99
use flowctl_core::state_machine::Status;
10-
use flowctl_core::types::{Domain, Task, FLOW_DIR, SPECS_DIR, TASKS_DIR};
10+
use flowctl_core::types::{Domain, Task, FLOW_DIR, SPECS_DIR};
1111

1212
use super::{
1313
create_task_spec, ensure_flow_exists, parse_domain, read_file_or_stdin, scan_max_task_id,
@@ -52,13 +52,15 @@ pub(super) fn cmd_task_create(
5252
let task_num = scan_max_task_id(&flow_dir, epic_id) + 1;
5353
let task_id = format!("{}.{}", epic_id, task_num);
5454

55-
// Check no collision
56-
let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id));
57-
if spec_path.exists() {
58-
error_exit(&format!(
59-
"Refusing to overwrite existing task {}. Check for orphaned files.",
60-
task_id
61-
));
55+
// Check no collision in DB
56+
if let Ok(conn) = require_db() {
57+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
58+
if repo.get(&task_id).is_ok() {
59+
error_exit(&format!(
60+
"Refusing to overwrite existing task {}. Check for orphaned entries.",
61+
task_id
62+
));
63+
}
6264
}
6365

6466
// Parse dependencies
@@ -119,28 +121,22 @@ pub(super) fn cmd_task_create(
119121
r#impl: None,
120122
review: None,
121123
sync: None,
122-
file_path: Some(format!("{}/{}/{}.md", FLOW_DIR, TASKS_DIR, task_id)),
124+
file_path: Some(format!("{}/tasks/{}.md", FLOW_DIR, task_id)),
123125
created_at: now,
124126
updated_at: now,
125127
};
126128

127129
// Create spec markdown body
128130
let body = create_task_spec(&task_id, title, acceptance.as_deref());
129131

130-
// Write Markdown file with frontmatter
132+
// Write to DB via write_task_doc (DB-only, no MD export)
131133
let doc = flowctl_core::frontmatter::Document {
132-
frontmatter: task.clone(),
134+
frontmatter: task,
133135
body,
134136
};
135137
write_task_doc(&flow_dir, &task_id, &doc);
136138

137-
// Upsert into SQLite if DB available
138-
if let Ok(conn) = require_db() {
139-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
140-
let _ = repo.upsert(&task);
141-
}
142-
143-
let spec_path_str = format!("{}/{}/{}.md", FLOW_DIR, TASKS_DIR, task_id);
139+
let spec_path_str = format!("{}/tasks/{}.md", FLOW_DIR, task_id);
144140
if json_mode {
145141
json_output(json!({
146142
"id": task_id,

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

Lines changed: 49 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ mod create;
55
mod mutate;
66
mod query;
77

8-
use std::fs;
98
use std::io::{self, Read as _};
109
use std::path::{Path, PathBuf};
1110

@@ -15,9 +14,9 @@ use regex::Regex;
1514
use crate::output::error_exit;
1615

1716
use flowctl_core::frontmatter;
18-
use flowctl_core::id::{epic_id_from_task, is_task_id};
17+
use flowctl_core::id::epic_id_from_task;
1918
use flowctl_core::types::{
20-
Domain, Epic, Task, EPICS_DIR, TASKS_DIR,
19+
Domain, Epic, Task,
2120
};
2221

2322
#[derive(Subcommand, Debug)]
@@ -143,34 +142,17 @@ fn read_file_or_stdin(path: &str) -> String {
143142
.unwrap_or_else(|e| error_exit(&format!("Failed to read stdin: {e}")));
144143
buf
145144
} else {
146-
fs::read_to_string(path)
145+
std::fs::read_to_string(path)
147146
.unwrap_or_else(|e| error_exit(&format!("Failed to read file '{}': {e}", path)))
148147
}
149148
}
150149

151-
/// Scan .flow/tasks/ to find max task number for an epic. Returns 0 if none exist.
152-
fn scan_max_task_id(flow_dir: &Path, epic_id: &str) -> u32 {
153-
let tasks_dir = flow_dir.join(TASKS_DIR);
154-
if !tasks_dir.exists() {
155-
return 0;
156-
}
157-
158-
let pattern = format!(r"^{}\.(\d+)\.md$", regex::escape(epic_id));
159-
let re = Regex::new(&pattern).expect("task ID regex is valid");
160-
161-
let mut max_n: u32 = 0;
162-
if let Ok(entries) = fs::read_dir(&tasks_dir) {
163-
for entry in entries.flatten() {
164-
let name = entry.file_name();
165-
let name_str = name.to_string_lossy();
166-
if let Some(caps) = re.captures(&name_str) {
167-
if let Ok(n) = caps[1].parse::<u32>() {
168-
max_n = max_n.max(n);
169-
}
170-
}
171-
}
172-
}
173-
max_n
150+
/// Get max task number for an epic from DB. Returns 0 if none exist.
151+
fn scan_max_task_id(_flow_dir: &Path, epic_id: &str) -> u32 {
152+
let conn = require_db()
153+
.unwrap_or_else(|e| error_exit(&format!("DB required for scan_max_task_id: {e}")));
154+
let n = crate::commands::db_shim::max_task_num(&conn, epic_id).unwrap_or(0);
155+
n as u32
174156
}
175157

176158
/// Parse a domain string into a Domain enum.
@@ -195,82 +177,43 @@ fn create_task_spec(id: &str, title: &str, acceptance: Option<&str>) -> String {
195177
)
196178
}
197179

198-
/// Load a task: DB first, markdown fallback.
180+
/// Load a task from DB (no MD fallback).
199181
fn load_task_md(_flow_dir: &Path, task_id: &str) -> Task {
200-
if let Ok(conn) = require_db() {
201-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
202-
if let Ok(task) = repo.get(task_id) {
203-
return task;
204-
}
205-
}
206-
// Fallback to markdown.
207-
let flow_dir = _flow_dir;
208-
let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id));
209-
if !spec_path.exists() {
210-
error_exit(&format!("Task {} not found", task_id));
211-
}
212-
let content = fs::read_to_string(&spec_path)
213-
.unwrap_or_else(|e| error_exit(&format!("Failed to read task {}: {e}", task_id)));
214-
let doc: frontmatter::Document<Task> = frontmatter::parse(&content)
215-
.unwrap_or_else(|e| error_exit(&format!("Failed to parse task {}: {e}", task_id)));
216-
doc.frontmatter
182+
let conn = require_db()
183+
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
184+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
185+
repo.get(task_id)
186+
.unwrap_or_else(|_| error_exit(&format!("Task {} not found", task_id)))
217187
}
218188

219-
/// Load an epic: DB first, markdown fallback.
189+
/// Load an epic from DB (no MD fallback).
220190
fn load_epic_md(_flow_dir: &Path, epic_id: &str) -> Option<Epic> {
221-
if let Ok(conn) = require_db() {
222-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
223-
if let Ok(epic) = repo.get(epic_id) {
224-
return Some(epic);
225-
}
226-
}
227-
let flow_dir = _flow_dir;
228-
let spec_path = flow_dir.join(EPICS_DIR).join(format!("{}.md", epic_id));
229-
if !spec_path.exists() {
230-
return None;
231-
}
232-
let content = fs::read_to_string(&spec_path).ok()?;
233-
let doc: frontmatter::Document<Epic> = frontmatter::parse(&content).ok()?;
234-
Some(doc.frontmatter)
191+
let conn = require_db().ok()?;
192+
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
193+
repo.get(epic_id).ok()
235194
}
236195

237-
/// Load task's full document (frontmatter + body): DB first, markdown fallback.
238-
fn load_task_doc(flow_dir: &Path, task_id: &str) -> frontmatter::Document<Task> {
239-
if let Ok(conn) = require_db() {
240-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
241-
if let Ok((task, body)) = repo.get_with_body(task_id) {
242-
return frontmatter::Document {
243-
frontmatter: task,
244-
body,
245-
};
246-
}
247-
}
248-
// Fallback to markdown.
249-
let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id));
250-
if !spec_path.exists() {
251-
error_exit(&format!("Task {} not found", task_id));
196+
/// 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> {
198+
let conn = require_db()
199+
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
200+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
201+
let (task, body) = repo.get_with_body(task_id)
202+
.unwrap_or_else(|_| error_exit(&format!("Task {} not found", task_id)));
203+
frontmatter::Document {
204+
frontmatter: task,
205+
body,
252206
}
253-
let content = fs::read_to_string(&spec_path)
254-
.unwrap_or_else(|e| error_exit(&format!("Failed to read task {}: {e}", task_id)));
255-
frontmatter::parse(&content)
256-
.unwrap_or_else(|e| error_exit(&format!("Failed to parse task {}: {e}", task_id)))
257207
}
258208

259-
/// Write a task document: DB first, then export markdown.
260-
fn write_task_doc(flow_dir: &Path, task_id: &str, doc: &frontmatter::Document<Task>) {
261-
// Write to DB.
262-
if let Ok(conn) = require_db() {
263-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
264-
if let Err(e) = repo.upsert_with_body(&doc.frontmatter, &doc.body) {
265-
eprintln!("warning: DB write failed for {task_id}: {e}");
266-
}
209+
/// 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>) {
211+
let conn = require_db()
212+
.unwrap_or_else(|e| error_exit(&format!("DB required for write: {e}")));
213+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
214+
if let Err(e) = repo.upsert_with_body(&doc.frontmatter, &doc.body) {
215+
error_exit(&format!("DB write failed for {task_id}: {e}"));
267216
}
268-
// Export to markdown.
269-
let spec_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id));
270-
let content = frontmatter::write(doc)
271-
.unwrap_or_else(|e| error_exit(&format!("Failed to serialize task {}: {e}", task_id)));
272-
fs::write(&spec_path, content)
273-
.unwrap_or_else(|e| error_exit(&format!("Failed to write task {}: {e}", task_id)));
274217
}
275218

276219
/// Patch a specific section in a Markdown body. Replaces content under `section`
@@ -319,38 +262,24 @@ fn patch_body_section(body: &str, section: &str, new_content: &str) -> String {
319262
result.join("\n")
320263
}
321264

322-
/// Find tasks that depend on a given task (recursive BFS within same epic).
323-
fn find_dependents(flow_dir: &Path, task_id: &str) -> Vec<String> {
324-
let tasks_dir = flow_dir.join(TASKS_DIR);
325-
if !tasks_dir.exists() {
326-
return vec![];
327-
}
328-
265+
/// Find tasks that depend on a given task (recursive BFS within same epic) via DB.
266+
fn find_dependents(_flow_dir: &Path, task_id: &str) -> Vec<String> {
329267
let epic_id = match epic_id_from_task(task_id) {
330268
Ok(id) => id,
331269
Err(_) => return vec![],
332270
};
333271

334-
// Load all tasks in the epic
335-
let mut all_tasks: Vec<(String, Vec<String>)> = Vec::new();
336-
if let Ok(entries) = fs::read_dir(&tasks_dir) {
337-
for entry in entries.flatten() {
338-
let name = entry.file_name();
339-
let name_str = name.to_string_lossy();
340-
if !name_str.starts_with(&epic_id) || !name_str.ends_with(".md") {
341-
continue;
342-
}
343-
let tid = name_str.trim_end_matches(".md").to_string();
344-
if !is_task_id(&tid) {
345-
continue;
346-
}
347-
if let Ok(content) = fs::read_to_string(entry.path()) {
348-
if let Ok(doc) = frontmatter::parse::<Task>(&content) {
349-
all_tasks.push((doc.frontmatter.id.clone(), doc.frontmatter.depends_on.clone()));
350-
}
351-
}
352-
}
353-
}
272+
let conn = match require_db() {
273+
Ok(c) => c,
274+
Err(_) => return vec![],
275+
};
276+
277+
// Load all tasks in the epic from DB
278+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
279+
let all_tasks: Vec<(String, Vec<String>)> = match repo.list_by_epic(&epic_id) {
280+
Ok(tasks) => tasks.into_iter().map(|t| (t.id.clone(), t.depends_on.clone())).collect(),
281+
Err(_) => return vec![],
282+
};
354283

355284
// BFS
356285
let mut dependents: Vec<String> = Vec::new();

0 commit comments

Comments
 (0)