Refactor CMS and IAM modules: restructure presentation and command layers
- Removed HTTP adapter module from CMS infrastructure. - Updated CMS infrastructure module to exclude HTTP. - Introduced presentation layer in CMS with DTOs and handlers for REST API. - Added command types for CMS domain operations to encapsulate input data. - Created typed error handling for CMS presentation layer. - Implemented handlers for CMS REST API endpoints. - Removed HTTP DTOs and handlers from IAM infrastructure. - Introduced command types for IAM domain operations. - Created presentation layer in IAM with DTOs and handlers for OAuth flow. - Implemented typed error handling for IAM presentation layer.
This commit is contained in:
@@ -139,7 +139,7 @@ pub(super) fn run_agent_turn(
|
||||
"[hive-mind] the Hive stirs — Core Intelligence compiling a cognitive cycle plan"
|
||||
);
|
||||
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "pipeline".to_string(),
|
||||
message: HIVE_MIND_KICKOFF_NOTE.to_string(),
|
||||
});
|
||||
@@ -206,7 +206,7 @@ pub(super) fn run_agent_turn(
|
||||
let response_chars = reply.content.as_deref().map_or(0, str::len);
|
||||
tok_out = ((response_chars / 4).max(1)).cast_or(1u64);
|
||||
}
|
||||
push_event(&events_q, TurnEvent::Usage {
|
||||
push_event(events_q, TurnEvent::Usage {
|
||||
tokens_in: tok_in,
|
||||
tokens_out: tok_out,
|
||||
});
|
||||
@@ -236,7 +236,7 @@ pub(super) fn run_agent_turn(
|
||||
.collect::<Vec<String>>()
|
||||
.join(", ");
|
||||
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "pipeline".to_string(),
|
||||
message: format!(
|
||||
"The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...",
|
||||
@@ -279,13 +279,13 @@ pub(super) fn run_agent_turn(
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
|
||||
msgs.push(pipeline_msg);
|
||||
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "pipeline".to_string(),
|
||||
message:
|
||||
"The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..."
|
||||
.to_string(),
|
||||
});
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "hive_mind_converged".to_string(),
|
||||
message: String::new(),
|
||||
});
|
||||
@@ -308,7 +308,7 @@ pub(super) fn run_agent_turn(
|
||||
// phase, which previously ran unchecked for minutes at a time.
|
||||
if crate::app::util::abort::is_aborted_direct(&tc.abort_flag)
|
||||
{
|
||||
push_event(&events_q, TurnEvent::Error("Generation aborted by user".to_string()));
|
||||
push_event(events_q, TurnEvent::Error("Generation aborted by user".to_string()));
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@@ -348,7 +348,7 @@ pub(super) fn run_agent_turn(
|
||||
|
||||
// Dispatch the compacted messages to the main thread so the local session history
|
||||
// is permanently compacted and doesn't trigger shaping again immediately on next turn.
|
||||
push_event(&events_q, TurnEvent::Compacted(compacted.clone()));
|
||||
push_event(events_q, TurnEvent::Compacted(compacted.clone()));
|
||||
|
||||
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||
msgs.clone_from(&compacted);
|
||||
@@ -417,7 +417,7 @@ pub(super) fn run_agent_turn(
|
||||
);
|
||||
|
||||
if reasoning_started && !reasoning_ended {
|
||||
push_event(&events_q, TurnEvent::StreamToken(
|
||||
push_event(events_q, TurnEvent::StreamToken(
|
||||
"\n</think>\n\n".to_string(),
|
||||
));
|
||||
}
|
||||
@@ -429,7 +429,7 @@ pub(super) fn run_agent_turn(
|
||||
if crate::app::util::abort::is_aborted_direct(&tc.abort_flag)
|
||||
|| e.to_string().contains("aborted")
|
||||
{
|
||||
push_event(&events_q, TurnEvent::Error(
|
||||
push_event(events_q, TurnEvent::Error(
|
||||
"Generation aborted by user".to_string(),
|
||||
));
|
||||
return Ok(());
|
||||
@@ -458,7 +458,7 @@ pub(super) fn run_agent_turn(
|
||||
Edit todo.md manually or ask me to focus on specific items.",
|
||||
);
|
||||
}
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: format!(
|
||||
"Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"
|
||||
@@ -484,7 +484,7 @@ pub(super) fn run_agent_turn(
|
||||
let response_chars = response.content.as_deref().map_or(0, str::len);
|
||||
tok_out = ((response_chars / 4).max(1)).cast_or(1u64);
|
||||
}
|
||||
push_event(&events_q, TurnEvent::Usage {
|
||||
push_event(events_q, TurnEvent::Usage {
|
||||
tokens_in: tok_in,
|
||||
tokens_out: tok_out,
|
||||
});
|
||||
@@ -558,7 +558,7 @@ pub(super) fn run_agent_turn(
|
||||
for (tool_call, tool_name, args, output, is_error, is_edit) in results_vec {
|
||||
if crate::app::util::abort::is_aborted_direct(&tc.abort_flag)
|
||||
{
|
||||
push_event(&events_q, TurnEvent::Error(
|
||||
push_event(events_q, TurnEvent::Error(
|
||||
"Turn aborted by user".to_string(),
|
||||
));
|
||||
return Ok(());
|
||||
@@ -629,7 +629,7 @@ pub(super) fn run_agent_turn(
|
||||
.and_then(|v| v.as_str())
|
||||
.map(std::string::ToString::to_string);
|
||||
|
||||
push_event(&events_q, TurnEvent::ToolResult {
|
||||
push_event(events_q, TurnEvent::ToolResult {
|
||||
tool_call_id: tool_call.id.clone(),
|
||||
tool_name: tool_name.clone(),
|
||||
output: output.clone(),
|
||||
@@ -646,9 +646,9 @@ pub(super) fn run_agent_turn(
|
||||
if !content.is_empty() {
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
||||
if stream_started {
|
||||
push_event(&events_q, TurnEvent::StreamDone(response.clone()));
|
||||
push_event(events_q, TurnEvent::StreamDone(response.clone()));
|
||||
} else {
|
||||
push_event(&events_q, TurnEvent::AssistantMessage(response.clone()));
|
||||
push_event(events_q, TurnEvent::AssistantMessage(response.clone()));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -666,7 +666,7 @@ pub(super) fn run_agent_turn(
|
||||
if has_unfinished {
|
||||
todo_retry_count += 1;
|
||||
if todo_retry_count > MAX_TODO_RETRIES {
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: format!("Giving up after {MAX_TODO_RETRIES} retries — some todo items remain unfinished. Edit todo.md manually or ask again."),
|
||||
});
|
||||
@@ -677,7 +677,7 @@ pub(super) fn run_agent_turn(
|
||||
let msg = ChatMessage::system(sys_text);
|
||||
archive_message(tc.db.as_ref(), &tc.session_id, &msg);
|
||||
msgs.push(msg);
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "task_retry".to_string(),
|
||||
message: sys_text_clone,
|
||||
});
|
||||
@@ -701,7 +701,7 @@ pub(super) fn run_agent_turn(
|
||||
|
||||
if let Some((total_edits_this_turn, prev_edits, el)) = &total_edits_this_turn {
|
||||
if *total_edits_this_turn > 0 {
|
||||
push_event(&events_q, TurnEvent::SystemNote {
|
||||
push_event(events_q, TurnEvent::SystemNote {
|
||||
kind: "edits".to_string(),
|
||||
message: total_edits_this_turn.to_string(),
|
||||
});
|
||||
@@ -738,7 +738,7 @@ pub(super) fn run_agent_turn(
|
||||
total_edits_this_turn.as_ref().map_or(0, |(c, _, _)| *c),
|
||||
);
|
||||
|
||||
push_event(&events_q, TurnEvent::Done);
|
||||
push_event(events_q, TurnEvent::Done);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
//! Command types for CMS domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / `XxxPatch` pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use super::settings::InternetMode;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Settings
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Partial update command for `Settings`.
|
||||
///
|
||||
/// Every field is `Option`al — only non-`None` fields are applied to the
|
||||
/// existing settings instance. Use `apply_to()` to merge into a `Settings`
|
||||
/// value.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SettingsPatch {
|
||||
/// Override the internet access mode.
|
||||
pub internet_mode: Option<String>,
|
||||
/// Override the active provider name.
|
||||
pub provider: Option<String>,
|
||||
/// Override the active model name.
|
||||
pub model: Option<String>,
|
||||
/// Replace the entire API-keys map.
|
||||
pub api_keys: Option<HashMap<String, String>>,
|
||||
/// Override the max tokens for completions.
|
||||
pub max_tokens: Option<Option<u32>>,
|
||||
/// Override the temperature for completions.
|
||||
pub temperature: Option<Option<f32>>,
|
||||
/// Override the review max lessons per run.
|
||||
pub review_max_lessons_per_run: Option<usize>,
|
||||
/// Override the adaptive review max skip count.
|
||||
pub adaptive_review_max_skip: Option<u32>,
|
||||
/// Override the verify shell command.
|
||||
pub verify_command: Option<Option<String>>,
|
||||
/// Override the verify timeout in milliseconds.
|
||||
pub verify_timeout_ms: Option<u64>,
|
||||
/// Override the max concurrency for workflow execution.
|
||||
pub workflow_max_concurrency: Option<usize>,
|
||||
/// Override the review-enabled flag.
|
||||
pub review_enabled: Option<bool>,
|
||||
/// Override the session-archive-enabled flag.
|
||||
pub session_archive_enabled: Option<bool>,
|
||||
/// Override the LSP auto-provision flag.
|
||||
pub lsp_auto_provision: Option<bool>,
|
||||
/// Override the list of LSP-managed languages.
|
||||
pub lsp_languages: Option<Vec<String>>,
|
||||
/// Override the hive-mind node timeout in milliseconds.
|
||||
pub hive_mind_node_timeout_ms: Option<u64>,
|
||||
}
|
||||
|
||||
impl SettingsPatch {
|
||||
/// Merge this patch into `settings`, overwriting each non-`None` field.
|
||||
///
|
||||
/// Flow: for each optional field, if `Some`, assign it to the target.
|
||||
///
|
||||
/// ## Errors
|
||||
/// Returns `Err` with a message if `internet_mode` is set to an
|
||||
/// unrecognised value.
|
||||
pub fn apply_to(&self, settings: &mut super::settings::Settings) -> Result<(), String> {
|
||||
if let Some(ref val) = self.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => InternetMode::Off,
|
||||
"ReadOnly" => InternetMode::ReadOnly,
|
||||
"Full" => InternetMode::Full,
|
||||
_ => return Err(format!("invalid internet_mode '{val}'; expected Off, ReadOnly, or Full")),
|
||||
};
|
||||
}
|
||||
if let Some(ref val) = self.provider {
|
||||
settings.provider = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.model {
|
||||
settings.model = val.clone();
|
||||
}
|
||||
if let Some(ref val) = self.api_keys {
|
||||
settings.api_keys = val.clone();
|
||||
}
|
||||
if let Some(val) = self.max_tokens {
|
||||
settings.max_tokens = val;
|
||||
}
|
||||
if let Some(val) = self.temperature {
|
||||
settings.temperature = val;
|
||||
}
|
||||
if let Some(val) = self.review_max_lessons_per_run {
|
||||
settings.review_max_lessons_per_run = val;
|
||||
}
|
||||
if let Some(val) = self.adaptive_review_max_skip {
|
||||
settings.adaptive_review_max_skip = val;
|
||||
}
|
||||
if let Some(ref val) = self.verify_command {
|
||||
settings.verify_command = val.clone();
|
||||
}
|
||||
if let Some(val) = self.verify_timeout_ms {
|
||||
settings.verify_timeout_ms = val;
|
||||
}
|
||||
if let Some(val) = self.workflow_max_concurrency {
|
||||
settings.workflow_max_concurrency = val;
|
||||
}
|
||||
if let Some(val) = self.review_enabled {
|
||||
settings.flags.review_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.session_archive_enabled {
|
||||
settings.flags.session_archive_enabled = val;
|
||||
}
|
||||
if let Some(val) = self.lsp_auto_provision {
|
||||
settings.flags.lsp_auto_provision = val;
|
||||
}
|
||||
if let Some(ref val) = self.lsp_languages {
|
||||
settings.lsp_languages = val.clone();
|
||||
}
|
||||
if let Some(val) = self.hive_mind_node_timeout_ms {
|
||||
settings.hive_mind_node_timeout_ms = val;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Memory
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Command to create a new memory entry.
|
||||
///
|
||||
/// All required fields are non-optional; optional fields use `Option`
|
||||
/// and default to sensible values (empty or the service default).
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewMemory {
|
||||
/// Unique name / slug for the memory.
|
||||
pub name: String,
|
||||
/// One-line summary of what the memory captures.
|
||||
pub description: String,
|
||||
/// The full memory content.
|
||||
pub content: String,
|
||||
/// Category kind (defaults to "reference" in the handler).
|
||||
pub kind: Option<String>,
|
||||
/// Outcome of the remembered action.
|
||||
pub outcome: Option<String>,
|
||||
/// Lifecycle stage (defaults to "new" in the handler).
|
||||
pub lifecycle: Option<String>,
|
||||
/// Scope context for the memory.
|
||||
pub scope: Option<String>,
|
||||
/// Code snippet captured before the action.
|
||||
pub before_snippet: Option<String>,
|
||||
/// Code snippet captured after the action.
|
||||
pub after_snippet: Option<String>,
|
||||
/// Source provenances (files, conversations, etc.).
|
||||
pub provenances: Option<Vec<String>>,
|
||||
}
|
||||
@@ -19,6 +19,7 @@
|
||||
//! They contain no I/O, no framework imports, and no side effects.
|
||||
|
||||
pub mod app_config;
|
||||
pub mod commands;
|
||||
pub mod conversation;
|
||||
pub mod edit_log;
|
||||
pub mod error;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
//! HTTP adapter — handler functions and DTOs for the CMS REST API.
|
||||
//!
|
||||
//! Provides hyper-based request handlers and serialisation types for
|
||||
//! the CMS HTTP endpoints. Handlers receive domain service trait objects
|
||||
//! via dependency injection (Arc-wrapped trait objects) and translate
|
||||
//! between HTTP request/response formats and domain types.
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `dto` — request/response DTO types (JSON serialisation)
|
||||
//! - `handlers` — hyper request handler functions
|
||||
//!
|
||||
//! ## Endpoints
|
||||
//! - `GET /settings` — load current application settings
|
||||
//! - `PUT /settings` — update application settings
|
||||
//! - `GET /memories` — list all memory slugs
|
||||
//! - `POST /memories` — create a new memory entry
|
||||
//! - `POST /memories/{name}` — (future) update memory
|
||||
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
|
||||
pub use dto::{
|
||||
ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse,
|
||||
SettingsUpdateRequest,
|
||||
};
|
||||
pub use handlers::{
|
||||
handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings,
|
||||
};
|
||||
@@ -6,7 +6,5 @@
|
||||
//!
|
||||
//! ## Sub-modules
|
||||
//! - `persistence` — file-based repository implementations (JSON, markdown, SQLite)
|
||||
//! - `http` — hyper-based HTTP API handlers and DTO types
|
||||
|
||||
pub mod http;
|
||||
pub mod persistence;
|
||||
|
||||
@@ -23,3 +23,4 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
pub mod presentation;
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
//! Typed presentation-layer error type for the CMS crate.
|
||||
//!
|
||||
//! `AppError` replaces bare `anyhow::Result` in handler signatures with a
|
||||
//! structured enum that callers can match on for status-code selection
|
||||
//! and structured error responses.
|
||||
//!
|
||||
//! `From<ServiceError>` auto-converts domain errors so handler code uses
|
||||
//! the `?` operator throughout.
|
||||
//!
|
||||
//! # Variants
|
||||
//!
|
||||
//! - `BadRequest` — invalid input, validation failure
|
||||
//! - `NotFound` — resource not found
|
||||
//! - `Conflict` — resource already exists
|
||||
//! - `Internal` — unexpected errors translated to a generic message
|
||||
|
||||
use crate::domain::error::ServiceError;
|
||||
|
||||
/// Typed presentation-layer error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// The request was malformed or contained invalid data.
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
/// The requested resource was not found.
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
/// The request conflicts with the current state.
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
/// An unexpected internal error occurred.
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<ServiceError> for AppError {
|
||||
fn from(e: ServiceError) -> Self {
|
||||
match e {
|
||||
ServiceError::Repository(repo_err) => match repo_err {
|
||||
zesdex_utils::Error::NotFound(msg) => AppError::NotFound(msg),
|
||||
zesdex_utils::Error::Conflict(msg) => AppError::Conflict(msg),
|
||||
_ => AppError::Internal(repo_err.to_string()),
|
||||
},
|
||||
ServiceError::InvalidInput(msg) => AppError::BadRequest(msg),
|
||||
ServiceError::Other(msg) => AppError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
+61
-87
@@ -17,14 +17,15 @@
|
||||
//! The caller (e.g. a hyper `Service`) is responsible for serialising the
|
||||
//! response and setting HTTP status codes.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use tracing::instrument;
|
||||
|
||||
use crate::domain::commands::{NewMemory, SettingsPatch};
|
||||
use crate::domain::memory::Memory;
|
||||
use crate::domain::service::{MemoryService, SettingsService};
|
||||
use crate::domain::settings::Settings;
|
||||
|
||||
use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest};
|
||||
use super::error::AppError;
|
||||
|
||||
/// Handle `GET /settings`
|
||||
///
|
||||
@@ -32,8 +33,8 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings
|
||||
///
|
||||
/// Flow: load settings from service → convert to DTO → return.
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
||||
let settings = service.load_settings().context("failed to load settings")?;
|
||||
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse, AppError> {
|
||||
let settings = service.load_settings()?;
|
||||
Ok(SettingsResponse::from(settings))
|
||||
}
|
||||
|
||||
@@ -42,83 +43,44 @@ pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsRe
|
||||
/// Applies a partial update from `req` to the current settings, persists
|
||||
/// the result, and returns the updated `SettingsResponse`.
|
||||
///
|
||||
/// Flow: load current settings → apply each optional field → save → return DTO.
|
||||
/// Flow: build `SettingsPatch` from DTO → apply to current settings → save → return DTO.
|
||||
///
|
||||
/// ## Validation
|
||||
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
|
||||
/// - `internet_mode` is validated by `SettingsPatch::apply_to`.
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_update_settings<S: SettingsService>(
|
||||
service: &S,
|
||||
req: SettingsUpdateRequest,
|
||||
) -> Result<SettingsResponse> {
|
||||
) -> Result<SettingsResponse, AppError> {
|
||||
// Build the domain patch command from the wire DTO
|
||||
let patch = SettingsPatch {
|
||||
internet_mode: req.internet_mode,
|
||||
provider: req.provider,
|
||||
model: req.model,
|
||||
api_keys: req.api_keys,
|
||||
max_tokens: req.max_tokens,
|
||||
temperature: req.temperature,
|
||||
review_max_lessons_per_run: req.review_max_lessons_per_run,
|
||||
adaptive_review_max_skip: req.adaptive_review_max_skip,
|
||||
verify_command: req.verify_command,
|
||||
verify_timeout_ms: req.verify_timeout_ms,
|
||||
workflow_max_concurrency: req.workflow_max_concurrency,
|
||||
review_enabled: req.review_enabled,
|
||||
session_archive_enabled: req.session_archive_enabled,
|
||||
lsp_auto_provision: req.lsp_auto_provision,
|
||||
lsp_languages: req.lsp_languages,
|
||||
hive_mind_node_timeout_ms: req.hive_mind_node_timeout_ms,
|
||||
};
|
||||
|
||||
// Load current settings as baseline for partial update
|
||||
let mut settings: Settings = service
|
||||
.load_settings()
|
||||
.context("failed to load current settings for update")?;
|
||||
let mut settings: Settings = service.load_settings()?;
|
||||
|
||||
// Apply each optional field from the request (None = skip, Some = overwrite)
|
||||
if let Some(val) = req.internet_mode {
|
||||
settings.internet_mode = match val.as_str() {
|
||||
"Off" => crate::domain::settings::InternetMode::Off,
|
||||
"ReadOnly" => crate::domain::settings::InternetMode::ReadOnly,
|
||||
"Full" => crate::domain::settings::InternetMode::Full,
|
||||
_ => {
|
||||
return Err(anyhow::anyhow!(
|
||||
"invalid internet_mode '{}'; expected Off, ReadOnly, or Full",
|
||||
val
|
||||
));
|
||||
}
|
||||
};
|
||||
}
|
||||
if let Some(val) = req.provider {
|
||||
settings.provider = val;
|
||||
}
|
||||
if let Some(val) = req.model {
|
||||
settings.model = val;
|
||||
}
|
||||
if let Some(val) = req.api_keys {
|
||||
settings.api_keys = val;
|
||||
}
|
||||
if let Some(val) = req.max_tokens {
|
||||
settings.max_tokens = val;
|
||||
}
|
||||
if let Some(val) = req.temperature {
|
||||
settings.temperature = val;
|
||||
}
|
||||
if let Some(val) = req.review_max_lessons_per_run {
|
||||
settings.review_max_lessons_per_run = val;
|
||||
}
|
||||
if let Some(val) = req.adaptive_review_max_skip {
|
||||
settings.adaptive_review_max_skip = val;
|
||||
}
|
||||
if let Some(val) = req.verify_command {
|
||||
settings.verify_command = val;
|
||||
}
|
||||
if let Some(val) = req.verify_timeout_ms {
|
||||
settings.verify_timeout_ms = val;
|
||||
}
|
||||
if let Some(val) = req.workflow_max_concurrency {
|
||||
settings.workflow_max_concurrency = val;
|
||||
}
|
||||
if let Some(val) = req.review_enabled {
|
||||
settings.flags.review_enabled = val;
|
||||
}
|
||||
if let Some(val) = req.session_archive_enabled {
|
||||
settings.flags.session_archive_enabled = val;
|
||||
}
|
||||
if let Some(val) = req.lsp_auto_provision {
|
||||
settings.flags.lsp_auto_provision = val;
|
||||
}
|
||||
if let Some(val) = req.lsp_languages {
|
||||
settings.lsp_languages = val;
|
||||
}
|
||||
if let Some(val) = req.hive_mind_node_timeout_ms {
|
||||
settings.hive_mind_node_timeout_ms = val;
|
||||
}
|
||||
// Apply the patch via the domain command
|
||||
patch
|
||||
.apply_to(&mut settings)
|
||||
.map_err(AppError::BadRequest)?;
|
||||
|
||||
service
|
||||
.save_settings(&settings)
|
||||
.context("failed to save updated settings")?;
|
||||
service.save_settings(&settings)?;
|
||||
|
||||
Ok(SettingsResponse::from(settings))
|
||||
}
|
||||
@@ -131,12 +93,12 @@ pub fn handle_update_settings<S: SettingsService>(
|
||||
///
|
||||
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
||||
let slugs = service.list_memories().context("failed to list memories")?;
|
||||
pub fn handle_list_memories<M: MemoryService>(
|
||||
service: &M,
|
||||
) -> Result<Vec<MemoryResponse>, AppError> {
|
||||
let slugs = service.list_memories()?;
|
||||
|
||||
// We can't load individual memories without a load_memory method on the
|
||||
// service. For now, list returns summary info; callers who need full
|
||||
// content use a separate endpoint. Return minimal responses keyed by slug.
|
||||
// Return minimal responses keyed by slug.
|
||||
let responses: Vec<MemoryResponse> = slugs
|
||||
.into_iter()
|
||||
.map(|slug| MemoryResponse {
|
||||
@@ -170,26 +132,38 @@ pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryR
|
||||
pub fn handle_create_memory<M: MemoryService>(
|
||||
service: &M,
|
||||
req: MemoryCreateRequest,
|
||||
) -> Result<MemoryResponse> {
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let memory = Memory {
|
||||
) -> Result<MemoryResponse, AppError> {
|
||||
// Build the domain command from the wire DTO
|
||||
let cmd = NewMemory {
|
||||
name: req.name,
|
||||
description: req.description,
|
||||
content: req.content,
|
||||
kind: req.kind.unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
kind: req.kind,
|
||||
outcome: req.outcome,
|
||||
lifecycle: req.lifecycle.unwrap_or_else(|| "new".to_string()),
|
||||
lifecycle: req.lifecycle,
|
||||
scope: req.scope,
|
||||
before_snippet: req.before_snippet,
|
||||
after_snippet: req.after_snippet,
|
||||
provenances: req.provenances.unwrap_or_default(),
|
||||
provenances: req.provenances,
|
||||
};
|
||||
|
||||
service
|
||||
.save_memory(&memory)
|
||||
.context("failed to save memory")?;
|
||||
let now = chrono::Utc::now().timestamp();
|
||||
let memory = Memory {
|
||||
name: cmd.name,
|
||||
description: cmd.description,
|
||||
content: cmd.content,
|
||||
kind: cmd.kind.unwrap_or_else(|| "reference".to_string()),
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
outcome: cmd.outcome,
|
||||
lifecycle: cmd.lifecycle.unwrap_or_else(|| "new".to_string()),
|
||||
scope: cmd.scope,
|
||||
before_snippet: cmd.before_snippet,
|
||||
after_snippet: cmd.after_snippet,
|
||||
provenances: cmd.provenances.unwrap_or_default(),
|
||||
};
|
||||
|
||||
service.save_memory(&memory)?;
|
||||
|
||||
Ok(MemoryResponse::from(memory))
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
//! HTTP presentation layer — handler functions and DTOs for the CMS crate.
|
||||
//!
|
||||
//! This is the outermost ring of the Clean Architecture onion. Handlers receive
|
||||
//! domain service trait references via generics and translate between
|
||||
//! request/response DTOs and domain types. They have **no dependency** on
|
||||
//! any HTTP framework — callers (IPC server, Axum, etc.) are responsible for
|
||||
//! mapping results into actual HTTP responses.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`dto`] — request/response DTO types (JSON serialisation)
|
||||
//! - [`handlers`] — handler functions that accept service trait refs + DTOs
|
||||
//! - [`error`] — typed presentation-layer error type
|
||||
//!
|
||||
//! # Dependency rule
|
||||
//!
|
||||
//! presentation → application → domain
|
||||
//! presentation may also depend on infrastructure for wiring/composition.
|
||||
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
|
||||
pub use dto::{
|
||||
ConversationResponse, MemoryCreateRequest, MemoryResponse, SettingsResponse,
|
||||
SettingsUpdateRequest,
|
||||
};
|
||||
pub use error::AppError;
|
||||
pub use handlers::{
|
||||
handle_create_memory, handle_get_settings, handle_list_memories, handle_update_settings,
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
//! Command types for IAM domain operations.
|
||||
//!
|
||||
//! Following the `NewXxx` / command pattern from clean architecture,
|
||||
//! these types encapsulate the input data for create/update operations
|
||||
//! on domain entities. They decouple presentation DTOs from the entity
|
||||
//! mutation surface and provide a clear boundary for validation.
|
||||
|
||||
/// Command to create a new session.
|
||||
///
|
||||
/// Carries only the data needed to construct a session entity — the
|
||||
/// service generates the UUID and timestamp internally.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct NewSession {
|
||||
/// Human-readable session title.
|
||||
pub title: String,
|
||||
}
|
||||
|
||||
impl From<String> for NewSession {
|
||||
fn from(title: String) -> Self {
|
||||
Self { title }
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&str> for NewSession {
|
||||
fn from(title: &str) -> Self {
|
||||
Self {
|
||||
title: title.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@
|
||||
//! - [`service`] — Trait definitions: `OAuthService`, `SessionService`
|
||||
//! - [`session`] — `Session` entity (IAM wrapper around `zesdex_entities::Session`)
|
||||
|
||||
pub mod commands;
|
||||
pub mod error;
|
||||
pub mod oauth;
|
||||
pub mod repository;
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
//! HTTP adapter layer for OAuth callback handling.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`dto`] — Request/response DTOs for the loopback endpoint
|
||||
//! - [`handlers`] — HTTP handler that validates state and extracts `?code=`
|
||||
|
||||
pub mod dto;
|
||||
pub mod handlers;
|
||||
@@ -5,12 +5,10 @@
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`http`] — HTTP server, DTOs, handlers for the OAuth callback
|
||||
//! - [`oauth_loopback`] — Loopback HTTP server to receive the OAuth redirect
|
||||
//! - [`persistence`] — Filesystem-backed repositories (JSON + PID locks)
|
||||
//! - [`rng`] — System random token / UUID generation
|
||||
|
||||
pub mod http;
|
||||
pub mod oauth_loopback;
|
||||
pub mod persistence;
|
||||
pub mod rng;
|
||||
|
||||
@@ -12,3 +12,4 @@
|
||||
pub mod application;
|
||||
pub mod domain;
|
||||
pub mod infrastructure;
|
||||
pub mod presentation;
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
//! Typed presentation-layer error type for the IAM crate.
|
||||
//!
|
||||
//! `AppError` replaces bare `anyhow::Result` in handler signatures with a
|
||||
//! structured enum that callers can match on for status-code selection
|
||||
//! and structured error responses.
|
||||
//!
|
||||
//! `From<ServiceError>` auto-converts domain errors so handler code uses
|
||||
//! the `?` operator throughout.
|
||||
//!
|
||||
//! # Variants
|
||||
//!
|
||||
//! - `BadRequest` — invalid input, validation failure, OAuth state mismatch
|
||||
//! - `NotFound` — resource (session, token) not found
|
||||
//! - `Conflict` — resource already exists (e.g. duplicate session)
|
||||
//! - `Internal` — unexpected errors translated to a generic message
|
||||
|
||||
use crate::domain::error::ServiceError;
|
||||
|
||||
/// Typed presentation-layer error.
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum AppError {
|
||||
/// The request was malformed or contained invalid data.
|
||||
#[error("Bad request: {0}")]
|
||||
BadRequest(String),
|
||||
/// The requested resource was not found.
|
||||
#[error("Not found: {0}")]
|
||||
NotFound(String),
|
||||
/// The request conflicts with the current state.
|
||||
#[error("Conflict: {0}")]
|
||||
Conflict(String),
|
||||
/// An unexpected internal error occurred.
|
||||
#[error("Internal error: {0}")]
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
impl From<ServiceError> for AppError {
|
||||
fn from(e: ServiceError) -> Self {
|
||||
match e {
|
||||
ServiceError::Repository(repo_err) => match repo_err {
|
||||
zesdex_utils::Error::NotFound(msg) => AppError::NotFound(msg),
|
||||
zesdex_utils::Error::Conflict(msg) => AppError::Conflict(msg),
|
||||
_ => AppError::Internal(repo_err.to_string()),
|
||||
},
|
||||
ServiceError::InvalidConfig(msg) => AppError::BadRequest(msg),
|
||||
ServiceError::StateMismatch => {
|
||||
AppError::BadRequest("OAuth state mismatch — possible CSRF attack".into())
|
||||
}
|
||||
ServiceError::OAuthProvider(msg) => AppError::Internal(msg),
|
||||
ServiceError::Other(msg) => AppError::Internal(msg),
|
||||
}
|
||||
}
|
||||
}
|
||||
+17
-9
@@ -19,24 +19,27 @@ use tracing::instrument;
|
||||
use zesdex_entities::domain::auth::SessionId;
|
||||
|
||||
use crate::domain::service::{OAuthService, SessionService};
|
||||
use crate::infrastructure::http::dto::{
|
||||
use crate::presentation::dto::{
|
||||
CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse,
|
||||
OAuthTokenResponse, SessionListResponse, SessionResponse,
|
||||
};
|
||||
use crate::presentation::error::AppError;
|
||||
|
||||
/// Handle a create-session request.
|
||||
#[instrument(skip(service), fields(title = %req.title))]
|
||||
pub fn handle_create_session<S: SessionService>(
|
||||
service: &S,
|
||||
req: CreateSessionRequest,
|
||||
) -> anyhow::Result<SessionResponse> {
|
||||
) -> Result<SessionResponse, AppError> {
|
||||
let session = service.create_session(&req.title)?;
|
||||
Ok(SessionResponse { session })
|
||||
}
|
||||
|
||||
/// Handle a list-sessions request.
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_list_sessions<S: SessionService>(service: &S) -> anyhow::Result<SessionListResponse> {
|
||||
pub fn handle_list_sessions<S: SessionService>(
|
||||
service: &S,
|
||||
) -> Result<SessionListResponse, AppError> {
|
||||
let sessions = service.list_all()?;
|
||||
let total = sessions.len();
|
||||
Ok(SessionListResponse { sessions, total })
|
||||
@@ -44,9 +47,12 @@ pub fn handle_list_sessions<S: SessionService>(service: &S) -> anyhow::Result<Se
|
||||
|
||||
/// Handle an archive-session request.
|
||||
#[instrument(skip(service), fields(session_id = %id))]
|
||||
pub fn handle_archive_session<S: SessionService>(service: &S, id: &str) -> anyhow::Result<()> {
|
||||
pub fn handle_archive_session<S: SessionService>(
|
||||
service: &S,
|
||||
id: &str,
|
||||
) -> Result<(), AppError> {
|
||||
let sid = SessionId::new(id)
|
||||
.map_err(|e| anyhow::anyhow!("invalid session id: {e}"))?;
|
||||
.map_err(|e| AppError::BadRequest(format!("invalid session id: {e}")))?;
|
||||
service.archive_session(sid)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -56,7 +62,7 @@ pub fn handle_archive_session<S: SessionService>(service: &S, id: &str) -> anyho
|
||||
pub fn handle_start_oauth<O: OAuthService>(
|
||||
service: &O,
|
||||
req: OAuthStartRequest,
|
||||
) -> anyhow::Result<OAuthStartResponse> {
|
||||
) -> Result<OAuthStartResponse, AppError> {
|
||||
let (auth_url, state) = service.start_flow(&req.config, &req.redirect_uri)?;
|
||||
Ok(OAuthStartResponse { auth_url, state })
|
||||
}
|
||||
@@ -66,16 +72,18 @@ pub fn handle_start_oauth<O: OAuthService>(
|
||||
pub fn handle_complete_oauth<O: OAuthService>(
|
||||
service: &O,
|
||||
req: OAuthCompleteRequest,
|
||||
) -> anyhow::Result<OAuthTokenResponse> {
|
||||
) -> Result<OAuthTokenResponse, AppError> {
|
||||
let token = service.complete_flow(&req.config, &req.redirect_uri, &req.code, &req.state)?;
|
||||
Ok(OAuthTokenResponse { token })
|
||||
}
|
||||
|
||||
/// Handle a get-token request.
|
||||
#[instrument(skip(service))]
|
||||
pub fn handle_get_token<O: OAuthService>(service: &O) -> anyhow::Result<OAuthTokenResponse> {
|
||||
pub fn handle_get_token<O: OAuthService>(
|
||||
service: &O,
|
||||
) -> Result<OAuthTokenResponse, AppError> {
|
||||
let token = service
|
||||
.get_token()?
|
||||
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
|
||||
.ok_or_else(|| AppError::NotFound("no OAuth token stored".into()))?;
|
||||
Ok(OAuthTokenResponse { token })
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
//! HTTP presentation layer — handler functions and DTOs for the IAM crate.
|
||||
//!
|
||||
//! This is the outermost ring of the Clean Architecture onion. Handlers receive
|
||||
//! domain service trait references via generics and translate between
|
||||
//! request/response DTOs and domain types. They have **no dependency** on
|
||||
//! any HTTP framework — callers (IPC server, Axum, etc.) are responsible for
|
||||
//! mapping results into actual HTTP responses.
|
||||
//!
|
||||
//! # Sub-modules
|
||||
//!
|
||||
//! - [`dto`] — request/response DTO types (JSON serialisation)
|
||||
//! - [`handlers`] — handler functions that accept service trait refs + DTOs
|
||||
//! - [`error`] — typed presentation-layer error type
|
||||
//!
|
||||
//! # Dependency rule
|
||||
//!
|
||||
//! presentation → application → domain
|
||||
//! presentation may also depend on infrastructure for wiring/composition.
|
||||
|
||||
pub mod dto;
|
||||
pub mod error;
|
||||
pub mod handlers;
|
||||
|
||||
pub use dto::{
|
||||
CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse,
|
||||
OAuthTokenResponse, SessionListResponse, SessionResponse,
|
||||
};
|
||||
pub use error::AppError;
|
||||
pub use handlers::{
|
||||
handle_archive_session, handle_complete_oauth, handle_create_session, handle_get_token,
|
||||
handle_list_sessions, handle_start_oauth,
|
||||
};
|
||||
@@ -122,7 +122,7 @@ pub struct SessionAuthMiddleware<S> {
|
||||
/// Extract and validate `X-Session-Id` from request headers.
|
||||
///
|
||||
/// Flow: read header -> validate non-empty -> return ID or a 401 error response.
|
||||
fn extract_session_id<ReqBody>(req: &Request<ReqBody>) -> Result<String, Response> {
|
||||
fn extract_session_id<ReqBody>(req: &Request<ReqBody>) -> Result<String, Box<Response>> {
|
||||
let session_id = req
|
||||
.headers()
|
||||
.get("X-Session-Id") // custom header carrying the session identifier
|
||||
@@ -131,7 +131,9 @@ fn extract_session_id<ReqBody>(req: &Request<ReqBody>) -> Result<String, Respons
|
||||
|
||||
match session_id {
|
||||
Some(id) if !id.is_empty() => Ok(id),
|
||||
_ => Err((StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response()),
|
||||
_ => Err(Box::new(
|
||||
(StatusCode::UNAUTHORIZED, "missing X-Session-Id header").into_response(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,7 +144,7 @@ fn validate_and_build_identity<ReqBody>(
|
||||
session_id: &str,
|
||||
store: &Store,
|
||||
req: &Request<ReqBody>,
|
||||
) -> Result<SessionIdentity, Response> {
|
||||
) -> Result<SessionIdentity, Box<Response>> {
|
||||
match validate_session(session_id, store) {
|
||||
Ok(_session) => {
|
||||
let user_agent = req
|
||||
@@ -153,11 +155,13 @@ fn validate_and_build_identity<ReqBody>(
|
||||
.to_string();
|
||||
Ok(SessionIdentity::new(session_id.to_string(), user_agent))
|
||||
}
|
||||
Err(e) => Err((
|
||||
StatusCode::UNAUTHORIZED,
|
||||
format!("session validation failed: {e}"),
|
||||
)
|
||||
.into_response()),
|
||||
Err(e) => Err(Box::new(
|
||||
(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
format!("session validation failed: {e}"),
|
||||
)
|
||||
.into_response(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -181,14 +185,14 @@ where
|
||||
|
||||
let session_id = match extract_session_id(&req) {
|
||||
Ok(id) => id,
|
||||
Err(resp) => return Box::pin(async move { Ok(resp) }),
|
||||
Err(resp) => return Box::pin(async move { Ok(*resp) }),
|
||||
};
|
||||
|
||||
match validate_and_build_identity(&session_id, &store, &req) {
|
||||
Ok(identity) => {
|
||||
req.extensions_mut().insert(identity);
|
||||
}
|
||||
Err(resp) => return Box::pin(async move { Ok(resp) }),
|
||||
Err(resp) => return Box::pin(async move { Ok(*resp) }),
|
||||
};
|
||||
|
||||
let fut = self.inner.call(req);
|
||||
@@ -212,12 +216,12 @@ pub async fn require_session(
|
||||
) -> Response {
|
||||
let session_id = match extract_session_id(&req) {
|
||||
Ok(id) => id,
|
||||
Err(resp) => return resp,
|
||||
Err(resp) => return *resp,
|
||||
};
|
||||
|
||||
let identity = match validate_and_build_identity(&session_id, &store, &req) {
|
||||
Ok(identity) => identity,
|
||||
Err(resp) => return resp,
|
||||
Err(resp) => return *resp,
|
||||
};
|
||||
req.extensions_mut().insert(identity);
|
||||
next.run(req).await
|
||||
|
||||
@@ -6,12 +6,6 @@
|
||||
|
||||
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
|
||||
|
||||
/// Return a permissive [`CorsLayer`] for local daemon IPC.
|
||||
///
|
||||
/// - **Origin**: any (`*`)
|
||||
/// - **Methods**: `GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `OPTIONS`
|
||||
/// - **Headers**: `Content-Type`, `Authorization`, `X-Session-Id`,
|
||||
/// `X-Request-Id`, `User-Agent`
|
||||
/// Return a permissive [`CorsLayer`] for local daemon IPC.
|
||||
///
|
||||
/// - **Origin**: any (`*`)
|
||||
|
||||
@@ -71,7 +71,8 @@ impl Error {
|
||||
|
||||
/// Convert an `anyhow::Error` to `zesdex_utils::Error` by attempting
|
||||
/// downcast to known inner types.
|
||||
pub fn from_anyhow(e: anyhow::Error) -> Self {
|
||||
#[must_use]
|
||||
pub fn from_anyhow(e: &anyhow::Error) -> Self {
|
||||
if let Some(ioe) = e.downcast_ref::<std::io::Error>() {
|
||||
return Error::Io(std::io::Error::new(ioe.kind(), ioe.to_string()));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user