Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ec401a6c7 | ||
|
|
378e8bf9df | ||
|
|
087a5366fb | ||
|
|
aed43ce4df |
11 changed files with 343 additions and 33 deletions
|
|
@ -393,6 +393,75 @@
|
||||||
}
|
}
|
||||||
renderStateBadge();
|
renderStateBadge();
|
||||||
}
|
}
|
||||||
|
// Open-threads section: same data the get_open_threads MCP tool
|
||||||
|
// returns. Best-effort fetch on cold load + after every turn_end
|
||||||
|
// (a turn likely answered or asked something). Silent failure
|
||||||
|
// keeps the section hidden rather than surfacing an empty banner.
|
||||||
|
let lastOpenThreadsCount = 0;
|
||||||
|
async function refreshOpenThreads() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/open-threads');
|
||||||
|
if (!resp.ok) {
|
||||||
|
renderOpenThreads([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await resp.json();
|
||||||
|
renderOpenThreads(data.threads || []);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('open-threads fetch failed', err);
|
||||||
|
renderOpenThreads([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function renderOpenThreads(threads) {
|
||||||
|
const root = $('open-threads-section');
|
||||||
|
const list = $('open-threads-list');
|
||||||
|
const summary = $('open-threads-summary');
|
||||||
|
if (!root || !list || !summary) return;
|
||||||
|
if (!threads.length) {
|
||||||
|
root.hidden = true;
|
||||||
|
lastOpenThreadsCount = 0;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.hidden = false;
|
||||||
|
summary.textContent = 'open threads · ' + threads.length;
|
||||||
|
list.innerHTML = '';
|
||||||
|
// Auto-expand on first appearance of any open thread so the
|
||||||
|
// operator notices new loose ends; collapse only on operator
|
||||||
|
// click (sticky after that).
|
||||||
|
if (lastOpenThreadsCount === 0) root.open = true;
|
||||||
|
lastOpenThreadsCount = threads.length;
|
||||||
|
const fmtAge = (s) => {
|
||||||
|
if (s < 60) return s + 's';
|
||||||
|
if (s < 3600) return Math.floor(s / 60) + 'm';
|
||||||
|
if (s < 86400) return Math.floor(s / 3600) + 'h';
|
||||||
|
return Math.floor(s / 86400) + 'd';
|
||||||
|
};
|
||||||
|
for (const t of threads) {
|
||||||
|
const li = el('li');
|
||||||
|
if (t.kind === 'approval') {
|
||||||
|
li.append(
|
||||||
|
el('span', { class: 'inbox-from' }, '◇ approval #' + t.id), ' ',
|
||||||
|
el('span', { class: 'inbox-sep' }, t.agent + ' @ ' + (t.commit_ref || '').slice(0, 12)), ' ',
|
||||||
|
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
|
||||||
|
);
|
||||||
|
if (t.description) {
|
||||||
|
li.append(el('div', { class: 'inbox-body' }, t.description));
|
||||||
|
}
|
||||||
|
} else if (t.kind === 'question') {
|
||||||
|
const target = t.target || 'operator';
|
||||||
|
li.append(
|
||||||
|
el('span', { class: 'inbox-from' }, '? #' + t.id), ' ',
|
||||||
|
el('span', { class: 'inbox-sep' }, t.asker + ' → ' + target), ' ',
|
||||||
|
el('span', { class: 'inbox-ts' }, fmtAge(t.age_seconds || 0) + ' ago'),
|
||||||
|
el('div', { class: 'inbox-body' }, t.question || ''),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
li.append(el('span', { class: 'inbox-body' }, JSON.stringify(t)));
|
||||||
|
}
|
||||||
|
list.append(li);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderInbox(rows) {
|
function renderInbox(rows) {
|
||||||
const root = $('inbox-section');
|
const root = $('inbox-section');
|
||||||
const list = $('inbox-list');
|
const list = $('inbox-list');
|
||||||
|
|
@ -542,6 +611,10 @@
|
||||||
renderAliveBadge(s.status);
|
renderAliveBadge(s.status);
|
||||||
renderModelChip(s.model);
|
renderModelChip(s.model);
|
||||||
renderTokenUsage(s.token_usage);
|
renderTokenUsage(s.token_usage);
|
||||||
|
// Open-threads aren't part of /api/state (kept on the broker
|
||||||
|
// db, fetched via the per-agent socket). Cold-load fetches
|
||||||
|
// it here; turn_end refreshes it via the renderer below.
|
||||||
|
refreshOpenThreads();
|
||||||
// Skip the re-render if nothing structurally changed. The most
|
// Skip the re-render if nothing structurally changed. The most
|
||||||
// common case is `online` polling itself — without this guard, the
|
// common case is `online` polling itself — without this guard, the
|
||||||
// operator's <input value> gets clobbered every cycle.
|
// operator's <input value> gets clobbered every cycle.
|
||||||
|
|
@ -730,6 +803,8 @@
|
||||||
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
openTurnsFromHistory = Math.max(0, openTurnsFromHistory - 1);
|
||||||
} else {
|
} else {
|
||||||
setBannerActive(false); setState('idle');
|
setBannerActive(false); setState('idle');
|
||||||
|
// Likely answered/asked/scheduled something — refresh.
|
||||||
|
refreshOpenThreads();
|
||||||
}
|
}
|
||||||
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
const cls = ev.ok ? 'turn-end-ok' : 'turn-end-fail';
|
||||||
api.row(cls,
|
api.row(cls,
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,11 @@
|
||||||
<ul id="inbox-list"></ul>
|
<ul id="inbox-list"></ul>
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
<details id="open-threads-section" class="agent-inbox" hidden>
|
||||||
|
<summary>▸ <span id="open-threads-summary">open threads</span></summary>
|
||||||
|
<ul id="open-threads-list"></ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
<div class="terminal-wrap">
|
<div class="terminal-wrap">
|
||||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||||
<div id="term-input" class="term-input"></div>
|
<div id="term-input" class="term-input"></div>
|
||||||
|
|
|
||||||
|
|
@ -105,6 +105,7 @@ pub async fn serve(
|
||||||
.route("/api/compact", post(post_compact))
|
.route("/api/compact", post(post_compact))
|
||||||
.route("/api/model", post(post_set_model))
|
.route("/api/model", post(post_set_model))
|
||||||
.route("/api/new-session", post(post_new_session))
|
.route("/api/new-session", post(post_new_session))
|
||||||
|
.route("/api/open-threads", get(api_open_threads))
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||||
let listener = bind_with_retry(addr, "web UI").await?;
|
let listener = bind_with_retry(addr, "web UI").await?;
|
||||||
|
|
@ -231,6 +232,49 @@ struct SessionView {
|
||||||
exit_note: Option<String>,
|
exit_note: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Proxy this agent's open-threads via the per-agent socket. The
|
||||||
|
/// web UI surfaces the result as a collapsible section in the page
|
||||||
|
/// so the operator can see at a glance what's pending against the
|
||||||
|
/// agent (questions asked by it, peer questions targeting it,
|
||||||
|
/// approvals for the manager). Same data the
|
||||||
|
/// `mcp__hyperhive__get_open_threads` tool sees from inside the
|
||||||
|
/// container.
|
||||||
|
async fn api_open_threads(State(state): State<AppState>) -> Response {
|
||||||
|
let threads: Vec<hive_sh4re::OpenThread> = match state.flavor() {
|
||||||
|
Flavor::Agent => {
|
||||||
|
match client::request::<_, hive_sh4re::AgentResponse>(
|
||||||
|
&state.socket,
|
||||||
|
&hive_sh4re::AgentRequest::GetOpenThreads,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(hive_sh4re::AgentResponse::OpenThreads { threads }) => threads,
|
||||||
|
Ok(hive_sh4re::AgentResponse::Err { message }) => {
|
||||||
|
return error_response(&format!("get_open_threads: {message}"));
|
||||||
|
}
|
||||||
|
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
|
||||||
|
Err(e) => return error_response(&format!("transport: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Flavor::Manager => {
|
||||||
|
match client::request::<_, hive_sh4re::ManagerResponse>(
|
||||||
|
&state.socket,
|
||||||
|
&hive_sh4re::ManagerRequest::GetOpenThreads,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(hive_sh4re::ManagerResponse::OpenThreads { threads }) => threads,
|
||||||
|
Ok(hive_sh4re::ManagerResponse::Err { message }) => {
|
||||||
|
return error_response(&format!("get_open_threads: {message}"));
|
||||||
|
}
|
||||||
|
Ok(other) => return error_response(&format!("unexpected response: {other:?}")),
|
||||||
|
Err(e) => return error_response(&format!("transport: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
axum::Json(serde_json::json!({ "threads": threads })).into_response()
|
||||||
|
}
|
||||||
|
|
||||||
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
||||||
// Capture seq *before* any reads so the dedupe contract is
|
// Capture seq *before* any reads so the dedupe contract is
|
||||||
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
// "events with seq > snapshot.seq are post-snapshot, never missed."
|
||||||
|
|
|
||||||
|
|
@ -335,6 +335,32 @@
|
||||||
if (containersState.delete(ev.name)) renderContainersFromState();
|
if (containersState.delete(ev.name)) renderContainersFromState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Derived tombstones + meta_inputs. Both are emitted as full
|
||||||
|
// snapshots (not diffs) — the lists are tiny and recomputing
|
||||||
|
// avoids ordering races between a same-tick destroy + purge.
|
||||||
|
let tombstonesState = [];
|
||||||
|
let metaInputsState = [];
|
||||||
|
function syncTombstonesFromSnapshot(s) {
|
||||||
|
tombstonesState = (s.tombstones || []).slice();
|
||||||
|
}
|
||||||
|
function syncMetaInputsFromSnapshot(s) {
|
||||||
|
metaInputsState = (s.meta_inputs || []).slice();
|
||||||
|
}
|
||||||
|
function applyTombstonesChanged(ev) {
|
||||||
|
tombstonesState = (ev.tombstones || []).slice();
|
||||||
|
renderTombstonesFromState();
|
||||||
|
}
|
||||||
|
function applyMetaInputsChanged(ev) {
|
||||||
|
metaInputsState = (ev.inputs || []).slice();
|
||||||
|
renderMetaInputsFromState();
|
||||||
|
}
|
||||||
|
function renderTombstonesFromState() {
|
||||||
|
renderTombstones({ tombstones: tombstonesState });
|
||||||
|
}
|
||||||
|
function renderMetaInputsFromState() {
|
||||||
|
renderMetaInputs({ meta_inputs: metaInputsState });
|
||||||
|
}
|
||||||
|
|
||||||
// Derived transient state — cold-loaded from /api/state.transients,
|
// Derived transient state — cold-loaded from /api/state.transients,
|
||||||
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
// then mutated live by `transient_set` / `transient_cleared`. Keyed
|
||||||
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
// by agent name so add/remove are O(1). `since_unix` is wall-clock so
|
||||||
|
|
@ -483,6 +509,14 @@
|
||||||
{ class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
|
{ class: 'meta', title: 'sha currently locked in /meta/flake.lock' },
|
||||||
`deployed:${c.deployed_sha}`));
|
`deployed:${c.deployed_sha}`));
|
||||||
}
|
}
|
||||||
|
if (c.pending_reminders && c.pending_reminders > 0) {
|
||||||
|
head.append(el('span',
|
||||||
|
{
|
||||||
|
class: 'badge badge-reminder',
|
||||||
|
title: 'pending reminders queued for this agent — see the reminders section to view / cancel',
|
||||||
|
},
|
||||||
|
`⏰ ${c.pending_reminders}`));
|
||||||
|
}
|
||||||
li.append(head);
|
li.append(head);
|
||||||
|
|
||||||
// ── line 2: action buttons ───────────────────────────────────
|
// ── line 2: action buttons ───────────────────────────────────
|
||||||
|
|
@ -512,14 +546,16 @@
|
||||||
if (!c.is_manager) {
|
if (!c.is_manager) {
|
||||||
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
|
// DESTR0Y is event-covered (ContainerRemoved); PURG3 also
|
||||||
// wipes tombstone state which isn't event-derived yet, so it
|
// wipes tombstone state which isn't event-derived yet, so it
|
||||||
// keeps the post-submit refetch.
|
// Both event-covered now (ContainerRemoved +
|
||||||
|
// TombstonesChanged); no /api/state refetch needed.
|
||||||
actions.append(
|
actions.append(
|
||||||
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
|
form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y',
|
||||||
'destroy ' + c.name + '? container is removed; state + creds kept.',
|
'destroy ' + c.name + '? container is removed; state + creds kept.',
|
||||||
{}, { noRefresh: true }),
|
{}, { noRefresh: true }),
|
||||||
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
|
form('/destroy/' + c.name, 'btn-destroy', 'PURG3',
|
||||||
'PURGE ' + c.name + '? container, config history, claude creds, '
|
'PURGE ' + c.name + '? container, config history, claude creds, '
|
||||||
+ 'and notes are all WIPED. no undo.', { purge: 'on' }),
|
+ 'and notes are all WIPED. no undo.',
|
||||||
|
{ purge: 'on' }, { noRefresh: true }),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
li.append(actions);
|
li.append(actions);
|
||||||
|
|
@ -683,8 +719,9 @@
|
||||||
actions.append(respawn);
|
actions.append(respawn);
|
||||||
actions.append(form(
|
actions.append(form(
|
||||||
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
'/purge-tombstone/' + t.name, 'btn-destroy', 'PURG3',
|
||||||
'PURGE ' + t.name + '? config history, claude creds, /state/ notes '
|
'PURGE ' + t.name + '? config history, claude creds, '
|
||||||
+ 'are all WIPED. no undo.',
|
+ 'and notes are all WIPED. no undo.',
|
||||||
|
{}, { noRefresh: true },
|
||||||
));
|
));
|
||||||
li.append(actions);
|
li.append(actions);
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
|
|
@ -711,6 +748,7 @@
|
||||||
asked_at: ev.asked_at,
|
asked_at: ev.asked_at,
|
||||||
deadline_at: ev.deadline_at ?? null,
|
deadline_at: ev.deadline_at ?? null,
|
||||||
target: ev.target || null,
|
target: ev.target || null,
|
||||||
|
question_refs: ev.question_refs || [],
|
||||||
});
|
});
|
||||||
renderQuestions();
|
renderQuestions();
|
||||||
}
|
}
|
||||||
|
|
@ -729,6 +767,8 @@
|
||||||
answer: ev.answer,
|
answer: ev.answer,
|
||||||
answerer: ev.answerer,
|
answerer: ev.answerer,
|
||||||
target: existing?.target ?? ev.target ?? null,
|
target: existing?.target ?? ev.target ?? null,
|
||||||
|
question_refs: existing?.question_refs || [],
|
||||||
|
answer_refs: ev.answer_refs || [],
|
||||||
});
|
});
|
||||||
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
if (questionsState.history.length > QUESTION_HISTORY_LIMIT) {
|
||||||
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
questionsState.history.length = QUESTION_HISTORY_LIMIT;
|
||||||
|
|
@ -820,7 +860,7 @@
|
||||||
head.append(' ', el('span', { class: 'q-ttl' }, txt));
|
head.append(' ', el('span', { class: 'q-ttl' }, txt));
|
||||||
}
|
}
|
||||||
const qBody = el('div', { class: 'q-body' });
|
const qBody = el('div', { class: 'q-body' });
|
||||||
const qPreviews = appendLinkified(qBody, q.question);
|
const qPreviews = appendLinkified(qBody, q.question, q.question_refs);
|
||||||
li.append(head, qBody);
|
li.append(head, qBody);
|
||||||
for (const d of qPreviews) li.appendChild(d);
|
for (const d of qPreviews) li.appendChild(d);
|
||||||
const f = el('form', {
|
const f = el('form', {
|
||||||
|
|
@ -916,9 +956,9 @@
|
||||||
el('span', { class: 'msg-sep' }, 'asked:'),
|
el('span', { class: 'msg-sep' }, 'asked:'),
|
||||||
);
|
);
|
||||||
const histBody = el('div', { class: 'q-body' });
|
const histBody = el('div', { class: 'q-body' });
|
||||||
const histBodyPreviews = appendLinkified(histBody, q.question);
|
const histBodyPreviews = appendLinkified(histBody, q.question, q.question_refs);
|
||||||
const ansText = el('span', { class: 'q-answer-text' });
|
const ansText = el('span', { class: 'q-answer-text' });
|
||||||
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)');
|
const histAnsPreviews = appendLinkified(ansText, q.answer || '(none)', q.answer_refs);
|
||||||
const ansLine = el('div', { class: 'q-answer' },
|
const ansLine = el('div', { class: 'q-answer' },
|
||||||
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
el('span', { class: 'msg-sep' }, `${q.answerer || '?'}: `),
|
||||||
ansText,
|
ansText,
|
||||||
|
|
@ -1214,6 +1254,10 @@
|
||||||
action: '/meta-update',
|
action: '/meta-update',
|
||||||
class: 'meta-inputs-form',
|
class: 'meta-inputs-form',
|
||||||
'data-async': '',
|
'data-async': '',
|
||||||
|
// run_meta_update emits MetaInputsChanged once the lock
|
||||||
|
// bump finishes; per-agent rebuilds fire their own
|
||||||
|
// ContainerStateChanged. No /api/state refetch needed.
|
||||||
|
'data-no-refresh': '',
|
||||||
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
'data-confirm': 'update selected meta flake inputs + rebuild affected agents?',
|
||||||
});
|
});
|
||||||
const ul = el('ul', { class: 'meta-inputs' });
|
const ul = el('ul', { class: 'meta-inputs' });
|
||||||
|
|
@ -1419,6 +1463,8 @@
|
||||||
// `transientsState` + `containersState`, not from `s.*`).
|
// `transientsState` + `containersState`, not from `s.*`).
|
||||||
syncTransientsFromSnapshot(s);
|
syncTransientsFromSnapshot(s);
|
||||||
syncContainersFromSnapshot(s);
|
syncContainersFromSnapshot(s);
|
||||||
|
syncTombstonesFromSnapshot(s);
|
||||||
|
syncMetaInputsFromSnapshot(s);
|
||||||
renderContainers(s);
|
renderContainers(s);
|
||||||
renderTombstones(s);
|
renderTombstones(s);
|
||||||
// Sync the derived approvals + questions stores from the
|
// Sync the derived approvals + questions stores from the
|
||||||
|
|
@ -1513,6 +1559,8 @@
|
||||||
transient_cleared: (ev) => { applyTransientCleared(ev); },
|
transient_cleared: (ev) => { applyTransientCleared(ev); },
|
||||||
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
|
container_state_changed: (ev) => { applyContainerStateChanged(ev); },
|
||||||
container_removed: (ev) => { applyContainerRemoved(ev); },
|
container_removed: (ev) => { applyContainerRemoved(ev); },
|
||||||
|
tombstones_changed: (ev) => { applyTombstonesChanged(ev); },
|
||||||
|
meta_inputs_changed: (ev) => { applyMetaInputsChanged(ev); },
|
||||||
},
|
},
|
||||||
// Both history backfill and live frames flow through here, so the
|
// Both history backfill and live frames flow through here, so the
|
||||||
// inbox section ends up populated correctly on first paint and
|
// inbox section ends up populated correctly on first paint and
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,10 @@ a:hover {
|
||||||
color: var(--muted); border-color: var(--purple-dim);
|
color: var(--muted); border-color: var(--purple-dim);
|
||||||
background: rgba(127, 132, 156, 0.08);
|
background: rgba(127, 132, 156, 0.08);
|
||||||
}
|
}
|
||||||
|
.badge-reminder {
|
||||||
|
color: var(--cyan); border-color: var(--cyan);
|
||||||
|
text-shadow: 0 0 6px rgba(137, 220, 235, 0.4);
|
||||||
|
}
|
||||||
.container-row.tombstone {
|
.container-row.tombstone {
|
||||||
border-style: dashed;
|
border-style: dashed;
|
||||||
background: rgba(24, 24, 37, 0.35);
|
background: rgba(24, 24, 37, 0.35);
|
||||||
|
|
|
||||||
|
|
@ -87,8 +87,12 @@ pub async fn approve(coord: Arc<Coordinator>, id: i64) -> Result<()> {
|
||||||
}
|
}
|
||||||
// New container row appeared (or didn't, on failure
|
// New container row appeared (or didn't, on failure
|
||||||
// before nixos-container create completed) — rescan so
|
// before nixos-container create completed) — rescan so
|
||||||
// dashboards reflect the post-spawn state.
|
// dashboards reflect the post-spawn state. Spawn can
|
||||||
|
// also consume a tombstone of the same name; emit the
|
||||||
|
// fresh list so the operator's dormant-state pane
|
||||||
|
// updates without a refetch.
|
||||||
coord_bg.rescan_containers_and_emit().await;
|
coord_bg.rescan_containers_and_emit().await;
|
||||||
|
crate::dashboard::emit_tombstones_snapshot(&coord_bg).await;
|
||||||
});
|
});
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -360,8 +364,11 @@ pub async fn destroy(coord: &Arc<Coordinator>, name: &str, purge: bool) -> Resul
|
||||||
agent: name.to_owned(),
|
agent: name.to_owned(),
|
||||||
});
|
});
|
||||||
// Container row disappeared — rescan so the dashboard fires
|
// Container row disappeared — rescan so the dashboard fires
|
||||||
// `ContainerRemoved` for the gone row.
|
// `ContainerRemoved` for the gone row, then emit the
|
||||||
|
// tombstones snapshot (gained one on destroy, lost one on
|
||||||
|
// purge — recompute either way).
|
||||||
coord.rescan_containers_and_emit().await;
|
coord.rescan_containers_and_emit().await;
|
||||||
|
crate::dashboard::emit_tombstones_snapshot(coord).await;
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,8 @@ pub async fn rebuild_agent(coord: &Arc<Coordinator>, name: &str, current_rev: &s
|
||||||
// shifted — rescan so dashboards drop the "needs update"
|
// shifted — rescan so dashboards drop the "needs update"
|
||||||
// chip without waiting for the next /api/state poll.
|
// chip without waiting for the next /api/state poll.
|
||||||
coord.rescan_containers_and_emit().await;
|
coord.rescan_containers_and_emit().await;
|
||||||
|
// Lock bump → meta-inputs panel needs to re-render.
|
||||||
|
crate::dashboard::emit_meta_inputs_snapshot(coord);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
|
coord.notify_manager(&hive_sh4re::HelperEvent::Rebuilt {
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ pub struct ContainerView {
|
||||||
/// for this agent's input.
|
/// for this agent's input.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
pub deployed_sha: Option<String>,
|
pub deployed_sha: Option<String>,
|
||||||
|
/// Count of this agent's pending reminders. Computed during
|
||||||
|
/// `build_all` via `Broker::count_pending_reminders_for`; the
|
||||||
|
/// dashboard renders a small chip when > 0. Updates with the
|
||||||
|
/// 10s `crash_watch` rescan + every container mutation site;
|
||||||
|
/// not real-time on remind/cancel-reminder but close enough.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pending_reminders: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the full container list. Wraps `lifecycle::list()` and
|
/// Build the full container list. Wraps `lifecycle::list()` and
|
||||||
|
|
@ -53,6 +60,20 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||||
let deployed_sha = locked
|
let deployed_sha = locked
|
||||||
.get(&format!("agent-{logical}"))
|
.get(&format!("agent-{logical}"))
|
||||||
.map(|s| s[..s.len().min(12)].to_owned());
|
.map(|s| s[..s.len().min(12)].to_owned());
|
||||||
|
// Recipient name the broker uses for this agent — sub-agents
|
||||||
|
// are addressed by logical name, the manager by the
|
||||||
|
// MANAGER_AGENT constant. Mirrors the rest of the broker
|
||||||
|
// surface so the count matches what `mcp__hyperhive__remind`
|
||||||
|
// queued.
|
||||||
|
let reminder_recipient = if is_manager {
|
||||||
|
hive_sh4re::MANAGER_AGENT
|
||||||
|
} else {
|
||||||
|
logical.as_str()
|
||||||
|
};
|
||||||
|
let pending_reminders = coord
|
||||||
|
.broker
|
||||||
|
.count_pending_reminders_for(reminder_recipient)
|
||||||
|
.unwrap_or(0);
|
||||||
out.push(ContainerView {
|
out.push(ContainerView {
|
||||||
port: lifecycle::agent_web_port(&logical),
|
port: lifecycle::agent_web_port(&logical),
|
||||||
running: lifecycle::is_running(&logical).await,
|
running: lifecycle::is_running(&logical).await,
|
||||||
|
|
@ -62,6 +83,7 @@ pub async fn build_all(coord: &Coordinator) -> Vec<ContainerView> {
|
||||||
needs_update,
|
needs_update,
|
||||||
needs_login,
|
needs_login,
|
||||||
deployed_sha,
|
deployed_sha,
|
||||||
|
pending_reminders,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
out
|
out
|
||||||
|
|
|
||||||
|
|
@ -262,6 +262,7 @@ impl Coordinator {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let question_refs = crate::dashboard::scan_validated_paths(question);
|
||||||
self.emit_dashboard_event(DashboardEvent::QuestionAdded {
|
self.emit_dashboard_event(DashboardEvent::QuestionAdded {
|
||||||
seq: self.next_seq(),
|
seq: self.next_seq(),
|
||||||
id,
|
id,
|
||||||
|
|
@ -272,6 +273,7 @@ impl Coordinator {
|
||||||
asked_at,
|
asked_at,
|
||||||
deadline_at,
|
deadline_at,
|
||||||
target: target.map(str::to_owned),
|
target: target.map(str::to_owned),
|
||||||
|
question_refs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -293,6 +295,7 @@ impl Coordinator {
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
.and_then(|d| i64::try_from(d.as_secs()).ok())
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
|
let answer_refs = crate::dashboard::scan_validated_paths(answer);
|
||||||
self.emit_dashboard_event(DashboardEvent::QuestionResolved {
|
self.emit_dashboard_event(DashboardEvent::QuestionResolved {
|
||||||
seq: self.next_seq(),
|
seq: self.next_seq(),
|
||||||
id,
|
id,
|
||||||
|
|
@ -301,6 +304,7 @@ impl Coordinator {
|
||||||
answered_at,
|
answered_at,
|
||||||
cancelled,
|
cancelled,
|
||||||
target: target.map(str::to_owned),
|
target: target.map(str::to_owned),
|
||||||
|
answer_refs,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -170,9 +170,9 @@ struct StateSnapshot {
|
||||||
/// fire `HelperEvent::QuestionAnswered` back into the asker's
|
/// fire `HelperEvent::QuestionAnswered` back into the asker's
|
||||||
/// inbox. Peer-to-peer questions live in the same table but never
|
/// inbox. Peer-to-peer questions live in the same table but never
|
||||||
/// surface here (see `OperatorQuestions::pending`).
|
/// surface here (see `OperatorQuestions::pending`).
|
||||||
questions: Vec<crate::operator_questions::OpQuestion>,
|
questions: Vec<QuestionView>,
|
||||||
/// Last 20 answered questions, newest-first.
|
/// Last 20 answered questions, newest-first.
|
||||||
question_history: Vec<crate::operator_questions::OpQuestion>,
|
question_history: Vec<QuestionView>,
|
||||||
/// State dirs (config history + claude creds + /state/ notes) that
|
/// State dirs (config history + claude creds + /state/ notes) that
|
||||||
/// survive after a destroy-without-purge. The operator can re-spawn
|
/// survive after a destroy-without-purge. The operator can re-spawn
|
||||||
/// with the same name to resume, or PURG3 to wipe them.
|
/// with the same name to resume, or PURG3 to wipe them.
|
||||||
|
|
@ -186,6 +186,35 @@ struct StateSnapshot {
|
||||||
meta_inputs: Vec<MetaInputView>,
|
meta_inputs: Vec<MetaInputView>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// OpQuestion + computed `question_refs` / `answer_refs`. Built
|
||||||
|
/// from the snapshot read; the live channel attaches the same
|
||||||
|
/// fields directly on `QuestionAdded` / `QuestionResolved`.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct QuestionView {
|
||||||
|
#[serde(flatten)]
|
||||||
|
inner: crate::operator_questions::OpQuestion,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
question_refs: Vec<String>,
|
||||||
|
#[serde(skip_serializing_if = "Vec::is_empty")]
|
||||||
|
answer_refs: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl QuestionView {
|
||||||
|
fn from_question(q: crate::operator_questions::OpQuestion) -> Self {
|
||||||
|
let question_refs = scan_validated_paths(&q.question);
|
||||||
|
let answer_refs = q
|
||||||
|
.answer
|
||||||
|
.as_deref()
|
||||||
|
.map(scan_validated_paths)
|
||||||
|
.unwrap_or_default();
|
||||||
|
Self {
|
||||||
|
inner: q,
|
||||||
|
question_refs,
|
||||||
|
answer_refs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct PortConflict {
|
struct PortConflict {
|
||||||
port: u16,
|
port: u16,
|
||||||
|
|
@ -193,15 +222,15 @@ struct PortConflict {
|
||||||
agents: Vec<String>,
|
agents: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
struct TombstoneView {
|
pub(crate) struct TombstoneView {
|
||||||
name: String,
|
pub name: String,
|
||||||
/// Bytes used by the state dir tree. Cheap-ish to compute; let the
|
/// Bytes used by the state dir tree. Cheap-ish to compute; let the
|
||||||
/// operator know how much they're holding onto.
|
/// operator know how much they're holding onto.
|
||||||
state_bytes: u64,
|
pub state_bytes: u64,
|
||||||
/// Mtime (unix seconds) of the state dir; rough "last seen".
|
/// Mtime (unix seconds) of the state dir; rough "last seen".
|
||||||
last_seen: i64,
|
pub last_seen: i64,
|
||||||
has_creds: bool,
|
pub has_creds: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
|
|
@ -311,12 +340,21 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
||||||
// dashboard now derives it client-side from the message stream
|
// dashboard now derives it client-side from the message stream
|
||||||
// (terminal backfill + live SSE), so the snapshot stops shipping it.
|
// (terminal backfill + live SSE), so the snapshot stops shipping it.
|
||||||
// Both operator-targeted and peer threads now surface on the
|
// Both operator-targeted and peer threads now surface on the
|
||||||
// dashboard. Client filters by target client-side.
|
// dashboard. Client filters by target client-side. Each row is
|
||||||
let questions = log_default("questions.pending_all", state.coord.questions.pending_all());
|
// wrapped in QuestionView so the snapshot carries the same
|
||||||
let question_history = log_default(
|
// file_refs the live event variants attach.
|
||||||
|
let questions: Vec<QuestionView> =
|
||||||
|
log_default("questions.pending_all", state.coord.questions.pending_all())
|
||||||
|
.into_iter()
|
||||||
|
.map(QuestionView::from_question)
|
||||||
|
.collect();
|
||||||
|
let question_history: Vec<QuestionView> = log_default(
|
||||||
"questions.recent_answered_all",
|
"questions.recent_answered_all",
|
||||||
state.coord.questions.recent_answered_all(20),
|
state.coord.questions.recent_answered_all(20),
|
||||||
);
|
)
|
||||||
|
.into_iter()
|
||||||
|
.map(QuestionView::from_question)
|
||||||
|
.collect();
|
||||||
|
|
||||||
axum::Json(StateSnapshot {
|
axum::Json(StateSnapshot {
|
||||||
seq,
|
seq,
|
||||||
|
|
@ -356,19 +394,19 @@ fn build_port_conflicts(containers: &[ContainerView]) -> Vec<PortConflict> {
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Serialize, Clone)]
|
#[derive(Serialize, Clone, Debug)]
|
||||||
struct MetaInputView {
|
pub(crate) struct MetaInputView {
|
||||||
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, etc.
|
/// Input key in meta's `flake.nix` — `hyperhive`, `agent-<n>`, etc.
|
||||||
name: String,
|
pub name: String,
|
||||||
/// Full locked sha. Not displayed verbatim; the dashboard
|
/// Full locked sha. Not displayed verbatim; the dashboard
|
||||||
/// truncates to the first 12 chars for the chip.
|
/// truncates to the first 12 chars for the chip.
|
||||||
rev: String,
|
pub rev: String,
|
||||||
/// Unix seconds — `locked.lastModified`. Drives the relative
|
/// Unix seconds — `locked.lastModified`. Drives the relative
|
||||||
/// "2h ago" timestamp on each input row.
|
/// "2h ago" timestamp on each input row.
|
||||||
last_modified: i64,
|
pub last_modified: i64,
|
||||||
/// `original.url` if available, for the tooltip / row meta text.
|
/// `original.url` if available, for the tooltip / row meta text.
|
||||||
#[serde(skip_serializing_if = "Option::is_none")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
url: Option<String>,
|
pub url: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Walk `flake.lock`'s `nodes` graph from `root` and emit one
|
/// Walk `flake.lock`'s `nodes` graph from `root` and emit one
|
||||||
|
|
@ -956,6 +994,36 @@ fn resolve_state_path(raw: &str) -> std::result::Result<std::path::PathBuf, Stri
|
||||||
Ok(canonical)
|
Ok(canonical)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Snapshot the current tombstone list and emit a
|
||||||
|
/// `TombstonesChanged` event. Call after any mutation that could
|
||||||
|
/// add or remove a tombstone (`actions::destroy`,
|
||||||
|
/// `post_purge_tombstone`, spawn finalisation). Cheap — the list
|
||||||
|
/// is tiny.
|
||||||
|
pub(crate) async fn emit_tombstones_snapshot(coord: &Arc<Coordinator>) {
|
||||||
|
let containers = coord.containers_snapshot().await;
|
||||||
|
let transient_snapshot = coord.transient_snapshot();
|
||||||
|
let tombstones = build_tombstone_views(coord, &containers, &transient_snapshot);
|
||||||
|
coord.emit_dashboard_event(
|
||||||
|
crate::dashboard_events::DashboardEvent::TombstonesChanged {
|
||||||
|
seq: coord.next_seq(),
|
||||||
|
tombstones,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Snapshot meta/flake.lock's root inputs + emit
|
||||||
|
/// `MetaInputsChanged`. Call after any mutation that bumps a lock
|
||||||
|
/// (`run_meta_update`, `auto_update::rebuild_agent`).
|
||||||
|
pub(crate) fn emit_meta_inputs_snapshot(coord: &Coordinator) {
|
||||||
|
let inputs = read_meta_inputs();
|
||||||
|
coord.emit_dashboard_event(
|
||||||
|
crate::dashboard_events::DashboardEvent::MetaInputsChanged {
|
||||||
|
seq: coord.next_seq(),
|
||||||
|
inputs,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Scan `body` for path-shaped tokens, validate each against the
|
/// Scan `body` for path-shaped tokens, validate each against the
|
||||||
/// allow-list, return the unique set of tokens that resolve to a
|
/// allow-list, return the unique set of tokens that resolve to a
|
||||||
/// regular file. Called at broker-message ingest time so the
|
/// regular file. Called at broker-message ingest time so the
|
||||||
|
|
@ -1094,9 +1162,10 @@ async fn post_purge_tombstone(
|
||||||
.fail_pending_for_agent(&name, "agent state purged");
|
.fail_pending_for_agent(&name, "agent state purged");
|
||||||
if errors.is_empty() {
|
if errors.is_empty() {
|
||||||
tracing::info!(%name, "tombstone purged");
|
tracing::info!(%name, "tombstone purged");
|
||||||
// Tombstones aren't event-derived yet, so the client still
|
// Fire the post-purge tombstones snapshot so dashboards
|
||||||
// refetches /api/state to see this one disappear (matching
|
// drop the row live; matching form carries
|
||||||
// form omits `data-no-refresh`).
|
// `data-no-refresh`.
|
||||||
|
emit_tombstones_snapshot(&state.coord).await;
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
} else {
|
} else {
|
||||||
error_response(&format!("purge {name} partial: {}", errors.join(", ")))
|
error_response(&format!("purge {name} partial: {}", errors.join(", ")))
|
||||||
|
|
@ -1148,10 +1217,10 @@ async fn post_meta_update(
|
||||||
let inputs_clone = inputs.clone();
|
let inputs_clone = inputs.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
run_meta_update(&coord, &inputs_clone).await;
|
run_meta_update(&coord, &inputs_clone).await;
|
||||||
|
// Lock file changed — emit so dashboards refresh the
|
||||||
|
// meta-inputs panel without a snapshot poll.
|
||||||
|
emit_meta_inputs_snapshot(&coord);
|
||||||
});
|
});
|
||||||
// Background task — each per-agent rebuild emits its own
|
|
||||||
// `ContainerStateChanged`; the meta inputs panel still relies on
|
|
||||||
// /api/state freshness (matching form omits `data-no-refresh`).
|
|
||||||
(StatusCode::OK, "ok").into_response()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
|
||||||
use crate::container_view::ContainerView;
|
use crate::container_view::ContainerView;
|
||||||
|
use crate::dashboard::{MetaInputView, TombstoneView};
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "snake_case", tag = "kind")]
|
#[serde(rename_all = "snake_case", tag = "kind")]
|
||||||
|
|
@ -105,6 +106,11 @@ pub enum DashboardEvent {
|
||||||
asked_at: i64,
|
asked_at: i64,
|
||||||
deadline_at: Option<i64>,
|
deadline_at: Option<i64>,
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
|
/// Verified file-path tokens that appear in `question`.
|
||||||
|
/// Same shape as broker `Sent`/`Delivered` events; the
|
||||||
|
/// client linkifies only what hive-c0re vouched for.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
question_refs: Vec<String>,
|
||||||
},
|
},
|
||||||
/// A question was answered (operator answer, peer answer,
|
/// A question was answered (operator answer, peer answer,
|
||||||
/// operator override on a peer thread, or ttl watchdog
|
/// operator override on a peer thread, or ttl watchdog
|
||||||
|
|
@ -119,6 +125,9 @@ pub enum DashboardEvent {
|
||||||
answered_at: i64,
|
answered_at: i64,
|
||||||
cancelled: bool,
|
cancelled: bool,
|
||||||
target: Option<String>,
|
target: Option<String>,
|
||||||
|
/// Verified file-path tokens that appear in `answer`.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
answer_refs: Vec<String>,
|
||||||
},
|
},
|
||||||
/// A lifecycle action started for an agent (spawn / start / stop
|
/// A lifecycle action started for an agent (spawn / start / stop
|
||||||
/// / restart / rebuild / destroy). Clients render a spinner next
|
/// / restart / rebuild / destroy). Clients render a spinner next
|
||||||
|
|
@ -156,4 +165,25 @@ pub enum DashboardEvent {
|
||||||
/// `nixos-container destroy` (operator-driven or otherwise) on the
|
/// `nixos-container destroy` (operator-driven or otherwise) on the
|
||||||
/// next rescan.
|
/// next rescan.
|
||||||
ContainerRemoved { seq: u64, name: String },
|
ContainerRemoved { seq: u64, name: String },
|
||||||
|
/// Full snapshot of the tombstones list. Emitted on every
|
||||||
|
/// mutation that could add / remove a tombstone: destroy
|
||||||
|
/// (with or without purge), purge-tombstone, spawn approval
|
||||||
|
/// (which can consume a tombstone of the same name). Snapshot
|
||||||
|
/// shape (not diff) because the list is tiny (single-digit
|
||||||
|
/// typical) and recomputing avoids the add/remove races a
|
||||||
|
/// per-row event would have.
|
||||||
|
TombstonesChanged {
|
||||||
|
seq: u64,
|
||||||
|
tombstones: Vec<TombstoneView>,
|
||||||
|
},
|
||||||
|
/// Full snapshot of `meta/flake.lock`'s root inputs. Emitted
|
||||||
|
/// after every operation that bumps a lock: `meta-update`,
|
||||||
|
/// `rebuild_agent` (lock bumps via two-phase staging),
|
||||||
|
/// `update-all`. Same snapshot-shape rationale as
|
||||||
|
/// `TombstonesChanged` — the list is small (one row per agent
|
||||||
|
/// plus their fetched inputs).
|
||||||
|
MetaInputsChanged {
|
||||||
|
seq: u64,
|
||||||
|
inputs: Vec<MetaInputView>,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue