Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
12985ade6d | ||
|
|
050e130eba | ||
|
|
c4ce91b61f | ||
|
|
cc6227ccb5 |
7 changed files with 1884 additions and 35 deletions
1363
docs/web-ui.md
1363
docs/web-ui.md
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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. */
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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 */
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue