events: LiveEvent::Note becomes struct variant so serde can actually serialize it

This commit is contained in:
müde 2026-05-17 13:14:09 +02:00
parent aa24080f7b
commit b60774a66c
5 changed files with 46 additions and 29 deletions

View file

@ -136,7 +136,9 @@ async fn serve(
} else {
tracing::info!(%from, %body, "system message");
}
bus.emit(LiveEvent::Note(format!("[system] {body}")));
bus.emit(LiveEvent::Note {
text: format!("[system] {body}"),
});
// Fall through: drive a turn with the event in the wake
// prompt body so claude sees it. Sender stays "system"
// so the wake prompt can label it as such.

View file

@ -105,7 +105,16 @@ pub enum LiveEvent {
/// Free-form note from the harness (e.g. "claude exited 0",
/// "stream-json parse error: ..."). Useful when stream-json itself
/// fails so the UI doesn't just go silent.
Note(String),
///
/// Must be a struct variant (not `Note(String)`): internally-tagged
/// enums can't flatten a tag onto a primitive newtype, and serde
/// fails serialization at runtime — silently, because the SSE
/// handler's `filter_map(... .ok()? ...)` swallows the error. From
/// 2025-08 through 2026-05 every `Note` emission was a no-op + the
/// sqlite history persisted them as the literal string `"null"`.
/// The web UI's `note` renderer already reads `ev.text`, so the
/// wire shape matches without a JS change.
Note { text: String },
/// Turn finished. `ok=false` means claude exited non-zero or the
/// harness hit a transport error.
TurnEnd { ok: bool, note: Option<String> },
@ -138,7 +147,7 @@ impl EventStore {
let kind = match event {
LiveEvent::TurnStart { .. } => "turn_start",
LiveEvent::Stream(_) => "stream",
LiveEvent::Note(_) => "note",
LiveEvent::Note { .. } => "note",
LiveEvent::TurnEnd { .. } => "turn_end",
};
let payload = serde_json::to_string(event).unwrap_or_else(|_| "null".into());

View file

@ -206,11 +206,13 @@ pub async fn run_turn(prompt: &str, files: &TurnFiles, bus: &Bus) -> TurnOutcome
/// 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(
"context overflow — running /compact on the persistent session".into(),
));
bus.emit(LiveEvent::Note {
text: "context overflow — running /compact on the persistent session".into(),
});
let _ = run_claude("/compact", files, bus).await?;
bus.emit(LiveEvent::Note("/compact done".into()));
bus.emit(LiveEvent::Note {
text: "/compact done".into(),
});
Ok(())
}
@ -218,9 +220,9 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<bool>
let model = bus.model();
let resume = !bus.take_skip_continue();
if !resume {
bus.emit(LiveEvent::Note(
"fresh session (--continue suppressed for this turn)".into(),
));
bus.emit(LiveEvent::Note {
text: "fresh session (--continue suppressed for this turn)".into(),
});
}
let mut cmd = Command::new("claude");
// Spawn inside the agent's state dir so relative paths in tool calls
@ -282,7 +284,9 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<bool>
}
bus_out.emit(LiveEvent::Stream(v));
}
Err(_) => bus_out.emit(LiveEvent::Note(format!("(non-json) {line}"))),
Err(_) => bus_out.emit(LiveEvent::Note {
text: format!("(non-json) {line}"),
}),
}
}
});
@ -304,7 +308,9 @@ async fn run_claude(prompt: &str, files: &TurnFiles, bus: &Bus) -> Result<bool>
// renders; the tracing line is what `journalctl -M <c> -b`
// surfaces when claude exits non-zero.
tracing::warn!(line = %line, "claude stderr");
bus_err.emit(LiveEvent::Note(format!("stderr: {line}")));
bus_err.emit(LiveEvent::Note {
text: format!("stderr: {line}"),
});
let mut t = tail_clone.lock().unwrap();
if t.len() >= STDERR_TAIL_LINES {
t.pop_front();

View file

@ -372,9 +372,9 @@ async fn events_stream(
let rx = state.bus.subscribe();
// Drop a "hello" note into the bus so every new subscriber sees at
// least one event immediately and can clear the connecting placeholder.
state.bus.emit(crate::events::LiveEvent::Note(
"live stream attached".into(),
));
state.bus.emit(crate::events::LiveEvent::Note {
text: "live stream attached".into(),
});
let stream = BroadcastStream::new(rx).filter_map(|res| {
let ev = res.ok()?;
let json = serde_json::to_string(&ev).ok()?;
@ -448,9 +448,9 @@ async fn post_set_model(State(state): State<AppState>, Form(form): Form<ModelFor
return error_response("model: name required");
}
state.bus.set_model(name);
state.bus.emit(crate::events::LiveEvent::Note(format!(
"operator: /model — claude model set to '{name}' for future turns"
)));
state.bus.emit(crate::events::LiveEvent::Note {
text: format!("operator: /model — claude model set to '{name}' for future turns"),
});
tracing::info!(%name, "operator set model");
Redirect::to("/").into_response()
}
@ -471,16 +471,16 @@ async fn post_compact(State(state): State<AppState>) -> Response {
let files = state.files.clone();
tokio::spawn(async move {
let _guard = guard; // keep lock alive for the duration of compaction
bus.emit(crate::events::LiveEvent::Note(
"operator: /compact — running on persistent session".into(),
));
bus.emit(crate::events::LiveEvent::Note {
text: "operator: /compact — running on persistent session".into(),
});
bus.set_state(crate::events::TurnState::Compacting);
let r = crate::turn::compact_session(&files, &bus).await;
bus.set_state(crate::events::TurnState::Idle);
if let Err(e) = r {
bus.emit(crate::events::LiveEvent::Note(format!(
"/compact failed: {e:#}"
)));
bus.emit(crate::events::LiveEvent::Note {
text: format!("/compact failed: {e:#}"),
});
}
});
Redirect::to("/").into_response()
@ -501,9 +501,9 @@ async fn post_compact(State(state): State<AppState>) -> Response {
/// than asking claude to forget mid-stream.
async fn post_new_session(State(state): State<AppState>) -> Response {
state.bus.request_new_session();
state.bus.emit(crate::events::LiveEvent::Note(
"operator: new session armed — next turn runs without --continue".into(),
));
state.bus.emit(crate::events::LiveEvent::Note {
text: "operator: new session armed — next turn runs without --continue".into(),
});
Redirect::to("/").into_response()
}
@ -524,7 +524,7 @@ async fn post_cancel_turn(State(state): State<AppState>) -> Response {
),
Err(e) => format!("operator: /cancel — pkill failed: {e}"),
};
state.bus.emit(crate::events::LiveEvent::Note(note));
state.bus.emit(crate::events::LiveEvent::Note { text: note });
Redirect::to("/").into_response()
}