ask_operator: operator-side ✗ CANC3L on pending questions
new POST /cancel-question/{id} resolves a pending operator question
with the sentinel answer '[cancelled]' and fires the usual
HelperEvent::OperatorAnswered so the manager sees a terminal state
and can fall back. uses the same OperatorQuestions::answer path —
no special handling, the manager already has to deal with arbitrary
answer strings.
dashboard renders the cancel as a separate <form> below the main
qform so the answer-merge submit handler on the main form doesn't
inadvertently fire when the operator clicks cancel. confirm dialog
spells out what the manager will see.
ttl-based auto-cancel is still on the todo (would spawn a tokio task
per submitted question).
This commit is contained in:
parent
bc87ff80d2
commit
ee5b85716d
4 changed files with 54 additions and 8 deletions
13
TODO.md
13
TODO.md
|
|
@ -70,12 +70,13 @@ Pick anything from here when relevant. Cross-cutting design notes live in
|
||||||
|
|
||||||
## Manager → operator question channel
|
## Manager → operator question channel
|
||||||
|
|
||||||
- **TTL / cancel on `ask_operator`.** Questions today block forever; the
|
- **TTL on `ask_operator`.** Manual cancel via dashboard already
|
||||||
manager turn stays alive until the operator answers. Add a per-question
|
ships (✗ CANC3L button resolves the question with answer
|
||||||
`ttl_seconds` (or a dashboard "cancel" button that resolves the question
|
`[cancelled]` and fires `OperatorAnswered` so the manager sees a
|
||||||
with a sentinel answer) so a long-idle question can time out and let the
|
terminal state). Still missing: per-question `ttl_seconds` that
|
||||||
manager fall back. Wire the timeout into `OperatorQuestions::wait_answered`
|
auto-cancels after a deadline. Spawn a tokio task per submitted
|
||||||
and surface remaining-time on the dashboard.
|
question that calls the same cancel path after the ttl expires
|
||||||
|
(cheap; rare). Surface remaining time on the dashboard.
|
||||||
|
|
||||||
## Spawn flow
|
## Spawn flow
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -284,12 +284,28 @@
|
||||||
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
if (!merged) { ev.preventDefault(); alert('pick an option or type an answer'); }
|
||||||
}, true);
|
}, true);
|
||||||
if (hasOptions) f.append(optionGroup);
|
if (hasOptions) f.append(optionGroup);
|
||||||
f.append(
|
const buttons = el('div', { class: 'q-buttons' });
|
||||||
el('div', { class: 'q-free' }, freeText),
|
buttons.append(
|
||||||
el('button', { type: 'submit', class: 'btn btn-approve' },
|
el('button', { type: 'submit', class: 'btn btn-approve' },
|
||||||
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
|
isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'),
|
||||||
);
|
);
|
||||||
|
f.append(
|
||||||
|
el('div', { class: 'q-free' }, freeText),
|
||||||
|
buttons,
|
||||||
|
);
|
||||||
li.append(f);
|
li.append(f);
|
||||||
|
// Separate form so the cancel button doesn't get the answer
|
||||||
|
// merge-on-submit handler attached to the main form.
|
||||||
|
const cancelForm = el('form', {
|
||||||
|
method: 'POST', action: '/cancel-question/' + q.id,
|
||||||
|
class: 'qform-cancel', 'data-async': '',
|
||||||
|
'data-confirm': 'cancel this question? manager will see '
|
||||||
|
+ '"[cancelled]" as the answer.',
|
||||||
|
});
|
||||||
|
cancelForm.append(
|
||||||
|
el('button', { type: 'submit', class: 'btn btn-deny' }, '✗ CANC3L'),
|
||||||
|
);
|
||||||
|
li.append(cancelForm);
|
||||||
ul.append(li);
|
ul.append(li);
|
||||||
}
|
}
|
||||||
root.append(ul);
|
root.append(ul);
|
||||||
|
|
|
||||||
|
|
@ -332,6 +332,7 @@ summary:hover { color: var(--purple); }
|
||||||
.qform .q-free input::placeholder { color: var(--muted); }
|
.qform .q-free input::placeholder { color: var(--muted); }
|
||||||
.qform .q-free input:focus { outline: 1px solid var(--amber); }
|
.qform .q-free input:focus { outline: 1px solid var(--amber); }
|
||||||
.qform button { align-self: flex-start; }
|
.qform button { align-self: flex-start; }
|
||||||
|
.qform-cancel { margin-top: 0.3em; }
|
||||||
.inbox {
|
.inbox {
|
||||||
background: var(--bg-elev);
|
background: var(--bg-elev);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc<Coordinator>) -> Result<()> {
|
||||||
.route("/rebuild/{name}", post(post_rebuild))
|
.route("/rebuild/{name}", post(post_rebuild))
|
||||||
.route("/update-all", post(post_update_all))
|
.route("/update-all", post(post_update_all))
|
||||||
.route("/answer-question/{id}", post(post_answer_question))
|
.route("/answer-question/{id}", post(post_answer_question))
|
||||||
|
.route("/cancel-question/{id}", post(post_cancel_question))
|
||||||
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
.route("/purge-tombstone/{name}", post(post_purge_tombstone))
|
||||||
.route("/request-spawn", post(post_request_spawn))
|
.route("/request-spawn", post(post_request_spawn))
|
||||||
.route("/messages/stream", get(messages_stream))
|
.route("/messages/stream", get(messages_stream))
|
||||||
|
|
@ -417,6 +418,33 @@ async fn post_answer_question(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a pending operator question with a sentinel answer when
|
||||||
|
/// the operator decides not to / can't answer. The manager harness
|
||||||
|
/// receives an `OperatorAnswered` event with `answer = "[cancelled]"`
|
||||||
|
/// so it can fall back on whatever default it had. Same code path as
|
||||||
|
/// a real answer — just lets the operator close the loop instead of
|
||||||
|
/// letting the question dangle forever.
|
||||||
|
async fn post_cancel_question(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
AxumPath(id): AxumPath<i64>,
|
||||||
|
) -> Response {
|
||||||
|
const SENTINEL: &str = "[cancelled]";
|
||||||
|
match state.coord.questions.answer(id, SENTINEL) {
|
||||||
|
Ok(question) => {
|
||||||
|
tracing::info!(%id, "operator cancelled question");
|
||||||
|
state
|
||||||
|
.coord
|
||||||
|
.notify_manager(&hive_sh4re::HelperEvent::OperatorAnswered {
|
||||||
|
id,
|
||||||
|
question,
|
||||||
|
answer: SENTINEL.to_owned(),
|
||||||
|
});
|
||||||
|
Redirect::to("/").into_response()
|
||||||
|
}
|
||||||
|
Err(e) => error_response(&format!("cancel-question {id} failed: {e:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async fn post_purge_tombstone(
|
async fn post_purge_tombstone(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
AxumPath(name): AxumPath<String>,
|
AxumPath(name): AxumPath<String>,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue