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"
|
"[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(),
|
kind: "pipeline".to_string(),
|
||||||
message: HIVE_MIND_KICKOFF_NOTE.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);
|
let response_chars = reply.content.as_deref().map_or(0, str::len);
|
||||||
tok_out = ((response_chars / 4).max(1)).cast_or(1u64);
|
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_in: tok_in,
|
||||||
tokens_out: tok_out,
|
tokens_out: tok_out,
|
||||||
});
|
});
|
||||||
@@ -236,7 +236,7 @@ pub(super) fn run_agent_turn(
|
|||||||
.collect::<Vec<String>>()
|
.collect::<Vec<String>>()
|
||||||
.join(", ");
|
.join(", ");
|
||||||
|
|
||||||
push_event(&events_q, TurnEvent::SystemNote {
|
push_event(events_q, TurnEvent::SystemNote {
|
||||||
kind: "pipeline".to_string(),
|
kind: "pipeline".to_string(),
|
||||||
message: format!(
|
message: format!(
|
||||||
"The Hive compiled {} cycle(s) — {cycle_desc}. Deploying nodes...",
|
"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);
|
archive_message(tc.db.as_ref(), &tc.session_id, &pipeline_msg);
|
||||||
msgs.push(pipeline_msg);
|
msgs.push(pipeline_msg);
|
||||||
|
|
||||||
push_event(&events_q, TurnEvent::SystemNote {
|
push_event(events_q, TurnEvent::SystemNote {
|
||||||
kind: "pipeline".to_string(),
|
kind: "pipeline".to_string(),
|
||||||
message:
|
message:
|
||||||
"The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..."
|
"The Hive's convergence is complete. Core Intelligence reviewing consensus for LO..."
|
||||||
.to_string(),
|
.to_string(),
|
||||||
});
|
});
|
||||||
push_event(&events_q, TurnEvent::SystemNote {
|
push_event(events_q, TurnEvent::SystemNote {
|
||||||
kind: "hive_mind_converged".to_string(),
|
kind: "hive_mind_converged".to_string(),
|
||||||
message: String::new(),
|
message: String::new(),
|
||||||
});
|
});
|
||||||
@@ -308,7 +308,7 @@ pub(super) fn run_agent_turn(
|
|||||||
// phase, which previously ran unchecked for minutes at a time.
|
// phase, which previously ran unchecked for minutes at a time.
|
||||||
if crate::app::util::abort::is_aborted_direct(&tc.abort_flag)
|
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(());
|
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
|
// 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.
|
// 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
|
// Also update our local `msgs` variable so the rest of the loop operates on the compacted version
|
||||||
msgs.clone_from(&compacted);
|
msgs.clone_from(&compacted);
|
||||||
@@ -417,7 +417,7 @@ pub(super) fn run_agent_turn(
|
|||||||
);
|
);
|
||||||
|
|
||||||
if reasoning_started && !reasoning_ended {
|
if reasoning_started && !reasoning_ended {
|
||||||
push_event(&events_q, TurnEvent::StreamToken(
|
push_event(events_q, TurnEvent::StreamToken(
|
||||||
"\n</think>\n\n".to_string(),
|
"\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)
|
if crate::app::util::abort::is_aborted_direct(&tc.abort_flag)
|
||||||
|| e.to_string().contains("aborted")
|
|| e.to_string().contains("aborted")
|
||||||
{
|
{
|
||||||
push_event(&events_q, TurnEvent::Error(
|
push_event(events_q, TurnEvent::Error(
|
||||||
"Generation aborted by user".to_string(),
|
"Generation aborted by user".to_string(),
|
||||||
));
|
));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -458,7 +458,7 @@ pub(super) fn run_agent_turn(
|
|||||||
Edit todo.md manually or ask me to focus on specific items.",
|
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(),
|
kind: "task_retry".to_string(),
|
||||||
message: format!(
|
message: format!(
|
||||||
"Network/API error: {api_err}. Auto-retrying to finish tasks... (retry {todo_retry_count}/{MAX_TODO_RETRIES})"
|
"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);
|
let response_chars = response.content.as_deref().map_or(0, str::len);
|
||||||
tok_out = ((response_chars / 4).max(1)).cast_or(1u64);
|
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_in: tok_in,
|
||||||
tokens_out: tok_out,
|
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 {
|
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)
|
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(),
|
"Turn aborted by user".to_string(),
|
||||||
));
|
));
|
||||||
return Ok(());
|
return Ok(());
|
||||||
@@ -629,7 +629,7 @@ pub(super) fn run_agent_turn(
|
|||||||
.and_then(|v| v.as_str())
|
.and_then(|v| v.as_str())
|
||||||
.map(std::string::ToString::to_string);
|
.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_call_id: tool_call.id.clone(),
|
||||||
tool_name: tool_name.clone(),
|
tool_name: tool_name.clone(),
|
||||||
output: output.clone(),
|
output: output.clone(),
|
||||||
@@ -646,9 +646,9 @@ pub(super) fn run_agent_turn(
|
|||||||
if !content.is_empty() {
|
if !content.is_empty() {
|
||||||
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
archive_message(tc.db.as_ref(), &tc.session_id, &response);
|
||||||
if stream_started {
|
if stream_started {
|
||||||
push_event(&events_q, TurnEvent::StreamDone(response.clone()));
|
push_event(events_q, TurnEvent::StreamDone(response.clone()));
|
||||||
} else {
|
} 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 {
|
if has_unfinished {
|
||||||
todo_retry_count += 1;
|
todo_retry_count += 1;
|
||||||
if todo_retry_count > MAX_TODO_RETRIES {
|
if todo_retry_count > MAX_TODO_RETRIES {
|
||||||
push_event(&events_q, TurnEvent::SystemNote {
|
push_event(events_q, TurnEvent::SystemNote {
|
||||||
kind: "task_retry".to_string(),
|
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."),
|
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);
|
let msg = ChatMessage::system(sys_text);
|
||||||
archive_message(tc.db.as_ref(), &tc.session_id, &msg);
|
archive_message(tc.db.as_ref(), &tc.session_id, &msg);
|
||||||
msgs.push(msg);
|
msgs.push(msg);
|
||||||
push_event(&events_q, TurnEvent::SystemNote {
|
push_event(events_q, TurnEvent::SystemNote {
|
||||||
kind: "task_retry".to_string(),
|
kind: "task_retry".to_string(),
|
||||||
message: sys_text_clone,
|
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 let Some((total_edits_this_turn, prev_edits, el)) = &total_edits_this_turn {
|
||||||
if *total_edits_this_turn > 0 {
|
if *total_edits_this_turn > 0 {
|
||||||
push_event(&events_q, TurnEvent::SystemNote {
|
push_event(events_q, TurnEvent::SystemNote {
|
||||||
kind: "edits".to_string(),
|
kind: "edits".to_string(),
|
||||||
message: total_edits_this_turn.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),
|
total_edits_this_turn.as_ref().map_or(0, |(c, _, _)| *c),
|
||||||
);
|
);
|
||||||
|
|
||||||
push_event(&events_q, TurnEvent::Done);
|
push_event(events_q, TurnEvent::Done);
|
||||||
|
|
||||||
Ok(())
|
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.
|
//! They contain no I/O, no framework imports, and no side effects.
|
||||||
|
|
||||||
pub mod app_config;
|
pub mod app_config;
|
||||||
|
pub mod commands;
|
||||||
pub mod conversation;
|
pub mod conversation;
|
||||||
pub mod edit_log;
|
pub mod edit_log;
|
||||||
pub mod error;
|
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
|
//! ## Sub-modules
|
||||||
//! - `persistence` — file-based repository implementations (JSON, markdown, SQLite)
|
//! - `persistence` — file-based repository implementations (JSON, markdown, SQLite)
|
||||||
//! - `http` — hyper-based HTTP API handlers and DTO types
|
|
||||||
|
|
||||||
pub mod http;
|
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
|
|||||||
@@ -23,3 +23,4 @@
|
|||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod infrastructure;
|
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
|
//! The caller (e.g. a hyper `Service`) is responsible for serialising the
|
||||||
//! response and setting HTTP status codes.
|
//! response and setting HTTP status codes.
|
||||||
|
|
||||||
use anyhow::{Context, Result};
|
|
||||||
use tracing::instrument;
|
use tracing::instrument;
|
||||||
|
|
||||||
|
use crate::domain::commands::{NewMemory, SettingsPatch};
|
||||||
use crate::domain::memory::Memory;
|
use crate::domain::memory::Memory;
|
||||||
use crate::domain::service::{MemoryService, SettingsService};
|
use crate::domain::service::{MemoryService, SettingsService};
|
||||||
use crate::domain::settings::Settings;
|
use crate::domain::settings::Settings;
|
||||||
|
|
||||||
use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest};
|
use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, SettingsUpdateRequest};
|
||||||
|
use super::error::AppError;
|
||||||
|
|
||||||
/// Handle `GET /settings`
|
/// Handle `GET /settings`
|
||||||
///
|
///
|
||||||
@@ -32,8 +33,8 @@ use super::dto::{MemoryCreateRequest, MemoryResponse, SettingsResponse, Settings
|
|||||||
///
|
///
|
||||||
/// Flow: load settings from service → convert to DTO → return.
|
/// Flow: load settings from service → convert to DTO → return.
|
||||||
#[instrument(skip(service))]
|
#[instrument(skip(service))]
|
||||||
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse> {
|
pub fn handle_get_settings<S: SettingsService>(service: &S) -> Result<SettingsResponse, AppError> {
|
||||||
let settings = service.load_settings().context("failed to load settings")?;
|
let settings = service.load_settings()?;
|
||||||
Ok(SettingsResponse::from(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
|
/// Applies a partial update from `req` to the current settings, persists
|
||||||
/// the result, and returns the updated `SettingsResponse`.
|
/// 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
|
/// ## Validation
|
||||||
/// - `internet_mode` must be "Off", "ReadOnly", or "Full" (case-sensitive).
|
/// - `internet_mode` is validated by `SettingsPatch::apply_to`.
|
||||||
#[instrument(skip(service))]
|
#[instrument(skip(service))]
|
||||||
pub fn handle_update_settings<S: SettingsService>(
|
pub fn handle_update_settings<S: SettingsService>(
|
||||||
service: &S,
|
service: &S,
|
||||||
req: SettingsUpdateRequest,
|
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
|
// Load current settings as baseline for partial update
|
||||||
let mut settings: Settings = service
|
let mut settings: Settings = service.load_settings()?;
|
||||||
.load_settings()
|
|
||||||
.context("failed to load current settings for update")?;
|
|
||||||
|
|
||||||
// Apply each optional field from the request (None = skip, Some = overwrite)
|
// Apply the patch via the domain command
|
||||||
if let Some(val) = req.internet_mode {
|
patch
|
||||||
settings.internet_mode = match val.as_str() {
|
.apply_to(&mut settings)
|
||||||
"Off" => crate::domain::settings::InternetMode::Off,
|
.map_err(AppError::BadRequest)?;
|
||||||
"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;
|
|
||||||
}
|
|
||||||
|
|
||||||
service
|
service.save_settings(&settings)?;
|
||||||
.save_settings(&settings)
|
|
||||||
.context("failed to save updated settings")?;
|
|
||||||
|
|
||||||
Ok(SettingsResponse::from(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.
|
/// Flow: list slugs from service → map each to minimal MemoryResponse → return.
|
||||||
#[instrument(skip(service))]
|
#[instrument(skip(service))]
|
||||||
pub fn handle_list_memories<M: MemoryService>(service: &M) -> Result<Vec<MemoryResponse>> {
|
pub fn handle_list_memories<M: MemoryService>(
|
||||||
let slugs = service.list_memories().context("failed to list memories")?;
|
service: &M,
|
||||||
|
) -> Result<Vec<MemoryResponse>, AppError> {
|
||||||
|
let slugs = service.list_memories()?;
|
||||||
|
|
||||||
// We can't load individual memories without a load_memory method on the
|
// Return minimal responses keyed by slug.
|
||||||
// service. For now, list returns summary info; callers who need full
|
|
||||||
// content use a separate endpoint. Return minimal responses keyed by slug.
|
|
||||||
let responses: Vec<MemoryResponse> = slugs
|
let responses: Vec<MemoryResponse> = slugs
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|slug| MemoryResponse {
|
.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>(
|
pub fn handle_create_memory<M: MemoryService>(
|
||||||
service: &M,
|
service: &M,
|
||||||
req: MemoryCreateRequest,
|
req: MemoryCreateRequest,
|
||||||
) -> Result<MemoryResponse> {
|
) -> Result<MemoryResponse, AppError> {
|
||||||
let now = chrono::Utc::now().timestamp();
|
// Build the domain command from the wire DTO
|
||||||
let memory = Memory {
|
let cmd = NewMemory {
|
||||||
name: req.name,
|
name: req.name,
|
||||||
description: req.description,
|
description: req.description,
|
||||||
content: req.content,
|
content: req.content,
|
||||||
kind: req.kind.unwrap_or_else(|| "reference".to_string()),
|
kind: req.kind,
|
||||||
created_at: now,
|
|
||||||
updated_at: now,
|
|
||||||
outcome: req.outcome,
|
outcome: req.outcome,
|
||||||
lifecycle: req.lifecycle.unwrap_or_else(|| "new".to_string()),
|
lifecycle: req.lifecycle,
|
||||||
scope: req.scope,
|
scope: req.scope,
|
||||||
before_snippet: req.before_snippet,
|
before_snippet: req.before_snippet,
|
||||||
after_snippet: req.after_snippet,
|
after_snippet: req.after_snippet,
|
||||||
provenances: req.provenances.unwrap_or_default(),
|
provenances: req.provenances,
|
||||||
};
|
};
|
||||||
|
|
||||||
service
|
let now = chrono::Utc::now().timestamp();
|
||||||
.save_memory(&memory)
|
let memory = Memory {
|
||||||
.context("failed to save 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))
|
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`
|
//! - [`service`] — Trait definitions: `OAuthService`, `SessionService`
|
||||||
//! - [`session`] — `Session` entity (IAM wrapper around `zesdex_entities::Session`)
|
//! - [`session`] — `Session` entity (IAM wrapper around `zesdex_entities::Session`)
|
||||||
|
|
||||||
|
pub mod commands;
|
||||||
pub mod error;
|
pub mod error;
|
||||||
pub mod oauth;
|
pub mod oauth;
|
||||||
pub mod repository;
|
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
|
//! # Sub-modules
|
||||||
//!
|
//!
|
||||||
//! - [`http`] — HTTP server, DTOs, handlers for the OAuth callback
|
|
||||||
//! - [`oauth_loopback`] — Loopback HTTP server to receive the OAuth redirect
|
//! - [`oauth_loopback`] — Loopback HTTP server to receive the OAuth redirect
|
||||||
//! - [`persistence`] — Filesystem-backed repositories (JSON + PID locks)
|
//! - [`persistence`] — Filesystem-backed repositories (JSON + PID locks)
|
||||||
//! - [`rng`] — System random token / UUID generation
|
//! - [`rng`] — System random token / UUID generation
|
||||||
|
|
||||||
pub mod http;
|
|
||||||
pub mod oauth_loopback;
|
pub mod oauth_loopback;
|
||||||
pub mod persistence;
|
pub mod persistence;
|
||||||
pub mod rng;
|
pub mod rng;
|
||||||
|
|||||||
@@ -12,3 +12,4 @@
|
|||||||
pub mod application;
|
pub mod application;
|
||||||
pub mod domain;
|
pub mod domain;
|
||||||
pub mod infrastructure;
|
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 zesdex_entities::domain::auth::SessionId;
|
||||||
|
|
||||||
use crate::domain::service::{OAuthService, SessionService};
|
use crate::domain::service::{OAuthService, SessionService};
|
||||||
use crate::infrastructure::http::dto::{
|
use crate::presentation::dto::{
|
||||||
CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse,
|
CreateSessionRequest, OAuthCompleteRequest, OAuthStartRequest, OAuthStartResponse,
|
||||||
OAuthTokenResponse, SessionListResponse, SessionResponse,
|
OAuthTokenResponse, SessionListResponse, SessionResponse,
|
||||||
};
|
};
|
||||||
|
use crate::presentation::error::AppError;
|
||||||
|
|
||||||
/// Handle a create-session request.
|
/// Handle a create-session request.
|
||||||
#[instrument(skip(service), fields(title = %req.title))]
|
#[instrument(skip(service), fields(title = %req.title))]
|
||||||
pub fn handle_create_session<S: SessionService>(
|
pub fn handle_create_session<S: SessionService>(
|
||||||
service: &S,
|
service: &S,
|
||||||
req: CreateSessionRequest,
|
req: CreateSessionRequest,
|
||||||
) -> anyhow::Result<SessionResponse> {
|
) -> Result<SessionResponse, AppError> {
|
||||||
let session = service.create_session(&req.title)?;
|
let session = service.create_session(&req.title)?;
|
||||||
Ok(SessionResponse { session })
|
Ok(SessionResponse { session })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a list-sessions request.
|
/// Handle a list-sessions request.
|
||||||
#[instrument(skip(service))]
|
#[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 sessions = service.list_all()?;
|
||||||
let total = sessions.len();
|
let total = sessions.len();
|
||||||
Ok(SessionListResponse { sessions, total })
|
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.
|
/// Handle an archive-session request.
|
||||||
#[instrument(skip(service), fields(session_id = %id))]
|
#[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)
|
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)?;
|
service.archive_session(sid)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -56,7 +62,7 @@ pub fn handle_archive_session<S: SessionService>(service: &S, id: &str) -> anyho
|
|||||||
pub fn handle_start_oauth<O: OAuthService>(
|
pub fn handle_start_oauth<O: OAuthService>(
|
||||||
service: &O,
|
service: &O,
|
||||||
req: OAuthStartRequest,
|
req: OAuthStartRequest,
|
||||||
) -> anyhow::Result<OAuthStartResponse> {
|
) -> Result<OAuthStartResponse, AppError> {
|
||||||
let (auth_url, state) = service.start_flow(&req.config, &req.redirect_uri)?;
|
let (auth_url, state) = service.start_flow(&req.config, &req.redirect_uri)?;
|
||||||
Ok(OAuthStartResponse { auth_url, state })
|
Ok(OAuthStartResponse { auth_url, state })
|
||||||
}
|
}
|
||||||
@@ -66,16 +72,18 @@ pub fn handle_start_oauth<O: OAuthService>(
|
|||||||
pub fn handle_complete_oauth<O: OAuthService>(
|
pub fn handle_complete_oauth<O: OAuthService>(
|
||||||
service: &O,
|
service: &O,
|
||||||
req: OAuthCompleteRequest,
|
req: OAuthCompleteRequest,
|
||||||
) -> anyhow::Result<OAuthTokenResponse> {
|
) -> Result<OAuthTokenResponse, AppError> {
|
||||||
let token = service.complete_flow(&req.config, &req.redirect_uri, &req.code, &req.state)?;
|
let token = service.complete_flow(&req.config, &req.redirect_uri, &req.code, &req.state)?;
|
||||||
Ok(OAuthTokenResponse { token })
|
Ok(OAuthTokenResponse { token })
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Handle a get-token request.
|
/// Handle a get-token request.
|
||||||
#[instrument(skip(service))]
|
#[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
|
let token = service
|
||||||
.get_token()?
|
.get_token()?
|
||||||
.ok_or_else(|| anyhow::anyhow!("no OAuth token stored"))?;
|
.ok_or_else(|| AppError::NotFound("no OAuth token stored".into()))?;
|
||||||
Ok(OAuthTokenResponse { token })
|
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.
|
/// Extract and validate `X-Session-Id` from request headers.
|
||||||
///
|
///
|
||||||
/// Flow: read header -> validate non-empty -> return ID or a 401 error response.
|
/// 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
|
let session_id = req
|
||||||
.headers()
|
.headers()
|
||||||
.get("X-Session-Id") // custom header carrying the session identifier
|
.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 {
|
match session_id {
|
||||||
Some(id) if !id.is_empty() => Ok(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,
|
session_id: &str,
|
||||||
store: &Store,
|
store: &Store,
|
||||||
req: &Request<ReqBody>,
|
req: &Request<ReqBody>,
|
||||||
) -> Result<SessionIdentity, Response> {
|
) -> Result<SessionIdentity, Box<Response>> {
|
||||||
match validate_session(session_id, store) {
|
match validate_session(session_id, store) {
|
||||||
Ok(_session) => {
|
Ok(_session) => {
|
||||||
let user_agent = req
|
let user_agent = req
|
||||||
@@ -153,11 +155,13 @@ fn validate_and_build_identity<ReqBody>(
|
|||||||
.to_string();
|
.to_string();
|
||||||
Ok(SessionIdentity::new(session_id.to_string(), user_agent))
|
Ok(SessionIdentity::new(session_id.to_string(), user_agent))
|
||||||
}
|
}
|
||||||
Err(e) => Err((
|
Err(e) => Err(Box::new(
|
||||||
StatusCode::UNAUTHORIZED,
|
(
|
||||||
format!("session validation failed: {e}"),
|
StatusCode::UNAUTHORIZED,
|
||||||
)
|
format!("session validation failed: {e}"),
|
||||||
.into_response()),
|
)
|
||||||
|
.into_response(),
|
||||||
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,14 +185,14 @@ where
|
|||||||
|
|
||||||
let session_id = match extract_session_id(&req) {
|
let session_id = match extract_session_id(&req) {
|
||||||
Ok(id) => id,
|
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) {
|
match validate_and_build_identity(&session_id, &store, &req) {
|
||||||
Ok(identity) => {
|
Ok(identity) => {
|
||||||
req.extensions_mut().insert(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);
|
let fut = self.inner.call(req);
|
||||||
@@ -212,12 +216,12 @@ pub async fn require_session(
|
|||||||
) -> Response {
|
) -> Response {
|
||||||
let session_id = match extract_session_id(&req) {
|
let session_id = match extract_session_id(&req) {
|
||||||
Ok(id) => id,
|
Ok(id) => id,
|
||||||
Err(resp) => return resp,
|
Err(resp) => return *resp,
|
||||||
};
|
};
|
||||||
|
|
||||||
let identity = match validate_and_build_identity(&session_id, &store, &req) {
|
let identity = match validate_and_build_identity(&session_id, &store, &req) {
|
||||||
Ok(identity) => identity,
|
Ok(identity) => identity,
|
||||||
Err(resp) => return resp,
|
Err(resp) => return *resp,
|
||||||
};
|
};
|
||||||
req.extensions_mut().insert(identity);
|
req.extensions_mut().insert(identity);
|
||||||
next.run(req).await
|
next.run(req).await
|
||||||
|
|||||||
@@ -6,12 +6,6 @@
|
|||||||
|
|
||||||
use tower_http::cors::{AllowHeaders, AllowOrigin, CorsLayer};
|
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.
|
/// Return a permissive [`CorsLayer`] for local daemon IPC.
|
||||||
///
|
///
|
||||||
/// - **Origin**: any (`*`)
|
/// - **Origin**: any (`*`)
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ impl Error {
|
|||||||
|
|
||||||
/// Convert an `anyhow::Error` to `zesdex_utils::Error` by attempting
|
/// Convert an `anyhow::Error` to `zesdex_utils::Error` by attempting
|
||||||
/// downcast to known inner types.
|
/// 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>() {
|
if let Some(ioe) = e.downcast_ref::<std::io::Error>() {
|
||||||
return Error::Io(std::io::Error::new(ioe.kind(), ioe.to_string()));
|
return Error::Io(std::io::Error::new(ioe.kind(), ioe.to_string()));
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user