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

Commit 0820c5c

Browse files
z23ccclaude
andcommitted
feat(flowctl): add declarative Changes pattern, bidirectional dep index, and --dry-run support
Introduce three foundational improvements inspired by IWE reference: 1. Changes pattern: All mutation operations (task/epic create, reset, skip, split, dep add/rm) now return a declarative Changes struct instead of writing directly. ChangesApplier persists to JSON + libSQL with auto audit logging to events table. 2. Bidirectional dependency index: task_reverse_deps table with SQL triggers on task_deps INSERT/DELETE. O(1) reverse lookups replace O(N×M) BFS scan. DepRepo gains list_dependents() and list_all_dependents() methods. 3. --dry-run CLI flag: Global flag on all mutation commands. Computes changes and outputs JSON preview without persisting. 2 integration tests verify no side effects. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 79c1340 commit 0820c5c

20 files changed

Lines changed: 1142 additions & 154 deletions

File tree

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

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -334,6 +334,21 @@ impl FileLockRepo {
334334
}
335335
}
336336

337+
// ── Event repository ──────────────────────────────────────────────
338+
339+
pub struct EventRepoSync(libsql::Connection);
340+
341+
impl EventRepoSync {
342+
pub fn new(conn: &Connection) -> Self {
343+
Self(conn.inner())
344+
}
345+
346+
/// Return the inner async EventRepo for use with ChangesApplier.
347+
pub fn as_async(&self) -> flowctl_db::EventRepo {
348+
flowctl_db::EventRepo::new(self.0.clone())
349+
}
350+
}
351+
337352
// ── Phase progress repository ──────────────────────────────────────
338353

339354
pub struct PhaseProgressRepo(libsql::Connection);

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

Lines changed: 70 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
//! Dependency management commands: add, remove.
22
3+
use std::path::Path;
4+
35
use chrono::Utc;
46
use clap::Subcommand;
57
use serde_json::json;
68

79
use crate::output::{error_exit, json_output};
810

11+
use flowctl_core::changes::{Changes, Mutation};
912
use flowctl_core::id::{epic_id_from_task, is_task_id};
1013

1114
use super::helpers::get_flow_dir;
@@ -36,16 +39,20 @@ fn ensure_flow_exists() -> std::path::PathBuf {
3639
flow_dir
3740
}
3841

39-
pub fn dispatch(cmd: &DepCmd, json: bool) {
42+
pub fn dispatch(cmd: &DepCmd, json: bool, dry_run: bool) {
4043
match cmd {
41-
DepCmd::Add { task, depends_on } => cmd_dep_add(json, task, depends_on),
42-
DepCmd::Rm { task, depends_on } => cmd_dep_rm(json, task, depends_on),
44+
DepCmd::Add { task, depends_on } => cmd_dep_add(json, task, depends_on, dry_run),
45+
DepCmd::Rm { task, depends_on } => cmd_dep_rm(json, task, depends_on, dry_run),
4346
}
4447
}
4548

46-
fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
47-
let flow_dir = ensure_flow_exists();
48-
49+
/// Pure compute: build Changes to add a dependency.
50+
/// Returns (updated task, whether dep was actually added).
51+
fn compute_dep_add(
52+
flow_dir: &Path,
53+
task_id: &str,
54+
depends_on: &str,
55+
) -> (flowctl_core::types::Task, bool, Changes) {
4956
if !is_task_id(task_id) {
5057
error_exit(&format!(
5158
"Invalid task ID: {}. Expected format: fn-N.M or fn-N-slug.M",
@@ -59,7 +66,6 @@ fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
5966
));
6067
}
6168

62-
// Validate same epic
6369
let task_epic = epic_id_from_task(task_id)
6470
.unwrap_or_else(|_| error_exit(&format!("Cannot parse epic from task ID: {}", task_id)));
6571
let dep_epic = epic_id_from_task(depends_on)
@@ -71,15 +77,34 @@ fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
7177
));
7278
}
7379

74-
let mut task = flowctl_core::json_store::task_read(&flow_dir, task_id)
80+
let mut task = flowctl_core::json_store::task_read(flow_dir, task_id)
7581
.unwrap_or_else(|_| error_exit(&format!("Task not found: {}", task_id)));
7682

77-
if !task.depends_on.contains(&depends_on.to_string()) {
83+
let added = if !task.depends_on.contains(&depends_on.to_string()) {
7884
task.depends_on.push(depends_on.to_string());
7985
task.updated_at = Utc::now();
80-
if let Err(e) = flowctl_core::json_store::task_write_definition(&flow_dir, &task) {
81-
error_exit(&format!("Failed to write task: {e}"));
82-
}
86+
true
87+
} else {
88+
false
89+
};
90+
91+
let changes = if added {
92+
Changes::new().with(Mutation::UpdateTask { task: task.clone() })
93+
} else {
94+
Changes::new()
95+
};
96+
97+
(task, added, changes)
98+
}
99+
100+
fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str, dry_run: bool) {
101+
let flow_dir = ensure_flow_exists();
102+
103+
let (task, _added, changes) = compute_dep_add(&flow_dir, task_id, depends_on);
104+
105+
crate::commands::helpers::maybe_apply_changes(&flow_dir, &changes, dry_run);
106+
if dry_run {
107+
return;
83108
}
84109

85110
if json {
@@ -93,26 +118,51 @@ fn cmd_dep_add(json: bool, task_id: &str, depends_on: &str) {
93118
}
94119
}
95120

96-
fn cmd_dep_rm(json: bool, task_id: &str, depends_on: &str) {
97-
let flow_dir = ensure_flow_exists();
98-
121+
/// Pure compute: build Changes to remove a dependency.
122+
/// Returns (updated task, whether dep was actually removed).
123+
fn compute_dep_rm(
124+
flow_dir: &Path,
125+
task_id: &str,
126+
depends_on: &str,
127+
) -> (flowctl_core::types::Task, bool, Changes) {
99128
if !is_task_id(task_id) {
100129
error_exit(&format!("Invalid task ID: {}", task_id));
101130
}
102131
if !is_task_id(depends_on) {
103132
error_exit(&format!("Invalid dependency ID: {}", depends_on));
104133
}
105134

106-
let mut task = flowctl_core::json_store::task_read(&flow_dir, task_id)
135+
let mut task = flowctl_core::json_store::task_read(flow_dir, task_id)
107136
.unwrap_or_else(|_| error_exit(&format!("Task not found: {}", task_id)));
108137

109-
if let Some(pos) = task.depends_on.iter().position(|d| d == depends_on) {
138+
let removed = if let Some(pos) = task.depends_on.iter().position(|d| d == depends_on) {
110139
task.depends_on.remove(pos);
111140
task.updated_at = Utc::now();
112-
if let Err(e) = flowctl_core::json_store::task_write_definition(&flow_dir, &task) {
113-
error_exit(&format!("Failed to write task: {e}"));
114-
}
141+
true
142+
} else {
143+
false
144+
};
145+
146+
let changes = if removed {
147+
Changes::new().with(Mutation::UpdateTask { task: task.clone() })
148+
} else {
149+
Changes::new()
150+
};
151+
152+
(task, removed, changes)
153+
}
154+
155+
fn cmd_dep_rm(json: bool, task_id: &str, depends_on: &str, dry_run: bool) {
156+
let flow_dir = ensure_flow_exists();
157+
158+
let (task, removed, changes) = compute_dep_rm(&flow_dir, task_id, depends_on);
159+
160+
crate::commands::helpers::maybe_apply_changes(&flow_dir, &changes, dry_run);
161+
if dry_run {
162+
return;
163+
}
115164

165+
if removed {
116166
if json {
117167
json_output(json!({
118168
"task": task_id,

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

Lines changed: 34 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,33 +1,38 @@
11
//! Epic CRUD commands: create, plan, review, completion, branch, title, backend, auto_exec.
22
33
use std::fs;
4+
use std::path::Path;
45

56
use chrono::Utc;
67
use serde_json::json;
78

89
use crate::output::{error_exit, json_output};
910

11+
use flowctl_core::changes::{Changes, Mutation};
1012
use flowctl_core::id::{generate_epic_suffix, parse_id, slugify};
1113
use flowctl_core::types::{
12-
Epic, EpicStatus, ReviewStatus, Document, FLOW_DIR, SPECS_DIR,
14+
Epic, EpicStatus, ReviewStatus, FLOW_DIR, SPECS_DIR,
1315
};
1416

1517
use super::helpers::{
1618
create_epic_spec_body, ensure_flow_exists, ensure_meta_exists, find_max_epic_number,
1719
load_epic, read_file_or_stdin, save_epic, validate_epic_id,
1820
};
19-
pub fn cmd_create(title: &str, branch: &Option<String>, json_mode: bool) {
20-
let flow_dir = ensure_flow_exists();
21-
ensure_meta_exists(&flow_dir);
2221

23-
// DB-based ID allocation
22+
/// Pure compute: build Changes for epic creation.
23+
/// Returns (epic_id, spec_body, changes).
24+
fn compute_epic_create(
25+
flow_dir: &Path,
26+
title: &str,
27+
branch: &Option<String>,
28+
) -> (String, String, Changes) {
2429
let max_epic = find_max_epic_number();
2530
let epic_num = max_epic + 1;
2631
let slug = slugify(title, 40);
2732
let suffix = slug.unwrap_or_else(|| generate_epic_suffix(3));
2833
let epic_id = format!("fn-{epic_num}-{suffix}");
2934

30-
// Collision check: only check spec file (no more epic MD)
35+
// Collision check
3136
let spec_path = flow_dir.join(SPECS_DIR).join(format!("{epic_id}.md"));
3237
if spec_path.exists() {
3338
error_exit(&format!(
@@ -59,15 +64,31 @@ pub fn cmd_create(title: &str, branch: &Option<String>, json_mode: bool) {
5964
updated_at: now,
6065
};
6166

62-
// Write to DB (sole source of truth)
6367
let body = create_epic_spec_body(&epic_id, title);
64-
let doc = Document {
65-
frontmatter: epic,
66-
body: body.clone(),
67-
};
68-
save_epic(&doc);
6968

70-
// Write spec file (body-only Markdown in specs/)
69+
let changes = Changes::new()
70+
.with(Mutation::CreateEpic { epic })
71+
.with(Mutation::SetEpicSpec {
72+
epic_id: epic_id.clone(),
73+
content: body.clone(),
74+
});
75+
76+
(epic_id, body, changes)
77+
}
78+
79+
pub fn cmd_create(title: &str, branch: &Option<String>, json_mode: bool, dry_run: bool) {
80+
let flow_dir = ensure_flow_exists();
81+
ensure_meta_exists(&flow_dir);
82+
83+
let (epic_id, body, changes) = compute_epic_create(&flow_dir, title, branch);
84+
85+
crate::commands::helpers::maybe_apply_changes(&flow_dir, &changes, dry_run);
86+
if dry_run {
87+
return;
88+
}
89+
90+
// Also write the spec Markdown file (body-only in specs/)
91+
let spec_path = flow_dir.join(SPECS_DIR).join(format!("{epic_id}.md"));
7192
if let Some(parent) = spec_path.parent() {
7293
let _ = fs::create_dir_all(parent);
7394
}

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ pub use history::{cmd_diff, cmd_replay};
1515
pub use types::EpicCmd;
1616

1717
/// Dispatch an epic subcommand.
18-
pub fn dispatch(cmd: &EpicCmd, json: bool) {
18+
pub fn dispatch(cmd: &EpicCmd, json: bool, dry_run: bool) {
1919
match cmd {
20-
EpicCmd::Create { title, branch } => crud::cmd_create(title, branch, json),
20+
EpicCmd::Create { title, branch } => crud::cmd_create(title, branch, json, dry_run),
2121
EpicCmd::Plan { id, file } => crud::cmd_set_plan(id, file, json),
2222
EpicCmd::Review { id, status } => crud::cmd_set_plan_review_status(id, status, json),
2323
EpicCmd::Completion { id, status } => {

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

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,68 @@ fn copy_dir_recursive(src: &Path, dst: &Path) -> Result<(), std::io::Error> {
143143
Ok(())
144144
}
145145

146+
/// Apply a `Changes` batch via the service-layer `ChangesApplier`.
147+
///
148+
/// This opens the libSQL event DB, spins a short-lived tokio runtime, and
149+
/// applies all mutations (JSON store writes + event logging) in order.
150+
/// Returns the number of mutations applied. Calls `error_exit` on failure.
151+
pub fn apply_changes(flow_dir: &Path, changes: &flowctl_core::changes::Changes) -> usize {
152+
use crate::output::error_exit;
153+
use flowctl_service::changes::ChangesApplier;
154+
155+
if changes.is_empty() {
156+
return 0;
157+
}
158+
159+
let actor = resolve_actor();
160+
161+
// Open DB for event logging (best-effort: if DB unavailable, fall back to JSON-only)
162+
let cwd = env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
163+
let rt = tokio::runtime::Builder::new_current_thread()
164+
.enable_all()
165+
.build()
166+
.unwrap_or_else(|e| error_exit(&format!("tokio runtime: {e}")));
167+
168+
let applied = rt.block_on(async {
169+
let db = flowctl_db::open_async(&cwd).await
170+
.unwrap_or_else(|e| error_exit(&format!("Failed to open DB: {e}")));
171+
let conn = db.connect()
172+
.unwrap_or_else(|e| error_exit(&format!("Failed to connect to DB: {e}")));
173+
let event_repo = flowctl_db::EventRepo::new(conn);
174+
175+
let applier = ChangesApplier::new(flow_dir, &event_repo)
176+
.with_actor(&actor);
177+
178+
let result = applier.apply(changes).await
179+
.unwrap_or_else(|e| error_exit(&format!("Failed to apply changes: {e}")));
180+
// Leak the DB handle to keep it alive (same pattern as db_shim)
181+
std::mem::forget(db);
182+
result.applied
183+
});
184+
185+
applied
186+
}
187+
188+
/// Handle dry-run or real apply of a `Changes` batch.
189+
///
190+
/// When `dry_run` is true, prints the changes as a JSON preview and returns 0
191+
/// without touching storage. Otherwise delegates to `apply_changes`.
192+
pub fn maybe_apply_changes(
193+
flow_dir: &Path,
194+
changes: &flowctl_core::changes::Changes,
195+
dry_run: bool,
196+
) -> usize {
197+
if dry_run {
198+
let preview = serde_json::json!({
199+
"dry_run": true,
200+
"changes": changes,
201+
});
202+
println!("{}", serde_json::to_string(&preview).unwrap());
203+
return 0;
204+
}
205+
apply_changes(flow_dir, changes)
206+
}
207+
146208
/// Resolve current actor: FLOW_ACTOR env > git config user.email > git config user.name > $USER > "unknown"
147209
pub fn resolve_actor() -> String {
148210
if let Ok(actor) = env::var("FLOW_ACTOR") {

0 commit comments

Comments
 (0)