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

Commit 9fd731b

Browse files
z23ccclaude
andcommitted
feat: Wave 1 — design system + backend API endpoints
Design System (.1): - CSS design tokens: colors, spacing, radius, transitions in index.css - 6 shared UI components: Button, Card, Badge, Modal, Table, Skeleton - Status color classes (.status-todo, .status-progress, .status-done, etc.) Backend API (.2): - Split handlers.rs (935 lines) into handlers/ module (6 files, each < 450 lines) - 11 new RESTful endpoints: epic plan/work, task start/done/block/restart/skip, dep add/remove, DAG detail with critical_path - All existing legacy endpoints preserved 292 Rust tests pass, frontend builds (481KB). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 4d2782a commit 9fd731b

15 files changed

Lines changed: 1698 additions & 952 deletions

File tree

flowctl/crates/flowctl-daemon/src/handlers.rs

Lines changed: 0 additions & 935 deletions
This file was deleted.
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
//! Shared types and helpers used across all handler modules.
2+
3+
use std::sync::Arc;
4+
5+
use axum::http::StatusCode;
6+
use axum::response::IntoResponse;
7+
use axum::Json;
8+
9+
use flowctl_service::ServiceError;
10+
11+
use crate::lifecycle::DaemonRuntime;
12+
13+
/// Application-level error type with proper HTTP status mapping.
14+
#[derive(Debug)]
15+
pub enum AppError {
16+
/// Database error (query failed, constraint violation, etc.)
17+
Db(String),
18+
/// Invalid state transition (e.g., done → in_progress)
19+
InvalidTransition(String),
20+
/// Invalid input (bad ID format, missing fields, etc.)
21+
InvalidInput(String),
22+
/// Internal error (serialization failure, lock poisoned, etc.)
23+
Internal(String),
24+
}
25+
26+
impl IntoResponse for AppError {
27+
fn into_response(self) -> axum::response::Response {
28+
let (status, message) = match &self {
29+
AppError::Db(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
30+
AppError::InvalidTransition(msg) => (StatusCode::CONFLICT, msg.clone()),
31+
AppError::InvalidInput(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
32+
AppError::Internal(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg.clone()),
33+
};
34+
(status, Json(serde_json::json!({"error": message}))).into_response()
35+
}
36+
}
37+
38+
impl From<flowctl_db::DbError> for AppError {
39+
fn from(e: flowctl_db::DbError) -> Self {
40+
AppError::Db(e.to_string())
41+
}
42+
}
43+
44+
/// Shared application state for all handlers.
45+
pub type AppState = Arc<DaemonState>;
46+
47+
/// Combined daemon state: runtime + event bus + shared DB connection.
48+
pub struct DaemonState {
49+
pub runtime: DaemonRuntime,
50+
pub event_bus: flowctl_scheduler::EventBus,
51+
pub db: Arc<std::sync::Mutex<rusqlite::Connection>>,
52+
}
53+
54+
impl DaemonState {
55+
/// Acquire DB lock, returning AppError instead of panicking.
56+
pub fn db_lock(&self) -> Result<std::sync::MutexGuard<'_, rusqlite::Connection>, AppError> {
57+
self.db
58+
.lock()
59+
.map_err(|_| AppError::Internal("DB lock poisoned".to_string()))
60+
}
61+
}
62+
63+
/// Map service-layer errors to HTTP-appropriate AppErrors.
64+
pub fn service_error_to_app_error(e: ServiceError) -> AppError {
65+
match e {
66+
ServiceError::TaskNotFound(msg) => AppError::InvalidInput(msg),
67+
ServiceError::EpicNotFound(msg) => AppError::InvalidInput(msg),
68+
ServiceError::InvalidTransition(msg) => AppError::InvalidTransition(msg),
69+
ServiceError::DependencyUnsatisfied { task, dependency } => {
70+
AppError::InvalidInput(format!("task {task} blocked by {dependency}"))
71+
}
72+
ServiceError::CrossActorViolation(msg) => AppError::InvalidInput(msg),
73+
ServiceError::ValidationError(msg) => AppError::InvalidInput(msg),
74+
ServiceError::DbError(e) => AppError::Db(e.to_string()),
75+
ServiceError::IoError(e) => AppError::Internal(e.to_string()),
76+
ServiceError::CoreError(e) => AppError::Internal(e.to_string()),
77+
}
78+
}
79+
80+
/// Check optimistic lock version against the task's `updated_at`.
81+
pub fn check_version(task: &flowctl_core::types::Task, version: &str) -> Result<(), AppError> {
82+
let task_version = task.updated_at.to_rfc3339();
83+
if task_version != version {
84+
return Err(AppError::InvalidTransition(format!(
85+
"version conflict: expected {version}, got {task_version}"
86+
)));
87+
}
88+
Ok(())
89+
}
90+
91+
/// Update the `updated_at` timestamp for a task.
92+
pub fn touch_updated_at(conn: &rusqlite::Connection, task_id: &str) -> Result<(), AppError> {
93+
conn.execute(
94+
"UPDATE tasks SET updated_at = ?1 WHERE id = ?2",
95+
rusqlite::params![chrono::Utc::now().to_rfc3339(), task_id],
96+
).map_err(|e| AppError::Db(e.to_string()))?;
97+
Ok(())
98+
}

0 commit comments

Comments
 (0)