dashboard: surface peer questions + operator override
questions pane now shows both operator-targeted threads (target IS NULL) and agent-to-agent threads (target = some agent). filter chips above the list: all / @operator / @peer / per-participant. peer rows get a mauve left rule + a 0V3RR1D3 button that POSTs the same /answer-question endpoint (OperatorQuestions::answer already permits the operator as answerer on any target). wire changes: OperatorQuestions gains pending_all + recent_answered_all; QuestionAdded + QuestionResolved events carry target: Option<String>; emit sites drop their target.is_none() guard. answered-history rows show the answerer prefix so override answers are auditable at a glance.
This commit is contained in:
parent
e7ce35c503
commit
a15fafb5de
9 changed files with 187 additions and 71 deletions
|
|
@ -243,11 +243,10 @@ impl Coordinator {
|
|||
});
|
||||
}
|
||||
|
||||
/// Emit `QuestionAdded` after an operator-targeted question is
|
||||
/// inserted. Peer-to-peer questions (those with a non-null
|
||||
/// `target` agent) never fire this — they don't surface on the
|
||||
/// dashboard at all. Caller is responsible for the
|
||||
/// `target.is_none()` guard.
|
||||
/// 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,
|
||||
id: i64,
|
||||
|
|
@ -256,6 +255,7 @@ impl Coordinator {
|
|||
options: &[String],
|
||||
multi: bool,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<&str>,
|
||||
) {
|
||||
let asked_at = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -271,20 +271,22 @@ impl Coordinator {
|
|||
multi,
|
||||
asked_at,
|
||||
deadline_at,
|
||||
target: target.map(str::to_owned),
|
||||
});
|
||||
}
|
||||
|
||||
/// Emit `QuestionResolved` when an operator-targeted question
|
||||
/// transitions to answered (operator answer, peer override,
|
||||
/// cancel, or ttl watchdog). Caller filters on the original
|
||||
/// question's `target.is_none()` — peer questions are dashboard-
|
||||
/// invisible.
|
||||
/// 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)
|
||||
|
|
@ -298,6 +300,7 @@ impl Coordinator {
|
|||
answerer: answerer.to_owned(),
|
||||
answered_at,
|
||||
cancelled,
|
||||
target: target.map(str::to_owned),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -307,9 +307,13 @@ async fn api_state(headers: HeaderMap, State(state): State<AppState>) -> axum::J
|
|||
// operator_inbox used to be served here as a 50-row array; the
|
||||
// dashboard now derives it client-side from the message stream
|
||||
// (terminal backfill + live SSE), so the snapshot stops shipping it.
|
||||
let questions = log_default("questions.pending", state.coord.questions.pending());
|
||||
let question_history =
|
||||
log_default("questions.recent_answered", state.coord.questions.recent_answered(20));
|
||||
// Both operator-targeted and peer threads now surface on the
|
||||
// dashboard. Client filters by target client-side.
|
||||
let questions = log_default("questions.pending_all", state.coord.questions.pending_all());
|
||||
let question_history = log_default(
|
||||
"questions.recent_answered_all",
|
||||
state.coord.questions.recent_answered_all(20),
|
||||
);
|
||||
|
||||
axum::Json(StateSnapshot {
|
||||
seq,
|
||||
|
|
@ -734,14 +738,13 @@ async fn post_answer_question(
|
|||
answerer: hive_sh4re::OPERATOR_RECIPIENT.to_owned(),
|
||||
},
|
||||
);
|
||||
if target.is_none() {
|
||||
state.coord.emit_question_resolved(
|
||||
id,
|
||||
answer,
|
||||
hive_sh4re::OPERATOR_RECIPIENT,
|
||||
false,
|
||||
);
|
||||
}
|
||||
state.coord.emit_question_resolved(
|
||||
id,
|
||||
answer,
|
||||
hive_sh4re::OPERATOR_RECIPIENT,
|
||||
false,
|
||||
target.as_deref(),
|
||||
);
|
||||
(StatusCode::OK, "ok").into_response()
|
||||
}
|
||||
Err(e) => error_response(&format!("answer {id} failed: {e:#}")),
|
||||
|
|
@ -766,14 +769,13 @@ async fn post_cancel_question(
|
|||
{
|
||||
Ok((question, asker, target)) => {
|
||||
tracing::info!(%id, %asker, "operator cancelled question");
|
||||
if target.is_none() {
|
||||
state.coord.emit_question_resolved(
|
||||
id,
|
||||
SENTINEL,
|
||||
hive_sh4re::OPERATOR_RECIPIENT,
|
||||
true,
|
||||
);
|
||||
}
|
||||
state.coord.emit_question_resolved(
|
||||
id,
|
||||
SENTINEL,
|
||||
hive_sh4re::OPERATOR_RECIPIENT,
|
||||
true,
|
||||
target.as_deref(),
|
||||
);
|
||||
state.coord.notify_agent(
|
||||
&asker,
|
||||
&hive_sh4re::HelperEvent::QuestionAnswered {
|
||||
|
|
|
|||
|
|
@ -79,11 +79,11 @@ pub enum DashboardEvent {
|
|||
note: Option<String>,
|
||||
description: Option<String>,
|
||||
},
|
||||
/// An operator-targeted question landed in the queue
|
||||
/// (`Ask { to: None | Some("operator") }`). Peer-to-peer
|
||||
/// questions (target = Some(<agent>)) never fire this event —
|
||||
/// the dashboard only ever shows operator-bound questions, so
|
||||
/// the emit site filters on `target.is_none()`.
|
||||
/// 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,
|
||||
|
|
@ -93,12 +93,13 @@ pub enum DashboardEvent {
|
|||
multi: bool,
|
||||
asked_at: i64,
|
||||
deadline_at: Option<i64>,
|
||||
target: Option<String>,
|
||||
},
|
||||
/// An operator-targeted question was answered (operator answer,
|
||||
/// peer override, or ttl watchdog `[expired]`). Clients move the
|
||||
/// row from pending to history. `cancelled = true` when the
|
||||
/// operator dismissed via the cancel button — same code path on
|
||||
/// the server but useful to surface differently in the UI.
|
||||
/// 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,
|
||||
|
|
@ -106,6 +107,7 @@ pub enum DashboardEvent {
|
|||
answerer: String,
|
||||
answered_at: i64,
|
||||
cancelled: bool,
|
||||
target: Option<String>,
|
||||
},
|
||||
/// A lifecycle action started for an agent (spawn / start / stop
|
||||
/// / restart / rebuild / destroy). Clients render a spinner next
|
||||
|
|
|
|||
|
|
@ -454,9 +454,7 @@ pub fn spawn_question_watchdog(coord: &Arc<Coordinator>, id: i64, ttl_secs: u64)
|
|||
answerer: TTL_ANSWERER.to_owned(),
|
||||
},
|
||||
);
|
||||
if target.is_none() {
|
||||
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false);
|
||||
}
|
||||
coord.emit_question_resolved(id, TTL_SENTINEL, TTL_ANSWERER, false, target.as_deref());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -209,15 +209,15 @@ impl OperatorQuestions {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Pending operator-targeted questions only (`target IS NULL`).
|
||||
/// Drives the dashboard's pending-question pane — agent-to-agent
|
||||
/// questions never appear here so the operator's queue stays clean.
|
||||
pub fn pending(&self) -> Result<Vec<OpQuestion>> {
|
||||
/// 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 AND target IS NULL
|
||||
WHERE answered_at IS NULL
|
||||
ORDER BY id ASC",
|
||||
)?;
|
||||
let rows = stmt.query_map([], row_to_question)?;
|
||||
|
|
@ -225,15 +225,14 @@ impl OperatorQuestions {
|
|||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
/// Last `limit` answered operator-targeted questions, newest-first.
|
||||
/// Same `target IS NULL` filter as `pending()` so the dashboard's
|
||||
/// history view only shows operator-relevant rows.
|
||||
pub fn recent_answered(&self, limit: u64) -> Result<Vec<OpQuestion>> {
|
||||
/// 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 AND target IS NULL
|
||||
WHERE answered_at IS NOT NULL
|
||||
ORDER BY answered_at DESC
|
||||
LIMIT ?1",
|
||||
)?;
|
||||
|
|
@ -241,6 +240,7 @@ impl OperatorQuestions {
|
|||
rows.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(Into::into)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
fn row_to_question(row: &rusqlite::Row<'_>) -> rusqlite::Result<OpQuestion> {
|
||||
|
|
|
|||
|
|
@ -86,9 +86,10 @@ pub fn handle_ask(
|
|||
multi,
|
||||
},
|
||||
);
|
||||
} else {
|
||||
coord.emit_question_added(id, asker, question, options, multi, deadline_at);
|
||||
}
|
||||
// Always fire on the dashboard channel — both operator-targeted
|
||||
// and peer threads now surface in the dashboard's questions pane.
|
||||
coord.emit_question_added(id, asker, question, options, multi, deadline_at, target);
|
||||
if let Some(t) = ttl {
|
||||
spawn_question_watchdog(coord, id, t);
|
||||
}
|
||||
|
|
@ -120,13 +121,11 @@ pub fn handle_answer(
|
|||
answerer: answerer.to_owned(),
|
||||
},
|
||||
);
|
||||
// Only operator-targeted questions surface on the dashboard;
|
||||
// peer-to-peer answers are invisible to it. `cancelled = false`
|
||||
// because this path is a real answer (operator cancel goes
|
||||
// through `post_cancel_question` directly).
|
||||
if target.is_none() {
|
||||
coord.emit_question_resolved(id, answer, answerer, false);
|
||||
}
|
||||
// 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(())
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue