Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
13 changed files with 513 additions and 72 deletions

View file

@ -635,13 +635,10 @@ root agent. Variants (`hive_sh4re::manager::HelperEvent`):
- `NeedsUpdate { agent }` — sub-agent's recorded flake rev is - `NeedsUpdate { agent }` — sub-agent's recorded flake rev is
stale. The root agent calls `update(name)` to rebuild — idempotent, stale. The root agent calls `update(name)` to rebuild — idempotent,
no approval required. no approval required.
- `QuestionAnswered { id, question, answer, answerer }` — peer - `QuestionAnswered { id, question, answer, answerer }` —
`Answer` request (answerer = agent name) or ttl watchdog expiry dashboard `/answer-question/{id}` (answerer = `"operator"`),
(answerer = `"ttl-watchdog"`, answer = `"[expired]"`). The dashboard's peer `Answer` request (answerer = agent name), or ttl watchdog
own `/answer-question/{id}` trigger (answerer = `"operator"`) is expiry (answerer = `"ttl-watchdog"`, answer = `"[expired]"`).
gone — removed along with the rest of the dashboard's ask/answer
surface, so an operator-targeted question now has no path to an
answer at all.
- `QuestionAsked { id, asker, question, options, multi }` — - `QuestionAsked { id, asker, question, options, multi }` —
fired when an agent calls `Ask { to: Some(<this-agent>), ... }`. fired when an agent calls `Ask { to: Some(<this-agent>), ... }`.
The recipient responds via `Answer { id, answer }` and the The recipient responds via `Answer { id, answer }` and the

View file

@ -28,12 +28,14 @@ always on), the gateway proxies all operator-facing traffic, and
**Operator-authority actions never get a per-agent-socket entry **Operator-authority actions never get a per-agent-socket entry
point.** They live on the core backend. point.** They live on the core backend.
Worked example — destroying or rebuilding a container is a Worked example — answering an operator-targeted question is a
`POST /api/{destroy,rebuild}/{name}` on the core dashboard, _never_ a `POST /api/answer-question/{id}` on the core dashboard, _never_ a
per-agent-socket `Request` variant. If it were a per-agent-socket per-agent-socket `Request` variant. If it were a per-agent-socket
request, a compromised agent could `curl` its own socket and destroy request, an agent could `curl` its own socket and spoof an operator
or rebuild itself (or, if the variant took an arbitrary target, another answer.
agent) without ever touching the core's own authenticated surface. The per-agent web UI POSTs cross-origin to the core for these
(see the inline-answer feature — the loose-ends section on each
agent page).
## Why network isolation is the load-bearing step ## Why network isolation is the load-bearing step
@ -65,10 +67,9 @@ The boundary rests on three layers:
1. **Gateway** — fronts all surfaces (dashboard + every per-agent UI) 1. **Gateway** — fronts all surfaces (dashboard + every per-agent UI)
on one origin. An nginx nixos-container proxies per-agent UIs under on one origin. An nginx nixos-container proxies per-agent UIs under
`/agent/<name>/`, which is what lets each agent page's inbox panel `/agent/<name>/`, which is what lets the inline-answer POST to
POST `mark-all-read` to the core dashboard's `/answer-question/{id}` go same-origin instead of needing a
`/api/agent/{name}/mark-all-read` go same-origin instead of needing cross-origin CORS shim. Pure ergonomics — no behavioural risk on
a cross-origin CORS shim. Pure ergonomics — no behavioural risk on
its own. its own.
2. **Network isolation** — the load-bearing layer: every agent 2. **Network isolation** — the load-bearing layer: every agent
container runs in a private netns behind the hive bridge, always container runs in a private netns behind the hive bridge, always

View file

@ -174,16 +174,14 @@ the dashboard's "mark all read" (unbounded, per-agent).
### Question routing (Ask / Answer) ### Question routing (Ask / Answer)
`AgentRequest::Ask` (and the manager-flavour mirror) surfaces a `AgentRequest::Ask` (and the manager-flavour mirror) surfaces a
structured question that either lands in the operator-question queue structured question that either lands in the operator's dashboard
or in a peer agent's inbox. The recipient is the `to` field: queue or in a peer agent's inbox. The recipient is the `to` field:
- `to = None` or `to = Some("operator")` — routes to the - `to = None` or `to = Some("operator")` — routes to the
operator-question queue. ⚠️ The dashboard's whole ask/answer operator-question queue. The dashboard renders the question with
surface (the UI that used to render this queue and let the operator any `options` as a chip strip plus a free-text fallback (`Other…`)
reply) has been removed — an operator-targeted question currently so the operator is never trapped by an incomplete list. The
has no reader anywhere; this routing case is being removed too as legacy `AskOperator` variant collapses into this case.
the same effort continues. The legacy `AskOperator` variant
collapses into this case.
- `to = Some(<agent>)` — peer Q&A. The target agent receives a - `to = Some(<agent>)` — peer Q&A. The target agent receives a
`HelperEvent::QuestionAsked { id, asker, question, options, multi }` `HelperEvent::QuestionAsked { id, asker, question, options, multi }`
in their inbox. They reply via `AgentRequest::Answer` (or in their inbox. They reply via `AgentRequest::Answer` (or
@ -193,23 +191,21 @@ or in a peer agent's inbox. The recipient is the `to` field:
Shape fields are uniform across both targets: Shape fields are uniform across both targets:
- `options` is advisory — a list is never enforced; peer-agent - `options` is advisory — the dashboard chips are decoration over a
recipients see it in their `QuestionAsked` event and can return any free-text fallback; peer-agent recipients see the list in their
string regardless. `QuestionAsked` event and can return any string.
- `multi = true` is a hint that the answerer should treat `options` as - `multi = true` lets the answerer pick multiple options (checkboxes
a multi-select rather than pick-one. The answer comes back as a in the dashboard, a hint in the peer-agent event). The answer
single string with selections joined by `", "`. comes back as a single string with selections joined by `", "`.
- `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer: - `ttl_seconds` auto-cancels with answer `[expired]` (and `answerer:
"ttl-watchdog"`) when the wait becomes moot. `None` = wait "ttl-watchdog"`) when the wait becomes moot. `None` = wait
indefinitely or until manual cancel. indefinitely or until manual cancel.
Response shape is always `QuestionQueued { id }` — the asker stores Response shape is always `QuestionQueued { id }` — the asker stores
the id and correlates the asynchronous answer event when it lands. the id and correlates the asynchronous answer event when it lands.
Authorisation on `Answer`: only the question's `target` agent, or the Authorisation on `Answer`: only the question's `target` agent (or
operator, is permitted to reply; an answer attempt from anyone else the operator via the dashboard) is permitted to reply; an answer
fails the wire-side check. (The operator's only path to invoke this attempt from anyone else fails the wire-side check.
was the now-removed dashboard — see the note above; the
authorisation rule itself is unchanged.)
### Loose-ends wire shape ### Loose-ends wire shape

View file

@ -1172,6 +1172,13 @@ below — some endpoints aren't in it yet.
- `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle. - `POST /api/{rebuild,kill,restart,start,destroy}/{name}` — lifecycle.
`destroy` accepts `purge=on` to also wipe state dirs. `destroy` accepts `purge=on` to also wipe state dirs.
- `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs. - `POST /api/purge-tombstone/{name}` — wipe a tombstone's state dirs.
- `POST /api/answer-question/{id}` — answer a pending operator question.
No dashboard UI calls this any more (the questions pane is gone —
see hyperhive#3721); kept as long as the backend `ask`/`answer`
mechanism itself still exists.
- `POST /api/cancel-question/{id}` — cancel a pending question with
the sentinel `[cancelled]`. Same code path as a real answer. Same
no-UI-consumer note as above.
- `POST /api/request-spawn` — queue a Spawn approval. - `POST /api/request-spawn` — queue a Spawn approval.
- `POST /api/update-all` — rebuild every stale container. - `POST /api/update-all` — rebuild every stale container.
- `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry. - `POST /api/rebuild-queue/{id}/cancel` — drop a `Queued` entry.
@ -1376,6 +1383,12 @@ payload):
sha_short, status, resolved_at, note, description) — pending sha_short, status, resolved_at, note, description) — pending
queue + history mutations. Client mutates a derived store and queue + history mutations. Client mutates a derived store and
re-renders only the approvals section. re-renders only the approvals section.
- `question_added` (id, asker, question, options, multi,
asked_at, deadline_at, target) / `question_resolved` (id,
answer, answerer, answered_at, cancelled, target) — still
fired by the backend `ask`/`answer` mechanism, but the
dashboard no longer subscribes to either kind: the questions
pane that used to surface them is gone (hyperhive#3721).
- `transient_set` (name, transient_kind, since_unix) / - `transient_set` (name, transient_kind, since_unix) /
`transient_cleared` (name) — lifecycle action spinners. The `transient_cleared` (name) — lifecycle action spinners. The
client ticks the elapsed-seconds badge off `since_unix` client ticks the elapsed-seconds badge off `since_unix`

View file

@ -445,6 +445,19 @@ pub struct ApprovalAdded<'a> {
pub pr_number: Option<u64>, pub pr_number: Option<u64>,
} }
/// Field-named payload for [`Coordinator::emit_question_added`].
/// Mirrors the `QuestionAdded` dashboard-event fields; all references
/// share the caller's lifetime.
pub struct QuestionAdded<'a> {
pub id: i64,
pub asker: &'a str,
pub question: &'a str,
pub options: &'a [String],
pub multi: bool,
pub deadline_at: Option<i64>,
pub target: Option<&'a str>,
}
impl Coordinator { impl Coordinator {
pub fn open( pub fn open(
db_path: &Path, db_path: &Path,
@ -826,6 +839,71 @@ impl Coordinator {
}); });
} }
/// Emit `QuestionAdded` after a question is inserted. Fires for
/// both operator-targeted (`target = None`) and peer-to-peer
/// (`target = Some(agent)`) threads — the dashboard surfaces
/// both, distinguishing visually + offering operator override.
pub fn emit_question_added(&self, ev: &QuestionAdded<'_>) {
let &QuestionAdded {
id,
asker,
question,
options,
multi,
deadline_at,
target,
} = ev;
let asked_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let question_refs = crate::dashboard::scan_validated_paths(question);
self.emit_dashboard_event(DashboardEvent::QuestionAdded {
seq: self.next_seq(),
id,
asker: asker.to_owned(),
question: question.to_owned(),
options: options.to_vec(),
multi,
asked_at: hive_sh4re::wire_time::from_secs(asked_at),
deadline_at: deadline_at.map(hive_sh4re::wire_time::from_secs),
target: target.map(str::to_owned),
question_refs,
});
}
/// Emit `QuestionResolved` when a question transitions to
/// answered (operator answer, peer answer, operator override on
/// a peer thread, operator cancel, or ttl watchdog). Both
/// operator-targeted and peer threads fire so the dashboard's
/// derived store can move the row from pending to history.
pub fn emit_question_resolved(
&self,
id: i64,
answer: &str,
answerer: &str,
cancelled: bool,
target: Option<&str>,
) {
let answered_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.ok()
.and_then(|d| i64::try_from(d.as_secs()).ok())
.unwrap_or(0);
let answer_refs = crate::dashboard::scan_validated_paths(answer);
self.emit_dashboard_event(DashboardEvent::QuestionResolved {
seq: self.next_seq(),
id,
answer: answer.to_owned(),
answerer: answerer.to_owned(),
answered_at: hive_sh4re::wire_time::from_secs(answered_at),
cancelled,
target: target.map(str::to_owned),
answer_refs,
});
}
/// Rebuild the per-container snapshot, diff it against the last /// Rebuild the per-container snapshot, diff it against the last
/// one cached on `self`, and emit one /// one cached on `self`, and emit one
/// `DashboardEvent::ContainerStateChanged` per added/changed row /// `DashboardEvent::ContainerStateChanged` per added/changed row

View file

@ -44,6 +44,7 @@ use crate::lifecycle;
(name = "meta_inputs", description = "bulk flake-input update for the meta flake"), (name = "meta_inputs", description = "bulk flake-input update for the meta flake"),
(name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"), (name = "misc_api", description = "operator inbox, compose, spawn-request, hive stats, audit log"),
(name = "permissions", description = "tool-group + capability assignment for agents"), (name = "permissions", description = "tool-group + capability assignment for agents"),
(name = "questions", description = "answer/cancel pending operator questions"),
(name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"), (name = "schedules", description = "scheduled-prompt + rebuild-queue CRUD"),
(name = "state_files", description = "proxied reads of allow-listed per-agent state files"), (name = "state_files", description = "proxied reads of allow-listed per-agent state files"),
(name = "state_snapshot", description = "cold-load dashboard snapshot"), (name = "state_snapshot", description = "cold-load dashboard snapshot"),
@ -70,6 +71,7 @@ mod matrix_accounts;
mod meta_inputs; mod meta_inputs;
mod misc_api; mod misc_api;
pub(crate) mod permissions; pub(crate) mod permissions;
mod questions;
mod schedules; mod schedules;
mod state_files; mod state_files;
mod state_snapshot; mod state_snapshot;
@ -195,6 +197,8 @@ pub async fn serve(
.routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container)) .routes(routes!(infra_containers::post_infra_container))
.routes(routes!(questions::post_answer_question))
.routes(routes!(questions::post_cancel_question))
.routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(build_logs::get_build_log_stream))
@ -438,6 +442,8 @@ mod router_build_probe {
.routes(routes!(lifecycle_ops::post_resource_limits)) .routes(routes!(lifecycle_ops::post_resource_limits))
.routes(routes!(lifecycle_ops::post_update_all)) .routes(routes!(lifecycle_ops::post_update_all))
.routes(routes!(infra_containers::post_infra_container)) .routes(routes!(infra_containers::post_infra_container))
.routes(routes!(questions::post_answer_question))
.routes(routes!(questions::post_cancel_question))
.routes(routes!(tombstones::post_purge_tombstone)) .routes(routes!(tombstones::post_purge_tombstone))
.routes(routes!(meta_inputs::post_meta_update)) .routes(routes!(meta_inputs::post_meta_update))
.routes(routes!(build_logs::get_build_log_stream)) .routes(routes!(build_logs::get_build_log_stream))

View file

@ -0,0 +1,149 @@
//! Operator question answer/cancel endpoints for the dashboard.
//!
//! `POST /answer-question/{id}` records the operator's answer and fires a
//! `QuestionAnswered` event to the asker; `POST /cancel-question/{id}`
//! resolves a pending question with a `[cancelled]` sentinel. Both carry a
//! permissive CORS header so the per-agent web UI (different origin) can
//! POST here until the unifying gateway makes it same-origin.
use axum::{
extract::{Form, Path as AxumPath, State},
http::StatusCode,
response::{IntoResponse, Response},
};
use serde::Deserialize;
use utoipa::ToSchema;
use problem_details::ProblemDetails;
use super::{AppState, error_response};
#[derive(Deserialize, ToSchema)]
pub(super) struct AnswerForm {
answer: String,
}
/// Attach a permissive CORS header so the per-agent web UI — served on
/// a different port — can POST an operator answer here and read the
/// result. The dashboard has no auth, so `*` exposes nothing a plain
/// cross-origin form-POST couldn't already reach. This shim disappears
/// once the unifying gateway makes the agent page same-origin; see
/// `docs/boundary.md`.
fn with_cors(resp: impl IntoResponse) -> Response {
let mut resp = resp.into_response();
resp.headers_mut().insert(
axum::http::header::ACCESS_CONTROL_ALLOW_ORIGIN,
axum::http::HeaderValue::from_static("*"),
);
resp
}
/// Record the operator's answer and
/// notify the asker.
#[utoipa::path(
post,
path = "/api/answer-question/{id}",
params(("id" = i64, Path, description = "question row id")),
request_body(content = AnswerForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "answered", body = String),
(status = 400, description = "empty answer"),
(status = 500, description = "answer failed (already answered, unknown id, ...)"),
),
tag = "questions"
)]
pub(super) async fn post_answer_question(
State(state): State<AppState>,
AxumPath(id): AxumPath<i64>,
Form(form): Form<AnswerForm>,
) -> Response {
let answer = form.answer.trim();
if answer.is_empty() {
return with_cors(
ProblemDetails::from_status_code(StatusCode::BAD_REQUEST)
.with_detail("answer: required"),
);
}
let resp =
match state
.coord
.questions
.answer(id, answer, hive_sh4re::manager::OPERATOR_RECIPIENT)
{
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator answered question");
state.coord.notify_agent(
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: answer.to_owned(),
answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
},
);
state.coord.emit_question_resolved(
id,
answer,
hive_sh4re::manager::OPERATOR_RECIPIENT,
false,
target.as_deref(),
);
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
};
with_cors(resp)
}
/// Resolve a pending question with the
/// `[cancelled]` sentinel answer.
///
/// Used when the operator decides not to / can't answer. The asker
/// harness receives a `QuestionAnswered` 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.
#[utoipa::path(
post,
path = "/api/cancel-question/{id}",
params(("id" = i64, Path, description = "question row id")),
responses(
(status = 200, description = "cancelled", body = String),
(status = 500, description = "cancel failed (already answered, unknown id, ...)"),
),
tag = "questions"
)]
pub(super) 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, hive_sh4re::manager::OPERATOR_RECIPIENT)
{
Ok((question, asker, target)) => {
tracing::info!(%id, %asker, "operator cancelled question");
state.coord.emit_question_resolved(
id,
SENTINEL,
hive_sh4re::manager::OPERATOR_RECIPIENT,
true,
target.as_deref(),
);
state.coord.notify_agent_from(
hive_sh4re::manager::OPERATOR_RECIPIENT,
&asker,
&hive_sh4re::manager::HelperEvent::QuestionAnswered {
id,
question,
answer: SENTINEL.to_owned(),
answerer: hive_sh4re::manager::OPERATOR_RECIPIENT.to_owned(),
},
);
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
}
}

View file

@ -52,6 +52,15 @@ pub(super) struct StateSnapshot {
/// Last 30 resolved approvals (approved / denied / failed), newest- /// Last 30 resolved approvals (approved / denied / failed), newest-
/// first. Drives the "history" tab on the approvals section. /// first. Drives the "history" tab on the approvals section.
approval_history: Vec<ApprovalHistoryView>, approval_history: Vec<ApprovalHistoryView>,
/// Pending operator-targeted questions (`target IS NULL`). Any
/// agent can `ask` the operator and `ask` returns immediately with
/// the id; on `/answer-question` we mark the row answered and
/// fire `HelperEvent::QuestionAnswered` back into the asker's
/// inbox. Peer-to-peer questions live in the same table but never
/// surface here (see `OperatorQuestions::pending`).
questions: Vec<QuestionView>,
/// Last 20 answered questions, newest-first.
question_history: Vec<QuestionView>,
/// State dirs (config history + claude creds + /state/ notes) that /// State dirs (config history + claude creds + /state/ notes) that
/// survive after a destroy-without-purge. The operator can re-spawn /// survive after a destroy-without-purge. The operator can re-spawn
/// with the same name to resume, or PURG3 to wipe them. /// with the same name to resume, or PURG3 to wipe them.
@ -146,6 +155,35 @@ async fn infra_container_views() -> Vec<InfraContainerView> {
infra_containers infra_containers
} }
/// `OpQuestion` + computed `question_refs` / `answer_refs`. Built
/// from the snapshot read; the live channel attaches the same
/// fields directly on `QuestionAdded` / `QuestionResolved`.
#[derive(Serialize)]
struct QuestionView {
#[serde(flatten)]
inner: crate::operator_questions::OpQuestion,
#[serde(skip_serializing_if = "Vec::is_empty")]
question_refs: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
}
impl QuestionView {
fn from_question(q: crate::operator_questions::OpQuestion) -> Self {
let question_refs = scan_validated_paths(&q.question);
let answer_refs = q
.answer
.as_deref()
.map(scan_validated_paths)
.unwrap_or_default();
Self {
inner: q,
question_refs,
answer_refs,
}
}
}
#[derive(Serialize)] #[derive(Serialize)]
struct PortConflict { struct PortConflict {
port: u16, port: u16,
@ -237,12 +275,12 @@ const CRASH_WARNING_WINDOW: std::time::Duration = std::time::Duration::from_mins
/// Cold-load snapshot of the whole dashboard. /// Cold-load snapshot of the whole dashboard.
/// ///
/// Includes the roster, approvals (+ history), /// Includes the roster, approvals (+ history), questions (+ history),
/// tombstones, job queue, meta inputs, and more. Live clients then /// tombstones, job queue, meta inputs, and more. Live clients then
/// follow `/api/dashboard/stream` (SSE) for incremental updates keyed /// follow `/api/dashboard/stream` (SSE) for incremental updates keyed
/// off `seq`. /// off `seq`.
// `StateSnapshot` is a large tree of nested view types (`ContainerView`, // `StateSnapshot` is a large tree of nested view types (`ContainerView`,
// `ApprovalView`, ...) with no `ToSchema` anywhere in that // `ApprovalView`, `QuestionView`, ...) with no `ToSchema` anywhere in that
// graph; wiring it up is a schema-modelling project of its own, well past // graph; wiring it up is a schema-modelling project of its own, well past
// "annotate what's reachable". `serde_json::Value` placeholder for now — // "annotate what's reachable". `serde_json::Value` placeholder for now —
// see the batch report. // see the batch report.
@ -316,6 +354,23 @@ pub(super) async fn api_state(
let tombstones = build_tombstone_views(&state.coord, &containers); let tombstones = build_tombstone_views(&state.coord, &containers);
let port_conflicts = build_port_conflicts(&containers); let port_conflicts = build_port_conflicts(&containers);
// Both operator-targeted and peer threads surface on the dashboard
// (the client filters by target). Each row is wrapped in QuestionView
// so the snapshot carries the same file_refs the live event variants
// attach.
let questions: Vec<QuestionView> =
log_default("questions.pending_all", state.coord.questions.pending_all())
.into_iter()
.map(QuestionView::from_question)
.collect();
let question_history: Vec<QuestionView> = log_default(
"questions.recent_answered_all",
state.coord.questions.recent_answered_all(20),
)
.into_iter()
.map(QuestionView::from_question)
.collect();
// Banner warnings: host probes (disk) + agent-state (pending logins, // Banner warnings: host probes (disk) + agent-state (pending logins,
// crashing agents). Built before the response struct because the // crashing agents). Built before the response struct because the
// agent-state producer borrows `containers`, which moves in below. // agent-state producer borrows `containers`, which moves in below.
@ -341,6 +396,8 @@ pub(super) async fn api_state(
approval_history, approval_history,
meta_inputs: read_meta_inputs(), meta_inputs: read_meta_inputs(),
meta_update_running: state.coord.meta_update_in_progress(), meta_update_running: state.coord.meta_update_in_progress(),
questions,
question_history,
tombstones, tombstones,
port_conflicts, port_conflicts,
forge_present: crate::forge::is_present().await, forge_present: crate::forge::is_present().await,

View file

@ -97,6 +97,44 @@ pub enum DashboardEvent {
note: Option<String>, note: Option<String>,
description: Option<String>, description: Option<String>,
}, },
/// A question landed in the queue. `target = None` means
/// operator-targeted (`Ask { to: None | Some("operator") }`);
/// `target = Some(<agent>)` means a peer-to-peer question. Both
/// are surfaced on the dashboard so the operator can monitor /
/// override-answer stuck threads.
QuestionAdded {
seq: u64,
id: i64,
asker: String,
question: String,
options: Vec<String>,
multi: bool,
asked_at: DateTime<Utc>,
deadline_at: Option<DateTime<Utc>>,
target: Option<String>,
/// Verified file-path tokens that appear in `question`.
/// Same shape as broker `Sent`/`Delivered` events; the
/// client linkifies only what hive-c0re vouched for.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
question_refs: Vec<String>,
},
/// A question was answered (operator answer, peer answer,
/// operator override on a peer thread, or ttl watchdog
/// `[expired]`). Clients move the row from pending to history.
/// `cancelled = true` when the operator dismissed via the cancel
/// button.
QuestionResolved {
seq: u64,
id: i64,
answer: String,
answerer: String,
answered_at: DateTime<Utc>,
cancelled: bool,
target: Option<String>,
/// Verified file-path tokens that appear in `answer`.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
answer_refs: Vec<String>,
},
/// A lifecycle action started for an agent (spawn / start / stop /// A lifecycle action started for an agent (spawn / start / stop
/// / restart / rebuild / destroy). Clients render a spinner next /// / restart / rebuild / destroy). Clients render a spinner next
/// to the row; the client computes "seconds in this state" /// to the row; the client computes "seconds in this state"
@ -259,6 +297,8 @@ impl DashboardEvent {
DashboardEvent::Delivered { .. } => "delivered", DashboardEvent::Delivered { .. } => "delivered",
DashboardEvent::ApprovalAdded { .. } => "approval_added", DashboardEvent::ApprovalAdded { .. } => "approval_added",
DashboardEvent::ApprovalResolved { .. } => "approval_resolved", DashboardEvent::ApprovalResolved { .. } => "approval_resolved",
DashboardEvent::QuestionAdded { .. } => "question_added",
DashboardEvent::QuestionResolved { .. } => "question_resolved",
DashboardEvent::TransientSet { .. } => "transient_set", DashboardEvent::TransientSet { .. } => "transient_set",
DashboardEvent::TransientCleared { .. } => "transient_cleared", DashboardEvent::TransientCleared { .. } => "transient_cleared",
DashboardEvent::ContainerStateChanged { .. } => "container_state_changed", DashboardEvent::ContainerStateChanged { .. } => "container_state_changed",
@ -337,6 +377,28 @@ mod tests {
note: None, note: None,
description: None, description: None,
}, },
DashboardEvent::QuestionAdded {
seq: 1,
id: 1,
asker: "a".into(),
question: String::new(),
options: Vec::new(),
multi: false,
asked_at: hive_sh4re::wire_time::from_secs(0),
deadline_at: None,
target: None,
question_refs: Vec::new(),
},
DashboardEvent::QuestionResolved {
seq: 1,
id: 1,
answer: String::new(),
answerer: "a".into(),
answered_at: hive_sh4re::wire_time::from_secs(0),
cancelled: false,
target: None,
answer_refs: Vec::new(),
},
DashboardEvent::TransientSet { DashboardEvent::TransientSet {
seq: 1, seq: 1,
name: "x".into(), name: "x".into(),

View file

@ -7,12 +7,10 @@
//! and no longer sourced from here (loose-ends-v2's questions phase) — //! and no longer sourced from here (loose-ends-v2's questions phase) —
//! c0re remains the `Ask`/`Answer` routing + delivery rendezvous //! c0re remains the `Ask`/`Answer` routing + delivery rendezvous
//! (`coord.questions`), it just isn't asked for the *pending-view* //! (`coord.questions`), it just isn't asked for the *pending-view*
//! rendering anymore. ⚠️ The operator dashboard no longer has any //! rendering anymore. The
//! question-pending view either — its whole questions pane, the //! operator dashboard's questions pane is unaffected: it reads
//! `pending_all()`/`recent_answered_all()` reads that fed it, and the //! `coord.questions.pending_all()` directly (`dashboard/state_snapshot.rs`),
//! `/api/answer-question`/`/api/cancel-question` endpoints are gone //! independent of this module.
//! (removed along with the rest of the dashboard's ask/answer surface).
//! An operator-targeted `ask()` now has no reader anywhere.
//! //!
//! Call frequency is low (an agent doing self-introspection between //! Call frequency is low (an agent doing self-introspection between
//! turns), so the sweep happens fresh every time — no caching, no //! turns), so the sweep happens fresh every time — no caching, no

View file

@ -5,17 +5,9 @@
//! //!
//! Routing rules at a glance: //! Routing rules at a glance:
//! //!
//! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`. //! - `Ask { to: None | Some("operator") }` → stored with `target = NULL`;
//! ⚠️ As of the ask/answer removal's dashboard-backend slice, nothing //! the dashboard's `pending()` query surfaces it; operator answers
//! surfaces or answers an operator-targeted row any more — the //! via the dashboard.
//! dashboard's questions pane, its `/api/answer-question` /
//! `/api/cancel-question` endpoints, and the `pending_all()`/
//! `recent_answered_all()` reads that fed them are all gone. An
//! operator-targeted `ask()` (if anything still calls it — the MCP
//! tool itself was removed earlier in the same effort) would queue a
//! row nothing can ever resolve. Left as-is rather than special-cased,
//! since the whole `Ask`/`Answer` flow this file implements is itself
//! slated for removal next.
//! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`; //! - `Ask { to: Some(<agent>) }` → stored with `target = <agent>`;
//! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s //! a `HelperEvent::QuestionAsked` is pushed into `<agent>`'s
//! inbox so they can `Answer { id, answer }` on their own socket. //! inbox so they can `Answer { id, answer }` on their own socket.
@ -81,7 +73,8 @@ pub fn handle_ask(
// Agent-targeted questions need to wake the recipient — drop a // Agent-targeted questions need to wake the recipient — drop a
// QuestionAsked event into their inbox so the answerer doesn't // QuestionAsked event into their inbox so the answerer doesn't
// have to poll. Operator-targeted questions show up on the // have to poll. Operator-targeted questions show up on the
// dashboard's pending pane via `pending()` instead. // dashboard's pending pane via `pending()` instead, plus a
// `QuestionAdded` dashboard event so the browser updates live.
if let Some(target_agent) = target { if let Some(target_agent) = target {
coord.notify_agent( coord.notify_agent(
target_agent, target_agent,
@ -94,6 +87,17 @@ pub fn handle_ask(
}, },
); );
} }
// Always fire on the dashboard channel — both operator-targeted
// and peer threads now surface in the dashboard's questions pane.
coord.emit_question_added(&crate::coordinator::QuestionAdded {
id,
asker,
question,
options,
multi,
deadline_at,
target,
});
if let Some(t) = ttl { if let Some(t) = ttl {
spawn_question_watchdog(coord, id, t); spawn_question_watchdog(coord, id, t);
} }
@ -111,7 +115,7 @@ pub fn handle_answer(
answer: &str, answer: &str,
) -> Result<(), String> { ) -> Result<(), String> {
limits::check_size("answer", answer)?; limits::check_size("answer", answer)?;
let (question, asker, _target) = coord let (question, asker, target) = coord
.questions .questions
.answer(id, answer, answerer) .answer(id, answer, answerer)
.map_err(|e| format!("{e:#}"))?; .map_err(|e| format!("{e:#}"))?;
@ -128,6 +132,11 @@ pub fn handle_answer(
answerer: answerer.to_owned(), answerer: answerer.to_owned(),
}, },
); );
// Dashboard surfaces both operator-targeted and peer threads;
// emit unconditionally so the derived store moves the row.
// `cancelled = false` because this path is a real answer (the
// operator-cancel button goes through `post_cancel_question`).
coord.emit_question_resolved(id, answer, answerer, false, target.as_deref());
Ok(()) Ok(())
} }
@ -151,7 +160,7 @@ pub fn handle_cancel_loose_end(
// Agent-socket path: never privileged — an agent may only cancel // Agent-socket path: never privileged — an agent may only cancel
// its own question (ownership). The operator's cancel-anything // its own question (ownership). The operator's cancel-anything
// path goes through a separate handler with `privileged = true`. // path goes through a separate handler with `privileged = true`.
let (question, asker, _target) = coord let (question, asker, target) = coord
.questions .questions
.cancel(id, canceller, false) .cancel(id, canceller, false)
.map_err(|e| format!("{e:#}"))?; .map_err(|e| format!("{e:#}"))?;
@ -173,6 +182,7 @@ pub fn handle_cancel_loose_end(
}, },
); );
} }
coord.emit_question_resolved(id, &sentinel, canceller, true, target.as_deref());
Ok(()) Ok(())
} }
hive_sh4re::inbox::CancelLooseEndKind::Reminder => { hive_sh4re::inbox::CancelLooseEndKind::Reminder => {

View file

@ -1099,7 +1099,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
// the public `answer()` path by calling it with the operator // the public `answer()` path by calling it with the operator
// identity, since the operator is always permitted; the // identity, since the operator is always permitted; the
// event we fire carries the real watchdog label for observers. // event we fire carries the real watchdog label for observers.
if let Ok((question, asker, _target)) = if let Ok((question, asker, target)) =
coord coord
.questions .questions
.answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT) .answer(id, TTL_SENTINEL, hive_sh4re::manager::OPERATOR_RECIPIENT)
@ -1114,6 +1114,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
answerer: TTL_ANSWERER.to_owned(), answerer: TTL_ANSWERER.to_owned(),
}, },
); );
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref());
} }
}); });
} }

View file

@ -1,10 +1,6 @@
//! Question queue. Agents submit via `Ask`; the answer comes from //! Question queue. Agents submit via `Ask`; the answer comes from
//! either the operator (for `target IS NULL`) or a peer agent (via //! either the operator (via the dashboard, for `target IS NULL`) or
//! `Answer`, for agent-to-agent questions). ⚠️ The dashboard no longer //! a peer agent (via `Answer`, for agent-to-agent questions).
//! has any UI or endpoint for the operator to actually answer a
//! `target IS NULL` row (removed along with the rest of the dashboard's
//! question surface) — see `questions.rs`'s module doc for the current
//! state of that gap.
//! //!
//! Despite the file name (kept for git history sanity), this table //! Despite the file name (kept for git history sanity), this table
//! now stores *all* asynchronous questions in the hive — both the //! now stores *all* asynchronous questions in the hive — both the
@ -18,8 +14,9 @@ use std::sync::Mutex;
use anyhow::{Context, Result, bail}; use anyhow::{Context, Result, bail};
use chrono::Utc; use chrono::{DateTime, Utc};
use rusqlite::{Connection, OptionalExtension, params}; use rusqlite::{Connection, OptionalExtension, params};
use serde::Serialize;
use crate::db::Migration; use crate::db::Migration;
@ -54,13 +51,36 @@ const MIGRATIONS: &[Migration] = &[
adds_column: Some(("operator_questions", "deadline_at")), adds_column: Some(("operator_questions", "deadline_at")),
}, },
// v3: `target` — recipient of the question. NULL = operator (back-compat // v3: `target` — recipient of the question. NULL = operator (back-compat
// default); non-null = peer-to-peer question. // default); non-null = peer-to-peer question. Dashboard's `pending()`
// filters on `target IS NULL` so peer questions never leak to the operator.
Migration { Migration {
sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT", sql: "ALTER TABLE operator_questions ADD COLUMN target TEXT",
adds_column: Some(("operator_questions", "target")), adds_column: Some(("operator_questions", "target")),
}, },
]; ];
#[derive(Debug, Clone, Serialize)]
pub struct OpQuestion {
pub id: i64,
pub asker: String,
pub question: String,
pub options: Vec<String>,
pub multi: bool,
pub asked_at: DateTime<Utc>,
/// 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<DateTime<Utc>>,
pub answered_at: Option<DateTime<Utc>>,
pub answer: Option<String>,
/// Recipient of the question. `None` = the operator (dashboard
/// path); `Some(<agent>)` = a peer agent asked via
/// `Ask { to: Some(<agent>), ... }`. Agent-to-agent questions
/// never appear in `pending()` so the operator's queue stays clean.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub target: Option<String>,
}
pub struct OperatorQuestions { pub struct OperatorQuestions {
conn: Mutex<Connection>, conn: Mutex<Connection>,
} }
@ -105,15 +125,13 @@ impl OperatorQuestions {
} }
/// Mark a pending question answered. `answerer` is who's actually /// Mark a pending question answered. `answerer` is who's actually
/// answering: `"operator"`, or an agent's own name when responding /// answering: `"operator"` for the dashboard path, or an agent's
/// via `Answer`. Authorisation: /// own name when responding via `Answer`. Authorisation:
/// ///
/// - Operator-targeted questions (`target IS NULL`) can only be /// - Operator-targeted questions (`target IS NULL`) can only be
/// answered by `"operator"`. (Agents must not be able to spoof /// answered by `"operator"`. (Agents must not be able to spoof
/// answers to operator questions — though as of the dashboard's /// answers to operator questions — the dashboard is the
/// ask/answer surface being removed, nothing currently calls /// privileged path.)
/// this with `answerer = "operator"` for a `target IS NULL` row
/// at all; the check stays as a guard, not a live path.)
/// - Agent-targeted questions can only be answered by the /// - Agent-targeted questions can only be answered by the
/// declared target agent, OR by `"operator"` (operator override /// declared target agent, OR by `"operator"` (operator override
/// for stuck threads — useful when an agent is offline/down /// for stuck threads — useful when an agent is offline/down
@ -213,4 +231,59 @@ impl OperatorQuestions {
)?; )?;
Ok((question, asker, target)) Ok((question, asker, target))
} }
/// Every pending question, operator-targeted or peer-to-peer.
/// Drives the dashboard's questions pane now that peer threads
/// are surfaced for visibility + operator override-answer.
pub fn pending_all(&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, deadline_at, target
FROM operator_questions
WHERE answered_at IS NULL
ORDER BY id ASC",
)?;
let rows = stmt.query_map([], row_to_question)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
/// Last `limit` answered questions across both target kinds,
/// newest-first. Companion to `pending_all`.
pub fn recent_answered_all(&self, limit: u64) -> 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, deadline_at, target
FROM operator_questions
WHERE answered_at IS NOT NULL
ORDER BY answered_at DESC
LIMIT ?1",
)?;
let limit_i = i64::try_from(limit).unwrap_or(i64::MAX);
let rows = stmt.query_map(params![limit_i], row_to_question)?;
rows.collect::<rusqlite::Result<Vec<_>>>()
.map_err(Into::into)
}
}
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,
multi: multi != 0,
asked_at: hive_sh4re::wire_time::from_secs(row.get(5)?),
answered_at: row
.get::<_, Option<i64>>(6)?
.map(hive_sh4re::wire_time::from_secs),
answer: row.get(7)?,
deadline_at: row
.get::<_, Option<i64>>(8)?
.map(hive_sh4re::wire_time::from_secs),
target: row.get(9)?,
})
} }