huskies: merge 1144 story Gateway trampoline-restart: detached helper survives the gateway's own death
This commit is contained in:
@@ -99,6 +99,11 @@ pub struct BotContext {
|
||||
/// each event is processed at most once. Insert the event ID before any
|
||||
/// side-effecting work; return early if the insert returns `false`.
|
||||
pub handled_incoming_event_ids: Arc<TokioMutex<SeenEventIds>>,
|
||||
/// In gateway mode: the port the gateway is listening on.
|
||||
///
|
||||
/// Used by the "rebuild gateway" command to construct the health-check URL
|
||||
/// passed to the trampoline. `None` in standalone single-project mode.
|
||||
pub gateway_port: Option<u16>,
|
||||
}
|
||||
|
||||
impl BotContext {
|
||||
@@ -293,6 +298,7 @@ mod tests {
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(SeenEventIds::new(
|
||||
SEEN_EVENT_IDS_CAP,
|
||||
))),
|
||||
gateway_port: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,23 @@ pub fn format_startup_announcement(bot_name: &str) -> String {
|
||||
format!("{bot_name} is online.")
|
||||
}
|
||||
|
||||
/// Format the ready announcement sent after a successful gateway trampoline restart.
|
||||
///
|
||||
/// Returns "gateway X.Y.Z ready" using the compiled-in crate version so the
|
||||
/// operator can confirm which binary is running after a rebuild.
|
||||
pub fn format_gateway_ready_announcement() -> String {
|
||||
format!("gateway {} ready", env!("CARGO_PKG_VERSION"))
|
||||
}
|
||||
|
||||
/// Format the failure announcement sent when the trampoline rolls back to the
|
||||
/// previous binary.
|
||||
///
|
||||
/// `reason` is the human-readable failure description from the trampoline
|
||||
/// (e.g. "port 3000 already in use").
|
||||
pub fn format_gateway_rollback_announcement(reason: &str) -> String {
|
||||
format!("Gateway rebuild failed: {reason}. Previous version restored.")
|
||||
}
|
||||
|
||||
/// Convert a Markdown string to an HTML string using pulldown-cmark.
|
||||
///
|
||||
/// Enables the standard extension set (tables, footnotes, strikethrough,
|
||||
|
||||
@@ -19,6 +19,28 @@ use super::super::verification::check_sender_verified;
|
||||
|
||||
use super::handle_message;
|
||||
|
||||
/// Return `true` when the message is a "rebuild gateway" command addressed to the bot.
|
||||
///
|
||||
/// The command is recognised case-insensitively as `rebuild gateway` after stripping
|
||||
/// the bot mention prefix so both `@Timmy rebuild gateway` and `Timmy rebuild gateway`
|
||||
/// match.
|
||||
fn extract_rebuild_gateway_command(message: &str, bot_name: &str, bot_user_id: &str) -> bool {
|
||||
let stripped = crate::chat::util::strip_bot_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
let (cmd, rest) = match trimmed.split_once(char::is_whitespace) {
|
||||
Some((c, r)) => (c, r.trim()),
|
||||
None => return false,
|
||||
};
|
||||
cmd.eq_ignore_ascii_case("rebuild")
|
||||
&& rest
|
||||
.split_whitespace()
|
||||
.next()
|
||||
.map(|w| w.eq_ignore_ascii_case("gateway"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Evaluate a `switch <arg>` command against the live project store.
|
||||
///
|
||||
/// Reads valid project names from the store at call time so newly added
|
||||
@@ -657,6 +679,87 @@ pub(in crate::chat::transport::matrix::bot) async fn on_room_message(
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, intercept "rebuild gateway" and route it through the
|
||||
// detached trampoline so the process swap survives any bash-tool kill cascade.
|
||||
if ctx.gateway_active_project.is_some()
|
||||
&& extract_rebuild_gateway_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
{
|
||||
slog!("[matrix-bot] Handling 'rebuild gateway' command from {sender}");
|
||||
let ack = "Rebuilding gateway\u{2026} this may take a moment.";
|
||||
let ack_html = markdown_to_html(ack);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, ack, &ack_html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
let config_dir = ctx.services.project_root.clone();
|
||||
let gateway_port: u16 = ctx.gateway_port.unwrap_or(3000);
|
||||
match crate::gateway::rebuild::rebuild_gateway(&config_dir, gateway_port).await {
|
||||
Ok(()) => {
|
||||
// Trampoline is running detached — it kills this gateway and starts
|
||||
// the new one, which will post "gateway X.Y.Z ready" on startup.
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Gateway rebuild failed: {e}");
|
||||
let html = markdown_to_html(&msg);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &msg, &html).await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In gateway mode, intercept "rebuild gateway" before the plain "rebuild"
|
||||
// handler so the trampoline path is used instead of a direct re-exec.
|
||||
if ctx.gateway_port.is_some()
|
||||
&& super::super::super::rebuild::extract_rebuild_gateway_command(
|
||||
&user_message,
|
||||
&ctx.services.bot_name,
|
||||
ctx.matrix_user_id.as_str(),
|
||||
)
|
||||
.is_some()
|
||||
{
|
||||
slog!("[matrix-bot] Handling rebuild-gateway command from {sender}");
|
||||
let ack = "Rebuilding gateway… this may take a moment. \
|
||||
The gateway will announce itself when the new version is ready.";
|
||||
let ack_html = markdown_to_html(ack);
|
||||
if let Ok(msg_id) = ctx
|
||||
.transport
|
||||
.send_message(&room_id_str, ack, &ack_html)
|
||||
.await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
let port = ctx.gateway_port.unwrap_or(3000);
|
||||
match crate::gateway::rebuild::rebuild_gateway(&ctx.services.project_root, port).await {
|
||||
Ok(()) => {
|
||||
// Trampoline is running — this gateway will be killed shortly.
|
||||
// No further reply needed; the new gateway posts "gateway X.Y.Z ready".
|
||||
}
|
||||
Err(e) => {
|
||||
let msg = format!("Gateway rebuild failed: {e}");
|
||||
let html = markdown_to_html(&msg);
|
||||
if let Ok(msg_id) = ctx.transport.send_message(&room_id_str, &msg, &html).await
|
||||
&& let Ok(event_id) = msg_id.parse()
|
||||
{
|
||||
ctx.bot_sent_event_ids.lock().await.insert(event_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check for the rebuild command, which requires async agent and process ops
|
||||
// and cannot be handled by the sync command registry.
|
||||
if super::super::super::rebuild::extract_rebuild_command(
|
||||
|
||||
@@ -39,6 +39,7 @@ pub async fn run_bot(
|
||||
gateway_event_rx: Option<
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
>,
|
||||
gateway_port: Option<u16>,
|
||||
) -> Result<(), String> {
|
||||
let project_root = &services.project_root;
|
||||
let store_path = project_root.join(".huskies").join("matrix_store");
|
||||
@@ -334,6 +335,7 @@ pub async fn run_bot(
|
||||
handled_incoming_event_ids: Arc::new(TokioMutex::new(super::context::SeenEventIds::new(
|
||||
super::context::SEEN_EVENT_IDS_CAP,
|
||||
))),
|
||||
gateway_port,
|
||||
};
|
||||
|
||||
slog!(
|
||||
@@ -408,7 +410,17 @@ pub async fn run_bot(
|
||||
// bot is online. This runs once per process start — the sync loop handles
|
||||
// reconnects internally so this code is never reached again on a network
|
||||
// blip or sync resumption.
|
||||
let announce_msg = format_startup_announcement(&announce_bot_name);
|
||||
//
|
||||
// When started by the trampoline the message is specialised:
|
||||
// - HUSKIES_TRAMPOLINE_STARTED=1 → "gateway X.Y.Z ready"
|
||||
// - HUSKIES_TRAMPOLINE_FAILURE=<reason> → rollback failure notice
|
||||
let announce_msg = if let Ok(reason) = std::env::var("HUSKIES_TRAMPOLINE_FAILURE") {
|
||||
super::format::format_gateway_rollback_announcement(&reason)
|
||||
} else if std::env::var("HUSKIES_TRAMPOLINE_STARTED").is_ok() {
|
||||
super::format::format_gateway_ready_announcement()
|
||||
} else {
|
||||
format_startup_announcement(&announce_bot_name)
|
||||
};
|
||||
let announce_html = markdown_to_html(&announce_msg);
|
||||
slog!("[matrix-bot] Sending startup announcement: {announce_msg}");
|
||||
for room_id in &announce_room_ids {
|
||||
|
||||
@@ -94,6 +94,7 @@ pub fn spawn_bot(
|
||||
gateway_event_rx: Option<
|
||||
tokio::sync::broadcast::Receiver<crate::service::gateway::GatewayStatusEvent>,
|
||||
>,
|
||||
gateway_port: Option<u16>,
|
||||
) -> Option<tokio::task::AbortHandle> {
|
||||
let config = match BotConfig::load(project_root) {
|
||||
Some(c) => c,
|
||||
@@ -132,6 +133,7 @@ pub fn spawn_bot(
|
||||
gateway_projects_store,
|
||||
timer_store,
|
||||
gateway_event_rx,
|
||||
gateway_port,
|
||||
)
|
||||
.await
|
||||
{
|
||||
|
||||
@@ -40,6 +40,43 @@ pub fn extract_rebuild_command(
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a "rebuild gateway" command from a raw message body.
|
||||
///
|
||||
/// Returns `Some(RebuildCommand)` only when the stripped message begins with
|
||||
/// "rebuild gateway" (case-insensitive). A plain "rebuild" without the
|
||||
/// "gateway" qualifier returns `None` so it falls through to the standard
|
||||
/// server rebuild handler.
|
||||
pub fn extract_rebuild_gateway_command(
|
||||
message: &str,
|
||||
bot_name: &str,
|
||||
bot_user_id: &str,
|
||||
) -> Option<RebuildCommand> {
|
||||
let stripped = strip_bot_mention(message, bot_name, bot_user_id);
|
||||
let trimmed = stripped
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
|
||||
let (cmd, rest) = trimmed.split_once(char::is_whitespace)?;
|
||||
|
||||
if !cmd.eq_ignore_ascii_case("rebuild") {
|
||||
return None;
|
||||
}
|
||||
|
||||
let qualifier = rest
|
||||
.trim()
|
||||
.trim_start_matches(|c: char| !c.is_alphanumeric());
|
||||
let first_word = match qualifier.split_once(char::is_whitespace) {
|
||||
Some((w, _)) => w,
|
||||
None => qualifier,
|
||||
};
|
||||
|
||||
if first_word.eq_ignore_ascii_case("gateway") {
|
||||
Some(RebuildCommand)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a rebuild command: trigger server rebuild and restart.
|
||||
///
|
||||
/// Returns a string describing the outcome. On build failure the error
|
||||
|
||||
Reference in New Issue
Block a user