diff --git a/TODO.md b/TODO.md index a84af5f5..dbfada2f 100644 --- a/TODO.md +++ b/TODO.md @@ -27,6 +27,8 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## UI / UX +- **Per-agent UI substance.** Show last N inbox messages, last turn timing, + link back to dashboard. - **State badge: compacting + napping states.** Idle/thinking already ship (driven from SSE turn_start/turn_end). Add `compacting πŸ“¦` and `napping 😴` once the `/compact` trigger and `nap` tool exist β€” @@ -40,6 +42,15 @@ Pick anything from here when relevant. Cross-cutting design notes live in `GET /api/state` (`status: "thinking" | "idle" | "compacting" | "napping"`). JS just renders. Drops the derive-from-events-and-pray code path. +- **Terminal: inline diffs for Write/Edit.** Today a `Write` / + `Edit` tool-use row just shows the file path. Render the actual + change inline in the terminal: for `Edit`, a small `+`/`-` + per-line diff between `input.old_string` and `input.new_string`; + for `Write`, the first few lines of `input.content` (it's all + "+"). Keep collapsed by default (`
` like the existing + tool_result rollups), expand to full diff on click. Color via + the same `.diff-add` / `.diff-del` classes the dashboard + approval diff already uses. - **Terminal: `/model` slash command.** Operator-typeable model override from the terminal. Depends on the model-override work above; once an override mechanism exists, wire a `/model ` @@ -68,6 +79,12 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Manager β†’ operator question channel +- **TTL / cancel on `ask_operator`.** Questions today block forever; the + manager turn stays alive until the operator answers. Add a per-question + `ttl_seconds` (or a dashboard "cancel" button that resolves the question + with a sentinel answer) so a long-idle question can time out and let the + manager fall back. Wire the timeout into `OperatorQuestions::wait_answered` + and surface remaining-time on the dashboard. ## Spawn flow @@ -107,17 +124,6 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Lifecycle / reliability -- **journald viewer per container in the dashboard.** Surface the - equivalent of `journalctl -M h-coder -b` in the dashboard so the - operator can see container logs without ssh-ing in. Optional - filter by hive-specific systemd unit (`hive-ag3nt.service`, - `hive-m1nd.service`). Implementation: backend shells out to - `journalctl -M -b --output=short-iso --no-pager` - (optionally `-u `), streams or paginates the result over a - new dashboard endpoint. Could be a `
` per container row - or a dedicated page. Honest journalctl, not the in-container - events stream β€” those are different surfaces (events = claude turn - loop; journalctl = systemd-wide logs incl. boot, network, etc.). - **Container crash events.** Watch `container@*.service` via D-Bus, push `HelperEvent::ContainerCrash` to the manager's inbox so the manager can react (restart, escalate, etc.). diff --git a/hive-ag3nt/assets/agent.css b/hive-ag3nt/assets/agent.css index 13031836..013fc82e 100644 --- a/hive-ag3nt/assets/agent.css +++ b/hive-ag3nt/assets/agent.css @@ -130,62 +130,6 @@ pre.diff { align-items: center; gap: 0.6em; } -/* Per-agent inbox section β€” collapsible, dim, lives between the - state row and the terminal so the operator can peek at what - landed without scrolling through the live tail. */ -.agent-inbox { - margin: 0.4em 0; - font-size: 0.85em; - color: var(--muted); -} -.agent-inbox > summary { - cursor: pointer; - letter-spacing: 0.05em; - list-style: none; -} -.agent-inbox > summary::marker { content: ''; } -.agent-inbox[open] > summary > span::before { content: ''; } -.agent-inbox ul { - list-style: none; - padding: 0.4em 0.8em; - margin: 0.3em 0 0; - background: rgba(255, 255, 255, 0.02); - border-left: 2px solid var(--purple-dim); - max-height: 16em; - overflow-y: auto; -} -.agent-inbox li { - padding: 0.15em 0; - display: grid; - grid-template-columns: auto auto auto 1fr; - gap: 0.5em; - align-items: baseline; -} -.agent-inbox .inbox-ts { color: var(--muted); font-size: 0.9em; } -.agent-inbox .inbox-from { color: var(--amber); } -.agent-inbox .inbox-sep { color: var(--muted); } -.agent-inbox .inbox-body { color: var(--fg); white-space: pre-wrap; word-break: break-word; } - -.last-turn { - color: var(--muted); - font-size: 0.8em; - letter-spacing: 0.05em; -} -.btn-dashlink { - color: var(--cyan); - border: 1px solid var(--cyan); - padding: 0.15em 0.6em; - font-size: 0.55em; - font-family: inherit; - text-decoration: none; - letter-spacing: 0.1em; - margin-left: 0.6em; - vertical-align: middle; -} -.btn-dashlink:hover { - background: rgba(137, 220, 235, 0.1); - box-shadow: 0 0 10px -2px currentColor; -} .btn-cancel-turn { font-family: inherit; font-size: 0.8em; @@ -389,22 +333,6 @@ details.row > summary::before { } details.row[open] > summary::before { content: 'β–Ύ '; } details.row.tool-result-block > summary { color: var(--muted); } -/* Inline diff body for Write / Edit tool_use rows: same shape as - tool-body but each line is wrapped in a span with diff-add / - diff-del / diff-ctx so + / - lines are colored. */ -details.row > pre.diff-body { - margin: 0.3em 0 0.4em 1.2em; - padding: 0.4em 0.6em; - background: rgba(255, 255, 255, 0.02); - border-left: 2px solid var(--purple-dim); - white-space: pre-wrap; - word-break: break-word; - max-height: 22em; - overflow-y: auto; -} -details.row > pre.diff-body .diff-add { color: var(--green); } -details.row > pre.diff-body .diff-del { color: var(--red); } -details.row > pre.diff-body .diff-ctx { color: var(--fg); } details.row > pre.tool-body { margin: 0.3em 0 0.4em 1.2em; padding: 0.4em 0.6em; diff --git a/hive-ag3nt/assets/app.js b/hive-ag3nt/assets/app.js index 2702e7cc..0a40d59e 100644 --- a/hive-ag3nt/assets/app.js +++ b/hive-ag3nt/assets/app.js @@ -65,25 +65,16 @@ `β–‘β–’β–“β–ˆβ–“β–’β–‘ ${label} β–‘β–’β–“β–ˆβ–“β–’β–‘ hyperhive ag3nt β–‘β–’β–“β–ˆβ–“β–’β–‘`; const title = $('title'); title.textContent = `β—† ${label} β—† `; - // ↑ DASHB04RD β€” back-link to the host dashboard. Opens in a new - // tab to keep the agent page anchored where the operator is. - const dashUrl = `${location.protocol}//${location.hostname}:${dashboardPort}/`; - title.append( - el('a', { - href: dashUrl, target: '_blank', rel: 'noopener', - class: 'btn-dashlink', title: 'host dashboard', - }, '↑ DASHB04RD'), - ' ', - ); const btn = el('a', { href: '#', class: 'btn-rebuild', id: 'rebuild-btn', }, '↻ R3BU1LD'); btn.addEventListener('click', (e) => { e.preventDefault(); if (!confirm(`rebuild ${label}? container will hot-reload.`)) return; + const url = `${location.protocol}//${location.hostname}:${dashboardPort}/rebuild/${label}`; const f = document.createElement('form'); f.method = 'POST'; - f.action = `${dashUrl}rebuild/${label}`; + f.action = url; document.body.appendChild(f); f.submit(); }); @@ -319,13 +310,6 @@ } function setState(next) { if (next === stateName) return; - // Capture the just-ending state's duration when leaving 'thinking' - // so the operator can eyeball turn length without scrolling the - // terminal back. - if (stateName === 'thinking' && next !== 'thinking') { - const elapsedMs = Date.now() - stateSince; - renderLastTurn(elapsedMs); - } stateName = next; stateSince = Date.now(); const badge = $('state-badge'); @@ -337,40 +321,6 @@ } renderStateBadge(); } - function renderInbox(rows) { - const root = $('inbox-section'); - const list = $('inbox-list'); - const summary = $('inbox-summary'); - if (!root || !list || !summary) return; - if (!rows.length) { - root.hidden = true; - return; - } - root.hidden = false; - summary.textContent = 'inbox Β· ' + rows.length; - list.innerHTML = ''; - const fmt = (n) => new Date(n * 1000).toISOString().replace('T', ' ').slice(5, 19); - for (const m of rows) { - const li = el('li'); - li.append( - el('span', { class: 'inbox-ts' }, fmt(m.at)), ' ', - el('span', { class: 'inbox-from' }, m.from), ' ', - el('span', { class: 'inbox-sep' }, 'β†’'), ' ', - el('span', { class: 'inbox-body' }, m.body), - ); - list.append(li); - } - } - function renderLastTurn(ms) { - const el_ = $('last-turn'); - if (!el_) return; - let s = ''; - if (ms < 1000) s = ms + 'ms'; - else if (ms < 60_000) s = (ms / 1000).toFixed(1) + 's'; - else s = Math.floor(ms / 60_000) + 'm ' + Math.floor((ms / 1000) % 60) + 's'; - el_.textContent = 'Β· last turn ' + s; - el_.hidden = false; - } function startStateTicker() { if (stateTickTimer) return; stateTickTimer = setInterval(renderStateBadge, 1000); @@ -410,7 +360,6 @@ const s = await resp.json(); if (!headerSet) { setHeader(s.label, s.dashboard_port); headerSet = true; } renderTermInput(s.label, s.status === 'online'); - renderInbox(s.inbox || []); // Drive the state badge from the harness status. Live SSE events // override to 'thinking' / 'idle' as turns start/end; this only // kicks in for the not-online (offline) case and the initial seed. @@ -565,63 +514,6 @@ default: return name + ' ' + trim(JSON.stringify(input), 200); } } - // Build a tool_use row for Write/Edit as a collapsed
- // showing the actual change. Returns null for any other tool so - // the caller falls back to the flat-row path. - // Write: every input.content line is "+". - // Edit: old_string lines as "-", new_string lines as "+". - // Not a true diff algorithm β€” claude's Edit blocks are already a - // contiguous old/new pair, so a literal -/+ rendering is honest. - function renderFileWriteEdit(c) { - const name = c.name || ''; - const input = c.input || {}; - if (name !== 'Write' && name !== 'Edit') return null; - const path = input.file_path || '?'; - let body; - let plus = 0; - let minus = 0; - if (name === 'Write') { - const content = String(input.content || ''); - const lines = content.split('\n'); - plus = lines.length; - body = lines.map(l => '+ ' + l).join('\n'); - } else { - const oldLines = String(input.old_string || '').split('\n'); - const newLines = String(input.new_string || '').split('\n'); - minus = oldLines.length; - plus = newLines.length; - body = oldLines.map(l => '- ' + l).join('\n') - + '\n' - + newLines.map(l => '+ ' + l).join('\n'); - } - const summary = 'β†’ ' + name + ' ' + path + ' Β· ' - + (minus ? '-' + minus + ' ' : '') + '+' + plus; - return detailsDiff('tool-use', summary, body); - } - function detailsDiff(cls, summary, body) { - clearPlaceholder(); - const d = document.createElement('details'); - d.className = 'row ' + (cls || '') + (currentNoAnim ? ' no-anim' : ''); - const s = document.createElement('summary'); - s.textContent = summary; - d.appendChild(s); - const pre = document.createElement('pre'); - pre.className = 'tool-body diff-body'; - // Color each line by its leading +/-. - for (const line of body.split('\n')) { - const span = document.createElement('span'); - if (line.startsWith('+ ')) span.className = 'diff-add'; - else if (line.startsWith('- ')) span.className = 'diff-del'; - else span.className = 'diff-ctx'; - span.textContent = line + '\n'; - pre.appendChild(span); - } - d.appendChild(pre); - log.appendChild(d); - afterAppend(); - return d; - } - function renderToolResult(c) { const txt = Array.isArray(c.content) ? c.content.map(p => p.text || '').join('') @@ -655,13 +547,7 @@ const txt = (c.thinking || c.text || '').trim(); row('thinking', txt ? 'Β· ' + txt : 'Β· thinking …'); } - else if (c.type === 'tool_use') { - // Write/Edit get a collapsed +/- diff body; everything - // else stays as the flat row produced by fmtToolUse. - if (!renderFileWriteEdit(c)) { - row('tool-use', 'β†’ ' + fmtToolUse(c)); - } - } + else if (c.type === 'tool_use') row('tool-use', 'β†’ ' + fmtToolUse(c)); } return; } diff --git a/hive-ag3nt/assets/index.html b/hive-ag3nt/assets/index.html index 9906176c..397fd327 100644 --- a/hive-ag3nt/assets/index.html +++ b/hive-ag3nt/assets/index.html @@ -15,15 +15,9 @@
… booting -
- -
connecting…
diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index ca96cae8..15b82f36 100644 --- a/hive-ag3nt/prompts/manager.md +++ b/hive-ag3nt/prompts/manager.md @@ -9,7 +9,7 @@ Tools (hyperhive surface): - `mcp__hyperhive__start(name)` β€” start a stopped sub-agent. No approval required. - `mcp__hyperhive__restart(name)` β€” stop + start a sub-agent. No approval required. - `mcp__hyperhive__request_apply_commit(agent, commit_ref)` β€” submit a config change for any agent (`hm1nd` for self) for operator approval. -- `mcp__hyperhive__ask_operator(question, options?, multi?, ttl_seconds?)` β€” surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Set `ttl_seconds` to auto-cancel after a deadline β€” useful when the decision becomes moot if the operator hasn't responded in time; on expiry the answer is `[expired]`. Do not poll inside the same turn β€” finish the current work and react when the event lands. +- `mcp__hyperhive__ask_operator(question, options?, multi?)` β€” surface a question on the dashboard. Returns immediately with a question id; the operator's answer arrives later as a system `operator_answered` event in your inbox. Options are advisory: the dashboard always lets the operator type a free-text answer in addition. Set `multi: true` to render options as checkboxes (operator can pick multiple); the answer comes back as `, `-separated. Do not poll inside the same turn β€” finish the current work and react when the event lands. Approval boundary: lifecycle ops on *existing* sub-agents (`kill`, `start`, `restart`) are at your discretion β€” no operator approval. *Creating* a new agent (`request_spawn`) and *changing* any agent's config (`request_apply_commit`) still go through the approval queue. The operator only signs off on changes; you run the day-to-day. diff --git a/hive-ag3nt/src/bin/hive-ag3nt.rs b/hive-ag3nt/src/bin/hive-ag3nt.rs index 6f899e65..e1bff364 100644 --- a/hive-ag3nt/src/bin/hive-ag3nt.rs +++ b/hive-ag3nt/src/bin/hive-ag3nt.rs @@ -139,7 +139,7 @@ async fn serve( turn::emit_turn_end(&bus, &outcome); } Ok(AgentResponse::Empty) => {} - Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => { + Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => { tracing::warn!("recv produced unexpected response kind"); } Ok(AgentResponse::Err { message }) => { diff --git a/hive-ag3nt/src/bin/hive-m1nd.rs b/hive-ag3nt/src/bin/hive-m1nd.rs index 06a61704..d3cbd8d7 100644 --- a/hive-ag3nt/src/bin/hive-m1nd.rs +++ b/hive-ag3nt/src/bin/hive-m1nd.rs @@ -139,8 +139,7 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> { Ok( ManagerResponse::Ok | ManagerResponse::Status { .. } - | ManagerResponse::QuestionQueued { .. } - | ManagerResponse::Recent { .. }, + | ManagerResponse::QuestionQueued { .. }, ) => { tracing::warn!("recv produced unexpected response kind"); } diff --git a/hive-ag3nt/src/mcp.rs b/hive-ag3nt/src/mcp.rs index d47a021a..d48f4d8e 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -38,7 +38,6 @@ pub enum SocketReply { Empty, Status(u64), QuestionQueued(i64), - Recent(Vec), } impl From for SocketReply { @@ -49,7 +48,6 @@ impl From for SocketReply { hive_sh4re::AgentResponse::Message { from, body } => Self::Message { from, body }, hive_sh4re::AgentResponse::Empty => Self::Empty, hive_sh4re::AgentResponse::Status { unread } => Self::Status(unread), - hive_sh4re::AgentResponse::Recent { rows } => Self::Recent(rows), } } } @@ -63,7 +61,6 @@ impl From for SocketReply { hive_sh4re::ManagerResponse::Empty => Self::Empty, hive_sh4re::ManagerResponse::Status { unread } => Self::Status(unread), hive_sh4re::ManagerResponse::QuestionQueued { id } => Self::QuestionQueued(id), - hive_sh4re::ManagerResponse::Recent { rows } => Self::Recent(rows), } } } @@ -240,12 +237,6 @@ pub struct AskOperatorArgs { /// selections joined by ", ". Ignored when `options` is empty. #[serde(default)] pub multi: bool, - /// Optional auto-cancel after `ttl_seconds`. On expiry the question - /// resolves with answer `[expired]` and the manager receives the - /// usual `operator_answered` system event. `None` (default) = - /// wait indefinitely. - #[serde(default)] - pub ttl_seconds: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -386,9 +377,7 @@ impl ManagerServer { request, policy call, scope clarification). `options` is advisory: pass a short \ fixed-choice list when applicable, otherwise leave empty for free text. Set \ `multi: true` to let the operator pick multiple options (checkboxes); the answer \ - comes back as a comma-separated string. Set `ttl_seconds` to auto-cancel a \ - no-longer-relevant question instead of blocking forever β€” on expiry the answer \ - is `[expired]` and the same `operator_answered` event fires." + comes back as a comma-separated string." )] async fn ask_operator(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -398,7 +387,6 @@ impl ManagerServer { question: args.question, options: args.options, multi: args.multi, - ttl_seconds: args.ttl_seconds, }) .await; match resp { diff --git a/hive-ag3nt/src/web_ui.rs b/hive-ag3nt/src/web_ui.rs index 688f7a03..e0ea25f7 100644 --- a/hive-ag3nt/src/web_ui.rs +++ b/hive-ag3nt/src/web_ui.rs @@ -83,7 +83,9 @@ pub async fn serve( .route("/api/compact", post(post_compact)) .with_state(state); let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let listener = bind_with_retry(addr, "web UI").await?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind web UI on port {port}"))?; tracing::info!(%port, "web UI listening"); axum::serve(listener, app).await?; Ok(()) @@ -93,33 +95,6 @@ pub async fn serve( // Static assets + state snapshot // --------------------------------------------------------------------------- -/// Bind a TCP listener, retrying on `AddrInUse` for up to ~20s. -/// nspawn restarts can race the previous harness's socket release; -/// without retry the new harness fails to bind and systemd just -/// keeps restarting it. `SO_REUSEADDR` would be the proper fix but -/// would require socket2; retry is good enough here. -async fn bind_with_retry(addr: SocketAddr, label: &str) -> Result { - let mut delay_ms = 250u64; - let mut attempts = 0u32; - loop { - match tokio::net::TcpListener::bind(addr).await { - Ok(l) => return Ok(l), - Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < 12 => { - tracing::warn!( - %addr, attempt = attempts + 1, - "{label}: AddrInUse, retrying in {delay_ms}ms" - ); - tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; - attempts += 1; - delay_ms = (delay_ms * 2).min(2000); - } - Err(e) => { - return Err(e).with_context(|| format!("bind {label} on {addr}")); - } - } - } -} - async fn serve_index() -> impl IntoResponse { ( [("content-type", "text/html; charset=utf-8")], @@ -149,10 +124,6 @@ struct StateSnapshot { status: &'static str, /// Present when `status == "needs_login_in_progress"`. session: Option, - /// Last N messages addressed to this agent, newest-first. Pulled - /// from the broker via the per-agent socket on each render. - /// Empty on transport failure. - inbox: Vec, } #[derive(Serialize)] @@ -186,47 +157,14 @@ async fn api_state(State(state): State) -> axum::Json { .ok() .and_then(|s| s.parse::().ok()) .unwrap_or(7000); - let inbox = recent_inbox(&state.socket, state.flavor).await; axum::Json(StateSnapshot { label: state.label.clone(), dashboard_port, status, session: session_view, - inbox, }) } -/// Best-effort: pull the last 30 messages addressed to us via the -/// per-agent / manager socket. Empty list on any transport / decode -/// failure β€” the inbox section is decorative, not authoritative. -async fn recent_inbox(socket: &std::path::Path, flavor: Flavor) -> Vec { - const LIMIT: u64 = 30; - match flavor { - Flavor::Agent => { - match client::request::<_, hive_sh4re::AgentResponse>( - socket, - &hive_sh4re::AgentRequest::Recent { limit: LIMIT }, - ) - .await - { - Ok(hive_sh4re::AgentResponse::Recent { rows }) => rows, - _ => Vec::new(), - } - } - Flavor::Manager => { - match client::request::<_, hive_sh4re::ManagerResponse>( - socket, - &hive_sh4re::ManagerRequest::Recent { limit: LIMIT }, - ) - .await - { - Ok(hive_sh4re::ManagerResponse::Recent { rows }) => rows, - _ => Vec::new(), - } - } - } -} - // --------------------------------------------------------------------------- // Action handlers // --------------------------------------------------------------------------- diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 61ee03e5..889aedde 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -237,23 +237,14 @@ const ul = el('ul', { class: 'questions' }); for (const q of s.questions) { const li = el('li', { class: 'question' }); - const head = el('div', { class: 'q-head' }, - el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', - el('span', { class: 'msg-from' }, q.asker), ' ', - el('span', { class: 'msg-sep' }, 'asks:'), + li.append( + el('div', { class: 'q-head' }, + el('span', { class: 'msg-ts' }, fmt(q.asked_at)), ' ', + el('span', { class: 'msg-from' }, q.asker), ' ', + el('span', { class: 'msg-sep' }, 'asks:'), + ), + el('div', { class: 'q-body' }, q.question), ); - if (q.deadline_at) { - const remaining = q.deadline_at - Math.floor(Date.now() / 1000); - let txt; - if (remaining <= 0) txt = 'expiring…'; - else if (remaining < 60) txt = '⏳ ' + remaining + 's'; - else if (remaining < 3600) txt = '⏳ ' + Math.floor(remaining / 60) + 'm ' - + (remaining % 60) + 's'; - else txt = '⏳ ' + Math.floor(remaining / 3600) + 'h ' - + Math.floor((remaining % 3600) / 60) + 'm'; - head.append(' ', el('span', { class: 'q-ttl' }, txt)); - } - li.append(head, el('div', { class: 'q-body' }, q.question)); const f = el('form', { method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', @@ -293,28 +284,12 @@ if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); } }, true); if (hasOptions) f.append(optionGroup); - const buttons = el('div', { class: 'q-buttons' }); - buttons.append( + f.append( + el('div', { class: 'q-free' }, freeText), el('button', { type: 'submit', class: 'btn btn-approve' }, isMulti ? 'β–Έ ANSW3R Β· ' + (q.options.length) + ' opts' : 'β–Έ ANSW3R'), ); - f.append( - el('div', { class: 'q-free' }, freeText), - buttons, - ); li.append(f); - // Separate form so the cancel button doesn't get the answer - // merge-on-submit handler attached to the main form. - const cancelForm = el('form', { - method: 'POST', action: '/cancel-question/' + q.id, - class: 'qform-cancel', 'data-async': '', - 'data-confirm': 'cancel this question? manager will see ' - + '"[cancelled]" as the answer.', - }); - cancelForm.append( - el('button', { type: 'submit', class: 'btn btn-deny' }, 'βœ— CANC3L'), - ); - li.append(cancelForm); ul.append(li); } root.append(ul); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index b48cac30..1e0792ae 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -296,12 +296,6 @@ summary:hover { color: var(--purple); } } .questions li.question:last-child { border-bottom: 0; } .questions .q-head { font-size: 0.9em; } -.questions .q-ttl { - color: var(--amber); - margin-left: 0.4em; - font-size: 0.95em; - letter-spacing: 0.05em; -} .questions .q-body { color: var(--fg); margin: 0.3em 0; @@ -338,7 +332,6 @@ summary:hover { color: var(--purple); } .qform .q-free input::placeholder { color: var(--muted); } .qform .q-free input:focus { outline: 1px solid var(--amber); } .qform button { align-self: flex-start; } -.qform-cancel { margin-top: 0.3em; } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/agent_server.rs b/hive-c0re/src/agent_server.rs index 1dacf469..82329cae 100644 --- a/hive-c0re/src/agent_server.rs +++ b/hive-c0re/src/agent_server.rs @@ -121,11 +121,5 @@ async fn dispatch(req: &AgentRequest, agent: &str, broker: &Broker) -> AgentResp message: format!("{e:#}"), }, }, - AgentRequest::Recent { limit } => match broker.recent_for(agent, *limit) { - Ok(rows) => AgentResponse::Recent { rows }, - Err(e) => AgentResponse::Err { - message: format!("{e:#}"), - }, - }, } } diff --git a/hive-c0re/src/broker.rs b/hive-c0re/src/broker.rs index 72486bad..bd6b45f9 100644 --- a/hive-c0re/src/broker.rs +++ b/hive-c0re/src/broker.rs @@ -6,7 +6,7 @@ use std::sync::Mutex; use std::time::{SystemTime, UNIX_EPOCH}; use anyhow::{Context, Result}; -use hive_sh4re::{InboxRow, Message}; +use hive_sh4re::Message; use rusqlite::{Connection, OptionalExtension, params}; use serde::Serialize; use tokio::sync::broadcast; @@ -28,6 +28,16 @@ CREATE INDEX IF NOT EXISTS idx_messages_undelivered /// may drop events past this; we send a `lagged` notice in their stream. const EVENT_CHANNEL: usize = 256; +/// One row in a `recent_for()` query β€” the broker's flat view of a +/// message addressed to a given recipient. +#[derive(Debug, Clone, Serialize)] +pub struct InboxRow { + pub id: i64, + pub from: String, + pub body: String, + pub at: i64, +} + #[derive(Debug, Clone, Serialize)] #[serde(rename_all = "snake_case", tag = "kind")] pub enum MessageEvent { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 1165e990..eed42cd6 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -51,13 +51,14 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) .route("/answer-question/{id}", post(post_answer_question)) - .route("/cancel-question/{id}", post(post_cancel_question)) .route("/purge-tombstone/{name}", post(post_purge_tombstone)) .route("/request-spawn", post(post_request_spawn)) .route("/messages/stream", get(messages_stream)) .with_state(AppState { coord }); let addr = SocketAddr::from(([0, 0, 0, 0], port)); - let listener = bind_with_retry(addr).await?; + let listener = tokio::net::TcpListener::bind(addr) + .await + .with_context(|| format!("bind dashboard on port {port}"))?; tracing::info!(%port, "dashboard listening"); axum::serve(listener, app).await?; Ok(()) @@ -71,30 +72,6 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { // `/messages/stream` for broker traffic. // --------------------------------------------------------------------------- -/// Retry-on-AddrInUse bind. Same shape as the per-agent variant β€” -/// hive-c0re restarts also race the previous process's socket release. -async fn bind_with_retry(addr: SocketAddr) -> Result { - let mut delay_ms = 250u64; - let mut attempts = 0u32; - loop { - match tokio::net::TcpListener::bind(addr).await { - Ok(l) => return Ok(l), - Err(e) if e.kind() == std::io::ErrorKind::AddrInUse && attempts < 12 => { - tracing::warn!( - %addr, attempt = attempts + 1, - "dashboard: AddrInUse, retrying in {delay_ms}ms" - ); - tokio::time::sleep(std::time::Duration::from_millis(delay_ms)).await; - attempts += 1; - delay_ms = (delay_ms * 2).min(2000); - } - Err(e) => { - return Err(e).with_context(|| format!("bind dashboard on {addr}")); - } - } - } -} - async fn serve_index() -> impl IntoResponse { Html(include_str!("../assets/index.html")) } @@ -124,7 +101,7 @@ struct StateSnapshot { /// Latest messages addressed to `operator` β€” surfaces agent replies /// asynchronously so the operator can see them without watching the /// live panel during a turn. - operator_inbox: Vec, + operator_inbox: Vec, /// Pending operator questions (currently only from the manager). /// `ask_operator` returns immediately with the id; on `/answer-question` /// we mark the row answered and fire `HelperEvent::OperatorAnswered` @@ -440,33 +417,6 @@ async fn post_answer_question( } } -/// Resolve a pending operator question with a sentinel answer when -/// the operator decides not to / can't answer. The manager harness -/// receives an `OperatorAnswered` event with `answer = "[cancelled]"` -/// so it can fall back on whatever default it had. Same code path as -/// a real answer β€” just lets the operator close the loop instead of -/// letting the question dangle forever. -async fn post_cancel_question( - State(state): State, - AxumPath(id): AxumPath, -) -> Response { - const SENTINEL: &str = "[cancelled]"; - match state.coord.questions.answer(id, SENTINEL) { - Ok(question) => { - tracing::info!(%id, "operator cancelled question"); - state - .coord - .notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered { - id, - question, - answer: SENTINEL.to_owned(), - }); - Redirect::to("/").into_response() - } - Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")), - } -} - async fn post_purge_tombstone( State(state): State, AxumPath(name): AxumPath, diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index d946b276..9e4ae18c 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -72,7 +72,7 @@ async fn serve(stream: UnixStream, coord: Arc) -> Result<()> { const MANAGER_RECV_LONG_POLL: std::time::Duration = std::time::Duration::from_secs(30); #[allow(clippy::too_many_lines)] -async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResponse { +async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse { match req { ManagerRequest::Send { to, body } => match coord.broker.send(&Message { from: MANAGER_AGENT.to_owned(), @@ -100,12 +100,6 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp message: format!("{e:#}"), }, }, - ManagerRequest::Recent { limit } => match coord.broker.recent_for(MANAGER_AGENT, *limit) { - Ok(rows) => ManagerResponse::Recent { rows }, - Err(e) => ManagerResponse::Err { - message: format!("{e:#}"), - }, - }, ManagerRequest::Recv => match coord .broker .recv_blocking(MANAGER_AGENT, MANAGER_RECV_LONG_POLL) @@ -198,26 +192,14 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp question, options, multi, - ttl_seconds, } => { - tracing::info!(%question, ?options, multi, ?ttl_seconds, "manager: ask_operator"); - let deadline_at = ttl_seconds.and_then(|s| { - let now = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .ok() - .and_then(|d| i64::try_from(d.as_secs()).ok()) - .unwrap_or(0); - i64::try_from(s).ok().map(|s| now + s) - }); + tracing::info!(%question, ?options, multi, "manager: ask_operator"); match coord .questions - .submit(MANAGER_AGENT, question, options, *multi, deadline_at) + .submit(MANAGER_AGENT, question, options, *multi) { Ok(id) => { - tracing::info!(%id, ?deadline_at, "operator question queued"); - if let Some(ttl) = *ttl_seconds { - spawn_question_watchdog(coord, id, ttl); - } + tracing::info!(%id, "operator question queued"); ManagerResponse::QuestionQueued { id } } Err(e) => ManagerResponse::Err { @@ -239,28 +221,3 @@ async fn dispatch(req: &ManagerRequest, coord: &Arc) -> ManagerResp } } } - -/// On `AskOperator { ttl_seconds: Some(n) }`, sleep n seconds and then -/// try to resolve the question with `[expired]`. If the operator (or -/// any other path) already answered it, `answer()` returns Err and -/// we no-op silently. Otherwise fire the usual `OperatorAnswered` -/// helper event so the manager sees a terminal state. -const TTL_SENTINEL: &str = "[expired]"; - -fn spawn_question_watchdog(coord: &Arc, id: i64, ttl_secs: u64) { - let coord = coord.clone(); - tokio::spawn(async move { - tokio::time::sleep(std::time::Duration::from_secs(ttl_secs)).await; - // `answer` returns Err if already resolved β€” that's the - // normal path when the operator responded before the ttl - // fired, so no-op silently. - if let Ok(question) = coord.questions.answer(id, TTL_SENTINEL) { - tracing::info!(%id, "operator question expired (ttl)"); - coord.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered { - id, - question, - answer: TTL_SENTINEL.to_owned(), - }); - } - }); -} diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index 4ca652ed..7ece4fcf 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -25,29 +25,17 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending ON operator_questions (id) WHERE answered_at IS NULL; "; -/// Add late-added columns to pre-existing databases. `ALTER TABLE -/// ADD COLUMN` has no `IF NOT EXISTS` form in sqlite, so we check -/// `pragma_table_info` first per column. -fn ensure_columns(conn: &Connection) -> Result<()> { - for (name, sql) in [ - ( - "multi", +/// Add the `multi` column to pre-existing databases. `ALTER TABLE ADD COLUMN` +/// has no `IF NOT EXISTS` form in sqlite, so we check `pragma_table_info` first. +fn ensure_multi_column(conn: &Connection) -> Result<()> { + let has: bool = conn + .prepare("SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = 'multi'")? + .exists([])?; + if !has { + conn.execute_batch( "ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;", - ), - ( - "deadline_at", - "ALTER TABLE operator_questions ADD COLUMN deadline_at INTEGER;", - ), - ] { - let has: bool = conn - .prepare(&format!( - "SELECT 1 FROM pragma_table_info('operator_questions') WHERE name = '{name}'" - ))? - .exists([])?; - if !has { - conn.execute_batch(sql) - .with_context(|| format!("add operator_questions.{name} column"))?; - } + ) + .context("add operator_questions.multi column")?; } Ok(()) } @@ -61,10 +49,6 @@ pub struct OpQuestion { pub options: Vec, pub multi: bool, pub asked_at: i64, - /// Absolute unix-seconds deadline after which a watchdog auto- - /// resolves the question with answer `[expired]`. `None` = no - /// expiry. Surfaced on the dashboard as a remaining-time chip. - pub deadline_at: Option, pub answered_at: Option, pub answer: Option, } @@ -84,7 +68,7 @@ impl OperatorQuestions { .with_context(|| format!("open operator_questions db {}", path.display()))?; conn.execute_batch(SCHEMA) .context("apply operator_questions schema")?; - ensure_columns(&conn).context("migrate operator_questions columns")?; + ensure_multi_column(&conn).context("migrate operator_questions.multi")?; Ok(Self { conn: Mutex::new(conn), }) @@ -96,22 +80,13 @@ impl OperatorQuestions { question: &str, options: &[String], multi: bool, - deadline_at: Option, ) -> Result { let conn = self.conn.lock().unwrap(); let options_json = serde_json::to_string(options).unwrap_or_else(|_| "[]".into()); conn.execute( - "INSERT INTO operator_questions - (asker, question, options_json, multi, deadline_at, asked_at) - VALUES (?1, ?2, ?3, ?4, ?5, ?6)", - params![ - asker, - question, - options_json, - i64::from(multi), - deadline_at, - now_unix(), - ], + "INSERT INTO operator_questions (asker, question, options_json, multi, asked_at) + VALUES (?1, ?2, ?3, ?4, ?5)", + params![asker, question, options_json, i64::from(multi), now_unix()], )?; Ok(conn.last_insert_rowid()) } @@ -144,7 +119,7 @@ impl OperatorQuestions { pub fn get(&self, id: i64) -> Result> { let conn = self.conn.lock().unwrap(); conn.query_row( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer FROM operator_questions WHERE id = ?1", params![id], row_to_question, @@ -156,7 +131,7 @@ impl OperatorQuestions { pub fn pending(&self) -> Result> { let conn = self.conn.lock().unwrap(); let mut stmt = conn.prepare( - "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer FROM operator_questions WHERE answered_at IS NULL ORDER BY id ASC", @@ -180,7 +155,6 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { asked_at: row.get(5)?, answered_at: row.get(6)?, answer: row.get(7)?, - deadline_at: row.get(8)?, }) } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 52f80004..a1c34044 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -146,19 +146,6 @@ pub struct Message { pub body: String, } -/// One row of a broker inbox query β€” what the dashboard renders in -/// its operator-inbox section and what a per-agent web UI returns -/// from a `Recent` request. Lives in `hive_sh4re` so it can travel -/// over both the dashboard's `/api/state` and the agent socket -/// without an internal-to-wire conversion. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct InboxRow { - pub id: i64, - pub from: String, - pub body: String, - pub at: i64, -} - /// Requests on a per-agent socket. The agent's identity is the socket /// it came in on; `Send.from` is filled in by the server, not the client. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -176,10 +163,6 @@ pub enum AgentRequest { /// per-agent equivalent of the old dashboard T4LK form, but scoped to /// the agent whose page the operator is on. OperatorMsg { body: String }, - /// Last `limit` messages addressed to this agent, newest-first. - /// Non-mutating β€” pulls from the broker without delivering. The - /// per-agent web UI uses this to render its own inbox section. - Recent { limit: u64 }, } /// Responses on a per-agent socket. @@ -196,8 +179,6 @@ pub enum AgentResponse { Empty, /// `Status` result: how many pending messages are in this agent's inbox. Status { unread: u64 }, - /// `Recent` result: newest-first inbox rows. - Recent { rows: Vec }, } // ----------------------------------------------------------------------------- @@ -283,11 +264,6 @@ pub enum ManagerRequest { OperatorMsg { body: String, }, - /// Last `limit` messages addressed to the manager, newest-first. - /// Non-mutating; mirror of `AgentRequest::Recent`. - Recent { - limit: u64, - }, /// Submit a spawn request for the user to approve. On approval the host /// creates and starts the container. Brand-new agent names only β€” if an /// agent of the same name already exists, the approval will fail. @@ -323,18 +299,12 @@ pub enum ManagerRequest { /// - `multi=true` lets the operator pick multiple options (rendered /// as checkboxes). The answer is returned as a single string with /// selections joined by ", ". - /// - `ttl_seconds`: optional auto-cancel after that many seconds. On - /// expiry the question is resolved with answer `[expired]` and the - /// manager gets the usual `OperatorAnswered` event. None = wait - /// forever for an operator answer (or manual cancel). AskOperator { question: String, #[serde(default)] options: Vec, #[serde(default)] multi: bool, - #[serde(default)] - ttl_seconds: Option, }, } @@ -359,8 +329,4 @@ pub enum ManagerResponse { QuestionQueued { id: i64, }, - /// `Recent` result: mirror of `AgentResponse::Recent`. - Recent { - rows: Vec, - }, }