mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(agent): thread per-tool reasoning from LLM through to REPL, HTTP, SSE, and DB Add end-to-end agent reasoning summaries so users can see *why* the agent chose specific tools, not just what it did. - Add `reasoning: Option<String>` to `ToolCall` (all providers) - Populate from LLM response content in `Reasoning::respond_with_tools` and `select_tools`, with per-tool override when providers supply it - Extend `Turn` with `narrative` and `TurnToolCall` with `rationale` + `tool_call_id` for identity-based result matching - Persist reasoning in DB via existing tool_calls JSON (no migration) - Add `StatusUpdate::ReasoningUpdate` and `SseEvent::ReasoningUpdate` + `SseEvent::JobReasoning` for real-time streaming - Emit reasoning events in both chat dispatcher and worker job path - Add `/reasoning [N|all]` command for inspecting turn reasoning - Surface `narrative` and `rationale` in HTTP `/api/chat/history` Based on the design from #361 and #456, reconstructed cleanly with Option<String> to minimize blast radius (vs mandatory String that broke compilation in #456). Closes #456 Co-Authored-By: panosAthDBX <[email protected]> Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address PR review feedback from Gemini and Copilot - Fix `_ => Ok(None)` in agent_loop.rs to avoid accidental shutdown - Fix fallback in record_tool_result_for/record_tool_error_for to use first pending call instead of last_mut (parallel execution safety) - Include per-tool decisions in WASM channel reasoning messages - Apply truncate_at_tool_tags + clean_response to shared_reasoning in select_tools (parity with respond_with_tools) - Persist turn-level narrative to DB in tool_calls JSON wrapper - Parse both old (array) and new (object) tool_calls formats in build_turns_from_db_messages for backward compatibility - Populate reasoning from action.reasoning in execute_plan ToolCalls [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address second round of review comments + merge fixes - Add reasoning: None to new github_copilot.rs ToolCall sites (from staging merge) - Run cargo fmt on 4 files with formatting diffs - Truncate narrative to 1000 chars before DB persistence - Clone turn data and drop session lock in /reasoning command - Extract ToolDecisionDto::from_json_array shared helper (deduplicate worker/job.rs and orchestrator/api.rs) - Add unit tests for wrapped tool_calls JSON format with narrative [skip-regression-check] Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address third round of review comments (Copilot + serrrfirat) - Reword ToolCall.reasoning docstring to reflect provider-supplied or fallback contract - Sanitize narrative through SafetyLayer before storage/emission - Clean per-tool reasoning via truncate_at_tool_tags + clean_response in select_tools (parity with shared reasoning) - Convert 4 approval-path recording sites in thread_ops.rs to identity-based record_tool_result_for/record_tool_error_for - Preserve tool_call_id and reasoning through restore_from_messages - Fix has_result/has_error to reject JSON null values - Truncate tool_call_id to 128 chars before DB persistence - Add 4 unit tests for record_tool_result_for/error_for edge cases Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian review — sanitize JobDelegate reasoning + warn on dropped results - Sanitize narrative and per-tool rationale through SafetyLayer in JobDelegate reasoning events (parity with ChatDelegate) - Add tracing::warn when record_tool_result_for/error_for drops a result because no matching or pending tool call exists - Add 3 unit tests for reasoning normalization (thinking tags, tool tags, empty-after-cleaning) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address 4 remaining unreplied review comments - Clean per-tool reasoning in respond_with_tools via truncate_at_tool_tags + clean_response (parity with select_tools) - Handle wrapped JSON format in rebuild_chat_messages_from_db so cold hydration works after persist_tool_calls format change - Update persist_tool_calls doc comment to describe new JSON shape - Sanitize per-tool rationale through SafetyLayer in ChatDelegate before emission and storage (parity with JobDelegate) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address zmanian review round 2 - Add tracing::debug on fallback-to-pending path in record_tool_result_for and record_tool_error_for (item 1) - Add comment explaining why /reasoning is special-cased in agent_loop.rs (item 4) - Items 2 (narrative persistence), 3 (rationale sanitization), and 5 (catch-all fix) were already addressed in prior commits Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: panosAthDBX <[email protected]> Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
574 lines
20 KiB
Rust
574 lines
20 KiB
Rust
//! Web gateway channel for browser-based access to IronClaw.
|
|
//!
|
|
//! Provides a single-page web UI with:
|
|
//! - Chat with the agent (via REST + SSE)
|
|
//! - Workspace/memory browsing
|
|
//! - Job management
|
|
//!
|
|
//! ```text
|
|
//! Browser ─── POST /api/chat/send ──► Agent Loop
|
|
//! ◄── GET /api/chat/events ── SSE stream
|
|
//! ─── GET /api/chat/ws ─────► WebSocket (bidirectional)
|
|
//! ─── GET /api/memory/* ────► Workspace
|
|
//! ─── GET /api/jobs/* ──────► Database
|
|
//! ◄── GET / ───────────────── Static HTML/CSS/JS
|
|
//! ```
|
|
|
|
pub mod auth;
|
|
pub(crate) mod handlers;
|
|
pub mod log_layer;
|
|
pub mod openai_compat;
|
|
pub mod server;
|
|
pub mod sse;
|
|
pub mod types;
|
|
pub(crate) mod util;
|
|
pub mod ws;
|
|
|
|
/// Test helpers for gateway integration tests.
|
|
///
|
|
/// Always compiled (not behind `#[cfg(test)]`) so that integration tests in
|
|
/// `tests/` -- which import this crate as a regular dependency -- can use
|
|
/// [`TestGatewayBuilder`](test_helpers::TestGatewayBuilder).
|
|
pub mod test_helpers;
|
|
|
|
#[cfg(test)]
|
|
mod tests;
|
|
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
|
|
use async_trait::async_trait;
|
|
use tokio::sync::mpsc;
|
|
use tokio_stream::wrappers::ReceiverStream;
|
|
|
|
use crate::agent::SessionManager;
|
|
use crate::channels::{Channel, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate};
|
|
use crate::config::GatewayConfig;
|
|
use crate::db::Database;
|
|
use crate::error::ChannelError;
|
|
use crate::extensions::ExtensionManager;
|
|
use crate::orchestrator::job_manager::ContainerJobManager;
|
|
use crate::skills::catalog::SkillCatalog;
|
|
use crate::skills::registry::SkillRegistry;
|
|
use crate::tools::ToolRegistry;
|
|
use crate::workspace::Workspace;
|
|
|
|
use self::log_layer::{LogBroadcaster, LogLevelHandle};
|
|
|
|
use self::auth::MultiAuthState;
|
|
use self::server::GatewayState;
|
|
use self::sse::SseManager;
|
|
use self::types::AppEvent;
|
|
|
|
/// Web gateway channel implementing the Channel trait.
|
|
pub struct GatewayChannel {
|
|
config: GatewayConfig,
|
|
state: Arc<GatewayState>,
|
|
/// Multi-user auth state (replaces bare auth_token).
|
|
auth: MultiAuthState,
|
|
}
|
|
|
|
impl GatewayChannel {
|
|
/// Create a new gateway channel.
|
|
///
|
|
/// If no auth token is configured, generates a random one and prints it.
|
|
/// Builds a single-user `MultiAuthState` from the config.
|
|
pub fn new(config: GatewayConfig) -> Self {
|
|
let auth_token = config.auth_token.clone().unwrap_or_else(|| {
|
|
use rand::RngCore;
|
|
use rand::rngs::OsRng;
|
|
let mut bytes = [0u8; 32];
|
|
OsRng.fill_bytes(&mut bytes);
|
|
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
|
});
|
|
|
|
let auth = MultiAuthState::single(auth_token, config.user_id.clone());
|
|
|
|
let state = Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
sse: Arc::new(SseManager::new()),
|
|
workspace: None,
|
|
workspace_pool: None,
|
|
session_manager: None,
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: None,
|
|
tool_registry: None,
|
|
store: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
scheduler: None,
|
|
owner_id: config.user_id.clone(),
|
|
default_sender_id: config.user_id.clone(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
|
llm_provider: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
active_config: server::ActiveConfigSnapshot::default(),
|
|
});
|
|
|
|
Self {
|
|
config,
|
|
state,
|
|
auth,
|
|
}
|
|
}
|
|
|
|
/// Rebind the single-user auth identity to the durable owner scope while
|
|
/// preserving the configured gateway sender/routing identity.
|
|
pub fn with_owner_scope(mut self, owner_id: impl Into<String>) -> Self {
|
|
let owner_id = owner_id.into();
|
|
let single_user_token = if self.config.user_tokens.is_none() {
|
|
self.auth.first_token().map(ToOwned::to_owned)
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(token) = single_user_token {
|
|
self.auth = MultiAuthState::single(token, owner_id.clone());
|
|
}
|
|
self.rebuild_state(|s| s.owner_id = owner_id);
|
|
self
|
|
}
|
|
|
|
/// Create a gateway channel with a pre-built multi-user auth state.
|
|
pub fn new_multi_auth(config: GatewayConfig, auth: MultiAuthState) -> Self {
|
|
let state = Arc::new(GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
sse: Arc::new(SseManager::new()),
|
|
workspace: None,
|
|
workspace_pool: None,
|
|
session_manager: None,
|
|
log_broadcaster: None,
|
|
log_level_handle: None,
|
|
extension_manager: None,
|
|
tool_registry: None,
|
|
store: None,
|
|
job_manager: None,
|
|
prompt_queue: None,
|
|
scheduler: None,
|
|
owner_id: config.user_id.clone(),
|
|
default_sender_id: config.user_id.clone(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: Some(Arc::new(ws::WsConnectionTracker::new())),
|
|
llm_provider: None,
|
|
skill_registry: None,
|
|
skill_catalog: None,
|
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: Vec::new(),
|
|
cost_guard: None,
|
|
routine_engine: Arc::new(tokio::sync::RwLock::new(None)),
|
|
startup_time: std::time::Instant::now(),
|
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
|
active_config: server::ActiveConfigSnapshot::default(),
|
|
});
|
|
|
|
Self {
|
|
config,
|
|
state,
|
|
auth,
|
|
}
|
|
}
|
|
|
|
/// Helper to rebuild state, copying existing fields and applying a mutation.
|
|
fn rebuild_state(&mut self, mutate: impl FnOnce(&mut GatewayState)) {
|
|
let mut new_state = GatewayState {
|
|
msg_tx: tokio::sync::RwLock::new(None),
|
|
// Preserve the existing broadcast channel so sender handles remain valid.
|
|
sse: Arc::new(SseManager::from_sender(self.state.sse.sender())),
|
|
workspace: self.state.workspace.clone(),
|
|
workspace_pool: self.state.workspace_pool.clone(),
|
|
session_manager: self.state.session_manager.clone(),
|
|
log_broadcaster: self.state.log_broadcaster.clone(),
|
|
log_level_handle: self.state.log_level_handle.clone(),
|
|
extension_manager: self.state.extension_manager.clone(),
|
|
tool_registry: self.state.tool_registry.clone(),
|
|
store: self.state.store.clone(),
|
|
job_manager: self.state.job_manager.clone(),
|
|
prompt_queue: self.state.prompt_queue.clone(),
|
|
scheduler: self.state.scheduler.clone(),
|
|
owner_id: self.state.owner_id.clone(),
|
|
default_sender_id: self.state.default_sender_id.clone(),
|
|
shutdown_tx: tokio::sync::RwLock::new(None),
|
|
ws_tracker: self.state.ws_tracker.clone(),
|
|
llm_provider: self.state.llm_provider.clone(),
|
|
skill_registry: self.state.skill_registry.clone(),
|
|
skill_catalog: self.state.skill_catalog.clone(),
|
|
chat_rate_limiter: server::PerUserRateLimiter::new(30, 60),
|
|
oauth_rate_limiter: server::RateLimiter::new(10, 60),
|
|
webhook_rate_limiter: server::RateLimiter::new(10, 60),
|
|
registry_entries: self.state.registry_entries.clone(),
|
|
cost_guard: self.state.cost_guard.clone(),
|
|
routine_engine: Arc::clone(&self.state.routine_engine),
|
|
startup_time: self.state.startup_time,
|
|
active_config: self.state.active_config.clone(),
|
|
};
|
|
mutate(&mut new_state);
|
|
self.state = Arc::new(new_state);
|
|
}
|
|
|
|
/// Inject the workspace reference for the memory API.
|
|
pub fn with_workspace(mut self, workspace: Arc<Workspace>) -> Self {
|
|
self.rebuild_state(|s| s.workspace = Some(workspace));
|
|
self
|
|
}
|
|
|
|
/// Inject the session manager for thread/session info.
|
|
pub fn with_session_manager(mut self, sm: Arc<SessionManager>) -> Self {
|
|
self.rebuild_state(|s| s.session_manager = Some(sm));
|
|
self
|
|
}
|
|
|
|
/// Inject the log broadcaster for the logs SSE endpoint.
|
|
pub fn with_log_broadcaster(mut self, lb: Arc<LogBroadcaster>) -> Self {
|
|
self.rebuild_state(|s| s.log_broadcaster = Some(lb));
|
|
self
|
|
}
|
|
|
|
/// Inject the log level handle for runtime log level control.
|
|
pub fn with_log_level_handle(mut self, h: Arc<LogLevelHandle>) -> Self {
|
|
self.rebuild_state(|s| s.log_level_handle = Some(h));
|
|
self
|
|
}
|
|
|
|
/// Inject the extension manager for the extensions API.
|
|
pub fn with_extension_manager(mut self, em: Arc<ExtensionManager>) -> Self {
|
|
self.rebuild_state(|s| s.extension_manager = Some(em));
|
|
self
|
|
}
|
|
|
|
/// Inject the tool registry for the extensions API.
|
|
pub fn with_tool_registry(mut self, tr: Arc<ToolRegistry>) -> Self {
|
|
self.rebuild_state(|s| s.tool_registry = Some(tr));
|
|
self
|
|
}
|
|
|
|
/// Inject the database store for sandbox job persistence.
|
|
pub fn with_store(mut self, store: Arc<dyn Database>) -> Self {
|
|
self.rebuild_state(|s| s.store = Some(store));
|
|
self
|
|
}
|
|
|
|
/// Inject the container job manager for sandbox operations.
|
|
pub fn with_job_manager(mut self, jm: Arc<ContainerJobManager>) -> Self {
|
|
self.rebuild_state(|s| s.job_manager = Some(jm));
|
|
self
|
|
}
|
|
|
|
/// Inject the prompt queue for Claude Code follow-up prompts.
|
|
pub fn with_prompt_queue(
|
|
mut self,
|
|
pq: Arc<
|
|
tokio::sync::Mutex<
|
|
std::collections::HashMap<
|
|
uuid::Uuid,
|
|
std::collections::VecDeque<crate::orchestrator::api::PendingPrompt>,
|
|
>,
|
|
>,
|
|
>,
|
|
) -> Self {
|
|
self.rebuild_state(|s| s.prompt_queue = Some(pq));
|
|
self
|
|
}
|
|
|
|
/// Inject the scheduler for sending follow-up messages to agent jobs.
|
|
pub fn with_scheduler(mut self, slot: crate::tools::builtin::SchedulerSlot) -> Self {
|
|
self.rebuild_state(|s| s.scheduler = Some(slot));
|
|
self
|
|
}
|
|
|
|
/// Inject the skill registry for skill management API.
|
|
pub fn with_skill_registry(mut self, sr: Arc<std::sync::RwLock<SkillRegistry>>) -> Self {
|
|
self.rebuild_state(|s| s.skill_registry = Some(sr));
|
|
self
|
|
}
|
|
|
|
/// Inject the skill catalog for skill search API.
|
|
pub fn with_skill_catalog(mut self, sc: Arc<SkillCatalog>) -> Self {
|
|
self.rebuild_state(|s| s.skill_catalog = Some(sc));
|
|
self
|
|
}
|
|
|
|
/// Inject the LLM provider for OpenAI-compatible API proxy.
|
|
pub fn with_llm_provider(mut self, llm: Arc<dyn crate::llm::LlmProvider>) -> Self {
|
|
self.rebuild_state(|s| s.llm_provider = Some(llm));
|
|
self
|
|
}
|
|
|
|
/// Inject registry catalog entries for the available extensions API.
|
|
pub fn with_registry_entries(mut self, entries: Vec<crate::extensions::RegistryEntry>) -> Self {
|
|
self.rebuild_state(|s| s.registry_entries = entries);
|
|
self
|
|
}
|
|
|
|
/// Inject the cost guard for token/cost tracking in the status popover.
|
|
pub fn with_cost_guard(mut self, cg: Arc<crate::agent::cost_guard::CostGuard>) -> Self {
|
|
self.rebuild_state(|s| s.cost_guard = Some(cg));
|
|
self
|
|
}
|
|
|
|
/// Inject a shared routine engine slot used by other HTTP ingress paths.
|
|
pub fn with_routine_engine_slot(mut self, slot: server::RoutineEngineSlot) -> Self {
|
|
self.rebuild_state(|s| s.routine_engine = slot);
|
|
self
|
|
}
|
|
|
|
/// Inject the active (resolved) configuration snapshot for the status endpoint.
|
|
pub fn with_active_config(mut self, config: server::ActiveConfigSnapshot) -> Self {
|
|
self.rebuild_state(|s| s.active_config = config);
|
|
self
|
|
}
|
|
|
|
/// Inject the per-user workspace pool for multi-user mode.
|
|
pub fn with_workspace_pool(mut self, pool: Arc<server::WorkspacePool>) -> Self {
|
|
self.rebuild_state(|s| s.workspace_pool = Some(pool));
|
|
self
|
|
}
|
|
|
|
/// Get the first auth token (for printing to console on startup).
|
|
pub fn auth_token(&self) -> &str {
|
|
self.auth.first_token().unwrap_or("")
|
|
}
|
|
|
|
/// Get a reference to the shared gateway state (for the agent to push SSE events).
|
|
pub fn state(&self) -> &Arc<GatewayState> {
|
|
&self.state
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for GatewayChannel {
|
|
fn name(&self) -> &str {
|
|
"gateway"
|
|
}
|
|
|
|
async fn start(&self) -> Result<MessageStream, ChannelError> {
|
|
let (tx, rx) = mpsc::channel(256);
|
|
*self.state.msg_tx.write().await = Some(tx);
|
|
|
|
let addr: SocketAddr = format!("{}:{}", self.config.host, self.config.port)
|
|
.parse()
|
|
.map_err(|e| ChannelError::StartupFailed {
|
|
name: "gateway".to_string(),
|
|
reason: format!(
|
|
"Invalid address '{}:{}': {}",
|
|
self.config.host, self.config.port, e
|
|
),
|
|
})?;
|
|
|
|
server::start_server(addr, self.state.clone(), self.auth.clone()).await?;
|
|
|
|
Ok(Box::pin(ReceiverStream::new(rx)))
|
|
}
|
|
|
|
async fn respond(
|
|
&self,
|
|
msg: &IncomingMessage,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = match &msg.thread_id {
|
|
Some(tid) => tid.clone(),
|
|
None => {
|
|
tracing::warn!(
|
|
"Gateway respond with no thread_id — skipping (clients would drop it)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
|
|
self.state.sse.broadcast_for_user(
|
|
&msg.user_id,
|
|
AppEvent::Response {
|
|
content: response.content,
|
|
thread_id,
|
|
},
|
|
);
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_status(
|
|
&self,
|
|
status: StatusUpdate,
|
|
metadata: &serde_json::Value,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = metadata
|
|
.get("thread_id")
|
|
.and_then(|v| v.as_str())
|
|
.map(String::from);
|
|
let event = match status {
|
|
StatusUpdate::Thinking(msg) => AppEvent::Thinking {
|
|
message: msg,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolStarted { name } => AppEvent::ToolStarted {
|
|
name,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolCompleted {
|
|
name,
|
|
success,
|
|
error,
|
|
parameters,
|
|
} => AppEvent::ToolCompleted {
|
|
name,
|
|
success,
|
|
error,
|
|
parameters,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ToolResult { name, preview } => AppEvent::ToolResult {
|
|
name,
|
|
preview,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::StreamChunk(content) => AppEvent::StreamChunk {
|
|
content,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::Status(msg) => AppEvent::Status {
|
|
message: msg,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
} => AppEvent::JobStarted {
|
|
job_id,
|
|
title,
|
|
browse_url,
|
|
},
|
|
StatusUpdate::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters,
|
|
allow_always,
|
|
} => AppEvent::ApprovalNeeded {
|
|
request_id,
|
|
tool_name,
|
|
description,
|
|
parameters: serde_json::to_string_pretty(¶meters)
|
|
.unwrap_or_else(|_| parameters.to_string()),
|
|
thread_id,
|
|
allow_always,
|
|
},
|
|
StatusUpdate::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
auth_url,
|
|
setup_url,
|
|
} => AppEvent::AuthRequired {
|
|
extension_name,
|
|
instructions,
|
|
auth_url,
|
|
setup_url,
|
|
},
|
|
StatusUpdate::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
} => AppEvent::AuthCompleted {
|
|
extension_name,
|
|
success,
|
|
message,
|
|
},
|
|
StatusUpdate::ImageGenerated { data_url, path } => AppEvent::ImageGenerated {
|
|
data_url,
|
|
path,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::Suggestions { suggestions } => AppEvent::Suggestions {
|
|
suggestions,
|
|
thread_id: thread_id.clone(),
|
|
},
|
|
StatusUpdate::ReasoningUpdate {
|
|
narrative,
|
|
decisions,
|
|
} => AppEvent::ReasoningUpdate {
|
|
narrative,
|
|
decisions: decisions
|
|
.into_iter()
|
|
.map(|d| crate::channels::web::types::ToolDecisionDto {
|
|
tool_name: d.tool_name,
|
|
rationale: d.rationale,
|
|
})
|
|
.collect(),
|
|
thread_id,
|
|
},
|
|
StatusUpdate::TurnCost {
|
|
input_tokens,
|
|
output_tokens,
|
|
cost_usd,
|
|
} => AppEvent::TurnCost {
|
|
input_tokens,
|
|
output_tokens,
|
|
cost_usd,
|
|
thread_id,
|
|
},
|
|
};
|
|
|
|
// Scope events to the user when user_id is available in metadata.
|
|
// When user_id is missing (heartbeat, routines), events go to all
|
|
// subscribers. In multi-tenant mode this leaks status across users.
|
|
if let Some(uid) = metadata.get("user_id").and_then(|v| v.as_str()) {
|
|
self.state.sse.broadcast_for_user(uid, event);
|
|
} else {
|
|
tracing::debug!("Status event missing user_id in metadata; broadcasting globally");
|
|
self.state.sse.broadcast(event);
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
async fn broadcast(
|
|
&self,
|
|
user_id: &str,
|
|
response: OutgoingResponse,
|
|
) -> Result<(), ChannelError> {
|
|
let thread_id = match response.thread_id {
|
|
Some(tid) => tid,
|
|
None => {
|
|
tracing::warn!(
|
|
"Gateway broadcast with no thread_id — skipping (clients would drop it)"
|
|
);
|
|
return Ok(());
|
|
}
|
|
};
|
|
self.state.sse.broadcast_for_user(
|
|
user_id,
|
|
AppEvent::Response {
|
|
content: response.content,
|
|
thread_id,
|
|
},
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
async fn health_check(&self) -> Result<(), ChannelError> {
|
|
if self.state.msg_tx.read().await.is_some() {
|
|
Ok(())
|
|
} else {
|
|
Err(ChannelError::HealthCheckFailed {
|
|
name: "gateway".to_string(),
|
|
})
|
|
}
|
|
}
|
|
|
|
async fn shutdown(&self) -> Result<(), ChannelError> {
|
|
if let Some(tx) = self.state.shutdown_tx.write().await.take() {
|
|
let _ = tx.send(());
|
|
}
|
|
*self.state.msg_tx.write().await = None;
|
|
Ok(())
|
|
}
|
|
}
|