Compare commits

...
Author SHA1 Message Date
iris
12985ade6d fix(#726): handle broadcast Lagged + clear on SSE reconnect
Distinguish RecvError::Lagged from Closed in the build-log stream
loop: Lagged continues (next recv delivers the full accumulated delta
via saved cursors), Closed returns (shutdown path).

Clear pre.textContent and reset cursor lengths in es.onerror when
readyState is CONNECTING so auto-reconnect doesn't double-append the
replay from cursor=0.
2026-05-31 21:48:31 +02:00
iris
050e130eba build-logs: SSE live streaming + raw download (#726 phase 4)
Backend (hive-c0re):
- build_logs.rs: add tokio::sync::broadcast::Sender<i64> to BuildLogs;
  append() and finish() notify subscribers after each sqlite write.
  Add BuildLogProgress struct and get_progress(id, stdout_cursor,
  stderr_cursor) for incremental delta reads.
- dashboard.rs: two new endpoints —
    GET /api/build-logs/id/{id}/stream  SSE; streams BuildLogFrame
      {stdout_append, stderr_append, status?, done} deltas until the
      build finishes or the browser disconnects. Backed by an mpsc
      channel task that watches the per-build broadcast notifications.
    GET /api/build-logs/id/{id}/raw    text/plain download with
      Content-Disposition: attachment; filename build-log-{agent}-{id}.txt

Frontend (dashboard):
- tabs.js: running builds (status === null) connect an EventSource to
  /stream and append lines live; "live" badge pulses amber while active,
  flips to ok/fail on done. Finished builds still use the JSON fetch path.
  Collapsing a running panel closes the EventSource; re-expanding
  reconnects. Adds a "⬇ raw" download link to every expanded row.
- dashboard.css: .build-logs-dl inline download link; .build-logs-live
  live pulse @keyframes animation.

Docs: web-ui.md updated for all three new endpoints + behaviour.
2026-05-31 21:48:31 +02:00
iris
c4ce91b61f build-logs viewer: add --crust var + aria-expanded on row buttons (argus nits) 2026-05-31 21:48:31 +02:00
iris
cc6227ccb5 dashboard: build-log side-panel viewer in agent card (#726 phase 3)
Adds a `↳ build logs · {agent}` drill-in to every agent card that
opens the side panel and fetches the last 10 build-log headers from
GET /api/build-logs/{agent}. Each row shows a status chip (ok/fail/
running), kind, age, and truncated cmdline. Clicking a row lazily
fetches the full stdout+stderr from GET /api/build-logs/id/{id} and
expands it inline as a scrollable pre.

CSS additions: .build-logs-{panel,toolbar,list,item,row-btn,...} plus
.badge-ok/.badge-fail/.badge-running status chips shared with future
uses. docs/web-ui.md updated with the new drill-in and the new badge
classes.
2026-05-31 21:48:31 +02:00
7 changed files with 1884 additions and 35 deletions

File diff suppressed because it is too large Load diff

View file

@ -369,6 +369,15 @@ fetch entirely.
layout so the `<pre>` log surface fills the full remaining panel layout so the `<pre>` log surface fills the full remaining panel
height; scroll happens inside the `<pre>`, not the side height; scroll happens inside the `<pre>`, not the side
panel body. panel body.
- `↳ build logs · <agent>` — opens the side panel and fetches
the last 10 build-log headers via
`GET /api/build-logs/{agent}` (status chip + kind + age +
truncated cmdline per row). Clicking a row lazy-fetches its
full stdout+stderr from `GET /api/build-logs/id/{id}` and
expands it inline as a scrollable `<pre>`. A refresh button
re-fetches the header list. Backed by the `build_logs.sqlite`
store that `lifecycle::run` and `lifecycle::prebuild_toplevel`
write into.
- Plain navigation links (config repo, forge profile, - Plain navigation links (config repo, forge profile,
`dashboardLinks` extras) now live in the icon-only nav strip `dashboardLinks` extras) now live in the icon-only nav strip
on Line 1 — see above. The agent's `config` link on Line 1 — see above. The agent's `config` link

View file

@ -487,7 +487,7 @@ a:hover {
.journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; } .journal-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.journal-output { .journal-output {
margin: 0; margin: 0;
background: #11111b; background: var(--crust);
color: var(--fg); color: var(--fg);
border: 1px solid var(--purple-dim); border: 1px solid var(--purple-dim);
padding: 0.5em 0.7em; padding: 0.5em 0.7em;
@ -504,6 +504,112 @@ a:hover {
word-break: normal; word-break: normal;
} }
/* Build-log side-panel viewer. Same general shape as the journald
viewer: a toolbar above a scrollable list of log header rows;
clicking a row expands full stdout+stderr inline. */
.build-logs-panel {
display: flex;
flex-direction: column;
height: 100%;
gap: 0.4em;
}
.build-logs-toolbar {
flex: 0 0 auto;
display: flex;
gap: 0.5em;
align-items: center;
}
.build-logs-refresh { font-size: 0.75em; padding: 0.15em 0.5em; }
.build-logs-list {
list-style: none;
margin: 0;
padding: 0;
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.25em;
}
.build-logs-loading,
.build-logs-empty,
.build-logs-error {
padding: 0.4em 0.6em;
font-size: 0.85em;
color: var(--subtext0);
}
.build-logs-error { color: var(--red); }
.build-logs-item {
border: 1px solid var(--border);
border-radius: 2px;
}
.build-logs-row-btn {
width: 100%;
background: none;
border: none;
color: var(--fg);
font-family: inherit;
font-size: 0.85em;
cursor: pointer;
padding: 0.35em 0.6em;
text-align: left;
display: flex;
gap: 0.5em;
align-items: center;
flex-wrap: wrap;
}
.build-logs-row-btn:hover { background: var(--bg-elev); }
.build-logs-kind { font-weight: 600; }
.build-logs-age { font-size: 0.88em; }
.build-logs-cmdline {
font-family: "JetBrains Mono", "Fira Code", monospace;
font-size: 0.8em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 28ch;
}
.build-logs-detail { padding: 0 0.4em 0.4em; }
.build-logs-output {
margin: 0;
background: var(--crust);
color: var(--fg);
border: 1px solid var(--purple-dim);
padding: 0.5em 0.7em;
overflow: auto;
font-size: 0.8em;
line-height: 1.4;
white-space: pre;
word-break: normal;
max-height: 60vh;
}
/* Status badge colours reused for build status chips. */
.badge-ok { background: rgba(166,227,161,0.12); color: var(--green); border-color: var(--green); }
.badge-fail { background: rgba(243,139,168,0.12); color: var(--red); border-color: var(--red); }
.badge-running { background: rgba(250,179,135,0.12); color: var(--amber); border-color: var(--amber); }
/* Download link sits inline after the row button. Shown only while
the detail is expanded (toggled by JS). */
.build-logs-dl {
display: none;
font-size: 0.72em;
padding: 0.15em 0.45em;
margin-left: 0.3em;
color: var(--muted);
text-decoration: none;
border: 1px solid var(--border);
border-radius: 3px;
}
.build-logs-dl:not([hidden]) { display: inline-block; }
.build-logs-dl:hover { color: var(--fg); border-color: var(--purple-dim); }
/* Live-streaming indicator badge inside the detail pane header. */
.build-logs-live-badge { margin-bottom: 0.4em; }
/* Pulse animation on the "live" badge text while streaming. */
.build-logs-live-badge.badge-running { animation: live-pulse 1.4s ease-in-out infinite; }
@keyframes live-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* Notification controls sit between the banner and the /* Notification controls sit between the banner and the
containers section. Hidden by JS when notifications are containers section. Hidden by JS when notifications are
unsupported, denied, or already in the right state. */ unsupported, denied, or already in the right state. */

View file

@ -709,6 +709,10 @@ window.marked = marked;
// narrows to the harness service (or empty = full machine). // narrows to the harness service (or empty = full machine).
const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service'; const journalUnit = c.is_manager ? 'hive-m1nd.service' : 'hive-ag3nt.service';
drill.append(buildJournalTrigger(c.container, journalUnit)); drill.append(buildJournalTrigger(c.container, journalUnit));
// Build-log viewer: lists recent nix build / nixos-container
// invocations for this agent, with click-to-expand full
// stdout + stderr. Backed by GET /api/build-logs/{name}.
drill.append(buildBuildLogsTrigger(c.name));
// The hardcoded config-repo trigger and the agent-declared // The hardcoded config-repo trigger and the agent-declared
// extras block both moved into the unified nav strip in the // extras block both moved into the unified nav strip in the
// head row above (sourced from the agent backend via // head row above (sourced from the agent backend via
@ -1068,6 +1072,180 @@ window.marked = marked;
return trigger; return trigger;
} }
// Build-log viewer. Fetches the last 10 build-log headers for
// `agentName` from GET /api/build-logs/{agentName}, then renders
// a clickable list. Clicking a row fetches its full stdout+stderr
// from GET /api/build-logs/id/{id} and expands it inline.
function buildBuildLogsTrigger(agentName) {
const trigger = el('button', { type: 'button', class: 'panel-trigger' },
'↳ build logs · ' + agentName);
trigger.addEventListener('click', () => {
const panelTitle = 'build logs · ' + agentName;
const wrap = el('div', { class: 'build-logs-panel' });
const hdr = el('div', { class: 'build-logs-toolbar' });
const refreshBtn = el('button',
{ type: 'button', class: 'btn btn-restart build-logs-refresh' },
'↻ refresh');
hdr.append(refreshBtn);
wrap.append(hdr);
const list = el('ul', { class: 'build-logs-list' });
wrap.append(list);
let fetching = false;
async function loadHeaders() {
if (fetching) return;
fetching = true;
list.innerHTML = '';
list.append(el('li', { class: 'build-logs-loading' }, 'fetching…'));
try {
const resp = await fetch('/api/build-logs/' + agentName + '?limit=10');
if (!resp.ok) {
list.innerHTML = '';
list.append(el('li', { class: 'build-logs-error' },
'error ' + resp.status + ': ' + await resp.text()));
return;
}
const rows = await resp.json();
list.innerHTML = '';
if (!rows || !rows.length) {
list.append(el('li', { class: 'build-logs-empty' }, '(no build logs yet)'));
return;
}
const nowUnix = Math.floor(Date.now() / 1000);
for (const h of rows) {
const li = el('li', { class: 'build-logs-item' });
const statusCls = h.status === 'ok' ? 'badge-ok'
: h.status === 'fail' ? 'badge-fail'
: 'badge-running';
const statusLabel = h.status === 'ok' ? 'ok'
: h.status === 'fail' ? 'fail'
: 'running';
const age = h.started_at
? fmtAgeSecs(nowUnix - h.started_at) + ' ago' : '?';
const rowBtn = el('button',
{ type: 'button', class: 'build-logs-row-btn', 'aria-expanded': 'false' },
el('span', { class: `badge ${statusCls}` }, statusLabel),
el('span', { class: 'build-logs-kind' }, h.kind),
el('span', { class: 'build-logs-age meta' }, age),
el('span', { class: 'build-logs-cmdline meta' }, h.cmdline),
);
const detail = el('div', { class: 'build-logs-detail' });
detail.hidden = true;
// `loaded` stays false for running builds until SSE
// signals done — re-collapsing a running panel stops the
// stream and re-expanding reconnects it.
let loaded = false;
// Download link always points at the raw-text endpoint;
// hidden until the row is expanded for the first time.
const dlLink = el('a', {
href: '/api/build-logs/id/' + h.id + '/raw',
download: 'build-log-' + h.id + '.txt',
class: 'build-logs-dl',
hidden: '',
}, '⬇ raw');
rowBtn.addEventListener('click', async () => {
if (!detail.hidden) {
// Collapse: hide panel, close any live SSE stream.
detail.hidden = true;
dlLink.hidden = true;
rowBtn.setAttribute('aria-expanded', 'false');
if (detail._es) { detail._es.close(); detail._es = null; }
return;
}
// Expand
detail.hidden = false;
dlLink.hidden = false;
rowBtn.setAttribute('aria-expanded', 'true');
if (loaded) return; // finished build, cached content ready
if (h.status) {
// ── finished build: fetch full JSON once ──────────────
detail.textContent = 'fetching…';
try {
const r2 = await fetch('/api/build-logs/id/' + h.id);
if (!r2.ok) {
detail.textContent = 'error ' + r2.status + ': ' + await r2.text();
} else {
const full = await r2.json();
const out = (full.stdout || '')
+ (full.stderr ? '\n--- stderr ---\n' + full.stderr : '');
const pre = el('pre', { class: 'build-logs-output' }, out || '(empty)');
detail.replaceChildren(pre);
loaded = true;
}
} catch (err) {
detail.textContent = 'fetch failed: ' + err;
}
} else {
// ── running build: stream via SSE ─────────────────────
const pre = el('pre', { class: 'build-logs-output build-logs-live' }, '');
detail.replaceChildren(
el('span', { class: 'build-logs-live-badge badge badge-running' }, 'live'),
pre,
);
let stdoutLen = 0;
let stderrLen = 0;
const es = new EventSource('/api/build-logs/id/' + h.id + '/stream');
detail._es = es;
es.onmessage = (ev) => {
let frame;
try { frame = JSON.parse(ev.data); } catch { return; }
if (frame.stdout_append) {
pre.textContent += frame.stdout_append;
stdoutLen += frame.stdout_append.length;
}
if (frame.stderr_append) {
if (stderrLen === 0) pre.textContent += '\n--- stderr ---\n';
pre.textContent += frame.stderr_append;
stderrLen += frame.stderr_append.length;
}
if (frame.done) {
es.close();
detail._es = null;
// Replace live badge with final status
const badge = detail.querySelector('.build-logs-live-badge');
if (badge) {
badge.className = frame.status === 'ok'
? 'badge badge-ok' : 'badge badge-fail';
badge.textContent = frame.status || 'done';
}
loaded = true;
}
};
es.onerror = () => {
if (es.readyState === EventSource.CONNECTING) {
// Auto-reconnect: clear accumulated content so the
// fresh stream from cursor=0 doesn't double-append.
pre.textContent = '';
stdoutLen = 0;
stderrLen = 0;
} else if (es.readyState === EventSource.CLOSED) {
detail._es = null;
}
};
}
});
li.append(rowBtn, dlLink, detail);
list.append(li);
}
} catch (err) {
list.innerHTML = '';
list.append(el('li', { class: 'build-logs-error' }, 'fetch failed: ' + err));
} finally {
fetching = false;
}
}
refreshBtn.addEventListener('click', loadHeaders);
Panel.open(panelTitle, wrap);
loadHeaders();
});
return trigger;
}
function renderTombstones(s) { function renderTombstones(s) {
const root = $('tombstones-section'); const root = $('tombstones-section');
// #tombstones-section only lives on /index.html (SYST3M tab); // #tombstones-section only lives on /index.html (SYST3M tab);

View file

@ -5,6 +5,7 @@
:root { :root {
--bg: #1e1e2e; /* base */ --bg: #1e1e2e; /* base */
--bg-elev: #181825; /* mantle */ --bg-elev: #181825; /* mantle */
--crust: #11111b; /* crust — terminal background */
--fg: #cdd6f4; /* text */ --fg: #cdd6f4; /* text */
--muted: #7f849c; /* overlay1 */ --muted: #7f849c; /* overlay1 */
--purple: #cba6f7; /* mauve */ --purple: #cba6f7; /* mauve */

View file

@ -26,6 +26,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize; use serde::Serialize;
use tokio::sync::broadcast;
/// Process-singleton handle, set once at coordinator startup. Lets /// Process-singleton handle, set once at coordinator startup. Lets
/// the `lifecycle` module's `run` / `prebuild_toplevel` access the /// the `lifecycle` module's `run` / `prebuild_toplevel` access the
@ -123,11 +124,38 @@ pub struct BuildLogFull {
pub stderr: String, pub stderr: String,
} }
/// Incremental text returned by `get_progress`. Carries only the new
/// bytes since the caller's last cursor positions so the SSE stream
/// handler can send deltas without re-transmitting the full log.
#[derive(Debug, Clone)]
pub struct BuildLogProgress {
/// New stdout bytes beyond `stdout_cursor`.
pub stdout_append: String,
/// New stderr bytes beyond `stderr_cursor`.
pub stderr_append: String,
/// `Some(unix_ts)` once the build is finished.
pub finished_at: Option<i64>,
/// Terminal status string (`"ok"` / `"fail"`) once finished.
pub status: Option<String>,
}
/// Channel capacity for per-build append notifications. 64 slots is
/// plenty — the consumer reads fast relative to line-append rate and
/// any lag means "read now, you have new content" rather than a lost
/// data line.
const NOTIFY_CAP: usize = 64;
/// Sqlite-backed build-log store. `Arc<BuildLogs>`-friendly: all /// Sqlite-backed build-log store. `Arc<BuildLogs>`-friendly: all
/// methods take `&self`, internal `Mutex<Connection>` serializes /// methods take `&self`, internal `Mutex<Connection>` serializes
/// access. /// access.
pub struct BuildLogs { pub struct BuildLogs {
conn: Mutex<Connection>, conn: Mutex<Connection>,
/// Broadcast channel that fires with the `id` of the row that just
/// had a line appended or was finished. The SSE stream handler
/// subscribes once per open panel and drives delta reads from this.
/// `send()` is non-async and silently drops frames when there are
/// no subscribers — safe to call from sync append/finish paths.
notify_tx: broadcast::Sender<i64>,
} }
impl BuildLogs { impl BuildLogs {
@ -139,11 +167,20 @@ impl BuildLogs {
.with_context(|| format!("open build_logs db {}", path.display()))?; .with_context(|| format!("open build_logs db {}", path.display()))?;
conn.execute_batch(SCHEMA) conn.execute_batch(SCHEMA)
.context("apply build_logs schema")?; .context("apply build_logs schema")?;
let (notify_tx, _) = broadcast::channel(NOTIFY_CAP);
Ok(Self { Ok(Self {
conn: Mutex::new(conn), conn: Mutex::new(conn),
notify_tx,
}) })
} }
/// Subscribe to per-build append/finish notifications. Each emitted
/// value is the `id` of the row that changed. The SSE stream handler
/// calls this once and filters for its target id.
pub fn subscribe_notifications(&self) -> broadcast::Receiver<i64> {
self.notify_tx.subscribe()
}
/// Open a row for a new build attempt. Returns the assigned id /// Open a row for a new build attempt. Returns the assigned id
/// — the caller threads it through `append_stdout` / `append_stderr` /// — the caller threads it through `append_stdout` / `append_stderr`
/// while the child runs and into `finish` once it exits. /// while the child runs and into `finish` once it exits.
@ -185,6 +222,14 @@ impl BuildLogs {
"build_logs: append failed (dropping line)" "build_logs: append failed (dropping line)"
); );
} }
drop(conn);
// Notify SSE stream subscribers — non-blocking, no-op when no
// subscribers are watching (e.g. no panel is open). Lagged
// receivers (channel full) automatically drop frames; the SSE
// handler re-reads the full delta on the next notification it
// does receive, so no content is lost, only an intermediate
// wake-up is coalesced.
let _ = self.notify_tx.send(id);
} }
/// Finalize a build attempt. Sets `finished_at` to now and /// Finalize a build attempt. Sets `finished_at` to now and
@ -202,6 +247,62 @@ impl BuildLogs {
"build_logs: finish failed" "build_logs: finish failed"
); );
} }
drop(conn);
// Final notification so the SSE stream handler sees the
// finished_at and status, closes the connection cleanly.
let _ = self.notify_tx.send(id);
}
/// Return incremental log content beyond the given byte cursors.
/// Used by the SSE stream handler to compute deltas between polls.
///
/// `stdout_cursor` / `stderr_cursor` are byte offsets into the
/// stored `stdout` / `stderr` columns from the previous read.
/// Slicing is safe because cursors are always derived from prior
/// `String::len()` values (valid UTF-8 boundaries).
///
/// Returns `None` when the row no longer exists (vacuum reap during
/// a long-open panel).
pub fn get_progress(
&self,
id: i64,
stdout_cursor: usize,
stderr_cursor: usize,
) -> Result<Option<BuildLogProgress>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT stdout, stderr, finished_at, status \
FROM build_logs WHERE id = ?1",
)?;
let row = stmt
.query_row(params![id], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, Option<i64>>(2)?,
r.get::<_, Option<String>>(3)?,
))
})
.optional()?;
match row {
None => Ok(None),
Some((stdout, stderr, finished_at, status)) => {
let stdout_append = stdout
.get(stdout_cursor..)
.unwrap_or("")
.to_string();
let stderr_append = stderr
.get(stderr_cursor..)
.unwrap_or("")
.to_string();
Ok(Some(BuildLogProgress {
stdout_append,
stderr_append,
finished_at,
status,
}))
}
}
} }
/// Return the most recent `limit` rows for `agent`, newest first. /// Return the most recent `limit` rows for `agent`, newest first.

View file

@ -21,7 +21,7 @@ use axum::{
}; };
use hive_sh4re::Approval; use hive_sh4re::Approval;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use tokio_stream::wrappers::BroadcastStream; use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
use tokio_stream::{Stream, StreamExt}; use tokio_stream::{Stream, StreamExt};
use tower_http::services::ServeDir; use tower_http::services::ServeDir;
@ -68,6 +68,8 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
.route("/api/reminders", get(api_reminders)) .route("/api/reminders", get(api_reminders))
.route("/api/build-logs/{agent}", get(get_build_logs_agent)) .route("/api/build-logs/{agent}", get(get_build_logs_agent))
.route("/api/build-logs/id/{id}", get(get_build_log_full)) .route("/api/build-logs/id/{id}", get(get_build_log_full))
.route("/api/build-logs/id/{id}/stream", get(get_build_log_stream))
.route("/api/build-logs/id/{id}/raw", get(get_build_log_raw))
.route("/api/agent/{name}/links", get(get_agent_links)) .route("/api/agent/{name}/links", get(get_agent_links))
.route("/api/agent/{name}/mark-all-read", post(post_mark_all_read)) .route("/api/agent/{name}/mark-all-read", post(post_mark_all_read))
.route("/cancel-reminder/{id}", post(post_cancel_reminder)) .route("/cancel-reminder/{id}", post(post_cancel_reminder))
@ -1686,6 +1688,155 @@ async fn get_build_log_full(
} }
} }
/// JSON frame sent on the `/api/build-logs/id/{id}/stream` SSE channel.
/// `stdout_append` / `stderr_append` carry only the new bytes since the
/// last frame; `done = true` means the build finished and the stream
/// will close after this frame.
#[derive(Serialize)]
struct BuildLogFrame {
stdout_append: String,
stderr_append: String,
#[serde(skip_serializing_if = "Option::is_none")]
status: Option<String>,
done: bool,
}
/// `GET /api/build-logs/id/{id}/stream` — SSE stream that delivers
/// incremental stdout/stderr as a build runs. The client connects when
/// it opens a running-build panel; the stream closes automatically once
/// the build finishes (or the row disappears due to a vacuum).
///
/// Each frame is a JSON-serialised `BuildLogFrame`. The first frame
/// always carries the full accumulated log so far (cursors start at 0);
/// subsequent frames carry only new bytes. `done: true` on the final
/// frame signals the browser to close the `EventSource`.
async fn get_build_log_stream(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Sse<impl Stream<Item = Result<Event, Infallible>>> {
let (tx, rx) = tokio::sync::mpsc::channel::<Result<Event, Infallible>>(32);
let logs = state.coord.build_logs.clone();
tokio::spawn(async move {
let mut notify_rx = logs.subscribe_notifications();
let mut stdout_cursor = 0usize;
let mut stderr_cursor = 0usize;
// ── initial snapshot ──────────────────────────────────────────
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
Ok(Some(prog)) => {
stdout_cursor += prog.stdout_append.len();
stderr_cursor += prog.stderr_append.len();
let done = prog.finished_at.is_some();
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
stdout_append: prog.stdout_append,
stderr_append: prog.stderr_append,
status: prog.status,
done,
}) {
let _ = tx.send(Ok(Event::default().data(json))).await;
}
if done {
return;
}
}
Ok(None) => {
// Row missing — send a single error event and exit.
let _ = tx
.send(Ok(Event::default()
.event("error")
.data(format!("build log #{id} not found"))))
.await;
return;
}
Err(e) => {
let _ = tx
.send(Ok(Event::default()
.event("error")
.data(format!("build log #{id}: {e:#}"))))
.await;
return;
}
}
// ── live delta loop ───────────────────────────────────────────
loop {
match notify_rx.recv().await {
// Notification for a different build — ignore and wait
// for the next one.
Ok(notif_id) if notif_id != id => continue,
Ok(_) => {
match logs.get_progress(id, stdout_cursor, stderr_cursor) {
Ok(Some(prog)) => {
stdout_cursor += prog.stdout_append.len();
stderr_cursor += prog.stderr_append.len();
let done = prog.finished_at.is_some();
if let Ok(json) = serde_json::to_string(&BuildLogFrame {
stdout_append: prog.stdout_append,
stderr_append: prog.stderr_append,
status: prog.status,
done,
}) {
if tx.send(Ok(Event::default().data(json))).await.is_err() {
return; // browser disconnected
}
}
if done {
return;
}
}
Ok(None) => return, // vacuum reaped the row
Err(_) => return,
}
}
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
Err(tokio::sync::broadcast::error::RecvError::Closed) => return,
}
}
});
Sse::new(ReceiverStream::new(rx)).keep_alive(KeepAlive::default())
}
/// `GET /api/build-logs/id/{id}/raw` — full log as `text/plain` for
/// download. Stdout and stderr are concatenated with a `--- stderr ---`
/// separator (same layout the JS side-panel renders). The
/// `Content-Disposition` header triggers a browser download with a
/// descriptive filename so the operator can save and share the log.
async fn get_build_log_raw(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
) -> Response {
match state.coord.build_logs.get_full(id) {
Ok(Some(log)) => {
let mut text = log.stdout;
if !log.stderr.is_empty() {
text.push_str("\n--- stderr ---\n");
text.push_str(&log.stderr);
}
(
StatusCode::OK,
[
("content-type", "text/plain; charset=utf-8".to_string()),
(
"content-disposition",
format!(
"attachment; filename=\"build-log-{}-{}.txt\"",
log.header.agent, id
),
),
],
text,
)
.into_response()
}
Ok(None) => {
(StatusCode::NOT_FOUND, format!("build log #{id} not found")).into_response()
}
Err(e) => error_response(&format!("build-log {id}: {e:#}")),
}
}
/// `GET /api/schedules` — snapshot of every schedule for the /// `GET /api/schedules` — snapshot of every schedule for the
/// scheduled-prompts tab. Returns the wire shape directly /// scheduled-prompts tab. Returns the wire shape directly
/// so the frontend can render without an extra translation layer. /// so the frontend can render without an extra translation layer.