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.
This commit is contained in:
parent
c4ce91b61f
commit
050e130eba
5 changed files with 365 additions and 14 deletions
|
|
@ -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<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
|
||||
/// methods take `&self`, internal `Mutex<Connection>` serializes
|
||||
/// access.
|
||||
pub struct BuildLogs {
|
||||
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 {
|
||||
|
|
@ -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<i64> {
|
||||
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<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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue