"""Transport interface ABC + payload dataclasses (design §3.3.1). The durable human-in-the-loop responder owns a notify+resume seam that is transport-agnostic. This module defines the contract every adapter implements: * :class:`Transport` — abstract base with ``post_question`` (deliver a question-set, return a ``channel_ref`` that embeds the ``question_id``) and ``parse_answer`` (normalize an inbound raw payload to ``(question_id, answer, via)``). * :class:`QuestionSet` — the question-set payload carried by a LangGraph ``interrupt()``. * :class:`NormalizedAnswer` — the normalized inbound answer the responder feeds into the §3.3.1 first-answer-wins compare-and-set. Concrete Slack / GitHub / Claude-Code adapters subclass :class:`Transport` in the leaves. The signatures here are CONTRACTS the leaf builders import verbatim, so they are explicit and final. """ from __future__ import annotations from abc import ABC, abstractmethod from dataclasses import dataclass, field from typing import Any __all__ = [ "NormalizedAnswer", "QuestionSet", "Transport", ] # Marker template embedded in transports without native callback metadata # (e.g. a GitHub issue comment), so an inbound answer can be mapped back to its # question. Slack embeds the question_id in ``callback_id`` instead. GITHUB_MARKER_TEMPLATE = "" @dataclass class QuestionSet: """A set of questions delivered to Adam for one ``turn`` of a task (§3.3.1). Carried in the ``interrupt()`` payload alongside ``thread_id``, ``question_id``, ``turn``, ``transport``, and ``deadline``. ``questions`` is the ordered list of prompts; ``context`` is optional rendering metadata (repo, summary) the adapter may surface. """ thread_id: str question_id: str turn: int questions: list[str] context: dict[str, Any] = field(default_factory=dict) @dataclass class NormalizedAnswer: """A transport-normalized inbound answer (§3.3.1). The responder maps this into the first-answer-wins compare-and-set: ``UPDATE ... SET status='answered' ... WHERE question_id=? AND status='open'``. ``via`` records the answering channel/identity for the audit trail (``answered_via``). """ question_id: str answer: Any via: str class Transport(ABC): """Abstract transport adapter (§3.3.1). Subclasses implement delivery and answer parsing for one channel. The ledger and resume worker depend only on this interface, so an adapter can ship first (Slack) and others follow without touching the durable core. """ @abstractmethod def post_question( self, *, thread_id: str, question_id: str, turn: int, question_set: QuestionSet, deadline: str, ) -> str: """Deliver ``question_set`` and return its ``channel_ref``. The posted message MUST embed ``question_id`` so an inbound answer can be mapped back (Slack ``callback_id``; a ```` marker in a GitHub comment). The returned ``channel_ref`` is the transport's locator for the post (Slack message ``ts`` / issue-comment id / Claude session id) and is stored on the ledger row so reconcile/recovery can act on it (§3.3.1). """ raise NotImplementedError @abstractmethod def parse_answer(self, raw: Any) -> tuple[str, Any, str]: """Normalize an inbound ``raw`` payload to ``(question_id, answer, via)``. Extracts the embedded ``question_id`` (from the Slack ``callback_id`` / the GitHub marker / the Claude session), the answer value, and the ``via`` channel identity. The responder feeds the result into the atomic compare-and-set. Implementations may build a :class:`NormalizedAnswer` internally and return its fields as the tuple the contract specifies. """ raise NotImplementedError