Prepare State Hub retirement baseline
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s
Build and Publish Multi-Context Image / build-and-push (push) Successful in 1m0s

This commit is contained in:
tegwick 2026-08-09 16:19:53 +02:00
parent 2217bdd9f5
commit 5927591be8
46 changed files with 32583 additions and 62 deletions

View file

@ -12,6 +12,13 @@ PGADMIN_PASSWORD=admin
# API
API_BASE=http://127.0.0.1:8000
# Read-only activity-core ops_run projection (STATE-WP-0078).
ACTIVITY_CORE_URL=http://127.0.0.1:8010
# Inject from OpenBao/ESO in production; never commit the value.
ACTIVITY_CORE_WORKER_TOKEN=
OPS_RUN_PROJECTION_TTL_SECONDS=15
OPS_RUN_SLA_HOURS=1
# Gitea (for gitea_inventory.py)
GITEA_URL=http://92.205.130.254:32166
GITEA_TOKEN=

View file

@ -13,8 +13,10 @@
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and
the `statehub` CLI by default. MCP is opt-in because the current Codex MCP bridge
adds severe call latency; the full administrative MCP surface remains available
to clients that need it.
| Context | URL |
|---------|-----|
@ -27,6 +29,14 @@ Queueable writes return an explicit queued receipt if the central hub is
unreachable. Treat that as pending local evidence, then ask the operator to run
statehub outbox status/replay after connectivity returns.
Codex workspace-write sandboxes need network access enabled to reach the host's
loopback listener. Bootstrap this once with `make -C ~/state-hub configure-codex`
and restart Codex. The canonical REST health endpoint is `/state/health`, not
`/health`. If a sandboxed loopback probe fails, retry it with escalated execution
before declaring State Hub unavailable; a managed Codex permission profile may
still enforce isolated networking. Experimental MCP can be enabled explicitly
with `make -C ~/state-hub configure-codex WITH_MCP=1`.
### Orient at session start
```bash

View file

@ -9,6 +9,26 @@ updated: "2026-05-17"
> This file explains why State Hub exists, where its authority begins and ends,
> and why it is now separate from the-custodian.
## Retirement status
State Hub is a legacy service in active retirement planning. It remains
operational during the migration, but it is not the target home for new
cross-domain capabilities.
The replacement program is coordinated in
`/home/worsch/prj-state-hub-retirement`, whose `GOAL.md` defines the migration
and retirement gates. Repository representation, file-backed record indexing,
consistency reconciliation, and governed repository changes are moving toward
`/home/worsch/repo-manager`. Cross-domain identity, communication, interaction,
state projections, telemetry, and domain-extension composition are moving
toward the consolidated `hub-core` framework.
Until the retirement gates are met, State Hub remains authoritative only where
the current contracts and repository-backed workflows require it. Changes made
during this period should preserve compatibility, enable extraction, reduce
scope, or address operational risk; they should not establish new permanent
architectural ownership here.
---
## Why it exists
@ -126,4 +146,3 @@ visible rather than fail opaquely when one component is unavailable.
**Narrow authority.** State Hub coordinates and exposes state; it does not make
irreversible human decisions or become the owner of every adjacent system.

View file

@ -1,4 +1,4 @@
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run
.PHONY: install install-cli dashboard-install dashboard-check db db-tools migrate seed api dashboard check test test-python clean register-project register-codex-project register-mcp configure-codex bootstrap-env dev-hub edge-relay mcp-profile validate-adr add-domain rename-domain add-repo list-repos register-path register-from-classification register-from-classification-all cleanup-stale tunnels-up tunnels-status tunnels-check bridges install-hooks install-hooks-all gitea-inventory token-reconcile railiance-state-hub-render railiance-state-hub-client-dry-run railiance-state-hub-server-dry-run
COMPOSE = docker compose -f infra/docker-compose.yml --env-file .env
PYTHON ?= python3
@ -180,6 +180,14 @@ register-mcp:
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(DRY_RUN),--dry-run,)
## Configure Codex workspace-write networking for local State Hub access.
configure-codex:
scripts/configure-codex.sh \
$(if $(CODEX_HOME),--codex-home "$(CODEX_HOME)",) \
$(if $(API_BASE),--api-base "$(API_BASE)",) \
$(if $(WITH_MCP),--with-mcp,) \
$(if $(DRY_RUN),--dry-run,)
## Bootstrap a new operator/collaborator environment. Optional: make bootstrap-env ARGS="--install-missing"
bootstrap-env:
scripts/bootstrap-env.sh $(ARGS)

View file

@ -10,6 +10,7 @@
| --- | --- | --- | --- | --- |
| workplan | ADHOC-2026-06-04 | finished | — | workplans/ADHOC-2026-06-04.md |
| workplan | ADHOC-2026-07-01 | finished | — | workplans/ADHOC-2026-07-01.md |
| workplan | ADHOC-2026-08-08 | finished | — | workplans/ADHOC-2026-08-08.md |
| workplan | CUST-WP-0003 | finished | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| workplan | CUST-WP-0012 | finished | — | workplans/CUST-WP-0012-multi-user-onboarding.md |
| workplan | CUST-WP-0038 | backlog | — | workplans/CUST-WP-0038-state-hub-threephoenix-ha.md |
@ -46,10 +47,14 @@
| workplan | STATE-WP-0075 | finished | — | workplans/STATE-WP-0075-workstream-route-410-stranglers.md |
| workplan | STATE-WP-0076 | finished | — | workplans/STATE-WP-0076-definition-of-ready-and-comprehension.md |
| workplan | STATE-WP-0077 | finished | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| workplan | STATE-WP-0078 | ready | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| workplan | STATE-WP-0078 | finished | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | ADHOC-2026-06-04-T01 | done | — | workplans/ADHOC-2026-06-04.md |
| task | ADHOC-2026-07-01-T01 | done | — | workplans/ADHOC-2026-07-01.md |
| task | ADHOC-2026-07-01-T02 | done | — | workplans/ADHOC-2026-07-01.md |
| task | ADHOC-2026-08-08-T01 | done | — | workplans/ADHOC-2026-08-08.md |
| task | ADHOC-2026-08-08-T02 | done | — | workplans/ADHOC-2026-08-08.md |
| task | ADHOC-2026-08-08-T03 | done | — | workplans/ADHOC-2026-08-08.md |
| task | ADHOC-2026-08-08-T04 | done | — | workplans/ADHOC-2026-08-08.md |
| task | CUST-WP-0003-T01 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T02 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
| task | CUST-WP-0003-T03 | done | — | workplans/CUST-WP-0003-whi-kpi-card.md |
@ -264,6 +269,6 @@
| task | STATE-WP-0077-T02 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0077-T03 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0077-T04 | done | — | workplans/STATE-WP-0077-dox-assessment-recording-and-soft-visibility.md |
| task | STATE-WP-0078-T01 | todo | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T02 | todo | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T03 | todo | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T01 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T02 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |
| task | STATE-WP-0078-T03 | done | — | workplans/STATE-WP-0078-ops-run-read-projection.md |

View file

@ -13,6 +13,10 @@ class Settings(BaseSettings):
debug: bool = False
state_hub_report_dir: str = "reports/recently-on-scope"
state_hub_markitect_cli_path: str | None = None
activity_core_url: str | None = None
activity_core_worker_token: str | None = None
ops_run_projection_ttl_seconds: float = 15.0
ops_run_sla_hours: float = 1.0
settings = Settings()

View file

@ -12,7 +12,7 @@ from starlette.responses import Response as StarletteResponse
from api.database import engine
from api.events import shutdown_publisher
from api.services.write_idempotency import WriteIdempotencyMiddleware
from api.routers import decisions, extension_points, intake, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import decisions, extension_points, intake, ops_runs, progress, state, suggestions, tasks, technical_debt, topics, workstreams, workstream_dependencies
from api.routers import domains, repos, contributions, sbom, policy, domain_goals, repo_goals, messages, capability_requests, tpsc, services
from api.routers import token_events
from api.routers import interface_changes
@ -135,6 +135,7 @@ app.include_router(execution.router)
app.include_router(fabric.router)
app.include_router(legacy_meter.router)
app.include_router(state.router)
app.include_router(ops_runs.router)
app.include_router(policy.router)

13
api/routers/ops_runs.py Normal file
View file

@ -0,0 +1,13 @@
from fastapi import APIRouter
from api.schemas.ops_run import OpsRunProjection
from api.services.ops_run_projection import get_ops_run_projection
router = APIRouter(prefix="/ops-runs", tags=["ops-runs"])
@router.get("/summary", response_model=OpsRunProjection)
async def get_ops_runs_summary(refresh: bool = False) -> OpsRunProjection:
"""Project activity-core queue health; State Hub never claims ops runs."""
return await get_ops_run_projection(refresh=refresh)

View file

@ -53,6 +53,7 @@ from api.services.summary_cache import (
get_summary_cache,
register_summary_cache_invalidation,
)
from api.services.ops_run_projection import get_ops_run_projection
def _dual_workplan_refs(
@ -111,22 +112,22 @@ async def get_summary(
if cache_status == "hit-revision" and cached is not None:
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return cached
return cached.model_copy(update={"ops_runs": await get_ops_run_projection()})
if cache_status == "progress-section" and cached is not None:
result = await apply_progress_section(session, cached, revision)
_summary_cache_headers(response, cache_status="hit-revision", revision=revision_token)
return result
return result.model_copy(update={"ops_runs": await get_ops_run_projection()})
if cache_status == "stale" and cached is not None:
cache.schedule_refresh(revision)
_summary_cache_headers(response, cache_status="stale", revision=revision_token)
return cached
return cached.model_copy(update={"ops_runs": await get_ops_run_projection()})
result = await build_state_summary(session)
cache.store(result, revision)
_summary_cache_headers(response, cache_status="miss", revision=revision_token)
return result
return result.model_copy(update={"ops_runs": await get_ops_run_projection(refresh=force_refresh)})
async def build_state_summary(session: AsyncSession) -> StateSummary:

27
api/schemas/ops_run.py Normal file
View file

@ -0,0 +1,27 @@
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
class OpsRunProjectionItem(BaseModel):
id: str
definition: str | None = None
target_repo: str | None = None
state: str
lease: dict[str, Any] | None = None
updated_at: datetime | None = None
last_error: str | None = None
class OpsRunProjection(BaseModel):
available: bool = False
stale: bool = False
source: str = "activity-core"
generated_at: datetime
open: int = 0
claimed: int = 0
failed_24h: int = 0
stuck_open_or_claimed: int = 0
items: list[OpsRunProjectionItem] = Field(default_factory=list)
error: str | None = None

View file

@ -11,6 +11,7 @@ from api.schemas.task import TaskRead
from api.schemas.topic import TopicWithWorkstreams
from api.schemas.suggestion import RankedSuggestionDigest
from api.schemas.workstream import WorkstreamWithDeps
from api.schemas.ops_run import OpsRunProjection
class TopicTotals(BaseModel):
@ -90,6 +91,7 @@ class StateSummary(BaseModel):
licence_risk_count: int = 0
open_capability_requests: int = 0
ranked_suggestions: list[RankedSuggestionDigest] = []
ops_runs: OpsRunProjection | None = None
class DashboardWorkplanRow(BaseModel):

View file

@ -0,0 +1,130 @@
"""Read-only activity-core ops_run projection (STATE-WP-0078)."""
from __future__ import annotations
import asyncio
import time
from datetime import datetime, timedelta, timezone
from typing import Any
import httpx
from api.config import settings
from api.schemas.ops_run import OpsRunProjection, OpsRunProjectionItem
_cache: OpsRunProjection | None = None
_cache_at: float = 0.0
_lock = asyncio.Lock()
def reset_ops_run_projection_cache() -> None:
global _cache, _cache_at
_cache = None
_cache_at = 0.0
def _parse_datetime(value: Any) -> datetime | None:
if not value or not isinstance(value, str):
return None
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return None
return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc)
async def _fetch_ops_runs() -> dict[str, Any]:
if not settings.activity_core_url:
raise RuntimeError("ACTIVITY_CORE_URL is not configured")
headers = {}
if settings.activity_core_worker_token:
headers["X-Worker-Token"] = settings.activity_core_worker_token
async with httpx.AsyncClient(
base_url=settings.activity_core_url.rstrip("/"),
timeout=10.0,
follow_redirects=True,
trust_env=False,
headers=headers,
) as client:
response = await client.get("/ops-runs", params={"limit": 200})
response.raise_for_status()
return response.json()
def _project(payload: dict[str, Any], now: datetime) -> OpsRunProjection:
raw_items = payload.get("items") or []
counts = payload.get("counts") or {}
failed_cutoff = now - timedelta(hours=24)
stuck_cutoff = now - timedelta(hours=max(0.1, settings.ops_run_sla_hours))
failed_24h = 0
stuck = 0
items: list[OpsRunProjectionItem] = []
for raw in raw_items:
state = str(raw.get("state") or "unknown")
updated_at = _parse_datetime(raw.get("updated_at"))
created_at = _parse_datetime(raw.get("created_at"))
if state == "failed" and updated_at and updated_at >= failed_cutoff:
failed_24h += 1
if state in {"open", "claimed"} and (created_at or updated_at):
if (created_at or updated_at) < stuck_cutoff:
stuck += 1
result = raw.get("result") if isinstance(raw.get("result"), dict) else {}
last_error = result.get("error") or raw.get("last_error")
if state in {"open", "claimed", "failed"}:
items.append(OpsRunProjectionItem(
id=str(raw.get("id")),
definition=str(raw.get("activity_definition_id")) if raw.get("activity_definition_id") else None,
target_repo=raw.get("target_repo"),
state=state,
lease={
"owner": raw.get("claim_owner"),
"until": raw.get("lease_until"),
"attempt": raw.get("attempt", 0),
} if raw.get("claim_owner") or raw.get("lease_until") else None,
updated_at=updated_at,
last_error=str(last_error) if last_error else None,
))
items.sort(key=lambda item: item.updated_at or datetime.min.replace(tzinfo=timezone.utc), reverse=True)
return OpsRunProjection(
available=True,
generated_at=now,
open=int(counts.get("open", sum(item.state == "open" for item in items))),
claimed=int(counts.get("claimed", sum(item.state == "claimed" for item in items))),
failed_24h=failed_24h,
stuck_open_or_claimed=stuck,
items=items[:50],
)
async def get_ops_run_projection(*, refresh: bool = False) -> OpsRunProjection:
global _cache, _cache_at
now_mono = time.monotonic()
ttl = max(1.0, settings.ops_run_projection_ttl_seconds)
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
return _cache
async with _lock:
now_mono = time.monotonic()
if not refresh and _cache is not None and now_mono - _cache_at < ttl:
return _cache
now = datetime.now(timezone.utc)
try:
projection = _project(await _fetch_ops_runs(), now)
except Exception as exc:
if _cache is not None and _cache.available:
projection = _cache.model_copy(update={
"stale": True,
"error": f"activity-core refresh failed: {exc}",
})
else:
projection = OpsRunProjection(
generated_at=now,
error=f"activity-core unavailable: {exc}",
)
_cache = projection
_cache_at = now_mono
return projection

View file

@ -159,6 +159,19 @@ Runbook: [`docs/consistency-sweep-runbook.md`](consistency-sweep-runbook.md).
cutover while the bridge target chain was rewired. Manual sweeps succeed.
Re-enablement is tracked outside this workplan (service-inventory gap).
## Activity-core ops run visibility
Ops-run claims, leases, completion, and failure remain on activity-core in
Railiance. State Hub only exposes the cached read projection described in
[`ops-run-read-projection.md`](ops-run-read-projection.md). Configure the
coulombcore State Hub deployment with a private `ACTIVITY_CORE_URL` and an
OpenBao/ESO-injected `ACTIVITY_CORE_WORKER_TOKEN`.
If the Railiance edge or tunnel is unavailable, State Hub serves the last
successful projection with `stale: true`; before any successful pull it returns
`available: false`. Alert on those flags. Do not add claim or completion writes
to State Hub as a failover mechanism.
## Pragmatic Limitations (Single-Node)
This deployment is intentionally **not** highly available:

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,28 @@ cd ~/state-hub
make bootstrap-env
```
The bootstrap enables `sandbox_workspace_write.network_access` in the
operator's Codex `config.toml`, removes any default `dev-hub` Codex MCP
registration, then checks whether a Codex sandbox can reach
`http://127.0.0.1:8000/state/health`. Use `--skip-codex` to omit this step, or
run it independently:
```bash
make configure-codex
```
If verification still reports isolated networking, a managed Codex permission
profile is overriding user configuration. Retry State Hub REST/CLI commands with
escalated execution or change that managed profile; do not infer that the API is
down until the same health check has been run outside the sandbox.
The slim six-tool Codex MCP server remains available for explicit testing, but
is not recommended while the Codex MCP bridge adds substantial call latency:
```bash
make configure-codex WITH_MCP=1
```
On a clean Ubuntu 24.04 machine, allow package installation explicitly:
```bash

View file

@ -0,0 +1,56 @@
# Ops run read projection
State Hub projects activity-core `ops_run` queue health for fleet visibility.
Activity-core remains the authority for run creation, claims, leases,
completion, failure, and expiry under ACT-ADR-005 and ACTIVITY-WP-0026.
## V1 contract
V1 is a cached **pull** projection. State Hub reads activity-core:
```text
GET {ACTIVITY_CORE_URL}/ops-runs?limit=200
X-Worker-Token: ${ACTIVITY_CORE_WORKER_TOKEN}
```
The credential is owned by the OpenBao/railiance-platform path and must be
injected at runtime. State Hub never persists or returns it.
The upstream fields consumed are:
| Upstream field | Projection field |
| --- | --- |
| `id` | `id` |
| `activity_definition_id` | `definition` |
| `target_repo` | `target_repo` |
| `state` | `state` |
| `claim_owner`, `lease_until`, `attempt` | `lease` |
| `updated_at` | `updated_at` |
| `result.error` or `last_error` | `last_error` |
State Hub exposes:
- `GET /ops-runs/summary`
- `/state/summary` field `ops_runs`
Both provide `open`, `claimed`, `failed_24h`, `stuck_open_or_claimed`, and a
bounded list of open/claimed/failed rows. There are deliberately no claim,
heartbeat, complete, fail, or expiry endpoints in State Hub.
## Cache and failure behavior
`OPS_RUN_PROJECTION_TTL_SECONDS` defaults to 15 seconds. A refresh failure
serves the last successful projection with `stale: true` and an `error` field.
Before the first successful read, the projection returns `available: false`;
State Hub summary and work-record APIs remain healthy.
`OPS_RUN_SLA_HOURS` defaults to 1 hour and classifies open or claimed runs older
than the threshold as stuck. This is an operator signal, not a state transition.
## Railiance and coulombcore boundary
Claims happen against activity-core on Railiance. State Hub on coulombcore only
pulls visibility through the configured private activity-core endpoint. If the
edge/tunnel path is unavailable, visibility may lag and the projection is marked
stale; operators should alert on stale/unavailable projection state rather than
moving claim authority into State Hub.

123
mcp_server/codex_server.py Normal file
View file

@ -0,0 +1,123 @@
"""Small State Hub MCP surface for Codex repository sessions.
The full dev-hub MCP server intentionally exposes administrative and catalog
operations. Codex repository work needs a much smaller coordination surface;
keeping it separate reduces tool discovery cost and makes the contract clear.
"""
from __future__ import annotations
import json
import os
from typing import Any
import httpx
from fastmcp import FastMCP
from mcp_server.server import get_domain_summary as _full_get_domain_summary
API_BASE = os.environ.get("API_BASE", "http://127.0.0.1:8000").rstrip("/")
mcp = FastMCP(
name="dev-hub-codex",
instructions=(
"Slim State Hub coordination surface for Codex repository sessions. "
"Start with get_domain_summary, check the repository inbox, and record "
"progress when work closes. Workplan files remain the source of truth."
),
)
def _request(method: str, path: str, body: dict[str, Any] | None = None) -> Any:
with httpx.Client(
base_url=API_BASE,
timeout=15.0,
follow_redirects=True,
trust_env=False,
) as client:
response = client.request(method, path, json=body)
response.raise_for_status()
return response.json()
def _json(value: Any) -> str:
return json.dumps(value, indent=2)
@mcp.tool()
def get_domain_summary(domain_slug: str) -> str:
"""Return actionable State Hub orientation scoped to one domain."""
return _full_get_domain_summary(domain_slug)
@mcp.tool()
def get_messages(to_agent: str, unread_only: bool = True) -> str:
"""Get coordination messages for one repository agent."""
suffix = "true" if unread_only else "false"
return _json(_request("GET", f"/messages/?to_agent={to_agent}&unread_only={suffix}"))
@mcp.tool()
def mark_message_read(message_id: str) -> str:
"""Mark one coordination message as read."""
return _json(_request("PATCH", f"/messages/{message_id}/read", {}))
@mcp.tool()
def add_progress_event(
summary: str,
author: str = "codex",
workplan_id: str | None = None,
task_id: str | None = None,
) -> str:
"""Record a State Hub progress note for completed or significant work."""
body: dict[str, Any] = {
"summary": summary,
"event_type": "note",
"author": author,
}
if workplan_id:
body["workplan_id"] = workplan_id
if task_id:
body["task_id"] = task_id
return _json(_request("POST", "/progress/", body))
@mcp.tool()
def record_decision(
title: str,
description: str,
topic_id: str,
proposed_by: str = "codex",
workplan_id: str | None = None,
) -> str:
"""Record a pending decision linked to a topic and optionally a workplan."""
body: dict[str, Any] = {
"title": title,
"description": description,
"topic_id": topic_id,
"decision_type": "pending",
"status": "open",
"proposed_by": proposed_by,
}
if workplan_id:
body["workplan_id"] = workplan_id
return _json(_request("POST", "/decisions/", body))
@mcp.tool()
def update_task_status(
task_id: str,
status: str,
blocking_reason: str | None = None,
) -> str:
"""Update a task to wait, todo, progress, done, or cancel."""
body: dict[str, Any] = {"status": status}
if blocking_reason is not None:
body["blocking_reason"] = blocking_reason
return _json(_request("PATCH", f"/tasks/{task_id}", body))
if __name__ == "__main__":
mcp.run(transport="stdio")

View file

@ -57,7 +57,12 @@ HubCoreMCPServer(
# ---------------------------------------------------------------------------
def _client() -> httpx.Client:
return httpx.Client(base_url=API_BASE, timeout=30.0, follow_redirects=True)
return httpx.Client(
base_url=API_BASE,
timeout=30.0,
follow_redirects=True,
trust_env=False,
)
def _get(path: str, params: dict | None = None) -> Any:
@ -268,6 +273,11 @@ def get_domain_summary(domain_slug: str) -> str:
blocking = _get("/decisions", {"decision_type": "pending", "topic_id": topic_id})
recent = _get("/progress", {"topic_id": topic_id, "limit": 5})
repos = _get("/repos", {"domain": domain_slug})
active_goals = _get("/repo-goals", {"status": "active"})
goals_by_repo: dict[str, list[dict]] = {}
for goal in active_goals:
repo_key = str(goal.get("repo_id") or goal.get("repo_slug") or "")
goals_by_repo.setdefault(repo_key, []).append(goal)
# ── Goal guidance ──────────────────────────────────────────────────────────
# Fetch active repo goals per repo, then cross-reference with workstreams.
@ -289,12 +299,12 @@ def get_domain_summary(domain_slug: str) -> str:
for repo in repos:
repo_slug = repo["slug"]
repo_id = repo["id"]
active_goals = _get("/repo-goals", {"repo_slug": repo_slug, "status": "active"})
if not active_goals:
repo_goals = goals_by_repo.get(str(repo_id), goals_by_repo.get(repo_slug, []))
if not repo_goals:
continue
active_goal_ids = {g["id"] for g in active_goals}
active_goal_ids = {g["id"] for g in repo_goals}
for goal in active_goals:
for goal in repo_goals:
linked = ws_by_repo_goal.get(goal["id"], [])
if not linked:
needs_workplan.append({
@ -324,7 +334,7 @@ def get_domain_summary(domain_slug: str) -> str:
"recent_workplan_title": recent_ws["title"],
"recent_workstream_id": recent_ws["id"],
"recent_workstream_title": recent_ws["title"],
"active_goal_titles": [g["title"] for g in active_goals],
"active_goal_titles": [g["title"] for g in repo_goals],
"message": (
f"Workplan '{recent_ws['title']}' is not linked to the current "
f"repo goal(s) for {repo_slug}. "
@ -353,31 +363,6 @@ def get_domain_summary(domain_slug: str) -> str:
if goal_guidance:
result["goal_guidance"] = goal_guidance
inbox_hygiene: dict[str, Any] = {}
try:
from scripts.consistency_check import collect_inbox_hygiene, STALE_UNREAD_DAYS
except ImportError:
collect_inbox_hygiene = None # type: ignore[assignment]
STALE_UNREAD_DAYS = 3
if collect_inbox_hygiene is not None:
for repo in repos:
repo_slug = repo["slug"]
hygiene = collect_inbox_hygiene(API_BASE, repo_slug)
if (
hygiene["stale_unread_count"]
or hygiene["missing_thread"]
or hygiene["work_requests_unpromoted"]
):
inbox_hygiene[repo_slug] = {
"stale_unread_count": hygiene["stale_unread_count"],
"stale_unread_days": STALE_UNREAD_DAYS,
"stale_unread": hygiene["stale_unread"][:5],
"missing_thread_count": len(hygiene["missing_thread"]),
"work_requests_unpromoted": hygiene["work_requests_unpromoted"][:3],
}
if inbox_hygiene:
result["inbox_hygiene"] = inbox_hygiene
# Compact capabilities list (type + title + repo_slug only, capped at 20)
caps_raw = _get("/capability-catalog/", {"domain": domain_slug, "status": "active"})
if isinstance(caps_raw, list):

View file

@ -14,6 +14,7 @@ AUTHORIZE_SSH=0
ALLOW_PLAINTEXT_STORE=0
SKIP_GITEA=0
SKIP_MCP=0
SKIP_CODEX=0
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
SSH_TARGETS=(
"tegwick@92.205.62.239"
@ -39,6 +40,7 @@ Options:
--gitea-token TOKEN Gitea token; otherwise prompted when interactive.
--skip-gitea Do not create or update ~/.railiance_gitea.conf.
--skip-mcp Do not run make register-mcp.
--skip-codex Do not configure Codex sandbox networking.
-h, --help Show this help.
USAGE
}
@ -118,6 +120,10 @@ while [ "$#" -gt 0 ]; do
SKIP_MCP=1
shift
;;
--skip-codex)
SKIP_CODEX=1
shift
;;
-h|--help)
usage
exit 0
@ -342,6 +348,19 @@ register_mcp() {
fi
}
configure_codex() {
step "Configuring Codex State Hub access"
if [ "$SKIP_CODEX" -eq 1 ]; then
warn "Skipping Codex configuration by request."
return
fi
local args=()
if [ "$DRY_RUN" -eq 1 ]; then
args+=(--dry-run)
fi
"$STATE_HUB_DIR/scripts/configure-codex.sh" "${args[@]}"
}
health_check() {
step "Checking State Hub reachability"
if curl -fsS --max-time 2 "http://127.0.0.1:8000/state/health" >/dev/null 2>&1; then
@ -362,6 +381,7 @@ main() {
setup_ssh_key
write_gitea_conf
register_mcp
configure_codex
health_check
ok "Bootstrap checks complete."
}

View file

@ -1,7 +1,9 @@
#!/usr/bin/env bash
set -euo pipefail
cd /home/worsch/state-hub
STATE_HUB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
STATE_HUB_PYTHON="${STATE_HUB_PYTHON:-$STATE_HUB_DIR/.venv/bin/python}"
cd "$STATE_HUB_DIR"
API_BASE="${API_BASE:-http://127.0.0.1:8000}"
HEALTH_URL="${API_BASE%/}/state/health"
@ -24,6 +26,12 @@ if ! api_healthy; then
exit 1
fi
if [ ! -x "$STATE_HUB_PYTHON" ]; then
echo "state-hub Python is not executable at $STATE_HUB_PYTHON" >&2
echo "install the repository environment before starting the Codex MCP server" >&2
exit 1
fi
export API_BASE
export MCP_TRANSPORT=stdio
exec uv run python mcp_server/server.py
exec "$STATE_HUB_PYTHON" mcp_server/codex_server.py

142
scripts/configure-codex.sh Executable file
View file

@ -0,0 +1,142 @@
#!/usr/bin/env bash
set -euo pipefail
CODEX_HOME_DIR="${CODEX_HOME:-$HOME/.codex}"
CONFIG_PATH="${CODEX_CONFIG_PATH:-$CODEX_HOME_DIR/config.toml}"
API_BASE="${API_BASE:-http://127.0.0.1:8000}"
DRY_RUN=0
SKIP_VERIFY=0
MCP_MODE="remove"
MCP_NAME="${STATE_HUB_MCP_NAME:-dev-hub}"
MCP_COMMAND="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/codex-state-hub-mcp.sh"
usage() {
cat <<'USAGE'
Usage: scripts/configure-codex.sh [options]
Enable direct network access for Codex workspace-write sandboxes and verify
that the sandbox can reach the local State Hub.
Options:
--codex-home DIR Codex home containing config.toml. Default: $CODEX_HOME or ~/.codex.
--api-base URL State Hub API base. Default: http://127.0.0.1:8000.
--dry-run Show the configuration change without writing it.
--skip-verify Do not run the sandboxed State Hub health check.
--with-mcp Opt in to the experimental slim State Hub MCP server.
--skip-mcp Do not add or remove a State Hub MCP registration.
-h, --help Show this help.
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--codex-home)
test -n "${2:-}" || { echo "ERROR: --codex-home requires a directory" >&2; exit 2; }
CODEX_HOME_DIR="$2"
CONFIG_PATH="$CODEX_HOME_DIR/config.toml"
shift 2
;;
--api-base)
test -n "${2:-}" || { echo "ERROR: --api-base requires a URL" >&2; exit 2; }
API_BASE="$2"
shift 2
;;
--dry-run) DRY_RUN=1; shift ;;
--skip-verify) SKIP_VERIFY=1; shift ;;
--with-mcp) MCP_MODE="add"; shift ;;
--skip-mcp) MCP_MODE="skip"; shift ;;
-h|--help) usage; exit 0 ;;
*) echo "ERROR: unknown argument: $1" >&2; usage >&2; exit 2 ;;
esac
done
python3 - "$CONFIG_PATH" "$DRY_RUN" <<'PY'
import re
import sys
import tomllib
from pathlib import Path
path = Path(sys.argv[1])
dry_run = sys.argv[2] == "1"
original = path.read_text(encoding="utf-8") if path.exists() else ""
if original:
tomllib.loads(original)
header = "[sandbox_workspace_write]"
lines = original.splitlines()
start = next((i for i, line in enumerate(lines) if line.strip() == header), None)
if start is None:
if lines and lines[-1].strip():
lines.append("")
lines.extend([header, "network_access = true"])
else:
end = next(
(i for i in range(start + 1, len(lines)) if re.match(r"^\s*\[", lines[i])),
len(lines),
)
setting = next(
(i for i in range(start + 1, end) if re.match(r"^\s*network_access\s*=", lines[i])),
None,
)
if setting is None:
lines.insert(end, "network_access = true")
else:
lines[setting] = "network_access = true"
updated = "\n".join(lines).rstrip() + "\n"
tomllib.loads(updated)
if updated == original:
print(f"OK: {path} already enables sandbox workspace-write network access")
elif dry_run:
print(f"DRY-RUN: would enable sandbox_workspace_write.network_access in {path}")
else:
path.parent.mkdir(parents=True, exist_ok=True)
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(updated, encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(path)
print(f"OK: enabled sandbox_workspace_write.network_access in {path}")
PY
if [ "$MCP_MODE" = "add" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY-RUN: would register Codex MCP server $MCP_NAME -> $MCP_COMMAND"
elif ! command -v codex >/dev/null 2>&1; then
echo "WARN: codex is not on PATH; State Hub MCP registration was skipped." >&2
elif CODEX_HOME="$CODEX_HOME_DIR" codex mcp get "$MCP_NAME" >/dev/null 2>&1; then
echo "OK: Codex MCP server $MCP_NAME is already registered"
else
CODEX_HOME="$CODEX_HOME_DIR" codex mcp add "$MCP_NAME" -- "$MCP_COMMAND"
echo "OK: registered Codex MCP server $MCP_NAME -> $MCP_COMMAND"
fi
elif [ "$MCP_MODE" = "remove" ]; then
if [ "$DRY_RUN" -eq 1 ]; then
echo "DRY-RUN: would remove Codex MCP server $MCP_NAME if registered"
elif command -v codex >/dev/null 2>&1 \
&& CODEX_HOME="$CODEX_HOME_DIR" codex mcp get "$MCP_NAME" >/dev/null 2>&1; then
CODEX_HOME="$CODEX_HOME_DIR" codex mcp remove "$MCP_NAME"
echo "OK: removed Codex MCP server $MCP_NAME; use REST/statehub CLI by default"
else
echo "OK: Codex MCP server $MCP_NAME is not registered"
fi
fi
if [ "$DRY_RUN" -eq 1 ] || [ "$SKIP_VERIFY" -eq 1 ]; then
exit 0
fi
if ! command -v codex >/dev/null 2>&1; then
echo "WARN: codex is not on PATH; configuration was written but not verified." >&2
exit 0
fi
HEALTH_URL="${API_BASE%/}/state/health"
if timeout 10 codex sandbox -- curl -fsS --max-time 3 "$HEALTH_URL" >/dev/null 2>&1; then
echo "OK: Codex sandbox can reach State Hub at $HEALTH_URL"
else
echo "WARN: Codex sandbox still cannot reach $HEALTH_URL." >&2
echo "WARN: A managed permission profile may enforce restricted networking/--unshare-net." >&2
echo "WARN: Retry State Hub REST/CLI commands with escalated execution or change the managed profile." >&2
fi

View file

@ -13,8 +13,10 @@
## State Hub Integration
The Custodian State Hub tracks work across all domains. Interact via HTTP REST —
there is no MCP server for Codex agents.
The Custodian State Hub tracks work across all domains. Codex uses HTTP REST and
the `statehub` CLI by default. MCP is opt-in because the current Codex MCP bridge
adds severe call latency; the full administrative MCP surface remains available
to clients that need it.
| Context | URL |
|---------|-----|
@ -27,6 +29,14 @@ Queueable writes return an explicit queued receipt if the central hub is
unreachable. Treat that as pending local evidence, then ask the operator to run
statehub outbox status/replay after connectivity returns.
Codex workspace-write sandboxes need network access enabled to reach the host's
loopback listener. Bootstrap this once with `make -C ~/state-hub configure-codex`
and restart Codex. The canonical REST health endpoint is `/state/health`, not
`/health`. If a sandboxed loopback probe fails, retry it with escalated execution
before declaring State Hub unavailable; a managed Codex permission profile may
still enforce isolated networking. Experimental MCP can be enabled explicitly
with `make -C ~/state-hub configure-codex WITH_MCP=1`.
### Orient at session start
```bash

View file

@ -0,0 +1,40 @@
import os
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
LAUNCHER = ROOT / "scripts" / "codex-state-hub-mcp.sh"
def test_codex_mcp_launcher_uses_configured_python(tmp_path: Path) -> None:
fake_python = tmp_path / "python"
fake_python.write_text(
"#!/usr/bin/env bash\n"
"printf 'python=%s\\nscript=%s\\ntransport=%s\\n' "
'"$0" "$1" "$MCP_TRANSPORT"\n'
)
fake_python.chmod(0o755)
env = os.environ.copy()
env["STATE_HUB_PYTHON"] = str(fake_python)
env["API_BASE"] = "http://127.0.0.1:8000"
result = subprocess.run(
[str(LAUNCHER)],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=True,
timeout=5,
)
assert f"python={fake_python}" in result.stdout
assert "script=mcp_server/codex_server.py" in result.stdout
assert "transport=stdio" in result.stdout
def test_codex_mcp_launcher_has_no_uv_runtime_dependency() -> None:
launcher = LAUNCHER.read_text()
assert "uv run" not in launcher
assert ".venv/bin/python" in launcher

View file

@ -0,0 +1,19 @@
import asyncio
from mcp_server import codex_server
def test_codex_server_has_only_repository_coordination_tools() -> None:
tools = asyncio.run(codex_server.mcp.list_tools())
assert {tool.name for tool in tools} == {
"get_domain_summary",
"get_messages",
"mark_message_read",
"add_progress_event",
"record_decision",
"update_task_status",
}
def test_codex_server_uses_distinct_server_identity() -> None:
assert codex_server.mcp.name == "dev-hub-codex"

View file

@ -0,0 +1,92 @@
import os
import subprocess
import tomllib
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPT = ROOT / "scripts" / "configure-codex.sh"
def run_configure(codex_home: Path, *args: str) -> subprocess.CompletedProcess[str]:
env = os.environ.copy()
env["CODEX_HOME"] = str(codex_home)
return subprocess.run(
[str(SCRIPT), *args],
cwd=ROOT,
env=env,
text=True,
capture_output=True,
check=True,
)
def test_configure_codex_creates_network_setting(tmp_path: Path) -> None:
codex_home = tmp_path / ".codex"
run_configure(codex_home, "--skip-verify", "--skip-mcp")
config = tomllib.loads((codex_home / "config.toml").read_text())
assert config["sandbox_workspace_write"]["network_access"] is True
def test_configure_codex_preserves_existing_settings_and_is_idempotent(
tmp_path: Path,
) -> None:
codex_home = tmp_path / ".codex"
codex_home.mkdir()
config_path = codex_home / "config.toml"
config_path.write_text(
'model = "gpt-test"\n\n'
"[sandbox_workspace_write]\n"
"network_access = false\n"
'writable_roots = ["/tmp/example"]\n'
)
run_configure(codex_home, "--skip-verify", "--skip-mcp")
first = config_path.read_text()
run_configure(codex_home, "--skip-verify", "--skip-mcp")
assert config_path.read_text() == first
config = tomllib.loads(first)
assert config["model"] == "gpt-test"
assert config["sandbox_workspace_write"] == {
"network_access": True,
"writable_roots": ["/tmp/example"],
}
def test_configure_codex_dry_run_does_not_write(tmp_path: Path) -> None:
codex_home = tmp_path / ".codex"
result = run_configure(codex_home, "--dry-run", "--skip-mcp")
assert "DRY-RUN" in result.stdout
assert not (codex_home / "config.toml").exists()
def test_configure_codex_removes_dev_hub_registration_by_default(
tmp_path: Path,
) -> None:
codex_home = tmp_path / ".codex"
codex_home.mkdir()
config_path = codex_home / "config.toml"
config_path.write_text(
'[mcp_servers.dev-hub]\ncommand = "/tmp/dev-hub"\n'
)
result = run_configure(codex_home, "--skip-verify")
assert "removed Codex MCP server dev-hub" in result.stdout
config = tomllib.loads(config_path.read_text())
assert "dev-hub" not in config.get("mcp_servers", {})
def test_configure_codex_mcp_is_explicit_opt_in(tmp_path: Path) -> None:
codex_home = tmp_path / ".codex"
run_configure(codex_home, "--skip-verify", "--with-mcp")
config = tomllib.loads((codex_home / "config.toml").read_text())
command = config["mcp_servers"]["dev-hub"]["command"]
assert command.endswith("/scripts/codex-state-hub-mcp.sh")

View file

@ -48,17 +48,30 @@ def _fake_get(path: str, params: dict | None = None):
if path == "/repos":
return [{"id": "repo-1", "slug": "demo-repo", "domain_slug": "infotech"}]
if path == "/repo-goals":
return [{"id": "goal-1", "title": "Ship it", "description": "d", "priority": "high"}]
return [{
"id": "goal-1",
"repo_id": "repo-1",
"repo_slug": "demo-repo",
"title": "Ship it",
"description": "d",
"priority": "high",
}]
if path == "/capability-catalog/":
return []
return []
def test_get_domain_summary_goal_guidance_is_workplan_first(monkeypatch) -> None:
monkeypatch.setattr(server, "_get", lambda path, params=None: _fake_get(path, params))
calls = []
monkeypatch.setattr(
server,
"_get",
lambda path, params=None: calls.append((path, params)) or _fake_get(path, params),
)
payload = json.loads(server.get_domain_summary("infotech"))
assert "workplans" in payload
assert payload["workstreams"] == payload["workplans"]
action = payload["goal_guidance"]["needs_workplan"][0]["action"]
assert "workplan" in action.lower()
assert "workstream is linked" not in action.lower()
assert [path for path, _ in calls].count("/repo-goals") == 1

View file

@ -0,0 +1,119 @@
from datetime import datetime, timedelta, timezone
import pytest
from api.config import settings
from api.services import ops_run_projection as projection
@pytest.fixture(autouse=True)
def reset_projection_cache() -> None:
projection.reset_ops_run_projection_cache()
@pytest.mark.asyncio
async def test_ops_run_projection_counts_and_contract(monkeypatch) -> None:
now = datetime.now(timezone.utc)
payload = {
"counts": {"open": 2, "claimed": 1, "succeeded": 9, "failed": 2},
"items": [
{
"id": "run-open",
"activity_definition_id": "definition-1",
"target_repo": "state-hub",
"state": "open",
"created_at": (now - timedelta(hours=2)).isoformat(),
"updated_at": (now - timedelta(hours=2)).isoformat(),
"result": {},
},
{
"id": "run-claimed",
"activity_definition_id": "definition-2",
"target_repo": "activity-core",
"state": "claimed",
"claim_owner": "rein-aharness@railiance01",
"lease_until": (now + timedelta(minutes=10)).isoformat(),
"attempt": 1,
"created_at": (now - timedelta(minutes=10)).isoformat(),
"updated_at": now.isoformat(),
"result": {},
},
{
"id": "run-failed",
"activity_definition_id": "definition-3",
"target_repo": "binky-control",
"state": "failed",
"created_at": (now - timedelta(hours=3)).isoformat(),
"updated_at": (now - timedelta(hours=1)).isoformat(),
"result": {"error": "executor timeout"},
},
],
}
async def fake_fetch():
return payload
monkeypatch.setattr(projection, "_fetch_ops_runs", fake_fetch)
result = await projection.get_ops_run_projection(refresh=True)
assert result.available is True
assert result.open == 2
assert result.claimed == 1
assert result.failed_24h == 1
assert result.stuck_open_or_claimed == 1
assert {item.id for item in result.items} == {"run-open", "run-claimed", "run-failed"}
failed = next(item for item in result.items if item.id == "run-failed")
assert failed.last_error == "executor timeout"
claimed = next(item for item in result.items if item.id == "run-claimed")
assert claimed.lease["owner"] == "rein-aharness@railiance01"
@pytest.mark.asyncio
async def test_ops_run_projection_serves_stale_cache_on_refresh_failure(monkeypatch) -> None:
async def initial_fetch():
return {"counts": {"open": 1}, "items": []}
monkeypatch.setattr(projection, "_fetch_ops_runs", initial_fetch)
initial = await projection.get_ops_run_projection(refresh=True)
assert initial.available is True
async def failed_fetch():
raise RuntimeError("upstream down")
monkeypatch.setattr(projection, "_fetch_ops_runs", failed_fetch)
stale = await projection.get_ops_run_projection(refresh=True)
assert stale.available is True
assert stale.stale is True
assert "upstream down" in stale.error
@pytest.mark.asyncio
async def test_ops_run_projection_reports_unconfigured(monkeypatch) -> None:
monkeypatch.setattr(settings, "activity_core_url", None)
result = await projection.get_ops_run_projection(refresh=True)
assert result.available is False
assert "ACTIVITY_CORE_URL is not configured" in result.error
@pytest.mark.asyncio
async def test_ops_runs_summary_route(client, monkeypatch) -> None:
async def fake_fetch():
return {"counts": {"open": 3, "claimed": 2}, "items": []}
monkeypatch.setattr(projection, "_fetch_ops_runs", fake_fetch)
response = await client.get("/ops-runs/summary", params={"refresh": "true"})
assert response.status_code == 200
assert response.json()["open"] == 3
assert response.json()["claimed"] == 2
assert (await client.post("/ops-runs/claim", json={})).status_code == 404
@pytest.mark.asyncio
async def test_state_summary_contains_ops_run_projection(client, monkeypatch) -> None:
async def fake_fetch():
return {"counts": {"open": 4}, "items": []}
monkeypatch.setattr(projection, "_fetch_ops_runs", fake_fetch)
response = await client.get("/state/summary", params={"refresh": "true"})
assert response.status_code == 200
assert response.json()["ops_runs"]["open"] == 4

View file

@ -0,0 +1,63 @@
---
id: ADHOC-2026-08-08
type: workplan
title: "Ad hoc fixes — 2026-08-08"
domain: infotech
repo: state-hub
status: finished
owner: codex
topic_slug: state-hub
created: "2026-08-08"
updated: "2026-08-08"
state_hub_workstream_id: "f0175ce9-452b-4e52-b835-b8736d94e3d5"
state_hub_workstream_id: "f0175ce9-452b-4e52-b835-b8736d94e3d5"
---
## Integrate State Hub connectivity into Codex setup
```task
id: ADHOC-2026-08-08-T01
status: done
priority: medium
state_hub_task_id: "5ad8b6f7-f005-43da-9d01-1fe7b0a5c8f8"
```
Add an idempotent Codex configuration step to the operator bootstrap, verify
State Hub loopback access from the Codex sandbox, distinguish managed-policy
isolation from an actual hub outage, and propagate accurate agent guidance.
## Repair Codex stdio MCP launcher
```task
id: ADHOC-2026-08-08-T02
status: done
priority: high
```
Remove the stdio MCP launcher's runtime dependency on the read-only global uv
cache, execute the repository virtual environment directly, and verify an MCP
initialize exchange through the registered launcher.
## Split a slim Codex MCP surface from full dev-hub administration
```task
id: ADHOC-2026-08-08-T03
status: done
priority: high
```
Limit Codex registration to essential repository coordination tools while
leaving the full State Hub MCP server and SSE transport available to clients
that need the administrative surface.
## Make Codex MCP opt-in
```task
id: ADHOC-2026-08-08-T04
status: done
priority: high
```
Remove the default Codex dev-hub registration after bridge-level latency and
startup interruption persisted, retain the slim server for explicit testing,
and make REST plus the statehub CLI the documented Codex default.

View file

@ -4,19 +4,18 @@ type: workplan
title: "Ops run read projection for fleet visibility"
domain: infotech
repo: state-hub
status: ready
owner: grok
status: finished
owner: codex
topic_slug: state-hub
priority: low
created: "2026-08-03"
updated: "2026-08-03"
updated: "2026-08-08"
depends_on:
- ACTIVITY-WP-0026
related:
- ACT-ADR-005
- STATE-WP-0074
state_hub_workstream_id: "57b75246-ef6d-4d6e-93dc-7f47e7200d1d"
state_hub_workstream_id: "57b75246-ef6d-4d6e-93dc-7f47e7200d1d"
---
# STATE-WP-0078 — Ops run read projection
@ -39,7 +38,7 @@ coulombcore-oriented agents see automation health without claiming work.
```task
id: STATE-WP-0078-T01
status: todo
status: done
priority: medium
state_hub_task_id: "399b4985-3578-4fff-97dd-0f68e1e266b3"
```
@ -50,13 +49,17 @@ push (actcore posts progress summary) is v1.
**Done when:** short contract note in `docs/` or linked from ACT-ADR-005.
Implemented in `docs/ops-run-read-projection.md`: v1 is an authenticated,
cached pull from activity-core `GET /ops-runs`; activity-core remains the only
claim/lease/completion authority.
---
### T02 — Read API + summary field
```task
id: STATE-WP-0078-T02
status: todo
status: done
priority: medium
state_hub_task_id: "192be2e0-0335-46da-ba22-17e3e8f0d28c"
```
@ -68,13 +71,18 @@ Minimal:
**Done when:** MCP/summary consumers can show stuck automation.
Implemented `GET /ops-runs/summary` and the `/state/summary.ops_runs` projection,
including open/claimed/failed-24h counts, SLA-stuck count, bounded run details,
and stale/unavailable behavior. No write endpoints exist under State Hub's
`/ops-runs` route.
---
### T03 — Edge/federation notes
```task
id: STATE-WP-0078-T03
status: todo
status: done
priority: low
state_hub_task_id: "09afc52e-e1c6-454f-ac1d-33d6e773f47f"
```
@ -84,11 +92,21 @@ may lag if edge relay down — alert preference.
**Done when:** cluster-operating-model or ops doc updated.
Documented in `docs/cluster-operating-model.md` and
`docs/ops-run-read-projection.md`.
## Acceptance
- [ ] Hub does not claim ops_runs
- [ ] Operators see automation backlog on primary hub views
- [ ] No Forgejo/Gitea dependency
- [x] Hub does not claim ops_runs
- [x] Operators see automation backlog on primary hub views
- [x] No Forgejo/Gitea dependency
## Residual handoff
Production deployment wiring is tracked as State Hub intake
`019fe359-debb-71a6-958c-b8f4ac3574a0` (`origin: residual`,
`origin_ref: STATE-WP-0078`). It covers the private activity-core URL,
OpenBao/ESO token injection, rollout, and live fleet verification.
## Out of scope