diff --git a/crates/miyabi-cli/src/main.rs b/crates/miyabi-cli/src/main.rs
index a4dab59..9f82b9d 100644
--- a/crates/miyabi-cli/src/main.rs
+++ b/crates/miyabi-cli/src/main.rs
@@ -4,6 +4,8 @@ use chrono::Duration as ChronoDuration;
use clap::{Parser, Subcommand, ValueEnum};
use miyabi_core::{FeatureFlagManager, RulesLoader};
use std::collections::HashMap;
+use std::io::{self, BufRead, BufReader, Write};
+use std::net::{TcpListener, TcpStream};
use std::path::PathBuf;
use tracing_subscriber::EnvFilter;
@@ -214,6 +216,12 @@ enum GateCommand {
Dag,
/// Show dispatchable tasks
Dispatchable,
+ /// Serve a minimal web dashboard
+ Serve {
+ /// Port to bind the dashboard to
+ #[arg(long, default_value_t = 4848)]
+ port: u16,
+ },
/// Analyze recent event logs and extract learnings
Dream {
/// Analyze only recent events, e.g. 24h, 30m, 7d
@@ -1368,6 +1376,10 @@ fn handle_gate_command(
}
}
}),
+ GateCommand::Serve { port } => {
+ serve_dashboard(store_path, port)?;
+ Ok(())
+ }
GateCommand::Dream { since, auto } => {
let since = since
.as_deref()
@@ -1516,6 +1528,377 @@ fn emit_gate_error(format: &OutputFormat, kind: &str, message: &str) {
}
}
+const POLARIS_DASHBOARD_HTML: &str = r##"
+
+
+
+
+ Polaris Dashboard
+
+
+
+
+
+
+
+ Tasks
+
+
+
+ DAG Levels
+
+
+
+ File Locks
+
+
+
+
+
+
+
+"##;
+
+fn serve_dashboard(store_path: &std::path::Path, port: u16) -> anyhow::Result<()> {
+ let protocol = miyabi_core::protocol::DeterministicExecutionProtocol::from_store_path(
+ store_path.to_path_buf(),
+ );
+ let listener = TcpListener::bind(("127.0.0.1", port))?;
+ println!("Polaris Dashboard listening on http://127.0.0.1:{port}");
+
+ for stream in listener.incoming() {
+ match stream {
+ Ok(mut stream) => {
+ if let Err(error) = handle_dashboard_connection(&protocol, &mut stream) {
+ eprintln!("dashboard request error: {error}");
+ }
+ }
+ Err(error) => eprintln!("dashboard accept error: {error}"),
+ }
+ }
+
+ Ok(())
+}
+
+fn handle_dashboard_connection(
+ protocol: &miyabi_core::protocol::DeterministicExecutionProtocol,
+ stream: &mut TcpStream,
+) -> anyhow::Result<()> {
+ let mut request_line = String::new();
+ let mut reader = BufReader::new(stream.try_clone()?);
+ reader.read_line(&mut request_line)?;
+
+ let mut parts = request_line.split_whitespace();
+ let method = parts.next().unwrap_or_default();
+ let path = parts.next().unwrap_or("/");
+
+ if method != "GET" {
+ write_http_response(
+ stream,
+ "405 Method Not Allowed",
+ "text/plain; charset=utf-8",
+ b"method not allowed",
+ )?;
+ return Ok(());
+ }
+
+ match path {
+ "/" => write_http_response(
+ stream,
+ "200 OK",
+ "text/html; charset=utf-8",
+ POLARIS_DASHBOARD_HTML.as_bytes(),
+ )?,
+ "/api/status" => {
+ let body =
+ serde_json::to_vec_pretty(&protocol.status(None)?).map_err(io::Error::other)?;
+ write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body)?;
+ }
+ "/api/locks" => {
+ let body = serde_json::to_vec_pretty(&protocol.locks()?).map_err(io::Error::other)?;
+ write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body)?;
+ }
+ "/api/dag" => {
+ let body = serde_json::to_vec_pretty(&protocol.dag()?).map_err(io::Error::other)?;
+ write_http_response(stream, "200 OK", "application/json; charset=utf-8", &body)?;
+ }
+ _ => write_http_response(
+ stream,
+ "404 Not Found",
+ "text/plain; charset=utf-8",
+ b"not found",
+ )?,
+ }
+
+ Ok(())
+}
+
+fn write_http_response(
+ stream: &mut TcpStream,
+ status: &str,
+ content_type: &str,
+ body: &[u8],
+) -> io::Result<()> {
+ write!(
+ stream,
+ "HTTP/1.1 {status}\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
+ body.len()
+ )?;
+ stream.write_all(body)?;
+ stream.flush()
+}
+
fn derive_task_id(issue: u64, title: &str) -> String {
if issue > 0 {
return format!("issue-{issue}");