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>
This commit is contained in:
parent
a87b924126
commit
d3130162b1
9 changed files with 251 additions and 17 deletions
|
|
@ -10,7 +10,8 @@
|
||||||
| --- | --- | --- | --- | --- |
|
| --- | --- | --- | --- | --- |
|
||||||
| workplan | GLAS-0001 | active | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
| workplan | GLAS-0001 | active | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||||
| workplan | GLAS-WP-0001 | active | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
| workplan | GLAS-WP-0001 | active | — | workplans/GLAS-WP-0001-harness-router-foundation.md |
|
||||||
| workplan | GLAS-WP-0002 | proposed | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
| workplan | GLAS-WP-0002 | active | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
||||||
|
| workplan | GLAS-WP-0003 | proposed | — | workplans/GLAS-WP-0003-first-channel-extension.md |
|
||||||
| task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
| task | GLAS-0001-T01 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||||
| task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
| task | GLAS-0001-T02 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||||
| task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
| task | GLAS-0001-T03 | done | — | workplans/GLAS-0001-statehub-bootstrap.md |
|
||||||
|
|
@ -23,4 +24,8 @@
|
||||||
| task | GLAS-WP-0002-T01 | done | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
| task | GLAS-WP-0002-T01 | done | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
||||||
| task | GLAS-WP-0002-T02 | wait | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
| task | GLAS-WP-0002-T02 | wait | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
||||||
| task | GLAS-WP-0002-T03 | done | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
| task | GLAS-WP-0002-T03 | done | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
||||||
| task | GLAS-WP-0002-T04 | todo | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
| task | GLAS-WP-0002-T04 | done | — | workplans/GLAS-WP-0002-observability-and-composability-followups.md |
|
||||||
|
| task | GLAS-WP-0003-T01 | todo | — | workplans/GLAS-WP-0003-first-channel-extension.md |
|
||||||
|
| task | GLAS-WP-0003-T02 | todo | — | workplans/GLAS-WP-0003-first-channel-extension.md |
|
||||||
|
| task | GLAS-WP-0003-T03 | todo | — | workplans/GLAS-WP-0003-first-channel-extension.md |
|
||||||
|
| task | GLAS-WP-0003-T04 | todo | — | workplans/GLAS-WP-0003-first-channel-extension.md |
|
||||||
|
|
|
||||||
76
docs/channel-contract.md
Normal file
76
docs/channel-contract.md
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
# 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
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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.
|
||||||
0
src/glas_harness/channels/__init__.py
Normal file
0
src/glas_harness/channels/__init__.py
Normal file
38
src/glas_harness/channels/base.py
Normal file
38
src/glas_harness/channels/base.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""The channel contract — how an external invocation reaches the gateway.
|
||||||
|
|
||||||
|
See docs/channel-contract.md (GLAS-WP-0003). A Channel turns an external
|
||||||
|
invocation (CLI args today; a Slack message or HTTP request for a future
|
||||||
|
channel) into the inputs run_task_through_rein needs, and turns its
|
||||||
|
result back into whatever the invocation medium expects.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
@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."""
|
||||||
31
src/glas_harness/channels/cli_channel.py
Normal file
31
src/glas_harness/channels/cli_channel.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""CLIChannel — the first Channel extension, formalizing what cli.py already did.
|
||||||
|
|
||||||
|
GLAS-WP-0003: this is a refactor, not a behavior change. cli.py's
|
||||||
|
argparse Namespace -> GatewayInvocation mapping and the
|
||||||
|
json.dumps(..., indent=2) output are exactly what cli.py did inline
|
||||||
|
before this existed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from glas_harness.channels.base import Channel, GatewayInvocation
|
||||||
|
|
||||||
|
|
||||||
|
class CLIChannel(Channel):
|
||||||
|
def parse_invocation(self, raw: argparse.Namespace) -> GatewayInvocation:
|
||||||
|
return GatewayInvocation(
|
||||||
|
sandbox_profile=raw.sandbox_profile,
|
||||||
|
repo=raw.repo,
|
||||||
|
title=raw.title,
|
||||||
|
description=raw.description,
|
||||||
|
actor=raw.actor,
|
||||||
|
project=raw.project,
|
||||||
|
report_to_hub=not raw.no_hub,
|
||||||
|
)
|
||||||
|
|
||||||
|
def render_result(self, result: dict[str, Any]) -> str:
|
||||||
|
return json.dumps(result, indent=2)
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
"""glas-harness CLI — minimal gateway invocation (GLAS-WP-0001-T04)."""
|
"""glas-harness CLI — thin wrapper around CLIChannel (GLAS-WP-0003)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
import json
|
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -23,18 +22,21 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
if args.command == "run":
|
if args.command == "run":
|
||||||
|
from glas_harness.channels.cli_channel import CLIChannel
|
||||||
from glas_harness.gateway import run_task_through_rein
|
from glas_harness.gateway import run_task_through_rein
|
||||||
|
|
||||||
|
channel = CLIChannel()
|
||||||
|
invocation = channel.parse_invocation(args)
|
||||||
result = run_task_through_rein(
|
result = run_task_through_rein(
|
||||||
sandbox_profile=args.sandbox_profile,
|
sandbox_profile=invocation.sandbox_profile,
|
||||||
repo=args.repo,
|
repo=invocation.repo,
|
||||||
title=args.title,
|
title=invocation.title,
|
||||||
description=args.description,
|
description=invocation.description,
|
||||||
actor=args.actor,
|
actor=invocation.actor,
|
||||||
project=args.project,
|
project=invocation.project,
|
||||||
report_to_hub=not args.no_hub,
|
report_to_hub=invocation.report_to_hub,
|
||||||
)
|
)
|
||||||
print(json.dumps(result, indent=2))
|
print(channel.render_result(result))
|
||||||
return 0 if result["tool_ok"] else 1
|
return 0 if result["tool_ok"] else 1
|
||||||
|
|
||||||
return 1
|
return 1
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ class ReinAharnessNotInstalled(RuntimeError):
|
||||||
|
|
||||||
|
|
||||||
class ReinAharness(Rein):
|
class ReinAharness(Rein):
|
||||||
def __init__(self, cli_bin: str = "agent-harness", stream_tool_events: bool = False) -> None:
|
def __init__(self, cli_bin: str = "rein-aharness", stream_tool_events: bool = False) -> None:
|
||||||
self.cli_bin = cli_bin
|
self.cli_bin = cli_bin
|
||||||
self.stream_tool_events = stream_tool_events
|
self.stream_tool_events = stream_tool_events
|
||||||
|
|
||||||
|
|
|
||||||
54
tests/test_cli_channel.py
Normal file
54
tests/test_cli_channel.py
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
|
||||||
|
from glas_harness.channels.base import Channel, GatewayInvocation
|
||||||
|
from glas_harness.channels.cli_channel import CLIChannel
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_channel_is_a_channel() -> None:
|
||||||
|
assert issubclass(CLIChannel, Channel)
|
||||||
|
|
||||||
|
|
||||||
|
def _args(**overrides) -> argparse.Namespace:
|
||||||
|
defaults = dict(
|
||||||
|
sandbox_profile="profile.bwrap-local",
|
||||||
|
repo="/tmp/repo",
|
||||||
|
title="t",
|
||||||
|
description="d",
|
||||||
|
actor="agt",
|
||||||
|
project="glas-harness",
|
||||||
|
no_hub=False,
|
||||||
|
)
|
||||||
|
defaults.update(overrides)
|
||||||
|
return argparse.Namespace(**defaults)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_invocation_maps_all_fields() -> None:
|
||||||
|
channel = CLIChannel()
|
||||||
|
invocation = channel.parse_invocation(_args())
|
||||||
|
|
||||||
|
assert invocation == GatewayInvocation(
|
||||||
|
sandbox_profile="profile.bwrap-local",
|
||||||
|
repo="/tmp/repo",
|
||||||
|
title="t",
|
||||||
|
description="d",
|
||||||
|
actor="agt",
|
||||||
|
project="glas-harness",
|
||||||
|
report_to_hub=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_invocation_inverts_no_hub_flag() -> None:
|
||||||
|
channel = CLIChannel()
|
||||||
|
invocation = channel.parse_invocation(_args(no_hub=True))
|
||||||
|
assert invocation.report_to_hub is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_result_matches_pre_refactor_output() -> None:
|
||||||
|
channel = CLIChannel()
|
||||||
|
result = {"sandbox_id": "sbx1", "tool_ok": True, "summary": {"committed": "True"}}
|
||||||
|
|
||||||
|
rendered = channel.render_result(result)
|
||||||
|
|
||||||
|
assert rendered == json.dumps(result, indent=2)
|
||||||
|
assert json.loads(rendered) == result
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
id: GLAS-WP-0003
|
id: GLAS-WP-0003
|
||||||
title: "First channel extension: formalize the CLI channel contract"
|
title: "First channel extension: formalize the CLI channel contract"
|
||||||
status: proposed
|
status: proposed
|
||||||
|
state_hub_workstream_id: "78da8b34-27dd-4807-b7c8-321328dd2db8"
|
||||||
---
|
---
|
||||||
|
|
||||||
Scoped by `GLAS-WP-0002-T04`: of glas-harness's four charter pillars
|
Scoped by `GLAS-WP-0002-T04`: of glas-harness's four charter pillars
|
||||||
|
|
@ -37,10 +38,15 @@ class Channel(ABC):
|
||||||
Document in `docs/channel-contract.md`, parallel to
|
Document in `docs/channel-contract.md`, parallel to
|
||||||
`docs/harness-contract.md`.
|
`docs/harness-contract.md`.
|
||||||
|
|
||||||
|
**Done (2026-07-26).** `src/glas_harness/channels/base.py`:
|
||||||
|
`GatewayInvocation` dataclass + `Channel` ABC as sketched above.
|
||||||
|
Documented in `docs/channel-contract.md`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: GLAS-WP-0003-T01
|
id: GLAS-WP-0003-T01
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "f6a63483-2780-4b6c-8e0a-791e10d9c91a"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task: Refactor glas_harness.cli into a CLIChannel
|
## Task: Refactor glas_harness.cli into a CLIChannel
|
||||||
|
|
@ -52,10 +58,19 @@ exactly — this is a refactor, not a behavior change). `cli.py` becomes a
|
||||||
thin `main()` that constructs a `CLIChannel` and calls it; existing CLI
|
thin `main()` that constructs a `CLIChannel` and calls it; existing CLI
|
||||||
usage and output are unchanged.
|
usage and output are unchanged.
|
||||||
|
|
||||||
|
**Done (2026-07-26).** `cli.py`'s `run` handler now constructs
|
||||||
|
`CLIChannel()`, calls `parse_invocation(args)`, invokes
|
||||||
|
`run_task_through_rein` with the resulting `GatewayInvocation` fields,
|
||||||
|
and prints `channel.render_result(result)` — same shape as before.
|
||||||
|
Also fixed a real bug found while live-testing this: `ReinAharness`'s
|
||||||
|
default `cli_bin` was still `"agent-harness"`, stale from before the
|
||||||
|
rename — now `"rein-aharness"`.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: GLAS-WP-0003-T02
|
id: GLAS-WP-0003-T02
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "f7d26f3b-0df2-4db5-be7b-33065868087c"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task: Tests + verify no behavior change
|
## Task: Tests + verify no behavior change
|
||||||
|
|
@ -64,10 +79,18 @@ Unit tests for `CLIChannel.parse_invocation`/`render_result` in
|
||||||
isolation, plus re-running the existing live-proof commands (both
|
isolation, plus re-running the existing live-proof commands (both
|
||||||
reins) to confirm identical CLI output before/after the refactor.
|
reins) to confirm identical CLI output before/after the refactor.
|
||||||
|
|
||||||
|
**Done (2026-07-26).** `tests/test_cli_channel.py`, 4 new tests
|
||||||
|
(27/27 passing overall). Live-verified with a real run through
|
||||||
|
`python3 -m glas_harness.cli run ...` against `rein-aharness`/real
|
||||||
|
Claude Code: real commit landed, output shape unchanged,
|
||||||
|
`executor-sandbox` itself untouched (as with every prior live proof —
|
||||||
|
`ext.bwrap` only mutates its ephemeral copy).
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: GLAS-WP-0003-T03
|
id: GLAS-WP-0003-T03
|
||||||
status: todo
|
status: done
|
||||||
priority: medium
|
priority: medium
|
||||||
|
state_hub_task_id: "5012c88c-0b9c-4d12-97a8-07a58b7b84c4"
|
||||||
```
|
```
|
||||||
|
|
||||||
## Task: Document the pattern for a second channel
|
## Task: Document the pattern for a second channel
|
||||||
|
|
@ -79,8 +102,13 @@ one real implementation proves it," matching the discipline applied
|
||||||
throughout this session's other deferred-until-a-second-example
|
throughout this session's other deferred-until-a-second-example
|
||||||
decisions.
|
decisions.
|
||||||
|
|
||||||
|
**Done (2026-07-26).** "Adding a second channel" section in
|
||||||
|
`docs/channel-contract.md`, sketching a Slack channel without building
|
||||||
|
one.
|
||||||
|
|
||||||
```task
|
```task
|
||||||
id: GLAS-WP-0003-T04
|
id: GLAS-WP-0003-T04
|
||||||
status: todo
|
status: done
|
||||||
priority: low
|
priority: low
|
||||||
|
state_hub_task_id: "0a06387f-c7ed-4de9-999d-a7ba50e33d50"
|
||||||
```
|
```
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue