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

Commit f0a5eae

Browse files
z23ccclaude
andcommitted
feat(cli): migrate task module from DB to json_store
Replace all db_shim/TaskRepo/EpicRepo calls in task/mod.rs, create.rs, mutate.rs, and query.rs with json_store file operations. Task helpers (load_task_md, load_task_doc, write_task_doc, scan_max_task_id, find_dependents) now read/write JSON files instead of SQLite. Add epic_max_num() and task_list_all() to json_store module. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7e476ae commit f0a5eae

4 files changed

Lines changed: 122 additions & 118 deletions

File tree

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

Lines changed: 18 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,11 @@ 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};
10+
use flowctl_core::types::{Domain, Task, FLOW_DIR};
1111

1212
use super::{
1313
create_task_spec, ensure_flow_exists, parse_domain, read_file_or_stdin, scan_max_task_id,
14-
require_db, write_task_doc,
14+
write_task_doc,
1515
};
1616

1717
#[allow(clippy::too_many_arguments)]
@@ -34,33 +34,21 @@ pub(super) fn cmd_task_create(
3434
));
3535
}
3636

37-
// Verify epic exists (DB is authoritative)
38-
if let Ok(conn) = require_db() {
39-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
40-
if repo.get(epic_id).is_err() {
41-
error_exit(&format!("Epic {} not found", epic_id));
42-
}
43-
} else {
44-
// Fallback: check spec file if DB unavailable
45-
let epic_spec = flow_dir.join(SPECS_DIR).join(format!("{}.md", epic_id));
46-
if !epic_spec.exists() {
47-
error_exit(&format!("Epic {} not found", epic_id));
48-
}
37+
// Verify epic exists (JSON file)
38+
if flowctl_core::json_store::epic_read(&flow_dir, epic_id).is_err() {
39+
error_exit(&format!("Epic {} not found", epic_id));
4940
}
5041

5142
// Scan-based ID allocation
5243
let task_num = scan_max_task_id(&flow_dir, epic_id) + 1;
5344
let task_id = format!("{}.{}", epic_id, task_num);
5445

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-
}
46+
// Check no collision
47+
if flowctl_core::json_store::task_read(&flow_dir, &task_id).is_ok() {
48+
error_exit(&format!(
49+
"Refusing to overwrite existing task {}. Check for orphaned entries.",
50+
task_id
51+
));
6452
}
6553

6654
// Parse dependencies
@@ -129,13 +117,19 @@ pub(super) fn cmd_task_create(
129117
// Create spec markdown body
130118
let body = create_task_spec(&task_id, title, acceptance.as_deref());
131119

132-
// Write to DB via write_task_doc (DB-only, no MD export)
120+
// Write task definition + spec + initial state
133121
let doc = flowctl_core::types::Document {
134122
frontmatter: task,
135123
body,
136124
};
137125
write_task_doc(&flow_dir, &task_id, &doc);
138126

127+
// Write initial runtime state
128+
let initial_state = flowctl_core::json_store::TaskState::default();
129+
if let Err(e) = flowctl_core::json_store::state_write(&flow_dir, &task_id, &initial_state) {
130+
error_exit(&format!("Failed to write initial state: {e}"));
131+
}
132+
139133
let spec_path_str = format!("{}/tasks/{}.md", FLOW_DIR, task_id);
140134
if json_mode {
141135
json_output(json!({

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

Lines changed: 25 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,6 @@ fn ensure_flow_exists() -> PathBuf {
130130
flow_dir
131131
}
132132

133-
/// Open DB connection (hard error if unavailable).
134-
fn require_db() -> Result<crate::commands::db_shim::Connection, crate::commands::db_shim::DbError> {
135-
crate::commands::db_shim::require_db()
136-
}
137-
138133
/// Read file content, or read from stdin if path is "-".
139134
fn read_file_or_stdin(path: &str) -> String {
140135
if path == "-" {
@@ -149,12 +144,9 @@ fn read_file_or_stdin(path: &str) -> String {
149144
}
150145
}
151146

152-
/// Get max task number for an epic from DB. Returns 0 if none exist.
153-
fn scan_max_task_id(_flow_dir: &Path, epic_id: &str) -> u32 {
154-
let conn = require_db()
155-
.unwrap_or_else(|e| error_exit(&format!("DB required for scan_max_task_id: {e}")));
156-
let n = crate::commands::db_shim::max_task_num(&conn, epic_id).unwrap_or(0);
157-
n as u32
147+
/// Get max task number for an epic from JSON files.
148+
fn scan_max_task_id(flow_dir: &Path, epic_id: &str) -> u32 {
149+
flowctl_core::json_store::task_max_num(flow_dir, epic_id).unwrap_or(0)
158150
}
159151

160152
/// Parse a domain string into a Domain enum.
@@ -179,42 +171,36 @@ fn create_task_spec(id: &str, title: &str, acceptance: Option<&str>) -> String {
179171
)
180172
}
181173

182-
/// Load a task from DB (no MD fallback).
183-
fn load_task_md(_flow_dir: &Path, task_id: &str) -> Task {
184-
let conn = require_db()
185-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
186-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
187-
repo.get(task_id)
174+
/// Load a task from JSON files.
175+
fn load_task_md(flow_dir: &Path, task_id: &str) -> Task {
176+
flowctl_core::json_store::task_read(flow_dir, task_id)
188177
.unwrap_or_else(|_| error_exit(&format!("Task {} not found", task_id)))
189178
}
190179

191-
/// Load an epic from DB (no MD fallback).
192-
fn load_epic_md(_flow_dir: &Path, epic_id: &str) -> Option<Epic> {
193-
let conn = require_db().ok()?;
194-
let repo = crate::commands::db_shim::EpicRepo::new(&conn);
195-
repo.get(epic_id).ok()
180+
/// Load an epic from JSON files.
181+
fn load_epic_md(flow_dir: &Path, epic_id: &str) -> Option<Epic> {
182+
flowctl_core::json_store::epic_read(flow_dir, epic_id).ok()
196183
}
197184

198-
/// Load task's full document (frontmatter + body) from DB (no MD fallback).
199-
fn load_task_doc(_flow_dir: &Path, task_id: &str) -> Document<Task> {
200-
let conn = require_db()
201-
.unwrap_or_else(|e| error_exit(&format!("DB required: {e}")));
202-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
203-
let (task, body) = repo.get_with_body(task_id)
185+
/// Load task's full document (definition + spec body) from JSON files.
186+
fn load_task_doc(flow_dir: &Path, task_id: &str) -> Document<Task> {
187+
let task = flowctl_core::json_store::task_read(flow_dir, task_id)
204188
.unwrap_or_else(|_| error_exit(&format!("Task {} not found", task_id)));
189+
let body = flowctl_core::json_store::task_spec_read(flow_dir, task_id)
190+
.unwrap_or_default();
205191
Document {
206192
frontmatter: task,
207193
body,
208194
}
209195
}
210196

211-
/// Write a task document to DB only (no MD export).
212-
fn write_task_doc(_flow_dir: &Path, task_id: &str, doc: &Document<Task>) {
213-
let conn = require_db()
214-
.unwrap_or_else(|e| error_exit(&format!("DB required for write: {e}")));
215-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
216-
if let Err(e) = repo.upsert_with_body(&doc.frontmatter, &doc.body) {
217-
error_exit(&format!("DB write failed for {task_id}: {e}"));
197+
/// Write a task document to JSON files (definition + spec body).
198+
fn write_task_doc(flow_dir: &Path, _task_id: &str, doc: &Document<Task>) {
199+
if let Err(e) = flowctl_core::json_store::task_write_definition(flow_dir, &doc.frontmatter) {
200+
error_exit(&format!("Failed to write task definition: {e}"));
201+
}
202+
if let Err(e) = flowctl_core::json_store::task_spec_write(flow_dir, &doc.frontmatter.id, &doc.body) {
203+
error_exit(&format!("Failed to write task spec: {e}"));
218204
}
219205
}
220206

@@ -264,21 +250,15 @@ fn patch_body_section(body: &str, section: &str, new_content: &str) -> String {
264250
result.join("\n")
265251
}
266252

267-
/// Find tasks that depend on a given task (recursive BFS within same epic) via DB.
268-
fn find_dependents(_flow_dir: &Path, task_id: &str) -> Vec<String> {
253+
/// Find tasks that depend on a given task (recursive BFS within same epic) via JSON files.
254+
fn find_dependents(flow_dir: &Path, task_id: &str) -> Vec<String> {
269255
let epic_id = match epic_id_from_task(task_id) {
270256
Ok(id) => id,
271257
Err(_) => return vec![],
272258
};
273259

274-
let conn = match require_db() {
275-
Ok(c) => c,
276-
Err(_) => return vec![],
277-
};
278-
279-
// Load all tasks in the epic from DB
280-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
281-
let all_tasks: Vec<(String, Vec<String>)> = match repo.list_by_epic(&epic_id) {
260+
// Load all tasks in the epic from JSON files
261+
let all_tasks: Vec<(String, Vec<String>)> = match flowctl_core::json_store::task_list_by_epic(flow_dir, &epic_id) {
282262
Ok(tasks) => tasks.into_iter().map(|t| (t.id.clone(), t.depends_on.clone())).collect(),
283263
Err(_) => return vec![],
284264
};

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

Lines changed: 29 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use flowctl_core::types::{Task, FLOW_DIR};
1111

1212
use super::{
1313
clear_evidence_in_body, create_task_spec, ensure_flow_exists, find_dependents, load_epic_md,
14-
load_task_doc, scan_max_task_id, require_db, write_task_doc,
14+
load_task_doc, scan_max_task_id, write_task_doc,
1515
};
1616

1717
pub(super) fn cmd_task_reset(json_mode: bool, task_id: &str, cascade: bool) {
@@ -61,18 +61,9 @@ pub(super) fn cmd_task_reset(json_mode: bool, task_id: &str, cascade: bool) {
6161
doc.body = clear_evidence_in_body(&doc.body);
6262
write_task_doc(&flow_dir, task_id, &doc);
6363

64-
// Update DB
65-
if let Ok(conn) = require_db() {
66-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
67-
let _ = repo.update_status(task_id, Status::Todo);
68-
// Clear runtime state by upserting a blank state
69-
let runtime_repo = crate::commands::db_shim::RuntimeRepo::new(&conn);
70-
let blank = flowctl_core::types::RuntimeState {
71-
task_id: task_id.to_string(),
72-
..Default::default()
73-
};
74-
let _ = runtime_repo.upsert(&blank);
75-
}
64+
// Reset runtime state
65+
let blank_state = flowctl_core::json_store::TaskState::default();
66+
let _ = flowctl_core::json_store::state_write(&flow_dir, task_id, &blank_state);
7667

7768
let mut reset_ids = vec![task_id.to_string()];
7869

@@ -91,16 +82,8 @@ pub(super) fn cmd_task_reset(json_mode: bool, task_id: &str, cascade: bool) {
9182
dep_doc.body = clear_evidence_in_body(&dep_doc.body);
9283
write_task_doc(&flow_dir, dep_id, &dep_doc);
9384

94-
if let Ok(conn) = require_db() {
95-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
96-
let _ = repo.update_status(dep_id, Status::Todo);
97-
let runtime_repo = crate::commands::db_shim::RuntimeRepo::new(&conn);
98-
let blank = flowctl_core::types::RuntimeState {
99-
task_id: dep_id.to_string(),
100-
..Default::default()
101-
};
102-
let _ = runtime_repo.upsert(&blank);
103-
}
85+
let blank_state = flowctl_core::json_store::TaskState::default();
86+
let _ = flowctl_core::json_store::state_write(&flow_dir, dep_id, &blank_state);
10487
reset_ids.push(dep_id.clone());
10588
}
10689
}
@@ -232,33 +215,30 @@ pub(super) fn cmd_task_split(json_mode: bool, task_id: &str, titles: &str, chain
232215
orig_doc.frontmatter.updated_at = now;
233216
write_task_doc(&flow_dir, task_id, &orig_doc);
234217

235-
// Update tasks that depended on original to depend on last sub-task (via DB)
218+
// Update tasks that depended on original to depend on last sub-task
236219
let last_sub = created.last().unwrap().clone();
237-
if let Ok(conn) = require_db() {
238-
let repo = crate::commands::db_shim::TaskRepo::new(&conn);
239-
if let Ok(all_tasks) = repo.list_by_epic(&epic_id) {
240-
for other_task in all_tasks {
241-
let other_id = &other_task.id;
242-
if other_id == task_id || created.contains(other_id) {
243-
continue;
244-
}
245-
if other_task.depends_on.contains(&task_id.to_string()) {
246-
let mut other_doc = load_task_doc(&flow_dir, other_id);
247-
other_doc.frontmatter.depends_on = other_doc
248-
.frontmatter
249-
.depends_on
250-
.iter()
251-
.map(|d| {
252-
if d == task_id {
253-
last_sub.clone()
254-
} else {
255-
d.clone()
256-
}
257-
})
258-
.collect();
259-
other_doc.frontmatter.updated_at = now;
260-
write_task_doc(&flow_dir, other_id, &other_doc);
261-
}
220+
if let Ok(all_tasks) = flowctl_core::json_store::task_list_by_epic(&flow_dir, &epic_id) {
221+
for other_task in all_tasks {
222+
let other_id = &other_task.id;
223+
if other_id == task_id || created.contains(other_id) {
224+
continue;
225+
}
226+
if other_task.depends_on.contains(&task_id.to_string()) {
227+
let mut other_doc = load_task_doc(&flow_dir, other_id);
228+
other_doc.frontmatter.depends_on = other_doc
229+
.frontmatter
230+
.depends_on
231+
.iter()
232+
.map(|d| {
233+
if d == task_id {
234+
last_sub.clone()
235+
} else {
236+
d.clone()
237+
}
238+
})
239+
.collect();
240+
other_doc.frontmatter.updated_at = now;
241+
write_task_doc(&flow_dir, other_id, &other_doc);
262242
}
263243
}
264244
}

flowctl/crates/flowctl-core/src/json_store.rs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,56 @@ pub fn state_write(flow_dir: &Path, task_id: &str, state: &TaskState) -> Result<
336336
Ok(())
337337
}
338338

339+
/// Find the highest epic number by scanning `epics/fn-N-*.json`.
340+
pub fn epic_max_num(flow_dir: &Path) -> Result<u32> {
341+
let dir = epics_dir(flow_dir);
342+
if !dir.exists() {
343+
return Ok(0);
344+
}
345+
let mut max = 0u32;
346+
for entry in fs::read_dir(&dir)? {
347+
let entry = entry?;
348+
let name = entry.file_name();
349+
let name = name.to_str().unwrap_or("");
350+
if name.starts_with("fn-") && name.ends_with(".json") {
351+
// Extract N from "fn-N-slug.json"
352+
let without_prefix = name.trim_start_matches("fn-");
353+
if let Some(num_str) = without_prefix.split('-').next() {
354+
if let Ok(num) = num_str.parse::<u32>() {
355+
max = max.max(num);
356+
}
357+
}
358+
}
359+
}
360+
Ok(max)
361+
}
362+
363+
/// List all tasks across all epics.
364+
pub fn task_list_all(flow_dir: &Path) -> Result<Vec<Task>> {
365+
let dir = tasks_dir(flow_dir);
366+
if !dir.exists() {
367+
return Ok(vec![]);
368+
}
369+
let mut tasks = Vec::new();
370+
for entry in fs::read_dir(&dir)? {
371+
let entry = entry?;
372+
let path = entry.path();
373+
let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
374+
if name.ends_with(".json") {
375+
let content = fs::read_to_string(&path)?;
376+
if let Ok(mut task) = serde_json::from_str::<Task>(&content) {
377+
if let Ok(state) = state_read(flow_dir, &task.id) {
378+
task.status = state.status;
379+
task.updated_at = state.updated_at;
380+
}
381+
tasks.push(task);
382+
}
383+
}
384+
}
385+
tasks.sort_by(|a, b| a.id.cmp(&b.id));
386+
Ok(tasks)
387+
}
388+
339389
// ── Gap operations ──────────────────────────────────────────────────
340390

341391
/// Gap entry stored in `gaps/<epic-id>.json`.

0 commit comments

Comments
 (0)