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

Commit 776e922

Browse files
z23ccclaude
andcommitted
feat: web orchestration platform — dashboard, Canvas DAG, operations, agents, memory, settings
12 tasks across 4 waves: Phase 1: Unified design system (CSS variables, cards, badges, stat-cards) + 7 new API endpoints (epic create, task skip/block/restart, config, memory, stats) Phase 2: Global dashboard (progress ring, stat cards, activity timeline) + Canvas DAG engine (replacing SVG, 2D context, Sugiyama layout) + Settings page (config viewer, invariants) Phase 3: Canvas DAG interaction (zoom/pan, hover tooltips, WebSocket real-time) + Epic/Task operation panel (create form, start/done/block buttons) + Agent log viewer (WebSocket event stream, filtering) + Memory browser (track/module filtering, search) + Responsive layout + keyboard shortcuts + breadcrumbs Phase 4: Agent operation buttons (retry/skip/block from web) + Toast notifications (WebSocket → bottom-right toasts) + Progress animations + completion flash effects Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent a3cd4e5 commit 776e922

19 files changed

Lines changed: 2974 additions & 558 deletions

File tree

flowctl/Cargo.lock

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

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

Lines changed: 246 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,11 @@ use axum::Json;
1313
use tokio::sync::broadcast;
1414
use tracing::{debug, info, warn};
1515

16-
use flowctl_core::id::is_task_id;
16+
use flowctl_core::id::{is_task_id, slugify};
1717
use flowctl_core::state_machine::{Status, Transition};
18-
use flowctl_core::types::FLOW_DIR;
18+
use flowctl_core::types::{FLOW_DIR, CONFIG_FILE};
1919
use flowctl_scheduler::TimestampedEvent;
20-
use flowctl_service::lifecycle::{DoneTaskRequest, StartTaskRequest};
20+
use flowctl_service::lifecycle::{BlockTaskRequest, DoneTaskRequest, RestartTaskRequest, StartTaskRequest};
2121
use flowctl_service::ServiceError;
2222

2323
use crate::lifecycle::DaemonRuntime;
@@ -278,6 +278,24 @@ pub struct TaskDoneRequest {
278278
pub summary: Option<String>,
279279
}
280280

281+
#[derive(Debug, serde::Deserialize)]
282+
pub struct TaskReasonRequest {
283+
pub task_id: String,
284+
pub reason: String,
285+
}
286+
287+
#[derive(Debug, serde::Deserialize)]
288+
pub struct CreateEpicRequest {
289+
pub title: String,
290+
}
291+
292+
/// Query parameters for the memory endpoint.
293+
#[derive(Debug, serde::Deserialize)]
294+
pub struct MemoryQuery {
295+
pub track: Option<String>,
296+
pub module: Option<String>,
297+
}
298+
281299
/// Query parameters for the tokens endpoint.
282300
#[derive(Debug, serde::Deserialize)]
283301
pub struct TokensQuery {
@@ -339,6 +357,7 @@ pub struct DagNode {
339357
pub id: String,
340358
pub title: String,
341359
pub status: String,
360+
pub domain: String,
342361
pub x: f64,
343362
pub y: f64,
344363
}
@@ -430,6 +449,9 @@ pub async fn dag_handler(
430449
status: task
431450
.map(|t| format!("{:?}", t.status).to_lowercase())
432451
.unwrap_or_else(|| "todo".to_string()),
452+
domain: task
453+
.map(|t| t.domain.to_string())
454+
.unwrap_or_else(|| "general".to_string()),
433455
x: layer_idx as f64 * node_spacing_x,
434456
y: y_offset + pos as f64 * node_spacing_y,
435457
});
@@ -616,6 +638,227 @@ fn touch_updated_at(conn: &rusqlite::Connection, task_id: &str) -> Result<(), Ap
616638
Ok(())
617639
}
618640

641+
// ── New API endpoints ──────────────────────────────────────────
642+
643+
/// POST /api/v1/epics/create -- create a new epic.
644+
pub async fn create_epic_handler(
645+
State(state): State<AppState>,
646+
Json(body): Json<CreateEpicRequest>,
647+
) -> Result<(StatusCode, Json<serde_json::Value>), AppError> {
648+
let title = body.title.trim().to_string();
649+
if title.is_empty() {
650+
return Err(AppError::InvalidInput("title is required".to_string()));
651+
}
652+
653+
let conn = state.db_lock()?;
654+
655+
// Determine next epic number from DB.
656+
let max_num: i64 = conn
657+
.query_row(
658+
"SELECT COALESCE(MAX(CAST(SUBSTR(id, 4, INSTR(SUBSTR(id, 4), '-') - 1) AS INTEGER)), 0) FROM epics WHERE id LIKE 'fn-%'",
659+
[],
660+
|row| row.get(0),
661+
)
662+
.unwrap_or(0);
663+
let epic_num = (max_num + 1) as u32;
664+
665+
let slug = slugify(&title, 40).unwrap_or_else(|| format!("epic{epic_num}"));
666+
let epic_id = format!("fn-{epic_num}-{slug}");
667+
668+
let epic = flowctl_core::types::Epic {
669+
schema_version: 1,
670+
id: epic_id.clone(),
671+
title: title.clone(),
672+
status: flowctl_core::types::EpicStatus::Open,
673+
branch_name: None,
674+
plan_review: flowctl_core::types::ReviewStatus::Unknown,
675+
completion_review: flowctl_core::types::ReviewStatus::Unknown,
676+
depends_on_epics: vec![],
677+
default_impl: None,
678+
default_review: None,
679+
default_sync: None,
680+
file_path: Some(format!("epics/{epic_id}.md")),
681+
created_at: chrono::Utc::now(),
682+
updated_at: chrono::Utc::now(),
683+
};
684+
685+
let repo = flowctl_db::EpicRepo::new(&conn);
686+
repo.upsert(&epic)?;
687+
688+
Ok((
689+
StatusCode::CREATED,
690+
Json(serde_json::json!({"success": true, "id": epic_id, "title": title})),
691+
))
692+
}
693+
694+
/// POST /api/v1/tasks/skip -- skip a task.
695+
pub async fn skip_task_handler(
696+
State(state): State<AppState>,
697+
Json(body): Json<TaskReasonRequest>,
698+
) -> Result<Json<serde_json::Value>, AppError> {
699+
let conn = state.db_lock()?;
700+
let repo = flowctl_db::TaskRepo::new(&conn);
701+
let task = repo
702+
.get(&body.task_id)
703+
.map_err(|_| AppError::InvalidInput(format!("task not found: {}", body.task_id)))?;
704+
705+
Transition::new(task.status, Status::Skipped).map_err(|e| {
706+
AppError::InvalidTransition(format!("cannot skip task '{}': {}", body.task_id, e))
707+
})?;
708+
709+
repo.update_status(&body.task_id, Status::Skipped)?;
710+
711+
Ok(Json(serde_json::json!({
712+
"success": true,
713+
"id": body.task_id,
714+
"status": "skipped"
715+
})))
716+
}
717+
718+
/// POST /api/v1/tasks/block -- block a task.
719+
pub async fn block_task_handler(
720+
State(state): State<AppState>,
721+
Json(body): Json<TaskReasonRequest>,
722+
) -> Result<Json<serde_json::Value>, AppError> {
723+
let task_id = body.task_id.clone();
724+
let reason = body.reason.clone();
725+
let db = state.db.clone();
726+
727+
let result = tokio::task::spawn_blocking(move || {
728+
let conn = db
729+
.lock()
730+
.map_err(|_| ServiceError::ValidationError("DB lock poisoned".to_string()))?;
731+
let flow_dir = std::env::current_dir()
732+
.unwrap_or_else(|_| std::path::PathBuf::from("."))
733+
.join(FLOW_DIR);
734+
let req = BlockTaskRequest { task_id, reason };
735+
flowctl_service::lifecycle::block_task(Some(&conn), &flow_dir, req)
736+
})
737+
.await
738+
.map_err(|e| AppError::Internal(format!("spawn_blocking failed: {e}")))?;
739+
740+
match result {
741+
Ok(resp) => Ok(Json(serde_json::json!({
742+
"success": true,
743+
"id": resp.task_id,
744+
"status": "blocked"
745+
}))),
746+
Err(e) => Err(service_error_to_app_error(e)),
747+
}
748+
}
749+
750+
/// POST /api/v1/tasks/restart -- restart a task and cascade.
751+
pub async fn restart_task_handler(
752+
State(state): State<AppState>,
753+
Json(body): Json<TaskIdRequest>,
754+
) -> Result<Json<serde_json::Value>, AppError> {
755+
let task_id = body.task_id.clone();
756+
let db = state.db.clone();
757+
758+
let result = tokio::task::spawn_blocking(move || {
759+
let conn = db
760+
.lock()
761+
.map_err(|_| ServiceError::ValidationError("DB lock poisoned".to_string()))?;
762+
let flow_dir = std::env::current_dir()
763+
.unwrap_or_else(|_| std::path::PathBuf::from("."))
764+
.join(FLOW_DIR);
765+
let req = RestartTaskRequest {
766+
task_id,
767+
dry_run: false,
768+
force: true,
769+
};
770+
flowctl_service::lifecycle::restart_task(Some(&conn), &flow_dir, req)
771+
})
772+
.await
773+
.map_err(|e| AppError::Internal(format!("spawn_blocking failed: {e}")))?;
774+
775+
match result {
776+
Ok(resp) => Ok(Json(serde_json::json!({
777+
"success": true,
778+
"reset": resp.reset_ids
779+
}))),
780+
Err(e) => Err(service_error_to_app_error(e)),
781+
}
782+
}
783+
784+
/// GET /api/v1/config -- read .flow/config.json.
785+
pub async fn config_handler(
786+
State(state): State<AppState>,
787+
) -> Result<Json<serde_json::Value>, AppError> {
788+
let config_path = state
789+
.runtime
790+
.paths
791+
.state_dir
792+
.parent() // .flow/
793+
.map(|flow_dir| flow_dir.join(CONFIG_FILE))
794+
.ok_or_else(|| AppError::Internal("cannot resolve .flow/ directory".to_string()))?;
795+
796+
if !config_path.exists() {
797+
return Ok(Json(serde_json::json!({})));
798+
}
799+
800+
let content = std::fs::read_to_string(&config_path)
801+
.map_err(|e| AppError::Internal(format!("failed to read config: {e}")))?;
802+
let value: serde_json::Value = serde_json::from_str(&content)
803+
.map_err(|e| AppError::Internal(format!("invalid config JSON: {e}")))?;
804+
Ok(Json(value))
805+
}
806+
807+
/// GET /api/v1/memory -- list memory entries.
808+
pub async fn memory_handler(
809+
State(state): State<AppState>,
810+
axum::extract::Query(params): axum::extract::Query<MemoryQuery>,
811+
) -> Result<Json<serde_json::Value>, AppError> {
812+
let conn = state.db_lock()?;
813+
814+
let mut sql = String::from("SELECT id, entry_type, content, summary, module, severity, problem_type, track, created_at FROM memory WHERE 1=1");
815+
let mut bind_values: Vec<String> = Vec::new();
816+
817+
if let Some(ref track) = params.track {
818+
bind_values.push(track.clone());
819+
sql.push_str(&format!(" AND track = ?{}", bind_values.len()));
820+
}
821+
if let Some(ref module) = params.module {
822+
bind_values.push(module.clone());
823+
sql.push_str(&format!(" AND module = ?{}", bind_values.len()));
824+
}
825+
sql.push_str(" ORDER BY created_at DESC");
826+
827+
let mut stmt = conn.prepare(&sql).map_err(|e| AppError::Db(e.to_string()))?;
828+
let params_slice: Vec<&dyn rusqlite::types::ToSql> =
829+
bind_values.iter().map(|s| s as &dyn rusqlite::types::ToSql).collect();
830+
let rows = stmt
831+
.query_map(params_slice.as_slice(), |row| {
832+
Ok(serde_json::json!({
833+
"id": row.get::<_, i64>(0)?,
834+
"entry_type": row.get::<_, String>(1)?,
835+
"content": row.get::<_, String>(2)?,
836+
"summary": row.get::<_, Option<String>>(3)?,
837+
"module": row.get::<_, Option<String>>(4)?,
838+
"severity": row.get::<_, Option<String>>(5)?,
839+
"problem_type": row.get::<_, Option<String>>(6)?,
840+
"track": row.get::<_, Option<String>>(7)?,
841+
"created_at": row.get::<_, String>(8)?,
842+
}))
843+
})
844+
.map_err(|e| AppError::Db(e.to_string()))?;
845+
846+
let entries: Vec<serde_json::Value> = rows.filter_map(|r| r.ok()).collect();
847+
Ok(Json(serde_json::json!({"entries": entries})))
848+
}
849+
850+
/// GET /api/v1/stats -- global statistics.
851+
pub async fn stats_handler(
852+
State(state): State<AppState>,
853+
) -> Result<Json<serde_json::Value>, AppError> {
854+
let conn = state.db_lock()?;
855+
let stats = flowctl_db::StatsQuery::new(&conn);
856+
let summary = stats.summary()?;
857+
let value = serde_json::to_value(&summary)
858+
.map_err(|e| AppError::Internal(format!("serialization error: {e}")))?;
859+
Ok(Json(value))
860+
}
861+
619862
/// GET /api/v1/events -- WebSocket upgrade for live event streaming.
620863
pub async fn events_ws_handler(
621864
ws: WebSocketUpgrade,

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,13 @@ pub fn build_router(state: AppState) -> axum::Router {
5454
.route("/api/v1/tasks/create", post(handlers::create_task_handler))
5555
.route("/api/v1/tasks/start", post(handlers::start_task_handler))
5656
.route("/api/v1/tasks/done", post(handlers::done_task_handler))
57+
.route("/api/v1/epics/create", post(handlers::create_epic_handler))
58+
.route("/api/v1/tasks/skip", post(handlers::skip_task_handler))
59+
.route("/api/v1/tasks/block", post(handlers::block_task_handler))
60+
.route("/api/v1/tasks/restart", post(handlers::restart_task_handler))
61+
.route("/api/v1/config", get(handlers::config_handler))
62+
.route("/api/v1/memory", get(handlers::memory_handler))
63+
.route("/api/v1/stats", get(handlers::stats_handler))
5764
.route("/api/v1/shutdown", post(handlers::shutdown_handler))
5865
.route("/api/v1/events", get(handlers::events_ws_handler))
5966
.layer(cors)

flowctl/crates/flowctl-web/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,8 @@ serde_json = { workspace = true }
1919
wasm-bindgen = { version = "0.2", optional = true }
2020
console_error_panic_hook = { version = "0.1", optional = true }
2121
gloo-net = { version = "0.6", optional = true }
22-
web-sys = { version = "0.3", optional = true, features = ["WebSocket", "MessageEvent", "Window", "Location"] }
22+
js-sys = { version = "0.3", optional = true }
23+
web-sys = { version = "0.3", optional = true, features = ["WebSocket", "MessageEvent", "Window", "Location", "HtmlCanvasElement", "CanvasRenderingContext2d", "TextMetrics", "WheelEvent", "Document", "KeyboardEvent", "EventTarget", "HtmlInputElement"] }
2324

2425
# Server-side only
2526
leptos_axum = { version = "0.8", optional = true }
@@ -33,6 +34,7 @@ hydrate = [
3334
"dep:console_error_panic_hook",
3435
"dep:gloo-net",
3536
"dep:web-sys",
37+
"dep:js-sys",
3638
]
3739
ssr = [
3840
"leptos/ssr",

0 commit comments

Comments
 (0)