789 lines
28 KiB
Rust
789 lines
28 KiB
Rust
//! Daemon state types — `AppStateRest`, `DaemonState`, and all supporting
|
|||
|
|
//! data structures for the background daemon session.
|
||
|
|
//!
|
||
|
|
//! `AppStateRest` is the single source-of-truth struct mutated in-place from
|
||
|
|
//! [`handler::apply_action`](crate::handler::apply_action) and the IPC handler.
|
||
|
|
//! `DaemonState` wraps it with IPC socket metadata.
|
||
|
|
//!
|
||
|
|
//! Also contains [`create_session()`] adapted from the legacy `main.rs`.
|
||
|
|
|
||
|
|
use std::collections::VecDeque;
|
||
|
|
use std::path::PathBuf;
|
||
|
|
use std::sync::atomic::AtomicBool;
|
||
|
|
use std::sync::{Arc, Mutex};
|
||
|
|
|
||
|
|
use anyhow::Result;
|
||
|
|
use tokio::sync::RwLock;
|
||
|
|
|
||
|
|
use zesdex_domain::cms::EditLog;
|
||
|
|
use zesdex_domain::Session;
|
||
|
|
use zesdex_domain::Settings;
|
||
|
|
use zesdex_domain::AppConfigRepository;
|
||
|
|
use zesdex_domain::EditLogRepository;
|
||
|
|
use zesdex_domain::SessionLockRepository;
|
||
|
|
use zesdex_domain::SessionRepository;
|
||
|
|
use zesdex_domain::SettingsRepository;
|
||
|
|
use zesdex_infrastructure::lsp::manager::LspManager;
|
||
|
|
use zesdex_infrastructure::mcp::manager::McpManager;
|
||
|
|
use zesdex_infrastructure::persistence::FileSystemSessionLockRepository;
|
||
|
|
use zesdex_infrastructure::persistence::JsonAppConfigRepository;
|
||
|
|
use zesdex_infrastructure::persistence::JsonlEditLogRepository;
|
||
|
|
use zesdex_infrastructure::persistence::JsonSettingsRepository;
|
||
|
|
use zesdex_infrastructure::AppConfig;
|
||
|
|
use zesdex_infrastructure::DirCache;
|
||
|
|
use zesdex_infrastructure::MentionIndex;
|
||
|
|
use zesdex_infrastructure::SessionRuntime;
|
||
|
|
use zesdex_infrastructure::Toast;
|
||
|
|
use zesdex_infrastructure::ToastKind;
|
||
|
|
use zesdex_infrastructure::TurnEvent;
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Supporting types
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// A single transcript entry rendered in the TUI chat pane.
|
||
|
|
#[derive(Debug, Clone, PartialEq)]
|
||
|
|
pub struct ChatMessageDisplay {
|
||
|
|
/// Message author: User, Assistant, System, or Tool.
|
||
|
|
pub role: RoleWrapper,
|
||
|
|
/// Rendered text content (plain text, no markdown).
|
||
|
|
pub content: String,
|
||
|
|
/// Millisecond timestamp when this display entry was created.
|
||
|
|
pub timestamp: i64,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Simple string-backed role wrapper for transcript display (avoids a direct
|
||
|
|
/// dependency on the domain's `Role` enum which may not round-trip all wire
|
||
|
|
/// strings).
|
||
|
|
#[derive(Debug, Clone, PartialEq)]
|
||
|
|
pub enum RoleWrapper {
|
||
|
|
User,
|
||
|
|
Assistant,
|
||
|
|
System,
|
||
|
|
Tool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ChatMessageDisplay {
|
||
|
|
/// Build a display entry, stamping it with the current time.
|
||
|
|
pub fn new(role: RoleWrapper, content: String) -> Self {
|
||
|
|
tracing::debug!(
|
||
|
|
"ChatMessageDisplay::new — role={:?}, content_len={}",
|
||
|
|
role,
|
||
|
|
content.len()
|
||
|
|
);
|
||
|
|
ChatMessageDisplay {
|
||
|
|
role,
|
||
|
|
content,
|
||
|
|
timestamp: chrono::Utc::now().timestamp_millis(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Which modal overlay, if any, is currently shown over the main TUI view.
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub enum Overlay {
|
||
|
|
/// No overlay; the main chat view is shown.
|
||
|
|
None,
|
||
|
|
/// Key bindings help screen.
|
||
|
|
Help,
|
||
|
|
/// Settings/configuration panel.
|
||
|
|
Settings,
|
||
|
|
/// Background bash job viewer.
|
||
|
|
Bash,
|
||
|
|
/// "Are you sure you want to quit?" confirmation.
|
||
|
|
QuitConfirm,
|
||
|
|
/// Raw key-code input capture (for binding custom keys).
|
||
|
|
KeyInput,
|
||
|
|
/// Inline editor (opened via `/edit`).
|
||
|
|
Editor,
|
||
|
|
/// Reasoning effort level selector.
|
||
|
|
Effort,
|
||
|
|
/// MCP server management panel.
|
||
|
|
Mcp,
|
||
|
|
/// TODO list overlay.
|
||
|
|
Todo,
|
||
|
|
/// Session rewind / history scrubber.
|
||
|
|
Rewind,
|
||
|
|
/// Learning / lesson management panel.
|
||
|
|
Learning,
|
||
|
|
/// Token usage statistics panel.
|
||
|
|
Usage,
|
||
|
|
/// Generic loading spinner overlay.
|
||
|
|
Loading,
|
||
|
|
/// Model selector dropdown.
|
||
|
|
ModelSelector,
|
||
|
|
/// "Clear conversation?" confirmation (distinct from QuitConfirm).
|
||
|
|
ClearConfirm,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Overlay {
|
||
|
|
/// Human-readable name for this overlay variant.
|
||
|
|
pub fn as_str(self) -> &'static str {
|
||
|
|
match self {
|
||
|
|
Overlay::None => "none",
|
||
|
|
Overlay::Help => "help",
|
||
|
|
Overlay::Settings => "settings",
|
||
|
|
Overlay::Bash => "bash",
|
||
|
|
Overlay::QuitConfirm => "quit_confirm",
|
||
|
|
Overlay::KeyInput => "key_input",
|
||
|
|
Overlay::Editor => "editor",
|
||
|
|
Overlay::Effort => "effort",
|
||
|
|
Overlay::Mcp => "mcp",
|
||
|
|
Overlay::Todo => "todo",
|
||
|
|
Overlay::Rewind => "rewind",
|
||
|
|
Overlay::Learning => "learning",
|
||
|
|
Overlay::Usage => "usage",
|
||
|
|
Overlay::Loading => "loading",
|
||
|
|
Overlay::ModelSelector => "model_selector",
|
||
|
|
Overlay::ClearConfirm => "clear_confirm",
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Whether any overlay (i.e. anything other than `None`) is active.
|
||
|
|
pub fn is_active(self) -> bool {
|
||
|
|
!matches!(self, Overlay::None)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl std::fmt::Display for Overlay {
|
||
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||
|
|
f.write_str(self.as_str())
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Bounded ring of recent chat messages used to render the transcript view.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct TranscriptCache {
|
||
|
|
/// Ordered display messages (newest appended, oldest evicted when full).
|
||
|
|
pub messages: Vec<ChatMessageDisplay>,
|
||
|
|
/// Maximum messages to retain before evicting the oldest.
|
||
|
|
pub max_lines: usize,
|
||
|
|
/// Whether the cache has changed since the last render sweep.
|
||
|
|
pub dirty: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl TranscriptCache {
|
||
|
|
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||
|
|
pub fn new(max_lines: usize) -> Self {
|
||
|
|
TranscriptCache {
|
||
|
|
messages: Vec::new(),
|
||
|
|
max_lines,
|
||
|
|
dirty: true,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Which source populated the autocomplete dropdown.
|
||
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||
|
|
pub enum AutocompleteKind {
|
||
|
|
/// Builtin slash-command (e.g. `/model`, `/help`).
|
||
|
|
Command,
|
||
|
|
/// `@file` mention from the workspace file index.
|
||
|
|
FileMention,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The user's input buffer, cursor position, history, and autocomplete state.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct InputState {
|
||
|
|
/// Raw UTF-8 input buffer content.
|
||
|
|
pub buffer: String,
|
||
|
|
/// Byte offset of the cursor within `buffer`.
|
||
|
|
pub cursor: usize,
|
||
|
|
/// Previously submitted input lines, oldest-first.
|
||
|
|
pub history: Vec<String>,
|
||
|
|
/// Index into `history` when browsing (None = at the current input).
|
||
|
|
pub history_idx: Option<usize>,
|
||
|
|
/// The prefix string used to filter candidates for autocomplete.
|
||
|
|
pub autocomplete_prefix: String,
|
||
|
|
/// Current autocomplete candidate list.
|
||
|
|
pub autocomplete_candidates: Vec<String>,
|
||
|
|
/// Focused index within `autocomplete_candidates`.
|
||
|
|
pub autocomplete_idx: usize,
|
||
|
|
/// Whether the autocomplete dropdown is visible.
|
||
|
|
pub autocomplete_visible: bool,
|
||
|
|
/// Which kind of autocomplete is active.
|
||
|
|
pub autocomplete_kind: AutocompleteKind,
|
||
|
|
/// Byte offset of the `@` character that triggered file mention autocomplete.
|
||
|
|
pub mention_start: usize,
|
||
|
|
/// Optional path to a persistent history file.
|
||
|
|
pub history_file: Option<PathBuf>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl InputState {
|
||
|
|
/// Create an empty input state with no buffer, no history, and no autocomplete.
|
||
|
|
pub fn new() -> Self {
|
||
|
|
InputState {
|
||
|
|
buffer: String::new(),
|
||
|
|
cursor: 0,
|
||
|
|
history: Vec::new(),
|
||
|
|
history_idx: None,
|
||
|
|
autocomplete_prefix: String::new(),
|
||
|
|
autocomplete_candidates: Vec::new(),
|
||
|
|
autocomplete_idx: 0,
|
||
|
|
autocomplete_visible: false,
|
||
|
|
autocomplete_kind: AutocompleteKind::Command,
|
||
|
|
mention_start: 0,
|
||
|
|
history_file: None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Close the autocomplete dropdown.
|
||
|
|
pub fn close_autocomplete(&mut self) {
|
||
|
|
self.autocomplete_visible = false;
|
||
|
|
self.autocomplete_candidates.clear();
|
||
|
|
self.autocomplete_prefix.clear();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Open the command-autocomplete dropdown.
|
||
|
|
pub fn open_autocomplete(&mut self) {
|
||
|
|
self.autocomplete_kind = AutocompleteKind::Command;
|
||
|
|
self.autocomplete_visible = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for InputState {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self::new()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Viewport scroll state: current offset and visible-line count.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct ScrollState {
|
||
|
|
/// Current scroll offset (how many lines have been scrolled past).
|
||
|
|
pub offset: usize,
|
||
|
|
/// Maximum number of lines that fit in the visible viewport area.
|
||
|
|
pub max_visible: usize,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl ScrollState {
|
||
|
|
/// Create a `ScrollState` with zero offset and 30 rows visible.
|
||
|
|
pub fn new() -> Self {
|
||
|
|
ScrollState {
|
||
|
|
offset: 0,
|
||
|
|
max_visible: 30,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Scroll the viewport up by `amount` lines (increasing the offset).
|
||
|
|
pub fn scroll_up(&mut self, amount: usize) {
|
||
|
|
self.offset = self.offset.saturating_add(amount);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Scroll the viewport down by `amount` lines (decreasing the offset).
|
||
|
|
pub fn scroll_down(&mut self, amount: usize) {
|
||
|
|
self.offset = self.offset.saturating_sub(amount);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Update the maximum number of visible lines in the viewport.
|
||
|
|
pub fn set_max_visible(&mut self, max: usize) {
|
||
|
|
self.max_visible = max;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for ScrollState {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self::new()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// The "miscellaneous" slice of app state: which overlay is showing,
|
||
|
|
/// toasts, thinking flags, editor state, and tick.
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct MiscState {
|
||
|
|
/// Currently active modal overlay (None = main chat view).
|
||
|
|
pub overlay: Overlay,
|
||
|
|
/// Active toast notifications (expired ones removed on each tick).
|
||
|
|
pub toasts: Vec<Toast>,
|
||
|
|
/// Whether the agent is currently "thinking".
|
||
|
|
pub thinking: bool,
|
||
|
|
/// Current LLM reasoning effort level (1-5).
|
||
|
|
pub effort_level: usize,
|
||
|
|
/// Whether the API connection is established.
|
||
|
|
pub api_connected: bool,
|
||
|
|
/// Currently focused index in list-type overlays.
|
||
|
|
pub selected_index: usize,
|
||
|
|
/// Monotonically increasing tick count, incremented each render frame.
|
||
|
|
pub tick_count: u64,
|
||
|
|
/// Cached content of the TODO file, shown in the overlay.
|
||
|
|
pub todo_content: String,
|
||
|
|
/// Whether a lesson background task is currently running.
|
||
|
|
pub lesson_running: bool,
|
||
|
|
/// Text waiting to be written to the system clipboard.
|
||
|
|
pub pending_clipboard_copy: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl MiscState {
|
||
|
|
/// Create a fresh `MiscState` with no overlay, no toasts, and default effort level 1.
|
||
|
|
pub fn new() -> Self {
|
||
|
|
MiscState {
|
||
|
|
overlay: Overlay::None,
|
||
|
|
toasts: Vec::new(),
|
||
|
|
thinking: false,
|
||
|
|
effort_level: 1,
|
||
|
|
api_connected: false,
|
||
|
|
selected_index: 0,
|
||
|
|
tick_count: 0,
|
||
|
|
todo_content: String::new(),
|
||
|
|
lesson_running: false,
|
||
|
|
pending_clipboard_copy: None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Append a toast notification to the active list.
|
||
|
|
pub fn push_toast(&mut self, toast: Toast) {
|
||
|
|
self.toasts.push(toast);
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Remove and return all toasts whose lifetime has expired at `now_ms`.
|
||
|
|
pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec<Toast> {
|
||
|
|
let expired: Vec<_> = self
|
||
|
|
.toasts
|
||
|
|
.iter()
|
||
|
|
.filter(|t| t.expired(now_ms))
|
||
|
|
.cloned()
|
||
|
|
.collect();
|
||
|
|
self.toasts.retain(|t| !t.expired(now_ms));
|
||
|
|
expired
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Default for MiscState {
|
||
|
|
fn default() -> Self {
|
||
|
|
Self::new()
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// State of a single workflow agent.
|
||
|
|
#[derive(Debug, Clone, Default)]
|
||
|
|
pub struct AgentState {
|
||
|
|
pub id: String,
|
||
|
|
pub status: String,
|
||
|
|
pub current_tool: String,
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Minimal workflow-engine placeholder for hive-mind orchestration state.
|
||
|
|
#[derive(Debug, Clone, Default)]
|
||
|
|
pub struct WorkflowEngine {
|
||
|
|
/// List of running workflow agent states.
|
||
|
|
pub agents: Vec<AgentState>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl WorkflowEngine {
|
||
|
|
/// Create an empty workflow engine.
|
||
|
|
pub fn new() -> Self {
|
||
|
|
Self {
|
||
|
|
agents: Vec::new(),
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// AppStateRest — single source-of-truth application state
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// The single source-of-truth state struct for the daemon.
|
||
|
|
///
|
||
|
|
/// Mutated in-place from two locations: `handler::apply_action`
|
||
|
|
/// and the IPC client handler in `handler::handle_daemon_client`.
|
||
|
|
/// Read-only from every other module.
|
||
|
|
#[derive(Clone)]
|
||
|
|
pub struct AppStateRest {
|
||
|
|
/// Persistent user settings (loaded from JSON store at startup).
|
||
|
|
pub settings: Settings,
|
||
|
|
/// Per-project app configuration (loaded from JSON store at startup).
|
||
|
|
pub app_config: AppConfig,
|
||
|
|
/// Absolute paths to each open workspace root directory.
|
||
|
|
pub workspace_roots: Vec<PathBuf>,
|
||
|
|
/// Unique session identifier.
|
||
|
|
pub session_id: String,
|
||
|
|
/// Path to the session's data directory.
|
||
|
|
pub session_dir: PathBuf,
|
||
|
|
/// Path to the session memory directory (lessons, review history).
|
||
|
|
pub memory_dir: PathBuf,
|
||
|
|
/// Path to the git worktrees directory (for sandboxed agent experiments).
|
||
|
|
pub worktrees_dir: PathBuf,
|
||
|
|
/// Shared async cache of directory listings.
|
||
|
|
pub dir_cache: Arc<RwLock<DirCache>>,
|
||
|
|
/// Shared workspace file-path index for `@file` mention autocomplete.
|
||
|
|
pub mention_index: MentionIndex,
|
||
|
|
/// Persistent edit history log (appended on every tool write).
|
||
|
|
pub edit_log: EditLog,
|
||
|
|
/// Optional per-session runtime state.
|
||
|
|
pub session_runtime: Option<SessionRuntime>,
|
||
|
|
/// Active IAM sessions linked to this app instance.
|
||
|
|
pub sessions: Vec<Session>,
|
||
|
|
/// Ring buffer of recent chat messages for the TUI transcript pane.
|
||
|
|
pub transcript_cache: TranscriptCache,
|
||
|
|
/// Viewport scroll offset tracker.
|
||
|
|
pub scroll: ScrollState,
|
||
|
|
/// Chat input buffer, cursor, history, and autocomplete.
|
||
|
|
pub input: InputState,
|
||
|
|
/// Miscellaneous state: overlay, toasts, flags, tick.
|
||
|
|
pub misc: MiscState,
|
||
|
|
/// Queue of events emitted by the running agent turn.
|
||
|
|
pub turn_events: Arc<Mutex<VecDeque<TurnEvent>>>,
|
||
|
|
/// Whether an agent turn is currently in flight.
|
||
|
|
pub turn_in_flight: Arc<Mutex<bool>>,
|
||
|
|
/// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape).
|
||
|
|
pub abort_flag: Arc<AtomicBool>,
|
||
|
|
/// Workflow engine state for multi-agent hive-mind orchestration.
|
||
|
|
pub workflow_engine: WorkflowEngine,
|
||
|
|
/// MCP server manager.
|
||
|
|
pub mcp_manager: McpManager,
|
||
|
|
/// LSP server manager, shared with tool context.
|
||
|
|
pub lsp_manager: Arc<Mutex<LspManager>>,
|
||
|
|
/// Shared queue for LSP provisioning messages.
|
||
|
|
pub lsp_provision_msgs: Arc<Mutex<VecDeque<String>>>,
|
||
|
|
/// Whether the state has been modified since the last render sweep.
|
||
|
|
pub dirty: bool,
|
||
|
|
/// Whether the application has been requested to quit.
|
||
|
|
pub quit: bool,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl AppStateRest {
|
||
|
|
/// Construct the initial application state for a session.
|
||
|
|
///
|
||
|
|
/// Flow: load settings/config → derive download/worktree dirs from
|
||
|
|
/// `memory_dir`'s parent → derive `session_id` from the session dir's
|
||
|
|
/// file name → build the sub-state structs.
|
||
|
|
///
|
||
|
|
/// Why: falls back to `memory_dir` itself (with a warning) when it has
|
||
|
|
/// no parent, and to an empty session id when the dir name can't be
|
||
|
|
/// read, so construction never fails.
|
||
|
|
pub fn new(
|
||
|
|
workspace_roots: Vec<PathBuf>,
|
||
|
|
session_dir: &std::path::Path,
|
||
|
|
memory_dir: PathBuf,
|
||
|
|
) -> Self {
|
||
|
|
let store_base_dir =
|
||
|
|
zesdex_infrastructure::Store::new().base_dir;
|
||
|
|
let settings = JsonSettingsRepository::new()
|
||
|
|
.load(&store_base_dir)
|
||
|
|
.unwrap_or_default();
|
||
|
|
let app_config = JsonAppConfigRepository::new()
|
||
|
|
.load(&store_base_dir)
|
||
|
|
.unwrap_or_default();
|
||
|
|
let worktrees_dir = memory_dir
|
||
|
|
.parent()
|
||
|
|
.unwrap_or_else(|| {
|
||
|
|
tracing::warn!(
|
||
|
|
"[state] memory_dir '{}' has no parent, using it for worktrees",
|
||
|
|
memory_dir.display()
|
||
|
|
);
|
||
|
|
&memory_dir
|
||
|
|
})
|
||
|
|
.join("worktrees");
|
||
|
|
let dir_cache = DirCache::new();
|
||
|
|
let session_id = session_dir.file_name().map_or_else(
|
||
|
|
|| {
|
||
|
|
tracing::warn!(
|
||
|
|
"[state] session_dir has no file_name component, using empty session_id"
|
||
|
|
);
|
||
|
|
String::new()
|
||
|
|
},
|
||
|
|
|n| n.to_string_lossy().to_string(),
|
||
|
|
);
|
||
|
|
let mut state = AppStateRest {
|
||
|
|
settings,
|
||
|
|
app_config,
|
||
|
|
workspace_roots,
|
||
|
|
session_id,
|
||
|
|
session_dir: session_dir.to_path_buf(),
|
||
|
|
memory_dir: memory_dir.clone(),
|
||
|
|
worktrees_dir,
|
||
|
|
turn_events: Arc::new(Mutex::new(VecDeque::new())),
|
||
|
|
turn_in_flight: Arc::new(Mutex::new(false)),
|
||
|
|
abort_flag: Arc::new(AtomicBool::new(false)),
|
||
|
|
dir_cache: Arc::new(RwLock::new(dir_cache)),
|
||
|
|
mention_index: MentionIndex::new(),
|
||
|
|
edit_log: JsonlEditLogRepository::new()
|
||
|
|
.open(session_dir)
|
||
|
|
.unwrap_or_else(|e| {
|
||
|
|
tracing::warn!(
|
||
|
|
"[state] failed to open edit log at '{}': {e}",
|
||
|
|
session_dir.display()
|
||
|
|
);
|
||
|
|
EditLog::new()
|
||
|
|
}),
|
||
|
|
session_runtime: Some(SessionRuntime::new(session_dir.to_path_buf())),
|
||
|
|
workflow_engine: WorkflowEngine::new(),
|
||
|
|
mcp_manager: McpManager::new(),
|
||
|
|
lsp_provision_msgs: Arc::new(Mutex::new(VecDeque::new())),
|
||
|
|
lsp_manager: Arc::new(Mutex::new(LspManager::new())),
|
||
|
|
sessions: Vec::new(),
|
||
|
|
transcript_cache: TranscriptCache::new(200),
|
||
|
|
scroll: ScrollState::new(),
|
||
|
|
input: InputState::new(),
|
||
|
|
misc: MiscState::new(),
|
||
|
|
dirty: true,
|
||
|
|
quit: false,
|
||
|
|
};
|
||
|
|
|
||
|
|
// Load project-specific input-line history from a file keyed by
|
||
|
|
// the first workspace root's SHA256 hash.
|
||
|
|
let base_dir = state.memory_dir.parent().unwrap_or(&state.memory_dir);
|
||
|
|
if let Some(root) = state.workspace_roots.first() {
|
||
|
|
if let Ok(abs_root) = std::fs::canonicalize(root) {
|
||
|
|
use sha2::Digest;
|
||
|
|
let mut hasher = sha2::Sha256::new();
|
||
|
|
hasher.update(abs_root.to_string_lossy().as_bytes());
|
||
|
|
let hash_hex = hex::encode(hasher.finalize());
|
||
|
|
let folder_name = abs_root
|
||
|
|
.file_name()
|
||
|
|
.map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string());
|
||
|
|
let history_filename = format!("{}-{}.txt", folder_name, &hash_hex[..8]);
|
||
|
|
let history_dir = base_dir.join("history");
|
||
|
|
let _ = std::fs::create_dir_all(&history_dir);
|
||
|
|
let history_file = history_dir.join(history_filename);
|
||
|
|
|
||
|
|
if let Ok(content) = std::fs::read_to_string(&history_file) {
|
||
|
|
let history: Vec<String> = content
|
||
|
|
.lines()
|
||
|
|
.map(std::string::ToString::to_string)
|
||
|
|
.filter(|s| !s.is_empty())
|
||
|
|
.collect();
|
||
|
|
state.input.history = history;
|
||
|
|
}
|
||
|
|
state.input.history_file = Some(history_file);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
state
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Spawn the background thread that walks every workspace root and
|
||
|
|
/// populates `mention_index` for `@file` mention autocomplete.
|
||
|
|
///
|
||
|
|
/// Callers that DO need the index (single-process mode, the daemon)
|
||
|
|
/// call this explicitly after construction.
|
||
|
|
pub fn spawn_mention_index_build(&self) {
|
||
|
|
let mention_index = self.mention_index.clone();
|
||
|
|
let roots = self.workspace_roots.clone();
|
||
|
|
std::thread::spawn(move || {
|
||
|
|
const MAX_MENTION_ENTRIES: usize = 50_000;
|
||
|
|
let mut paths = Vec::new();
|
||
|
|
'roots: for (i, root) in roots.iter().enumerate() {
|
||
|
|
for entry in ignore::Walk::new(root).flatten() {
|
||
|
|
if !entry.path().is_file() {
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
let rel = entry.path().strip_prefix(root).unwrap_or(entry.path());
|
||
|
|
let rel_str = rel.display().to_string();
|
||
|
|
let formatted = if i == 0 {
|
||
|
|
rel_str
|
||
|
|
} else {
|
||
|
|
format!("[{i}]{rel_str}")
|
||
|
|
};
|
||
|
|
paths.push(formatted);
|
||
|
|
if paths.len() >= MAX_MENTION_ENTRIES {
|
||
|
|
break 'roots;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
mention_index.set(paths);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Whether an agent turn is currently running.
|
||
|
|
pub fn turn_in_flight(&self) -> bool {
|
||
|
|
self.turn_in_flight.lock().map_or_else(
|
||
|
|
|_| {
|
||
|
|
tracing::warn!("[state] turn_in_flight mutex poisoned");
|
||
|
|
false
|
||
|
|
},
|
||
|
|
|g| *g,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Shut down every running LSP server process.
|
||
|
|
pub fn shutdown_lsp(&mut self) {
|
||
|
|
if let Ok(mut mgr) = self.lsp_manager.lock() {
|
||
|
|
mgr.shutdown_all();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Append a message to the transcript, evicting the oldest entry once
|
||
|
|
/// `max_lines` is exceeded.
|
||
|
|
pub fn push_transcript(&mut self, msg: ChatMessageDisplay) {
|
||
|
|
self.transcript_cache.messages.push(msg);
|
||
|
|
if self.transcript_cache.messages.len() > self.transcript_cache.max_lines {
|
||
|
|
self.transcript_cache.messages.remove(0);
|
||
|
|
}
|
||
|
|
self.transcript_cache.dirty = true;
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Mark the app state as dirty, triggering a TUI re-render on the next frame.
|
||
|
|
pub fn mark_dirty(&mut self) {
|
||
|
|
self.dirty = true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Queue a toast notification for display and mark the app dirty.
|
||
|
|
pub fn push_toast(&mut self, toast: Toast) {
|
||
|
|
self.misc.push_toast(toast);
|
||
|
|
self.mark_dirty();
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Push an info toast with the given message.
|
||
|
|
pub fn toast_info(&mut self, msg: impl Into<String>) {
|
||
|
|
self.push_toast(Toast::new(ToastKind::Info, msg.into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Push a success toast with the given message.
|
||
|
|
pub fn toast_success(&mut self, msg: impl Into<String>) {
|
||
|
|
self.push_toast(Toast::new(ToastKind::Success, msg.into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Push a warning toast with the given message.
|
||
|
|
pub fn toast_warning(&mut self, msg: impl Into<String>) {
|
||
|
|
self.push_toast(Toast::new(ToastKind::Warning, msg.into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Push an error toast with the given message.
|
||
|
|
pub fn toast_error(&mut self, msg: impl Into<String>) {
|
||
|
|
self.push_toast(Toast::new(ToastKind::Error, msg.into()));
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Resolve the base directory that stores this session (grandparent of
|
||
|
|
/// `session_dir`).
|
||
|
|
pub fn store_base_dir(&self) -> PathBuf {
|
||
|
|
self.session_dir
|
||
|
|
.parent()
|
||
|
|
.and_then(|p| p.parent())
|
||
|
|
.map_or_else(
|
||
|
|
|| {
|
||
|
|
tracing::warn!(
|
||
|
|
"[state] session_dir '{}' has no grandparent, using parent",
|
||
|
|
self.session_dir.display()
|
||
|
|
);
|
||
|
|
self.session_dir.parent().map_or_else(
|
||
|
|
|| {
|
||
|
|
tracing::warn!(
|
||
|
|
"[state] session_dir '{}' has no parent at all, using itself",
|
||
|
|
self.session_dir.display()
|
||
|
|
);
|
||
|
|
self.session_dir.clone()
|
||
|
|
},
|
||
|
|
std::path::Path::to_path_buf,
|
||
|
|
)
|
||
|
|
},
|
||
|
|
std::path::Path::to_path_buf,
|
||
|
|
)
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Persist the current settings to the store and swallow any error.
|
||
|
|
pub fn save_settings(&self) {
|
||
|
|
let _ = JsonSettingsRepository::new()
|
||
|
|
.save(&self.store_base_dir(), &self.settings);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// SessionLockGuard — RAII guard that releases a session lock on drop
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// RAII guard that releases a session lock on drop.
|
||
|
|
pub struct SessionLockGuard {
|
||
|
|
lock_repo: FileSystemSessionLockRepository,
|
||
|
|
session_dir: PathBuf,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl SessionLockGuard {
|
||
|
|
/// Create a new guard. Caller must have already acquired the lock.
|
||
|
|
pub fn new(lock_repo: FileSystemSessionLockRepository, session_dir: PathBuf) -> Self {
|
||
|
|
tracing::debug!("acquired session lock for {:?}", session_dir);
|
||
|
|
Self {
|
||
|
|
lock_repo,
|
||
|
|
session_dir,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
impl Drop for SessionLockGuard {
|
||
|
|
fn drop(&mut self) {
|
||
|
|
tracing::debug!("releasing session lock for {:?}", self.session_dir);
|
||
|
|
let _ = self.lock_repo.unlock(&self.session_dir);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// DaemonState — wraps AppStateRest with IPC socket metadata
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// The daemon's overall state: owns the application state and the IPC socket
|
||
|
|
/// metadata for client connections.
|
||
|
|
pub struct DaemonState {
|
||
|
|
/// The canonical application state for this daemon session.
|
||
|
|
pub app_state: AppStateRest,
|
||
|
|
/// The daemon session's unique identifier (same as `app_state.session_id`).
|
||
|
|
pub session_id: String,
|
||
|
|
/// Path to the bound Unix socket, if any.
|
||
|
|
pub socket_path: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
impl DaemonState {
|
||
|
|
/// Wrap an `AppStateRest` into a `DaemonState`.
|
||
|
|
pub fn new(app_state: AppStateRest) -> Self {
|
||
|
|
let session_id = app_state.session_id.clone();
|
||
|
|
DaemonState {
|
||
|
|
app_state,
|
||
|
|
session_id,
|
||
|
|
socket_path: None,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/// Set the socket path after binding.
|
||
|
|
pub fn set_socket_path(&mut self, path: String) {
|
||
|
|
self.socket_path = Some(path);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// Session creation
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
|
||
|
|
/// Create a new daemon session: store, session directory, exclusive lock,
|
||
|
|
/// application state, and tokio runtime.
|
||
|
|
///
|
||
|
|
/// Flow: create the store → create a new session directory → attempt an
|
||
|
|
/// exclusive lock → build `AppStateRest` → spawn mention-index builder →
|
||
|
|
/// load session list → start a tokio runtime.
|
||
|
|
///
|
||
|
|
/// Return: (store, lock guard, app_state, tokio_runtime).
|
||
|
|
pub fn create_session() -> Result<(
|
||
|
|
zesdex_infrastructure::Store,
|
||
|
|
SessionLockGuard,
|
||
|
|
AppStateRest,
|
||
|
|
tokio::runtime::Runtime,
|
||
|
|
)> {
|
||
|
|
tracing::info!("creating new daemon session");
|
||
|
|
let store = zesdex_infrastructure::Store::new();
|
||
|
|
store.ensure_dirs()?;
|
||
|
|
|
||
|
|
let session_id = uuid::Uuid::new_v4().to_string();
|
||
|
|
let session_dir = store.base_dir.join("sessions").join(&session_id);
|
||
|
|
std::fs::create_dir_all(&session_dir)?;
|
||
|
|
|
||
|
|
let lock_repo = FileSystemSessionLockRepository::new();
|
||
|
|
if !lock_repo.try_lock(&session_dir)? {
|
||
|
|
anyhow::bail!(
|
||
|
|
"session already active (another zesdex process holds the lock for this session directory)"
|
||
|
|
);
|
||
|
|
}
|
||
|
|
let session_lock_guard = SessionLockGuard::new(lock_repo, session_dir.clone());
|
||
|
|
|
||
|
|
let workspace_roots = vec![std::env::current_dir()?];
|
||
|
|
let mut state = AppStateRest::new(workspace_roots, &session_dir, store.memory_dir.clone());
|
||
|
|
state.spawn_mention_index_build();
|
||
|
|
let session_repo =
|
||
|
|
zesdex_infrastructure::persistence::FileSystemSessionRepository::new();
|
||
|
|
state.sessions = session_repo
|
||
|
|
.list_sessions(&store.base_dir)
|
||
|
|
.unwrap_or_default();
|
||
|
|
|
||
|
|
let rt = tokio::runtime::Runtime::new()?;
|
||
|
|
|
||
|
|
Ok((store, session_lock_guard, state, rt))
|
||
|
|
}
|