feat(tui): add usage overlay and sidebar for displaying usage statistics and tasks
feat(tui): implement status bar with connection and turn state indicators feat(tui): create workflow panel for agent status and progress visualization feat(web): introduce web frontend interface with static file serving feat(ws): add WebSocket interface for real-time communication and session management
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
[package]
|
||||
name = "zesdex-gateway"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
# Gateway binary — assembles domain + application + infrastructure
|
||||
# + selected interface(s) into a running application process.
|
||||
# This is the main entry point that wires everything together.
|
||||
[[bin]]
|
||||
name = "zesdex"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "seed"
|
||||
path = "src/bin/seed.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "migrate"
|
||||
path = "src/bin/migrate.rs"
|
||||
|
||||
[dependencies]
|
||||
zesdex-domain = { path = "../domain" }
|
||||
zesdex-application = { path = "../application" }
|
||||
zesdex-infrastructure = { path = "../infrastructure" }
|
||||
zesdex-tui = { path = "../interfaces/tui" }
|
||||
zesdex-api = { path = "../interfaces/api" }
|
||||
zesdex-daemon = { path = "../interfaces/daemon" }
|
||||
zesdex-ws = { path = "../interfaces/ws" }
|
||||
zesdex-grpc = { path = "../interfaces/grpc" }
|
||||
zesdex-web = { path = "../interfaces/web" }
|
||||
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
chrono.workspace = true
|
||||
uuid.workspace = true
|
||||
anyhow.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
dirs.workspace = true
|
||||
rusqlite.workspace = true
|
||||
axum.workspace = true
|
||||
clap = { version = "4", features = ["derive"] }
|
||||
@@ -0,0 +1,108 @@
|
||||
//! Database migration binary.
|
||||
//!
|
||||
//! Scans all session directories and initializes or upgrades the SQLite
|
||||
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
|
||||
|
||||
use std::path::Path;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
let sessions_dir = store.base_dir.join("sessions");
|
||||
|
||||
if !sessions_dir.exists() {
|
||||
eprintln!("No sessions directory found, nothing to migrate");
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let mut migrated = 0u32;
|
||||
let mut failed = 0u32;
|
||||
|
||||
for entry in std::fs::read_dir(&sessions_dir)? {
|
||||
let entry = entry?;
|
||||
let path = entry.path();
|
||||
if !path.is_dir() {
|
||||
continue;
|
||||
}
|
||||
|
||||
match migrate_session_msglog(&path) {
|
||||
Ok(_) => {
|
||||
migrated += 1;
|
||||
eprintln!("Migrated session: {:?}", path.file_name());
|
||||
}
|
||||
Err(e) => {
|
||||
failed += 1;
|
||||
eprintln!("Failed to migrate session {:?}: {e}", path.file_name());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
eprintln!("Migration complete: {migrated} succeeded, {failed} failed");
|
||||
if failed > 0 {
|
||||
anyhow::bail!("{failed} session(s) failed to migrate");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> {
|
||||
let msglog_path = session_dir.join("messages.sqlite");
|
||||
|
||||
if let Some(parent) = msglog_path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
|
||||
let conn = rusqlite::Connection::open(&msglog_path)?;
|
||||
conn.execute_batch("PRAGMA journal_mode = WAL;")?;
|
||||
conn.execute_batch("PRAGMA busy_timeout = 5000;")?;
|
||||
conn.execute_batch("PRAGMA foreign_keys = ON;")?;
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_call_id TEXT,
|
||||
tool_name TEXT,
|
||||
tool_arguments TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS archives (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT,
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
message_count INTEGER DEFAULT 0,
|
||||
token_count INTEGER DEFAULT 0,
|
||||
summary TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_created_at ON messages(created_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_archives_created_at ON archives(created_at);
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL,
|
||||
blob_key TEXT NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
mime_type TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
UNIQUE(session_id, blob_key)
|
||||
);",
|
||||
)?;
|
||||
|
||||
let version: i32 = conn
|
||||
.pragma_query_value(None, "user_version", |row| row.get(0))
|
||||
.unwrap_or(0);
|
||||
|
||||
if version < 1 {
|
||||
conn.pragma_update(None, "user_version", 1)?;
|
||||
}
|
||||
if version < 2 {
|
||||
conn.execute_batch(
|
||||
"CREATE INDEX IF NOT EXISTS idx_messages_session_role ON messages(session_id, role);",
|
||||
)?;
|
||||
conn.pragma_update(None, "user_version", 2)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
//! Database seeder binary.
|
||||
//!
|
||||
//! Initialises the store directory structure and creates default
|
||||
//! configuration files plus a seed session for development/testing.
|
||||
//! Invoked as `cargo run --bin seed`.
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
|
||||
// Create default settings if not present
|
||||
let settings_path = store.base_dir.join("settings.json");
|
||||
if !settings_path.exists() {
|
||||
let settings = zesdex_domain::cms::Settings::default();
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
let tmp = store.base_dir.join("settings.json.tmp");
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, settings_path)?;
|
||||
println!("Default settings created");
|
||||
} else {
|
||||
println!("Settings already exist, skipping");
|
||||
}
|
||||
|
||||
// Create default app config if not present
|
||||
let config_path = store.base_dir.join("app_config.json");
|
||||
if !config_path.exists() {
|
||||
let config = zesdex_domain::cms::AppConfig::default();
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
let tmp = store.base_dir.join("app_config.json.tmp");
|
||||
std::fs::write(&tmp, content)?;
|
||||
let f = std::fs::File::open(&tmp)?;
|
||||
f.sync_all()?;
|
||||
std::fs::rename(&tmp, config_path)?;
|
||||
println!("Default app_config created");
|
||||
} else {
|
||||
println!("App config already exists, skipping");
|
||||
}
|
||||
|
||||
// Create data directories
|
||||
std::fs::create_dir_all(&store.memory_dir)?;
|
||||
std::fs::create_dir_all(&store.session_images_dir)?;
|
||||
println!("All store directories verified");
|
||||
|
||||
// Create a seed session
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let session = zesdex_domain::auth::Session::new(
|
||||
session_id.clone(),
|
||||
"Seed Session".to_string(),
|
||||
);
|
||||
// Persist via the session repository
|
||||
use zesdex_domain::SessionRepository;
|
||||
let repo = zesdex_infrastructure::persistence::iam::session_repo::FileSystemSessionRepository::new();
|
||||
repo.save_session(&store.base_dir, &session)?;
|
||||
println!("Seed session created: id={session_id}");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
//! Gateway library — provides shared utilities for the gateway binary.
|
||||
//! The main entry point is in `main.rs`.
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Zesdex Gateway — main entry point.
|
||||
//!
|
||||
//! Assembles domain + application + infrastructure layers and dispatches
|
||||
//! to the requested interface: TUI (default), daemon (background IPC),
|
||||
//! API server (REST), WebSocket server, gRPC server, or Web frontend.
|
||||
//!
|
||||
//! # CLI flags
|
||||
//!
|
||||
//! | Flag | Description |
|
||||
//! |------|-------------|
|
||||
//! | `--daemon` | Run as background daemon with IPC socket |
|
||||
//! | `--attach <id>` | Attach TUI client to a running daemon |
|
||||
//! | `--api` | Run REST API server |
|
||||
//! | `--api-port <port>` | REST API port (default 8080) |
|
||||
//! | `--ws` | Run WebSocket server |
|
||||
//! | `--ws-port <port>` | WebSocket port (default 8081) |
|
||||
//! | `--grpc` | Run gRPC server |
|
||||
//! | `--grpc-port <port>` | gRPC port (default 50051) |
|
||||
//! | `--web` | Serve web frontend |
|
||||
//! | `--version` | Print version and exit |
|
||||
|
||||
use std::sync::Mutex;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let args: Vec<String> = std::env::args().collect();
|
||||
let is_daemon = args.iter().any(|a| a == "--daemon");
|
||||
let is_api = args.iter().any(|a| a == "--api");
|
||||
let is_ws = args.iter().any(|a| a == "--ws");
|
||||
let is_grpc = args.iter().any(|a| a == "--grpc");
|
||||
let is_web = args.iter().any(|a| a == "--web");
|
||||
let attach_session = args
|
||||
.iter()
|
||||
.position(|a| a == "--attach")
|
||||
.and_then(|i| args.get(i + 1).cloned());
|
||||
|
||||
if args.iter().any(|a| a == "--version") {
|
||||
println!("Zesdex version {}", env!("CARGO_PKG_VERSION"));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// ── Setup logging ────────────────────────────────────────────────────
|
||||
let log_dir = dirs::data_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("."))
|
||||
.join("zesdex");
|
||||
let _ = std::fs::create_dir_all(&log_dir);
|
||||
let log_path = log_dir.join("zesdex.log");
|
||||
let log_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(&log_path)
|
||||
.unwrap_or_else(|_| {
|
||||
std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.open("/dev/null")
|
||||
.expect("cannot open /dev/null")
|
||||
});
|
||||
|
||||
tracing_subscriber::fmt()
|
||||
.with_env_filter(
|
||||
tracing_subscriber::EnvFilter::try_from_default_env()
|
||||
.unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")),
|
||||
)
|
||||
.with_writer(Mutex::new(log_file))
|
||||
.init();
|
||||
|
||||
tracing::info!("zesdex gateway starting");
|
||||
|
||||
// ── Dispatch to interface ────────────────────────────────────────────
|
||||
// Validate mutually exclusive flags
|
||||
let mode_count = [is_daemon, is_api, is_ws, is_grpc, is_web]
|
||||
.iter()
|
||||
.filter(|&&b| b)
|
||||
.count()
|
||||
+ if attach_session.is_some() { 1 } else { 0 };
|
||||
|
||||
if mode_count > 1 {
|
||||
anyhow::bail!(
|
||||
"Cannot specify multiple modes: --daemon, --attach, --api, --ws, --grpc, --web are mutually exclusive"
|
||||
);
|
||||
}
|
||||
|
||||
if is_daemon {
|
||||
tracing::info!("starting in daemon mode");
|
||||
zesdex_daemon::server::run_daemon()?;
|
||||
} else if let Some(session_id) = attach_session {
|
||||
tracing::info!("starting in attach mode for session {session_id}");
|
||||
zesdex_daemon::client::run_attach(&session_id)?;
|
||||
} else if is_api {
|
||||
tracing::info!("starting in API server mode");
|
||||
run_api_server(&args)?;
|
||||
} else if is_ws {
|
||||
tracing::info!("starting in WebSocket server mode");
|
||||
run_ws_server()?;
|
||||
} else if is_grpc {
|
||||
tracing::info!("starting in gRPC server mode");
|
||||
run_grpc_server()?;
|
||||
} else if is_web {
|
||||
tracing::info!("starting in web server mode");
|
||||
run_web_server()?;
|
||||
} else {
|
||||
// Default: run TUI single-process mode
|
||||
tracing::info!("starting in TUI single-process mode");
|
||||
run_tui_single_process()?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the TUI in single-process mode (TUI + agent in one process).
|
||||
fn run_tui_single_process() -> anyhow::Result<()> {
|
||||
// Import and run the TUI's single-process entry point
|
||||
zesdex_tui::run_single_process()
|
||||
}
|
||||
|
||||
/// Run the REST API server.
|
||||
fn run_api_server(args: &[String]) -> anyhow::Result<()> {
|
||||
let port = args
|
||||
.iter()
|
||||
.position(|a| a == "--api-port")
|
||||
.and_then(|i| args.get(i + 1))
|
||||
.and_then(|s| s.parse::<u16>().ok())
|
||||
.unwrap_or(8080);
|
||||
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
let state = zesdex_api::ApiState::new(
|
||||
store.base_dir.clone(),
|
||||
"dev-secret",
|
||||
"",
|
||||
"deepseek-v4-flash-free",
|
||||
Some("https://opencode.ai/zen/v1".to_string()),
|
||||
);
|
||||
let app = zesdex_api::build_router(state);
|
||||
let addr = std::net::SocketAddr::from(([0, 0, 0, 0], port));
|
||||
tracing::info!("REST API server listening on {addr}");
|
||||
println!("REST API server listening on http://{addr}/api/v1/health");
|
||||
let listener = tokio::net::TcpListener::bind(addr).await?;
|
||||
axum::serve(listener, app).await?;
|
||||
Ok::<_, anyhow::Error>(())
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the WebSocket server.
|
||||
fn run_ws_server() -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async { zesdex_ws::run_server(8081).await })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run the gRPC server.
|
||||
fn run_grpc_server() -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async { zesdex_grpc::run_server(50051).await })?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Serve the web frontend.
|
||||
fn run_web_server() -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async { zesdex_web::run_server(3000, None).await })?;
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user