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
This commit is contained in:
@@ -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<HashMap>` inside a `OnceLock`;
|
||||
/// subsequent calls return the same static reference.
|
||||
///
|
||||
/// Return: a reference to the static `Mutex<HashMap<...>>`, created on
|
||||
/// first access.
|
||||
pub(crate) fn bash_jobs_map() -> &'static Mutex<HashMap<String, BashJob>> {
|
||||
static JOBS: OnceLock<Mutex<HashMap<String, BashJob>>> = 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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
/// 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(())
|
||||
}
|
||||
|
||||
@@ -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:<code>` sentinels from the background thread.
|
||||
pub output_rx: mpsc::Receiver<String>,
|
||||
/// 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<i32>,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <reason>`.
|
||||
///
|
||||
/// Flow: try JSON parse first → fall back to text line parsing → fall
|
||||
/// back to keyword heuristics.
|
||||
fn parse_verdict(text: &str) -> Option<Verdict> {
|
||||
let trimmed = text.trim();
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(trimmed) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<std::process::ChildStdout>,
|
||||
/// 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<Self> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
let mut content_length: Option<usize> = 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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__";
|
||||
|
||||
|
||||
@@ -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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
/// 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
|
||||
}
|
||||
|
||||
@@ -3,30 +3,43 @@
|
||||
//!
|
||||
//! Each helper downloads a prebuilt binary (or archive) and places it
|
||||
//! under `~/.local/share/zesdex/lsp/<server-name>/`.
|
||||
//!
|
||||
//! 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/<server>/` (using `dirs::data_dir`).
|
||||
fn lsp_install_dir(server: &str) -> Result<PathBuf, String> {
|
||||
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/<name>/`).
|
||||
///
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
}
|
||||
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<PathBuf, String> {
|
||||
info!(tier = name, "running download tier");
|
||||
match name {
|
||||
DOWNLOAD_RUST_BIN => install_rust_analyzer_binary(env, progress),
|
||||
DOWNLOAD_JDTLS => install_jdtls_from_eclipse(progress),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<StdioChild> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<String>>) -> 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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<LearningItem>`.
|
||||
|
||||
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<LearningItem>` (pending first, then stored). Empty
|
||||
/// vec if nothing is found.
|
||||
pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
@@ -29,6 +50,7 @@ pub fn get_learning_items(state: &AppStateRest) -> Vec<LearningItem> {
|
||||
} 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<LearningItem> {
|
||||
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<LearningItem> {
|
||||
}
|
||||
}
|
||||
|
||||
debug!(total_items = items.len(), "learning items loaded");
|
||||
items
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)`.
|
||||
///
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<rusqlite::Connection> {
|
||||
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<std::path::PathBuf> {
|
||||
let el = zesdex_cms::infrastructure::persistence::edit_log_repo::JsonlEditLogRepository::new()
|
||||
.open(&state.session_dir)
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -60,12 +60,13 @@ pub fn process_pending_lessons(
|
||||
) -> std::io::Result<Vec<PendingLesson>> {
|
||||
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);
|
||||
|
||||
@@ -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<String> {
|
||||
// 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::<serde_json::Value>(&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
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> = 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\
|
||||
|
||||
@@ -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<Vec<String>> {
|
||||
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<Vec<
|
||||
for name in names {
|
||||
if let Ok(mut mem) = MarkdownMemoryRepository::new().load(memory_dir, &name) {
|
||||
if mem.updated_at < cutoff && mem.lifecycle != "stale" {
|
||||
tracing::info!("[staleness] flagging as stale: {name}");
|
||||
mem.lifecycle = "stale".to_string();
|
||||
MarkdownMemoryRepository::new()
|
||||
.save(memory_dir, &mem)
|
||||
@@ -48,8 +50,10 @@ pub fn run_staleness_sweep(memory_dir: &std::path::Path) -> std::io::Result<Vec<
|
||||
pub fn maybe_run_staleness_sweep(state: &mut AppStateRest) {
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
if now.saturating_sub(state.misc.last_staleness_sweep_ms) < 600_000 {
|
||||
tracing::trace!("[staleness] sweep skipped (rate-limited)");
|
||||
return;
|
||||
}
|
||||
tracing::debug!("[staleness] sweep window elapsed, running");
|
||||
state.misc.last_staleness_sweep_ms = now;
|
||||
if let Ok(flagged) = run_staleness_sweep(&state.memory_dir) {
|
||||
if !flagged.is_empty() {
|
||||
|
||||
@@ -53,6 +53,7 @@ pub struct Lesson {
|
||||
pub provenance: Provenance,
|
||||
}
|
||||
|
||||
/// Default is an empty unverified project-scoped lesson with no provenance.
|
||||
impl Default for Lesson {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
|
||||
@@ -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<Action>` (always non-empty) to be applied sequentially
|
||||
/// by `apply_action`.
|
||||
pub fn apply_command(command: Command) -> Vec<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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<Action> {
|
||||
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(),
|
||||
|
||||
@@ -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<String> =
|
||||
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<String> =
|
||||
command.split_whitespace().map(std::string::ToString::to_string).collect();
|
||||
let cmd = extra_args.first().cloned().unwrap_or_default();
|
||||
let args: Vec<String> = extra_args.into_iter().skip(1).collect();
|
||||
let cmd = extra_args.first().cloned().unwrap_or_default(); // main executable
|
||||
let args: Vec<String> = 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);
|
||||
}
|
||||
|
||||
@@ -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 `<base_url>/models`, pushing the result as a `SystemNote` so the
|
||||
/// next `Tick` handler updates `api_connected`.
|
||||
/// request to `<base_url>/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(),
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> {
|
||||
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<String> {
|
||||
|
||||
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}."))
|
||||
}
|
||||
|
||||
@@ -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<std::path::PathBuf> = 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;
|
||||
}
|
||||
|
||||
@@ -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<TurnEvent> = {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
|
||||
@@ -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<ChatMessage>, 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<String, (String, String)> = HashMap::new();
|
||||
for m in messages {
|
||||
@@ -84,6 +92,7 @@ pub fn collapse(messages: &[ChatMessage]) -> (Vec<ChatMessage>, 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};
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
let content = m.content.as_deref()?;
|
||||
if content.starts_with(SUMMARY_PREFIX) {
|
||||
@@ -124,6 +142,13 @@ fn extract_prior_summary(m: &ChatMessage) -> Option<String> {
|
||||
/// 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<ChatMessage> {
|
||||
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;
|
||||
|
||||
|
||||
@@ -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::<serde_json::Value>(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<usize>) -> String {
|
||||
let mut kept_sorted: Vec<usize> = keep.iter().copied().collect();
|
||||
kept_sorted.sort_unstable();
|
||||
@@ -309,6 +331,9 @@ fn render_kept_lines(lines: &[&str], keep: &HashSet<usize>) -> 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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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<VecDeque<TurnEvent>>,
|
||||
) -> Vec<TurnEvent> {
|
||||
events.lock().map(|mut q| q.drain(..).collect()).unwrap_or_default()
|
||||
let drained: Vec<TurnEvent> = 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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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<Mutex<VecDeque<TurnEvent>>>,
|
||||
event: TurnEvent,
|
||||
q: &Arc<Mutex<VecDeque<TurnEvent>>>, // 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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<char> = 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]
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<ToolCall> = 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 <think> 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::<Value>(&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 {
|
||||
|
||||
@@ -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<Change> {
|
||||
// Short-circuit: no allocation when nothing changed
|
||||
if before == after {
|
||||
return Vec::new();
|
||||
}
|
||||
debug!("state diff: value changed");
|
||||
vec 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<String>,
|
||||
/// Index into `history` when browsing (None = at the current input).
|
||||
pub history_idx: Option<usize>,
|
||||
/// The prefix string used to filter candidates for autocomplete.
|
||||
pub autocomplete_prefix: String,
|
||||
/// Current autocomplete candidate list.
|
||||
pub autocomplete_candidates: Vec<String>,
|
||||
/// Focused index within `autocomplete_candidates`.
|
||||
pub autocomplete_idx: usize,
|
||||
/// Whether the autocomplete dropdown is visible.
|
||||
pub autocomplete_visible: bool,
|
||||
/// Which kind of autocomplete is active (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<PathBuf>,
|
||||
}
|
||||
|
||||
@@ -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 => {}
|
||||
}
|
||||
|
||||
@@ -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<RwLock<Vec<PathBuf>>>` 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<RwLock<Vec<PathBuf>>>,
|
||||
}
|
||||
|
||||
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<PathBuf>) {
|
||||
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<std::sync::RwLock<Vec<String>>>` (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<std::sync::RwLock<Vec<String>>>,
|
||||
}
|
||||
|
||||
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<String>) {
|
||||
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<String> {
|
||||
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<super::types::Toast>,
|
||||
/// 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<super::super::mode::editor::EditorState>,
|
||||
/// 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<String>,
|
||||
}
|
||||
|
||||
@@ -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::<String>()
|
||||
);
|
||||
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<super::types::Toast> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PathBuf>,
|
||||
/// 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<RwLock<DirCache>>,
|
||||
/// Shared workspace file-path index for `@file` mention autocomplete.
|
||||
pub mention_index: MentionIndex,
|
||||
/// Persistent edit history log (appended on every tool write).
|
||||
pub edit_log: EditLog,
|
||||
/// Optional per-session runtime state (message history, tool queue, counters).
|
||||
pub session_runtime: Option<SessionRuntime>,
|
||||
/// Active IAM sessions linked to this app instance.
|
||||
pub sessions: Vec<zesdex_iam::domain::session::Session>,
|
||||
/// Ring buffer of recent chat messages for the TUI transcript pane.
|
||||
pub transcript_cache: TranscriptCache,
|
||||
/// Viewport scroll offset tracker.
|
||||
pub scroll: ScrollState,
|
||||
/// Chat input buffer, cursor, history, and autocomplete.
|
||||
pub input: InputState,
|
||||
/// Miscellaneous state: overlay, toasts, flags, 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<Mutex<VecDeque<TurnEvent>>>,
|
||||
/// Whether an agent turn is currently in flight (guarded by a mutex).
|
||||
pub turn_in_flight: Arc<Mutex<bool>>,
|
||||
/// Atomic flag set when the user aborts the current turn (Ctrl-C / Escape).
|
||||
pub abort_flag: Arc<std::sync::atomic::AtomicBool>,
|
||||
/// 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<Mutex<LspManager>>,
|
||||
/// 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<Mutex<VecDeque<String>>>,
|
||||
/// Whether the state has been modified since the last render sweep.
|
||||
pub dirty: bool,
|
||||
/// Whether the application has been requested to quit.
|
||||
pub quit: bool,
|
||||
}
|
||||
|
||||
@@ -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<String> = 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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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<String>) {
|
||||
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(),
|
||||
|
||||
@@ -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<SessionRuntime>`.
|
||||
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<crate::dto::chat::message::ChatMessage>,
|
||||
/// Completed tool-call results (used for display/review).
|
||||
pub tool_call_results: Vec<ToolCallResult>,
|
||||
/// Tools queued for execution when the turn resumes.
|
||||
pub pending_tool_queue: Vec<PendingTool>,
|
||||
/// Background bash job display records.
|
||||
pub bash_jobs: Vec<BashJobRef>,
|
||||
/// 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<String>,
|
||||
},
|
||||
/// 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<crate::dto::chat::message::ChatMessage>),
|
||||
/// 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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<Vec<u8>> {
|
||||
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<StateSnapshot> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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::<String>());
|
||||
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<super::rest::ChatMessageDisplay>,
|
||||
/// Maximum messages to retain before evicting the oldest.
|
||||
pub max_lines: usize,
|
||||
/// Whether the cache has changed since the last render sweep.
|
||||
pub dirty: bool,
|
||||
}
|
||||
|
||||
impl TranscriptCache {
|
||||
/// Create an empty transcript cache holding at most `max_lines` messages.
|
||||
pub fn new(max_lines: usize) -> Self {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String> {
|
||||
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<AtomicBool>>,
|
||||
) -> Result<String, String> {
|
||||
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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
// 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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
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<Mutex<VecDeque<TurnEvent>>>,
|
||||
abort_flag: Arc<AtomicBool>,
|
||||
) {
|
||||
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<String> = 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<String> = 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<String> = 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(|| {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String>`.
|
||||
///
|
||||
/// Return: an owned `Vec<String>` suitable for `AgentDefinition::with_allowed_tools`.
|
||||
pub fn tools_for(scope: &str) -> Vec<String> {
|
||||
// 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;
|
||||
|
||||
@@ -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<SubagentEvent>,
|
||||
) -> anyhow::Result<String> {
|
||||
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<ChatMessage> = 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<Vec<ToolDef>> = 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<String> {
|
||||
// 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()))
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>, 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<String>, 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<()> {
|
||||
|
||||
@@ -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<String>,
|
||||
/// Optional tool allowlist. `None` means role-based defaults apply.
|
||||
pub allowed_tools: Option<Vec<String>>,
|
||||
/// Optional step budget. `None` means no limit (usize::MAX).
|
||||
pub max_steps: Option<usize>,
|
||||
/// Optional temperature override for the LLM call.
|
||||
pub temperature: Option<f32>,
|
||||
}
|
||||
|
||||
@@ -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<String>) -> 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<F>(
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn crate::tool::Tool>>, Vec<ToolDef>) {
|
||||
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<Box<dyn crate::tool::Tool>> = 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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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<Arc<AtomicBool>>) -> bool {
|
||||
flag.as_ref().is_some_and(|f| f.load(Ordering::SeqCst))
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<String> {
|
||||
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 {
|
||||
|
||||
@@ -90,6 +90,20 @@ impl WorkflowEngine {
|
||||
pub type LiveStateFn = Arc<dyn Fn(String, String, AgentStatus) + Send + Sync>;
|
||||
|
||||
/// 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<String> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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);
|
||||
|
||||
@@ -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<String>` 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<Vec<String>> {
|
||||
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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Mutex<Vec<String>>>` 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<usize>,
|
||||
/// 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<Vec<String>>);
|
||||
|
||||
/// 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<String, String>,
|
||||
/// 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<Arc<AtomicBool>>,
|
||||
/// 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<Mutex<Vec<String>>>,
|
||||
/// Optional per-primitive timeout in milliseconds. Propagated to
|
||||
/// individual agent spawns so no single turn can exceed the deadline.
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
@@ -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, String>) -> 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, String>) -> String {
|
||||
/// Return: a `Vec<String>` of all agent outputs (or error strings) in
|
||||
/// the order they were submitted.
|
||||
pub fn execute_primitive(pc: PrimitiveCtx<'_>) -> anyhow::Result<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
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<Vec<String>> {
|
||||
}
|
||||
|
||||
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<Vec<String>> {
|
||||
}
|
||||
|
||||
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<Vec<String>> {
|
||||
// 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<Vec<String>> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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<NodeReport>)` 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<NodeReport>)` — 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<Vec<NodeReport>> {
|
||||
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<String> = (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<ScriptPrimitive> = 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<String, String> = 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)
|
||||
}
|
||||
|
||||
@@ -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<Mutex<std::collections::VecDeque<crate::app::state::runtime::TurnEvent>>>,
|
||||
>,
|
||||
) -> Option<LiveStateFn> {
|
||||
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::<String>();
|
||||
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
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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<AtomicBool>>,
|
||||
) -> anyhow::Result<(String, Vec<NodeReport>)> {
|
||||
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<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
// Accumulates NodeReports across all cycles for the convergence doc.
|
||||
let mut reports: Vec<NodeReport> = 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)
|
||||
|
||||
@@ -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<Mutex<Vec<String>>>` 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<AtomicBool>>,
|
||||
node_timeout_ms: Option<u64>,
|
||||
) -> anyhow::Result<String> {
|
||||
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<String, String> = HashMap::new();
|
||||
let abort_owned: Option<Arc<AtomicBool>> = 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)
|
||||
}
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -42,6 +42,7 @@ pub struct ScriptOptions {
|
||||
pub timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
/// Default options: 5-way concurrency, fail-fast, no timeout.
|
||||
impl Default for ScriptOptions {
|
||||
fn default() -> Self {
|
||||
ScriptOptions {
|
||||
|
||||
@@ -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<CrosstermBackend<io::Stdout>>,
|
||||
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/<session_id>.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<DaemonFrame>) {
|
||||
tracing::debug!("received daemon frame");
|
||||
match frame {
|
||||
Some(DaemonFrame::StateUpdate(payload)) => {
|
||||
apply_client_update(client_state, *payload);
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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 <provider>` — trigger OAuth login for the given provider.
|
||||
Login { provider: String },
|
||||
/// `/edit <path>` — open the given file for review/inline editing.
|
||||
Edit(String),
|
||||
/// `/mcp add <name> <command>` — 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 <name> <command…>
|
||||
// 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)]
|
||||
|
||||
@@ -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<Action>` — 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<Action> {
|
||||
state.mark_dirty();
|
||||
Vec::new()
|
||||
@@ -20,15 +33,21 @@ fn mark(state: &mut AppStateRest) -> Vec<Action> {
|
||||
/// 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<Action>` so a single key (e.g. Ctrl+C) can produce multiple
|
||||
/// queued actions.
|
||||
pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
// 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<Action> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<Action> {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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<Action> {
|
||||
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<Action> {
|
||||
/// 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<Action> {
|
||||
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<Action> {
|
||||
let providers: Vec<String> = 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<Action> {
|
||||
});
|
||||
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<Action> {
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<ipc::protocol::KeyAction> {
|
||||
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<ipc::protoc
|
||||
/// from a `KeyAction` received over IPC, for replaying it into the
|
||||
/// daemon's normal key-handling path.
|
||||
pub fn key_action_to_code(action: &ipc::protocol::KeyAction) -> crossterm::event::KeyCode {
|
||||
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<MessageEntry> = 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<ToastEntry> = 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::<ClientRequest>()? {
|
||||
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)?;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<CrosstermBackend<io::Stdout>>,
|
||||
) -> 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<CrosstermBackend<io::Stdout>>,
|
||||
) -> 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()?;
|
||||
|
||||
@@ -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::*;
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user