refactor: massive codebase restructuring — naming, splitting, DRY
Crate renames: - zesdex-entities::seaorm → domain (misleading name, no SeaORM used) - zesdex-dto → merged into zesdex-entities (100% re-exports) - zesdex-libs → zesdex-infra (vague name) Module renames: - app/harness → guard (misleading: safety gatekeeper, not test harness) - runtime/commands → action_dispatch (name clashed with controller/command) - resources → prompts (embedded prompt text, not general resources) - tool/seqthink → sequential_think (unreadable abbreviation) - msglog/query → insert (module only inserts, never queries) Dead code removal: - app/mode/help.rs (orphaned — not declared in mod.rs) - app/mode/loading.rs (orphaned — not declared in mod.rs) File splitting (71 new files, avg ~115 lines/file): - app/runtime/actions/: 1→8 files (was 2030 lines) - view/overlays/: 1→16 files (was 1167 lines) - tool/lsp/: 1→8 per-tool files (was 909 lines) - main.rs: 1→5 files (session, daemon, attach, event_loop) - workflow/engine + hive_mind: 2→10 files - subagent/engine + auto: 2→9 files - lsp/provisioner: 1→5 files - review/: 1→6 files - guard/: 1→2 files (extracted patterns) - state/misc: 1→3 files (input, scroll) - mcp/: 1→3 files (transport, adapter) - stream/json_repair extracted from turn.rs DRY: - Pattern constants (STUB_PATTERNS etc) in guard/patterns shared with subagent - 3 near-identical background spawners → 1 generic + thin wrappers - Shared spawn_subagent_with_drain() extracted - Shared create_session() in main - write_osc52 deduplicated Bug fixes: - archive_message(): sess.db → db (wrong variable name) - execute_one_tool(): wrong parameter name - check_credential_read() function was missing (restored from test expectations)
This commit is contained in:
@@ -0,0 +1,256 @@
|
||||
//! Daemon mode — background process that owns the agent state, listens on a
|
||||
//! per-session Unix socket, and drives one attached client at a time.
|
||||
//!
|
||||
//! Also contains the `key_code_to_action` / `key_action_to_code` conversion
|
||||
//! functions shared between daemon and attach modes.
|
||||
|
||||
use anyhow::Result;
|
||||
use app::runtime::actions::{apply_action, Action};
|
||||
use app::state::rest::AppStateRest;
|
||||
use crossterm::event::KeyCode;
|
||||
use ipc::protocol::{ClientRequest, DaemonFrame, MessageEntry, StatePayload, ToastEntry};
|
||||
use zesdex_cms::domain::repository::SettingsRepository;
|
||||
|
||||
use crate::app;
|
||||
use crate::controller;
|
||||
use crate::ipc;
|
||||
|
||||
/// Map a `crossterm` key code to the wire-serializable `KeyAction`, for
|
||||
/// sending key input from an attached client to the daemon.
|
||||
///
|
||||
/// Return: `None` for key codes with no `KeyAction` equivalent (e.g.
|
||||
/// media keys), which are silently dropped.
|
||||
pub fn key_code_to_action(code: crossterm::event::KeyCode) -> Option<ipc::protocol::KeyAction> {
|
||||
match code {
|
||||
KeyCode::Char(c) => Some(ipc::protocol::KeyAction::Char(c)),
|
||||
KeyCode::Enter => Some(ipc::protocol::KeyAction::Enter),
|
||||
KeyCode::Esc => Some(ipc::protocol::KeyAction::Escape),
|
||||
KeyCode::Backspace => Some(ipc::protocol::KeyAction::Backspace),
|
||||
KeyCode::Delete => Some(ipc::protocol::KeyAction::Delete),
|
||||
KeyCode::Tab => Some(ipc::protocol::KeyAction::Tab),
|
||||
KeyCode::Up => Some(ipc::protocol::KeyAction::Up),
|
||||
KeyCode::Down => Some(ipc::protocol::KeyAction::Down),
|
||||
KeyCode::Left => Some(ipc::protocol::KeyAction::Left),
|
||||
KeyCode::Right => Some(ipc::protocol::KeyAction::Right),
|
||||
KeyCode::Home => Some(ipc::protocol::KeyAction::Home),
|
||||
KeyCode::End => Some(ipc::protocol::KeyAction::End),
|
||||
KeyCode::PageUp => Some(ipc::protocol::KeyAction::PageUp),
|
||||
KeyCode::PageDown => Some(ipc::protocol::KeyAction::PageDown),
|
||||
KeyCode::F(n) => Some(ipc::protocol::KeyAction::Function(n)),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `key_code_to_action`: reconstruct a `crossterm::KeyCode`
|
||||
/// from a `KeyAction` received over IPC, for replaying it into the
|
||||
/// daemon's normal key-handling path.
|
||||
pub fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode {
|
||||
match action {
|
||||
ipc::protocol::KeyAction::Char(c) => KeyCode::Char(*c),
|
||||
ipc::protocol::KeyAction::Enter => KeyCode::Enter,
|
||||
ipc::protocol::KeyAction::Escape => KeyCode::Esc,
|
||||
ipc::protocol::KeyAction::Backspace => KeyCode::Backspace,
|
||||
ipc::protocol::KeyAction::Delete => KeyCode::Delete,
|
||||
ipc::protocol::KeyAction::Tab => KeyCode::Tab,
|
||||
ipc::protocol::KeyAction::Up => KeyCode::Up,
|
||||
ipc::protocol::KeyAction::Down => KeyCode::Down,
|
||||
ipc::protocol::KeyAction::Left => KeyCode::Left,
|
||||
ipc::protocol::KeyAction::Right => KeyCode::Right,
|
||||
ipc::protocol::KeyAction::Home => KeyCode::Home,
|
||||
ipc::protocol::KeyAction::End => KeyCode::End,
|
||||
ipc::protocol::KeyAction::PageUp => KeyCode::PageUp,
|
||||
ipc::protocol::KeyAction::PageDown => KeyCode::PageDown,
|
||||
ipc::protocol::KeyAction::Function(n) => KeyCode::F(*n),
|
||||
}
|
||||
}
|
||||
|
||||
/// Flatten the daemon's `AppStateRest` into a `StatePayload` and send it
|
||||
/// to the attached client as a `DaemonFrame::StateUpdate`.
|
||||
///
|
||||
/// Flow: map transcript messages/toasts to their wire DTOs → derive the
|
||||
/// active overlay name (or `None` if no overlay is active) → build and
|
||||
/// send one `DaemonFrame`.
|
||||
///
|
||||
/// Why: the client never shares memory with the daemon, so every action
|
||||
/// on the daemon side is followed by a full state push rather than a diff.
|
||||
fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) -> Result<()> {
|
||||
let messages: Vec<MessageEntry> = state
|
||||
.transcript_cache
|
||||
.messages
|
||||
.iter()
|
||||
.map(|m| MessageEntry {
|
||||
role: format!("{:?}", m.role),
|
||||
content: m.content.clone(),
|
||||
timestamp: m.timestamp,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let toasts: Vec<ToastEntry> = state
|
||||
.misc
|
||||
.toasts
|
||||
.iter()
|
||||
.map(|t| ToastEntry {
|
||||
kind: format!("{:?}", t.kind),
|
||||
message: t.message.clone(),
|
||||
created_at: t.created_at,
|
||||
lifetime_ms: t.lifetime_ms,
|
||||
})
|
||||
.collect();
|
||||
|
||||
let overlay = if state.misc.overlay.is_active() {
|
||||
Some(format!("{:?}", state.misc.overlay))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let frame = DaemonFrame::StateUpdate(Box::new(StatePayload {
|
||||
session_id: state.session_id.clone(),
|
||||
messages,
|
||||
edit_count: state.edit_log.len() as u32,
|
||||
message_count: state.transcript_cache.messages.len(),
|
||||
overlay,
|
||||
toasts,
|
||||
dirty: state.dirty,
|
||||
input_buffer: state.input.buffer.clone(),
|
||||
input_cursor: state.input.cursor,
|
||||
}));
|
||||
|
||||
conn.send(&frame)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handle an incoming client connection for the daemon.
|
||||
///
|
||||
/// Flow: loop reading requests, modifying state, and sending updates back.
|
||||
fn handle_daemon_client(
|
||||
mut conn: ipc::conn::Connection,
|
||||
state: &mut AppStateRest,
|
||||
) -> Result<()> {
|
||||
let mut running = true;
|
||||
while running {
|
||||
match conn.receive::<ClientRequest>()? {
|
||||
Some(req) => {
|
||||
match req {
|
||||
ClientRequest::Tick => {
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::KeyPress {
|
||||
key,
|
||||
ctrl,
|
||||
alt,
|
||||
shift,
|
||||
} => {
|
||||
let mut modifiers = crossterm::event::KeyModifiers::NONE;
|
||||
if ctrl {
|
||||
modifiers |= crossterm::event::KeyModifiers::CONTROL;
|
||||
}
|
||||
if alt {
|
||||
modifiers |= crossterm::event::KeyModifiers::ALT;
|
||||
}
|
||||
if shift {
|
||||
modifiers |= crossterm::event::KeyModifiers::SHIFT;
|
||||
}
|
||||
let key_event =
|
||||
crossterm::event::KeyEvent::new(key_action_to_code(&key), modifiers);
|
||||
let actions = controller::input::handle_key(key_event, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Submit(text) => {
|
||||
state.input.buffer = text;
|
||||
let enter_event = crossterm::event::KeyEvent::new(
|
||||
crossterm::event::KeyCode::Enter,
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
let actions = controller::input::handle_key(enter_event, state);
|
||||
for action in actions {
|
||||
apply_action(state, action);
|
||||
}
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Paste(text) => {
|
||||
state.input.buffer.insert_str(state.input.cursor, &text);
|
||||
state.input.cursor += text.len();
|
||||
state.dirty = true;
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Resize(w, h) => {
|
||||
apply_action(state, Action::Resize(w, h));
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::ScrollUp => {
|
||||
apply_action(state, Action::ScrollUp);
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::ScrollDown => {
|
||||
apply_action(state, Action::ScrollDown);
|
||||
apply_action(state, Action::Tick);
|
||||
}
|
||||
ClientRequest::Close => {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||
conn.send(&ipc::protocol::DaemonFrame::ClipboardCopy(text))?;
|
||||
}
|
||||
send_daemon_update(&mut conn, state)?;
|
||||
}
|
||||
None => {
|
||||
running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Run zesdex as a background daemon: owns the agent state, listens on a
|
||||
/// per-session Unix socket, and drives one attached client.
|
||||
///
|
||||
/// Flow: create session + lock it → bind a Unix socket under
|
||||
/// `<store>/run/<session_id>.sock` → block for a single client to
|
||||
/// `accept()` → loop reading `ClientRequest`s, translating each into
|
||||
/// `Action`(s) via the same `controller::input`/`apply_action` path the
|
||||
/// single-process mode uses, then pushing a full state update back →
|
||||
/// on `Close` or client disconnect, clean up the socket file, save
|
||||
/// settings, and release the lock.
|
||||
///
|
||||
/// Why: reuses `controller::input::handle_key` by synthesizing a
|
||||
/// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and
|
||||
/// single-process modes share identical key-handling logic.
|
||||
pub fn run_daemon() -> Result<()> {
|
||||
let (store, _session_lock_guard, mut state, _rt) = crate::create_session()?;
|
||||
|
||||
let run_dir = store.base_dir.join("run");
|
||||
std::fs::create_dir_all(&run_dir)?;
|
||||
let socket_path = run_dir.join(format!("{}.sock", state.session_id));
|
||||
let addr = socket_path.to_string_lossy().to_string();
|
||||
|
||||
let server = ipc::server::IpcServer::bind_unix(&addr)?;
|
||||
eprintln!("daemon: listening on {addr}");
|
||||
|
||||
loop {
|
||||
let conn = match server.accept() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("daemon: accept error: {e}");
|
||||
break;
|
||||
}
|
||||
};
|
||||
eprintln!("daemon: client connected");
|
||||
|
||||
if let Err(e) = handle_daemon_client(conn, &mut state) {
|
||||
eprintln!("daemon: error handling client: {e}");
|
||||
}
|
||||
|
||||
eprintln!("daemon: client disconnected, waiting for next connection...");
|
||||
let _ =
|
||||
zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new()
|
||||
.save(&state.store_base_dir(), &state.settings);
|
||||
}
|
||||
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
Reference in New Issue
Block a user