huskies: merge 1180 story Sled↔gateway goes WS-only: remove the deprecated HTTP fallback paths

This commit is contained in:
Huskies Agent
2026-07-16 18:10:14 +00:00
parent 0e684fb06f
commit de7d22cb9f
13 changed files with 203 additions and 1053 deletions
-202
View File
@@ -1,202 +0,0 @@
//! Per-project event buffer and `GET /api/events` HTTP endpoint.
//!
//! The gateway polls `/api/events?since={ts_ms}` on each registered project
//! server to aggregate cross-project pipeline notifications into a single
//! gateway chat channel. Each project server buffers up to 500 events in
//! memory and serves them via this endpoint.
//!
//! Domain logic lives in `service::events`; this module is a thin HTTP
//! adapter: extract query params → call service → shape response.
#[cfg(test)]
pub use crate::service::events::StoredEvent;
pub use crate::service::events::{EventBuffer, subscribe_to_watcher};
// MAX_BUFFER_SIZE is used in tests via `use super::*`.
#[cfg(test)]
pub use crate::service::events::MAX_BUFFER_SIZE;
use poem::web::{Data, Query};
use poem::{Response, handler, http::StatusCode};
use serde::Deserialize;
/// Query parameters for `GET /api/events`.
#[derive(Deserialize)]
pub struct EventsQuery {
/// Return only events with `timestamp_ms` strictly greater than this value.
/// Defaults to `0` (return all buffered events).
#[serde(default)]
pub since: u64,
}
/// `GET /api/events?since={ts_ms}`
///
/// Returns a JSON array of [`StoredEvent`] objects recorded after `since` ms.
/// The gateway polls this endpoint on each registered project server to build
/// an aggregated cross-project notification stream.
#[handler]
pub fn events_handler(
Query(params): Query<EventsQuery>,
Data(buffer): Data<&EventBuffer>,
) -> Response {
let events = crate::service::events::events_since(buffer, params.since);
let body = serde_json::to_vec(&events).unwrap_or_else(|_| b"[]".to_vec());
Response::builder()
.status(StatusCode::OK)
.header(poem::http::header::CONTENT_TYPE, "application/json")
.body(body)
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::broadcast;
#[test]
fn event_buffer_push_and_retrieve() {
let buf = EventBuffer::new();
buf.push(StoredEvent::MergeFailure {
story_id: "42_story_x".to_string(),
story_name: String::new(),
reason: "conflict".to_string(),
timestamp_ms: 1000,
});
buf.push(StoredEvent::StoryBlocked {
story_id: "43_story_y".to_string(),
story_name: String::new(),
reason: "retry limit".to_string(),
timestamp_ms: 2000,
});
let all = buf.events_since(0);
assert_eq!(all.len(), 2);
let after_1000 = buf.events_since(1000);
assert_eq!(after_1000.len(), 1);
assert!(matches!(after_1000[0], StoredEvent::StoryBlocked { .. }));
}
#[test]
fn event_buffer_evicts_oldest_when_full() {
let buf = EventBuffer::new();
for i in 0..MAX_BUFFER_SIZE + 1 {
buf.push(StoredEvent::MergeFailure {
story_id: format!("{i}_story_x"),
story_name: String::new(),
reason: "x".to_string(),
timestamp_ms: i as u64,
});
}
// Buffer must not exceed MAX_BUFFER_SIZE.
assert_eq!(buf.events_since(0).len(), MAX_BUFFER_SIZE);
// Oldest entry (timestamp_ms == 0) should have been evicted.
assert!(buf.events_since(0).iter().all(|e| e.timestamp_ms() > 0));
}
#[test]
fn stage_transition_timestamp_ms_accessor() {
let e = StoredEvent::StageTransition {
story_id: "1".to_string(),
story_name: String::new(),
from_stage: "2_current".to_string(),
to_stage: "3_qa".to_string(),
timestamp_ms: 9999,
};
assert_eq!(e.timestamp_ms(), 9999);
}
#[tokio::test]
async fn subscribe_to_watcher_stores_work_item_with_from_stage() {
let buf = EventBuffer::new();
let (tx, rx) = broadcast::channel(16);
subscribe_to_watcher(buf.clone(), rx);
tx.send(crate::io::watcher::WatcherEvent::WorkItem {
stage: "3_qa".to_string(),
item_id: "42_story_foo".to_string(),
action: "qa".to_string(),
commit_msg: "huskies: qa 42_story_foo".to_string(),
from_stage: Some("2_current".to_string()),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = buf.events_since(0);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StoredEvent::StageTransition { .. }));
if let StoredEvent::StageTransition {
ref story_id,
ref from_stage,
ref to_stage,
..
} = events[0]
{
assert_eq!(story_id, "42_story_foo");
assert_eq!(from_stage, "2_current");
assert_eq!(to_stage, "3_qa");
}
}
#[tokio::test]
async fn subscribe_to_watcher_ignores_work_item_without_from_stage() {
let buf = EventBuffer::new();
let (tx, rx) = broadcast::channel(16);
subscribe_to_watcher(buf.clone(), rx);
// Synthetic event: no from_stage.
tx.send(crate::io::watcher::WatcherEvent::WorkItem {
stage: "2_current".to_string(),
item_id: "99_story_syn".to_string(),
action: "start".to_string(),
commit_msg: "huskies: start 99_story_syn".to_string(),
from_stage: None,
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
assert_eq!(buf.events_since(0).len(), 0);
}
#[tokio::test]
async fn subscribe_to_watcher_stores_merge_failure() {
let buf = EventBuffer::new();
let (tx, rx) = broadcast::channel(16);
subscribe_to_watcher(buf.clone(), rx);
tx.send(crate::io::watcher::WatcherEvent::MergeFailure {
story_id: "42_story_foo".to_string(),
reason: "merge conflict".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = buf.events_since(0);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StoredEvent::MergeFailure { .. }));
}
#[tokio::test]
async fn subscribe_to_watcher_stores_story_blocked() {
let buf = EventBuffer::new();
let (tx, rx) = broadcast::channel(16);
subscribe_to_watcher(buf.clone(), rx);
tx.send(crate::io::watcher::WatcherEvent::StoryBlocked {
story_id: "43_story_bar".to_string(),
reason: "retry limit exceeded".to_string(),
})
.unwrap();
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
let events = buf.events_since(0);
assert_eq!(events.len(), 1);
assert!(matches!(events[0], StoredEvent::StoryBlocked { .. }));
}
}
+4 -19
View File
@@ -3,8 +3,6 @@
pub mod agents_sse;
/// Shared application context threaded through handlers.
pub mod context;
/// Server-sent event stream for pipeline/watcher events.
pub mod events;
/// Node identity endpoint (public key, node ID).
pub mod identity;
/// Model Context Protocol (MCP) HTTP endpoint and tool modules.
@@ -86,7 +84,6 @@ pub fn build_routes(
whatsapp_ctx: Option<Arc<WhatsAppWebhookContext>>,
slack_ctx: Option<Arc<SlackWebhookContext>>,
port: u16,
event_buffer: Option<events::EventBuffer>,
) -> impl poem::Endpoint {
let ctx_arc = std::sync::Arc::new(ctx);
@@ -118,10 +115,6 @@ pub fn build_routes(
.at("/oauth/status", get(oauth::oauth_status))
.at("/debug/crdt", get(debug_crdt_handler));
if let Some(buf) = event_buffer {
route = route.at("/api/events", get(events::events_handler).data(buf));
}
route = route
.at("/api/upgrade", post(upgrade_trigger_handler))
.at("/api/artifacts/:filename", get(serve_artifact_handler));
@@ -388,7 +381,7 @@ mod tests {
fn build_routes_constructs_without_panic() {
let tmp = tempfile::tempdir().unwrap();
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
let _endpoint = build_routes(ctx, None, None, 3001, None);
let _endpoint = build_routes(ctx, None, None, 3001);
}
#[test]
@@ -407,7 +400,7 @@ mod tests {
async fn artifact_endpoint_rejects_traversal_and_misses() {
let tmp = tempfile::tempdir().unwrap();
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
let app = build_routes(ctx, None, None, 3001, None);
let app = build_routes(ctx, None, None, 3001);
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/artifacts/..").send().await;
@@ -424,7 +417,7 @@ mod tests {
async fn version_endpoint_reports_version_and_git_hash() {
let tmp = tempfile::tempdir().unwrap();
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
let app = build_routes(ctx, None, None, 3001, None);
let app = build_routes(ctx, None, None, 3001);
let cli = poem::test::TestClient::new(app);
let resp = cli.get("/api/version").send().await;
@@ -447,14 +440,6 @@ mod tests {
// ensuring the port parameter flows through to OAuthState.
let tmp = tempfile::tempdir().unwrap();
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
let _endpoint = build_routes(ctx, None, None, 9999, None);
}
#[test]
fn build_routes_with_event_buffer_constructs_without_panic() {
let tmp = tempfile::tempdir().unwrap();
let ctx = context::AppContext::new_test(tmp.path().to_path_buf());
let buf = events::EventBuffer::new();
let _endpoint = build_routes(ctx, None, None, 3001, Some(buf));
let _endpoint = build_routes(ctx, None, None, 9999);
}
}