Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8344dd9ab7 | ||
|
|
c337cc06f8 |
10 changed files with 287 additions and 73 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
/// 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<AskOperatorArgs>) -> 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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -201,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);
|
||||
}
|
||||
|
|
@ -304,8 +334,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) {
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
@ -233,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);
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<AppState>) -> 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<AppState>) -> axum::J
|
|||
is_manager,
|
||||
needs_update,
|
||||
needs_login,
|
||||
pending,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -196,9 +205,7 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> 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<AppState>, AxumPath(name): AxumPath<St
|
|||
"rebuild: hyperhive_flake has no canonical path; manual rebuild only via `hive-c0re rebuild`",
|
||||
);
|
||||
};
|
||||
let result = crate::auto_update::rebuild_agent(&state.coord, &name, ¤t_rev).await;
|
||||
let logical = strip_container_prefix(&name);
|
||||
state
|
||||
.coord
|
||||
.set_transient(&logical, crate::coordinator::TransientKind::Rebuilding);
|
||||
let result = crate::auto_update::rebuild_agent(&state.coord, &logical, ¤t_rev).await;
|
||||
state.coord.clear_transient(&logical);
|
||||
match result {
|
||||
Ok(()) => 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<AppState>, AxumPath(name): AxumPath<Strin
|
|||
if logical == lifecycle::MANAGER_NAME {
|
||||
return error_response("kill: refusing to stop the manager");
|
||||
}
|
||||
match lifecycle::kill(&logical).await {
|
||||
state
|
||||
.coord
|
||||
.set_transient(&logical, crate::coordinator::TransientKind::Stopping);
|
||||
let result = lifecycle::kill(&logical).await;
|
||||
state.coord.clear_transient(&logical);
|
||||
match result {
|
||||
Ok(()) => {
|
||||
state.coord.unregister_agent(&logical);
|
||||
state
|
||||
|
|
@ -363,20 +380,27 @@ async fn post_kill(State(state): State<AppState>, AxumPath(name): AxumPath<Strin
|
|||
}
|
||||
}
|
||||
|
||||
async fn post_restart(
|
||||
State(_state): State<AppState>,
|
||||
AxumPath(name): AxumPath<String>,
|
||||
) -> Response {
|
||||
async fn post_restart(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> 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<AppState>, AxumPath(name): AxumPath<String>) -> Response {
|
||||
async fn post_start(State(state): State<AppState>, AxumPath(name): AxumPath<String>) -> 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<AppState>) -> 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 {
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
pub multi: bool,
|
||||
pub asked_at: i64,
|
||||
pub answered_at: Option<i64>,
|
||||
pub answer: Option<String>,
|
||||
|
|
@ -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<i64> {
|
||||
pub fn submit(
|
||||
&self,
|
||||
asker: &str,
|
||||
question: &str,
|
||||
options: &[String],
|
||||
multi: bool,
|
||||
) -> 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, 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<Option<OpQuestion>> {
|
||||
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<Vec<OpQuestion>> {
|
||||
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<OpQuestion> {
|
||||
let options_json: String = row.get(3)?;
|
||||
let options: Vec<String> = 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)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<String>,
|
||||
#[serde(default)]
|
||||
multi: bool,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue