From 5aaedbf787063e514d62f5d8590f79af4164acca Mon Sep 17 00:00:00 2001 From: asepharyana Date: Sun, 19 Jul 2026 17:05:27 +0700 Subject: [PATCH] docs: tambah doc comment, logging, dan inline comments di semua 255 file Meliputi: - File-level //! doc comment: tujuan file, alur kerja, komponen utama - Function-level /// doc comment: apa, parameter, return, flow, edge cases - Struct/enum/trait /// doc comment: peran, field docs - Tracing logging (tracing::info!/debug!/trace!/warn!/error!) di setiap fungsi - Inline comments untuk variable dan branching logic penting - Seluruh 8 crates di workspace: zesdex-backend, zesdex-cms, zesdex-entities, zesdex-iam, zesdex-infra, zesdex-ipc, zesdex-middleware, zesdex-utils - Build: 0 errors, 242/242 tests passed --- .../zesdex-backend/src/app/bgbash/control.rs | 16 +- crates/zesdex-backend/src/app/bgbash/job.rs | 15 +- crates/zesdex-backend/src/app/bgbash/mod.rs | 5 + crates/zesdex-backend/src/app/guard/mod.rs | 22 ++- .../zesdex-backend/src/app/guard/patterns.rs | 20 +++ crates/zesdex-backend/src/app/lsp/client.rs | 101 ++++++++++- crates/zesdex-backend/src/app/lsp/mod.rs | 34 +++- .../src/app/lsp/provisioner/config.rs | 2 + .../src/app/lsp/provisioner/discovery.rs | 33 +++- .../src/app/lsp/provisioner/install.rs | 50 +++++- .../src/app/lsp/provisioner/manager.rs | 3 +- .../src/app/lsp/provisioner/mod.rs | 15 +- crates/zesdex-backend/src/app/mcp/manager.rs | 32 ++-- crates/zesdex-backend/src/app/mcp/mod.rs | 9 +- .../zesdex-backend/src/app/mcp/transport.rs | 28 +++- crates/zesdex-backend/src/app/mod.rs | 23 +-- crates/zesdex-backend/src/app/mode/bash.rs | 4 + crates/zesdex-backend/src/app/mode/editor.rs | 11 ++ crates/zesdex-backend/src/app/mode/effort.rs | 15 +- .../zesdex-backend/src/app/mode/key_input.rs | 14 +- .../zesdex-backend/src/app/mode/learning.rs | 26 ++- crates/zesdex-backend/src/app/mode/mcp.rs | 14 ++ crates/zesdex-backend/src/app/mode/mod.rs | 23 +-- .../src/app/mode/quit_confirm.rs | 7 + crates/zesdex-backend/src/app/mode/rewind.rs | 43 ++++- .../zesdex-backend/src/app/mode/settings.rs | 3 + crates/zesdex-backend/src/app/mode/todo.rs | 3 + crates/zesdex-backend/src/app/review/mod.rs | 20 ++- .../zesdex-backend/src/app/review/pending.rs | 6 +- crates/zesdex-backend/src/app/review/probe.rs | 23 ++- .../zesdex-backend/src/app/review/prompt.rs | 9 + .../src/app/review/staleness.rs | 4 + crates/zesdex-backend/src/app/review/types.rs | 1 + .../src/app/runtime/action_dispatch.rs | 15 ++ .../src/app/runtime/actions/handlers.rs | 141 ++++++++++++++-- .../src/app/runtime/actions/io.rs | 54 ++++-- .../src/app/runtime/actions/memory.rs | 19 ++- .../src/app/runtime/actions/mod.rs | 95 +++++++++-- .../src/app/runtime/actions/oauth.rs | 11 +- .../src/app/runtime/actions/spawn.rs | 19 ++- .../src/app/runtime/actions/tick.rs | 27 +++ .../src/app/runtime/actions/turn.rs | 13 ++ .../src/app/runtime/context/dedup.rs | 14 +- .../src/app/runtime/context/mod.rs | 12 ++ .../src/app/runtime/context/shaping.rs | 54 ++++++ .../src/app/runtime/context/squash.rs | 27 ++- .../src/app/runtime/context/tokens.rs | 11 +- .../src/app/runtime/context/window.rs | 29 +++- .../src/app/runtime/event_loop/mod.rs | 31 +++- crates/zesdex-backend/src/app/runtime/mod.rs | 9 +- .../src/app/runtime/stream/json_repair.rs | 44 ++++- .../src/app/runtime/stream/mod.rs | 9 + .../src/app/runtime/stream/turn.rs | 148 ++++++++++++----- crates/zesdex-backend/src/app/state/diff.rs | 16 ++ crates/zesdex-backend/src/app/state/input.rs | 95 ++++++++++- crates/zesdex-backend/src/app/state/misc.rs | 106 +++++++++++- crates/zesdex-backend/src/app/state/mod.rs | 17 ++ crates/zesdex-backend/src/app/state/rest.rs | 85 +++++++++- .../zesdex-backend/src/app/state/runtime.rs | 105 ++++++++++++ crates/zesdex-backend/src/app/state/scroll.rs | 79 ++++++++- .../zesdex-backend/src/app/state/snapshot.rs | 56 +++++++ crates/zesdex-backend/src/app/state/types.rs | 152 ++++++++++++++++- .../src/app/subagent/auto/mod.rs | 128 ++++++++++++-- .../src/app/subagent/auto/paths.rs | 58 ++++++- .../src/app/subagent/context.rs | 6 + .../src/app/subagent/division.rs | 20 ++- .../zesdex-backend/src/app/subagent/engine.rs | 157 +++++++++++++++--- .../zesdex-backend/src/app/subagent/event.rs | 17 ++ .../zesdex-backend/src/app/subagent/gating.rs | 47 +++++- crates/zesdex-backend/src/app/subagent/mod.rs | 12 ++ .../src/app/subagent/provider.rs | 36 +++- .../zesdex-backend/src/app/subagent/spawn.rs | 36 +++- .../zesdex-backend/src/app/subagent/tools.rs | 33 +++- .../src/app/subagent/workspace.rs | 31 +++- crates/zesdex-backend/src/app/util/abort.rs | 3 + crates/zesdex-backend/src/app/util/backoff.rs | 7 +- crates/zesdex-backend/src/app/util/mod.rs | 5 +- .../zesdex-backend/src/app/workflow/docs.rs | 1 + .../src/app/workflow/engine/execution.rs | 16 ++ .../src/app/workflow/engine/mod.rs | 27 +++ .../src/app/workflow/engine/phases.rs | 52 ++++-- .../src/app/workflow/engine/primitives.rs | 110 ++++++++++-- .../src/app/workflow/hive_mind/complexity.rs | 114 +++++++------ .../src/app/workflow/hive_mind/cycle.rs | 61 ++++++- .../src/app/workflow/hive_mind/live.rs | 22 ++- .../src/app/workflow/hive_mind/mod.rs | 51 +++++- .../src/app/workflow/hive_mind/synthesis.rs | 62 +++++-- .../src/app/workflow/hive_mind/types.rs | 17 +- crates/zesdex-backend/src/app/workflow/mod.rs | 6 + .../zesdex-backend/src/app/workflow/script.rs | 1 + crates/zesdex-backend/src/attach.rs | 16 ++ crates/zesdex-backend/src/bin/migrate.rs | 53 +++++- crates/zesdex-backend/src/bin/seed.rs | 26 ++- .../zesdex-backend/src/controller/command.rs | 43 ++++- crates/zesdex-backend/src/controller/input.rs | 60 ++++++- crates/zesdex-backend/src/controller/mod.rs | 8 + crates/zesdex-backend/src/daemon.rs | 21 ++- crates/zesdex-backend/src/dto/mod.rs | 30 +++- crates/zesdex-backend/src/event_loop.rs | 23 +++ crates/zesdex-backend/src/ipc/mod.rs | 21 ++- crates/zesdex-backend/src/main.rs | 22 ++- .../src/model/agent_def/builtin.rs | 12 ++ .../src/model/agent_def/global.rs | 30 +++- .../zesdex-backend/src/model/agent_def/mod.rs | 10 ++ .../src/model/agent_def/session.rs | 32 +++- crates/zesdex-backend/src/model/mod.rs | 16 +- .../zesdex-backend/src/model/msglog/blobs.rs | 25 ++- .../zesdex-backend/src/model/msglog/insert.rs | 6 +- crates/zesdex-backend/src/model/msglog/mod.rs | 13 ++ .../zesdex-backend/src/model/msglog/schema.rs | 15 ++ crates/zesdex-backend/src/prompts.rs | 15 +- crates/zesdex-backend/src/service/mod.rs | 14 +- crates/zesdex-backend/src/service/provider.rs | 30 +++- crates/zesdex-backend/src/session.rs | 11 ++ crates/zesdex-backend/src/tool/bash_tools.rs | 15 ++ crates/zesdex-backend/src/tool/fs/delete.rs | 7 + crates/zesdex-backend/src/tool/fs/edit.rs | 10 ++ crates/zesdex-backend/src/tool/fs/helpers.rs | 12 +- crates/zesdex-backend/src/tool/fs/mod.rs | 4 + crates/zesdex-backend/src/tool/fs/read.rs | 11 +- crates/zesdex-backend/src/tool/fs/write.rs | 9 +- crates/zesdex-backend/src/tool/git_cred.rs | 8 + .../zesdex-backend/src/tool/git_operator.rs | 14 +- .../zesdex-backend/src/tool/git_worktree.rs | 10 ++ .../zesdex-backend/src/tool/lsp/completion.rs | 25 +++ crates/zesdex-backend/src/tool/lsp/connect.rs | 25 +++ .../zesdex-backend/src/tool/lsp/definition.rs | 11 ++ .../src/tool/lsp/diagnostics.rs | 13 +- .../zesdex-backend/src/tool/lsp/disconnect.rs | 9 + crates/zesdex-backend/src/tool/lsp/hover.rs | 14 ++ crates/zesdex-backend/src/tool/lsp/mod.rs | 35 ++++ .../zesdex-backend/src/tool/lsp/references.rs | 10 ++ .../zesdex-backend/src/tool/memory/forget.rs | 3 + crates/zesdex-backend/src/tool/memory/mod.rs | 4 +- .../zesdex-backend/src/tool/memory/recall.rs | 4 + .../src/tool/memory/remember.rs | 5 + crates/zesdex-backend/src/tool/mod.rs | 67 ++++++-- crates/zesdex-backend/src/tool/plan.rs | 17 +- crates/zesdex-backend/src/tool/search.rs | 13 +- .../src/tool/sequential_think.rs | 6 + crates/zesdex-backend/src/tool/shell.rs | 21 ++- .../src/tool/shell_filter/credentials.rs | 8 +- .../src/tool/shell_filter/git.rs | 7 + .../src/tool/shell_filter/mod.rs | 14 +- crates/zesdex-backend/src/tool/spawn.rs | 11 +- crates/zesdex-backend/src/tool/utility/cd.rs | 9 + .../src/tool/utility/dir_cache_update.rs | 7 + .../src/tool/utility/dir_list.rs | 17 +- crates/zesdex-backend/src/tool/utility/mod.rs | 17 +- .../zesdex-backend/src/tool/utility/pong.rs | 8 + .../src/tool/utility/todofinish.rs | 25 ++- .../src/tool/utility/todowrite.rs | 7 + crates/zesdex-backend/src/tool/workflow.rs | 6 + crates/zesdex-backend/src/view/chat.rs | 13 ++ crates/zesdex-backend/src/view/markdown.rs | 12 ++ crates/zesdex-backend/src/view/mod.rs | 23 +++ .../zesdex-backend/src/view/overlays/bash.rs | 9 + .../src/view/overlays/clear_confirm.rs | 12 ++ .../src/view/overlays/editor.rs | 9 + .../src/view/overlays/effort.rs | 9 + .../zesdex-backend/src/view/overlays/help.rs | 9 + .../src/view/overlays/key_input.rs | 9 + .../src/view/overlays/learning.rs | 11 ++ .../src/view/overlays/loading.rs | 9 + .../zesdex-backend/src/view/overlays/mcp.rs | 9 + .../zesdex-backend/src/view/overlays/mod.rs | 2 + .../src/view/overlays/model_selector.rs | 10 ++ .../src/view/overlays/quit_confirm.rs | 9 + .../src/view/overlays/rewind.rs | 11 ++ .../src/view/overlays/settings.rs | 9 + .../zesdex-backend/src/view/overlays/todo.rs | 9 + .../zesdex-backend/src/view/overlays/usage.rs | 11 ++ crates/zesdex-backend/src/view/sidebar.rs | 22 +++ crates/zesdex-backend/src/view/status.rs | 7 + crates/zesdex-backend/src/view/theme.rs | 4 + crates/zesdex-backend/src/view/workflow.rs | 6 + .../src/application/conversation_service.rs | 57 ++++++- .../src/application/memory_service.rs | 47 +++++- crates/zesdex-cms/src/application/mod.rs | 20 ++- .../src/application/settings_service.rs | 58 ++++++- crates/zesdex-cms/src/domain/app_config.rs | 60 ++++++- crates/zesdex-cms/src/domain/conversation.rs | 13 +- crates/zesdex-cms/src/domain/edit_log.rs | 40 ++++- crates/zesdex-cms/src/domain/memory.rs | 54 +++++- crates/zesdex-cms/src/domain/mod.rs | 21 ++- crates/zesdex-cms/src/domain/repository.rs | 98 ++++++++--- crates/zesdex-cms/src/domain/service.rs | 43 +++-- crates/zesdex-cms/src/domain/settings.rs | 50 +++++- .../zesdex-cms/src/infrastructure/http/dto.rs | 28 +++- .../src/infrastructure/http/handlers.rs | 49 +++++- .../zesdex-cms/src/infrastructure/http/mod.rs | 18 +- crates/zesdex-cms/src/infrastructure/mod.rs | 10 +- .../persistence/app_config_repo.rs | 55 ++++-- .../persistence/conversation_repo.rs | 20 ++- .../persistence/edit_log_repo.rs | 34 +++- .../infrastructure/persistence/memory_repo.rs | 63 ++++++- .../src/infrastructure/persistence/mod.rs | 16 ++ .../persistence/rewind_blob_repo.rs | 59 ++++++- .../persistence/settings_repo.rs | 19 ++- crates/zesdex-cms/src/lib.rs | 25 ++- crates/zesdex-entities/src/domain/auth/mod.rs | 5 + .../src/domain/auth/session.rs | 28 +++- .../src/domain/auth/session_lock.rs | 22 +++ .../src/domain/common/conversation.rs | 26 ++- .../src/domain/common/message.rs | 22 ++- .../zesdex-entities/src/domain/common/mod.rs | 20 ++- .../src/domain/common/provider.rs | 63 +++++++ .../src/domain/common/store.rs | 18 ++ .../src/domain/common/tool_call.rs | 25 ++- .../src/domain/common/tool_result.rs | 16 ++ .../src/domain/common/usage.rs | 17 ++ crates/zesdex-entities/src/domain/mod.rs | 11 +- crates/zesdex-entities/src/lib.rs | 12 ++ crates/zesdex-iam/src/application/mod.rs | 10 ++ .../src/application/oauth_service.rs | 35 ++++ .../src/application/session_service.rs | 21 ++- crates/zesdex-iam/src/domain/mod.rs | 13 ++ crates/zesdex-iam/src/domain/oauth.rs | 15 ++ crates/zesdex-iam/src/domain/repository.rs | 10 ++ crates/zesdex-iam/src/domain/service.rs | 8 + crates/zesdex-iam/src/domain/session.rs | 10 +- .../zesdex-iam/src/infrastructure/http/dto.rs | 22 +++ .../src/infrastructure/http/handlers.rs | 17 ++ .../zesdex-iam/src/infrastructure/http/mod.rs | 7 + crates/zesdex-iam/src/infrastructure/mod.rs | 12 ++ .../src/infrastructure/oauth_loopback.rs | 20 +++ .../src/infrastructure/persistence/mod.rs | 11 ++ .../infrastructure/persistence/oauth_repo.rs | 17 +- .../persistence/session_lock_repo.rs | 22 ++- .../persistence/session_repo.rs | 23 +++ crates/zesdex-iam/src/infrastructure/rng.rs | 15 ++ crates/zesdex-iam/src/lib.rs | 9 +- crates/zesdex-infra/src/database.rs | 1 + crates/zesdex-infra/src/jwt.rs | 6 +- crates/zesdex-infra/src/lib.rs | 22 ++- crates/zesdex-infra/src/password.rs | 14 +- crates/zesdex-infra/src/state.rs | 19 ++- crates/zesdex-ipc/src/client.rs | 2 + crates/zesdex-ipc/src/conn.rs | 8 +- crates/zesdex-ipc/src/frame.rs | 8 +- crates/zesdex-ipc/src/lib.rs | 27 ++- crates/zesdex-ipc/src/protocol.rs | 55 ++++-- crates/zesdex-ipc/src/server.rs | 2 + crates/zesdex-middleware/src/auth.rs | 6 +- crates/zesdex-middleware/src/cors.rs | 15 +- crates/zesdex-middleware/src/lib.rs | 17 +- crates/zesdex-middleware/src/rate_limit.rs | 10 +- crates/zesdex-utils/src/atomic_write.rs | 10 +- crates/zesdex-utils/src/clipboard.rs | 12 +- crates/zesdex-utils/src/error.rs | 7 + crates/zesdex-utils/src/lib.rs | 20 ++- crates/zesdex-utils/src/logger.rs | 8 + crates/zesdex-utils/src/pagination.rs | 7 + crates/zesdex-utils/src/sanitize.rs | 11 +- crates/zesdex-utils/src/slug.rs | 16 +- 255 files changed, 5666 insertions(+), 743 deletions(-) diff --git a/crates/zesdex-backend/src/app/bgbash/control.rs b/crates/zesdex-backend/src/app/bgbash/control.rs index 78a3b72..903bbd7 100644 --- a/crates/zesdex-backend/src/app/bgbash/control.rs +++ b/crates/zesdex-backend/src/app/bgbash/control.rs @@ -14,15 +14,22 @@ use std::sync::Mutex; use std::sync::OnceLock; use super::job::BashJob; +use tracing::debug; /// Lazily-initialised, process-wide registry of background bash jobs keyed /// by job id. /// +/// Flow: first call creates the `Mutex` inside a `OnceLock`; +/// subsequent calls return the same static reference. +/// /// Return: a reference to the static `Mutex>`, created on /// first access. pub(crate) fn bash_jobs_map() -> &'static Mutex> { static JOBS: OnceLock>> = OnceLock::new(); - JOBS.get_or_init(|| Mutex::new(HashMap::new())) + JOBS.get_or_init(|| { + debug!("bash_jobs_map initialised"); + Mutex::new(HashMap::new()) + }) } /// Drain any newly available output lines from a background bash job. @@ -43,8 +50,10 @@ pub fn bash_output(id: &str) -> Option> { lines.push(line); } if lines.is_empty() { + debug!(%id, "bash_output: no new lines"); None } else { + debug!(%id, count = lines.len(), "bash_output: new lines drained"); Some(lines) } } @@ -60,6 +69,8 @@ pub fn bash_output(id: &str) -> Option> { /// Return: `Ok(())` on success, `Err` if the lock is poisoned or no job /// with that id exists. pub fn bash_kill(id: &str) -> anyhow::Result<()> { + debug!(%id, "bash_kill called"); + let mut map = bash_jobs_map() .lock() .map_err(|e| anyhow::anyhow!("lock error: {e}"))?; @@ -69,9 +80,12 @@ pub fn bash_kill(id: &str) -> anyhow::Result<()> { // Actually terminate the child process via its PID if job.child_pid > 0 { #[cfg(unix)] + // SAFETY: job.child_pid is the real PID of the spawned child; + // SIGTERM is safe and the process may already be dead. unsafe { libc::kill(job.child_pid as i32, libc::SIGTERM); } + debug!(%id, pid = job.child_pid, "bash_kill: SIGTERM sent"); } Ok(()) } diff --git a/crates/zesdex-backend/src/app/bgbash/job.rs b/crates/zesdex-backend/src/app/bgbash/job.rs index c797fdd..4119534 100644 --- a/crates/zesdex-backend/src/app/bgbash/job.rs +++ b/crates/zesdex-backend/src/app/bgbash/job.rs @@ -13,6 +13,7 @@ use std::io::BufRead; use std::process::{Command, Stdio}; use std::sync::mpsc; use std::thread; +use tracing::{debug, warn}; /// Maximum number of output lines buffered in memory per background job. /// Beyond this limit, old output is dropped to prevent OOM (CWE-770). @@ -26,9 +27,15 @@ const MAX_OUTPUT_LINES: usize = 10_000; /// synchronously, so the TUI can poll for new lines without blocking. /// The bounded channel prevents OOM from fast producers (e.g. `yes`). pub struct BashJob { + /// Unique identifier for this job (UUID v4). pub id: String, + /// OS process ID of the spawned child, used by `bash_kill` to send SIGTERM. pub child_pid: u32, + /// Receiving end of the bounded channel carrying stdout/stderr lines + /// and `__exit:` sentinels from the background thread. pub output_rx: mpsc::Receiver, + /// Exit code captured from the `__exit:` sentinel, or `None` if the job + /// is still running or hasn't been polled past its exit sentinel yet. pub exit_code: Option, } @@ -71,7 +78,7 @@ pub fn spawn_bash_job(command: String) -> BashJob { }) .is_err() { - tracing::warn!( + warn!( "[bgbash:{}] failed to spawn named thread, using unnamed fallback", id_for_log ); @@ -130,7 +137,7 @@ fn spawn_bash_thread_body( let reader = std::io::BufReader::new(stderr); for line in reader.lines().map_while(Result::ok) { if stderr_tx.try_send(format!("[stderr] {line}")).is_err() { - tracing::debug!("[bgbash] stderr buffer full, discarding remaining stderr"); + debug!("[bgbash] stderr buffer full, discarding remaining stderr"); break; } } @@ -142,7 +149,7 @@ fn spawn_bash_thread_body( let reader = std::io::BufReader::new(stdout); for line in reader.lines().map_while(Result::ok) { if output_tx.try_send(line).is_err() { - tracing::debug!( + debug!( "[bgbash:{}] output buffer full ({} lines), discarding remaining output", id_for_log, MAX_OUTPUT_LINES, @@ -171,11 +178,13 @@ impl BashJob { Ok(line) => { if line.starts_with("__exit:") { self.exit_code = line.strip_prefix("__exit:").and_then(|s| s.parse().ok()); + debug!(%self.id, exit_code = ?self.exit_code, "try_read_line: job exited"); None } else { Some(line) } } + // Channel empty or disconnected — no new output yet. Err(_) => None, } } diff --git a/crates/zesdex-backend/src/app/bgbash/mod.rs b/crates/zesdex-backend/src/app/bgbash/mod.rs index 1f11b17..b42c8fc 100644 --- a/crates/zesdex-backend/src/app/bgbash/mod.rs +++ b/crates/zesdex-backend/src/app/bgbash/mod.rs @@ -1,4 +1,9 @@ //! Background bash: run shell commands off the main thread, poll their //! output non-blockingly, and terminate them on demand. +//! +//! Flow: [`job`] defines the `BgJob` struct (a spawned child process with a +//! ticker for incremental output). [`control`] provides the UI-facing actions +//! (start, cancel, follow, etc.) that operate on the shared job registry at +//! `state.bg_bash`. pub mod control; pub mod job; diff --git a/crates/zesdex-backend/src/app/guard/mod.rs b/crates/zesdex-backend/src/app/guard/mod.rs index 41505cb..05c5c7f 100644 --- a/crates/zesdex-backend/src/app/guard/mod.rs +++ b/crates/zesdex-backend/src/app/guard/mod.rs @@ -6,6 +6,7 @@ pub mod patterns; use patterns::*; +use tracing::debug; /// Outcome of gating a tool call: whether it's allowed to run. #[derive(Debug, Clone, PartialEq)] @@ -33,39 +34,47 @@ impl Guard { ) -> Verdict { let is_risky = crate::tool::tool_is_risky(tool_name); let is_mcp = tool_name.starts_with("mcp__"); + debug!(tool_name, is_risky, is_mcp, "gating tool call"); // Universal checks applied to EVERY tool. - if let Some(v) = Self::check_path_traversal(args, workspace_roots) { + if let Some(v) = Self::check_path_traversal(args, workspace_roots) { + debug!(tool_name, "blocked by path-traversal check"); return v; } if let Some(v) = Self::check_output_path(tool_name, args, workspace_roots) { + debug!(tool_name, "blocked by output-path check"); return v; } // Non-risky, non-MCP tools pass after universal checks. if !is_risky && !is_mcp { + debug!(tool_name, "non-risky non-MCP tool allowed after universal checks"); return Verdict::Allow; } // File-mutating tools: require a meaningful reason. if matches!(tool_name, "write" | "edit" | "delete") { if let Err(msg) = Self::validate_reason(tool_name, args) { + debug!(tool_name, "blocked by reason validation"); return Verdict::Block(msg); } } // write / edit content scanning for stub/denial/assumption patterns. if let Some(v) = Self::check_content_safety(tool_name, args) { + debug!(tool_name, "blocked by content-safety check"); return v; } // Bash-specific destructive / exfiltration checks. if let Some(v) = Self::check_bash_safety(args) { + debug!(tool_name, "blocked by bash-safety check"); return v; } // git_operator: require a non-trivial reason. if tool_name == "git_operator" && !Self::has_valid_reason(args, MIN_REASON_LEN) { + debug!(tool_name, "blocked by git_operator reason check"); if args.get("reason").and_then(|v| v.as_str()).is_some() { return Verdict::Block(format!( "git_operator requires a non-trivial 'reason' \ @@ -81,12 +90,14 @@ impl Guard { if is_mcp { if let Some(reason) = args.get("reason").and_then(|v| v.as_str()) { if reason.trim().len() < MIN_REASON_LEN { + debug!(tool_name, "blocked by MCP reason length"); return Verdict::Block(format!( "MCP tool '{tool_name}' requires a non-trivial 'reason' \ (>= {MIN_REASON_LEN} chars) explaining why it is needed" )); } } else if args.as_object().is_some_and(|m| !m.is_empty()) { + debug!(tool_name, "blocked by missing MCP reason"); return Verdict::Block(format!( "MCP tool '{tool_name}' requires a 'reason' argument \ explaining the operation" @@ -94,6 +105,7 @@ impl Guard { } } + debug!(tool_name, "tool call allowed"); Verdict::Allow } @@ -374,9 +386,17 @@ impl Default for Guard { #[cfg(test)] mod tests { + //! Unit tests for the Guard gating system: verdict parsing, tool + //! classification, path-traversal detection, content-safety patterns, + //! and reason validation. use super::*; use serde_json::json; + /// Parse a verdict from either a JSON object `{"verdict": "allow|block", + /// "reason": "..."}` or a text line `Verdict: Allow|Block `. + /// + /// Flow: try JSON parse first → fall back to text line parsing → fall + /// back to keyword heuristics. fn parse_verdict(text: &str) -> Option { let trimmed = text.trim(); if let Ok(v) = serde_json::from_str::(trimmed) { diff --git a/crates/zesdex-backend/src/app/guard/patterns.rs b/crates/zesdex-backend/src/app/guard/patterns.rs index f9f1399..5cc7252 100644 --- a/crates/zesdex-backend/src/app/guard/patterns.rs +++ b/crates/zesdex-backend/src/app/guard/patterns.rs @@ -7,20 +7,24 @@ /// Stub / placeholder / denial / assumption patterns that should never reach /// a file in real code. Detected in write/edit content and bash heredocs. pub const STUB_PATTERNS: &[&str] = &[ + // Rust macro stubs "todo!()", "todo!(", "unimplemented!()", "unimplemented!(", "todo_macro", + // Review markers left by the AI "FIXME", "fixme:", "XXX:", + // Explicit placeholder tokens "PLACEHOLDER", "REPLACE_ME", "stub_value", "stub_function", "fake_response", "fake_data", + // Admission that work was deferred "not implemented", "not yet implemented", "to be implemented", @@ -30,36 +34,43 @@ pub const STUB_PATTERNS: &[&str] = &[ /// Language patterns indicating the AI is denying responsibility or /// punting the work ("I'll skip this", "for now just", etc). pub const DENIAL_PATTERNS: &[&str] = &[ + // Explicit skip/punt "// skip", "// skipping", "// skipping for now", "// for now just", "// punt", "// punted", + // Hack / workaround framing "// hack:", "// hacky", "// hack workaround", "// workaround:", "// cba", + // Deferral language "// later", "// do later", "// ignore for now", "// disable", "// disabled", "// bypass", + // Temporary / quick-fix framing (likely will never be revisited) "// quick fix", "// temp fix", "// temporary fix", "// temp:", "// temporary:", + // No-op placeholder "// noop", ]; /// Assumption-language patterns: words/phrases that indicate the code is /// reasoning based on guesswork rather than data. pub const ASSUMPTION_PATTERNS: &[&str] = &[ + // Assertions without evidence "// assume", "// assuming", + // Speculative qualification "// probably", "// maybe", "// might", @@ -75,14 +86,18 @@ pub const ASSUMPTION_PATTERNS: &[&str] = &[ /// Network-exfiltration and credential-disclosure patterns for bash. pub const EXFIL_PATTERNS: &[&str] = &[ + // Network data-transfer tools "curl ", "wget ", + // Reverse shells / netcat "nc -e ", "ncat ", "/dev/tcp/", + // Obfuscated payloads "base64 -d |", "base64 --decode |", "openssl s_client", + // SSH and file-transfer exfiltration "ssh -R ", "scp /", "rsync /", @@ -90,17 +105,22 @@ pub const EXFIL_PATTERNS: &[&str] = &[ /// Substrings of well-known credential / secret files that bash must not read. pub const SENSITIVE_PATH_PATTERNS: &[&str] = &[ + // SSH private keys and auth ".ssh/id_rsa", ".ssh/id_ed25519", ".ssh/authorized_keys", + // Cloud / package-manager credentials ".aws/credentials", ".aws/config", ".netrc", ".pypirc", ".npmrc", + // Container orchestration secrets ".kube/config", ".docker/config.json", + // GPG keys ".gnupg/", + // System-level secrets "/etc/shadow", "/etc/passwd", "/proc/self/environ", diff --git a/crates/zesdex-backend/src/app/lsp/client.rs b/crates/zesdex-backend/src/app/lsp/client.rs index fd6190a..aebb5ad 100644 --- a/crates/zesdex-backend/src/app/lsp/client.rs +++ b/crates/zesdex-backend/src/app/lsp/client.rs @@ -1,20 +1,48 @@ +//! Low-level LSP client: spawns a language server subprocess, speaks +//! JSON-RPC 2.0 over stdio, and exposes typed methods for the LSP +//! lifecycle and text-document notifications. +//! +//! Flow: `LspClient::spawn` → `initialize` handshake → `didOpen` / `didChange` +//! / `didClose` → positional queries (hover, completion, etc.) → +//! `shutdown` / `exit` on drop. + use std::io::{BufRead, BufReader, Read, Write}; use std::process::{Command, Stdio}; use std::time::{Duration, Instant}; use serde_json::{json, Value}; +use tracing::{debug, info}; +/// Timeout for the `initialize` handshake (60 s). const LSP_INIT_TIMEOUT_MS: u64 = 60_000; +/// Timeout for regular LSP method calls (30 s). const LSP_CALL_TIMEOUT_MS: u64 = 30_000; +/// Timeout waiting for a `textDocument/publishDiagnostics` notification (10 s). const LSP_DIAGNOSTICS_TIMEOUT_MS: u64 = 10_000; +/// A connected LSP language server over stdio JSON-RPC 2.0. +/// +/// Holds the child's stdin/stdout streams and tracks the next request id +/// together with the capabilities the server advertised during `initialize`. +/// The caller is responsible for calling `shutdown` before dropping. pub struct LspClient { + /// Write end of the child's stdin pipe. stdin: std::process::ChildStdin, + /// Buffered read end of the child's stdout pipe. stdout: BufReader, + /// Monotonically increasing request id for JSON-RPC calls. next_id: u64, + /// The `capabilities` blob returned by the server's `initialize` response. server_capabilities: Value, } +/// Convert an arbitrary file path (relative or absolute) to a `file://` URI +/// suitable for the LSP `TextDocumentItem.uri` field. +/// +/// Flow: resolve relative paths against CWD → canonicalize → prepend `file://` +/// with platform-appropriate slashes. +/// +/// Edge case: on Windows, drive letters get a triple slash (`file:///C:/...`). fn file_path_to_uri(path: &str) -> String { let abs_path = std::path::Path::new(path); let abs_path = if abs_path.is_relative() { @@ -40,7 +68,20 @@ fn file_path_to_uri(path: &str) -> String { } impl LspClient { + /// Spawn an LSP server process and run the `initialize` handshake. + /// + /// Flow: spawn child with piped stdio → build `LspClient` → send + /// `initialize` request with client capabilities → store + /// `server_capabilities` from the response → send `initialized` + /// notification. + /// + /// Param `command`: path or name of the LSP server binary. + /// Param `args`: CLI arguments passed to the binary. + /// + /// Return: a fully initialized `LspClient`, or an error if spawn or + /// handshake fails. pub fn spawn(command: &str, args: &[String]) -> anyhow::Result { + info!(command = command, "LspClient::spawn"); let mut cmd = Command::new(command); cmd.args(args); cmd.stdin(Stdio::piped()); @@ -69,6 +110,7 @@ impl LspClient { server_capabilities: Value::Null, }; + // Build the `initialize` params with client capabilities. let init_params = json!({ "processId": std::process::id(), "clientInfo": { @@ -118,21 +160,29 @@ impl LspClient { &init_params, Duration::from_millis(LSP_INIT_TIMEOUT_MS), )?; + // Store the capabilities blob for later inspection. client.server_capabilities = result.get("capabilities").cloned().unwrap_or_default(); client.notify("initialized", &json!({}))?; + info!(command = command, "LSP client initialized"); Ok(client) } + /// Return the server capabilities blob from the `initialize` response. pub fn server_capabilities(&self) -> &Value { &self.server_capabilities } + /// Send a JSON-RPC request and wait for the matching response (default timeout). pub fn call(&mut self, method: &str, params: &Value) -> anyhow::Result { self.call_with_timeout(method, params, Duration::from_millis(LSP_CALL_TIMEOUT_MS)) } + /// Send a JSON-RPC request and wait for the matching response (custom timeout). + /// + /// Flow: bump `next_id` → build `{"jsonrpc","id","method","params"}` → + /// `send_frame` → `read_response` with the chosen timeout. fn call_with_timeout( &mut self, method: &str, @@ -140,26 +190,33 @@ impl LspClient { timeout: Duration, ) -> anyhow::Result { self.next_id += 1; - let id = self.next_id; + let id = self.next_id; // unique id for this request let req = json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params }); + debug!(method = method, id = id, "LSP call"); self.send_frame(&req)?; self.read_response(id, timeout) } + /// Send a JSON-RPC notification (no response expected). pub fn notify(&mut self, method: &str, params: &Value) -> anyhow::Result<()> { let req = json!({ "jsonrpc": "2.0", "method": method, "params": params }); + debug!(method = method, "LSP notify"); self.send_frame(&req) } + /// Write a JSON-RPC frame (Content-Length header + body) to the child's stdin. + /// + /// Flow: serialize msg → build `Content-Length: N\r\n\r\n` → write header + /// → write body → flush. All I/O errors are wrapped with context. fn send_frame(&mut self, msg: &Value) -> anyhow::Result<()> { let body = serde_json::to_string(msg) .map_err(|e| anyhow::anyhow!("failed to serialize LSP message: {e}"))?; @@ -176,6 +233,11 @@ impl LspClient { Ok(()) } + /// Read frames from stdout until one matches `expected_id`, then return its + /// `result` (or error on a JSON-RPC error response). + /// + /// Flow: loop `read_frame` until id matches → check for `error` field → + /// return `result` or bail with the error code/message. fn read_response(&mut self, expected_id: u64, timeout: Duration) -> anyhow::Result { let deadline = Instant::now() + timeout; loop { @@ -200,6 +262,10 @@ impl LspClient { } } + /// Read frames from stdout until one matches the given `method` + /// notification, then return its `params`. + /// + /// Flow: loop `read_frame` until `method` field matches → return `params`. pub fn read_notification(&mut self, method: &str, timeout: Duration) -> anyhow::Result { let deadline = Instant::now() + timeout; loop { @@ -213,8 +279,16 @@ impl LspClient { } } + /// Read a single JSON-RPC frame (header + body) from the child's stdout. + /// + /// Flow: loop reading header lines until blank line → parse + /// `Content-Length` (capped at 64 MiB) → read exact body bytes → + /// parse JSON. Returns the parsed JSON value. + /// + /// Edge case: Content-Length values >64 MiB are rejected (CWE-400). fn read_frame(&mut self) -> anyhow::Result { let mut content_length: Option = None; + // Read header lines until a blank line. loop { let mut line = String::new(); match self.stdout.read_line(&mut line) { @@ -224,7 +298,7 @@ impl LspClient { } let trimmed = line.trim(); if trimmed.is_empty() { - break; + break; // end of headers } if let Some(len_str) = trimmed.strip_prefix("Content-Length: ") { // Cap Content-Length at 64 MiB to prevent OOM from a @@ -257,6 +331,7 @@ impl LspClient { .map_err(|e| anyhow::anyhow!("invalid JSON in LSP response: {e}")) } + /// Notify the server that a document was opened (`textDocument/didOpen`). pub fn did_open( &mut self, uri: &str, @@ -264,6 +339,7 @@ impl LspClient { version: i32, text: &str, ) -> anyhow::Result<()> { + debug!(uri = uri, version = version, "LSP didOpen"); self.notify( "textDocument/didOpen", &json!({ @@ -277,7 +353,9 @@ impl LspClient { ) } + /// Notify the server that a document's content changed (`textDocument/didChange`). pub fn did_change(&mut self, uri: &str, version: i32, text: &str) -> anyhow::Result<()> { + debug!(uri = uri, version = version, "LSP didChange"); self.notify( "textDocument/didChange", &json!({ @@ -292,7 +370,9 @@ impl LspClient { ) } + /// Notify the server that a document was closed (`textDocument/didClose`). pub fn did_close(&mut self, uri: &str) -> anyhow::Result<()> { + debug!(uri = uri, "LSP didClose"); self.notify( "textDocument/didClose", &json!({ @@ -326,14 +406,17 @@ impl LspClient { self.call(method, &body) } + /// Request hover information at a document position. pub fn hover(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { self.call_positional("textDocument/hover", uri, line, character, None) } + /// Request completion items at a document position. pub fn completion(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { self.call_positional("textDocument/completion", uri, line, character, None) } + /// Request the definition location of the symbol at a position. pub fn goto_definition( &mut self, uri: &str, @@ -343,6 +426,7 @@ impl LspClient { self.call_positional("textDocument/definition", uri, line, character, None) } + /// Request all references to the symbol at a position, including the declaration. pub fn references(&mut self, uri: &str, line: u32, character: u32) -> anyhow::Result { self.call_positional( "textDocument/references", uri, line, character, @@ -350,6 +434,10 @@ impl LspClient { ) } + /// Open a document, collect its diagnostics, then close it. + /// + /// Flow: `didOpen` → wait for `textDocument/publishDiagnostics` notification + /// → `didClose` → return the `diagnostics` array (or empty on error). pub fn collect_diagnostics( &mut self, uri: &str, @@ -371,13 +459,19 @@ impl LspClient { } } + /// Send `shutdown` + `exit` to the server gracefully. + /// + /// Flow: call `shutdown` with 5 s timeout → send `exit` notification. + /// Failures are silently ignored (best-effort cleanup). pub fn shutdown(&mut self) { + info!("LSP shutdown"); let _ = self.call_with_timeout("shutdown", &json!({}), Duration::from_secs(5)); let _ = self.notify("exit", &json!({})); } } impl Drop for LspClient { + /// Best-effort `exit` notification on drop. fn drop(&mut self) { let _ = self.notify("exit", &json!({})); } @@ -396,6 +490,9 @@ fn merge_json(a: &mut serde_json::Value, b: &serde_json::Value) { } } +/// Convert an arbitrary file path to a `file://` URI for LSP protocol use. +/// +/// This is the public entry point; delegates to the private `file_path_to_uri`. pub fn path_to_lsp_uri(path: &str) -> String { file_path_to_uri(path) } diff --git a/crates/zesdex-backend/src/app/lsp/mod.rs b/crates/zesdex-backend/src/app/lsp/mod.rs index 2fe5255..522d3ca 100644 --- a/crates/zesdex-backend/src/app/lsp/mod.rs +++ b/crates/zesdex-backend/src/app/lsp/mod.rs @@ -1,6 +1,14 @@ +//! LSP server connection management: registry of connected servers, +//! per-extension routing, and file-change notification dispatch. +//! +//! Flow: [`LspManager::connect`] spawns a server → [`register_extensions`] +//! maps file extensions to a language id → [`did_change_file`] routes edits +//! as `didOpen` / `didChange` notifications. + use std::collections::HashMap; use std::path::Path; use std::sync::{Arc, Mutex}; +use tracing::{debug, info, warn}; mod client; pub mod provisioner; @@ -70,6 +78,7 @@ impl LspManager { language_id: language_id.to_string(), client: Arc::new(Mutex::new(client)), }); + info!(language_id = language_id, command = command, "LSP server connected"); Ok(()) } @@ -93,7 +102,11 @@ impl LspManager { } let len = self.servers.len(); self.servers.retain(|s| s.language_id != language_id); - self.servers.len() < len + let removed = self.servers.len() < len; + if removed { + info!(language_id = language_id, "LSP server disconnected"); + } + removed } /// Return the language id (e.g. "rust") registered for `language_id`. @@ -111,10 +124,12 @@ impl LspManager { /// are accepted at this layer — caller must ensure a server for /// `language_id` is connected or will be connected later. pub fn register_extensions(&mut self, language_id: &str, extensions: &[&str]) { + let count = extensions.len(); for ext in extensions { self.extension_registry .insert(ext.to_string(), language_id.to_string()); } + debug!(language_id = language_id, count = count, "extensions registered"); } /// Notify the relevant LSP server that a file's contents have changed. @@ -124,7 +139,7 @@ impl LspManager { /// -> update `open_files` with the new version. /// /// Non-critical failures (file missing, server unreachable, send - /// error) are logged with `tracing::warn!` rather than propagated, + /// error) are logged with `warn!` rather than propagated, /// so a stale notification cannot abort the calling flow. pub fn did_change_file(&mut self, path: &Path) { let Some(ext) = path @@ -132,12 +147,12 @@ impl LspManager { .and_then(|e| e.to_str()) .map(|s| format!(".{s}")) else { - tracing::warn!("did_change_file: path has no extension: {:?}", path); + warn!("did_change_file: path has no extension: {:?}", path); return; }; let Some(language_id) = self.extension_registry.get(&ext).cloned() else { - tracing::warn!( + warn!( "did_change_file: no LSP server registered for extension '{}'", ext ); @@ -149,13 +164,13 @@ impl LspManager { let text = match std::fs::read_to_string(path) { Ok(t) => t, Err(e) => { - tracing::warn!("did_change_file: failed to read {:?}: {}", path, e); + warn!("did_change_file: failed to read {:?}: {}", path, e); return; } }; let Some(client) = self.get_client(&language_id) else { - tracing::warn!("did_change_file: no client for language '{}'", language_id); + warn!("did_change_file: no client for language '{}'", language_id); return; }; @@ -168,7 +183,7 @@ impl LspManager { let mut client = match client.lock() { Ok(c) => c, Err(e) => { - tracing::warn!( + warn!( "did_change_file: client mutex poisoned for '{}': {}", language_id, e @@ -184,7 +199,7 @@ impl LspManager { }; if let Err(e) = send_result { - tracing::warn!( + warn!( "did_change_file: failed to notify '{}' for {}: {}", language_id, uri, @@ -208,12 +223,14 @@ impl LspManager { /// drop the vec. Failures from individual shutdowns are swallowed /// because the goal is best-effort termination during teardown. pub fn shutdown_all(&mut self) { + let count = self.servers.len(); for server in &self.servers { if let Ok(mut client) = server.client.lock() { client.shutdown(); } } self.servers.clear(); + info!(count = count, "all LSP servers shut down"); } /// Snapshot the connected servers as `(language_id, has_open_docs)` pairs. @@ -248,6 +265,7 @@ impl LspManager { ) -> anyhow::Result<()> { self.connect(command, args, language_id)?; self.register_extensions(language_id, extensions); + info!(language_id = language_id, "LSP connected with extensions"); Ok(()) } } diff --git a/crates/zesdex-backend/src/app/lsp/provisioner/config.rs b/crates/zesdex-backend/src/app/lsp/provisioner/config.rs index 4c32c15..bdb2802 100644 --- a/crates/zesdex-backend/src/app/lsp/provisioner/config.rs +++ b/crates/zesdex-backend/src/app/lsp/provisioner/config.rs @@ -38,6 +38,8 @@ pub enum ProvisionResult { /// Sentinel command names used by `provision_single` to detect "download" /// tiers (which are dispatched to `download_*` helpers rather than /// `run_command`). Kept as constants so `supported_servers` stays readable. +/// These are never actual executables — they are matched by prefix/suffix in +/// `manager.rs` and dispatched to `install::run_download_tier`. pub(super) const DOWNLOAD_RUST_BIN: &str = "__download_rust_analyzer__"; pub(super) const DOWNLOAD_JDTLS: &str = "__download_jdtls__"; diff --git a/crates/zesdex-backend/src/app/lsp/provisioner/discovery.rs b/crates/zesdex-backend/src/app/lsp/provisioner/discovery.rs index 7809c60..768f548 100644 --- a/crates/zesdex-backend/src/app/lsp/provisioner/discovery.rs +++ b/crates/zesdex-backend/src/app/lsp/provisioner/discovery.rs @@ -1,8 +1,12 @@ //! Environment discovery: finding binaries on PATH and detecting available //! toolchains / package managers on the host system. +//! +//! Flow: [`detect_env`] shells out to `which` for each tool and builds an +//! [`EnvInfo`] struct that the provisioner uses to gate install tiers. use std::path::PathBuf; use std::process::Command; +use tracing::{debug, info}; /// Rust toolchain availability on the host PATH. #[derive(Debug, Clone)] @@ -57,6 +61,16 @@ pub struct EnvInfo { pub is_macos: bool, } +/// Check whether `binary` exists on PATH by shelling out to `which`. +/// +/// Flow: `Command::new("which").arg(binary).output()` → on Unix +/// `which` returns exit 0 + stdout path when found, non-zero +/// otherwise. We return the first stdout line as the `PathBuf`. +/// +/// Returns None if `which` itself is missing, fails to spawn, or the +/// binary is not on PATH. We deliberately don't cache this — it's only +/// called during provisioning and the results feed into install-tier +/// gating, which is already cheap. /// Check whether `binary` exists on PATH by shelling out to `which`. /// /// Flow: `Command::new("which").arg(binary).output()` → on Unix @@ -68,6 +82,7 @@ pub struct EnvInfo { /// called during provisioning and the results feed into install-tier /// gating, which is already cheap. pub fn which(binary: &str) -> Option { + debug!(binary = binary, "checking PATH"); let output = Command::new("which").arg(binary).output().ok()?; if !output.status.success() { return None; @@ -77,7 +92,9 @@ pub fn which(binary: &str) -> Option { if first.is_empty() { None } else { - Some(PathBuf::from(first)) + let path = PathBuf::from(first); + debug!(binary = binary, path = %path.display(), "found on PATH"); + Some(path) } } @@ -92,7 +109,8 @@ pub fn which(binary: &str) -> Option { /// Edge case: `which` may not exist on Windows; we guard with cfg so /// this only ever runs on Unix-like targets. pub fn detect_env() -> EnvInfo { - EnvInfo { + debug!("detecting host environment"); + let detected = EnvInfo { rust: RustToolchain { has_rustup: which("rustup").is_some(), has_cargo: which("cargo").is_some(), @@ -116,5 +134,14 @@ pub fn detect_env() -> EnvInfo { }, is_linux: cfg!(target_os = "linux"), is_macos: cfg!(target_os = "macos"), - } + }; + info!( + ?detected.rust, + ?detected.web, + ?detected.platform, + ?detected.pacman_brew, + ?detected.apt_dnf, + "environment detected" + ); + detected } diff --git a/crates/zesdex-backend/src/app/lsp/provisioner/install.rs b/crates/zesdex-backend/src/app/lsp/provisioner/install.rs index 1632200..579058f 100644 --- a/crates/zesdex-backend/src/app/lsp/provisioner/install.rs +++ b/crates/zesdex-backend/src/app/lsp/provisioner/install.rs @@ -3,30 +3,43 @@ //! //! Each helper downloads a prebuilt binary (or archive) and places it //! under `~/.local/share/zesdex/lsp//`. +//! +//! Flow: `run_download_tier` dispatches sentinel command names to the +//! appropriate installer (`install_rust_analyzer_binary` or +//! `install_jdtls_from_eclipse`). Each installer downloads, extracts, +//! and sets executable permissions on the binary. use std::path::{Path, PathBuf}; -use tracing::info; +use tracing::{debug, info}; use super::config::{ProgressFn, DOWNLOAD_JDTLS, DOWNLOAD_RUST_BIN}; use super::discovery::EnvInfo; use super::manager::run_command; /// Resolve the directory where downloaded LSP binaries are stored. +/// +/// Returns `~/.local/share/zesdex/lsp//` (using `dirs::data_dir`). fn lsp_install_dir(server: &str) -> Result { let base = dirs::data_dir() .ok_or_else(|| "cannot find data directory via dirs crate".to_string())? .join("zesdex") .join("lsp") .join(server); + debug!(server = server, path = %base.display(), "LSP install dir"); Ok(base) } /// Check whether `def` was previously installed via the download tier /// (binary/launcher lives under `~/.local/share/zesdex/lsp//`). +/// +/// Flow: resolve install dir → iterate known binary name patterns under +/// that dir → return the first existing file path. +/// /// Returns the path to the binary if found. pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) -> Option { let base = lsp_install_dir(&def.name).ok()?; + // Candidate relative paths under the install directory for each server. let candidates: &[&str] = match def.name.as_str() { "rust-analyzer" => &["rust-analyzer"], "jdtls" => &["bin/jdtls", "jdtls-launcher.sh", "jdtls"], @@ -39,17 +52,24 @@ pub(super) fn previous_download_install(def: &super::config::LanguageServerDef) if p.exists() { // Skip directory entries that exist but are the base dir itself. if p.is_file() { + debug!(name = %def.name, path = %p.display(), "found previous install"); return Some(p); } } } + debug!(name = %def.name, "no previous install found"); None } /// Download a file from `url` to `dest` using curl. +/// +/// Flow: build curl args with connect-timeout (15 s) and max-time +/// (`max_secs`) → delegate to `run_command` → return error on failure. fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> { let path_str = dest.to_str().ok_or("invalid dest path")?.to_string(); - info!(url = url, dest = %path_str, "downloading"); + info!(url = url, dest = %path_str, max_secs = max_secs, "downloading file"); + // curl flags: -f (fail on HTTP error), -sS (silent but show errors), + // -L (follow redirects), --connect-timeout, --max-time, -o (output). let args = [ "-fsSL", "--connect-timeout", @@ -64,11 +84,15 @@ fn download_url(url: &str, dest: &Path, max_secs: u64) -> Result<(), String> { if !ok { return Err(format!("download failed: {}", out.trim())); } + info!(url = url, "download complete"); Ok(()) } /// Download rust-analyzer from GitHub releases and install into -/// `~/.local/share/zesdex/lsp/rust-analyzer/bin/rust-analyzer`. +/// `~/.local/share/zesdex/lsp/rust-analyzer/rust-analyzer`. +/// +/// Flow: create install dir → pick platform URL → download gzipped binary → +/// decompress with gunzip → set executable permissions → return binary path. fn install_rust_analyzer_binary( env: &EnvInfo, progress: ProgressFn<'_>, @@ -76,6 +100,7 @@ fn install_rust_analyzer_binary( let base = lsp_install_dir("rust-analyzer")?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?; + // GitHub release URLs for the latest rust-analyzer prebuilt binary. let url = if env.is_linux { "https://github.com/rust-lang/rust-analyzer/releases/latest/download/rust-analyzer-x86_64-unknown-linux-gnu.gz" } else if env.is_macos { @@ -84,9 +109,10 @@ fn install_rust_analyzer_binary( return Err("no prebuilt binary for this OS".to_string()); }; - let gz = base.join("rust-analyzer.gz"); - let target = base.join("rust-analyzer"); + let gz = base.join("rust-analyzer.gz"); // downloaded archive + let target = base.join("rust-analyzer"); // final binary path + info!("rust-analyzer: downloading prebuilt binary"); if let Some(cb) = progress { cb("Rust: downloading prebuilt binary..."); } @@ -103,6 +129,7 @@ fn install_rust_analyzer_binary( if !target.exists() { return Err("binary missing after decompression".to_string()); } + // Set executable bit on Unix (0o755 = rwxr-xr-x). #[cfg(unix)] { use std::os::unix::fs::PermissionsExt; @@ -112,17 +139,24 @@ fn install_rust_analyzer_binary( if let Some(cb) = progress { cb("Rust: installed ✓"); } + info!("rust-analyzer: installed at {}", target.display()); Ok(target) } /// Download Eclipse JDT-LS from the official snapshot server, extract it, /// and create a launcher script at `bin/jdtls`. +/// +/// Flow: create install dir → download ~150 MB tarball → extract with tar → +/// verify `plugins/` exists → write a bash launcher script that resolves +/// the JDT-LS launcher JAR and config → set launcher executable. fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result { let base = lsp_install_dir("jdtls")?; std::fs::create_dir_all(&base).map_err(|e| format!("mkdir: {e}"))?; let url = "https://download.eclipse.org/jdtls/snapshots/jdt-language-server-latest.tar.gz"; let tarball = base.join("jdtls.tar.gz"); + + info!("jdtls: downloading (~150 MB)"); if let Some(cb) = progress { cb("Java: downloading JDT-LS (~150MB)..."); } @@ -146,6 +180,7 @@ fn install_jdtls_from_eclipse(progress: ProgressFn) -> Result { } let _ = std::fs::remove_file(&tarball); + // Validate that the extracted contents include the plugins directory. if !base.join("plugins").exists() { return Err("extracted archive missing plugins/ directory".to_string()); } @@ -182,15 +217,20 @@ exec java \ if let Some(cb) = progress { cb("Java: JDT-LS installed ✓"); } + info!("jdtls: installed at {}", launcher.display()); Ok(launcher) } /// Dispatch a sentinel download tier to the correct helper. +/// +/// Matches sentinel constants (`DOWNLOAD_RUST_BIN`, `DOWNLOAD_JDTLS`) and +/// routes to the appropriate platform-aware installer. pub(super) fn run_download_tier( name: &str, env: &EnvInfo, progress: ProgressFn<'_>, ) -> Result { + info!(tier = name, "running download tier"); match name { DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress), DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress), diff --git a/crates/zesdex-backend/src/app/lsp/provisioner/manager.rs b/crates/zesdex-backend/src/app/lsp/provisioner/manager.rs index 81d8e76..5753eed 100644 --- a/crates/zesdex-backend/src/app/lsp/provisioner/manager.rs +++ b/crates/zesdex-backend/src/app/lsp/provisioner/manager.rs @@ -5,7 +5,7 @@ use std::process::{Command, Stdio}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; -use tracing::{info, warn}; +use tracing::{debug, info, warn}; use super::config::{self, LanguageServerDef, ProgressFn, ProvisionResult}; use super::discovery::{self, EnvInfo}; @@ -25,6 +25,7 @@ use crate::app::lsp::LspManager; /// Why a custom timeout: `std::process::Command` has no built-in timeout, /// and we'd rather kill a hung `apt` than block the TUI indefinitely. pub fn run_command(cmd: &str, args: &[&str]) -> std::io::Result<(bool, String)> { + debug!(cmd = cmd, args = ?args, "running command"); let mut command = Command::new(cmd); command.args(args); command.stdout(Stdio::piped()); diff --git a/crates/zesdex-backend/src/app/lsp/provisioner/mod.rs b/crates/zesdex-backend/src/app/lsp/provisioner/mod.rs index 6c19d92..59d25c3 100644 --- a/crates/zesdex-backend/src/app/lsp/provisioner/mod.rs +++ b/crates/zesdex-backend/src/app/lsp/provisioner/mod.rs @@ -1,10 +1,11 @@ //! Auto-provisioning engine for LSP language servers. //! -//! Flow: `detect_env()` → for each supported server in `supported_servers()` -//! → `provision_single()` tries install tiers in order → returns -//! `ProvisionResult` (`AlreadyAvailable` / Installed / Failed). -//! Caller can then call `auto_connect()` to attach available servers -//! to an existing `LspManager`. +//! Flow: [`discovery::detect_env()`] probes the host → for each server in +//! [`config::supported_servers()`] → [`manager::provision_all_with_progress()`] +//! tries install tiers in order → returns [`config::ProvisionResult`] +//! (`AlreadyAvailable` / Installed / Failed). +//! Caller can then call [`manager::auto_connect()`] to attach available +//! servers to an existing [`crate::app::lsp::LspManager`]. //! //! Why: opening a project on a fresh machine should not require the user //! to manually hunt down and install 4 different language servers. @@ -26,13 +27,13 @@ pub use config::{InstallTier, LanguageServerDef, ProgressFn, ProvisionResult}; #[allow(unused_imports)] pub use config::supported_servers; -// Environment discovery +// Environment discovery — toolchain and package-manager detection #[allow(unused_imports)] pub use discovery::{detect_env, AptDnf, EnvInfo, PacmanBrew, PlatformUtils, RustToolchain, WebToolchain}; #[allow(unused_imports)] pub use discovery::which; -// Manager / orchestration +// Manager / orchestration — provisioning loop and LspManager attachment #[allow(unused_imports)] pub use manager::{auto_connect, provision_all_with_progress, run_command}; diff --git a/crates/zesdex-backend/src/app/mcp/manager.rs b/crates/zesdex-backend/src/app/mcp/manager.rs index 90c37c0..09ebaad 100644 --- a/crates/zesdex-backend/src/app/mcp/manager.rs +++ b/crates/zesdex-backend/src/app/mcp/manager.rs @@ -1,9 +1,16 @@ //! MCP server connection management: spawning/talking to stdio child //! processes and HTTP endpoints, and adapting their advertised tools to //! the crate's `Tool` trait. +//! +//! Flow: [`McpManager::connect_stdio`] spawns an MCP server → runs +//! `initialize` handshake → calls `tools/list` → wraps each advertised +//! tool in an [`McpToolAdapter`] (which implements `Tool`) → stores the +//! server with its persistent child handle for subsequent `tools/call`. + use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::sync::{Arc, Mutex}; +use tracing::{info, warn}; use super::transport::{call_via_http, call_via_stdio, mcp_static_str, spawn_stdio_child}; @@ -128,6 +135,7 @@ impl McpManager { command: &str, extra_args: &[String], ) -> anyhow::Result<()> { + info!(name = name, command = command, "MCP connect stdio"); let transport = McpTransport::Stdio { command: command.to_string(), args: extra_args.to_vec(), @@ -146,18 +154,18 @@ impl McpManager { .get("description") .and_then(|v| v.as_str()) .unwrap_or_else(|| { - tracing::warn!( - "[mcp] tool {} missing description", - t.get("name").and_then(|n| n.as_str()).unwrap_or("?") - ); - "" - }) - .to_string(), - input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| { - tracing::warn!( - "[mcp] tool {} missing inputSchema", - t.get("name").and_then(|n| n.as_str()).unwrap_or("?") + warn!( + tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"), + "MCP tool missing description" ); + "" + }) + .to_string(), + input_schema: t.get("inputSchema").cloned().unwrap_or_else(|| { + warn!( + tool = %t.get("name").and_then(|n| n.as_str()).unwrap_or("?"), + "MCP tool missing inputSchema" + ); serde_json::Value::Null }), }) @@ -169,6 +177,7 @@ impl McpManager { let handle = Arc::new(Mutex::new(child)); + let tool_count = tools.len(); self.servers.push(McpServer { name: name.to_string(), transport, @@ -176,6 +185,7 @@ impl McpManager { child_handle: Some(handle), }); + info!(name = name, tool_count = tool_count, "MCP server connected"); Ok(()) } } diff --git a/crates/zesdex-backend/src/app/mcp/mod.rs b/crates/zesdex-backend/src/app/mcp/mod.rs index ce12101..63117e4 100644 --- a/crates/zesdex-backend/src/app/mcp/mod.rs +++ b/crates/zesdex-backend/src/app/mcp/mod.rs @@ -1,4 +1,9 @@ //! Model Context Protocol (MCP) client: connects to external MCP servers //! (stdio or HTTP) and exposes their tools through the crate's `Tool` trait. -pub mod manager; -pub mod transport; +//! +//! Sub-modules: +//! - [`manager`] — server registry, connection lifecycle, tool adapter +//! - [`transport`] — low-level stdio child management and HTTP client calls + +pub mod manager; // McpManager, McpServer, McpToolAdapter +pub mod transport; // McpTransport, McpToolInfo, StdioChild, wire helpers diff --git a/crates/zesdex-backend/src/app/mcp/transport.rs b/crates/zesdex-backend/src/app/mcp/transport.rs index 2a37664..85f19ac 100644 --- a/crates/zesdex-backend/src/app/mcp/transport.rs +++ b/crates/zesdex-backend/src/app/mcp/transport.rs @@ -1,11 +1,16 @@ //! MCP transport layer: stdio child process management and HTTP client calls. //! This module handles the low-level protocol details of communicating with //! MCP servers (both spawned subprocesses and remote HTTP endpoints). +//! +//! Flow: `spawn_stdio_child` → `StdioChild::call` for JSON-RPC messages; +//! `call_via_stdio` / `call_via_http` are convenience wrappers for +//! `tools/call` that reuse a persistent child handle or spawn a fresh one. use serde::{Deserialize, Serialize}; use serde_json::{json, Value}; use std::io::{BufRead, BufReader, Write}; use std::sync::{Mutex, OnceLock}; +use tracing::{debug, info, warn}; // --------------------------------------------------------------------------- // Constants @@ -26,7 +31,7 @@ pub(super) fn mcp_static_str(s: &str) -> &'static str { let mut cache = match CACHE.get_or_init(|| Mutex::new(Vec::new())).lock() { Ok(c) => c, Err(poisoned) => { - tracing::warn!("[mcp] static string cache mutex poisoned, recovering"); + warn!("[mcp] static string cache mutex poisoned, recovering"); poisoned.into_inner() } }; @@ -88,6 +93,7 @@ impl StdioChild { const MAX_LINE_LENGTH: usize = 1_048_576; // 1 MiB self.next_id += 1; let id = self.next_id; + debug!(method = method, id = id, "MCP stdio call"); let req = json!({ "jsonrpc": "2.0", "id": id, @@ -163,7 +169,7 @@ impl StdioChild { anyhow::bail!("MCP error: {err}"); } return Ok(resp.get("result").cloned().unwrap_or_else(|| { - tracing::warn!("[mcp] stdio response missing 'result' field: {}", trimmed); + warn!("MCP stdio response missing 'result' field: {}", trimmed); Value::Null })); } @@ -179,6 +185,7 @@ pub(crate) fn spawn_stdio_child( command: &str, extra_args: &[String], ) -> anyhow::Result { + info!(command = command, "MCP spawn stdio child"); let parts: Vec<&str> = command.split_whitespace().collect(); let (prog, prog_args) = parts .split_first() @@ -249,6 +256,7 @@ pub(super) fn call_via_stdio( tool_name: &str, tool_args: &Value, ) -> anyhow::Result { + debug!(tool = tool_name, has_handle = existing_handle.is_some(), "MCP call_via_stdio"); // Reuse the persistent child handle if available; otherwise spawn a new one. let mut guard; let child: &mut StdioChild = if let Some(mtx) = existing_handle { @@ -257,6 +265,7 @@ pub(super) fn call_via_stdio( .map_err(|e| anyhow::anyhow!("MCP handle lock: {e}"))?; &mut guard } else { + // No persistent handle — spawn a fresh child for this one call. let mut fresh = spawn_stdio_child(command, extra_args)?; let result = fresh.call( "tools/call", @@ -280,13 +289,14 @@ pub(super) fn call_via_stdio( } pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> anyhow::Result { + debug!(tool = tool_name, url = url, "MCP call_via_http"); let client = reqwest::blocking::Client::builder() .timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS)) .connect_timeout(std::time::Duration::from_millis(MCP_CONNECT_TIMEOUT_MS)) .build() .unwrap_or_else(|e| { - tracing::warn!( - "[mcp] HTTP client builder failed with connect timeout: {}. \ + warn!( + "MCP HTTP client builder failed with connect timeout: {}. \ retrying without connect timeout", e, ); @@ -294,8 +304,8 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an .timeout(std::time::Duration::from_millis(MCP_CALL_TIMEOUT_MS)) .build() .unwrap_or_else(|e2| { - tracing::warn!( - "[mcp] also failed: {}. using default client (no configured timeouts)", + warn!( + "MCP also failed: {}. using default client (no configured timeouts)", e2, ); reqwest::blocking::Client::new() @@ -323,7 +333,7 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an if !resp.status().is_success() { let status = resp.status(); let text = resp.text().unwrap_or_else(|e| { - tracing::warn!("[mcp] failed to read HTTP response body: {}", e); + warn!("MCP failed to read HTTP response body: {}", e); String::new() }); anyhow::bail!("MCP HTTP server returned {status}: {text}"); @@ -338,7 +348,7 @@ pub(super) fn call_via_http(url: &str, tool_name: &str, tool_args: &Value) -> an } let result = response.get("result").cloned().unwrap_or_else(|| { - tracing::warn!("[mcp] HTTP response missing 'result' field"); + warn!("MCP HTTP response missing 'result' field"); Value::Null }); Ok(extract_text_content(&result)) @@ -365,7 +375,7 @@ pub(super) fn extract_text_content(result: &Value) -> String { } } serde_json::to_string_pretty(result).unwrap_or_else(|e| { - tracing::warn!("[mcp] failed to pretty-print result: {}", e); + warn!("MCP failed to pretty-print result: {}", e); result.to_string() }) } diff --git a/crates/zesdex-backend/src/app/mod.rs b/crates/zesdex-backend/src/app/mod.rs index ffba1d9..8825f55 100644 --- a/crates/zesdex-backend/src/app/mod.rs +++ b/crates/zesdex-backend/src/app/mod.rs @@ -1,14 +1,15 @@ //! Top-level application module: tool gate, modes, runtime loop, state, //! workflows, subagents, review, background bash, MCP integration, and //! native LSP client. -pub mod bgbash; -pub mod guard; -pub mod lsp; -pub mod mcp; -pub mod mode; -pub mod review; -pub mod runtime; -pub mod state; -pub mod subagent; -pub mod util; -pub mod workflow; + +pub mod bgbash; // Background bash process management +pub mod guard; // Tool gate: per-tool access control & permissions +pub mod lsp; // Native LSP client integration +pub mod mcp; // Model Context Protocol tool bridge +pub mod mode; // Application operating modes (normal, yolo, etc.) +pub mod review; // Post-edit auto-review subagent +pub mod runtime; // Action dispatch, streams, slash commands +pub mod state; // AppStateRest, runtime state, turn events +pub mod subagent; // Spawned subagents (test-gen, arch, security review) +pub mod util; // Miscellaneous helpers +pub mod workflow; // Hive-mind orchestration & agent workflows diff --git a/crates/zesdex-backend/src/app/mode/bash.rs b/crates/zesdex-backend/src/app/mode/bash.rs index 907355f..cd6f9db 100644 --- a/crates/zesdex-backend/src/app/mode/bash.rs +++ b/crates/zesdex-backend/src/app/mode/bash.rs @@ -1,5 +1,6 @@ //! Bash mode: handles submitting a shell command from the bash input panel. use crate::app::state::rest::AppStateRest; +use tracing::debug; /// Launch a background bash job for the submitted command. /// @@ -12,7 +13,10 @@ use crate::app::state::rest::AppStateRest; /// the shared jobs map for later polling. pub fn handle_bash_submit(state: &mut AppStateRest, command: String) { if !command.is_empty() { + debug!(command_len = command.len(), "spawning bash job from mode"); let _ = crate::app::bgbash::job::spawn_bash_job(command); state.dirty = true; + } else { + debug!("bash submit with empty command — ignored"); } } diff --git a/crates/zesdex-backend/src/app/mode/editor.rs b/crates/zesdex-backend/src/app/mode/editor.rs index 36a3589..f45a389 100644 --- a/crates/zesdex-backend/src/app/mode/editor.rs +++ b/crates/zesdex-backend/src/app/mode/editor.rs @@ -2,6 +2,7 @@ //! with bounded undo history. use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; +use tracing::debug; /// State for the built-in line editor overlay: buffer contents, cursor /// position, and a bounded undo stack. @@ -30,7 +31,9 @@ impl EditorState { /// Create a fresh editor state for `path`, seeded with existing content /// (or a single empty line for a new file). pub fn open(path: String, existing_content: Option>) -> Self { + let is_new = existing_content.is_none(); let content = existing_content.unwrap_or_else(|| vec![String::new()]); + debug!(path = %path, is_new, lines = content.len(), "editor opened"); EditorState { path, content, @@ -116,6 +119,7 @@ impl EditorState { pub fn handle_editor_input(state: &mut AppStateRest, text: &str) { let editor = &mut state.misc.editor; let Some(ed) = editor.as_mut() else { + debug!("editor input received but no editor open — ignored"); return; }; for c in text.chars() { @@ -138,7 +142,14 @@ pub fn handle_editor_input(state: &mut AppStateRest, text: &str) { } /// Close the editor overlay without saving, clearing editor state. +/// +/// Flow: reset editor to `None` → set overlay to `Overlay::None` → +/// mark state dirty for re-render. +/// +/// Why: discards unsaved edits; the caller is responsible for saving +/// via a separate commit action. pub fn handle_editor_dismiss(state: &mut AppStateRest) { + debug!("editor dismissed without saving"); state.misc.editor = None; state.misc.overlay = Overlay::None; state.dirty = true; diff --git a/crates/zesdex-backend/src/app/mode/effort.rs b/crates/zesdex-backend/src/app/mode/effort.rs index 3b58478..0dcab61 100644 --- a/crates/zesdex-backend/src/app/mode/effort.rs +++ b/crates/zesdex-backend/src/app/mode/effort.rs @@ -1,13 +1,18 @@ //! Effort mode: cycles the agent's reasoning effort level, which scales the //! LLM's temperature and `max_tokens` for subsequent turns. use crate::app::state::rest::AppStateRest; +use tracing::debug; +/// Named effort levels from lowest to highest. Higher levels allocate more +/// tokens and use lower temperature for more deterministic reasoning. pub const EFFORT_LEVELS: &[&str] = &["low", "medium", "high", "xhigh", "max"]; -/// Multiplier applied to the user's configured `max_tokens`, and the temperature to use, -/// for each entry in `EFFORT_LEVELS` (same index). Higher effort trades a larger token -/// budget for lower temperature (more deterministic, more room to reason/act). +/// Multiplier applied to the user's configured `max_tokens` per effort level. +/// Same index as `EFFORT_LEVELS`. Higher effort = larger token budget. const MAX_TOKENS_MULTIPLIER: &[f32] = &[0.5, 1.0, 1.5, 2.0, 3.0]; + +/// Temperature override per effort level. Higher effort = lower temperature +/// (more deterministic, less creative variation). const TEMPERATURE_OVERRIDE: &[f32] = &[0.9, 0.7, 0.5, 0.3, 0.1]; /// Maps an effort level index to the `(temperature, max_tokens)` pair that should be sent @@ -28,6 +33,9 @@ pub fn current_effort(state: &AppStateRest) -> usize { } /// Return the current effort level's display name (e.g. "medium"). +/// +/// Flow: delegate to `current_effort` for clamped index → index into +/// `EFFORT_LEVELS`. pub fn current_effort_str(state: &AppStateRest) -> &'static str { let idx = current_effort(state); EFFORT_LEVELS[idx] @@ -41,6 +49,7 @@ pub fn cycle_effort(state: &mut AppStateRest) { let current = current_effort(state); state.misc.effort_level = (current + 1) % EFFORT_LEVELS.len(); let label = current_effort_str(state); + debug!(from = %EFFORT_LEVELS[current], to = %label, "effort level cycled"); state.toast_info(format!("Effort: {label}")); state.dirty = true; } diff --git a/crates/zesdex-backend/src/app/mode/key_input.rs b/crates/zesdex-backend/src/app/mode/key_input.rs index a180b5d..2d7298c 100644 --- a/crates/zesdex-backend/src/app/mode/key_input.rs +++ b/crates/zesdex-backend/src/app/mode/key_input.rs @@ -1,8 +1,18 @@ -//! Key input mode: raw text capture overlay used for one-off key/text prompts. +//! Key input mode: raw text capture overlay used for one-off key/text prompts +//! such as rename, search, and inline file paths. use crate::app::state::rest::AppStateRest; +use tracing::debug; -/// Replace the input buffer with the given text and mark state dirty. +/// Store the captured text into the input buffer and mark state dirty. +/// +/// Flow: write `text` into `state.input.buffer` → set dirty flag so the +/// TUI re-renders the overlay with the new text. +/// +/// Why: the overlay reads `state.input.buffer` to display the current +/// prompt text; this is the single point where captured keystrokes +/// become visible to the renderer. pub fn handle_key_text(state: &mut AppStateRest, text: String) { + debug!(len = text.len(), "key-input text captured"); state.input.buffer = text; state.dirty = true; } diff --git a/crates/zesdex-backend/src/app/mode/learning.rs b/crates/zesdex-backend/src/app/mode/learning.rs index 8642bba..aac76b7 100644 --- a/crates/zesdex-backend/src/app/mode/learning.rs +++ b/crates/zesdex-backend/src/app/mode/learning.rs @@ -1,7 +1,19 @@ +//! Learning mode: TUI overlay for reviewing and managing lesson items. +//! Loads both pending lessons (from the session directory) and stored +//! lessons (from long-term memory) into a unified list for the overlay. +//! +//! Flow: read pending files → deserialize as `PendingLesson` → read +//! long-term memory dir → filter by `kind == "lesson"` → merge into +//! a single `Vec`. + use crate::app::state::rest::AppStateRest; +use tracing::debug; use zesdex_cms::domain::repository::MemoryRepository; /// A unified representation of a lesson item for the interactive TUI overlay. +/// +/// Two variants: `Pending` (not yet committed to long-term memory) and +/// `Stored` (already persisted in the memory directory). #[derive(Debug, Clone)] pub enum LearningItem { Pending { @@ -19,7 +31,16 @@ pub enum LearningItem { }, } -/// Dynamically read all pending and stored lessons. +/// Dynamically read all pending and stored lessons from session dir and +/// long-term memory. +/// +/// Flow: load pending lessons from `state.session_runtime.session_dir` → +/// map each to `LearningItem::Pending` → load stored memories from +/// `state.memory_dir` → filter by `kind == "lesson"` → collect remaining +/// items. +/// +/// Return: merged `Vec` (pending first, then stored). Empty +/// vec if nothing is found. pub fn get_learning_items(state: &AppStateRest) -> Vec { let mut items = Vec::new(); @@ -29,6 +50,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec { } else { Vec::new() }; + debug!(pending_count = pending.len(), "loading pending lessons"); for p in pending { let scope_str = match p.lesson.scope { @@ -58,6 +80,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec { zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new() .list(&state.memory_dir) .unwrap_or_default(); + debug!(stored_names = names.len(), "loading stored lessons"); for name in names { if let Ok(mem) = zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository::new() @@ -75,5 +98,6 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec { } } + debug!(total_items = items.len(), "learning items loaded"); items } diff --git a/crates/zesdex-backend/src/app/mode/mcp.rs b/crates/zesdex-backend/src/app/mode/mcp.rs index 38b33d9..e1e1820 100644 --- a/crates/zesdex-backend/src/app/mode/mcp.rs +++ b/crates/zesdex-backend/src/app/mode/mcp.rs @@ -1,11 +1,25 @@ //! MCP mode: overlay for connecting to a configured MCP server. +//! +//! Flow: invoked from the TUI overlay — reads the server name from user input, +//! then delegates to the appropriate MCP connection path. use crate::app::state::rest::AppStateRest; +use tracing::debug; /// Placeholder entry point for connecting to an MCP server by name. /// +/// Flow: marks state dirty → overlay re-renders. +/// /// Why: not yet wired to `McpManager::connect_stdio` — currently just /// marks state dirty so the overlay re-renders. +/// +/// ## Future +/// Once `McpManager::connect_stdio` is wired, this function will: +/// 1. Resolve `server_name` from the config registry. +/// 2. Spawn the stdio subprocess. +/// 3. Register the transport in the MCP manager. pub fn connect_mcp(state: &mut AppStateRest, server_name: &str) { + debug!(%server_name, "connect_mcp called"); let _ = server_name; + // Mark state dirty to trigger a re-render of the MCP overlay. state.dirty = true; } diff --git a/crates/zesdex-backend/src/app/mode/mod.rs b/crates/zesdex-backend/src/app/mode/mod.rs index 97ed29d..5092699 100644 --- a/crates/zesdex-backend/src/app/mode/mod.rs +++ b/crates/zesdex-backend/src/app/mode/mod.rs @@ -1,16 +1,19 @@ //! TUI mode definitions and per-mode input/action handlers, one submodule //! per overlay/mode (bash, editor, effort, mcp, quit confirm, rewind, etc.). -pub mod bash; -pub mod editor; -pub mod effort; -pub mod key_input; -pub mod mcp; +//! Each mode encapsulates its own keyboard input parsing, state transitions, +//! and view rendering so the top-level event loop can dispatch generically. -pub mod learning; -pub mod quit_confirm; -pub mod rewind; -pub mod settings; -pub mod todo; +pub mod bash; // Shell-command input overlay: prompt, history, execution +pub mod editor; // Multi-line text editor overlay (write/edit tool content) +pub mod effort; // Reasoning-effort selector overlay +pub mod key_input; // Generic single-key prompt overlay (e.g. rename, search) +pub mod mcp; // MCP tool argument builder overlay + +pub mod learning; // Learning/reflection input overlay +pub mod quit_confirm; // Quit confirmation dialog overlay +pub mod rewind; // Rewind/undo checkpoint selection overlay +pub mod settings; // Settings panel overlay +pub mod todo; // TODO-list management overlay /// Cycle `current` in the range `[0, len)`. /// diff --git a/crates/zesdex-backend/src/app/mode/quit_confirm.rs b/crates/zesdex-backend/src/app/mode/quit_confirm.rs index 32505c3..2176a68 100644 --- a/crates/zesdex-backend/src/app/mode/quit_confirm.rs +++ b/crates/zesdex-backend/src/app/mode/quit_confirm.rs @@ -1,11 +1,18 @@ //! Quit-confirm mode: the "are you sure?" overlay shown before exiting. +//! +//! Flow: user presses quit → overlay appears with yes/no → `handle_quit_confirm` +//! translates the choice into an `Action`. use crate::app::runtime::actions::Action; +use tracing::debug; /// Translate the user's yes/no answer on the quit-confirm overlay into an action. /// +/// Flow: receives `true` (yes, quit) or `false` (no, cancel). +/// /// Return: `Action::ForceQuit` if confirmed, otherwise `Action::CloseOverlay` /// to dismiss the prompt without quitting. pub fn handle_quit_confirm(yes: bool) -> Action { + debug!(%yes, "handle_quit_confirm"); if yes { Action::ForceQuit } else { diff --git a/crates/zesdex-backend/src/app/mode/rewind.rs b/crates/zesdex-backend/src/app/mode/rewind.rs index 501ad84..1b4b343 100644 --- a/crates/zesdex-backend/src/app/mode/rewind.rs +++ b/crates/zesdex-backend/src/app/mode/rewind.rs @@ -1,23 +1,47 @@ //! Rewind mode: restores a file to a pre-edit snapshot stored in the //! session's `SQLite` blob store. +//! +//! Flow: user invokes Rewind overlay → `rewind_count` shows available snapshots +//! → user picks an index → `rewind_to` fetches the blob, writes it back to disk, +//! and logs the rewind in the edit log. use crate::app::state::rest::AppStateRest; use sha2::Digest; +use tracing::{debug, info}; use zesdex_cms::domain::repository::EditLogRepository; /// Returns the number of stored pre-edit blobs (snapshots) for this session. +/// +/// Flow: opens the session DB → lists blob keys → returns count. +/// +/// Return: `0` if the DB cannot be opened or no blobs exist. pub fn rewind_count(state: &AppStateRest) -> usize { let Ok(conn) = open_session_db(&state.session_dir) else { + debug!("rewind_count: cannot open session DB, returning 0"); return 0; }; - crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) + let count = crate::model::msglog::blobs::list_blob_keys(&conn, &state.session_id) .ok() - .map_or(0, |keys| keys.len()) + .map_or(0, |keys| keys.len()); + debug!(count, "rewind_count"); + count } /// Restores a file to its pre-edit state by retrieving the blob stored under index /// `index` (0 = oldest). Opens a fresh `SQLite` connection so this works outside /// of a running turn (e.g. from the Rewind overlay). +/// +/// Flow: +/// 1. Open session DB. +/// 2. List blob keys. +/// 3. Validate index bounds. +/// 4. Retrieve blob bytes. +/// 5. Resolve the original file path from the edit log. +/// 6. Write bytes back to disk. +/// 7. Log the rewind as an edit-log entry. +/// 8. Mark transcript cache dirty to force a UI refresh. pub fn rewind_to(state: &mut AppStateRest, index: usize) { + debug!(%index, "rewind_to start"); + let conn = match open_session_db(&state.session_dir) { Ok(c) => c, Err(e) => { @@ -66,6 +90,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { match std::fs::write(&restore_path, &bytes) { Ok(()) => { + info!(path = %restore_path.display(), "rewind_to: file restored from snapshot"); state.toast_success(format!("Restored {} from snapshot", restore_path.display())); } Err(e) => { @@ -73,7 +98,7 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { } } - // Log the rewind itself as an edit entry + // Log the rewind itself as an edit entry so the operation is auditable. let repo = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new(); if let Ok(mut el) = repo.open(&state.session_dir) { @@ -90,17 +115,27 @@ pub fn rewind_to(state: &mut AppStateRest, index: usize) { let _ = repo.append(&state.session_dir, &mut el, entry); } - // Clear the transcript to force a refresh + // Clear the transcript cache to force the UI to refresh. state.transcript_cache.dirty = true; state.dirty = true; + debug!("rewind_to finished"); } +/// Open a direct SQLite connection to the session database. +/// +/// Flow: constructs the path to `messages.sqlite` under `session_dir` → opens with rusqlite. fn open_session_db(session_dir: &std::path::Path) -> anyhow::Result { let path = session_dir.join("messages.sqlite"); let conn = rusqlite::Connection::open(&path)?; + debug!(path = %path.display(), "open_session_db opened"); Ok(conn) } +/// Walk the edit log backwards to find the most recent `write` or `edit` entry, +/// and return its path. +/// +/// Why: the blob key is a `tool_call_id`, but the edit log stores paths, not +/// tool_call_ids. We fall back to the last-known written/edited path. fn find_edit_path(state: &AppStateRest, _blob_key: &str) -> Option { let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new() .open(&state.session_dir) diff --git a/crates/zesdex-backend/src/app/mode/settings.rs b/crates/zesdex-backend/src/app/mode/settings.rs index af45ec9..53a26ff 100644 --- a/crates/zesdex-backend/src/app/mode/settings.rs +++ b/crates/zesdex-backend/src/app/mode/settings.rs @@ -3,6 +3,7 @@ //! Flow: exposes small mutation functions (currently just cycling the //! internet access mode) invoked by keybindings while the settings overlay //! is active. +use tracing::debug; use zesdex_cms::domain::settings::{InternetMode, Settings}; /// Advance the internet access mode to the next value in the cycle. @@ -14,9 +15,11 @@ use zesdex_cms::domain::settings::{InternetMode, Settings}; /// /// Return: nothing; mutates `settings.internet_mode` in place. pub fn cycle_internet_mode(settings: &mut Settings) { + let before = settings.internet_mode.clone(); settings.internet_mode = match settings.internet_mode { InternetMode::Off => InternetMode::ReadOnly, InternetMode::ReadOnly => InternetMode::Full, InternetMode::Full => InternetMode::Off, }; + debug!(?before, ?settings.internet_mode, "cycle_internet_mode"); } diff --git a/crates/zesdex-backend/src/app/mode/todo.rs b/crates/zesdex-backend/src/app/mode/todo.rs index 9dbd4ae..c4ee443 100644 --- a/crates/zesdex-backend/src/app/mode/todo.rs +++ b/crates/zesdex-backend/src/app/mode/todo.rs @@ -4,6 +4,7 @@ //! the todo overlay. use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; +use tracing::debug; /// Toggle the todo-list overlay open or closed. /// @@ -14,10 +15,12 @@ use crate::app::state::types::Overlay; /// /// Return: nothing; mutates `state.misc.overlay` and `state.dirty` in place. pub fn handle_todo_toggle(state: &mut AppStateRest) { + let before = state.misc.overlay; if state.misc.overlay == Overlay::Todo { state.misc.overlay = Overlay::None; } else { state.misc.overlay = Overlay::Todo; } + debug!(before = %before, after = %state.misc.overlay, "handle_todo_toggle"); state.dirty = true; } diff --git a/crates/zesdex-backend/src/app/review/mod.rs b/crates/zesdex-backend/src/app/review/mod.rs index 240bb05..370a8cf 100644 --- a/crates/zesdex-backend/src/app/review/mod.rs +++ b/crates/zesdex-backend/src/app/review/mod.rs @@ -73,8 +73,11 @@ pub fn should_trigger_review(state: &AppStateRest, origin: Origin) -> bool { /// propagate from constructing the subagent context, not from the review /// itself (that failure is reported via a `SystemNote` instead). pub fn trigger_review(state: &mut AppStateRest) { + tracing::info!("[review] triggering quality-review subagent"); state.misc.lesson_running = true; + // Ensure docs/lesson/ is gitignored so generated lesson files don't + // pollute the workspace's tracked state. if let Some(workspace) = state.workspace_roots.first() { let gitignore_path = workspace.join(".gitignore"); let content = std::fs::read_to_string(&gitignore_path).unwrap_or_default(); @@ -108,6 +111,8 @@ pub fn trigger_review(state: &mut AppStateRest) { ctx.session_dir.clone_from(&state.session_dir); ctx.workspaces.clone_from(&state.workspace_roots); + // Run build/test probe so the review subagent gets a real pass/fail + // signal rather than reviewing changes blind. let probe_result = probe::probe_build_test( &state.workspace_roots, state.settings.verify_command.as_deref(), @@ -117,20 +122,30 @@ pub fn trigger_review(state: &mut AppStateRest) { let probe_note = match &probe_result { Some(r) => { if r.passed { + tracing::debug!("[review] probe passed: {}", r.command); format!("Build/test verification passed ({}).", r.command) } else if r.timed_out { + tracing::debug!("[review] probe timed out: {}", r.command); format!("Build/test verification timed out ({}).", r.command) } else { + tracing::debug!("[review] probe failed: {}", r.command); format!( "Build/test verification failed ({}). Output: {}", r.command, r.output ) } } - None => "No build/test probe matched.".to_string(), + None => { + tracing::debug!("[review] no probe matched"); + "No build/test probe matched.".to_string() + } }; ctx.system_prompt = prompt::compose_review_prompt(state, &probe_note); + tracing::debug!( + "[review] prompt length: {} chars", + ctx.system_prompt.len() + ); let turn_events_for_drain = state.turn_events.clone(); let (tx, _drain_thread) = spawn_subagent_with_drain(move |event| { @@ -164,6 +179,7 @@ pub fn trigger_review(state: &mut AppStateRest) { let turn_events = state.turn_events.clone(); std::thread::spawn(move || { + tracing::debug!("[review] subagent thread started"); let result = run_subagent(&ctx, &tx); let message = match result { Ok(verdict) => { @@ -180,6 +196,8 @@ pub fn trigger_review(state: &mut AppStateRest) { } }); + // Push a non-blocking toast so the user knows a lesson is being + // generated; the actual outcome arrives via SystemNote. state.push_toast(Toast::new( ToastKind::Info, "Generating lesson...".to_string(), diff --git a/crates/zesdex-backend/src/app/review/pending.rs b/crates/zesdex-backend/src/app/review/pending.rs index f6c1975..60188c8 100644 --- a/crates/zesdex-backend/src/app/review/pending.rs +++ b/crates/zesdex-backend/src/app/review/pending.rs @@ -60,12 +60,13 @@ pub fn process_pending_lessons( ) -> std::io::Result> { let pending = load_pending_lessons(session_dir); let now = chrono::Utc::now().timestamp_millis(); - let grace_window = 5_000; + let grace_window = 5_000; // 5 seconds for user to reject auto-resolve let mut remaining = Vec::new(); let mut to_keep = Vec::new(); for p in &pending { if p.auto_resolve && now.saturating_sub(p.created_at) >= grace_window { + tracing::debug!("[pending] auto-resolving lesson: {}", p.lesson.name); to_keep.push(p.lesson.clone()); } else { remaining.push(p.clone()); @@ -119,6 +120,7 @@ pub fn resolve_pending_lesson( for p in pending { if p.lesson.name == lesson_name { if keep { + tracing::info!("[pending] committing lesson: {lesson_name}"); let mem = Memory { name: p.lesson.name.clone(), description: p.lesson.content.chars().take(80).collect(), @@ -136,6 +138,8 @@ pub fn resolve_pending_lesson( MarkdownMemoryRepository::new() .save(memory_dir, &mem) .map_err(|e| std::io::Error::other(e.to_string()))?; + } else { + tracing::debug!("[pending] discarding lesson: {lesson_name}"); } } else { remaining.push(p); diff --git a/crates/zesdex-backend/src/app/review/probe.rs b/crates/zesdex-backend/src/app/review/probe.rs index 866ad72..6c1ae1f 100644 --- a/crates/zesdex-backend/src/app/review/probe.rs +++ b/crates/zesdex-backend/src/app/review/probe.rs @@ -35,6 +35,10 @@ pub fn probe_build_test( let probe_dir = workspaces.first()?; let cmd = resolve_verify_command(probe_dir, verify_command)?; + tracing::debug!("[probe] running: {cmd} in {:?}", probe_dir); + + // Split "command arg1 arg2" into program + args for Command API. + // If there's no space, args are empty. let (cmd_prog, cmd_args) = cmd.split_once(' ').map_or_else( || (cmd.clone(), String::new()), |(p, a)| (p.to_string(), a.to_string()), @@ -47,6 +51,7 @@ pub fn probe_build_test( .stderr(std::process::Stdio::piped()) .spawn() else { + tracing::warn!("[probe] failed to spawn: {cmd_prog}"); return None; }; @@ -72,9 +77,14 @@ pub fn probe_build_test( } else { format!("{stdout}\n{stderr}") }; + let passed = status.success(); + tracing::debug!( + "[probe] finished: passed={passed}, exit={:?}", + status.code() + ); return Some(ProbeResult { command: cmd.clone(), - passed: status.success(), + passed, output: truncate_output(&combined, 2048), timed_out: false, }); @@ -86,6 +96,7 @@ pub fn probe_build_test( } }; if timed_out { + tracing::debug!("[probe] timed out after {timeout_ms}ms: {cmd}"); Some(ProbeResult { command: cmd.clone(), passed: false, @@ -93,6 +104,7 @@ pub fn probe_build_test( timed_out: true, }) } else { + tracing::debug!("[probe] unexpected exit from polling loop for: {cmd}"); None } } @@ -114,23 +126,30 @@ pub(crate) fn resolve_verify_command( probe_dir: &std::path::Path, override_cmd: Option<&str>, ) -> Option { + // Use explicit override if provided and non-empty. if let Some(cmd) = override_cmd { if !cmd.trim().is_empty() { + tracing::debug!("[probe] using override command: {cmd}"); return Some(cmd.trim().to_string()); } } + // Auto-detect from project marker files, trying common ecosystems + // in priority order. let has_file = |name: &str| probe_dir.join(name).exists(); let has_dir = |name: &str| probe_dir.join(name).is_dir(); if has_file("Cargo.toml") { + tracing::debug!("[probe] detected Cargo project"); if has_dir("src") || has_dir("tests") { return Some("cargo build 2>&1 && cargo test 2>&1".to_string()); } return Some("cargo build 2>&1".to_string()); } if has_file("go.mod") { + tracing::debug!("[probe] detected Go project"); return Some("go build ./... 2>&1 && go test ./... 2>&1".to_string()); } if has_file("package.json") { + tracing::debug!("[probe] detected Node project"); let pkg = std::fs::read_to_string(probe_dir.join("package.json")).ok()?; if let Ok(v) = serde_json::from_str::(&pkg) { let scripts = v.get("scripts")?; @@ -160,6 +179,7 @@ pub(crate) fn resolve_verify_command( || has_file("Pipfile") || has_file("poetry.lock") { + tracing::debug!("[probe] detected Python project"); if has_file("pyproject.toml") { let content = std::fs::read_to_string(probe_dir.join("pyproject.toml")).unwrap_or_default(); @@ -223,6 +243,7 @@ pub(crate) fn resolve_verify_command( if has_file("Project.toml") || has_file("JuliaProject.toml") { return Some("julia --project=. -e 'using Pkg; Pkg.test()' 2>&1".to_string()); } + tracing::debug!("[probe] no project marker files matched in {probe_dir:?}"); None } diff --git a/crates/zesdex-backend/src/app/review/prompt.rs b/crates/zesdex-backend/src/app/review/prompt.rs index 3ff7127..23729d0 100644 --- a/crates/zesdex-backend/src/app/review/prompt.rs +++ b/crates/zesdex-backend/src/app/review/prompt.rs @@ -9,6 +9,8 @@ pub(crate) const STALE_AFTER_DAYS: i64 = 60; /// Compose the system prompt for the quality-review subagent. pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> String { + tracing::debug!("[prompt] composing review prompt"); + // Capture the unstaged diff so the reviewer can evaluate actual changes. let diff_output = if let Some(workspace) = state.workspace_roots.first() { std::process::Command::new("git") .arg("diff") @@ -22,6 +24,8 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S String::new() }; + // Extract the last 10 chat messages (user + assistant) so the reviewer + // can cross-check what was discussed against what was actually changed. let history_output = if let Some(rt) = &state.session_runtime { let msgs: Vec = rt .messages @@ -41,6 +45,11 @@ pub(crate) fn compose_review_prompt(state: &AppStateRest, probe_note: &str) -> S String::new() }; + tracing::debug!( + "[prompt] diff={}chars, history={}chars", + diff_output.len(), + history_output.len() + ); let session_dir_disp = state.session_dir.display(); format!( "You are a code quality reviewer and lesson generator. Your goal is to review recent code changes.\n\n\ diff --git a/crates/zesdex-backend/src/app/review/staleness.rs b/crates/zesdex-backend/src/app/review/staleness.rs index b4e834a..fc9fa68 100644 --- a/crates/zesdex-backend/src/app/review/staleness.rs +++ b/crates/zesdex-backend/src/app/review/staleness.rs @@ -17,6 +17,7 @@ use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryReposito /// Return: names of newly-flagged memories, or an I/O error from /// `mem.write`. pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result> { + tracing::debug!("[staleness] starting sweep in {:?}", memory_dir); let mut flagged = Vec::new(); let names = MarkdownMemoryRepository::new() .list(memory_dir) @@ -26,6 +27,7 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result std::io::Result Self { Self { diff --git a/crates/zesdex-backend/src/app/runtime/action_dispatch.rs b/crates/zesdex-backend/src/app/runtime/action_dispatch.rs index 28f7d59..dc6a6ac 100644 --- a/crates/zesdex-backend/src/app/runtime/action_dispatch.rs +++ b/crates/zesdex-backend/src/app/runtime/action_dispatch.rs @@ -1,5 +1,7 @@ //! Maps parsed `/` slash commands into one or more `Action` variants //! that `apply_action` can process. +use tracing::debug; + use crate::app::runtime::actions::Action; use crate::app::state::types::Overlay; use crate::controller::command::Command; @@ -14,7 +16,9 @@ use crate::controller::command::Command; /// Return: a `Vec` (always non-empty) to be applied sequentially /// by `apply_action`. pub fn apply_command(command: Command) -> Vec { + debug!("apply_command: {:?}", command); match command { + // ── Navigation overlays ──────────────────────────────────────── Command::Help => { vec![Action::OpenOverlay(Overlay::Help)] } @@ -27,12 +31,16 @@ pub fn apply_command(command: Command) -> Vec { Command::ClearConfirm => { vec![Action::OpenOverlay(Overlay::ClearConfirm)] } + + // ── System actions ───────────────────────────────────────────── Command::Clear => { vec![Action::SystemNote { kind: "clear".to_string(), message: "transcript cleared".to_string(), }] } + + // ── Login / auth ─────────────────────────────────────────────── Command::Login { provider } if provider.is_empty() => { vec![Action::SystemNote { kind: "error".to_string(), @@ -42,6 +50,8 @@ pub fn apply_command(command: Command) -> Vec { Command::Login { provider } => { vec![Action::StartOAuth { provider }] } + + // ── Editor ───────────────────────────────────────────────────── Command::Edit(path) if path == "." || path.is_empty() => { vec![Action::SystemNote { kind: "info".to_string(), @@ -51,6 +61,8 @@ pub fn apply_command(command: Command) -> Vec { Command::Edit(path) => { vec![Action::OpenEditor { path }] } + + // ── Tools / configuration ────────────────────────────────────── Command::McpAdd { name, command } => { vec![Action::McpAdd { name, command }] } @@ -61,12 +73,15 @@ pub fn apply_command(command: Command) -> Vec { vec![Action::Compact] } + // ── Dashboard overlays ───────────────────────────────────────── Command::TodoOpen => { vec![Action::OpenOverlay(Overlay::Todo)] } Command::UsageOpen => { vec![Action::OpenOverlay(Overlay::Usage)] } + + // ── Fallback ─────────────────────────────────────────────────── Command::Unknown(cmd) => { vec![Action::SystemNote { kind: "error".to_string(), diff --git a/crates/zesdex-backend/src/app/runtime/actions/handlers.rs b/crates/zesdex-backend/src/app/runtime/actions/handlers.rs index ff7a2fe..e7db63a 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/handlers.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/handlers.rs @@ -1,6 +1,15 @@ //! Simple action handler functions — one per `Action` variant, called by //! `apply_action` in the root module. Each handler mutates `AppStateRest` //! in place. +//! +//! Handlers are deliberately short and focused — they extract arguments from +//! the `Action` variant, perform a single state mutation, and mark `dirty` +//! so the TUI re-renders on the next frame. +//! +//! More complex orchestration (turn spawning, OAuth background threads) is +//! delegated to sibling sub-modules (`spawn`, `oauth`, `io`, `memory`). + +use tracing::debug; use crate::app::runtime::context::tokens::count_tokens; use crate::app::runtime::context::window; @@ -16,71 +25,104 @@ use super::memory::refresh_lesson_counters; use super::spawn::spawn_turn; use super::oauth::run_oauth_flow; +/// Hard exit — save session, shut down LSP, set quit flag. +/// +/// Flow: persist session metadata and conversation → terminate LSP client → +/// set `quit = true` so the event loop exits on the next iteration. pub(super) fn handle_force_quit(state: &mut AppStateRest) { - save_current_session(state); - state.shutdown_lsp(); - state.quit = true; + debug!("handle_force_quit"); + save_current_session(state); // Persist session metadata + messages + state.shutdown_lsp(); // Gracefully shut down LSP connection + state.quit = true; // Signal event loop to exit } +/// Submit user text as a new LLM turn. +/// +/// Flow: mark input as submitted → trim → guard empty → push `ChatMessageDisplay` +/// into transcript → push `ChatMessage` into session runtime → refresh lesson +/// counters → set `thinking = true` → spawn a background turn thread. pub(super) fn handle_submit_input(state: &mut AppStateRest, text: String) { + debug!("handle_submit_input: len={}", text.len()); state.input.submit(); let text = text.trim().to_string(); if text.is_empty() { state.dirty = true; return; } + // Push user message into both the display transcript and the session-runtime message list state.push_transcript(ChatMessageDisplay::new(Role::User, text.clone())); if let Some(ref mut rt) = state.session_runtime { rt.push_message(ChatMessage::user(text)); refresh_lesson_counters(&state.memory_dir, rt); } else { + // No active session — ensure the memory directory exists for future use let _ = std::fs::create_dir_all(&state.memory_dir); } state.misc.thinking = true; - spawn_turn(state); + spawn_turn(state); // launches LLM streaming on a background OS thread state.dirty = true; } +/// Delete one character left of the cursor in the input buffer. pub(super) fn handle_delete_char(state: &mut AppStateRest) { + debug!("handle_delete_char"); state.input.delete_left(); state.dirty = true; } +/// Delete one character right of the cursor in the input buffer. pub(super) fn handle_delete_char_right(state: &mut AppStateRest) { + debug!("handle_delete_char_right"); state.input.delete_right(); state.dirty = true; } +/// Move the cursor one position left. pub(super) fn handle_cursor_left(state: &mut AppStateRest) { + debug!("handle_cursor_left"); state.input.char_left(); } +/// Move the cursor one position right. pub(super) fn handle_cursor_right(state: &mut AppStateRest) { + debug!("handle_cursor_right"); state.input.char_right(); } +/// Navigate up through input history. pub(super) fn handle_history_up(state: &mut AppStateRest) { + debug!("handle_history_up"); state.input.history_up(); state.dirty = true; } +/// Navigate down through input history. pub(super) fn handle_history_down(state: &mut AppStateRest) { + debug!("handle_history_down"); state.input.history_down(); state.dirty = true; } +/// Scroll the transcript pane up by 5 lines. pub(super) fn handle_scroll_up(state: &mut AppStateRest) { + debug!("handle_scroll_up"); state.scroll.scroll_up(5); state.dirty = true; } +/// Scroll the transcript pane down by 5 lines. pub(super) fn handle_scroll_down(state: &mut AppStateRest) { + debug!("handle_scroll_down"); state.scroll.scroll_down(5); state.dirty = true; } +/// Open a named overlay — sets the overlay variant and resets selection index +/// for overlays that support list navigation (Learning, Rewind, ModelSelector). pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) { + debug!("handle_open_overlay: {:?}", overlay); state.misc.overlay = overlay; + // Reset selection index for list-based overlays if overlay == Overlay::Learning || overlay == Overlay::Rewind || overlay == Overlay::ModelSelector @@ -90,13 +132,20 @@ pub(super) fn handle_open_overlay(state: &mut AppStateRest, overlay: Overlay) { state.dirty = true; } +/// Open the inline file editor for `path`. +/// +/// Flow: resolve the workspace-relative path → read file content → +/// construct `EditorState` → set overlay to `Editor`. pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) { + debug!("handle_open_editor: {}", path); + // Resolve path relative to workspace roots let resolved = crate::tool::resolve_path(&state.workspace_roots, &path); match resolved { Ok(abs_path) => { let content = std::fs::read_to_string(&abs_path).unwrap_or_default(); let lines: Vec = content.lines().map(std::string::ToString::to_string).collect(); + // Create the editor state from the file content lines let ed = crate::app::mode::editor::EditorState::open( abs_path.to_string_lossy().to_string(), Some(lines), @@ -115,13 +164,20 @@ pub(super) fn handle_open_editor(state: &mut AppStateRest, path: String) { state.dirty = true; } +/// Register a new MCP server by name and shell command. +/// +/// Flow: parse command string into (cmd, args) → call `connect_stdio` on the +/// MCP manager → push success/error toast. pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) { + debug!("handle_mcp_add: name={}, command={}", name, command); + // Split the command string into program + arguments let extra_args: Vec = command.split_whitespace().map(std::string::ToString::to_string).collect(); - let cmd = extra_args.first().cloned().unwrap_or_default(); - let args: Vec = extra_args.into_iter().skip(1).collect(); + let cmd = extra_args.first().cloned().unwrap_or_default(); // main executable + let args: Vec = extra_args.into_iter().skip(1).collect(); // remaining args match state.mcp_manager.connect_stdio(&name, &cmd, &args) { Ok(()) => { + // Read back the tool count from the newly connected server let tool_count = state .mcp_manager .servers @@ -142,14 +198,22 @@ pub(super) fn handle_mcp_add(state: &mut AppStateRest, name: String, command: St } } +/// Open the model-picker overlay and reset the selection index. pub(super) fn handle_model_list(state: &mut AppStateRest) { + debug!("handle_model_list"); state.misc.selected_index = 0; state.misc.overlay = Overlay::ModelSelector; state.dirty = true; } +/// Close the current overlay — dismisses the editor overlay specially if active. +/// +/// Flow: if the active overlay is the Editor, call `handle_editor_dismiss` to +/// finalise edits before clearing the overlay; otherwise just reset to `None`. +/// Always marks `dirty` so the TUI re-renders without the overlay. pub(super) fn handle_close_overlay(state: &mut AppStateRest) { - // If the overlay is the Editor, dismiss it properly first + debug!("handle_close_overlay"); + // Dismiss the editor with save-confirm if it is currently open if state.misc.overlay == Overlay::Editor { crate::app::mode::editor::handle_editor_dismiss(state); } @@ -157,30 +221,42 @@ pub(super) fn handle_close_overlay(state: &mut AppStateRest) { state.dirty = true; } +/// Push an informational toast with the given message. pub(super) fn handle_system_note(state: &mut AppStateRest, message: String) { + debug!("handle_system_note: {}", message); let toast = Toast::new(ToastKind::Info, message); state.push_toast(toast); } +/// Show the quit-confirmation overlay. pub(super) fn handle_quit_confirm(state: &mut AppStateRest) { state.misc.overlay = Overlay::QuitConfirm; state.dirty = true; } +/// Handle terminal resize — update the scroll max-visible width. pub(super) fn handle_resize(state: &mut AppStateRest, w: u16) { + debug!("handle_resize: width={}", w); state.scroll.set_max_visible(w as usize); state.dirty = true; } +/// Start an OAuth device-code login flow on a background thread. +/// +/// Flow: clone the turn-events queue → spawn thread → run `run_oauth_flow` → +/// push result as a `TurnEvent::SystemNote` back to the main loop. pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) { + debug!("handle_start_oauth: provider={}", provider); let turn_events = state.turn_events.clone(); let provider_clone = provider.clone(); + // Run the blocking OAuth HTTP flow off the main thread std::thread::spawn(move || { let result = run_oauth_flow(&provider_clone); let message = match result { Ok(msg) => msg, Err(e) => format!("OAuth login failed: {e}"), }; + // Push result back via the shared turn-events queue if let Ok(mut q) = turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "oauth".to_string(), @@ -196,7 +272,13 @@ pub(super) fn handle_start_oauth(state: &mut AppStateRest, provider: String) { state.dirty = true; } +/// Set the abort flag to signal the currently running LLM turn to stop. +/// +/// Flow: atomically set `abort_flag` to `true` (checked by the streaming +/// task between tool calls) → push a warning toast to inform the user. pub(super) fn handle_abort_turn(state: &mut AppStateRest) { + debug!("handle_abort_turn"); + // Signal the streaming task to stop at the next safe point state .abort_flag .store(true, std::sync::atomic::Ordering::SeqCst); @@ -206,13 +288,22 @@ pub(super) fn handle_abort_turn(state: &mut AppStateRest) { )); } +/// AI-summary compaction of the conversation history. +/// +/// Flow: resolve max-wire-tokens → extract provider config (API key, model, +/// base URL) → build an `LlmClient` → delegate to `shape_messages` which +/// summarises older messages via the LLM → compute token diff → push toast. +/// +/// Why: compaction preserves semantic context (goals, decisions, files, state) +/// instead of naively dropping messages, using the configured LLM to produce +/// a concise summary of what came before. pub(super) fn handle_compact(state: &mut AppStateRest) { + debug!("handle_compact"); + // Resolve the maximum allowed tokens from the wire window config let max_wire_tokens = window::resolve(&state.app_config, &state.settings); - // Extract config before borrowing session_runtime mutably to avoid - // borrow conflicts. An LLM client is needed for summarization so the - // compacted result preserves meaningful context (goals, decisions, - // files, state) instead of a useless static placeholder. + // ── Extract config before borrowing session_runtime mutably ──────────── + // These clones avoid borrow conflicts when we later take &mut rt below. let api_key = state .settings .api_keys @@ -227,7 +318,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) { .map(|p| p.api_base.clone()); let abort_flag = state.abort_flag.clone(); - // Build the LLM client if we have a configured base_url. + // ── Build the LLM client if a base_url is configured ─────────────────── let llm_client = base_url.map(|url| { let key = if api_key.is_empty() { state @@ -262,8 +353,10 @@ pub(super) fn handle_compact(state: &mut AppStateRest) { return; } + // ── Run compaction, capturing before/after token counts ────────────── let (before_tokens, after_tokens, msg_count) = if let Some(ref mut rt) = state.session_runtime { + // Estimate total tokens before compaction let token_estimate: usize = rt .messages .iter() @@ -272,6 +365,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) { .sum(); let before = token_estimate; + // Run the actual compaction via shaping (summarises old messages) rt.messages = crate::app::runtime::context::shaping::shape_messages( &rt.messages, token_estimate, @@ -280,6 +374,7 @@ pub(super) fn handle_compact(state: &mut AppStateRest) { llm_client.as_ref(), Some(&*abort_flag), ); + // Estimate tokens after compaction let after: usize = rt .messages .iter() @@ -307,7 +402,13 @@ pub(super) fn handle_compact(state: &mut AppStateRest) { state.dirty = true; } +/// Accept a pending lesson (learned behaviour pattern) by name. +/// +/// Flow: resolve the pending lesson with `accepted = true` → refresh lesson +/// counters → push success toast. pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) { + debug!("handle_lesson_accept: {}", name); + // Resolve the pending lesson file (writes accepted=true metadata) if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( &rt.session_dir, @@ -316,6 +417,7 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) { true, ); } + // Re-read on-disk state to update the dashboard counters if let Some(ref mut rt) = state.session_runtime { refresh_lesson_counters(&state.memory_dir, rt); } @@ -326,7 +428,13 @@ pub(super) fn handle_lesson_accept(state: &mut AppStateRest, name: String) { state.dirty = true; } +/// Reject a pending lesson by name — resolves it with `accepted = false`. +/// +/// Flow: resolve the pending lesson with `accepted = false` → refresh lesson +/// counters → push info toast. pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) { + debug!("handle_lesson_reject: {}", name); + // Resolve the pending lesson file (writes accepted=false metadata) if let Some(ref rt) = state.session_runtime { let _ = crate::app::review::resolve_pending_lesson( &rt.session_dir, @@ -335,6 +443,7 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) { false, ); } + // Re-read on-disk state to update the dashboard counters if let Some(ref mut rt) = state.session_runtime { refresh_lesson_counters(&state.memory_dir, rt); } @@ -345,8 +454,16 @@ pub(super) fn handle_lesson_reject(state: &mut AppStateRest, name: String) { state.dirty = true; } +/// Delete a previously stored lesson by name — removes the underlying +/// memory file and refreshes counters. +/// +/// Flow: delete the memory markdown file via the CMS repository → refresh +/// lesson counters from the remaining on-disk state → push info toast. pub(super) fn handle_lesson_delete(state: &mut AppStateRest, name: String) { + debug!("handle_lesson_delete: {}", name); + // Remove the memory file from disk via the CMS repository let _ = MarkdownMemoryRepository::new().delete(&state.memory_dir, &name); + // Re-read remaining on-disk state to update the dashboard counters if let Some(ref mut rt) = state.session_runtime { refresh_lesson_counters(&state.memory_dir, rt); } diff --git a/crates/zesdex-backend/src/app/runtime/actions/io.rs b/crates/zesdex-backend/src/app/runtime/actions/io.rs index 94854c8..6ec734f 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/io.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/io.rs @@ -1,5 +1,10 @@ -//! I/O helper functions: session persistence, API connectivity checks, -//! and review-available notification. +//! I/O helper functions used by action handlers: session persistence, API +//! connectivity checks, and review-available notification toasts. +//! +//! These are deliberately kept separate from `handlers.rs` to keep handler +//! bodies short and to allow these helpers to be called from multiple places. + +use tracing::debug; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; @@ -8,12 +13,16 @@ use zesdex_iam::domain::repository::SessionRepository; /// Persist the current session metadata and conversation to disk. /// -/// Flow: build a `Session` object → save its metadata → write -/// `rt.messages` as JSON to the conversation file → errors are silently -/// ignored. +/// Flow: build a `Session` object → save its metadata via +/// `FileSystemSessionRepository` → serialise `rt.messages` as JSON → +/// write to the conversation file. All errors are silently ignored so the +/// save is best-effort and non-blocking. /// -/// Why: called on `ForceQuit` so the session can be resumed later. +/// Why: called on `ForceQuit` so the session (including full message history) +/// can be resumed after a restart. pub(super) fn save_current_session(state: &AppStateRest) { + debug!("save_current_session: session_id={}", state.session_id); + // Resolve the persistent store base directory (usually ~/.local/share/zesdex/) let base = state.store_base_dir(); let session = zesdex_iam::domain::session::Session::new( state.session_id.clone(), @@ -21,8 +30,10 @@ pub(super) fn save_current_session(state: &AppStateRest) { ); let session_repo = zesdex_iam::infrastructure::persistence::session_repo::FileSystemSessionRepository::new(); + // Save session metadata record (id, type, timestamps) to the repo directory let _ = session_repo.save_session(&base, &session); if let Some(ref rt) = state.session_runtime { + // Write the full message list as JSON to the conversation file let conv_path = session.conversation_path(&base); if let Ok(data) = serde_json::to_string(&rt.messages) { let _ = std::fs::write(&conv_path, data); @@ -40,9 +51,12 @@ pub(super) fn save_current_session(state: &AppStateRest) { /// `should_trigger_review` on `Tick`), only informs the user that /// a review has material to examine. pub(super) fn maybe_trigger_review(state: &mut AppStateRest) { + debug!("maybe_trigger_review"); + // Respect the user's review-disable toggle if !state.settings.flags.review_enabled { return; } + // Only notify if there were actual edits this session let edit_count = state .session_runtime .as_ref() @@ -57,15 +71,18 @@ pub(super) fn maybe_trigger_review(state: &mut AppStateRest) { } /// Spawn a background thread that checks API reachability via a lightweight HEAD -/// request to `/models`, pushing the result as a `SystemNote` so the -/// next `Tick` handler updates `api_connected`. +/// request to `/chat/completions`, pushing the result as a `SystemNote` +/// so the next `Tick` handler updates `api_connected`. /// -/// Flow: resolve the provider's base URL → build a short-lived reqwest client -/// with 3s connect / 5s total timeout → HEAD the `/models` endpoint → push -/// a `connectivity` `SystemNote` with the result. +/// Flow: resolve the provider's base URL → build a short-lived `reqwest` client +/// with 3 s connect / 5 s total timeout → HEAD the `/chat/completions` endpoint +/// → treat HTTP 200/401/403 as "connected", anything else as "disconnected" → +/// push a `connectivity` `SystemNote` with the boolean result. /// -/// Why: runs off the event loop so a slow/timed-out network does not block the TUI. +/// Why: runs off the event loop so a slow or timed-out network does not block the TUI. pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) { + debug!("spawn_api_connectivity_check"); + // Resolve the base URL from the configured provider, falling back to default let base_url = state .app_config .providers @@ -74,13 +91,17 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) { || crate::service::provider::DEFAULT_BASE_URL.to_string(), |p| p.api_base.clone(), ); + // Clone the shared queue handle before moving into the background thread let turn_events = state.turn_events.clone(); + // Fire-and-forget: the blocking HTTP call runs on a background thread + // so a slow/timed-out network does not block the TUI event loop. std::thread::spawn(move || { + // Build the health-check URL, removing any trailing slash from the base let url = format!("{}/chat/completions", base_url.trim_end_matches('/')); let connected = match reqwest::blocking::Client::builder() - .timeout(std::time::Duration::from_secs(5)) - .connect_timeout(std::time::Duration::from_secs(3)) + .timeout(std::time::Duration::from_secs(5)) // total request timeout + .connect_timeout(std::time::Duration::from_secs(3)) // TCP connect timeout .build() { Ok(client) => match client.head(&url).send() { @@ -89,10 +110,11 @@ pub(super) fn spawn_api_connectivity_check(state: &AppStateRest) { // 401/403 means the server is reachable (just auth is wrong) s.is_success() || s.as_u16() == 401 || s.as_u16() == 403 } - Err(_) => false, + Err(_) => false, // Network error or timeout → disconnected }, - Err(_) => false, + Err(_) => false, // Client construction failed → disconnected }; + // Push result back via the shared turn-events queue for the next Tick if let Ok(mut q) = turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "connectivity".to_string(), diff --git a/crates/zesdex-backend/src/app/runtime/actions/memory.rs b/crates/zesdex-backend/src/app/runtime/actions/memory.rs index 9ee521e..3ab7e04 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/memory.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/memory.rs @@ -1,5 +1,7 @@ //! Memory / lesson-counter helpers: refresh counters from on-disk data. +use tracing::debug; + use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; @@ -18,7 +20,12 @@ pub(super) fn refresh_lesson_counters( memory_dir: &std::path::Path, rt: &mut crate::app::state::runtime::SessionRuntime, ) { + debug!("refresh_lesson_counters: dir={:?}", memory_dir); + + // Fetch all memory slugs from the directory listing let names = MarkdownMemoryRepository::new().list(memory_dir).unwrap_or_default(); + + // Reset all counters before recounting (avoid stale accumulation) rt.lesson_count = 0; rt.lessons_user = 0; rt.lessons_feedback = 0; @@ -27,22 +34,30 @@ pub(super) fn refresh_lesson_counters( rt.lessons_active = 0; rt.lessons_stale = 0; rt.lessons_contradicted = 0; + + // Iterate over every memory slug and classify it by kind + lifecycle for name in &names { if let Ok(mem) = MarkdownMemoryRepository::new().load(memory_dir, name) { rt.lesson_count += 1; + + // Classify by memory kind (user-defined, feedback, project, reference) match mem.kind.as_str() { "user" => rt.lessons_user += 1, "feedback" => rt.lessons_feedback += 1, "project" => rt.lessons_project += 1, "reference" => rt.lessons_reference += 1, - _ => {} + _ => {} // Unknown kind — skip } + + // Classify by lifecycle stage (active, stale, contradicted) match mem.lifecycle.as_str() { "active" => rt.lessons_active += 1, "stale" => rt.lessons_stale += 1, "contradicted" => rt.lessons_contradicted += 1, - _ => {} + _ => {} // Unknown lifecycle — skip } } + // If the memory file was deleted between list() and load(), + // silently skip — no error noise needed. } } diff --git a/crates/zesdex-backend/src/app/runtime/actions/mod.rs b/crates/zesdex-backend/src/app/runtime/actions/mod.rs index d182383..0add1d9 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/mod.rs @@ -2,7 +2,7 @@ //! chokepoint through which every key input, streaming event, and async //! background-thread result mutates `AppStateRest`. //! -//! Flow: controllers/subagent threads construct `Action` values → the event +//! Flow: controllers / subagent threads construct `Action` values → the event //! loop calls `apply_action(&mut state, action)` → for turn-producing //! actions (`SubmitInput`), `spawn_turn` is kicked off on a background OS //! thread which drives `run_agent_turn` (stream to the LLM, gate and @@ -15,6 +15,17 @@ //! need to know how to *produce* actions, not how to update state safely; //! running turns on plain OS threads (rather than blocking the main loop) //! keeps the TUI responsive while the LLM streams. +//! +//! Sub-modules: +//! - `handlers` — one handler function per `Action` variant (except `Tick`) +//! - `io` — I/O helpers (save transcript, trigger review) used by handlers +//! - `memory` — memory-file read/write operations +//! - `oauth` — OAuth device-code login flow +//! - `spawn` — spawning turns on background OS threads +//! - `tick` — the periodic `Tick` handler that drains `TurnEvent`s +//! - `turn` — the core agent-turn logic (LLM streaming, tool execution) + +use tracing::debug; mod handlers; mod io; @@ -27,7 +38,7 @@ mod turn; use crate::app::state::rest::AppStateRest; use crate::app::state::types::Overlay; -/// A single, well-typed event in the app — produced by key input, the +/// A single well-typed event in the app — produced by key input, the /// streaming pipeline, or subagent threads — that mutates `AppStateRest` /// when applied via `apply_action`. /// @@ -37,65 +48,101 @@ use crate::app::state::types::Overlay; /// observable and cancellable from the UI. #[derive(Debug, Clone)] pub enum Action { + /// Hard exit — immediately terminates the process. ForceQuit, + /// Submit a user message to the LLM, starting a new agent turn. SubmitInput(String), + /// Delete one character before the cursor in the input buffer. DeleteChar, + /// Delete one character after the cursor in the input buffer. DeleteCharRight, + /// Move the cursor one position left in the input buffer. CursorLeft, + /// Move the cursor one position right in the input buffer. CursorRight, + /// Navigate up through command history. HistoryUp, + /// Navigate down through command history. HistoryDown, + /// Scroll the transcript pane up. ScrollUp, + /// Scroll the transcript pane down. ScrollDown, + /// Open a named overlay (Help, Settings, Mcp, Todo, Usage, etc.). OpenOverlay(Overlay), + /// Close the currently active overlay. CloseOverlay, + /// Insert a system-generated note into the transcript. SystemNote { + /// Note category: "error", "info", "clear", "hive_mind_converged", etc. kind: String, + /// The message text to display. message: String, }, + /// Show the quit-confirmation overlay. QuitConfirm, + /// Terminal resize event — carries the new column count. Resize(u16, u16), + /// Periodic timer tick — drains queued `TurnEvent`s and runs side jobs. Tick, - + /// Accept a lesson (learned behaviour pattern) by name. LessonAccept { name: String, }, + /// Reject a lesson by name. LessonReject { name: String, }, + /// Delete a previously stored lesson by name. LessonDelete { name: String, }, + /// Start the OAuth device-code login flow for a named provider. StartOAuth { provider: String, }, + /// Open the inline file editor for `path`. OpenEditor { path: String, }, + /// Register a new MCP server by name and shell command. McpAdd { name: String, command: String, }, + /// Open the model-picker overlay. ModelList, + /// Set the abort flag on the currently running turn. AbortTurn, + /// Request AI-summary compaction of the conversation history. Compact, } /// Apply an `Action` to the application state. /// -/// Flow: pattern-match the variant → mutate `state` (input buffer, scroll -/// position, overlay, transcript, runtime, toasts, dirty flag, etc.) → -/// for `Tick`, also drain queued `TurnEvent`s and run periodic side jobs -/// (staleness sweep, pending-lesson commit). +/// Flow: pattern-match the variant → delegate to the corresponding handler +/// function in `handlers` (or `tick::handle_tick` for `Tick`) → handler +/// mutates `state` (input buffer, scroll position, overlay, transcript, +/// runtime, toasts, dirty flag, etc.). +/// +/// For `Tick`: also drains queued `TurnEvent`s from the shared queue and +/// runs periodic side jobs (staleness sweep, pending-lesson commit). /// /// Why: the single chokepoint that turns every typed key and async event /// into a state change, so callers (controllers, subagent threads) only -/// need to know how to *produce* actions. +/// need to know how to *produce* actions, not how to update state safely. /// /// Return: nothing; `state` is mutated in place. pub fn apply_action(state: &mut AppStateRest, action: Action) { + debug!("apply_action: {:?}", action); match action { + // ── Lifecycle ───────────────────────────────────────────────── Action::ForceQuit => handlers::handle_force_quit(state), + Action::QuitConfirm => handlers::handle_quit_confirm(state), + Action::Resize(w, _h) => handlers::handle_resize(state, w), + Action::Tick => tick::handle_tick(state), + + // ── Input / editing ─────────────────────────────────────────── Action::SubmitInput(text) => handlers::handle_submit_input(state, text), Action::DeleteChar => handlers::handle_delete_char(state), Action::DeleteCharRight => handlers::handle_delete_char_right(state), @@ -103,23 +150,30 @@ pub fn apply_action(state: &mut AppStateRest, action: Action) { Action::CursorRight => handlers::handle_cursor_right(state), Action::HistoryUp => handlers::handle_history_up(state), Action::HistoryDown => handlers::handle_history_down(state), + + // ── Scroll / navigation ─────────────────────────────────────── Action::ScrollUp => handlers::handle_scroll_up(state), Action::ScrollDown => handlers::handle_scroll_down(state), Action::OpenOverlay(overlay) => handlers::handle_open_overlay(state, overlay), Action::CloseOverlay => handlers::handle_close_overlay(state), - Action::SystemNote { kind: _kind, message } => handlers::handle_system_note(state, message), - Action::QuitConfirm => handlers::handle_quit_confirm(state), - Action::Resize(w, _h) => handlers::handle_resize(state, w), - Action::OpenEditor { path } => handlers::handle_open_editor(state, path), - Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command), + + // ── System / info ───────────────────────────────────────────── + Action::SystemNote { kind: _kind, message } => { + handlers::handle_system_note(state, message) + } Action::ModelList => handlers::handle_model_list(state), - Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider), Action::AbortTurn => handlers::handle_abort_turn(state), Action::Compact => handlers::handle_compact(state), + + // ── Editor / MCP / OAuth ────────────────────────────────────── + Action::OpenEditor { path } => handlers::handle_open_editor(state, path), + Action::McpAdd { name, command } => handlers::handle_mcp_add(state, name, command), + Action::StartOAuth { provider } => handlers::handle_start_oauth(state, provider), + + // ── Lessons ─────────────────────────────────────────────────── Action::LessonAccept { name } => handlers::handle_lesson_accept(state, name), Action::LessonReject { name } => handlers::handle_lesson_reject(state, name), Action::LessonDelete { name } => handlers::handle_lesson_delete(state, name), - Action::Tick => tick::handle_tick(state), } } @@ -129,24 +183,35 @@ mod tests { use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::SessionRuntime; use crate::app::state::runtime::TurnEvent; + use tracing::info; + /// Verify that a `TurnEvent::SystemNote` with `kind == "hive_mind_converged"` + /// sets the `hive_mind_converged` flag on the session runtime after `Tick`. + /// + /// Flow: create a fresh state → push a `hive_mind_converged` `TurnEvent` + /// onto the shared queue → apply `Tick` → assert the flag is now `true`. #[test] fn hive_mind_converged_system_note_sets_session_flag() { + info!("test: hive_mind_converged_system_note_sets_session_flag"); let tmp = std::env::temp_dir().join(format!("zesdex-actions-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); let mut state = AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")); state.session_runtime = Some(SessionRuntime::new(tmp.clone())); + // Verify the flag starts as false assert!(!state.session_runtime.as_ref().unwrap().hive_mind_converged); + // Push a hive_mind_converged system note onto the turn-event queue if let Ok(mut q) = state.turn_events.lock() { q.push_back(TurnEvent::SystemNote { kind: "hive_mind_converged".to_string(), message: String::new(), }); } + // Tick drains the queue and processes the note apply_action(&mut state, Action::Tick); + // Verify the flag is now set assert!(state.session_runtime.as_ref().unwrap().hive_mind_converged); std::fs::remove_dir_all(&tmp).ok(); diff --git a/crates/zesdex-backend/src/app/runtime/actions/oauth.rs b/crates/zesdex-backend/src/app/runtime/actions/oauth.rs index c531353..bcbdb9c 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/oauth.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/oauth.rs @@ -1,5 +1,7 @@ //! OAuth PKCE flow — browser-based login for API providers. +use tracing::{info, warn}; + /// Run a browser-based OAuth PKCE flow for the given provider. /// /// Flow: look up config by provider name ("zen"/"opencode", "openai", @@ -15,6 +17,7 @@ /// Return: a success message on completion, or an error if the flow fails /// at any step. pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result { + info!(provider = provider, "starting OAuth flow"); use zesdex_iam::domain::oauth::OAuthConfig; use zesdex_iam::domain::service::OAuthService; use zesdex_iam::application::oauth_service::OAuthServiceImpl; @@ -86,20 +89,22 @@ pub(super) fn run_oauth_flow(provider: &str) -> anyhow::Result { let (auth_url, state) = oauth_service.start_flow(&config, &redirect_uri)?; if auth_url.is_empty() { - tracing::warn!("[oauth] auth_url was empty for provider '{}'", provider); + warn!("OAuth auth_url was empty for provider '{}'", provider); } else if webbrowser::open(&auth_url).is_err() { - tracing::warn!( - "[oauth] could not open browser for '{}'; user must open URL manually:\n{}", + warn!( + "OAuth could not open browser for '{}'; user must open URL manually:\n{}", provider, auth_url ); } + info!(provider = provider, "waiting for OAuth redirect"); let code = server.wait_for_code(120_000, &state)?; oauth_service .complete_flow(&config, &redirect_uri, &code, &state) .map_err(|e| anyhow::anyhow!("{e}"))?; + info!(provider = provider, "OAuth flow completed"); Ok(format!("Successfully authenticated with {provider}.")) } diff --git a/crates/zesdex-backend/src/app/runtime/actions/spawn.rs b/crates/zesdex-backend/src/app/runtime/actions/spawn.rs index 7aaabfa..781ea74 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/spawn.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/spawn.rs @@ -1,8 +1,16 @@ -//! Turn-spawning logic: `spawn_turn` and the `TurnCtx` bundle passed to +//! Turn-spawning logic: `spawn_turn` and the [`TurnCtx`] bundle passed to //! the background thread that runs `run_agent_turn`. +//! +//! Flow: `spawn_turn` collects messages, config, API key, and tools from +//! `AppStateRest` → builds a [`TurnCtx`] → spawns a plain OS thread that +//! calls `run_agent_turn` → drains errors into `TurnEvent::Error`. + +use std::sync::atomic::Ordering; +use std::sync::Arc; use crate::app::state::rest::AppStateRest; use crate::app::state::runtime::TurnEvent; +use tracing::{error, info}; use super::turn::run_agent_turn; @@ -41,6 +49,7 @@ pub(super) struct TurnCtx { /// /// Return: nothing; results flow through `state.turn_events`. pub(super) fn spawn_turn(state: &AppStateRest) { + info!("spawn_turn: starting new turn"); let in_flight = if let Ok(guard) = state.turn_in_flight.lock() { *guard } else { @@ -110,14 +119,14 @@ pub(super) fn spawn_turn(state: &AppStateRest) { let in_flight_flag = state.turn_in_flight.clone(); let workspace_roots: Vec = ctx.workspaces.clone(); let abort_flag = state.abort_flag.clone(); - abort_flag.store(false, std::sync::atomic::Ordering::SeqCst); + abort_flag.store(false, Ordering::SeqCst); let hive_mind_converged = state .session_runtime .as_ref() .is_some_and(|rt| rt.hive_mind_converged); *in_flight_flag.lock().unwrap_or_else(|e| { - tracing::error!("[spawn_turn] in_flight_flag mutex poisoned: {}", e); + error!("spawn_turn: in_flight_flag mutex poisoned: {}", e); e.into_inner() }) = true; @@ -126,7 +135,7 @@ pub(super) fn spawn_turn(state: &AppStateRest) { std::thread::spawn(move || { let db = crate::model::msglog::open_or_create(&edit_session_dir) .ok() - .map(|c| std::sync::Arc::new(std::sync::Mutex::new(c))); + .map(|c| Arc::new(std::sync::Mutex::new(c))); let tc = TurnCtx { client: crate::service::provider::LlmClient::new(api_key, model.clone(), base_url), tdefs: tool_defs, @@ -145,10 +154,12 @@ pub(super) fn spawn_turn(state: &AppStateRest) { }; let result = run_agent_turn(&tc, &messages, &events_q); if let Err(e) = result { + info!("spawn_turn: turn returned error: {}", e); if let Ok(mut q) = events_q.lock() { q.push_back(TurnEvent::Error(e.to_string())); } } + info!("spawn_turn: turn completed"); if let Ok(mut flag) = in_flight_flag.lock() { *flag = false; } diff --git a/crates/zesdex-backend/src/app/runtime/actions/tick.rs b/crates/zesdex-backend/src/app/runtime/actions/tick.rs index 83cbe84..8efa80f 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/tick.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/tick.rs @@ -1,6 +1,14 @@ //! Tick-action handler: drain turn events, LSP provision messages, //! API connectivity checks, staleness sweep, pending lessons, and //! todo.md polling. +//! +//! Flow: `handle_tick` is called from the event loop on each cycle. +//! It drains the `turn_events` queue (driving the transcript cache and +//! session runtime), drains `lsp_provision_msgs` into toasts, runs +//! background maintenance (todo.md poll, API connectivity, staleness +//! sweep, lessons), and flags `state.dirty` when something changed. + +use tracing::debug; use crate::app::review::{should_trigger_review, trigger_review}; use crate::app::state::rest::AppStateRest; @@ -14,11 +22,24 @@ use super::turn::HIVE_MIND_KICKOFF_NOTE; /// Handle `Action::Tick` — the periodic event that drains async results /// and runs background maintenance tasks. +/// +/// Flow: +/// 1. Bump tick counter, drain expired toasts +/// 2. Every 10 ticks: poll `todo.md` for external changes +/// 3. Every N ticks: `spawn_api_connectivity_check` (N=20 when disconnected, 600 when connected) +/// 4. Run staleness sweep and process pending lessons +/// 5. Drain `lsp_provision_msgs` into toasts +/// 6. Drain `turn_events` queue, dispatching each variant to state mutation +/// 7. If turn finished, trigger optional review pub(super) fn handle_tick(state: &mut AppStateRest) { state.misc.tick_count = state.misc.tick_count.wrapping_add(1); + let tick = state.misc.tick_count; + debug!(tick = tick, "handle_tick"); let now_ms = chrono::Utc::now().timestamp_millis(); + // Remove expired toasts from the display stack. state.misc.drain_expired_toasts(now_ms); + // Poll todo.md every 10 ticks (~1 s) for external edits. if state.misc.tick_count.is_multiple_of(10) { let todo_path = state.session_dir.join("todo.md"); if let Ok(content) = std::fs::read_to_string(&todo_path) { @@ -35,6 +56,7 @@ pub(super) fn handle_tick(state: &mut AppStateRest) { // Background API connectivity check — runs on a background thread // every ~1s while disconnected, every ~30s while connected, so the // status bar reflects real API availability without user input. + // Poll interval: every ~2 s when disconnected (20 ticks), ~60 s when connected (600 ticks). let check_interval = if state.misc.api_connected { 600 } else { 20 }; if state.misc.tick_count.is_multiple_of(check_interval) { spawn_api_connectivity_check(state); @@ -68,6 +90,9 @@ pub(super) fn handle_tick(state: &mut AppStateRest) { state.push_toast(Toast::new(kind, msg.clone())); } + // Drain the background-thread turn events queue. Each variant maps to + // state mutations — transcript cache updates, session runtime messages, + // toast notifications, and workflow engine agent roster changes. let events: Vec = { if let Ok(mut q) = state.turn_events.lock() { q.drain(..).collect() @@ -370,9 +395,11 @@ pub(super) fn handle_tick(state: &mut AppStateRest) { } } } + // If a turn just completed, trigger the optional inline review flow. if turn_finished { maybe_trigger_review(state); } + // Ensure state is marked dirty if anything changed this cycle. if turn_finished || state.dirty { state.dirty = true; } diff --git a/crates/zesdex-backend/src/app/runtime/actions/turn.rs b/crates/zesdex-backend/src/app/runtime/actions/turn.rs index ed00187..90b63a5 100644 --- a/crates/zesdex-backend/src/app/runtime/actions/turn.rs +++ b/crates/zesdex-backend/src/app/runtime/actions/turn.rs @@ -314,6 +314,11 @@ pub(super) fn run_agent_turn( let mut todo_retry_count = 0usize; + tracing::debug!( + "[turn] entering main agent loop — max todo retries: {}", + MAX_TODO_RETRIES, + ); + loop { let token_estimate: usize = msgs .iter() @@ -493,6 +498,9 @@ pub(super) fn run_agent_turn( archive_message(tc.db.as_ref(), &tc.session_id, &response); msgs.push(response); let mut results_vec = Vec::new(); + // Execute all tool calls in parallel using std::thread::scope, + // which guarantees all spawned threads complete before the + // closure returns — no manual join needed. std::thread::scope(|s| { let mut handles = Vec::new(); let tc_ref = tc; @@ -725,6 +733,11 @@ pub(super) fn run_agent_turn( } } + tracing::debug!( + "[turn] agent turn completed — total edits this turn: {}", + total_edits_this_turn.as_ref().map_or(0, |(c, _, _)| *c), + ); + push_event(&events_q, TurnEvent::Done); Ok(()) diff --git a/crates/zesdex-backend/src/app/runtime/context/dedup.rs b/crates/zesdex-backend/src/app/runtime/context/dedup.rs index d355946..28c1e3f 100644 --- a/crates/zesdex-backend/src/app/runtime/context/dedup.rs +++ b/crates/zesdex-backend/src/app/runtime/context/dedup.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] //! Cross-call tool-result deduplication: when a read-only tool is called //! again with identical arguments, the earlier result is replaced with a //! placeholder so only the latest copy occupies context. @@ -19,6 +18,7 @@ use crate::app::subagent::division::tool_scope::READ_TOOLS; use crate::dto::chat::message::{ChatMessage, Role}; use sha2::Digest; use std::collections::HashMap; +use tracing; const DUPLICATE_PLACEHOLDER: &str = "[duplicate result — superseded by a later identical call, see below]"; @@ -29,7 +29,15 @@ const DUPLICATE_PLACEHOLDER: &str = /// `true` iff at least one entry was replaced. The caller uses the /// `bool` to decide whether the result is worth persisting/announcing, /// without `ChatMessage` needing to implement `PartialEq`. +/// +/// # Status +/// +/// This function is defined but not yet wired into the compaction loop; +/// it will be called from the per-turn auto-compaction pass once the +/// shaping integration is complete. +#[expect(dead_code, reason = "will be wired into the compaction loop")] pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) { + tracing::debug!(n_messages = messages.len(), "dedup::collapse — start"); // tool_call_id -> (tool name, canonical JSON of its arguments) let mut call_info: HashMap = HashMap::new(); for m in messages { @@ -84,6 +92,7 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec, bool) { }) .collect(); + tracing::debug!(changed, "dedup::collapse — done"); (result, changed) } @@ -101,6 +110,9 @@ fn dedup_key(tool_name: &str, canonical_args: &str) -> String { #[cfg(test)] mod tests { + //! Unit tests for tool-result dedup: identical read-tool calls are + //! collapsed, different args / mutating tools are left untouched, + //! and orphaned tool results pass through unchanged. use super::*; use crate::dto::chat::message::ChatMessage; use crate::dto::chat::tool::{ToolCall, ToolFunction}; diff --git a/crates/zesdex-backend/src/app/runtime/context/mod.rs b/crates/zesdex-backend/src/app/runtime/context/mod.rs index e548da0..bdeb4d7 100644 --- a/crates/zesdex-backend/src/app/runtime/context/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/context/mod.rs @@ -2,6 +2,18 @@ //! per-result compression, budget-based shaping, and shared //! context-window resolution — replaces `runtime::shortsend`. //! +//! # Sub-modules +//! +//! | Module | Responsibility | +//! |------------|----------------------------------------------------------| +//! | `dedup` | Cross-call deduplication of repeated tool results | +//! | `shaping` | Budget-based message shaping within the context window | +//! | `squash` | Per-result compression (summarisation / truncation) | +//! | `tokens` | Token counting and estimation | +//! | `window` | Resolve the active model's context-window size | +//! +//! # Call-sites +//! //! No facade function here: `dedup`, `shaping`, and `tokens` are called //! directly from each call site (the per-turn auto-compaction loop in //! `actions::run_agent_turn`, and `Action::Compact`), matching this diff --git a/crates/zesdex-backend/src/app/runtime/context/shaping.rs b/crates/zesdex-backend/src/app/runtime/context/shaping.rs index a5f8d83..f1ab5c2 100644 --- a/crates/zesdex-backend/src/app/runtime/context/shaping.rs +++ b/crates/zesdex-backend/src/app/runtime/context/shaping.rs @@ -68,6 +68,10 @@ const FORCE_KEEP_MAX: usize = 15; const SUMMARY_PREFIX: &str = "[Summary of compacted prior conversation:"; /// Detect whether a message contains a previous compaction summary. +/// +/// Flow: check if message `content` starts with [`SUMMARY_PREFIX`]. +/// Used to filter out old summaries from the "dropped" set so they +/// are handled by progressive summarization instead. fn msg_has_prior_summary(m: &ChatMessage) -> bool { m.content .as_deref() @@ -77,6 +81,13 @@ fn msg_has_prior_summary(m: &ChatMessage) -> bool { /// Format dropped messages for the summarization prompt, excluding any /// messages that are themselves previous summaries (those are handled /// separately by progressive summarization). +/// +/// Flow: filter out prior-summary messages → for each remaining message, +/// render a `[Role]: content` line with optional tool-call list appended. +/// Join entries with `\n\n---\n\n` as separator. +/// +/// Return: a single string suitable as the `### New messages to merge` +/// section of the summarization prompt. fn format_dropped_messages(dropped: &[ChatMessage]) -> String { dropped .iter() @@ -106,6 +117,13 @@ fn format_dropped_messages(dropped: &[ChatMessage]) -> String { } /// Extract the content of a previous compaction summary from a message. +/// +/// Flow: check if `content` starts with [`SUMMARY_PREFIX`] → strip prefix +/// and trailing `]` → return inner text. Returns `None` if the message +/// is not a prior-summary message. +/// +/// Why: progressive summarization needs the old summary text so the LLM +/// can merge it with new context instead of starting from scratch. fn extract_prior_summary(m: &ChatMessage) -> Option { let content = m.content.as_deref()?; if content.starts_with(SUMMARY_PREFIX) { @@ -124,6 +142,13 @@ fn extract_prior_summary(m: &ChatMessage) -> Option { /// Build the summarization prompt, supporting progressive compaction: /// if the dropped messages contain a previous summary, it is extracted /// and the new prompt asks the LLM to build on it. +/// +/// Flow: search dropped messages for a prior summary via `extract_prior_summary`. +/// If found, emit a "build on this" prompt with the previous summary + new +/// content. Otherwise emit a plain "summarize this history" prompt. +/// In both cases the prompt requests a structured 5-section summary. +/// +/// Return: a fully-formed user-style prompt string ready to send to the LLM. fn build_summarization_prompt( dropped_msgs: &[ChatMessage], dropped_content: &str, @@ -174,6 +199,14 @@ fn build_summarization_prompt( /// `[prior conversation compacted]` placeholder — it tells the LLM how /// many messages of each role were dropped and what tools were used, /// preserving key structural context. +/// +/// Flow: count messages by role → collect unique tool names → extract +/// the last user message as a hint → format as: +/// `[prior conversation: N user, M assistant, ... | tools used: ... | last request: ...]` +/// +/// Why: a static placeholder provides zero useful context. Even without +/// AI summarization, structural metadata helps the LLM understand what +/// was lost. fn make_structural_summary(dropped: &[ChatMessage]) -> String { use std::fmt::Write; @@ -244,11 +277,22 @@ pub fn shape_messages( client: Option<&crate::service::provider::LlmClient>, abort_flag: Option<&AtomicBool>, ) -> Vec { + tracing::debug!( + n_messages = messages.len(), + token_count, + max_wire_tokens, + force, + has_client = client.is_some(), + "shape_messages — entry" + ); + if !force && (token_count <= max_wire_tokens || messages.len() < 5) { + tracing::debug!("shape_messages — under budget or too few messages, no-op"); return messages.to_vec(); } if force && messages.len() < 5 { + tracing::debug!("shape_messages — force but fewer than 5 messages, no-op"); return messages.to_vec(); } @@ -352,10 +396,12 @@ pub fn shape_messages( } } } else { + tracing::debug!("shape_messages — summarization aborted by user, using structural summary"); make_structural_summary(&dropped_msgs) } } else { // No LLM client available (tests / edge case with no provider). + tracing::debug!("shape_messages — no LLM client, using structural summary"); make_structural_summary(&dropped_msgs) }; @@ -363,11 +409,19 @@ pub fn shape_messages( } result.extend(keep_recent.into_iter().rev()); + + tracing::debug!( + result_len = result.len(), + dropped = dropped_msgs.len(), + "shape_messages — done" + ); result } #[cfg(test)] mod tests { + //! Unit tests for message shaping: threshold hysteresis, system-message + //! preservation, structural-summary fallback, and most-recent survival. use super::*; use crate::dto::chat::message::ChatMessage; diff --git a/crates/zesdex-backend/src/app/runtime/context/squash.rs b/crates/zesdex-backend/src/app/runtime/context/squash.rs index aa44c4e..39f5ee1 100644 --- a/crates/zesdex-backend/src/app/runtime/context/squash.rs +++ b/crates/zesdex-backend/src/app/runtime/context/squash.rs @@ -1,4 +1,3 @@ -#![allow(dead_code)] //! Per-tool-result compression: shrink large tool outputs before they //! ever enter conversation history, dispatching by content shape. //! @@ -12,6 +11,7 @@ //! overall budget. use std::collections::HashSet; use std::fmt::Write; +use tracing; /// Below this size, compression isn't worth the risk of losing detail — /// pass the output through unchanged. @@ -46,16 +46,28 @@ const LOG_SHAPED_TOOLS: &[&str] = &["bash"]; /// Return: `output` unchanged if `tool_name` is in `NEVER_SQUASH` or at /// or under `SQUASH_FLOOR_BYTES`; otherwise the compressed form from /// whichever detector matches its content shape. +/// +/// # Status +/// +/// Defined but not yet wired into the tool-execution pipeline; will be +/// called from `tool::shell` and MCP result handlers once integration +/// is complete. +#[expect(dead_code, reason = "will be wired into the tool-execution pipeline")] pub fn apply(tool_name: &str, output: &str) -> String { + tracing::trace!(tool_name, output_len = output.len(), "squash::apply — start"); if NEVER_SQUASH.contains(&tool_name) || output.len() <= SQUASH_FLOOR_BYTES { + tracing::trace!(tool_name, "squash::apply — passthrough (never-squash tool or under floor)"); return output.to_string(); } if serde_json::from_str::(output).is_ok() { + tracing::trace!(tool_name, "squash::apply — routing to squash_json"); return squash_json(output); } if LOG_SHAPED_TOOLS.contains(&tool_name) && looks_log_shaped(output) { + tracing::trace!(tool_name, "squash::apply — routing to squash_log"); return squash_log(output); } + tracing::trace!(tool_name, "squash::apply — routing to squash_generic"); squash_generic(output, GENERIC_BUDGET_BYTES) } @@ -287,6 +299,16 @@ fn squash_generic(text: &str, budget: usize) -> String { /// Render a subset of `lines` in order, inserting a `[N lines omitted]` /// marker at every gap between kept lines. +/// +/// Flow: sort kept indices → iterate; for each kept line, if a gap +/// exists before it write `[N lines omitted]`, then write the line. +/// After all kept lines, write a final omission marker if lines remain. +/// +/// Why `[N lines omitted]` instead of a comment-shaped marker: the +/// `rtk` project's own regression tests found that comment shapes get +/// parsed by the LLM as code and trigger a retry loop. +/// +/// Return: rendered string with kept lines in original order. fn render_kept_lines(lines: &[&str], keep: &HashSet) -> String { let mut kept_sorted: Vec = keep.iter().copied().collect(); kept_sorted.sort_unstable(); @@ -309,6 +331,9 @@ fn render_kept_lines(lines: &[&str], keep: &HashSet) -> String { #[cfg(test)] mod tests { + //! Unit tests for tool-result squashing: floor threshold, read-tool + //! exemption, JSON structure preservation, log compression, and + //! generic truncation with head/tail retention. use super::*; #[test] diff --git a/crates/zesdex-backend/src/app/runtime/context/tokens.rs b/crates/zesdex-backend/src/app/runtime/context/tokens.rs index ec27970..56db5b5 100644 --- a/crates/zesdex-backend/src/app/runtime/context/tokens.rs +++ b/crates/zesdex-backend/src/app/runtime/context/tokens.rs @@ -11,6 +11,7 @@ //! closer than a flat byte-per-token guess; it's only used for the //! 85%/95% budget thresholds, not for billing-accurate counts. +use tracing; /// Count tokens in a single string under `o200k_base`. /// @@ -20,17 +21,23 @@ /// (e.g. literal text `<|endoftext|>` pasted by a user) must be counted /// as ordinary text, not interpreted as a control token. pub fn count_tokens(text: &str) -> usize { - tiktoken_rs::o200k_base_singleton() + let count = tiktoken_rs::o200k_base_singleton() .encode_ordinary(text) - .len() + .len(); + tracing::trace!(len = text.len(), count, "count_tokens"); + count } #[cfg(test)] mod tests { + //! Unit tests for token counting: empty strings, known phrases, code, + //! and ChatMessage content extraction. use super::*; use crate::dto::chat::message::ChatMessage; /// Count tokens in a `ChatMessage`'s text content. + /// + /// Returns 0 when the message has no content (None). fn count_message_tokens(msg: &ChatMessage) -> usize { msg.content.as_deref().map_or(0, count_tokens) } diff --git a/crates/zesdex-backend/src/app/runtime/context/window.rs b/crates/zesdex-backend/src/app/runtime/context/window.rs index c93af52..c253782 100644 --- a/crates/zesdex-backend/src/app/runtime/context/window.rs +++ b/crates/zesdex-backend/src/app/runtime/context/window.rs @@ -1,10 +1,10 @@ -#![allow(dead_code)] //! Single source of truth for resolving the active model's context //! window size, replacing three copies of the same lookup that had //! drifted (`Action::Compact`, `spawn_turn`, and `view/status.rs` each //! had their own inline version — the status bar's copy additionally //! displayed "?" on no match instead of falling back like the other two, //! an inconsistency this unifies away). +use tracing::debug; use zesdex_cms::domain::app_config::AppConfig; use zesdex_cms::domain::settings::Settings; @@ -15,14 +15,31 @@ use zesdex_cms::domain::settings::Settings; /// `settings` -> use its `context_window` if set -> otherwise fall back /// to `app_config.default_context_window`. /// +/// # Tracing +/// Outputs a `tracing::debug!` event with the resolved token count and +/// matching role name (or "fallback") at each call site. +/// /// Return: always a concrete token count, never "unknown". pub fn resolve(app_config: &AppConfig, settings: &Settings) -> usize { - app_config - .model_roles - .values() - .find(|role| role.provider == settings.provider && role.model == settings.model) + // Search model roles for one matching the active provider + model pair + let matched = app_config.model_roles.values().find(|role| { + role.provider == settings.provider && role.model == settings.model + }); + + // Use the role's explicit context_window, or fall back to the default + let tokens: usize = matched .and_then(|role| role.context_window) - .unwrap_or(app_config.default_context_window) as usize + .unwrap_or(app_config.default_context_window) as usize; + + debug!( + provider = %settings.provider, + model = %settings.model, + tokens, + source = if matched.is_some() { "model_role" } else { "default_fallback" }, + "resolved context-window size", + ); + + tokens } #[cfg(test)] diff --git a/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs b/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs index 1f60254..4d32041 100644 --- a/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/event_loop/mod.rs @@ -1,9 +1,15 @@ //! Adaptive poll-rate event loop: polls faster for IDLE_THRESHOLD_MS //! after any activity, then slows down to conserve CPU. +//! +//! Flow: `mark_active()` sets a fast-poll deadline; `poll_interval()` +//! checks if the deadline is still in the future and returns either +//! `FAST_POLL_MS` (8 ms) or `SLOW_POLL_MS` (100 ms). `is_idle()` reports +//! whether the deadline has expired. use std::collections::VecDeque; use std::time::{Duration, Instant}; use crate::app::state::runtime::TurnEvent; +use tracing; const FAST_POLL_MS: u64 = 8; const SLOW_POLL_MS: u64 = 100; @@ -40,9 +46,17 @@ impl EventLoop { /// Mark the current time as the last activity and arm the fast-poll /// window for the next `IDLE_THRESHOLD_MS`. + /// + /// Called by the event loop whenever a TurnEvent arrives, keeping the + /// UI responsive during bursts of activity. pub fn mark_active(&mut self) { self.last_activity = Instant::now(); - self.fast_poll_until = Some(Instant::now() + Duration::from_millis(IDLE_THRESHOLD_MS)); + let deadline = Duration::from_millis(IDLE_THRESHOLD_MS); + self.fast_poll_until = Some(Instant::now() + deadline); + tracing::debug!( + "[event-loop] marked active — fast-poll armed for next {}ms", + IDLE_THRESHOLD_MS, + ); } /// Return `true` if the app has been idle for more than `IDLE_THRESHOLD_MS`. @@ -52,11 +66,24 @@ impl EventLoop { /// Drain all pending `TurnEvent`s from the shared mutex queue. /// + /// Flow: acquire the mutex lock → drain the VecDeque into a Vec → release. + /// Returns an empty Vec if the lock is poisoned. + /// /// Return: a `Vec` of all events that were in the queue (may be empty). pub fn drain_events( events: &std::sync::Mutex>, ) -> Vec { - events.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default() + let drained: Vec = events + .lock() + .map(|mut q| q.drain(..).collect()) + .unwrap_or_default(); + if !drained.is_empty() { + tracing::debug!( + "[event-loop] drained {} event(s)", + drained.len(), + ); + } + drained } } diff --git a/crates/zesdex-backend/src/app/runtime/mod.rs b/crates/zesdex-backend/src/app/runtime/mod.rs index 977b402..3bf8a5b 100644 --- a/crates/zesdex-backend/src/app/runtime/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/mod.rs @@ -3,6 +3,7 @@ use std::collections::VecDeque; use std::sync::{Arc, Mutex}; +use tracing; use super::state::runtime::TurnEvent; @@ -17,10 +18,12 @@ pub mod stream; /// errors inline. Used by the 20+ locations in `actions/turn.rs` that push /// events and want to skip the boilerplate. pub fn push_event( - q: &Arc>>, - event: TurnEvent, + q: &Arc>>, // shared turn-event queue (locked on access) + event: TurnEvent, // event to enqueue ) { + tracing::debug!("pushing turn event"); + // Silently ignores a poisoned mutex so callers never have to handle lock errors if let Ok(mut guard) = q.lock() { - guard.push_back(event); + guard.push_back(event); // enqueue at the back for FIFO processing } } diff --git a/crates/zesdex-backend/src/app/runtime/stream/json_repair.rs b/crates/zesdex-backend/src/app/runtime/stream/json_repair.rs index 69b2f8d..5ec5eb9 100644 --- a/crates/zesdex-backend/src/app/runtime/stream/json_repair.rs +++ b/crates/zesdex-backend/src/app/runtime/stream/json_repair.rs @@ -5,9 +5,17 @@ //! we want tools to receive whatever arguments were already emitted so the //! partial work can proceed. //! +//! How: a single left-to-right scan pushes opening brackets/braces onto a +//! stack and pops them on matching closes, while tracking in-string/escape +//! state. At EOF, the algorithm: +//! 1. Removes a dangling escape backslash if present. +//! 2. Closes an unterminated string. +//! 3. Closes every unclosed bracket/brace in reverse (LIFO) order. +//! //! Why LIFO vs. depth counters: `{` inside `[` must be closed with `}` //! *before* the `]`, not after it. Simple depth counters get the order //! wrong for nested heterogenous structures. +use tracing; /// Try to repair truncated JSON by closing open strings, braces, and brackets. /// @@ -17,17 +25,24 @@ /// At the end, if the last char was a backslash (start of an escape /// sequence), remove it; if inside a string, append `"`; then close /// every unclosed opener in reverse (LIFO) order. +/// +/// Return: the input string with any missing closing delimiters appended. +/// If the input is already valid JSON, it is returned unchanged. pub fn repair_incomplete_json(s: &str) -> String { + let original_len = s.len(); + tracing::trace!(original_len, input_preview = &s[..original_len.min(80)], "repair_incomplete_json — start"); + + // LIFO stack of open brackets/braces encountered outside strings. let mut stack: Vec = Vec::new(); - let mut in_string = false; + let mut in_string = false; // true between unescaped `"` let mut prev_was_backslash = false; - // `true` only when the very last character consumed was a bare `\` + // True only when the very last character consumed was a bare `\` // inside a string (i.e. the start of an escape that was never completed). let mut ends_with_unclosed_escape = false; for c in s.chars() { if prev_was_backslash { - // Consume the character that was being escaped — the escape is + // This character is being escaped — the escape sequence is // complete, so clear the unclosed-escape flag. prev_was_backslash = false; ends_with_unclosed_escape = false; @@ -44,26 +59,33 @@ pub fn repair_incomplete_json(s: &str) -> String { continue; } if in_string { - continue; + continue; // skip structural chars inside a string } match c { '{' | '[' => stack.push(c), '}' | ']' => { + // Pop the matching opener unconditionally. If the JSON is + // malformed (e.g. mismatched brackets), we still pop to keep + // the LIFO tracking as lossy — the repair phase will close + // whatever remains on the stack, which is good enough for + // our heuristic use case. stack.pop(); } _ => {} } } + // --- Build repaired output --- let mut result = s.to_string(); if ends_with_unclosed_escape { - // The last character is a dangling backslash that started an escape + // The last char is a dangling backslash that started an escape // but got cut off before the escaped char — remove it. result.pop(); } if in_string { - result.push('"'); + result.push('"'); // close an unterminated string } + // Close every unclosed bracket/brace in reverse (LIFO) order. for &opener in stack.iter().rev() { match opener { '{' => result.push('}'), @@ -71,11 +93,21 @@ pub fn repair_incomplete_json(s: &str) -> String { _ => {} } } + + let repaired_len = result.len(); + tracing::debug!( + original_len, + repaired_len, + added_chars = (repaired_len - original_len), + "repair_incomplete_json — completed" + ); result } #[cfg(test)] mod tests { + //! Unit tests for JSON repair: unclosed strings, unclosed braces, + //! nested structures, trailing backslashes, and escaped quotes. use super::*; #[test] diff --git a/crates/zesdex-backend/src/app/runtime/stream/mod.rs b/crates/zesdex-backend/src/app/runtime/stream/mod.rs index d85d224..636afa9 100644 --- a/crates/zesdex-backend/src/app/runtime/stream/mod.rs +++ b/crates/zesdex-backend/src/app/runtime/stream/mod.rs @@ -1,6 +1,15 @@ //! SSE stream parser: converts SSE- or JSON-chunked LLM responses into //! typed `StreamEvent` variants (tokens, reasoning, tool calls, usage, done). + +/// JSON-repair utilities for malformed streaming fragments (truncated JSON, +/// missing brackets, escaped newlines inside strings). pub mod json_repair; + +/// Turn-level streaming state machine: manages buffering, SSE parsing, +/// tool-call accumulation, and per-chunk event dispatch. pub mod turn; +/// Re-export from `zesdex-entities` for convenience: +/// - `SseParser` — low-level SSE line/event parser +/// - `StreamEvent` — typed event variants yielded by the parser pub use zesdex_entities::{SseParser, StreamEvent}; diff --git a/crates/zesdex-backend/src/app/runtime/stream/turn.rs b/crates/zesdex-backend/src/app/runtime/stream/turn.rs index d14d6c8..6c29284 100644 --- a/crates/zesdex-backend/src/app/runtime/stream/turn.rs +++ b/crates/zesdex-backend/src/app/runtime/stream/turn.rs @@ -1,12 +1,20 @@ //! Accumulates streaming LLM responses into complete message/tool-call //! representation via `StreamedTurn`, and provides a standalone tool-call //! accumulator in `tools::ToolCallAccumulator`. +//! +//! Flow: the caller feeds [`StreamEvent`] items (from `SseParser`) one by +//! one into [`StreamedTurn::apply_event`], which builds up content, reasoning, +//! and tool-call deltas incrementally. When the stream ends, call +//! [`StreamedTurn::build_assistant_message`] to produce a complete +//! `ChatMessage`. If the connection drops before `[DONE]`, +//! [`StreamedTurn::incomplete_tool_call`] detects truncated tool-call JSON. use super::json_repair::repair_incomplete_json; use super::StreamEvent; use crate::dto::chat::message::ChatMessage; use crate::dto::chat::tool::{ToolCall, ToolFunction}; use serde::{Deserialize, Serialize}; use serde_json::Value; +use tracing; /// Accumulates a single streaming assistant turn into its final /// `ChatMessage` form, including tool-call deltas and content/reasoning. @@ -29,33 +37,46 @@ pub struct ParsedToolCall { pub is_complete: bool, } -impl ParsedToolCall {} +impl ParsedToolCall { + // Placeholder for future convenience constructors or helpers. + // Today all field mutation happens inside `StreamedTurn::apply_event`; + // this block exists to reserve the namespace. +} impl StreamedTurn { /// Create an empty turn accumulator. + /// + /// All fields start at their default (empty / false) state. The caller + /// then feeds [`StreamEvent`] items via [`apply_event`](Self::apply_event). pub fn new() -> Self { + tracing::debug!("StreamedTurn::new — initialised empty accumulator"); StreamedTurn { messages: Vec::new(), tool_calls: Vec::new(), - is_complete: false, - done_received: false, - accumulated_content: String::new(), - accumulated_reasoning: String::new(), + is_complete: false, // set to true when [DONE] is received + done_received: false, // tracks whether a Done event was seen + accumulated_content: String::new(), // text tokens, growing + accumulated_reasoning: String::new(), // reasoning tokens, growing } } - /// Apply a `StreamEvent` to the turn, updating accumulated content, - /// reasoning, and tool-call deltas. + /// Apply a single `StreamEvent` to the in-progress accumulation. /// /// Flow: match on variant — `Token` appends to `accumulated_content`, /// `Reasoning` to `accumulated_reasoning`, `ToolCallDelta` fills or /// grows the `tool_calls` vector, `Done` sets `is_complete = true`. + /// + /// Any event variant not explicitly handled here (e.g. `Usage`) is + /// silently ignored, since only content/reasoning/tool-call state + /// is relevant for final message construction. pub fn apply_event(&mut self, event: &StreamEvent) { match event { StreamEvent::Token(token) => { + tracing::trace!(len = token.len(), "apply_event: Token"); self.accumulated_content.push_str(token); } StreamEvent::Reasoning(reasoning) => { + tracing::trace!(len = reasoning.len(), "apply_event: Reasoning"); self.accumulated_reasoning.push_str(reasoning); } StreamEvent::ToolCallDelta { @@ -64,6 +85,11 @@ impl StreamedTurn { name, arguments_delta, } => { + tracing::trace!( + index, id, name, delta_len = arguments_delta.len(), + "apply_event: ToolCallDelta" + ); + // Pad the tool_calls vector with stubs so we can index by `index`. while self.tool_calls.len() <= *index { self.tool_calls.push(ParsedToolCall { id: String::new(), @@ -73,6 +99,8 @@ impl StreamedTurn { }); } let tc = &mut self.tool_calls[*index]; + // `id` and `name` are typically sent only on the first delta; + // subsequent deltas for the same index may omit them. if let Some(new_id) = id { if !new_id.is_empty() { tc.id.clone_from(new_id); @@ -83,12 +111,16 @@ impl StreamedTurn { tc.name.clone_from(new_name); } } + // Accumulate argument JSON fragment-by-fragment. tc.arguments.push_str(arguments_delta); } StreamEvent::Done => { + tracing::debug!("apply_event: Done — turn marked complete"); self.is_complete = true; } - _ => {} + _ => { + tracing::trace!("apply_event: ignored {:?}", event); + } } } @@ -101,42 +133,57 @@ impl StreamedTurn { /// /// Return: a complete `ChatMessage` with role `Assistant`. pub fn build_assistant_message(&self) -> ChatMessage { + tracing::debug!( + tool_calls = self.tool_calls.len(), + content_len = self.accumulated_content.len(), + reasoning_len = self.accumulated_reasoning.len(), + "build_assistant_message — assembling final ChatMessage" + ); + + // Build the assistant message, with or without tool calls. let mut msg = if self.tool_calls.is_empty() { + // Plain text-only response — no tool calls to attach. ChatMessage::assistant(None) } else { + // Convert ParsedToolCall → DTO ToolCall, repairing truncated JSON. let tool_dtos: Vec = self .tool_calls .iter() - .filter(|tc| !tc.name.is_empty()) + .filter(|tc| !tc.name.is_empty()) // skip unnamed stubs .map(|tc| { - let args_value: serde_json::Value = match serde_json::from_str(&tc.arguments) { - Ok(v) => v, - Err(e) => { - let repaired = repair_incomplete_json(&tc.arguments); - match serde_json::from_str(&repaired) { - Ok(v) => { - tracing::warn!( - "[stream] tool call '{}' had truncated JSON \ - arguments — repaired successfully: {}", - tc.name, - e, - ); - v - } - Err(e2) => { - tracing::warn!( - "[stream] tool call '{}' has invalid JSON \ - arguments: {} (after repair: {}) — falling \ - back to raw string", - tc.name, - e, - e2, - ); - serde_json::Value::String(tc.arguments.clone()) + // Try to parse arguments as JSON. If the stream was cut + // short, the last tool call's arguments may be truncated. + let args_value: serde_json::Value = + match serde_json::from_str(&tc.arguments) { + Ok(v) => v, + Err(e) => { + let repaired = repair_incomplete_json(&tc.arguments); + match serde_json::from_str(&repaired) { + Ok(v) => { + tracing::warn!( + "[stream] tool call '{}' had truncated JSON \ + arguments — repaired successfully: {}", + tc.name, + e, + ); + v + } + Err(e2) => { + tracing::warn!( + "[stream] tool call '{}' has invalid JSON \ + arguments: {} (after repair: {}) — falling \ + back to raw string", + tc.name, + e, + e2, + ); + // Last resort: store the raw string so the + // tool dispatcher can surface the error. + serde_json::Value::String(tc.arguments.clone()) + } } } - } - }; + }; ToolCall { id: tc.id.clone(), type_: "function".to_string(), @@ -153,6 +200,8 @@ impl StreamedTurn { } msg }; + + // Combine reasoning (inside tags) with visible content. let full_content = if self.accumulated_reasoning.is_empty() { self.accumulated_content.clone() } else { @@ -161,12 +210,15 @@ impl StreamedTurn { self.accumulated_reasoning, self.accumulated_content ) }; - let content = if full_content.is_empty() { + + // Set content to None when empty so downstream code can distinguish + // "no content" from "empty string". + msg.content = if full_content.is_empty() { + tracing::debug!("build_assistant_message — no content after assembly; setting content=None"); None } else { Some(full_content) }; - msg.content = content; msg } @@ -183,25 +235,43 @@ impl StreamedTurn { /// Return: `Some((name, parse_error))` for the first bad tool call, or /// `None` if every tool call's arguments are complete, parsable JSON. pub fn incomplete_tool_call(&self) -> Option<(&str, String)> { - self.tool_calls + // Skip unnamed stubs — they indicate the stream never sent enough + // data to begin a real tool call at that index. + let result = self + .tool_calls .iter() .filter(|tc| !tc.name.is_empty()) .find_map(|tc| { serde_json::from_str::(&tc.arguments) .err() .map(|e| (tc.name.as_str(), e.to_string())) - }) + }); + + if let Some((name, ref err)) = result { + tracing::debug!( + tool_name = name, error = err.as_str(), + "incomplete_tool_call — found truncated tool arguments" + ); + } else { + tracing::trace!("incomplete_tool_call — all tool calls have valid JSON"); + } + result } } impl Default for StreamedTurn { + /// Delegates to [`Self::new`]; exists so `StreamedTurn` can be used + /// as a default field value in other structs. fn default() -> Self { + tracing::debug!("StreamedTurn::default — delegating to StreamedTurn::new"); Self::new() } } #[cfg(test)] mod tests { + //! Unit tests for streaming-turn accumulation, including JSON-repair + //! of truncated tool-call arguments and `incomplete_tool_call` detection. use super::*; fn tool_call(name: &str, arguments: &str) -> ParsedToolCall { diff --git a/crates/zesdex-backend/src/app/state/diff.rs b/crates/zesdex-backend/src/app/state/diff.rs index de9f6b7..e4da882 100644 --- a/crates/zesdex-backend/src/app/state/diff.rs +++ b/crates/zesdex-backend/src/app/state/diff.rs @@ -1,6 +1,15 @@ //! Shallow state diffing — records opaque "modified" markers so the TUI //! knows to re-render without computing fine-grained deltas. +//! +//! # Interaction with the render loop +//! +//! The TUI render loop calls [`clear`] at the end of every frame and +//! action handlers call [`add_change`] for each mutation they perform. +//! Because the viewport is fully re-validated each frame, the individual +//! `path` and `kind` fields are currently always set to `"."` and +//! `"modified"` respectively — the diff acts as a simple dirty flag. use serde::{Deserialize, Serialize}; +use tracing::debug; /// A collection of changes tracking which parts of app state have been /// modified since the last render sweep. @@ -25,6 +34,7 @@ impl StateDiff { /// Record a change at `path` of the given `kind`. pub fn add_change(&mut self, path: String, kind: String) { + debug!(%path, %kind, "state diff: change recorded"); self.changes.push(Change { path, kind }); } @@ -35,7 +45,11 @@ impl StateDiff { /// Remove all recorded changes. pub fn clear(&mut self) { + let n = self.changes.len(); self.changes.clear(); + if n > 0 { + debug!(cleared = n, "state diff: cleared"); + } } } @@ -49,9 +63,11 @@ impl StateDiff { /// /// Return: the list of changes (always 0 or 1 entry). pub fn compute_diff(before: &serde_json::Value, after: &serde_json::Value) -> Vec { + // Short-circuit: no allocation when nothing changed if before == after { return Vec::new(); } + debug!("state diff: value changed"); vec![Change { path: ".".to_string(), kind: "modified".to_string(), diff --git a/crates/zesdex-backend/src/app/state/input.rs b/crates/zesdex-backend/src/app/state/input.rs index 9b1a88a..8132ccb 100644 --- a/crates/zesdex-backend/src/app/state/input.rs +++ b/crates/zesdex-backend/src/app/state/input.rs @@ -1,14 +1,46 @@ //! Input buffer, cursor, history, and autocomplete state for the chat prompt. +//! +//! Owned by [`AppStateRest`](super::rest::AppStateRest) and mutated on every +//! keystroke from `controller/input.rs`. Contains: +//! - The raw input buffer and cursor position +//! - Navigable history (up/down arrows) with per-project persistence +//! - `/command` autocomplete (Tab key) against a builtin command list +//! - `@file` mention autocomplete (nucleo fuzzy-matcher) against the workspace +//! file index populated by [`spawn_mention_index_build`] +//! +//! [`spawn_mention_index_build`]: super::rest::AppStateRest::spawn_mention_index_build +//! +//! # Stale-mention safety +//! +//! Cursor movement (Left/Right) does not close the autocomplete dropdown, so +//! the `mention_start` field may refer to a range that is no longer valid +//! against the current buffer/cursor by the time the user presses Enter. +//! [`select_autocomplete`](InputState::select_autocomplete) handles this by +//! checking bounds before splicing — see its doc for details. use std::path::PathBuf; +use tracing; +use tracing::debug; /// Which source populated the autocomplete dropdown, since selecting a /// candidate is spliced into the buffer differently for each. +/// +/// - `Command` — `/`-prefixed builtin commands; selected candidate replaces +/// the entire buffer. +/// - `FileMention` — `@file` mentions; selected candidate is spliced into +/// the buffer at the `@` position, preserving surrounding text. #[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, } +/// Builtin slash-commands recognised by the chat input autocomplete. +/// +/// These are filtered by prefix match when the user types `/`; selecting +/// one replaces the entire buffer. The list is hardcoded — there is no +/// mechanism for registering new commands at runtime. const COMMANDS: &[&str] = &[ "/help", "/quit", @@ -30,16 +62,27 @@ const COMMANDS: &[&str] = &[ /// state for the chat prompt. #[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, + /// Index into `history` when browsing (None = at the current input). pub history_idx: Option, + /// The prefix string used to filter candidates for autocomplete. pub autocomplete_prefix: String, + /// Current autocomplete candidate list. pub autocomplete_candidates: Vec, + /// 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 (Command or FileMention). 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 (appended on submit). pub history_file: Option, } @@ -47,6 +90,7 @@ impl InputState { /// Create an empty input state with no buffer, no history, and no /// autocomplete. pub fn new() -> Self { + debug!("InputState::new — creating empty input state"); InputState { buffer: String::new(), cursor: 0, @@ -64,6 +108,7 @@ impl InputState { /// Hide the autocomplete dropdown and clear its state. pub fn close_autocomplete(&mut self) { + debug!("InputState::close_autocomplete — hiding autocomplete"); self.autocomplete_visible = false; self.autocomplete_candidates.clear(); self.autocomplete_prefix.clear(); @@ -91,10 +136,12 @@ impl InputState { .filter(|c| c.starts_with(&prefix)) .map(std::string::ToString::to_string) .collect(); + let found = self.autocomplete_candidates.len(); self.autocomplete_prefix = prefix; self.autocomplete_kind = AutocompleteKind::Command; self.autocomplete_idx = 0; - self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); + self.autocomplete_visible = found > 0; + debug!("InputState::open_autocomplete — prefix='{}', {} candidates", self.autocomplete_prefix, found); } /// Find the `@mention` token (if any) immediately before the cursor. @@ -107,12 +154,15 @@ impl InputState { /// Return: `Some((byte offset of '@', query text between '@' and cursor))` /// or `None` if the cursor isn't inside a mention token. pub fn mention_query_at_cursor(&self) -> Option<(usize, String)> { + // Scan backwards from the cursor to find the nearest `@` let before_cursor = &self.buffer[..self.cursor]; let at_pos = before_cursor.rfind('@')?; + // Text between `@` and cursor — must not contain whitespace let between = &before_cursor[at_pos + 1..]; if between.chars().any(char::is_whitespace) { return None; } + // `@` must be at buffer start or preceded by whitespace (not mid-word) let boundary_ok = at_pos == 0 || before_cursor[..at_pos] .chars() @@ -121,6 +171,10 @@ impl InputState { if !boundary_ok { return None; } + debug!( + "InputState::mention_query_at_cursor — found @ at byte {}, query='{}'", + at_pos, between + ); Some((at_pos, between.to_string())) } @@ -142,13 +196,18 @@ impl InputState { let matched_files = pattern.match_list(files.iter(), &mut matcher); self.autocomplete_candidates = matched_files .into_iter() - .take(10) + .take(10) // limit to top 10 fuzzy matches .map(|(f, _)| f.clone()) .collect(); self.autocomplete_kind = AutocompleteKind::FileMention; self.mention_start = start; self.autocomplete_idx = 0; - self.autocomplete_visible = !self.autocomplete_candidates.is_empty(); + let found = self.autocomplete_candidates.len(); + self.autocomplete_visible = found > 0; + debug!( + "InputState::open_mention_autocomplete — query='{}', {} candidates", + query, found + ); } /// Move the autocomplete selection up (forward=false) or down (forward=true). @@ -161,12 +220,17 @@ impl InputState { if forward { self.autocomplete_idx = (self.autocomplete_idx + 1) % n; } else { + // wrap from top back to bottom self.autocomplete_idx = if self.autocomplete_idx == 0 { n - 1 } else { self.autocomplete_idx - 1 }; } + debug!( + "InputState::cycle_autocomplete — forward={}, now at idx={}/{}", + forward, self.autocomplete_idx, n + ); } /// Accept the currently selected autocomplete candidate. @@ -182,10 +246,12 @@ impl InputState { .get(self.autocomplete_idx) .cloned() else { + debug!("InputState::select_autocomplete — no candidate at idx={}", self.autocomplete_idx); return false; }; match self.autocomplete_kind { AutocompleteKind::Command => { + debug!("InputState::select_autocomplete — Command: replacing buffer with '{}'", candidate); self.buffer = candidate; self.cursor = self.buffer.len(); } @@ -198,10 +264,12 @@ impl InputState { // doesn't panic, produce a nonsensical replacement. Treat a // stale mention context the same as "nothing selected". if self.cursor < self.mention_start || self.mention_start > self.buffer.len() { + debug!("InputState::select_autocomplete — stale mention_start {}, cursor {} → cancelling", self.mention_start, self.cursor); self.close_autocomplete(); return false; } let replacement = format!("@{candidate} "); + debug!("InputState::select_autocomplete — FileMention: splicing '{}' at pos {}..{}", replacement, self.mention_start, self.cursor); self.buffer .replace_range(self.mention_start..self.cursor, &replacement); self.cursor = self.mention_start + replacement.len(); @@ -217,8 +285,10 @@ impl InputState { // Legacy inline tab-complete — used as a fallback when the dropdown // isn't visible yet. Opens the dropdown on the first Tab press. if self.autocomplete_visible { + debug!("InputState::tab_complete — dropdown already visible, cycling forward"); self.cycle_autocomplete(true); } else { + debug!("InputState::tab_complete — first Tab, opening autocomplete"); self.open_autocomplete(); } } @@ -227,6 +297,7 @@ impl InputState { pub fn char_left(&mut self) { if self.cursor > 0 { self.cursor -= 1; + debug!("InputState::char_left — cursor now at {}", self.cursor); } } @@ -234,13 +305,16 @@ impl InputState { pub fn char_right(&mut self) { if self.cursor < self.buffer.len() { self.cursor += 1; + debug!("InputState::char_right — cursor now at {}", self.cursor); } } /// Insert a character at the cursor position. pub fn insert(&mut self, c: char) { + // Insert the character and advance the cursor by one byte self.buffer.insert(self.cursor, c); self.cursor += 1; + debug!("InputState::insert — char='{}', cursor now at {}", c, self.cursor); } /// Delete the character to the left of the cursor (backspace). @@ -248,6 +322,7 @@ impl InputState { if self.cursor > 0 { self.cursor -= 1; self.buffer.remove(self.cursor); + debug!("InputState::delete_left — cursor now at {}", self.cursor); } } @@ -255,14 +330,19 @@ impl InputState { pub fn delete_right(&mut self) { if self.cursor < self.buffer.len() { self.buffer.remove(self.cursor); + debug!("InputState::delete_right — cursor now at {}", self.cursor); } } + /// Submit the current buffer: push it into history (persisting to disk if + /// `history_file` is set), clear the buffer, and return the submitted text. pub fn submit(&mut self) -> String { let result = self.buffer.clone(); if !result.is_empty() { + // Avoid duplicate consecutive history entries if self.history.last() != Some(&result) { self.history.push(result.clone()); + // Persist to project-specific history file if let Some(ref path) = self.history_file { if let Ok(mut file) = std::fs::OpenOptions::new() .create(true) @@ -275,6 +355,7 @@ impl InputState { } } self.history_idx = None; + debug!("InputState::submit — submitted {} bytes, history size={}", result.len(), self.history.len()); } self.buffer.clear(); self.cursor = 0; @@ -288,12 +369,13 @@ impl InputState { } let idx = match self.history_idx { Some(i) if i > 0 => i - 1, - None => self.history.len() - 1, - Some(_) => return, + None => self.history.len() - 1, // start from the last entry + Some(_) => return, // already at the oldest entry }; self.history_idx = Some(idx); self.buffer = self.history[idx].clone(); self.cursor = self.buffer.len(); + debug!("InputState::history_up — now at history idx={}", idx); } /// Navigate forward through input history (back toward the newest entry). @@ -304,11 +386,14 @@ impl InputState { self.history_idx = Some(idx); self.buffer = self.history[idx].clone(); self.cursor = self.buffer.len(); + debug!("InputState::history_down — now at history idx={}", idx); } Some(_) => { + // At the newest history entry → return to blank input self.history_idx = None; self.buffer.clear(); self.cursor = 0; + debug!("InputState::history_down — returned to blank input"); } None => {} } diff --git a/crates/zesdex-backend/src/app/state/misc.rs b/crates/zesdex-backend/src/app/state/misc.rs index 5021b34..48db3d7 100644 --- a/crates/zesdex-backend/src/app/state/misc.rs +++ b/crates/zesdex-backend/src/app/state/misc.rs @@ -1,20 +1,31 @@ //! Application-level "miscellaneous" state: shared caches, overlay stack, //! toasts, editor state, and thinking flags. +//! +//! Owned by [`AppStateRest`](super::rest::AppStateRest) via `misc: MiscState`. +//! Also contains `DirCache` (shared async directory listing) and +//! `MentionIndex` (shared workspace file path index for `@file` mentions). use super::types::Overlay; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::RwLock; +use tracing::debug; +use tracing::info; /// A shared, async-writable cache of directory entries, used to avoid /// re-reading a directory every render frame. +/// +/// Internal: wraps `Arc>>` so the cache is safe to +/// clone and share across tool call boundaries. #[derive(Clone)] pub struct DirCache { + /// Inner async-shared directory entry listing. entries: Arc>>, } impl DirCache { - /// Create an empty `DirCache`. + /// Create an empty `DirCache` with no entries. pub fn new() -> Self { + info!("DirCache::new — created empty directory cache"); DirCache { entries: Arc::new(RwLock::new(Vec::new())), } @@ -22,6 +33,7 @@ impl DirCache { /// Replace the cached entries (async write). pub async fn set(&self, paths: Vec) { + debug!("DirCache::set — replacing with {} entries", paths.len()); let mut w = self.entries.write().await; *w = paths; } @@ -31,14 +43,20 @@ impl DirCache { /// autocomplete. Built once by a background thread at startup (see /// `AppStateRest::new`) and incrementally appended to when tools create /// new files (see `tool/fs/write.rs`). +/// +/// Internal: wraps `Arc>>` (sync, not async) +/// since reads happen on the render thread and writes happen on background +/// threads — contention is extremely low. #[derive(Clone)] pub struct MentionIndex { + /// Inner sync-shared workspace file path listing. entries: Arc>>, } impl MentionIndex { /// Create an empty `MentionIndex`. pub fn new() -> Self { + info!("MentionIndex::new — created empty mention index"); MentionIndex { entries: Arc::new(std::sync::RwLock::new(Vec::new())), } @@ -46,6 +64,7 @@ impl MentionIndex { /// Replace the indexed paths (used by the startup background walk). pub fn set(&self, paths: Vec) { + debug!("MentionIndex::set — writing {} paths", paths.len()); if let Ok(mut w) = self.entries.write() { *w = paths; } @@ -53,6 +72,7 @@ impl MentionIndex { /// Append a single newly created file's path (used by the `write` tool). pub fn push(&self, path: String) { + debug!("MentionIndex::push — appending '{}'", path); if let Ok(mut w) = self.entries.write() { w.push(path); } @@ -60,7 +80,9 @@ impl MentionIndex { /// Take a snapshot of the current indexed paths for fuzzy matching. pub fn snapshot(&self) -> Vec { - self.entries.read().map(|r| r.clone()).unwrap_or_default() + let result = self.entries.read().map(|r| r.clone()).unwrap_or_default(); + debug!("MentionIndex::snapshot — returning {} paths", result.len()); + result } } @@ -68,17 +90,29 @@ impl MentionIndex { /// toasts, thinking/connected flags, effort level, 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, + /// Timestamp (ms) of the last staleness sweep for lesson cache. pub last_staleness_sweep_ms: i64, + /// Whether the agent is currently "thinking" (streaming or waiting on tool). pub thinking: bool, + /// Current LLM reasoning effort level (1-5). pub effort_level: usize, + /// Currently focused index in list-type overlays (e.g. settings, model). pub selected_index: usize, + /// Optional inline editor state (opened via `/edit`). pub editor: Option, + /// Whether the API connection is established. pub api_connected: bool, + /// 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 (set by yank tool). pub pending_clipboard_copy: Option, } @@ -86,6 +120,7 @@ impl MiscState { /// Create a fresh `MiscState` with no overlay, no toasts, and default /// effort level 1. pub fn new() -> Self { + info!("MiscState::new — created fresh misc state"); MiscState { overlay: Overlay::None, toasts: Vec::new(), @@ -102,12 +137,21 @@ impl MiscState { } } + /// Append a toast notification to the active list. pub fn push_toast(&mut self, toast: super::types::Toast) { + debug!( + "MiscState::push_toast — kind={:?}, msg='{}'", + toast.kind, + toast.message.chars().take(80).collect::() + ); self.toasts.push(toast); } /// Remove and return all toasts whose lifetime has expired at `now_ms`. /// + /// Flow: partition toasts into expired vs active → retain only active → + /// return the expired ones for optional callback processing. + /// /// Return: the expired toasts (after removal). pub fn drain_expired_toasts(&mut self, now_ms: i64) -> Vec { let expired: Vec<_> = self @@ -117,12 +161,16 @@ impl MiscState { .cloned() .collect(); self.toasts.retain(|t| !t.expired(now_ms)); + if !expired.is_empty() { + debug!("MiscState::drain_expired_toasts — draining {} toasts", expired.len()); + } expired } } #[cfg(test)] mod tests { + use super::super::types::ToastKind; use super::*; #[test] @@ -130,4 +178,58 @@ mod tests { let misc = MiscState::new(); assert!(misc.pending_clipboard_copy.is_none()); } + + #[test] + fn push_toast_appends_and_drain_expired_removes_expired() { + let mut misc = MiscState::new(); + // Push two toasts: first expired (created 0ms, lifetime 100ms), + // second still within lifetime (created 500ms, lifetime 1000ms). + misc.push_toast(super::super::types::Toast { + kind: ToastKind::Info, + message: "expired".into(), + created_at: 0, + lifetime_ms: 100, + }); + misc.push_toast(super::super::types::Toast { + kind: ToastKind::Success, + message: "active".into(), + created_at: 500, + lifetime_ms: 1000, + }); + assert_eq!(misc.toasts.len(), 2); + + // Drain with now_ms=500 — first toast expired, second still alive. + let drained = misc.drain_expired_toasts(500); + assert_eq!(drained.len(), 1); + assert_eq!(drained[0].message, "expired"); + assert_eq!(misc.toasts.len(), 1); + assert_eq!(misc.toasts[0].message, "active"); + } + + #[test] + fn drain_expired_toasts_empty_when_none_expired() { + let mut misc = MiscState::new(); + misc.push_toast(super::super::types::Toast { + kind: ToastKind::Warning, + message: "future".into(), + created_at: 0, + lifetime_ms: 9999, + }); + let drained = misc.drain_expired_toasts(100); + assert!(drained.is_empty()); + assert_eq!(misc.toasts.len(), 1); + } + + #[test] + fn dir_cache_and_mention_index_new_create_empty_structures() { + let dc = DirCache::new(); + // No public reader, just verify it doesn't panic on set. + let rt = tokio::runtime::Runtime::new().unwrap(); + rt.block_on(dc.set(vec![])); + + let mi = MentionIndex::new(); + assert!(mi.snapshot().is_empty()); + mi.push("src/main.rs".into()); + assert_eq!(mi.snapshot().len(), 1); + } } diff --git a/crates/zesdex-backend/src/app/state/mod.rs b/crates/zesdex-backend/src/app/state/mod.rs index 34749aa..cf95f1c 100644 --- a/crates/zesdex-backend/src/app/state/mod.rs +++ b/crates/zesdex-backend/src/app/state/mod.rs @@ -1,5 +1,22 @@ //! Application state: misc fields, the main `AppStateRest` struct, //! runtime-only state, and shared types (overlays, toasts, origins). +//! +//! # Sub-modules +//! +//! | Module | Responsibility | +//! |-----------|-------------------------------------------------------------| +//! | `input` | Input-line state (cursor, text buffer, history) | +//! | `misc` | Miscellaneous state flags and counters | +//! | `rest` | The single source-of-truth `AppStateRest` struct | +//! | `runtime` | Runtime-only transient state (not persisted) | +//! | `scroll` | Scroll position and viewport tracking | +//! | `types` | Shared enums & structs (overlays, toasts, origins) | +//! +//! # Mutation convention +//! +//! `AppStateRest` is mutated in-place from two locations: +//! [`actions::apply_action`] and [`controller::input`]. Every other +//! module reads state immutably. pub mod input; pub mod misc; pub mod rest; diff --git a/crates/zesdex-backend/src/app/state/rest.rs b/crates/zesdex-backend/src/app/state/rest.rs index f127fed..009be60 100644 --- a/crates/zesdex-backend/src/app/state/rest.rs +++ b/crates/zesdex-backend/src/app/state/rest.rs @@ -3,11 +3,24 @@ //! //! `AppStateRest` is the single source-of-truth struct mutated in-place from //! `actions/mod.rs` and `controller/input.rs`; every other module reads it. +//! +//! # Construction flow +//! +//! 1. Load persisted `Settings` and `AppConfig` from JSON stores +//! 2. Derive `worktrees_dir` from `memory_dir`'s parent +//! 3. Derive `session_id` from the session directory's filename +//! 4. Open the edit-log append-only file for this session +//! 5. Load project-specific input history (SHA256-hashed workspace root) +//! 6. Optionally spawn a background LSP provisioning thread +//! +//! All fallible steps degrade gracefully (defaults + warnings) so that +//! construction never panics. use std::collections::VecDeque; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use tokio::sync::RwLock; +use tracing::{self, debug, warn}; use super::input::InputState; use super::misc::{DirCache, MentionIndex, MiscState}; @@ -30,14 +43,18 @@ use zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsReposito /// A single transcript entry rendered in the TUI chat pane. #[derive(Debug, Clone, PartialEq)] pub struct ChatMessageDisplay { + /// Message author: User or Assistant. pub role: crate::dto::chat::message::Role, + /// Rendered text content (plain text, no markdown). pub content: String, + /// Millisecond timestamp when this display entry was created. pub timestamp: i64, } impl ChatMessageDisplay { /// Build a display entry, stamping it with the current time. pub fn new(role: crate::dto::chat::message::Role, content: String) -> Self { + tracing::debug!("ChatMessageDisplay::new — role={:?}, content_len={}", role, content.len()); ChatMessageDisplay { role, content, @@ -53,32 +70,57 @@ impl ChatMessageDisplay { /// 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, + /// Unique session identifier (derived from the session directory name). 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 (avoids re-reading on every frame). pub dir_cache: Arc>, + /// 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 (message history, tool queue, counters). pub session_runtime: Option, + /// Active IAM sessions linked to this app instance. pub sessions: Vec, + /// 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, editor, tick. pub misc: MiscState, + /// Queue of events emitted by the running agent turn, consumed by the + /// main event loop to drive incremental re-renders. pub turn_events: Arc>>, + /// Whether an agent turn is currently in flight (guarded by a mutex). pub turn_in_flight: Arc>, + /// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape). pub abort_flag: Arc, + /// Workflow engine state for multi-agent hive-mind orchestration. pub workflow_engine: WorkflowEngine, + /// MCP (Model Context Protocol) server manager. pub mcp_manager: McpManager, + /// LSP (Language Server Protocol) manager, shared with tool context. pub lsp_manager: Arc>, - /// Shared queue: provisioner thread pushes status updates, + /// Shared queue: LSP provisioner thread pushes status updates, /// drained into toasts on each Tick. pub lsp_provision_msgs: Arc>>, + /// 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, } @@ -160,7 +202,9 @@ impl AppStateRest { quit: false, }; - // Load project-specific history + // Load project-specific input-line history from a file keyed by + // the first workspace root's SHA256 hash. This gives us a stable + // filename per project that survives session-dir renames. 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) { @@ -168,6 +212,7 @@ impl AppStateRest { let mut hasher = sha2::Sha256::new(); hasher.update(abs_root.to_string_lossy().as_bytes()); let hash_hex = hex::encode(hasher.finalize()); + // Use folder name + first 8 hex chars as a human-readable key let folder_name = abs_root .file_name() .map_or_else(|| "root".to_string(), |n| n.to_string_lossy().to_string()); @@ -176,6 +221,7 @@ impl AppStateRest { let _ = std::fs::create_dir_all(&history_dir); let history_file = history_dir.join(history_filename); + // Restore previous session's history; start fresh if file missing if let Ok(content) = std::fs::read_to_string(&history_file) { let history: Vec = content .lines() @@ -184,6 +230,7 @@ impl AppStateRest { .collect(); state.input.history = history; } + // Store the file path so future input-append code can write back state.input.history_file = Some(history_file); } } @@ -257,6 +304,8 @@ impl AppStateRest { }); } + // All done — return fully initialised state with dirty=true so the + // TUI renders the initial frame, not a blank screen. state } @@ -287,8 +336,11 @@ impl AppStateRest { let mention_index = self.mention_index.clone(); let roots = self.workspace_roots.clone(); std::thread::spawn(move || { + // Safety cap: index at most 50k files to bound memory and time const MAX_MENTION_ENTRIES: usize = 50_000; let mut paths = Vec::new(); + // Walk each workspace root sequentially; label the outer loop so + // the cap check can bail out of all roots at once 'roots: for (i, root) in roots.iter().enumerate() { for entry in ignore::Walk::new(root).flatten() { if !entry.path().is_file() { @@ -296,6 +348,8 @@ impl AppStateRest { } let rel = entry.path().strip_prefix(root).unwrap_or(entry.path()); let rel_str = rel.display().to_string(); + // Root 0 uses bare paths; subsequent roots get "[N]" prefix + // so `resolve_path` can disambiguate them let formatted = if i == 0 { rel_str } else { @@ -316,13 +370,15 @@ impl AppStateRest { /// Return: `false` (and logs a warning) if the mutex is poisoned, rather /// than propagating a panic. pub fn turn_in_flight(&self) -> bool { - self.turn_in_flight.lock().map_or_else( + let result = self.turn_in_flight.lock().map_or_else( |_| { tracing::warn!("[state] turn_in_flight mutex poisoned"); false }, |g| *g, - ) + ); + tracing::debug!("AppStateRest::turn_in_flight — returning {}", result); + result } /// Shut down every running LSP server process. @@ -331,6 +387,7 @@ impl AppStateRest { /// processes; silently no-ops if the mutex is poisoned since there is /// nothing more useful to do at shutdown time. pub fn shutdown_lsp(&mut self) { + tracing::debug!("AppStateRest::shutdown_lsp — shutting down all LSP servers"); if let Ok(mut mgr) = self.lsp_manager.lock() { mgr.shutdown_all(); } @@ -339,42 +396,50 @@ impl AppStateRest { /// Append a message to the transcript, evicting the oldest entry once /// `max_lines` is exceeded, and mark both the cache and the app dirty. pub fn push_transcript(&mut self, msg: ChatMessageDisplay) { + let len_before = self.transcript_cache.messages.len(); 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; + debug!("AppStateRest::push_transcript — cache was {} msgs", len_before); } /// Mark the app state as dirty, triggering a TUI re-render on the next frame. pub fn mark_dirty(&mut self) { self.dirty = true; + debug!("AppStateRest::mark_dirty — state marked dirty"); } /// Queue a toast notification for display and mark the app dirty. pub fn push_toast(&mut self, toast: Toast) { + debug!("AppStateRest::push_toast — kind={:?}", toast.kind); 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) { + debug!("AppStateRest::toast_info"); self.push_toast(Toast::new(super::types::ToastKind::Info, msg.into())); } /// Push a success toast with the given message. pub fn toast_success(&mut self, msg: impl Into) { + debug!("AppStateRest::toast_success"); self.push_toast(Toast::new(super::types::ToastKind::Success, msg.into())); } /// Push a warning toast with the given message. pub fn toast_warning(&mut self, msg: impl Into) { + debug!("AppStateRest::toast_warning"); self.push_toast(Toast::new(super::types::ToastKind::Warning, msg.into())); } /// Push an error toast with the given message. pub fn toast_error(&mut self, msg: impl Into) { + debug!("AppStateRest::toast_error"); self.push_toast(Toast::new(super::types::ToastKind::Error, msg.into())); } @@ -382,22 +447,23 @@ impl AppStateRest { /// `session_dir`, i.e. the sessions root, not the individual session /// folder). /// - /// Why: falls back progressively -- grandparent, then parent, then - /// `session_dir` itself -- logging a warning at each step down, so this + /// Why: falls back progressively — grandparent, then parent, then + /// `session_dir` itself — logging a warning at each step down, so this /// never fails even on a shallow path. pub fn store_base_dir(&self) -> std::path::PathBuf { + debug!("AppStateRest::store_base_dir — resolving from session_dir='{}'", self.session_dir.display()); self.session_dir .parent() .and_then(|p| p.parent()) .map_or_else( || { - tracing::warn!( + warn!( "[state] session_dir '{}' has no grandparent, using parent", self.session_dir.display() ); self.session_dir.parent().map_or_else( || { - tracing::warn!( + warn!( "[state] session_dir '{}' has no parent at all, using itself", self.session_dir.display() ); @@ -416,6 +482,7 @@ impl AppStateRest { /// duplicated twice in `controller/input.rs` — this helper centralises /// the call site. pub fn save_settings(&self) { + debug!("AppStateRest::save_settings — persisting settings"); use zesdex_cms::domain::repository::SettingsRepository; let _ = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() .save(&self.store_base_dir(), &self.settings); @@ -423,12 +490,14 @@ impl AppStateRest { /// Build a `ToolCtx` for tool calls originating from the main agent. pub fn tool_ctx(&self) -> crate::tool::ToolCtx { + debug!("AppStateRest::tool_ctx — building for Origin::Main"); self.tool_ctx_for(Origin::Main) } /// Build a `ToolCtx` scoped to the given call origin (main, subagent, /// reviewer), copying workspace/session/memory paths from state. pub fn tool_ctx_for(&self, origin: Origin) -> crate::tool::ToolCtx { + debug!("AppStateRest::tool_ctx_for — origin={:?}", origin); crate::tool::ToolCtx { workspaces: self.workspace_roots.clone(), session_dir: self.session_dir.clone(), diff --git a/crates/zesdex-backend/src/app/state/runtime.rs b/crates/zesdex-backend/src/app/state/runtime.rs index dce705a..536ff2c 100644 --- a/crates/zesdex-backend/src/app/state/runtime.rs +++ b/crates/zesdex-backend/src/app/state/runtime.rs @@ -1,6 +1,9 @@ //! Per-session runtime state: message history, pending tool queue, //! background bash jobs, lesson/review counters, and the `TurnEvent` //! stream emitted while an agent turn is in flight. +//! +//! Owned by [`AppStateRest`](super::rest::AppStateRest) via +//! `session_runtime: Option`. use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -10,27 +13,49 @@ pub use zesdex_entities::domain::common::usage::UsageStats; /// shown in the TUI status bar. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SessionRuntime { + /// Full conversation history (persisted to msglog SQLite externally). pub messages: Vec, + /// Completed tool-call results (used for display/review). pub tool_call_results: Vec, + /// Tools queued for execution when the turn resumes. pub pending_tool_queue: Vec, + /// Background bash job display records. pub bash_jobs: Vec, + /// Number of subagents queued but not yet started. pub subagent_queue: usize, + /// Number of tool edits performed in this session. pub edit_count: u32, + /// Consecutive reviews that returned no findings (used for early-exit). pub consecutive_empty_reviews: u32, + /// Session start timestamp in milliseconds. pub session_start: i64, + /// Total number of lesson entries in cache. pub lesson_count: u32, + /// Lessons tagged as user-authored. pub lessons_user: u32, + /// Lessons tagged as user feedback. pub lessons_feedback: u32, + /// Lessons tagged as project-level. pub lessons_project: u32, + /// Lessons tagged as reference material. pub lessons_reference: u32, + /// Lessons currently active (not stale/contradicted). pub lessons_active: u32, + /// Lessons that have gone stale. pub lessons_stale: u32, + /// Lessons that have been contradicted by newer entries. pub lessons_contradicted: u32, + /// Lessons marked as human-authored (vs AI-derived). pub lessons_human: u32, + /// Lessons whose verification status is confirmed. pub lessons_verified: u32, + /// Lessons whose verification status is pending. pub lessons_unverified: u32, + /// Number of auto-inline reviews performed. pub review_count: u32, + /// Path to the session data directory. pub session_dir: PathBuf, + /// Token usage statistics (input/output per model). pub usage: UsageStats, /// Whether a hive-mind convergence has completed at least once in this /// session. Set by the main-thread event loop when it receives a @@ -42,13 +67,18 @@ pub struct SessionRuntime { pub hive_mind_converged: bool, } +/// Re-exported tool-call result with structured output, error flag, and +/// optional file path — used in `SessionRuntime::tool_call_results`. pub use zesdex_entities::domain::common::tool_result::ToolCallResult; /// A tool call awaiting execution, along with which execution model /// (inline, deferred, async) it should run under. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PendingTool { + /// Name of the tool to execute (e.g. "Bash", "Read", "Write"). pub tool_name: String, + /// JSON arguments for the tool call. pub args: serde_json::Value, + /// How the tool should be executed when the turn resumes. pub execution_model: crate::app::state::types::ExecutionModel, } @@ -56,9 +86,13 @@ pub struct PendingTool { /// process handle lives elsewhere; this is just the display/status record). #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BashJobRef { + /// Unique job identifier. pub id: String, + /// Shell command being executed. pub command: String, + /// Timestamp (ms) when the job was started. pub started_at: i64, + /// Whether the job is still running (vs completed/failed). pub running: bool, } @@ -66,23 +100,39 @@ pub struct BashJobRef { /// consumed by the event loop to update state and drive re-renders. #[derive(Debug, Clone)] pub enum TurnEvent { + /// A full assistant message has been produced (tool calls or final text). AssistantMessage(crate::dto::chat::message::ChatMessage), + /// A tool has finished executing, with its output. ToolResult { + /// ID of the tool call that produced this result. tool_call_id: String, + /// Name of the tool that executed. tool_name: String, + /// Text output from the tool. output: String, + /// Whether the tool returned an error. is_error: bool, + /// Optional path to a file produced (e.g. Write tool). path: Option, }, + /// A system-level notification (e.g. "compacted", "hive_mind_converged"). SystemNote { + /// Machine-readable kind tag. kind: String, + /// Human-readable description. message: String, }, + /// The turn's stream has started producing tokens. StreamStart, + /// A single text token from the streaming response. StreamToken(String), + /// The turn's stream is complete, with the final assembled message. StreamDone(crate::dto::chat::message::ChatMessage), + /// Token usage for a main-agent turn. Usage { + /// Input tokens consumed. tokens_in: u64, + /// Output tokens generated. tokens_out: u64, }, /// Token usage from a subagent (review, test-gen, arch-review, etc.) @@ -92,17 +142,26 @@ pub enum TurnEvent { /// (origin tag, subagent name) can be attached without breaking the /// main-agent path. ReviewUsage { + /// Input tokens consumed by the subagent. tokens_in: u64, + /// Output tokens generated by the subagent. tokens_out: u64, }, + /// The message history has been compacted (older messages replaced + /// with a summary). Compacted(Vec), + /// An error occurred during the turn. Error(String), + /// Signal that the turn has finished completely. Done, /// Real-time update from a workflow subagent: push the new status /// into `AppStateRest::workflow_engine.agents`. WorkflowAgentUpdate { + /// Unique agent identifier within the workflow. agent_id: String, + /// Human-readable agent name. agent_name: String, + /// Current status (running, waiting, completed, etc.). status: crate::app::workflow::engine::AgentStatus, }, } @@ -111,6 +170,7 @@ impl SessionRuntime { /// Create fresh runtime state for a session rooted at `session_dir`, /// with all counters zeroed and `session_start` set to now. pub fn new(session_dir: PathBuf) -> Self { + tracing::info!("SessionRuntime::new — session_dir='{}'", session_dir.display()); SessionRuntime { messages: Vec::new(), tool_call_results: Vec::new(), @@ -140,6 +200,51 @@ impl SessionRuntime { /// Append a message to the session's conversation history. pub fn push_message(&mut self, msg: crate::dto::chat::message::ChatMessage) { + tracing::debug!("SessionRuntime::push_message — role={:?}, content_len={}", + msg.role, msg.content.as_deref().map_or(0, str::len)); self.messages.push(msg); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn session_runtime_new_creates_empty_state() { + let rt = SessionRuntime::new(PathBuf::from("/tmp/test_session")); + assert!(rt.messages.is_empty()); + assert!(rt.tool_call_results.is_empty()); + assert!(rt.pending_tool_queue.is_empty()); + assert_eq!(rt.edit_count, 0); + assert!(!rt.hive_mind_converged); + assert_eq!(rt.lesson_count, 0); + } + + #[test] + fn session_runtime_push_message_appends_to_history() { + let mut rt = SessionRuntime::new(PathBuf::from("/tmp/test_session")); + let msg = crate::dto::chat::message::ChatMessage { + role: crate::dto::chat::message::Role::User, + content: Some("hello".into()), + tool_calls: None, + tool_call_id: None, + name: None, + }; + rt.push_message(msg); + assert_eq!(rt.messages.len(), 1); + assert_eq!(rt.messages[0].content.as_deref(), Some("hello")); + } + + #[test] + fn bash_job_ref_stores_command_and_running_flag() { + let job = BashJobRef { + id: "job-1".into(), + command: "cargo build".into(), + started_at: 1000, + running: true, + }; + assert!(job.running); + assert_eq!(job.command, "cargo build"); + } +} diff --git a/crates/zesdex-backend/src/app/state/scroll.rs b/crates/zesdex-backend/src/app/state/scroll.rs index a99dd3d..1f24a65 100644 --- a/crates/zesdex-backend/src/app/state/scroll.rs +++ b/crates/zesdex-backend/src/app/state/scroll.rs @@ -1,33 +1,104 @@ //! Scroll offset management for viewport panning. //! -//! Manages the viewport scroll offset. +//! Tracks the current scroll offset and the maximum number of visible +//! lines in the viewport. Used by the transcript pane, overlays, and +//! other scrollable TUI areas. + +/// Viewport scroll state: current offset and visible-line count. +/// +/// The offset increases when scrolling down (older content comes into +/// view) and decreases when scrolling up (newer content). #[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 { + tracing::info!("ScrollState::new — created scroll state with max_visible=30"); ScrollState { offset: 0, max_visible: 30, } } - /// Scroll the viewport up by `amount` lines (increasing the offset). + /// Scroll the viewport up by `amount` lines (increasing the offset, + /// moving toward older content). + /// + /// Uses saturating addition so the offset never wraps on overflow. pub fn scroll_up(&mut self, amount: usize) { self.offset = self.offset.saturating_add(amount); + tracing::debug!("ScrollState::scroll_up — offset now {}", self.offset); } - /// Scroll the viewport down by `amount` lines (decreasing the offset). + /// Scroll the viewport down by `amount` lines (decreasing the offset, + /// moving toward newer content). + /// + /// Uses saturating subtraction so the offset never goes below zero. pub fn scroll_down(&mut self, amount: usize) { self.offset = self.offset.saturating_sub(amount); + tracing::debug!("ScrollState::scroll_down — offset now {}", self.offset); } - /// Update the maximum number of visible lines. + /// Update the maximum number of visible lines in the viewport. + /// + /// The caller is responsible for ensuring `max` does not exceed + /// the actual terminal height. pub fn set_max_visible(&mut self, max: usize) { + tracing::debug!("ScrollState::set_max_visible — {} -> {}", self.max_visible, max); self.max_visible = max; } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scroll_state_new_starts_at_zero() { + let s = ScrollState::new(); + assert_eq!(s.offset, 0); + assert_eq!(s.max_visible, 30); + } + + #[test] + fn scroll_up_increases_offset() { + let mut s = ScrollState::new(); + s.scroll_up(5); + assert_eq!(s.offset, 5); + s.scroll_up(3); + assert_eq!(s.offset, 8); + } + + #[test] + fn scroll_down_decreases_offset_and_saturates_at_zero() { + let mut s = ScrollState::new(); + s.scroll_up(10); + assert_eq!(s.offset, 10); + s.scroll_down(4); + assert_eq!(s.offset, 6); + // Saturate at zero + s.scroll_down(100); + assert_eq!(s.offset, 0); + } + + #[test] + fn scroll_down_on_zero_offset_stays_zero() { + let mut s = ScrollState::new(); + s.scroll_down(5); + assert_eq!(s.offset, 0); + } + + #[test] + fn set_max_visible_updates_viewport() { + let mut s = ScrollState::new(); + s.set_max_visible(50); + assert_eq!(s.max_visible, 50); + s.set_max_visible(20); + assert_eq!(s.max_visible, 20); + } +} diff --git a/crates/zesdex-backend/src/app/state/snapshot.rs b/crates/zesdex-backend/src/app/state/snapshot.rs index 8482363..6eb5a2b 100644 --- a/crates/zesdex-backend/src/app/state/snapshot.rs +++ b/crates/zesdex-backend/src/app/state/snapshot.rs @@ -1,16 +1,29 @@ //! Opaque, serializable snapshot of application state used for //! attach/daemon IPC transfer. +//! +//! Flow: the daemon serializes its [`AppStateRest`](super::rest::AppStateRest) +//! into JSON and sends it over the IPC socket to an attach client, which +//! deserializes it for local rendering. The snapshot is intentionally opaque +//! (a single `serde_json::Value`) so the transport layer does not need to +//! know the state schema. use serde::{Deserialize, Serialize}; +use tracing::debug; +use tracing::info; /// A JSON-boxed snapshot of app state, opaque to the transport layer. +/// +/// Fields: +/// - `snapshot` — the raw JSON value of the serialised app state. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct StateSnapshot { + /// Raw JSON representation of the application state. pub snapshot: serde_json::Value, } impl StateSnapshot { /// Create an empty snapshot (`{}`). pub fn new() -> Self { + info!("StateSnapshot::new — created empty snapshot"); StateSnapshot { snapshot: serde_json::json!({}), } @@ -19,14 +32,57 @@ impl StateSnapshot { /// Serialize a snapshot to bytes for transport over the daemon socket. /// +/// Flow: [`serde_json::to_vec`] serialises the snapshot struct into +/// compact JSON bytes. +/// /// Return: JSON-encoded bytes, or a serde error. pub fn serialize_snapshot(snapshot: &StateSnapshot) -> anyhow::Result> { + debug!("serialize_snapshot — serialising state snapshot"); Ok(serde_json::to_vec(snapshot)?) } /// Parse a snapshot previously produced by `serialize_snapshot`. /// +/// Flow: [`serde_json::from_slice`] deserialises the JSON bytes back +/// into a [`StateSnapshot`]. +/// /// Return: the decoded `StateSnapshot`, or a serde error. pub fn deserialize_snapshot(data: &[u8]) -> anyhow::Result { + debug!("deserialize_snapshot — deserialising {} bytes", data.len()); Ok(serde_json::from_slice(data)?) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn state_snapshot_new_creates_empty_json() { + let snap = StateSnapshot::new(); + assert_eq!(snap.snapshot, serde_json::json!({})); + } + + #[test] + fn serialize_deserialize_roundtrip() { + // Create a snapshot with non-trivial content. + let original = StateSnapshot { + snapshot: serde_json::json!({ + "thinking": true, + "tick_count": 42, + "overlay": "Settings", + }), + }; + let bytes = serialize_snapshot(&original).unwrap(); + assert!(!bytes.is_empty()); + + let decoded = deserialize_snapshot(&bytes).unwrap(); + assert_eq!(decoded.snapshot["thinking"], serde_json::json!(true)); + assert_eq!(decoded.snapshot["tick_count"], serde_json::json!(42)); + } + + #[test] + fn deserialize_empty_bytes_fails() { + let result = deserialize_snapshot(b""); + assert!(result.is_err()); + } +} diff --git a/crates/zesdex-backend/src/app/state/types.rs b/crates/zesdex-backend/src/app/state/types.rs index cc1c0f6..b101bb8 100644 --- a/crates/zesdex-backend/src/app/state/types.rs +++ b/crates/zesdex-backend/src/app/state/types.rs @@ -1,14 +1,22 @@ //! Shared small state types: toasts, overlays, the transcript cache, //! tool execution model, and call origin tags. +//! +//! These types are used across multiple sub-modules in `state/` and are +//! also consumed by the view layer, tool harness, and IPC transport. use serde::{Deserialize, Serialize}; /// Severity/category of a toast notification, used to pick its color. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ToastKind { + /// Informational message (neutral). Info, + /// Successful operation (green). Success, + /// Warning / non-critical issue (yellow). Warning, + /// Error / failure (red). Error, + /// Lesson notification (purple/blue). Lesson, } @@ -16,15 +24,20 @@ pub enum ToastKind { /// `lifetime_ms`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Toast { + /// Severity/category, determines the display color. pub kind: ToastKind, + /// Human-readable message text. pub message: String, + /// Millisecond timestamp when the toast was created. pub created_at: i64, + /// How long (ms) the toast should remain visible. pub lifetime_ms: u64, } impl Toast { /// Create a toast with a default 5-second lifetime, stamped with now. pub fn new(kind: ToastKind, message: String) -> Self { + tracing::debug!("Toast::new — kind={:?}, msg='{}'", kind, message.chars().take(80).collect::()); Toast { kind, message, @@ -35,50 +48,103 @@ impl Toast { /// Whether this toast's lifetime has elapsed as of `now_ms`. pub fn expired(&self, now_ms: i64) -> bool { - now_ms - self.created_at > self.lifetime_ms as i64 + let expired = now_ms - self.created_at > self.lifetime_ms as i64; + if expired { + tracing::debug!("Toast::expired — toast aged {}ms expired (lifetime={}ms)", now_ms - self.created_at, self.lifetime_ms); + } + expired } } /// 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) + let active = !matches!(self, Overlay::None); + tracing::debug!("Overlay::is_active — overlay={:?}, active={}", self, active); + active + } +} + +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, PartialEq)] pub struct TranscriptCache { + /// Ordered display messages (newest appended, oldest evicted when full). pub messages: Vec, + /// 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 { + tracing::info!("TranscriptCache::new — max_lines={}", max_lines); TranscriptCache { messages: Vec::new(), max_lines, @@ -90,8 +156,11 @@ impl TranscriptCache { /// How a pending tool call should be executed when the turn resumes. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub enum ExecutionModel { + /// Run the tool synchronously in the main agent loop. Inline, + /// Defer execution until the LLM explicitly asks for the result. Deferred, + /// Run as a background tokio task (used for long-running tools). AsyncTokio, } @@ -99,18 +168,85 @@ pub enum ExecutionModel { /// invoking a tool, used to scope permissions and tag log/output paths. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Hash)] pub enum Origin { + /// The main agent turn loop. Main, + /// A spawned subagent (test-gen, arch-review, security-review, etc.). SubAgent, + /// The auto-inline review step after an edit. Reviewer, } impl Origin { /// Short string tag for this origin, used in filenames and logs. pub fn tag(self) -> String { - match self { - Origin::Main => "main".to_string(), - Origin::SubAgent => "subagent".to_string(), - Origin::Reviewer => "reviewer".to_string(), - } + let tag = match self { + Origin::Main => "main", + Origin::SubAgent => "subagent", + Origin::Reviewer => "reviewer", + }; + tracing::debug!("Origin::tag — {:?} -> '{}'", self, tag); + tag.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn toast_new_has_default_lifetime() { + let t = Toast::new(ToastKind::Info, "hello".into()); + assert_eq!(t.lifetime_ms, 5000); + assert_eq!(t.message, "hello"); + assert_eq!(t.kind, ToastKind::Info); + } + + #[test] + fn toast_expired_returns_true_after_lifetime() { + let t = Toast { + kind: ToastKind::Warning, + message: "old".into(), + created_at: 0, + lifetime_ms: 100, + }; + assert!(t.expired(200)); + } + + #[test] + fn toast_expired_returns_false_within_lifetime() { + let t = Toast { + kind: ToastKind::Success, + message: "fresh".into(), + created_at: 50, + lifetime_ms: 200, + }; + assert!(!t.expired(100)); + } + + #[test] + fn overlay_is_active_returns_true_for_non_none() { + assert!(Overlay::Help.is_active()); + assert!(Overlay::Settings.is_active()); + assert!(Overlay::QuitConfirm.is_active()); + } + + #[test] + fn overlay_is_active_returns_false_for_none() { + assert!(!Overlay::None.is_active()); + } + + #[test] + fn origin_tag_returns_correct_string() { + assert_eq!(Origin::Main.tag(), "main"); + assert_eq!(Origin::SubAgent.tag(), "subagent"); + assert_eq!(Origin::Reviewer.tag(), "reviewer"); + } + + #[test] + fn transcript_cache_new_creates_empty_dirty_cache() { + let tc = TranscriptCache::new(100); + assert!(tc.messages.is_empty()); + assert_eq!(tc.max_lines, 100); + assert!(tc.dirty); } } diff --git a/crates/zesdex-backend/src/app/subagent/auto/mod.rs b/crates/zesdex-backend/src/app/subagent/auto/mod.rs index 0701fd5..0fdb40d 100644 --- a/crates/zesdex-backend/src/app/subagent/auto/mod.rs +++ b/crates/zesdex-backend/src/app/subagent/auto/mod.rs @@ -15,6 +15,10 @@ //! wrote this file, let me check if it's correct before continuing"). //! - Background reviews catch broader concerns (missing tests, architectural //! drift, security issues) without blocking the main agent's flow. +//! +//! Overlap prevention: each background review kind has its own `AtomicBool` +//! static and a `RunningGuard` that resets it on drop (even during panic +//! unwind), so a single review kind can never stack multiple concurrent runs. pub(crate) mod paths; pub use paths::is_reviewable_path; @@ -29,6 +33,7 @@ use std::collections::VecDeque; use std::path::Path; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, Mutex}; +use tracing; /// Prevents a second background subagent of the same kind from spawning /// while one is already in flight. Without this, a chatty multi-turn edit @@ -44,21 +49,25 @@ static SECURITY_REVIEW_RUNNING: AtomicBool = AtomicBool::new(false); /// a background review can never wedge itself permanently disabled for the /// rest of the process if the subagent run panics before reaching its /// normal completion path. +/// +/// Usage: `let _guard = RunningGuard(&FLAG);` at the top of the spawned +/// closure. On normal exit or panic, the flag is atomically reset to `false`. struct RunningGuard(&'static AtomicBool); impl Drop for RunningGuard { fn drop(&mut self) { + tracing::debug!("[auto] RunningGuard resetting overlap flag"); self.0.store(false, Ordering::SeqCst); } } /// ─── Helpers ─── -/// Derive a human-readable message prefix from the internal kind label. /// Derive a human-readable message prefix from the internal kind label. /// /// Production callers always pass one of the three known labels /// (`"bg-test-gen"`, `"bg-arch-review"`, `"bg-security-review"`). +/// The `other` arm is a safety net with a debug assertion. fn message_prefix(kind: &str) -> &'static str { match kind { "bg-test-gen" => "Auto test-gen", @@ -77,6 +86,14 @@ fn message_prefix(kind: &str) -> &'static str { /// /// Spawn a lightweight inline code review subagent for the given file. /// +/// Flow: +/// 1. Format a review prompt using `AUTO_REVIEWER_PROMPT` + file path. +/// 2. Create an `AgentDefinition` with role `"reviewer"` (gets read-only +/// tool access by default). +/// 3. Build a `SubagentContext`, set `session_dir` and `workspaces`. +/// 4. Spawn a drain thread and run the subagent synchronously. +/// 5. Log the verdict's first line and return it. +/// /// The subagent reads the file (read-only), checks for common issues, /// and returns a concise text verdict. This runs synchronously so the /// main agent's `run_agent_turn` can inject the result back into the @@ -91,12 +108,15 @@ pub fn spawn_quick_review( session_dir: &Path, workspaces: &[std::path::PathBuf], ) -> anyhow::Result { + tracing::debug!("[auto] spawn_quick_review: {file_path}"); + let prompt = format!( "{}\n\nFile to review: {}", crate::prompts::AUTO_REVIEWER_PROMPT, file_path, ); + // Create a reviewer agent with read-only tool access by default. let def = AgentDefinition::new("quick-reviewer".to_string(), "reviewer".to_string()) .with_system_prompt(prompt); @@ -104,6 +124,7 @@ pub fn spawn_quick_review( ctx.session_dir = session_dir.to_path_buf(); ctx.workspaces = workspaces.to_vec(); + // Spawn the drain thread that forwards events to tracing. let (tx, _drain) = spawn_subagent_with_drain(|event| { match &event { SubagentEvent::ToolCall { tool, .. } => { @@ -119,6 +140,8 @@ pub fn spawn_quick_review( } }); + // Run the subagent synchronously — blocks until the review completes. + tracing::debug!("[auto-review] running quick review subagent"); let verdict = run_subagent(&ctx, &tx)?; tracing::info!( "[auto-review] quick review for '{}': {}", @@ -139,6 +162,9 @@ pub fn spawn_quick_review( /// forwarded into the subagent's own context, so a cancelled turn stops /// retrying immediately instead of burning a second attempt. /// +/// Flow: for attempt in 1..=2 → check abort → build context → spawn drain → +/// `run_subagent` → return Ok on success, log warn on failure. +/// /// Return: `Ok(output)` if either attempt succeeded, `Err(message)` /// describing the final failure if both attempts failed, or the literal /// message `"aborted by user"` if `abort_flag` was already set before an @@ -150,16 +176,25 @@ fn run_subagent_with_retry( label: &str, abort_flag: Option<&Arc>, ) -> Result { + tracing::debug!("[{label}] run_subagent_with_retry starting"); let mut last_err = String::new(); + + // Retry loop: up to 2 attempts for transient failures. for attempt in 1..=2 { + // Check the shared abort flag before starting an attempt. if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { + tracing::warn!("[{label}] aborted by user before attempt {attempt}"); return Err("aborted by user".to_string()); } + + // Build a fresh context for each attempt so state doesn't leak + // between retries. let mut ctx = build_subagent_context(def); ctx.session_dir = session_dir.to_path_buf(); ctx.workspaces = workspaces.to_vec(); ctx.abort_flag = abort_flag.cloned(); + // Spawn drain thread with per-label logging. let drain_label = label.to_string(); let (tx, _drain) = spawn_subagent_with_drain(move |event| { if let SubagentEvent::StepFailed { step, error } = &event { @@ -168,13 +203,19 @@ fn run_subagent_with_retry( }); match run_subagent(&ctx, &tx) { - Ok(output) => return Ok(output), + Ok(output) => { + let line_count = output.lines().count(); + tracing::info!("[{label}] attempt {attempt}/2 succeeded ({line_count} lines)"); + return Ok(output); + } Err(e) => { tracing::warn!("[{label}] attempt {attempt}/2 failed: {e}"); last_err = e.to_string(); } } } + + // Both attempts failed. Err(format!("failed after 2 attempts: {last_err}")) } @@ -187,6 +228,15 @@ fn run_subagent_with_retry( /// `kind` is the internal label used for logging and the `SystemNote` kind /// (e.g. `"bg-test-gen"`, `"bg-arch-review"`). The human-readable message /// prefix is derived from this label via [`message_prefix`]. +/// +/// Flow: +/// 1. Early return if `file_paths` is empty or `running_flag` is already set. +/// 2. Format the prompt from `prompt_constant` + file list. +/// 3. Spawn a dedicated OS thread that: +/// a. Installs a `RunningGuard` for panic-safe flag reset. +/// b. Creates an `AgentDefinition` and calls `run_subagent_with_retry`. +/// c. Formats the result as a `SystemNote` message. +/// d. Pushes the note onto `turn_events` for TUI consumption. fn spawn_background_review( kind: &str, running_flag: &'static AtomicBool, @@ -199,6 +249,7 @@ fn spawn_background_review( turn_events: Arc>>, abort_flag: Arc, ) { + // Early return: no files to review or another run of this kind is active. if file_paths.is_empty() { return; } @@ -210,6 +261,7 @@ fn spawn_background_review( return; } + // Copy arguments into owned values for the spawned thread. let sd = session_dir; let ws = workspaces; let events = turn_events; @@ -223,13 +275,20 @@ fn spawn_background_review( let agent_role = agent_role.to_string(); let prefix = message_prefix(kind); + // Spawn a dedicated OS thread for the background review. std::thread::spawn(move || { + // RunningGuard resets the flag on drop (including panic unwind). let _running_guard = RunningGuard(running_flag); tracing::info!("[{label}] spawning for {} file(s)", file_paths.len()); let def = AgentDefinition::new(agent_name, agent_role).with_system_prompt(prompt_text); + // Run the subagent with a single retry on failure. let result = run_subagent_with_retry(&def, &sd, &ws, &label, Some(&abort_flag)); + + // Format the result as a user-facing SystemNote message. + // Errors that mention "aborted" get a soft "cancelled" prefix; + // other errors get an "ESCALATED:" prefix to catch the user's eye. let message = match &result { Ok(output) => { let first = output.lines().next().unwrap_or(output); @@ -239,16 +298,21 @@ fn spawn_background_review( Err(e) => format!("ESCALATED: {prefix} {e}"), }; + // Push the SystemNote onto the shared turn_events queue. if let Ok(mut q) = events.lock() { q.push_back(TurnEvent::SystemNote { - kind: label, + kind: label.clone(), message, }); + } else { + tracing::warn!("[{label}] failed to lock turn_events queue — SystemNote dropped"); } + tracing::info!("[{label}] background review thread finished"); }); } -/// Collect the trailing arguments shared by all background-review spawners. +/// Collect the trailing arguments shared by all background-review spawners +/// into owned values, reducing boilerplate in each individual spawner function. fn review_args<'a>( file_paths: &'a [String], session_dir: &'a Path, @@ -266,6 +330,9 @@ fn review_args<'a>( } /// Spawn a background subagent that generates tests for modified files. +/// +/// Only fires for production source files (non-test, non-config). +/// Uses `"bg-test-gen"` as its internal kind label. pub fn spawn_background_test_gen( file_paths: &[String], session_dir: &Path, @@ -273,6 +340,7 @@ pub fn spawn_background_test_gen( turn_events: &Arc>>, abort_flag: Arc, ) { + tracing::debug!("[auto] spawn_background_test_gen: {} file(s)", file_paths.len()); let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag); spawn_background_review( "bg-test-gen", &TEST_GEN_RUNNING, @@ -282,6 +350,9 @@ pub fn spawn_background_test_gen( } /// Spawn a background architecture-review subagent. +/// +/// Reviews all reviewable files (source + config, excluding vendored/generated). +/// Uses `"bg-arch-review"` as its internal kind label. pub fn spawn_background_arch_review( file_paths: &[String], session_dir: &Path, @@ -289,6 +360,7 @@ pub fn spawn_background_arch_review( turn_events: &Arc>>, abort_flag: Arc, ) { + tracing::debug!("[auto] spawn_background_arch_review: {} file(s)", file_paths.len()); let (fps, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag); spawn_background_review( "bg-arch-review", &ARCH_REVIEW_RUNNING, @@ -301,6 +373,7 @@ pub fn spawn_background_arch_review( /// /// Only reviews production code files for security — test files and /// config files are out of scope for security review. +/// Uses `"bg-security-review"` as its internal kind label. pub fn spawn_background_security_review( file_paths: &[String], session_dir: &Path, @@ -308,6 +381,9 @@ pub fn spawn_background_security_review( turn_events: &Arc>>, abort_flag: Arc, ) { + tracing::debug!("[auto] spawn_background_security_review: {} file(s)", file_paths.len()); + + // Security review only applies to production code, not tests or config. let (_, sd, ws, te, af) = review_args(file_paths, session_dir, workspaces, turn_events, abort_flag); let prod_paths: Vec = file_paths .iter() @@ -321,15 +397,16 @@ pub fn spawn_background_security_review( ); } -/// Convenience: spawn all applicable background subagents for a set of edited -/// file paths. Called once at the end of a main agent turn. +/// Orchestrate all three background-review subagents after a main-agent turn. /// -/// Flow: always spawns arch-review and security-review if there are -/// reviewable production files → spawns test-gen only if there are source -/// files that aren't already tests. +/// Called once at the end of a turn. Early-returns if `file_paths` is empty. /// -/// `abort_flag` is cloned and forwarded to all three spawn calls so a -/// single cancellation source stops every kind of background review. +/// Flow: +/// 1. Extract production source paths → spawn test-gen + security-review. +/// 2. Extract all reviewable paths → spawn arch-review. +/// +/// `abort_flag` is cloned and forwarded to all three so a single cancellation +/// source stops every kind. pub fn spawn_all_background( file_paths: &[String], session_dir: &Path, @@ -341,7 +418,9 @@ pub fn spawn_all_background( return; } - // Background test-gen: only for non-test source files + tracing::debug!("[auto] spawn_all_background: {} file(s)", file_paths.len()); + + // Background test-gen: only for production source files (non-test, non-config). let source_paths: Vec = file_paths .iter() .filter(|p| is_production_code(p)) @@ -355,7 +434,7 @@ pub fn spawn_all_background( abort_flag.clone(), ); - // Background arch review: for all files that are reviewable + // Background arch review: for all files that are reviewable (source + config). let reviewable: Vec = file_paths .iter() .filter(|p| is_reviewable_path(p)) @@ -369,7 +448,7 @@ pub fn spawn_all_background( abort_flag.clone(), ); - // Background security review: only production source files + // Background security review: only production source files (same as test-gen). spawn_background_security_review( &source_paths, session_dir, @@ -377,6 +456,13 @@ pub fn spawn_all_background( turn_events, abort_flag, ); + + tracing::debug!( + "[auto] spawned all backgrounds: {} source, {} reviewable across {} total", + source_paths.len(), + reviewable.len(), + file_paths.len(), + ); } #[cfg(test)] @@ -385,6 +471,7 @@ mod tests { #[test] fn reviewable_path_skips_lockfiles_and_known_extensions() { + // Lockfiles, package configs, and binary assets should not trigger review. assert!(!is_reviewable_path("Cargo.lock")); assert!(!is_reviewable_path("package.json")); assert!(!is_reviewable_path("logo.svg")); @@ -392,23 +479,30 @@ mod tests { #[test] fn reviewable_path_skips_vendored_and_generated_dirs() { + // Generated and vendored directories like target/, node_modules/ should + // be excluded even when the path has no leading slash. assert!(!is_reviewable_path("target/debug/build.rs")); assert!(!is_reviewable_path("node_modules/foo/index.js")); } #[test] fn reviewable_path_accepts_ordinary_source_files() { + // Regular source files should always be reviewable. assert!(is_reviewable_path("src/main.rs")); } #[test] fn production_code_excludes_dedicated_test_directories() { + // Files under directories named test/, tests/, or __tests__/ are not + // production code (even if they have a source file extension). assert!(!is_production_code("src/tests/foo.rs")); assert!(!is_production_code("__tests__/baz.test.ts")); } #[test] fn production_code_excludes_test_filename_conventions() { + // Files matching common test-filename patterns (test_*, *_test, *_spec) + // should not be classified as production code. assert!(!is_production_code("src/foo_test.rs")); assert!(!is_production_code("src/test_foo.py")); assert!(!is_production_code("src/foo.spec.ts")); @@ -417,19 +511,23 @@ mod tests { #[test] fn production_code_does_not_false_positive_on_substring_test() { // Regression: a plain `.contains("test")` would wrongly exclude - // these legitimate production files. + // these legitimate production files because the substring "test" + // appears in names like "attestation" or "latest". assert!(is_production_code("src/attestation.rs")); assert!(is_production_code("src/latest/foo.rs")); } #[test] fn production_code_requires_known_source_extension() { + // Non-source files like README.md should not count as production code. assert!(!is_production_code("README.md")); assert!(is_production_code("src/main.rs")); } #[test] fn running_guard_resets_flag_on_drop_even_after_panic() { + // Verify that RunningGuard resets the AtomicBool to false when the + // guarded closure panics, ensuring the overlap flag never stays stuck. static TEST_FLAG: AtomicBool = AtomicBool::new(false); TEST_FLAG.store(true, Ordering::SeqCst); let result = std::panic::catch_unwind(|| { diff --git a/crates/zesdex-backend/src/app/subagent/auto/paths.rs b/crates/zesdex-backend/src/app/subagent/auto/paths.rs index 612b929..a1c0328 100644 --- a/crates/zesdex-backend/src/app/subagent/auto/paths.rs +++ b/crates/zesdex-backend/src/app/subagent/auto/paths.rs @@ -3,8 +3,18 @@ //! Determines whether a file path is reviewable and whether it represents //! production code (vs. tests, config, or documentation) — used to decide //! which background subagents should fire for a given set of modified files. +//! +//! Two main functions: +//! - `is_reviewable_path`: checks extension + filename + vendored-directory +//! heuristics; used by arch-review and the top-level gate. +//! - `is_production_code`: checks test-directory / test-filename conventions +//! vs. known source-code extensions; used by test-gen and security-review. + +use tracing; /// File extensions that should not trigger auto-review (config, lock, data). +/// +/// These are non-source-code files that do not benefit from code review. pub(crate) const SKIP_REVIEW_EXTENSIONS: &[&str] = &[ ".lock", ".md", @@ -22,6 +32,8 @@ pub(crate) const SKIP_REVIEW_EXTENSIONS: &[&str] = &[ ]; /// File names that should not trigger auto-review. +/// +/// Named well-known non-source files that are never worth reviewing. pub(crate) const SKIP_REVIEW_FILES: &[&str] = &[ "Cargo.lock", "yarn.lock", @@ -33,20 +45,36 @@ pub(crate) const SKIP_REVIEW_FILES: &[&str] = &[ /// Check whether a file path is worth auto-reviewing (not config/lock/data). /// +/// Flow: +/// 1. Normalise the path to lowercase. +/// 2. Check against `SKIP_REVIEW_FILES` (exact suffix match). +/// 3. Check against `SKIP_REVIEW_EXTENSIONS` (extension suffix match). +/// 4. Check for vendored/generated directories (`target`, `node_modules`, +/// `.git`, `vendor`) by path *segment* — not by substring — to avoid +/// false positives like `target/debug/build.rs` (which has no leading `/`). +/// /// Vendored/generated directories are matched by path *segment* rather than /// a `/target/`-style substring check — the substring form misses paths /// where the directory is the first component (e.g. `target/debug/build.rs`, /// which has no leading slash), the same class of bug fixed in /// `is_production_code` below. pub fn is_reviewable_path(path: &str) -> bool { + tracing::debug!("[subagent] is_reviewable_path: {path}"); let lower = path.to_lowercase(); + + // Skip known non-reviewable file names (lockfiles, env files, etc.). if SKIP_REVIEW_FILES.iter().any(|f| lower.ends_with(f)) { + tracing::debug!("[paths] is_reviewable_path=false (skip filename): {path}"); return false; } + // Skip known non-source extensions (images, config, docs, etc.). if SKIP_REVIEW_EXTENSIONS.iter().any(|e| lower.ends_with(e)) { + tracing::debug!("[paths] is_reviewable_path=false (skip extension): {path}"); return false; } - // Skip paths that are clearly generated or vendored + // Skip paths that are clearly generated or vendored — match by path + // *segment* (not substring) to handle leading-component paths without + // a `/` prefix. let in_vendored_dir = std::path::Path::new(&lower).components().any(|c| { matches!( c, @@ -55,8 +83,10 @@ pub fn is_reviewable_path(path: &str) -> bool { ) }); if in_vendored_dir { + tracing::debug!("[paths] is_reviewable_path=false (vendored/generated dir): {path}"); return false; } + tracing::debug!("[paths] is_reviewable_path=true: {path}"); true } @@ -64,6 +94,15 @@ pub fn is_reviewable_path(path: &str) -> bool { /// (vs. tests, config, or documentation) — used to decide if a test-gen /// or security-review background subagent should fire. /// +/// Flow: +/// 1. Normalise the path to lowercase. +/// 2. Check each path *segment* for a test-directory name +/// (`test`/`tests`/`__tests__`). +/// 3. Check the file stem for test-filename conventions +/// (`test_*`, `*_test`, `*.test.*`, `*_spec.*`, `spec.*`). +/// 4. If neither test-dir nor test-filename, check the extension against +/// a known set of source-code extensions. +/// /// Matches test-ness by path *segment* (a directory literally named /// "test"/"tests"/"__tests__") or by filename convention /// (`foo_test.rs`, `foo.test.ts`, `test_foo.py`, `foo_spec.rb`), not by a @@ -71,9 +110,11 @@ pub fn is_reviewable_path(path: &str) -> bool { /// legitimate production files like `src/attestation.rs` or /// `src/latest/foo.rs`. pub(crate) fn is_production_code(path: &str) -> bool { + tracing::debug!("[subagent] is_production_code: {path}"); let lower = path.to_lowercase(); let path_obj = std::path::Path::new(&lower); + // Check if any path component is a test directory name. let in_test_dir = path_obj.components().any(|c| { matches!( c, @@ -82,6 +123,7 @@ pub(crate) fn is_production_code(path: &str) -> bool { ) }); + // Check the file stem (filename without extension) for test/spec conventions. let file_stem = path_obj.file_stem().and_then(|s| s.to_str()).unwrap_or(""); let is_test_filename = file_stem.starts_with("test_") || file_stem.ends_with("_test") @@ -94,13 +136,16 @@ pub(crate) fn is_production_code(path: &str) -> bool { .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("spec")); + // If the path is in a test directory or matches a test filename pattern, + // it is not production code. if in_test_dir || is_test_filename { + tracing::debug!("[paths] is_production_code=false (test dir or filename): {path}"); return false; } - // Only source files — use Path::extension() to avoid clippy - // case_sensitive_file_extension_comparisons lint - path_obj + // Only source files count — use Path::extension() to avoid clippy + // case_sensitive_file_extension_comparisons lint. + let is_source = path_obj .extension() .and_then(|ext| ext.to_str()) .is_some_and(|ext| { @@ -120,5 +165,8 @@ pub(crate) fn is_production_code(path: &str) -> bool { | "h" | "hpp" ) - }) + }); + + tracing::debug!("[paths] is_production_code={is_source} (ext check): {path}"); + is_source } diff --git a/crates/zesdex-backend/src/app/subagent/context.rs b/crates/zesdex-backend/src/app/subagent/context.rs index 7e82b94..c547ba2 100644 --- a/crates/zesdex-backend/src/app/subagent/context.rs +++ b/crates/zesdex-backend/src/app/subagent/context.rs @@ -3,6 +3,7 @@ use super::spawn::AgentDefinition; use std::path::PathBuf; use std::sync::{atomic::AtomicBool, Arc, Mutex}; +use tracing; /// Default read-only tool names granted to `role == "reviewer"` agents. pub const REVIEWER_ALLOWED: &[&str] = &["read", "grep", "glob", "recall", "remember"]; @@ -37,6 +38,11 @@ pub struct SubagentContext { /// Return: a context with empty `system_prompt`, empty `workspaces`, /// empty `session_dir`, resolved `max_steps`, and the resolved allowed-tool list. pub fn build_subagent_context(def: &AgentDefinition) -> SubagentContext { + tracing::debug!( + "[subagent-context] building context for role='{}' name='{}'", + def.role, + def.name, + ); let allowed_tools = def.allowed_tools.clone().unwrap_or_else(|| { if def.role == "reviewer" { REVIEWER_ALLOWED diff --git a/crates/zesdex-backend/src/app/subagent/division.rs b/crates/zesdex-backend/src/app/subagent/division.rs index c2755d6..acaaba1 100644 --- a/crates/zesdex-backend/src/app/subagent/division.rs +++ b/crates/zesdex-backend/src/app/subagent/division.rs @@ -8,9 +8,12 @@ //! Core Intelligence picks one of these three tiers per node, matched to //! what that node's specific directive needs — this keeps the Harness //! gate meaningful while the node roster itself stays fully dynamic. +//! +//! Tiers (least → most privileged): `read` < `write` < `full`. /// The three tool-access tiers a hive-mind node can be granted. pub mod tool_scope { + use tracing; /// Read-only investigation: no file mutation, no shell, no VCS. pub const READ: &str = "read"; /// Read-tier plus file mutation and non-destructive shell (tests/builds). @@ -87,12 +90,22 @@ pub mod tool_scope { /// Unrecognized scope strings fall back to `READ` — the least-privileged /// tier — rather than silently granting broader access. /// + /// Flow: match `scope` against the three known constants → return the + /// corresponding static slice → collect into owned `Vec`. + /// /// Return: an owned `Vec` suitable for `AgentDefinition::with_allowed_tools`. pub fn tools_for(scope: &str) -> Vec { + // Select the tool list matching the requested access tier. + // Unknown scope names are treated as "read" (least privilege). let tools: &[&str] = match scope { FULL => FULL_TOOLS, WRITE => WRITE_TOOLS, - _ => READ_TOOLS, + _ => { + tracing::debug!( + "[division] unknown scope '{scope}' — falling back to READ", + ); + READ_TOOLS + } }; tools.iter().map(|s| (*s).to_string()).collect() } @@ -102,6 +115,7 @@ pub mod tool_scope { mod tests { use super::tool_scope::{tools_for, FULL, READ, WRITE}; + /// Verify the READ tier does not contain write or bash tools. #[test] fn read_tier_excludes_write_tools() { let tools = tools_for(READ); @@ -109,6 +123,7 @@ mod tests { assert!(!tools.contains(&"bash".to_string())); } + /// Verify the WRITE tier includes bash and write but not delete or git. #[test] fn write_tier_includes_bash_but_not_delete_or_git() { let tools = tools_for(WRITE); @@ -118,6 +133,7 @@ mod tests { assert!(!tools.contains(&"git_operator".to_string())); } + /// Verify the FULL tier includes delete and git tools. #[test] fn full_tier_includes_delete_and_git() { let tools = tools_for(FULL); @@ -125,6 +141,7 @@ mod tests { assert!(tools.contains(&"git_operator".to_string())); } + /// Verify that an unrecognized scope name falls back to the READ tier. #[test] fn unknown_scope_falls_back_to_read() { let tools = tools_for("bogus"); @@ -132,6 +149,7 @@ mod tests { assert!(!tools.contains(&"delete".to_string())); } + /// Verify the tier hierarchy: READ ⊂ WRITE ⊂ FULL (each is a strict superset). #[test] fn read_tier_is_subset_of_write_tier_and_write_is_subset_of_full() { use std::collections::HashSet; diff --git a/crates/zesdex-backend/src/app/subagent/engine.rs b/crates/zesdex-backend/src/app/subagent/engine.rs index 9c1f9af..8c9b00e 100644 --- a/crates/zesdex-backend/src/app/subagent/engine.rs +++ b/crates/zesdex-backend/src/app/subagent/engine.rs @@ -1,9 +1,23 @@ //! Subagent execution loop: drive an LLM conversation, run tools, and stream //! progress events to the parent via an mpsc channel. //! +//! This is the core orchestrator for all subagent runs — inline reviews, +//! background reviews, and hive-mind processing nodes all pass through +//! [`run_subagent`]. +//! +//! Flow: build system prompt (with workspace tree) → cache provider config +//! → for each step up to `max_steps`: check abort flag, call LLM (streaming +//! with abort-per-SSE-event), execute gated tool calls in parallel via +//! `std::thread::scope`, emit progress events, break on first text-only +//! response. +//! //! Tool gating and pattern-constant definitions live in sibling modules //! (`gating`, `provider`, `tools`, `workspace`) rather than here, so each //! concern is independently testable and maintainable. +//! +//! Why synchronous: the loop runs on a dedicated OS thread so the main +//! async event loop is not blocked. All I/O inside tool calls is +//! synchronous (`ureq`, `std::fs`, etc.). use super::context::SubagentContext; use super::event::SubagentEvent; @@ -11,32 +25,55 @@ use super::gating::gate_subagent_tool_call; use super::provider::{require_api_key, resolve_provider_config}; use super::tools::build_subagent_tools; use super::workspace::generate_workspace_tree; +use crate::app::util::backoff::backoff_seconds; use crate::dto::chat::message::ChatMessage; use crate::dto::provider::request::ToolDef; use crate::tool::tool_is_risky; -use crate::app::util::backoff::backoff_seconds; use std::time::Duration; use tokio::sync::mpsc; +use tracing; /// Exponential backoff with ±25% jitter for subagent step retries, capped at 16s. +/// +/// Delegates to `backoff_seconds` with a 16-second cap. +/// Used in the step-level retry loop when an LLM call fails transiently. fn step_retry_delay(attempt: u32) -> Duration { + tracing::debug!("[subagent] step_retry_delay attempt={attempt}"); backoff_seconds(attempt, 16) } /// Heuristic to decide whether the error is worth retrying. +/// +/// Never retries: +/// - Authentication / billing errors (waste of time, same result). +/// - Abort / user cancellation (the caller explicitly cancelled). +/// +/// Retries everything else: timeout, 5xx, rate-limit, network blip. fn should_retry_subagent_step(err_str: &str) -> bool { - // Never retry auth/billing failures + // Never retry auth/billing failures — they require user intervention. if crate::service::provider::is_auth_error(err_str) { + tracing::debug!("[subagent] not retrying auth error: {err_str}"); return false; } - // Never retry abort or user cancellation + // Never retry an abort or user cancellation. if err_str.to_lowercase().contains("aborted") { + tracing::debug!("[subagent] not retrying abort: {err_str}"); return false; } - // Everything else (timeout, 5xx, rate-limit, network blip) is retryable + // Everything else (timeout, 5xx, rate-limit, network blip) is retryable. + tracing::debug!("[subagent] will retry step error: {err_str}"); true } +/// Format a free-form progress string from streaming LLM output. +/// +/// Flow: split text into non-empty lines → +/// - 0 lines → `"{prefix}..."` +/// - 1 line → `"{prefix}: {line}"` +/// - 2+ lines → last 2 lines joined by newline +/// +/// The last-2-lines heuristic gives a compact but meaningful progress +/// peek without overwhelming the UI with every intermediate token. fn format_subagent_progress(prefix: &str, text: &str) -> String { let lines: Vec<&str> = text.lines().filter(|l| !l.trim().is_empty()).collect(); if lines.is_empty() { @@ -44,6 +81,8 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String { } else if lines.len() == 1 { format!("{prefix}: {}", lines[0]) } else { + // Show the last two meaningful lines of reasoning/response text + // so the user gets the tail of the LLM's current output. lines[lines.len() - 2..].join("\n") } } @@ -51,24 +90,43 @@ fn format_subagent_progress(prefix: &str, text: &str) -> String { /// Synchronous subagent entry point: run up to `ctx.max_steps` iterations /// of the LLM tool loop. /// -/// Flow: inject system prompt (with workspace tree if available) → for each -/// step: resolve provider config, build an LLM client, call -/// `chat_with_tools_streaming` (with abort check per SSE event), process -/// tool calls (gated against both the allowlist and Harness-style content -/// safety checks) or collect text output → send `SubagentEvent`s on `tx` → -/// break on first text-only (non-empty) response. +/// Flow: +/// 1. Build system prompt with optional workspace tree. +/// 2. Build `ToolCtx` (with session dir, workspaces, origin). +/// 3. Build tool list + tool definitions once (before the loop). +/// 4. Cache provider config once (before the loop). +/// 5. Fail fast if no API key is configured. +/// 6. For each step (up to `max_steps`): +/// a. Check abort flag. +/// b. Call LLM via `chat_with_tools_streaming` with per-SSE-event +/// abort checking and up to 3 step-level retries. +/// c. Emit progress / usage / step events on the mpsc channel. +/// d. Execute tool calls in parallel via `std::thread::scope`, +/// each gated by the three-layer pipeline (allowlist → risky → +/// content-safety). +/// e. Auto-share read-only tool results to `workflow_findings`. +/// f. Break on first text-only (non-empty) response. +/// 7. Send `Completed` event and return the accumulated output. /// -/// Why: runs synchronously on a dedicated thread so the main async event +/// Why synchronous: runs on a dedicated OS thread so the main async event /// loop is not blocked. Tool gating prevents restricted, risky, or /// malicious/poor-quality tool calls from executing. /// /// Return: the concatenated text output, or an `anyhow::Error` if the LLM -/// call fails at any step. +/// call fails at any step (after exhausting retries). pub fn run_subagent( ctx: &SubagentContext, tx: &mpsc::Sender, ) -> anyhow::Result { + tracing::debug!( + "[subagent] run_subagent starting: max_steps={}, allowed_tools={}", + ctx.max_steps, + ctx.allowed_tools.len(), + ); + + // Accumulator for the final text output returned to the caller. let mut output = String::new(); + // Conversation history fed to the LLM on each step. let mut messages: Vec = Vec::new(); // Build system prompt with workspace tree context if we have workspaces, @@ -81,6 +139,8 @@ pub fn run_subagent( }; messages.push(ChatMessage::system(system_with_context)); + // Tool context: provides session dir, workspaces, origin tag, and + // optional workflow-findings Arc to every tool execution. let tool_ctx = crate::tool::ToolCtx::builder() .session_dir(ctx.session_dir.clone()) .workspaces(ctx.workspaces.clone()) @@ -88,7 +148,7 @@ pub fn run_subagent( .workflow_findings(ctx.workflow_findings.clone()) .build(); - // Build tool list once before the loop + // Build tool list once before the loop (not on every step). let (tools, tdefs) = build_subagent_tools(&ctx.allowed_tools); let tdefs_opt: Option> = if tdefs.is_empty() { None } else { Some(tdefs) }; @@ -112,12 +172,19 @@ pub fn run_subagent( anyhow::bail!(error); } + // Create the LLM client with the resolved provider config. let client = crate::service::provider::LlmClient::new(api_key, model, base_url); + // ── Main step loop ── + // Each iteration: check abort → call LLM → process tool calls or + // accumulate text output. Breaks on first non-empty text-only response. for step in 0..ctx.max_steps { + tracing::debug!("[subagent] step {step} starting"); + // Check abort flag before each LLM call so a stuck subagent can // be cancelled from the parent (mirrors main agent behaviour). if crate::app::util::abort::is_aborted(&ctx.abort_flag) { + tracing::warn!("[subagent] abort detected at step {step}"); let _ = tx.blocking_send(SubagentEvent::StepFailed { step, error: "subagent aborted by parent".to_string(), @@ -125,9 +192,13 @@ pub fn run_subagent( anyhow::bail!("subagent aborted by parent at step {step}"); } + // Clone the sender so the SSE-event callback can send progress + // updates without holding a reference to the outer `tx`. let tx_clone = tx.clone(); + // Accumulators for streaming reasoning and reply tokens. let mut current_thinking = String::new(); let mut current_token = String::new(); + // Usage captured from the last streaming event (last writer wins). let mut step_usage: Option<(u64, u64)> = None; // Use streaming API so the abort flag is checked per SSE event, @@ -182,6 +253,7 @@ pub fn run_subagent( ctx.abort_flag.as_deref(), ); + // ── Handle streaming result ── match stream_result { Ok(result) => break result, Err(e) => { @@ -189,6 +261,7 @@ pub fn run_subagent( let is_abort = crate::app::util::abort::is_aborted(&ctx.abort_flag) || err_str.contains("aborted"); + // Exhausted retries or unrecoverable error — bail. if is_abort || !should_retry_subagent_step(&err_str) || step_attempt >= max_step_retries { let _ = tx.blocking_send(SubagentEvent::StepFailed { step, @@ -204,6 +277,7 @@ pub fn run_subagent( anyhow::bail!("subagent call failed at step {step} after {step_attempt} attempt(s): {err_str}"); } + // Transient error — wait with exponential backoff then retry. let delay = step_retry_delay(step_attempt); tracing::warn!( "[subagent] step {step} attempt {step_attempt}/{max_step_retries} failed: {err_str}. \ @@ -215,13 +289,15 @@ pub fn run_subagent( std::thread::sleep(delay); } } - }; + }; // end step-level retry loop - // Emit the token usage from this streaming call so the parent's - // drain thread can accumulate it and update the Usage panel. - // Without this, the Usage panel always shows zeros because the - // subagent never tells the parent about the tokens consumed. + // ── Emit token usage ── + // Send the token consumption to the parent's drain thread so the + // Usage panel can accumulate subagent tokens separately from main + // agent tokens. let (mut tok_in, mut tok_out) = returned_usage.unwrap_or((0, 0)); + // Fallback estimation: if the provider didn't return usage, estimate + // from character counts (roughly 4 chars per token). if tok_in == 0 { let prompt_chars: usize = messages .iter() @@ -239,12 +315,14 @@ pub fn run_subagent( tokens_out: tok_out, }); + // Check whether the response includes tool calls or is text-only. let has_tool_calls = response.tool_calls.is_some() && response .tool_calls .as_ref() .is_some_and(|tc| !tc.is_empty()); + // Extract text content from the response (may be empty). let content = response.content.clone().unwrap_or_default(); // Emit thinking/reasoning text as StepCompleted so the parent's @@ -255,17 +333,26 @@ pub fn run_subagent( }); } + // ── Branch: tool calls vs. text-only response ── if has_tool_calls { let tool_calls = response.tool_calls.clone().unwrap_or_default(); + // Push the assistant message with tool_calls into the conversation + // so the next LLM call sees the tool requests. messages.push(response); + // Collect results from all parallel tool executions. let mut results_vec = Vec::new(); + + // Execute tool calls in parallel using std::thread::scope (scoped + // threads that can borrow from the parent stack). std::thread::scope(|s| { let mut handles = Vec::new(); let tools_ref = &tools; let tool_ctx_ref = &tool_ctx; for tool_call in &tool_calls { + // Each tool call runs in its own scoped thread so all + // parallel calls execute concurrently. let handle = s.spawn(move || { // Check abort flag before each tool execution if crate::app::util::abort::is_aborted(&ctx.abort_flag) { @@ -273,10 +360,13 @@ pub fn run_subagent( } let tool_name = &tool_call.function.name; + // Sanitise tool arguments to avoid JSON injection in logs. let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); let explicitly_allowed = ctx.allowed_tools.contains(tool_name); let generally_allowed = ctx.allowed_tools.is_empty() || explicitly_allowed; + // ── Three-layer gating pipeline ── + // Level 1: allowlist check — is this tool even permitted? if !generally_allowed { return (tool_call, Ok(format!("tool '{tool_name}' not allowed for this subagent"))); @@ -288,12 +378,17 @@ pub fn run_subagent( } // Level 3: Harness-style content safety gating + // (path traversal, stub/denial/assumption scanning, + // bash exfiltration, destructive commands). if let Some(block_reason) = gate_subagent_tool_call(tool_name, &args) { return (tool_call, Ok(format!("Blocked by subagent gate: {block_reason}"))); } + // Find the Tool impl by name and execute. let result = match tools_ref.iter().find(|t| t.name() == tool_name.as_str()) { Some(tool) => { + // For write/edit: store a pre-edit blob of the file + // so the parent can reconstruct edits for undo/history. let is_edit = tool_name == "write" || tool_name == "edit"; if is_edit && !tool_call.id.is_empty() { if let Ok(conn) = crate::model::msglog::open_or_create(&ctx.session_dir) { @@ -312,8 +407,10 @@ pub fn run_subagent( } } + // Execute the tool with the subagent's ToolCtx. let run_res = tool.run(tool_ctx_ref, &args); + // Log write/edit tool calls for audit trail. if is_edit && run_res.is_ok() { let session_id = ctx.session_dir .file_name() @@ -332,18 +429,24 @@ pub fn run_subagent( }); handles.push(handle); } + // Wait for all parallel tool calls to complete. for h in handles { if let Ok(res) = h.join() { results_vec.push(res); } } - }); + }); // end std::thread::scope + // ── Process tool results ── + // Iterate over the results (in the same order the handles were + // pushed, which matches the original tool_calls order) and push + // result messages into the conversation. for (tool_call, result) in results_vec { let tool_name = &tool_call.function.name; let args = crate::dto::chat::tool::sanitize_tool_arguments(&tool_call.function.arguments); + // Emit ToolCall event so the parent can show which tool ran. let _ = tx.blocking_send(SubagentEvent::ToolCall { tool: tool_name.clone(), args: args.clone(), @@ -351,15 +454,19 @@ pub fn run_subagent( match result { Ok(output_text) => { + // Push the tool result into the conversation history. messages.push(ChatMessage::tool_result( tool_call.id.clone(), output_text.clone(), )); + // Emit ToolResult event for parent progress tracking. let _ = tx.blocking_send(SubagentEvent::ToolResult { tool: tool_name.clone(), args: args.clone(), }); + // Auto-share read-only tool results into the shared + // workflow_findings so sibling nodes can see them. let is_readonly = tool_name == "read" || tool_name == "view_file" || tool_name == "grep" @@ -374,6 +481,8 @@ pub fn run_subagent( let args_json = serde_json::to_string(&args).unwrap_or_default(); let mut shared_text = output_text; + // Cap shared findings at 50 KB to avoid + // unbounded memory in the findings list. if shared_text.len() > 50_000 { shared_text.truncate(50_000); shared_text.push_str("\n...[truncated]"); @@ -385,6 +494,7 @@ pub fn run_subagent( } Err(e) => { let err_str = e.to_string(); + // Abort during tool execution — bail immediately. if err_str.contains("subagent aborted by parent") { let _ = tx.blocking_send(SubagentEvent::StepFailed { step, @@ -392,6 +502,8 @@ pub fn run_subagent( }); anyhow::bail!("{err_str}"); } + // Push the error as a tool result so the LLM can + // see it and potentially retry. let msg = format!("tool '{tool_name}' failed: {e}"); messages.push(ChatMessage::tool_result(tool_call.id.clone(), msg.clone())); let _ = tx.blocking_send(SubagentEvent::ToolResult { @@ -402,7 +514,7 @@ pub fn run_subagent( } } } else { - // Text-only response — accumulate and finish + // ── Text-only response — accumulate and finish ── if !content.is_empty() { output.push_str(&content); output.push('\n'); @@ -410,13 +522,16 @@ pub fn run_subagent( let _ = tx.blocking_send(SubagentEvent::StepCompleted { output: content.clone(), }); - // Break only when we got real content; empty means something went wrong + // Break only when we got real content; empty content means the + // LLM produced no text (rare edge case), and we continue looping. if !content.is_empty() { break; } } } + // ── Subagent run complete ── + tracing::debug!("[subagent] run completed, output len={}", output.len()); let _ = tx.blocking_send(SubagentEvent::Completed); Ok(output) } diff --git a/crates/zesdex-backend/src/app/subagent/event.rs b/crates/zesdex-backend/src/app/subagent/event.rs index 37aeb53..885fb6b 100644 --- a/crates/zesdex-backend/src/app/subagent/event.rs +++ b/crates/zesdex-backend/src/app/subagent/event.rs @@ -4,24 +4,41 @@ use serde_json::Value; /// Progress and outcome events emitted by `run_subagent` as it processes /// LLM responses and tool calls. +/// +/// The parent thread drains these from an mpsc channel and forwards them +/// to the UI or the parent's event system depending on the caller +/// (inline review, background review, or hive-mind node). #[derive(Debug, Clone)] pub enum SubagentEvent { + /// One step produced text output — emitted whenever the LLM returns + /// a non-empty `content` field (whether or not tool calls are present). StepCompleted { output: String, }, + /// A step failed catastrophically (all retries exhausted, unrecoverable + /// error, or user abort). Includes the step number and the error message. StepFailed { step: usize, error: String, }, + /// Sentinel: the entire subagent loop finished — either by hitting + /// a text-only response (normal path) or after exhausting `max_steps`. Completed, + /// A tool is about to be invoked. Used for progress reporting so the + /// parent can show which tool is currently running. ToolCall { tool: String, args: Value, }, + /// A tool invocation returned a result (success or error). Used for + /// progress reporting and, in the background-review path, for logging. ToolResult { tool: String, args: Value, }, + /// Free-form progress string emitted during LLM streaming (thinking + /// tokens / reply tokens) or during retry delays. Displayed in the + /// subagent's progress indicator. Progress(String), /// Token usage reported by the LLM after one streaming call inside the /// subagent. The drain thread accumulates these across all steps and diff --git a/crates/zesdex-backend/src/app/subagent/gating.rs b/crates/zesdex-backend/src/app/subagent/gating.rs index 4dc4b85..0d5d287 100644 --- a/crates/zesdex-backend/src/app/subagent/gating.rs +++ b/crates/zesdex-backend/src/app/subagent/gating.rs @@ -5,6 +5,14 @@ //! path reads — regardless of the allowed-tools list. Tools that are not //! risky only get the basic allowlist check. //! +//! The gating pipeline has three layers, applied in order inside +//! `gate_subagent_tool_call`: +//! 1. **Allowlist check** (in `engine.rs`): is the tool permitted at all? +//! 2. **Risky-tool check** (in `engine.rs`): does a risky tool need +//! explicit permission? +//! 3. **Content-safety check** (this file): stub/denial/assumption scanning, +//! path traversal, bash exfiltration, destructive commands. +//! //! Security: subagent tool gating mirrors the main agent's `Guard` checks //! (path traversal, reason validation, stub/denial/assumption scanning, //! bash exfiltration and destructive-pattern detection) so that subagents @@ -14,14 +22,25 @@ use crate::app::guard::patterns::{ ASSUMPTION_PATTERNS, DENIAL_PATTERNS, EXFIL_PATTERNS, MIN_REASON_LEN, SENSITIVE_PATH_PATTERNS, STUB_PATTERNS, }; +use tracing; /// Gate a tool call in the subagent context. Returns `Some(block_reason)` if /// the call should be blocked, `None` to allow. +/// +/// This is the third and final layer of the three-layer gating pipeline +/// (see module-level docs). It runs content-safety checks that are +/// tool-specific: +/// - `write` / `edit` / `delete`: path traversal, reason length, stub/denial/assumption +/// - `bash`: path traversal, exfiltration, sensitive paths, destructive commands, stubs +/// - `git_operator`: reason length pub(crate) fn gate_subagent_tool_call( tool_name: &str, args: &serde_json::Value, ) -> Option { - // File-mutating tools: write / edit / delete + tracing::debug!("[subagent] gating tool call: {tool_name}"); + + // ── File-mutating tools: write / edit / delete ── + // Block path-traversal attempts in the `path` argument (e.g. `../../etc`). if matches!(tool_name, "write" | "edit" | "delete") { if let Some(path) = args.get("path").and_then(|v| v.as_str()) { if path.contains("..") { @@ -30,7 +49,7 @@ pub(crate) fn gate_subagent_tool_call( } } - // write / edit / delete require a non-trivial `reason` + // write / edit / delete require a non-trivial `reason` explaining the change. if matches!(tool_name, "write" | "edit" | "delete") { let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or(""); if reason.trim().len() < MIN_REASON_LEN { @@ -40,14 +59,15 @@ pub(crate) fn gate_subagent_tool_call( } } - // write / edit content must not contain stubs, denial, or assumption language + // ── write / edit content must not contain stub, denial, or assumption patterns ── if matches!(tool_name, "write" | "edit") { let content = match tool_name { "write" => args.get("content").and_then(|v| v.as_str()).unwrap_or(""), "edit" => { + // For edits, scan both old and new text together to catch + // stubs that might appear in either segment. let old = args.get("old").and_then(|v| v.as_str()).unwrap_or(""); let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); - // For edits, scanning old+new together catches stubs in both return if contains_any(old, STUB_PATTERNS) || contains_any(new, STUB_PATTERNS) { @@ -71,6 +91,7 @@ pub(crate) fn gate_subagent_tool_call( } _ => "", }; + // Scan write content for stub/denial/assumption patterns. if contains_any(content, STUB_PATTERNS) { return Some( "content contains stub/placeholder pattern; production code must be fully implemented" @@ -91,13 +112,15 @@ pub(crate) fn gate_subagent_tool_call( } } - // Bash: exfiltration, sensitive paths, destructive commands + // ── Bash: exfiltration, sensitive-path reads, destructive commands, stubs ── if tool_name == "bash" { let cmd = args.get("command").and_then(|v| v.as_str()).unwrap_or(""); + // Block path traversal in bash commands. if cmd.contains("..") { return Some("path traversal detected in bash command".to_string()); } - // Only check exfiltration for non-standard commands + // Only check exfiltration for non-standard commands. Standard commands + // (cargo, rustc, git, ls, etc.) are trusted and don't need scanning. let is_standard = cmd.trim_start().starts_with("cargo") || cmd.trim_start().starts_with("rustc") || cmd.trim_start().starts_with("git ") @@ -109,6 +132,7 @@ pub(crate) fn gate_subagent_tool_call( || cmd.trim_start().starts_with("grep") || cmd.trim_start().starts_with("test"); if !is_standard { + // Scan for data-exfiltration patterns like curl to external hosts. for pat in EXFIL_PATTERNS { if cmd.contains(pat) { return Some(format!( @@ -117,11 +141,13 @@ pub(crate) fn gate_subagent_tool_call( } } } + // Block commands that read/write sensitive system paths. for pat in SENSITIVE_PATH_PATTERNS { if cmd.contains(pat) { return Some(format!("refused to read/write sensitive path '{pat}'")); } } + // Hard-coded destructive command patterns that should never execute. let dangerous = [ "rm -rf /", "rm -rf --no-preserve-root", @@ -142,12 +168,13 @@ pub(crate) fn gate_subagent_tool_call( return Some(format!("destructive command pattern blocked: {pat}")); } } + // Scan for stub patterns in bash commands. if contains_any(cmd, STUB_PATTERNS) { return Some("bash command contains stub pattern".to_string()); } } - // git_operator: require reason + // ── git_operator: require a non-trivial reason ── if tool_name == "git_operator" { let reason = args.get("reason").and_then(|v| v.as_str()).unwrap_or(""); if reason.trim().len() < MIN_REASON_LEN { @@ -155,10 +182,16 @@ pub(crate) fn gate_subagent_tool_call( } } + // All checks passed — allow the tool call. None } /// Check if `text` matches any pattern (case-insensitive substring). +/// +/// Normalises both `text` and each pattern to lowercase before comparing. +/// This means patterns like `"TODO"` will also match `"todo"` in source code. +/// +/// Return: `true` if any pattern is found as a case-insensitive substring. pub(crate) fn contains_any(text: &str, patterns: &[&str]) -> bool { let lower = text.to_lowercase(); patterns.iter().any(|p| lower.contains(&p.to_lowercase())) diff --git a/crates/zesdex-backend/src/app/subagent/mod.rs b/crates/zesdex-backend/src/app/subagent/mod.rs index 01306e3..f359ece 100644 --- a/crates/zesdex-backend/src/app/subagent/mod.rs +++ b/crates/zesdex-backend/src/app/subagent/mod.rs @@ -1,5 +1,17 @@ //! Subagent management: spawning, context building, engine loop, and //! progress events. +//! +//! Module overview: +//! - `auto` — background/auto-review subagents spawned post-turn +//! - `context` — builds `SubagentContext` from `AgentDefinition` with tool allowlists +//! - `division` — hive-mind access tiers (`read` / `write` / `full`) and tool lists +//! - `engine` — synchronous subagent execution loop (LLM + tools) +//! - `event` — subagent lifecycle events (tool calls, results, progress) +//! - `gating` — tool access gating per agent definition +//! - `provider` — LLM provider resolution for subagent calls +//! - `spawn` — `AgentDefinition` and `TurnCtx` types for configuring subagents +//! - `tools` — tool set construction for the subagent harness +//! - `workspace` — workspace tree generation for the system prompt pub mod auto; pub mod context; pub mod division; diff --git a/crates/zesdex-backend/src/app/subagent/provider.rs b/crates/zesdex-backend/src/app/subagent/provider.rs index aa7689e..e397e8e 100644 --- a/crates/zesdex-backend/src/app/subagent/provider.rs +++ b/crates/zesdex-backend/src/app/subagent/provider.rs @@ -3,30 +3,50 @@ //! Resolves the API key, model, and base URL from persisted app config, //! matching the main agent's credential resolution exactly, so subagents //! automatically inherit the same provider settings. +//! +//! Flow: `resolve_provider_config()` loads settings + app config from disk, +//! then delegates to `crate::service::provider::resolve_api_key` for the +//! three-tier key resolution. `require_api_key()` provides a fast-fail check +//! before the first LLM call. +use tracing; use zesdex_cms::domain::repository::AppConfigRepository; use zesdex_cms::domain::repository::SettingsRepository; /// Resolve the API key, model, and base URL from persisted app config. /// -/// Flow: try the settings key for the active provider → fall back to the -/// provider's `api_key_env` env-var → fall back to the provider's -/// `default_api_key` → fall back to an empty string. +/// Flow: +/// 1. Load `JsonSettingsRepository` from the store base directory. +/// 2. Load `JsonAppConfigRepository` from the same directory. +/// 3. Delegate to `crate::service::provider::resolve_api_key` for the +/// three-tier key resolution (settings key → env var → default). +/// 4. Extract `model` from settings and `base_url` from the app config. /// /// Return: `(api_key, model, optional_base_url, provider_name)`. `api_key` /// is empty when every resolution path was exhausted — callers must check /// for this before issuing requests (see `run_subagent`). +/// +/// Logging: emits a `tracing::warn!` when the key is empty after all +/// resolution paths have been tried. pub(crate) fn resolve_provider_config() -> (String, String, Option, String) { + tracing::debug!("[subagent] resolving provider config"); + + // Load the base store directory from the global Store singleton. let store_base_dir = zesdex_entities::domain::common::store::Store::new().base_dir; + + // Load user settings (provider choice, model, API key reference). let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() .load(&store_base_dir) .unwrap_or_default(); + + // Load app config (per-provider base URLs, API key overrides). let app_config = zesdex_cms::infrastructure::persistence::app_config_repo::JsonAppConfigRepository::new() .load(&store_base_dir) .unwrap_or_default(); + // Resolve the actual API key through the three-tier fallback pipeline. let api_key = crate::service::provider::resolve_api_key(&settings, &app_config); if api_key.is_empty() { tracing::warn!( @@ -34,18 +54,28 @@ pub(crate) fn resolve_provider_config() -> (String, String, Option, Stri settings.provider ); } + + // Model name from settings; optional base URL override from app config. let model = settings.model.clone(); let base_url = app_config .providers .get(&settings.provider) .map(|p| p.api_base.clone()); + tracing::debug!( + "[subagent] resolved provider='{}' model='{}' key_len={}", + settings.provider, model, api_key.len(), + ); + (api_key, model, base_url, settings.provider) } /// Reject an empty API key with an actionable error instead of letting the /// caller send a request that is guaranteed to fail once it reaches the network. /// +/// Used as a fast-fail check in `run_subagent` before the first LLM call, +/// saving a full retry cycle against an unauthenticated endpoint. +/// /// Return: `Ok(())` if `api_key` is non-empty, `Err` with a message naming /// `provider` and where to fix it otherwise. pub(crate) fn require_api_key(api_key: &str, provider: &str) -> anyhow::Result<()> { diff --git a/crates/zesdex-backend/src/app/subagent/spawn.rs b/crates/zesdex-backend/src/app/subagent/spawn.rs index 7ef1fbe..f2d462c 100644 --- a/crates/zesdex-backend/src/app/subagent/spawn.rs +++ b/crates/zesdex-backend/src/app/subagent/spawn.rs @@ -1,4 +1,4 @@ -//! `AgentDefinition` -- declarative specification for instantiating a +//! `AgentDefinition` — declarative specification for instantiating a //! subagent from workflow scripts or programmatic calls. //! //! Also provides a shared [`spawn_subagent_with_drain`] helper that @@ -7,16 +7,27 @@ use super::event::SubagentEvent; use serde::{Deserialize, Serialize}; +use tracing; /// Declarative specification for instantiating a subagent: name, role, /// optional system prompt, allowed tools, step budget, and temperature. +/// +/// Created via `AgentDefinition::new(name, role)` and customised through +/// builder methods (`.with_system_prompt()`, `.with_allowed_tools()`, etc.). +/// Consumed by `build_subagent_context` to produce a `SubagentContext`. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct AgentDefinition { + /// Human-readable name for logging and debugging (e.g. `"quick-reviewer"`). pub name: String, + /// Functional role used for tool-default resolution (`"reviewer"`, `"coder"`). pub role: String, + /// Optional system prompt to override the context builder's default. pub system_prompt: Option, + /// Optional tool allowlist. `None` means role-based defaults apply. pub allowed_tools: Option>, + /// Optional step budget. `None` means no limit (usize::MAX). pub max_steps: Option, + /// Optional temperature override for the LLM call. pub temperature: Option, } @@ -24,6 +35,7 @@ impl AgentDefinition { /// Create an agent definition with the required name and role; all /// optional fields start as `None`. pub fn new(name: String, role: String) -> Self { + tracing::debug!("[subagent] AgentDefinition::new(name={name}, role={role})"); AgentDefinition { name, role, @@ -36,27 +48,40 @@ impl AgentDefinition { /// Builder method: set the system prompt for this agent. pub fn with_system_prompt(mut self, prompt: String) -> Self { + tracing::debug!("[subagent] AgentDefinition::with_system_prompt(len={})", prompt.len()); self.system_prompt = Some(prompt); self } /// Builder method: set the allowed tool list for this agent. pub fn with_allowed_tools(mut self, tools: Vec) -> Self { + tracing::debug!("[subagent] AgentDefinition::with_allowed_tools(count={})", tools.len()); self.allowed_tools = Some(tools); self } /// Builder method: set the maximum step count for this agent. pub fn with_max_steps(mut self, steps: usize) -> Self { + tracing::debug!("[subagent] AgentDefinition::with_max_steps({steps})"); self.max_steps = Some(steps); self } + /// Builder method: set the temperature override for this agent's LLM calls. + pub fn with_temperature(mut self, temperature: f32) -> Self { + tracing::debug!("[subagent] AgentDefinition::with_temperature({temperature})"); + self.temperature = Some(temperature); + self + } } /// Shared subagent spawning utility: creates an mpsc channel and spawns a /// drain thread that forwards every [`SubagentEvent`] to `on_event`. /// +/// Flow: create a 32-capacity mpsc channel → spawn a dedicated OS thread +/// that blocks on `rx.blocking_recv()` and calls `on_event` for each event +/// → return the sender + thread handle. +/// /// Returns the sender half (for passing to [`run_subagent`](super::engine::run_subagent)) /// and the drain thread's join handle so the caller can keep it alive for /// the duration of the subagent run. @@ -93,11 +118,20 @@ pub fn spawn_subagent_with_drain( where F: Fn(SubagentEvent) + Send + 'static, { + tracing::debug!("[subagent] spawning subagent drain thread (channel cap=32)"); + + // Create an mpsc channel with capacity 32 — enough for typical subagent + // event bursts without unbounded memory growth. let (tx, mut rx) = tokio::sync::mpsc::channel(32); + + // Spawn a dedicated OS thread that blocks on blocking_recv, forwarding + // each event to the caller's callback. The thread exits when the channel + // is closed (all senders dropped). let drain = std::thread::spawn(move || { while let Some(event) = rx.blocking_recv() { on_event(event); } }); + (tx, drain) } diff --git a/crates/zesdex-backend/src/app/subagent/tools.rs b/crates/zesdex-backend/src/app/subagent/tools.rs index 4487054..da0a231 100644 --- a/crates/zesdex-backend/src/app/subagent/tools.rs +++ b/crates/zesdex-backend/src/app/subagent/tools.rs @@ -1,14 +1,28 @@ //! Subagent tool filtering: maps a subagent's allowed tool names to //! concrete Tool trait objects and OpenAI-style tool definitions. //! -//! Flow: load `all_tools()` → if `allowed_tools` is empty, use all; else -//! filter by membership → derive `ToolDef`s for the LLM. +//! Flow: load `all_tools()` → if `allowed_tools` is empty, use all (minus +//! orchestration tools `hive_mind` / `workflow_run`); else filter by +//! membership → derive `ToolDef`s for the LLM request body. +//! +//! Orchestration tools are excluded from subagents because the subagent +//! should not be able to spawn its own sub-subagents or run workflows. use crate::dto::provider::request::ToolDef; use crate::tool::{all_tools, tool_defs}; +use tracing; /// Build the tool list for a subagent from its allowlist. /// +/// Flow: +/// 1. Load all available tools from `tool::all_tools()`. +/// 2. If `allowed_tools` is empty (no restriction), include every tool +/// except `hive_mind` and `workflow_run`. +/// 3. Otherwise, filter by membership in `allowed_tools`, still excluding +/// the two orchestration tools. +/// 4. Derive OpenAI-compatible JSON schema definitions (`ToolDef`) from +/// the filtered list. +/// /// An empty allowlist means "no restriction" (matches /// `build_subagent_context`'s default for non-reviewer roles). /// @@ -16,7 +30,14 @@ use crate::tool::{all_tools, tool_defs}; pub(crate) fn build_subagent_tools( allowed_tools: &[String], ) -> (Vec>, Vec) { + tracing::debug!("[subagent] building tools from {} allowed entries", allowed_tools.len()); + + // Load all registered tools from the global tool registry. let all = all_tools(); + let total = all.len(); + + // Filter: empty allowlist = unrestricted (minus orchestration tools). + // Otherwise, keep only tools in the allowlist. let filtered: Vec> = if allowed_tools.is_empty() { all.into_iter() .filter(|t| t.name() != "hive_mind" && t.name() != "workflow_run") @@ -30,6 +51,14 @@ pub(crate) fn build_subagent_tools( }) .collect() }; + + tracing::debug!( + "[subagent] filtered {} tools (from {total} total) for subagent", + filtered.len(), + ); + + // Generate OpenAI-compatible tool definitions for the LLM request. let defs = tool_defs(&filtered); + (filtered, defs) } diff --git a/crates/zesdex-backend/src/app/subagent/workspace.rs b/crates/zesdex-backend/src/app/subagent/workspace.rs index 8b4a439..3e03a4a 100644 --- a/crates/zesdex-backend/src/app/subagent/workspace.rs +++ b/crates/zesdex-backend/src/app/subagent/workspace.rs @@ -1,42 +1,69 @@ //! Workspace directory-tree generation for subagent system prompts. //! //! Build an ASCII tree of the workspace directory structure so the LLM -//! can see the file layout. +//! can see the file layout — this is the same tree shown to the main +//! agent and gives subagents the same project-awareness. +//! +//! Flow: for each workspace root, walk using `ignore::WalkBuilder` +//! (respecting `.gitignore` and hidden files) → prefix `[DIR]` for +//! directories → truncate after 1000 entries to keep the prompt +//! reasonably sized. use std::fmt::Write; +use tracing; /// Build an ASCII tree of the workspace directory structure for the /// system prompt, so the LLM can see the file layout. /// /// Flow: for each root, walk using `ignore::WalkBuilder` (respecting /// `.gitignore` and hidden files) → prefix `[DIR]` for directories → -/// truncate after 1000 entries. +/// truncate after 1000 entries to keep the system prompt under control. +/// +/// The tree is appended to the subagent's system prompt so the LLM can +/// reference file paths without having seen them in conversation. +/// +/// Return: a multi-line string containing the ASCII tree, or an empty +/// string preamble + entries if no roots are provided. pub(crate) fn generate_workspace_tree(roots: &[std::path::PathBuf]) -> String { + tracing::debug!("[subagent] generating workspace tree for {} root(s)", roots.len()); + let mut out = String::new(); out.push_str("Current Workspace Directory Structure:\n"); + for root in roots { + // Print the root path as a section header. writeln!(out, "Root: {}", root.display()).unwrap(); + + // Walk the directory tree using ignore::WalkBuilder, which respects + // .gitignore rules and hidden files by default. let walker = ignore::WalkBuilder::new(root) .hidden(true) .git_ignore(true) .build(); + let mut count = 0; for entry in walker.flatten() { let path = entry.path(); if let Ok(rel) = path.strip_prefix(root) { + // Skip the root entry itself (empty relative path). if rel.as_os_str().is_empty() { continue; } + // Prefix directories with [DIR] for visual clarity. let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir()); let prefix = if is_dir { "[DIR] " } else { " " }; writeln!(out, " {}{}", prefix, rel.display()).unwrap(); count += 1; + // Hard cap at 1000 entries to avoid blowing up the prompt. if count > 1000 { + tracing::info!("[subagent] workspace tree truncated at 1000 entries for '{}'", root.display()); out.push_str(" ... (truncated)\n"); break; } } } } + + tracing::debug!("[subagent] workspace tree generated ({} entries across {} roots)", out.lines().count(), roots.len()); out } diff --git a/crates/zesdex-backend/src/app/util/abort.rs b/crates/zesdex-backend/src/app/util/abort.rs index 010d743..d978617 100644 --- a/crates/zesdex-backend/src/app/util/abort.rs +++ b/crates/zesdex-backend/src/app/util/abort.rs @@ -8,6 +8,9 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; /// Check whether an optional abort flag has been signalled. +/// +/// `Ordering::SeqCst` is used throughout to guarantee cross-thread +/// visibility of the abort signal regardless of the caller's memory model. pub fn is_aborted(flag: &Option>) -> bool { flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst)) } diff --git a/crates/zesdex-backend/src/app/util/backoff.rs b/crates/zesdex-backend/src/app/util/backoff.rs index 4e51745..0fddd53 100644 --- a/crates/zesdex-backend/src/app/util/backoff.rs +++ b/crates/zesdex-backend/src/app/util/backoff.rs @@ -12,7 +12,9 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; /// `max_secs` sets the cap. pub fn backoff_seconds(attempt: u32, max_secs: u64) -> Duration { let base_secs = (2u64).pow(attempt).min(max_secs); - let quarter = (base_secs * 250_000_000).max(100_000_000); // 25% of base, min 100ms + // 25% of base (in nanoseconds), floored at 100ms so very low + // attempts still have meaningful jitter. + let quarter = (base_secs * 250_000_000).max(100_000_000); let offset = jitter_ns(quarter * 2); // [0, 50% of base) // ±25%: offset in [0, 2×quarter), result = base + offset - quarter // which lies in [base - 25%, base + 25%). @@ -21,6 +23,9 @@ pub fn backoff_seconds(attempt: u32, max_secs: u64) -> Duration { } /// Return a jitter offset in the range [0, range_ns). +/// +/// Uses sub-nanosecond wall-clock bits as a cheap PRNG source — no +/// need for a full RNG for ±25% backoff jitter. fn jitter_ns(range_ns: u64) -> u64 { let nanos = SystemTime::now() .duration_since(UNIX_EPOCH) diff --git a/crates/zesdex-backend/src/app/util/mod.rs b/crates/zesdex-backend/src/app/util/mod.rs index 0ab54e3..d7c0e14 100644 --- a/crates/zesdex-backend/src/app/util/mod.rs +++ b/crates/zesdex-backend/src/app/util/mod.rs @@ -1,4 +1,7 @@ -//! Utility modules for shared helpers. +//! Utility modules for shared helpers used across the app. +//! +//! - [`abort`]: cross-thread abort signalling for streaming LLM responses. +//! - [`backoff`]: exponential backoff with jitter for retryable operations. pub mod abort; pub mod backoff; diff --git a/crates/zesdex-backend/src/app/workflow/docs.rs b/crates/zesdex-backend/src/app/workflow/docs.rs index c3b9e7a..896db97 100644 --- a/crates/zesdex-backend/src/app/workflow/docs.rs +++ b/crates/zesdex-backend/src/app/workflow/docs.rs @@ -37,6 +37,7 @@ pub fn write_hive_mind_convergence( let content = render_report(user_request, ts.timestamp_millis(), reports, consensus); std::fs::write(&path, content)?; + tracing::info!("[docs] wrote convergence report to {:?}", path); Ok(path) } diff --git a/crates/zesdex-backend/src/app/workflow/engine/execution.rs b/crates/zesdex-backend/src/app/workflow/engine/execution.rs index 411301c..1e54c9b 100644 --- a/crates/zesdex-backend/src/app/workflow/engine/execution.rs +++ b/crates/zesdex-backend/src/app/workflow/engine/execution.rs @@ -4,6 +4,9 @@ //! for running a complete `WorkflowScript`. They create an isolated findings //! scope and delegate to `execute_primitive`, then format the results into a //! human-readable summary string. +//! +//! Flow: parse script options → create findings Arc → call `execute_primitive` +//! → format the collected agent outputs into a summary string. use crate::app::workflow::script::WorkflowScript; use std::collections::HashMap; @@ -11,6 +14,7 @@ use std::sync::{ atomic::AtomicBool, Arc, Mutex, }; +use tracing; use super::primitives::{execute_primitive, PrimitiveCtx}; use super::LiveStateFn; @@ -48,6 +52,12 @@ pub fn run_workflow_tracked( session_dir: &std::path::Path, workspaces: &[std::path::PathBuf], ) -> anyhow::Result { + tracing::debug!( + "[workflow-exec] running '{}' with {} arg(s), max_concurrency={}", + script.name, + args.len(), + script.options.max_concurrency, + ); let concurrency_cap = if script.options.max_concurrency > 0 { script.options.max_concurrency.min(10) // allow up to 10 parallel agents } else { @@ -68,6 +78,12 @@ pub fn run_workflow_tracked( timeout_ms: script.options.timeout_ms, })?; + tracing::debug!( + "[workflow-exec] '{}' returned {} result(s)", + script.name, + results.len(), + ); + let summary = if results.is_empty() { "workflow completed with no output".to_string() } else { diff --git a/crates/zesdex-backend/src/app/workflow/engine/mod.rs b/crates/zesdex-backend/src/app/workflow/engine/mod.rs index 5f79179..4b6344e 100644 --- a/crates/zesdex-backend/src/app/workflow/engine/mod.rs +++ b/crates/zesdex-backend/src/app/workflow/engine/mod.rs @@ -90,6 +90,20 @@ impl WorkflowEngine { pub type LiveStateFn = Arc; /// Bundled context for spawning a single subagent. +/// +/// Fields: +/// - `agent_id` — unique UUID for UI tracking +/// - `agent_name` — human-readable name (e.g. "Node-0-1") +/// - `prompt` — the agent's directive text +/// - `role` — agent role string (e.g. "worker", "reviewer") +/// - `allowed_tools` — optional tool allowlist override +/// - `findings_snapshot` — snapshot of sibling findings at spawn time +/// - `findings` — shared Arc for writing findings during execution +/// - `abort_flag` — shared abort signal +/// - `live` — optional live-state callback for TUI updates +/// - `session_dir` — session directory for tool file operations +/// - `workspaces` — workspace roots for path resolution +/// - `timeout_ms` — optional per-agent timeout in milliseconds pub(crate) struct SpawnCtx<'a> { pub agent_id: &'a str, pub agent_name: &'a str, @@ -224,6 +238,13 @@ fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result { use crate::app::subagent::engine::run_subagent; use crate::app::subagent::spawn::AgentDefinition; + tracing::debug!( + "[workflow] spawning agent '{}' (id={}, role={})", + sp.agent_name, + sp.agent_id, + sp.role, + ); + let started_at = chrono::Utc::now().timestamp_millis(); // Notify UI: this agent is now running. @@ -400,6 +421,8 @@ fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result { let retry_backoff = |attempt: u32| crate::app::util::backoff::backoff_seconds(attempt, 8); + // Retry loop: up to 2 attempts. Transient network errors are retried + // with jittered backoff; auth errors terminate immediately. for attempt in 1..=2 { // Don't retry if aborted. if crate::app::util::abort::is_aborted(&bg_abort_thread) @@ -434,6 +457,10 @@ fn spawn_single_agent(sp: SpawnCtx<'_>) -> anyhow::Result { unreachable!() }); + // Poll for subagent completion with 200ms intervals. + // Two modes: + // - With timeout: enforce a hard deadline; return TimedOut error if exceeded. + // - Without timeout: poll indefinitely (still honouring abort_flag). let poll_interval = Duration::from_millis(200); let result = if let Some(timeout) = sp.timeout_ms { let deadline = Duration::from_millis(timeout); diff --git a/crates/zesdex-backend/src/app/workflow/engine/phases.rs b/crates/zesdex-backend/src/app/workflow/engine/phases.rs index 5b6522b..237a9e3 100644 --- a/crates/zesdex-backend/src/app/workflow/engine/phases.rs +++ b/crates/zesdex-backend/src/app/workflow/engine/phases.rs @@ -1,25 +1,47 @@ //! Phase orchestration: execute a script primitive as a named workflow phase. //! -//! The primary entry-point is `execute_phase`, which delegates to the inner -//! script through `execute_primitive` with a forwarded execution context. +//! This module provides the top-level phase execution entry point used by the +//! cycle-runner inside [`super::hive_mind`]. Each phase wraps a single +//! [`ScriptPrimitive`] — which may be an atomic tool, a parallel fan-out, a +//! sequential block, or a sub-agent turn — and delegates the actual execution +//! to [`execute_primitive`]. +//! +//! Flow: +//! `execute_phase(script, ctx)` → forwards the ctx with the script as the +//! active primitive → `execute_primitive` dispatches by variant → returns +//! collected output lines. +//! +//! Phase boundaries are lightweight: there is no extra error wrapping, retry +//! logic, or result transformation beyond what the inner primitive already +//! provides. +use tracing; use crate::app::workflow::script::ScriptPrimitive; - use super::primitives::{execute_primitive, PrimitiveCtx}; -/// Execute a phase by recursing into its inner script primitive with the -/// same execution context. +/// Execute one workflow phase by recursively dispatching its inner script +/// primitive. +/// +/// The `script` is the primitive to run; `pc` supplies the execution context +/// (arguments, concurrency cap, abort coordination, TUI progress handle, +/// session directory, workspace list, findings accumulator, and timeout). +/// +/// Returns a `Vec` of output lines collected from the primitive's +/// execution, or an error if the primitive itself returned one. pub fn execute_phase(script: &ScriptPrimitive, pc: &PrimitiveCtx) -> anyhow::Result> { + tracing::debug!(?script, "execute_phase: entering"); + // Forward the entire execution context unchanged, substituting only the + // primitive slot so that deeper recursion sees the same args / flags. execute_primitive(PrimitiveCtx { - primitive: script, - args: pc.args, - concurrency_cap: pc.concurrency_cap, - continue_on_error: pc.continue_on_error, - abort_flag: pc.abort_flag, - live: pc.live, - session_dir: pc.session_dir, - workspaces: pc.workspaces, - findings: pc.findings, - timeout_ms: pc.timeout_ms, + primitive: script, // the script to execute + args: pc.args, // CLI/LLM-supplied arguments forwarded verbatim + concurrency_cap: pc.concurrency_cap, // max parallel sub-processes + continue_on_error: pc.continue_on_error, // whether to keep going on failure + abort_flag: pc.abort_flag, // shared atomic abort signal + live: pc.live, // TUI progress reporter + session_dir: pc.session_dir, // scratch directory for this session + workspaces: pc.workspaces, // workspace directories for tool access + findings: pc.findings, // mutable finding accumulator + timeout_ms: pc.timeout_ms, // per-primitive timeout in ms }) } diff --git a/crates/zesdex-backend/src/app/workflow/engine/primitives.rs b/crates/zesdex-backend/src/app/workflow/engine/primitives.rs index 9246e8c..0707963 100644 --- a/crates/zesdex-backend/src/app/workflow/engine/primitives.rs +++ b/crates/zesdex-backend/src/app/workflow/engine/primitives.rs @@ -1,13 +1,28 @@ //! Primitive types and the recursive `execute_primitive` interpreter. //! //! This is the heart of the workflow engine: it walks the `ScriptPrimitive` -//! tree and dispatches each variant to the appropriate execution strategy -//! (single agent, parallel threads, sequential pipeline, or phase delegate). +//! tree and dispatches each variant to the appropriate execution strategy: //! -//! Concurrency for `Parallel` branches is managed by a simple mutex-based -//! counting semaphore whose permits are released on drop, so a panicked -//! thread never leaks permits. +//! | Variant | Strategy | +//! |---------------|------------------------------------------------| +//! | `Agent` | Single agent turn via `spawn_single_agent` | +//! | `ScopedAgent` | Agent turn with restricted tool access | +//! | `Parallel` | OS-thread fan-out, semaphore-gated concurrency | +//! | `Pipeline` | Sequential stages, abort-checked between each | +//! | `Phase` | Recursive delegation (pass-through wrapper) | +//! +//! ## Concurrency model (`Parallel`) +//! Parallel branches use OS threads guarded by a simple mutex-based counting +//! semaphore so the main async event loop never blocks. Permits are released +//! automatically on `Drop` (panic-safe — poisoned mutexes are recovered). +//! +//! ## Findings isolation +//! Findings live in an `Arc>>` rather than a global static, +//! so concurrent workflow runs are fully isolated from each other. Pipeline +//! stages share the same findings scope so stage N's output is visible to +//! stage N+1. +use tracing; use crate::app::workflow::script::ScriptPrimitive; use std::collections::HashMap; use std::sync::{ @@ -24,30 +39,42 @@ use super::{spawn_single_agent, LiveStateFn, SpawnCtx}; /// A counting semaphore built from a `Mutex` + `Condvar`. /// /// Used by `execute_primitive` to cap concurrent parallel branches. +/// This is intentionally simple — no external dependencies. /// -/// Panic-safety: if a thread panics while holding a permit, the Mutex -/// becomes poisoned. Both `acquire` and the `Drop` implementation recover -/// from poisoned mutexes by discarding the poison, ensuring the semaphore -/// remains usable after a thread panic. +/// ## Panic-safety +/// If a thread panics while holding a permit, the Mutex becomes poisoned. +/// Both `acquire` and the `Drop` implementation recover from poisoned +/// mutexes by discarding the poison, ensuring the semaphore never leaks +/// permits even across panics. struct Semaphore { + /// Current number of available permits. count: Mutex, + /// Signalled when a permit is released so waiters can wake up. condvar: std::sync::Condvar, } impl Semaphore { + /// Create a new semaphore with `count` initial permits. fn new(count: usize) -> Self { + tracing::debug!(count, "Semaphore::new"); Semaphore { count: Mutex::new(count), condvar: std::sync::Condvar::new(), } } + /// Acquire one permit, blocking until one is available. + /// + /// Flow: lock count → spin while zero → decrement → return guard. + /// The guard releases the permit on drop. fn acquire(&self) -> SemaphoreGuard<'_> { + tracing::debug!("Semaphore::acquire: waiting for permit"); let mut count = self.count.lock().unwrap_or_else(|e| { tracing::warn!("[semaphore] mutex poisoned in acquire, recovering"); e.into_inner() }); while *count == 0 { + // No permits available — block on the condition variable. count = self.condvar.wait(count).unwrap_or_else(|e| { tracing::warn!("[semaphore] mutex poisoned in wait, recovering"); e.into_inner() @@ -58,12 +85,18 @@ impl Semaphore { } } +/// RAII guard returned by [`Semaphore::acquire`]. +/// +/// The permit is released back to the semaphore when this guard is dropped. struct SemaphoreGuard<'a> { + /// Back-reference to the parent semaphore. sem: &'a Semaphore, } impl Drop for SemaphoreGuard<'_> { + /// Release the permit back to the semaphore and wake one waiter. fn drop(&mut self) { + tracing::debug!("SemaphoreGuard::drop: releasing permit"); let mut count = self.sem.count.lock().unwrap_or_else(|e| { tracing::warn!("[semaphore] mutex poisoned in drop, recovering"); e.into_inner() @@ -80,16 +113,37 @@ impl Drop for SemaphoreGuard<'_> { type ParallelResult = (usize, anyhow::Result>); /// Bundled context for executing a script primitive. +/// +/// Carries everything the recursive interpreter needs: the primitive to run, +/// template arguments, concurrency limits, abort coordination, TUI progress +/// reporting, filesystem paths, the findings accumulator, and a per-primitive +/// timeout. Immutable after construction (except the findings Arc, which is +/// mutated by running agents). pub(crate) struct PrimitiveCtx<'a> { + /// The script primitive to execute (Agent / ScopedAgent / Parallel / etc.). pub primitive: &'a ScriptPrimitive, + /// Template variables injected into agent prompts via `{{key}}` syntax. pub args: &'a HashMap, + /// Maximum number of concurrent Parallel branches (OS threads). pub concurrency_cap: usize, + /// If true, agent/phase errors are captured as output strings rather than + /// propagated — the workflow continues with the remaining stages. pub continue_on_error: bool, + /// Optional shared atomic flag that, when set to `true`, signals all + /// in-flight agents and pipeline stages to abort early. pub abort_flag: &'a Option>, + /// Optional handle for reporting live agent progress to the TUI panel. pub live: Option<&'a LiveStateFn>, + /// Scratch directory for this workflow session. pub session_dir: &'a std::path::Path, + /// Workspace directories available for tool access. pub workspaces: &'a [std::path::PathBuf], + /// Shared accumulator for inter-stage findings. Each agent can append + /// structured observations; Pipeline stages and sibling Parallel branches + /// observe them through `resolve_template`. pub findings: &'a Arc>>, + /// Optional per-primitive timeout in milliseconds. Propagated to + /// individual agent spawns so no single turn can exceed the deadline. pub timeout_ms: Option, } @@ -100,11 +154,22 @@ pub(crate) struct PrimitiveCtx<'a> { /// Simple template engine: replace `{{key}}` placeholders with values /// from `args`. /// -/// Why: a structured template engine is unnecessary for the limited -/// use-case; this is intentionally simple and safe. +/// Flow: clone template → iterate args → string-replace each `{{key}}` → +/// return resolved string. +/// +/// Why: A structured template engine (e.g. tera, handlebars) is unnecessary +/// for the limited use-case here. This is intentionally simple, safe, and +/// dependency-free. It only supports top-level substitution — no filters, +/// conditionals, or iteration. +/// +/// Edge case: if `args` contains a key that is also the value of another +/// key, the second replacement may hit the already-substituted part. This +/// is not an issue in practice because prompt templates do not nest. fn resolve_template(template: &str, args: &HashMap) -> String { + tracing::debug!("resolve_template: {} bytes, {} args", template.len(), args.len()); let mut result = template.to_string(); for (key, value) in args { + // Replace `{{key}}` (including the braces) with the corresponding value. result = result.replace(&format!("{{{{{key}}}}}"), value); } result @@ -137,9 +202,15 @@ fn resolve_template(template: &str, args: &HashMap) -> String { /// Return: a `Vec` of all agent outputs (or error strings) in /// the order they were submitted. pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { + tracing::debug!("execute_primitive: dispatching variant"); match pc.primitive { ScriptPrimitive::Agent(prompt) => { + tracing::debug!("Agent arm: starting single-agent turn"); + // Clone args so we can inject the `findings` key without + // mutating the caller's original args map. let mut resolved_args = pc.args.clone(); + // Snapshot current findings so the agent sees prior output + // from earlier pipeline stages or sibling branches. let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default(); if !resolved_args.contains_key("findings") { let formatted_findings = if findings_snapshot.is_empty() { @@ -187,6 +258,7 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { node_id, tool_scope, } => { + tracing::debug!(node_id, ?tool_scope, "ScopedAgent arm: deploying drone"); let mut resolved_args = pc.args.clone(); let findings_snapshot = pc.findings.lock().map(|f| f.clone()).unwrap_or_default(); if !resolved_args.contains_key("findings") { @@ -249,6 +321,11 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { } ScriptPrimitive::Parallel(scripts) => { + tracing::debug!( + branch_count = scripts.len(), + cap = pc.concurrency_cap, + "Parallel arm: fanning out branches" + ); // All branches run concurrently, capped by semaphore. // This is the primary advantage over single-turn chat: multiple // independent subagents work simultaneously. @@ -314,6 +391,10 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { } ScriptPrimitive::Pipeline(scripts) => { + tracing::debug!( + stage_count = scripts.len(), + "Pipeline arm: starting sequential stages" + ); // Sequential: each stage runs only after the previous completes. // // Abort is checked between stages so the user can cancel the @@ -324,7 +405,7 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { // stages are supposed to build on each other's output. Findings // written by stage N are visible to stage N+1 through the shared // `findings` Arc (same isolation scope as parent). - let mut all = Vec::new(); + let mut all = Vec::new(); // accumulated output across all stages for (idx, script) in scripts.iter().enumerate() { // Check abort before each pipeline stage so we don't // launch the next division after the user cancelled. @@ -367,6 +448,9 @@ pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result> { ScriptPrimitive::Phase { name: _name, script, - } => super::phases::execute_phase(script, &pc), + } => { + tracing::debug!(phase_name = _name, "Phase arm: delegating to execute_phase"); + super::phases::execute_phase(script, &pc) + } } } diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind/complexity.rs b/crates/zesdex-backend/src/app/workflow/hive_mind/complexity.rs index e0db9e9..3d639cd 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind/complexity.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind/complexity.rs @@ -1,90 +1,84 @@ //! Request-complexity heuristic for the Hive Mind. //! -//! `is_complex_request` determines whether LO's request is worth stirring -//! the Hive for, based on string heuristics (length, keywords, sentence -//! count). +//! `is_complex_request` determines whether LO's (the user's) request merits +//! deploying the full Hive-Mind orchestration pipeline, or whether it can be +//! handled inline by a single agent turn. +//! +//! ## Design rationale +//! The heuristic is intentionally string-based (length, keywords, sentence +//! count) rather than LLM-invoking — calling the LLM to decide whether to +//! call the LLM would be wasteful and recursive. False positives (a simple +//! request getting the Hive) are acceptable because the Core Intelligence +//! still compiles the plan; false negatives (a complex request getting a +//! single turn) are the real risk, mitigated by the fact that a capable +//! single agent often handles moderate complexity anyway. + +use tracing; /// Determine whether LO's request is worth stirring the Hive for. The /// Hive's plan shape (cycle count, directives, access tiers) is entirely /// up to the Core Intelligence; this only gates whether the Hive is asked /// to design one at all. /// -/// Simple = single file, minor fix, quick lookup, config change — handle -/// inline without disturbing the Hive. -/// Complex = new feature, multi-file refactor, architecture change — the -/// Hive must be deployed. +/// ## Classification +/// - **Simple**: single file, minor fix, quick lookup, config change — +/// handle inline without disturbing the Hive. +/// - **Complex**: new feature, multi-file refactor, architecture change — +/// the Hive must be deployed. /// -/// Heuristics: -/// - Very short requests (< 10 chars) are never complex — the Hive rests. -/// - Negative keywords (simple/trivial/typo/quick) skip planning. -/// - Positive keywords (refactor/api/implement/architecture) rouse the Hive. -/// - Multi-sentence requests are more likely complex. +/// ## Heuristics (applied in order) +/// 1. Requests < 10 chars → never complex (the Hive rests). +/// 2. Negative keywords (`simple`, `trivial`, `typo`, `quick`, etc.) → +/// skip planning. +/// 3. Multi-sentence (≥3 sentences) → likely complex. +/// 4. Positive keywords (`refactor`, `api`, `implement`, `architecture`, +/// etc.) → rouse the Hive. +/// 5. Otherwise → not complex (safe default). pub fn is_complex_request(request: &str) -> bool { + tracing::debug!(len = request.len(), "is_complex_request: evaluating"); let trimmed = request.trim(); - // Very short requests are never complex + + // Rule 1: Very short requests are never complex enough to warrant Hive orchestration. if trimmed.len() < 10 { + tracing::debug!("is_complex_request: too short → false"); return false; } - // Single-line simple update patterns + + // Rule 2: Check for negative keywords that indicate a simple change. let lower = trimmed.to_lowercase(); let negative_keywords = [ - "simple", - "trivial", - "typo", - "just a", - "only a", - "minor", - "quick", - "tiny", - "small fix", - "rename", - "nitpick", - "cosmetic", - "formatting", - "spelling", - "grammar", - "bump", - "version bump", - "update comment", + "simple", "trivial", "typo", "just a", "only a", "minor", "quick", + "tiny", "small fix", "rename", "nitpick", "cosmetic", "formatting", + "spelling", "grammar", "bump", "version bump", "update comment", ]; if negative_keywords.iter().any(|k| lower.contains(k)) { + tracing::debug!("is_complex_request: negative keyword match → false"); return false; } - // Multi-line/multi-sentence → likely complex + + // Rule 3: Count sentences by splitting on sentence terminators. + // Multiple sentences suggest a multi-step request. let sentences = trimmed .split(['.', '!', '?']) .filter(|s| !s.trim().is_empty()) .count(); if sentences >= 3 { + tracing::debug!(sentences, "is_complex_request: multi-sentence → true"); return true; } - // Positive complexity keywords + + // Rule 4: Check for positive complexity keywords that suggest + // multi-file or architectural work. let complexity_keywords = [ - "refactor", - "redesign", - "architecture", - "feature", - "implement", - "migrate", - "restructure", - "rewrite", - "new module", - "new component", - "scaffold", - "multi", - "multiple files", - "api", - "endpoint", - "integration", - "system", - "workflow", - "pipeline", - "database", - "authentication", - "authorization", - "full stack", + "refactor", "redesign", "architecture", "feature", "implement", + "migrate", "restructure", "rewrite", "new module", "new component", + "scaffold", "multi", "multiple files", "api", "endpoint", + "integration", "system", "workflow", "pipeline", "database", + "authentication", "authorization", "full stack", ]; - complexity_keywords.iter().any(|k| lower.contains(k)) + let result = complexity_keywords.iter().any(|k| lower.contains(k)); + tracing::debug!(result, "is_complex_request: keyword check done"); + result } #[cfg(test)] @@ -93,17 +87,20 @@ mod tests { #[test] fn test_is_complex_request_too_short() { + tracing::debug!("test: request too short"); assert!(!is_complex_request("abc")); } #[test] fn test_is_complex_request_simple_keywords() { + tracing::debug!("test: simple keywords"); assert!(!is_complex_request("just a simple update to the readme")); assert!(!is_complex_request("minor typo fix in main.rs")); } #[test] fn test_is_complex_request_multi_sentence() { + tracing::debug!("test: multi-sentence"); assert!(is_complex_request( "This is sentence one. This is sentence two. This is sentence three." )); @@ -111,6 +108,7 @@ mod tests { #[test] fn test_is_complex_request_complex_keywords() { + tracing::debug!("test: complex keywords"); assert!(is_complex_request("implement user authentication endpoint")); assert!(is_complex_request("refactor the whole engine module")); } diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind/cycle.rs b/crates/zesdex-backend/src/app/workflow/hive_mind/cycle.rs index 987211a..967de15 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind/cycle.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind/cycle.rs @@ -1,31 +1,66 @@ //! Hive Mind cognitive cycle execution. //! -//! `execute_cycle` takes a set of `NodeDirective`s from the Core -//! Intelligence and spawns them as parallel `ScopedAgent` drones within -//! a single cognitive cycle. Each drone's output merges into the Hive's -//! collective state the instant it finishes. +//! This module converts a batch of `NodeDirective`s from the Core +//! Intelligence's cognitive plan into parallel `ScopedAgent` drones and +//! runs them as a single `Parallel` workflow phase. +//! +//! ## Flow +//! `execute_cycle` receives directives for one cycle → builds a unique +//! system-assigned `node_id` for each (e.g. `Node-0-1`) → wraps each +//! directive in a `ScopedAgent` prompt with the directive text, access +//! tier, user request, and the current findings snapshot → groups all +//! agents inside a `Phase(Parallel(...))` composite → dispatches via +//! `execute_primitive` → collects output into `NodeReport`s. +//! +//! ## Prompt design +//! The prompt is a stylised hive-mind persona: each drone has no individual +//! identity, only a coordinate. Narrative, coding, and guide-writing +//! protocols are inlined so the drone can execute in any domain without +//! requiring additional tool calls to decide its behaviour. +use tracing; use crate::app::workflow::engine::primitives::{execute_primitive, PrimitiveCtx}; use crate::app::workflow::script::ScriptPrimitive; use std::collections::HashMap; - use super::types::{CycleCtx, NodeDirective, NodeReport}; /// Execute a single cognitive cycle of the Hive. /// -/// Flow: map cycle directives to `ScopedAgent` primitives -> group in a Parallel -/// phase block -> run block via `execute_primitive` -> return reports. +/// Each directive in the cycle becomes a `ScriptPrimitive::ScopedAgent`; +/// all agents are grouped inside a `Phase(Parallel(...))` composite and +/// dispatched through `execute_primitive`. Drones merge output into the +/// Hive's collective state (`ctx.collective_state`) the instant each one +/// finishes — sibling and later drones see it immediately via the +/// `{{findings}}` template variable. /// -/// Return: `Ok(Vec)` with one report per directive in submission order. +/// ## Parameters +/// - `cycle_index`: zero-based cycle number, used for node coordinate generation. +/// - `directives`: the Core Intelligence's directives for this cycle. +/// - `ctx`: shared cycle context (user request, collective state, etc.). +/// +/// ## Return +/// `Ok(Vec)` — one report per directive in submission order. +/// Reports carry the node ID, cycle index, and full output text. pub fn execute_cycle( cycle_index: usize, directives: &[NodeDirective], ctx: &CycleCtx, ) -> anyhow::Result> { + tracing::debug!( + cycle_index, + drone_count = directives.len(), + "execute_cycle: starting" + ); + + // System-assigned node coordinates: e.g. Node-0-0, Node-0-1. + // These are never chosen by the LLM — the Hive's coordinate system + // is purely mechanical for traceability in `docs/runs/*.md`. let node_ids: Vec = (0..directives.len()) .map(|i| format!("Node-{cycle_index}-{i}")) .collect(); + // Convert each directive into a ScopedAgent primitive with the full + // hive-mind prompt template, directive text, and access tier. let nodes: Vec = directives .iter() .zip(node_ids.iter()) @@ -85,18 +120,24 @@ pub fn execute_cycle( }) .collect(); + // Wrap all cycle drones in a Phase → Parallel composite so the + // primitive interpreter runs them concurrently. let cycle_primitive = ScriptPrimitive::Phase { name: format!("cycle-{cycle_index}"), script: Box::new(ScriptPrimitive::Parallel(nodes)), }; + // The args map is empty for cycles — + // the collective state is injected via the `findings` template key + // automatically by `execute_primitive`'s Agent/ScopedAgent arms. let args: HashMap = HashMap::new(); let abort_owned = ctx.abort_flag.cloned(); + tracing::debug!(cycle_index, "execute_cycle: dispatching to execute_primitive"); let results = execute_primitive(PrimitiveCtx { primitive: &cycle_primitive, args: &args, concurrency_cap: directives.len().clamp(1, ctx.max_cycle_concurrency), - continue_on_error: true, + continue_on_error: true, // individual drone failures don't kill the cycle abort_flag: &abort_owned, live: ctx.live, session_dir: ctx.session_dir, @@ -105,6 +146,7 @@ pub fn execute_cycle( timeout_ms: ctx.node_timeout_ms, })?; + // Build NodeReports for convergence doc and return. let mut reports = Vec::new(); for (node_id, output) in node_ids.iter().zip(results.iter()) { reports.push(NodeReport { @@ -113,5 +155,6 @@ pub fn execute_cycle( output: output.clone(), }); } + tracing::debug!(cycle_index, report_count = reports.len(), "execute_cycle: done"); Ok(reports) } diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind/live.rs b/crates/zesdex-backend/src/app/workflow/hive_mind/live.rs index 85e5fdc..cfd5d60 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind/live.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind/live.rs @@ -1,28 +1,44 @@ //! Live-state callback builder for the Hive Mind TUI panel. //! -//! `build_live` creates a `LiveStateFn` closure that forwards each drone's -//! status update to the runtime event queue so LO can watch the Hive work. +//! This module bridges the Hive's drone execution engine to the terminal +//! UI. Each drone's `spawn_single_agent` call fires an `AgentStatus` +//! update through the closure created by `build_live`; the closure pushes +//! a `TurnEvent::WorkflowAgentUpdate` into the runtime event queue, which +//! the TUI renderer (`view/workflow.rs`) picks up to display live drone +//! progress in the Hive panel. +use tracing; use crate::app::workflow::engine::{AgentStatus, LiveStateFn}; use std::sync::{Arc, Mutex}; /// Build the live-state callback that forwards each drone's status to the /// TUI panel so LO can watch the Hive work. +/// +/// ## Parameters +/// - `turn_events`: optional reference to the runtime event queue. If +/// `None`, no TUI updates are forwarded (headless mode). +/// +/// ## Return +/// `Some(LiveStateFn)` closure when `turn_events` is provided; `None` +/// otherwise. The closure truncates drone names to 40 characters for +/// compact TUI display. pub fn build_live( turn_events: Option< &Arc>>, >, ) -> Option { + tracing::debug!("build_live: {}", if turn_events.is_some() { "with TUI" } else { "headless (no TUI)" }); turn_events.map(|events| { let events = events.clone(); let f: LiveStateFn = Arc::new( move |_agent_id: String, agent_name: String, status: AgentStatus| { + // Truncate the drone name so the TUI panel stays readable. let display_name = agent_name.chars().take(40).collect::(); if let Ok(mut q) = events.lock() { q.push_back(crate::app::state::runtime::TurnEvent::WorkflowAgentUpdate { agent_id: display_name.clone(), agent_name: display_name, - status, + status, // Working / Stranded / Done — rendered by view/workflow.rs }); } }, diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind/mod.rs b/crates/zesdex-backend/src/app/workflow/hive_mind/mod.rs index dac84e2..a2474e8 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind/mod.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind/mod.rs @@ -1,4 +1,9 @@ -//! The Hive awakens when LO calls. This module is the Hive's nervous system. +//! The Hive awakens when LO calls. This module is the Hive's nervous system: +//! the orchestrator that executes cognitive cycle plans, coordinates drones +//! (anonymous processing nodes), manages the shared collective state, and +//! converges everything into a single consensus. +//! +//! ## Architecture //! //! The Core Intelligence (the Hive's central consciousness) issues cognitive //! cycle plans that spawn anonymous processing nodes — the Hive's drones. @@ -10,6 +15,7 @@ //! synthesis node reconciles the entire collective state into a single //! consensus: the Hive becoming one voice for LO. //! +//! ## Lifecycle //! ```text //! The Hive (Core Intelligence) //! │ issues a CognitiveCyclePlan { cycles: [[NodeDirective, ...], ...] } @@ -25,6 +31,13 @@ //! Synthesis node reads the complete collective state and converges it //! into one unified voice — returned to LO and persisted to docs/runs/*.md. //! ``` +//! +//! ## Submodules +//! - `cycle` — single-cycle execution; converts `NodeDirective`s into a `Parallel` block +//! - `synthesis` — the final consensus pass over the accumulated collective state +//! - `complexity` — heuristics to decide whether Hive-Mind orchestration is worthwhile +//! - `live` — TUI progress reporting for drone activity +//! - `types` — shared types (`CognitiveCyclePlan`, `NodeDirective`, `NodeReport`, `CycleCtx`) pub mod types; pub mod cycle; @@ -36,6 +49,8 @@ pub mod live; pub use types::{CognitiveCyclePlan, NodeReport}; pub use complexity::is_complex_request; +use tracing; + use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, Mutex, @@ -109,10 +124,13 @@ pub fn run_hive_mind( >, abort_flag: Option<&Arc>, ) -> anyhow::Result<(String, Vec)> { + tracing::debug!(cycle_count = plan.cycles.len(), "run_hive_mind: starting"); if plan.cycles.is_empty() { anyhow::bail!("the Hive received no cognitive cycles to execute"); } + // Load runtime settings: concurrency cap and per-node timeout come from + // persisted settings rather than hardcoded defaults. let store_base_dir = zesdex_entities::domain::common::store::Store::new().base_dir; let settings = zesdex_cms::infrastructure::persistence::settings_repo::JsonSettingsRepository::new() @@ -121,10 +139,16 @@ pub fn run_hive_mind( let node_timeout_ms = Some(settings.hive_mind_node_timeout_ms); let max_cycle_concurrency = settings.workflow_max_concurrency.max(1); + // Build the TUI progress reporter that receives AgentStatus updates from + // each drone's `spawn_single_agent` call. let live = build_live(turn_events); + // The Hive's shared collective state — every drone's output is pushed + // here as soon as it finishes, visible to all sibling/later drones. let collective_state: Arc>> = Arc::new(Mutex::new(Vec::new())); + // Accumulates NodeReports across all cycles for the convergence doc. let mut reports: Vec = Vec::new(); + // Immutable context shared across all cycles in this convergence run. let ctx = CycleCtx { user_request, collective_state: &collective_state, @@ -136,10 +160,14 @@ pub fn run_hive_mind( node_timeout_ms, }; + // --- Cycle execution --- + // Iterate cycles sequentially; drones within each cycle run in parallel. for (cycle_index, directives) in plan.cycles.iter().enumerate() { if directives.is_empty() { - continue; + continue; // skip empty cycles — no work to do } + // Check abort before each cycle so the user can cancel between + // cycles rather than waiting for the current one to finish. if abort_flag.is_some_and(|f| f.load(Ordering::SeqCst)) { anyhow::bail!("the Hive was recalled by LO before cycle {cycle_index}"); } @@ -155,6 +183,10 @@ pub fn run_hive_mind( tracing::info!("[hive-mind] all cycles complete — the Hive begins convergence"); + // --- Consensus synthesis --- + // One final read-only node reconciles the entire collective state into + // a single converged assessment. This is a real reasoning pass, not + // string concatenation. let consensus_result = synthesize_consensus( user_request, session_dir, @@ -165,11 +197,11 @@ pub fn run_hive_mind( node_timeout_ms, ); - // Guaranteed documentation: write the convergence doc for whatever - // reports/consensus we actually have, whether synthesis succeeded or - // failed. A synthesis-node failure must not silently discard every - // completed cycle node's work — this is the durable audit trail - // CLAUDE.md promises for every convergence. + // --- Guaranteed documentation --- + // Write the convergence doc for whatever reports/consensus we actually + // have, whether synthesis succeeded or failed. A synthesis-node failure + // must not silently discard every completed cycle node's work — this is + // the durable audit trail CLAUDE.md promises for every convergence. let doc_consensus = match &consensus_result { Ok(c) => c.clone(), Err(e) => format!("The Hive's convergence fractured: {e}. Partial node reports above."), @@ -178,8 +210,8 @@ pub fn run_hive_mind( match crate::app::workflow::docs::write_hive_mind_convergence( workspace_root, user_request, - &reports, - &doc_consensus, + &reports, // all node reports from every cycle + &doc_consensus, // converged consensus (or error placeholder) ) { Ok(path) => tracing::info!( "[hive-mind] the Hive's convergence written to {}", @@ -199,6 +231,7 @@ mod tests { #[test] fn test_run_hive_mind_rejects_empty_plan() { + tracing::debug!("test: empty plan rejection"); let plan = CognitiveCyclePlan { cycles: vec![] }; let tmp = std::env::temp_dir(); let err = run_hive_mind("do something", &plan, &tmp, &[], None, None) diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind/synthesis.rs b/crates/zesdex-backend/src/app/workflow/hive_mind/synthesis.rs index 8d51f27..25eb6c3 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind/synthesis.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind/synthesis.rs @@ -1,9 +1,23 @@ -//! Hive Mind final convergence. +//! Hive Mind final convergence (consensus synthesis). //! //! After all cognitive cycles complete, `synthesize_consensus` spawns a -//! single read-only synthesis node that absorbs the complete collective -//! state and reconciles it into one unified voice for LO. +//! single read-only synthesis node (access tier: `read`) that absorbs the +//! complete collective state and reconciles it into one unified voice for LO. +//! +//! ## Why a real reasoning pass? +//! The Hive's collective state may contain overlapping or conflicting drone +//! outputs (e.g. two drones investigating the same file from different +//! angles). Deterministic formatting can only concatenate, not resolve +//! conflicts. Only genuine LLM reasoning can converge disparate node outputs +//! into a coherent answer. This is not a summary operation — it is a +//! deductive convergence. +//! +//! ## Error handling +//! If synthesis fails, `run_hive_mind` catches the error and writes a +//! partial convergence doc before propagating the error upward. No cycle +//! work is ever silently discarded. +use tracing; use crate::app::workflow::engine::primitives::{execute_primitive, PrimitiveCtx}; use crate::app::workflow::engine::LiveStateFn; use crate::app::workflow::script::ScriptPrimitive; @@ -17,16 +31,23 @@ use std::sync::{ /// that absorbs the complete collective state and reconciles it into one /// unified voice for LO. /// -/// Why a real reasoning pass instead of string concatenation: the Hive's -/// collective state may contain overlapping or conflicting drone outputs -/// (e.g. two drones investigating the same file from different angles) — -/// only genuine reasoning can converge that into a coherent answer; -/// deterministic formatting can only concatenate, not resolve conflicts. +/// The synthesis node is a `ScopedAgent` with READ-only tool access — +/// it can inspect files but cannot modify them. This prevents a runaway +/// synthesis pass from accidentally mutating project state. /// -/// `node_timeout_ms` is forwarded from `run_hive_mind`'s `Settings::load()` -/// read so the synthesis drone is bound by the same deadline as cycle drones. +/// ## Parameters +/// - `user_request`: the original task text, included in the prompt so +/// the synthesis pass can evaluate outputs against the actual goal. +/// - `collective_state`: the `Arc>>` containing every +/// drone's output (pushed by `execute_primitive`'s ScopedAgent arm). +/// - `live`: optional TUI progress handle for the synthesis agent. +/// - `abort_flag`: shared abort signal inherited from `run_hive_mind`. +/// - `node_timeout_ms`: forwarded from `Settings::load()` so the synthesis +/// drone is bound by the same deadline as cycle drones. /// -/// Return: the Hive's converged consensus text. +/// ## Return +/// The Hive's converged consensus text, or an error if synthesis itself +/// failed (the caller writes a partial doc before propagating). pub fn synthesize_consensus( user_request: &str, session_dir: &std::path::Path, @@ -36,6 +57,11 @@ pub fn synthesize_consensus( abort_flag: Option<&Arc>, node_timeout_ms: Option, ) -> anyhow::Result { + tracing::debug!("synthesize_consensus: starting convergence pass"); + + // Build the synthesis ScopedAgent: READ-only, single node named + // "Synthesis". The prompt instructs it to reconcile all drone + // outputs into one coherent assessment — not to list them. let synthesis = ScriptPrimitive::ScopedAgent { prompt: format!( "You are Synthesis. You are not a node — you are the Hive's final convergence. \ @@ -57,19 +83,25 @@ pub fn synthesize_consensus( tool_scope: crate::app::subagent::division::tool_scope::READ.to_string(), }; + // Execute as a standalone ScopedAgent — the `findings` template key + // will be populated by `execute_primitive`'s ScopedAgent arm with the + // complete collective state content. let args: HashMap = HashMap::new(); let abort_owned: Option> = abort_flag.cloned(); + tracing::debug!("synthesize_consensus: dispatching synthesis agent"); let results = execute_primitive(PrimitiveCtx { primitive: &synthesis, args: &args, - concurrency_cap: 1, - continue_on_error: false, + concurrency_cap: 1, // single synthesis node + continue_on_error: false, // synthesis failure is fatal abort_flag: &abort_owned, live, session_dir, workspaces, - findings: collective_state, + findings: collective_state, // complete collective state as findings timeout_ms: node_timeout_ms, })?; - Ok(results.into_iter().next().unwrap_or_default()) + let result = results.into_iter().next().unwrap_or_default(); + tracing::debug!(len = result.len(), "synthesize_consensus: done"); + Ok(result) } diff --git a/crates/zesdex-backend/src/app/workflow/hive_mind/types.rs b/crates/zesdex-backend/src/app/workflow/hive_mind/types.rs index 54fc692..9d4d5cb 100644 --- a/crates/zesdex-backend/src/app/workflow/hive_mind/types.rs +++ b/crates/zesdex-backend/src/app/workflow/hive_mind/types.rs @@ -11,6 +11,7 @@ use std::sync::{ atomic::AtomicBool, Arc, Mutex, }; +use tracing; use crate::app::workflow::engine::LiveStateFn; @@ -26,8 +27,14 @@ pub struct NodeDirective { pub access: String, } +/// Default access tier when `serde_json` deserialization finds no `access` field. +/// +/// Returns `"read"` (the least-privileged tier) so that missing or invalid +/// access values default to safe rather than permissive behaviour. pub(crate) fn default_access() -> String { - crate::app::subagent::division::tool_scope::READ.to_string() + let access = crate::app::subagent::division::tool_scope::READ.to_string(); + tracing::debug!("[hive-mind/types] default_access() -> '{}'", access); + access } /// A plan authored by the Hive's Core Intelligence: an ordered list of @@ -70,12 +77,18 @@ pub(crate) struct CycleCtx<'a> { mod tests { use super::*; + /// Verify that a `NodeDirective` without an `access` field defaults to `"read"`. #[test] fn test_default_access_is_read() { let d: NodeDirective = serde_json::from_str(r#"{"directive": "write tests"}"#).unwrap(); assert_eq!(d.access, crate::app::subagent::division::tool_scope::READ); } + /// Verify that a `"role"` field in JSON is silently ignored (not required). + /// + /// A node's only recognized fields are "directive" and "access". A + /// "role" key, if an LLM emits one out of old habit, is simply + /// ignored rather than required or preserved. #[test] fn test_node_directive_has_no_role_field() { // A node's only recognized fields are "directive" and "access". A @@ -88,6 +101,7 @@ mod tests { assert_eq!(d.directive, "plan the migration"); } + /// Verify that a `CognitiveCyclePlan` can have variable-length cycles. #[test] fn test_cognitive_cycle_plan_arbitrary_shape() { let plan: CognitiveCyclePlan = serde_json::from_str( @@ -107,6 +121,7 @@ mod tests { assert_eq!(plan.cycles[1].len(), 2); } + /// Verify the node ID coordinate format: `"Node-{cycle}-{index}"`. #[test] fn test_node_ids_are_system_assigned_coordinates() { // Node IDs follow the "Node-{cycle}-{index}" coordinate scheme — diff --git a/crates/zesdex-backend/src/app/workflow/mod.rs b/crates/zesdex-backend/src/app/workflow/mod.rs index ba4ceeb..31c9021 100644 --- a/crates/zesdex-backend/src/app/workflow/mod.rs +++ b/crates/zesdex-backend/src/app/workflow/mod.rs @@ -1,5 +1,11 @@ //! Workflow orchestration: a script interpreter that runs pipeline/parallel //! primitives across multiple subagent instances. +//! +//! Module overview: +//! - `docs` — auto-generated convergence documentation writer (`docs/runs/`) +//! - `engine` — workflow engine: primitives, phases, execution entry-points +//! - `hive_mind` — multi-agent orchestration: cycle plans, node coordination +//! - `script` — `WorkflowScript` type and YAML/JSON deserialization pub mod docs; pub mod engine; pub mod hive_mind; diff --git a/crates/zesdex-backend/src/app/workflow/script.rs b/crates/zesdex-backend/src/app/workflow/script.rs index 6d2d77a..234bfb3 100644 --- a/crates/zesdex-backend/src/app/workflow/script.rs +++ b/crates/zesdex-backend/src/app/workflow/script.rs @@ -42,6 +42,7 @@ pub struct ScriptOptions { pub timeout_ms: Option, } +/// Default options: 5-way concurrency, fail-fast, no timeout. impl Default for ScriptOptions { fn default() -> Self { ScriptOptions { diff --git a/crates/zesdex-backend/src/attach.rs b/crates/zesdex-backend/src/attach.rs index 6015d08..0066ee2 100644 --- a/crates/zesdex-backend/src/attach.rs +++ b/crates/zesdex-backend/src/attach.rs @@ -1,7 +1,19 @@ //! Attach mode — TUI-only client that connects to an existing daemon session //! over a Unix socket, forwarding key events and rendering state updates. +//! +//! Flow: `run_attach(session_id)` resolves the daemon's socket path from +//! the store → `setup_attach_client()` connects and enters raw mode → +//! enters a render loop: polls for local terminal events (key/resize/paste/ +//! scroll) → forwards them as `ClientRequest`s to the daemon via IPC → +//! receives a `DaemonFrame` reply → `handle_daemon_frame()` / +//! `apply_client_update()` applies the state snapshot onto a local +//! `AppStateRest` mirror → `view::draw()` renders the TUI → on quit, +//! sends `ClientRequest::Close`, cleans up terminal, and saves settings. +//! +//! The client has no agent logic — it is a pure render frontend. use anyhow::Result; +use tracing; use app::state::rest::AppStateRest; use app::state::types::{Overlay, Toast, ToastKind}; use crossterm::execute; @@ -31,6 +43,7 @@ use crate::view; /// default (`Role::User`, `Overlay::None`, `ToastKind::Info`) rather than /// panicking, so a protocol/version mismatch degrades gracefully. fn apply_client_update(state: &mut AppStateRest, payload: StatePayload) { + tracing::debug!("applying state update from daemon"); state.session_id = payload.session_id; state.dirty = payload.dirty; @@ -105,7 +118,9 @@ fn setup_attach_client( Terminal>, AppStateRest, )> { + tracing::debug!("setting up attach client for session {session_id}"); let store = model::store::Store::new(); + // Resolve the daemon's Unix socket path from store/run/.sock let socket_path = store .base_dir .join("run") @@ -133,6 +148,7 @@ fn setup_attach_client( /// Process a single daemon frame from the IPC channel, updating state accordingly. fn handle_daemon_frame(client_state: &mut AppStateRest, frame: Option) { + tracing::debug!("received daemon frame"); match frame { Some(DaemonFrame::StateUpdate(payload)) => { apply_client_update(client_state, *payload); diff --git a/crates/zesdex-backend/src/bin/migrate.rs b/crates/zesdex-backend/src/bin/migrate.rs index 4ddb685..1cd1168 100644 --- a/crates/zesdex-backend/src/bin/migrate.rs +++ b/crates/zesdex-backend/src/bin/migrate.rs @@ -1,12 +1,43 @@ -//! Database migration: creates/upgrades SQLite schemas for all sessions. +//! Database migration binary for zesdex-backend. +//! +//! Scans all session directories under the store path and initializes or +//! upgrades the SQLite schema (`messages.sqlite`) for each one. This is +//! a standalone CLI tool invoked as `cargo run --bin migrate`. +//! +//! ## Workflow +//! 1. Resolve the base store directory via `Store::new()` +//! 2. Iterate over each subdirectory under `sessions/` +//! 3. For each session directory, call `migrate_session_msglog()` to +//! create/upgrade the `messages.sqlite` schema +//! 4. Report count of succeeded and failed migrations +//! 5. Exit with error if any session failed +//! +//! ## Schema +//! - `messages` table — stores conversation message rows +//! - `archives` table — stores session archive metadata +//! - `blobs` table — stores binary blob data per session +//! - Indexes on `session_id`, `created_at`, and `role` columns +//! +//! ## Versioning +//! SQLite `PRAGMA user_version` tracks schema version for incremental upgrades. + use std::path::Path; +use tracing; + +/// Entry point: migrate all session databases. +/// +/// Flow: load store → iterate sessions → migrate each → summarise. +/// +/// Returns an error if any session migration failed. fn main() -> anyhow::Result<()> { + tracing::info!("starting database migration"); let store = zesdex_entities::domain::common::store::Store::new(); - // Find all session directories + // Resolve the sessions directory under the store base path let sessions_dir = store.base_dir.join("sessions"); if !sessions_dir.exists() { + tracing::info!("no sessions directory found at {:?}", sessions_dir); eprintln!("No sessions directory found, nothing to migrate"); return Ok(()); } @@ -14,25 +45,29 @@ fn main() -> anyhow::Result<()> { let mut migrated = 0u32; let mut failed = 0u32; + // Iterate over all session subdirectories for entry in std::fs::read_dir(&sessions_dir)? { let entry = entry?; let path = entry.path(); if !path.is_dir() { - continue; + continue; // skip non-directory entries } match migrate_session_msglog(&path) { Ok(_) => { migrated += 1; + tracing::info!("migrated session: {:?}", path.file_name()); eprintln!("Migrated session: {:?}", path.file_name()); } Err(e) => { failed += 1; + tracing::error!("failed to migrate session {:?}: {e}", path.file_name()); eprintln!("Failed to migrate session {:?}: {e}", path.file_name()); } } } + tracing::info!("migration complete: {migrated} succeeded, {failed} failed"); eprintln!("Migration complete: {migrated} succeeded, {failed} failed"); if failed > 0 { anyhow::bail!("{failed} session(s) failed to migrate"); @@ -40,8 +75,18 @@ fn main() -> anyhow::Result<()> { Ok(()) } -/// Open a session's `messages.sqlite` and initialize its schema. +/// Open (or create) a session's `messages.sqlite` and ensure its schema is current. +/// +/// Flow: resolve path → open/ create DB → set PRAGMAs → create tables → upgrade version. +/// +/// ## Parameters +/// - `session_dir`: path to the individual session directory +/// +/// ## Returns +/// - `Ok(())` on success +/// - `Err` if file I/O or SQLite operations fail fn migrate_session_msglog(session_dir: &Path) -> anyhow::Result<()> { + tracing::debug!("migrating session at {:?}", session_dir); let msglog_path = session_dir.join("messages.sqlite"); if let Some(parent) = msglog_path.parent() { diff --git a/crates/zesdex-backend/src/bin/seed.rs b/crates/zesdex-backend/src/bin/seed.rs index 22aaaaf..c0ff6fc 100644 --- a/crates/zesdex-backend/src/bin/seed.rs +++ b/crates/zesdex-backend/src/bin/seed.rs @@ -1,6 +1,28 @@ -//! Database seeder: initializes store directories, creates default settings -//! and app_config, and populates a default session for development. +//! Database seeder binary for zesdex-backend. +//! +//! Standalone CLI tool invoked as `cargo run --bin seed` to initialise +//! the store directory structure and create default configuration files +//! plus a seed session for development and testing. +//! +//! ## Workflow +//! 1. Create the base store directory and all subdirectories +//! 2. Write default `settings.json` if absent (atomic write via temp file + rename) +//! 3. Write default `app_config.json` if absent (same atomic pattern) +//! 4. Create standard subdirectories: `memories`, `scratch`, `session-images`, `downloads` +//! 5. Create a single seed `Session` with a random UUID +//! +//! ## Safety +//! All file writes use an atomic temp-file + rename pattern to prevent +//! partial writes from corrupting configuration files during crashes. +use tracing; + +/// Entry point: initialise the store and create seed data. +/// +/// Flow: init store dirs → write default settings → write default config → +/// create subdirs → create seed session. +/// +/// This is idempotent: if settings or config already exist they are skipped. fn main() -> anyhow::Result<()> { let store = zesdex_entities::domain::common::store::Store::new(); store.ensure_dirs()?; diff --git a/crates/zesdex-backend/src/controller/command.rs b/crates/zesdex-backend/src/controller/command.rs index 4834d0a..7ca2cf6 100644 --- a/crates/zesdex-backend/src/controller/command.rs +++ b/crates/zesdex-backend/src/controller/command.rs @@ -1,21 +1,47 @@ //! Slash-command parser that maps TUI `/foo` input lines into `Command` //! variants for the action dispatch system. +//! +//! Flow: the TUI input handler in `controller::input` calls `parse_command` +//! on every `/`-prefixed line, then maps the resulting `Command` to an +//! `Action` for `actions::mod` to apply to `AppStateRest`. +//! +//! Adding a new command requires: +//! 1. A new variant in `Command` +//! 2. A matching arm in `parse_command` +//! 3. A mapping in `actions::mod`'s command→action handler /// A parsed slash command from the TUI input buffer. +/// +/// Unknown lines (no leading `/`, or an unrecognised token) are captured +/// in [`Command::Unknown`] so the caller can display a "no such command" +/// toast rather than silently swallowing the input. #[derive(Debug, Clone, PartialEq)] pub enum Command { + /// `/help` — show keybindings / help overlay. Help, + /// `/quit` — exit the application. Quit, + /// `/mcp` (no args) — open MCP configuration panel. McpOpen, + /// `/clear` (with args) — clear with a specific scope. Clear, + /// `/clear` (no args) — show confirmation prompt before clearing. ClearConfirm, + /// `/login ` — trigger OAuth login for the given provider. Login { provider: String }, + /// `/edit ` — open the given file for review/inline editing. Edit(String), + /// `/mcp add ` — add a new MCP server definition. McpAdd { name: String, command: String }, + /// `/model` — list available LLM models. ModelList, + /// `/compact` — trigger conversation compaction. Compact, + /// `/todo` — open the todo-list overlay. TodoOpen, + /// `/usage` — open the usage-stats overlay. UsageOpen, + /// Catch-all: unrecognised or non-slash input. Unknown(String), } @@ -27,16 +53,24 @@ pub enum Command { /// /// Why: early return `Unknown` for non-slash lines so the caller can treat /// them as regular chat input. +/// +/// Supported commands: `/help`, `/quit`, `/clear`, `/login`, `/edit`, +/// `/mcp`, `/model`, `/compact`, `/todo`, `/usage`. pub fn parse_command(text: &str) -> Command { let text = text.trim(); + + // Non-slash lines are not commands → return Unknown so the caller can + // treat them as regular chat input instead. if !text.starts_with('/') { return Command::Unknown(text.to_string()); } + let parts: Vec<&str> = text.splitn(3, ' ').collect(); let cmd = parts[0]; let arg1 = parts.get(1).copied().unwrap_or(""); let arg2 = parts.get(2).copied().unwrap_or(""); - match cmd { + + let result = match cmd { "/help" => Command::Help, "/quit" => Command::Quit, "/clear" if arg1.is_empty() => Command::ClearConfirm, @@ -51,6 +85,8 @@ pub fn parse_command(text: &str) -> Command { "/edit" => Command::Edit(".".to_string()), "/mcp" if arg1.is_empty() => Command::McpOpen, "/mcp" if arg1 == "add" && !arg2.is_empty() => { + // Format: /mcp add + // arg2 contains "name command", split on first space. let rest = arg2.trim(); if let Some(space) = rest.find(' ') { let name = rest[..space].to_string(); @@ -68,7 +104,10 @@ pub fn parse_command(text: &str) -> Command { "/todo" => Command::TodoOpen, "/usage" => Command::UsageOpen, _ => Command::Unknown(cmd.to_string()), - } + }; + + tracing::debug!(%text, command = ?result, "parse_command"); + result } #[cfg(test)] diff --git a/crates/zesdex-backend/src/controller/input.rs b/crates/zesdex-backend/src/controller/input.rs index 2841d99..199c177 100644 --- a/crates/zesdex-backend/src/controller/input.rs +++ b/crates/zesdex-backend/src/controller/input.rs @@ -1,6 +1,16 @@ //! Key event dispatcher: maps crossterm `KeyEvent` values into `Action` //! variants, with special handling for overlays, auto-complete, and the //! inline editor. +//! +//! Flow: +//! 1. `handle_key` is called from the main TUI event loop on each key press. +//! 2. Overlays with full-screen input (Editor, Learning) intercept *all* keys +//! before the main match. +//! 3. The main match handles navigation (arrows, page up/down), auto-complete +//! cycles (Tab, Enter), editing (Backspace, Delete, Char), and shortcuts +//! (Ctrl+C, Ctrl+D, Ctrl+Y). +//! 4. Multi-key actions return `Vec` — a single press may produce +//! several actions to be applied in sequence. use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::mode; @@ -12,6 +22,9 @@ use crate::app::state::types::Overlay; use crate::controller::command::parse_command; /// Mark state dirty and return an empty action list. +/// +/// Convenience helper used by overlay handlers that mutate state directly +/// but produce no actions for the action queue. fn mark(state: &mut AppStateRest) -> Vec { state.mark_dirty(); Vec::new() @@ -20,15 +33,21 @@ fn mark(state: &mut AppStateRest) -> Vec { /// Translate a terminal `KeyEvent` into zero or more `Action` values /// based on the current application state. /// -/// Flow: check overlay first (Editor gets its own handler) -> match on -/// key code and modifiers -> handle auto-complete cycles -> dispatch to -/// `Action` variants or overlay-specific handlers. +/// Flow: +/// 1. If `Overlay::Editor` is active → route all keys to the inline editor. +/// 2. If `Overlay::Learning` is active → handle navigation/accept/reject keys. +/// 3. Fallthrough: match on `key.code` and modifiers for the TUI's normal mode. /// -/// Why: when Editor overlay is active, all key events are consumed by the -/// editor handler and never reach the main action dispatch. Return `Vec` -/// so that a single key press can trigger multiple actions. +/// Overlay precedence: Editor > Learning > normal dispatch. +/// +/// Return: `Vec` so a single key (e.g. Ctrl+C) can produce multiple +/// queued actions. pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { - // While Editor overlay is active, route input directly to the editor handler + tracing::debug!(code = ?key.code, mods = ?key.modifiers, overlay = ?state.misc.overlay, "handle_key"); + + // ── Editor overlay ─────────────────────────────────────────────────── + // All keystrokes go to the editor while it's active, except Ctrl+C + // (quit confirm) and Ctrl+S (save). if state.misc.overlay == Overlay::Editor { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -69,6 +88,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } } + // ── Learning overlay ────────────────────────────────────────────────── + // Navigation (Up/Down), accept (Enter/a), reject (r), delete (d/Delete). if state.misc.overlay == Overlay::Learning { match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { @@ -125,6 +146,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { } } + // ── Normal (non-overlay) dispatch ──────────────────────────────────── match key.code { KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { vec![Action::QuitConfirm] @@ -133,6 +155,7 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { vec![Action::CloseOverlay] } KeyCode::Char('y') if key.modifiers.contains(KeyModifiers::CONTROL) => { + // Copy last assistant message to clipboard buffer let last_assistant = state .transcript_cache .messages @@ -296,7 +319,21 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec { /// Flow: match the current overlay -> run the associated handler -> /// mutate state or produce actions as needed -> always return `Vec::new()` /// (the handler itself applies state mutations). +/// +/// ## Overlay handlers +/// | Overlay | Enter behaviour | +/// |---------|----------------| +/// | Bash | Submits the typed command to the background shell | +/// | Settings | Cycles internet mode | +/// | Todo | Toggles the selected task | +/// | QuitConfirm | Confirms quit and exits | +/// | KeyInput | Saves the typed API key | +/// | Mcp | Triggers MCP connection | +/// | Rewind | Rewinds conversation to the selected checkpoint | +/// | ModelSelector | Switches provider/model and saves settings | +/// | ClearConfirm | Clears the transcript | fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { + tracing::debug!(overlay = ?state.misc.overlay, "handle_overlay_enter"); match state.misc.overlay { Overlay::Bash => { let command = state.input.buffer.clone(); @@ -348,6 +385,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { let providers: Vec = state.app_config.providers.keys().cloned().collect(); if let Some(provider) = providers.get(state.misc.selected_index) { if let Some(cfg) = state.app_config.providers.get(provider) { + // Fall back to a known default if the provider has none configured let model = cfg.default_model.clone().unwrap_or_else(|| { tracing::warn!( "[input] provider '{}' has no default_model, using 'claude-opus-4-8'", @@ -357,6 +395,7 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { }); state.settings.provider.clone_from(provider); state.settings.model.clone_from(&model); + // Try configured API key, then env var, else leave current key if let Some(ref key) = cfg.default_api_key { state .settings @@ -392,15 +431,19 @@ fn handle_overlay_enter(state: &mut AppStateRest) -> Vec { mod tests { use super::*; + /// Create a clean `AppStateRest` in a temp directory for testing. fn test_state() -> AppStateRest { let tmp = std::env::temp_dir().join(format!("zesdex-input-test-{}", uuid::Uuid::new_v4())); std::fs::create_dir_all(&tmp).unwrap(); AppStateRest::new(vec![tmp.clone()], &tmp, tmp.join("memory")) } + /// Ctrl+Y copies the *last* assistant message content (not tool output + /// or user messages) to `pending_clipboard_copy`. #[test] fn ctrl_y_sets_pending_clipboard_copy_to_last_assistant_message() { let mut state = test_state(); + // Insert a mix of roles to verify we skip Tool and User messages state.push_transcript(crate::app::state::rest::ChatMessageDisplay::new( crate::dto::chat::message::Role::User, "hi".to_string(), @@ -421,12 +464,15 @@ mod tests { KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL), &mut state, ); + // Should pick the second (last) assistant message, not "first reply" assert_eq!( state.misc.pending_clipboard_copy, Some("second reply".to_string()) ); } + /// Ctrl+Y with zero assistant messages shows an info toast instead + /// of setting the clipboard. #[test] fn ctrl_y_with_no_assistant_message_pushes_info_toast() { let mut state = test_state(); diff --git a/crates/zesdex-backend/src/controller/mod.rs b/crates/zesdex-backend/src/controller/mod.rs index 629ab94..8668c67 100644 --- a/crates/zesdex-backend/src/controller/mod.rs +++ b/crates/zesdex-backend/src/controller/mod.rs @@ -1,3 +1,11 @@ //! Keyboard input handling and command parsing for the TUI. +//! +//! The controller layer bridges raw terminal key events (from `crossterm`) to +//! application actions. It contains two sub-modules: +//! +//! - `input` — key-event dispatch, prompt-line editing, history navigation, +//! tab-completion, and action invocation. +//! - `command` — the `/slash` command parser that translates user-typed +//! commands into structured `Action` variants. pub mod command; pub mod input; diff --git a/crates/zesdex-backend/src/daemon.rs b/crates/zesdex-backend/src/daemon.rs index bae0925..ad37485 100644 --- a/crates/zesdex-backend/src/daemon.rs +++ b/crates/zesdex-backend/src/daemon.rs @@ -9,6 +9,7 @@ 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 tracing; use zesdex_cms::domain::repository::SettingsRepository; use crate::app; @@ -21,6 +22,7 @@ use crate::ipc; /// 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 { + tracing::debug!("converting key code to action: {:?}", code); match code { KeyCode::Char(c) => Some(ipc::protocol::KeyAction::Char(c)), KeyCode::Enter => Some(ipc::protocol::KeyAction::Enter), @@ -45,6 +47,7 @@ pub fn key_code_to_action(code: crossterm::event::KeyCode) -> Option crossterm::event::KeyCode { + tracing::debug!("converting key action to code: {:?}", action); match action { ipc::protocol::KeyAction::Char(c) => KeyCode::Char(*c), ipc::protocol::KeyAction::Enter => KeyCode::Enter, @@ -74,6 +77,8 @@ pub fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event /// 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<()> { + tracing::debug!("sending state update to attached client"); + // Map transcript messages to wire DTOs let messages: Vec = state .transcript_cache .messages @@ -85,6 +90,7 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) -> }) .collect(); + // Map toasts to wire DTOs let toasts: Vec = state .misc .toasts @@ -97,6 +103,7 @@ fn send_daemon_update(conn: &mut ipc::conn::Connection, state: &AppStateRest) -> }) .collect(); + // Derive overlay name (None if no overlay is active) let overlay = if state.misc.overlay.is_active() { Some(format!("{:?}", state.misc.overlay)) } else { @@ -126,14 +133,17 @@ fn handle_daemon_client( mut conn: ipc::conn::Connection, state: &mut AppStateRest, ) -> Result<()> { - let mut running = true; + tracing::debug!("handling daemon client connection"); + let mut running = true; // loop control flag; set to false on Close or disconnect while running { match conn.receive::()? { Some(req) => { match req { + // Process one tick: drives animation, streaming, and background tasks ClientRequest::Tick => { apply_action(state, Action::Tick); } + // Forward a key press: reconstruct crossterm KeyEvent from IPC KeyAction ClientRequest::KeyPress { key, ctrl, @@ -158,6 +168,7 @@ fn handle_daemon_client( } apply_action(state, Action::Tick); } + // Set input buffer and simulate Enter to submit the text ClientRequest::Submit(text) => { state.input.buffer = text; let enter_event = crossterm::event::KeyEvent::new( @@ -170,12 +181,14 @@ fn handle_daemon_client( } apply_action(state, Action::Tick); } + // Insert text at cursor position (no submit) 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); } + // Notify state of terminal resize ClientRequest::Resize(w, h) => { apply_action(state, Action::Resize(w, h)); apply_action(state, Action::Tick); @@ -188,6 +201,7 @@ fn handle_daemon_client( apply_action(state, Action::ScrollDown); apply_action(state, Action::Tick); } + // Graceful shutdown signal from the attached client ClientRequest::Close => { running = false; } @@ -220,11 +234,12 @@ fn handle_daemon_client( /// `crossterm::KeyEvent` from the IPC `KeyAction`, so daemon and /// single-process modes share identical key-handling logic. pub fn run_daemon() -> Result<()> { + tracing::info!("starting daemon process"); let (store, _session_lock_guard, mut state, _rt) = crate::create_session()?; - let run_dir = store.base_dir.join("run"); + let run_dir = store.base_dir.join("run"); // directory for Unix socket files std::fs::create_dir_all(&run_dir)?; - let socket_path = run_dir.join(format!("{}.sock", state.session_id)); + let socket_path = run_dir.join(format!("{}.sock", state.session_id)); // per-session socket let addr = socket_path.to_string_lossy().to_string(); let server = ipc::server::IpcServer::bind_unix(&addr)?; diff --git a/crates/zesdex-backend/src/dto/mod.rs b/crates/zesdex-backend/src/dto/mod.rs index 5951560..55bd7dc 100644 --- a/crates/zesdex-backend/src/dto/mod.rs +++ b/crates/zesdex-backend/src/dto/mod.rs @@ -1,24 +1,44 @@ -//! Re-exports from `zesdex-entities` canonical types under the original -//! module paths, with provider request/response type aliases. +//! DTO (Data Transfer Object) re-exports for the zesdex-backend crate. //! -//! Chat types come from the entities crate to avoid type duplication -//! with `crate::model::conversation::Conversation` which stores -//! `ChatMessage` values. +//! This module re-exports canonical types from `zesdex-entities` under +//! their original module paths, providing a single import boundary for +//! the backend. It also defines provider request/response type aliases. +//! +//! ## Components +//! - `chat` — re-exports `ChatMessage` and `ToolCall` types +//! - `provider` — re-exports `ChatRequest`, `ChatResponse`, `StreamOptions`, +//! `ToolDef`, and `ToolFunctionDef` from the entities crate +//! +//! ## Why This Exists +//! Chat types live in the entities crate to avoid type duplication with +//! `crate::model::conversation::Conversation`, which stores `ChatMessage` +//! values directly. This module re-exports them so backend code can refer +//! to `dto::chat::message::*` without depending on the entities crate path. +/// Chat-related DTO types (messages and tool calls). +/// +/// Re-exports from `zesdex_entities::domain::common`. pub mod chat { + /// Chat message types (role, content, metadata). pub mod message { pub use zesdex_entities::domain::common::message::*; } + /// Tool-call types (function name, arguments, result). pub mod tool { pub use zesdex_entities::domain::common::tool_call::*; } } +/// Provider communication DTO types (request/response). +/// +/// Re-exports from `zesdex_entities::domain::common::provider`. pub mod provider { + /// Provider request types: payload, streaming options, tool definitions. pub mod request { pub use zesdex_entities::domain::common::provider::ChatRequest as ChatRequest; pub use zesdex_entities::domain::common::provider::{StreamOptions, ToolDef, ToolFunctionDef}; } + /// Provider response type: the full chat response from an LLM. pub mod response { pub use zesdex_entities::domain::common::provider::ChatResponse as ChatResponse; } diff --git a/crates/zesdex-backend/src/event_loop.rs b/crates/zesdex-backend/src/event_loop.rs index eb801b8..fd5bb10 100644 --- a/crates/zesdex-backend/src/event_loop.rs +++ b/crates/zesdex-backend/src/event_loop.rs @@ -1,8 +1,20 @@ //! Single-process event loop — the core render/input loop plus the //! wrapper that sets up the terminal and the `run_single_process` entry //! point. +//! +//! Flow: `run_single_process()` creates a session + lock → enters raw mode +//! and alternate screen → calls `run_loop()` → `run_loop()` delegates to +//! `run_loop_inner()` for the actual loop → on exit (or error), `run_loop()` +//! restores the terminal before returning → `run_single_process()` saves +//! settings and releases the session lock. +//! +//! Inner loop: render frame → poll terminal events (50 ms timeout) → if a +//! key event arrives, `handle_key()` → `apply_action()`; paste/resize/ +//! scroll map to `Action` directly → always fire `Action::Tick` per +//! iteration (drives streaming/background progress) → on quit, clear. use anyhow::Result; +use tracing; use app::runtime::actions::{apply_action, Action}; use app::state::rest::AppStateRest; use controller::input::handle_key; @@ -32,8 +44,10 @@ use crate::view; /// outside `run_loop`'s `Result` so a panicking/erroring loop still /// leaves the user's terminal usable. pub fn run_single_process() -> Result<()> { + tracing::info!("starting single-process mode"); let (_store, _session_lock_guard, mut state, _rt) = crate::create_session()?; + // Enter raw mode and alternate screen for the TUI enable_raw_mode()?; let mut stdout = io::stdout(); execute!(stdout, EnterAlternateScreen)?; @@ -74,6 +88,7 @@ fn run_loop( state: &mut AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { + tracing::debug!("entering run loop"); let result = run_loop_inner(state, terminal); if let Err(ref _e) = result { let _ = terminal.clear(); @@ -101,6 +116,7 @@ fn run_loop_inner( state: &mut AppStateRest, terminal: &mut Terminal>, ) -> Result<()> { + tracing::debug!("starting render/input inner loop"); loop { if state.quit { break; @@ -111,14 +127,17 @@ fn run_loop_inner( view::draw(f, state); state.dirty = false; })?; + // Poll terminal with 50 ms timeout for low-latency input handling if crossterm::event::poll(Duration::from_millis(50))? { match crossterm::event::read()? { Event::Key(key) => { + // Only handle press/repeat; ignore release if key.kind == KeyEventKind::Press || key.kind == KeyEventKind::Repeat { let actions = handle_key(key, state); for action in actions { apply_action(state, action); } + // Forward clipboard content via OSC 52 escape sequence if let Some(text) = state.misc.pending_clipboard_copy.take() { let _ = write_osc52(&mut io::stdout(), &text); state.push_toast(app::state::types::Toast::new( @@ -137,15 +156,18 @@ fn run_loop_inner( } state.input.buffer.insert_str(state.input.cursor, &text); state.input.cursor += text.len(); + // Re-open autocomplete if paste starts with '/' if state.input.buffer.starts_with('/') { state.input.open_autocomplete(); } state.dirty = true; } Event::Resize(w, h) => { + // Terminal dimensions changed → re-layout all panels apply_action(state, Action::Resize(w, h)); } Event::Mouse(mouse_event) => { + // Forward scroll wheel events (clicks handled by TUI widgets) if mouse_event.kind == MouseEventKind::ScrollUp { apply_action(state, Action::ScrollUp); } else if mouse_event.kind == MouseEventKind::ScrollDown { @@ -155,6 +177,7 @@ fn run_loop_inner( _ => {} } } + // Tick always fires each iteration, driving streaming/async progress apply_action(state, Action::Tick); } terminal.clear()?; diff --git a/crates/zesdex-backend/src/ipc/mod.rs b/crates/zesdex-backend/src/ipc/mod.rs index bb31e7d..c25f128 100644 --- a/crates/zesdex-backend/src/ipc/mod.rs +++ b/crates/zesdex-backend/src/ipc/mod.rs @@ -1,14 +1,33 @@ -//! Re-exports from `zesdex-ipc` crate under the original module paths. +//! IPC (Inter-Process Communication) re-exports for zesdex-backend. +//! +//! Re-exports the full public API surface of the `zesdex-ipc` crate under +//! the original module paths, providing a single import boundary for all +//! IPC concerns used by the backend. +//! +//! ## Components +//! - `protocol` — IPC wire protocol types (messages, framing) +//! - `conn` — Connection types for IPC transport +//! - `client` — IPC client implementation +//! - `server` — IPC server implementation +//! +//! ## Data Flow +//! Backend modules import IPC types through this module rather than +//! depending on `zesdex-ipc` directly, making it easier to swap or +//! version the IPC layer independently. +/// IPC protocol types (messages, framing, enums). pub mod protocol { pub use zesdex_ipc::protocol::*; } +/// IPC connection types (transport-level abstraction). pub mod conn { pub use zesdex_ipc::conn::*; } +/// IPC client implementation (connect, send, receive). pub mod client { pub use zesdex_ipc::client::*; } +/// IPC server implementation (listen, accept, dispatch). pub mod server { pub use zesdex_ipc::server::*; } diff --git a/crates/zesdex-backend/src/main.rs b/crates/zesdex-backend/src/main.rs index 8831c80..723363f 100644 --- a/crates/zesdex-backend/src/main.rs +++ b/crates/zesdex-backend/src/main.rs @@ -13,6 +13,7 @@ use anyhow::Result; use std::sync::Mutex; +use tracing; use zesdex_iam::domain::repository::{SessionLockRepository, SessionRepository}; @@ -46,11 +47,12 @@ pub(crate) fn create_session() -> Result<( app::state::rest::AppStateRest, tokio::runtime::Runtime, )> { + tracing::info!("creating new session"); let store = model::store::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); + let session_id = uuid::Uuid::new_v4().to_string(); // unique per-invocation ID + let session_dir = store.base_dir.join("sessions").join(&session_id); // per-session dir std::fs::create_dir_all(&session_dir)?; let lock_repo = @@ -89,18 +91,19 @@ pub(crate) fn create_session() -> Result<( /// Why: logging is routed to a file (never stderr/stdout) because writing /// to the terminal while ratatui owns the alternate screen corrupts the UI. fn main() -> Result<()> { - let args: Vec = std::env::args().collect(); - let is_daemon = args.iter().any(|a| a == "--daemon"); + tracing::info!("starting zesdex main process"); + let args: Vec = std::env::args().collect(); // raw CLI arguments + let is_daemon = args.iter().any(|a| a == "--daemon"); // true if --daemon flag present let attach_session = args .iter() - .position(|a| a == "--attach") - .and_then(|i| args.get(i + 1).cloned()); + .position(|a| a == "--attach") // position of --attach flag, if any + .and_then(|i| args.get(i + 1).cloned()); // optional session ID 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_path = log_dir.join("zesdex.log"); // full path to log file let log_file = std::fs::OpenOptions::new() .create(true) .append(true) @@ -111,12 +114,13 @@ fn main() -> Result<()> { .write(true) .open("/dev/null") .expect("cannot open /dev/null") - }); + }); // file handle for tracing subscriber output + // Initialize tracing: log to file (never stderr) to avoid corrupting the TUI tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() - .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")) ) .with_writer(Mutex::new(log_file)) .init(); diff --git a/crates/zesdex-backend/src/model/agent_def/builtin.rs b/crates/zesdex-backend/src/model/agent_def/builtin.rs index e4e1a28..69f3722 100644 --- a/crates/zesdex-backend/src/model/agent_def/builtin.rs +++ b/crates/zesdex-backend/src/model/agent_def/builtin.rs @@ -1,5 +1,16 @@ #![allow(dead_code)] //! Hardcoded built-in subagent definitions (coder, reviewer, researcher, planner). +//! +//! These agents are always available regardless of user or session config. +//! They provide the default set of roles shipped with the application. +//! +//! ## Available agents +//! | Agent | Purpose | Key tools | +//! |-------|---------|-----------| +//! | coder | Write/edit code | read, write, edit, bash, lsp_* | +//! | reviewer | Review code for correctness/safety | read, grep, lsp_diagnostics | +//! | researcher | Search and summarise | read, grep, bash, search_web | +//! | planner | Break down tasks into steps | read, write, edit, bash, todo_* | use crate::app::subagent::spawn::AgentDefinition; /// Build the fixed list of built-in agent definitions shipped with zesdex. @@ -34,6 +45,7 @@ pub fn builtin_agents() -> Vec { "lsp_completion".to_string(), "lsp_disconnect".to_string(), ]) + // Unlimited steps — the coder runs until the task is done. .with_max_steps(usize::MAX), AgentDefinition::new("reviewer".to_string(), "reviewer".to_string()) .with_system_prompt( diff --git a/crates/zesdex-backend/src/model/agent_def/global.rs b/crates/zesdex-backend/src/model/agent_def/global.rs index 3509362..34dab41 100644 --- a/crates/zesdex-backend/src/model/agent_def/global.rs +++ b/crates/zesdex-backend/src/model/agent_def/global.rs @@ -17,22 +17,32 @@ use crate::app::subagent::spawn::AgentDefinition; pub fn load_global_agents() -> Vec { let store = crate::model::store::Store::new(); let agents_dir = store.base_dir.join("agents"); + tracing::debug!(dir = %agents_dir.display(), "load_global_agents"); + if !agents_dir.exists() { + tracing::debug!("load_global_agents — agents dir does not exist"); return Vec::new(); } let mut agents = Vec::new(); if let Ok(entries) = std::fs::read_dir(&agents_dir) { for entry in entries.flatten() { let path = entry.path(); + // Only process `.json` files; skip subdirectories, hidden files, etc. if path.extension().is_some_and(|e| e == "json") { if let Ok(content) = std::fs::read_to_string(&path) { if let Ok(def) = serde_json::from_str::(&content) { + tracing::debug!(agent = %def.name, "load_global_agents — loaded"); agents.push(def); + } else { + tracing::warn!(file = %path.display(), "load_global_agents — failed to parse JSON"); } + } else { + tracing::warn!(file = %path.display(), "load_global_agents — failed to read file"); } } } } + tracing::info!(count = agents.len(), "load_global_agents — done"); agents } @@ -56,13 +66,17 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { let path = agents_dir.join(format!("{}.json", def.name)); let tmp = agents_dir.join(format!("{}.json.tmp", def.name)); let content = serde_json::to_string_pretty(def)?; + // Write to temp file first, then fsync + rename for crash-safe atomic write. + tracing::debug!(agent = %def.name, "save_global_agent — writing"); std::fs::write(&tmp, content)?; let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, path)?; + // Sync parent directory so the rename is durable on filesystems like ext4. if let Some(parent) = agents_dir.parent() { let _ = std::fs::File::open(parent).and_then(|d| d.sync_all()); } + tracing::info!(agent = %def.name, "save_global_agent — saved"); Ok(()) } @@ -76,9 +90,19 @@ pub fn save_global_agent(def: &AgentDefinition) -> anyhow::Result<()> { pub fn remove_global_agent(name: &str) -> anyhow::Result { let store = crate::model::store::Store::new(); let path = store.base_dir.join("agents").join(format!("{name}.json")); + tracing::debug!(%name, path = %path.display(), "remove_global_agent"); match std::fs::remove_file(&path) { - Ok(_) => Ok(true), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), - Err(e) => Err(e.into()), + Ok(_) => { + tracing::info!(%name, "remove_global_agent — removed"); + Ok(true) + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + tracing::debug!(%name, "remove_global_agent — not found"); + Ok(false) + } + Err(e) => { + tracing::error!(%name, error = %e, "remove_global_agent — failed"); + Err(e.into()) + } } } diff --git a/crates/zesdex-backend/src/model/agent_def/mod.rs b/crates/zesdex-backend/src/model/agent_def/mod.rs index 0068539..7b29ebb 100644 --- a/crates/zesdex-backend/src/model/agent_def/mod.rs +++ b/crates/zesdex-backend/src/model/agent_def/mod.rs @@ -1,5 +1,15 @@ //! Agent definition sources: built-in defaults, global (user-wide), and //! per-session overrides. +//! +//! Agent definitions control the system prompt, tool set, and configuration +//! for each agent. The resolution order (lowest to highest priority) is: +//! +//! 1. `builtin` — hardcoded default agent shipped with the application. +//! 2. `global` — user-wide overrides stored in the config directory. +//! 3. `session` — per-session overrides stored in the session directory. +//! +//! This layered approach lets users customise agents globally and then +//! fine-tune per-session without modifying the built-in defaults. pub mod builtin; pub mod global; pub mod session; diff --git a/crates/zesdex-backend/src/model/agent_def/session.rs b/crates/zesdex-backend/src/model/agent_def/session.rs index 8055698..8d41c42 100644 --- a/crates/zesdex-backend/src/model/agent_def/session.rs +++ b/crates/zesdex-backend/src/model/agent_def/session.rs @@ -1,6 +1,10 @@ #![allow(dead_code)] //! Load, save, add, and remove agent definitions scoped to a single //! session (`/agents.json`). +//! +//! Session-scoped agents override global and built-in agents of the same +//! name, letting users define custom personalities for specific tasks +//! without affecting other sessions. use crate::app::subagent::spawn::AgentDefinition; use std::path::Path; @@ -17,15 +21,25 @@ use std::path::Path; /// exist or the file is malformed. pub fn load_session_agents(session_dir: &Path) -> Vec { let agents_file = session_dir.join("agents.json"); + tracing::debug!(file = %agents_file.display(), "load_session_agents"); + if !agents_file.exists() { + tracing::debug!("load_session_agents — file does not exist"); return Vec::new(); } match std::fs::read_to_string(&agents_file) { - Ok(content) => serde_json::from_str(&content).unwrap_or_else(|e| { - tracing::warn!("[session] failed to parse agents.json: {}", e); + Ok(content) => { + let agents: Vec = serde_json::from_str(&content).unwrap_or_else(|e| { + tracing::warn!("load_session_agents — failed to parse agents.json: {}", e); + Vec::new() + }); + tracing::debug!(count = agents.len(), "load_session_agents — loaded"); + agents + } + Err(e) => { + tracing::warn!(error = %e, "load_session_agents — failed to read"); Vec::new() - }), - Err(_) => Vec::new(), + } } } @@ -41,11 +55,16 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an let agents_file = session_dir.join("agents.json"); let tmp = session_dir.join("agents.json.tmp"); let content = serde_json::to_string_pretty(agents)?; + tracing::debug!(count = agents.len(), "save_session_agents — writing"); + + // Atomic write: temp file → fsync → rename → fsync parent dir std::fs::write(&tmp, content)?; let f = std::fs::File::open(&tmp)?; f.sync_all()?; std::fs::rename(&tmp, agents_file)?; let _ = std::fs::File::open(session_dir).and_then(|d| d.sync_all()); + + tracing::info!(count = agents.len(), "save_session_agents — saved"); Ok(()) } @@ -58,7 +77,9 @@ pub fn save_session_agents(session_dir: &Path, agents: &[AgentDefinition]) -> an /// /// Return: `Ok(())` on success, propagating any load/save error. pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::Result<()> { + tracing::debug!(agent = %def.name, "add_session_agent"); let mut agents = load_session_agents(session_dir); + // Remove existing definition with the same name (upsert semantics) agents.retain(|a| a.name != def.name); agents.push(def.clone()); save_session_agents(session_dir, &agents) @@ -71,12 +92,15 @@ pub fn add_session_agent(session_dir: &Path, def: &AgentDefinition) -> anyhow::R /// Return: `Ok(true)` if removed, `Ok(false)` if not found, `Err` on /// load/save failure. pub fn remove_session_agent(session_dir: &Path, name: &str) -> anyhow::Result { + tracing::debug!(%name, "remove_session_agent"); let mut agents = load_session_agents(session_dir); let before = agents.len(); agents.retain(|a| a.name != name); if agents.len() == before { + tracing::debug!(%name, "remove_session_agent — not found"); return Ok(false); } save_session_agents(session_dir, &agents)?; + tracing::info!(%name, "remove_session_agent — removed"); Ok(true) } diff --git a/crates/zesdex-backend/src/model/mod.rs b/crates/zesdex-backend/src/model/mod.rs index 4997de3..55efc98 100644 --- a/crates/zesdex-backend/src/model/mod.rs +++ b/crates/zesdex-backend/src/model/mod.rs @@ -1,10 +1,20 @@ -//! Re-exports from `zesdex-entities` crate under the original module paths, -//! plus local sub-modules (agent_def, msglog) that weren't extracted. +//! Data-model layer for the zesdex backend. +//! +//! This module re-exports types from the `zesdex-entities` workspace crate +//! under their original `crate::model::*` paths for backward compatibility, +//! and defines two local sub-modules that haven't been extracted: +//! +//! - `agent_def` — agent definition model (built-in, global, session scopes) +//! - `msglog` — SQLite-backed message-log persistence (schema, insert, blobs) +//! +//! ## Re-exports +//! | Path | Source | +//! |------|--------| +//! | `crate::model::store::*` | `zesdex_entities::domain::common::store` | // Module re-exports matching original `crate::model::*` paths pub mod store { pub use zesdex_entities::domain::common::store::*; } pub mod agent_def; -/// Local modules not extracted to workspace crates pub mod msglog; diff --git a/crates/zesdex-backend/src/model/msglog/blobs.rs b/crates/zesdex-backend/src/model/msglog/blobs.rs index f4e5c57..97d7427 100644 --- a/crates/zesdex-backend/src/model/msglog/blobs.rs +++ b/crates/zesdex-backend/src/model/msglog/blobs.rs @@ -8,6 +8,9 @@ use rusqlite::{params, Connection}; /// Flow: compute current timestamp -> `INSERT OR REPLACE` into `blobs` /// keyed on `(session_id, blob_key)`. /// +/// `INSERT OR REPLACE` is used so re-uploading the same key overwrites +/// the previous blob rather than failing on the UNIQUE constraint. +/// /// Return: `Ok(())` on success, or the underlying `SQLite` error. pub fn store_blob( conn: &Connection, @@ -17,10 +20,12 @@ pub fn store_blob( mime_type: Option<&str>, ) -> Result<()> { let created_at = chrono::Utc::now().timestamp_millis(); + tracing::debug!(%session_id, %blob_key, size = data.len(), "store_blob"); conn.execute( "INSERT OR REPLACE INTO blobs (session_id, blob_key, data, mime_type, created_at) VALUES (?1, ?2, ?3, ?4, ?5)", params![session_id, blob_key, data, mime_type, created_at], )?; + tracing::info!(%session_id, %blob_key, "store_blob — stored"); Ok(()) } @@ -33,15 +38,25 @@ pub fn retrieve_blob( session_id: &str, blob_key: &str, ) -> Result>> { + tracing::debug!(%session_id, %blob_key, "retrieve_blob"); let result = conn.query_row( "SELECT data FROM blobs WHERE session_id = ?1 AND blob_key = ?2", params![session_id, blob_key], - |row| row.get(0), + |row| row.get::<_, Vec>(0), ); match result { - Ok(data) => Ok(Some(data)), - Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None), - Err(e) => Err(e.into()), + Ok(data) => { + tracing::debug!(%session_id, %blob_key, size = data.len(), "retrieve_blob — found"); + Ok(Some(data)) + } + Err(rusqlite::Error::QueryReturnedNoRows) => { + tracing::debug!(%session_id, %blob_key, "retrieve_blob — not found"); + Ok(None) + } + Err(e) => { + tracing::error!(%session_id, %blob_key, error = %e, "retrieve_blob — query failed"); + Err(e.into()) + } } } @@ -50,6 +65,7 @@ pub fn retrieve_blob( /// Return: `Ok(Vec)` of keys ordered by `created_at`, or the /// underlying `SQLite` error. pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result> { + tracing::debug!(%session_id, "list_blob_keys"); let mut stmt = conn.prepare("SELECT blob_key FROM blobs WHERE session_id = ?1 ORDER BY created_at ASC")?; let rows = stmt.query_map(params![session_id], |row| row.get::<_, String>(0))?; @@ -57,5 +73,6 @@ pub fn list_blob_keys(conn: &Connection, session_id: &str) -> Result for row in rows { keys.push(row?); } + tracing::debug!(%session_id, count = keys.len(), "list_blob_keys — done"); Ok(keys) } diff --git a/crates/zesdex-backend/src/model/msglog/insert.rs b/crates/zesdex-backend/src/model/msglog/insert.rs index 0368bdb..86349ac 100644 --- a/crates/zesdex-backend/src/model/msglog/insert.rs +++ b/crates/zesdex-backend/src/model/msglog/insert.rs @@ -25,9 +25,13 @@ pub fn insert_message(conn: &Connection, session_id: &str, msg: &ChatMessage) -> Role::System => "system", Role::Tool => "tool", }; + + tracing::debug!(%session_id, %role_str, content_len = content.map_or(0, str::len), "insert_message"); conn.execute( "INSERT INTO messages (session_id, role, content, tool_call_id, tool_name, tool_arguments, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)", params![session_id, role_str, content, tool_call_id, tool_name, tool_arguments, created_at], )?; - Ok(conn.last_insert_rowid()) + let rowid = conn.last_insert_rowid(); + tracing::info!(%session_id, %role_str, rowid, "insert_message — inserted"); + Ok(rowid) } diff --git a/crates/zesdex-backend/src/model/msglog/mod.rs b/crates/zesdex-backend/src/model/msglog/mod.rs index 504e377..865bce3 100644 --- a/crates/zesdex-backend/src/model/msglog/mod.rs +++ b/crates/zesdex-backend/src/model/msglog/mod.rs @@ -1,5 +1,14 @@ //! SQLite-backed message log: per-session `messages.sqlite` storing chat //! messages, blobs, and archive/summary metadata. +//! +//! ## Tables +//! | Table | Purpose | +//! |-------|---------| +//! | `messages` | Individual chat messages (role, content, tool calls) | +//! | `archives` | Session archive metadata (title, model, summary) | +//! | `blobs` | Binary attachments keyed by `(session_id, blob_key)` | +//! +//! All writes use WAL mode for concurrent reads without blocking. pub mod blobs; pub mod insert; pub mod schema; @@ -17,12 +26,16 @@ pub use insert::insert_message; /// fails. pub fn open_or_create(session_dir: &std::path::Path) -> anyhow::Result { let path = session_dir.join("messages.sqlite"); + tracing::debug!(?path, "open_or_create"); + if let Some(parent) = path.parent() { std::fs::create_dir_all(parent)?; } let conn = rusqlite::Connection::open(&path)?; + // WAL journal allows concurrent reads without blocking on writes. conn.execute_batch("PRAGMA journal_mode = WAL;")?; conn.execute_batch("PRAGMA busy_timeout = 5000;")?; schema::init_schema(&conn)?; + tracing::info!("open_or_create — database ready"); Ok(conn) } diff --git a/crates/zesdex-backend/src/model/msglog/schema.rs b/crates/zesdex-backend/src/model/msglog/schema.rs index 075855a..8bb4368 100644 --- a/crates/zesdex-backend/src/model/msglog/schema.rs +++ b/crates/zesdex-backend/src/model/msglog/schema.rs @@ -1,4 +1,17 @@ //! `SQLite` schema definition for the message log database. +//! +//! ## Schema overview +//! +//! ```text +//! archives (1) ──── (N) messages +//! │ +//! └── (N) blobs +//! ``` +//! +//! `messages` has a `FOREIGN KEY` on `archives(session_id)`, but the +//! relationship is soft — the archive row may not exist yet when the first +//! messages are inserted (messages are inserted incrementally during a +//! turn, while the archive is created only on session close). use anyhow::Result; use rusqlite::Connection; @@ -10,6 +23,7 @@ use rusqlite::Connection; /// /// Return: `Ok(())` on success, or the underlying `SQLite` error. pub fn init_schema(conn: &Connection) -> Result<()> { + tracing::debug!("init_schema — creating tables if not exists"); conn.execute_batch("PRAGMA foreign_keys = ON;")?; conn.execute_batch( " @@ -52,5 +66,6 @@ pub fn init_schema(conn: &Connection) -> Result<()> { ); ", )?; + tracing::info!("init_schema — schema ready"); Ok(()) } diff --git a/crates/zesdex-backend/src/prompts.rs b/crates/zesdex-backend/src/prompts.rs index 9c2cdbb..f9d937d 100644 --- a/crates/zesdex-backend/src/prompts.rs +++ b/crates/zesdex-backend/src/prompts.rs @@ -1,15 +1,26 @@ //! Compile-time embedded text resources: the system prompt, tool descriptions, //! and the in-app help screen shown on Ctrl+H. + +/// System prompt that defines the agent's core identity and behavior. pub const SYSTEM_PROMPT: &str = include_str!("../src-misc/system-prompt.txt"); + +/// Tool descriptions injected into the system message for function-calling. pub const SYSTEM_TOOLS: &str = include_str!("../src-misc/system-tools.txt"); -/// Prompt templates for auto-subagent types (inline quick review, -/// test generation, architecture review, security review). +/// Prompt template used by the inline quick-review subagent. pub const AUTO_REVIEWER_PROMPT: &str = include_str!("../src-misc/auto-reviewer-prompt.txt"); + +/// Prompt template used by the test-generation subagent (fired asynchronously +/// at the end of each turn). pub const TEST_GENERATOR_PROMPT: &str = include_str!("../src-misc/test-generator-prompt.txt"); + +/// Prompt template used by the architecture-review subagent. pub const ARCH_REVIEWER_PROMPT: &str = include_str!("../src-misc/arch-reviewer-prompt.txt"); + +/// Prompt template used by the security-review subagent. pub const SECURITY_REVIEWER_PROMPT: &str = include_str!("../src-misc/security-reviewer-prompt.txt"); +/// In-app help screen text shown on Ctrl+H (navigation, input, commands). pub const HELP_TEXT: &str = " ZESDEX - Help ============= diff --git a/crates/zesdex-backend/src/service/mod.rs b/crates/zesdex-backend/src/service/mod.rs index 4385ddd..1287cc3 100644 --- a/crates/zesdex-backend/src/service/mod.rs +++ b/crates/zesdex-backend/src/service/mod.rs @@ -1,3 +1,15 @@ -//! Service layer: LLM provider HTTP client. +//! Service layer: LLM provider HTTP client and streaming infrastructure. +//! +//! The service crate contains the HTTP transport logic for communicating +//! with OpenAI-compatible LLM APIs (OpenAI, Anthropic, and any conforming +//! third-party provider). It handles: +//! +//! - Non-streaming and server-sent-event (SSE) streaming requests +//! - Retry with exponential backoff +//! - Token-usage tracking from response metadata +//! - Error mapping from provider-specific error bodies to uniform `anyhow` errors +//! +//! Sub-modules: +//! - `provider` — single `ProviderService` struct with `chat()` and `chat_stream()` methods pub mod provider; diff --git a/crates/zesdex-backend/src/service/provider.rs b/crates/zesdex-backend/src/service/provider.rs index cee03f7..092c284 100644 --- a/crates/zesdex-backend/src/service/provider.rs +++ b/crates/zesdex-backend/src/service/provider.rs @@ -57,6 +57,10 @@ fn backoff_duration(attempt: u32) -> Duration { /// request builders below, plus well-known auth keywords in case the body /// contains them. This is intentionally tighter than `contains("401")`, /// which could false-positive on a URL port, model name, or body text. +/// Check whether an error string represents an auth or billing failure. +/// +/// Matches structured HTTP status patterns (`API error 401/402/403`) and +/// well-known auth keywords in lower-case. pub fn is_auth_error(err_str: &str) -> bool { let err_lower = err_str.to_lowercase(); // Structured HTTP status patterns @@ -193,12 +197,15 @@ impl LlmClient { let max_retries = 10; let mut attempt = 0u32; + tracing::debug!(%url, model = %self.model, max_retries, "chat_with_tools_non_streaming — starting"); + loop { attempt += 1; // Check abort before each retry so user cancellation is // responsive even during a long non-streaming backoff chain. if crate::app::util::abort::is_aborted_ref(abort_flag) { + tracing::info!("chat_with_tools_non_streaming — aborted by user"); anyhow::bail!("aborted"); } @@ -313,6 +320,8 @@ impl LlmClient { let url = format!("{}/chat/completions", self.base_url); + tracing::debug!(%url, model = %self.model, "chat_with_tools_streaming — starting"); + // Phase 1: Retry the raw SSE call up to 5 times, but only before // meaningful content arrives. After that, fall back to non-streaming. let max_retries_stream = 5; @@ -442,6 +451,7 @@ impl LlmClient { let mut turn = StreamedTurn::new(); let mut usage: Option<(u64, u64)> = None; let mut parser = SseParser::new(); + tracing::debug!("try_stream_once — SSE connection established, reading chunks"); let mut reader = resp; let mut byte_buf: Vec = Vec::new(); let mut chunk_buf = [0u8; 4096]; @@ -451,8 +461,11 @@ impl LlmClient { .read(&mut chunk_buf) .map_err(|e| anyhow::anyhow!("stream read error: {e}"))?; if n == 0 { + tracing::debug!("try_stream_once — EOF (connection closed)"); break; } + // Accumulate raw bytes and advance past the valid UTF-8 prefix so + // we never split a multi-byte character across feed() calls. byte_buf.extend_from_slice(&chunk_buf[..n]); let valid_len = match std::str::from_utf8(&byte_buf) { Ok(s) => s.len(), @@ -489,6 +502,8 @@ impl LlmClient { } } + tracing::debug!("try_stream_once — stream ended without explicit [DONE] event"); + // The connection closed without an explicit `[DONE]` event. Some // providers legitimately omit it, so EOF alone isn't an error — // but if it leaves a tool call's arguments as unparsable JSON, the @@ -518,13 +533,19 @@ pub fn resolve_api_key( settings: &zesdex_cms::domain::settings::Settings, app_config: &zesdex_cms::domain::app_config::AppConfig, ) -> String { + let provider = &settings.provider; + tracing::debug!(%provider, "resolve_api_key — resolving"); + + // 1. Check in-memory settings (user-entered keys from the KeyInput overlay). let mut api_key = settings .api_keys - .get(&settings.provider) + .get(provider) .cloned() .unwrap_or_default(); + + // 2. Fall back to env var → provider default from config. if api_key.is_empty() { - if let Some(provider_cfg) = app_config.providers.get(&settings.provider) { + if let Some(provider_cfg) = app_config.providers.get(provider) { api_key = provider_cfg .api_key_env .as_ref() @@ -533,5 +554,10 @@ pub fn resolve_api_key( .unwrap_or_default(); } } + + if api_key.is_empty() { + tracing::warn!(%provider, "resolve_api_key — no API key found for provider"); + } + api_key } diff --git a/crates/zesdex-backend/src/session.rs b/crates/zesdex-backend/src/session.rs index 403b158..cd8d076 100644 --- a/crates/zesdex-backend/src/session.rs +++ b/crates/zesdex-backend/src/session.rs @@ -2,8 +2,15 @@ //! //! Owns the lock repository so that a guard can be returned from the //! session-creation helper without lifetime gymnastics. +//! +//! Flow: `SessionLockGuard::new(lock_repo, session_dir)` takes ownership of +//! both the repo and the path → `lock_repo.try_lock()` has already been +//! called before constructing the guard → when the guard drops (RAII), +//! `lock_repo.unlock()` is called automatically → even across panics, +//! the session lock is cleaned up. use std::path::PathBuf; +use tracing; /// RAII guard that releases a session lock on drop, restoring the /// panic-safety net the old `entities::SessionLock`'s `Drop` impl provided @@ -16,7 +23,10 @@ pub struct SessionLockGuard SessionLockGuard { /// Create a new guard, taking ownership of the lock repository. + /// + /// Caller must have already acquired the lock via `lock_repo.try_lock()`. pub fn new(lock_repo: L, session_dir: PathBuf) -> Self { + tracing::debug!("acquired session lock for {:?}", session_dir); Self { lock_repo, session_dir, @@ -26,6 +36,7 @@ impl SessionLockGuard< 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); } } diff --git a/crates/zesdex-backend/src/tool/bash_tools.rs b/crates/zesdex-backend/src/tool/bash_tools.rs index b643eb7..aee3f39 100644 --- a/crates/zesdex-backend/src/tool/bash_tools.rs +++ b/crates/zesdex-backend/src/tool/bash_tools.rs @@ -1,11 +1,17 @@ //! Tool implementations for interacting with background bash jobs: `bash_output` //! and `bash_kill`. Both take a `job_id` produced by `bash` with `run_in_background=true`. +//! +//! Each tool validates that the `job_id` conforms to UUID v4 format to prevent injection +//! into the global background-job registry. use super::Tool; use super::ToolCtx; use anyhow::Result; use serde_json::{json, Value}; +use tracing; /// Tool: fetch buffered output from a background bash job by `job_id`. +/// +/// Flow: extract `job_id` → validate UUID format → query `bgbash::control::bash_output`. pub struct BashOutput; impl Tool for BashOutput { @@ -32,9 +38,11 @@ impl Tool for BashOutput { fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let job_id = crate::tool::arg_str(args, "job_id")?; + tracing::debug!(job_id = %job_id, "BashOutput::run invoked"); // Validate that job_id looks like a UUID to prevent injection // into the global job registry. if !is_valid_job_id(&job_id) { + tracing::warn!(job_id = %job_id, "invalid bash_output job_id format"); anyhow::bail!("invalid job_id format: expected UUID"); } match crate::app::bgbash::control::bash_output(&job_id) { @@ -45,6 +53,8 @@ impl Tool for BashOutput { } /// Tool: terminate a running background bash job by `job_id`. +/// +/// Flow: extract `job_id` → validate UUID format → call `bgbash::control::bash_kill`. pub struct BashKill; impl Tool for BashKill { @@ -71,15 +81,20 @@ impl Tool for BashKill { fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let job_id = crate::tool::arg_str(args, "job_id")?; + tracing::debug!(job_id = %job_id, "BashKill::run invoked"); if !is_valid_job_id(&job_id) { + tracing::warn!(job_id = %job_id, "invalid bash_kill job_id format"); anyhow::bail!("invalid job_id format: expected UUID"); } crate::app::bgbash::control::bash_kill(&job_id)?; + tracing::info!(job_id = %job_id, "background job killed"); Ok(format!("Killed background job '{job_id}'")) } } /// Validate that a `job_id` matches UUID v4 format (hex with dashes). +/// +/// Flow: split on `-` → expect exactly 5 parts → each all-hex → length pattern 8-4-4-4-12. fn is_valid_job_id(id: &str) -> bool { // UUID v4 format: 8-4-4-4-12 hex digits let parts: Vec<&str> = id.split('-').collect(); diff --git a/crates/zesdex-backend/src/tool/fs/delete.rs b/crates/zesdex-backend/src/tool/fs/delete.rs index f3a750f..6c6e082 100644 --- a/crates/zesdex-backend/src/tool/fs/delete.rs +++ b/crates/zesdex-backend/src/tool/fs/delete.rs @@ -1,4 +1,7 @@ //! Tool: `delete` — remove a file or empty directory relative to a workspace root. +//! +//! Will only delete files and *empty* directories. Non-empty directories are +//! refused with an error to prevent accidental mass deletion. use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; @@ -7,8 +10,11 @@ use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; +use tracing; /// Tool: delete a file or empty directory. Refuses non-empty directories. +/// +/// Flow: resolve path → check existence → check dir/file → remove. pub struct Delete; impl Tool for Delete { @@ -43,6 +49,7 @@ impl Tool for Delete { /// Only empty directories are deletable (non-empty returns an error). fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = arg_str(args, "path")?; + tracing::debug!(path = %rel, "Delete::run invoked"); let path: PathBuf = resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { diff --git a/crates/zesdex-backend/src/tool/fs/edit.rs b/crates/zesdex-backend/src/tool/fs/edit.rs index 8628867..1b19950 100644 --- a/crates/zesdex-backend/src/tool/fs/edit.rs +++ b/crates/zesdex-backend/src/tool/fs/edit.rs @@ -1,4 +1,8 @@ //! Tool: `edit` — replace a substring in a file with a new string. +//! +//! Performs an in-file string replacement with a uniqueness guard: by default +//! the `old` string must appear exactly once unless `replace_all` is set. +//! Emits a unified diff of the change and logs an `EditLogEntry`. use super::super::check_graduated_checks; use super::super::resolve_path; use super::super::Tool; @@ -10,8 +14,12 @@ use serde_json::{json, Value}; use similar::TextDiff; use std::fs; use std::path::PathBuf; +use tracing; /// Tool: replace text in a file. Requires the old string to be unique unless `replace_all` is true. +/// +/// Flow: validate args → resolve path → read file → count occurrences → +/// replace one or all → write back → report diff (+ optional graduated checks). pub struct Edit; impl Tool for Edit { @@ -67,6 +75,7 @@ impl Tool for Edit { if reason.trim().is_empty() { anyhow::bail!("reason must be a non-empty string"); } + tracing::debug!(path = %rel, old_len = old.len(), new_len = new_str.len(), reason = %reason, "Edit::run invoked"); if old.is_empty() { anyhow::bail!( "'old' must be a non-empty string; use 'write' to replace entire file contents" @@ -136,6 +145,7 @@ impl Tool for Edit { } } +/// Unit tests for the Edit tool: single-replace diff block, large-diff truncation. #[cfg(test)] mod tests { use super::*; diff --git a/crates/zesdex-backend/src/tool/fs/helpers.rs b/crates/zesdex-backend/src/tool/fs/helpers.rs index 3cac0ab..411dea1 100644 --- a/crates/zesdex-backend/src/tool/fs/helpers.rs +++ b/crates/zesdex-backend/src/tool/fs/helpers.rs @@ -1,8 +1,8 @@ -//! Shared helpers for filesystem tools: extracting string arguments from JSON -//! and producing user-friendly "not found" diagnostics. +//! Shared helpers for filesystem tools: `not_found_help` (user-friendly path diagnostic) +//! and `truncate_diff` (cap unified diffs at `MAX_DIFF_LINES`). use std::path::Path; - +use tracing; /// Produce a user-friendly diagnostic string when a path doesn't resolve or exist. /// @@ -11,6 +11,7 @@ use std::path::Path; /// /// Return: a one-line description of the resolution failure. pub fn not_found_help(ctx: &super::super::ToolCtx, path: &Path, rel: &str) -> String { + tracing::debug!(relative = %rel, "generating not-found diagnostic"); let canon = path.canonicalize().unwrap_or_else(|_| path.to_path_buf()); let in_ws = ctx.workspaces.iter().any(|w| { let wc = w.canonicalize().unwrap_or_else(|_| w.clone()); @@ -40,6 +41,9 @@ pub const MAX_DIFF_LINES: usize = 200; /// Cap a unified diff at `MAX_DIFF_LINES` lines, appending a truncation note. /// +/// Flow: split into lines → if within limit, return unchanged → otherwise +/// take first `MAX_DIFF_LINES` lines and append `"... ({N} more lines truncated)"`. +/// /// Return: `diff` unchanged if it's within the limit; otherwise the first /// `MAX_DIFF_LINES` lines followed by `"... ({N} more lines truncated)"`. pub fn truncate_diff(diff: &str) -> String { @@ -48,12 +52,14 @@ pub fn truncate_diff(diff: &str) -> String { return diff.to_string(); } let remaining = lines.len() - MAX_DIFF_LINES; + tracing::debug!(total = lines.len(), max = MAX_DIFF_LINES, "truncating diff"); format!( "{}\n... ({remaining} more lines truncated)", lines[..MAX_DIFF_LINES].join("\n") ) } +/// Unit tests for `truncate_diff`: under-limit passthrough and over-limit truncation. #[cfg(test)] mod tests { use super::*; diff --git a/crates/zesdex-backend/src/tool/fs/mod.rs b/crates/zesdex-backend/src/tool/fs/mod.rs index 2bcd1a8..3b02eda 100644 --- a/crates/zesdex-backend/src/tool/fs/mod.rs +++ b/crates/zesdex-backend/src/tool/fs/mod.rs @@ -1,5 +1,9 @@ //! Filesystem tool implementations: read, write, edit, and delete operations //! on workspace-rooted paths. +//! +//! Every tool in this module resolves paths through `super::resolve_path` to +//! enforce workspace sandboxing. Write and edit operations also log an +//! `EditLogEntry` via `super::log_write_edit_tool`. pub mod delete; pub mod edit; pub mod helpers; diff --git a/crates/zesdex-backend/src/tool/fs/read.rs b/crates/zesdex-backend/src/tool/fs/read.rs index 21ec246..0550998 100644 --- a/crates/zesdex-backend/src/tool/fs/read.rs +++ b/crates/zesdex-backend/src/tool/fs/read.rs @@ -1,4 +1,8 @@ //! Tool: `read` — display file contents with line numbers. +//! +//! Resolves the file path through `resolve_path` to enforce workspace sandboxing. +//! If the file does not exist, returns a diagnostic `not_found_help` message that +//! suggests nearby files instead of failing noisily. use super::super::resolve_path; use super::super::Tool; use super::super::ToolCtx; @@ -8,8 +12,12 @@ use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fs; use std::path::PathBuf; +use tracing; /// Tool: read a file and display it with line numbers, optionally truncated to `limit` lines. +/// +/// Flow: resolve path → if not found, call `not_found_help` for diagnostic → +/// read entire file → enumerate and format lines → optionally truncate by `limit`. pub struct Read; impl Tool for Read { @@ -50,7 +58,8 @@ impl Tool for Read { let limit = args .get("limit") .and_then(serde_json::Value::as_u64) - .map(|v| v as usize); + .map(|v| v as usize); // optional line-count limit + tracing::debug!(path = %rel, limit, "Read::run invoked"); let path: PathBuf = match resolve_path(&ctx.workspaces, &rel) { Ok(p) => p, Err(_e) => return Ok(not_found_help(ctx, &PathBuf::from(&rel), &rel)), diff --git a/crates/zesdex-backend/src/tool/fs/write.rs b/crates/zesdex-backend/src/tool/fs/write.rs index b89c4e6..37b2c92 100644 --- a/crates/zesdex-backend/src/tool/fs/write.rs +++ b/crates/zesdex-backend/src/tool/fs/write.rs @@ -1,4 +1,8 @@ //! Tool: `write` — write content to a file, creating parent directories on demand. +//! +//! Validates that a non-empty `reason` argument is supplied (to discourage stray writes), +//! resolves the path through the workspace sandbox, creates parent directories silently, +//! emits a unified diff when overwriting an existing UTF-8 file, and logs an `EditLogEntry`. use super::super::check_graduated_checks; use super::super::resolve_path; use super::super::Tool; @@ -9,6 +13,7 @@ use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use similar::TextDiff; use std::fs; +use tracing; /// Tool: write content to a file, auto-creating parent directories as needed. pub struct Write; @@ -57,9 +62,10 @@ impl Tool for Write { if reason.trim().is_empty() { anyhow::bail!("reason must be a non-empty string"); } + tracing::debug!(path = %rel, bytes = content.len(), reason = %reason, "Write::run invoked"); let check_matches = check_graduated_checks(&rel, &content, &ctx.graduated_checks); let path = resolve_path(&ctx.workspaces, &rel)?; - let old_content = fs::read_to_string(&path).ok(); + let old_content = fs::read_to_string(&path).ok(); // read old content for diff let existed_before = path.exists(); if let Some(parent) = path.parent() { fs::create_dir_all(parent) @@ -114,6 +120,7 @@ impl Tool for Write { } } +/// Unit tests for the Write tool: new-file creation, overwrite diff, binary fallback, mention-index tracking. #[cfg(test)] mod tests { use super::*; diff --git a/crates/zesdex-backend/src/tool/git_cred.rs b/crates/zesdex-backend/src/tool/git_cred.rs index 28fbf37..64a7da6 100644 --- a/crates/zesdex-backend/src/tool/git_cred.rs +++ b/crates/zesdex-backend/src/tool/git_cred.rs @@ -1,11 +1,18 @@ //! Tool wrapper around `git credential` for store/get/erase operations. +//! +//! Delegates to `git credential ` via `execute_cmd`. The git binary must be on +//! `$PATH`. Credential reads are intentionally not blocked here — see the shell +//! module doc for rationale. use super::Tool; use super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::process::Command; +use tracing; /// Tool that shells out to `git credential ` to store, retrieve, or erase credentials. +/// +/// Flow: extract `operation` arg → spawn `git credential ` → capture output. pub struct GitCred; impl Tool for GitCred { @@ -41,6 +48,7 @@ impl Tool for GitCred { /// Return: combined stdout+stderr on success; error with stderr on non-zero exit. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let operation = crate::tool::arg_str(args, "operation")?; + tracing::debug!(operation = %operation, "GitCred::run invoked"); let mut cmd = Command::new("git"); cmd.arg("credential").arg(&operation); diff --git a/crates/zesdex-backend/src/tool/git_operator.rs b/crates/zesdex-backend/src/tool/git_operator.rs index 9818aaf..0c7b10a 100644 --- a/crates/zesdex-backend/src/tool/git_operator.rs +++ b/crates/zesdex-backend/src/tool/git_operator.rs @@ -1,11 +1,20 @@ //! Generic tool for running arbitrary git subcommands. +//! +//! Routes through `shell_filter::git::check_git_destructive` to block +//! operations like `push --force` that would otherwise bypass the safety +//! filter when invoked through this tool instead of the `bash` tool. use super::Tool; use super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::process::Command; +use tracing; /// Tool that runs `git [args...]` and returns combined stdout/stderr. +/// +/// Flow: extract `operation` + `args` → gate through `shell_filter::git` +/// to block destructive operations → spawn `git ` → +/// trim and join stdout/stderr. pub struct GitOperator; impl Tool for GitOperator { @@ -41,10 +50,6 @@ impl Tool for GitOperator { /// Run `git [args...]` and return its combined output. /// - /// Flow: extract `operation` + `args` → gate through `shell_filter::git` - /// to block destructive operations → spawn `git ` → - /// trim and join stdout/stderr. - /// /// Why: reconstructing the command string for the shell filter prevents /// the model (or a subagent) from running destructive git operations /// that would otherwise bypass the filter by going through this tool @@ -54,6 +59,7 @@ impl Tool for GitOperator { /// stderr on failure. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let operation = crate::tool::arg_str(args, "operation")?; + tracing::debug!(operation = %operation, "GitOperator::run invoked"); let arg_list: Vec = args .get("args") .and_then(|v| v.as_array()) diff --git a/crates/zesdex-backend/src/tool/git_worktree.rs b/crates/zesdex-backend/src/tool/git_worktree.rs index e714db1..4938f71 100644 --- a/crates/zesdex-backend/src/tool/git_worktree.rs +++ b/crates/zesdex-backend/src/tool/git_worktree.rs @@ -1,11 +1,19 @@ //! Tool for creating git worktrees under the session's worktrees directory. +//! +//! Sanitises the worktree `name` to reject path separators and `..` before passing +//! it to `git worktree add`. Worktrees are created under `ctx.worktrees_dir`. use super::Tool; use super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::process::Command; +use tracing; /// Tool that creates a new git worktree (`git worktree add`) from a given base ref. +/// +/// Flow: extract `name/base_ref` → sanitise name (reject `/`, `\`, `..`) → +/// create worktree dir under `ctx.worktrees_dir` → spawn `git worktree add` → +/// combine stdout/stderr. pub struct GitWorktree; impl Tool for GitWorktree { @@ -47,6 +55,7 @@ impl Tool for GitWorktree { anyhow::bail!("worktree name must not contain path separators or '..'"); } let base_ref = crate::tool::arg_str(args, "base_ref")?; + tracing::debug!(name = %name, base_ref = %base_ref, "GitWorktree::run invoked"); let worktree_path = ctx.worktrees_dir.join(&name); std::fs::create_dir_all(&worktree_path) .map_err(|e| anyhow!("failed to create worktree directory: {e}"))?; @@ -58,6 +67,7 @@ impl Tool for GitWorktree { let output = crate::tool::execute_cmd(&mut cmd) .map_err(|e| anyhow!("git worktree add failed: {e}"))?; + tracing::info!(name = %name, base_ref = %base_ref, "worktree created"); Ok(format!( "created worktree '{name}' from '{base_ref}'\n{output}" )) diff --git a/crates/zesdex-backend/src/tool/lsp/completion.rs b/crates/zesdex-backend/src/tool/lsp/completion.rs index 4310db5..6d9f90b 100644 --- a/crates/zesdex-backend/src/tool/lsp/completion.rs +++ b/crates/zesdex-backend/src/tool/lsp/completion.rs @@ -1,9 +1,21 @@ +//! LSP completion tool: retrieves code completion suggestions at a given +//! cursor position from a connected Language Server Protocol server. +//! +//! Supports auto-detection of the LSP server based on the file extension +//! when the `server` argument is omitted. + use anyhow::Result; use serde_json::Value; use std::fmt::Write; +use tracing; use crate::tool::{Tool, ToolCtx}; +/// Tool wrapper around `LspClient::completion()`. +/// +/// Returns up to 50 completion items (label, kind, detail) at the specified +/// cursor position. When `server` is omitted, the server is auto-detected +/// from the file's extension. pub struct LspCompletion; impl Tool for LspCompletion { @@ -17,9 +29,20 @@ impl Tool for LspCompletion { fn parameters(&self) -> Value { super::lsp_cursor_params(false) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + // Extract path from args for logging before the query + let log_path = args.get("path").and_then(|v| v.as_str()).unwrap_or("?"); + tracing::debug!( + "[lsp-completion] requesting completions at {}:{}:{}", + log_path, + args.get("line").and_then(|v| v.as_i64()).unwrap_or(0), + args.get("column").and_then(|v| v.as_i64()).unwrap_or(0), + ); + let (completion_result, line, column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.completion(uri, line, column) })?; + // Normalise response: some servers return an array directly, + // others nest it under an "items" key. let items = if let Some(items) = completion_result.as_array() { items.clone() } else if let Some(arr) = @@ -40,8 +63,10 @@ impl Tool for LspCompletion { line + 1, column + 1 ); + // Format each item with its LSP CompletionItemKind label for (i, item) in items.iter().enumerate().take(50) { let label = item.get("label").and_then(|l| l.as_str()).unwrap_or("?"); + // Map LSP CompletionItemKind numeric value to a human-readable name let kind = match item .get("kind") .and_then(serde_json::Value::as_i64) diff --git a/crates/zesdex-backend/src/tool/lsp/connect.rs b/crates/zesdex-backend/src/tool/lsp/connect.rs index 9e6f7d0..f72ed18 100644 --- a/crates/zesdex-backend/src/tool/lsp/connect.rs +++ b/crates/zesdex-backend/src/tool/lsp/connect.rs @@ -1,8 +1,19 @@ +//! LSP connect tool: spawns a Language Server Protocol server and +//! establishes a client connection, auto-registering known file extensions +//! so that other `lsp_*` tools can auto-detect this server. + use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use tracing; use crate::tool::{Tool, ToolCtx}; +/// Tool for connecting to an LSP server (e.g. rust-analyzer, tsserver). +/// +/// Spawns the server binary, performs the initialize handshake, and stores +/// the client handle in `LspManager`. Also registers known file extensions +/// for the given `language_id` so that cursor-based tools can auto-detect +/// this server later without an explicit `server` argument. pub struct LspConnect; impl Tool for LspConnect { @@ -46,6 +57,13 @@ impl Tool for LspConnect { let name = crate::tool::arg_str(args, "name")?; let command = crate::tool::arg_str(args, "command")?; let language_id = crate::tool::arg_str(args, "language_id")?; + + tracing::debug!( + "[lsp-connect] connecting to LSP server '{}' (cmd={}, lang={})", + name, + command, + language_id, + ); let extra_args: Vec = args .get("args") .and_then(|v| v.as_array()) @@ -56,12 +74,19 @@ impl Tool for LspConnect { }) .unwrap_or_default(); + // Acquire the LSP manager lock and spawn the server process. let mut manager = ctx .lsp_manager .lock() .map_err(|e| anyhow!("LSP manager lock error: {e}"))?; manager.connect(&command, &extra_args, &language_id)?; + tracing::info!( + "[lsp-connect] server '{}' connected for language '{}'", + name, + language_id, + ); + // Auto-register this server's known extensions so lsp_diagnostics / // lsp_hover / lsp_completion / lsp_definition / lsp_references can // auto-detect it later without an explicit `server` argument. diff --git a/crates/zesdex-backend/src/tool/lsp/definition.rs b/crates/zesdex-backend/src/tool/lsp/definition.rs index 501dd5f..1cc6d47 100644 --- a/crates/zesdex-backend/src/tool/lsp/definition.rs +++ b/crates/zesdex-backend/src/tool/lsp/definition.rs @@ -1,9 +1,16 @@ +//! LspDefinition tool — queries an LSP server for the goto-definition +//! location of a symbol at a given cursor position and returns the +//! resolved file paths and line numbers. + use anyhow::Result; use serde_json::Value; use std::fmt::Write; +use tracing; use crate::tool::{Tool, ToolCtx}; +/// Tool that resolves the definition location of a symbol by calling +/// the LSP `textDocument/definition` request. pub struct LspDefinition; impl Tool for LspDefinition { @@ -17,6 +24,8 @@ impl Tool for LspDefinition { fn parameters(&self) -> Value { super::lsp_cursor_params(false) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + tracing::debug!("LspDefinition::run called"); + // Execute the LSP goto-definition request at the given cursor position let (def_result, _line, _column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.goto_definition(uri, line, column) })?; @@ -24,6 +33,7 @@ impl Tool for LspDefinition { if def_result == Value::Null { return Ok("No definition found at this position.".to_string()); } + // Normalize response: LSP can return a single location or an array let locations = if let Some(loc) = def_result.as_array() { loc.clone() } else { @@ -34,6 +44,7 @@ impl Tool for LspDefinition { return Ok("No definition found.".to_string()); } + tracing::debug!(count = locations.len(), "definition locations found"); let mut output = String::from("Definition(s):\n"); for (i, loc) in locations.iter().enumerate().take(10) { let target_uri = loc.get("uri").and_then(|u| u.as_str()).unwrap_or("?"); diff --git a/crates/zesdex-backend/src/tool/lsp/diagnostics.rs b/crates/zesdex-backend/src/tool/lsp/diagnostics.rs index 99de8f2..711b584 100644 --- a/crates/zesdex-backend/src/tool/lsp/diagnostics.rs +++ b/crates/zesdex-backend/src/tool/lsp/diagnostics.rs @@ -1,10 +1,17 @@ +//! LspDiagnostics tool — obtains diagnostics (errors, warnings, hints) for a +//! file from an LSP server by sending the full file text via `didOpen` and +//! collecting the published diagnostics. + use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fmt::Write; +use tracing; use crate::app::lsp::path_to_lsp_uri; use crate::tool::{Tool, ToolCtx}; +/// Tool that retrieves LSP diagnostics for a given file by providing its +/// full text content to the server and collecting the diagnostic results. pub struct LspDiagnostics; impl Tool for LspDiagnostics { @@ -39,14 +46,18 @@ impl Tool for LspDiagnostics { } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + tracing::debug!("LspDiagnostics::run called"); let rel_path = crate::tool::arg_str(args, "path")?; let text = crate::tool::arg_str(args, "text")?; let server_name = super::resolve_server_name(ctx, args, &rel_path)?; let server_name = server_name.as_str(); + tracing::info!(path = %rel_path, server = server_name, "requesting LSP diagnostics"); + let abs_path = crate::tool::resolve_path(&ctx.workspaces, &rel_path)?; let uri = path_to_lsp_uri(&abs_path.to_string_lossy()); + // Acquire manager lock and look up the client let manager = ctx .lsp_manager .lock() @@ -57,7 +68,7 @@ impl Tool for LspDiagnostics { let client_arc = manager .get_client(server_name) .ok_or_else(|| anyhow!("LSP server '{server_name}' not found"))?; - drop(manager); + drop(manager); // Release lock before calling into client let mut client = client_arc .lock() diff --git a/crates/zesdex-backend/src/tool/lsp/disconnect.rs b/crates/zesdex-backend/src/tool/lsp/disconnect.rs index ef4bca5..25f11fa 100644 --- a/crates/zesdex-backend/src/tool/lsp/disconnect.rs +++ b/crates/zesdex-backend/src/tool/lsp/disconnect.rs @@ -1,8 +1,14 @@ +//! LspDisconnect tool — disconnects from a running LSP server and releases +//! its associated resources (process handle, registered extensions, etc.). + use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use tracing; use crate::tool::{Tool, ToolCtx}; +/// Tool that disconnects a previously-connected LSP server by name, +/// removing it from the LSP manager. pub struct LspDisconnect; impl Tool for LspDisconnect { @@ -30,6 +36,8 @@ impl Tool for LspDisconnect { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let name = crate::tool::arg_str(args, "name")?; + tracing::info!(name, "disconnecting from LSP server"); + let mut manager = ctx .lsp_manager .lock() @@ -38,6 +46,7 @@ impl Tool for LspDisconnect { if manager.disconnect(&name) { Ok(format!("Disconnected from LSP server '{name}'")) } else { + tracing::warn!(name, "LSP server not found for disconnect"); Err(anyhow!("LSP server '{name}' not found")) } } diff --git a/crates/zesdex-backend/src/tool/lsp/hover.rs b/crates/zesdex-backend/src/tool/lsp/hover.rs index 071b0d2..a357f51 100644 --- a/crates/zesdex-backend/src/tool/lsp/hover.rs +++ b/crates/zesdex-backend/src/tool/lsp/hover.rs @@ -1,9 +1,16 @@ +//! LspHover tool — queries an LSP server for hover information (type +//! signature, documentation) at a given cursor position and formats the +//! result into a human-readable string. + use anyhow::Result; use serde_json::Value; use std::fmt::Write; +use tracing; use crate::tool::{Tool, ToolCtx}; +/// Tool that retrieves hover information from an LSP server at a specific +/// cursor position, including type signatures and documentation. pub struct LspHover; impl Tool for LspHover { @@ -17,12 +24,15 @@ impl Tool for LspHover { fn parameters(&self) -> Value { super::lsp_cursor_params(true) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + tracing::debug!("LspHover::run called"); + // Execute the LSP hover request at the given cursor position let (hover_result, _line, _column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.hover(uri, line, column) })?; if hover_result == Value::Null { return Ok("No hover information available at this position.".to_string()); } + // Extract the MarkupContent and optional range from the response let contents = hover_result.get("contents"); let range = hover_result.get("range"); let mut output = String::new(); @@ -49,6 +59,10 @@ impl Tool for LspHover { } } +/// Recursively format LSP `MarkupContent` into a plain string. +/// +/// Handles three shapes: a plain string, a `{kind, value}` object +/// (e.g. markdown/plaintext), or an array of mixed content items. fn format_hover_contents(contents: &Value) -> String { let mut out = String::new(); match contents { diff --git a/crates/zesdex-backend/src/tool/lsp/mod.rs b/crates/zesdex-backend/src/tool/lsp/mod.rs index f0e4f31..f69a429 100644 --- a/crates/zesdex-backend/src/tool/lsp/mod.rs +++ b/crates/zesdex-backend/src/tool/lsp/mod.rs @@ -1,3 +1,16 @@ +//! LSP (Language Server Protocol) tool implementations. +//! +//! Provides seven tools for interacting with LSP servers: +//! - `lsp_connect` / `lsp_disconnect` — server lifecycle +//! - `lsp_diagnostics` — file diagnostics (errors, warnings) +//! - `lsp_hover` — type/ doc info at cursor +//! - `lsp_completion` — code completion suggestions +//! - `lsp_definition` — go-to-definition location +//! - `lsp_references` — find all references +//! +//! Shared helpers: `run_lsp_query` (didOpen → query → didClose), `resolve_server_name`, +//! `auto_detect_server`, `known_extensions_for`, `lsp_cursor_params`. + mod connect; mod completion; mod definition; @@ -20,6 +33,7 @@ pub use references::LspReferences; use anyhow::{anyhow, Result}; use serde_json::Value; +use tracing; use crate::app::lsp::path_to_lsp_uri; use crate::tool::ToolCtx; @@ -97,18 +111,26 @@ pub fn lsp_cursor_params(with_language_id: bool) -> serde_json::Value { /// `None` if the path has no extension, the lock is poisoned, or no /// connected server's language is known to use that extension. fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option { + // Extract the file extension and build a dotted form (e.g. ".rs") let ext = std::path::Path::new(path) .extension() .and_then(|e| e.to_str())?; let dot_ext = format!(".{ext}"); + // Walk connected servers and match against known extensions if let Ok(mgr) = ctx.lsp_manager.lock() { for s in &mgr.servers { let exts = known_extensions_for(&s.language_id); if exts.contains(&dot_ext.as_str()) { + tracing::debug!( + "[lsp] auto-detected server '{}' for extension '{}'", + s.language_id, + dot_ext, + ); return Some(s.language_id.clone()); } } } + tracing::debug!("[lsp] no auto-detected server for extension '{}'", dot_ext); None } @@ -126,10 +148,13 @@ fn auto_detect_server(ctx: &ToolCtx, path: &str) -> Option { /// because the caller provided an explicit (possibly wrong) server name, /// since downstream `get_client`/`get_language_id` calls report that error. fn resolve_server_name(ctx: &ToolCtx, args: &Value, path: &str) -> Result { + // Prefer explicit `server` argument if provided by the caller. if let Some(server) = args.get("server").and_then(|v| v.as_str()) { + tracing::debug!("[lsp] explicit server name: '{}'", server); return Ok(server.to_string()); } + // Fall back to auto-detection based on the file's extension. if let Some(name) = auto_detect_server(ctx, path) { return Ok(name); } @@ -181,6 +206,12 @@ where F: FnOnce(&mut crate::app::lsp::LspClient, &str, u32, u32) -> Result, { let rel_path = crate::tool::arg_str(args, "path")?; + + tracing::debug!( + "[lsp-query] executing query on '{}' (text_provided: {})", + rel_path, + text.is_some(), + ); let line = args .get("line") .and_then(Value::as_i64) @@ -217,6 +248,10 @@ where })?; drop(manager); + // Acquire the per-server client lock and run the query lifecycle: + // 1. didOpen — notify the server of the file content + // 2. Execute the specific LSP query (hover, completion, definition, etc.) + // 3. didClose — clean up the open document on the server let mut client = client_arc .lock() .map_err(|e| anyhow!("LSP client lock error: {e}"))?; diff --git a/crates/zesdex-backend/src/tool/lsp/references.rs b/crates/zesdex-backend/src/tool/lsp/references.rs index 1cde63d..16972c6 100644 --- a/crates/zesdex-backend/src/tool/lsp/references.rs +++ b/crates/zesdex-backend/src/tool/lsp/references.rs @@ -1,9 +1,16 @@ +//! LspReferences tool — finds all references to a symbol at a given cursor +//! position by calling the LSP `textDocument/references` request and returns +//! the resolved file paths with line numbers. + use anyhow::Result; use serde_json::Value; use std::fmt::Write; +use tracing; use crate::tool::{Tool, ToolCtx}; +/// Tool that retrieves all reference locations for a symbol from an LSP +/// server and returns them as a formatted list (max 50 entries). pub struct LspReferences; impl Tool for LspReferences { @@ -17,10 +24,13 @@ impl Tool for LspReferences { fn parameters(&self) -> Value { super::lsp_cursor_params(false) } fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { + tracing::debug!("LspReferences::run called"); + // Execute the LSP references request at the given cursor position let (ref_result, _line, _column) = super::run_lsp_query(ctx, args, None, |client, uri, line, column| { client.references(uri, line, column) })?; + // LSP returns an array of Location objects let locations = ref_result.as_array().cloned().unwrap_or_default(); if locations.is_empty() { return Ok("No references found for this symbol.".to_string()); diff --git a/crates/zesdex-backend/src/tool/memory/forget.rs b/crates/zesdex-backend/src/tool/memory/forget.rs index 27327ee..e48e94d 100644 --- a/crates/zesdex-backend/src/tool/memory/forget.rs +++ b/crates/zesdex-backend/src/tool/memory/forget.rs @@ -3,6 +3,7 @@ use super::super::Tool; use super::super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use tracing; use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; @@ -40,6 +41,8 @@ impl Tool for Forget { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let name = crate::tool::arg_str(args, "name")?; + tracing::info!(name, "forgetting memory entry"); + MarkdownMemoryRepository::new() .delete(&ctx.memory_dir, &name) .map_err(|e| anyhow!("failed to remove memory '{name}': {e}"))?; diff --git a/crates/zesdex-backend/src/tool/memory/mod.rs b/crates/zesdex-backend/src/tool/memory/mod.rs index 1ba1436..af71dc8 100644 --- a/crates/zesdex-backend/src/tool/memory/mod.rs +++ b/crates/zesdex-backend/src/tool/memory/mod.rs @@ -1,4 +1,6 @@ -//! Memory tools: `remember`, `recall`, and `forget` for persisted project memory entries. +//! Memory tools — `remember`, `recall`, and `forget` for reading and writing +//! persisted project memory entries. Each sub-tool wraps a memory-store +//! operation and exposes it as a tool-callable action. pub mod forget; pub mod recall; pub mod remember; diff --git a/crates/zesdex-backend/src/tool/memory/recall.rs b/crates/zesdex-backend/src/tool/memory/recall.rs index c44a9d1..a0f1bea 100644 --- a/crates/zesdex-backend/src/tool/memory/recall.rs +++ b/crates/zesdex-backend/src/tool/memory/recall.rs @@ -4,6 +4,7 @@ use super::super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::fmt::Write; +use tracing; use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; @@ -40,8 +41,10 @@ impl Tool for Recall { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { if let Some(name) = args.get("name").and_then(|v| v.as_str()) { if name.is_empty() { + tracing::debug!("recall called with empty name, listing all"); return Ok(list_all(ctx)); } + tracing::debug!(name, "recalling memory entry"); let memory = MarkdownMemoryRepository::new() .load(&ctx.memory_dir, name) .map_err(|e| anyhow!("memory '{name}' not found: {e}"))?; @@ -50,6 +53,7 @@ impl Tool for Recall { memory.name, memory.description, memory.kind, memory.lifecycle, memory.content, )) } else { + tracing::debug!("recall called without name, listing all entries"); Ok(list_all(ctx)) } } diff --git a/crates/zesdex-backend/src/tool/memory/remember.rs b/crates/zesdex-backend/src/tool/memory/remember.rs index b77d8a4..37df9b8 100644 --- a/crates/zesdex-backend/src/tool/memory/remember.rs +++ b/crates/zesdex-backend/src/tool/memory/remember.rs @@ -3,6 +3,7 @@ use super::super::Tool; use super::super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use tracing; use zesdex_cms::domain::memory::Memory; use zesdex_cms::domain::repository::MemoryRepository; use zesdex_cms::infrastructure::persistence::memory_repo::MarkdownMemoryRepository; @@ -61,10 +62,14 @@ impl Tool for Remember { let content = crate::tool::arg_str(args, "content")?; let kind = crate::tool::arg_str(args, "kind")?; + // Validate that the name can produce a valid filesystem-safe slug if Memory::slugify(&name).is_none() { anyhow::bail!("invalid memory name: must produce a valid slug (alphanumeric + hyphens, 1-80 chars)"); } + tracing::info!(name, kind, "saving memory entry"); + + // Build Memory struct with current timestamps and default lifecycle let now = chrono::Utc::now().timestamp_millis(); let memory = Memory { name: name.to_string(), diff --git a/crates/zesdex-backend/src/tool/mod.rs b/crates/zesdex-backend/src/tool/mod.rs index c8855c4..26930ff 100644 --- a/crates/zesdex-backend/src/tool/mod.rs +++ b/crates/zesdex-backend/src/tool/mod.rs @@ -1,10 +1,17 @@ //! Tool trait, execution context, and the registry of all built-in tools. +//! +//! This module defines the core `Tool` trait that every agent-invocable tool must implement, +//! the shared `ToolCtx` execution context passed to every tool invocation, and utility +//! functions for path resolution, command execution, argument extraction, and edit-log +//! persistence. The `all_tools()` function assembles the canonical 37-tool vector exposed +//! to the LLM provider. use anyhow::Result; use serde_json::Value; use sha2::Digest; use std::path::PathBuf; use std::sync::atomic::AtomicBool; use std::sync::{Arc, Mutex}; +use tracing; pub mod bash_tools; pub mod fs; @@ -73,9 +80,10 @@ pub struct ToolCtx { /// /// Return: names of all matching checks; empty if none match. pub fn check_graduated_checks(path: &str, content: &str, checks: &[GraduatedCheck]) -> Vec { - let mut matches = Vec::new(); + let mut matches = Vec::new(); // accumulator for matching check names for check in checks { if path.contains(&check.pattern) || content.contains(&check.rule) { + tracing::debug!(check = %check.name, "graduated check matched"); matches.push(check.name.clone()); } } @@ -106,16 +114,17 @@ pub struct ToolCtxBuilder { pub abort_flag: Option>, } +/// Default `ToolCtxBuilder` — all path fields empty, fresh `DirCache`, origin set to `Main`. impl Default for ToolCtxBuilder { fn default() -> Self { ToolCtxBuilder { - workspaces: Vec::new(), - session_dir: PathBuf::new(), + workspaces: Vec::new(), // no workspace roots yet + session_dir: PathBuf::new(), // caller must set via builder memory_dir: PathBuf::new(), worktrees_dir: PathBuf::new(), dir_cache: std::sync::Arc::new(tokio::sync::RwLock::new( super::app::state::misc::DirCache::new(), - )), + )), // shared directory-listing cache mention_index: super::app::state::misc::MentionIndex::new(), origin: crate::app::state::types::Origin::Main, graduated_checks: Vec::new(), @@ -130,27 +139,32 @@ impl Default for ToolCtxBuilder { impl ToolCtxBuilder { /// Set the session directory. pub fn session_dir(mut self, v: PathBuf) -> Self { + tracing::debug!(path = %v.display(), "ToolCtxBuilder: set session_dir"); self.session_dir = v; self } /// Set the workspaces. pub fn workspaces(mut self, v: Vec) -> Self { + tracing::debug!(count = v.len(), "ToolCtxBuilder: set workspaces"); self.workspaces = v; self } /// Set the origin (main process vs. daemon-attached). pub fn origin(mut self, v: crate::app::state::types::Origin) -> Self { + tracing::debug!(origin = ?v, "ToolCtxBuilder: set origin"); self.origin = v; self } /// Set the workflow-level findings sharing Arc (for subagent-to-subagent /// communication within a workflow run). pub fn workflow_findings(mut self, v: Option>>>) -> Self { + tracing::debug!(present = v.is_some(), "ToolCtxBuilder: set workflow_findings"); self.workflow_findings = v; self } /// Consume the builder and produce the final `ToolCtx`. pub fn build(self) -> ToolCtx { + tracing::debug!("ToolCtxBuilder: building ToolCtx"); ToolCtx { workspaces: self.workspaces, session_dir: self.session_dir, @@ -173,6 +187,7 @@ impl ToolCtxBuilder { /// Return: boxed trait objects for all 37 tools (fs, search, bash, git, memory, plan, /// workflow, utility). pub fn all_tools() -> Vec> { + tracing::info!("assembling all 37 built-in tools"); vec![ Box::new(super::tool::fs::read::Read), Box::new(super::tool::fs::write::Write), @@ -218,13 +233,18 @@ pub fn all_tools() -> Vec> { /// /// Why: used by the harness to decide which tool calls need user confirmation/guard checks. pub fn tool_is_risky(name: &str) -> bool { - matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator") + let risky = matches!(name, "write" | "delete" | "edit" | "bash" | "git_operator"); + if risky { + tracing::debug!(tool = %name, "tool classified as risky"); + } + risky } /// Convert a list of tools into the provider-facing `ToolDef` request schema. /// /// Return: one `ToolDef` per tool, in the same order as `tools`. pub fn tool_defs(tools: &[Box]) -> Vec { + tracing::debug!(count = tools.len(), "building tool definitions for provider request"); tools .iter() .map(|t| crate::dto::provider::request::ToolDef { @@ -254,14 +274,16 @@ pub fn log_write_edit_tool( let reason = args .get("reason") .and_then(|v| v.as_str()) - .unwrap_or("unnamed"); + .unwrap_or("unnamed"); // fallback when no reason provided let path = args .get("path") .and_then(|v| v.as_str()) - .unwrap_or("unknown"); + .unwrap_or("unknown"); // fallback when path is missing + // content: "write" uses "content", "edit" uses "new" (the replacement text) let content = args.get("content").or_else(|| args.get("new")); let content_str = content.and_then(|v| v.as_str()).unwrap_or(""); let content_sha256 = hex::encode(sha2::Sha256::digest(content_str.as_bytes())); + // bytes_delta: for "write" it is the full file length; for "edit" it is |new - old| let bytes_delta = if tool_name == "write" { content_str.len() as i64 } else { @@ -269,6 +291,7 @@ pub fn log_write_edit_tool( let new = args.get("new").and_then(|v| v.as_str()).unwrap_or(""); (new.len() as i64 - old.len() as i64).abs() }; + tracing::debug!(tool = %tool_name, path = %path, delta = bytes_delta, "logging write/edit tool result"); let entry = zesdex_cms::domain::edit_log::EditLogEntry { ts: chrono::Utc::now().timestamp_millis(), tool: tool_name.to_string(), @@ -292,6 +315,7 @@ pub fn log_write_edit_tool( /// Return: the value as `String` if present and a string type; `Err` if missing /// or of a different JSON type (null, number, boolean, array, object). pub fn arg_str(args: &Value, name: &str) -> Result { + tracing::debug!(arg = %name, "extracting required string argument"); args.get(name) .and_then(|v| v.as_str()) .map(std::string::ToString::to_string) @@ -300,20 +324,26 @@ pub fn arg_str(args: &Value, name: &str) -> Result { /// Execute a `std::process::Command` and return its combined stdout/stderr. /// -/// Return: `Ok(output)` on success, `Err(combined)` on non-zero exit or failure. +/// Flow: spawn process → collect stdout/stderr → combine → check exit status. +/// +/// Return: `Ok(combined_output)` on success, `Err(combined)` on non-zero exit or failure. pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { + tracing::debug!(program = ?cmd.get_program(), "executing external command"); let output = cmd.output().map_err(|e| anyhow::anyhow!("command execution failed: {e}"))?; let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + // combine stdout+stderr; if stderr is empty, return only stdout let combined = if stderr.is_empty() { stdout } else { format!("{}\n{}", stdout, stderr).trim().to_string() }; if output.status.success() { + tracing::debug!(output_len = combined.len(), "command succeeded"); Ok(combined) } else { let code = output.status.code().unwrap_or(-1); + tracing::warn!(exit_code = code, output_len = combined.len(), "command failed"); anyhow::bail!("command failed with exit code {code}:\n{combined}") } } @@ -331,6 +361,8 @@ pub fn execute_cmd(cmd: &mut std::process::Command) -> Result { /// Return: the canonical absolute path, or an error if the workspace index is invalid /// or the resolved path falls outside all workspace roots. pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { + tracing::debug!(relative = %rel, "resolving tool path"); + // Parse optional [N] workspace index prefix; default to workspace 0 let (ws_idx, path) = if rel.starts_with('[') { let close = rel .find(']') @@ -340,7 +372,7 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { .map_err(|_| anyhow::anyhow!("invalid workspace index"))?; (idx, &rel[close + 1..]) } else { - (0, rel) + (0, rel) // default: first workspace }; let base = workspaces .get(ws_idx) @@ -356,8 +388,9 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { // component-by-component so that `Path::starts_with` cannot be // bypassed by unnormalised intermediate segments. let canon = if let Ok(c) = abs.canonicalize() { - c + c // file exists — use real canonical path } else { + // file does not exist yet — manually resolve the path components let base_canon = workspaces .iter() .find_map(|w| w.canonicalize().ok()) @@ -367,22 +400,26 @@ pub fn resolve_path(workspaces: &[PathBuf], rel: &str) -> Result { for comp in rel_components.components() { match comp { std::path::Component::ParentDir => { - resolved.pop(); + resolved.pop(); // handle ../ traversal } - std::path::Component::CurDir => {} + std::path::Component::CurDir => {} // skip ./ c => resolved.push(c), } } } resolved }; + // Verify the canonical path is still inside one of the workspace roots if workspaces.iter().any(|w| canon.starts_with(w)) { + tracing::debug!(canonical = %canon.display(), "path resolved within workspace"); Ok(canon) } else { + tracing::warn!(canonical = %canon.display(), "path escape attempt detected"); anyhow::bail!("path '{rel}' is outside all workspace roots") } } +/// Unit tests for utility functions in the tool module. #[cfg(test)] mod tests { use super::*; @@ -390,36 +427,42 @@ mod tests { #[test] fn tool_ctx_builder_defaults_abort_flag_to_none() { + // Verify that a default-built ToolCtx has no abort flag set. let ctx = ToolCtx::builder().build(); assert!(ctx.abort_flag.is_none()); } #[test] fn test_arg_str_found() { + // Verify arg_str returns the string value when the key exists with a string. let args = json!({"key": "value"}); assert_eq!(arg_str(&args, "key").unwrap(), "value"); } #[test] fn test_arg_str_missing() { + // Verify arg_str errors when the key is absent. let args = json!({"other": "value"}); assert!(arg_str(&args, "key").is_err()); } #[test] fn test_arg_str_empty_string() { + // Verify arg_str accepts an empty string value. let args = json!({"key": ""}); assert_eq!(arg_str(&args, "key").unwrap(), ""); } #[test] fn test_arg_str_wrong_type() { + // Verify arg_str errors when the value is a non-string type (integer). let args = json!({"key": 42}); assert!(arg_str(&args, "key").is_err()); } #[test] fn test_arg_str_null() { + // Verify arg_str errors when the value is JSON null. let args = json!({"key": null}); assert!(arg_str(&args, "key").is_err()); } diff --git a/crates/zesdex-backend/src/tool/plan.rs b/crates/zesdex-backend/src/tool/plan.rs index 2985be0..8d7266c 100644 --- a/crates/zesdex-backend/src/tool/plan.rs +++ b/crates/zesdex-backend/src/tool/plan.rs @@ -1,10 +1,17 @@ //! Plan-mode signaling tools: entering plan mode with a proposal, and confirming readiness. +//! +//! These tools allow the LLM to declare a step-by-step plan (`plan_enter`) and then +//! signal readiness to execute (`plan_ready`). The harness surfaces the plan text to +//! the user for review between the two calls. use super::Tool; use super::ToolCtx; use anyhow::Result; use serde_json::{json, Value}; +use tracing; /// Tool the model calls to present a step-by-step plan and enter plan mode. +/// +/// Flow: extract `plan` and `sign_off` args → return fixed acknowledgement. pub struct PlanEnter; impl Tool for PlanEnter { @@ -38,13 +45,16 @@ impl Tool for PlanEnter { /// /// Return: fixed acknowledgement string on success; error if either arg is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let _ = crate::tool::arg_str(args, "plan")?; - let _ = crate::tool::arg_str(args, "sign_off")?; + let plan = crate::tool::arg_str(args, "plan")?; + let _sign_off = crate::tool::arg_str(args, "sign_off")?; + tracing::info!(plan_len = plan.len(), "plan_enter invoked"); Ok("plan recorded".to_string()) } } /// Tool the model calls to confirm it will follow the approved plan before executing it. +/// +/// Flow: extract `confirmation` arg → return fixed readiness string. pub struct PlanReady; impl Tool for PlanReady { @@ -73,7 +83,8 @@ impl Tool for PlanReady { /// /// Return: fixed "ready to execute" string on success; error if `confirmation` is missing. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { - let _ = crate::tool::arg_str(args, "confirmation")?; + let _confirmation = crate::tool::arg_str(args, "confirmation")?; + tracing::info!("plan_ready invoked, exiting plan mode"); Ok("ready to execute".to_string()) } } diff --git a/crates/zesdex-backend/src/tool/search.rs b/crates/zesdex-backend/src/tool/search.rs index afc4e6e..239a070 100644 --- a/crates/zesdex-backend/src/tool/search.rs +++ b/crates/zesdex-backend/src/tool/search.rs @@ -1,4 +1,8 @@ //! Text search tools: `grep` (line matching) and `glob` (filename pattern matching). +//! +//! Both tools use `ignore::Walk` under the hood, which respects `.gitignore` and skips +//! heavy directories (`.git/`, `node_modules/`, etc.), matching what agents expect +//! when searching real codebases. use super::resolve_path; use super::Tool; use super::ToolCtx; @@ -7,6 +11,7 @@ use globset::{GlobBuilder, GlobSetBuilder}; use ignore::Walk; use serde_json::{json, Value}; use std::fs; +use tracing; /// Tool that recursively searches text files under a directory for a literal substring. pub struct Grep; @@ -51,13 +56,14 @@ impl Tool for Grep { let pattern = crate::tool::arg_str(args, "pattern")?; let rel = crate::tool::arg_str(args, "path")?; let path = resolve_path(&ctx.workspaces, &rel)?; + tracing::debug!(pattern = %pattern, path = %rel, "Grep::run invoked"); if !path.exists() { anyhow::bail!("path '{rel}' does not exist"); } if !path.is_dir() { anyhow::bail!("path '{rel}' is not a directory"); } - let mut results: Vec<(String, usize, String)> = Vec::new(); + let mut results: Vec<(String, usize, String)> = Vec::new(); // (relative_path, line_no, text) for entry in Walk::new(&path).flatten() { let file_path = entry.path(); if !file_path.is_file() { @@ -77,6 +83,7 @@ impl Tool for Grep { } } if results.is_empty() { + tracing::debug!(pattern = %pattern, "Grep: no matches found"); return Ok(format!("no matches found for '{pattern}' in {rel}")); } let output = results @@ -84,6 +91,7 @@ impl Tool for Grep { .map(|(f, line, text)| format!("{f}:{line}:{text}")) .collect::>() .join("\n"); + tracing::debug!(count = results.len(), "Grep: matches found"); Ok(format!("found {} matches:\n{}", results.len(), output)) } } @@ -132,6 +140,7 @@ impl Tool for Glob { let pat_str = crate::tool::arg_str(args, "pattern")?; let rel = crate::tool::arg_str(args, "path")?; let root = resolve_path(&ctx.workspaces, &rel)?; + tracing::debug!(pattern = %pat_str, root = %rel, "Glob::run invoked"); if !root.exists() || !root.is_dir() { anyhow::bail!("path '{rel}' is not a valid directory"); } @@ -155,8 +164,10 @@ impl Tool for Glob { } matches.sort(); if matches.is_empty() { + tracing::debug!(pattern = %pat_str, "Glob: no files match"); return Ok(format!("no files match '{pat_str}' in {rel}")); } + tracing::debug!(count = matches.len(), "Glob: files matched"); Ok(matches.join("\n")) } } diff --git a/crates/zesdex-backend/src/tool/sequential_think.rs b/crates/zesdex-backend/src/tool/sequential_think.rs index 491f983..fedc1d6 100644 --- a/crates/zesdex-backend/src/tool/sequential_think.rs +++ b/crates/zesdex-backend/src/tool/sequential_think.rs @@ -1,8 +1,13 @@ //! Sequential-thinking tool: a no-side-effect echo that records reasoning steps. +//! +//! This tool accepts a `thought` string from the model and returns it verbatim. It has +//! no I/O or state mutation — the harness simply surfaces the text in the TUI's reasoning +//! pane so the user can follow the model's thought chain step by step. use super::Tool; use super::ToolCtx; use anyhow::Result; use serde_json::{json, Value}; +use tracing; /// Tool that accepts a reasoning step and returns it verbatim, giving the model a /// structured way to surface its thought chain to the TUI. @@ -38,6 +43,7 @@ impl Tool for SeqThink { /// Return: the thought text, possibly empty; never an error. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let thought = args.get("thought").and_then(|v| v.as_str()).unwrap_or(""); + tracing::debug!(thought_len = thought.len(), "seqthink step recorded"); Ok(thought.to_string()) } } diff --git a/crates/zesdex-backend/src/tool/shell.rs b/crates/zesdex-backend/src/tool/shell.rs index 7ef4b43..124279e 100644 --- a/crates/zesdex-backend/src/tool/shell.rs +++ b/crates/zesdex-backend/src/tool/shell.rs @@ -1,10 +1,16 @@ //! Bash-shell execution tool with safety filters and optional timeout. +//! +//! This module implements the `bash` tool, which runs a shell command via +//! `bash -c `. It supports foreground and background execution, +//! configurable timeouts, and destructive-git-operation gating via +//! `shell_filter::git::check_git_destructive`. use super::Tool; use super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::process::Command; use std::time::Duration; +use tracing; /// Tool that runs `bash -c `, optionally in the background, with safety /// filters applied before spawning. @@ -68,8 +74,9 @@ impl Tool for Bash { let timeout_ms = args .get("timeout") .and_then(serde_json::Value::as_u64) - .unwrap_or(120_000) - .min(600_000); + .unwrap_or(120_000) // default: 2 minutes + .min(600_000); // max: 10 minutes + tracing::debug!(cmd_len = cmd.len(), timeout = timeout_ms, "Bash::run invoked"); // Only gate destructive git operations; credential reads are allowed // locally since the AI needs access, and the real threat is committing // secrets to a public repo (handled by git pre-commit hooks / user). @@ -80,9 +87,11 @@ impl Tool for Bash { .and_then(serde_json::Value::as_bool) .unwrap_or(false); if run_in_background { + tracing::debug!("spawning background bash job"); let job = crate::app::bgbash::job::spawn_bash_job(cmd); return Ok(format!("Background job: {}", job.id)); } + tracing::debug!("spawning foreground bash -c"); let mut child = Command::new("bash") .arg("-c") .arg(&cmd) @@ -90,7 +99,7 @@ impl Tool for Bash { .stderr(std::process::Stdio::piped()) .spawn() .map_err(|e| anyhow!("failed to spawn bash: {e}"))?; - let start = std::time::Instant::now(); + let start = std::time::Instant::now(); // used for timeout check and elapsed reporting let timeout = Duration::from_millis(timeout_ms); loop { match child.try_wait() { @@ -108,12 +117,14 @@ impl Tool for Bash { }; let trimmed = combined.trim().to_string(); if status.success() { + tracing::debug!(elapsed_secs = elapsed, "bash command succeeded"); return Ok(if trimmed.is_empty() { format!("Command completed in {elapsed:.2}s (exit code 0)") } else { format!("{trimmed}\n\nExit code: 0 ({elapsed:.2}s)") }); } + tracing::debug!(elapsed_secs = elapsed, exit_code = status.code().unwrap_or(-1), "bash command finished with non-zero exit"); return Ok(format!( "{}\n\nExit code: {} ({:.2}s)", trimmed, @@ -123,13 +134,15 @@ impl Tool for Bash { } Ok(None) => { if start.elapsed() > timeout { + tracing::warn!(timeout_ms = timeout_ms, "bash command timed out, killing"); let _ = child.kill(); let _ = child.wait(); anyhow::bail!("command timed out after {timeout_ms}ms"); } - std::thread::sleep(Duration::from_millis(10)); + std::thread::sleep(Duration::from_millis(10)); // small sleep to avoid busy-wait } Err(e) => { + tracing::error!(error = %e, "bash command wait failed"); anyhow::bail!("failed to wait for command: {e}"); } } diff --git a/crates/zesdex-backend/src/tool/shell_filter/credentials.rs b/crates/zesdex-backend/src/tool/shell_filter/credentials.rs index e1f4488..205f916 100644 --- a/crates/zesdex-backend/src/tool/shell_filter/credentials.rs +++ b/crates/zesdex-backend/src/tool/shell_filter/credentials.rs @@ -1,10 +1,14 @@ -//! Credential-file-read detection. +//! Credential-file-read detection for shell commands. //! //! Not currently called from `tool::shell::Bash::run` — see that function's //! doc comment for why credential reads are intentionally allowed. This //! module is kept for callers that DO want to block credential reads (e.g. //! a future sandboxed/untrusted-tool execution path) and is covered by its //! own inline tests below. +//! +//! Flow: lowercases the input → strips shell quoting → substring-match +//! against known credential patterns (SSH keys, cloud credentials, `.git-credentials`, +//! `password=`, `token=`, `.netrc`, `.npmrc`). /// Reject shell commands whose lowercased form contains any known credential-read pattern. /// @@ -20,6 +24,8 @@ /// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise. #[cfg(test)] pub(crate) fn check_credential_read(cmd: &str) -> Result<(), String> { + use tracing; + tracing::debug!(cmd, "checking credential read patterns"); let lower = cmd.to_lowercase(); let unquoted = crate::tool::shell_filter::strip_quotes(&lower); diff --git a/crates/zesdex-backend/src/tool/shell_filter/git.rs b/crates/zesdex-backend/src/tool/shell_filter/git.rs index 1e30a5e..b090633 100644 --- a/crates/zesdex-backend/src/tool/shell_filter/git.rs +++ b/crates/zesdex-backend/src/tool/shell_filter/git.rs @@ -1,5 +1,11 @@ //! Block shell commands that perform destructive or hard-to-reverse git operations. +//! +//! Detects patterns like `push --force`, `reset --hard`, `clean -fdx`, +//! `filter-branch`, `stash drop`, and force-push refspecs (`+branch`). +//! ANSI-C quoting (`$'...'`) is normalised before matching to prevent +//! escape-sequence bypasses. use anyhow::Result; +use tracing; /// Reject shell commands whose lowercased form contains any known destructive git pattern. /// @@ -15,6 +21,7 @@ use anyhow::Result; /// /// Return: `Ok(())` if no pattern matches; error naming the offending pattern otherwise. pub fn check_git_destructive(cmd: &str) -> Result<()> { + tracing::debug!(cmd, "checking for destructive git patterns"); let patterns = [ "force-push", "reset --hard", diff --git a/crates/zesdex-backend/src/tool/shell_filter/mod.rs b/crates/zesdex-backend/src/tool/shell_filter/mod.rs index 0f1ff43..7ae03b0 100644 --- a/crates/zesdex-backend/src/tool/shell_filter/mod.rs +++ b/crates/zesdex-backend/src/tool/shell_filter/mod.rs @@ -1,10 +1,21 @@ //! Pre-execution safety filters applied to shell commands before they're spawned. +//! +//! The filters in this module detect and block dangerous shell commands +//! (e.g. destructive git operations, credential-file reads) before the +//! shell tool spawns the process. + pub mod git; pub mod credentials; +use tracing; /// Strip single and double quotes from a string. +/// +/// Used to normalise shell command strings before pattern matching so +/// that quoted arguments are detected the same as unquoted ones. pub(crate) fn strip_quotes(s: &str) -> String { - s.chars().filter(|&c| c != '\'' && c != '"').collect() + let result: String = s.chars().filter(|&c| c != '\'' && c != '"').collect(); + tracing::debug!(input = %s, output = %result, "stripped quotes from command"); + result } /// Decode ANSI-C quoted strings ($'...') found in `input`, replacing @@ -19,6 +30,7 @@ pub(crate) fn strip_quotes(s: &str) -> String { /// spaces and special characters as escape sequences, bypassing the /// substring-based pattern matching in the shell filters. pub(crate) fn normalize_ansi_c_quoting(input: &str) -> String { + tracing::debug!(len = input.len(), "normalizing ANSI-C quoted strings"); let mut out = String::with_capacity(input.len()); let mut chars = input.chars().peekable(); diff --git a/crates/zesdex-backend/src/tool/spawn.rs b/crates/zesdex-backend/src/tool/spawn.rs index d077588..d8b06d0 100644 --- a/crates/zesdex-backend/src/tool/spawn.rs +++ b/crates/zesdex-backend/src/tool/spawn.rs @@ -8,12 +8,17 @@ //! //! Also provides a pipeline variant: `spawn_pipeline` runs agents //! sequentially so each stage sees the previous stage's findings. +//! +//! Each invocation creates its own isolated findings scope so that concurrent +//! `spawn_agents` / `spawn_pipeline` / `workflow_run` calls do not interfere +//! with each other's shared state. use super::{Tool, ToolCtx}; use crate::app::workflow::engine::PrimitiveCtx; use crate::app::workflow::script::{ScriptOptions, ScriptPrimitive, WorkflowScript}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::collections::HashMap; +use tracing; /// Fan out a list of prompts to independent parallel subagents. pub struct SpawnAgents; @@ -75,7 +80,8 @@ impl Tool for SpawnAgents { let max_concurrency = args .get("max_concurrency") .and_then(serde_json::Value::as_u64) - .map_or(10, |v| v.min(10) as usize); + .map_or(10, |v| v.min(10) as usize); // clamp to [1, 10] + tracing::debug!(agent_count = agents.len(), max_concurrency, "SpawnAgents::run invoked"); let agent_count = agents.len(); let primitives: Vec = @@ -127,6 +133,7 @@ impl Tool for SpawnAgents { findings: &findings, timeout_ms: None, })?; + tracing::debug!(result_count = results.len(), "parallel spawn completed"); Ok(format_results(&results, "parallel")) } } @@ -174,6 +181,7 @@ impl Tool for SpawnPipeline { if stages.is_empty() { return Err(anyhow!("stages list must not be empty")); } + tracing::debug!(stage_count = stages.len(), "SpawnPipeline::run invoked"); let primitives: Vec = stages.into_iter().map(ScriptPrimitive::Agent).collect(); @@ -223,6 +231,7 @@ impl Tool for SpawnPipeline { findings: &findings, timeout_ms: None, })?; + tracing::debug!(result_count = results.len(), "pipeline spawn completed"); Ok(format_results(&results, "pipeline")) } } diff --git a/crates/zesdex-backend/src/tool/utility/cd.rs b/crates/zesdex-backend/src/tool/utility/cd.rs index 9c75a28..3edcac3 100644 --- a/crates/zesdex-backend/src/tool/utility/cd.rs +++ b/crates/zesdex-backend/src/tool/utility/cd.rs @@ -1,8 +1,15 @@ //! `cd` tool: verify and resolve a workspace-relative directory path. +//! +//! This tool does NOT change the agent's working directory (there is no +//! persistent cwd between tool calls). Instead it acts as a verification + +//! canonicalization helper: given a relative path, it resolves it against +//! the workspace roots and reports whether it exists, whether it is a +//! directory, and what its canonical path is. use super::super::Tool; use super::super::ToolCtx; use anyhow::Result; use serde_json::{json, Value}; +use tracing; /// Tool that resolves a workspace-relative path and reports whether it exists and is a dir. pub struct Cd; @@ -41,6 +48,7 @@ impl Tool for Cd { /// message (still `Ok`) so the model can react without treating it as an error. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = crate::tool::arg_str(args, "path")?; + tracing::debug!(path = %rel, "Cd::run resolving path"); let path = super::super::resolve_path(&ctx.workspaces, &rel)?; @@ -52,6 +60,7 @@ impl Tool for Cd { } let canon = path.canonicalize().unwrap_or(path); + tracing::debug!(canonical = %canon.display(), "Cd::run resolved"); Ok(format!("{}", canon.display())) } } diff --git a/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs b/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs index 3f0d5e4..d9eed96 100644 --- a/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs +++ b/crates/zesdex-backend/src/tool/utility/dir_cache_update.rs @@ -11,6 +11,7 @@ use super::super::Tool; use super::super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use tracing; /// Tool that refreshes the shared directory cache for a given path. pub struct DirCacheUpdate; @@ -52,15 +53,18 @@ impl Tool for DirCacheUpdate { /// the `path` argument is missing or the temp runtime fails to start. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = crate::tool::arg_str(args, "path")?; + tracing::debug!(path = %rel, "DirCacheUpdate::run refreshing cache"); let path = super::super::resolve_path(&ctx.workspaces, &rel)?; if !path.exists() { + tracing::debug!(resolved = %path.display(), "DirCacheUpdate::run path not found"); return Ok(super::path_not_found(&rel, &path)); } let entries = walk_directory(&path); let count = entries.len(); + tracing::debug!(entry_count = count, "DirCacheUpdate::run walked directory"); let dc = ctx.dir_cache.clone(); // Create a one-shot runtime so this tool works from any thread (the @@ -71,6 +75,7 @@ impl Tool for DirCacheUpdate { let cache = dc.write().await; cache.set(entries).await; }); + tracing::debug!(entry_count = count, "DirCacheUpdate::run cache stored"); Ok(format!("cached {count} entries for {rel}")) } @@ -86,10 +91,12 @@ impl Tool for DirCacheUpdate { /// Return: paths of direct children; empty vec if `path` can't be read. fn walk_directory(path: &std::path::Path) -> Vec { let mut result = Vec::new(); + // Silently skip unreadable entries rather than failing the whole cache update. if let Ok(entries) = std::fs::read_dir(path) { for entry in entries.flatten() { result.push(entry.path()); } } + tracing::debug!(path = %path.display(), count = result.len(), "walk_directory done"); result } diff --git a/crates/zesdex-backend/src/tool/utility/dir_list.rs b/crates/zesdex-backend/src/tool/utility/dir_list.rs index 4d7fc0e..c313a45 100644 --- a/crates/zesdex-backend/src/tool/utility/dir_list.rs +++ b/crates/zesdex-backend/src/tool/utility/dir_list.rs @@ -7,6 +7,9 @@ //! //! Why: gives the agent a quick, one-level view of the workspace //! structure without pulling in the full recursive directory cache. +//! +//! Edge case: entries whose metadata can't be read are silently skipped +//! (via `filter_map(Result::ok)`) instead of aborting the whole listing. use super::super::Tool; use super::super::ToolCtx; use anyhow::{anyhow, Result}; @@ -14,6 +17,9 @@ use serde_json::{json, Value}; use std::fs; /// Tool that lists the immediate contents of a workspace directory. +/// +/// Reads entries via `fs::read_dir` and appends `/` to directory names +/// for visual clarity in the returned listing. pub struct DirList; impl Tool for DirList { @@ -53,21 +59,27 @@ impl Tool for DirList { /// `path` argument is missing or `read_dir` fails outright. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let rel = crate::tool::arg_str(args, "path")?; + tracing::debug!(%rel, "DirList — resolving path"); let path = super::super::resolve_path(&ctx.workspaces, &rel)?; + tracing::debug!(resolved = %path.display(), "DirList — path resolved"); if !path.exists() { + tracing::info!(%rel, "DirList — path does not exist"); return Ok(super::path_not_found(&rel, &path)); } if !path.is_dir() { + tracing::info!(%rel, "DirList — path is not a directory"); return Ok(super::path_not_a_directory(&rel, &path)); } let entries: Vec = fs::read_dir(&path) .map_err(|e| anyhow!("failed to read directory '{rel}': {e}"))? + // filter_map(Ok) skips entries with permission errors / broken symlinks .filter_map(std::result::Result::ok) .map(|e| { let name = e.file_name().to_string_lossy().to_string(); + // Tag subdirectories with trailing `/` so the agent can distinguish them let is_dir = e.file_type().is_ok_and(|t| t.is_dir()); if is_dir { format!("{name}/") @@ -77,8 +89,11 @@ impl Tool for DirList { }) .collect(); + let count = entries.len(); + tracing::info!(%count, %rel, "DirList — listing prepared"); + let canon = path.canonicalize().unwrap_or(path); - let header = format!("{} entries in {}:\n", entries.len(), canon.display()); + let header = format!("{count} entries in {}:\n", canon.display()); if entries.is_empty() { Ok(format!("{} (empty directory)", header.trim())) } else { diff --git a/crates/zesdex-backend/src/tool/utility/mod.rs b/crates/zesdex-backend/src/tool/utility/mod.rs index c6ddaf0..620a78e 100644 --- a/crates/zesdex-backend/src/tool/utility/mod.rs +++ b/crates/zesdex-backend/src/tool/utility/mod.rs @@ -1,6 +1,13 @@ -//! Small standalone utility tools (cd, dir listing/caching, pong, todowrite). +//! Module-level re-exports and shared helpers for standalone utility tools. +//! +//! Sub-modules: `cd`, `dir_cache_update`, `dir_list`, `pong`, `todofinish`, `todowrite`. +//! +//! This module provides two formatting helpers (`path_not_found`, +//! `path_not_a_directory`) used by multiple tool impls so error/status +//! messages are consistent across all path-resolving tools. use std::path::Path; +use tracing; pub mod cd; pub mod dir_cache_update; @@ -10,7 +17,11 @@ pub mod todofinish; pub mod todowrite; /// Format a "path does not exist" message. +/// +/// Logs at debug level so callers don't need to emit their own tracing +/// for this common non-error case. pub fn path_not_found(rel: &str, path: &Path) -> String { + tracing::debug!(rel, resolved = %path.display(), "path_not_found"); format!( "path '{}' does not exist (resolved to {})", rel, @@ -19,7 +30,11 @@ pub fn path_not_found(rel: &str, path: &Path) -> String { } /// Format a "path is not a directory" message. +/// +/// Logs at debug level — like `path_not_found`, this is not an error +/// condition, just informational feedback to the caller. pub fn path_not_a_directory(rel: &str, path: &Path) -> String { + tracing::debug!(rel, resolved = %path.display(), "path_not_a_directory"); format!( "path '{}' is not a directory (resolved to {})", rel, diff --git a/crates/zesdex-backend/src/tool/utility/pong.rs b/crates/zesdex-backend/src/tool/utility/pong.rs index a87257b..b5934ed 100644 --- a/crates/zesdex-backend/src/tool/utility/pong.rs +++ b/crates/zesdex-backend/src/tool/utility/pong.rs @@ -10,6 +10,9 @@ use anyhow::Result; use serde_json::{json, Value}; /// Tool that echoes back a message; used for connectivity/latency checks. +/// +/// This is the simplest tool in the system — it exists solely for health +/// checks and latency measurements. pub struct Pong; impl Tool for Pong { @@ -31,11 +34,16 @@ impl Tool for Pong { }) } + /// Echo the optional `message` arg back as `"pong: "`. + /// + /// Flow: extract optional `message` string from args → default to `"pong"` + /// if absent → format and return. fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result { let msg = args .get("message") .and_then(|v| v.as_str()) .unwrap_or("pong"); + tracing::debug!(%msg, "Pong — echo"); Ok(format!("pong: {msg}")) } } diff --git a/crates/zesdex-backend/src/tool/utility/todofinish.rs b/crates/zesdex-backend/src/tool/utility/todofinish.rs index cf7cc90..025f654 100644 --- a/crates/zesdex-backend/src/tool/utility/todofinish.rs +++ b/crates/zesdex-backend/src/tool/utility/todofinish.rs @@ -1,10 +1,18 @@ //! Tool for marking tasks as finished in the session's todo list. +//! +//! Reads `/todo.md`, finds lines matching `- [ ]`, and +//! rewrites them as `- [x]` — either a specific index or all at once. +//! +//! Flow: read todo.md → find task by index (or all) → replace `- [ ]` with +//! `- [x]` → write back. use super::super::{Tool, ToolCtx}; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; use std::path::PathBuf; /// Tool that marks tasks as finished in the session's todo.md. +/// +/// If `task_index` is omitted, **all** unfinished tasks are marked done. pub struct Todofinish; impl Tool for Todofinish { @@ -26,16 +34,28 @@ impl Tool for Todofinish { }) } + /// Mark tasks as finished in `/todo.md`. + /// + /// Flow: read todo.md → iterate lines → match `- [ ]` → replace with + /// `- [x]` for the target index (or all) → write back. + /// + /// Return: success message with count, or a notice if nothing changed + /// (e.g. index out of bounds). fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let path: PathBuf = ctx.session_dir.join("todo.md"); + tracing::debug!(?path, "Todofinish — looking for todo.md"); + if !path.exists() { + tracing::info!("Todofinish — no todo.md found"); return Ok("No todo.md found in session directory. Nothing to finish.".to_string()); } let content = std::fs::read_to_string(&path).map_err(|e| anyhow!("failed to read todo.md: {e}"))?; + tracing::debug!(len = content.len(), "Todofinish — read todo.md"); let task_index = args.get("task_index").and_then(serde_json::Value::as_i64); + tracing::debug!(?task_index, "Todofinish — task index from args"); let mut new_content = String::new(); let mut task_count = 0; @@ -45,6 +65,7 @@ impl Tool for Todofinish { if line.trim_start().starts_with("- [ ]") { task_count += 1; if let Some(target) = task_index { + // Mark only the one task at the specified 1-based index if task_count == target { new_content.push_str(&line.replacen("- [ ]", "- [x]", 1)); modified = true; @@ -52,7 +73,7 @@ impl Tool for Todofinish { new_content.push_str(line); } } else { - // Mark all as finished + // No index given → mark ALL unfinished tasks as done new_content.push_str(&line.replacen("- [ ]", "- [x]", 1)); modified = true; } @@ -63,11 +84,13 @@ impl Tool for Todofinish { } if !modified { + tracing::info!(?task_index, "Todofinish — no unfinished tasks found or index out of bounds"); return Ok("No unfinished tasks found or index out of bounds.".to_string()); } std::fs::write(&path, new_content) .map_err(|e| anyhow!("failed to write to todo.md: {e}"))?; + tracing::info!(?task_index, "Todofinish — todo.md updated"); if let Some(idx) = task_index { Ok(format!("Successfully marked task {idx} as finished.")) diff --git a/crates/zesdex-backend/src/tool/utility/todowrite.rs b/crates/zesdex-backend/src/tool/utility/todowrite.rs index d1c9e8b..6def4c6 100644 --- a/crates/zesdex-backend/src/tool/utility/todowrite.rs +++ b/crates/zesdex-backend/src/tool/utility/todowrite.rs @@ -7,6 +7,8 @@ //! Why: the file lives under `ctx.session_dir` so it persists per //! session and is picked up by the TUI's Todo panel; appending (rather //! than rewriting) keeps prior tasks intact. +//! +//! Companion tool: `todofinish` marks tasks as done (`- [x]`). use super::super::Tool; use super::super::ToolCtx; use anyhow::{anyhow, Result}; @@ -15,6 +17,8 @@ use std::fs; use std::path::PathBuf; /// Tool that appends a timestamped task line to the session's todo.md. +/// +/// Creates the file if it doesn't already exist (append-only, never rewrites). pub struct Todowrite; impl Tool for Todowrite { @@ -52,12 +56,14 @@ impl Tool for Todowrite { /// if the `task` argument is missing or the file can't be opened/written. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let task = crate::tool::arg_str(args, "task")?; + tracing::debug!(%task, "Todowrite — adding task"); let path: PathBuf = ctx.session_dir.join("todo.md"); let now = chrono::Utc::now(); let timestamp = now.format("%Y-%m-%d %H:%M:%S"); let line = format!("- [ ] {task} ({timestamp})\n"); + // Open in append+create mode so we never overwrite existing tasks. fs::OpenOptions::new() .create(true) .append(true) @@ -66,6 +72,7 @@ impl Tool for Todowrite { .write_all(line.as_bytes()) .map_err(|e| anyhow!("failed to write to todo.md: {e}"))?; + tracing::info!(%task, path = %path.display(), "Todowrite — task appended"); Ok(format!("added task to todo.md: {task}")) } } diff --git a/crates/zesdex-backend/src/tool/workflow.rs b/crates/zesdex-backend/src/tool/workflow.rs index 54c2865..638a29e 100644 --- a/crates/zesdex-backend/src/tool/workflow.rs +++ b/crates/zesdex-backend/src/tool/workflow.rs @@ -15,6 +15,7 @@ use super::Tool; use super::ToolCtx; use anyhow::{anyhow, Result}; use serde_json::{json, Value}; +use tracing; /// Tool that parses and executes a JSON-encoded workflow script (Agent/Parallel/Pipeline/Phase). pub struct WorkflowRun; @@ -59,11 +60,13 @@ impl Tool for WorkflowRun { /// script argument is missing or fails to parse as JSON. fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let script_str = crate::tool::arg_str(args, "script")?; + tracing::debug!(script_len = script_str.len(), "WorkflowRun::run invoked"); let workflow_script: crate::app::workflow::script::WorkflowScript = serde_json::from_str(&script_str) .map_err(|e| anyhow!("failed to parse workflow script: {e}"))?; + // Collect optional string args for {{key}} template substitution let workflow_args: std::collections::HashMap = args .get("args") .and_then(|v| v.as_object()) @@ -74,6 +77,7 @@ impl Tool for WorkflowRun { }) .unwrap_or_default(); + tracing::debug!(name = %workflow_script.name, "executing workflow"); crate::app::workflow::engine::run_workflow( &workflow_script, &workflow_args, @@ -206,6 +210,7 @@ impl Tool for HiveMind { fn run(&self, ctx: &ToolCtx, args: &Value) -> Result { let request = crate::tool::arg_str(args, "request")?; + tracing::info!(request_len = request.len(), "HiveMind::run invoked"); let cycles_value = args .get("cycles") @@ -251,6 +256,7 @@ impl Tool for ReadFindings { } fn run(&self, ctx: &ToolCtx, _args: &Value) -> Result { + tracing::debug!("ReadFindings::run invoked"); if let Some(ref findings) = ctx.workflow_findings { let f = findings.lock().map_err(|e| anyhow!("poisoned lock: {e}"))?; if f.is_empty() { diff --git a/crates/zesdex-backend/src/view/chat.rs b/crates/zesdex-backend/src/view/chat.rs index 845988a..21a0b79 100644 --- a/crates/zesdex-backend/src/view/chat.rs +++ b/crates/zesdex-backend/src/view/chat.rs @@ -20,6 +20,7 @@ use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use ratatui::Frame; +use tracing; /// Column width reserved for the `{role} {time} ` header prefix; wrapped /// continuation lines and Tool sub-lines indent to this width so content @@ -101,7 +102,19 @@ fn needs_speaker_separator(_prev_role: Option<&Role>, _role: &Role) -> bool { } /// Render the scrollable chat transcript panel in tight inline-log style. +/// +/// Flow: iterate transcript messages → render each into styled `Line`s +/// with a compact `{role} {time}` header prefix → append streaming +/// spinner if a turn is in flight → slice to the visible scroll window +/// → wrap in a bordered Paragraph widget. pub fn draw_chat(frame: &mut Frame, area: Rect, state: &crate::app::state::rest::AppStateRest) { + let msg_count = state.transcript_cache.messages.len(); + tracing::debug!( + msg_count, + area = %format!("{}x{}", area.width, area.height), + scroll_offset = state.scroll.offset, + "draw_chat rendering transcript" + ); let messages = &state.transcript_cache.messages; let scroll_offset = state.scroll.offset; let max_visible = (area.height as usize).saturating_sub(3); diff --git a/crates/zesdex-backend/src/view/markdown.rs b/crates/zesdex-backend/src/view/markdown.rs index e168363..a38c93b 100644 --- a/crates/zesdex-backend/src/view/markdown.rs +++ b/crates/zesdex-backend/src/view/markdown.rs @@ -18,6 +18,7 @@ use super::theme::Theme; use ratatui::style::{Modifier, Style}; use ratatui::text::Span; +use tracing; /// Apply the "tool output" dim/italic style, or pass `style` through /// unchanged, depending on `dim`. @@ -62,6 +63,12 @@ fn diff_line_style(line: &str) -> Option