Compare commits
5 changed files with 184 additions and 161 deletions
|
|
@ -56,16 +56,24 @@ async fn main() -> Result<()> {
|
||||||
let initial = LoginState::from_dir(&claude_dir);
|
let initial = LoginState::from_dir(&claude_dir);
|
||||||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
|
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "harness boot");
|
||||||
let login_state = Arc::new(Mutex::new(initial));
|
let login_state = Arc::new(Mutex::new(initial));
|
||||||
|
let ui_state = login_state.clone();
|
||||||
let bus = Bus::new();
|
let bus = Bus::new();
|
||||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Agent).await?;
|
let ui_bus = bus.clone();
|
||||||
tokio::spawn(web_ui::serve(
|
let ui_socket = cli.socket.clone();
|
||||||
label,
|
tokio::spawn(async move {
|
||||||
port,
|
if let Err(e) = web_ui::serve(
|
||||||
login_state.clone(),
|
label,
|
||||||
bus.clone(),
|
port,
|
||||||
cli.socket.clone(),
|
ui_state,
|
||||||
files.clone(),
|
ui_bus,
|
||||||
));
|
ui_socket,
|
||||||
|
web_ui::Flavor::Agent,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = ?e, "web ui failed");
|
||||||
|
}
|
||||||
|
});
|
||||||
match initial {
|
match initial {
|
||||||
LoginState::Online => {
|
LoginState::Online => {
|
||||||
serve(
|
serve(
|
||||||
|
|
@ -73,7 +81,6 @@ async fn main() -> Result<()> {
|
||||||
Duration::from_millis(poll_ms),
|
Duration::from_millis(poll_ms),
|
||||||
login_state,
|
login_state,
|
||||||
bus,
|
bus,
|
||||||
&files,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -87,7 +94,6 @@ async fn main() -> Result<()> {
|
||||||
Duration::from_millis(poll_ms),
|
Duration::from_millis(poll_ms),
|
||||||
login_state,
|
login_state,
|
||||||
bus,
|
bus,
|
||||||
&files,
|
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
@ -102,10 +108,13 @@ async fn serve(
|
||||||
interval: Duration,
|
interval: Duration,
|
||||||
state: Arc<Mutex<LoginState>>,
|
state: Arc<Mutex<LoginState>>,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
files: &turn::TurnFiles,
|
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
|
tracing::info!(socket = %socket.display(), "hive-ag3nt serve");
|
||||||
let _ = state; // reserved for future state transitions (turn-loop -> needs-login)
|
let _ = state; // reserved for future state transitions (turn-loop -> needs-login)
|
||||||
|
let mcp_config = turn::write_mcp_config(socket).await?;
|
||||||
|
let settings = turn::write_settings(socket).await?;
|
||||||
|
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hive-ag3nt".into());
|
||||||
|
let system_prompt = turn::write_system_prompt(socket, &label, mcp::Flavor::Agent).await?;
|
||||||
loop {
|
loop {
|
||||||
let recv: Result<AgentResponse> =
|
let recv: Result<AgentResponse> =
|
||||||
client::request(socket, &AgentRequest::Recv { wait_seconds: None }).await;
|
client::request(socket, &AgentRequest::Recv { wait_seconds: None }).await;
|
||||||
|
|
@ -120,7 +129,15 @@ async fn serve(
|
||||||
});
|
});
|
||||||
bus.set_state(TurnState::Thinking);
|
bus.set_state(TurnState::Thinking);
|
||||||
let prompt = format_wake_prompt(&from, &body, unread);
|
let prompt = format_wake_prompt(&from, &body, unread);
|
||||||
let outcome = turn::drive_turn(&prompt, files, &bus).await;
|
let outcome = turn::drive_turn(
|
||||||
|
&prompt,
|
||||||
|
&mcp_config,
|
||||||
|
&system_prompt,
|
||||||
|
&settings,
|
||||||
|
&bus,
|
||||||
|
mcp::Flavor::Agent,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
turn::emit_turn_end(&bus, &outcome);
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
bus.set_state(TurnState::Idle);
|
bus.set_state(TurnState::Idle);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -59,23 +59,29 @@ async fn main() -> Result<()> {
|
||||||
let initial = LoginState::from_dir(&claude_dir);
|
let initial = LoginState::from_dir(&claude_dir);
|
||||||
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot");
|
tracing::info!(state = ?initial, claude_dir = %claude_dir.display(), "hm1nd boot");
|
||||||
let login_state = Arc::new(Mutex::new(initial));
|
let login_state = Arc::new(Mutex::new(initial));
|
||||||
|
let ui_state = login_state.clone();
|
||||||
let bus = Bus::new();
|
let bus = Bus::new();
|
||||||
let files = turn::TurnFiles::prepare(&cli.socket, &label, mcp::Flavor::Manager).await?;
|
let ui_bus = bus.clone();
|
||||||
tokio::spawn(web_ui::serve(
|
let ui_socket = cli.socket.clone();
|
||||||
label,
|
tokio::spawn(async move {
|
||||||
port,
|
if let Err(e) = web_ui::serve(
|
||||||
login_state.clone(),
|
label,
|
||||||
bus.clone(),
|
port,
|
||||||
cli.socket.clone(),
|
ui_state,
|
||||||
files.clone(),
|
ui_bus,
|
||||||
));
|
ui_socket,
|
||||||
match initial {
|
web_ui::Flavor::Manager,
|
||||||
LoginState::Online => {
|
)
|
||||||
serve(&cli.socket, Duration::from_millis(poll_ms), bus, &files).await
|
.await
|
||||||
|
{
|
||||||
|
tracing::error!(error = ?e, "web ui failed");
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
match initial {
|
||||||
|
LoginState::Online => serve(&cli.socket, Duration::from_millis(poll_ms), bus).await,
|
||||||
LoginState::NeedsLogin => {
|
LoginState::NeedsLogin => {
|
||||||
turn::wait_for_login(&claude_dir, login_state, poll_ms).await;
|
turn::wait_for_login(&claude_dir, login_state, poll_ms).await;
|
||||||
serve(&cli.socket, Duration::from_millis(poll_ms), bus, &files).await
|
serve(&cli.socket, Duration::from_millis(poll_ms), bus).await
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -83,13 +89,12 @@ async fn main() -> Result<()> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn serve(
|
async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
||||||
socket: &Path,
|
|
||||||
interval: Duration,
|
|
||||||
bus: Bus,
|
|
||||||
files: &turn::TurnFiles,
|
|
||||||
) -> Result<()> {
|
|
||||||
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
tracing::info!(socket = %socket.display(), "hive-m1nd serve");
|
||||||
|
let mcp_config = turn::write_mcp_config(socket).await?;
|
||||||
|
let settings = turn::write_settings(socket).await?;
|
||||||
|
let label = std::env::var("HIVE_LABEL").unwrap_or_else(|_| "hm1nd".into());
|
||||||
|
let system_prompt = turn::write_system_prompt(socket, &label, mcp::Flavor::Manager).await?;
|
||||||
loop {
|
loop {
|
||||||
let recv: Result<ManagerResponse> =
|
let recv: Result<ManagerResponse> =
|
||||||
client::request(socket, &ManagerRequest::Recv { wait_seconds: None }).await;
|
client::request(socket, &ManagerRequest::Recv { wait_seconds: None }).await;
|
||||||
|
|
@ -121,7 +126,15 @@ async fn serve(
|
||||||
});
|
});
|
||||||
let prompt = format_wake_prompt(&from, &body, unread);
|
let prompt = format_wake_prompt(&from, &body, unread);
|
||||||
bus.set_state(TurnState::Thinking);
|
bus.set_state(TurnState::Thinking);
|
||||||
let outcome = turn::drive_turn(&prompt, files, &bus).await;
|
let outcome = turn::drive_turn(
|
||||||
|
&prompt,
|
||||||
|
&mcp_config,
|
||||||
|
&system_prompt,
|
||||||
|
&settings,
|
||||||
|
&bus,
|
||||||
|
mcp::Flavor::Manager,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
turn::emit_turn_end(&bus, &outcome);
|
turn::emit_turn_end(&bus, &outcome);
|
||||||
bus.set_state(TurnState::Idle);
|
bus.set_state(TurnState::Idle);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,34 +33,6 @@ const CLAUDE_SETTINGS: &str = include_str!("../prompts/claude-settings.json");
|
||||||
/// claude exit with a useful error in the live view.
|
/// claude exit with a useful error in the live view.
|
||||||
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
|
const PROMPT_TOO_LONG_MARKER: &str = "Prompt is too long";
|
||||||
|
|
||||||
/// The set of files claude reads on every invocation: the MCP server
|
|
||||||
/// config (`--mcp-config`), static settings (`--settings`), and the
|
|
||||||
/// pre-rendered role/tools system prompt (`--system-prompt-file`).
|
|
||||||
/// Materialised once at harness startup; shared between the turn loop
|
|
||||||
/// and the operator-driven `/compact` path so both invocations look
|
|
||||||
/// identical to claude (same MCP surface, same allowed tools, same
|
|
||||||
/// role prompt — only the stdin payload differs).
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct TurnFiles {
|
|
||||||
pub mcp_config: PathBuf,
|
|
||||||
pub settings: PathBuf,
|
|
||||||
pub system_prompt: PathBuf,
|
|
||||||
pub flavor: mcp::Flavor,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl TurnFiles {
|
|
||||||
/// Write all three files into the per-agent runtime dir alongside
|
|
||||||
/// `socket`. Idempotent — overwrites whatever was there.
|
|
||||||
pub async fn prepare(socket: &Path, label: &str, flavor: mcp::Flavor) -> Result<Self> {
|
|
||||||
Ok(Self {
|
|
||||||
mcp_config: write_mcp_config(socket).await?,
|
|
||||||
settings: write_settings(socket).await?,
|
|
||||||
system_prompt: write_system_prompt(socket, label, flavor).await?,
|
|
||||||
flavor,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
/// Drop the MCP config blob claude reads from `--mcp-config <path>`.
|
||||||
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
/// `socket` is the hyperhive per-container socket (forwarded to the child
|
||||||
/// as `--socket <path>`); `binary_subcommand` is e.g. `"mcp"` for sub-agents
|
/// as `--socket <path>`); `binary_subcommand` is e.g. `"mcp"` for sub-agents
|
||||||
|
|
@ -127,14 +99,21 @@ pub enum TurnOutcome {
|
||||||
|
|
||||||
/// Drive one turn end-to-end, transparently compacting + retrying once on
|
/// Drive one turn end-to-end, transparently compacting + retrying once on
|
||||||
/// `Prompt is too long`. Both the sub-agent and manager loops call this.
|
/// `Prompt is too long`. Both the sub-agent and manager loops call this.
|
||||||
pub async fn drive_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
pub async fn drive_turn(
|
||||||
match run_turn(prompt, files, bus).await {
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
system_prompt: &Path,
|
||||||
|
settings: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
) -> TurnOutcome {
|
||||||
|
match run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await {
|
||||||
TurnOutcome::PromptTooLong => {
|
TurnOutcome::PromptTooLong => {
|
||||||
if let Err(e) = compact_session(files, bus).await {
|
if let Err(e) = compact_session(settings, bus).await {
|
||||||
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
tracing::warn!(error = %format!("{e:#}"), "compact failed");
|
||||||
return TurnOutcome::Failed(e);
|
return TurnOutcome::Failed(e);
|
||||||
}
|
}
|
||||||
run_turn(prompt, files, bus).await
|
run_turn(prompt, mcp_config, system_prompt, settings, bus, flavor).await
|
||||||
}
|
}
|
||||||
other => other,
|
other => other,
|
||||||
}
|
}
|
||||||
|
|
@ -187,8 +166,25 @@ pub async fn wait_for_login(claude_dir: &Path, state: Arc<Mutex<LoginState>>, po
|
||||||
/// prompt). The session is persistent across turns via `--continue` and
|
/// prompt). The session is persistent across turns via `--continue` and
|
||||||
/// claude's in-session auto-compact is disabled via `--settings` so it
|
/// claude's in-session auto-compact is disabled via `--settings` so it
|
||||||
/// doesn't stall mid-turn — hyperhive owns compaction.
|
/// doesn't stall mid-turn — hyperhive owns compaction.
|
||||||
pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome {
|
pub async fn run_turn(
|
||||||
match run_claude(prompt, files, bus).await {
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
system_prompt: &Path,
|
||||||
|
settings: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
) -> TurnOutcome {
|
||||||
|
match run_claude(
|
||||||
|
prompt,
|
||||||
|
mcp_config,
|
||||||
|
Some(system_prompt),
|
||||||
|
settings,
|
||||||
|
bus,
|
||||||
|
flavor,
|
||||||
|
ClaudeMode::Turn,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
Ok(too_long) if too_long => TurnOutcome::PromptTooLong,
|
Ok(too_long) if too_long => TurnOutcome::PromptTooLong,
|
||||||
Ok(_) => TurnOutcome::Ok,
|
Ok(_) => TurnOutcome::Ok,
|
||||||
Err(e) => TurnOutcome::Failed(e),
|
Err(e) => TurnOutcome::Failed(e),
|
||||||
|
|
@ -196,23 +192,49 @@ pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run claude's built-in `/compact` slash command on the persistent
|
/// Run claude's built-in `/compact` slash command on the persistent
|
||||||
/// session. Takes the *same* params as `run_turn` because compact
|
/// session so the next turn can fit. No MCP tools needed; we just feed
|
||||||
/// re-initialises claude with the full session shape — same MCP
|
/// `/compact` over stdin and let claude rewrite its own history.
|
||||||
/// surface, same system prompt, same allowed-tools — so the post-
|
pub async fn compact_session(settings: &Path, bus: &Bus) -> Result<()> {
|
||||||
/// compact state matches a normal turn's. Only the prompt over stdin
|
|
||||||
/// differs (`/compact` vs the wake-up payload).
|
|
||||||
pub async fn compact_session(files: &TurnFiles, bus: &Bus) -> Result<()> {
|
|
||||||
bus.emit(LiveEvent::Note(
|
bus.emit(LiveEvent::Note(
|
||||||
"context overflow — running /compact on the persistent session".into(),
|
"context overflow — running /compact on the persistent session".into(),
|
||||||
));
|
));
|
||||||
let _ = run_claude("/compact", files, bus).await?;
|
let _ = run_claude(
|
||||||
|
"/compact",
|
||||||
|
Path::new("/dev/null"),
|
||||||
|
None,
|
||||||
|
settings,
|
||||||
|
bus,
|
||||||
|
mcp::Flavor::Agent, // tool surface unused for /compact
|
||||||
|
ClaudeMode::Compact,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
bus.emit(LiveEvent::Note("/compact done".into()));
|
bus.emit(LiveEvent::Note("/compact done".into()));
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<bool> {
|
#[derive(Clone, Copy)]
|
||||||
|
enum ClaudeMode {
|
||||||
|
Turn,
|
||||||
|
Compact,
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn run_claude(
|
||||||
|
prompt: &str,
|
||||||
|
mcp_config: &Path,
|
||||||
|
system_prompt: Option<&Path>,
|
||||||
|
settings: &Path,
|
||||||
|
bus: &Bus,
|
||||||
|
flavor: mcp::Flavor,
|
||||||
|
mode: ClaudeMode,
|
||||||
|
) -> Result<bool> {
|
||||||
let model = bus.model();
|
let model = bus.model();
|
||||||
let resume = !bus.take_skip_continue();
|
// /compact must always run against the existing session — otherwise
|
||||||
|
// there's nothing to compact. Only normal turns honor the
|
||||||
|
// operator's "new session" one-shot flag.
|
||||||
|
let resume = match mode {
|
||||||
|
ClaudeMode::Turn => !bus.take_skip_continue(),
|
||||||
|
ClaudeMode::Compact => true,
|
||||||
|
};
|
||||||
if !resume {
|
if !resume {
|
||||||
bus.emit(LiveEvent::Note(
|
bus.emit(LiveEvent::Note(
|
||||||
"fresh session (--continue suppressed for this turn)".into(),
|
"fresh session (--continue suppressed for this turn)".into(),
|
||||||
|
|
@ -236,18 +258,22 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<bool>
|
||||||
.arg("--model")
|
.arg("--model")
|
||||||
.arg(&model)
|
.arg(&model)
|
||||||
.arg("--settings")
|
.arg("--settings")
|
||||||
.arg(&files.settings);
|
.arg(settings);
|
||||||
if resume {
|
if resume {
|
||||||
cmd.arg("--continue");
|
cmd.arg("--continue");
|
||||||
}
|
}
|
||||||
cmd.arg("--system-prompt-file").arg(&files.system_prompt);
|
if let Some(p) = system_prompt {
|
||||||
cmd.arg("--mcp-config")
|
cmd.arg("--system-prompt-file").arg(p);
|
||||||
.arg(&files.mcp_config)
|
}
|
||||||
.arg("--strict-mcp-config")
|
if let ClaudeMode::Turn = mode {
|
||||||
.arg("--tools")
|
cmd.arg("--mcp-config")
|
||||||
.arg(mcp::builtin_tools_arg())
|
.arg(mcp_config)
|
||||||
.arg("--allowedTools")
|
.arg("--strict-mcp-config")
|
||||||
.arg(mcp::allowed_tools_arg(files.flavor));
|
.arg("--tools")
|
||||||
|
.arg(mcp::builtin_tools_arg())
|
||||||
|
.arg("--allowedTools")
|
||||||
|
.arg(mcp::allowed_tools_arg(flavor));
|
||||||
|
}
|
||||||
let mut child = cmd
|
let mut child = cmd
|
||||||
.stdin(Stdio::piped())
|
.stdin(Stdio::piped())
|
||||||
.stdout(Stdio::piped())
|
.stdout(Stdio::piped())
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,6 @@ use crate::client;
|
||||||
use crate::events::Bus;
|
use crate::events::Bus;
|
||||||
use crate::login::LoginState;
|
use crate::login::LoginState;
|
||||||
use crate::login_session::{LoginSession, drop_if_finished};
|
use crate::login_session::{LoginSession, drop_if_finished};
|
||||||
use crate::mcp;
|
|
||||||
use crate::turn::TurnFiles;
|
|
||||||
|
|
||||||
/// Live login state for the web UI. The harness updates this in place as it
|
/// Live login state for the web UI. The harness updates this in place as it
|
||||||
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
/// transitions between `NeedsLogin` and `Online`; the UI reads on each
|
||||||
|
|
@ -43,25 +41,16 @@ struct AppState {
|
||||||
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
session: Arc<Mutex<Option<Arc<LoginSession>>>>,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
/// Same `TurnFiles` the harness's turn loop uses. Shared so
|
flavor: Flavor,
|
||||||
/// `/api/compact` re-uses the exact MCP config / system prompt /
|
|
||||||
/// settings claude saw on the last regular turn — keeps the
|
|
||||||
/// session shape identical across compact + normal turns.
|
|
||||||
files: TurnFiles,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl AppState {
|
|
||||||
fn flavor(&self) -> Flavor {
|
|
||||||
self.files.flavor
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Which wire protocol the per-agent UI's `/send` handler should speak.
|
/// Which wire protocol the per-agent UI's `/send` handler should speak.
|
||||||
/// Sub-agent → `AgentRequest::OperatorMsg`; manager →
|
/// Sub-agent → `AgentRequest::OperatorMsg`; manager → `ManagerRequest::OperatorMsg`.
|
||||||
/// `ManagerRequest::OperatorMsg`. Reuses the MCP-side enum so a
|
#[derive(Debug, Clone, Copy)]
|
||||||
/// single value drives both the send protocol and (in
|
pub enum Flavor {
|
||||||
/// `post_compact`) the allowed-tools surface claude sees.
|
Agent,
|
||||||
pub type Flavor = mcp::Flavor;
|
Manager,
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn serve(
|
pub async fn serve(
|
||||||
label: String,
|
label: String,
|
||||||
|
|
@ -69,7 +58,7 @@ pub async fn serve(
|
||||||
login: LoginStateCell,
|
login: LoginStateCell,
|
||||||
bus: Bus,
|
bus: Bus,
|
||||||
socket: PathBuf,
|
socket: PathBuf,
|
||||||
files: TurnFiles,
|
flavor: Flavor,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let state = AppState {
|
let state = AppState {
|
||||||
label,
|
label,
|
||||||
|
|
@ -77,7 +66,7 @@ pub async fn serve(
|
||||||
session: Arc::new(Mutex::new(None)),
|
session: Arc::new(Mutex::new(None)),
|
||||||
bus,
|
bus,
|
||||||
socket,
|
socket,
|
||||||
files,
|
flavor,
|
||||||
};
|
};
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(serve_index))
|
.route("/", get(serve_index))
|
||||||
|
|
@ -219,7 +208,7 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|s| s.parse::<u16>().ok())
|
.and_then(|s| s.parse::<u16>().ok())
|
||||||
.unwrap_or(7000);
|
.unwrap_or(7000);
|
||||||
let inbox = recent_inbox(&state.socket, state.flavor()).await;
|
let inbox = recent_inbox(&state.socket, state.flavor).await;
|
||||||
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
let (turn_state, turn_state_since) = state.bus.state_snapshot();
|
||||||
let model = state.bus.model();
|
let model = state.bus.model();
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
|
|
@ -279,7 +268,7 @@ async fn post_send(State(state): State<AppState>, Form(form): Form<SendForm>) ->
|
||||||
if body.is_empty() {
|
if body.is_empty() {
|
||||||
return error_response("send: `body` required");
|
return error_response("send: `body` required");
|
||||||
}
|
}
|
||||||
let result = match state.flavor() {
|
let result = match state.flavor {
|
||||||
Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>(
|
Flavor::Agent => match client::request::<_, hive_sh4re::AgentResponse>(
|
||||||
&state.socket,
|
&state.socket,
|
||||||
&hive_sh4re::AgentRequest::OperatorMsg { body },
|
&hive_sh4re::AgentRequest::OperatorMsg { body },
|
||||||
|
|
@ -407,13 +396,22 @@ async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelFor
|
||||||
|
|
||||||
async fn post_compact(State(state): State<AppState>) -> Response {
|
async fn post_compact(State(state): State<AppState>) -> Response {
|
||||||
let bus = state.bus.clone();
|
let bus = state.bus.clone();
|
||||||
let files = state.files.clone();
|
let socket = state.socket.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
bus.emit(crate::events::LiveEvent::Note(
|
bus.emit(crate::events::LiveEvent::Note(
|
||||||
"operator: /compact — running on persistent session".into(),
|
"operator: /compact — running on persistent session".into(),
|
||||||
));
|
));
|
||||||
|
let settings = match crate::turn::write_settings(&socket).await {
|
||||||
|
Ok(p) => p,
|
||||||
|
Err(e) => {
|
||||||
|
bus.emit(crate::events::LiveEvent::Note(format!(
|
||||||
|
"/compact failed: settings write — {e:#}"
|
||||||
|
)));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
bus.set_state(crate::events::TurnState::Compacting);
|
bus.set_state(crate::events::TurnState::Compacting);
|
||||||
let r = crate::turn::compact_session(&files, &bus).await;
|
let r = crate::turn::compact_session(&settings, &bus).await;
|
||||||
bus.set_state(crate::events::TurnState::Idle);
|
bus.set_state(crate::events::TurnState::Idle);
|
||||||
if let Err(e) = r {
|
if let Err(e) = r {
|
||||||
bus.emit(crate::events::LiveEvent::Note(format!(
|
bus.emit(crate::events::LiveEvent::Note(format!(
|
||||||
|
|
|
||||||
|
|
@ -73,18 +73,8 @@ pub async fn sync_agents(
|
||||||
if initial {
|
if initial {
|
||||||
git(&dir, &["init", "--initial-branch=main"]).await?;
|
git(&dir, &["init", "--initial-branch=main"]).await?;
|
||||||
}
|
}
|
||||||
// Stage flake.nix *before* running nix flake lock. When meta is
|
|
||||||
// a git repo, nix treats it as a `git+file://` self-reference;
|
|
||||||
// its dirty-tree fetcher includes index entries (tracked +
|
|
||||||
// staged) but skips untracked files, so without the stage step
|
|
||||||
// an untracked flake.nix surfaces as "source tree does not
|
|
||||||
// contain '/flake.nix'". Lock then commit once with both
|
|
||||||
// flake.nix and flake.lock — single commit per change.
|
|
||||||
git(&dir, &["add", "flake.nix"]).await?;
|
|
||||||
nix(&dir, &["flake", "lock"]).await?;
|
nix(&dir, &["flake", "lock"]).await?;
|
||||||
if std::path::Path::new(&dir).join("flake.lock").exists() {
|
git(&dir, &["add", "-A"]).await?;
|
||||||
git(&dir, &["add", "flake.lock"]).await?;
|
|
||||||
}
|
|
||||||
let msg = if initial {
|
let msg = if initial {
|
||||||
format!("seed meta from {} agent(s)", agents.len())
|
format!("seed meta from {} agent(s)", agents.len())
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -96,60 +86,39 @@ pub async fn sync_agents(
|
||||||
|
|
||||||
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
|
/// Phase 1 of an apply-commit deploy. Updates the locked rev of
|
||||||
/// `agent-<name>` to whatever `applied/<name>/main` currently points
|
/// `agent-<name>` to whatever `applied/<name>/main` currently points
|
||||||
/// at and **stages** the lock so `nixos-container update --flake
|
/// at. **Doesn't commit** — caller must follow with
|
||||||
/// meta#<n>` (which reads via `git+file://`) sees the new rev via
|
/// `finalize_deploy` on build success or `abort_deploy` on failure.
|
||||||
/// the index. Doesn't commit — `finalize_deploy` commits on build
|
|
||||||
/// success, `abort_deploy` drops the staged change on failure so
|
|
||||||
/// meta history only carries successful deploys.
|
|
||||||
#[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit
|
#[allow(dead_code)] // wired up by actions::run_apply_commit in a later commit
|
||||||
pub async fn prepare_deploy(name: &str) -> Result<()> {
|
pub async fn prepare_deploy(name: &str) -> Result<()> {
|
||||||
let dir = meta_dir();
|
let dir = meta_dir();
|
||||||
let input = format!("agent-{name}");
|
let input = format!("agent-{name}");
|
||||||
nix(&dir, &["flake", "update", &input]).await?;
|
nix(&dir, &["flake", "update", &input]).await
|
||||||
// Stage the new lock — git+file://'s dirty-tree fetcher reads
|
|
||||||
// index entries, so the upcoming nixos-container update sees the
|
|
||||||
// bumped rev without a commit yet.
|
|
||||||
git(&dir, &["add", "flake.lock"]).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2-success. Commit the staged lock with the deployed tag +
|
/// Phase 2-success. Commits the staged `flake.lock` change with a
|
||||||
/// sha as the message. No-op when the rev was already at the right
|
/// deploy-shaped message. No-op (clean working tree) is tolerated —
|
||||||
/// place (nothing staged → nothing to commit).
|
/// some lock-updates resolve to the same rev that's already locked.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
|
pub async fn finalize_deploy(name: &str, sha: &str, tag: &str) -> Result<()> {
|
||||||
let dir = meta_dir();
|
let dir = meta_dir();
|
||||||
if !has_staged_changes(&dir).await? {
|
if git_is_clean(&dir).await? {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
|
git(&dir, &["add", "flake.lock"]).await?;
|
||||||
let short = &sha[..sha.len().min(12)];
|
let short = &sha[..sha.len().min(12)];
|
||||||
git_commit(&dir, &format!("deploy {name} {tag} {short}")).await
|
git_commit(&dir, &format!("deploy {name} {tag} {short}")).await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Phase 2-failure. Unstage + restore the lock so meta returns to
|
/// Phase 2-failure. Drops the uncommitted `flake.lock` change so meta
|
||||||
/// the previously-committed shas. The failed proposal is still
|
/// stays pinned at the previously-deployed shas. The failed proposal
|
||||||
/// captured in `applied/<n>`'s annotated `failed/<id>` tag.
|
/// is still captured in `applied/<n>`'s annotated `failed/<id>` tag —
|
||||||
|
/// meta's history only carries successful deploys.
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn abort_deploy() -> Result<()> {
|
pub async fn abort_deploy() -> Result<()> {
|
||||||
let dir = meta_dir();
|
let dir = meta_dir();
|
||||||
git(&dir, &["restore", "--staged", "flake.lock"]).await?;
|
|
||||||
git(&dir, &["restore", "flake.lock"]).await
|
git(&dir, &["restore", "flake.lock"]).await
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn has_staged_changes(dir: &Path) -> Result<bool> {
|
|
||||||
let st = lifecycle::git_command()
|
|
||||||
.current_dir(dir)
|
|
||||||
.args(["diff", "--cached", "--quiet"])
|
|
||||||
.status()
|
|
||||||
.await
|
|
||||||
.with_context(|| format!("git diff --cached in {}", dir.display()))?;
|
|
||||||
// exit 1 = differences present, 0 = no diff, other = error
|
|
||||||
match st.code() {
|
|
||||||
Some(0) => Ok(false),
|
|
||||||
Some(1) => Ok(true),
|
|
||||||
_ => bail!("git diff --cached exited unexpectedly"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// One-shot used by the manual-rebuild path: relock just one
|
/// One-shot used by the manual-rebuild path: relock just one
|
||||||
/// agent's input and commit the lock change if any. Single-phase
|
/// agent's input and commit the lock change if any. Single-phase
|
||||||
/// (no separate finalize) because rebuild has no failure-revert
|
/// (no separate finalize) because rebuild has no failure-revert
|
||||||
|
|
@ -159,11 +128,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
||||||
let dir = meta_dir();
|
let dir = meta_dir();
|
||||||
let input = format!("agent-{name}");
|
let input = format!("agent-{name}");
|
||||||
nix(&dir, &["flake", "update", &input]).await?;
|
nix(&dir, &["flake", "update", &input]).await?;
|
||||||
if git_is_clean(&dir).await? {
|
if !git_is_clean(&dir).await? {
|
||||||
return Ok(());
|
git(&dir, &["add", "flake.lock"]).await?;
|
||||||
|
git_commit(&dir, &format!("rebuild {name}: lock update")).await?;
|
||||||
}
|
}
|
||||||
git(&dir, &["add", "flake.lock"]).await?;
|
Ok(())
|
||||||
git_commit(&dir, &format!("rebuild {name}: lock update")).await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One-shot used by the auto-update path: pin the latest hyperhive
|
/// One-shot used by the auto-update path: pin the latest hyperhive
|
||||||
|
|
@ -173,11 +142,11 @@ pub async fn lock_update_for_rebuild(name: &str) -> Result<()> {
|
||||||
pub async fn lock_update_hyperhive() -> Result<()> {
|
pub async fn lock_update_hyperhive() -> Result<()> {
|
||||||
let dir = meta_dir();
|
let dir = meta_dir();
|
||||||
nix(&dir, &["flake", "update", "hyperhive"]).await?;
|
nix(&dir, &["flake", "update", "hyperhive"]).await?;
|
||||||
if git_is_clean(&dir).await? {
|
if !git_is_clean(&dir).await? {
|
||||||
return Ok(());
|
git(&dir, &["add", "flake.lock"]).await?;
|
||||||
|
git_commit(&dir, "bump hyperhive").await?;
|
||||||
}
|
}
|
||||||
git(&dir, &["add", "flake.lock"]).await?;
|
Ok(())
|
||||||
git_commit(&dir, "bump hyperhive").await
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]) -> String {
|
fn render_flake(hyperhive_flake: &str, dashboard_port: u16, agents: &[AgentSpec]) -> String {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue