diff --git a/docs/web-ui.md b/docs/web-ui.md index b6663633..991516a6 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -642,12 +642,17 @@ fetch entirely. - `↳ build logs · ` — 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 `
`. A refresh button
-    re-fetches the header list. Backed by the `build_logs.sqlite`
-    store that `lifecycle::run` and `lifecycle::prebuild_toplevel`
-    write into.
+    truncated cmdline per row). Clicking a row expands the detail
+    inline: finished builds lazy-fetch `GET /api/build-logs/id/{id}`
+    (full JSON); running builds (`status: null`) instead open an
+    `EventSource` to `GET /api/build-logs/id/{id}/stream` and
+    stream stdout/stderr live — the "live" badge pulses amber while
+    the build runs and flips to the final status colour on
+    completion. A `⬇ raw` link downloads the full log as
+    `text/plain` via `GET /api/build-logs/id/{id}/raw`. 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,
     `dashboardLinks` extras) now live in the icon-only nav strip
     on Line 1 — see above. The agent's `config` link
@@ -855,6 +860,21 @@ that's a browser-level decision, not ours.
   `BuildLogFull` (JSON): all header fields plus `stdout` and
   `stderr` as plain text (newline-terminated lines, utf-8). HTTP
   404 when the row is missing (vacuum-reaped or stale id).
+- `GET /api/build-logs/id/{id}/stream` — SSE stream for live
+  monitoring of a running build. The client connects while the
+  build's `status` is `null`; the server sends incremental
+  `BuildLogFrame` JSON frames: `{ stdout_append, stderr_append,
+  status?, done }`. The first frame carries the full accumulated
+  log since build start (cursors at 0); subsequent frames carry
+  only new bytes. `done: true` on the final frame signals the
+  browser to close the `EventSource`. The stream closes on its
+  own once the build finishes, or if the row is vacuum-reaped
+  mid-stream. An `error` event name signals a server-side failure
+  (row not found, etc.).
+- `GET /api/build-logs/id/{id}/raw` — plain-text download of the
+  full log (stdout followed by `\n--- stderr ---\n` + stderr when
+  non-empty). `Content-Disposition: attachment` triggers a
+  browser download; filename is `build-log-{agent}-{id}.txt`.
 - `GET /api/journal/{name}?unit=&lines=` — journalctl viewer for
   a managed container; rendered in the side panel.
 - `GET /api/approval-diff/{id}?base=applied|approved|previous` —
diff --git a/frontend/packages/dashboard/src/dashboard.css b/frontend/packages/dashboard/src/dashboard.css
index 5efd7ef9..04545a12 100644
--- a/frontend/packages/dashboard/src/dashboard.css
+++ b/frontend/packages/dashboard/src/dashboard.css
@@ -587,6 +587,28 @@ a:hover {
 .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
    containers section. Hidden by JS when notifications are
diff --git a/frontend/packages/dashboard/src/tabs.js b/frontend/packages/dashboard/src/tabs.js
index 0564b46e..ca24ec8c 100644
--- a/frontend/packages/dashboard/src/tabs.js
+++ b/frontend/packages/dashboard/src/tabs.js
@@ -1134,24 +1134,44 @@ window.marked = marked;
             );
             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;
               }
-              if (!loaded) {
+              // 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…';
-                detail.hidden = false;
-                rowBtn.setAttribute('aria-expanded', 'true');
                 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 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;
@@ -1160,11 +1180,49 @@ window.marked = marked;
                   detail.textContent = 'fetch failed: ' + err;
                 }
               } else {
-                detail.hidden = false;
-                rowBtn.setAttribute('aria-expanded', 'true');
+                // ── 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.CLOSED) {
+                    detail._es = null;
+                  }
+                };
               }
             });
-            li.append(rowBtn, detail);
+            li.append(rowBtn, dlLink, detail);
             list.append(li);
           }
         } catch (err) {
diff --git a/hive-c0re/src/build_logs.rs b/hive-c0re/src/build_logs.rs
index 79d45c88..e54d7f01 100644
--- a/hive-c0re/src/build_logs.rs
+++ b/hive-c0re/src/build_logs.rs
@@ -26,6 +26,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
 use anyhow::{Context, Result};
 use rusqlite::{Connection, OptionalExtension, params};
 use serde::Serialize;
+use tokio::sync::broadcast;
 
 /// Process-singleton handle, set once at coordinator startup. Lets
 /// the `lifecycle` module's `run` / `prebuild_toplevel` access the
@@ -123,11 +124,38 @@ pub struct BuildLogFull {
     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,
+    /// Terminal status string (`"ok"` / `"fail"`) once finished.
+    pub status: Option,
+}
+
+/// 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`-friendly: all
 /// methods take `&self`, internal `Mutex` serializes
 /// access.
 pub struct BuildLogs {
     conn: Mutex,
+    /// 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,
 }
 
 impl BuildLogs {
@@ -139,11 +167,20 @@ impl BuildLogs {
             .with_context(|| format!("open build_logs db {}", path.display()))?;
         conn.execute_batch(SCHEMA)
             .context("apply build_logs schema")?;
+        let (notify_tx, _) = broadcast::channel(NOTIFY_CAP);
         Ok(Self {
             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 {
+        self.notify_tx.subscribe()
+    }
+
     /// Open a row for a new build attempt. Returns the assigned id
     /// — the caller threads it through `append_stdout` / `append_stderr`
     /// while the child runs and into `finish` once it exits.
@@ -185,6 +222,14 @@ impl BuildLogs {
                 "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
@@ -202,6 +247,62 @@ impl BuildLogs {
                 "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> {
+        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>(2)?,
+                    r.get::<_, Option>(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.
diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs
index 37f13e8b..e43490e2 100644
--- a/hive-c0re/src/dashboard.rs
+++ b/hive-c0re/src/dashboard.rs
@@ -21,7 +21,7 @@ use axum::{
 };
 use hive_sh4re::Approval;
 use serde::{Deserialize, Serialize};
-use tokio_stream::wrappers::BroadcastStream;
+use tokio_stream::wrappers::{BroadcastStream, ReceiverStream};
 use tokio_stream::{Stream, StreamExt};
 use tower_http::services::ServeDir;
 
@@ -68,6 +68,8 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> {
         .route("/api/reminders", get(api_reminders))
         .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}/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}/mark-all-read", post(post_mark_all_read))
         .route("/cancel-reminder/{id}", post(post_cancel_reminder))
@@ -1686,6 +1688,154 @@ 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,
+    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,
+    AxumPath(id): AxumPath,
+) -> Sse>> {
+    let (tx, rx) = tokio::sync::mpsc::channel::>(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(_) => return, // notification channel closed (shutdown)
+            }
+        }
+    });
+
+    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,
+    AxumPath(id): AxumPath,
+) -> 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
 /// scheduled-prompts tab. Returns the wire shape directly
 /// so the frontend can render without an extra translation layer.