Prepare State Hub retirement baseline
This commit is contained in:
parent
2217bdd9f5
commit
5927591be8
46 changed files with 32583 additions and 62 deletions
|
|
@ -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=
|
||||
|
|
|
|||
14
AGENTS.md
14
AGENTS.md
|
|
@ -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
|
||||
|
|
|
|||
21
INTENT.md
21
INTENT.md
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
10
Makefile
10
Makefile
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
13
api/routers/ops_runs.py
Normal 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)
|
||||
|
|
@ -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
27
api/schemas/ops_run.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
130
api/services/ops_run_projection.py
Normal file
130
api/services/ops_run_projection.py
Normal 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
|
||||
|
|
@ -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:
|
||||
|
|
@ -197,4 +210,4 @@ After image or chart changes, see `deploy/railiance/README.md` and
|
|||
- `workplans/CUST-WP-0011-state-hub-threephoenix-migration.md` — migration plan
|
||||
- `workplans/CUST-WP-0038-state-hub-threephoenix-ha.md` — future HA target
|
||||
- `deploy/railiance/README.md` — Helm/CNPG handoff
|
||||
- `the-custodian/ops/service-inventory.yml` — live endpoint inventory
|
||||
- `the-custodian/ops/service-inventory.yml` — live endpoint inventory
|
||||
|
|
|
|||
1755
docs/evidence/legacy-meter-weekly-review-20260723.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260723.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260724.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260724.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260725.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260725.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260726.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260726.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260727.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260727.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260728.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260728.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260729.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260729.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260730.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260730.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260731.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260731.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260801.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260801.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260802.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260802.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260803.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260803.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260804.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260804.json
Normal file
File diff suppressed because it is too large
Load diff
1716
docs/evidence/legacy-meter-weekly-review-20260805.json
Normal file
1716
docs/evidence/legacy-meter-weekly-review-20260805.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260806.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260806.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260807.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260807.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260808.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260808.json
Normal file
File diff suppressed because it is too large
Load diff
1755
docs/evidence/legacy-meter-weekly-review-20260809.json
Normal file
1755
docs/evidence/legacy-meter-weekly-review-20260809.json
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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
|
||||
|
|
|
|||
56
docs/ops-run-read-projection.md
Normal file
56
docs/ops-run-read-projection.md
Normal 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
123
mcp_server/codex_server.py
Normal 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")
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
142
scripts/configure-codex.sh
Executable 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
40
tests/test_codex_mcp_launcher.py
Normal file
40
tests/test_codex_mcp_launcher.py
Normal 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
|
||||
19
tests/test_codex_mcp_server.py
Normal file
19
tests/test_codex_mcp_server.py
Normal 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"
|
||||
92
tests/test_configure_codex.py
Normal file
92
tests/test_configure_codex.py
Normal 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")
|
||||
|
|
@ -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 "workstream is linked" not in action.lower()
|
||||
assert [path for path, _ in calls].count("/repo-goals") == 1
|
||||
|
|
|
|||
119
tests/test_ops_run_projection.py
Normal file
119
tests/test_ops_run_projection.py
Normal 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
|
||||
63
workplans/ADHOC-2026-08-08.md
Normal file
63
workplans/ADHOC-2026-08-08.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue