Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ members = [
"crates/korg-tui",
"crates/korg-server",
"crates/korg-bridge",
"crates/korg-verify",
]
resolver = "2"

Expand Down
28 changes: 28 additions & 0 deletions crates/korg-runtime/src/tui_bridge.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,25 @@ pub enum ContractResponse {
Rewind(u64),
}

/// Lifecycle phase of a single worker, as a structured signal (not a display
/// string). Emitted alongside the human-readable `TuiUpdate::Trace` lines so the
/// operator TUI can build a live leader → worker tree from real state.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum WorkerLifecycle {
/// Worker is being spawned (process/handshake in flight).
Spawning,
/// Worker is actively running.
Running,
/// Worker completed successfully (or was self-healed).
Ok,
/// Worker process crashed — queued for recovery.
Crashed,
/// Worker exceeded the timeout budget.
TimedOut,
/// Worker failed to spawn at all.
SpawnError,
}

/// Events pushed by the LeaderOrchestrator to the live operator TUI.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub enum TuiUpdate {
Expand Down Expand Up @@ -59,4 +78,13 @@ pub enum TuiUpdate {
},
/// Runtime surfaced rewind options after a doom-loop / failure detection.
RewindAvailable(Vec<crate::recovery::RewindCandidate>),
/// Structured worker-lifecycle signal feeding the live swarm tree. Emitted at
/// the same real lifecycle points as the worker `Trace` lines — never parsed
/// from a display string, never fabricated.
WorkerState {
node_id: String,
persona: String,
state: WorkerLifecycle,
elapsed_ms: u64,
},
}
40 changes: 40 additions & 0 deletions crates/korg-runtime/src/workers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,9 @@ pub async fn dispatch_level(
}
}

// Per-node spawn timestamps so the worker-lifecycle signal carries REAL elapsed.
let mut spawn_instants: HashMap<String, std::time::Instant> = HashMap::new();

// Spawn all workers concurrently — each gets a workspace from the manager
for node_id in level_node_ids {
let pkg = match packages_map.get(node_id) {
Expand All @@ -174,6 +177,18 @@ pub async fn dispatch_level(
}
}

// Real lifecycle point: this worker is now being spawned. Emit the
// structured signal (feeds the live swarm tree) and stamp its start.
spawn_instants.insert(node_id.clone(), std::time::Instant::now());
if let Some(ref tx) = tui_tx {
let _ = tx.try_send(crate::tui_bridge::TuiUpdate::WorkerState {
node_id: node_id.clone(),
persona: pkg.persona.name().to_string(),
state: crate::tui_bridge::WorkerLifecycle::Spawning,
elapsed_ms: 0,
});
}

let bb = bb.clone();
let key = signing_key.clone();
let node_id_owned = node_id.clone();
Expand Down Expand Up @@ -331,6 +346,31 @@ pub async fn dispatch_level(
WorkerOutcome::SpawnError(e) => format!(" [✗] {} spawn error: {}", slot.node_id, e),
};
let _ = tx.try_send(crate::tui_bridge::TuiUpdate::Trace(msg));

// Same real lifecycle point: emit the structured signal
// for the live swarm tree, with REAL elapsed since spawn.
let state = match &slot.result {
WorkerOutcome::Ok(_) => crate::tui_bridge::WorkerLifecycle::Ok,
WorkerOutcome::Crashed(_) => {
crate::tui_bridge::WorkerLifecycle::Crashed
}
WorkerOutcome::TimedOut => {
crate::tui_bridge::WorkerLifecycle::TimedOut
}
WorkerOutcome::SpawnError(_) => {
crate::tui_bridge::WorkerLifecycle::SpawnError
}
};
let elapsed_ms = spawn_instants
.get(&slot.node_id)
.map(|t| t.elapsed().as_millis() as u64)
.unwrap_or(0);
let _ = tx.try_send(crate::tui_bridge::TuiUpdate::WorkerState {
node_id: slot.node_id.clone(),
persona: slot.persona.name().to_string(),
state,
elapsed_ms,
});
}

match slot.result {
Expand Down
62 changes: 56 additions & 6 deletions crates/korg-server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
//! - GET `/api/events` (SSE stream broadcasting TuiUpdate JSONs)
//! - POST `/api/override` (forwards ContractResponse user overrides back to the leader)
//! - GET `/api/state` (exposes active blackboard.json snapshot)
//! - Static embedding of the sleek glassmorphism HTML dashboard
//! - Serves a static landing page (LANDING_HTML); no SPA or WASM frontend is bundled
//! - Auto-opens browser upon starting.

use ax_sse::{Event, Sse};
Expand Down Expand Up @@ -55,6 +55,21 @@ fn open_browser(url: &str) {
let _ = std::process::Command::new("xdg-open").arg(url).status();
}

/// The address the dashboard server binds to. Defaults to loopback
/// (`127.0.0.1:8080`) so the (mostly unauthenticated) control/telemetry routes
/// aren't exposed to the network; set `KORG_SERVER_ADDR` to bind elsewhere on purpose.
fn server_bind_addr() -> String {
resolve_bind_addr(std::env::var("KORG_SERVER_ADDR").ok())
}

/// Pure resolution of the bind address from an optional override — loopback
/// unless an explicit, non-empty override is given.
fn resolve_bind_addr(override_addr: Option<String>) -> String {
override_addr
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "127.0.0.1:8080".to_string())
}

/// Runs a web dashboard campaign.
/// This matches `crate::tui::run_tui_with_campaign` but routes telemetry to a web server.
pub async fn run_web_with_campaign(
Expand Down Expand Up @@ -188,7 +203,7 @@ pub async fn run_web_with_campaign(
)
.with_state(app_state);

let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
let listener = tokio::net::TcpListener::bind(server_bind_addr()).await?;
println!("\n\x1b[1m[korg] Axum server listening on http://localhost:8080\x1b[0m");

// Auto-open browser in a separate thread
Expand Down Expand Up @@ -311,7 +326,7 @@ pub async fn run_web_with_leader(mut leader: LeaderOrchestrator) -> anyhow::Resu
)
.with_state(app_state);

let listener = tokio::net::TcpListener::bind("0.0.0.0:8080").await?;
let listener = tokio::net::TcpListener::bind(server_bind_addr()).await?;
println!("\n\x1b[1m[korg] Axum server listening on http://localhost:8080\x1b[0m");

tokio::spawn(async {
Expand All @@ -323,17 +338,25 @@ pub async fn run_web_with_leader(mut leader: LeaderOrchestrator) -> anyhow::Resu
Ok(())
}

/// Serves the embedded glassmorphism SPA index.html
/// Serves the static landing page (LANDING_HTML).
async fn index_handler() -> impl IntoResponse {
Html(LANDING_HTML)
}

async fn wasm_js_handler() -> impl IntoResponse {
([("content-type", "application/javascript")], "")
// No WASM frontend is bundled in this build — 404 honestly rather than
// serving an empty 200 that looks like a real (but empty) asset.
(
axum::http::StatusCode::NOT_FOUND,
"korg WASM frontend is not bundled in this build",
)
}

async fn wasm_bytes_handler() -> impl IntoResponse {
([("content-type", "application/wasm")], &[] as &[u8])
(
axum::http::StatusCode::NOT_FOUND,
"korg WASM frontend is not bundled in this build",
)
}

/// Serves the premium monochrome landing page
Expand Down Expand Up @@ -2833,6 +2856,33 @@ mod tests {
use std::sync::Mutex as StdMutex;
use tokio::sync::Mutex as TokioMutex;

#[test]
fn default_bind_addr_is_loopback_not_all_interfaces() {
// Security: with no override the server must bind loopback only — never
// 0.0.0.0, which exposed the (mostly unauthenticated) control + telemetry
// routes to the whole local network.
let addr = resolve_bind_addr(None);
assert!(addr.starts_with("127.0.0.1"), "default must be loopback, got {addr}");
assert!(!addr.starts_with("0.0.0.0"), "default must not bind all interfaces");
}

#[test]
fn bind_addr_honors_explicit_override() {
// Intentional network exposure stays possible, but only by explicit opt-in.
assert_eq!(resolve_bind_addr(Some("0.0.0.0:9000".into())), "0.0.0.0:9000");
}

#[tokio::test]
async fn wasm_routes_404_when_no_frontend_is_bundled() {
// No WASM frontend ships in this build — the routes must 404 honestly,
// not serve an empty 200 that masquerades as a real (empty) asset.
use axum::response::IntoResponse;
let js = wasm_js_handler().await.into_response();
assert_eq!(js.status(), axum::http::StatusCode::NOT_FOUND);
let wasm = wasm_bytes_handler().await.into_response();
assert_eq!(wasm.status(), axum::http::StatusCode::NOT_FOUND);
}

/// Set KORG_MASTER_KEY once for the whole test binary so the auth store's
/// production-mode `expect()` doesn't panic in tests. Anything that touches
/// JsonTokenStore must call this first.
Expand Down
Loading
Loading