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

Commit d655b51

Browse files
z23ccclaude
andcommitted
feat(db): epic.rs + gap.rs + dep.rs DB-only — remove MD dual-write
- epic.rs: remove all MD fallback reads and frontmatter writes for epics - epic.rs: archive sets DB archived flag instead of moving MD files - epic.rs: find_max_epic_number uses DB query instead of dir scan - gap.rs: migrate from epics/{id}.gaps.json sidecar to DB gaps table - dep.rs: remove MD canonical pattern, DB is authoritative - task/create.rs: check epic existence via DB instead of epics/{id}.md Net: -389 lines (493 added, 882 removed) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent f8ad320 commit d655b51

6 files changed

Lines changed: 490 additions & 879 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,22 @@ impl DepRepo {
221221
block_on(flowctl_db::DepRepo::new(self.0.clone()).list_task_deps(task_id))
222222
}
223223

224+
pub fn add_epic_dep(&self, epic_id: &str, depends_on: &str) -> Result<(), DbError> {
225+
block_on(
226+
flowctl_db::DepRepo::new(self.0.clone()).add_epic_dep(epic_id, depends_on),
227+
)
228+
}
229+
230+
pub fn remove_epic_dep(&self, epic_id: &str, depends_on: &str) -> Result<(), DbError> {
231+
block_on(
232+
flowctl_db::DepRepo::new(self.0.clone()).remove_epic_dep(epic_id, depends_on),
233+
)
234+
}
235+
236+
pub fn list_epic_deps(&self, epic_id: &str) -> Result<Vec<String>, DbError> {
237+
block_on(flowctl_db::DepRepo::new(self.0.clone()).list_epic_deps(epic_id))
238+
}
239+
224240
/// Replace all deps for a task (delete-all + insert each).
225241
pub fn replace_task_deps(&self, task_id: &str, deps: &[String]) -> Result<(), DbError> {
226242
let inner = self.0.clone();

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

Lines changed: 43 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,14 @@
11
//! Dependency commands: dep add, dep rm.
22
//!
3-
//! Updates both the Markdown frontmatter (canonical) and SQLite (cache).
4-
5-
use std::env;
6-
use std::fs;
7-
use std::path::{Path, PathBuf};
3+
//! DB canonical — all dependency operations go through the DB (sole source of truth).
84
95
use chrono::Utc;
106
use clap::Subcommand;
117
use serde_json::json;
128

139
use crate::output::{error_exit, json_output};
1410

15-
use flowctl_core::frontmatter;
1611
use flowctl_core::id::{epic_id_from_task, is_task_id};
17-
use flowctl_core::types::{Task, TASKS_DIR};
1812

1913
use super::helpers::get_flow_dir;
2014

@@ -36,69 +30,26 @@ pub enum DepCmd {
3630
},
3731
}
3832

39-
fn ensure_flow_exists() -> PathBuf {
33+
fn ensure_flow_exists() -> std::path::PathBuf {
4034
let flow_dir = get_flow_dir();
4135
if !flow_dir.exists() {
4236
error_exit(".flow/ does not exist. Run 'flowctl init' first.");
4337
}
4438
flow_dir
4539
}
4640

47-
/// Try to open a DB connection.
48-
fn try_open_db() -> Option<crate::commands::db_shim::Connection> {
49-
let cwd = env::current_dir().ok()?;
50-
crate::commands::db_shim::open(&cwd).ok()
51-
}
52-
53-
/// Read a task document: DB first, markdown fallback.
54-
fn read_task_doc(flow_dir: &Path, task_id: &str) -> (PathBuf, frontmatter::Document<Task>) {
55-
let task_path = flow_dir.join(TASKS_DIR).join(format!("{}.md", task_id));
56-
// Try DB first.
57-
if let Some(conn) = try_open_db() {
58-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
59-
if let Ok((task, body)) = repo.get_with_body(task_id) {
60-
return (task_path, frontmatter::Document { frontmatter: task, body });
61-
}
62-
}
63-
// Fallback to markdown.
64-
if !task_path.exists() {
65-
error_exit(&format!("Task not found: {}", task_id));
66-
}
67-
let content = fs::read_to_string(&task_path)
68-
.unwrap_or_else(|e| error_exit(&format!("Cannot read {}: {}", task_path.display(), e)));
69-
let doc: frontmatter::Document<Task> = frontmatter::parse(&content)
70-
.unwrap_or_else(|e| error_exit(&format!("Cannot parse {}: {}", task_path.display(), e)));
71-
(task_path, doc)
72-
}
73-
74-
/// Write a task document: DB first, then export markdown.
75-
fn write_task_doc(path: &Path, doc: &frontmatter::Document<Task>) {
76-
// Write to DB.
77-
if let Some(conn) = try_open_db() {
78-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
79-
if let Err(e) = repo.upsert_with_body(&doc.frontmatter, &doc.body) {
80-
eprintln!("warning: DB write failed for {}: {e}", doc.frontmatter.id);
81-
}
82-
}
83-
// Export to markdown.
84-
let content = frontmatter::write(doc)
85-
.unwrap_or_else(|e| error_exit(&format!("Cannot serialize task: {}", e)));
86-
fs::write(path, content)
87-
.unwrap_or_else(|e| error_exit(&format!("Cannot write {}: {}", path.display(), e)));
41+
/// Open DB connection (hard error if unavailable).
42+
fn require_db() -> crate::commands::db_shim::Connection {
43+
crate::commands::db_shim::require_db()
44+
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")))
8845
}
8946

90-
/// Update the SQLite cache for a task's dependencies (best-effort).
91-
fn sync_deps_to_db(task_id: &str, deps: &[String]) {
92-
let cwd = match env::current_dir() {
93-
Ok(c) => c,
94-
Err(_) => return,
95-
};
96-
let conn = match crate::commands::db_shim::open(&cwd) {
97-
Ok(c) => c,
98-
Err(_) => return,
99-
};
100-
let dep_repo = crate::commands::db_shim::DepRepo::new(&conn);
101-
let _ = dep_repo.replace_task_deps(task_id, deps);
47+
/// Read a task from DB (sole source of truth).
48+
fn read_task(task_id: &str) -> flowctl_core::types::Task {
49+
let conn = require_db();
50+
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
51+
repo.get(task_id)
52+
.unwrap_or_else(|_| error_exit(&format!("Task not found: {}", task_id)))
10253
}
10354

10455
pub fn dispatch(cmd: &DepCmd, json: bool) {
@@ -109,7 +60,7 @@ pub fn dispatch(cmd: &DepCmd, json: bool) {
10960
}
11061

11162
fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
112-
let flow_dir = ensure_flow_exists();
63+
ensure_flow_exists();
11364

11465
if !is_task_id(task_id) {
11566
error_exit(&format!(
@@ -136,19 +87,26 @@ fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
13687
));
13788
}
13889

139-
let (task_path, mut doc) = read_task_doc(&flow_dir, task_id);
90+
let mut task = read_task(task_id);
91+
92+
if !task.depends_on.contains(&depends_on.to_string()) {
93+
task.depends_on.push(depends_on.to_string());
94+
task.updated_at = Utc::now();
14095

141-
if !doc.frontmatter.depends_on.contains(&depends_on.to_string()) {
142-
doc.frontmatter.depends_on.push(depends_on.to_string());
143-
doc.frontmatter.updated_at = Utc::now();
144-
write_task_doc(&task_path, &doc);
145-
sync_deps_to_db(task_id, &doc.frontmatter.depends_on);
96+
// Write to DB
97+
let conn = require_db();
98+
let dep_repo = crate::commands::db_shim::DepRepo::new(&conn);
99+
if let Err(e) = dep_repo.add_task_dep(task_id, depends_on) {
100+
error_exit(&format!("Failed to add dep: {e}"));
101+
}
102+
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
103+
let _ = task_repo.upsert(&task);
146104
}
147105

148106
if json {
149107
json_output(json!({
150108
"task": task_id,
151-
"depends_on": doc.frontmatter.depends_on,
109+
"depends_on": task.depends_on,
152110
"message": format!("Dependency {} added to {}", depends_on, task_id),
153111
}));
154112
} else {
@@ -157,7 +115,7 @@ fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
157115
}
158116

159117
fn cmd_dep_rm(json: bool, task_id: &str, depends_on: &str) {
160-
let flow_dir = ensure_flow_exists();
118+
ensure_flow_exists();
161119

162120
if !is_task_id(task_id) {
163121
error_exit(&format!("Invalid task ID: {}", task_id));
@@ -166,18 +124,25 @@ fn cmd_dep_rm(json: bool, task_id: &str, depends_on: &str) {
166124
error_exit(&format!("Invalid dependency ID: {}", depends_on));
167125
}
168126

169-
let (task_path, mut doc) = read_task_doc(&flow_dir, task_id);
127+
let mut task = read_task(task_id);
128+
129+
if let Some(pos) = task.depends_on.iter().position(|d| d == depends_on) {
130+
task.depends_on.remove(pos);
131+
task.updated_at = Utc::now();
170132

171-
if let Some(pos) = doc.frontmatter.depends_on.iter().position(|d| d == depends_on) {
172-
doc.frontmatter.depends_on.remove(pos);
173-
doc.frontmatter.updated_at = Utc::now();
174-
write_task_doc(&task_path, &doc);
175-
sync_deps_to_db(task_id, &doc.frontmatter.depends_on);
133+
// Write to DB
134+
let conn = require_db();
135+
let dep_repo = crate::commands::db_shim::DepRepo::new(&conn);
136+
if let Err(e) = dep_repo.remove_task_dep(task_id, depends_on) {
137+
error_exit(&format!("Failed to remove dep: {e}"));
138+
}
139+
let task_repo = crate::commands::db_shim::TaskRepo::new(&conn);
140+
let _ = task_repo.upsert(&task);
176141

177142
if json {
178143
json_output(json!({
179144
"task": task_id,
180-
"depends_on": doc.frontmatter.depends_on,
145+
"depends_on": task.depends_on,
181146
"removed": true,
182147
"message": format!("Dependency {} removed from {}", depends_on, task_id),
183148
}));
@@ -187,7 +152,7 @@ fn cmd_dep_rm(json: bool, task_id: &str, depends_on: &str) {
187152
} else if json {
188153
json_output(json!({
189154
"task": task_id,
190-
"depends_on": doc.frontmatter.depends_on,
155+
"depends_on": task.depends_on,
191156
"removed": false,
192157
"message": format!("{} not in dependencies", depends_on),
193158
}));

0 commit comments

Comments
 (0)