diff --git a/TODO.md b/TODO.md index 6410b53..58013ad 100644 --- a/TODO.md +++ b/TODO.md @@ -70,12 +70,13 @@ Pick anything from here when relevant. Cross-cutting design notes live in ## Manager → operator question channel -- **TTL / cancel on `ask_operator`.** Questions today block forever; the - manager turn stays alive until the operator answers. Add a per-question - `ttl_seconds` (or a dashboard "cancel" button that resolves the question - with a sentinel answer) so a long-idle question can time out and let the - manager fall back. Wire the timeout into `OperatorQuestions::wait_answered` - and surface remaining-time on the dashboard. +- **TTL on `ask_operator`.** Manual cancel via dashboard already + ships (✗ CANC3L button resolves the question with answer + `[cancelled]` and fires `OperatorAnswered` so the manager sees a + terminal state). Still missing: per-question `ttl_seconds` that + auto-cancels after a deadline. Spawn a tokio task per submitted + question that calls the same cancel path after the ttl expires + (cheap; rare). Surface remaining time on the dashboard. ## Spawn flow diff --git a/hive-c0re/assets/app.js b/hive-c0re/assets/app.js index 889aedd..feb1926 100644 --- a/hive-c0re/assets/app.js +++ b/hive-c0re/assets/app.js @@ -284,12 +284,28 @@ 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), + const buttons = el('div', { class: 'q-buttons' }); + buttons.append( el('button', { type: 'submit', class: 'btn btn-approve' }, isMulti ? '▸ ANSW3R · ' + (q.options.length) + ' opts' : '▸ ANSW3R'), ); + f.append( + el('div', { class: 'q-free' }, freeText), + buttons, + ); 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); } root.append(ul); diff --git a/hive-c0re/assets/dashboard.css b/hive-c0re/assets/dashboard.css index 1e0792a..5fc4c4e 100644 --- a/hive-c0re/assets/dashboard.css +++ b/hive-c0re/assets/dashboard.css @@ -332,6 +332,7 @@ summary:hover { color: var(--purple); } .qform .q-free input::placeholder { color: var(--muted); } .qform .q-free input:focus { outline: 1px solid var(--amber); } .qform button { align-self: flex-start; } +.qform-cancel { margin-top: 0.3em; } .inbox { background: var(--bg-elev); border: 1px solid var(--border); diff --git a/hive-c0re/src/dashboard.rs b/hive-c0re/src/dashboard.rs index eed42cd..0188fd7 100644 --- a/hive-c0re/src/dashboard.rs +++ b/hive-c0re/src/dashboard.rs @@ -51,6 +51,7 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { .route("/rebuild/{name}", post(post_rebuild)) .route("/update-all", post(post_update_all)) .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("/request-spawn", post(post_request_spawn)) .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, + AxumPath(id): AxumPath, +) -> 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( State(state): State, AxumPath(name): AxumPath,