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

Commit 1043898

Browse files
committed
feat(service): create flowctl-service crate scaffold with ServiceError and connection management
- New flowctl-service crate added as 7th workspace member - ServiceError enum with 9 variants: TaskNotFound, EpicNotFound, InvalidTransition, DependencyUnsatisfied, CrossActorViolation, DbError, IoError, ValidationError, CoreError - From<DbError> and From<CoreError> impls via thiserror derive - ConnectionProvider trait for sync/async connection abstraction - FileConnectionProvider wraps flowctl_db::open() for file-backed DBs - open_sync() convenience function for CLI callers - Tests for file-backed, in-memory, and sync connection paths Task: fn-10-dag-delight.1
1 parent 770ce7e commit 1043898

6 files changed

Lines changed: 210 additions & 0 deletions

File tree

flowctl/Cargo.lock

Lines changed: 14 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

flowctl/Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ members = [
44
"crates/flowctl-core",
55
"crates/flowctl-db",
66
"crates/flowctl-scheduler",
7+
"crates/flowctl-service",
78
"crates/flowctl-cli",
89
"crates/flowctl-daemon",
910
"crates/flowctl-web",
@@ -75,6 +76,7 @@ trycmd = "0.15"
7576
flowctl-core = { path = "crates/flowctl-core" }
7677
flowctl-db = { path = "crates/flowctl-db" }
7778
flowctl-scheduler = { path = "crates/flowctl-scheduler" }
79+
flowctl-service = { path = "crates/flowctl-service" }
7880

7981
# ── Release profile (size-optimized) ─────────────────────────────────
8082
[profile.release]
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
[package]
2+
name = "flowctl-service"
3+
version = "0.1.0"
4+
description = "Business logic service layer for flowctl — unifies CLI, daemon, and MCP execution paths"
5+
edition.workspace = true
6+
rust-version.workspace = true
7+
license.workspace = true
8+
9+
[dependencies]
10+
flowctl-core = { workspace = true }
11+
flowctl-db = { workspace = true }
12+
rusqlite = { workspace = true }
13+
serde = { workspace = true }
14+
serde_json = { workspace = true }
15+
thiserror = { workspace = true }
16+
tracing = { workspace = true }
17+
18+
[dev-dependencies]
19+
tempfile = "3"
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
//! Connection management for the service layer.
2+
//!
3+
//! Wraps `flowctl_db::open()` behind a trait so that:
4+
//! - Sync callers (CLI) use it directly
5+
//! - Async callers (daemon) use `spawn_blocking` to avoid blocking the runtime
6+
//!
7+
//! The `ConnectionProvider` trait enables testing with in-memory databases.
8+
9+
use std::path::{Path, PathBuf};
10+
11+
use rusqlite::Connection;
12+
13+
use crate::error::{ServiceError, ServiceResult};
14+
15+
/// Trait for obtaining a database connection.
16+
///
17+
/// The default implementation opens a file-backed SQLite database via
18+
/// `flowctl_db::open()`. Tests can provide an in-memory alternative.
19+
pub trait ConnectionProvider: Send + Sync {
20+
/// Open a new database connection.
21+
///
22+
/// Each call returns a fresh `Connection`. rusqlite `Connection` is
23+
/// `!Send`, so callers in async contexts must use `spawn_blocking`.
24+
fn connect(&self) -> ServiceResult<Connection>;
25+
}
26+
27+
/// File-backed connection provider using a working directory.
28+
///
29+
/// Resolves the database path via `flowctl_db::pool::resolve_db_path()`
30+
/// and opens with production PRAGMAs + migrations.
31+
#[derive(Debug, Clone)]
32+
pub struct FileConnectionProvider {
33+
working_dir: PathBuf,
34+
}
35+
36+
impl FileConnectionProvider {
37+
/// Create a provider rooted at the given working directory.
38+
pub fn new(working_dir: impl Into<PathBuf>) -> Self {
39+
Self {
40+
working_dir: working_dir.into(),
41+
}
42+
}
43+
44+
/// Return the working directory this provider is rooted at.
45+
pub fn working_dir(&self) -> &Path {
46+
&self.working_dir
47+
}
48+
}
49+
50+
impl ConnectionProvider for FileConnectionProvider {
51+
fn connect(&self) -> ServiceResult<Connection> {
52+
flowctl_db::open(&self.working_dir).map_err(ServiceError::from)
53+
}
54+
}
55+
56+
/// Open a connection synchronously (convenience for CLI callers).
57+
pub fn open_sync(working_dir: &Path) -> ServiceResult<Connection> {
58+
flowctl_db::open(working_dir).map_err(ServiceError::from)
59+
}
60+
61+
#[cfg(test)]
62+
mod tests {
63+
use super::*;
64+
65+
/// In-memory connection provider for tests.
66+
pub struct MemoryConnectionProvider;
67+
68+
impl ConnectionProvider for MemoryConnectionProvider {
69+
fn connect(&self) -> ServiceResult<Connection> {
70+
let conn = Connection::open_in_memory()
71+
.map_err(|e| ServiceError::DbError(flowctl_db::DbError::Sqlite(e)))?;
72+
Ok(conn)
73+
}
74+
}
75+
76+
#[test]
77+
fn file_provider_roundtrip() {
78+
let tmp = tempfile::tempdir().unwrap();
79+
let provider = FileConnectionProvider::new(tmp.path());
80+
let conn = provider.connect();
81+
assert!(conn.is_ok(), "should open file-backed connection");
82+
}
83+
84+
#[test]
85+
fn memory_provider_works() {
86+
let provider = MemoryConnectionProvider;
87+
let conn = provider.connect();
88+
assert!(conn.is_ok(), "should open in-memory connection");
89+
}
90+
91+
#[test]
92+
fn open_sync_works() {
93+
let tmp = tempfile::tempdir().unwrap();
94+
let conn = open_sync(tmp.path());
95+
assert!(conn.is_ok(), "open_sync should succeed");
96+
}
97+
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
//! Service-layer error types.
2+
//!
3+
//! `ServiceError` is the canonical error type for all business logic
4+
//! operations. It wraps lower-level errors from `flowctl-core` and
5+
//! `flowctl-db` and adds service-specific variants.
6+
7+
use thiserror::Error;
8+
9+
/// Top-level error type for service operations.
10+
#[derive(Debug, Error)]
11+
pub enum ServiceError {
12+
/// Task not found in the database.
13+
#[error("task not found: {0}")]
14+
TaskNotFound(String),
15+
16+
/// Epic not found in the database.
17+
#[error("epic not found: {0}")]
18+
EpicNotFound(String),
19+
20+
/// Invalid state transition (e.g., done → in_progress without restart).
21+
#[error("invalid transition: {0}")]
22+
InvalidTransition(String),
23+
24+
/// A dependency is not satisfied (blocking task not done/skipped).
25+
#[error("dependency unsatisfied: task {task} blocked by {dependency}")]
26+
DependencyUnsatisfied { task: String, dependency: String },
27+
28+
/// Cross-actor violation (e.g., modifying another agent's locked task).
29+
#[error("cross-actor violation: {0}")]
30+
CrossActorViolation(String),
31+
32+
/// Underlying database error.
33+
#[error("database error: {0}")]
34+
DbError(#[from] flowctl_db::DbError),
35+
36+
/// I/O error (file reads, state directory operations).
37+
#[error("io error: {0}")]
38+
IoError(#[from] std::io::Error),
39+
40+
/// Validation error (bad input, missing fields, constraint checks).
41+
#[error("validation error: {0}")]
42+
ValidationError(String),
43+
44+
/// Core-layer error (ID parsing, DAG operations).
45+
#[error("core error: {0}")]
46+
CoreError(#[from] flowctl_core::CoreError),
47+
}
48+
49+
/// Convenience alias used throughout the service layer.
50+
pub type ServiceResult<T> = Result<T, ServiceError>;
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
//! flowctl-service: Business logic service layer for flowctl.
2+
//!
3+
//! This crate provides the canonical business logic that is shared across
4+
//! all three execution paths (CLI, daemon, MCP). It sits between the
5+
//! transport layer (HTTP handlers, CLI commands, MCP protocol) and the
6+
//! storage layer (flowctl-db).
7+
//!
8+
//! # Architecture
9+
//!
10+
//! ```text
11+
//! CLI commands ─┐
12+
//! HTTP handlers ─┼─► flowctl-service ──► flowctl-db ──► SQLite
13+
//! MCP server ───┘ │
14+
//! flowctl-core (types, DAG, state machine)
15+
//! ```
16+
//!
17+
//! # Connection management
18+
//!
19+
//! rusqlite `Connection` is `!Send`. The service layer provides a
20+
//! `ConnectionProvider` trait that async callers (daemon) wrap with
21+
//! `tokio::task::spawn_blocking`, while sync callers (CLI) use directly.
22+
23+
pub mod connection;
24+
pub mod error;
25+
26+
// Re-export key types at crate root.
27+
pub use connection::{open_sync, ConnectionProvider, FileConnectionProvider};
28+
pub use error::{ServiceError, ServiceResult};

0 commit comments

Comments
 (0)