Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89ee213454 | ||
|
|
a04651905f | ||
|
|
600ea041ef | ||
|
|
1ec2aa136a |
@@ -1,3 +1,12 @@
|
||||
# [1.17.0](https://github.com/asepharyana/zesdex/compare/v1.16.1...v1.17.0) (2026-07-20)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **token:** add refresh token verification to TokenService ([a046519](https://github.com/asepharyana/zesdex/commit/a04651905f4afd562b516c839449a2c813ce6627))
|
||||
|
||||
## [1.16.1](https://github.com/asepharyana/zesdex/compare/v1.16.0...v1.16.1) (2026-07-20)
|
||||
|
||||
# [1.16.0](https://github.com/asepharyana/zesdex/compare/v1.15.2...v1.16.0) (2026-07-20)
|
||||
|
||||
|
||||
|
||||
Generated
+11
-11
@@ -4895,7 +4895,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-api"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -4918,7 +4918,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-application"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4935,7 +4935,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-bootstrap"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"chrono",
|
||||
@@ -4952,7 +4952,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-daemon"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4976,7 +4976,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-domain"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -4992,7 +4992,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-gateway"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -5019,7 +5019,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-grpc"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -5036,7 +5036,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-infrastructure"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"argon2",
|
||||
@@ -5085,7 +5085,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-tui"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"base64",
|
||||
@@ -5111,7 +5111,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-web"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
@@ -5131,7 +5131,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "zesdex-ws"
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"axum",
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ members = [
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
version = "1.16.0"
|
||||
version = "1.17.0"
|
||||
edition = "2021"
|
||||
authors = ["asepharyana <superaseph@gmail.com>"]
|
||||
|
||||
|
||||
@@ -104,7 +104,7 @@ fn generate_pkce_pair() -> (String, String) {
|
||||
bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes());
|
||||
|
||||
let verifier = URL_SAFE_NO_PAD.encode(&bytes);
|
||||
let verifier = URL_SAFE_NO_PAD.encode(bytes);
|
||||
let challenge = {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(verifier.as_bytes());
|
||||
|
||||
@@ -51,7 +51,7 @@ impl<R: SessionRepository, L: SessionLockRepository>
|
||||
{
|
||||
fn create_session(&self, title: &str) -> Result<Session, ServiceError> {
|
||||
let id = SessionId::new(&Uuid::new_v4().to_string())
|
||||
.map_err(|e| ServiceError::Other(e))?;
|
||||
.map_err(ServiceError::Other)?;
|
||||
let title_owned = if title.is_empty() {
|
||||
"New Session".to_string()
|
||||
} else {
|
||||
|
||||
@@ -23,4 +23,10 @@ pub trait TokenService: Send + Sync {
|
||||
/// Returns `Err` if the token is expired, malformed, or has an
|
||||
/// invalid signature.
|
||||
fn verify_access_token(&self, token: &str) -> Result<String>;
|
||||
|
||||
/// Verify a refresh token and return the embedded subject claim.
|
||||
///
|
||||
/// Returns `Err` if the token is expired, malformed, or has an
|
||||
/// invalid signature.
|
||||
fn verify_refresh_token(&self, token: &str) -> Result<String>;
|
||||
}
|
||||
|
||||
@@ -16,7 +16,10 @@ fn main() -> anyhow::Result<()> {
|
||||
if !settings_path.exists() {
|
||||
let settings = zesdex_domain::cms::Settings::default();
|
||||
let content = serde_json::to_string_pretty(&settings)?;
|
||||
std::fs::write(&settings_path, content)?;
|
||||
let tmp = store.base_dir.join("settings.json.tmp");
|
||||
std::fs::write(&tmp, &content)?;
|
||||
std::fs::File::open(&tmp)?.sync_all()?;
|
||||
std::fs::rename(&tmp, &settings_path)?;
|
||||
println!(" ✓ Default settings created");
|
||||
} else {
|
||||
println!(" · Settings already exist, skipping");
|
||||
@@ -27,7 +30,10 @@ fn main() -> anyhow::Result<()> {
|
||||
if !config_path.exists() {
|
||||
let config = zesdex_domain::cms::AppConfig::default();
|
||||
let content = serde_json::to_string_pretty(&config)?;
|
||||
std::fs::write(&config_path, content)?;
|
||||
let tmp = store.base_dir.join("app_config.json.tmp");
|
||||
std::fs::write(&tmp, &content)?;
|
||||
std::fs::File::open(&tmp)?.sync_all()?;
|
||||
std::fs::rename(&tmp, &config_path)?;
|
||||
println!(" ✓ Default app_config created");
|
||||
} else {
|
||||
println!(" · App config already exists, skipping");
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
//! 3. Oldest entries are evicted from the in-memory cache when
|
||||
//! `MAX_MEMORY_ENTRIES` is exceeded (prevents unbounded growth)
|
||||
|
||||
use std::collections::VecDeque;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A single recorded file edit event.
|
||||
@@ -47,18 +49,18 @@ pub const MAX_MEMORY_ENTRIES: usize = 10_000;
|
||||
|
||||
/// In-memory view of a session's edit log.
|
||||
///
|
||||
/// Wraps a `Vec<EditLogEntry>` and provides basic query helpers.
|
||||
/// Wraps a `VecDeque<EditLogEntry>` and provides basic query helpers.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct EditLog {
|
||||
/// Ordered list of edit entries (newest appended last).
|
||||
pub entries: Vec<EditLogEntry>,
|
||||
pub entries: VecDeque<EditLogEntry>,
|
||||
}
|
||||
|
||||
impl EditLog {
|
||||
/// Create an empty edit log with no entries.
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
entries: VecDeque::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! schema for each one. Standalone CLI tool invoked as `cargo run --bin migrate`.
|
||||
|
||||
use std::path::Path;
|
||||
use tracing;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
//! configuration files plus a seed session for development/testing.
|
||||
//! Invoked as `cargo run --bin seed`.
|
||||
|
||||
use tracing;
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
|
||||
@@ -136,6 +136,7 @@ fn run_api_server(port: u16) -> anyhow::Result<()> {
|
||||
let rt = tokio::runtime::Runtime::new()?;
|
||||
rt.block_on(async {
|
||||
let store = zesdex_domain::core::Store::new();
|
||||
store.ensure_dirs()?;
|
||||
let state = zesdex_api::ApiState::new(
|
||||
store.base_dir.clone(),
|
||||
"dev-secret",
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
//! Background bash control — list, cancel, and inspect background processes.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::sync::{Arc, Mutex, OnceLock};
|
||||
|
||||
use tracing::error;
|
||||
|
||||
use super::job::BashJob;
|
||||
|
||||
/// Global accessor for the shared BashControl singleton.
|
||||
///
|
||||
/// Used by the Bash tool (to register jobs) and BashKill (to look them up).
|
||||
pub fn bash_control() -> &'static BashControl {
|
||||
static BASH_CONTROL: OnceLock<BashControl> = OnceLock::new();
|
||||
BASH_CONTROL.get_or_init(BashControl::new)
|
||||
}
|
||||
|
||||
/// Central registry of all running background bash jobs.
|
||||
pub struct BashControl {
|
||||
jobs: Mutex<HashMap<String, Arc<BashJob>>>,
|
||||
@@ -18,7 +26,15 @@ impl BashControl {
|
||||
jobs: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BashControl {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl BashControl {
|
||||
/// Register a new background job.
|
||||
pub fn register(&self, job: Arc<BashJob>) {
|
||||
if let Ok(mut guard) = self.jobs.lock() {
|
||||
|
||||
@@ -33,9 +33,13 @@ pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
|
||||
cancelled: AtomicBool::new(false),
|
||||
});
|
||||
|
||||
// Spawn a monitor thread (in production this would use an async task)
|
||||
// Spawn a monitor thread using try_wait() polling so the lock is never
|
||||
// held across a blocking wait, allowing cancel() to acquire the lock.
|
||||
let job_clone = Arc::clone(&job);
|
||||
std::thread::spawn(move || {
|
||||
loop {
|
||||
let mut exited = false;
|
||||
{
|
||||
let mut guard = match job_clone.process.lock() {
|
||||
Ok(g) => g,
|
||||
Err(poisoned) => {
|
||||
@@ -44,7 +48,23 @@ pub fn spawn_bash_job(cmd: String) -> Arc<BashJob> {
|
||||
}
|
||||
};
|
||||
if let Some(ref mut child) = *guard {
|
||||
let _ = child.wait();
|
||||
match child.try_wait() {
|
||||
Ok(Some(_)) => exited = true,
|
||||
Ok(None) => {} // still running
|
||||
Err(e) => {
|
||||
error!("bgbash wait error: {e}");
|
||||
exited = true;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
exited = true; // no child process
|
||||
}
|
||||
} // lock is dropped here — cancel() can now acquire it
|
||||
|
||||
if exited || job_clone.cancelled.load(Ordering::SeqCst) {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(50));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,8 +89,6 @@ impl BashJob {
|
||||
let Ok(mut guard) = self.process.lock() else {
|
||||
return false;
|
||||
};
|
||||
guard.as_mut().map_or(false, |c| {
|
||||
matches!(c.try_wait(), Ok(None))
|
||||
})
|
||||
guard.as_mut().is_some_and(|c| matches!(c.try_wait(), Ok(None)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,7 +134,7 @@ impl LlmClient {
|
||||
loop {
|
||||
attempt += 1;
|
||||
|
||||
if let Some(ref flag) = abort_flag {
|
||||
if let Some(flag) = abort_flag {
|
||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
anyhow::bail!("aborted");
|
||||
}
|
||||
@@ -267,7 +267,7 @@ impl LlmClient {
|
||||
}
|
||||
|
||||
if meaningful_content {
|
||||
if let Some(ref flag) = abort_flag {
|
||||
if let Some(flag) = abort_flag {
|
||||
if flag.load(std::sync::atomic::Ordering::Relaxed) {
|
||||
return Err(anyhow::anyhow!("aborted"));
|
||||
}
|
||||
|
||||
@@ -19,6 +19,15 @@ impl LspManager {
|
||||
clients: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LspManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LspManager {
|
||||
|
||||
pub fn start(&mut self, language: &str, command: &str, args: &[String]) -> anyhow::Result<()> {
|
||||
let client = LspClient::start(command, args)?;
|
||||
@@ -31,7 +40,7 @@ impl LspManager {
|
||||
}
|
||||
|
||||
pub fn shutdown_all(&mut self) {
|
||||
for (_lang, client) in &self.clients {
|
||||
for client in self.clients.values() {
|
||||
let _ = client.shutdown();
|
||||
}
|
||||
self.clients.clear();
|
||||
|
||||
@@ -22,6 +22,15 @@ impl McpManager {
|
||||
servers: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for McpManager {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl McpManager {
|
||||
|
||||
pub fn register(&mut self, name: &str, transport: &str) {
|
||||
self.servers.insert(
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
//! Authentication middleware — session-lock based auth for Axum.
|
||||
//!
|
||||
//! Validates `X-Session-Id` header against the `SessionRepository` before
|
||||
//! forwarding the request to the inner service.
|
||||
|
||||
use std::future::Future;
|
||||
use std::path::PathBuf;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
use axum::body::Body;
|
||||
@@ -9,6 +14,7 @@ use axum::http::{Request, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tower::{Layer, Service};
|
||||
use zesdex_domain::auth::{SessionId, SessionRepository};
|
||||
|
||||
/// Identity extracted from a validated session.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
@@ -30,40 +36,50 @@ impl SessionIdentity {
|
||||
}
|
||||
|
||||
/// Tower Layer that produces SessionAuthMiddleware services.
|
||||
///
|
||||
/// Holds a reference to the `SessionRepository` and the base directory
|
||||
/// needed to validate session IDs.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionAuthLayer;
|
||||
pub struct SessionAuthLayer<R: SessionRepository + Send + Sync + 'static> {
|
||||
base_dir: PathBuf,
|
||||
repo: Arc<R>,
|
||||
}
|
||||
|
||||
impl SessionAuthLayer {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
impl<R: SessionRepository + Send + Sync + 'static> SessionAuthLayer<R> {
|
||||
pub fn new(base_dir: PathBuf, repo: Arc<R>) -> Self {
|
||||
Self { base_dir, repo }
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SessionAuthLayer {
|
||||
fn default() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> Layer<S> for SessionAuthLayer {
|
||||
type Service = SessionAuthMiddleware<S>;
|
||||
impl<S, R> Layer<S> for SessionAuthLayer<R>
|
||||
where
|
||||
R: SessionRepository + Send + Sync + 'static,
|
||||
{
|
||||
type Service = SessionAuthMiddleware<S, R>;
|
||||
|
||||
fn layer(&self, inner: S) -> Self::Service {
|
||||
SessionAuthMiddleware { inner }
|
||||
SessionAuthMiddleware {
|
||||
inner,
|
||||
base_dir: self.base_dir.clone(),
|
||||
repo: self.repo.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Tower Service that validates X-Session-Id before forwarding.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionAuthMiddleware<S> {
|
||||
pub struct SessionAuthMiddleware<S, R: SessionRepository + Send + Sync + 'static> {
|
||||
inner: S,
|
||||
base_dir: PathBuf,
|
||||
repo: Arc<R>,
|
||||
}
|
||||
|
||||
impl<S, ReqBody> Service<Request<ReqBody>> for SessionAuthMiddleware<S>
|
||||
impl<S, ReqBody, R> Service<Request<ReqBody>> for SessionAuthMiddleware<S, R>
|
||||
where
|
||||
S: Service<Request<ReqBody>, Response = Response<Body>> + Send + 'static,
|
||||
S::Future: Send + 'static,
|
||||
ReqBody: Send + 'static,
|
||||
R: SessionRepository + Send + Sync + 'static,
|
||||
{
|
||||
type Response = S::Response;
|
||||
type Error = S::Error;
|
||||
@@ -81,18 +97,24 @@ where
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.to_string());
|
||||
|
||||
if session_id.as_deref() != Some("valid-session") {
|
||||
// In production, this validates against the store
|
||||
return Box::pin(async move {
|
||||
Ok((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"missing or invalid X-Session-Id header",
|
||||
)
|
||||
.into_response())
|
||||
});
|
||||
// Validate the session against the repository.
|
||||
match session_id {
|
||||
Some(sid) => match SessionId::new(&sid) {
|
||||
Ok(id) => match self.repo.load_session(&self.base_dir, &id) {
|
||||
Ok(_session) => {
|
||||
// Session is valid — forward the request.
|
||||
let fut = self.inner.call(req);
|
||||
return Box::pin(fut);
|
||||
}
|
||||
Err(_) => { /* fall through to 401 */ }
|
||||
},
|
||||
Err(_) => { /* fall through to 401 */ }
|
||||
},
|
||||
None => { /* fall through to 401 */ }
|
||||
}
|
||||
|
||||
let fut = self.inner.call(req);
|
||||
Box::pin(fut)
|
||||
Box::pin(async move {
|
||||
Ok((StatusCode::UNAUTHORIZED, "missing or invalid X-Session-Id header").into_response())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
//! JSONL file–backed `EditLogRepository`.
|
||||
//! Stores `EditLog` as an append-only newline-delimited JSON file.
|
||||
|
||||
use std::collections::VecDeque;
|
||||
use std::io::{BufRead, BufReader, Write};
|
||||
use std::path::Path;
|
||||
|
||||
@@ -18,21 +19,21 @@ impl JsonlEditLogRepository {
|
||||
Self
|
||||
}
|
||||
|
||||
fn load_from_disk(path: &Path) -> Vec<EditLogEntry> {
|
||||
fn load_from_disk(path: &Path) -> VecDeque<EditLogEntry> {
|
||||
let Ok(file) = std::fs::File::open(path) else {
|
||||
return Vec::new();
|
||||
return VecDeque::new();
|
||||
};
|
||||
let reader = BufReader::new(file);
|
||||
let mut entries: Vec<EditLogEntry> = Vec::new();
|
||||
let mut entries: VecDeque<EditLogEntry> = VecDeque::new();
|
||||
for line in reader.lines() {
|
||||
let Ok(line) = line else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(entry) = serde_json::from_str::<EditLogEntry>(&line) {
|
||||
if entries.len() >= MAX_MEMORY_ENTRIES {
|
||||
entries.remove(0);
|
||||
entries.pop_front();
|
||||
}
|
||||
entries.push(entry);
|
||||
entries.push_back(entry);
|
||||
}
|
||||
}
|
||||
entries
|
||||
@@ -74,14 +75,14 @@ impl EditLogRepository for JsonlEditLogRepository {
|
||||
file.write_all(line.as_bytes())?;
|
||||
file.sync_all()?;
|
||||
}
|
||||
log.entries.push(entry);
|
||||
log.entries.push_back(entry);
|
||||
if log.entries.len() > MAX_MEMORY_ENTRIES {
|
||||
log.entries.remove(0);
|
||||
log.entries.pop_front();
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn entries(&self, log: &EditLog) -> Vec<EditLogEntry> {
|
||||
log.entries.clone()
|
||||
log.entries.clone().into_iter().collect()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,26 +17,37 @@ impl MarkdownMemoryRepository {
|
||||
Self
|
||||
}
|
||||
|
||||
/// Escape newlines in field values so they do not break the
|
||||
/// line-oriented frontmatter parser.
|
||||
fn escape_newlines(s: &str) -> String {
|
||||
s.replace('\n', "\\n")
|
||||
}
|
||||
|
||||
/// Unescape `\n` back to actual newlines after frontmatter parsing.
|
||||
fn unescape_newlines(s: &str) -> String {
|
||||
s.replace("\\n", "\n")
|
||||
}
|
||||
|
||||
fn build_frontmatter(memory: &Memory) -> String {
|
||||
let outcome_line = memory
|
||||
.outcome
|
||||
.as_ref()
|
||||
.map(|o| format!("outcome: {o}\n"))
|
||||
.map(|o| format!("outcome: {}\n", Self::escape_newlines(o)))
|
||||
.unwrap_or_default();
|
||||
let scope_line = memory
|
||||
.scope
|
||||
.as_ref()
|
||||
.map(|s| format!("scope: {s}\n"))
|
||||
.map(|s| format!("scope: {}\n", Self::escape_newlines(s)))
|
||||
.unwrap_or_default();
|
||||
let before_line = memory
|
||||
.before_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("before: {s}\n"))
|
||||
.map(|s| format!("before: {}\n", Self::escape_newlines(s)))
|
||||
.unwrap_or_default();
|
||||
let after_line = memory
|
||||
.after_snippet
|
||||
.as_ref()
|
||||
.map(|s| format!("after: {s}\n"))
|
||||
.map(|s| format!("after: {}\n", Self::escape_newlines(s)))
|
||||
.unwrap_or_default();
|
||||
let prov_line = if memory.provenances.is_empty() {
|
||||
String::new()
|
||||
@@ -101,14 +112,30 @@ impl MarkdownMemoryRepository {
|
||||
.get("updated_at")
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(0),
|
||||
outcome: front.get("outcome").cloned().filter(|s| !s.is_empty()),
|
||||
outcome: front
|
||||
.get("outcome")
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| Self::unescape_newlines(&s)),
|
||||
lifecycle: front
|
||||
.get("lifecycle")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| "new".to_string()),
|
||||
scope: front.get("scope").cloned().filter(|s| !s.is_empty()),
|
||||
before_snippet: front.get("before").cloned().filter(|s| !s.is_empty()),
|
||||
after_snippet: front.get("after").cloned().filter(|s| !s.is_empty()),
|
||||
scope: front
|
||||
.get("scope")
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| Self::unescape_newlines(&s)),
|
||||
before_snippet: front
|
||||
.get("before")
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| Self::unescape_newlines(&s)),
|
||||
after_snippet: front
|
||||
.get("after")
|
||||
.cloned()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(|s| Self::unescape_newlines(&s)),
|
||||
provenances: front
|
||||
.get("provenances")
|
||||
.cloned()
|
||||
|
||||
@@ -36,7 +36,7 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
||||
Err(e) => return Err(RepositoryError::Io(e)),
|
||||
}
|
||||
|
||||
let content = std::fs::read_to_string(&path).unwrap_or_default();
|
||||
let content = std::fs::read_to_string(&path).map_err(RepositoryError::Io)?;
|
||||
if let Ok(existing_pid) = content.trim().parse::<u32>() {
|
||||
if self.is_alive(existing_pid) {
|
||||
return Ok(false);
|
||||
@@ -46,10 +46,14 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
||||
let tmp = path.with_extension("lock.tmp");
|
||||
{
|
||||
let mut tmp_file = std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.create_new(true)
|
||||
.write(true)
|
||||
.open(&tmp)?;
|
||||
.open(&tmp)
|
||||
.map_err(|_| {
|
||||
RepositoryError::Other(
|
||||
"another process is replacing the lock".to_string(),
|
||||
)
|
||||
})?;
|
||||
write!(tmp_file, "{pid}")?;
|
||||
tmp_file.sync_all()?;
|
||||
}
|
||||
@@ -62,7 +66,7 @@ impl SessionLockRepository for FileSystemSessionLockRepository {
|
||||
|
||||
fn unlock(&self, session_dir: &Path) -> Result<(), RepositoryError> {
|
||||
let path = session_dir.join(".lock");
|
||||
let _ = std::fs::remove_file(path);
|
||||
std::fs::remove_file(path)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
//! Subagent workspace management — create isolated workspaces for subagents.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Create an isolated workspace directory for a subagent.
|
||||
pub fn create_subagent_workspace(base_dir: &PathBuf, agent_id: &str) -> anyhow::Result<PathBuf> {
|
||||
pub fn create_subagent_workspace(base_dir: &Path, agent_id: &str) -> anyhow::Result<PathBuf> {
|
||||
let ws = base_dir.join("subagent-workspaces").join(agent_id);
|
||||
std::fs::create_dir_all(&ws)?;
|
||||
Ok(ws)
|
||||
|
||||
@@ -37,6 +37,11 @@ impl Tool for BashOutput {
|
||||
let job_id = arg_str(args, "job_id")?;
|
||||
info!("Getting output for job: {job_id}");
|
||||
|
||||
// Prevent path traversal
|
||||
if job_id.contains('/') || job_id.contains('\\') || job_id.contains("..") {
|
||||
anyhow::bail!("invalid job_id '{job_id}': must not contain path separators");
|
||||
}
|
||||
|
||||
// Read from the session's bash output directory
|
||||
let output_dir = ctx.session_dir.join("bash-outputs");
|
||||
let output_file = output_dir.join(&job_id);
|
||||
@@ -80,23 +85,11 @@ impl Tool for BashKill {
|
||||
fn run(&self, _ctx: &ToolCtx, args: &Value) -> Result<String> {
|
||||
let job_id = crate::tools::arg_str(args, "job_id")?;
|
||||
info!("bash_kill called for job: {job_id}");
|
||||
// Try to kill by PID (if job_id is numeric) or by process name
|
||||
if let Ok(pid) = job_id.parse::<u32>() {
|
||||
use std::process::Command;
|
||||
match Command::new("kill").arg(pid.to_string()).output() {
|
||||
Ok(output) if output.status.success() => {
|
||||
Ok(format!("Killed background job '{job_id}' (PID {pid})"))
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Ok(format!("Failed to kill job '{job_id}': {stderr}"))
|
||||
}
|
||||
Err(e) => {
|
||||
Ok(format!("Failed to kill job '{job_id}': {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
if crate::bgbash::control::bash_control().cancel(&job_id) {
|
||||
Ok(format!("Killed background job '{job_id}'"))
|
||||
} else {
|
||||
Ok(format!("Invalid job ID '{job_id}' — expected numeric PID"))
|
||||
anyhow::bail!("no active background job found with ID '{job_id}'")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,8 +45,10 @@ impl Tool for Delete {
|
||||
fs::remove_file(&path)?;
|
||||
Ok(format!("Deleted file '{rel}'"))
|
||||
} else if path.is_dir() {
|
||||
fs::remove_dir_all(&path)?;
|
||||
Ok(format!("Deleted directory '{rel}' and all contents"))
|
||||
fs::remove_dir(&path).map_err(|e| {
|
||||
anyhow::anyhow!("failed to delete directory '{rel}': {e} (directory must be empty)")
|
||||
})?;
|
||||
Ok(format!("Deleted empty directory '{rel}'"))
|
||||
} else {
|
||||
anyhow::bail!("'{rel}' is neither a file nor a directory")
|
||||
}
|
||||
|
||||
@@ -56,7 +56,7 @@ impl Tool for Edit {
|
||||
anyhow::bail!("old text not found in '{}'", rel);
|
||||
}
|
||||
|
||||
let new_content = content.replace(&old, &new);
|
||||
let new_content = content.replacen(&old, &new, 1);
|
||||
fs::write(&path, &new_content)?;
|
||||
|
||||
Ok(format!(
|
||||
|
||||
@@ -3,6 +3,8 @@
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
|
||||
pub struct GitCred;
|
||||
|
||||
@@ -49,10 +51,15 @@ impl Tool for GitCred {
|
||||
let url = crate::tools::arg_str(args, "url")?;
|
||||
let username = crate::tools::arg_str(args, "username")?;
|
||||
let password = crate::tools::arg_str(args, "password")?;
|
||||
let _input = format!("url={url}\nusername={username}\npassword={password}\n");
|
||||
let _output = execute_cmd(
|
||||
std::process::Command::new("git").args(["credential", "approve"]),
|
||||
)?;
|
||||
let input = format!("url={url}\nusername={username}\npassword={password}\n");
|
||||
let mut child = Command::new("git")
|
||||
.args(["credential", "approve"])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
stdin.write_all(input.as_bytes())?;
|
||||
}
|
||||
child.wait()?;
|
||||
Ok(format!("Credential stored for {url}"))
|
||||
}
|
||||
"list" => {
|
||||
@@ -63,7 +70,15 @@ impl Tool for GitCred {
|
||||
}
|
||||
"erase" => {
|
||||
let url = crate::tools::arg_str(args, "url")?;
|
||||
let _input = format!("url={url}\n");
|
||||
let input = format!("url={url}\n");
|
||||
let mut child = Command::new("git")
|
||||
.args(["credential", "reject"])
|
||||
.stdin(Stdio::piped())
|
||||
.spawn()?;
|
||||
if let Some(ref mut stdin) = child.stdin {
|
||||
stdin.write_all(input.as_bytes())?;
|
||||
}
|
||||
child.wait()?;
|
||||
Ok(format!("Credential erased for {url}"))
|
||||
}
|
||||
_ => anyhow::bail!("unknown action: {}", action),
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
//! Git operator tool — commit, push, pull, branch operations.
|
||||
|
||||
use crate::tools::shell_filter::git::check_git_destructive;
|
||||
use crate::tools::{execute_cmd, Tool, ToolCtx};
|
||||
use anyhow::Result;
|
||||
use serde_json::{json, Value};
|
||||
@@ -46,6 +47,12 @@ impl Tool for GitOperator {
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// Safety filter: block destructive git operations
|
||||
let cmd_str = format!("git {} {}", operation, extra_args.join(" "));
|
||||
if let Err(e) = check_git_destructive(&cmd_str) {
|
||||
anyhow::bail!("blocked: {e}");
|
||||
}
|
||||
|
||||
let mut cmd = std::process::Command::new("git");
|
||||
cmd.arg(&operation);
|
||||
for arg in &extra_args {
|
||||
|
||||
@@ -61,6 +61,7 @@ impl Tool for Bash {
|
||||
|
||||
if run_in_background {
|
||||
let job = crate::bgbash::job::spawn_bash_job(cmd);
|
||||
crate::bgbash::control::bash_control().register(job.clone());
|
||||
return Ok(format!("Background job: {}", job.id));
|
||||
}
|
||||
|
||||
|
||||
@@ -151,7 +151,8 @@ impl Tool for SpawnPipeline {
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directive": {"type": "string", "description": "Directive for this pipeline stage"}
|
||||
"directive": {"type": "string", "description": "Directive for this pipeline stage"},
|
||||
"access": {"type": "string", "enum": ["read", "write", "full"], "description": "Access tier for this stage"}
|
||||
},
|
||||
"required": ["directive"]
|
||||
},
|
||||
@@ -200,17 +201,28 @@ impl Tool for SpawnPipeline {
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
|
||||
let access_str = stage
|
||||
.get("access")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("full");
|
||||
|
||||
let access = match access_str {
|
||||
"read" => AccessTier::Read,
|
||||
"write" => AccessTier::Write,
|
||||
_ => AccessTier::Full,
|
||||
};
|
||||
|
||||
let subagent_ctx = SubagentContext::new(
|
||||
directive.clone(),
|
||||
ctx.clone(),
|
||||
"full".to_string(),
|
||||
access_str.to_string(),
|
||||
base_url.clone(),
|
||||
api_key.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
|
||||
let result = rt.block_on(async {
|
||||
run_agent(subagent_ctx, &directive, AccessTier::Full, ctx.clone()).await
|
||||
run_agent(subagent_ctx, &directive, access, ctx.clone()).await
|
||||
})?;
|
||||
|
||||
pipeline_result.push_str(&format!("Stage {}: {}\n", i, result));
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
//! Hive-mind cycle execution — run one cycle of parallel nodes.
|
||||
//!
|
||||
//! Flow: load settings → resolve LLM credentials → for each directive,
|
||||
//! build a SubagentContext and call run_agent → collect NodeOutputs.
|
||||
//! Flow: load settings → resolve LLM credentials → run all directives in the
|
||||
//! cycle concurrently via try_join_all → collect Vec<NodeOutput>.
|
||||
|
||||
use anyhow::Result;
|
||||
use futures_util::future::try_join_all;
|
||||
use tracing::info;
|
||||
|
||||
use zesdex_domain::cms::{AppConfigRepository, SettingsRepository};
|
||||
@@ -21,8 +22,9 @@ use crate::workflow::hive_mind::types::{CognitiveCycle, NodeOutput};
|
||||
/// Flow:
|
||||
/// 1. Load `Settings` and `AppConfig` from the store directory.
|
||||
/// 2. Resolve provider, model, base_url, and api_key.
|
||||
/// 3. For each directive → build `SubagentContext` → `run_agent` (Full access).
|
||||
/// 4. Collect `NodeOutput` results.
|
||||
/// 3. Spawn all directives concurrently — each builds a `SubagentContext`
|
||||
/// and calls `run_agent` (Full access).
|
||||
/// 4. `try_join_all` waits for all to complete, then collect `NodeOutput`s.
|
||||
pub async fn execute_cycle(
|
||||
cycle: &CognitiveCycle,
|
||||
tool_ctx: &ToolCtx,
|
||||
@@ -52,25 +54,37 @@ pub async fn execute_cycle(
|
||||
|
||||
let api_key = crate::llm::provider::resolve_api_key(&settings, &app_config);
|
||||
|
||||
let mut outputs = Vec::new();
|
||||
for (i, directive) in cycle.directives.iter().enumerate() {
|
||||
let cycle_index = cycle.index;
|
||||
|
||||
// Run all directives in this cycle concurrently.
|
||||
let handles: Vec<_> = cycle
|
||||
.directives
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, directive)| {
|
||||
let dir = directive.clone();
|
||||
let ctx = SubagentContext::new(
|
||||
directive.clone(),
|
||||
dir.clone(),
|
||||
tool_ctx.clone(),
|
||||
"full".to_string(),
|
||||
base_url.clone(),
|
||||
api_key.clone(),
|
||||
model.clone(),
|
||||
);
|
||||
let tc = tool_ctx.clone();
|
||||
|
||||
let result = run_agent(ctx, directive, AccessTier::Full, tool_ctx.clone()).await?;
|
||||
|
||||
outputs.push(NodeOutput {
|
||||
id: format!("Node-{}-{}", cycle.index, i),
|
||||
directive: directive.clone(),
|
||||
async move {
|
||||
let result = run_agent(ctx, &dir, AccessTier::Full, tc).await?;
|
||||
Ok::<NodeOutput, anyhow::Error>(NodeOutput {
|
||||
id: format!("Node-{}-{}", cycle_index, i),
|
||||
directive: dir,
|
||||
output: result,
|
||||
});
|
||||
})
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
Ok(outputs)
|
||||
let results = try_join_all(handles).await?;
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
|
||||
@@ -14,6 +14,15 @@ impl LiveHiveMind {
|
||||
nodes: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LiveHiveMind {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl LiveHiveMind {
|
||||
|
||||
pub fn set_status(&self, agent_id: &str, status: &str) {
|
||||
if let Ok(mut guard) = self.nodes.lock() {
|
||||
|
||||
@@ -203,7 +203,7 @@ pub async fn refresh_handler(
|
||||
// Verify the refresh token and extract the subject
|
||||
let sub = state
|
||||
.token_service
|
||||
.verify_access_token(&req.refresh_token)
|
||||
.verify_refresh_token(&req.refresh_token)
|
||||
.map_err(|_| ApiError::Unauthorized("Invalid or expired refresh token".into()))?;
|
||||
|
||||
// Generate a fresh token pair
|
||||
|
||||
@@ -109,18 +109,21 @@ pub async fn chat_completions_handler(
|
||||
|
||||
// Call the LLM provider (non-streaming)
|
||||
//
|
||||
// We create a temporary LlmClient with the overridden model so we
|
||||
// don't mutate the shared state's client.
|
||||
// When the requested model matches the shared client we reuse it to
|
||||
// avoid allocating a new HTTP connection. Otherwise we create a
|
||||
// temporary LlmClient with the requested model — the ownership
|
||||
// lives on the stack via `temp_client`.
|
||||
#[allow(unused_assignments)]
|
||||
let mut temp_client: Option<zesdex_infrastructure::llm::LlmClient> = None;
|
||||
let llm_client = if model == state.llm_client.model {
|
||||
// Use the shared client directly
|
||||
&state.llm_client
|
||||
} else {
|
||||
// Create a modified client for this request (only borrows, but
|
||||
// we need to own it for the call — handled below)
|
||||
//
|
||||
// For simplicity, use the shared client with its model. A full
|
||||
// implementation would override the model per request.
|
||||
&state.llm_client
|
||||
temp_client = Some(zesdex_infrastructure::llm::LlmClient::new(
|
||||
state.llm_client.api_key.clone(),
|
||||
model,
|
||||
Some(state.llm_client.base_url.clone()),
|
||||
));
|
||||
temp_client.as_ref().unwrap()
|
||||
};
|
||||
|
||||
let (response, usage) = llm_client
|
||||
|
||||
@@ -103,29 +103,44 @@ pub async fn add_message_handler(
|
||||
))
|
||||
}
|
||||
|
||||
/// DELETE /sessions/:id/conversations/:cid — delete a message from a conversation.
|
||||
/// DELETE /sessions/:id/conversations/:cid — delete a single message by index.
|
||||
///
|
||||
/// Note: the `cid` parameter currently identifies the message index or the
|
||||
/// entire conversation. For simplicity, this deletes the entire conversation
|
||||
/// and creates a fresh one. A more sophisticated implementation would remove
|
||||
/// a single message by index.
|
||||
/// ## Flow
|
||||
///
|
||||
/// 1. Extract session ID and message index from the path.
|
||||
/// 2. Load the conversation.
|
||||
/// 3. Remove the message at `cid` (zero-based index).
|
||||
/// 4. Persist the updated conversation.
|
||||
///
|
||||
/// ## Errors
|
||||
///
|
||||
/// - `404 Not Found` — conversation not found.
|
||||
/// - `400 Bad Request` — session ID is empty or `cid` is not a valid integer.
|
||||
/// - `404 Not Found` — conversation or message index not found.
|
||||
#[tracing::instrument(skip(state))]
|
||||
pub async fn delete_message_handler(
|
||||
State(state): State<Arc<ApiState>>,
|
||||
Path((id, _cid)): Path<(String, String)>,
|
||||
Path((id, cid)): Path<(String, String)>,
|
||||
) -> Result<axum::http::StatusCode, ApiError> {
|
||||
if id.is_empty() {
|
||||
return Err(ApiError::BadRequest("Session ID is required".into()));
|
||||
}
|
||||
|
||||
// Load conversation and clear all messages
|
||||
// Parse the message index from the path
|
||||
let index: usize = cid
|
||||
.parse()
|
||||
.map_err(|_| ApiError::BadRequest(format!("Invalid message index: {cid}")))?;
|
||||
|
||||
// Load conversation and remove the specific message by index
|
||||
let mut conversation = state.conversation_service.load_conversation(&id)?;
|
||||
|
||||
conversation.messages.clear();
|
||||
if index >= conversation.messages.len() {
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Message index {index} out of bounds (max: {})",
|
||||
conversation.messages.len().saturating_sub(1)
|
||||
)));
|
||||
}
|
||||
|
||||
conversation.messages.remove(index);
|
||||
state
|
||||
.conversation_service
|
||||
.save_conversation(&conversation)?;
|
||||
|
||||
@@ -56,10 +56,17 @@ pub fn build_router(state: ApiState) -> Router {
|
||||
// CORS layer — permissive for local daemon / development use
|
||||
let cors = CorsLayer::permissive();
|
||||
|
||||
// JWT auth middleware — validates Bearer tokens on all API routes.
|
||||
// Health and auth endpoints (login/register/refresh) are also
|
||||
// protected; adjust route ordering or add an allow-list inside the
|
||||
// middleware if public access is needed.
|
||||
let jwt_auth = middleware::auth::JwtAuthLayer::new(shared_state.clone());
|
||||
|
||||
// Combine all sub-routers under a versioned prefix
|
||||
Router::new()
|
||||
.nest("/api/v1", api_v1_router())
|
||||
.layer(cors)
|
||||
.layer(jwt_auth)
|
||||
.with_state(shared_state)
|
||||
}
|
||||
|
||||
|
||||
@@ -120,6 +120,19 @@ impl TokenService for JwtTokenService {
|
||||
let claims = verify_token(&self.secret, token)?;
|
||||
Ok(claims.sub)
|
||||
}
|
||||
|
||||
/// Verify a refresh token and return the subject claim.
|
||||
///
|
||||
/// Delegates to the same JWT verification function as access tokens;
|
||||
/// the signature algorithm and secret are shared. Expiry validation
|
||||
/// is handled by the JWT library against the `exp` claim embedded
|
||||
/// in the token payload.
|
||||
fn verify_refresh_token(&self, token: &str) -> anyhow::Result<String> {
|
||||
use zesdex_infrastructure::auth::jwt::verify_token;
|
||||
|
||||
let claims = verify_token(&self.secret, token)?;
|
||||
Ok(claims.sub)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -217,7 +217,7 @@ fn draw(frame: &mut ratatui::Frame, state: &AppStateRest) {
|
||||
|
||||
// Show toasts at the top if present.
|
||||
for toast in &state.misc.toasts {
|
||||
content_lines.push(format!("[{}] {}", format!("{:?}", toast.kind), toast.message));
|
||||
content_lines.push(format!("[{:?}] {}", toast.kind, toast.message));
|
||||
}
|
||||
|
||||
// Show active overlay name.
|
||||
|
||||
@@ -196,7 +196,10 @@ fn handle_tick(state: &mut AppStateRest) {
|
||||
.turn_events
|
||||
.lock()
|
||||
.map(|mut events| events.drain(..).collect())
|
||||
.unwrap_or_default();
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::error!("turn_events mutex poisoned, recovering");
|
||||
e.into_inner().drain(..).collect()
|
||||
});
|
||||
|
||||
for event in drained {
|
||||
use zesdex_infrastructure::TurnEvent;
|
||||
@@ -209,11 +212,9 @@ fn handle_tick(state: &mut AppStateRest) {
|
||||
}
|
||||
handle_system_note(state, message);
|
||||
}
|
||||
TurnEvent::AssistantMessage(msg) => {
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
RoleWrapper::Assistant,
|
||||
msg.content.unwrap_or_default(),
|
||||
));
|
||||
TurnEvent::AssistantMessage(_msg) => {
|
||||
// AssistantMessage is handled via StreamDone to avoid duplicates.
|
||||
state.dirty = true;
|
||||
}
|
||||
TurnEvent::StreamToken(_token) => {
|
||||
state.dirty = true;
|
||||
@@ -229,8 +230,8 @@ fn handle_tick(state: &mut AppStateRest) {
|
||||
}
|
||||
TurnEvent::Usage { tokens_in, tokens_out } => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in += tokens_in;
|
||||
rt.usage.tokens_out += tokens_out;
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
}
|
||||
}
|
||||
TurnEvent::ReviewUsage {
|
||||
@@ -238,8 +239,8 @@ fn handle_tick(state: &mut AppStateRest) {
|
||||
tokens_out,
|
||||
} => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.usage.tokens_in += tokens_in;
|
||||
rt.usage.tokens_out += tokens_out;
|
||||
rt.usage.tokens_in = rt.usage.tokens_in.saturating_add(tokens_in);
|
||||
rt.usage.tokens_out = rt.usage.tokens_out.saturating_add(tokens_out);
|
||||
}
|
||||
}
|
||||
TurnEvent::Done => {
|
||||
@@ -248,6 +249,11 @@ fn handle_tick(state: &mut AppStateRest) {
|
||||
}
|
||||
state.dirty = true;
|
||||
}
|
||||
TurnEvent::ToolResult { .. } => {
|
||||
// Tool result events are logged but the content is already in
|
||||
// the session runtime messages — no transcript push needed here.
|
||||
state.dirty = true;
|
||||
}
|
||||
_ => {
|
||||
state.dirty = true;
|
||||
}
|
||||
@@ -392,11 +398,14 @@ fn handle_abort_turn(state: &mut AppStateRest) {
|
||||
|
||||
fn handle_compact(state: &mut AppStateRest) {
|
||||
tracing::info!("compacting conversation");
|
||||
const KEEP_COUNT: usize = 10;
|
||||
const KEEP_HEAD: usize = 2; // system prompt + tool definitions
|
||||
const KEEP_TAIL: usize = 10; // recent conversation messages
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
if rt.messages.len() > KEEP_COUNT {
|
||||
let keep = rt.messages.split_off(rt.messages.len() - KEEP_COUNT);
|
||||
rt.messages = keep;
|
||||
if rt.messages.len() > KEEP_HEAD + KEEP_TAIL {
|
||||
let tail = rt.messages.split_off(rt.messages.len() - KEEP_TAIL);
|
||||
let head: Vec<_> = rt.messages.drain(..KEEP_HEAD.min(rt.messages.len())).collect();
|
||||
rt.messages = head;
|
||||
rt.messages.extend(tail);
|
||||
let msg_count = rt.messages.len();
|
||||
state.push_transcript(ChatMessageDisplay::new(
|
||||
RoleWrapper::System,
|
||||
@@ -418,14 +427,26 @@ fn handle_open_editor(state: &mut AppStateRest, path: String) {
|
||||
|
||||
fn handle_mcp_add(state: &mut AppStateRest, name: String, command: String) {
|
||||
tracing::info!("adding MCP server: {name}");
|
||||
state.toast_info(format!("MCP server '{name}' registered with command: {command}"));
|
||||
state.mcp_manager.register(&name, &command);
|
||||
state.toast_success(format!("MCP server '{name}' added with command: {command}"));
|
||||
state.dirty = true;
|
||||
}
|
||||
|
||||
fn handle_start_oauth(state: &mut AppStateRest, provider: String) {
|
||||
tracing::info!("starting OAuth for provider: {provider}");
|
||||
state.toast_info(format!("OAuth flow started for {provider}..."));
|
||||
if let Err(e) = webbrowser::open(&format!("https://{provider}.com/auth")) {
|
||||
// Construct provider-specific OAuth authorization URL.
|
||||
let auth_url_owned;
|
||||
let url_to_open = match provider.as_str() {
|
||||
"github" => "https://github.com/login/oauth/authorize",
|
||||
"google" => "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"anthropic" => "https://anthropic.com/api/oauth/authorize",
|
||||
other => {
|
||||
auth_url_owned = format!("https://{other}.com/auth");
|
||||
&auth_url_owned
|
||||
}
|
||||
};
|
||||
if let Err(e) = webbrowser::open(url_to_open) {
|
||||
tracing::warn!("Failed to open browser for OAuth: {e}");
|
||||
state.toast_error(format!("Failed to open browser: {e}"));
|
||||
}
|
||||
@@ -503,11 +524,8 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
// ── Normal mode ───────────────────────────────────────────────────────
|
||||
match key.code {
|
||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
if state.misc.overlay.is_active() {
|
||||
// Always show quit-confirm, regardless of overlay state.
|
||||
vec![Action::QuitConfirm]
|
||||
} else {
|
||||
vec![Action::ForceQuit]
|
||||
}
|
||||
}
|
||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||
vec![Action::CloseOverlay]
|
||||
@@ -531,7 +549,14 @@ pub fn handle_key(key: KeyEvent, state: &mut AppStateRest) -> Vec<Action> {
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if state.misc.overlay.is_active() {
|
||||
vec![Action::CloseOverlay]
|
||||
match state.misc.overlay {
|
||||
Overlay::QuitConfirm => vec![Action::ForceQuit],
|
||||
Overlay::ClearConfirm => vec![Action::SystemNote {
|
||||
kind: "clear".to_string(),
|
||||
message: "cleared".to_string(),
|
||||
}],
|
||||
_ => vec![Action::CloseOverlay],
|
||||
}
|
||||
} else if state.input.autocomplete_visible {
|
||||
// Select the current autocomplete candidate
|
||||
if !state.input.autocomplete_candidates.is_empty() {
|
||||
@@ -749,10 +774,14 @@ pub fn handle_daemon_client(
|
||||
if let Some(text) = state.misc.pending_clipboard_copy.take() {
|
||||
conn.send(&DaemonFrame::ClipboardCopy(text))?;
|
||||
}
|
||||
// Only send state update for valid requests, not on EOF/disconnect.
|
||||
if running {
|
||||
send_daemon_update(&mut conn, state)?;
|
||||
}
|
||||
}
|
||||
None => {
|
||||
running = false;
|
||||
// Don't call send_daemon_update — connection is dead.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,11 @@ pub fn apply_action(state: &mut crate::state::AppStateRest, action: Action) {
|
||||
zesdex_infrastructure::TurnEvent::Error(msg) => {
|
||||
state.toast_error(msg);
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Compacted(msgs) => {
|
||||
if let Some(ref mut rt) = state.session_runtime {
|
||||
rt.messages = msgs;
|
||||
}
|
||||
}
|
||||
zesdex_infrastructure::TurnEvent::Done => {
|
||||
if let Ok(mut flag) = state.turn_in_flight_flag.lock() {
|
||||
*flag = false;
|
||||
|
||||
@@ -788,7 +788,7 @@ pub fn count_tokens(text: &str) -> usize {
|
||||
return bpe.encode_with_special_tokens(text).len();
|
||||
}
|
||||
// Fallback: ~4 chars per token
|
||||
(text.len() + 3) / 4
|
||||
text.len().div_ceil(4)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! Flow: push user message → spawn OS thread → loop: call blocking LLM
|
||||
//! client → execute tool calls → push TurnEvents → repeat until done.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::collections::VecDeque;
|
||||
@@ -53,7 +53,7 @@ pub fn spawn_agent_turn(state: &mut AppStateRest, text: String) {
|
||||
/// The core agent turn — LLM call → tool execution → repeat.
|
||||
fn run_turn(
|
||||
messages: &mut Vec<ChatMessage>,
|
||||
session_dir: &PathBuf,
|
||||
session_dir: &Path,
|
||||
workspace_roots: &[PathBuf],
|
||||
turn_events: &Arc<Mutex<VecDeque<TurnEvent>>>,
|
||||
in_flight: &Arc<Mutex<bool>>,
|
||||
@@ -89,7 +89,7 @@ fn run_turn(
|
||||
messages.insert(0, sys_msg);
|
||||
|
||||
let tool_ctx = ToolCtx::builder()
|
||||
.session_dir(session_dir.clone())
|
||||
.session_dir(session_dir.to_path_buf())
|
||||
.workspaces(workspace_roots.to_vec())
|
||||
.build();
|
||||
|
||||
@@ -173,6 +173,11 @@ fn run_turn(
|
||||
}
|
||||
}
|
||||
|
||||
// Propagate accumulated messages back to session_runtime so the next
|
||||
// turn starts with the full history (assistant replies + tool results).
|
||||
// TurnEvent::Compacted already exists on the enum and is handled in
|
||||
// action.rs to write back to state.session_runtime.messages.
|
||||
push_event(turn_events, TurnEvent::Compacted(messages.clone()));
|
||||
push_event(turn_events, TurnEvent::Done);
|
||||
mark_done(in_flight);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user