mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-25 14:53:34 +00:00
* feat(ux): complete UX overhaul — design system, boot screen, onboarding, web polish Shared design system: CSS custom properties for spacing, typography, transitions, and color tokens used across web UI and boot screen. Boot screen: compact feature-tags line showing enabled subsystems (db, tools, routines, heartbeat, skills, sandbox, embeddings) at a glance. Downgrade startup info logs (libSQL, webhook, workspace seed) to debug level since the boot screen now covers this. Onboarding wizard: model picker with live API fetch, provider-aware auth flow, improved error recovery and progress display. Web UI: ARIA attributes, welcome card, streaming debounce, connection status banner, skeleton loaders, send cooldown. CLI: doctor command enhancements, status command cleanup, REPL banner consolidation, shared fmt module. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(ux): Apple-level design refinements — spring physics, glass morphism, chat polish Merge staging theme support (dark/light/system toggle) and layer UX polish on top: spring-physics motion, glass morphism depth, chat experience improvements, and responsive mobile refinements. Design system: - Restore and extend design token system (spacing, typography, timing, easing) with legacy aliases for theme compatibility - Add shadow tiers, accent glow, glass morphism, spring easing tokens - Tokens defined in both dark (:root) and light ([data-theme="light"]) Micro-interactions (Phase 2): - Spring-overshoot message entry animation (slideUp) - Spring-scale button press on all interactive buttons - Tab crossfade animation, tool card smooth accordion (max-height) - Modal scale(0.95) + blur(8px) entry, toast spring slide - Sidebar width crossfade, card hover lift Visual depth (Phase 3): - Tab bar glass morphism + surface highlight + sliding indicator - Active tab accent background pill - Assistant message accent left border, user message bubble tail - Floating input area (rounded + shadow + margin) Chat polish (Phase 4): - Smooth streaming cursor (cursorPulse), message hover timestamps - Time separators (Today/Yesterday/date) - Textarea smooth auto-expand, send button glow Settings & forms (Phase 5): - iOS-style toggle switches for boolean settings - Input focus glow, save feedback spring animation - Welcome card with gradient background + proper spacing - Sticky settings group headers with glass backdrop Accessibility & mobile (Phase 6): - Animated focus ring, prefers-reduced-motion global kill-switch - Touch target audit (44px min), mobile bottom-sheet modals - Mobile bottom tab bar, toast redesign (icon + border + countdown) - Thread hover translateX, badge in_progress pulse Bug fixes: - Gateway/TEE popover z-index (tab-bar z-index: 200, popovers 500) - Connection lost banner as fixed top bar instead of flex child - Sidebar collapse keeps toggle + new thread buttons visible - Downgrade noisy startup logs (db, webhook, vector) to debug - Remove green dot pulse animation on connected status - Deduplicate confirm-modal in HTML, add tab-indicator div Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(web): mobile layout improvements — sidebar toggle, settings drill-down, tab bar polish - Fix mobile sidebar toggle: use expanded-mobile class instead of collapsed, add backdrop overlay, auto-close on thread select, outside-click dismiss - Settings: replace cramped horizontal tabs with drill-down navigation (category list → detail view → back button) - Bottom tab bar: add glass morphism, hide theme toggle, flip tab indicator to top edge - Keep thread toggle button visible in collapsed 36px sidebar strip Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): interactive approval selector and transient status lines - Replace ASCII-art approval box with clean horizontal rule card - Add inquire-based interactive selector for tool approvals (↑↓ + Enter) - Selector runs directly from send_status via spawn_blocking, with stdin_locked flag to prevent readline from competing for stdin - Transient thinking/tool-started lines: each replaces the previous, all erased before final output (no clutter left in scrollback) - Esc in selector sends denial so agent never gets stuck Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: widen TurnCost token fields to u64 and remove unused variable - Change input_tokens/output_tokens from u32 to u64 in StatusUpdate::TurnCost, SseEvent::TurnCost, and the thread_ops emit site to avoid truncation on large conversations - Remove unused _routine_engine_for_loop binding in agent_loop.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: reduce startup log noise — demote info to debug Demote routine startup messages (builder, WASM tools, tunnel, WASM channels) from info to debug so the default log output stays clean. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): allow CDN scripts in CSP connect-src directive Add cdn.jsdelivr.net and cdnjs.cloudflare.com to connect-src so the browser can fetch marked.js and DOMPurify without CSP violations. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: fix cargo fmt in repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(web): gate turn_cost SSE handler on current thread Prevents cost badge from attaching to the wrong message when switching threads or receiving events from background threads. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * ci: retrigger CI * fix: add missing extension_manager to webhook EngineContext The webhook trigger path added in #736 was missing the extension_manager field introduced by #1453. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore: ignore RUSTSEC-2026-0049 rustls-webpki CRL advisory Low impact — requires compromised CA to exploit. Tracked for upstream rustls-webpki upgrade. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix(routines): use fields.join for cron normalization Use split_whitespace fields instead of re-trimming the original string to avoid preserving extra internal whitespace in cron expressions. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * feat(repl): Apple-style approval card — clean vertical flow - Drop verbose tool description (the command IS the decision surface) - Unified vertical pipe layout: ◆ header → │ params → │ selector - Selector options show keyboard shortcuts inline: Approve (y) - Compact help message, answered state uses └ to close the flow - No horizontal rules, no blank-line padding — just breathing room Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * refactor(repl): replace inquire with crossterm for approval selector Drop the inquire dependency (which pulled in crossterm 0.25, duplicating the existing 0.28). The 3-option approval selector is now built directly with crossterm raw mode — same UX, zero new dependencies. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * chore(deps): upgrade crossterm 0.28 → 0.29, eliminate duplication termimad (via crokey) uses crossterm 0.29. Upgrading our direct dependency from 0.28 to 0.29 collapses to a single crossterm version in the dependency tree. Also migrated termimad::crossterm:: references to the direct crossterm import. Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review comments — box_top off-by-one, smart_truncate overflow, mobile theme toggle - Fix box_top() fill calculation: was off-by-one, producing boxes 1 char too wide (fmt.rs) - Fix smart_truncate(): account for "..." in the budget so output never exceeds max_chars (repl.rs) - Move theme toggle to settings sidebar on mobile instead of display:none, so mobile users can still switch themes (style.css, index.html, app.js) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * style: cargo fmt repl.rs Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> * fix: address review — retry duplication, CSP connect-src, deny color - Remove failed message before retry to prevent duplicate user messages - Revert connect-src to 'self' — CDN hosts only need script-src - Use red for Deny confirmation in REPL approval selector Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]> --------- Co-authored-by: Claude Opus 4.6 (1M context) <[email protected]>
381 lines
13 KiB
Rust
381 lines
13 KiB
Rust
//! Unified HTTP server for all webhook routes.
|
|
//!
|
|
//! Composes route fragments from HttpChannel, WASM channel router, etc.
|
|
//! into a single axum server. Channels define routes but never spawn servers.
|
|
|
|
use std::net::SocketAddr;
|
|
|
|
use axum::Router;
|
|
use tokio::sync::oneshot;
|
|
use tokio::task::JoinHandle;
|
|
|
|
use crate::error::ChannelError;
|
|
|
|
/// Configuration for the unified webhook server.
|
|
pub struct WebhookServerConfig {
|
|
/// Address to bind the server to.
|
|
pub addr: SocketAddr,
|
|
}
|
|
|
|
/// A single HTTP server that hosts all webhook routes.
|
|
///
|
|
/// Channels contribute route fragments via `add_routes()`, then a single
|
|
/// `start()` call binds the listener and spawns the server task.
|
|
pub struct WebhookServer {
|
|
config: WebhookServerConfig,
|
|
routes: Vec<Router>,
|
|
/// Merged router saved after start() for restarts via `install_listener()`.
|
|
merged_router: Option<Router>,
|
|
shutdown_tx: Option<oneshot::Sender<()>>,
|
|
handle: Option<JoinHandle<()>>,
|
|
}
|
|
|
|
impl WebhookServer {
|
|
/// Create a new webhook server with the given bind address.
|
|
pub fn new(config: WebhookServerConfig) -> Self {
|
|
Self {
|
|
config,
|
|
routes: Vec::new(),
|
|
merged_router: None,
|
|
shutdown_tx: None,
|
|
handle: None,
|
|
}
|
|
}
|
|
|
|
/// Accumulate a route fragment. Each fragment should already have its
|
|
/// state applied via `.with_state()`.
|
|
pub fn add_routes(&mut self, router: Router) {
|
|
self.routes.push(router);
|
|
}
|
|
|
|
/// Bind the listener, merge all route fragments, and spawn the server.
|
|
pub async fn start(&mut self) -> Result<(), ChannelError> {
|
|
let mut app = Router::new();
|
|
for fragment in self.routes.drain(..) {
|
|
app = app.merge(fragment);
|
|
}
|
|
self.merged_router = Some(app.clone());
|
|
self.bind_and_spawn(app).await
|
|
}
|
|
|
|
/// Bind a listener to the configured address and spawn the server task.
|
|
/// Private helper used by `start()`.
|
|
async fn bind_and_spawn(&mut self, app: Router) -> Result<(), ChannelError> {
|
|
let listener = tokio::net::TcpListener::bind(self.config.addr)
|
|
.await
|
|
.map_err(|e| ChannelError::StartupFailed {
|
|
name: "webhook_server".to_string(),
|
|
reason: format!("Failed to bind to {}: {}", self.config.addr, e),
|
|
})?;
|
|
|
|
tracing::debug!("Webhook server listening on {}", self.config.addr);
|
|
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
self.shutdown_tx = Some(shutdown_tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
if let Err(e) = axum::serve(listener, app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = shutdown_rx.await;
|
|
tracing::debug!("Webhook server shutting down");
|
|
})
|
|
.await
|
|
{
|
|
tracing::error!("Webhook server error: {}", e);
|
|
}
|
|
});
|
|
|
|
self.handle = Some(handle);
|
|
Ok(())
|
|
}
|
|
|
|
/// Clone the merged router, if `start()` has been called.
|
|
pub fn merged_router_clone(&self) -> Option<Router> {
|
|
self.merged_router.clone()
|
|
}
|
|
|
|
/// Install a pre-bound listener, replacing the current one.
|
|
///
|
|
/// The caller is responsible for binding the `TcpListener` *outside* any
|
|
/// lock so that the async bind does not block other lock waiters. This
|
|
/// method only does synchronous bookkeeping plus spawning the (non-blocking)
|
|
/// server task, so it is safe to call while holding a mutex.
|
|
pub fn install_listener(
|
|
&mut self,
|
|
new_addr: SocketAddr,
|
|
listener: tokio::net::TcpListener,
|
|
app: Router,
|
|
) -> (Option<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
|
|
// Capture old handles so the caller can shut them down outside the lock.
|
|
let old_shutdown_tx = self.shutdown_tx.take();
|
|
let old_handle = self.handle.take();
|
|
|
|
self.config.addr = new_addr;
|
|
|
|
// Spawn the new server task (non-blocking).
|
|
let (shutdown_tx, shutdown_rx) = oneshot::channel();
|
|
self.shutdown_tx = Some(shutdown_tx);
|
|
|
|
let handle = tokio::spawn(async move {
|
|
if let Err(e) = axum::serve(listener, app)
|
|
.with_graceful_shutdown(async {
|
|
let _ = shutdown_rx.await;
|
|
tracing::debug!("Webhook server shutting down");
|
|
})
|
|
.await
|
|
{
|
|
tracing::error!("Webhook server error: {}", e);
|
|
}
|
|
});
|
|
self.handle = Some(handle);
|
|
|
|
tracing::debug!("Webhook server listening on {}", new_addr);
|
|
|
|
(old_shutdown_tx, old_handle)
|
|
}
|
|
|
|
/// Return the current bind address.
|
|
pub fn current_addr(&self) -> SocketAddr {
|
|
self.config.addr
|
|
}
|
|
|
|
/// Take ownership of shutdown primitives so callers can perform async
|
|
/// shutdown work without holding external locks around this server.
|
|
pub fn begin_shutdown(&mut self) -> (Option<oneshot::Sender<()>>, Option<JoinHandle<()>>) {
|
|
(self.shutdown_tx.take(), self.handle.take())
|
|
}
|
|
|
|
/// Signal graceful shutdown and wait for the server task to finish.
|
|
pub async fn shutdown(&mut self) {
|
|
let (shutdown_tx, handle) = self.begin_shutdown();
|
|
if let Some(tx) = shutdown_tx {
|
|
let _ = tx.send(());
|
|
}
|
|
if let Some(handle) = handle {
|
|
let _ = handle.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use axum::Json;
|
|
use serde_json::json;
|
|
|
|
#[tokio::test]
|
|
async fn test_restart_with_addr_rebinds_listener() {
|
|
use std::net::TcpListener as StdTcpListener;
|
|
|
|
// Find two available ports by binding and immediately closing
|
|
let port1 = {
|
|
let listener =
|
|
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 1");
|
|
listener
|
|
.local_addr()
|
|
.expect("Failed to get local addr")
|
|
.port()
|
|
};
|
|
|
|
let port2 = {
|
|
let listener =
|
|
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port 2");
|
|
listener
|
|
.local_addr()
|
|
.expect("Failed to get local addr")
|
|
.port()
|
|
};
|
|
|
|
assert_ne!(port1, port2, "Should have different ports");
|
|
assert_ne!(port1, 0, "Port 1 should be non-zero");
|
|
assert_ne!(port2, 0, "Port 2 should be non-zero");
|
|
|
|
// Start server on first port
|
|
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
|
|
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
|
|
|
|
// Create a test router that responds to health checks
|
|
let test_router = axum::Router::new().route(
|
|
"/health",
|
|
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
|
|
);
|
|
server.add_routes(test_router);
|
|
|
|
// Start the server on first port
|
|
server.start().await.expect("Failed to start server");
|
|
assert_eq!(
|
|
server.current_addr(),
|
|
addr1,
|
|
"Server should be bound to initial address"
|
|
);
|
|
|
|
// Verify the first server is actually listening
|
|
let client = reqwest::Client::new();
|
|
let response = client
|
|
.get(format!("http://{}/health", addr1))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request to first server");
|
|
assert_eq!(
|
|
response.status(),
|
|
200,
|
|
"First server should respond to health check"
|
|
);
|
|
|
|
// Restart on second port using two-phase approach
|
|
let addr2: SocketAddr = format!("127.0.0.1:{}", port2).parse().unwrap();
|
|
let app = server
|
|
.merged_router_clone()
|
|
.expect("Router should exist after start()");
|
|
let listener = tokio::net::TcpListener::bind(addr2)
|
|
.await
|
|
.expect("Failed to bind to new addr");
|
|
let (old_tx, old_handle) = server.install_listener(addr2, listener, app);
|
|
if let Some(tx) = old_tx {
|
|
let _ = tx.send(());
|
|
}
|
|
if let Some(handle) = old_handle {
|
|
let _ = handle.await;
|
|
}
|
|
|
|
// Assert the address changed
|
|
assert_eq!(
|
|
server.current_addr(),
|
|
addr2,
|
|
"Server address should be updated after restart"
|
|
);
|
|
assert_ne!(
|
|
addr1, addr2,
|
|
"Address should change after restart_with_addr"
|
|
);
|
|
|
|
// Verify the new server is actually listening on the new address
|
|
let response = client
|
|
.get(format!("http://{}/health", addr2))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request to restarted server");
|
|
assert_eq!(
|
|
response.status(),
|
|
200,
|
|
"Restarted server should respond to health check on new address"
|
|
);
|
|
|
|
// Verify the old address is no longer responding
|
|
let old_result = tokio::time::timeout(
|
|
std::time::Duration::from_millis(200),
|
|
client.get(format!("http://{}/health", addr1)).send(),
|
|
)
|
|
.await;
|
|
assert!(
|
|
old_result.is_err() || old_result.as_ref().unwrap().is_err(),
|
|
"Old address should not respond after server restarts"
|
|
);
|
|
|
|
// Clean up
|
|
server.shutdown().await;
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_begin_shutdown_takes_handles_for_lock_free_shutdown() {
|
|
let addr = SocketAddr::from((std::net::Ipv4Addr::LOCALHOST, 0));
|
|
let mut server = WebhookServer::new(WebhookServerConfig { addr });
|
|
|
|
let test_router = axum::Router::new().route(
|
|
"/health",
|
|
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
|
|
);
|
|
server.add_routes(test_router);
|
|
server.start().await.expect("Failed to start server"); // safety: test assertion for setup precondition
|
|
|
|
let (shutdown_tx, handle) = server.begin_shutdown();
|
|
assert!(shutdown_tx.is_some(), "shutdown sender should be available"); // safety: test assertion for expected server state
|
|
assert!(handle.is_some(), "server handle should be available"); // safety: test assertion for expected server state
|
|
|
|
// begin_shutdown() should leave no handles behind on the server.
|
|
let (shutdown_tx2, handle2) = server.begin_shutdown();
|
|
assert!(shutdown_tx2.is_none(), "shutdown sender should be consumed"); // safety: test assertion for postcondition
|
|
assert!(handle2.is_none(), "server handle should be consumed"); // safety: test assertion for postcondition
|
|
|
|
if let Some(tx) = shutdown_tx {
|
|
let _ = tx.send(());
|
|
}
|
|
if let Some(handle) = handle {
|
|
let _ = handle.await;
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn test_restart_with_addr_rollback_on_bind_failure() {
|
|
use std::net::TcpListener as StdTcpListener;
|
|
|
|
// Find an available port
|
|
let port1 = {
|
|
let listener =
|
|
StdTcpListener::bind("127.0.0.1:0").expect("Failed to find available port");
|
|
listener
|
|
.local_addr()
|
|
.expect("Failed to get local addr")
|
|
.port()
|
|
};
|
|
|
|
// Start server on first port
|
|
let addr1 = format!("127.0.0.1:{}", port1).parse().unwrap();
|
|
let mut server = WebhookServer::new(WebhookServerConfig { addr: addr1 });
|
|
|
|
// Create a test router
|
|
let test_router = axum::Router::new().route(
|
|
"/health",
|
|
axum::routing::get(|| async { Json(json!({"status": "ok"})) }),
|
|
);
|
|
server.add_routes(test_router);
|
|
|
|
// Start the server on first port
|
|
server.start().await.expect("Failed to start server");
|
|
|
|
// Verify the server is listening
|
|
let client = reqwest::Client::new();
|
|
let response = client
|
|
.get(format!("http://{}/health", addr1))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request");
|
|
assert_eq!(response.status(), 200, "Server should be listening");
|
|
|
|
// Try to restart on an invalid address (port 1 typically requires elevated privileges)
|
|
let invalid_addr: SocketAddr = "127.0.0.1:1".parse().unwrap();
|
|
|
|
// Attempt bind (should fail); server state is untouched because we
|
|
// never call install_listener on failure.
|
|
let app = server
|
|
.merged_router_clone()
|
|
.expect("Router should exist after start()");
|
|
let result = tokio::net::TcpListener::bind(invalid_addr).await;
|
|
assert!(result.is_err(), "Bind to privileged port should fail");
|
|
// `app` is dropped — server state unchanged (rollback by construction)
|
|
drop(app);
|
|
|
|
// Verify the old address is still responding (rollback succeeded)
|
|
let response = client
|
|
.get(format!("http://{}/health", addr1))
|
|
.send()
|
|
.await
|
|
.expect("Failed to send request to old address");
|
|
assert_eq!(
|
|
response.status(),
|
|
200,
|
|
"Old listener should still be running after failed restart"
|
|
);
|
|
|
|
// Verify the server address is unchanged
|
|
assert_eq!(
|
|
server.current_addr(),
|
|
addr1,
|
|
"Server address should be restored after failed restart"
|
|
);
|
|
|
|
// Clean up
|
|
server.shutdown().await;
|
|
}
|
|
}
|