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:
asepharyana
2026-07-20 09:04:57 +07:00
parent bceba665c0
commit da2ed6da25
454 changed files with 13979 additions and 29539 deletions
+30
View File
@@ -0,0 +1,30 @@
//! Command types for IAM domain operations.
//!
//! Following the `NewXxx` / command pattern from clean architecture,
//! these types encapsulate the input data for create/update operations
//! on domain entities. They decouple presentation DTOs from the entity
//! mutation surface and provide a clear boundary for validation.
/// Command to create a new session.
///
/// Carries only the data needed to construct a session entity — the
/// service generates the UUID and timestamp internally.
#[derive(Debug, Clone)]
pub struct NewSession {
/// Human-readable session title.
pub title: String,
}
impl From<String> for NewSession {
fn from(title: String) -> Self {
Self { title }
}
}
impl From<&str> for NewSession {
fn from(title: &str) -> Self {
Self {
title: title.to_string(),
}
}
}
+62
View File
@@ -0,0 +1,62 @@
//! Domain error types for the IAM (auth) module.
//!
//! Typed error enums replace `anyhow::Result` in domain traits and
//! application services, enabling callers to match on specific error
//! variants (e.g. `NotFound` vs `Conflict`) rather than string-checking.
//!
//! # Components
//!
//! - [`RepositoryError`] — persistence-layer errors (not found, conflict, I/O)
//! - [`ServiceError`] — use-case / orchestration errors (config, state
//! mismatch, provider failures)
use std::fmt;
use crate::error::DomainError;
/// Shared repository error type for IAM persistence operations.
pub type RepositoryError = DomainError;
/// Errors from service / use-case operations in the IAM domain.
#[derive(Debug)]
pub enum ServiceError {
/// A repository operation failed.
Repository(DomainError),
/// The provided configuration is invalid.
InvalidConfig(String),
/// OAuth state mismatch — possible CSRF attack.
StateMismatch,
/// The OAuth provider returned an error.
OAuthProvider(String),
/// A generic error with a message.
Other(String),
}
impl From<DomainError> for ServiceError {
fn from(err: DomainError) -> Self {
ServiceError::Repository(err)
}
}
impl fmt::Display for ServiceError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ServiceError::Repository(err) => write!(f, "repository error: {err}"),
ServiceError::InvalidConfig(msg) => write!(f, "invalid configuration: {msg}"),
ServiceError::StateMismatch => {
write!(f, "OAuth state mismatch — possible CSRF attack")
}
ServiceError::OAuthProvider(msg) => write!(f, "OAuth provider error: {msg}"),
ServiceError::Other(msg) => write!(f, "{msg}"),
}
}
}
impl std::error::Error for ServiceError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
ServiceError::Repository(err) => Some(err),
_ => None,
}
}
}
+12
View File
@@ -0,0 +1,12 @@
//! IAM Session re-export.
//!
//! Re-exports `Session` from the auth module for consistent IAM-boundary
//! imports. Consumers of the IAM module import `Session` from here rather
//! than from the core session module directly, keeping the dependency
//! internal and allowing the IAM crate to own its domain vocabulary.
pub use super::session::Session;
/// Alias for `Session` used in IAM contexts to distinguish from other
/// session types in the system.
pub type IamSession = Session;
+36
View File
@@ -0,0 +1,36 @@
//! Authentication domain entities, commands, errors, and repository/service traits.
//!
//! Combines the session types from `zesdex-entities` (auth sub-module) with the
//! IAM domain types (commands, OAuth, repository/service traits) from `zesdex-iam`.
//!
//! # Sub-modules
//!
//! - [`session`] — `Session` entity (session metadata)
//! - [`session_id`] — `SessionId` value object (validated newtype)
//! - [`session_lock`] — `SessionLock` RAII guard (PID-file lock)
//! - [`oauth`] — `OAuthToken`, `OAuthConfig` entities
//! - [`iam_session`] — Re-export of `Session` for IAM-boundary consistency
//! - [`commands`] — `NewSession` command type
//! - [`error`] — `RepositoryError`, `ServiceError` types
//! - [`repository`] — `SessionRepository`, `SessionLockRepository`, `OAuthRepository`
//! - [`service`] — `SessionService`, `OAuthService` traits
pub mod commands;
pub mod error;
pub mod iam_session;
pub mod oauth;
pub mod repository;
pub mod service;
pub mod session;
pub mod session_id;
pub mod session_lock;
pub use commands::NewSession;
pub use error::{RepositoryError, ServiceError};
pub use iam_session::IamSession;
pub use oauth::{OAuthConfig, OAuthToken};
pub use repository::{OAuthRepository, SessionLockRepository, SessionRepository};
pub use service::{OAuthService, SessionService};
pub use session::Session;
pub use session_id::SessionId;
pub use session_lock::SessionLock;
+53
View File
@@ -0,0 +1,53 @@
//! Pure OAuth entities — no HTTP or persistence logic.
//!
//! # Components
//!
//! - [`OAuthToken`] — access token with optional refresh token, epoch expiry
//! - [`OAuthConfig`] — provider configuration (auth URL, token URL, client id,
//! optional client secret, scopes)
use serde::{Deserialize, Serialize};
/// An OAuth 2.0 access token with optional refresh token and absolute
/// expiry time (epoch seconds).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthToken {
/// The OAuth 2.0 access token string.
pub access_token: String,
/// Optional refresh token for long-lived access.
pub refresh_token: Option<String>,
/// Absolute expiry timestamp (epoch seconds since UNIX_EPOCH).
pub expires_at: u64,
/// Token type, e.g. `"Bearer"`.
pub token_type: String,
}
/// Static configuration for an OAuth provider.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthConfig {
/// Authorization endpoint URL.
pub auth_url: String,
/// Token exchange endpoint URL.
pub token_url: String,
/// OAuth client identifier.
pub client_id: String,
/// Optional client secret (not all flows require it).
pub client_secret: Option<String>,
/// Space-separated list of requested scopes.
pub scopes: Vec<String>,
}
impl Default for OAuthConfig {
fn default() -> Self {
OAuthConfig {
auth_url: String::new(),
token_url: String::new(),
client_id: String::new(),
client_secret: None,
scopes: vec![
"openid".to_string(),
"profile".to_string(),
"email".to_string(),
],
}
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Repository trait definitions (pure — no impls, no concrete persistence).
//!
//! Defines the repository contracts that infrastructure adapters implement.
//! Following clean architecture, domain code depends only on these traits,
//! not on concrete persistence libraries.
//!
//! # Traits
//!
//! - [`SessionRepository`] — CRUD for session metadata
//! - [`SessionLockRepository`] — acquire/release/liveness for session locks
//! - [`OAuthRepository`] — persist/load OAuth tokens
use std::path::Path;
use crate::auth::error::RepositoryError;
use crate::auth::oauth::OAuthToken;
use crate::auth::session::Session;
use crate::auth::session_id::SessionId;
/// Repository for loading, saving, listing, and deleting sessions.
pub trait SessionRepository {
/// List all loadable sessions under `<base_dir>/sessions/`.
fn list_sessions(&self, base_dir: &Path) -> Result<Vec<Session>, RepositoryError>;
/// Load a single session by id.
fn load_session(&self, base_dir: &Path, id: &SessionId) -> Result<Session, RepositoryError>;
/// Save a session's metadata to disk.
fn save_session(&self, base_dir: &Path, session: &Session) -> Result<(), RepositoryError>;
/// Delete a session directory and all its contents.
fn delete_session(&self, base_dir: &Path, id: &SessionId) -> Result<(), RepositoryError>;
}
/// Repository for per-session PID-file advisory locks.
pub trait SessionLockRepository {
/// Try to acquire the lock for a session directory.
/// Returns `true` if the lock was acquired, `false` if another live
/// process holds it.
fn try_lock(&self, session_dir: &Path) -> Result<bool, RepositoryError>;
/// Release the lock by removing the lock file.
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError>;
/// Check whether a process with the given PID is alive.
fn is_alive(&self, pid: u32) -> bool;
}
/// Repository for persisting and loading OAuth tokens.
pub trait OAuthRepository {
/// Persist an OAuth token to a JSON file.
fn save_token(&self, path: &Path, token: &OAuthToken) -> Result<(), RepositoryError>;
/// Load an OAuth token from a JSON file, returning `None` if the file
/// does not exist.
fn load_token(&self, path: &Path) -> Result<Option<OAuthToken>, RepositoryError>;
}
+56
View File
@@ -0,0 +1,56 @@
//! Service trait definitions — use-case interfaces for session management
//! and OAuth flows.
//!
//! These traits define the boundary between the application orchestration
//! layer and the domain. Implementations live in the application layer.
//!
//! # Traits
//!
//! - [`SessionService`] — create, list, archive sessions
//! - [`OAuthService`] — start PKCE flow, complete code exchange, retrieve token
use crate::auth::error::ServiceError;
use crate::auth::oauth::{OAuthConfig, OAuthToken};
use crate::auth::session::Session;
use crate::auth::session_id::SessionId;
/// Session management use-case boundary.
pub trait SessionService {
/// Create a new session with a generated UUID and the given title.
fn create_session(&self, title: &str) -> Result<Session, ServiceError>;
/// List all available sessions.
fn list_all(&self) -> Result<Vec<Session>, ServiceError>;
/// Archive a session by id (sets `archived = true`).
fn archive_session(&self, id: SessionId) -> Result<(), ServiceError>;
}
/// OAuth flow use-case boundary.
pub trait OAuthService {
/// Start an OAuth authorization-code + PKCE flow for the given
/// `redirect_uri` (the caller is responsible for actually listening on
/// it — e.g. a bound `LoopbackServer`). Returns `(auth_url, state)`:
/// the URL to send the user to, and the CSRF state token that must be
/// passed back into `complete_flow` unchanged.
fn start_flow(
&self,
config: &OAuthConfig,
redirect_uri: &str,
) -> Result<(String, String), ServiceError>;
/// Complete the OAuth flow: validates `state` against the value
/// persisted during `start_flow` (bailing on mismatch — this is the
/// CSRF check), then exchanges `code` for a token using the same
/// `redirect_uri` passed to `start_flow`.
fn complete_flow(
&self,
config: &OAuthConfig,
redirect_uri: &str,
code: &str,
state: &str,
) -> Result<OAuthToken, ServiceError>;
/// Retrieve the currently stored OAuth token (if any).
fn get_token(&self) -> Result<Option<OAuthToken>, ServiceError>;
}
+71
View File
@@ -0,0 +1,71 @@
//! Session metadata: id, title, workspace roots, and message/token counts,
//! persisted as `session.json` per session directory.
//!
//! # Flow
//!
//! Created via [`Session::new`] → mutated in-memory → persisted via repository.
//!
//! # Components
//!
//! - `Session` struct — fields for all session metadata
//! - `new` — timestamped constructor
//! - `session_dir` / `conversation_path` — pure path computation
use chrono::Utc;
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
/// Metadata for one conversation session (distinct from the message
/// history itself, which lives in `Conversation`/the msglog).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Session {
/// Unique session identifier (validated against path traversal in `load`).
pub id: String,
/// Epoch-millis timestamp of creation (`Utc::now().timestamp_millis()`).
pub created_at: i64,
/// Epoch-millis timestamp of last update.
pub updated_at: i64,
/// Human-readable title for the conversation.
pub title: String,
/// Model identifier string, e.g. `"anthropic/claude-opus-4-8"`.
pub model: String,
/// Workspace root directories associated with this session.
pub workspace_roots: Vec<PathBuf>,
/// Running count of messages in the conversation.
pub message_count: u32,
/// Running count of tokens consumed.
pub token_count: u32,
/// Soft-delete flag — archived sessions are hidden from the default list.
pub archived: bool,
/// Optional AI-generated conversation summary (used for compact context).
pub summary: Option<String>,
}
impl Session {
/// Create a new session with the given id/title, defaulting the
/// model, workspace root (current dir), and counters.
pub fn new(id: String, title: String) -> Self {
let now = Utc::now().timestamp_millis();
Session {
id,
created_at: now,
updated_at: now,
title,
model: "anthropic/claude-opus-4-8".to_string(),
workspace_roots: vec![std::env::current_dir().unwrap_or_default()],
message_count: 0,
token_count: 0,
archived: false,
summary: None,
}
}
/// Compute this session's directory under `<base_dir>/sessions/<id>`.
pub fn session_dir(&self, base_dir: &Path) -> PathBuf {
base_dir.join("sessions").join(&self.id)
}
/// Compute this session's `conversation.json` path.
pub fn conversation_path(&self, base_dir: &Path) -> PathBuf {
self.session_dir(base_dir).join("conversation.json")
}
}
+106
View File
@@ -0,0 +1,106 @@
//! Validated session identifier newtype.
//!
//! [`SessionId`] wraps a `String` that has been checked for path-traversal
//! characters. Construction via `SessionId::new(str)` validates the input
//! once; the guarantee is then enforced by the type system for all
//! downstream use.
//!
//! # Validation rules
//!
//! - Must not be empty
//! - Must only contain alphanumeric characters, hyphens, and underscores
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::Path;
use std::path::PathBuf;
/// A validated session identifier.
///
/// Guarantees the inner string is non-empty and contains no path-traversal
/// characters (`/`, `\\`, `..`) or other unsafe delimiters.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)]
pub struct SessionId(String);
impl SessionId {
/// Validate and construct a `SessionId`.
///
/// Returns `Err(msg)` if the input contains path separators, `..`, or
/// is empty.
pub fn new(id: &str) -> Result<Self, String> {
if id.is_empty() {
return Err("session id must not be empty".to_string());
}
if id.contains('/') || id.contains('\\') || id.contains("..") {
return Err(format!(
"session id '{id}' must not contain path separators"
));
}
Ok(SessionId(id.to_string()))
}
/// Return the underlying string.
pub fn as_str(&self) -> &str {
&self.0
}
/// Return the underlying owned string.
pub fn into_string(self) -> String {
self.0
}
/// Append this session id as a component of `base_dir`, yielding
/// `base_dir / self.0`.
///
/// Safe because the id has been validated to contain no path separators.
pub fn join_to(&self, base_dir: &Path) -> PathBuf {
base_dir.join(&self.0)
}
}
impl AsRef<str> for SessionId {
fn as_ref(&self) -> &str {
&self.0
}
}
impl fmt::Display for SessionId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(&self.0)
}
}
impl From<SessionId> for String {
fn from(sid: SessionId) -> Self {
sid.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_valid_uuids() {
assert!(SessionId::new("550e8400-e29b-41d4-a716-446655440000").is_ok());
assert!(SessionId::new("my-session_123").is_ok());
}
#[test]
fn test_rejects_path_traversal() {
assert!(SessionId::new("../etc/passwd").is_err());
assert!(SessionId::new("foo/../../bar").is_err());
assert!(SessionId::new("foo\\..\\bar").is_err());
}
#[test]
fn test_rejects_empty() {
assert!(SessionId::new("").is_err());
}
#[test]
fn test_into_string() {
let sid = SessionId::new("abc-123").unwrap();
assert_eq!(sid.into_string(), "abc-123");
}
}
+154
View File
@@ -0,0 +1,154 @@
//! PID-file based advisory lock preventing two processes from operating on
//! the same session directory concurrently.
//!
//! # Flow
//!
//! [`SessionLock::new`] creates a handle → [`SessionLock::try_lock`] attempts
//! atomic `O_CREAT|O_EXCL` creation. If the lock file already exists, the
//! owning PID is checked via liveness verification. Stale locks are
//! overwritten atomically (temp-file + rename + fsync). On [`Drop`],
//! the lock file is removed automatically.
//!
//! # Components
//!
//! - `SessionLock` — RAII guard wrapping a lock file path and PID
//! - `try_lock` — three-phase atomic acquire with stale-lock recovery
//! - `unlock` / `Drop` — explicit and implicit release
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use tracing;
/// A PID-file lock (`<session_dir>/.lock`) tied to the current process,
/// auto-removed on drop.
#[derive(Debug)]
pub struct SessionLock {
/// Path to the `.lock` file inside the session directory.
pub(crate) path: PathBuf,
/// Process ID that holds (or will hold) this lock.
pub(crate) pid: u32,
}
impl SessionLock {
/// Construct a lock handle for a session directory (does not acquire
/// the lock yet — call `try_lock`).
pub fn new(session_dir: &Path) -> Self {
SessionLock {
path: session_dir.join(".lock"),
pid: std::process::id(),
}
}
/// Attempt to acquire the session lock using an atomic file creation.
///
/// Flow: try `O_CREAT | O_EXCL` via `create_new(true)` → if that
/// succeeds, the lock is ours — write our PID and return ok. If the
/// file already exists, read the PID inside it and check whether that
/// PID is still alive: if the process is still running, fail to acquire;
/// otherwise the lock is stale — overwrite it with our own PID and succeed.
///
/// Return: `Ok(true)` if acquired, `Ok(false)` if another live
/// process holds it, `Err` on I/O failure.
pub fn try_lock(&self) -> std::io::Result<bool> {
// Phase 1: try atomic create. If it succeeds, the lock is ours.
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&self.path)
{
Ok(mut file) => {
write!(file, "{}", self.pid)?;
file.sync_all()?;
tracing::debug!(path = %self.path.display(), pid = self.pid, "session lock acquired");
return Ok(true);
}
Err(ref e) if e.kind() == std::io::ErrorKind::AlreadyExists => {
tracing::debug!(path = %self.path.display(), "session lock already exists, checking staleness");
// Lock file exists — check if it's stale.
}
Err(e) => return Err(e),
}
// Phase 2: lock file exists — check liveness of the owning process.
let content = fs::read_to_string(&self.path).unwrap_or_default();
if let Ok(pid) = content.trim().parse::<u32>() {
if Self::is_alive(pid) {
tracing::warn!(stale = pid, path = %self.path.display(), "session lock held by live process");
return Ok(false);
}
tracing::debug!(stale = pid, "stale lock detected, overwriting");
}
// Phase 3: stale lock — overwrite it atomically (best-effort).
// Use a temp file + rename to avoid partial writes corrupting the lock.
let tmp = self.path.with_extension("lock.tmp");
{
let mut tmp_file = fs::OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&tmp)?;
write!(tmp_file, "{}", self.pid)?;
tmp_file.sync_all()?;
}
fs::rename(&tmp, &self.path)?;
// Sync the parent directory so the rename survives a crash.
if let Some(parent) = self.path.parent() {
let _ = fs::File::open(parent).and_then(|d| d.sync_all());
}
Ok(true)
}
/// Explicitly release the lock by removing the lock file.
pub fn unlock(&self) {
let _ = fs::remove_file(&self.path);
}
/// Check whether a process with the given PID is currently alive.
///
/// Uses `kill(pid, 0)` on Unix via the `nix` or `libc` crate in production;
/// here we provide a best-effort check using the process table.
/// On non-Unix platforms this always returns `true` (conservative).
fn is_alive(pid: u32) -> bool {
// On Unix, signal 0 checks process existence without sending a signal.
#[cfg(unix)]
{
// SAFETY: `libc::kill(pid, 0)` does not send a signal; it only checks
// whether the process exists and the caller has permission to signal it.
// The integer argument is a PID validated by `try_lock`.
let pid_signed: i32 = match pid.try_into() {
Ok(p) => p,
Err(_) => return false,
};
if unsafe { libc::kill(pid_signed, 0) != 0 } {
return false;
}
// Extra check: verify the PID belongs to a zesdex process via
// /proc/<pid>/exe to mitigate the PID-reuse race.
let proc_exe = std::path::PathBuf::from(format!("/proc/{pid}/exe"));
if let Ok(target) = std::fs::read_link(&proc_exe) {
if let Ok(exe) = std::env::current_exe() {
if target != exe {
return false;
}
}
}
true
}
#[cfg(not(unix))]
{
// Fallback: always assume alive (conservative).
let _ = pid;
true
}
}
}
impl Drop for SessionLock {
/// Release the lock automatically when the guard goes out of scope,
/// so an ungracefully-exited process doesn't leave a dangling lock.
fn drop(&mut self) {
let _ = fs::remove_file(&self.path);
}
}