From c337cc06f8bb63209f06a14ca78e11f4668157df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 19:49:43 +0200 Subject: [PATCH 1/2] dashboard: spinners on in-flight lifecycle actions + cleaner row layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit backend: - TransientKind grows Starting / Stopping / Restarting / Rebuilding / Destroying alongside the existing Spawning. each dashboard handler (start/restart/kill/rebuild/destroy) wraps the lifecycle call with set_transient + clear_transient so the dashboard knows what's in flight. transient kind is surfaced inline on ContainerView.pending (existing-container actions) — only Spawning (pre-creation) lands in the separate transients list. frontend: - container row is now two lines: identity + meta on top, action buttons below. less cluttered, leaves room for the pending state pill. pending rows dim their actions and surface a pulsing '◐ spawning… / starting… / stopping… / restarting… / rebuilding… / destroying…' indicator next to the name. - 'needs login' / 'needs update' chips moved into a unified .badge styling for consistency. - auto-refresh kicks in not only on transient spawn but on any container with a pending action. --- hive-c0re/assets/app.js | 54 +++++++++++++++------------- hive-c0re/assets/dashboard.css | 59 +++++++++++++++++++++++++++++++ hive-c0re/src/actions.rs | 8 ++++- hive-c0re/src/coordinator.rs | 10 ++++++ hive-c0re/src/dashboard.rs | 64 +++++++++++++++++++++++++++------- 5 files changed, 157 insertions(+), 38 deletions(-) diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 4fdc6923..6090909d 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -118,61 +118,65 @@ return; } - const ul = el('ul'); + const ul = el('ul', { class: 'containers' }); for (const c of s.containers) { const url = `http://${s.hostname}:${c.port}/`; - const li = el('li'); - li.append( - el('a', { href: url }, c.name), - ' ', + const li = el('li', { class: 'container-row' + (c.pending ? ' pending' : '') }); + + // ── line 1: identity ───────────────────────────────────────── + const head = el('div', { class: 'head' }); + head.append( + el('a', { class: 'name', href: url }, c.name), el('span', { class: c.is_manager ? 'role role-m1nd' : 'role role-ag3nt' }, c.is_manager ? 'm1nd' : 'ag3nt'), ); - if (c.needs_login) { - li.append(' ', el('a', - { class: 'role role-pending', href: url }, 'needs login →')); + if (c.pending) { + head.append(el('span', { class: 'pending-state' }, + el('span', { class: 'spinner' }, '◐'), ' ', c.pending + '…')); + } else if (c.needs_login) { + head.append(el('a', + { class: 'badge badge-warn', href: url }, 'needs login →')); } if (c.needs_update) { - li.append(' ', form( - '/rebuild/' + c.name, 'role role-pending btn-inline', 'needs update ↻', + head.append(form( + '/rebuild/' + c.name, 'badge badge-warn btn-inline', 'needs update ↻', 'rebuild ' + c.name + '? hot-reloads the container.', )); } - li.append(' ', el('span', { class: 'meta' }, `${c.container} :${c.port}`)); + head.append(el('span', { class: 'meta' }, `${c.container} :${c.port}`)); + li.append(head); + // ── line 2: action buttons ─────────────────────────────────── + const actions = el('div', { class: 'actions' }); if (c.running) { - li.append( - ' ', + actions.append( form('/restart/' + c.name, 'btn-restart', '↺ R3ST4RT', 'restart ' + c.name + '?'), ); if (!c.is_manager) { - li.append( - ' ', + actions.append( form('/kill/' + c.name, 'btn-stop', '■ ST0P', 'stop ' + c.name + '?'), ); } } else { - li.append( - ' ', + actions.append( form('/start/' + c.name, 'btn-start', '▶ ST4RT', 'start ' + c.name + '?'), ); } - li.append( - ' ', + actions.append( form('/rebuild/' + c.name, 'btn-rebuild', '↻ R3BU1LD', 'rebuild ' + c.name + '? hot-reloads the container.'), ); if (!c.is_manager) { - li.append( - ' ', + actions.append( form('/destroy/' + c.name, 'btn-destroy', 'DESTR0Y', 'destroy ' + c.name + '? container is removed; state + creds kept.'), - ' ', form('/destroy/' + c.name, 'btn-destroy', 'PURG3', 'PURGE ' + c.name + '? container, config history, claude creds, ' + 'and /state/ notes are all WIPED. no undo.', { purge: 'on' }), ); } + li.append(actions); + ul.append(li); } root.append(ul); @@ -304,8 +308,10 @@ renderQuestions(s); renderInbox(s); renderApprovals(s); - // Auto-refresh while a spawn is in flight; otherwise back off. - const next = s.transients.length ? 2000 : 0; + // Auto-refresh while a spawn is in flight OR while any container + // has a pending lifecycle action; otherwise back off. + const anyPending = s.containers.some((c) => c.pending); + const next = (s.transients.length || anyPending) ? 2000 : 0; if (pollTimer) { clearTimeout(pollTimer); pollTimer = null; } if (next) pollTimer = setTimeout(refreshState, next); } catch (err) { diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 10b86ac1..e61c5ac5 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -86,6 +86,65 @@ a:hover { } .role-m1nd { color: var(--pink); border-color: var(--pink); background: rgba(245, 194, 231, 0.08); } .role-ag3nt { color: var(--amber); border-color: var(--amber); background: rgba(250, 179, 135, 0.08); } +/* Container rows: identity + meta on a flowing first line, action + buttons grouped on a second. Pending rows dim everything except + the pending-state indicator. */ +.containers { display: flex; flex-direction: column; gap: 0.4em; } +.container-row { + padding: 0.6em 0.8em; + border: 1px solid var(--border); + border-radius: 4px; + background: rgba(24, 24, 37, 0.55); + transition: opacity 200ms ease, border-color 200ms ease; +} +.container-row.pending { + border-color: var(--amber); + background: rgba(250, 179, 135, 0.05); +} +.container-row.pending .actions { opacity: 0.4; pointer-events: none; } +.container-row .head { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.5em; + margin-bottom: 0.4em; +} +.container-row .head .name { + font-size: 1.05em; + font-weight: bold; +} +.container-row .head .meta { margin-left: auto; } +.container-row .actions { + display: flex; + flex-wrap: wrap; + gap: 0.4em; +} +.container-row .actions form.inline { display: inline-block; margin: 0; } +.badge { + display: inline-block; + padding: 0.05em 0.5em; + border: 1px solid; + border-radius: 2px; + font-size: 0.75em; + letter-spacing: 0.08em; + text-transform: uppercase; +} +.badge-warn { + color: var(--amber); border-color: var(--amber); + text-shadow: 0 0 6px rgba(250, 179, 135, 0.5); +} +.pending-state { + color: var(--amber); + font-size: 0.85em; + letter-spacing: 0.08em; + text-transform: uppercase; + text-shadow: 0 0 6px rgba(250, 179, 135, 0.55); + animation: badge-pulse 1.6s ease-in-out infinite; +} +@keyframes badge-pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} .meta { color: var(--muted); font-size: 0.85em; margin-left: 0.4em; } .id { color: var(--pink); font-weight: bold; margin-right: 0.4em; } .agent { color: var(--amber); font-weight: bold; margin-right: 0.6em; } diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index 069b640c..1b54efc0 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -140,7 +140,12 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> bail!("refusing to destroy the manager ({name})"); } tracing::info!(%name, purge, "destroy"); - lifecycle::destroy(name).await?; + coord.set_transient(name, TransientKind::Destroying); + let result = lifecycle::destroy(name).await; + if result.is_err() { + coord.clear_transient(name); + } + result?; coord.unregister_agent(name); let runtime = Coordinator::agent_dir(name); if runtime.exists() { @@ -166,6 +171,7 @@ pub async fn destroy(coord: &Coordinator, name: &str, purge: bool) -> Result<()> "agent destroyed" }, ); + coord.clear_transient(name); coord.notify_manager(&HelperEvent::Destroyed { agent: name.to_owned(), }); diff --git a/hive-c0re/src/coordinator.rs b/hive-c0re/src/coordinator.rs index 389397d2..ed44beaf 100644 --- a/hive-c0re/src/coordinator.rs +++ b/hive-c0re/src/coordinator.rs @@ -54,6 +54,16 @@ pub struct TransientState { pub enum TransientKind { /// `lifecycle::spawn` is running (nixos-container create + update + start). Spawning, + /// `lifecycle::start` is running. + Starting, + /// `lifecycle::kill` is running. + Stopping, + /// `lifecycle::restart` is running. + Restarting, + /// `lifecycle::rebuild` is running (nixos-container update). + Rebuilding, + /// `actions::destroy` is running. + Destroying, } impl Coordinator { diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index 02b5bc9c..c5f9334a 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -120,6 +120,11 @@ struct ContainerView { running: bool, needs_update: bool, needs_login: bool, + /// When a lifecycle action is in flight on this container, the kind + /// (`starting`, `stopping`, etc.) so the JS can render a spinner + + /// disable other buttons. + #[serde(skip_serializing_if = "Option::is_none")] + pending: Option<&'static str>, } #[derive(Serialize)] @@ -176,6 +181,9 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J } else { !claude_has_session(&Coordinator::agent_claude_dir(&logical)) }; + let pending = transient_snapshot + .get(&logical) + .map(|st| transient_label(st.kind)); containers.push(ContainerView { port: lifecycle::agent_web_port(&logical), running: lifecycle::is_running(&logical).await, @@ -184,6 +192,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J is_manager, needs_update, needs_login, + pending, }); } @@ -196,9 +205,7 @@ async fn api_state(headers: HeaderMap, State(state): State) -> axum::J }) .map(|(name, st)| TransientView { name, - kind: match st.kind { - crate::coordinator::TransientKind::Spawning => "spawning", - }, + kind: transient_label(st.kind), secs: st.since.elapsed().as_secs(), }) .collect(); @@ -337,10 +344,15 @@ async fn post_rebuild(State(state): State, AxumPath(name): AxumPath Redirect::to("/").into_response(), - Err(e) => error_response(&format!("rebuild {name} failed: {e:#}")), + Err(e) => error_response(&format!("rebuild {logical} failed: {e:#}")), } } @@ -349,7 +361,12 @@ async fn post_kill(State(state): State, AxumPath(name): AxumPath { state.coord.unregister_agent(&logical); state @@ -363,20 +380,27 @@ async fn post_kill(State(state): State, AxumPath(name): AxumPath, - AxumPath(name): AxumPath, -) -> Response { +async fn post_restart(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); - match lifecycle::restart(&logical).await { + state + .coord + .set_transient(&logical, crate::coordinator::TransientKind::Restarting); + let result = lifecycle::restart(&logical).await; + state.coord.clear_transient(&logical); + match result { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("restart {logical} failed: {e:#}")), } } -async fn post_start(State(_state): State, AxumPath(name): AxumPath) -> Response { +async fn post_start(State(state): State, AxumPath(name): AxumPath) -> Response { let logical = strip_container_prefix(&name); - match lifecycle::start(&logical).await { + state + .coord + .set_transient(&logical, crate::coordinator::TransientKind::Starting); + let result = lifecycle::start(&logical).await; + state.coord.clear_transient(&logical); + match result { Ok(()) => Redirect::to("/").into_response(), Err(e) => error_response(&format!("start {logical} failed: {e:#}")), } @@ -416,6 +440,20 @@ async fn post_update_all(State(state): State) -> Response { } } +fn transient_label(k: crate::coordinator::TransientKind) -> &'static str { + use crate::coordinator::TransientKind::{ + Destroying, Rebuilding, Restarting, Spawning, Starting, Stopping, + }; + match k { + Spawning => "spawning", + Starting => "starting", + Stopping => "stopping", + Restarting => "restarting", + Rebuilding => "rebuilding", + Destroying => "destroying", + } +} + /// Convert either a logical name or a container name back to the logical /// name. Sub-agents are `h-foo` → `foo`; manager stays `hm1nd`. fn strip_container_prefix(name: &str) -> String { From 8344dd9ab7162a1e49f3b59d75a19121e919b426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?m=C3=BCde?= Date: Fri, 15 May 2026 19:52:44 +0200 Subject: [PATCH 2/2] ask_operator: multi-select + free-text fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask_operator now accepts a multi: bool. when true and options is non-empty, the dashboard renders the choices as checkboxes — operator picks any subset, answer comes back as a ', '-joined string. when false (default), options are radio buttons. independent of multi, a free-text input ('or type your own…') is always rendered alongside options so the operator is never trapped by an incomplete list. submit merges checked options + free text into the single 'answer' field. schema migration: operator_questions grows a multi INTEGER column with a one-shot ALTER TABLE on open. backward compatible — old rows default to 0 (not multi). prompt + mcp tool description updated; existing dashboard css for .qform was rewritten around the new vertical layout. --- hive-ag3nt/prompts/manager.md | 2 +- hive-ag3nt/src/mcp.rs | 15 +++++++-- hive-c0re/assets/app.js | 48 ++++++++++++++++++++++------- hive-c0re/assets/dashboard.css | 28 ++++++++++++++--- hive-c0re/src/manager_server.rs | 13 ++++++-- hive-c0re/src/operator_questions.rs | 44 ++++++++++++++++++++------ hive-sh4re/src/lib.rs | 15 +++++++-- 7 files changed, 130 insertions(+), 35 deletions(-) diff --git a/hive-ag3nt/prompts/manager.md b/hive-ag3nt/prompts/manager.md index d7adc846..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?)` — 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. 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/mcp.rs b/hive-ag3nt/src/mcp.rs index 575cf99d..d48f4d8e 100644 --- a/hive-ag3nt/src/mcp.rs +++ b/hive-ag3nt/src/mcp.rs @@ -227,10 +227,16 @@ pub struct RestartArgs { pub struct AskOperatorArgs { /// The question to surface on the dashboard. pub question: String, - /// Optional fixed-choice answers. If empty, the dashboard renders a - /// free-text input. Otherwise renders a select list of these options. + /// Optional fixed-choice answers. The dashboard always renders a + /// free-text fallback ("Other…") so the operator is never trapped + /// by an incomplete list. #[serde(default)] pub options: Vec, + /// When true, options are rendered as checkboxes — operator can pick + /// any subset. The answer comes back as a single string with + /// selections joined by ", ". Ignored when `options` is empty. + #[serde(default)] + pub multi: bool, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -369,7 +375,9 @@ impl ManagerServer { with event `operator_answered { id, question, answer }` lands in your inbox; handle it \ on a future turn. Use this when a decision needs human signal (ambiguous sub-agent \ request, policy call, scope clarification). `options` is advisory: pass a short \ - fixed-choice list when applicable, otherwise leave empty for free text." + 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." )] async fn ask_operator(&self, Parameters(args): Parameters) -> String { let log = format!("{args:?}"); @@ -378,6 +386,7 @@ impl ManagerServer { .dispatch(hive_sh4re::ManagerRequest::AskOperator { question: args.question, options: args.options, + multi: args.multi, }) .await; match resp { diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 6090909d..33ed8804 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -205,20 +205,46 @@ method: 'POST', action: '/answer-question/' + q.id, class: 'qform', 'data-async': '', }); - let input; - if (q.options && q.options.length) { - input = el('select', { name: 'answer', required: '' }); - input.append(el('option', { value: '', disabled: '', selected: '' }, 'choose…')); + const hasOptions = q.options && q.options.length; + const isMulti = !!q.multi && hasOptions; + const freeText = el('input', { + type: 'text', name: 'answer-free', + placeholder: hasOptions ? 'or type your own…' : 'your answer', + autocomplete: 'off', + }); + const optionGroup = el('div', { class: 'q-options' }); + if (hasOptions) { for (const opt of q.options) { - input.append(el('option', { value: opt }, opt)); + const inputType = isMulti ? 'checkbox' : 'radio'; + const id = 'q' + q.id + '-' + Math.random().toString(36).slice(2, 8); + const input = el('input', { type: inputType, name: 'choice', value: opt, id }); + const label = el('label', { for: id }, ' ' + opt); + optionGroup.append(el('div', { class: 'q-option' }, input, label)); } - } else { - input = el('input', { - name: 'answer', type: 'text', required: '', - placeholder: 'your answer', autocomplete: 'off', - }); } - f.append(input, el('button', { type: 'submit', class: 'btn btn-approve' }, '▸ ANSW3R')); + // On submit, build the final `answer` field from selected + // options + free-text, joined by ', '. This lets the operator + // pick options AND add free text in the same form. + f.addEventListener('submit', (ev) => { + const parts = []; + for (const cb of f.querySelectorAll('input[name="choice"]:checked')) { + parts.push(cb.value); + } + const ft = (freeText.value || '').trim(); + if (ft) parts.push(ft); + const merged = parts.join(', '); + // Replace the existing hidden `answer` (if any) with the merged value. + const existing = f.querySelector('input[name="answer"]'); + if (existing) existing.remove(); + f.append(el('input', { type: 'hidden', name: 'answer', value: merged })); + 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), + el('button', { type: 'submit', class: 'btn btn-approve' }, + isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'), + ); li.append(f); ul.append(li); } diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index e61c5ac5..08479d83 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -292,18 +292,36 @@ summary:hover { color: var(--purple); } white-space: pre-wrap; word-break: break-word; } -.qform { display: flex; gap: 0.6em; align-items: stretch; margin-top: 0.3em; } -.qform input, .qform select { +.qform { + display: flex; + flex-direction: column; + gap: 0.5em; + margin-top: 0.4em; +} +.qform .q-options { + display: flex; + flex-direction: column; + gap: 0.25em; + background: var(--bg); + border: 1px solid var(--border); + border-radius: 4px; + padding: 0.4em 0.6em; +} +.qform .q-option label { cursor: pointer; user-select: none; } +.qform .q-option input { margin-right: 0.4em; accent-color: var(--amber); } +.qform .q-free { display: flex; } +.qform .q-free input { + flex: 1; font-family: inherit; font-size: 1em; background: var(--bg); color: var(--fg); border: 1px solid var(--border); padding: 0.4em 0.6em; - flex: 1; } -.qform input::placeholder { color: var(--muted); } -.qform input:focus, .qform select:focus { outline: 1px solid var(--amber); } +.qform .q-free input::placeholder { color: var(--muted); } +.qform .q-free input:focus { outline: 1px solid var(--amber); } +.qform button { align-self: flex-start; } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/manager_server.rs b/hive-c0re/src/manager_server.rs index 8eb1c4ab..f17f2972 100644 --- a/hive-c0re/src/manager_server.rs +++ b/hive-c0re/src/manager_server.rs @@ -182,9 +182,16 @@ async fn dispatch(req: &ManagerRequest, coord: &Coordinator) -> ManagerResponse }, } } - ManagerRequest::AskOperator { question, options } => { - tracing::info!(%question, ?options, "manager: ask_operator"); - match coord.questions.submit(MANAGER_AGENT, question, options) { + ManagerRequest::AskOperator { + question, + options, + multi, + } => { + tracing::info!(%question, ?options, multi, "manager: ask_operator"); + match coord + .questions + .submit(MANAGER_AGENT, question, options, *multi) + { Ok(id) => { tracing::info!(%id, "operator question queued"); ManagerResponse::QuestionQueued { id } diff --git a/hive-c0re/src/operator_questions.rs b/hive-c0re/src/operator_questions.rs index aa985f4e..cfb8b4fd 100644 --- a/hive-c0re/src/operator_questions.rs +++ b/hive-c0re/src/operator_questions.rs @@ -25,12 +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( + "ALTER TABLE operator_questions ADD COLUMN multi INTEGER NOT NULL DEFAULT 0;", + ) + .context("add operator_questions.multi column")?; + } + Ok(()) +} + #[derive(Debug, Clone, Serialize)] +#[allow(clippy::doc_markdown)] pub struct OpQuestion { pub id: i64, pub asker: String, pub question: String, pub options: Vec, + pub multi: bool, pub asked_at: i64, pub answered_at: Option, pub answer: Option, @@ -51,18 +68,25 @@ 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")?; Ok(Self { conn: Mutex::new(conn), }) } - pub fn submit(&self, asker: &str, question: &str, options: &[String]) -> Result { + pub fn submit( + &self, + asker: &str, + question: &str, + options: &[String], + multi: bool, + ) -> 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, asked_at) - VALUES (?1, ?2, ?3, ?4)", - params![asker, question, options_json, 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()) } @@ -95,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, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer FROM operator_questions WHERE id = ?1", params![id], row_to_question, @@ -107,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, asked_at, answered_at, answer + "SELECT id, asker, question, options_json, multi, asked_at, answered_at, answer FROM operator_questions WHERE answered_at IS NULL ORDER BY id ASC", @@ -121,14 +145,16 @@ impl OperatorQuestions { fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result { let options_json: String = row.get(3)?; let options: Vec = serde_json::from_str(&options_json).unwrap_or_default(); + let multi: i64 = row.get(4)?; Ok(OpQuestion { id: row.get(0)?, asker: row.get(1)?, question: row.get(2)?, options, - asked_at: row.get(4)?, - answered_at: row.get(5)?, - answer: row.get(6)?, + multi: multi != 0, + asked_at: row.get(5)?, + answered_at: row.get(6)?, + answer: row.get(7)?, }) } diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 067d79e7..a1c34044 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -289,13 +289,22 @@ pub enum ManagerRequest { agent: String, commit_ref: String, }, - /// Ask the operator a question. The host-side handler blocks until the - /// operator answers via the dashboard; the answer is then returned as the - /// response. `options` is advisory: an empty list means free-text. + /// Ask the operator a question. Returns immediately with the queued + /// question id; the operator's answer arrives later as a + /// `HelperEvent::OperatorAnswered` in the manager inbox. + /// + /// - `options` is advisory: empty = free-text only; non-empty = the + /// dashboard renders the choices alongside a free-text fallback + /// ("Other…") so the operator is never trapped. + /// - `multi=true` lets the operator pick multiple options (rendered + /// as checkboxes). The answer is returned as a single string with + /// selections joined by ", ". AskOperator { question: String, #[serde(default)] options: Vec, + #[serde(default)] + multi: bool, }, }