refactor(modules): extract approvals + interactive as registry-based modules

Phase 2 / PR #3 of the module refactor. Moves the approval and interactive-
question flows out of core and into src/modules/, wired through the response
dispatcher and delivery action registries.

New modules:
- src/modules/interactive/ — registers a response handler that claims
  pending_questions rows, writes question_response to the session DB, wakes
  the container. createPendingQuestion call stays inline in delivery.ts
  (guarded by hasTable) per plan.
- src/modules/approvals/ — registers 3 delivery actions (install_packages,
  request_rebuild, add_mcp_server), a response handler for pending_approvals
  (including OneCLI action fall-through), an adapter-ready hook that boots
  the OneCLI manual-approval handler, and a shutdown hook that stops it.
  OneCLI implementation (src/onecli-approvals.ts) moves into the module.

Core lifecycle hooks added (narrow, not registries):
- onDeliveryAdapterReady(cb) in delivery.ts — fires when setDeliveryAdapter
  runs (or immediately if already set). Used by approvals for OneCLI boot.
- onShutdown(cb) in index.ts — fires on SIGTERM/SIGINT. Used by approvals
  for OneCLI teardown.
- getDeliveryAdapter() getter in delivery.ts — for live-flow adapter access
  in registered delivery actions.

Core shrinks: delivery.ts 911 → 665 lines, index.ts 405 → 224 lines.
dispatchResponse now logs "Unclaimed response" instead of falling through
to an inline handler — the inline fallback moved into the two modules.

Migration files renamed to the module-<name>-<short>.ts convention:
- 003-pending-approvals.ts → module-approvals-pending-approvals.ts
- 007-pending-approvals-title-options.ts → module-approvals-title-options.ts
Migration.name fields unchanged so existing DBs treat them as already-applied.

Degradation verified: emptying the modules barrel builds clean and 137/137
tests pass. Actions would log "Unknown system action"; button clicks would
log "Unclaimed response".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
gavrielc
2026-04-18 15:16:53 +03:00
parent a612c2ca24
commit a4573395d9
15 changed files with 666 additions and 404 deletions

View File

@@ -0,0 +1,21 @@
## ask_user_question
Use `ask_user_question` when you need the user to pick from a small set of concrete options and you can't infer a reasonable default. This is a **blocking** call — your turn pauses until the user clicks or the timeout expires.
**When to use:**
- Confirming a destructive action ("Delete these 3 files?")
- Choosing between incompatible paths ("Keep their version or yours?")
- Gathering a required parameter that must be one of a known set
**When NOT to use:**
- Open-ended text input — just send a regular message asking.
- Yes/no confirmations where "no" is the safe default — just proceed and let the user interrupt.
- Anything you can work out from context.
**Arguments:**
- `title` (string) — short card header, e.g. "Confirm deletion"
- `question` (string) — the full question
- `options` (array) — each is either a plain string or `{ label, selectedLabel?, value? }`. `selectedLabel` replaces the button text after click; `value` is what gets returned to you
- `timeout` (number, seconds, default 300) — how long to wait before giving up
The response is the `value` (or label if no value set) of whichever option the user chose. On timeout you get an error and should proceed with a sensible default or tell the user you timed out.

View File

@@ -0,0 +1,55 @@
/**
* Interactive module — generic ask_user_question flow.
*
* Container-side `ask_user_question` writes a chat-sdk card to outbound.db +
* polls inbound.db for a `question_response` system message. On the host side
* this module handles the button-click response: look up the pending_questions
* row, write the response into the session's inbound.db, wake the container.
*
* The `createPendingQuestion` call in `deliverMessage` (delivery.ts) stays
* inline in core — it's 15 lines guarded by `hasTable('pending_questions')`,
* modularizing it adds more registry surface than it saves.
*/
import { getDb, hasTable } from '../../db/connection.js';
import { deletePendingQuestion, getPendingQuestion, getSession } from '../../db/sessions.js';
import { wakeContainer } from '../../container-runner.js';
import { registerResponseHandler, type ResponsePayload } from '../../index.js';
import { log } from '../../log.js';
import { writeSessionMessage } from '../../session-manager.js';
async function handleInteractiveResponse(payload: ResponsePayload): Promise<boolean> {
if (!hasTable(getDb(), 'pending_questions')) return false;
const pq = getPendingQuestion(payload.questionId);
if (!pq) return false;
const session = getSession(pq.session_id);
if (!session) {
log.warn('Session not found for pending question', { questionId: payload.questionId, sessionId: pq.session_id });
deletePendingQuestion(payload.questionId);
return true; // claimed — we owned this questionId even though the session is gone
}
writeSessionMessage(session.agent_group_id, session.id, {
id: `qr-${payload.questionId}-${Date.now()}`,
kind: 'system',
timestamp: new Date().toISOString(),
platformId: pq.platform_id,
channelType: pq.channel_type,
threadId: pq.thread_id,
content: JSON.stringify({
type: 'question_response',
questionId: payload.questionId,
selectedOption: payload.value,
userId: payload.userId ?? '',
}),
});
deletePendingQuestion(payload.questionId);
log.info('Question response routed', { questionId: payload.questionId, selectedOption: payload.value, sessionId: session.id });
await wakeContainer(session);
return true;
}
registerResponseHandler(handleInteractiveResponse);

View File

@@ -0,0 +1,12 @@
## Interactive module
Generic ask_user_question flow. Lives in `src/modules/interactive/`.
The container-side MCP tool `ask_user_question` writes a chat-sdk card to outbound.db and polls inbound.db for a `question_response` system message. The host side of this is split:
- **Inline in `src/delivery.ts`:** the `deliverMessage` path intercepts `content.type === 'ask_question'` messages and writes a row to `pending_questions`. Guarded by `hasTable(db, 'pending_questions')`.
- **This module:** registers a `ResponseHandler` that runs when a button-click arrives via the channel adapter's `onAction`. It looks up the `pending_questions` row, writes a `question_response` system message into the session's inbound.db, wakes the container.
The `pending_questions` table is in the core `001-initial.ts` migration — the module doesn't own the schema, just the behavior. Removing the module disables the button-click response path only; cards are still delivered.
`getAskQuestionRender` in `src/db/sessions.ts` resolves card render metadata for `chat-sdk-bridge.ts`. It reads both `pending_questions` and `pending_approvals` and degrades via `hasTable`. Stays in core.