Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754db7830e | ||
|
|
2146e47770 | ||
|
|
538e0446d7 | ||
|
|
bd7d2d4860 | ||
|
|
ee5b85716d | ||
|
|
bc87ff80d2 |
17 changed files with 513 additions and 71 deletions
28
TODO.md
28
TODO.md
|
|
@ -27,8 +27,6 @@ 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 —
|
||||
|
|
@ -42,15 +40,6 @@ 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 (`<details>` 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 <name>`
|
||||
|
|
@ -79,12 +68,6 @@ 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
|
||||
|
||||
|
|
@ -124,6 +107,17 @@ 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 <container> -b --output=short-iso --no-pager`
|
||||
(optionally `-u <unit>`), streams or paginates the result over a
|
||||
new dashboard endpoint. Could be a `<details>` 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.).
|
||||
|
|
|
|||
|
|
@ -130,6 +130,62 @@ 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;
|
||||
|
|
@ -333,6 +389,22 @@ 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;
|
||||
|
|
|
|||
|
|
@ -65,16 +65,25 @@
|
|||
`░▒▓█▓▒░ ${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 = url;
|
||||
f.action = `${dashUrl}rebuild/${label}`;
|
||||
document.body.appendChild(f);
|
||||
f.submit();
|
||||
});
|
||||
|
|
@ -310,6 +319,13 @@
|
|||
}
|
||||
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');
|
||||
|
|
@ -321,6 +337,40 @@
|
|||
}
|
||||
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);
|
||||
|
|
@ -360,6 +410,7 @@
|
|||
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.
|
||||
|
|
@ -514,6 +565,63 @@
|
|||
default: return name + ' ' + trim(JSON.stringify(input), 200);
|
||||
}
|
||||
}
|
||||
// Build a tool_use row for Write/Edit as a collapsed <details>
|
||||
// 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('')
|
||||
|
|
@ -547,7 +655,13 @@
|
|||
const txt = (c.thinking || c.text || '').trim();
|
||||
row('thinking', txt ? '· ' + txt : '· thinking …');
|
||||
}
|
||||
else if (c.type === 'tool_use') row('tool-use', '→ ' + fmtToolUse(c));
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,9 +15,15 @@
|
|||
|
||||
<div id="state-row">
|
||||
<span id="state-badge" class="state-badge state-loading">… booting</span>
|
||||
<span id="last-turn" class="last-turn" hidden></span>
|
||||
<button type="button" id="cancel-btn" class="btn-cancel-turn" hidden>■ cancel turn</button>
|
||||
</div>
|
||||
|
||||
<details id="inbox-section" class="agent-inbox" hidden>
|
||||
<summary>▸ <span id="inbox-summary">inbox</span></summary>
|
||||
<ul id="inbox-list"></ul>
|
||||
</details>
|
||||
|
||||
<div class="terminal-wrap">
|
||||
<div id="live" class="live terminal"><div class="meta">connecting…</div></div>
|
||||
<div id="term-input" class="term-input"></div>
|
||||
|
|
|
|||
|
|
@ -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?)` — 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.
|
||||
- `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.
|
||||
|
||||
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.
|
||||
|
||||
|
|
|
|||
|
|
@ -139,7 +139,7 @@ async fn serve(
|
|||
turn::emit_turn_end(&bus, &outcome);
|
||||
}
|
||||
Ok(AgentResponse::Empty) => {}
|
||||
Ok(AgentResponse::Ok | AgentResponse::Status { .. }) => {
|
||||
Ok(AgentResponse::Ok | AgentResponse::Status { .. } | AgentResponse::Recent { .. }) => {
|
||||
tracing::warn!("recv produced unexpected response kind");
|
||||
}
|
||||
Ok(AgentResponse::Err { message }) => {
|
||||
|
|
|
|||
|
|
@ -139,7 +139,8 @@ async fn serve(socket: &Path, interval: Duration, bus: Bus) -> Result<()> {
|
|||
Ok(
|
||||
ManagerResponse::Ok
|
||||
| ManagerResponse::Status { .. }
|
||||
| ManagerResponse::QuestionQueued { .. },
|
||||
| ManagerResponse::QuestionQueued { .. }
|
||||
| ManagerResponse::Recent { .. },
|
||||
) => {
|
||||
tracing::warn!("recv produced unexpected response kind");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ pub enum SocketReply {
|
|||
Empty,
|
||||
Status(u64),
|
||||
QuestionQueued(i64),
|
||||
Recent(Vec<hive_sh4re::InboxRow>),
|
||||
}
|
||||
|
||||
impl From<hive_sh4re::AgentResponse> for SocketReply {
|
||||
|
|
@ -48,6 +49,7 @@ impl From<hive_sh4re::AgentResponse> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -61,6 +63,7 @@ impl From<hive_sh4re::ManagerResponse> 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),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -237,6 +240,12 @@ 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<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
|
|
@ -377,7 +386,9 @@ 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."
|
||||
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."
|
||||
)]
|
||||
async fn ask_operator(&self, Parameters(args): Parameters<AskOperatorArgs>) -> String {
|
||||
let log = format!("{args:?}");
|
||||
|
|
@ -387,6 +398,7 @@ impl ManagerServer {
|
|||
question: args.question,
|
||||
options: args.options,
|
||||
multi: args.multi,
|
||||
ttl_seconds: args.ttl_seconds,
|
||||
})
|
||||
.await;
|
||||
match resp {
|
||||
|
|
|
|||
|
|
@ -83,9 +83,7 @@ pub async fn serve(
|
|||
.route("/api/compact", post(post_compact))
|
||||
.with_state(state);
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], port));
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("bind web UI on port {port}"))?;
|
||||
let listener = bind_with_retry(addr, "web UI").await?;
|
||||
tracing::info!(%port, "web UI listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
|
|
@ -95,6 +93,33 @@ 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<tokio::net::TcpListener> {
|
||||
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")],
|
||||
|
|
@ -124,6 +149,10 @@ struct StateSnapshot {
|
|||
status: &'static str,
|
||||
/// Present when `status == "needs_login_in_progress"`.
|
||||
session: Option<SessionView>,
|
||||
/// 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<hive_sh4re::InboxRow>,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
|
|
@ -157,14 +186,47 @@ async fn api_state(State(state): State<AppState>) -> axum::Json<StateSnapshot> {
|
|||
.ok()
|
||||
.and_then(|s| s.parse::<u16>().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<hive_sh4re::InboxRow> {
|
||||
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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -237,14 +237,23 @@
|
|||
const ul = el('ul', { class: 'questions' });
|
||||
for (const q of s.questions) {
|
||||
const li = el('li', { class: 'question' });
|
||||
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),
|
||||
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:'),
|
||||
);
|
||||
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': '',
|
||||
|
|
@ -284,12 +293,28 @@
|
|||
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
||||
}, true);
|
||||
if (hasOptions) f.append(optionGroup);
|
||||
f.append(
|
||||
el('div', { class: 'q-free' }, freeText),
|
||||
const buttons = el('div', { class: 'q-buttons' });
|
||||
buttons.append(
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -296,6 +296,12 @@ 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;
|
||||
|
|
@ -332,6 +338,7 @@ 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);
|
||||
|
|
|
|||
|
|
@ -121,5 +121,11 @@ 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:#}"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ use std::sync::Mutex;
|
|||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use hive_sh4re::Message;
|
||||
use hive_sh4re::{InboxRow, Message};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
|
@ -28,16 +28,6 @@ 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 {
|
||||
|
|
|
|||
|
|
@ -51,14 +51,13 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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 = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.with_context(|| format!("bind dashboard on port {port}"))?;
|
||||
let listener = bind_with_retry(addr).await?;
|
||||
tracing::info!(%port, "dashboard listening");
|
||||
axum::serve(listener, app).await?;
|
||||
Ok(())
|
||||
|
|
@ -72,6 +71,30 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> 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<tokio::net::TcpListener> {
|
||||
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"))
|
||||
}
|
||||
|
|
@ -101,7 +124,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<crate::broker::InboxRow>,
|
||||
operator_inbox: Vec<hive_sh4re::InboxRow>,
|
||||
/// 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`
|
||||
|
|
@ -417,6 +440,33 @@ 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<AppState>,
|
||||
AxumPath(id): AxumPath<i64>,
|
||||
) -> 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<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
|
|
|
|||
|
|
@ -72,7 +72,7 @@ async fn serve(stream: UnixStream, coord: Arc<Coordinator>) -> 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: &Coordinator) -> ManagerResponse {
|
||||
async fn dispatch(req: &ManagerRequest, coord: &Arc<Coordinator>) -> ManagerResponse {
|
||||
match req {
|
||||
ManagerRequest::Send { to, body } => match coord.broker.send(&Message {
|
||||
from: MANAGER_AGENT.to_owned(),
|
||||
|
|
@ -100,6 +100,12 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
|||
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)
|
||||
|
|
@ -192,14 +198,26 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
|||
question,
|
||||
options,
|
||||
multi,
|
||||
ttl_seconds,
|
||||
} => {
|
||||
tracing::info!(%question, ?options, multi, "manager: ask_operator");
|
||||
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)
|
||||
});
|
||||
match coord
|
||||
.questions
|
||||
.submit(MANAGER_AGENT, question, options, *multi)
|
||||
.submit(MANAGER_AGENT, question, options, *multi, deadline_at)
|
||||
{
|
||||
Ok(id) => {
|
||||
tracing::info!(%id, "operator question queued");
|
||||
tracing::info!(%id, ?deadline_at, "operator question queued");
|
||||
if let Some(ttl) = *ttl_seconds {
|
||||
spawn_question_watchdog(coord, id, ttl);
|
||||
}
|
||||
ManagerResponse::QuestionQueued { id }
|
||||
}
|
||||
Err(e) => ManagerResponse::Err {
|
||||
|
|
@ -221,3 +239,28 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<Coordinator>, 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(),
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -25,17 +25,29 @@ CREATE INDEX IF NOT EXISTS idx_operator_questions_pending
|
|||
ON operator_questions (id) WHERE answered_at IS NULL;
|
||||
";
|
||||
|
||||
/// 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(
|
||||
/// 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",
|
||||
"ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;",
|
||||
)
|
||||
.context("add operator_questions.multi column")?;
|
||||
),
|
||||
(
|
||||
"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"))?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -49,6 +61,10 @@ pub struct OpQuestion {
|
|||
pub options: Vec<String>,
|
||||
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<i64>,
|
||||
pub answered_at: Option<i64>,
|
||||
pub answer: Option<String>,
|
||||
}
|
||||
|
|
@ -68,7 +84,7 @@ impl OperatorQuestions {
|
|||
.with_context(|| format!("open operator_questions db {}", path.display()))?;
|
||||
conn.execute_batch(SCHEMA)
|
||||
.context("apply operator_questions schema")?;
|
||||
ensure_multi_column(&conn).context("migrate operator_questions.multi")?;
|
||||
ensure_columns(&conn).context("migrate operator_questions columns")?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
|
|
@ -80,13 +96,22 @@ impl OperatorQuestions {
|
|||
question: &str,
|
||||
options: &[String],
|
||||
multi: bool,
|
||||
deadline_at: Option<i64>,
|
||||
) -> Result<i64> {
|
||||
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, asked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)",
|
||||
params![asker, question, options_json, i64::from(multi), now_unix()],
|
||||
"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(),
|
||||
],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
|
@ -119,7 +144,7 @@ impl OperatorQuestions {
|
|||
pub fn get(&self, id: i64) -> Result<Option<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.query_row(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
|
||||
FROM operator_questions WHERE id = ?1",
|
||||
params![id],
|
||||
row_to_question,
|
||||
|
|
@ -131,7 +156,7 @@ impl OperatorQuestions {
|
|||
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer
|
||||
"SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer, deadline_at
|
||||
FROM operator_questions
|
||||
WHERE answered_at IS NULL
|
||||
ORDER BY id ASC",
|
||||
|
|
@ -155,6 +180,7 @@ fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
|||
asked_at: row.get(5)?,
|
||||
answered_at: row.get(6)?,
|
||||
answer: row.get(7)?,
|
||||
deadline_at: row.get(8)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -146,6 +146,19 @@ 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)]
|
||||
|
|
@ -163,6 +176,10 @@ 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.
|
||||
|
|
@ -179,6 +196,8 @@ 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<InboxRow> },
|
||||
}
|
||||
|
||||
// -----------------------------------------------------------------------------
|
||||
|
|
@ -264,6 +283,11 @@ 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.
|
||||
|
|
@ -299,12 +323,18 @@ 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<String>,
|
||||
#[serde(default)]
|
||||
multi: bool,
|
||||
#[serde(default)]
|
||||
ttl_seconds: Option<u64>,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -329,4 +359,8 @@ pub enum ManagerResponse {
|
|||
QuestionQueued {
|
||||
id: i64,
|
||||
},
|
||||
/// `Recent` result: mirror of `AgentResponse::Recent`.
|
||||
Recent {
|
||||
rows: Vec<InboxRow>,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue