Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 56 additions & 0 deletions .console/log.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,59 @@
## 2026-08-19 — council: a health probe must not be able to throw

The Forgejo row I added to `dependency_check` called `response.json()`
unguarded. A 200 carrying non-JSON — a reverse-proxy error page, a login
interstitial — would raise out of a function whose entire job is to *report*
health, taking the whole dependency report down over one row. The Plane probe
it replaced never parsed a body, so I introduced the failure mode while
replacing something that did not have it.

Guarded, and it returns unhealthy rather than healthy: something answering on
that URL that is not the API means the fleet has no board. Five tests cover the
probe, including the non-JSON path.

## 2026-08-19 — stale custodian exclusion caught by CI, not by me

The Plane deletion PR went red on `custodian-doctor --strict`:
`audit.exclude_paths.D11: glob 'src/operations_center/adapters/plane/**'
matches no files (stale exclusion?)`. Correct — and exactly the residue the
deletion should have swept.

Local/CI gap worth remembering: the pinned custodian in `.venv` reports that as
a WARN and exits 0; CI installs `.[dev]` fresh and its `--strict` treats the
same warning as fatal. This is the one gate where running the exact CI command
locally still produced a green CI would not give.

## 2026-08-19 — the Plane adapter is deleted

Point 3 of the migration. `adapters/plane` (382 lines) and its 1,068 lines of
tests are gone; `PlaneSettings`, `Settings.plane` and `plane_token()` with
them. `board_backend` narrows to `Literal["forgejo"]`, and a config still
naming the retired backend gets an explanation rather than "Input should be
'forgejo'", which would read as a typo.

`dependency_check` traded its Plane service row for a Forgejo one — the board
is the one service whose absence stops everything, so a dependency report
without it would be blind where it matters most. `--create-plane-tasks` becomes
`--create-board-tasks`; it always went through `make_board_client` and was
never Plane-specific, only Plane-named.

The board-seam ratchet is retargeted rather than retired: the reason a caller
must not name a concrete client never depended on which client it was, so it
now guards `ForgejoClient`, with the setup wizard as the single allowlisted
direct constructor.

Fallout worth recording: 30 unit tests broke, all fixtures describing a
Plane-shaped settings object. Fixing them exposed a real gap — `settings.forgejo`
raised AttributeError on a stub lacking the attribute instead of the explained
"no `forgejo:` settings block" error sitting right below it. Both factory paths
use `getattr` now.

Measurement note, fourth instance this session: a bare `python -c` from a
worktree resolves `operations_center` through the editable install to the MAIN
checkout, so my first check of the new validator reported "no error" against
code that did not have it. pytest is fine (pyproject sets `pythonpath`); bare
python needs PYTHONPATH.

## 2026-08-19 — council round 2: unset is not the same as misconfigured

#520 again, and the reviewer was right again. `egress_proxy_hostport` returned
Expand Down
1 change: 0 additions & 1 deletion .custodian/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -467,7 +467,6 @@ audit:
# PR-quality + reporting adapters that wrap GitHub APIs share patterns.
- src/operations_center/adapters/pr_quality.py
- "src/operations_center/adapters/reporting/**"
- "src/operations_center/adapters/plane/**"
# Spec-director phase orchestrators are a CRUD family by design.
- "src/operations_center/spec_author/**"
# Routing rules / decision rules / proposer / planning / lifecycle —
Expand Down
2 changes: 1 addition & 1 deletion scripts/operations-center.sh
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ Usage:
scripts/operations-center.sh watch --role review
scripts/operations-center.sh watch-stop --role goal
scripts/operations-center.sh run --task-id TASK-123
scripts/operations-center.sh dependency-check [--create-plane-tasks]
scripts/operations-center.sh dependency-check [--create-board-tasks]
scripts/operations-center.sh janitor
scripts/operations-center.sh dev-up
scripts/operations-center.sh dev-down
Expand Down
101 changes: 48 additions & 53 deletions src/operations_center/adapters/board/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
# Copyright (C) 2026 ProtocolWarden
"""The board seam: what the fleet needs from a task board, and one place to build it.

OC's board is Plane today and will not be. Replacing it is currently a 37-file
change, not because the surface is large — it is eleven operations — but because
every one of those files imports `PlaneClient` by name, constructs it from the
same four settings fields, and type-hints against the concrete class. Ten of them
have independently hand-rolled the identical `_make_plane_client()` helper, which
is the clearest possible evidence that the missing piece is a shared one.
OC's board was Plane, and replacing it was a 37-file change — not because the
surface is large (it is eleven operations) but because every one of those files
imported `PlaneClient` by name, constructed it from the same four settings
fields, and type-hinted against the concrete class. Ten had independently
hand-rolled the identical `_make_plane_client()` helper, which was the clearest
possible evidence that the missing piece was a shared one.

The migration finished on 2026-08-18 and the Plane adapter is gone. What remains
is the property that made it finishable: callers name this module, not a
backend.

This module is that piece:

Expand All @@ -21,7 +25,7 @@
migration should be boring and reviewable, and any behaviour change should be its
own commit.

Nothing outside ``adapters/`` should import `PlaneClient` directly.
Nothing outside ``adapters/`` should import a concrete client directly.
``tests/unit/adapters/test_board_seam.py`` enforces that against a shrinking
allowlist, so the boundary tightens instead of eroding.
"""
Expand Down Expand Up @@ -92,48 +96,32 @@ def make_board_client(settings: Any) -> BoardClient:
"""Build the configured board client.

The one place that names a concrete backend. Every caller that used to
construct `PlaneClient` from `settings.plane.*` calls this instead, so
construct a concrete client from its settings block calls this instead, so
pointing the fleet at a different board is a change here and nowhere else.

Kept byte-compatible with the ten hand-rolled `_make_plane_client()` helpers
it replaces — same four fields, same token accessor — so adopting it cannot
change behaviour.
"""
backend = _backend_name(settings)
_reject_retired(backend)

if backend == "forgejo":
from operations_center.adapters.forgejo import ForgejoClient

cfg = settings.forgejo
if cfg is None:
raise RuntimeError(
"board_backend is 'forgejo' but no `forgejo:` settings block is "
"configured — refusing to fall back to Plane, because a silent "
"fallback would point the fleet at the board it is migrating off"
)
return ForgejoClient(
base_url=cfg.base_url,
api_token=settings.forgejo_token(),
owner=cfg.owner,
repo=cfg.repo,
)

if backend != "plane":
raise RuntimeError(f"unknown board_backend {backend!r} (plane, forgejo)")
if backend != "forgejo":
raise RuntimeError(f"unknown board_backend {backend!r} (forgejo)")

from operations_center.adapters.plane import PlaneClient
from operations_center.adapters.forgejo import ForgejoClient

board = settings.plane
if board is None:
cfg = getattr(settings, "forgejo", None)
if cfg is None:
raise RuntimeError(
"board_backend is 'plane' but no `plane:` settings block is "
"board_backend is 'forgejo' but no `forgejo:` settings block is "
"configured — the fleet has no board to talk to"
)
return PlaneClient(
base_url=board.base_url,
api_token=settings.plane_token(),
workspace_slug=board.workspace_slug,
project_id=board.project_id,
return ForgejoClient(
base_url=cfg.base_url,
api_token=settings.forgejo_token(),
owner=cfg.owner,
repo=cfg.repo,
)


Expand All @@ -147,8 +135,23 @@ def _backend_name(settings: Any) -> str:
validates this field as a str, so a non-string here means "nothing
configured this", not "someone chose backend 42".
"""
backend = getattr(settings, "board_backend", "plane")
return backend if isinstance(backend, str) else "plane"
backend = getattr(settings, "board_backend", "forgejo")
return backend if isinstance(backend, str) else "forgejo"


def _reject_retired(backend: str) -> None:
"""Answer an old config honestly.

"unknown board_backend 'plane'" reads as a typo. Plane was a real backend
until the 2026-08-18 cutover, so an operator whose config still says it is
asking a reasonable question and deserves the actual answer.
"""
if backend == "plane":
raise RuntimeError(
"board_backend 'plane' was removed — the Plane adapter is gone as of "
"the 2026-08-18 Forgejo cutover. Set `board_backend: forgejo` and a "
"`forgejo:` block (see config/operations_center.example.yaml)."
)


def board_project_id(settings: Any) -> str:
Expand All @@ -165,23 +168,15 @@ def board_project_id(settings: Any) -> str:
`owner/repo`.
"""
backend = _backend_name(settings)
_reject_retired(backend)

if backend == "forgejo":
cfg = settings.forgejo
if cfg is None:
raise RuntimeError(
"board_backend is 'forgejo' but no `forgejo:` settings block is "
"configured — the fleet has no board to talk to"
)
return f"{cfg.owner}/{cfg.repo}"

if backend != "plane":
raise RuntimeError(f"unknown board_backend {backend!r} (plane, forgejo)")
if backend != "forgejo":
raise RuntimeError(f"unknown board_backend {backend!r} (forgejo)")

board = settings.plane
if board is None:
cfg = getattr(settings, "forgejo", None)
if cfg is None:
raise RuntimeError(
"board_backend is 'plane' but no `plane:` settings block is "
"board_backend is 'forgejo' but no `forgejo:` settings block is "
"configured — the fleet has no board to talk to"
)
return board.project_id
return f"{cfg.owner}/{cfg.repo}"
5 changes: 0 additions & 5 deletions src/operations_center/adapters/plane/__init__.py

This file was deleted.

Loading
Loading