glas-harness/docs/channel-contract.md
tegwick d3130162b1
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Formalize the CLI as the first Channel extension (GLAS-WP-0003)
channels/base.py: GatewayInvocation dataclass + Channel ABC
(parse_invocation/render_result), mirroring Rein one layer up -- a
channel turns an external invocation into what run_task_through_rein
needs, and turns its result back into whatever the invocation medium
expects.

channels/cli_channel.py: CLIChannel, a pure refactor of what cli.py did
inline -- argparse Namespace -> GatewayInvocation, result dict ->
json.dumps(..., indent=2). cli.py's run handler is now a thin
construct-parse-invoke-render wrapper.

docs/channel-contract.md documents the pattern and sketches (without
building) a second channel.

Found and fixed a real bug while live-testing this refactor:
ReinAharness's default cli_bin was still "agent-harness", stale from
before HARNESS-WP-0002-T02's rename.

4 new tests (27/27 passing), plus a real live run through
python3 -m glas_harness.cli against rein-aharness/real Claude Code
confirming identical output shape and a real commit.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-26 22:41:51 +02:00

3.3 KiB

Channel contract

How an external invocation reaches the gateway, per GLAS-WP-0003. Mirrors docs/harness-contract.md's Rein at a different layer: Rein governs how a session runs once glas-harness has decided to run one; a Channel governs how an external caller (CLI today; a future Slack message, HTTP request, or scheduled trigger) gets translated into that decision in the first place, and how the result gets translated back.

Interface

@dataclass
class GatewayInvocation:
    """Everything run_task_through_rein needs, channel-agnostic."""
    sandbox_profile: str
    repo: str
    title: str
    description: str
    actor: str = "agt"
    project: str = "glas-harness"
    report_to_hub: bool = True


class Channel(ABC):
    """Base class for concrete invocation channels (CLI, Slack, HTTP, ...)."""

    @abstractmethod
    def parse_invocation(self, raw: Any) -> GatewayInvocation:
        """Turn a channel-specific invocation into a GatewayInvocation."""

    @abstractmethod
    def render_result(self, result: dict[str, Any]) -> Any:
        """Turn a gateway result dict into whatever this channel returns."""

src/glas_harness/channels/base.py. A channel does not choose the rein or run the gateway itself — cli.py's main() still does that, calling parse_invocation/render_result around the existing run_task_through_rein call. Keeping channel construction and gateway invocation separate (rather than folding gateway-calling into the Channel ABC itself) is what lets CLIChannel be a pure input/output mapper, easy to unit test without touching sand-boxer or a rein at all.

CLIChannel — the first, and so far only, implementation

src/glas_harness/channels/cli_channel.py. Maps an argparse Namespace to GatewayInvocation (one field each, --no-hub inverts to report_to_hub) and renders a result dict as json.dumps(result, indent=2) — exactly what cli.py did inline before this existed. This was a refactor, not a behavior change: verified with a real live run through python3 -m glas_harness.cli run ... producing byte-identical output shape to the pre-refactor version, plus unit tests for the mapping in isolation (tests/test_cli_channel.py).

Adding a second channel (not done — pattern only)

No second channel exists yet, and none should be built speculatively — same discipline as ADR-002/ADR-004 (generalize from a real second example, not in anticipation of one). If/when one is needed, e.g. a Slack channel:

  1. Implement Channel in src/glas_harness/channels/slack_channel.py: parse_invocation turns a Slack event payload into a GatewayInvocation (message text → title/description, a configured default sandbox_profile, the Slack user id as actor or project); render_result turns the result dict into a Slack message (success/failure, commit sha if present).
  2. Whatever process receives Slack events constructs the channel and calls run_task_through_rein the same way cli.py does — no change needed to gateway.py, contract.py, or any rein.
  3. If two real channels turn out to share invocation-parsing logic (e.g. both need the same sandbox_profile defaulting rule), factor that shared piece out then — not before, and not into the Channel ABC itself unless every channel needs it.