Files
nanoclaw/src/modules/interactive/index.ts
gavrielc a4573395d9 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>
2026-04-18 15:16:53 +03:00

56 lines
2.2 KiB
TypeScript

/**
* 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);