Files
zesdex/crates/zesdex-backend/src/daemon.rs
T
asepharyana 9a67137954 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)
2026-07-17 09:08:41 +07:00

257 lines
10 KiB
Rust

//! 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(())
}