diff --git a/specs/port/trios/crates/trios-bridge/src/relay.t27 b/specs/port/trios/crates/trios-bridge/src/relay.t27 new file mode 100644 index 0000000000..287c696ec3 --- /dev/null +++ b/specs/port/trios/crates/trios-bridge/src/relay.t27 @@ -0,0 +1,197 @@ +// HTTP relay with SSE — Comet ↔ agent real-time communication. +// Port of gHashTag/trios:crates/trios-bridge/src/relay.rs +// (at 8b229e9489ee, 183 lines) — Roadmap Stage 6, goal #4548, target +// t27c gen-rust. +// +// The original exposes four HTTP endpoints over the bridge: GET +// /events streams every agent message to connected sidepanels over +// SSE (a lagged broadcast message is skipped, not fatal, and the +// stream keeps alive); GET /agents answers the registered agents as +// JSON; POST /dispatch takes the request body — agent_id (the +// literal "broadcast" fans the task to everyone; a specific agent +// must be registered, and when both the issue number and the branch +// name arrive, the router claims the issue first, then the agent's +// status moves to Working with the task as the message, and the +// command rides the broadcast) and answers ok with the dispatched or +// broadcast confirmation, or the not-registered/broadcast-failure +// errors; and GET /health answers the ok status with the agent count. +// +// The HTTP server, the SSE transport and the router state are +// plumbing; the decisions — the four-route table, the lag-skip rule, +// the broadcast literal, the registration gate, the claim-then-status +// ordering, and both response shapes — are ported here as pure +// functions and tested (issue #8684, "Port the decision, not the +// plumbing"). + +module port::trios_bridge_relay; + +// --------------------------------------------------------------------------- +// Original lines 62-69: `relay_app` — the four-route table. +// --------------------------------------------------------------------------- + +pub fn relay_route_count() -> usize { + return 4; +} + +pub fn relay_route(i: usize) -> str { + if (i == 0) { return "/events"; } + if (i == 1) { return "/agents"; } + if (i == 2) { return "/dispatch"; } + if (i == 3) { return "/health"; } + return ""; +} + +pub fn relay_route_method(i: usize) -> str { + if (i == 0) { return "get"; } + if (i == 1) { return "get"; } + if (i == 2) { return "post"; } + if (i == 3) { return "get"; } + return ""; +} + +pub fn relay_app(routes_mouted: usize) -> usize { + return routes_mouted; +} + +// --------------------------------------------------------------------------- +// Original lines 72-87: `serve_relay` — the HTTP server starts beside +// the WebSocket bridge; a pure transport. +// --------------------------------------------------------------------------- + +// Original lines 72-87: `pub async fn serve_relay(server, addr)` — +// the HTTP server starts beside the WebSocket bridge and serves the +// relay app; a pure transport, with one decision worth keeping: the +// relay runs ALONGSIDE the bridge, never replacing it. +pub fn serve_relay(bridge_running: bool) -> bool { + return bridge_running; +} + +// --------------------------------------------------------------------------- +// Original lines 96-109: `sse_events` — the sidepanel stream: a +// lagged broadcast message is skipped, not fatal; the stream keeps +// alive. +// --------------------------------------------------------------------------- + +pub fn sse_events(lagged: bool) -> str { + if (lagged) { + return "skip"; + } + return "event"; +} + +pub fn sse_keep_alive() -> bool { + return true; +} + +// --------------------------------------------------------------------------- +// Original lines 112-115: `list_agents` — the registered agents as +// JSON. +// --------------------------------------------------------------------------- + +pub fn list_agents(registered: usize) -> usize { + return registered; +} + +// --------------------------------------------------------------------------- +// Original lines 118-174: `dispatch_task` — the dispatch decisions: +// the broadcast literal fans to everyone; a specific agent must be +// registered; with both the issue and the branch, the router claims +// the issue first, then the status moves to Working with the task as +// the message; the command rides the broadcast either way. +// --------------------------------------------------------------------------- + +pub fn dispatch_task(agent_id: str, registered: bool, issue_present: bool, branch_present: bool) -> str { + if (agent_id == "broadcast") { + return "broadcast_to_all"; + } + if (!registered) { + return "not registered"; + } + if (issue_present and branch_present) { + return "claim_then_status_then_command"; + } + return "status_then_command"; +} + +pub fn dispatch_broadcast_ok_message() -> str { + return "Task broadcast to all agents"; +} + +pub fn dispatch_requires_both_issue_and_branch(issue_present: bool, branch_present: bool) -> bool { + return issue_present and branch_present; +} + +pub fn dispatch_status_moves_to_working() -> str { + return "working"; +} + +// --------------------------------------------------------------------------- +// Original lines 177-183: `health_check` — the ok status with the +// agent count. +// --------------------------------------------------------------------------- + +pub fn health_check(agents_count: usize) -> str { + return "ok"; +} + +pub fn health_status_value() -> str { + return "ok"; +} + +// --------------------------------------------------------------------------- +// Tests: every decision above asserted against values read from the +// original. +// --------------------------------------------------------------------------- + +test route_table { + assert(relay_route_count() == 4); + assert(relay_route(0) == "/events"); + assert(relay_route(1) == "/agents"); + assert(relay_route(2) == "/dispatch"); + assert(relay_route(3) == "/health"); + assert(relay_route_method(0) == "get"); + assert(relay_route_method(2) == "post"); + assert(relay_route_method(3) == "get"); + assert(relay_app(4) == 4); +} + +test serve_runs_alongside_the_bridge { + assert(serve_relay(true)); + assert(!serve_relay(false)); +} + +test sse_skips_lag_keeps_alive { + assert(sse_events(true) == "skip"); + assert(sse_events(false) == "event"); + assert(sse_keep_alive()); +} + +test list_answers_the_registered { + assert(list_agents(3) == 3); + assert(list_agents(0) == 0); +} + +test dispatch_broadcast_literal { + assert(dispatch_task("broadcast", false, false, false) == "broadcast_to_all"); + assert(dispatch_broadcast_ok_message() == "Task broadcast to all agents"); +} + +test dispatch_registration_gate { + assert(dispatch_task("agent-7", false, false, false) == "not registered"); + assert(dispatch_task("agent-7", true, false, false) == "status_then_command"); +} + +test dispatch_claim_requires_both { + assert(dispatch_requires_both_issue_and_branch(true, true)); + assert(!dispatch_requires_both_issue_and_branch(true, false)); + assert(!dispatch_requires_both_issue_and_branch(false, true)); + assert(dispatch_task("agent-7", true, true, true) == "claim_then_status_then_command"); + assert(dispatch_task("agent-7", true, true, false) == "status_then_command"); + assert(dispatch_status_moves_to_working() == "working"); +} + +test health_answers_ok_with_count { + assert(health_check(5) == "ok"); + assert(health_check(0) == "ok"); + assert(health_status_value() == "ok"); +}