feat(token): add refresh token verification to TokenService

feat(bootstrap): create temporary settings and config files to prevent data loss

refactor(edit_log): switch from Vec to VecDeque for efficient memory management

fix(gateway): ensure store directories are created before starting the API server

refactor(bgbash): implement a global singleton for BashControl

feat(auth): enhance session authentication middleware to use SessionRepository

fix(edit_log_repo): update to use VecDeque for in-memory edit log storage

fix(memory_repo): add newline escaping for frontmatter fields

fix(session_lock_repo): improve error handling for lock file operations

fix(bash_tools): prevent path traversal in job_id argument

refactor(delete): enforce empty directory deletion in file system tools

fix(edit): optimize string replacement to only replace the first occurrence

fix(git_cred): improve credential management with piped input to git commands

feat(git_operator): add safety filter to block destructive git operations

fix(shell): register background jobs in Bash control

feat(spawn): add access tier specification for pipeline stages

refactor(hive_mind): run directives concurrently for improved performance

fix(auth): update refresh token verification in the refresh handler

fix(chat): optimize LLM client usage based on model matching

fix(conversations): enhance message deletion to target specific indices

feat(api): add JWT authentication middleware for all API routes

fix(state): implement refresh token verification in JwtTokenService

fix(daemon): improve usage tracking with saturating addition

fix(tui): handle compacted messages in the TUI state management
This commit is contained in:
asepharyana
2026-07-20 12:26:10 +07:00
parent 600ea041ef
commit a04651905f
26 changed files with 497 additions and 453 deletions
+6
View File
@@ -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>;
}
+8 -2
View File
@@ -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");
+5 -3
View File
@@ -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(),
}
}
+1
View File
@@ -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",
+9 -1
View File
@@ -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>>>,
+48 -26
View File
@@ -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 filebacked `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(())
}
+9 -16
View File
@@ -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}'")
}
}
}
+4 -2
View File
@@ -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")
}
+1 -1
View File
@@ -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!(
+20 -5
View File
@@ -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 {
+1
View File
@@ -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));
}
+15 -3
View File
@@ -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 ctx = SubagentContext::new(
directive.clone(),
tool_ctx.clone(),
"full".to_string(),
base_url.clone(),
api_key.clone(),
model.clone(),
);
let cycle_index = cycle.index;
let result = run_agent(ctx, directive, AccessTier::Full, tool_ctx.clone()).await?;
// 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(
dir.clone(),
tool_ctx.clone(),
"full".to_string(),
base_url.clone(),
api_key.clone(),
model.clone(),
);
let tc = tool_ctx.clone();
outputs.push(NodeOutput {
id: format!("Node-{}-{}", cycle.index, i),
directive: directive.clone(),
output: result,
});
}
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)
}
+1 -1
View File
@@ -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
+12 -9
View File
@@ -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)?;
+7
View File
@@ -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)
}
+13
View File
@@ -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)
}
}
// ---------------------------------------------------------------------------
+14 -10
View File
@@ -230,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 {
@@ -239,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 => {
@@ -524,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() {
vec![Action::QuitConfirm]
} else {
vec![Action::ForceQuit]
}
// Always show quit-confirm, regardless of overlay state.
vec![Action::QuitConfirm]
}
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
vec![Action::CloseOverlay]
@@ -552,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() {
+5
View File
@@ -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;
+5
View File
@@ -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);
}